diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..716f387 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,360 @@ +name: project_name + +services: + backend: + build: + context: ./services/backend + dockerfile: Dockerfile + depends_on: + backend-initdb: + restart: false + condition: service_completed_successfully + required: true + postgres: + restart: false + condition: service_healthy + required: true + redis: + restart: false + condition: service_healthy + required: true + minio: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/backend/.env.template + required: true + - path: ./infrastructure/backend/.env + required: false + ports: + - name: web + target: 8080 + published: 8080 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + + backend-initdb: + build: + context: ./services/backend + dockerfile: Dockerfile + command: ./scripts/initdb + depends_on: + postgres: + restart: false + condition: service_healthy + required: true + redis: + restart: false + condition: service_healthy + required: true + minio: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/backend/.env.template + required: true + - path: ./infrastructure/backend/.env + required: false + + backend-staticfiles: + build: + context: ./services/backend + dockerfile: Dockerfile.staticfiles + env_file: + - path: ./infrastructure/backend/.env.template + required: true + - path: ./infrastructure/backend/.env + required: false + healthcheck: + test: ["CMD", "service", "nginx", "status", "||", " exit 1"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + ports: + - name: web + target: 80 + published: 13241 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + + backend-celery-worker: + build: + context: ./services/backend + dockerfile: Dockerfile + command: celery -A config worker -l INFO + depends_on: + redis: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/backend/.env.template + required: true + - path: ./infrastructure/backend/.env + required: false + healthcheck: + test: ["CMD", "celery", "-A", "config", "inspect", "ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + start_interval: 2s + restart: unless-stopped + + celery-exporter: + image: docker.io/danihodovic/celery-exporter:0.11.1 + depends_on: + redis: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/celery-exporter/.env.template + required: true + - path: ./infrastructure/celery-exporter/.env + required: false + restart: unless-stopped + + redis: + image: docker.io/redis:7-alpine3.21 + command: redis-server /usr/local/etc/redis/redis.conf + configs: + - source: redis_config + target: /usr/local/etc/redis/redis.conf + env_file: + - path: ./infrastructure/redis/.env.template + required: true + - path: ./infrastructure/redis/.env + required: false + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + restart: unless-stopped + shm_size: 4mb + volumes: + - type: volume + source: redis_data + target: /data + + redis-exporter: + image: docker.io/oliver006/redis_exporter:v1.67.0-alpine + depends_on: + redis: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/redis-exporter/.env.template + required: true + - path: ./infrastructure/redis-exporter/.env + required: false + restart: unless-stopped + shm_size: 4mb + + postgres: + image: docker.io/postgres:17-alpine3.21 + configs: + - source: postgres_config + target: /etc/postgresql/postgresql.conf + env_file: + - path: ./infrastructure/postgres/.env.template + required: true + - path: ./infrastructure/postgres/.env + required: false + healthcheck: + test: ["CMD", "pg_isready"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + oom_kill_disable: true + restart: unless-stopped + secrets: + - source: postgres_password + target: /run/secrets/postgres_password + shm_size: 128mb + volumes: + - type: volume + source: postgres_data + target: /var/lib/postgresql/data + + postgres-exporter: + image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0 + depends_on: + postgres: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/postgres-exporter/.env.template + required: true + - path: ./infrastructure/postgres-exporter/.env + required: false + restart: unless-stopped + shm_size: 4mb + + pgadmin: + image: docker.io/dpage/pgadmin4:9 + configs: + - source: pgadmin_servers + target: /pgadmin4/servers.json + depends_on: + postgres: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/pgadmin/.env.template + required: true + - path: ./infrastructure/pgadmin/.env + required: false + healthcheck: + test: ["CMD", "wget", "-O", "-", "http://localhost:80/misc/ping"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + ports: + - name: web + target: 80 + published: 13242 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + secrets: + - source: pgadmin_password + target: /run/secrets/pgadmin_password + shm_size: 4mb + volumes: + - type: volume + source: pgadmin_data + target: /var/lib/pgadmin + + grafana: + image: docker.io/grafana/grafana-oss:11.5.0 + configs: + - source: grafana_config + target: /usr/share/grafana/conf/defaults.ini + entrypoint: ["/etc/grafana/scripts/entrypoint.sh"] + healthcheck: + test: ["CMD", "wget", "-O", "-", "http://localhost:3000/api/health"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + ports: + - name: web + target: 3000 + published: 13243 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + shm_size: 4mb + volumes: + - type: volume + source: grafana_data + target: /var/lib/grafana + - type: bind + source: ./infrastructure/grafana/provisioning + target: /etc/grafana/provisioning + - type: bind + source: ./infrastructure/grafana/scripts + target: /etc/grafana/scripts + + minio: + command: server --console-address ":9001" + image: docker.io/minio/minio:RELEASE.2025-02-03T21-03-04Z + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + env_file: + - path: ./infrastructure/minio/.env.template + required: true + - path: ./infrastructure/minio/.env + required: false + ports: + - name: api + target: 9000 + published: 13244 + host_ip: 127.0.0.1 + protocol: tcp + - name: console + target: 9001 + published: 13245 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + volumes: + - type: volume + source: minio_data + target: /data + + prometheus: + image: docker.io/prom/prometheus:v3.1.0 + command: + - "--config.file=/etc/prometheus/prometheus.yaml" + configs: + - source: prometheus_config + target: /etc/prometheus/prometheus.yaml + healthcheck: + test: ["CMD", "wget", "-O", "-", "http://localhost:9090/-/healthy"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + ports: + - name: web + target: 9090 + published: 13246 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + shm_size: 4mb + volumes: + - type: volume + source: prometheus_data + target: /prometheus + +volumes: + redis_data: + postgres_data: + pgadmin_data: + grafana_data: + prometheus_data: + minio_data: + +configs: + redis_config: + file: ./infrastructure/redis/redis.conf + postgres_config: + file: ./infrastructure/postgres/postgresql.conf + pgadmin_servers: + file: ./infrastructure/pgadmin/servers.json + grafana_config: + file: ./infrastructure/grafana/grafana.ini + prometheus_config: + file: ./infrastructure/prometheus/prometheus.yaml + +secrets: + postgres_password: + file: ./infrastructure/postgres/password + pgadmin_password: + file: ./infrastructure/pgadmin/password diff --git a/services/backend/api/v1/auth.py b/services/backend/api/v1/auth.py new file mode 100644 index 0000000..039e328 --- /dev/null +++ b/services/backend/api/v1/auth.py @@ -0,0 +1,27 @@ +import datetime +from typing import Optional, Any + +import jwt +from django.conf import settings +from django.http import HttpRequest +from ninja.security import HttpBearer + +from apps.users.models import User + + +class BearerAuth(HttpBearer): + def authenticate(self, request: HttpRequest, token: str) -> Optional[Any]: + data = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + if data["exp"] < datetime.datetime.now().timestamp(): + return None + + user = User.objects.get(id=data["id"]) + return user + + @staticmethod + def generate_jwt(user: User) -> str: + data = { + "exp": (datetime.datetime.now() + datetime.timedelta(days=365)).timestamp(), + "id": str(user.id) + } + return jwt.encode(data, settings.SECRET_KEY, algorithm="HS256") diff --git a/services/backend/api/v1/competitions/__init__.py b/services/backend/api/v1/competitions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/competitions/schemas.py b/services/backend/api/v1/competitions/schemas.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/competitions/views.py b/services/backend/api/v1/competitions/views.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/router.py b/services/backend/api/v1/router.py index b494b4b..d816945 100644 --- a/services/backend/api/v1/router.py +++ b/services/backend/api/v1/router.py @@ -17,6 +17,10 @@ router.add_router( "ping", ping_router, ) +router.add_router( + "", + users_router, +) for exception, handler in handlers.exception_handlers: diff --git a/services/backend/api/v1/users/__init__.py b/services/backend/api/v1/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/users/schemas.py b/services/backend/api/v1/users/schemas.py new file mode 100644 index 0000000..95fe84f --- /dev/null +++ b/services/backend/api/v1/users/schemas.py @@ -0,0 +1,26 @@ +from ninja import Schema, ModelSchema + + +from apps.users.models import User + + +class TokenSchema(Schema): + token: str + + +class RegisterSchema(ModelSchema): + class Meta: + model = User + fields = ["email", "username", "password"] + + +class LoginSchema(ModelSchema): + class Meta: + model = User + fields = ["email", "password"] + + +class UserSchema(ModelSchema): + class Meta: + model = User + fields = ["email", "username"] diff --git a/services/backend/api/v1/users/views.py b/services/backend/api/v1/users/views.py new file mode 100644 index 0000000..d6aa0fa --- /dev/null +++ b/services/backend/api/v1/users/views.py @@ -0,0 +1,56 @@ +from ninja import Router +from ninja.errors import AuthenticationError + +from api.v1.users.schemas import LoginSchema, RegisterSchema, TokenSchema, UserSchema +from api.v1.auth import BearerAuth +from api.v1.schemas import BadRequestError, ForbiddenError, NotFoundError +from apps.users.models import User + +router = Router(tags=["users"]) + + +@router.post( + path="/sign-up", + response={ + 201: TokenSchema, + 400: BadRequestError, + } +) +def sign_up(request, data: RegisterSchema): + user = User(**data.dict()) + user.full_clean() + user.save() + + token = BearerAuth.generate_jwt(user) + return 201, TokenSchema(token=token) + + +@router.post( + path="/sign-in", + response={ + 200: TokenSchema, + 400: BadRequestError, + 401: ForbiddenError, + } +) +def sign_in(request, data: LoginSchema): + user = User.objects.filter(email=data.email).first() + if not user: + raise AuthenticationError + if user.password != data.password: + raise AuthenticationError + + token = BearerAuth.generate_jwt(user) + return 200, TokenSchema(token=token) + + +@router.get( + path="/user/{user_id}", + response={ + 200: UserSchema, + 400: BadRequestError, + 404: NotFoundError, + } +) +def get_user(request, user_id: str): + ... diff --git a/services/backend/apps/competitions/__init__.py b/services/backend/apps/competitions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/competitions/apps.py b/services/backend/apps/competitions/apps.py new file mode 100644 index 0000000..a74f56a --- /dev/null +++ b/services/backend/apps/competitions/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CompetitionsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.competitions' + label = 'competitions' diff --git a/services/backend/apps/competitions/migrations/__init__.py b/services/backend/apps/competitions/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/competitions/models.py b/services/backend/apps/competitions/models.py new file mode 100644 index 0000000..0fa5134 --- /dev/null +++ b/services/backend/apps/competitions/models.py @@ -0,0 +1,28 @@ +from django.db import models + +from apps.core.models import BaseModel + + +class CompetitionType(models.TextChoices): + SOLO = "solo" + + +class CompetitionPartipicationType(models.TextChoices): + EDU = "edu" + COMPETITIVE = "competitive" + + +class Competition(BaseModel): + title = models.CharField(max_length=100, verbose_name="Название") + description = models.TextField(verbose_name="Описание") + image_url = models.FileField(verbose_name="Изображение соревнования") + due_to = models.DateTimeField(verbose_name="Дедлайн участия") + + type = models.CharField(max_length=10, choices=CompetitionType.choices, + verbose_name="Тип участия") + participation_type = models.CharField(max_length=10, choices=CompetitionPartipicationType.choices, + verbose_name="Тип соревнования") + + class Meta: + verbose_name = "соревнование" + verbose_name_plural = "соревнования" diff --git a/services/backend/apps/users/__init__.py b/services/backend/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/users/apps.py b/services/backend/apps/users/apps.py new file mode 100644 index 0000000..cdb5bb5 --- /dev/null +++ b/services/backend/apps/users/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.users' + label = 'users' + + diff --git a/services/backend/apps/users/migrations/0001_initial.py b/services/backend/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..41491e9 --- /dev/null +++ b/services/backend/apps/users/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.6 on 2025-02-28 20:46 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + 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='Пароль')), + ], + options={ + 'verbose_name': 'пользователь', + 'verbose_name_plural': 'пользователи', + }, + ), + ] diff --git a/services/backend/apps/users/migrations/__init__.py b/services/backend/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/users/models.py b/services/backend/apps/users/models.py new file mode 100644 index 0000000..3806689 --- /dev/null +++ b/services/backend/apps/users/models.py @@ -0,0 +1,16 @@ +from django.db import models + +from apps.core.models import BaseModel + + +class User(BaseModel): + email = models.EmailField(unique=True, verbose_name="Почта") + username = models.SlugField(unique=True, verbose_name="Юзернейм") + password = models.TextField(verbose_name="Пароль") + + def __str__(self): + return self.username + + class Meta: + verbose_name = "пользователь" + verbose_name_plural = "пользователи" diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index 6e2a3b1..d19a537 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -443,6 +443,7 @@ INSTALLED_APPS = [ "minio_storage", # Internal apps "apps.core", + "apps.users", ] # GUID diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29