diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml index 19ac87e..8fa453d 100644 --- a/.github/workflows/build_and_publish.yml +++ b/.github/workflows/build_and_publish.yml @@ -87,7 +87,8 @@ jobs: --network=web \ --env DB_DSN='${{ secrets.DB_DSN }}' \ --env ROOT_PATH='/services' \ - --env GUNICORN_CMD_ARGS='--log-config logging_test.conf' \ + --env AUTH_URL='https://api.test.profcomff.com/auth' \ + --env GUNICORN_CMD_ARGS='--log-config logging_test.conf' \ --name ${{ env.CONTAITER_NAME }} \ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test @@ -129,6 +130,7 @@ jobs: --network=web \ --env DB_DSN='${{ secrets.DB_DSN }}' \ --env ROOT_PATH='/services' \ - --env GUNICORN_CMD_ARGS='--log-config logging_prod.conf' \ + --env AUTH_URL='https://api.profcomff.com/auth' \ + --env GUNICORN_CMD_ARGS='--log-config logging_prod.conf' \ --name ${{ env.CONTAITER_NAME }} \ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest diff --git a/Makefile b/Makefile index d16211d..61dde27 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,16 @@ run: - source ./venv/bin/activate && uvicorn --reload --log-level debug services_backend.routes.base:app + source ./venv/bin/activate && uvicorn --reload --log-config logging_dev.conf services_backend.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 ./services_backend + isort ./services_backend + black ./services_backend db: docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-services-backend postgres:15 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 index 971f309..37d976a 100644 --- a/logging_prod.conf +++ b/logging_prod.conf @@ -10,6 +10,7 @@ keys=json [logger_root] level=INFO handlers=all +formatter=json [logger_gunicorn.error] level=INFO diff --git a/requirements.dev.txt b/requirements.dev.txt index 2b10fc2..661f027 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -1,3 +1,7 @@ pytest pytest-cov -httpx \ No newline at end of file +httpx +pytest_mock +autoflake +isort +black diff --git a/requirements.txt b/requirements.txt index 74edae6..e72fced 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ alembic SQLAlchemy gunicorn logging-profcomff +auth-lib-profcomff[fastapi] diff --git a/services_backend/__main__.py b/services_backend/__main__.py index 4db9614..2640c6a 100644 --- a/services_backend/__main__.py +++ b/services_backend/__main__.py @@ -1,6 +1,7 @@ -from .routes.base import app import uvicorn +from .routes.base import app + if __name__ == '__main__': uvicorn.run(app) diff --git a/services_backend/models/__init__.py b/services_backend/models/__init__.py index 72261d9..f387ff3 100644 --- a/services_backend/models/__init__.py +++ b/services_backend/models/__init__.py @@ -1,3 +1,4 @@ from .database import Button, Category + __all__ = ["Button", "Category"] diff --git a/services_backend/models/base.py b/services_backend/models/base.py index 9ce7811..16065b5 100644 --- a/services_backend/models/base.py +++ b/services_backend/models/base.py @@ -1,4 +1,5 @@ import re + from sqlalchemy.ext.declarative import as_declarative, declared_attr diff --git a/services_backend/models/database.py b/services_backend/models/database.py index 7804df1..ce4790a 100644 --- a/services_backend/models/database.py +++ b/services_backend/models/database.py @@ -1,6 +1,8 @@ from __future__ import annotations -from sqlalchemy import Integer, String, ForeignKey -from sqlalchemy.orm import relationship, Mapped, mapped_column + +from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + from .base import Base diff --git a/services_backend/routes/base.py b/services_backend/routes/base.py index 29ca20e..05582ca 100644 --- a/services_backend/routes/base.py +++ b/services_backend/routes/base.py @@ -14,7 +14,6 @@ title='API управления списком сервисов', description='Программный интерфейс управления списком сервисов в приложении Твой ФФ!', version=__version__, - # Настраиваем интернет документацию root_path=settings.ROOT_PATH if __version__ != 'dev' else '/', docs_url=None if __version__ != 'dev' else '/docs', diff --git a/services_backend/routes/button.py b/services_backend/routes/button.py index 0ae46fe..d7dc535 100644 --- a/services_backend/routes/button.py +++ b/services_backend/routes/button.py @@ -1,15 +1,29 @@ -from fastapi import HTTPException, APIRouter +import logging + +from auth_lib.fastapi import UnionAuth +from fastapi import APIRouter, Depends, HTTPException from fastapi_sqlalchemy import db -from .models.button import ButtonCreate, ButtonUpdate, ButtonGet -from .models.category import CategoryGet from ..models.database import Button, Category +from .models.button import ButtonCreate, ButtonGet, ButtonUpdate +from .models.category import CategoryGet + +logger = logging.getLogger(__name__) button = APIRouter() @button.post("/", response_model=ButtonGet) -def create_button(button_inp: ButtonCreate, category_id: int): +def create_button( + button_inp: ButtonCreate, + category_id: int, + user=Depends(UnionAuth(['services.button.create'])), +): + """Создать кнопку + + Необходимые scopes: `services.button.create` + """ + logger.info(f"User {user.get('id')} triggered create_button") category = db.session.query(Category).filter(Category.id == category_id).one_or_none() if not category: raise HTTPException(status_code=404, detail="Category does not exist") @@ -24,7 +38,15 @@ def create_button(button_inp: ButtonCreate, category_id: int): @button.get("/", response_model=CategoryGet) -def get_buttons(category_id: int): +def get_buttons( + category_id: int, + user=Depends(UnionAuth(allow_none=True, auto_error=False)), +): + """Показать все кнопки в категории + + Необходимые scopes: `-` + """ + logger.info(f"User {user.get('id')} triggered create_category") category = db.session.query(Category).filter(Category.id == category_id).one_or_none() if not category: raise HTTPException(status_code=404, detail="Category does not exist") @@ -32,7 +54,16 @@ def get_buttons(category_id: int): @button.get("/{button_id}", response_model=ButtonGet) -def get_button(button_id: int, category_id: int): +def get_button( + button_id: int, + category_id: int, + user=Depends(UnionAuth(allow_none=True, auto_error=False)), +): + """Показать одну кнопку + + Необходимые scopes: `-` + """ + logger.info(f"User {user.get('id')} triggered create_category") category = db.session.query(Category).filter(Category.id == category_id).one_or_none() if not category: raise HTTPException(status_code=404, detail="Category does not exist") @@ -45,7 +76,16 @@ def get_button(button_id: int, category_id: int): @button.delete("/{button_id}", response_model=None) -def remove_button(button_id: int, category_id: int): +def remove_button( + button_id: int, + category_id: int, + user=Depends(UnionAuth(['services.button.remove'])), +): + """Удалить кнопку + + Необходимые scopes: `services.button.remove` + """ + logger.info(f"User {user.get('id')} triggered create_category") category = db.session.query(Category).filter(Category.id == category_id).one_or_none() if not category: raise HTTPException(status_code=404, detail="Category does not exist") @@ -60,7 +100,17 @@ def remove_button(button_id: int, category_id: int): @button.patch("/{button_id}", response_model=ButtonUpdate) -def update_button(button_inp: ButtonUpdate, button_id: int, category_id: int): +def update_button( + button_inp: ButtonUpdate, + button_id: int, + category_id: int, + user=Depends(UnionAuth(['services.button.update'])), +): + """Обновить кнопку + + Необходимые scopes: `services.button.update` + """ + logger.info(f"User {user.get('id')} triggered create_category") query = db.session.query(Button).filter(Button.id == button_id) button = query.one_or_none() last_button = ( diff --git a/services_backend/routes/category.py b/services_backend/routes/category.py index 524ea2f..b81d55b 100644 --- a/services_backend/routes/category.py +++ b/services_backend/routes/category.py @@ -1,14 +1,28 @@ -from fastapi import HTTPException, APIRouter +import logging +from typing import Literal + +from auth_lib.fastapi import UnionAuth +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi_sqlalchemy import db -from .models.category import CategoryCreate, CategoryUpdate, CategoryGet -from ..models.database import Category, Button +from ..models.database import Button, Category +from .models.category import CategoryCreate, CategoryGet, CategoryUpdate + +logger = logging.getLogger(__name__) category = APIRouter() @category.post("/", response_model=CategoryGet) -def create_category(category_inp: CategoryCreate): +def create_category( + category_inp: CategoryCreate, + user=Depends(UnionAuth(['services.category.create'])), +): + """Создает категорию + + Необходимые scopes: `services.category.create` + """ + logger.info(f"User {user.get('id')} triggered create_category") last_category = db.session.query(Category).order_by(Category.order.desc()).first() category = Category(**category_inp.dict(exclude_none=True)) if last_category: @@ -19,17 +33,31 @@ def create_category(category_inp: CategoryCreate): @category.get("/", response_model=list[CategoryGet], response_model_exclude_none=True) -def get_categories(offset: int = 0, limit: int = 100): - if (offset < 0) or (limit < 0): - raise HTTPException(400, detail="Offset or limit cant be negative") +def get_categories( + info: list[Literal['buttons']] = Query([]), + user=Depends(UnionAuth(allow_none=True, auto_error=False)), +): + """Показывает список категорий + + Необходимые scopes: `-` + """ + logger.info(f"User {user.get('id')} triggered get_categories") return [ - CategoryGet.from_orm(row).dict(exclude={"buttons"}) - for row in db.session.query(Category).order_by(Category.order).offset(offset).limit(limit).all() + CategoryGet.from_orm(row).dict(exclude={"buttons"} if 'buttons' not in info else {}) + for row in db.session.query(Category).order_by(Category.order).all() ] @category.get("/{category_id}", response_model=CategoryGet, response_model_exclude_none=True) -def get_category(category_id: int): +def get_category( + category_id: int, + user=Depends(UnionAuth(allow_none=True, auto_error=False)), +): + """Показывает категорию + + Необходимые scopes: `-` + """ + logger.info(f"User {user.get('id')} triggered get_category") category = db.session.query(Category).filter(Category.id == category_id).one_or_none() if not category: raise HTTPException(status_code=404, detail="Category does not exist") @@ -42,7 +70,15 @@ def get_category(category_id: int): @category.delete("/{category_id}", response_model=None) -def remove_category(category_id: int): +def remove_category( + category_id: int, + user=Depends(UnionAuth(['services.category.delete'])), +): + """Удаляет категорию и все кнопки в ней + + Необходимые scopes: `services.category.delete` + """ + logger.info(f"User {user.get('id')} triggered remove_category") category = db.session.query(Category).filter(Category.id == category_id).one_or_none() if not category: raise HTTPException(status_code=404, detail="Category does not exist") @@ -55,7 +91,16 @@ def remove_category(category_id: int): @category.patch("/{category_id}", response_model=CategoryUpdate) -def update_category(category_inp: CategoryUpdate, category_id: int): +def update_category( + category_inp: CategoryUpdate, + category_id: int, + user=Depends(UnionAuth(['services.category.update'])), +): + """Обновляет категорию + + Необходимые scopes: `services.category.update` + """ + logger.info(f"User {user.get('id')} triggered update_category") category = db.session.query(Category).filter(Category.id == category_id).one_or_none() last_category = db.session.query(Category).order_by(Category.order.desc()).first() diff --git a/services_backend/settings.py b/services_backend/settings.py index 234f2d8..a602307 100644 --- a/services_backend/settings.py +++ b/services_backend/settings.py @@ -7,7 +7,7 @@ class Settings(BaseSettings): """Application settings""" - DB_DSN: PostgresDsn + DB_DSN: PostgresDsn = 'postgresql://postgres@localhost:5432/postgres' ROOT_PATH: str = '/' + os.getenv('APP_NAME', '') CORS_ALLOW_ORIGINS: list[str] = ['*'] diff --git a/tests/conftest.py b/tests/conftest.py index d967a94..5e1b17c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,25 @@ import pytest from fastapi.testclient import TestClient -from sqlalchemy.orm import Session, sessionmaker +from pytest_mock import MockerFixture from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from services_backend.models.base import Base from services_backend.routes.base import app from services_backend.settings import get_settings -from services_backend.models.base import Base -@pytest.fixture(scope='session') -def client(): +@pytest.fixture() +def client(mocker: MockerFixture): + user_mock = mocker.patch('auth_lib.fastapi.UnionAuth.__call__') + user_mock.return_value = { + "session_scopes": [{"id": 0, "name": "string", "comment": "string"}], + "user_scopes": [{"id": 0, "name": "string", "comment": "string"}], + "indirect_groups": [{"id": 0, "name": "string", "parent_id": 0}], + "groups": [{"id": 0, "name": "string", "parent_id": 0}], + "id": 0, + "email": "string", + } client = TestClient(app) return client