upd project structure

This commit is contained in:
Alexander Kalinovsky
2025-02-18 20:57:52 +01:00
parent baa55d28d6
commit 1d162677a8
62 changed files with 1305 additions and 48 deletions

View File

@@ -0,0 +1,43 @@
from functools import wraps
from sqlalchemy.inspection import inspect
from sqlalchemy.orm.state import InstanceState
from typing import cast
from .bot_enum import BotEnum, EnumMember
from ..db import async_session
class EntityPermission(BotEnum):
LIST = EnumMember("list")
READ = EnumMember("read")
CREATE = EnumMember("create")
UPDATE = EnumMember("update")
DELETE = EnumMember("delete")
LIST_ALL = EnumMember("list_all")
READ_ALL = EnumMember("read_all")
CREATE_ALL = EnumMember("create_all")
UPDATE_ALL = EnumMember("update_all")
DELETE_ALL = EnumMember("delete_all")
def session_dep(func):
@wraps(func)
async def wrapper(cls, *args, **kwargs):
if "session" in kwargs and kwargs["session"]:
return await func(cls, *args, **kwargs)
_session = None
state = cast(InstanceState, inspect(cls))
if hasattr(state, "async_session"):
_session = state.async_session
if not _session:
async with async_session() as session:
kwargs["session"] = session
return await func(cls, *args, **kwargs)
else:
kwargs["session"] = _session
return await func(cls, *args, **kwargs)
return wrapper

View File

@@ -0,0 +1,7 @@
class Singleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[cls]

View File

@@ -0,0 +1,432 @@
from types import NoneType, UnionType
from typing import (
Any,
ClassVar,
ForwardRef,
Optional,
Self,
Union,
get_args,
get_origin,
TYPE_CHECKING,
dataclass_transform,
)
from pydantic import BaseModel
from sqlmodel import SQLModel, BigInteger, Field, select, func, column, col
from sqlmodel.main import FieldInfo
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import SelectOfScalar
from sqlmodel.main import SQLModelMetaclass, RelationshipInfo
from .descriptors import EntityDescriptor, EntityField, FieldDescriptor, Filter
from .entity_metadata import EntityMetadata
from . import session_dep
if TYPE_CHECKING:
from .user import UserBase
@dataclass_transform(
kw_only_default=True,
field_specifiers=(Field, FieldInfo, EntityField, FieldDescriptor),
)
class BotEntityMetaclass(SQLModelMetaclass):
_future_references = {}
def __new__(mcs, name, bases, namespace, **kwargs):
bot_fields_descriptors = {}
if bases:
bot_entity_descriptor = bases[0].__dict__.get("bot_entity_descriptor")
bot_fields_descriptors = (
{
key: FieldDescriptor(**value.__dict__.copy())
for key, value in bot_entity_descriptor.fields_descriptors.items()
}
if bot_entity_descriptor
else {}
)
if "__annotations__" in namespace:
for annotation in namespace["__annotations__"]:
if annotation in ["bot_entity_descriptor", "entity_metadata"]:
continue
attribute_value = namespace.get(annotation)
if isinstance(attribute_value, RelationshipInfo):
continue
descriptor_kwargs = {}
descriptor_name = annotation
if attribute_value:
if isinstance(attribute_value, EntityField):
descriptor_kwargs = attribute_value.__dict__.copy()
sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None)
if sm_descriptor:
namespace[annotation] = sm_descriptor
else:
namespace.pop(annotation)
descriptor_name = descriptor_kwargs.pop("name") or annotation
type_ = namespace["__annotations__"][annotation]
type_origin = get_origin(type_)
field_descriptor = FieldDescriptor(
name=descriptor_name,
field_name=annotation,
type_=type_,
type_base=type_,
**descriptor_kwargs,
)
is_list = False
is_optional = False
if type_origin is list:
field_descriptor.is_list = is_list = True
field_descriptor.type_base = type_ = get_args(type_)[0]
if type_origin is Union:
args = get_args(type_)
if isinstance(args[0], ForwardRef):
field_descriptor.is_optional = is_optional = True
field_descriptor.type_base = type_ = args[0].__forward_arg__
elif args[1] is NoneType:
field_descriptor.is_optional = is_optional = True
field_descriptor.type_base = type_ = args[0]
if type_origin is UnionType and get_args(type_)[1] is NoneType:
field_descriptor.is_optional = is_optional = True
field_descriptor.type_base = type_ = get_args(type_)[0]
if isinstance(type_, str):
type_not_found = True
for (
entity_descriptor
) in EntityMetadata().entity_descriptors.values():
if type_ == entity_descriptor.class_name:
field_descriptor.type_base = entity_descriptor.type_
field_descriptor.type_ = (
list[entity_descriptor.type_]
if is_list
else (
Optional[entity_descriptor.type_]
if type_origin == Union and is_optional
else (
entity_descriptor.type_ | None
if (type_origin == UnionType and is_optional)
else entity_descriptor.type_
)
)
)
type_not_found = False
break
if type_not_found:
if type_ in mcs._future_references:
mcs._future_references[type_].append(field_descriptor)
else:
mcs._future_references[type_] = [field_descriptor]
bot_fields_descriptors[descriptor_name] = field_descriptor
descriptor_name = name
if "bot_entity_descriptor" in namespace:
entity_descriptor = namespace.pop("bot_entity_descriptor")
descriptor_kwargs: dict = entity_descriptor.__dict__.copy()
descriptor_name = descriptor_kwargs.pop("name", None)
descriptor_name = descriptor_name or name.lower()
namespace["bot_entity_descriptor"] = EntityDescriptor(
name=descriptor_name,
class_name=name,
type_=name,
fields_descriptors=bot_fields_descriptors,
**descriptor_kwargs,
)
else:
descriptor_name = name.lower()
namespace["bot_entity_descriptor"] = EntityDescriptor(
name=descriptor_name,
class_name=name,
type_=name,
fields_descriptors=bot_fields_descriptors,
)
descriptor_fields_sequence = [
key
for key, val in bot_fields_descriptors.items()
if not (val.is_optional or val.name == "id")
]
entity_descriptor: EntityDescriptor = namespace["bot_entity_descriptor"]
if entity_descriptor.default_form.edit_field_sequence is None:
entity_descriptor.default_form.edit_field_sequence = (
descriptor_fields_sequence
)
for form in entity_descriptor.forms.values():
if form.edit_field_sequence is None:
form.edit_field_sequence = descriptor_fields_sequence
for field_descriptor in bot_fields_descriptors.values():
field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"]
if "table" not in kwargs:
kwargs["table"] = True
if kwargs["table"]:
entity_metadata = EntityMetadata()
entity_metadata.entity_descriptors[descriptor_name] = namespace[
"bot_entity_descriptor"
]
if "__annotations__" in namespace:
namespace["__annotations__"]["entity_metadata"] = ClassVar[
EntityMetadata
]
else:
namespace["__annotations__"] = {
"entity_metadata": ClassVar[EntityMetadata]
}
namespace["entity_metadata"] = entity_metadata
type_ = super().__new__(mcs, name, bases, namespace, **kwargs)
if name in mcs._future_references:
for field_descriptor in mcs._future_references[name]:
type_origin = get_origin(field_descriptor.type_)
field_descriptor.type_base = type_
field_descriptor.type_ = (
list[type_]
if type_origin is list
else (
Optional[type_]
if type_origin == Union
and isinstance(get_args(field_descriptor.type_)[0], ForwardRef)
else type_ | None
if type_origin == UnionType
else type_
)
)
setattr(namespace["bot_entity_descriptor"], "type_", type_)
return type_
class BotEntity[CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel](
SQLModel, metaclass=BotEntityMetaclass, table=False
):
bot_entity_descriptor: ClassVar[EntityDescriptor]
entity_metadata: ClassVar[EntityMetadata]
id: int = EntityField(
sm_descriptor=Field(primary_key=True, sa_type=BigInteger), is_visible=False
)
@classmethod
@session_dep
async def get(cls, *, session: AsyncSession | None = None, id: int):
return await session.get(cls, id, populate_existing=True)
@classmethod
def _static_filter_condition(
cls, select_statement: SelectOfScalar[Self], static_filter: list[Filter]
):
for sfilt in static_filter:
column = getattr(cls, sfilt.field_name)
if sfilt.operator == "==":
condition = column.__eq__(sfilt.value)
elif sfilt.operator == "!=":
condition = column.__ne__(sfilt.value)
elif sfilt.operator == "<":
condition = column.__lt__(sfilt.value)
elif sfilt.operator == "<=":
condition = column.__le__(sfilt.value)
elif sfilt.operator == ">":
condition = column.__gt__(sfilt.value)
elif sfilt.operator == ">=":
condition = column.__ge__(sfilt.value)
elif sfilt.operator == "ilike":
condition = col(column).ilike(f"%{sfilt.value}%")
elif sfilt.operator == "like":
condition = col(column).like(f"%{sfilt.value}%")
elif sfilt.operator == "in":
condition = col(column).in_(sfilt.value)
elif sfilt.operator == "not in":
condition = col(column).notin_(sfilt.value)
elif sfilt.operator == "is none":
condition = col(column).is_(None)
elif sfilt.operator == "is not none":
condition = col(column).isnot(None)
elif sfilt.operator == "contains":
condition = sfilt.value == col(column).any_()
else:
condition = None
if condition is not None:
select_statement = select_statement.where(condition)
return select_statement
@classmethod
def _filter_condition(
cls,
select_statement: SelectOfScalar[Self],
filter: str,
filter_fields: list[str],
):
condition = None
for field in filter_fields:
if condition is not None:
condition = condition | (column(field).ilike(f"%{filter}%"))
else:
condition = column(field).ilike(f"%{filter}%")
return select_statement.where(condition)
@classmethod
@session_dep
async def get_count(
cls,
*,
session: AsyncSession | None = None,
static_filter: list[Filter] | Any = None,
filter: str = None,
filter_fields: list[str] = None,
ext_filter: Any = None,
user: "UserBase" = None,
) -> int:
select_statement = select(func.count()).select_from(cls)
if static_filter:
if isinstance(static_filter, list):
select_statement = cls._static_filter_condition(
select_statement, static_filter
)
else:
select_statement = select_statement.where(static_filter)
if filter and filter_fields:
select_statement = cls._filter_condition(
select_statement, filter, filter_fields
)
if ext_filter:
select_statement = select_statement.where(ext_filter)
if user:
select_statement = cls._ownership_condition(select_statement, user)
return await session.scalar(select_statement)
@classmethod
@session_dep
async def get_multi(
cls,
*,
session: AsyncSession | None = None,
order_by=None,
static_filter: list[Filter] | Any = None,
filter: str = None,
filter_fields: list[str] = None,
ext_filter: Any = None,
user: "UserBase" = None,
skip: int = 0,
limit: int = None,
):
select_statement = select(cls).offset(skip)
if limit:
select_statement = select_statement.limit(limit)
if static_filter is not None:
if isinstance(static_filter, list):
select_statement = cls._static_filter_condition(
select_statement, static_filter
)
else:
select_statement = select_statement.where(static_filter)
if filter and filter_fields:
select_statement = cls._filter_condition(
select_statement, filter, filter_fields
)
if ext_filter is not None:
select_statement = select_statement.where(ext_filter)
if user:
select_statement = cls._ownership_condition(select_statement, user)
if order_by is not None:
select_statement = select_statement.order_by(order_by)
return (await session.exec(select_statement)).all()
@classmethod
def _ownership_condition(
cls, select_statement: SelectOfScalar[Self], user: "UserBase"
):
if cls.bot_entity_descriptor.ownership_fields:
condition = None
for role in user.roles:
if role in cls.bot_entity_descriptor.ownership_fields:
owner_col = column(cls.bot_entity_descriptor.ownership_fields[role])
if condition is not None:
condition = condition | (owner_col == user.id)
else:
condition = owner_col == user.id
else:
condition = None
break
if condition is not None:
return select_statement.where(condition)
return select_statement
@classmethod
@session_dep
async def create(
cls,
*,
session: AsyncSession | None = None,
obj_in: CreateSchemaType,
commit: bool = False,
):
if isinstance(obj_in, cls):
obj = obj_in
else:
obj = cls(**obj_in.model_dump())
session.add(obj)
if commit:
await session.commit()
return obj
@classmethod
@session_dep
async def update(
cls,
*,
session: AsyncSession | None = None,
id: int,
obj_in: UpdateSchemaType,
commit: bool = False,
):
obj = await session.get(cls, id)
if obj:
obj_data = obj.model_dump()
update_data = obj_in.model_dump(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(obj, field, update_data[field])
session.add(obj)
if commit:
await session.commit()
return obj
return None
@classmethod
@session_dep
async def remove(
cls, *, session: AsyncSession | None = None, id: int, commit: bool = False
):
obj = await session.get(cls, id)
if obj:
await session.delete(obj)
if commit:
await session.commit()
return obj
return None

181
src/qbot/model/bot_enum.py Normal file
View File

@@ -0,0 +1,181 @@
from aiogram.utils.i18n import I18n
from pydantic_core.core_schema import str_schema
from sqlalchemy.types import TypeDecorator
from sqlmodel import AutoString
from typing import Any, Self, overload
class BotEnumMetaclass(type):
def __new__(cls, name: str, bases: tuple[type], namespace: dict[str, Any]):
all_members = {}
if (
bases
and bases[0].__name__ != "BotEnum"
and "all_members" in bases[0].__dict__
):
all_members = bases[0].__dict__["all_members"]
annotations = {}
for key, value in namespace.items():
if key.isupper() and not key.startswith("__") and not key.endswith("__"):
if not isinstance(value, EnumMember):
value = EnumMember(value, None)
if key in all_members.keys() and all_members[key].value != value.value:
raise ValueError(
f"Enum member {key} already exists with different value. Use same value to extend it."
)
if (
value.value in [member.value for member in all_members.values()]
and key not in all_members.keys()
):
raise ValueError(f"Duplicate enum value {value[0]}")
member = EnumMember(
value=value.value,
loc_obj=value.loc_obj,
parent=None,
name=key,
casting=False,
)
namespace[key] = member
all_members[key] = member
annotations[key] = type(member)
namespace["__annotations__"] = annotations
namespace["all_members"] = all_members
type_ = super().__new__(cls, name, bases, namespace)
for key, value in all_members.items():
if not value._parent:
value._parent = type_
return type_
class EnumMember(object):
@overload
def __init__(self, value: str) -> "EnumMember": ...
@overload
def __init__(self, value: "EnumMember") -> "EnumMember": ...
@overload
def __init__(self, value: str, loc_obj: dict[str, str]) -> "EnumMember": ...
def __init__(
self,
value: str = None,
loc_obj: dict[str, str] = None,
parent: type = None,
name: str = None,
casting: bool = True,
) -> "EnumMember":
if not casting:
self._parent = parent
self._name = name
self.value = value
self.loc_obj = loc_obj
@overload
def __new__(cls: Self, *args, **kwargs) -> "EnumMember": ...
def __new__(cls, *args, casting: bool = True, **kwargs) -> "EnumMember":
if (cls.__name__ == "EnumMember") or not casting:
obj = super().__new__(cls)
kwargs["casting"] = False
obj.__init__(*args, **kwargs)
return obj
if args.__len__() == 0:
return list(cls.all_members.values())[0]
if args.__len__() == 1 and isinstance(args[0], str):
return {member.value: member for key, member in cls.all_members.items()}[
args[0]
]
elif args.__len__() == 1:
return {member.value: member for key, member in cls.all_members.items()}[
args[0].value
]
else:
return args[0]
def __get_pydantic_core_schema__(cls, *args, **kwargs):
return str_schema()
def __get__(self, instance, owner) -> Self:
return {
member.value: member for key, member in self._parent.all_members.items()
}[self.value]
def __set__(self, instance, value):
instance.__dict__[self] = value
def __repr__(self):
return f"<{self._parent.__name__ if self._parent else 'EnumMember'}.{self._name}: '{self.value}'>"
def __str__(self):
return self.value
def __eq__(self, other: Self | str | Any | None) -> bool:
if other is None:
return False
if isinstance(other, str):
return self.value == other
if isinstance(other, EnumMember):
return self.value == other.value and (
issubclass(self._parent, other._parent)
or issubclass(other._parent, self._parent)
)
return other.__eq__(self.value)
def __hash__(self):
return hash(self.value)
def localized(self, lang: str = None) -> str:
if self.loc_obj:
if not lang:
i18n = I18n.get_current()
if i18n:
lang = i18n.current_locale
else:
lang = list(self.loc_obj.keys())[0]
if lang in self.loc_obj.keys():
return self.loc_obj[lang]
else:
return self.loc_obj[list(self.loc_obj.keys())[0]]
return self.value
class BotEnum(EnumMember, metaclass=BotEnumMetaclass):
all_members: dict[str, EnumMember]
class EnumType(TypeDecorator):
impl = AutoString
cache_ok = True
def __init__(self, enum_type: BotEnum):
self._enum_type = enum_type
super().__init__()
def _process_param(self, value):
if value and isinstance(value, EnumMember):
return value.value
return str(value)
def process_bind_param(self, value, dialect):
return self._process_param(value)
def process_result_value(self, value, dialect):
if value:
return self._enum_type(value)
return None
def process_literal_param(self, value, dialect):
return self._process_param(value)

View File

@@ -0,0 +1,4 @@
from .user import UserBase
class DefaultUser(UserBase): ...

View File

@@ -0,0 +1,212 @@
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from aiogram.utils.i18n import I18n
from aiogram.utils.keyboard import InlineKeyboardBuilder
from typing import Any, Callable, TYPE_CHECKING, Literal, Union
from babel.support import LazyProxy
from dataclasses import dataclass, field
from sqlmodel.ext.asyncio.session import AsyncSession
from .role import RoleBase
from . import EntityPermission
from ..bot.handlers.context import ContextData
if TYPE_CHECKING:
from .bot_entity import BotEntity
from ..main import QBotApp
from .user import UserBase
EntityCaptionCallable = Callable[["EntityDescriptor"], str]
EntityItemCaptionCallable = Callable[["EntityDescriptor", Any], str]
EntityFieldCaptionCallable = Callable[["FieldDescriptor", Any, Any], str]
@dataclass
class FieldEditButton:
field_name: str
caption: str | LazyProxy | EntityFieldCaptionCallable | None = None
visibility: Callable[[Any], bool] | None = None
@dataclass
class CommandButton:
command: ContextData | Callable[[ContextData, Any], ContextData] | str
caption: str | LazyProxy | EntityItemCaptionCallable
visibility: Callable[[Any], bool] | None = None
@dataclass
class InlineButton:
inline_button: InlineKeyboardButton | Callable[[Any], InlineKeyboardButton]
visibility: Callable[[Any], bool] | None = None
@dataclass
class Filter:
field_name: str
operator: Literal[
"==",
"!=",
">",
"<",
">=",
"<=",
"in",
"not in",
"like",
"ilike",
"is none",
"is not none",
"contains",
]
value_type: Literal["const", "param"] = "const"
value: Any | None = None
param_index: int | None = None
@dataclass
class EntityList:
caption: str | LazyProxy | EntityCaptionCallable | None = None
item_repr: EntityItemCaptionCallable | None = None
show_add_new_button: bool = True
item_form: str | None = None
pagination: bool = True
static_filters: list[Filter] = None
filtering: bool = False
filtering_fields: list[str] = None
order_by: str | Any | None = None
@dataclass
class EntityForm:
item_repr: EntityItemCaptionCallable | None = None
edit_field_sequence: list[str] = None
form_buttons: list[list[FieldEditButton | CommandButton | InlineButton]] = None
show_edit_button: bool = True
show_delete_button: bool = True
@dataclass(kw_only=True)
class _BaseFieldDescriptor:
icon: str = None
caption: str | LazyProxy | EntityFieldCaptionCallable | None = None
description: str | LazyProxy | EntityFieldCaptionCallable | None = None
edit_prompt: str | LazyProxy | EntityFieldCaptionCallable | None = None
caption_value: EntityFieldCaptionCallable | None = None
is_visible: bool = True
localizable: bool = False
bool_false_value: str | LazyProxy = "no"
bool_true_value: str | LazyProxy = "yes"
ep_form: str | None = None
ep_parent_field: str | None = None
ep_child_field: str | None = None
dt_type: Literal["date", "datetime"] = "date"
default: Any = None
@dataclass(kw_only=True)
class EntityField(_BaseFieldDescriptor):
name: str | None = None
sm_descriptor: Any = None
@dataclass(kw_only=True)
class Setting(_BaseFieldDescriptor):
name: str | None = None
@dataclass(kw_only=True)
class FormField(_BaseFieldDescriptor):
name: str | None = None
type_: type
@dataclass(kw_only=True)
class FieldDescriptor(_BaseFieldDescriptor):
name: str
field_name: str
type_: type
type_base: type = None
is_list: bool = False
is_optional: bool = False
entity_descriptor: "EntityDescriptor" = None
command: "BotCommand" = None
def __hash__(self):
return self.name.__hash__()
@dataclass(kw_only=True)
class _BaseEntityDescriptor:
icon: str = "📘"
full_name: str | LazyProxy | EntityCaptionCallable | None = None
full_name_plural: str | LazyProxy | EntityCaptionCallable | None = None
description: str | LazyProxy | EntityCaptionCallable | None = None
item_repr: EntityItemCaptionCallable | None = None
default_list: EntityList = field(default_factory=EntityList)
default_form: EntityForm = field(default_factory=EntityForm)
lists: dict[str, EntityList] = field(default_factory=dict[str, EntityList])
forms: dict[str, EntityForm] = field(default_factory=dict[str, EntityForm])
show_in_entities_menu: bool = True
ownership_fields: dict[RoleBase, str] = field(default_factory=dict[RoleBase, str])
permissions: dict[EntityPermission, list[RoleBase]] = field(
default_factory=lambda: {
EntityPermission.LIST: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.READ: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.CREATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.UPDATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.DELETE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.LIST_ALL: [RoleBase.SUPER_USER],
EntityPermission.READ_ALL: [RoleBase.SUPER_USER],
EntityPermission.CREATE_ALL: [RoleBase.SUPER_USER],
EntityPermission.UPDATE_ALL: [RoleBase.SUPER_USER],
EntityPermission.DELETE_ALL: [RoleBase.SUPER_USER],
}
)
@dataclass(kw_only=True)
class Entity(_BaseEntityDescriptor):
name: str | None = None
@dataclass
class EntityDescriptor(_BaseEntityDescriptor):
name: str
class_name: str
type_: type["BotEntity"]
fields_descriptors: dict[str, FieldDescriptor]
@dataclass(kw_only=True)
class CommandCallbackContext[UT: UserBase]:
keyboard_builder: InlineKeyboardBuilder = field(
default_factory=InlineKeyboardBuilder
)
message_text: str | None = None
register_navigation: bool = True
message: Message | CallbackQuery
callback_data: ContextData
db_session: AsyncSession
user: UT
app: "QBotApp"
state_data: dict[str, Any]
state: FSMContext
form_data: dict[str, Any]
i18n: I18n
kwargs: dict[str, Any] = field(default_factory=dict)
@dataclass(kw_only=True)
class BotCommand:
name: str
caption: str | dict[str, str] | None = None
pre_check: Callable[[Union[Message, CallbackQuery], Any], bool] | None = None
show_in_bot_commands: bool = False
register_navigation: bool = True
clear_navigation: bool = False
clear_state: bool = True
param_form: dict[str, FieldDescriptor] | None = None
show_cancel_in_param_form: bool = True
show_back_in_param_form: bool = True
handler: Callable[[CommandCallbackContext], None]

View File

@@ -0,0 +1,7 @@
from .descriptors import EntityDescriptor
from ._singleton import Singleton
class EntityMetadata(metaclass=Singleton):
def __init__(self):
self.entity_descriptors: dict[str, EntityDescriptor] = {}

View File

@@ -0,0 +1,7 @@
from sqlmodel import SQLModel, Field
class FSMStorage(SQLModel, table=True):
__tablename__ = "fsm_storage"
key: str = Field(primary_key=True)
value: str | None = None

View File

@@ -0,0 +1,5 @@
from .bot_enum import BotEnum, EnumMember
class LanguageBase(BotEnum):
EN = EnumMember("en", {"en": "🇬🇧 english"})

6
src/qbot/model/role.py Normal file
View File

@@ -0,0 +1,6 @@
from .bot_enum import BotEnum, EnumMember
class RoleBase(BotEnum):
SUPER_USER = EnumMember("super_user")
DEFAULT_USER = EnumMember("default_user")

284
src/qbot/model/settings.py Normal file
View File

@@ -0,0 +1,284 @@
from types import NoneType, UnionType
from aiogram.utils.i18n.context import get_i18n
from datetime import datetime
from sqlmodel import SQLModel, Field, select
from typing import Any, get_args, get_origin
from ..db import async_session
from .role import RoleBase
from .descriptors import FieldDescriptor, Setting
from ..utils.serialization import deserialize, serialize
import ujson as json
class DbSettings(SQLModel, table=True):
__tablename__ = "settings"
name: str = Field(primary_key=True)
value: str
class SettingsMetaclass(type):
def __new__(cls, class_name, base_classes, attributes):
settings_descriptors = {}
if base_classes:
settings_descriptors = base_classes[0].__dict__.get(
"_settings_descriptors", {}
)
for annotation in attributes.get("__annotations__", {}):
if annotation in ["_settings_descriptors", "_cache", "_cached_settings"]:
continue
attr_value = attributes.get(annotation)
name = annotation
type_ = attributes["__annotations__"][annotation]
if isinstance(attr_value, Setting):
descriptor_kwargs = attr_value.__dict__.copy()
name = descriptor_kwargs.pop("name") or annotation
attributes[annotation] = FieldDescriptor(
name=name,
field_name=annotation,
type_=type_,
type_base=type_,
**descriptor_kwargs,
)
else:
attributes[annotation] = FieldDescriptor(
name=annotation,
field_name=annotation,
type_=type_,
type_base=type_,
default=attr_value,
)
type_origin = get_origin(type_)
if type_origin is list:
attributes[annotation].is_list = True
attributes[annotation].type_base = type_ = get_args(type_)[0]
elif type_origin == UnionType and get_args(type_)[1] == NoneType:
attributes[annotation].is_optional = True
attributes[annotation].type_base = type_ = get_args(type_)[0]
settings_descriptors[name] = attributes[annotation]
if (
base_classes
and base_classes[0].__name__ == "Settings"
and hasattr(base_classes[0], annotation)
):
setattr(base_classes[0], annotation, attributes[annotation])
attributes["__annotations__"] = {}
attributes["_settings_descriptors"] = settings_descriptors
return super().__new__(cls, class_name, base_classes, attributes)
class Settings(metaclass=SettingsMetaclass):
_cache: dict[str, Any] = dict[str, Any]()
_settings_descriptors: dict[str, FieldDescriptor] = {}
PAGE_SIZE: int = Setting(
default=10,
)
SECURITY_PARAMETERS_ROLES: list[RoleBase] = Setting(
name="SECPARAMS_ROLES", default=[RoleBase.SUPER_USER], is_visible=False
)
APP_STRINGS_WELCOME_P_NAME: str = Setting(
name="AS_WELCOME", default="Welcome, {name}", is_visible=False
)
APP_STRINGS_GREETING_P_NAME: str = Setting(
name="AS_GREETING", default="Hello, {name}", is_visible=False
)
APP_STRINGS_INTERNAL_ERROR_P_ERROR: str = Setting(
name="AS_INTERNAL_ERROR", default="Internal error\n{error}", is_visible=False
)
APP_STRINGS_USER_BLOCKED_P_NAME: str = Setting(
name="AS_USER_BLOCKED", default="User {name} is blocked", is_visible=False
)
APP_STRINGS_FORBIDDEN: str = Setting(
name="AS_FORBIDDEN", default="Forbidden", is_visible=False
)
APP_STRINGS_NOT_FOUND: str = Setting(
name="AS_NOT_FOUND", default="Object not found", is_visible=False
)
APP_STRINGS_MAIN_NENU: str = Setting(
name="AS_MAIN_MENU", default="Main menu", is_visible=False
)
APP_STRINGS_REFERENCES: str = Setting(
name="AS_REFERENCES", default="References", is_visible=False
)
APP_STRINGS_REFERENCES_BTN: str = Setting(
name="AS_REFERENCES_BTN", default="📚 References", is_visible=False
)
APP_STRINGS_SETTINGS: str = Setting(
name="AS_SETTINGS", default="Settings", is_visible=False
)
APP_STRINGS_SETTINGS_BTN: str = Setting(
name="AS_SETTINGS_BTN", default="⚙️ Settings", is_visible=False
)
APP_STRINGS_PARAMETERS: str = Setting(
name="AS_PARAMETERS", default="Parameters", is_visible=False
)
APP_STRINGS_PARAMETERS_BTN: str = Setting(
name="AS_PARAMETERS_BTN", default="🎛️ Parameters", is_visible=False
)
APP_STRINGS_LANGUAGE: str = Setting(
name="AS_LANGUAGE", default="Language", is_visible=False
)
APP_STRINGS_LANGUAGE_BTN: str = Setting(
name="AS_LANGUAGE_BTN", default="🗣️ Language", is_visible=False
)
APP_STRINGS_BACK_BTN: str = Setting(
name="AS_BACK_BTN", default="⬅️ Back", is_visible=False
)
APP_STRINGS_DELETE_BTN: str = Setting(
name="AS_DELETE_BTN", default="🗑️ Delete", is_visible=False
)
APP_STRINGS_CONFIRM_DELETE_P_NAME: str = Setting(
name="AS_CONFIRM_DEL",
default='Are you sure you want to delete "{name}"?',
is_visible=False,
)
APP_STRINGS_EDIT_BTN: str = Setting(
name="AS_EDIT_BTN", default="✏️ Edit", is_visible=False
)
APP_STRINGS_ADD_BTN: str = Setting(
name="AS_ADD_BTN", default=" Add", is_visible=False
)
APP_STRINGS_YES_BTN: str = Setting(
name="AS_YES_BTN", default="✅ Yes", is_visible=False
)
APP_STRINGS_NO_BTN: str = Setting(
name="AS_NO_BTN", default="❌ No", is_visible=False
)
APP_STRINGS_CANCEL_BTN: str = Setting(
name="AS_CANCEL_BTN", default="❌ Cancel", is_visible=False
)
APP_STRINGS_CLEAR_BTN: str = Setting(
name="AS_CLEAR_BTN", default="⌫ Clear", is_visible=False
)
APP_STRINGS_DONE_BTN: str = Setting(
name="AS_DONE_BTN", default="✅ Done", is_visible=False
)
APP_STRINGS_SKIP_BTN: str = Setting(
name="AS_SKIP_BTN", default="⏩️ Skip", is_visible=False
)
APP_STRINGS_FIELD_EDIT_PROMPT_TEMPLATE_P_NAME_VALUE: str = Setting(
name="AS_FIELDEDIT_PROMPT",
default='Enter new value for "{name}" (current value: {value})',
is_visible=False,
)
APP_STRINGS_FIELD_CREATE_PROMPT_TEMPLATE_P_NAME: str = Setting(
name="AS_FIELDCREATE_PROMPT",
default='Enter new value for "{name}"',
is_visible=False,
)
APP_STRINGS_STRING_EDITOR_LOCALE_TEMPLATE_P_NAME: str = Setting(
name="AS_STREDIT_LOC_TEMPLATE", default='string for "{name}"', is_visible=False
)
APP_STRINGS_VIEW_FILTER_EDIT_PROMPT: str = Setting(
name="AS_FILTEREDIT_PROMPT", default="Enter filter value", is_visible=False
)
APP_STRINGS_INVALID_INPUT: str = Setting(
name="AS_INVALID_INPUT", default="Invalid input", is_visible=False
)
@classmethod
async def get[T](cls, param: T, all_locales=False, locale: str = None) -> T:
name = param.field_name
if name not in cls._cache.keys():
cls._cache[name] = await cls.load_param(param)
ret_val = cls._cache[name]
if param.localizable and not all_locales:
if not locale:
locale = get_i18n().current_locale
try:
obj = json.loads(ret_val)
except Exception:
return ret_val
return obj.get(locale, obj[list(obj.keys())[0]])
return ret_val
@classmethod
async def load_param(cls, param: FieldDescriptor) -> Any:
async with async_session() as session:
db_setting = (
await session.exec(
select(DbSettings).where(DbSettings.name == param.field_name)
)
).first()
if db_setting:
return await deserialize(
session=session, type_=param.type_, value=db_setting.value
)
return (
param.default
if param.default
else (
[]
if (get_origin(param.type_) is list or param.type_ is list)
else datetime(2000, 1, 1)
if param.type_ == datetime
else param.type_()
)
)
@classmethod
async def load_params(cls):
async with async_session() as session:
db_settings = (await session.exec(select(DbSettings))).all()
for db_setting in db_settings:
if db_setting.name in cls.__dict__:
setting = cls.__dict__[db_setting.name] # type: FieldDescriptor
cls._cache[db_setting.name] = await deserialize(
session=session,
type_=setting.type_,
value=db_setting.value,
default=setting.default,
)
cls._loaded = True
@classmethod
async def set_param(cls, param: str | FieldDescriptor, value) -> None:
if isinstance(param, str):
param = cls._settings_descriptors[param]
ser_value = serialize(value, param)
async with async_session() as session:
db_setting = (
await session.exec(
select(DbSettings).where(DbSettings.name == param.field_name)
)
).first()
if db_setting is None:
db_setting = DbSettings(name=param.field_name)
db_setting.value = str(ser_value)
session.add(db_setting)
await session.commit()
cls._cache[param.field_name] = value
@classmethod
def list_params(cls) -> dict[str, FieldDescriptor]:
return cls._settings_descriptors
@classmethod
async def get_params(cls) -> dict[FieldDescriptor, Any]:
params = cls.list_params()
return {
param: await cls.get(param, all_locales=True) for _, param in params.items()
}

23
src/qbot/model/user.py Normal file
View File

@@ -0,0 +1,23 @@
from sqlmodel import Field, ARRAY
from .bot_entity import BotEntity
from .bot_enum import EnumType
from .language import LanguageBase
from .role import RoleBase
from .settings import DbSettings as DbSettings
from .fsm_storage import FSMStorage as FSMStorage
from .view_setting import ViewSetting as ViewSetting
class UserBase(BotEntity, table=False):
__tablename__ = "user"
lang: LanguageBase = Field(sa_type=EnumType(LanguageBase), default=LanguageBase.EN)
is_active: bool = True
name: str
roles: list[RoleBase] = Field(
sa_type=ARRAY(EnumType(RoleBase)), default=[RoleBase.DEFAULT_USER]
)

View File

@@ -0,0 +1,39 @@
from sqlmodel import SQLModel, Field, BigInteger
from sqlalchemy.ext.asyncio.session import AsyncSession
from . import session_dep
class ViewSetting(SQLModel, table=True):
__tablename__ = "view_setting"
user_id: int = Field(
sa_type=BigInteger, primary_key=True, foreign_key="user.id", ondelete="CASCADE"
)
entity_name: str = Field(primary_key=True)
filter: str | None = None
@classmethod
@session_dep
async def get_filter(
cls, *, session: AsyncSession | None = None, user_id: int, entity_name: str
):
setting = await session.get(cls, (user_id, entity_name))
return setting.filter if setting else None
@classmethod
@session_dep
async def set_filter(
cls,
*,
session: AsyncSession | None = None,
user_id: int,
entity_name: str,
filter: str,
):
setting = await session.get(cls, (user_id, entity_name))
if setting:
setting.filter = filter
else:
setting = cls(user_id=user_id, entity_name=entity_name, filter=filter)
session.add(setting)
await session.commit()