0.4.9-drafts

This commit is contained in:
Untone 2025-02-09 17:18:01 +03:00
parent dce05342df
commit 37a9a284ef
13 changed files with 468 additions and 37 deletions

View File

@ -1,3 +1,9 @@
#### [0.4.9] - 2025-02-09
- `Shout.draft` field added
- `Draft` entity added
- `create_draft`, `update_draft`, `delete_draft` mutations and resolvers added
- `get_shout_drafts` resolver updated
#### [0.4.8] - 2025-02-03 #### [0.4.8] - 2025-02-03
- `Reaction.deleted_at` filter on `update_reaction` resolver added - `Reaction.deleted_at` filter on `update_reaction` resolver added
- `triggers` module updated with `after_shout_handler`, `after_reaction_handler` for cache revalidation - `triggers` module updated with `after_shout_handler`, `after_reaction_handler` for cache revalidation

View File

@ -44,8 +44,8 @@ Start API server with `dev` keyword added and `mkcert` installed:
```shell ```shell
mkdir .venv mkdir .venv
python3.12 -m venv .venv python3.12 -m venv venv
poetry env use .venv/bin/python3.12 poetry env use venv/bin/python3.12
poetry update poetry update
mkcert -install mkcert -install

33
cache/cache.py vendored
View File

@ -156,17 +156,17 @@ async def get_cached_authors_by_ids(author_ids: List[int]) -> List[dict]:
async def get_cached_topic_followers(topic_id: int): async def get_cached_topic_followers(topic_id: int):
""" """
Получает подписчиков темы по ID, используя кеш Redis. Получает подписчиков темы по ID, используя кеш Redis.
Args: Args:
topic_id: ID темы topic_id: ID темы
Returns: Returns:
List[dict]: Список подписчиков с их данными List[dict]: Список подписчиков с их данными
""" """
try: try:
cache_key = CACHE_KEYS["TOPIC_FOLLOWERS"].format(topic_id) cache_key = CACHE_KEYS["TOPIC_FOLLOWERS"].format(topic_id)
cached = await redis_operation("GET", cache_key) cached = await redis_operation("GET", cache_key)
if cached: if cached:
followers_ids = json.loads(cached) followers_ids = json.loads(cached)
logger.debug(f"Found {len(followers_ids)} cached followers for topic #{topic_id}") logger.debug(f"Found {len(followers_ids)} cached followers for topic #{topic_id}")
@ -174,12 +174,13 @@ async def get_cached_topic_followers(topic_id: int):
with local_session() as session: with local_session() as session:
followers_ids = [ followers_ids = [
f[0] for f in session.query(Author.id) f[0]
for f in session.query(Author.id)
.join(TopicFollower, TopicFollower.follower == Author.id) .join(TopicFollower, TopicFollower.follower == Author.id)
.filter(TopicFollower.topic == topic_id) .filter(TopicFollower.topic == topic_id)
.all() .all()
] ]
await redis_operation("SETEX", cache_key, value=json.dumps(followers_ids), ttl=CACHE_TTL) await redis_operation("SETEX", cache_key, value=json.dumps(followers_ids), ttl=CACHE_TTL)
followers = await get_cached_authors_by_ids(followers_ids) followers = await get_cached_authors_by_ids(followers_ids)
logger.debug(f"Cached {len(followers)} followers for topic #{topic_id}") logger.debug(f"Cached {len(followers)} followers for topic #{topic_id}")
@ -405,7 +406,7 @@ async def cache_related_entities(shout: Shout):
async def invalidate_shout_related_cache(shout: Shout, author_id: int): async def invalidate_shout_related_cache(shout: Shout, author_id: int):
""" """
Инвалидирует весь кэш, связанный с публикацией и её связями Инвалидирует весь кэш, связанный с публикацией и её связями
Args: Args:
shout: Объект публикации shout: Объект публикации
author_id: ID автора author_id: ID автора
@ -418,22 +419,14 @@ async def invalidate_shout_related_cache(shout: Shout, author_id: int):
"recent", # последние "recent", # последние
"coauthored", # совместные "coauthored", # совместные
} }
# Добавляем ключи авторов # Добавляем ключи авторов
cache_keys.update( cache_keys.update(f"author_{a.id}" for a in shout.authors)
f"author_{a.id}" for a in shout.authors cache_keys.update(f"authored_{a.id}" for a in shout.authors)
)
cache_keys.update(
f"authored_{a.id}" for a in shout.authors
)
# Добавляем ключи тем # Добавляем ключи тем
cache_keys.update( cache_keys.update(f"topic_{t.id}" for t in shout.topics)
f"topic_{t.id}" for t in shout.topics cache_keys.update(f"topic_shouts_{t.id}" for t in shout.topics)
)
cache_keys.update(
f"topic_shouts_{t.id}" for t in shout.topics
)
await invalidate_shouts_cache(list(cache_keys)) await invalidate_shouts_cache(list(cache_keys))

56
orm/draft.py Normal file
View File

@ -0,0 +1,56 @@
import time
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from orm.author import Author
from orm.topic import Topic
from services.db import Base
class DraftTopic(Base):
__tablename__ = "draft_topic"
id = None # type: ignore
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)
class DraftAuthor(Base):
__tablename__ = "draft_author"
id = None # type: ignore
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="")
class Draft(Base):
__tablename__ = "draft"
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at: int | None = Column(Integer, nullable=True, index=True)
deleted_at: int | None = Column(Integer, nullable=True, index=True)
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)
description: 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)
lang: str = Column(String, nullable=False, default="ru", comment="Language")
oid: str | None = Column(String, nullable=True)
seo: str | None = Column(String, nullable=True) # JSON
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)
authors = relationship(Author, secondary="draft_author")
topics = relationship(Topic, secondary="draft_topic")
shout: int | None = Column(ForeignKey("shout.id"), nullable=True)

View File

@ -72,3 +72,5 @@ class Shout(Base):
oid: str | None = Column(String, nullable=True) oid: str | None = Column(String, nullable=True)
seo: str | None = Column(String, nullable=True) # JSON seo: str | None = Column(String, nullable=True) # JSON
draft: int | None = Column(ForeignKey("draft.id"), nullable=True)

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "core" name = "core"
version = "0.4.8" version = "0.4.9"
description = "core module for discours.io" description = "core module for discours.io"
authors = ["discoursio devteam"] authors = ["discoursio devteam"]
license = "MIT" license = "MIT"
@ -32,6 +32,8 @@ authlib = "^1.3.2"
ruff = "^0.4.7" ruff = "^0.4.7"
isort = "^5.13.2" isort = "^5.13.2"
pydantic = "^2.9.2" pydantic = "^2.9.2"
pytest = "^8.3.4"
mypy = "^1.15.0"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
@ -39,7 +41,7 @@ build-backend = "poetry.core.masonry.api"
[tool.pyright] [tool.pyright]
venvPath = "." venvPath = "."
venv = ".venv" venv = "venv"
[tool.isort] [tool.isort]
multi_line_output = 3 multi_line_output = 3

View File

@ -11,6 +11,14 @@ from resolvers.author import ( # search_authors,
update_author, update_author,
) )
from resolvers.community import get_communities_all, get_community from resolvers.community import get_communities_all, get_community
from resolvers.draft import (
create_draft,
delete_draft,
load_drafts,
publish_draft,
unpublish_draft,
update_draft,
)
from resolvers.editor import create_shout, delete_shout, update_shout from resolvers.editor import create_shout, delete_shout, update_shout
from resolvers.feed import ( from resolvers.feed import (
load_shouts_coauthored, load_shouts_coauthored,
@ -113,4 +121,12 @@ __all__ = [
"rate_author", "rate_author",
"get_my_rates_comments", "get_my_rates_comments",
"get_my_rates_shouts", "get_my_rates_shouts",
# draft
"load_drafts",
"create_draft",
"update_draft",
"delete_draft",
"publish_draft",
"publish_shout",
"unpublish_shout",
] ]

230
resolvers/draft.py Normal file
View File

@ -0,0 +1,230 @@
import time
from importlib import invalidate_caches
from sqlalchemy import select
from cache.cache import invalidate_shout_related_cache, invalidate_shouts_cache
from orm.author import Author
from orm.draft import Draft
from orm.shout import Shout
from services.auth import login_required
from services.db import local_session
from services.schema import mutation, query
from utils.logger import root_logger as logger
@query.field("load_drafts")
@login_required
async def load_drafts(_, info):
user_id = info.context.get("user_id")
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
if not user_id or not author_id:
return {"error": "User ID and author ID are required"}
with local_session() as session:
drafts = session.query(Draft).filter(Draft.authors.any(Author.id == author_id)).all()
return {"drafts": drafts}
@mutation.field("create_draft")
@login_required
async def create_draft(_, info, shout_id: int = 0):
user_id = info.context.get("user_id")
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
if not user_id or not author_id:
return {"error": "User ID and author ID are required"}
with local_session() as session:
draft = Draft(created_by=author_id)
if shout_id:
draft.shout = shout_id
session.add(draft)
session.commit()
return {"draft": draft}
@mutation.field("update_draft")
@login_required
async def update_draft(_, info, draft_input):
user_id = info.context.get("user_id")
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
draft_id = draft_input.get("id")
if not user_id or not author_id:
return {"error": "User ID and author ID are required"}
with local_session() as session:
draft = session.query(Draft).filter(Draft.id == draft_id).first()
Draft.update(draft, {**draft_input})
if not draft:
return {"error": "Draft not found"}
draft.updated_at = int(time.time())
session.commit()
return {"draft": draft}
@mutation.field("delete_draft")
@login_required
async def delete_draft(_, info, draft_id: int):
user_id = info.context.get("user_id")
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
with local_session() as session:
draft = session.query(Draft).filter(Draft.id == draft_id).first()
if not draft:
return {"error": "Draft not found"}
session.delete(draft)
session.commit()
return {"draft": draft}
@mutation.field("publish_draft")
@login_required
async def publish_draft(_, info, draft_id: int):
user_id = info.context.get("user_id")
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
if not user_id or not author_id:
return {"error": "User ID and author ID are required"}
with local_session() as session:
draft = session.query(Draft).filter(Draft.id == draft_id).first()
if not draft:
return {"error": "Draft not found"}
return publish_shout(None, None, draft.shout, draft)
@mutation.field("unpublish_draft")
@login_required
async def unpublish_draft(_, info, draft_id: int):
user_id = info.context.get("user_id")
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
if not user_id or not author_id:
return {"error": "User ID and author ID are required"}
with local_session() as session:
draft = session.query(Draft).filter(Draft.id == draft_id).first()
shout_id = draft.shout
unpublish_shout(None, None, shout_id)
@mutation.field("publish_shout")
@login_required
async def publish_shout(_, info, shout_id: int, draft=None):
"""Publish draft as a shout or update existing shout.
Args:
session: SQLAlchemy session to use for database operations
"""
user_id = info.context.get("user_id")
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
if not user_id or not author_id:
return {"error": "User ID and author ID are required"}
try:
# Use proper SQLAlchemy query
with local_session() as session:
if not draft:
find_draft_stmt = select(Draft).where(Draft.shout == shout_id)
draft = session.execute(find_draft_stmt).scalar_one_or_none()
now = int(time.time())
if not shout:
# Create new shout from draft
shout = Shout(
body=draft.body,
slug=draft.slug,
cover=draft.cover,
cover_caption=draft.cover_caption,
lead=draft.lead,
description=draft.description,
title=draft.title,
subtitle=draft.subtitle,
layout=draft.layout,
media=draft.media,
lang=draft.lang,
seo=draft.seo,
created_by=author_id,
community=draft.community,
authors=draft.authors.copy(), # Create copies of relationships
topics=draft.topics.copy(),
draft=draft.id,
deleted_at=None,
)
else:
# Update existing shout
shout.authors = draft.authors.copy()
shout.topics = draft.topics.copy()
shout.draft = draft.id
shout.created_by = author_id
shout.title = draft.title
shout.subtitle = draft.subtitle
shout.body = draft.body
shout.cover = draft.cover
shout.cover_caption = draft.cover_caption
shout.lead = draft.lead
shout.description = draft.description
shout.layout = draft.layout
shout.media = draft.media
shout.lang = draft.lang
shout.seo = draft.seo
shout.updated_at = now
shout.published_at = now
draft.updated_at = now
draft.published_at = now
session.add(shout)
session.add(draft)
session.commit()
invalidate_shout_related_cache(shout)
invalidate_shouts_cache()
return {"shout": shout}
except Exception as e:
import traceback
logger.error(f"Failed to publish shout: {e}")
logger.error(traceback.format_exc())
session.rollback()
return {"error": "Failed to publish shout"}
@mutation.field("unpublish_shout")
@login_required
async def unpublish_shout(_, info, shout_id: int):
"""Unpublish a shout.
Args:
shout_id: The ID of the shout to unpublish
Returns:
dict: The unpublished shout or an error message
"""
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
if not author_id:
return {"error": "Author ID is required"}
shout = None
with local_session() as session:
try:
shout = session.query(Shout).filter(Shout.id == shout_id).first()
shout.published_at = None
session.commit()
invalidate_shout_related_cache(shout)
invalidate_shouts_cache()
except Exception:
session.rollback()
return {"error": "Failed to unpublish shout"}
return {"shout": shout}

View File

@ -7,8 +7,10 @@ from sqlalchemy.sql.functions import coalesce
from cache.cache import cache_author, cache_topic, invalidate_shout_related_cache, invalidate_shouts_cache from cache.cache import cache_author, cache_topic, invalidate_shout_related_cache, invalidate_shouts_cache
from orm.author import Author from orm.author import Author
from orm.draft import Draft
from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic from orm.topic import Topic
from resolvers.draft import create_draft, publish_draft
from resolvers.follower import follow, unfollow from resolvers.follower import follow, unfollow
from resolvers.stat import get_with_stat from resolvers.stat import get_with_stat
from services.auth import login_required from services.auth import login_required
@ -20,6 +22,23 @@ from utils.logger import root_logger as logger
async def cache_by_id(entity, entity_id: int, cache_method): async def cache_by_id(entity, entity_id: int, cache_method):
"""Cache an entity by its ID using the provided cache method.
Args:
entity: The SQLAlchemy model class to query
entity_id (int): The ID of the entity to cache
cache_method: The caching function to use
Returns:
dict: The cached entity data if successful, None if entity not found
Example:
>>> async def test_cache():
... author = await cache_by_id(Author, 1, cache_author)
... assert author['id'] == 1
... assert 'name' in author
... return author
"""
caching_query = select(entity).filter(entity.id == entity_id) caching_query = select(entity).filter(entity.id == entity_id)
result = get_with_stat(caching_query) result = get_with_stat(caching_query)
if not result or not result[0]: if not result or not result[0]:
@ -34,7 +53,34 @@ async def cache_by_id(entity, entity_id: int, cache_method):
@query.field("get_my_shout") @query.field("get_my_shout")
@login_required @login_required
async def get_my_shout(_, info, shout_id: int): async def get_my_shout(_, info, shout_id: int):
# logger.debug(info) """Get a shout by ID if the requesting user has permission to view it.
DEPRECATED: use `load_drafts` instead
Args:
info: GraphQL resolver info containing context
shout_id (int): ID of the shout to retrieve
Returns:
dict: Contains either:
- error (str): Error message if retrieval failed
- shout (Shout): The requested shout if found and accessible
Permissions:
User must be:
- The shout creator
- Listed as an author
- Have editor role
Example:
>>> async def test_get_my_shout():
... context = {'user_id': '123', 'author': {'id': 1}, 'roles': []}
... info = type('Info', (), {'context': context})()
... result = await get_my_shout(None, info, 1)
... assert result['error'] is None
... assert result['shout'].id == 1
... return result
"""
user_id = info.context.get("user_id", "") user_id = info.context.get("user_id", "")
author_dict = info.context.get("author", {}) author_dict = info.context.get("author", {})
author_id = author_dict.get("id") author_id = author_dict.get("id")
@ -105,8 +151,8 @@ async def get_shouts_drafts(_, info):
return {"shouts": shouts} return {"shouts": shouts}
@mutation.field("create_shout") # @mutation.field("create_shout")
@login_required # @login_required
async def create_shout(_, info, inp): async def create_shout(_, info, inp):
logger.info(f"Starting create_shout with input: {inp}") logger.info(f"Starting create_shout with input: {inp}")
user_id = info.context.get("user_id") user_id = info.context.get("user_id")
@ -214,6 +260,27 @@ async def create_shout(_, info, inp):
def patch_main_topic(session, main_topic_slug, shout): def patch_main_topic(session, main_topic_slug, shout):
"""Update the main topic for a shout.
Args:
session: SQLAlchemy session
main_topic_slug (str): Slug of the topic to set as main
shout (Shout): The shout to update
Side Effects:
- Updates ShoutTopic.main flags in database
- Only one topic can be main at a time
Example:
>>> def test_patch_main_topic():
... with local_session() as session:
... shout = session.query(Shout).first()
... patch_main_topic(session, 'tech', shout)
... main_topic = session.query(ShoutTopic).filter_by(
... shout=shout.id, main=True).first()
... assert main_topic.topic.slug == 'tech'
... return main_topic
"""
logger.info(f"Starting patch_main_topic for shout#{shout.id} with slug '{main_topic_slug}'") logger.info(f"Starting patch_main_topic for shout#{shout.id} with slug '{main_topic_slug}'")
with session.begin(): with session.begin():
@ -252,6 +319,34 @@ def patch_main_topic(session, main_topic_slug, shout):
def patch_topics(session, shout, topics_input): def patch_topics(session, shout, topics_input):
"""Update the topics associated with a shout.
Args:
session: SQLAlchemy session
shout (Shout): The shout to update
topics_input (list): List of topic dicts with fields:
- id (int): Topic ID (<0 for new topics)
- slug (str): Topic slug
- title (str): Topic title (for new topics)
Side Effects:
- Creates new topics if needed
- Updates shout-topic associations
- Refreshes shout object with new topics
Example:
>>> def test_patch_topics():
... topics = [
... {'id': -1, 'slug': 'new-topic', 'title': 'New Topic'},
... {'id': 1, 'slug': 'existing-topic'}
... ]
... with local_session() as session:
... shout = session.query(Shout).first()
... patch_topics(session, shout, topics)
... assert len(shout.topics) == 2
... assert any(t.slug == 'new-topic' for t in shout.topics)
... return shout.topics
"""
logger.info(f"Starting patch_topics for shout#{shout.id}") logger.info(f"Starting patch_topics for shout#{shout.id}")
logger.info(f"Received topics_input: {topics_input}") logger.info(f"Received topics_input: {topics_input}")
@ -292,8 +387,8 @@ def patch_topics(session, shout, topics_input):
logger.info(f"Final shout topics: {[t.dict() for t in shout.topics]}") logger.info(f"Final shout topics: {[t.dict() for t in shout.topics]}")
@mutation.field("update_shout") # @mutation.field("update_shout")
@login_required # @login_required
async def update_shout(_, info, shout_id: int, shout_input=None, publish=False): async def update_shout(_, info, shout_id: int, shout_input=None, publish=False):
logger.info(f"Starting update_shout with id={shout_id}, publish={publish}") logger.info(f"Starting update_shout with id={shout_id}, publish={publish}")
logger.debug(f"Full shout_input: {shout_input}") logger.debug(f"Full shout_input: {shout_input}")
@ -505,8 +600,8 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False):
return {"error": "cant update shout"} return {"error": "cant update shout"}
@mutation.field("delete_shout") # @mutation.field("delete_shout")
@login_required # @login_required
async def delete_shout(_, info, shout_id: int): async def delete_shout(_, info, shout_id: int):
user_id = info.context.get("user_id") user_id = info.context.get("user_id")
roles = info.context.get("roles", []) roles = info.context.get("roles", [])

View File

@ -1,4 +1,4 @@
input ShoutInput { input DraftInput {
slug: String slug: String
title: String title: String
body: String body: String

View File

@ -3,10 +3,15 @@ type Mutation {
rate_author(rated_slug: String!, value: Int!): CommonResult! rate_author(rated_slug: String!, value: Int!): CommonResult!
update_author(profile: ProfileInput!): CommonResult! update_author(profile: ProfileInput!): CommonResult!
# editor # draft
create_shout(inp: ShoutInput!): CommonResult! create_draft(input: DraftInput!): CommonResult!
update_shout(shout_id: Int!, shout_input: ShoutInput, publish: Boolean): CommonResult! update_draft(draft_id: Int!, input: DraftInput!): CommonResult!
delete_shout(shout_id: Int!): CommonResult! delete_draft(draft_id: Int!): CommonResult!
# publication
publish_shout(shout_id: Int!): CommonResult!
publish_draft(draft_id: Int!): CommonResult!
unpublish_draft(draft_id: Int!): CommonResult!
unpublish_shout(shout_id: Int!): CommonResult!
# follower # follower
follow(what: FollowingEntity!, slug: String!): AuthorFollowsResult! follow(what: FollowingEntity!, slug: String!): AuthorFollowsResult!

View File

@ -51,6 +51,7 @@ type Query {
# editor # editor
get_my_shout(shout_id: Int!): CommonResult! get_my_shout(shout_id: Int!): CommonResult!
get_shouts_drafts: CommonResult! get_shouts_drafts: CommonResult!
load_drafts: CommonResult!
# topic # topic
get_topic(slug: String!): Topic get_topic(slug: String!): Topic

View File

@ -100,12 +100,37 @@ type Shout {
deleted_at: Int deleted_at: Int
version_of: Shout # TODO: use version_of somewhere version_of: Shout # TODO: use version_of somewhere
draft: Draft
media: [MediaItem] media: [MediaItem]
stat: Stat stat: Stat
score: Float score: Float
} }
type Draft {
id: Int!
shout: Shout
created_at: Int!
updated_at: Int
deleted_at: Int
created_by: Author!
updated_by: Author
deleted_by: Author
authors: [Author]
topics: [Topic]
media: [MediaItem]
lead: String
description: String
subtitle: String
layout: String
lang: String
seo: String
body: String
title: String
slug: String
cover: String
cover_caption: String
}
type Stat { type Stat {
rating: Int rating: Int
commented: Int commented: Int