From 9a08c88ddec15fe599acded9cbc1b71118d28ca9 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Mon, 1 May 2023 22:33:00 +0300 Subject: [PATCH] Init --- .github/workflows/build_and_publish.yml | 131 ++++++++++++++++++++++++ .github/workflows/checks.yml | 67 ++++++++++++ .gitignore | 129 +++++++++++++++++++++++ Dockerfile | 18 ++++ LICENSE | 29 ++++++ Makefile | 19 ++++ README.md | 26 +++++ alembic.ini | 102 ++++++++++++++++++ flake8.conf | 35 +++++++ logging_dev.conf | 21 ++++ logging_prod.conf | 35 +++++++ logging_test.conf | 36 +++++++ migrations/README | 1 + migrations/env.py | 81 +++++++++++++++ migrations/script.py.mako | 24 +++++ migrations/versions/.gitkeep | 0 pyproject.toml | 23 +++++ rating_api/__init__.py | 3 + rating_api/__main__.py | 7 ++ rating_api/exceptions.py | 0 rating_api/models/__init__.py | 0 rating_api/models/base.py | 22 ++++ rating_api/routes/__init__.py | 0 rating_api/routes/base.py | 32 ++++++ rating_api/routes/models/__init__.py | 0 rating_api/routes/models/base.py | 12 +++ rating_api/settings.py | 28 +++++ rating_api/utils/__init__.py | 0 requirements.dev.txt | 7 ++ requirements.txt | 10 ++ tests/conftest.py | 0 tests/test_routes/.gitkeep | 1 + 32 files changed, 899 insertions(+) create mode 100644 .github/workflows/build_and_publish.yml create mode 100644 .github/workflows/checks.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 flake8.conf create mode 100644 logging_dev.conf create mode 100644 logging_prod.conf create mode 100644 logging_test.conf create mode 100644 migrations/README create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/.gitkeep create mode 100644 pyproject.toml create mode 100644 rating_api/__init__.py create mode 100644 rating_api/__main__.py create mode 100644 rating_api/exceptions.py create mode 100644 rating_api/models/__init__.py create mode 100644 rating_api/models/base.py create mode 100644 rating_api/routes/__init__.py create mode 100644 rating_api/routes/base.py create mode 100644 rating_api/routes/models/__init__.py create mode 100644 rating_api/routes/models/base.py create mode 100644 rating_api/settings.py create mode 100644 rating_api/utils/__init__.py create mode 100644 requirements.dev.txt create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_routes/.gitkeep diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml new file mode 100644 index 0000000..56a7c4f --- /dev/null +++ b/.github/workflows/build_and_publish.yml @@ -0,0 +1,131 @@ +name: Build, publish and deploy docker + +on: + push: + branches: ['main'] + tags: + - 'v*' + + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + name: Build and push + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@master + + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=tag,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=test,enable=true + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + APP_VERSION=${{ github.ref_name }} + deploy-testing: + name: Deploy Testing + needs: build-and-push-image + runs-on: [self-hosted, Linux] + environment: + name: Testing + url: https://rating_api.api.test.profcomff.com/ + env: + CONTAINER_NAME: com_profcomff_api_rating_api_test + permissions: + packages: read + + steps: + - name: Pull new version + run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test + + - name: Migrate DB + run: | + docker run \ + --rm \ + --network=web \ + --env DB_DSN=${{ secrets.DB_DSN }} \ + --name ${{ env.CONTAINER_NAME }}_migration \ + --workdir="/" \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test \ + alembic upgrade head + - name: Run new version + id: run_test + run: | + docker stop ${{ env.CONTAINER_NAME }} || true && docker rm ${{ env.CONTAINER_NAME }} || true + docker run \ + --detach \ + --restart on-failure:3 \ + --network=web \ + --env DB_DSN='${{ secrets.DB_DSN }}' \ + --env ROOT_PATH='/rating_api' \ + --env AUTH_URL='https://api.test.profcomff.com/auth' \ + --env GUNICORN_CMD_ARGS='--log-config logging_test.conf' \ + --name ${{ env.CONTAINER_NAME }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test + deploy-production: + name: Deploy Production + needs: build-and-push-image + if: startsWith(github.ref, 'refs/tags/v') + runs-on: [self-hosted, Linux] + environment: + name: Production + url: https://rating_api.api.profcomff.com/ + env: + CONTAINER_NAME: com_profcomff_api_rating_api + permissions: + packages: read + + steps: + - name: Pull new version + run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - name: Migrate DB + run: | + docker run \ + --rm \ + --network=web \ + --env DB_DSN=${{ secrets.DB_DSN }} \ + --name ${{ env.CONTAINER_NAME }}_migration \ + --workdir="/" \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ + alembic upgrade head + - name: Run new version + id: run_test + run: | + docker stop ${{ env.CONTAINER_NAME }} || true && docker rm ${{ env.CONTAINER_NAME }} || true + docker run \ + --detach \ + --restart always \ + --network=web \ + --env DB_DSN='${{ secrets.DB_DSN }}' \ + --env ROOT_PATH='/rating_api' \ + --env AUTH_URL='https://api.profcomff.com/auth' \ + --env GUNICORN_CMD_ARGS='--log-config logging_prod.conf' \ + --name ${{ env.CONTAINER_NAME }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..bce75d7 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,67 @@ +name: Python package + +on: + pull_request: + +jobs: + test: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@master + - name: Set up docker + uses: docker-practice/actions-setup-docker@master + - name: Run postgres + run: | + docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-test postgres:15-alpine + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m ensurepip + python -m pip install --upgrade --no-cache-dir pip + python -m pip install --upgrade --no-cache-dir -r requirements.txt -r requirements.dev.txt + - name: Migrate DB + run: | + DB_DSN=postgresql://postgres@localhost:5432/postgres alembic upgrade head + - name: Build coverage file + run: | + DB_DSN=postgresql://postgres@localhost:5432/postgres pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=rating_api tests/ | tee pytest-coverage.txt + - name: Print report + if: always() + run: | + cat pytest-coverage.txt + - name: Pytest coverage comment + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-coverage-path: ./pytest-coverage.txt + title: Coverage Report + badge-title: Code Coverage + hide-badge: false + hide-report: false + create-new-comment: false + hide-comment: false + report-only-changed-files: false + remove-link-from-badge: false + junitxml-path: ./pytest.xml + junitxml-title: Summary + linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: 3.11 + - uses: isort/isort-action@master + with: + requirementsFiles: "requirements.txt requirements.dev.txt" + - uses: psf/black@stable + - name: Comment if linting failed + if: ${{ failure() }} + uses: thollander/actions-comment-pull-request@v2 + with: + message: | + :poop: Code linting failed, use `black` and `isort` to fix it. + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..62b065a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11 +ARG APP_VERSION=dev +ENV APP_VERSION=${APP_VERSION} +ENV APP_NAME=rating_api +ENV APP_MODULE=${APP_NAME}.routes.base:app + +COPY ./requirements.txt /app/ +COPY ./logging_prod.conf /app/ +COPY ./logging_test.conf /app/ +RUN pip install -U -r /app/requirements.txt + +COPY ./alembic.ini /alembic.ini +COPY ./migrations /migrations/ + +COPY ./${APP_NAME} /app/${APP_NAME} + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5494ac6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2022, Профком студентов физфака МГУ +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c6a2d1c --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +run: + source ./venv/bin/activate && uvicorn --reload --log-config logging_dev.conf rating_api.routes.base:app + +configure: venv + source ./venv/bin/activate && pip install -r requirements.dev.txt -r requirements.txt + +venv: + python3.11 -m venv venv + +format: + autoflake -r --in-place --remove-all-unused-imports ./rating_api + isort ./rating_api + black ./rating_api + +db: + docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-rating_api postgres:15 + +migrate: + alembic upgrade head diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ce7ac4 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Рейтинг преподавателей + +Хранение и работа с рейтингом преподавателей и отзывами на них. + +## Запуск + +1. Перейдите в папку проекта + +2. Создайте виртуальное окружение командой и активируйте его: + ```console + foo@bar:~$ python3 -m venv venv + foo@bar:~$ source ./venv/bin/activate # На MacOS и Linux + foo@bar:~$ venv\Scripts\activate # На Windows + ``` + +3. Установите библиотеки + ```console + foo@bar:~$ pip install -r requirements.txt + ``` +4. Запускайте приложение! + ```console + foo@bar:~$ python -m rating_api + ``` + +## ENV-file description +- `DB_DSN=postgresql://postgres@localhost:5432/postgres` – Данные для подключения к БД diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..f6c8899 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,102 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/flake8.conf b/flake8.conf new file mode 100644 index 0000000..547208a --- /dev/null +++ b/flake8.conf @@ -0,0 +1,35 @@ +[flake8] +select = + E, W, # pep8 errors and warnings + F, # pyflakes + C9, # McCabe + N8, # Naming Conventions + #B, S, # bandit + #C, # commas + #D, # docstrings + #P, # string-format + #Q, # quotes + +ignore = + E122, # continuation line missing indentation or outdented + E123, # closing bracket does not match indentation of opening bracket's line + E127, # continuation line over-indented for visual indent + E131, # continuation line unaligned for hanging + E203, # whitespace before ':' + E225, # missing whitespace around operator + E226, # missing whitespace around arithmetic operator + E24, # multiple spaces after ',' or tab after ',' + E275, # missing whitespace after keyword + E305, # expected 2 blank lines after end of function or class + E306, # expected 1 blank line before a nested definition + E402, # module level import not at top of file + E722, # do not use bare except, specify exception instead + E731, # do not assign a lambda expression, use a def + E741, # do not use variables named 'l', 'O', or 'I' + + F722, # syntax error in forward annotation + + W503, # line break before binary operator + W504, # line break after binary operator + +max-line-length = 120 \ No newline at end of file diff --git a/logging_dev.conf b/logging_dev.conf new file mode 100644 index 0000000..7837272 --- /dev/null +++ b/logging_dev.conf @@ -0,0 +1,21 @@ +[loggers] +keys=root + +[handlers] +keys=all + +[formatters] +keys=main + +[logger_root] +level=DEBUG +handlers=all + +[handler_all] +class=StreamHandler +formatter=main +level=DEBUG +args=(sys.stdout,) + +[formatter_main] +format=%(asctime)s %(levelname)-8s %(name)-15s %(message)s diff --git a/logging_prod.conf b/logging_prod.conf new file mode 100644 index 0000000..971f309 --- /dev/null +++ b/logging_prod.conf @@ -0,0 +1,35 @@ +[loggers] +keys=root,gunicorn.error,gunicorn.access + +[handlers] +keys=all + +[formatters] +keys=json + +[logger_root] +level=INFO +handlers=all + +[logger_gunicorn.error] +level=INFO +handlers=all +propagate=0 +qualname=gunicorn.error +formatter=json + +[logger_gunicorn.access] +level=INFO +handlers=all +propagate=0 +qualname=gunicorn.access +formatter=json + +[handler_all] +class=StreamHandler +formatter=json +level=INFO +args=(sys.stdout,) + +[formatter_json] +class=logger.formatter.JSONLogFormatter diff --git a/logging_test.conf b/logging_test.conf new file mode 100644 index 0000000..6bbe691 --- /dev/null +++ b/logging_test.conf @@ -0,0 +1,36 @@ +[loggers] +keys=root,gunicorn.error,gunicorn.access + +[handlers] +keys=all + +[formatters] +keys=json + +[logger_root] +level=DEBUG +handlers=all +formatter=json + +[logger_gunicorn.error] +level=DEBUG +handlers=all +propagate=0 +qualname=gunicorn.error +formatter=json + +[logger_gunicorn.access] +level=DEBUG +handlers=all +propagate=0 +qualname=gunicorn.access +formatter=json + +[handler_all] +class=StreamHandler +formatter=json +level=DEBUG +args=(sys.stdout,) + +[formatter_json] +class=logger.formatter.JSONLogFormatter diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..75b1840 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,81 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from rating_api.models.base import Base +from rating_api.settings import get_settings + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +settings = get_settings() + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + configuration = config.get_section(config.config_ini_section) + configuration['sqlalchemy.url'] = settings.DB_DSN + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/.gitkeep b/migrations/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bf2a06d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[tool.black] +line-length = 120 +target-version = ['py311'] +skip-string-normalization = true + +[tool.isort] +line_length = 120 +multi_line_output = 3 +profile = "black" +lines_after_imports = 2 +include_trailing_comma = true + +[tool.pytest.ini_options] +minversion = "7.0" +python_files = "*.py" +testpaths = [ + "tests" +] +pythonpath = [ + "." +] +log_cli=true +log_level=0 diff --git a/rating_api/__init__.py b/rating_api/__init__.py new file mode 100644 index 0000000..ebcaeac --- /dev/null +++ b/rating_api/__init__.py @@ -0,0 +1,3 @@ +import os + +__version__ = os.getenv('APP_VERSION', 'dev') diff --git a/rating_api/__main__.py b/rating_api/__main__.py new file mode 100644 index 0000000..f2d345b --- /dev/null +++ b/rating_api/__main__.py @@ -0,0 +1,7 @@ +import uvicorn + +from rating_api.routes.base import app + + +if __name__ == '__main__': + uvicorn.run(app) diff --git a/rating_api/exceptions.py b/rating_api/exceptions.py new file mode 100644 index 0000000..e69de29 diff --git a/rating_api/models/__init__.py b/rating_api/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rating_api/models/base.py b/rating_api/models/base.py new file mode 100644 index 0000000..16065b5 --- /dev/null +++ b/rating_api/models/base.py @@ -0,0 +1,22 @@ +import re + +from sqlalchemy.ext.declarative import as_declarative, declared_attr + + +@as_declarative() +class Base: + """Base class for all database entities""" + + @classmethod + @declared_attr + def __tablename__(cls) -> str: + """Generate database table name automatically. + Convert CamelCase class name to snake_case db table name. + """ + return re.sub(r"(? str: + attrs = [] + for c in self.__table__.columns: + attrs.append(f"{c.name}={getattr(self, c.name)}") + return "{}({})".format(self.__class__.__name__, ', '.join(attrs)) diff --git a/rating_api/routes/__init__.py b/rating_api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rating_api/routes/base.py b/rating_api/routes/base.py new file mode 100644 index 0000000..15a4664 --- /dev/null +++ b/rating_api/routes/base.py @@ -0,0 +1,32 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi_sqlalchemy import DBSessionMiddleware +from rating_api import __version__ +from rating_api.settings import get_settings + +settings = get_settings() +app = FastAPI( + title='Рейтинг преподавателей', + description='Хранение и работа с рейтингом преподавателей и отзывами на них.', + version=__version__, + + # Отключаем нелокальную документацию + root_path=settings.ROOT_PATH if __version__ != 'dev' else '/', + docs_url=None if __version__ != 'dev' else '/docs', + redoc_url=None, +) + + +app.add_middleware( + DBSessionMiddleware, + db_url=settings.DB_DSN, + engine_args={"pool_pre_ping": True, "isolation_level": "AUTOCOMMIT"}, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ALLOW_ORIGINS, + allow_credentials=settings.CORS_ALLOW_CREDENTIALS, + allow_methods=settings.CORS_ALLOW_METHODS, + allow_headers=settings.CORS_ALLOW_HEADERS, +) diff --git a/rating_api/routes/models/__init__.py b/rating_api/routes/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rating_api/routes/models/base.py b/rating_api/routes/models/base.py new file mode 100644 index 0000000..d96d4d5 --- /dev/null +++ b/rating_api/routes/models/base.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class Base(BaseModel): + def __repr__(self) -> str: + attrs = [] + for k, v in self.__class__.schema().items(): + attrs.append(f"{k}={v}") + return "{}({})".format(self.__class__.__name__, ', '.join(attrs)) + + class Config: + orm_mode = True diff --git a/rating_api/settings.py b/rating_api/settings.py new file mode 100644 index 0000000..9a90f55 --- /dev/null +++ b/rating_api/settings.py @@ -0,0 +1,28 @@ +import os +from functools import lru_cache + +from pydantic import BaseSettings, PostgresDsn + + +class Settings(BaseSettings): + """Application settings""" + + DB_DSN: PostgresDsn = 'postgresql://postgres@localhost:5432/postgres' + ROOT_PATH: str = '/' + os.getenv("APP_NAME", "") + + CORS_ALLOW_ORIGINS: list[str] = ['*'] + CORS_ALLOW_CREDENTIALS: bool = True + CORS_ALLOW_METHODS: list[str] = ['*'] + CORS_ALLOW_HEADERS: list[str] = ['*'] + + class Config: + """Pydantic BaseSettings config""" + + case_sensitive = True + env_file = ".env" + + +@lru_cache +def get_settings() -> Settings: + settings = Settings() + return settings diff --git a/rating_api/utils/__init__.py b/rating_api/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.dev.txt b/requirements.dev.txt new file mode 100644 index 0000000..c8d847a --- /dev/null +++ b/requirements.dev.txt @@ -0,0 +1,7 @@ +autoflake +black +httpx +isort +pytest +pytest-cov +pytest-mock diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3c83f42 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +alembic +auth-lib-profcomff[fastapi] +fastapi +fastapi-sqlalchemy +gunicorn +logging-profcomff +psycopg2-binary +pydantic[dotenv] +SQLAlchemy +uvicorn diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_routes/.gitkeep b/tests/test_routes/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/test_routes/.gitkeep @@ -0,0 +1 @@ +