diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index ca9f89c..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,100 +0,0 @@ -stages: - - lint - - build - - backup - - deploy - -.configure_ssh: - before_script: - # Run ssh-agent for keys management - - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )' - - eval $(ssh-agent -s) - # Add place for ssh related files - - mkdir -p ~/.ssh - - chmod 700 ~/.ssh - # Initialize token - - chmod 400 "$SSH_PRIVATE_KEY" - - ssh-add "$SSH_PRIVATE_KEY" - # Add server fingerprint to known hosts - - ssh-keyscan "$SSH_HOST" >> ~/.ssh/known_hosts - - chmod 644 ~/.ssh/known_hosts - -.on_merge_request: - rules: - - if: $CI_PIPELINE_SOURCE == "merge_request_event" - - when: never - -lint-ruff: - stage: lint - image: registry.gitlab.com/pipeline-components/ruff:latest - rules: - - !reference [.on_merge_request, rules] - script: - - echo "☕ Linting with ruff" - - ruff check --output-format=gitlab src/ - - echo "✅ Passed" - -lint-mypy: - stage: lint - image: python:3.12 - rules: - - !reference [.on_merge_request, rules] - before_script: - - pip install mypy - - apt install make - - make deps - script: - - echo "🐍 Typechecking with mypy" - - mypy src - - echo "✅ Passed" - -build: - stage: build - image: docker:latest - services: - - docker:dind - before_script: - - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - rules: - - if: '$CI_COMMIT_BRANCH == "main"' - script: - - docker pull $CI_REGISTRY_IMAGE:latest || true - - docker build --target prod --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA --tag $CI_REGISTRY_IMAGE:latest . - - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - - docker push $CI_REGISTRY_IMAGE:latest - -database-backup: - stage: backup - image: ubuntu:latest - rules: - - if: '$CI_COMMIT_BRANCH == "main"' - before_script: - - !reference [.configure_ssh, before_script] - script: - - echo "💾 backuping database" - - ssh $SSH_USER@$SSH_HOST "docker exec database pg_dump --column-inserts udom >> pre_deploy.sql" - - echo "✅ Passed" - -deploy-dev: - stage: deploy - image: ubuntu:latest - rules: - - if: '$CI_COMMIT_BRANCH == "dev"' - before_script: - - !reference [.configure_ssh, before_script] - script: - - echo "🚀🧨 Deploing dev changes" - - ssh $SSH_USER@$SSH_HOST "cd /root/udom_dev/ && git pull && docker compose -f compose-dev.yaml up -d --build --remove-orphans" - - echo "✅ Passed" - -deploy-main: - stage: deploy - image: ubuntu:latest - rules: - - if: '$CI_COMMIT_BRANCH == "main"' - before_script: - - !reference [.configure_ssh, before_script] - script: - - echo "🚀 Deploing changes" - - ssh $SSH_USER@$SSH_HOST "cd /root/udom/ && git pull && echo $SERVER_TOKEN | docker login registry.gitlab.com -u 'Server' --password-stdin && docker compose pull && docker compose up -d --build --remove-orphans" - - echo "✅ Passed" diff --git a/README.md b/README.md index f7d4d92..3d2d76f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,50 @@ # python_dev -# launch +# Запуск +```cp .env.example .env``` ```docker compose up -d``` -# migrations +# миграции БД +```cp .env.example .env``` ```cd alembic/db1 && alembic revision --autogenerate && alembic upgrade head``` ```cd alembic/db2 && alembic revision --autogenerate && alembic upgrade head``` +# режим разработки +```cp .env.example .env``` +```rye sync``` +```granian src/app.py --reload``` + +# структура проекта + 📦python_dev_farpost + ┣ 📂alembic + ┃ ┣ 📂db1 - вспомогательные инструменты alembic для миграций первой БД + ┃ ┗ 📂db2 - вспомогательные инструменты alembic для миграций второй БД + ┣ 📂database + ┃ ┣ 📂dumps - дампы БД + ┃ ┗ 📜....sh - скрипт, для автоматизации создания и импорта дампов в multiDB + ┣ 📂src + ┃ ┣ 📂adapters + ┃ ┃ ┗ 📂database - модели sqlAlchemy и инструменты для подключения к БД + ┃ ┣ 📂api - эндпоинты fastapi + формирование датасета (не делил ввиду отстутствия необходимости) + ┃ ┣ 📂schemas - схемы для обработки входных и выходных данных (в тч валидации данных для БД) + ┃ ┣ 📜app.py - приложение для запуска + ┃ ┣ 📜settings.py - валидация настроек из .env, которые в дальнейшем удобно брать + ┣ 📜.env.example - + ┣ 📜.gitignore + ┣ 📜compose.yaml + ┣ 📜Dockerfile + ┣ 📜Makefile + ┣ 📜pyproject.toml - данные проекта для запуска rye + ┣ 📜README.md + ┣ 📜requirements-dev.lock + ┗ 📜requirements.lock + + # что не по ТЗ - мне не очень понравилось, что space_type и event_type сделаны через отдельные таблицы - потому что работать с этой таблицей будет бэкенд, и у нас есть различные API хэндлеры, которым, чтобы создать запись в БД нужно сходить на дочерние таблицы, найти нужный тип (например event_type - login), взять от него id, прийти назад и создать запись с нужным id, при этом это ещё будет не надёжным (кто-то удалит тип, поменяет название, всё поляжет) + а зачем нам отдельная таблица? (я в том смысле, что над этими типа у нас есть все операции круд, но без изменения кода бэкенда - это либо бесполезно, либо опасно) - я заменил на более удобные Enum +- (не знаю успею ли я закончить формирование датасета comments после ответа на вопрос) + - в ТЗ было сказано, что мне нужно сделать датасет в котором должна быть информация о том, кто кому какой коммент оставил, хотя упоминание комментов было только в логах. Если бы мне такое попалось как реальное задание, то я бы пошёл к аналитикам, и стал настаивать на создании отдельной таблицы комментариев, где было бы указано - от кого, на какой пост, текст комментария diff --git a/src/utils/__init__.py b/src/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/utils/exceptions.py b/src/utils/exceptions.py deleted file mode 100644 index ec143ba..0000000 --- a/src/utils/exceptions.py +++ /dev/null @@ -1,52 +0,0 @@ -class RepositoryException(Exception): ... - - -class AccessDenied(Exception): ... - - -class ResultNotFound(RepositoryException): ... - - -class AuthorizationException(Exception): ... - - -class WrongCredentials(RepositoryException): ... - - -class ForeignKeyError(RepositoryException): ... - - -class JwtException(AuthorizationException): ... - - -class JwtExpired(JwtException): ... - - -class JwtInvalid(JwtException): ... - - -class RefreshException(AuthorizationException): ... - - -class RefreshExpired(RefreshException): ... - - -class RefreshInvalid(RefreshException): ... - - -class VerificationException(Exception): ... - - -class RefreshClientInfoIncorrect(RefreshException): ... - - -class FileNotFound(Exception): ... - - -class UserNotRegistered(Exception): ... - - -class PlaceOrderForeignKeyError(Exception): ... - - -class UserAlreadyExist(Exception): ... diff --git a/src/utils/repository.py b/src/utils/repository.py deleted file mode 100644 index 9608831..0000000 --- a/src/utils/repository.py +++ /dev/null @@ -1,142 +0,0 @@ -from abc import abstractmethod -from typing import Any, Optional, Protocol - -from sqlalchemy import func, insert, select, update -from sqlalchemy.exc import IntegrityError, NoResultFound -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.sql.base import ExecutableOption - -from src.utils.exceptions import ForeignKeyError, ResultNotFound - -_sentinel: Any = object() - - -class AbstractRepository(Protocol): - @abstractmethod - async def add_one(self, **data): - raise NotImplementedError - - -class SQLAlchemyRepository(AbstractRepository): - model = _sentinel - - def __init__(self, session: AsyncSession): - self.session = session - - async def add_one(self, **data): - stmt = insert(self.model).values(**data).returning(self.model) - try: - res = await self.session.execute(stmt) - return res.scalar_one() - except IntegrityError: - raise ForeignKeyError - - async def edit_one(self, id: int, **data): - stmt = update(self.model).values(**data).filter_by(id=id).returning(self.model) - try: - res = await self.session.execute(stmt) - return res.unique().scalar_one() - except NoResultFound: - raise ResultNotFound - except IntegrityError: - raise ForeignKeyError - - async def find_all(self): - stmt = select(self.model).options(*self.get_select_options()) - res = await self.session.execute(stmt) - return res.unique().scalars().fetchall() - - async def find_filtered(self, sort_by: str = "", **filter_by): - stmt = ( - select(self.model) - .options(*self.get_select_options()) - .filter_by(**filter_by) - .order_by(getattr(self.model, sort_by, None)) - ) - res = await self.session.execute(stmt) - return res.unique().scalars().fetchall() - - async def find_filtered_and_paginated(self, page: int, limit: int, **filter_by): - stmt = ( - select(self.model) - .options(*self.get_select_options()) - .filter_by(**filter_by) - .offset((page - 1) * limit) - .limit(limit) - ) - - res = await self.session.execute(stmt) - return res.unique().scalars().fetchall() - - async def find_one(self, **filter_by): - stmt = ( - select(self.model) - .options(*self.get_select_options()) - .filter_by(**filter_by) - ) - res = await self.session.execute(stmt) - try: - return res.scalar_one() - except NoResultFound: - raise ResultNotFound - - async def count_filtered(self, **filter_by): - stmt = ( - select(func.count()) - .select_from(self.model) - .options(*self.get_select_options()) - .filter_by(**filter_by) - ) - res = await self.session.execute(stmt) - return res.unique().scalar_one() - - async def find_filtered_in(self, column_name: str, values: list): - stmt = ( - select(self.model) - .options(*self.get_select_options()) - .filter(getattr(self.model, column_name).in_(values)) - ) - res = await self.session.execute(stmt) - return res.unique().scalars().fetchall() - - # this find operation in delete methods help us to raise 404 error instead of 50x - async def delete_one(self, id: int) -> None: - await self.session.delete((await self.find_one(id=id))) - - async def delete_filtered(self, **filter_by) -> None: - for cart_item in await self.find_filtered(**filter_by): - await self.session.delete(cart_item) - - def get_select_options(self) -> list[ExecutableOption]: - return [] - - async def count_filtered_by_fastadmin( - self, - joins: list[Any], - filters: list[Any], - ): - stmt = select(func.count()).select_from(self.model).filter(*filters) - for join in joins: - stmt = stmt.join(join) - - res = await self.session.execute(stmt) - return res.unique().scalar_one() - - async def find_filtered_by_fastadmin( - self, - options: list[Any], - joins: list[Any], - filters: list[Any], - sort_by: Optional[Any], - offset: int, - limit: int, - ): - stmt = select(self.model).filter(*filters).offset(offset).limit(limit) - if sort_by is not None: - stmt = stmt.order_by(sort_by) - for join in joins: - stmt = stmt.join(join) - stmt = stmt.options(*options) - - res = await self.session.execute(stmt) - return res.scalars()