Improve topic sorting: add popular sorting by publications and authors count

This commit is contained in:
2025-06-02 02:56:11 +03:00
parent baca19a4d5
commit 3327976586
113 changed files with 7238 additions and 3739 deletions

View File

@@ -2,7 +2,7 @@ import time
from sqlalchemy import Column, ForeignKey, Integer, String
from services.db import Base
from services.db import BaseModel as Base
class ShoutCollection(Base):

View File

@@ -1,11 +1,12 @@
import enum
import time
from sqlalchemy import Column, ForeignKey, Integer, String, Text, distinct, func
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String, Text, distinct, func
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
from auth.orm import Author
from services.db import Base
from services.db import BaseModel
class CommunityRole(enum.Enum):
@@ -14,28 +15,36 @@ class CommunityRole(enum.Enum):
ARTIST = "artist" # + can be credited as featured artist
EXPERT = "expert" # + can add proof or disproof to shouts, can manage topics
EDITOR = "editor" # + can manage topics, comments and community settings
ADMIN = "admin"
@classmethod
def as_string_array(cls, roles):
return [role.value for role in roles]
@classmethod
def from_string(cls, value: str) -> "CommunityRole":
return cls(value)
class CommunityFollower(Base):
__tablename__ = "community_author"
author = Column(ForeignKey("author.id"), primary_key=True)
class CommunityFollower(BaseModel):
__tablename__ = "community_follower"
community = Column(ForeignKey("community.id"), primary_key=True)
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
roles = Column(Text, nullable=True, comment="Roles (comma-separated)")
follower = Column(ForeignKey("author.id"), primary_key=True)
roles = Column(String, nullable=True)
def set_roles(self, roles):
self.roles = CommunityRole.as_string_array(roles)
def __init__(self, community: int, follower: int, roles: list[str] | None = None) -> None:
self.community = community # type: ignore[assignment]
self.follower = follower # type: ignore[assignment]
if roles:
self.roles = ",".join(roles) # type: ignore[assignment]
def get_roles(self):
return [CommunityRole(role) for role in self.roles]
def get_roles(self) -> list[CommunityRole]:
roles_str = getattr(self, "roles", "")
return [CommunityRole(role) for role in roles_str.split(",")] if roles_str else []
class Community(Base):
class Community(BaseModel):
__tablename__ = "community"
name = Column(String, nullable=False)
@@ -44,6 +53,12 @@ class Community(Base):
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")
@hybrid_property
def stat(self):
@@ -54,12 +69,39 @@ class Community(Base):
return self.roles.split(",") if self.roles else []
@role_list.setter
def role_list(self, value):
self.roles = ",".join(value) if value else None
def role_list(self, value) -> None:
self.roles = ",".join(value) if value else None # type: ignore[assignment]
def is_followed_by(self, author_id: int) -> bool:
# Check if the author follows this community
from services.db import local_session
with local_session() as session:
follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.community == self.id, CommunityFollower.follower == author_id)
.first()
)
return follower is not None
def get_role(self, author_id: int) -> CommunityRole | None:
# Get the role of the author in this community
from services.db import local_session
with local_session() as session:
follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.community == self.id, CommunityFollower.follower == author_id)
.first()
)
if follower and follower.roles:
roles = follower.roles.split(",")
return CommunityRole.from_string(roles[0]) if roles else None
return None
class CommunityStats:
def __init__(self, community):
def __init__(self, community) -> None:
self.community = community
@property
@@ -71,7 +113,7 @@ class CommunityStats:
@property
def followers(self):
return (
self.community.session.query(func.count(CommunityFollower.author))
self.community.session.query(func.count(CommunityFollower.follower))
.filter(CommunityFollower.community == self.community.id)
.scalar()
)
@@ -93,7 +135,7 @@ class CommunityStats:
)
class CommunityAuthor(Base):
class CommunityAuthor(BaseModel):
__tablename__ = "community_author"
id = Column(Integer, primary_key=True)
@@ -106,5 +148,5 @@ class CommunityAuthor(Base):
return self.roles.split(",") if self.roles else []
@role_list.setter
def role_list(self, value):
self.roles = ",".join(value) if value else None
def role_list(self, value) -> None:
self.roles = ",".join(value) if value else None # type: ignore[assignment]

View File

@@ -5,7 +5,7 @@ from sqlalchemy.orm import relationship
from auth.orm import Author
from orm.topic import Topic
from services.db import Base
from services.db import BaseModel as Base
class DraftTopic(Base):
@@ -29,76 +29,27 @@ class DraftAuthor(Base):
class Draft(Base):
__tablename__ = "draft"
# required
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
# Колонки для связей с автором
created_by: int = Column("created_by", ForeignKey("author.id"), nullable=False)
community: int = Column("community", ForeignKey("community.id"), nullable=False, default=1)
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)
# optional
layout: str = Column(String, nullable=True, default="article")
slug: str = Column(String, unique=True)
title: str = Column(String, nullable=True)
subtitle: str | None = Column(String, nullable=True)
lead: str | None = Column(String, nullable=True)
body: str = Column(String, nullable=False, comment="Body")
media: dict | None = Column(JSON, nullable=True)
cover: str | None = Column(String, nullable=True, comment="Cover image url")
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
lang: str = Column(String, nullable=False, default="ru", comment="Language")
seo: str | None = Column(String, nullable=True) # JSON
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
# auto
updated_at: int | None = Column(Integer, nullable=True, index=True)
deleted_at: int | None = Column(Integer, nullable=True, index=True)
updated_by: int | None = Column("updated_by", ForeignKey("author.id"), nullable=True)
deleted_by: int | None = Column("deleted_by", ForeignKey("author.id"), nullable=True)
# --- Relationships ---
# Только many-to-many связи через вспомогательные таблицы
authors = relationship(Author, secondary="draft_author", lazy="select")
topics = relationship(Topic, secondary="draft_topic", lazy="select")
# Связь с Community (если нужна как объект, а не ID)
# community = relationship("Community", foreign_keys=[community_id], lazy="joined")
# Пока оставляем community_id как ID
# Связь с публикацией (один-к-одному или один-к-нулю)
# Загружается через joinedload в резолвере
publication = relationship(
"Shout",
primaryjoin="Draft.id == Shout.draft",
foreign_keys="Shout.draft",
uselist=False,
lazy="noload", # Не грузим по умолчанию, только через options
viewonly=True, # Указываем, что это связь только для чтения
)
def dict(self):
"""
Сериализует объект Draft в словарь.
Гарантирует, что поля topics и authors всегда будут списками.
"""
return {
"id": self.id,
"created_at": self.created_at,
"created_by": self.created_by,
"community": self.community,
"layout": self.layout,
"slug": self.slug,
"title": self.title,
"subtitle": self.subtitle,
"lead": self.lead,
"body": self.body,
"media": self.media or [],
"cover": self.cover,
"cover_caption": self.cover_caption,
"lang": self.lang,
"seo": self.seo,
"updated_at": self.updated_at,
"deleted_at": self.deleted_at,
"updated_by": self.updated_by,
"deleted_by": self.deleted_by,
# Гарантируем, что topics и authors всегда будут списками
"topics": [topic.dict() for topic in (self.topics or [])],
"authors": [author.dict() for author in (self.authors or [])],
}
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")

View File

@@ -3,7 +3,7 @@ import enum
from sqlalchemy import Column, ForeignKey, String
from sqlalchemy.orm import relationship
from services.db import Base
from services.db import BaseModel as Base
class InviteStatus(enum.Enum):
@@ -29,7 +29,7 @@ class Invite(Base):
shout = relationship("Shout")
def set_status(self, status: InviteStatus):
self.status = status.value
self.status = status.value # type: ignore[assignment]
def get_status(self) -> InviteStatus:
return InviteStatus.from_string(self.status)

View File

@@ -5,7 +5,7 @@ from sqlalchemy import JSON, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from auth.orm import Author
from services.db import Base
from services.db import BaseModel as Base
class NotificationEntity(enum.Enum):
@@ -51,13 +51,13 @@ class Notification(Base):
seen = relationship(Author, secondary="notification_seen")
def set_entity(self, entity: NotificationEntity):
self.entity = entity.value
self.entity = entity.value # type: ignore[assignment]
def get_entity(self) -> NotificationEntity:
return NotificationEntity.from_string(self.entity)
def set_action(self, action: NotificationAction):
self.action = action.value
self.action = action.value # type: ignore[assignment]
def get_action(self) -> NotificationAction:
return NotificationAction.from_string(self.action)

View File

@@ -3,7 +3,7 @@ from enum import Enum as Enumeration
from sqlalchemy import Column, ForeignKey, Integer, String
from services.db import Base
from services.db import BaseModel as Base
class ReactionKind(Enumeration):

View File

@@ -6,7 +6,7 @@ from sqlalchemy.orm import relationship
from auth.orm import Author
from orm.reaction import Reaction
from orm.topic import Topic
from services.db import Base
from services.db import BaseModel as Base
class ShoutTopic(Base):
@@ -71,70 +71,41 @@ class ShoutAuthor(Base):
class Shout(Base):
"""
Публикация в системе.
Attributes:
body (str)
slug (str)
cover (str) : "Cover image url"
cover_caption (str) : "Cover image alt caption"
lead (str)
title (str)
subtitle (str)
layout (str)
media (dict)
authors (list[Author])
topics (list[Topic])
reactions (list[Reaction])
lang (str)
version_of (int)
oid (str)
seo (str) : JSON
draft (int)
created_at (int)
updated_at (int)
published_at (int)
featured_at (int)
deleted_at (int)
created_by (int)
updated_by (int)
deleted_by (int)
community (int)
"""
__tablename__ = "shout"
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at: int | None = Column(Integer, nullable=True, index=True)
published_at: int | None = Column(Integer, nullable=True, index=True)
featured_at: int | None = Column(Integer, nullable=True, index=True)
deleted_at: int | None = Column(Integer, nullable=True, index=True)
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)
created_by: int = Column(ForeignKey("author.id"), nullable=False)
updated_by: int | None = Column(ForeignKey("author.id"), nullable=True)
deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True)
community: int = Column(ForeignKey("community.id"), nullable=False)
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)
body: str = Column(String, nullable=False, comment="Body")
slug: str = Column(String, unique=True)
cover: str | None = Column(String, nullable=True, comment="Cover image url")
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
lead: str | None = Column(String, nullable=True)
title: str = Column(String, nullable=False)
subtitle: str | None = Column(String, nullable=True)
layout: str = Column(String, nullable=False, default="article")
media: dict | None = Column(JSON, nullable=True)
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)
authors = relationship(Author, secondary="shout_author")
topics = relationship(Topic, secondary="shout_topic")
reactions = relationship(Reaction)
lang: str = Column(String, nullable=False, default="ru", comment="Language")
version_of: int | None = Column(ForeignKey("shout.id"), nullable=True)
oid: str | None = Column(String, nullable=True)
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
seo: str | None = Column(String, nullable=True) # JSON
draft: int | None = Column(ForeignKey("draft.id"), nullable=True)
draft = Column(ForeignKey("draft.id"), nullable=True)
# Определяем индексы
__table_args__ = (

View File

@@ -2,7 +2,7 @@ import time
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from services.db import Base
from services.db import BaseModel as Base
class TopicFollower(Base):