diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6060120..29ea8e2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -80,7 +80,7 @@ deploy: cd ~/deploy docker compose pull > deploy.log 2>&1 docker compose down >> deploy.log 2>&1 - docker compose up -d --remove-orphans >> deploy.log 2>&1 + docker compose up -d --remove-orphans --force-recreate >> deploy.log 2>&1 docker compose ps >> deploy.log 2>&1 EOF - ssh $SSH_ADDRESS "docker system prune -a --force" diff --git a/compose.yaml b/compose.yaml index 87231d1..21501ef 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,6 +3,8 @@ name: datarush services: backend: image: gitlab.prodcontest.ru:5050/team-15/project/backend:latest + build: + context: ./services/backend depends_on: backend-initdb: restart: false @@ -35,6 +37,8 @@ services: backend-initdb: image: gitlab.prodcontest.ru:5050/team-15/project/backend:latest + build: + context: ./services/backend command: ./scripts/initdb depends_on: postgres: @@ -57,6 +61,9 @@ services: backend-staticfiles: image: gitlab.prodcontest.ru:5050/team-15/project/backend-staticfiles:latest + build: + context: ./services/backend + dockerfile: Dockerfile.staticfiles env_file: - path: ./infrastructure/backend/.env.template required: true @@ -79,6 +86,8 @@ services: backend-celery-worker: image: gitlab.prodcontest.ru:5050/team-15/project/backend:latest + build: + context: ./services/backend command: celery -A config worker -l INFO depends_on: redis: @@ -307,12 +316,12 @@ services: - name: api target: 9000 published: 8005 - host_ip: 127.0.0.1 + host_ip: 0.0.0.0 protocol: tcp - name: console target: 9001 published: 8006 - host_ip: 127.0.0.1 + host_ip: 0.0.0.0 protocol: tcp restart: unless-stopped volumes: @@ -338,7 +347,7 @@ services: - name: web target: 9090 published: 8007 - host_ip: 127.0.0.1 + host_ip: 0.0.0.0 protocol: tcp restart: unless-stopped shm_size: 4mb diff --git a/infrastructure/backend/.env.template b/infrastructure/backend/.env.template index c964cde..8866be3 100644 --- a/infrastructure/backend/.env.template +++ b/infrastructure/backend/.env.template @@ -1,11 +1,11 @@ DJANGO_SECRET_KEY=secretees DJANGO_DEBUG=False DJANGO_ALLOWED_HOSTS=* -DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1,https://*.final.prodcontest.ru DJANGO_CORS_ALLOWED_ORIGINS=* DJANGO_INTERNAL_IPS=127.0.0.1 -DJANGO_LANGUAGE_CODE=en-us -DJANGO_STATIC_URL=http://localhost:13241/ +DJANGO_LANGUAGE_CODE=ru +DJANGO_STATIC_URL=static/ REDIS_URI=redis://redis:6379 DJANGO_DB_URI=postgresql://postgres:postgres@postgres/postgres @@ -15,6 +15,6 @@ DJANGO_SUPERUSER_EMAIL=admin@mail.com DJANGO_SUPERUSER_PASSWORD=admin MINIO_ENDPOINT=minio:9000 -MINIO_CUSTOM_ENDPOINT_URL=http://127.0.0.1:13244 +MINIO_CUSTOM_ENDPOINT_URL=https://prod-team-15-minio-2pc0i3lc.final.prodcontest.ru MINIO_ACCESS_KEY=admin MINIO_SECRET_KEY=password diff --git a/infrastructure/grafana/grafana.ini b/infrastructure/grafana/grafana.ini index 7f08784..86cb5f8 100644 --- a/infrastructure/grafana/grafana.ini +++ b/infrastructure/grafana/grafana.ini @@ -48,10 +48,10 @@ domain = localhost enforce_domain = false # The full public facing url -root_url = %(protocol)s://%(domain)s:%(http_port)s/ +root_url = https://prod-team-15-2pc0i3lc.final.prodcontest.ru/admin/grafana/ # Serve Grafana from subpath specified in `root_url` setting. By default it is set to `false` for compatibility reasons. -serve_from_sub_path = false +serve_from_sub_path = true # Log web requests router_logging = false diff --git a/infrastructure/minio/.env.template b/infrastructure/minio/.env.template index 9d55c3a..7b7c699 100644 --- a/infrastructure/minio/.env.template +++ b/infrastructure/minio/.env.template @@ -1,3 +1,4 @@ MINIO_ROOT_USER=admin MINIO_ROOT_PASSWORD=password MINIO_VOLUMES=/data +MINIO_BROWSER_REDIRECT_URL=https://prod-team-15-minio-2pc0i3lc.final.prodcontest.ru/minio/ui/ diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf index 0286b05..2773428 100644 --- a/infrastructure/nginx/nginx.conf +++ b/infrastructure/nginx/nginx.conf @@ -23,6 +23,7 @@ http { ssl_stapling_verify on; resolver 1.1.1.1 1.0.0.1 8.8.8.8 valid=300s; resolver_timeout 5s; + server_names_hash_bucket_size 128; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; @@ -109,6 +110,25 @@ http { proxy_read_timeout 600s; } + location /static { + rewrite ^/static/(.*)$ /$1 break; + proxy_pass http://backend-staticfiles:80; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_cache_bypass $http_upgrade; + proxy_hide_header X-Powered-By; + + proxy_connect_timeout 75s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + location /api { proxy_pass http://backend:8080; proxy_http_version 1.1; @@ -127,19 +147,89 @@ http { return 204; } - add_header 'Access-Control-Allow-Origin' "$http_origin" always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Requested-With' always; - add_header 'Access-Control-Allow-Credentials' 'true' always; + proxy_buffering off; + proxy_request_buffering off; + client_max_body_size 100M; + } + + location /admin { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_request_buffering off; client_max_body_size 100M; } + location /admin/grafana/ { + rewrite ^/admin/grafana/(.*) /$1 break; + proxy_pass http://grafana:3000/; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + error_page 404 = @notfound; location @notfound { return 444; } } + + server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name prod-team-15-minio-2pc0i3lc.final.prodcontest.ru; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + + ignore_invalid_headers off; + client_max_body_size 0; + proxy_buffering off; + proxy_request_buffering off; + + location / { + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 300; + proxy_http_version 1.1; + proxy_set_header Connection ""; + chunked_transfer_encoding off; + + proxy_pass http://minio:9000; + } + + location /minio/ui/ { + rewrite ^/minio/ui/(.*) /$1 break; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-NginX-Proxy true; + + real_ip_header X-Real-IP; + + proxy_connect_timeout 300; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + chunked_transfer_encoding off; + + proxy_pass http://minio:9001; + } + } } diff --git a/services/backend/Dockerfile b/services/backend/Dockerfile index b55f660..2678475 100644 --- a/services/backend/Dockerfile +++ b/services/backend/Dockerfile @@ -37,6 +37,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --start-interval=2s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/health?format=json || exit 1 + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/api/health?format=json || exit 1 -CMD gunicorn config.wsgi --workers=8 -b 0.0.0.0:8080 --access-logfile - --error-logfile - +CMD gunicorn config.wsgi --workers=8 -b 0.0.0.0:8080 --access-logfile - --error-logfile - --timeout=600 diff --git a/services/backend/apps/competition/admin.py b/services/backend/apps/competition/admin.py new file mode 100644 index 0000000..2aca32c --- /dev/null +++ b/services/backend/apps/competition/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + +from apps.competition.models import Competition +from apps.task.admin import CompetitionTaskInline + + +@admin.register(Competition) +class CompetitionAdmin(admin.ModelAdmin): + list_display = ("title", "end_date", "type",) + search_fields = ("title", "description",) + list_filter = ("type", "participation_type",) + inlines = [CompetitionTaskInline] diff --git a/services/backend/apps/competition/apps.py b/services/backend/apps/competition/apps.py index ff3cb4b..d343cd0 100644 --- a/services/backend/apps/competition/apps.py +++ b/services/backend/apps/competition/apps.py @@ -4,3 +4,4 @@ from django.apps import AppConfig class CompetitionsConfig(AppConfig): name = "apps.competition" label = "competition" + verbose_name = "Соревнование" diff --git a/services/backend/apps/competition/migrations/0002_competition_tasks_alter_competition_participants_and_more.py b/services/backend/apps/competition/migrations/0002_competition_tasks_alter_competition_participants_and_more.py new file mode 100644 index 0000000..5cfcfdf --- /dev/null +++ b/services/backend/apps/competition/migrations/0002_competition_tasks_alter_competition_participants_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.6 on 2025-03-01 12:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0001_initial'), + ('task', '0001_initial'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='tasks', + field=models.ManyToManyField(blank=True, related_name='tasks', to='task.competitiontask'), + ), + migrations.AlterField( + model_name='competition', + name='participants', + field=models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user'), + ), + migrations.AlterField( + model_name='competition', + name='participation_type', + field=models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='Тип соревнования'), + ), + migrations.AlterField( + model_name='competition', + name='type', + field=models.CharField(choices=[('solo', 'Индивидуальный')], max_length=10, verbose_name='Тип участия'), + ), + ] diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py index 589ce91..0c6bd19 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -8,11 +8,11 @@ from apps.user.models import User class Competition(BaseModel): class CompetitionType(models.TextChoices): - SOLO = "solo" + SOLO = "solo", "Индивидуальный" class CompetitionParticipationType(models.TextChoices): - EDU = "edu" - COMPETITIVE = "competitive" + EDU = "edu", "Образовательный" + COMPETITIVE = "competitive", "Соревновательный" title = models.CharField(max_length=100, verbose_name="Название") description = models.TextField(verbose_name="Описание") @@ -35,7 +35,11 @@ class Competition(BaseModel): choices=CompetitionParticipationType.choices, verbose_name="Тип соревнования", ) - participants = models.ManyToManyField(User, related_name="participants") + participants = models.ManyToManyField(User, related_name="participants", blank=True, + editable=False) + + def __str__(self): + return self.title class Meta: verbose_name = "соревнование" diff --git a/services/backend/apps/core/admin.py b/services/backend/apps/core/admin.py new file mode 100644 index 0000000..8a7dbe4 --- /dev/null +++ b/services/backend/apps/core/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin +from django.contrib.auth.models import Group, User + +admin.site.unregister(Group) +admin.site.unregister(User) diff --git a/services/backend/apps/task/admin.py b/services/backend/apps/task/admin.py new file mode 100644 index 0000000..a466334 --- /dev/null +++ b/services/backend/apps/task/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from apps.task.models import CompetitionTask + + +@admin.register(CompetitionTask) +class CompetitionTaskAdmin(admin.ModelAdmin): + list_display = ("title", "type", "points") + + +class CompetitionTaskInline(admin.StackedInline): + model = CompetitionTask + extra = 0 diff --git a/services/backend/apps/task/apps.py b/services/backend/apps/task/apps.py index 46f853d..4fe9436 100644 --- a/services/backend/apps/task/apps.py +++ b/services/backend/apps/task/apps.py @@ -4,3 +4,4 @@ from django.apps import AppConfig class CompetitionsConfig(AppConfig): name = "apps.task" label = "task" + verbose_name = "Задания" diff --git a/services/backend/apps/task/migrations/0002_alter_competitiontask_options_and_more.py b/services/backend/apps/task/migrations/0002_alter_competitiontask_options_and_more.py new file mode 100644 index 0000000..4a14698 --- /dev/null +++ b/services/backend/apps/task/migrations/0002_alter_competitiontask_options_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 5.1.6 on 2025-03-01 12:21 + +import apps.task.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0002_competition_tasks_alter_competition_participants_and_more'), + ('task', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='competitiontask', + options={'verbose_name': 'задание', 'verbose_name_plural': 'задания'}, + ), + migrations.AlterField( + model_name='competitiontask', + name='competition', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition', verbose_name='соревнование'), + ), + migrations.AlterField( + model_name='competitiontask', + name='correct_answer_file', + field=models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='правильный ответ'), + ), + migrations.AlterField( + model_name='competitiontask', + name='criteries', + field=models.JSONField(blank=True, null=True, verbose_name='критерии проверки'), + ), + migrations.AlterField( + model_name='competitiontask', + name='points', + field=models.IntegerField(blank=True, null=True, verbose_name='баллы за задание'), + ), + migrations.AlterField( + model_name='competitiontask', + name='type', + field=models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8, verbose_name='тип задания'), + ), + ] diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 08fe998..e03a31e 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -10,33 +10,66 @@ from apps.user.models import User class CompetitionTask(BaseModel): class CompetitionTaskType(models.TextChoices): - INPUT = "input" - CHECKER = "checker" - REVIEW = "review" + INPUT = "input", "Ввод правильного ответа" + CHECKER = "checker", "Вывод кода" + REVIEW = "review", "Ручная" def answer_file_upload_to(instance, filename) -> str: return f"/tasks/{instance.id}/answer/{uuid4()}/filename" competition = models.ForeignKey(Competition, on_delete=models.CASCADE) - title = models.TextField(verbose_name="заголовок", max_length=50) + title = models.CharField(verbose_name="заголовок", max_length=50) description = models.TextField(verbose_name="описание", max_length=300) - type = models.CharField(choices=CompetitionTaskType, max_length=8) + max_attemps = models.PositiveSmallIntegerField() + type = models.CharField( + choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" + ) # only when "input" or "checker" type correct_answer_file = models.FileField( - upload_to=answer_file_upload_to, null=True, blank=True + upload_to=answer_file_upload_to, + null=True, + blank=True, + verbose_name="файл с правильным ответом", + ) + points = models.IntegerField( + null=True, blank=True, verbose_name="баллы за задание" ) - points = models.IntegerField(null=True, blank=True) # only when "checker" type - answer_file_path = models.TextField(null=True, blank=True) + answer_file_path = models.TextField( + null=True, + blank=True, + verbose_name="куда сохранять решения", + default="stdout", + ) # only when "review" type - criteries = models.JSONField(blank=True, null=True) + # todo make it more humanize + criteries = models.JSONField( + blank=True, + null=True, + verbose_name="критерии", + default=lambda: [ + { + "name": "CHANGE ME", + "slug": "CHANGE ME", + "max_value": 0, + "min_value": 0, + } + ], + ) def clean(self): ContestTaskCriteriesValidator()(self) + def __str__(self): + return self.title + + class Meta: + verbose_name = "задание" + verbose_name_plural = "задания" + class CompetetionTaskSumbission(BaseModel): class StatusChoices(models.TextChoices): diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index 63e2122..e383e80 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -227,7 +227,7 @@ FIRST_DAY_OF_WEEK = 1 FORMAT_MODULE_PATH = None -LANGUAGE_CODE = env("DJANGO_LANGUAGE_CODE", default="en-us") +LANGUAGE_CODE = env("DJANGO_LANGUAGE_CODE", default="ru-ru") LANGUAGES = [("en", _("English")), ("ru", _("Russian"))] diff --git a/services/backend/pyproject.toml b/services/backend/pyproject.toml index 0aaa38b..b6292e0 100644 --- a/services/backend/pyproject.toml +++ b/services/backend/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "psycopg2-binary>=2.9.10", "pydantic>=2.10.5", "pyjwt>=2.10.1", + "python-gettext>=5.0", "python-json-logger>=3.2.1", "pytz>=2024.2", "redis>=5.2.1",