tests-passed

This commit is contained in:
2025-07-31 18:55:59 +03:00
parent b7abb8d8a1
commit e7230ba63c
126 changed files with 8326 additions and 3207 deletions

0
orm/__init__.py Normal file
View File

View File

@@ -1,10 +1,10 @@
import builtins
import logging
from typing import Any, Callable, ClassVar, Type, Union
from typing import Any, Type
import orjson
from sqlalchemy import JSON, Column, Integer
from sqlalchemy.orm import declarative_base, declared_attr
from sqlalchemy import JSON
from sqlalchemy.orm import DeclarativeBase
logger = logging.getLogger(__name__)
@@ -14,44 +14,12 @@ REGISTRY: dict[str, Type[Any]] = {}
# Список полей для фильтрации при сериализации
FILTERED_FIELDS: list[str] = []
# Создаем базовый класс для декларативных моделей
_Base = declarative_base()
class SafeColumnMixin:
class BaseModel(DeclarativeBase):
"""
Миксин для безопасного присваивания значений столбцам с автоматическим преобразованием типов
Базовая модель с методами сериализации и обновления
"""
@declared_attr
def __safe_setattr__(self) -> Callable:
def safe_setattr(self, key: str, value: Any) -> None:
"""
Безопасно устанавливает атрибут для экземпляра.
Args:
key (str): Имя атрибута.
value (Any): Значение атрибута.
"""
setattr(self, key, value)
return safe_setattr
def __setattr__(self, key: str, value: Any) -> None:
"""
Переопределяем __setattr__ для использования безопасного присваивания
"""
# Используем object.__setattr__ для избежания рекурсии
object.__setattr__(self, key, value)
class BaseModel(_Base, SafeColumnMixin): # type: ignore[valid-type,misc]
__abstract__ = True
__allow_unmapped__ = True
__table_args__: ClassVar[Union[dict[str, Any], tuple]] = {"extend_existing": True}
id = Column(Integer, primary_key=True)
def __init_subclass__(cls, **kwargs: Any) -> None:
REGISTRY[cls.__name__] = cls
super().__init_subclass__(**kwargs)
@@ -68,6 +36,7 @@ class BaseModel(_Base, SafeColumnMixin): # type: ignore[valid-type,misc]
"""
column_names = filter(lambda x: x not in FILTERED_FIELDS, self.__table__.columns.keys())
data: builtins.dict[str, Any] = {}
logger.debug(f"Converting object to dictionary {'with access' if access else 'without access'}")
try:
for column_name in column_names:
try:
@@ -81,7 +50,7 @@ class BaseModel(_Base, SafeColumnMixin): # type: ignore[valid-type,misc]
try:
data[column_name] = orjson.loads(value)
except (TypeError, orjson.JSONDecodeError) as e:
logger.exception(f"Error decoding JSON for column '{column_name}': {e}")
logger.warning(f"Error decoding JSON for column '{column_name}': {e}")
data[column_name] = value
else:
data[column_name] = value
@@ -91,10 +60,14 @@ class BaseModel(_Base, SafeColumnMixin): # type: ignore[valid-type,misc]
except AttributeError as e:
logger.warning(f"Attribute error for column '{column_name}': {e}")
except Exception as e:
logger.exception(f"Error occurred while converting object to dictionary: {e}")
logger.warning(f"Error occurred while converting object to dictionary {e}")
return data
def update(self, values: builtins.dict[str, Any]) -> None:
for key, value in values.items():
if hasattr(self, key):
setattr(self, key, value)
# Alias for backward compatibility
Base = BaseModel

View File

@@ -1,7 +1,7 @@
import time
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy import ForeignKey, Index, Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from orm.base import BaseModel as Base
@@ -9,19 +9,29 @@ from orm.base import BaseModel as Base
class ShoutCollection(Base):
__tablename__ = "shout_collection"
shout = Column(ForeignKey("shout.id"), primary_key=True)
collection = Column(ForeignKey("collection.id"), primary_key=True)
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"))
collection: Mapped[int] = mapped_column(ForeignKey("collection.id"))
created_at: Mapped[int] = mapped_column(Integer, default=lambda: int(time.time()))
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), comment="Created By")
__table_args__ = (
PrimaryKeyConstraint(shout, collection),
Index("idx_shout_collection_shout", "shout"),
Index("idx_shout_collection_collection", "collection"),
{"extend_existing": True},
)
class Collection(Base):
__tablename__ = "collection"
slug = Column(String, unique=True)
title = Column(String, nullable=False, comment="Title")
body = Column(String, nullable=True, comment="Body")
pic = Column(String, nullable=True, comment="Picture")
created_at = Column(Integer, default=lambda: int(time.time()))
created_by = Column(ForeignKey("author.id"), comment="Created By")
published_at = Column(Integer, default=lambda: int(time.time()))
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
slug: Mapped[str] = mapped_column(String, unique=True)
title: Mapped[str] = mapped_column(String, nullable=False, comment="Title")
body: Mapped[str | None] = mapped_column(String, nullable=True, comment="Body")
pic: Mapped[str | None] = mapped_column(String, nullable=True, comment="Picture")
created_at: Mapped[int] = mapped_column(Integer, default=lambda: int(time.time()))
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), comment="Created By")
published_at: Mapped[int] = mapped_column(Integer, default=lambda: int(time.time()))
created_by_author = relationship("Author", foreign_keys=[created_by])

View File

@@ -1,13 +1,31 @@
import asyncio
import time
from typing import Any, Dict
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String, UniqueConstraint, distinct, func
from sqlalchemy import (
JSON,
Boolean,
ForeignKey,
Index,
Integer,
PrimaryKeyConstraint,
String,
UniqueConstraint,
distinct,
func,
)
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
from sqlalchemy.orm import Mapped, mapped_column
from auth.orm import Author
from orm.base import BaseModel
from services.rbac import get_permissions_for_role
from orm.shout import Shout
from services.db import local_session
from services.rbac import (
get_permissions_for_role,
initialize_community_permissions,
user_has_permission,
)
# Словарь названий ролей
role_names = {
@@ -40,38 +58,35 @@ class CommunityFollower(BaseModel):
__tablename__ = "community_follower"
# Простые поля - стандартный подход
community = Column(ForeignKey("community.id"), nullable=False, index=True)
follower = Column(ForeignKey("author.id"), nullable=False, index=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
community: Mapped[int] = mapped_column(Integer, ForeignKey("community.id"), nullable=False, index=True)
follower: Mapped[int] = mapped_column(Integer, ForeignKey(Author.id), nullable=False, index=True)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
# Уникальность по паре сообщество-подписчик
__table_args__ = (
UniqueConstraint("community", "follower", name="uq_community_follower"),
PrimaryKeyConstraint("community", "follower"),
{"extend_existing": True},
)
def __init__(self, community: int, follower: int) -> None:
self.community = community # type: ignore[assignment]
self.follower = follower # type: ignore[assignment]
self.community = community
self.follower = follower
class Community(BaseModel):
__tablename__ = "community"
name = Column(String, nullable=False)
slug = Column(String, nullable=False, unique=True)
desc = Column(String, nullable=False, default="")
pic = Column(String, nullable=False, default="")
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
created_by = Column(ForeignKey("author.id"), nullable=False)
settings = Column(JSON, nullable=True)
updated_at = Column(Integer, nullable=True)
deleted_at = Column(Integer, nullable=True)
private = Column(Boolean, default=False)
followers = relationship("Author", secondary="community_follower")
created_by_author = relationship("Author", foreign_keys=[created_by])
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False)
slug: Mapped[str] = mapped_column(String, nullable=False, unique=True)
desc: Mapped[str] = mapped_column(String, nullable=False, default="")
pic: Mapped[str | None] = mapped_column(String, nullable=False, default="")
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
created_by: Mapped[int | None] = mapped_column(Integer, nullable=True)
settings: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
private: Mapped[bool] = mapped_column(Boolean, default=False)
@hybrid_property
def stat(self):
@@ -79,12 +94,10 @@ class Community(BaseModel):
def is_followed_by(self, author_id: int) -> bool:
"""Проверяет, подписан ли пользователь на сообщество"""
from services.db import local_session
with local_session() as session:
follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.community == self.id, CommunityFollower.follower == author_id)
.where(CommunityFollower.community == self.id, CommunityFollower.follower == author_id)
.first()
)
return follower is not None
@@ -99,12 +112,10 @@ class Community(BaseModel):
Returns:
Список ролей пользователя в сообществе
"""
from services.db import local_session
with local_session() as session:
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.first()
)
@@ -132,13 +143,11 @@ class Community(BaseModel):
user_id: ID пользователя
role: Название роли
"""
from services.db import local_session
with local_session() as session:
# Ищем существующую запись
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.first()
)
@@ -160,12 +169,10 @@ class Community(BaseModel):
user_id: ID пользователя
role: Название роли
"""
from services.db import local_session
with local_session() as session:
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.first()
)
@@ -186,13 +193,11 @@ class Community(BaseModel):
user_id: ID пользователя
roles: Список ролей для установки
"""
from services.db import local_session
with local_session() as session:
# Ищем существующую запись
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.first()
)
@@ -221,10 +226,8 @@ class Community(BaseModel):
Returns:
Список участников с информацией о ролях
"""
from services.db import local_session
with local_session() as session:
community_authors = session.query(CommunityAuthor).filter(CommunityAuthor.community_id == self.id).all()
community_authors = session.query(CommunityAuthor).where(CommunityAuthor.community_id == self.id).all()
members = []
for ca in community_authors:
@@ -237,8 +240,6 @@ class Community(BaseModel):
member_info["roles"] = ca.role_list # type: ignore[assignment]
# Получаем разрешения синхронно
try:
import asyncio
member_info["permissions"] = asyncio.run(ca.get_permissions()) # type: ignore[assignment]
except Exception:
# Если не удается получить разрешения асинхронно, используем пустой список
@@ -287,8 +288,6 @@ class Community(BaseModel):
Инициализирует права ролей для сообщества из дефолтных настроек.
Вызывается при создании нового сообщества.
"""
from services.rbac import initialize_community_permissions
await initialize_community_permissions(int(self.id))
def get_available_roles(self) -> list[str]:
@@ -319,34 +318,63 @@ class Community(BaseModel):
"""Устанавливает slug сообщества"""
self.slug = slug # type: ignore[assignment]
def get_followers(self):
"""
Получает список подписчиков сообщества.
Returns:
list: Список ID авторов, подписанных на сообщество
"""
with local_session() as session:
return [
follower.id
for follower in session.query(Author)
.join(CommunityFollower, Author.id == CommunityFollower.follower)
.where(CommunityFollower.community == self.id)
.all()
]
def add_community_creator(self, author_id: int) -> None:
"""
Создатель сообщества
Args:
author_id: ID пользователя, которому назначаются права
"""
with local_session() as session:
# Проверяем существование связи
existing = CommunityAuthor.find_author_in_community(author_id, self.id, session)
if not existing:
# Создаем нового CommunityAuthor с ролью редактора
community_author = CommunityAuthor(community_id=self.id, author_id=author_id, roles="editor")
session.add(community_author)
session.commit()
class CommunityStats:
def __init__(self, community) -> None:
self.community = community
@property
def shouts(self):
from orm.shout import Shout
return self.community.session.query(func.count(Shout.id)).filter(Shout.community == self.community.id).scalar()
def shouts(self) -> int:
return self.community.session.query(func.count(Shout.id)).where(Shout.community == self.community.id).scalar()
@property
def followers(self):
def followers(self) -> int:
return (
self.community.session.query(func.count(CommunityFollower.follower))
.filter(CommunityFollower.community == self.community.id)
.where(CommunityFollower.community == self.community.id)
.scalar()
)
@property
def authors(self):
from orm.shout import Shout
def authors(self) -> int:
# author has a shout with community id and its featured_at is not null
return (
self.community.session.query(func.count(distinct(Author.id)))
.join(Shout)
.filter(
.where(
Shout.community == self.community.id,
Shout.featured_at.is_not(None),
Author.id.in_(Shout.authors),
@@ -369,15 +397,11 @@ class CommunityAuthor(BaseModel):
__tablename__ = "community_author"
id = Column(Integer, primary_key=True)
community_id = Column(Integer, ForeignKey("community.id"), nullable=False)
author_id = Column(Integer, ForeignKey("author.id"), nullable=False)
roles = Column(String, nullable=True, comment="Roles (comma-separated)")
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
# Связи
community = relationship("Community", foreign_keys=[community_id])
author = relationship("Author", foreign_keys=[author_id])
id: Mapped[int] = mapped_column(Integer, primary_key=True)
community_id: Mapped[int] = mapped_column(Integer, ForeignKey("community.id"), nullable=False)
author_id: Mapped[int] = mapped_column(Integer, ForeignKey(Author.id), nullable=False)
roles: Mapped[str | None] = mapped_column(String, nullable=True, comment="Roles (comma-separated)")
joined_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
# Уникальность по сообществу и автору
__table_args__ = (
@@ -397,41 +421,40 @@ class CommunityAuthor(BaseModel):
"""Устанавливает список ролей из списка строк"""
self.roles = ",".join(value) if value else None # type: ignore[assignment]
def has_role(self, role: str) -> bool:
"""
Проверяет наличие роли у автора в сообществе
Args:
role: Название роли для проверки
Returns:
True если роль есть, False если нет
"""
return role in self.role_list
def add_role(self, role: str) -> None:
"""
Добавляет роль автору (если её ещё нет)
Добавляет роль в список ролей.
Args:
role: Название роли для добавления
role (str): Название роли
"""
roles = self.role_list
if role not in roles:
roles.append(role)
self.role_list = roles
if not self.roles:
self.roles = role
elif role not in self.role_list:
self.roles += f",{role}"
def remove_role(self, role: str) -> None:
"""
Удаляет роль у автора
Удаляет роль из списка ролей.
Args:
role: Название роли для удаления
role (str): Название роли
"""
roles = self.role_list
if role in roles:
roles.remove(role)
self.role_list = roles
if self.roles and role in self.role_list:
roles_list = [r for r in self.role_list if r != role]
self.roles = ",".join(roles_list) if roles_list else None
def has_role(self, role: str) -> bool:
"""
Проверяет наличие роли.
Args:
role (str): Название роли
Returns:
bool: True, если роль есть, иначе False
"""
return bool(self.roles and role in self.role_list)
def set_roles(self, roles: list[str]) -> None:
"""
@@ -443,7 +466,7 @@ class CommunityAuthor(BaseModel):
# Фильтруем и очищаем роли
valid_roles = [role.strip() for role in roles if role and role.strip()]
# Если список пустой, устанавливаем None
# Если список пустой, устанавливаем пустую строку
self.roles = ",".join(valid_roles) if valid_roles else ""
async def get_permissions(self) -> list[str]:
@@ -461,17 +484,30 @@ class CommunityAuthor(BaseModel):
return list(all_permissions)
def has_permission(self, permission: str) -> bool:
def has_permission(
self, permission: str | None = None, resource: str | None = None, operation: str | None = None
) -> bool:
"""
Проверяет наличие разрешения у автора
Args:
permission: Разрешение для проверки (например: "shout:create")
resource: Опциональный ресурс (для обратной совместимости)
operation: Опциональная операция (для обратной совместимости)
Returns:
True если разрешение есть, False если нет
"""
return permission in self.role_list
# Если передан полный permission, используем его
if permission and ":" in permission:
return any(permission == role for role in self.role_list)
# Если переданы resource и operation, формируем permission
if resource and operation:
full_permission = f"{resource}:{operation}"
return any(full_permission == role for role in self.role_list)
return False
def dict(self, access: bool = False) -> dict[str, Any]:
"""
@@ -510,13 +546,11 @@ class CommunityAuthor(BaseModel):
Returns:
Список словарей с информацией о сообществах и ролях
"""
from services.db import local_session
if session is None:
with local_session() as ssession:
return cls.get_user_communities_with_roles(author_id, ssession)
community_authors = session.query(cls).filter(cls.author_id == author_id).all()
community_authors = session.query(cls).where(cls.author_id == author_id).all()
return [
{
@@ -529,7 +563,7 @@ class CommunityAuthor(BaseModel):
]
@classmethod
def find_by_user_and_community(cls, author_id: int, community_id: int, session=None) -> "CommunityAuthor | None":
def find_author_in_community(cls, author_id: int, community_id: int, session=None) -> "CommunityAuthor | None":
"""
Находит запись CommunityAuthor по ID автора и сообщества
@@ -541,13 +575,11 @@ class CommunityAuthor(BaseModel):
Returns:
CommunityAuthor или None
"""
from services.db import local_session
if session is None:
with local_session() as ssession:
return cls.find_by_user_and_community(author_id, community_id, ssession)
return ssession.query(cls).where(cls.author_id == author_id, cls.community_id == community_id).first()
return session.query(cls).filter(cls.author_id == author_id, cls.community_id == community_id).first()
return session.query(cls).where(cls.author_id == author_id, cls.community_id == community_id).first()
@classmethod
def get_users_with_role(cls, community_id: int, role: str, session=None) -> list[int]:
@@ -562,13 +594,11 @@ class CommunityAuthor(BaseModel):
Returns:
Список ID пользователей
"""
from services.db import local_session
if session is None:
with local_session() as ssession:
return cls.get_users_with_role(community_id, role, ssession)
community_authors = session.query(cls).filter(cls.community_id == community_id).all()
community_authors = session.query(cls).where(cls.community_id == community_id).all()
return [ca.author_id for ca in community_authors if ca.has_role(role)]
@@ -584,13 +614,11 @@ class CommunityAuthor(BaseModel):
Returns:
Словарь со статистикой ролей
"""
from services.db import local_session
if session is None:
with local_session() as s:
return cls.get_community_stats(community_id, s)
community_authors = session.query(cls).filter(cls.community_id == community_id).all()
community_authors = session.query(cls).where(cls.community_id == community_id).all()
role_counts: dict[str, int] = {}
total_members = len(community_authors)
@@ -622,10 +650,8 @@ def get_user_roles_in_community(author_id: int, community_id: int = 1) -> list[s
Returns:
Список ролей пользователя
"""
from services.db import local_session
with local_session() as session:
ca = CommunityAuthor.find_by_user_and_community(author_id, community_id, session)
ca = CommunityAuthor.find_author_in_community(author_id, community_id, session)
return ca.role_list if ca else []
@@ -641,9 +667,6 @@ async def check_user_permission_in_community(author_id: int, permission: str, co
Returns:
True если разрешение есть, False если нет
"""
# Используем новую систему RBAC с иерархией
from services.rbac import user_has_permission
return await user_has_permission(author_id, permission, community_id)
@@ -659,10 +682,8 @@ def assign_role_to_user(author_id: int, role: str, community_id: int = 1) -> boo
Returns:
True если роль была добавлена, False если уже была
"""
from services.db import local_session
with local_session() as session:
ca = CommunityAuthor.find_by_user_and_community(author_id, community_id, session)
ca = CommunityAuthor.find_author_in_community(author_id, community_id, session)
if ca:
if ca.has_role(role):
@@ -689,10 +710,8 @@ def remove_role_from_user(author_id: int, role: str, community_id: int = 1) -> b
Returns:
True если роль была удалена, False если её не было
"""
from services.db import local_session
with local_session() as session:
ca = CommunityAuthor.find_by_user_and_community(author_id, community_id, session)
ca = CommunityAuthor.find_author_in_community(author_id, community_id, session)
if ca and ca.has_role(role):
ca.remove_role(role)
@@ -713,9 +732,6 @@ def migrate_old_roles_to_community_author():
[непроверенное] Предполагает, что старые роли хранились в auth.orm.AuthorRole
"""
from auth.orm import AuthorRole
from services.db import local_session
with local_session() as session:
# Получаем все старые роли
old_roles = session.query(AuthorRole).all()
@@ -732,10 +748,7 @@ def migrate_old_roles_to_community_author():
# Извлекаем базовое имя роли (убираем суффикс сообщества если есть)
role_name = role.role
if isinstance(role_name, str) and "-" in role_name:
base_role = role_name.split("-")[0]
else:
base_role = role_name
base_role = role_name.split("-")[0] if (isinstance(role_name, str) and "-" in role_name) else role_name
if base_role not in user_community_roles[key]:
user_community_roles[key].append(base_role)
@@ -744,7 +757,7 @@ def migrate_old_roles_to_community_author():
migrated_count = 0
for (author_id, community_id), roles in user_community_roles.items():
# Проверяем, есть ли уже запись
existing = CommunityAuthor.find_by_user_and_community(author_id, community_id, session)
existing = CommunityAuthor.find_author_in_community(author_id, community_id, session)
if not existing:
ca = CommunityAuthor(community_id=community_id, author_id=author_id)
@@ -772,10 +785,8 @@ def get_all_community_members_with_roles(community_id: int = 1) -> list[dict[str
Returns:
Список участников с полной информацией
"""
from services.db import local_session
with local_session() as session:
community = session.query(Community).filter(Community.id == community_id).first()
community = session.query(Community).where(Community.id == community_id).first()
if not community:
return []

View File

@@ -1,7 +1,8 @@
import time
from typing import Any
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from auth.orm import Author
from orm.base import BaseModel as Base
@@ -11,45 +12,68 @@ from orm.topic import Topic
class DraftTopic(Base):
__tablename__ = "draft_topic"
id = None # type: ignore[misc]
shout = Column(ForeignKey("draft.id"), primary_key=True, index=True)
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
main = Column(Boolean, nullable=True)
draft: Mapped[int] = mapped_column(ForeignKey("draft.id"), index=True)
topic: Mapped[int] = mapped_column(ForeignKey("topic.id"), index=True)
main: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
__table_args__ = (
PrimaryKeyConstraint(draft, topic),
Index("idx_draft_topic_topic", "topic"),
Index("idx_draft_topic_draft", "draft"),
{"extend_existing": True},
)
class DraftAuthor(Base):
__tablename__ = "draft_author"
id = None # type: ignore[misc]
shout = Column(ForeignKey("draft.id"), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
caption = Column(String, nullable=True, default="")
draft: Mapped[int] = mapped_column(ForeignKey("draft.id"), index=True)
author: Mapped[int] = mapped_column(ForeignKey(Author.id), index=True)
caption: Mapped[str | None] = mapped_column(String, nullable=True, default="")
__table_args__ = (
PrimaryKeyConstraint(draft, author),
Index("idx_draft_author_author", "author"),
Index("idx_draft_author_draft", "draft"),
{"extend_existing": True},
)
class Draft(Base):
__tablename__ = "draft"
# required
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
created_by = Column(ForeignKey("author.id"), nullable=False)
community = Column(ForeignKey("community.id"), nullable=False, default=1)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
created_by: Mapped[int] = mapped_column(ForeignKey(Author.id), nullable=False)
community: Mapped[int] = mapped_column(ForeignKey("community.id"), nullable=False, default=1)
# optional
layout = Column(String, nullable=True, default="article")
slug = Column(String, unique=True)
title = Column(String, nullable=True)
subtitle = Column(String, nullable=True)
lead = Column(String, nullable=True)
body = Column(String, nullable=False, comment="Body")
media = Column(JSON, nullable=True)
cover = Column(String, nullable=True, comment="Cover image url")
cover_caption = Column(String, nullable=True, comment="Cover image alt caption")
lang = Column(String, nullable=False, default="ru", comment="Language")
seo = Column(String, nullable=True) # JSON
layout: Mapped[str | None] = mapped_column(String, nullable=True, default="article")
slug: Mapped[str | None] = mapped_column(String, unique=True)
title: Mapped[str | None] = mapped_column(String, nullable=True)
subtitle: Mapped[str | None] = mapped_column(String, nullable=True)
lead: Mapped[str | None] = mapped_column(String, nullable=True)
body: Mapped[str] = mapped_column(String, nullable=False, comment="Body")
media: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
cover: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image url")
cover_caption: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image alt caption")
lang: Mapped[str] = mapped_column(String, nullable=False, default="ru", comment="Language")
seo: Mapped[str | None] = mapped_column(String, nullable=True) # JSON
# auto
updated_at = Column(Integer, nullable=True, index=True)
deleted_at = Column(Integer, nullable=True, index=True)
updated_by = Column(ForeignKey("author.id"), nullable=True)
deleted_by = Column(ForeignKey("author.id"), nullable=True)
authors = relationship(Author, secondary="draft_author")
topics = relationship(Topic, secondary="draft_topic")
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
updated_by: Mapped[int | None] = mapped_column(ForeignKey(Author.id), nullable=True)
deleted_by: Mapped[int | None] = mapped_column(ForeignKey(Author.id), nullable=True)
authors = relationship(Author, secondary=DraftAuthor.__table__)
topics = relationship(Topic, secondary=DraftTopic.__table__)
# shout/publication
# Временно закомментировано для совместимости с тестами
# shout: Mapped[int | None] = mapped_column(ForeignKey("shout.id"), nullable=True)
__table_args__ = (
Index("idx_draft_created_by", "created_by"),
Index("idx_draft_community", "community"),
{"extend_existing": True},
)

View File

@@ -1,7 +1,7 @@
import enum
from sqlalchemy import Column, ForeignKey, String
from sqlalchemy.orm import relationship
from sqlalchemy import ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from orm.base import BaseModel as Base
@@ -12,24 +12,33 @@ class InviteStatus(enum.Enum):
REJECTED = "REJECTED"
@classmethod
def from_string(cls, value: str) -> "Invite":
def from_string(cls, value: str) -> "InviteStatus":
return cls(value)
class Invite(Base):
__tablename__ = "invite"
inviter_id = Column(ForeignKey("author.id"), primary_key=True)
author_id = Column(ForeignKey("author.id"), primary_key=True)
shout_id = Column(ForeignKey("shout.id"), primary_key=True)
status = Column(String, default=InviteStatus.PENDING.value)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
inviter_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
shout_id: Mapped[int] = mapped_column(ForeignKey("shout.id"))
status: Mapped[str] = mapped_column(String, default=InviteStatus.PENDING.value)
inviter = relationship("Author", foreign_keys=[inviter_id])
author = relationship("Author", foreign_keys=[author_id])
shout = relationship("Shout")
def set_status(self, status: InviteStatus):
self.status = status.value # type: ignore[assignment]
__table_args__ = (
UniqueConstraint(inviter_id, author_id, shout_id),
Index("idx_invite_inviter_id", "inviter_id"),
Index("idx_invite_author_id", "author_id"),
Index("idx_invite_shout_id", "shout_id"),
{"extend_existing": True},
)
def set_status(self, status: InviteStatus) -> None:
self.status = status.value
def get_status(self) -> InviteStatus:
return InviteStatus.from_string(self.status)
return InviteStatus.from_string(str(self.status))

View File

@@ -1,9 +1,9 @@
from datetime import datetime
from enum import Enum
from typing import Any
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy import Enum as SQLAlchemyEnum
from sqlalchemy.orm import relationship
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from auth.orm import Author
from orm.base import BaseModel as Base
@@ -21,6 +21,7 @@ class NotificationEntity(Enum):
SHOUT = "shout"
AUTHOR = "author"
COMMUNITY = "community"
REACTION = "reaction"
@classmethod
def from_string(cls, value: str) -> "NotificationEntity":
@@ -80,27 +81,41 @@ NotificationKind = NotificationAction # Для совместимости со
class NotificationSeen(Base):
__tablename__ = "notification_seen"
viewer = Column(ForeignKey("author.id"), primary_key=True)
notification = Column(ForeignKey("notification.id"), primary_key=True)
viewer: Mapped[int] = mapped_column(ForeignKey("author.id"))
notification: Mapped[int] = mapped_column(ForeignKey("notification.id"))
__table_args__ = (
PrimaryKeyConstraint(viewer, notification),
Index("idx_notification_seen_viewer", "viewer"),
Index("idx_notification_seen_notification", "notification"),
{"extend_existing": True},
)
class Notification(Base):
__tablename__ = "notification"
id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
entity = Column(String, nullable=False)
action = Column(String, nullable=False)
payload = Column(JSON, nullable=True)
entity: Mapped[str] = mapped_column(String, nullable=False)
action: Mapped[str] = mapped_column(String, nullable=False)
payload: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
status = Column(SQLAlchemyEnum(NotificationStatus), default=NotificationStatus.UNREAD)
kind = Column(SQLAlchemyEnum(NotificationKind), nullable=False)
status: Mapped[NotificationStatus] = mapped_column(default=NotificationStatus.UNREAD)
kind: Mapped[NotificationKind] = mapped_column(nullable=False)
seen = relationship(Author, secondary="notification_seen")
def set_entity(self, entity: NotificationEntity):
__table_args__ = (
Index("idx_notification_created_at", "created_at"),
Index("idx_notification_status", "status"),
Index("idx_notification_kind", "kind"),
{"extend_existing": True},
)
def set_entity(self, entity: NotificationEntity) -> None:
"""Устанавливает сущность уведомления."""
self.entity = entity.value
@@ -108,7 +123,7 @@ class Notification(Base):
"""Возвращает сущность уведомления."""
return NotificationEntity.from_string(self.entity)
def set_action(self, action: NotificationAction):
def set_action(self, action: NotificationAction) -> None:
"""Устанавливает действие уведомления."""
self.action = action.value

View File

@@ -10,21 +10,14 @@ PROPOSAL_REACTIONS = [
]
PROOF_REACTIONS = [ReactionKind.PROOF.value, ReactionKind.DISPROOF.value]
RATING_REACTIONS = [ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]
POSITIVE_REACTIONS = [ReactionKind.ACCEPT.value, ReactionKind.LIKE.value, ReactionKind.PROOF.value]
NEGATIVE_REACTIONS = [ReactionKind.REJECT.value, ReactionKind.DISLIKE.value, ReactionKind.DISPROOF.value]
def is_negative(x):
return x in [
ReactionKind.DISLIKE.value,
ReactionKind.DISPROOF.value,
ReactionKind.REJECT.value,
]
def is_negative(x: ReactionKind) -> bool:
return x.value in NEGATIVE_REACTIONS
def is_positive(x):
return x in [
ReactionKind.ACCEPT.value,
ReactionKind.LIKE.value,
ReactionKind.PROOF.value,
]
def is_positive(x: ReactionKind) -> bool:
return x.value in POSITIVE_REACTIONS

View File

@@ -1,8 +1,10 @@
import time
from enum import Enum as Enumeration
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from auth.orm import Author
from orm.base import BaseModel as Base
@@ -44,15 +46,24 @@ REACTION_KINDS = ReactionKind.__members__.keys()
class Reaction(Base):
__tablename__ = "reaction"
body = Column(String, default="", comment="Reaction Body")
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()), index=True)
updated_at = Column(Integer, nullable=True, comment="Updated at", index=True)
deleted_at = Column(Integer, nullable=True, comment="Deleted at", index=True)
deleted_by = Column(ForeignKey("author.id"), nullable=True)
reply_to = Column(ForeignKey("reaction.id"), nullable=True)
quote = Column(String, nullable=True, comment="Original quoted text")
shout = Column(ForeignKey("shout.id"), nullable=False, index=True)
created_by = Column(ForeignKey("author.id"), nullable=False)
kind = Column(String, nullable=False, index=True)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
body: Mapped[str] = mapped_column(String, default="", comment="Reaction Body")
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()), index=True)
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="Updated at", index=True)
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="Deleted at", index=True)
deleted_by: Mapped[int | None] = mapped_column(ForeignKey(Author.id), nullable=True)
reply_to: Mapped[int | None] = mapped_column(ForeignKey("reaction.id"), nullable=True)
quote: Mapped[str | None] = mapped_column(String, nullable=True, comment="Original quoted text")
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), nullable=False, index=True)
created_by: Mapped[int] = mapped_column(ForeignKey(Author.id), nullable=False)
kind: Mapped[str] = mapped_column(String, nullable=False, index=True)
oid = Column(String)
oid: Mapped[str | None] = mapped_column(String)
__table_args__ = (
Index("idx_reaction_created_at", "created_at"),
Index("idx_reaction_created_by", "created_by"),
Index("idx_reaction_shout", "shout"),
Index("idx_reaction_kind", "kind"),
{"extend_existing": True},
)

View File

@@ -1,7 +1,8 @@
import time
from typing import Any
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from auth.orm import Author
from orm.base import BaseModel as Base
@@ -21,13 +22,13 @@ class ShoutTopic(Base):
__tablename__ = "shout_topic"
id = None # type: ignore[misc]
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
main = Column(Boolean, nullable=True)
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), index=True)
topic: Mapped[int] = mapped_column(ForeignKey("topic.id"), index=True)
main: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# Определяем дополнительные индексы
__table_args__ = (
PrimaryKeyConstraint(shout, topic),
# Оптимизированный составной индекс для запросов, которые ищут публикации по теме
Index("idx_shout_topic_topic_shout", "topic", "shout"),
)
@@ -36,12 +37,18 @@ class ShoutTopic(Base):
class ShoutReactionsFollower(Base):
__tablename__ = "shout_reactions_followers"
id = None # type: ignore[misc]
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
auto = Column(Boolean, nullable=False, default=False)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at = Column(Integer, nullable=True)
follower: Mapped[int] = mapped_column(ForeignKey(Author.id), index=True)
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), index=True)
auto: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
__table_args__ = (
PrimaryKeyConstraint(follower, shout),
Index("idx_shout_reactions_followers_follower", "follower"),
Index("idx_shout_reactions_followers_shout", "shout"),
{"extend_existing": True},
)
class ShoutAuthor(Base):
@@ -56,13 +63,13 @@ class ShoutAuthor(Base):
__tablename__ = "shout_author"
id = None # type: ignore[misc]
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
caption = Column(String, nullable=True, default="")
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), index=True)
author: Mapped[int] = mapped_column(ForeignKey(Author.id), index=True)
caption: Mapped[str | None] = mapped_column(String, nullable=True, default="")
# Определяем дополнительные индексы
__table_args__ = (
PrimaryKeyConstraint(shout, author),
# Оптимизированный индекс для запросов, которые ищут публикации по автору
Index("idx_shout_author_author_shout", "author", "shout"),
)
@@ -75,37 +82,36 @@ class Shout(Base):
__tablename__ = "shout"
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=True, index=True)
published_at = Column(Integer, nullable=True, index=True)
featured_at = Column(Integer, nullable=True, index=True)
deleted_at = Column(Integer, nullable=True, index=True)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
published_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
featured_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
created_by = Column(ForeignKey("author.id"), nullable=False)
updated_by = Column(ForeignKey("author.id"), nullable=True)
deleted_by = Column(ForeignKey("author.id"), nullable=True)
community = Column(ForeignKey("community.id"), nullable=False)
created_by: Mapped[int] = mapped_column(ForeignKey(Author.id), nullable=False)
updated_by: Mapped[int | None] = mapped_column(ForeignKey(Author.id), nullable=True)
deleted_by: Mapped[int | None] = mapped_column(ForeignKey(Author.id), nullable=True)
community: Mapped[int] = mapped_column(ForeignKey("community.id"), nullable=False)
body = Column(String, nullable=False, comment="Body")
slug = Column(String, unique=True)
cover = Column(String, nullable=True, comment="Cover image url")
cover_caption = Column(String, nullable=True, comment="Cover image alt caption")
lead = Column(String, nullable=True)
title = Column(String, nullable=False)
subtitle = Column(String, nullable=True)
layout = Column(String, nullable=False, default="article")
media = Column(JSON, nullable=True)
body: Mapped[str] = mapped_column(String, nullable=False, comment="Body")
slug: Mapped[str | None] = mapped_column(String, unique=True)
cover: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image url")
cover_caption: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image alt caption")
lead: Mapped[str | None] = mapped_column(String, nullable=True)
title: Mapped[str] = mapped_column(String, nullable=False)
subtitle: Mapped[str | None] = mapped_column(String, nullable=True)
layout: Mapped[str] = mapped_column(String, nullable=False, default="article")
media: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
authors = relationship(Author, secondary="shout_author")
topics = relationship(Topic, secondary="shout_topic")
reactions = relationship(Reaction)
lang = Column(String, nullable=False, default="ru", comment="Language")
version_of = Column(ForeignKey("shout.id"), nullable=True)
oid = Column(String, nullable=True)
seo = Column(String, nullable=True) # JSON
draft = Column(ForeignKey("draft.id"), nullable=True)
lang: Mapped[str] = mapped_column(String, nullable=False, default="ru", comment="Language")
version_of: Mapped[int | None] = mapped_column(ForeignKey("shout.id"), nullable=True)
oid: Mapped[str | None] = mapped_column(String, nullable=True)
seo: Mapped[str | None] = mapped_column(String, nullable=True) # JSON
# Определяем индексы
__table_args__ = (

View File

@@ -1,7 +1,17 @@
import time
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from sqlalchemy import (
JSON,
Boolean,
ForeignKey,
Index,
Integer,
PrimaryKeyConstraint,
String,
)
from sqlalchemy.orm import Mapped, mapped_column
from auth.orm import Author
from orm.base import BaseModel as Base
@@ -18,14 +28,14 @@ class TopicFollower(Base):
__tablename__ = "topic_followers"
id = None # type: ignore[misc]
follower = Column(Integer, ForeignKey("author.id"), primary_key=True)
topic = Column(Integer, ForeignKey("topic.id"), primary_key=True)
created_at = Column(Integer, nullable=False, default=int(time.time()))
auto = Column(Boolean, nullable=False, default=False)
follower: Mapped[int] = mapped_column(ForeignKey(Author.id))
topic: Mapped[int] = mapped_column(ForeignKey("topic.id"))
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
auto: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Определяем индексы
__table_args__ = (
PrimaryKeyConstraint(topic, follower),
# Индекс для быстрого поиска всех подписчиков топика
Index("idx_topic_followers_topic", "topic"),
# Индекс для быстрого поиска всех топиков, на которые подписан автор
@@ -49,13 +59,14 @@ class Topic(Base):
__tablename__ = "topic"
slug = Column(String, unique=True)
title = Column(String, nullable=False, comment="Title")
body = Column(String, nullable=True, comment="Body")
pic = Column(String, nullable=True, comment="Picture")
community = Column(ForeignKey("community.id"), default=1)
oid = Column(String, nullable=True, comment="Old ID")
parent_ids = Column(JSON, nullable=True, comment="Parent Topic IDs")
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
slug: Mapped[str] = mapped_column(String, unique=True)
title: Mapped[str] = mapped_column(String, nullable=False, comment="Title")
body: Mapped[str | None] = mapped_column(String, nullable=True, comment="Body")
pic: Mapped[str | None] = mapped_column(String, nullable=True, comment="Picture")
community: Mapped[int] = mapped_column(ForeignKey("community.id"), default=1)
oid: Mapped[str | None] = mapped_column(String, nullable=True, comment="Old ID")
parent_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True, comment="Parent Topic IDs")
# Определяем индексы
__table_args__ = (