From 37a9a284ef4158002fc18bf5b7277cea934345dd Mon Sep 17 00:00:00 2001 From: Untone Date: Sun, 9 Feb 2025 17:18:01 +0300 Subject: [PATCH] 0.4.9-drafts --- CHANGELOG.md | 6 ++ README.md | 4 +- cache/cache.py | 33 +++--- orm/draft.py | 56 ++++++++++ orm/shout.py | 2 + pyproject.toml | 6 +- resolvers/__init__.py | 16 +++ resolvers/draft.py | 230 ++++++++++++++++++++++++++++++++++++++++ resolvers/editor.py | 109 +++++++++++++++++-- schema/input.graphql | 2 +- schema/mutation.graphql | 13 ++- schema/query.graphql | 1 + schema/type.graphql | 27 ++++- 13 files changed, 468 insertions(+), 37 deletions(-) create mode 100644 orm/draft.py create mode 100644 resolvers/draft.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b6793412..c499b46f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - `Reaction.deleted_at` filter on `update_reaction` resolver added - `triggers` module updated with `after_shout_handler`, `after_reaction_handler` for cache revalidation diff --git a/README.md b/README.md index dd1c3a15..5736bf47 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ Start API server with `dev` keyword added and `mkcert` installed: ```shell mkdir .venv -python3.12 -m venv .venv -poetry env use .venv/bin/python3.12 +python3.12 -m venv venv +poetry env use venv/bin/python3.12 poetry update mkcert -install diff --git a/cache/cache.py b/cache/cache.py index 0b0b4b1c..afd16990 100644 --- a/cache/cache.py +++ b/cache/cache.py @@ -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): """ Получает подписчиков темы по ID, используя кеш Redis. - + Args: topic_id: ID темы - + Returns: List[dict]: Список подписчиков с их данными """ try: cache_key = CACHE_KEYS["TOPIC_FOLLOWERS"].format(topic_id) cached = await redis_operation("GET", cache_key) - + if cached: followers_ids = json.loads(cached) 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: 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) .filter(TopicFollower.topic == topic_id) .all() ] - + await redis_operation("SETEX", cache_key, value=json.dumps(followers_ids), ttl=CACHE_TTL) followers = await get_cached_authors_by_ids(followers_ids) 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): """ Инвалидирует весь кэш, связанный с публикацией и её связями - + Args: shout: Объект публикации author_id: ID автора @@ -418,22 +419,14 @@ async def invalidate_shout_related_cache(shout: Shout, author_id: int): "recent", # последние "coauthored", # совместные } - + # Добавляем ключи авторов - cache_keys.update( - 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"author_{a.id}" for a in shout.authors) + cache_keys.update(f"authored_{a.id}" for a in shout.authors) + # Добавляем ключи тем - cache_keys.update( - 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_{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)) diff --git a/orm/draft.py b/orm/draft.py new file mode 100644 index 00000000..8c14e93a --- /dev/null +++ b/orm/draft.py @@ -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) diff --git a/orm/shout.py b/orm/shout.py index 5ed3db61..db352441 100644 --- a/orm/shout.py +++ b/orm/shout.py @@ -72,3 +72,5 @@ class Shout(Base): oid: str | None = Column(String, nullable=True) seo: str | None = Column(String, nullable=True) # JSON + + draft: int | None = Column(ForeignKey("draft.id"), nullable=True) diff --git a/pyproject.toml b/pyproject.toml index 66d9736f..f46a3dc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "core" -version = "0.4.8" +version = "0.4.9" description = "core module for discours.io" authors = ["discoursio devteam"] license = "MIT" @@ -32,6 +32,8 @@ authlib = "^1.3.2" ruff = "^0.4.7" isort = "^5.13.2" pydantic = "^2.9.2" +pytest = "^8.3.4" +mypy = "^1.15.0" [build-system] requires = ["poetry-core>=1.0.0"] @@ -39,7 +41,7 @@ build-backend = "poetry.core.masonry.api" [tool.pyright] venvPath = "." -venv = ".venv" +venv = "venv" [tool.isort] multi_line_output = 3 diff --git a/resolvers/__init__.py b/resolvers/__init__.py index b31e2a1b..058e847f 100644 --- a/resolvers/__init__.py +++ b/resolvers/__init__.py @@ -11,6 +11,14 @@ from resolvers.author import ( # search_authors, update_author, ) 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.feed import ( load_shouts_coauthored, @@ -113,4 +121,12 @@ __all__ = [ "rate_author", "get_my_rates_comments", "get_my_rates_shouts", + # draft + "load_drafts", + "create_draft", + "update_draft", + "delete_draft", + "publish_draft", + "publish_shout", + "unpublish_shout", ] diff --git a/resolvers/draft.py b/resolvers/draft.py new file mode 100644 index 00000000..9516e1fb --- /dev/null +++ b/resolvers/draft.py @@ -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} diff --git a/resolvers/editor.py b/resolvers/editor.py index 8d5b44e1..17774372 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -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 orm.author import Author +from orm.draft import Draft from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic +from resolvers.draft import create_draft, publish_draft from resolvers.follower import follow, unfollow from resolvers.stat import get_with_stat 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): + """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) result = get_with_stat(caching_query) 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") @login_required 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", "") author_dict = info.context.get("author", {}) author_id = author_dict.get("id") @@ -105,8 +151,8 @@ async def get_shouts_drafts(_, info): return {"shouts": shouts} -@mutation.field("create_shout") -@login_required +# @mutation.field("create_shout") +# @login_required async def create_shout(_, info, inp): logger.info(f"Starting create_shout with input: {inp}") 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): + """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}'") with session.begin(): @@ -252,6 +319,34 @@ def patch_main_topic(session, main_topic_slug, shout): 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"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]}") -@mutation.field("update_shout") -@login_required +# @mutation.field("update_shout") +# @login_required 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.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"} -@mutation.field("delete_shout") -@login_required +# @mutation.field("delete_shout") +# @login_required async def delete_shout(_, info, shout_id: int): user_id = info.context.get("user_id") roles = info.context.get("roles", []) diff --git a/schema/input.graphql b/schema/input.graphql index 4d79f76e..07367fc2 100644 --- a/schema/input.graphql +++ b/schema/input.graphql @@ -1,4 +1,4 @@ -input ShoutInput { +input DraftInput { slug: String title: String body: String diff --git a/schema/mutation.graphql b/schema/mutation.graphql index ed32f89b..df2074a5 100644 --- a/schema/mutation.graphql +++ b/schema/mutation.graphql @@ -3,10 +3,15 @@ type Mutation { rate_author(rated_slug: String!, value: Int!): CommonResult! update_author(profile: ProfileInput!): CommonResult! - # editor - create_shout(inp: ShoutInput!): CommonResult! - update_shout(shout_id: Int!, shout_input: ShoutInput, publish: Boolean): CommonResult! - delete_shout(shout_id: Int!): CommonResult! + # draft + create_draft(input: DraftInput!): CommonResult! + update_draft(draft_id: Int!, input: DraftInput!): 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 follow(what: FollowingEntity!, slug: String!): AuthorFollowsResult! diff --git a/schema/query.graphql b/schema/query.graphql index 6c87f50d..f39fba5c 100644 --- a/schema/query.graphql +++ b/schema/query.graphql @@ -51,6 +51,7 @@ type Query { # editor get_my_shout(shout_id: Int!): CommonResult! get_shouts_drafts: CommonResult! + load_drafts: CommonResult! # topic get_topic(slug: String!): Topic diff --git a/schema/type.graphql b/schema/type.graphql index fd9e432e..df4be71a 100644 --- a/schema/type.graphql +++ b/schema/type.graphql @@ -100,12 +100,37 @@ type Shout { deleted_at: Int version_of: Shout # TODO: use version_of somewhere - + draft: Draft media: [MediaItem] stat: Stat 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 { rating: Int commented: Int