Merge branch 'master' of gitlab.prodcontest.ru:team-15/project

This commit is contained in:
ITQ
2025-03-02 03:00:59 +03:00
14 changed files with 85 additions and 149 deletions
@@ -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
@@ -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={
@@ -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='изображение соревнования'),
),
]
+1 -1
View File
@@ -61,5 +61,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)
+15 -31
View File
@@ -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
@@ -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,
},
),
]
@@ -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 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):
@@ -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': 'задания',
},
),
@@ -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 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': 'приглашения',
},
),
]
+12
View File
@@ -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 = "приглашения"
@@ -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={
@@ -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='юзернейм'),
),
]