diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1e8916e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,16 @@ +# services-api + +Ссылки: + +1) Backend разработка – https://github.com/profcomff/.github/wiki/%5Bdev%5D-Backend-разработка + + +Для запуска проекта нужно иметь доступ к БД профкома/иметь локальную БД. + +Локальную БД можно поднять так: +- Установить Docker +- В терминале запустить: ```docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-services-backend postgres:15``` + + +Переменные: +1) DB_DSN = 'postgres://логин:пароль@адрес:порт/бд' diff --git a/README.md b/README.md index 4e7d1c2..2eaf00f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,21 @@ # services-api -Бэкэдн сервисов приложения Твой ФФ для профкома ФФ МГУ +Бэкэдн сервисов приложения Твой ФФ для профкома ФФ МГУ. Реализует логику работы с кнопками и категориями в приложении. +Репозиторий был создан для упрощения работы фронтэнд-разработчиков с бэкэндом сервисов, для переноса данных кнопок и категорий из захардкодженного json файла в Postgresql базу данных (cringe) и для разграничения доступа. -## Запуск +## Функционал + +- Создание кнопок и категорий для отображения на фронте (в приложении) +- Управление доступами к категориям кнопок +- Редактирование любых атрибутов/полей кнопое и категорий + + +## Разработка +Backend разработка – https://github.com/profcomff/.github/wiki/%5Bdev%5D-Backend-разработка + +CONTRIBUTING.md - [CONTRIBUTING.md](services-api/CONTRIBUTING.md) + +## Quick Start 1) Перейдите в папку проекта @@ -10,18 +23,46 @@ ```console foo@bar:~$ python3 -m venv ./venv/ ``` - 3) Установите библиотеки ```console foo@bar:~$ pip install -m requirements.txt ``` -4) Запускайте приложение! +4) Установите все переменные окружения (см. CONTRIBUTING.md) + +5) Запускайте приложение! ```console foo@bar:~$ python -m services-backend ``` -## ENV-file description -DB_DSN= +## Использование +1) Создание категории кнопок +*Необходимо иметь права services.category.create* + 1. Создать новую категорию по запросу `POST /category` с телом `{"name": "имя_категории", "type": "тип отображения категории в приложении"}` + + 2. *Необходимо иметь права services.button.create* Создать в категории новую кнопку по запросу `POST /category/id_категории/button` с телом `{"name": "имя кнопки", "icon": "ссылка на иконку", "link": "ссылка сервиса, на которую ведет кнопка", "type": "тип ссылки"}` + + 3. *Опционально* Навесить права запросом `POST /category/{category_id}/scope` с телом `{"name": "название права доступа"}` + + +2) Получение категорий кнопок +*Нет необходимых прав* + 1. Получить категории по запросу `GET /category` + 2. *Опционально* Выбрать отображение кнопок принадлежащих категории по запросу `GET /category?info=buttons` + + +3) Удаление категории кнопок. *Необходимо иметь права services.category.delete* + + ВАЖНОЕ УТОЧНЕНИЕ: При удалении категории все кнопки, принадлежащей ей также удаляются. + 1. Удалить категорию кнопок по запросу 'DELETE /category/{category_id} + +## Параметризация и плагины: +Никаких настроек кроме стандартных нет + +## Ссылки: +Документация проекта - https://api.test.profcomff.com/?urls.primaryName=services# + +Backend разработка – https://github.com/profcomff/.github/wiki/%5Bdev%5D-Backend-разработка + +CONTRIBUTING.md - [CONTRIBUTING.md](services-api/CONTRIBUTING.md) ---- diff --git a/migrations/versions/6a486347af93_order.py b/migrations/versions/6a486347af93_order.py index 02b12cf..bd87bed 100644 --- a/migrations/versions/6a486347af93_order.py +++ b/migrations/versions/6a486347af93_order.py @@ -21,14 +21,14 @@ def upgrade(): op.add_column('button', sa.Column('link', sa.String(), nullable=True)) op.add_column('button', sa.Column('type', sa.String(), nullable=True)) conn = op.get_bind() - res = conn.execute(sa.text("select id from button")).fetchall() + res = conn.execute(sa.text("select * from button")).fetchall() for i in range(0, len(res)): conn.execute( sa.text( f"""UPDATE "button" SET "order"={i + 1}, "link"='#', - "type"='external' + "type"='external' WHERE id={res[i][0]}""" ) ) @@ -39,13 +39,13 @@ def upgrade(): op.alter_column('button', 'category_id', existing_type=sa.INTEGER(), nullable=False) op.alter_column('button', 'icon', existing_type=sa.VARCHAR(), nullable=False) 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)): + res_c = conn.execute(sa.text("select id from category")).fetchall() + for i in range(0, len(res_c)): conn.execute( sa.text( f"""UPDATE "category" SET "order"={i + 1} - WHERE id={res[i][0]}""" + WHERE id={res_c[i][0]}""" ) ) op.alter_column('category', 'order', nullable=False) @@ -55,7 +55,6 @@ def upgrade(): def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### op.alter_column('category', 'type', existing_type=sa.VARCHAR(), nullable=True) op.alter_column('category', 'name', existing_type=sa.VARCHAR(), nullable=True) op.drop_column('category', 'order') @@ -65,4 +64,3 @@ def downgrade(): op.drop_column('button', 'type') op.drop_column('button', 'link') op.drop_column('button', 'order') - # ### end Alembic commands ### diff --git a/migrations/versions/d35e88f39f85_fixing.py b/migrations/versions/d35e88f39f85_fixing.py new file mode 100644 index 0000000..f755c78 --- /dev/null +++ b/migrations/versions/d35e88f39f85_fixing.py @@ -0,0 +1,58 @@ +"""Fixing + +Revision ID: d35e88f39f85 +Revises: 660bb7891726 +Create Date: 2023-04-09 11:28:59.326067 + +""" +import operator + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'd35e88f39f85' +down_revision = '660bb7891726' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + res_c = conn.execute(sa.text("select * from category ORDER BY category.order")).fetchall() + for category in res_c: + res_b = conn.execute( + sa.text(f"select * from button WHERE category_id={category[0]} ORDER BY button.order") + ).fetchall() + for i in range(0, len(res_b)): + conn.execute( + sa.text( + f"""UPDATE "button" + SET "order"={i + 1}, + "link"='{res_b[i][5]}', + "type"='{res_b[i][6]}' + WHERE id={res_b[i][0]}""" + ) + ) + + +def downgrade(): + k = 0 + conn = op.get_bind() + res_c = conn.execute(sa.text("select * from category ORDER BY category.order")).fetchall() + for category in res_c: + res_b = conn.execute( + sa.text(f"select * from button WHERE category_id={category[0]} ORDER BY button.order") + ).fetchall() + for i in range(0, len(res_b)): + conn.execute( + sa.text( + f"""UPDATE "button" + SET "order"={k + 1}, + "link"='{res_b[i][5]}', + "type"='{res_b[i][6]}' + WHERE id={res_b[i][0]}""" + ) + ) + k += 1 diff --git a/services_backend/models/database.py b/services_backend/models/database.py index c0b6cca..6568f41 100644 --- a/services_backend/models/database.py +++ b/services_backend/models/database.py @@ -1,5 +1,8 @@ from __future__ import annotations +from enum import Enum + +from sqlalchemy import Enum as DbEnum from sqlalchemy import ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -11,10 +14,18 @@ class Category(Base): order: Mapped[int] = mapped_column(Integer, default=1) 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") + buttons: Mapped[list[Button]] = relationship( + "Button", back_populates="category", foreign_keys="Button.category_id", order_by='Button.order' + ) scopes: Mapped[list[Scope]] = relationship("Scope", back_populates="category") +class Type(str, Enum): + INAPP: str = "inapp" + INTERNAL: str = "internal" + EXTERNAL: str = "external" + + class Button(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String) @@ -23,7 +34,7 @@ class Button(Base): category: Mapped[Category] = relationship("Category", back_populates="buttons", foreign_keys=[category_id]) icon: Mapped[str] = mapped_column(String) link: Mapped[str] = mapped_column(String) - type: Mapped[str] = mapped_column(String) + type: Mapped[Type] = mapped_column(DbEnum(Type, native_enum=False), nullable=False) class Scope(Base): diff --git a/services_backend/routes/button.py b/services_backend/routes/button.py index f876826..f7eb7e1 100644 --- a/services_backend/routes/button.py +++ b/services_backend/routes/button.py @@ -27,7 +27,9 @@ def 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") - last_button = db.session.query(Button).order_by(Button.order.desc()).first() + 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)) button.category_id = category_id if last_button: @@ -81,7 +83,7 @@ def get_button( def remove_button( button_id: int, category_id: int, - user=Depends(UnionAuth(['services.button.remove'])), + user=Depends(UnionAuth(['services.button.delete'])), ): """Удалить кнопку diff --git a/services_backend/routes/category.py b/services_backend/routes/category.py index 230c825..8a0744d 100644 --- a/services_backend/routes/category.py +++ b/services_backend/routes/category.py @@ -6,7 +6,7 @@ from fastapi_sqlalchemy import db from sqlalchemy.orm import joinedload -from ..models.database import Button, Category, Scope +from ..models.database import Button, Category from .models.category import CategoryCreate, CategoryGet, CategoryUpdate diff --git a/services_backend/routes/models/button.py b/services_backend/routes/models/button.py index 938436a..59cca46 100644 --- a/services_backend/routes/models/button.py +++ b/services_backend/routes/models/button.py @@ -1,26 +1,29 @@ +from pydantic import Field + +from ...models.database import Type from .base import Base class ButtonCreate(Base): - icon: str | None - name: str | None - link: str | None - type: str | None + icon: str = Field(description='Иконка кнопки') + name: str = Field(description='Название кнопки') + link: str = Field(description='Ссылка, на которую перенаправляет кнопка') + type: Type = Field(description='Тип открываемой ссылки (Ссылка приложения/Браузер в приложении/Браузер') class ButtonUpdate(Base): - category_id: int | None - icon: str | None - name: str | None - order: int | None - link: str | None - type: str | None + category_id: int | None = Field(description='Айди категории') + icon: str | None = Field(description='Иконка кнопки') + name: str | None = Field(description='Название кнопки') + order: int | None = Field(description='Порядок, в котором отображаются кнопки') + link: str | None = Field(description='Ссылка, на которую перенаправляет кнопка') + type: str | None = Field(description='Тип открываемой ссылки (Ссылка приложения/Браузер в приложении/Браузер') class ButtonGet(Base): - id: int - order: int - icon: str | None - name: str | None - link: str | None - type: str | None + id: int = Field(description='Айди кнопки') + order: int = Field(description='Порядок, в котором отображаются кнопки') + icon: str | None = Field(description='Иконка кнопки') + name: str | None = Field(description='Название кнопки') + link: str | None = Field(description='Ссылка, на которую перенаправляет кнопка') + type: str | None = Field(description='Тип открываемой ссылки (Ссылка приложения/Браузер в приложении/Браузер') diff --git a/services_backend/routes/models/category.py b/services_backend/routes/models/category.py index fffa8e3..6c8774f 100644 --- a/services_backend/routes/models/category.py +++ b/services_backend/routes/models/category.py @@ -1,11 +1,13 @@ +from pydantic import Field + from .base import Base from .button import ButtonGet from .scope import ScopeGet class CategoryCreate(Base): - type: str | None - name: str | None + type: str = Field(description='Тип отображения категории') + name: str = Field(description='Имя категории') class CategoryUpdate(Base): diff --git a/tests/api/button.py b/tests/api/button.py index f0cbffd..b841b23 100644 --- a/tests/api/button.py +++ b/tests/api/button.py @@ -19,7 +19,7 @@ def test_post_success(self, client, db_category, dbsession): "icon": "https://lh3.googleusercontent.com/yURn6ISxDySTdXZAW2PUcADMnU3y9YX0M1RyXOH8a3sa1Tr0pHhPLGw5BKuiLiXa3Eh0fyHm7Dfsd9FodK3fxJge6g=w640-h400-e365-rj-sc0x00ffffff", "name": "string", "link": "google.com", - "type": "test", + "type": "inapp", } res = client.post(f"/category/{db_category.id}/button/", data=json.dumps(body)) assert res.status_code == status.HTTP_200_OK @@ -57,7 +57,7 @@ def test_delete_by_id_success(self, client, dbsession, db_button, db_category): assert get_res.status_code == status.HTTP_404_NOT_FOUND def test_patch_by_id_success(self, db_button, client, db_category): - body = {"icon": "cool icon", "name": "nice name", "order": 2, "link": "ya.ru", "type": "nice type"} + 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 res_body = res.json() @@ -99,7 +99,7 @@ def test_create_first(self, client, db_button, db_category): "icon": "test", "name": "test", "link": "test", - "type": "test", + "type": "inapp", } res = client.post(f"/category/{db_category.id}/button/", data=json.dumps(body)) @@ -115,7 +115,7 @@ def test_patch_order_fail(self, client, db_button, db_category): "icon": "test", "name": "new", "link": "test", - "type": "test", + "type": "inapp", } res1 = client.post(f"/category/{db_category.id}/button/", data=json.dumps(body)) assert res1.status_code == status.HTTP_200_OK @@ -132,7 +132,7 @@ def test_patch_negative_order_fail(self, db_button, client, db_category): "icon": "test", "name": "new", "link": "test", - "type": "test", + "type": "inapp", } 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})) @@ -143,7 +143,7 @@ def test_delete_order(self, db_button, client, db_category): "icon": "test", "name": "new", "link": "test", - "type": "test", + "type": "inapp", } res1 = client.post(f"/category/{db_category.id}/button/", data=json.dumps(body)) assert res1.status_code == status.HTTP_200_OK diff --git a/tests/api/conftest.py b/tests/api/conftest.py index a43f479..c32b8fb 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -19,7 +19,7 @@ def db_category(dbsession): @pytest.fixture def db_button(dbsession, db_category): - _button = Button(name='button', category_id=db_category.id, icon='test', link='g', type='d') + _button = Button(name='button', category_id=db_category.id, icon='test', link='g', type='inapp') dbsession.add(_button) dbsession.commit() _button = dbsession.query(Button).filter(Button.id == _button.id).one_or_none()