diff --git a/services_backend/models/database.py b/services_backend/models/database.py index db1c887..f79fd0b 100644 --- a/services_backend/models/database.py +++ b/services_backend/models/database.py @@ -4,6 +4,7 @@ from enum import Enum from fastapi_sqlalchemy import db +from sqlalchemy import Boolean from sqlalchemy import Enum as DbEnum from sqlalchemy import ForeignKey, Integer, String from sqlalchemy.ext.hybrid import hybrid_property @@ -63,9 +64,54 @@ class Button(Base): link: Mapped[str] = mapped_column(String) type: Mapped[Type] = mapped_column(DbEnum(Type, native_enum=False), nullable=False) + _scopes: Mapped[list[Scope]] = relationship("Scope", back_populates="button", lazy='joined', cascade='delete') + + @hybrid_property + def required_scopes(self) -> set[str]: + return set(s.name for s in self._scopes if s.is_required is True) + + @required_scopes.inplace.setter + def _required_scopes_setter(self, value: set[str]): + old_scopes = self.required_scopes + new_scopes = set(value) + + # Удаляем более ненужные скоупы + for s in self._scopes: + if s.name in (old_scopes - new_scopes): + db.session.delete(s) + + # Добавляем недостающие скоупы + for s in new_scopes - old_scopes: + new_scope = Scope(button=self, name=s, is_required=True) + db.session.add(new_scope) + self._scopes.append(new_scope) + + @hybrid_property + def optional_scopes(self) -> set[str]: + return set(s.name for s in self._scopes if s.is_required is False) + + @optional_scopes.inplace.setter + def _optional_scopes_setter(self, value: set[str]): + old_scopes = self.optional_scopes + new_scopes = set(value) + + # Удаляем более ненужные скоупы + for s in self._scopes: + if s.name in (old_scopes - new_scopes): + db.session.delete(s) + + # Добавляем недостающие скоупы + for s in new_scopes - old_scopes: + new_scope = Scope(button=self, name=s, is_required=False) + db.session.add(new_scope) + self._scopes.append(new_scope) + 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_id: Mapped[int] = mapped_column(Integer, ForeignKey("category.id"), nullable=True) + button_id: Mapped[int] = mapped_column(Integer, ForeignKey("button.id"), nullable=True) + is_required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=True) category: Mapped[Category] = relationship("Category", back_populates="_scopes", foreign_keys=[category_id]) + button: Mapped[Category] = relationship("Button", back_populates="_scopes", foreign_keys=[button_id]) diff --git a/services_backend/routes/button.py b/services_backend/routes/button.py index 7ccef5f..07371cd 100644 --- a/services_backend/routes/button.py +++ b/services_backend/routes/button.py @@ -1,9 +1,10 @@ import logging +from enum import Enum from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends, HTTPException from fastapi_sqlalchemy import db -from pydantic import Field +from pydantic import Field, conint from services_backend.models.database import Button, Category, Type from services_backend.schemas import Base @@ -21,17 +22,26 @@ class ButtonCreate(Base): name: str = Field(description='Название кнопки') link: str = Field(description='Ссылка, на которую перенаправляет кнопка') type: Type = Field(description='Тип открываемой ссылки (Ссылка приложения/Браузер в приложении/Браузер') + required_scopes: set[str] | None = Field(description='Каким скоупы нужны, чтобы кнопка была доступна', default=None) + optional_scopes: set[str] | None = Field(description='Каким скоупы желательны', default=None) class ButtonUpdate(Base): category_id: int | None = Field(description='Айди категории', default=None) icon: str | None = Field(description='Иконка кнопки', default=None) name: str | None = Field(description='Название кнопки', default=None) - order: int | None = Field(description='Порядок, в котором отображаются кнопки', default=None) + order: conint(gt=0) | None = Field(description='Порядок, в котором отображаются кнопки', default=None) link: str | None = Field(description='Ссылка, на которую перенаправляет кнопка', default=None) type: Type | None = Field( description='Тип открываемой ссылки (Ссылка приложения/Браузер в приложении/Браузер', default=None ) + required_scopes: set[str] | None = Field(description='Каким скоупы нужны, чтобы кнопка была доступна', default=None) + optional_scopes: set[str] | None = Field(description='Каким скоупы желательны', default=None) + + +class ButtonView(Enum): + ACTIVE = "active" + BLOCKED = "blocked" class ButtonGet(Base): @@ -41,6 +51,10 @@ class ButtonGet(Base): link: str | None = Field(description='Ссылка, на которую перенаправляет кнопка') order: int | None = Field(description='Порядок, в котором отображаются кнопки') type: Type | None = Field(description='Тип открываемой ссылки (Ссылка приложения/Браузер в приложении/Браузер') + view: ButtonView | None = Field(description='Доступна ли запрашиваемая кнопка', default=None) + required_scopes: list[str] | None = None + optional_scopes: list[str] | None = None + scopes: list[str] | None = Field(description='Скоупы, которые можно запросить', default=None) class ButtonsGet(Base): @@ -50,7 +64,7 @@ class ButtonsGet(Base): # endregion -@button.post("", response_model=ButtonGet) +@button.post("", response_model=ButtonGet, response_model_exclude_none=True) def create_button( button_inp: ButtonCreate, category_id: int, @@ -67,16 +81,24 @@ def create_button( last_button = ( db.session.query(Button).filter(Button.category_id == category_id).order_by(Button.order.desc()).first() ) - button = Button(**button_inp.dict(exclude_none=True)) + required_scopes = button_inp.required_scopes + optional_scopes = button_inp.optional_scopes + button_inp.optional_scopes = None + button_inp.required_scopes = None + button = Button(**button_inp.model_dump(exclude_none=True)) button.category_id = category_id if last_button: button.order = last_button.order + 1 + if required_scopes is not None: + button.required_scopes = required_scopes + if optional_scopes is not None: + button.optional_scopes = optional_scopes db.session.add(button) db.session.flush() return button -@button.get("", response_model=ButtonsGet) +@button.get("", response_model=ButtonsGet, response_model_exclude_unset=True) def get_buttons( category_id: int, user=Depends(UnionAuth(allow_none=True, auto_error=False)), @@ -90,10 +112,37 @@ def get_buttons( 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") - return category - - -@button.get("/{button_id}", response_model=ButtonGet) + result = {"buttons": []} + try: + user_scopes = {scope["name"] for scope in user["user_scopes"]} + except TypeError: + user_scopes = frozenset() + for button in category.buttons: + view = ButtonView.ACTIVE + scopes = set() + if button.required_scopes - user_scopes: + view = ButtonView.BLOCKED + else: + scopes |= button.required_scopes + scopes |= user_scopes & button.optional_scopes + to_add = { + "id": button.id, + "icon": button.icon, + "name": button.name, + "link": button.link, + "order": button.order, + "type": button.type, + "view": view.value, + "required_scopes": button.required_scopes, + "optional_scopes": button.optional_scopes, + } + if scopes: + to_add["scopes"] = list(scopes) + result["buttons"].append(to_add) + return result + + +@button.get("/{button_id}", response_model=ButtonGet, response_model_exclude_unset=True) def get_button( button_id: int, category_id: int, @@ -104,6 +153,10 @@ def get_button( Необходимые scopes: `-` """ user_id = user.get('id') if user is not None else None + try: + user_scopes = {scope["name"] for scope in user["user_scopes"]} + except TypeError: + user_scopes = frozenset() logger.info(f"User {user_id} triggered get_button") category = db.session.query(Category).filter(Category.id == category_id).one_or_none() if not category: @@ -113,7 +166,27 @@ def get_button( raise HTTPException(status_code=404, detail="Button does not exist") if button.category_id != category_id: raise HTTPException(status_code=404, detail="Button is not this category") - return button + view = ButtonView.ACTIVE + scopes = set() + if button.required_scopes - user_scopes: + view = ButtonView.BLOCKED + else: + scopes |= button.required_scopes + scopes |= user_scopes & button.optional_scopes + result = { + "id": button.id, + "icon": button.icon, + "name": button.name, + "link": button.link, + "order": button.order, + "type": button.type, + "view": view.value, + "required_scopes": button.required_scopes, + "optional_scopes": button.optional_scopes, + } + if scopes: + result["scopes"] = list(scopes) + return result @button.delete("/{button_id}", response_model=None) @@ -152,14 +225,10 @@ def update_button( Необходимые scopes: `services.button.update` """ logger.info(f"User {user.get('id')} triggered create_category") - query = db.session.query(Button).filter(Category.id == category_id).filter(Button.id == button_id) + query = db.session.query(Button).filter(Button.category_id == category_id).filter(Button.id == button_id) button = query.one_or_none() last_button = ( - db.session.query(Button) - .filter(Category.id == category_id) - .filter(Button.category_id == category_id) - .order_by(Button.order.desc()) - .first() + db.session.query(Button).filter(Button.category_id == category_id).order_by(Button.order.desc()).first() ) category = db.session.query(Category).filter(Category.id == category_id).one_or_none() @@ -167,30 +236,41 @@ def update_button( raise HTTPException(status_code=404, detail="Category does not exist") if not button: raise HTTPException(status_code=404, detail="Button does not exist") - if not any(button_inp.dict().values()): + if not any(button_inp.model_dump().values()): raise HTTPException(status_code=400, detail="Empty schema") if button.category_id != category_id: raise HTTPException(status_code=404, detail="Button is not this category") - if button_inp.order: - if last_button and (button_inp.order > last_button.order + 1): + if button_inp.required_scopes is not None: + button.required_scopes = button_inp.required_scopes + if button_inp.optional_scopes is not None: + button.optional_scopes = button_inp.optional_scopes + db.session.flush() + + if button_inp.order and button.order != button_inp.order: + if last_button and (button_inp.order > last_button.order): raise HTTPException( status_code=400, detail=f"Can`t create button with order {button_inp.order}. " f"Last category is {last_button.order}", ) - if button_inp.order < 1: - raise HTTPException(status_code=400, detail="Order can`t be less than 1") - if button.order > button_inp.order: - db.session.query(Button).filter(Category.id == category_id).filter(Button.order < button.order).update( - {"order": Button.order + 1} - ) - elif button.order < button_inp.order: - db.session.query(Button).filter(Category.id == category_id).filter(Button.order > button.order).update( - {"order": Button.order - 1} - ) - - query.update(button_inp.dict(exclude_unset=True, exclude_none=True)) - db.session.flush() + swapping_button = ( + db.session.query(Button) + .filter(Button.category_id == category_id) + .filter(Button.order == button_inp.order) + .one() + ) + swapping_button.order, button.order = button.order, swapping_button.order + required_scopes = button_inp.required_scopes + optional_scopes = button_inp.optional_scopes + button_inp.optional_scopes = None + button_inp.required_scopes = None + if dump := button_inp.model_dump(exclude_unset=True, exclude_none=True): + query.update(dump) + db.session.flush() + if required_scopes is not None: + button.required_scopes = required_scopes + if optional_scopes is not None: + button.optional_scopes = optional_scopes return button @@ -206,8 +286,32 @@ def get_service( TODO: Переделать ручку, сделав сервис независимым от кнопки """ user_id = user.get('id') if user is not None else None + try: + user_scopes = {scope["name"] for scope in user["user_scopes"]} + except TypeError: + user_scopes = frozenset() logger.info(f"User {user_id} triggered get_button") button = db.session.query(Button).filter(Button.id == button_id).one_or_none() if not button: raise HTTPException(status_code=404, detail="Button does not exist") - return button + view = ButtonView.ACTIVE + scopes = set() + if button.required_scopes - user_scopes: + view = ButtonView.BLOCKED + else: + scopes |= button.required_scopes + scopes |= user_scopes & button.optional_scopes + result = { + "id": button.id, + "icon": button.icon, + "name": button.name, + "link": button.link, + "order": button.order, + "type": button.type, + "view": view.value, + "required_scopes": button.required_scopes, + "optional_scopes": button.optional_scopes, + } + if scopes: + result["scopes"] = list(scopes) + return scopes diff --git a/services_backend/routes/category.py b/services_backend/routes/category.py index 2a3ac4d..95e34ee 100644 --- a/services_backend/routes/category.py +++ b/services_backend/routes/category.py @@ -4,11 +4,13 @@ from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends, HTTPException, Query from fastapi_sqlalchemy import db -from pydantic import Field +from pydantic import Field, conint from services_backend.models.database import Button, Category, Type from services_backend.schemas import Base +from .button import ButtonGet, ButtonView + logger = logging.getLogger(__name__) category = APIRouter() @@ -17,15 +19,6 @@ # region schemas -class ButtonGet(Base): - id: int = Field(description='Айди кнопки') - icon: str | None = Field(description='Иконка кнопки') - name: str | None = Field(description='Название кнопки') - link: str | None = Field(description='Ссылка, на которую перенаправляет кнопка') - order: int | None = Field(description='Порядок, в котором отображаются кнопки') - type: Type | None = Field(description='Тип открываемой ссылки (Ссылка приложения/Браузер в приложении/Браузер') - - class CategoryCreate(Base): type: str = Field(description='Тип отображения категории') name: str = Field(description='Имя категории') @@ -33,7 +26,7 @@ class CategoryCreate(Base): class CategoryUpdate(Base): - order: int | None = Field(description='На какую позицию перенести категорию', default=None) + order: conint(gt=0) | None = Field(description='На какую позицию перенести категорию', default=None) type: str | None = Field(description='Тип отображения категории', default=None) name: str | None = Field(description='Имя категории', default=None) scopes: set[str] | None = Field(description='Каким пользователям будет видна категория', default=None) @@ -66,7 +59,7 @@ def create_category( last_category = db.session.query(Category).order_by(Category.order.desc()).first() scopes = category_inp.scopes category_inp.scopes = None - category = Category(**category_inp.dict(exclude_none=True)) + category = Category(**category_inp.model_dump(exclude_none=True)) if scopes is not None: category.scopes = scopes if last_category: @@ -90,18 +83,53 @@ def get_categories( logger.info("Unauthorised user triggered get_categories") else: logger.info(f"User {user_id} triggered get_categories") + try: + user_scopes = {scope["name"] for scope in user["user_scopes"]} + except TypeError: + user_scopes = frozenset() - user_scopes = set([scope["name"] for scope in user["session_scopes"]] if user else []) + session_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).all(): category_scopes = set(category.scopes) - if (category_scopes == set()) or len(category_scopes - user_scopes) == 0: + if (category_scopes == set()) or len(category_scopes - session_scopes) == 0: filtered_categories.append(category) - - return [ - CategoryGet.from_orm(row).dict(exclude={"buttons"} if 'buttons' not in info else {}) - for row in filtered_categories - ] + if 'buttons' not in info: + return [CategoryGet.model_validate(row).model_dump(exclude={"buttons"}) for row in filtered_categories] + result = [] + for row in filtered_categories: + category = { + "id": row.id, + "order": row.order, + "type": row.type, + "name": row.name, + "scopes": row.scopes, + "buttons": [], + } + for button in row.buttons: + view = ButtonView.ACTIVE + scopes = set() + if button.required_scopes - user_scopes: + view = ButtonView.BLOCKED + else: + scopes |= button.required_scopes + scopes |= user_scopes & button.optional_scopes + category["buttons"].append( + { + "id": button.id, + "icon": button.icon, + "name": button.name, + "link": button.link, + "order": button.order, + "type": button.type, + "view": view.value, + "scopes": list(scopes) if scopes else None, + "required_scopes": button.required_scopes, + "optional_scopes": button.optional_scopes, + } + ) + result.append(category) + return result @category.get("/{category_id}", response_model=CategoryGet, response_model_exclude_none=True) @@ -176,9 +204,7 @@ def update_category( category.scopes = category_inp.scopes db.session.flush() - if category_inp.order: - if category_inp.order < 1: - raise HTTPException(status_code=400, detail="Order can`t be less than 1") + if category_inp.order and category.order != category_inp.order: if last_category and (category_inp.order > last_category.order): raise HTTPException( status_code=400, @@ -186,13 +212,11 @@ def update_category( f"Last category is {last_category.order}", ) - if category.order > category_inp.order: - db.session.query(Category).filter(Category.order < category.order).update({"order": Category.order + 1}) - elif category.order < category_inp.order: - db.session.query(Category).filter(Category.order > category.order).update({"order": Category.order - 1}) + swapping_category = db.session.query(Category).filter(Category.order == category_inp.order).one() + swapping_category.order, category.order = category.order, swapping_category.order query = db.session.query(Category).filter(Category.id == category_id) - update_values = category_inp.dict(exclude_unset=True, exclude_none=True, exclude={'scopes': True}) + update_values = category_inp.model_dump(exclude_unset=True, exclude_none=True, exclude={'scopes': True}) if update_values: query.update(update_values) db.session.commit() diff --git a/tests/api/button.py b/tests/api/button.py index 1655b09..821c90e 100644 --- a/tests/api/button.py +++ b/tests/api/button.py @@ -40,6 +40,52 @@ def test_post_success(client, db_category, dbsession): assert db_button_created.order == 1 +def test_post_required_scopes_success(client, db_category, dbsession): + body = { + "icon": "https://lh3.googleusercontent.com/yURn6ISxDySTdXZAW2PUcADMnU3y9YX0M1RyXOH8a3sa1Tr0pHhPLGw5BKuiLiXa3Eh0fyHm7Dfsd9FodK3fxJge6g=w640-h400-e365-rj-sc0x00ffffff", + "name": "string", + "link": "google.com", + "type": "inapp", + "required_scopes": ["string"], + } + res = client.post(f"/category/{db_category.id}/button", data=json.dumps(body)) + assert res.status_code == status.HTTP_200_OK + res = client.get(f"/category/{db_category.id}/button/{res.json()['id']}") + assert res.json()["view"] == "active" + assert res.json()["scopes"] == ["string"] + + +def test_post_optional_scopes_success(client, db_category, dbsession): + body = { + "icon": "https://lh3.googleusercontent.com/yURn6ISxDySTdXZAW2PUcADMnU3y9YX0M1RyXOH8a3sa1Tr0pHhPLGw5BKuiLiXa3Eh0fyHm7Dfsd9FodK3fxJge6g=w640-h400-e365-rj-sc0x00ffffff", + "name": "string", + "link": "google.com", + "type": "inapp", + "optional_scopes": ["string"], + } + res = client.post(f"/category/{db_category.id}/button", data=json.dumps(body)) + assert res.status_code == status.HTTP_200_OK + res = client.get(f"/category/{db_category.id}/button/{res.json()['id']}") + assert res.json()["view"] == "active" + assert res.json()["scopes"] == ["string"] + + +def test_post_required_optional_scopes_success(client, db_category, dbsession): + body = { + "icon": "https://lh3.googleusercontent.com/yURn6ISxDySTdXZAW2PUcADMnU3y9YX0M1RyXOH8a3sa1Tr0pHhPLGw5BKuiLiXa3Eh0fyHm7Dfsd9FodK3fxJge6g=w640-h400-e365-rj-sc0x00ffffff", + "name": "string", + "link": "google.com", + "type": "inapp", + "required_scopes": ["string"], + "optional_scopes": ["string"], + } + res = client.post(f"/category/{db_category.id}/button", data=json.dumps(body)) + assert res.status_code == status.HTTP_200_OK + res = client.get(f"/category/{db_category.id}/button/{res.json()['id']}") + assert res.json()["view"] == "active" + assert res.json()["scopes"] == ["string"] + + def test_get_by_id_success(client, db_button, db_category): res = client.get(f"/category/{db_category.id}/button/{db_button.id}") assert res.status_code == status.HTTP_200_OK @@ -61,6 +107,16 @@ def test_delete_by_id_success(client, dbsession, db_button, db_category): def test_patch_by_id_success(db_button, client, db_category): + body = { + "icon": "https://lh3.googleusercontent.com/yURn6ISxDySTdXZAW2PUcADMnU3y9YX0M1RyXOH8a3sa1Tr0pHhPLGw5BKuiLiXa3Eh0fyHm7Dfsd9FodK3fxJge6g=w640-h400-e365-rj-sc0x00ffffff", + "name": "string", + "link": "google.com", + "type": "inapp", + } + res = client.post(f"/category/{db_category.id}/button", data=json.dumps(body)) + second_id = res.json()["id"] + assert res.status_code == status.HTTP_200_OK + assert res.json()["order"] == 2 body = {"icon": "cool icon", "name": "nice name", "order": 2, "link": "ya.ru", "type": "inapp"} res = client.patch(f"/category/{db_category.id}/button/{db_button.id}", data=json.dumps(body)) assert res.status_code == status.HTTP_200_OK @@ -70,6 +126,9 @@ def test_patch_by_id_success(db_button, client, db_category): assert res_body["name"] == body["name"] assert res_body["link"] == body["link"] assert res_body["type"] == body["type"] + res = client.get(f"/category/{db_category.id}/button/{second_id}") + assert res.json()["order"] == 1 + client.delete(f"/category/{db_category.id}/button/{second_id}") def test_patch_unset_params(client, db_button, db_category): @@ -147,7 +206,7 @@ def test_patch_negative_order_fail(db_button, client, db_category): } res = client.post(f"/category/{db_category.id}/button", data=json.dumps(body)) res1 = client.patch(f"/category/{db_category.id}/button/{res.json()['id']}", data=json.dumps({"order": -10})) - assert res1.status_code == status.HTTP_400_BAD_REQUEST + assert res1.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY def test_delete_order(db_button, client, db_category): diff --git a/tests/api/category.py b/tests/api/category.py index b6cc116..255571d 100644 --- a/tests/api/category.py +++ b/tests/api/category.py @@ -169,7 +169,7 @@ def test_create_negative_order_fail(db_category, client): res1 = client.post('/category', data=json.dumps(body)) assert res1.status_code == status.HTTP_200_OK res = client.patch(f"/category/{res1.json()['id']}", data=json.dumps({"order": -1})) - assert res.status_code == status.HTTP_400_BAD_REQUEST + assert res.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY client.delete(f"/category/{res1.json()['id']}")