Merge remote-tracking branch 'origin/master'

This commit is contained in:
Андрей Сумин
2025-03-02 02:57:52 +03:00
12 changed files with 76 additions and 148 deletions
@@ -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 apps.competition.models
import datetime import datetime
@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('user', '0002_alter_user_email_alter_user_password_and_more'), ('user', '0001_initial'),
] ]
operations = [ operations = [
@@ -22,11 +22,11 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=100, verbose_name='название')), ('title', models.CharField(max_length=100, verbose_name='название')),
('description', models.TextField(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='дедлайн участия')), ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия')),
('start_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='тип участия')), ('type', models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип участия')),
('participation_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')), ('participants', models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user')),
], ],
options={ options={
@@ -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='изображение соревнования'),
),
]
+15 -31
View File
@@ -21,8 +21,8 @@ class CompetitionEndpointTests(TestCase):
self.competition = Competition.objects.create( self.competition = Competition.objects.create(
title="AI Challenge", title="AI Challenge",
description="Machine Learning Competition", description="Machine Learning Competition",
type="solo", type="edu",
participation_type="edu", participation_type="solo",
) )
resp = self.client.post( resp = self.client.post(
@@ -34,13 +34,10 @@ class CompetitionEndpointTests(TestCase):
self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"} self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
# --- Helper methods ---
def get_url(self, competition_id): def get_url(self, competition_id):
return f"/api/v1/competition/{competition_id}" return f"/api/v1/competition/{competition_id}"
# --- Test Cases ---
def test_get_competition_success(self): def test_get_competition_success(self):
"""Authenticated user gets competition details (200 OK)"""
response = self.client.get( response = self.client.get(
self.get_url(self.competition.id), **self.valid_headers self.get_url(self.competition.id), **self.valid_headers
) )
@@ -51,7 +48,7 @@ class CompetitionEndpointTests(TestCase):
# Validate required fields # Validate required fields
self.assertEqual(data["id"], str(self.competition.id)) self.assertEqual(data["id"], str(self.competition.id))
self.assertEqual(data["title"], "AI Challenge") self.assertEqual(data["title"], "AI Challenge")
self.assertEqual(data["type"], "solo") self.assertEqual(data["type"], "edu")
# Validate optional null fields # Validate optional null fields
self.assertIsNone(data["image_url"]) self.assertIsNone(data["image_url"])
@@ -59,20 +56,17 @@ class CompetitionEndpointTests(TestCase):
self.assertIsNone(data["end_date"]) self.assertIsNone(data["end_date"])
def test_invalid_uuid_format(self): def test_invalid_uuid_format(self):
"""Invalid UUID format returns 400 Bad Request"""
response = self.client.get( response = self.client.get(
self.get_url("invalid-id"), **self.valid_headers self.get_url("invalid-id"), **self.valid_headers
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_unauthenticated_access(self): def test_unauthenticated_access(self):
"""Missing auth token returns 401 Unauthorized"""
response = self.client.get(self.get_url(self.competition.id)) response = self.client.get(self.get_url(self.competition.id))
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Unauthorized") self.assertEqual(response.json()["detail"], "Unauthorized")
def test_nonexistent_competition(self): def test_nonexistent_competition(self):
"""Valid UUID but missing competition returns 404"""
new_uuid = uuid.uuid4() new_uuid = uuid.uuid4()
response = self.client.get( response = self.client.get(
self.get_url(new_uuid), **self.valid_headers self.get_url(new_uuid), **self.valid_headers
@@ -81,7 +75,6 @@ class CompetitionEndpointTests(TestCase):
self.assertEqual(response.json()["detail"], "Not Found") self.assertEqual(response.json()["detail"], "Not Found")
def test_invalid_auth_token(self): def test_invalid_auth_token(self):
"""Invalid token returns 401 Unauthorized"""
response = self.client.get( response = self.client.get(
self.get_url(self.competition.id), self.get_url(self.competition.id),
HTTP_AUTHORIZATION="Bearer invalid_token", HTTP_AUTHORIZATION="Bearer invalid_token",
@@ -90,7 +83,6 @@ class CompetitionEndpointTests(TestCase):
self.assertEqual(response.json()["detail"], "Unauthorized") self.assertEqual(response.json()["detail"], "Unauthorized")
def test_malformed_auth_header(self): def test_malformed_auth_header(self):
"""Malformed Authorization header returns 401"""
cases = [ cases = [
("InvalidScheme valid_token_123", 401), ("InvalidScheme valid_token_123", 401),
("Bearer", 401), # Missing token ("Bearer", 401), # Missing token
@@ -128,11 +120,11 @@ class CompetitionsEndpointTests(TestCase):
competition = Competition.objects.create( competition = Competition.objects.create(
title=f"Competition {i}", title=f"Competition {i}",
description=f"Description {i}", description=f"Description {i}",
type=Competition.CompetitionType.SOLO, type=(
participation_type=( Competition.CompetitionType.EDU if i % 2 == 0
Competition.CompetitionParticipationType.EDU if i % 2 == 0 else Competition.CompetitionType.COMPETITIVE
else Competition.CompetitionParticipationType.COMPETITIVE
), ),
participation_type=Competition.CompetitionParticipationType.SOLO,
start_date=(now + timedelta(days=i)).isoformat(), start_date=(now + timedelta(days=i)).isoformat(),
end_date=(now + timedelta(days=i + 7)).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 return f"{base_url}?{params}" if params else base_url
def test_get_participating_competitions(self): def test_get_participating_competitions(self):
"""Test filtering competitions where user is participating"""
response = self.client.get( response = self.client.get(
self.get_url("is_participating=true"), self.get_url("is_participating=true"),
**self.valid_headers **self.valid_headers
@@ -164,17 +155,15 @@ class CompetitionsEndpointTests(TestCase):
) )
def test_competition_type_values(self): def test_competition_type_values(self):
"""Test competition type choices are respected"""
response = self.client.get( response = self.client.get(
self.get_url("is_participating=true"), self.get_url("is_participating=true"),
**self.valid_headers **self.valid_headers
) )
for item in response.json(): for item in response.json():
self.assertEqual(item["type"], "solo") self.assertEqual(item["type"], "competitive")
def test_participation_type_values(self): def test_participation_type_values(self):
"""Test participation type alternates between edu/competitive"""
response = self.client.get( response = self.client.get(
self.get_url("is_participating=false"), self.get_url("is_participating=false"),
**self.valid_headers **self.valid_headers
@@ -183,11 +172,10 @@ class CompetitionsEndpointTests(TestCase):
types = [item["participation_type"] for item in response.json()] types = [item["participation_type"] for item in response.json()]
self.assertCountEqual( self.assertCountEqual(
types, types,
["competitive", "edu", "competitive"] ["solo", "solo", "solo"]
) )
def test_datetime_formatting(self): def test_datetime_formatting(self):
"""Test start/end date ISO formatting"""
response = self.client.get( response = self.client.get(
self.get_url("is_participating=true"), self.get_url("is_participating=true"),
**self.valid_headers **self.valid_headers
@@ -206,7 +194,6 @@ class CompetitionsEndpointTests(TestCase):
self.fail("Invalid end_date format") self.fail("Invalid end_date format")
def test_competition_metadata(self): def test_competition_metadata(self):
"""Test competition metadata fields"""
response = self.client.get( response = self.client.get(
self.get_url("is_participating=true"), self.get_url("is_participating=true"),
**self.valid_headers **self.valid_headers
@@ -215,27 +202,25 @@ class CompetitionsEndpointTests(TestCase):
item = response.json()[0] item = response.json()[0]
self.assertEqual(item["title"], "Competition 1") self.assertEqual(item["title"], "Competition 1")
self.assertEqual(item["description"], "Description 1") self.assertEqual(item["description"], "Description 1")
self.assertEqual(item["type"], "solo") self.assertEqual(item["type"], "competitive")
self.assertEqual(item["participation_type"], "competitive") self.assertEqual(item["participation_type"], "solo")
def test_verbose_name_consistency(self): def test_verbose_name_consistency(self):
"""Test model verbose names don't affect API schema"""
response = self.client.get( response = self.client.get(
self.get_url("is_participating=true"), self.get_url("is_participating=true"),
**self.valid_headers **self.valid_headers
) )
item = response.json()[0] item = response.json()[0]
self.assertNotIn("название", item) # Russian verbose name self.assertNotIn("название", item)
self.assertIn("title", item) # Actual API field name self.assertIn("title", item)
def test_null_dates_handling(self): def test_null_dates_handling(self):
"""Test competitions with null dates"""
competition = Competition.objects.create( competition = Competition.objects.create(
title="No Dates Competition", title="No Dates Competition",
description="Test competition", description="Test competition",
type=Competition.CompetitionType.SOLO, type=Competition.CompetitionType.EDU,
participation_type=Competition.CompetitionParticipationType.EDU participation_type=Competition.CompetitionParticipationType.SOLO
) )
response = self.client.get( response = self.client.get(
@@ -251,7 +236,6 @@ class CompetitionsEndpointTests(TestCase):
self.assertIsNone(test_item["end_date"]) self.assertIsNone(test_item["end_date"])
def test_participation_status_filtering(self): def test_participation_status_filtering(self):
"""Test filtering by participation_type"""
response = self.client.get( response = self.client.get(
self.get_url("is_participating=false"), self.get_url("is_participating=false"),
**self.valid_headers **self.valid_headers
@@ -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 import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -9,20 +10,10 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('task', '0001_initial'),
] ]
operations = [ 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( migrations.CreateModel(
name='Reviewer', name='Reviewer',
fields=[ fields=[
@@ -35,4 +26,17 @@ class Migration(migrations.Migration):
'abstract': False, '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,
},
),
] ]
@@ -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'),
),
]
+7 -1
View File
@@ -1,11 +1,17 @@
from django.contrib import admin 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) @admin.register(CompetitionTask)
class CompetitionTaskAdmin(admin.ModelAdmin): class CompetitionTaskAdmin(admin.ModelAdmin):
list_display = ("title", "type", "points") list_display = ("title", "type", "points")
inlines = [CompletionAttachmentInline]
class CompetitionTaskInline(admin.StackedInline): class CompetitionTaskInline(admin.StackedInline):
@@ -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 apps.task.models
import django.db.models.deletion import django.db.models.deletion
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('competition', '0001_initial'), ('competition', '0001_initial'),
('user', '0002_alter_user_email_alter_user_password_and_more'), ('user', '0001_initial'),
] ]
operations = [ operations = [
@@ -32,6 +32,7 @@ class Migration(migrations.Migration):
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
], ],
options={ options={
'verbose_name': 'задание',
'verbose_name_plural': 'задания', 'verbose_name_plural': 'задания',
}, },
), ),
@@ -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': 'задания'},
),
]
@@ -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 django.db.models.deletion
import uuid import uuid
@@ -10,7 +10,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('user', '0002_alter_user_email_alter_user_password_and_more'), ('user', '0001_initial'),
] ]
operations = [ operations = [
@@ -27,4 +27,16 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'команды', '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': 'приглашения',
},
),
] ]
+12
View File
@@ -1,3 +1,5 @@
from uuid import uuid4
from django.db import models from django.db import models
from apps.core.models import BaseModel from apps.core.models import BaseModel
@@ -17,3 +19,13 @@ class Team(BaseModel):
class Meta: class Meta:
verbose_name = "команда" verbose_name = "команда"
verbose_name_plural = "команды" 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 = "приглашения"
@@ -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 import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -16,9 +16,9 @@ class Migration(migrations.Migration):
name='User', name='User',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')), ('email', models.EmailField(max_length=254, unique=True, verbose_name='почта')),
('username', models.SlugField(unique=True, verbose_name='Юзернейм')), ('username', models.SlugField(unique=True, verbose_name='юзернейм')),
('password', models.TextField(verbose_name='Пароль')), ('password', models.TextField(verbose_name='пароль')),
('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)), ('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)),
], ],
options={ options={
@@ -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='юзернейм'),
),
]