Skip to content

Commit

Permalink
Dynamic options and auto assign users/scopes to groups (#169)
Browse files Browse the repository at this point in the history
## Изменения
Делал автоматическое присваивание юзеров в группу users и автоматическое
добавление скоупов группе root

## Детали реализации
- Во время миграции создается новая таблица с опциями. Опции бывают
строковыми, даблавыми и интовыми
- Создаются 2 интовые опции: для группы root и для группы users,
хранящие их id
- Если групп с такими именами еще не существует, создает группы и
добавляет в них все существующие скоупы/юзеров соответственно
  • Loading branch information
dyakovri committed Apr 5, 2024
1 parent 6b9b19e commit cc8330c
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 12 deletions.
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ format:
source ./venv/bin/activate && autoflake -r --in-place --remove-all-unused-imports ./auth_backend
source ./venv/bin/activate && isort ./auth_backend
source ./venv/bin/activate && black ./auth_backend
source ./venv/bin/activate && autoflake -r --in-place --remove-all-unused-imports ./tests
source ./venv/bin/activate && isort ./tests
source ./venv/bin/activate && black ./tests
source ./venv/bin/activate && autoflake -r --in-place --remove-all-unused-imports ./migrations
source ./venv/bin/activate && isort ./migrations
source ./venv/bin/activate && black ./migrations

db:
docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-auth_api postgres:15
Expand Down
3 changes: 2 additions & 1 deletion auth_backend/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .base import Base
from .db import AuthMethod, User, UserSession
from .dynamic_settings import DynamicOption


__all__ = ["Base", "User", "UserSession", "AuthMethod"]
__all__ = ["Base", "User", "UserSession", "AuthMethod", "DynamicOption"]
37 changes: 34 additions & 3 deletions auth_backend/models/db.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import datetime
import logging
from typing import Iterator

import sqlalchemy.orm
Expand All @@ -9,13 +10,13 @@
from sqlalchemy.orm import Mapped, Session, backref, mapped_column, relationship

from auth_backend.exceptions import ObjectNotFound
from auth_backend.models.base import BaseDbModel
from auth_backend.models.dynamic_settings import DynamicOption
from auth_backend.settings import get_settings


settings = get_settings()


from auth_backend.models.base import BaseDbModel
logger = logging.getLogger(__name__)


class User(BaseDbModel):
Expand All @@ -41,6 +42,21 @@ class User(BaseDbModel):
secondaryjoin="and_(Group.id==UserGroup.group_id, not_(Group.is_deleted))",
)

@classmethod
def create(cls, *, session: Session, **kwargs) -> User:
user: User = super().create(session=session, **kwargs)
users_group_id = DynamicOption.get("users_group_id", session=session).value
if users_group_id:
users_group = Group.query(with_deleted=True, session=session).get(users_group_id)
else:
logger.error("Fail to obtain root group id")
if users_group:
user.groups.append(users_group)
else:
logger.error("Root group not found")
session.flush()
return user

@hybrid_property
def scopes(self) -> set[Scope]:
_scopes = set()
Expand Down Expand Up @@ -210,6 +226,21 @@ class Scope(BaseDbModel):
secondaryjoin="(UserSession.id==UserSessionScope.user_session_id)",
)

@classmethod
def create(cls, *, session: Session, **kwargs) -> Scope:
scope: Scope = super().create(session=session, **kwargs)
root_group_id = DynamicOption.get("root_group_id", session=session).value
if root_group_id:
root_group = Group.query(with_deleted=True, session=session).get(root_group_id)
else:
logger.error("Fail to obtain root group id")
if root_group:
scope.groups.append(root_group)
else:
logger.error("Root group not found")
session.flush()
return scope

@classmethod
def get_by_name(cls, name: str, *, with_deleted: bool = False, session: Session) -> Scope:
scope = (
Expand Down
35 changes: 35 additions & 0 deletions auth_backend/models/dynamic_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from datetime import datetime

from sqlalchemy import DateTime, Double, Integer, String
from sqlalchemy.orm import Mapped, Session, mapped_column

from .base import Base


class DynamicOption(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, unique=True)
value_integer: Mapped[int] = mapped_column(Integer, nullable=True)
value_double: Mapped[float] = mapped_column(Double, nullable=True)
value_string: Mapped[str] = mapped_column(String, nullable=True)
create_ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
update_ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

@property
def value(self) -> str | float | int:
return self.value_double or self.value_integer or self.value_string

@value.setter
def set_value(self, value: str | float | int):
if isinstance(value, str):
self.value_string = value
elif isinstance(value, float):
self.value_double = value
elif isinstance(value, int):
self.value_integer = value
else:
raise TypeError("Only str, float or int options allowed")

@staticmethod
def get(name, default=None, *, session: Session):
return session.query(DynamicOption).filter(DynamicOption.name == name).one_or_none() or default
3 changes: 2 additions & 1 deletion auth_backend/routes/user_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from auth_backend.base import StatusResponseModel
from auth_backend.exceptions import ObjectNotFound, SessionExpired
from auth_backend.models.db import AuthMethod, UserSession
from auth_backend.models.db import AuthMethod, UserSession, session_expires_date
from auth_backend.schemas.models import (
Session,
SessionPatch,
Expand Down Expand Up @@ -51,6 +51,7 @@ async def me(
default=[]
),
) -> dict[str, str | int]:
session.expires = session_expires_date() # Автопродление сессии при активности пользователя
result: dict[str, str | int] = {}
result = (
result
Expand Down
75 changes: 75 additions & 0 deletions migrations/versions/7c1cd7ceacd8_dynamic_option_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Dynamic option model
Revision ID: 7c1cd7ceacd8
Revises: bda218c91211
Create Date: 2024-04-05 22:36:58.224670
"""

import sqlalchemy as sa
from alembic import op
from sqlalchemy.orm import Session

from auth_backend.models.db import Group, Scope, User


# revision identifiers, used by Alembic.
revision = '7c1cd7ceacd8'
down_revision = 'bda218c91211'
branch_labels = None
depends_on = None


def upgrade():
dynamic_option_table = op.create_table(
'dynamic_option',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False, unique=True),
sa.Column('value_integer', sa.Integer(), nullable=True),
sa.Column('value_double', sa.Double(), nullable=True),
sa.Column('value_string', sa.String(), nullable=True),
sa.Column('create_ts', sa.DateTime(), nullable=False),
sa.Column('update_ts', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'),
)

conn = op.get_bind()
session = Session(conn)
try:
root_group_id = session.execute(sa.text("SELECT \"id\" FROM \"group\" WHERE name = 'root'")).scalar()
except Exception:
pass

if root_group_id is None:
group: Group = Group.create(name="root", session=session)
scopes = session.execute(sa.text("SELECT id FROM \"scope\"")).fetchall()
for scope_id in scopes:
scope_id = Scope.get(scope_id[0], with_deleted=True, session=session)
group.scopes.add(scope_id)
root_group_id = group.id

try:
users_group_id = session.execute(sa.text("SELECT \"id\" FROM \"group\" WHERE name = 'users'")).scalar()
except Exception:
pass

if users_group_id is None:
group: Group = Group.create(name="users", session=session)
users = session.execute(sa.text("SELECT id FROM \"user\"")).fetchall()
for user_id in users:
user = User.get(user_id[0], with_deleted=True, session=session)
group.users.append(user)
users_group_id = group.id

session.flush()

values = [
{"name": "root_group_id", "create_ts": "now()", "update_ts": "now()", "value_integer": root_group_id},
{"name": "users_group_id", "create_ts": "now()", "update_ts": "now()", "value_integer": users_group_id},
]
op.bulk_insert(dynamic_option_table, values)


def downgrade():
op.drop_table('dynamic_option')
8 changes: 6 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def client_auth():
patcher1.stop()


@pytest.fixture(scope='session')
@pytest.fixture()
def dbsession():
settings = get_settings()
engine = create_engine(str(settings.DB_DSN))
Expand Down Expand Up @@ -133,7 +133,9 @@ def _group(client: TestClient):

yield _group
for row in _ids:
Group.delete(row, session=dbsession)
group: Group = Group.get(row, session=dbsession)
group.users.clear()
group.delete(session=dbsession)
dbsession.commit()


Expand Down Expand Up @@ -233,6 +235,8 @@ def yandex_user(dbsession) -> User:
dbsession.add(user_id)
dbsession.commit()
yield user
user.sessions.clear()
user.groups.clear()
dbsession.query(AuthMethod).filter(AuthMethod.user_id == user.id).delete()
dbsession.query(User).filter(User.id == user.id).delete()
dbsession.commit()
13 changes: 8 additions & 5 deletions tests/test_routes/test_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,10 @@ def test_get_all(client, dbsession):
assert group in [row["id"] for row in response]
assert child in [row["id"] for row in response]
response = client.get("/group", params={"info": ["child"]}).json()["items"]
child_ = [row["child"] for row in response]
assert child in [row["id"] for row in child_[0]]
child_ = []
for row in response:
child_.extend(row["child"])
assert child in [row["id"] for row in child_]

for row in (dbsession.query(Group).get(child), dbsession.query(Group).get(group)):
dbsession.delete(row)
Expand Down Expand Up @@ -173,16 +175,16 @@ def test_delete(client, dbsession):
assert db2 in db1.child
assert db3 in db2.child
assert db3.child == []
del db1
del db2
del db3
response = client.get(f"/group/{_group3}")
assert response.status_code == 200
assert response.json()["parent_id"] == _group2
response = client.get(f"/group/{_group2}")
assert response.status_code == 200
assert response.json()["parent_id"] == _group1
client.delete(f"/group/{_group2}")
dbsession.refresh(db1)
dbsession.refresh(db2)
dbsession.refresh(db3)
response = client.get(f"/group/{_group3}")
assert response.status_code == 200
assert response.json()["parent_id"] == _group1
Expand All @@ -198,5 +200,6 @@ def test_delete(client, dbsession):
dbsession.query(Group).get(_group2),
dbsession.query(Group).get(_group3),
):
row: Group
dbsession.delete(row)
dbsession.commit()

0 comments on commit cc8330c

Please sign in to comment.