diff --git a/Makefile b/Makefile index 61dde27..01e5bf1 100644 --- a/Makefile +++ b/Makefile @@ -8,12 +8,12 @@ venv: python3.11 -m venv venv format: - autoflake -r --in-place --remove-all-unused-imports ./services_backend - isort ./services_backend - black ./services_backend + source ./venv/bin/activate && autoflake -r --in-place --remove-all-unused-imports ./services_backend + source ./venv/bin/activate && isort ./services_backend + source ./venv/bin/activate && black ./services_backend db: docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-services-backend postgres:15 migrate: - alembic upgrade head + source ./venv/bin/activate && alembic upgrade head diff --git a/migrations/versions/660bb7891726_scopes.py b/migrations/versions/660bb7891726_scopes.py new file mode 100644 index 0000000..97adc99 --- /dev/null +++ b/migrations/versions/660bb7891726_scopes.py @@ -0,0 +1,34 @@ +"""Scopes + +Revision ID: 660bb7891726 +Revises: 6a486347af93 +Create Date: 2023-03-16 14:38:26.163590 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '660bb7891726' +down_revision = '6a486347af93' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'scope', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ['category_id'], + ['category.id'], + ), + sa.PrimaryKeyConstraint('id'), + ) + + +def downgrade(): + op.drop_table('scope') diff --git a/migrations/versions/6a486347af93_order.py b/migrations/versions/6a486347af93_order.py index f0579e8..a3c9998 100644 --- a/migrations/versions/6a486347af93_order.py +++ b/migrations/versions/6a486347af93_order.py @@ -23,11 +23,15 @@ def upgrade(): conn = op.get_bind() res = conn.execute(sa.text("select id from button")).fetchall() for i in range(0, len(res)): - conn.execute(sa.text(f"""UPDATE "button" + conn.execute( + sa.text( + f"""UPDATE "button" SET "order"={i + 1}, "link"='#', "type"='external' - WHERE id={res[i][0]}""")) + WHERE id={res[i][0]}""" + ) + ) op.alter_column('button', 'order', nullable=False) op.alter_column('button', 'link', nullable=False) op.alter_column('button', 'type', nullable=False) @@ -37,9 +41,13 @@ def upgrade(): op.add_column('category', sa.Column('order', sa.Integer(), nullable=True)) res = conn.execute(sa.text("select id from category")).fetchall() for i in range(0, len(res)): - conn.execute(sa.text(f"""UPDATE "category" + conn.execute( + sa.text( + f"""UPDATE "category" SET "order"={i + 1} - WHERE id={res[i][0]}""")) + WHERE id={res[i][0]}""" + ) + ) op.alter_column('category', 'order', nullable=False) op.alter_column('category', 'name', existing_type=sa.VARCHAR(), nullable=False) op.alter_column('category', 'type', existing_type=sa.VARCHAR(), nullable=False) diff --git a/services_backend/models/database.py b/services_backend/models/database.py index ce4790a..c0b6cca 100644 --- a/services_backend/models/database.py +++ b/services_backend/models/database.py @@ -12,6 +12,7 @@ class Category(Base): name: Mapped[str] = mapped_column(String) type: Mapped[str] = mapped_column(String) buttons: Mapped[list[Button]] = relationship("Button", back_populates="category", foreign_keys="Button.category_id") + scopes: Mapped[list[Scope]] = relationship("Scope", back_populates="category") class Button(Base): @@ -23,3 +24,10 @@ class Button(Base): icon: Mapped[str] = mapped_column(String) link: Mapped[str] = mapped_column(String) type: Mapped[str] = mapped_column(String) + + +class Scope(Base): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=True) + category_id: Mapped[int] = mapped_column(Integer, ForeignKey("category.id")) + category: Mapped[Category] = relationship("Category", back_populates="scopes", foreign_keys=[category_id]) diff --git a/services_backend/routes/base.py b/services_backend/routes/base.py index 05582ca..67697cc 100644 --- a/services_backend/routes/base.py +++ b/services_backend/routes/base.py @@ -7,6 +7,7 @@ from .button import button from .category import category +from .scope import scope settings = get_settings() @@ -21,7 +22,11 @@ ) -app.add_middleware(DBSessionMiddleware, db_url=settings.DB_DSN, engine_args={"pool_pre_ping": True}) +app.add_middleware( + DBSessionMiddleware, + db_url=settings.DB_DSN, + engine_args={"pool_pre_ping": True, "isolation_level": "AUTOCOMMIT"}, +) app.add_middleware( CORSMiddleware, @@ -33,3 +38,4 @@ app.include_router(button, prefix='/category/{category_id}/button', tags=["Button"]) app.include_router(category, prefix='/category', tags=["Category"]) +app.include_router(scope, prefix='/category/{category_id}/scope', tags=["Scope"]) diff --git a/services_backend/routes/button.py b/services_backend/routes/button.py index 4e2933c..f876826 100644 --- a/services_backend/routes/button.py +++ b/services_backend/routes/button.py @@ -33,7 +33,7 @@ def create_button( if last_button: button.order = last_button.order + 1 db.session.add(button) - db.session.commit() + db.session.flush() return button @@ -98,7 +98,7 @@ def remove_button( raise HTTPException(status_code=404, detail="Button is not in this category") db.session.delete(button) db.session.query(Button).filter(Button.order > button.order).update({"order": Button.order - 1}) - db.session.commit() + db.session.flush() @button.patch("/{button_id}", response_model=ButtonUpdate) @@ -143,5 +143,5 @@ def update_button( db.session.query(Button).filter(Button.order > button.order).update({"order": Button.order - 1}) query.update(button_inp.dict(exclude_unset=True, exclude_none=True)) - db.session.commit() + db.session.flush() return button diff --git a/services_backend/routes/category.py b/services_backend/routes/category.py index 0b831dd..cc0c5c3 100644 --- a/services_backend/routes/category.py +++ b/services_backend/routes/category.py @@ -4,11 +4,11 @@ from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends, HTTPException, Query from fastapi_sqlalchemy import db +from sqlalchemy.orm import joinedload -from ..models.database import Button, Category +from ..models.database import Button, Category, Scope from .models.category import CategoryCreate, CategoryGet, CategoryUpdate - logger = logging.getLogger(__name__) category = APIRouter() @@ -28,7 +28,7 @@ def create_category( if last_category: category.order = last_category.order + 1 db.session.add(category) - db.session.commit() + db.session.flush() return category @@ -42,10 +42,21 @@ def get_categories( Необходимые scopes: `-` """ user_id = user.get('id') if user is not None else None - logger.info(f"User {user_id} triggered get_categories") + if user_id is None: + logger.info("Unauthorised user triggered get_categories") + else: + logger.info(f"User {user_id} triggered get_categories") + + user_scopes = set([scope["name"] for scope in user["session_scopes"]] if user else []) + filtered_categories = [] + for category in db.session.query(Category).order_by(Category.order).options(joinedload(Category.scopes)).all(): + category_scopes = set([scope.__dict__["name"] for scope in category.scopes]) + if (category_scopes == set()) or (user_scopes & category_scopes): + filtered_categories.append(category) + return [ 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() + for row in filtered_categories ] @@ -59,15 +70,23 @@ def get_category( Необходимые scopes: `-` """ user_id = user.get('id') if user is not None else None - logger.info(f"User {user_id} triggered get_category") + if user_id is None: + logger.info("Unauthorised user triggered get_category") + else: + logger.info(f"User {user_id} triggered get_category") + + user_scopes = set([scope["name"] for scope in user["session_scopes"]] if user else []) category = db.session.query(Category).filter(Category.id == category_id).one_or_none() - if not category: + if not category or ( + category.scopes and not (user_scopes & set([scope.__dict__["name"] for scope in category.scopes])) + ): raise HTTPException(status_code=404, detail="Category does not exist") return { "id": category_id, "order": category.order, "name": category.name, "type": category.type, + "scopes": category.scopes, } @@ -90,7 +109,7 @@ def remove_category( db.session.flush() db.session.query(Category).filter(Category.order > category.order).update({"order": Category.order - 1}) db.session.delete(category) - db.session.commit() + db.session.flush() @category.patch("/{category_id}", response_model=CategoryUpdate) @@ -129,5 +148,5 @@ def update_category( query = db.session.query(Category).filter(Category.id == category_id) query.update(category_inp.dict(exclude_unset=True, exclude_none=True)) - db.session.commit() + db.session.flush() return category diff --git a/services_backend/routes/models/category.py b/services_backend/routes/models/category.py index 0c48093..fffa8e3 100644 --- a/services_backend/routes/models/category.py +++ b/services_backend/routes/models/category.py @@ -1,5 +1,6 @@ from .base import Base from .button import ButtonGet +from .scope import ScopeGet class CategoryCreate(Base): @@ -19,3 +20,4 @@ class CategoryGet(Base): type: str | None name: str | None buttons: list[ButtonGet] | None + scopes: list[ScopeGet] | None diff --git a/services_backend/routes/models/scope.py b/services_backend/routes/models/scope.py new file mode 100644 index 0000000..438698f --- /dev/null +++ b/services_backend/routes/models/scope.py @@ -0,0 +1,10 @@ +from .base import Base + + +class ScopeCreate(Base): + name: str + + +class ScopeGet(Base): + id: int + name: str diff --git a/services_backend/routes/scope.py b/services_backend/routes/scope.py new file mode 100644 index 0000000..826a719 --- /dev/null +++ b/services_backend/routes/scope.py @@ -0,0 +1,30 @@ +from fastapi import HTTPException, APIRouter, Depends +from fastapi_sqlalchemy import db + +from auth_lib.fastapi import UnionAuth +from .models.scope import ScopeGet, ScopeCreate +from ..models.database import Scope + +scope = APIRouter() + + +@scope.post("/", response_model=ScopeGet) +def create_scope(scope_inp: ScopeCreate, category_id: int, user=Depends(UnionAuth(['services.category.permission.create']))): + scope = Scope(**{"name": scope_inp.name, "category_id": category_id}) + db.session.add(scope) + db.session.flush() + return scope + + +@scope.get("/", response_model=list[ScopeGet]) +def get_scopes(category_id: int, offset: int = 0, limit: int = 100): + return db.session.query(Scope).filter(category_id == Scope.category_id).offset(offset).limit(limit).all() + + +@scope.delete("/{scope_id}", response_model=None) +def delete_scope(category_id: int, scope_id: int, user=Depends(UnionAuth(['services.category.permission.delete']))): + scope = db.session.query(Scope).filter(category_id == Scope.category_id).filter(Scope.id == scope_id).one_or_none() + if not scope: + raise HTTPException(status_code=404, detail="Scope does not exist") + db.session.delete(scope) + db.session.flush() diff --git a/tests/api/category.py b/tests/api/category.py index 7531257..73b0ca3 100644 --- a/tests/api/category.py +++ b/tests/api/category.py @@ -2,6 +2,7 @@ from starlette import status from services_backend.settings import get_settings from services_backend.models.database import Category +from pytest_mock import MockerFixture class TestCategory: @@ -32,8 +33,28 @@ def test_post_success(self, client, dbsession): assert db_category_created.type == body["type"] assert db_category_created.order == 1 assert not db_category_created.buttons + client.delete(f'{self._url}{db_category_created.id}') + + def test_get_by_id_success(self, client, db_category, mocker: MockerFixture): + res = client.get(f'{self._url}{db_category.id}') + assert res.status_code == status.HTTP_200_OK + res_body = res.json() + assert res_body['id'] == db_category.id + assert res_body['type'] == db_category.type + assert res_body['name'] == db_category.name + assert res_body['order'] == db_category.order + + user_mock = mocker.patch('auth_lib.fastapi.UnionAuth.__call__') + user_mock.return_value = { + "session_scopes": [{"id": 0, "name": "string", "comment": "string"}, + {"id": 1, "name": "test", "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", + } - def test_get_by_id_success(self, client, db_category): res = client.get(f'{self._url}{db_category.id}') assert res.status_code == status.HTTP_200_OK res_body = res.json() @@ -152,3 +173,52 @@ def test_delete_order(self, db_category, client): res = client.get(f"{self._url}{res1.json()['id']}") assert res.json()['order'] == 1 + + def test_category_scopes(self, client, dbsession, mocker: MockerFixture): + category_body = {"name": "t", "type": "string"} + res1 = client.post(self._url, data=json.dumps(category_body)) + assert res1.status_code == status.HTTP_200_OK + res1_body = res1.json() + + scope_body = {"name": "test"} + res2 = client.post(f"{self._url}{res1_body['id']}/scope/", data=json.dumps(scope_body)) + assert res2.status_code == status.HTTP_200_OK + + res = client.get(f"{self._url}{res1_body['id']}") + assert res.status_code == status.HTTP_404_NOT_FOUND + + user_mock = mocker.patch('auth_lib.fastapi.UnionAuth.__call__') + user_mock.return_value = { + "session_scopes": [{"id": 0, "name": "string", "comment": "string"}, {"id": 1, "name": "test", "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", + } + + res = client.get(f"{self._url}{res1_body['id']}") + assert res.status_code == status.HTTP_200_OK + + def test_category_invalid_scopes(self, client, dbsession, mocker: MockerFixture): + category_body = {"name": "t", "type": "string"} + res1 = client.post(self._url, data=json.dumps(category_body)) + assert res1.status_code == status.HTTP_200_OK + res1_body = res1.json() + + scope_body = {"name": "test"} + res2 = client.post(f"{self._url}{res1_body['id']}/scope/", data=json.dumps(scope_body)) + assert res2.status_code == status.HTTP_200_OK + + user_mock = mocker.patch('auth_lib.fastapi.UnionAuth.__call__') + user_mock.return_value = { + "session_scopes": [{"id": 0, "name": "string", "comment": "string"}, {"id": 3, "name": "lmao", "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", + } + + res = client.get(f"{self._url}{res1_body['id']}") + assert res.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 203b3c8..33cd018 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -11,7 +11,7 @@ def db_category(dbsession): yield category for button in dbsession.query(Button).filter(Button.category_id == category.id).all(): dbsession.delete(button) - dbsession.commit() + dbsession.flush() dbsession.delete(category) dbsession.commit() @@ -25,4 +25,4 @@ def db_button(dbsession, db_category): yield _button if _button: dbsession.delete(_button) - dbsession.commit() + dbsession.commit()