diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..27adf76 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.9.4-slim + +# set work directory +WORKDIR /app + +# set env variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +RUN apt-get update +RUN apt-get install -y python3-dev gcc libc-dev libffi-dev +RUN apt-get -y install libpq-dev gcc + +# install dependencies +COPY requirements.txt . +RUN pip install --upgrade pip +RUN pip install -r requirements.txt +# copy project +COPY . . \ No newline at end of file diff --git a/README.md b/README.md index cd9d21e..ab89142 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,102 @@ -# alfa_test_project +# Тестовый проект для Python-разработчика +Привет! Круто, что ты решил присоединиться к нашей команде. Меня зовут Артем, я собираю команду крутых разработчиков(или тех, кто ими хочет стать). Вместе мы решаем сложные и комплексные задачи. Мы стараемся не брать в работу совсем простые проекты(в духе - сделать сайт с админкой), каждый проект для нас - это вызов в первую очередь к самим себе. Конечно, у нас порой попадаются рутинные задачки, куда без них. Но едва ли проекты, которые мы делаем, можно назвать тривиальными. Наши приоритеты - свобода и профессионализм. + +Сейчас я расскажу тебе как всё устроено у нас в команде в плане разработки, а затем расскажу что нужно сделать, чтобы к нам попасть. + +## Процесс разработки + +Коммуникацию с тобой будет держать проект-менеджер. Это твой бро и он любит тебя! ПМ обычно ставит задачи в таск-трекере. В целом процесс состоит из простых этапов: +- **Постановка задачи** (ПМ описывает задачу и ставит вас исполнителем) +- **Примерная оценка** (ожидаем от тебя уточнения по задаче - ты должен четко понимать что нужно будет сделать и как это сделать, и примерную оцену - это нужно для утверждения эстимейтов и бюджета с заказчиком и планирования загруза) +- **Утверждение** (ПМ утверждает затраты по задачам и подтверждает задачу в план или просит уточнения) +- **Планирование** (ПМ ставит задачу в план, можно приступать к её реализации) +- **Решение задачи** (тут ожидаем от тебя красивого решения задачи. Если нужна помощь или возник вопрос/переоценка/что-угодно другое - сообщи ПМ. Мы за открытость, а поскольку команда удаленная нам важна коммуникация в первую очередь) +- **Задача решена** (ты выгружаешь решение на тестовое окружение, меняешь статус по задаче, задача переходит в тестирование в первую очередь к ПМ и/или на code-review к ответственным лицам) +- **Подтверждение ответственными лицами** (ПМ или тим-лид подтверждает решение и дает добро на выгрузку в продакшен. Некоторые проекты мы не можем выгружать в определенное время(высокая нагрузка/зависимость от других задач), поэтому ПМ может согласовать выгрузку в определенную дату. Обычно выгрузка - это merge ветки задачи/тестового окружение в продакшен) + +### Про окружение и CI/CD +На всех проектах мы имеем тестовое окружение, где другие участники процесса могут проверить результаты работы команды. Мы также следуем концепции CI/CD, делая всё, чтобы ребята могли выгружать код используя только гит. Если что-то будет необходимо сделать с сервером до/после выгрузки - сообщи об этом ПМ, он решит этот вопрос или любой другой. + +### Про эстимейты и декомпозицию +Стуктурирование очень важно при работе со сложными/комплексными задачами. Поэтому мы просим раскладывать все задачи на отрезки длиной от 15 минут до 2 часов. Если задача занимает 2+ часов, то она должна быть декомпозирована на более мелкие. Задачи длиной меньше 15 минут в нашей вселенной быть не может :) + +### Про коммуникации +Поскольку у нас удаленка, нам очень важна коммуникация и прозрачность процессов. И тут нельзя не сказать о важности ПМ - очень важный человек, строй с ним хорошую коммуникацию. Его задача - заботится о тебе, защищая от натисков внешнего мира(например, заказчика). Дружи с ПМ и веди с ним активную коммуникацию, он ценит тебя и то, что ты делаешь больше, чем кто бы то ни было другой. + +Кроме того у нас есть некоторые ежедневные процессы, которых просим придерживаться: + +- **Daily** (каждый день коммуникация с ПМ и командой по 10-20 минут для синхронизации по задачам) +- **Ежедневный чек по задачам и затраченному времени** (каждый вечер просим потратить время на то, чтобы отписаться по задачам, которые были взяты в работу и выполнены или не доделаны - сколько затратил времени, что осталось доделать. Также просим запушить в гит результат своей работы каждый день, даже если работа еще не закончена - создать отдельную ветку под каждую задачу -- **это единственный элемент контроля твоей работы, который мы требуем**. Если день был не очень продуктивный или что-то пошло не так - сообщи об этом ПМ. Мы лояльны и открыты, нам важно, чтобы работа шла и была прозрачна) + +Поскольку у нас удаленка и свобода в приоритете, то мы ожидаем, что ты будешь на связи с 9-00 до 18-00 по МСК. Когда ты будешь решать задачи - выбираешь ты. Мы просим: +- постараться попадать в свои же оценки +- быть доступным для коммуникаций в рабочее время(либо предупреждать ПМа если куда-то нужно отойти, всякое бывает) +- быть честным, открытым, активным и позитивным ;) + +# Тестовое задание + +Мы не сторонники интервью и сложных тестовых на часы. Предлагаем выполнить простые задачки в рамках имеющегося проекта. Проверять их будем двумя путями: +- тесты +- просмотр кода + +## О проекте +Итак, перед тобой проект, на котором подключены FastAPI и Django одновременно. Django используется только как ORM и админка, FastAPI используется для запросов. Мы не фанаты DRF, почти все проекты пишем в связке какой-то фронт(Vue/React) + API(чаще всего не REST), поэтому использование Class-based Views пригодится максимум для админки. Собственно, от Django кроме ORM и умения рулить админкой мы больше ничего и не берем. Если не сталкивался с FastAPI - не беда, наверняка ты уже пробовал Flask, они очень похожи. + +Дисклеймер: не пытайся оценить структуру проекта, это всего лишь тестовое, которое должно решаться парой десятков строк кода. + +Мы храним базу данных игроков и игр. В игре может быть не более 5 игроков. Игроки и игры создаются при помощи API-вызовов с применением аутентификации по методу JWT(уже внедрено, читай OpenAPI-документацию доступную по запросу на http://localhost:8000/docs). + +## Инфраструктура +Мы используем активно Docker и Docker-compose для поднятия инфраструктуры, используй их. +`docker-compose up -d` поднимет всю инфраструктуру проекта и проект будет доступен по http://localhost:8000/. Правки применятся автоматически(благодаря --reload(command в docker-compose), файлы внутри контейнеров и на локальной машине синхронизированы через volumes(docker-compose). +Внутрь контейнера с python можно попасть, используя команды: +``` +docker ps #отобразит список всех контейнеров +docker exec -ti bash #войдет в запущенный контейнер для выполнения команд, например - создания superuser +```` + +## Задачи в рамках тестового задания: + +- [ ] Необходимо отобразить модели игрока и игр в админке (http://localhost:8000/django/admin) + +*Задача на понимание моделей Django, миграциями, работы с админкой и docker* + +ТЗ: отобразить в списке игроков: имя, email, дату и время создания игрока, дату и время изменения игрока(в нашем случае только через админку); добавить поиск по имени или email. Отобразить в списке игр название игры и имена игроков через запятую(отдельным столбцом), дату и время создания игры, дату и время изменения игры. На странице редактирования игры отобразить inline'ами всех привязанных игроков. +*Модели менять можно!* + +Что будем оценивать: +* создана ли миграция? +* выполнены ли все условия ТЗ? +* как выполнен reverse lookup? + +- [ ] Задваивание игроков с одинаковым name и email + +*Задача на понимание FastAPI, валидации и транзакций* + +ТЗ: при создании игрока(/new_player) его имя и email должны быть уникальны. Имя должно содержать только цифры от 0 до 9 и только буквы от a до f. +Если пользователь с таким email и name уже существует, необходимо вернуть ошибку с HTTP-кодом 400, status = "error", текстом "player with such name or email already exists". Аналогичную ошибку необходимо вернуть при ошибки валидации имени пользователя или e-mail'а с указанием соответствующего текста ошибки + +Что будем оценивать: +* возможна ли race condition? +* выполнены ли все условия ТЗ? +* можно ли получить 500 ошибку при отправке данных, величина которых не предусмотрена БД? + +- [ ] Реализовать логику для метода /add_player_to_game + +*Задача на работу с ManyToMany Django ORM, валидацией FastAPI, транзакциями* + +ТЗ: при запросе игрок с указанным id должен добавляться в игру с указанным id. Если игрока или игры с заданными id не существует, должна возвращаться ошибка с HTTP-кодом 400, status = "error" и соответствующим текстом. Количество игроков в одной игре не более 5. + +Что будем оценивать: +* возможна ли race condition? +* выполнены ли все условия ТЗ? +* можно ли получить 500 ошибку? +* что произойдет при добавлении одного игрока два и более раз? + +## Как сдавать тестовое +Перешлите архив с вашими доработками нашему менеджеру, мы дадим ответ на следующий день. + +Название архива должно содержать ваше имя и фамилию. ВасяПономорев.zip / VasyaPonomorev.zip + +Спасибо за внимание ;) \ No newline at end of file diff --git a/callback/__init__.py b/callback/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/callback/admin.py b/callback/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/callback/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/callback/apps.py b/callback/apps.py new file mode 100644 index 0000000..9416109 --- /dev/null +++ b/callback/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CallbackConfig(AppConfig): + name = "callback" diff --git a/callback/fastapi.py b/callback/fastapi.py new file mode 100644 index 0000000..3900ece --- /dev/null +++ b/callback/fastapi.py @@ -0,0 +1,174 @@ +from fastapi import FastAPI, HTTPException, Depends, Request +from fastapi.responses import JSONResponse +from fastapi_jwt_auth import AuthJWT +from fastapi_jwt_auth.exceptions import AuthJWTException +from pydantic import BaseModel +from typing import Optional +from callback.models import ( + Player, + Game +) + +description = """ +We use JWT for auth. +""" + +app = FastAPI( + title="Test Project API", + description=description, + version="0.0.1" +) + + +class User(BaseModel): + username: str + password: str + + +class LoginMessage(BaseModel): + access_token: str + + +class UserMessage(BaseModel): + user: str + + +class StatusMessage(BaseModel): + status: str + id: Optional[int] = None + success: Optional[bool] = None + + +class ErrorMessage(BaseModel): + status: str + message: str + + +class PlayerItem(BaseModel): + name: str + email: str + + +class GameItem(BaseModel): + name: str + + +class Settings(BaseModel): + authjwt_secret_key: str = "secret" + + +# callback to get your configuration +@AuthJWT.load_config +def get_config(): + return Settings() + + +# exception handler for auth-jwt +# in production, you can tweak performance using orjson response +@app.exception_handler(AuthJWTException) +def authjwt_exception_handler(request: Request, exc: AuthJWTException): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.message} + ) + + +# provide a method to create access tokens. The create_access_token() +# function is used to actually generate the token to use authorization +# later in endpoint protected +@app.post('/login', tags=['Auth'], responses={200: {"model": LoginMessage}}) +def login(user: User, Authorize: AuthJWT = Depends()): + """ + Use username=test and password=test for now. + This endpoint will response you with access_token + to use in header like: "Authorization: Bearer $TOKEN" to get protected endpoints + """ + if user.username != "test" or user.password != "test": + raise HTTPException(status_code=401, detail="Bad username or password") + + # subject identifier for who this token is for example id or username from database + access_token = Authorize.create_access_token(subject=user.username) + return JSONResponse(status_code=200, content={"access_token": access_token}) + + +# protect endpoint with function jwt_required(), which requires +# a valid access token in the request headers to access. +@app.get('/user', tags=['Auth'], responses={200: {"model": UserMessage}}) +def user(Authorize: AuthJWT = Depends()): + """ + Endpoint response with user that fits "Authorization: Bearer $TOKEN" + """ + Authorize.jwt_required() + + current_user = Authorize.get_jwt_subject() + return JSONResponse(status_code=200, content={"user": current_user}) + + +@app.get('/protected_example', tags=['Auth'], responses={200: {"model": UserMessage}}) +def protected_example(Authorize: AuthJWT = Depends()): + """ + Just for test of Auth. + + Auth usage example: + $ curl http://ip:8000/user + + {"detail":"Missing Authorization Header"} + + $ curl -H "Content-Type: application/json" -X POST \ + -d '{"username":"test","password":"test"}' http://localhost:8000/login + + {"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwiaWF0IjoxNjAzNjkyMjYxLCJuYmYiOjE2MDM2OTIyNjEsImp0aSI6IjZiMjZkZTkwLThhMDYtNDEzMy04MzZiLWI5ODJkZmI3ZjNmZSIsImV4cCI6MTYwMzY5MzE2MSwidHlwZSI6ImFjY2VzcyIsImZyZXNoIjpmYWxzZX0.ro5JMHEVuGOq2YsENkZigSpqMf5cmmgPP8odZfxrzJA"} + + $ export TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwiaWF0IjoxNjAzNjkyMjYxLCJuYmYiOjE2MDM2OTIyNjEsImp0aSI6IjZiMjZkZTkwLThhMDYtNDEzMy04MzZiLWI5ODJkZmI3ZjNmZSIsImV4cCI6MTYwMzY5MzE2MSwidHlwZSI6ImFjY2VzcyIsImZyZXNoIjpmYWxzZX0.ro5JMHEVuGOq2YsENkZigSpqMf5cmmgPP8odZfxrzJA + + $ curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/user + + {"user":"test"} + + $ curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/protected_example + + {"user":"test", "test": true} + """ + Authorize.jwt_required() + + current_user = Authorize.get_jwt_subject() + return JSONResponse(status_code=200, content={"user": current_user}) + + +@app.post('/new_player', tags=['Main'], responses={200: {"model": StatusMessage}, 400: {"model": ErrorMessage}}) +def create_new_player(player: PlayerItem, Authorize: AuthJWT = Depends()): + """ + Creates new player. + """ + Authorize.jwt_required() + + new_player = Player() + new_player.name = player.name + new_player.email = player.email + new_player.save() + + return JSONResponse(content={"status": "success", "id": new_player.id, "success": True}) + + +@app.post('/new_game', tags=['Main'], responses={200: {"model": StatusMessage}, 400: {"model": ErrorMessage}}) +def create_new_game(game: GameItem, Authorize: AuthJWT = Depends()): + """ + Creates new game. + """ + Authorize.jwt_required() + + new_game = Game() + new_game.name = game.name + new_game.save() + + return JSONResponse(content={"status": "success", "id": new_game.id, "success": True}) + + +@app.post('/add_player_to_game', tags=['Main'], responses={200: {"model": StatusMessage}, 400: {"model": ErrorMessage}}) +def add_player_to_game(game_id: int, player_id: int, Authorize: AuthJWT = Depends()): + """ + Adds existing player to existing game. + """ + Authorize.jwt_required() + + return JSONResponse(content={"status": "success", "id": game_id, "success": True}) diff --git a/callback/migrations/0001_initial.py b/callback/migrations/0001_initial.py new file mode 100644 index 0000000..6ead91f --- /dev/null +++ b/callback/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.5 on 2021-11-22 03:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Player', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='', max_length=54)), + ('email', models.EmailField(max_length=54)), + ], + ), + migrations.CreateModel( + name='Game', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='', max_length=254)), + ('players', models.ManyToManyField(blank=True, related_name='player_games', to='callback.Player')), + ], + ), + ] diff --git a/callback/migrations/__init__.py b/callback/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/callback/migrations/__pycache__/0001_initial.cpython-36.pyc b/callback/migrations/__pycache__/0001_initial.cpython-36.pyc new file mode 100644 index 0000000..f9c198d Binary files /dev/null and b/callback/migrations/__pycache__/0001_initial.cpython-36.pyc differ diff --git a/callback/migrations/__pycache__/__init__.cpython-36.pyc b/callback/migrations/__pycache__/__init__.cpython-36.pyc new file mode 100644 index 0000000..a3e9aec Binary files /dev/null and b/callback/migrations/__pycache__/__init__.cpython-36.pyc differ diff --git a/callback/models.py b/callback/models.py new file mode 100644 index 0000000..386f816 --- /dev/null +++ b/callback/models.py @@ -0,0 +1,11 @@ +from django.db import models + + +class Player(models.Model): + name = models.CharField(max_length=54, default="") + email = models.EmailField(max_length=54) + + +class Game(models.Model): + name = models.CharField(max_length=254, default="") + players = models.ManyToManyField(Player, blank=True, related_name='player_games') diff --git a/callback/utils.py b/callback/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..eb81218 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,52 @@ +version: "3" + +services: + db: + image: postgres:latest + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres + volumes: + - postgresql-data:/var/lib/postgresql/data + + app: + build: . + command: bash -c 'while !