From b7d82d9cc566fb733da68e7a675faf3c7fb39aad Mon Sep 17 00:00:00 2001 From: Untone Date: Fri, 19 Apr 2024 18:22:07 +0300 Subject: [PATCH] refactored-author-on-login-required --- orm/notification.py | 2 +- orm/rating.py | 1 - resolvers/__init__.py | 64 +++++----------- resolvers/author.py | 3 +- resolvers/collab.py | 89 +++++++++++----------- resolvers/editor.py | 168 ++++++++++++++++++++++-------------------- resolvers/follower.py | 47 ++++++------ resolvers/notifier.py | 17 ++--- resolvers/rating.py | 12 +-- resolvers/reaction.py | 46 ++++++------ resolvers/reader.py | 44 +++++------ resolvers/stat.py | 6 +- services/auth.py | 47 +++++------- services/cache.py | 91 +++++++++++------------ services/db.py | 3 +- services/notify.py | 2 +- services/triggers.py | 12 +-- services/viewed.py | 8 +- 18 files changed, 316 insertions(+), 346 deletions(-) diff --git a/orm/notification.py b/orm/notification.py index a164be6e..5ef3377d 100644 --- a/orm/notification.py +++ b/orm/notification.py @@ -2,8 +2,8 @@ import time from enum import Enum as Enumeration from sqlalchemy import JSON, Column, ForeignKey, Integer, String -from sqlalchemy.orm import relationship from sqlalchemy.exc import ProgrammingError +from sqlalchemy.orm import relationship from orm.author import Author from services.db import Base, engine diff --git a/orm/rating.py b/orm/rating.py index 79dce662..fa469fea 100644 --- a/orm/rating.py +++ b/orm/rating.py @@ -1,6 +1,5 @@ from orm.reaction import ReactionKind - PROPOSAL_REACTIONS = [ ReactionKind.ACCEPT.value, ReactionKind.REJECT.value, diff --git a/resolvers/__init__.py b/resolvers/__init__.py index 41e40d03..dd22ecb7 100644 --- a/resolvers/__init__.py +++ b/resolvers/__init__.py @@ -1,52 +1,24 @@ -from resolvers.author import ( - get_author, - get_author_followers, - get_author_follows, - get_author_follows_authors, - get_author_follows_topics, - get_author_id, - get_authors_all, - load_authors_by, - search_authors, - update_author, -) +from resolvers.author import (get_author, get_author_followers, + get_author_follows, get_author_follows_authors, + get_author_follows_topics, get_author_id, + get_authors_all, load_authors_by, search_authors, + update_author) from resolvers.community import get_communities_all, get_community from resolvers.editor import create_shout, delete_shout, update_shout -from resolvers.follower import ( - follow, - get_shout_followers, - get_topic_followers, - unfollow, -) -from resolvers.notifier import ( - load_notifications, - notification_mark_seen, - notifications_seen_after, - notifications_seen_thread, -) +from resolvers.follower import (follow, get_shout_followers, + get_topic_followers, unfollow) +from resolvers.notifier import (load_notifications, notification_mark_seen, + notifications_seen_after, + notifications_seen_thread) from resolvers.rating import rate_author -from resolvers.reaction import ( - create_reaction, - delete_reaction, - load_reactions_by, - load_shouts_followed, - update_reaction, -) -from resolvers.reader import ( - get_shout, - load_shouts_by, - load_shouts_feed, - load_shouts_random_top, - load_shouts_random_topic, - load_shouts_search, - load_shouts_unrated, -) -from resolvers.topic import ( - get_topic, - get_topics_all, - get_topics_by_author, - get_topics_by_community, -) +from resolvers.reaction import (create_reaction, delete_reaction, + load_reactions_by, load_shouts_followed, + update_reaction) +from resolvers.reader import (get_shout, load_shouts_by, load_shouts_feed, + load_shouts_random_top, load_shouts_random_topic, + load_shouts_search, load_shouts_unrated) +from resolvers.topic import (get_topic, get_topics_all, get_topics_by_author, + get_topics_by_community) from services.triggers import events_register events_register() diff --git a/resolvers/author.py b/resolvers/author.py index 04993b62..fdadb4a7 100644 --- a/resolvers/author.py +++ b/resolvers/author.py @@ -8,7 +8,8 @@ from sqlalchemy_searchable import search from orm.author import Author, AuthorFollower from orm.shout import ShoutAuthor, ShoutTopic from orm.topic import Topic -from resolvers.stat import author_follows_authors, author_follows_topics, get_with_stat +from resolvers.stat import (author_follows_authors, author_follows_topics, + get_with_stat) from services.auth import login_required from services.cache import cache_author, cache_follower from services.db import local_session diff --git a/resolvers/collab.py b/resolvers/collab.py index 0d43d849..e2fd073f 100644 --- a/resolvers/collab.py +++ b/resolvers/collab.py @@ -9,50 +9,57 @@ from services.schema import mutation @mutation.field("accept_invite") @login_required async def accept_invite(_, info, invite_id: int): - user_id = info.context["user_id"] - - # Check if the user exists - with local_session() as session: - author = session.query(Author).filter(Author.user == user_id).first() - if author: + info.context["user_id"] + author_dict = info.context["author"] + author_id = author_dict.get("id") + if author_id: + author_id = int(author_id) + # Check if the user exists + with local_session() as session: # Check if the invite exists invite = session.query(Invite).filter(Invite.id == invite_id).first() if ( invite - and invite.author_d is author.id + and invite.author_id is author_id and invite.status is InviteStatus.PENDING.value ): # Add the user to the shout authors shout = session.query(Shout).filter(Shout.id == invite.shout_id).first() if shout: - if author not in shout.authors: - shout.authors.append(author) - session.delete(invite) - session.add(shout) - session.commit() + if author_id not in shout.authors: + author = ( + session.query(Author).filter(Author.id == author_id).first() + ) + if author: + shout.authors.append(author) + session.add(shout) + session.delete(invite) + session.commit() return {"success": True, "message": "Invite accepted"} else: return {"error": "Shout not found"} else: return {"error": "Invalid invite or already accepted/rejected"} - else: - return {"error": "User not found"} + else: + return {"error": "Unauthorized"} @mutation.field("reject_invite") @login_required async def reject_invite(_, info, invite_id: int): - user_id = info.context["user_id"] + info.context["user_id"] + author_dict = info.context["author"] + author_id = author_dict.get("id") - # Check if the user exists - with local_session() as session: - author = session.query(Author).filter(Author.user == user_id).first() - if author: + if author_id: + # Check if the user exists + with local_session() as session: + author_id = int(author_id) # Check if the invite exists invite = session.query(Invite).filter(Invite.id == invite_id).first() if ( invite - and invite.author_id is author.id + and invite.author_id is author_id and invite.status is InviteStatus.PENDING.value ): # Delete the invite @@ -61,23 +68,21 @@ async def reject_invite(_, info, invite_id: int): return {"success": True, "message": "Invite rejected"} else: return {"error": "Invalid invite or already accepted/rejected"} - else: - return {"error": "User not found"} + return {"error": "User not found"} @mutation.field("create_invite") @login_required async def create_invite(_, info, slug: str = "", author_id: int = 0): user_id = info.context["user_id"] - - # Check if the inviter is the owner of the shout - with local_session() as session: - shout = session.query(Shout).filter(Shout.slug == slug).first() - inviter = session.query(Author).filter(Author.user == user_id).first() - if inviter and shout and shout.authors and inviter.id is shout.created_by: - # Check if the author is a valid author - author = session.query(Author).filter(Author.id == author_id).first() - if author: + author_dict = info.context["author"] + author_id = author_dict.get("id") + if author_id: + # Check if the inviter is the owner of the shout + with local_session() as session: + shout = session.query(Shout).filter(Shout.slug == slug).first() + inviter = session.query(Author).filter(Author.user == user_id).first() + if inviter and shout and shout.authors and inviter.id is shout.created_by: # Check if an invite already exists existing_invite = ( session.query(Invite) @@ -105,8 +110,8 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0): return {"error": None, "invite": new_invite} else: return {"error": "Invalid author"} - else: - return {"error": "Access denied"} + else: + return {"error": "Access denied"} @mutation.field("remove_author") @@ -130,18 +135,20 @@ async def remove_author(_, info, slug: str = "", author_id: int = 0): @mutation.field("remove_invite") @login_required async def remove_invite(_, info, invite_id: int): - user_id = info.context["user_id"] + info.context["user_id"] - # Check if the user exists - with local_session() as session: - author = session.query(Author).filter(Author.user == user_id).first() - if author: + author_dict = info.context["author"] + author_id = author_dict.get("id") + if author_id: + # Check if the user exists + with local_session() as session: + author_id == int(author_id) # Check if the invite exists invite = session.query(Invite).filter(Invite.id == invite_id).first() if isinstance(invite, Invite): shout = session.query(Shout).filter(Shout.id == invite.shout_id).first() if shout and shout.deleted_at is None and invite: - if invite.inviter_id is author.id or author.id is shout.created_by: + if invite.inviter_id is author_id or author_id == shout.created_by: if invite.status is InviteStatus.PENDING.value: # Delete the invite session.delete(invite) @@ -149,5 +156,5 @@ async def remove_invite(_, info, invite_id: int): return {} else: return {"error": "Invalid invite or already accepted/rejected"} - else: - return {"error": "Author not found"} + else: + return {"error": "Author not found"} diff --git a/resolvers/editor.py b/resolvers/editor.py index 2bce9f79..a2eb82f2 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -4,7 +4,6 @@ from sqlalchemy import and_, desc, select from sqlalchemy.orm import joinedload from sqlalchemy.sql.functions import coalesce -from orm.author import Author from orm.rating import is_negative, is_positive from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutTopic @@ -24,6 +23,8 @@ from services.search import search_service async def get_my_shout(_, info, shout_id: int): with local_session() as session: user_id = info.context.get("user_id", "") + author_dict = info.context["author"] + author_id = author_dict.get("id") if not user_id: return {"error": "unauthorized", "shout": None} shout = ( @@ -36,12 +37,11 @@ async def get_my_shout(_, info, shout_id: int): if not shout: return {"error": "no shout found", "shout": None} if not bool(shout.published_at): - author = session.query(Author).filter(Author.user == user_id).first() - if not author: + if not author_id: return {"error": "no author found", "shout": None} roles = info.context.get("roles", []) if "editor" not in roles and not filter( - lambda x: x.id == author.id, [x for x in shout.authors] + lambda x: x.id == int(author_id), [x for x in shout.authors] ): return {"error": "forbidden", "shout": None} return {"error": None, "shout": shout} @@ -50,15 +50,18 @@ async def get_my_shout(_, info, shout_id: int): @query.field("get_shouts_drafts") @login_required async def get_shouts_drafts(_, info): - user_id = info.context.get("user_id") + info.context.get("user_id") + author_dict = info.context["author"] + author_id = author_dict.get("id") shouts = [] with local_session() as session: - author = session.query(Author).filter(Author.user == user_id).first() - if author: + if author_id: q = ( select(Shout) .options(joinedload(Shout.authors), joinedload(Shout.topics)) - .filter(and_(Shout.deleted_at.is_(None), Shout.created_by == author.id)) + .filter( + and_(Shout.deleted_at.is_(None), Shout.created_by == int(author_id)) + ) .filter(Shout.published_at.is_(None)) .order_by(desc(coalesce(Shout.updated_at, Shout.created_at))) .group_by(Shout.id) @@ -71,67 +74,68 @@ async def get_shouts_drafts(_, info): @login_required async def create_shout(_, info, inp): user_id = info.context.get("user_id") - if user_id: + author_dict = info.context["author"] + author_id = author_dict.get("id") + if user_id and author_id: with local_session() as session: - author = session.query(Author).filter(Author.user == user_id).first() - if isinstance(author, Author): - current_time = int(time.time()) - slug = inp.get("slug") or f"draft-{current_time}" - shout_dict = { - "title": inp.get("title", ""), - "subtitle": inp.get("subtitle", ""), - "lead": inp.get("lead", ""), - "description": inp.get("description", ""), - "body": inp.get("body", ""), - "layout": inp.get("layout", "article"), - "created_by": author.id, - "authors": [], - "slug": slug, - "topics": inp.get("topics", []), - "published_at": None, - "created_at": current_time, # Set created_at as Unix timestamp - } + author_id = int(author_id) + current_time = int(time.time()) + slug = inp.get("slug") or f"draft-{current_time}" + shout_dict = { + "title": inp.get("title", ""), + "subtitle": inp.get("subtitle", ""), + "lead": inp.get("lead", ""), + "description": inp.get("description", ""), + "body": inp.get("body", ""), + "layout": inp.get("layout", "article"), + "created_by": author_id, + "authors": [], + "slug": slug, + "topics": inp.get("topics", []), + "published_at": None, + "created_at": current_time, # Set created_at as Unix timestamp + } + same_slug_shout = ( + session.query(Shout) + .filter(Shout.slug == shout_dict.get("slug")) + .first() + ) + c = 1 + while same_slug_shout is not None: same_slug_shout = ( session.query(Shout) .filter(Shout.slug == shout_dict.get("slug")) .first() ) - c = 1 - while same_slug_shout is not None: - same_slug_shout = ( - session.query(Shout) - .filter(Shout.slug == shout_dict.get("slug")) - .first() - ) - c += 1 - shout_dict["slug"] += f"-{c}" - new_shout = Shout(**shout_dict) - session.add(new_shout) + c += 1 + shout_dict["slug"] += f"-{c}" + new_shout = Shout(**shout_dict) + session.add(new_shout) + session.commit() + + # NOTE: requesting new shout back + shout = session.query(Shout).where(Shout.slug == slug).first() + if shout: + sa = ShoutAuthor(shout=shout.id, author=author_id) + session.add(sa) + + topics = ( + session.query(Topic) + .filter(Topic.slug.in_(inp.get("topics", []))) + .all() + ) + for topic in topics: + t = ShoutTopic(topic=topic.id, shout=shout.id) + session.add(t) + session.commit() - # NOTE: requesting new shout back - shout = session.query(Shout).where(Shout.slug == slug).first() - if shout: - sa = ShoutAuthor(shout=shout.id, author=author.id) - session.add(sa) + reactions_follow(author_id, shout.id, True) - topics = ( - session.query(Topic) - .filter(Topic.slug.in_(inp.get("topics", []))) - .all() - ) - for topic in topics: - t = ShoutTopic(topic=topic.id, shout=shout.id) - session.add(t) + # notifier + # await notify_shout(shout_dict, 'create') - session.commit() - - reactions_follow(author.id, shout.id, True) - - # notifier - # await notify_shout(shout_dict, 'create') - - return {"shout": shout} + return {"shout": shout} return {"error": "cant create shout" if user_id else "unauthorized"} @@ -220,6 +224,8 @@ def patch_topics(session, shout, topics_input): async def update_shout(_, info, shout_id: int, shout_input=None, publish=False): user_id = info.context.get("user_id") roles = info.context.get("roles", []) + author_dict = info.context["author"] + author_id = author_dict.get("id") shout_input = shout_input or {} current_time = int(time.time()) shout_id = shout_id or shout_input.get("id", shout_id) @@ -228,9 +234,8 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False): return {"error": "unauthorized"} try: with local_session() as session: - author = session.query(Author).filter(Author.user == user_id).first() - if author: - logger.info(f"author for shout#{shout_id} detected {author.dict()}") + if author_id: + logger.info(f"author for shout#{shout_id} detected author #{author_id}") shout_by_id = session.query(Shout).filter(Shout.id == shout_id).first() if not shout_by_id: return {"error": "shout not found"} @@ -249,7 +254,7 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False): if ( filter( - lambda x: x.id == author.id, [x for x in shout_by_id.authors] + lambda x: x.id == author_id, [x for x in shout_by_id.authors] ) or "editor" in roles ): @@ -297,28 +302,29 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False): @login_required async def delete_shout(_, info, shout_id: int): user_id = info.context.get("user_id") - roles = info.context.get("roles") - if user_id: + roles = info.context.get("roles", []) + author_dict = info.context["author"] + author_id = author_dict.get("id") + if user_id and author_id: + author_id = int(author_id) with local_session() as session: - author = session.query(Author).filter(Author.user == user_id).first() shout = session.query(Shout).filter(Shout.id == shout_id).first() - if not shout: + if not isinstance(shout, Shout): return {"error": "invalid shout id"} - if author and shout: - # NOTE: only owner and editor can mark the shout as deleted - if shout.created_by == author.id or "editor" in roles: - for author_id in shout.authors: - reactions_unfollow(author_id, shout_id) + shout_dict = shout.dict() + # NOTE: only owner and editor can mark the shout as deleted + if shout_dict["created_by"] == author_id or "editor" in roles: + for author_id in shout.authors: + reactions_unfollow(author_id, shout_id) - shout_dict = shout.dict() - shout_dict["deleted_at"] = int(time.time()) - Shout.update(shout, shout_dict) - session.add(shout) - session.commit() - await notify_shout(shout_dict, "delete") - return {"error": None} - else: - return {"error": "access denied"} + shout_dict["deleted_at"] = int(time.time()) + Shout.update(shout, shout_dict) + session.add(shout) + session.commit() + await notify_shout(shout_dict, "delete") + return {"error": None} + else: + return {"error": "access denied"} def handle_proposing(session, r, shout): diff --git a/resolvers/follower.py b/resolvers/follower.py index 19532d89..eb0d4e09 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -11,7 +11,8 @@ from orm.community import Community from orm.reaction import Reaction from orm.shout import Shout, ShoutReactionsFollower from orm.topic import Topic, TopicFollower -from resolvers.stat import author_follows_authors, author_follows_topics, get_with_stat +from resolvers.stat import (author_follows_authors, author_follows_topics, + get_with_stat) from services.auth import login_required from services.cache import DEFAULT_FOLLOWS, cache_follower from services.db import local_session @@ -26,43 +27,42 @@ from services.schema import mutation, query async def follow(_, info, what, slug): error = None user_id = info.context.get("user_id") - if not user_id: + follower_dict = info.context["author"] + follower_id = follower_dict.get("id") + if not user_id or not follower_id: return {"error": "unauthorized"} - follower = local_session().query(Author).filter(Author.user == user_id).first() - if not follower: - return {"error": "cant find follower account"} - entity = what.lower() follows = [] - follows_str = await redis.execute("GET", f"author:{follower.id}:follows-{entity}s") + follows_str = await redis.execute("GET", f"author:{follower_id}:follows-{entity}s") if isinstance(follows_str, str): follows = json.loads(follows_str) - if not follower: - return {"error": "cant find follower"} + if not follows: + return {"error": "cant find following cache"} if what == "AUTHOR": - error = author_follow(follower.id, slug) + follower_id = int(follower_id) + error = author_follow(follower_id, slug) if not error: result = get_with_stat(select(Author).where(Author.slug == slug)) if result: [author] = result if author: - await cache_follower(follower, author) - await notify_follower(follower.dict(), author.id, "follow") + await cache_follower(follower_dict, author.dict()) + await notify_follower(follower_dict, author.id, "follow") if not any(a["id"] == author.id for a in follows): follows.append(author.dict()) elif what == "TOPIC": - error = topic_follow(follower.id, slug) + error = topic_follow(follower_id, slug) elif what == "COMMUNITY": # FIXME: when more communities follows = local_session().execute(select(Community)) elif what == "SHOUT": - error = reactions_follow(follower.id, slug) + error = reactions_follow(follower_id, slug) if error: return {"error": error} @@ -76,38 +76,39 @@ async def unfollow(_, info, what, slug): follows = [] error = None user_id = info.context.get("user_id") + follower_dict = info.context["author"] + follower_id = follower_dict.get("id") if not user_id: return {"error": "unauthorized"} - follower = local_session().query(Author).filter(Author.user == user_id).first() - if not follower: + if not follower_id: return {"error": "cant find follower account"} if what == "AUTHOR": - error = author_unfollow(follower.id, slug) + error = author_unfollow(follower_id, slug) # NOTE: after triggers should update cached stats if not error: - logger.info(f"@{follower.slug} unfollowed @{slug}") + logger.info(f"@{follower_dict.get('slug')} unfollowed @{slug}") author = local_session().query(Author).where(Author.slug == slug).first() if isinstance(author, Author): - await cache_follower(follower, author, False) - await notify_follower(follower.dict(), author.id, "unfollow") + await cache_follower(follower_dict, author.dict(), False) + await notify_follower(follower_dict, author.id, "unfollow") for idx, item in enumerate(follows): if item["id"] == author.id: follows.pop(idx) # Remove the author_dict from the follows list break elif what == "TOPIC": - error = topic_unfollow(follower.id, slug) + error = topic_unfollow(follower_id, slug) elif what == "COMMUNITY": follows = local_session().execute(select(Community)) elif what == "SHOUT": - error = reactions_unfollow(follower.id, slug) + error = reactions_unfollow(follower_id, slug) entity = what.lower() - follows_str = await redis.execute("GET", f"author:{follower.id}:follows-{entity}s") + follows_str = await redis.execute("GET", f"author:{follower_id}:follows-{entity}s") if isinstance(follows_str, str): follows = json.loads(follows_str) return {"error": error, f"{entity}s": follows} diff --git a/resolvers/notifier.py b/resolvers/notifier.py index 70551ba7..18e3fc22 100644 --- a/resolvers/notifier.py +++ b/resolvers/notifier.py @@ -8,12 +8,8 @@ from sqlalchemy.orm import aliased from sqlalchemy.sql import not_ from orm.author import Author -from orm.notification import ( - Notification, - NotificationAction, - NotificationEntity, - NotificationSeen, -) +from orm.notification import (Notification, NotificationAction, + NotificationEntity, NotificationSeen) from orm.shout import Shout from services.auth import login_required from services.db import local_session @@ -213,7 +209,8 @@ def get_notifications_grouped( @query.field("load_notifications") @login_required async def load_notifications(_, info, after: int, limit: int = 50, offset=0): - author_id = info.context.get("author_id") + author_dict = info.context.get("author") + author_id = author_dict.get("id") error = None total = 0 unread = 0 @@ -238,7 +235,7 @@ async def load_notifications(_, info, after: int, limit: int = 50, offset=0): @mutation.field("notification_mark_seen") @login_required async def notification_mark_seen(_, info, notification_id: int): - author_id = info.context.get("author_id") + author_id = info.context.get("author", {}).get("id") if author_id: with local_session() as session: try: @@ -258,7 +255,7 @@ async def notifications_seen_after(_, info, after: int): # TODO: use latest loaded notification_id as input offset parameter error = None try: - author_id = info.context.get("author_id") + author_id = info.context.get("author", {}).get("id") if author_id: with local_session() as session: nnn = ( @@ -283,7 +280,7 @@ async def notifications_seen_after(_, info, after: int): @login_required async def notifications_seen_thread(_, info, thread: str, after: int): error = None - author_id = info.context.get("author_id") + author_id = info.context.get("author", {}).get("id") if author_id: [shout_id, reply_to_id] = thread.split(":") with local_session() as session: diff --git a/resolvers/rating.py b/resolvers/rating.py index cb71d5cc..21cb4daf 100644 --- a/resolvers/rating.py +++ b/resolvers/rating.py @@ -12,17 +12,17 @@ from services.schema import mutation @mutation.field("rate_author") @login_required async def rate_author(_, info, rated_slug, value): - user_id = info.context["user_id"] - + info.context["user_id"] + rater_id = info.context.get("author", {}).get("id") with local_session() as session: + rater_id = int(rater_id) rated_author = session.query(Author).filter(Author.slug == rated_slug).first() - rater = session.query(Author).filter(Author.slug == user_id).first() - if rater and rated_author: + if rater_id and rated_author: rating: AuthorRating = ( session.query(AuthorRating) .filter( and_( - AuthorRating.rater == rater.id, + AuthorRating.rater == rater_id, AuthorRating.author == rated_author.id, ) ) @@ -36,7 +36,7 @@ async def rate_author(_, info, rated_slug, value): else: try: rating = AuthorRating( - rater=rater.id, author=rated_author.id, plus=value > 0 + rater=rater_id, author=rated_author.id, plus=value > 0 ) session.add(rating) session.commit() diff --git a/resolvers/reaction.py b/resolvers/reaction.py index d4ae75d3..31dd51b7 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -1,17 +1,18 @@ import time from typing import List -from resolvers.stat import update_author_stat from sqlalchemy import and_, asc, case, desc, func, select, text from sqlalchemy.orm import aliased, joinedload from sqlalchemy.sql import union from orm.author import Author -from orm.rating import PROPOSAL_REACTIONS, RATING_REACTIONS, is_negative, is_positive +from orm.rating import (PROPOSAL_REACTIONS, RATING_REACTIONS, is_negative, + is_positive) from orm.reaction import Reaction, ReactionKind from orm.shout import Shout from resolvers.editor import handle_proposing from resolvers.follower import reactions_follow +from resolvers.stat import update_author_stat from services.auth import add_user_role, login_required from services.db import local_session from services.logger import root_logger as logger @@ -116,7 +117,7 @@ def set_unfeatured(session, shout_id): session.commit() -async def _create_reaction(session, shout, author, reaction): +async def _create_reaction(session, shout, author_id: int, reaction): r = Reaction(**reaction) session.add(r) session.commit() @@ -124,38 +125,38 @@ async def _create_reaction(session, shout, author, reaction): # пересчет счетчика комментариев if str(r.kind) == ReactionKind.COMMENT.value: - await update_author_stat(author) + await update_author_stat(author_id) # collaborative editing if ( rdict.get("reply_to") and r.kind in PROPOSAL_REACTIONS - and author.id in shout.authors + and author_id in shout.authors ): handle_proposing(session, r, shout) # рейтинг и саморегуляция if r.kind in RATING_REACTIONS: # self-regultaion mechanics - if check_to_unfeature(session, author.id, r): + if check_to_unfeature(session, author_id, r): set_unfeatured(session, shout.id) - elif check_to_feature(session, author.id, r): + elif check_to_feature(session, author_id, r): await set_featured(session, shout.id) # follow if liked if r.kind == ReactionKind.LIKE.value: try: # reactions auto-following - reactions_follow(author.id, reaction["shout"], True) + reactions_follow(author_id, reaction["shout"], True) except Exception: pass # обновление счетчика комментариев в кеше if str(r.kind) == ReactionKind.COMMENT.value: - await update_author_stat(author) + await update_author_stat(author_id) rdict["shout"] = shout.dict() - rdict["created_by"] = author.id + rdict["created_by"] = author_id rdict["stat"] = {"commented": 0, "reacted": 0, "rating": 0} # notifications call @@ -164,7 +165,7 @@ async def _create_reaction(session, shout, author, reaction): return rdict -def prepare_new_rating(reaction: dict, shout_id: int, session, author: Author): +def prepare_new_rating(reaction: dict, shout_id: int, session, author_id: int): kind = reaction.get("kind") opposite_kind = ( ReactionKind.DISLIKE.value if is_positive(kind) else ReactionKind.LIKE.value @@ -173,7 +174,7 @@ def prepare_new_rating(reaction: dict, shout_id: int, session, author: Author): q = select(Reaction).filter( and_( Reaction.shout == shout_id, - Reaction.created_by == author.id, + Reaction.created_by == author_id, Reaction.kind.in_(RATING_REACTIONS), ) ) @@ -182,18 +183,18 @@ def prepare_new_rating(reaction: dict, shout_id: int, session, author: Author): q = q.filter(Reaction.reply_to == reply_to) rating_reactions = session.execute(q).all() same_rating = filter( - lambda r: r.created_by == author.id and r.kind == opposite_kind, + lambda r: r.created_by == author_id and r.kind == opposite_kind, rating_reactions, ) opposite_rating = filter( - lambda r: r.created_by == author.id and r.kind == opposite_kind, + lambda r: r.created_by == author_id and r.kind == opposite_kind, rating_reactions, ) if same_rating: return {"error": "You can't rate the same thing twice"} elif opposite_rating: return {"error": "Remove opposite vote first"} - elif filter(lambda r: r.created_by == author.id, rating_reactions): + elif filter(lambda r: r.created_by == author_id, rating_reactions): return {"error": "You can't rate your own thing"} return @@ -202,7 +203,8 @@ def prepare_new_rating(reaction: dict, shout_id: int, session, author: Author): @login_required async def create_reaction(_, info, reaction): logger.debug(f"{info.context} for {reaction}") - user_id = info.context.get("user_id") + info.context.get("user_id") + author_id = info.context.get("author", {}).get("id") shout_id = reaction.get("shout") if not shout_id: @@ -211,9 +213,8 @@ async def create_reaction(_, info, reaction): try: with local_session() as session: shout = session.query(Shout).filter(Shout.id == shout_id).first() - author = session.query(Author).filter(Author.user == user_id).first() - if shout and author: - reaction["created_by"] = author.id + if shout and author_id: + reaction["created_by"] = int(author_id) kind = reaction.get("kind") if not kind and isinstance(reaction.get("body"), str): @@ -224,12 +225,12 @@ async def create_reaction(_, info, reaction): if kind in RATING_REACTIONS: error_result = prepare_new_rating( - reaction, shout_id, session, author + reaction, shout_id, session, author_id ) if error_result: return error_result - rdict = await _create_reaction(session, shout, author, reaction) + rdict = await _create_reaction(session, shout, author_id, reaction) # TODO: call recount ratings periodically @@ -315,13 +316,14 @@ async def update_reaction(_, info, reaction): async def delete_reaction(_, info, reaction_id: int): logger.debug(f"{info.context} for {reaction_id}") user_id = info.context.get("user_id") + author_id = info.context.get("author", {}).get("id") roles = info.context.get("roles", []) if user_id: with local_session() as session: try: author = session.query(Author).filter(Author.user == user_id).one() r = session.query(Reaction).filter(Reaction.id == reaction_id).one() - if r.created_by != author.id and "editor" not in roles: + if r.created_by != author_id and "editor" not in roles: return {"error": "access denied"} logger.debug(f"{user_id} user removing his #{reaction_id} reaction") diff --git a/resolvers/reader.py b/resolvers/reader.py index 61e5aa08..8090cd15 100644 --- a/resolvers/reader.py +++ b/resolvers/reader.py @@ -1,6 +1,7 @@ from sqlalchemy import bindparam, distinct, or_, text from sqlalchemy.orm import aliased, joinedload -from sqlalchemy.sql.expression import and_, asc, case, desc, func, nulls_last, select +from sqlalchemy.sql.expression import (and_, asc, case, desc, func, nulls_last, + select) from orm.author import Author, AuthorFollower from orm.reaction import Reaction, ReactionKind @@ -25,31 +26,26 @@ def query_shouts(): def filter_my(info, session, q): - reader_id = None - user_id = None - if isinstance(info.context, dict): - user_id = info.context.get("user_id") - if user_id: - reader = session.query(Author).filter(Author.user == user_id).first() - if reader: - reader_followed_authors = select(AuthorFollower.author).where( - AuthorFollower.follower == reader.id - ) - reader_followed_topics = select(TopicFollower.topic).where( - TopicFollower.follower == reader.id - ) + user_id = info.context.get("user_id") + reader_id = info.context.get("author", {}).get("id") + if user_id and reader_id: + reader_followed_authors = select(AuthorFollower.author).where( + AuthorFollower.follower == reader_id + ) + reader_followed_topics = select(TopicFollower.topic).where( + TopicFollower.follower == reader_id + ) - subquery = ( - select(Shout.id) - .where(Shout.id == ShoutAuthor.shout) - .where(Shout.id == ShoutTopic.shout) - .where( - (ShoutAuthor.author.in_(reader_followed_authors)) - | (ShoutTopic.topic.in_(reader_followed_topics)) - ) + subquery = ( + select(Shout.id) + .where(Shout.id == ShoutAuthor.shout) + .where(Shout.id == ShoutTopic.shout) + .where( + (ShoutAuthor.author.in_(reader_followed_authors)) + | (ShoutTopic.topic.in_(reader_followed_topics)) ) - q = q.filter(Shout.id.in_(subquery)) - reader_id = reader.id + ) + q = q.filter(Shout.id.in_(subquery)) return q, reader_id diff --git a/resolvers/stat.py b/resolvers/stat.py index ac58e205..8d2a2b1a 100644 --- a/resolvers/stat.py +++ b/resolvers/stat.py @@ -5,8 +5,8 @@ from orm.author import Author, AuthorFollower from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic, TopicFollower -from services.db import local_session from services.cache import cache_author +from services.db import local_session from services.logger import root_logger as logger @@ -167,8 +167,8 @@ def author_follows_topics(author_id: int): return get_with_stat(q) -async def update_author_stat(author: Author): - author_with_stat = get_with_stat(select(Author).where(Author.id == author.id)) +async def update_author_stat(author_id: int): + author_with_stat = get_with_stat(select(Author).where(Author.id == author_id)) if isinstance(author_with_stat, Author): author_dict = author_with_stat.dict() await cache_author(author_dict) diff --git a/services/auth.py b/services/auth.py index 5cb4310d..5eaf3977 100644 --- a/services/auth.py +++ b/services/auth.py @@ -1,12 +1,24 @@ +import json from functools import wraps import httpx -from starlette.exceptions import HTTPException from services.logger import root_logger as logger +from services.rediscache import redis from settings import ADMIN_SECRET, AUTH_URL +async def get_author_by_user(user: str): + author = None + redis_key = f"user:{user}" + + result = await redis.execute("GET", redis_key) + if isinstance(result, str): + author = json.loads(result) + + return author + + async def request_data(gql, headers=None): if headers is None: headers = {"Content-Type": "application/json"} @@ -78,32 +90,13 @@ def login_required(f): async def decorated_function(*args, **kwargs): info = args[1] req = info.context.get("request") - authorized = await check_auth(req) - if authorized: - logger.info(authorized) - user_id, user_roles = authorized - if user_id and user_roles: - logger.info(f" got {user_id} roles: {user_roles}") - info.context["user_id"] = user_id.strip() - info.context["roles"] = user_roles + user_id, user_roles = await check_auth(req) + if user_id and user_roles: + logger.info(f" got {user_id} roles: {user_roles}") + info.context["user_id"] = user_id.strip() + info.context["roles"] = user_roles + author = await get_author_by_user(user_id) + info.context["author"] = author return await f(*args, **kwargs) return decorated_function - - -def auth_request(f): - @wraps(f) - async def decorated_function(*args, **kwargs): - req = args[0] - authorized = await check_auth(req) - if authorized: - user_id, user_roles = authorized - if user_id and user_roles: - logger.info(f" got {user_id} roles: {user_roles}") - req["user_id"] = user_id.strip() - req["roles"] = user_roles - return await f(*args, **kwargs) - else: - raise HTTPException(status_code=401, detail="Unauthorized") - - return decorated_function diff --git a/services/cache.py b/services/cache.py index 354c2c89..12646932 100644 --- a/services/cache.py +++ b/services/cache.py @@ -1,8 +1,5 @@ import json - -from orm.author import Author -from orm.topic import Topic from services.encoders import CustomJSONEncoder from services.rediscache import redis @@ -67,55 +64,57 @@ async def cache_author(author: dict): followed_author_followers.append(author) -async def cache_follows(follower: Author, entity_type: str, entity, is_insert=True): +async def cache_follows(follower: dict, entity_type: str, entity: dict, is_insert=True): # prepare follows = [] - redis_key = f"author:{follower.id}:follows-{entity_type}s" - follows_str = await redis.execute("GET", redis_key) - if isinstance(follows_str, str): - follows = json.loads(follows_str) - if is_insert: - follows.append(entity) - else: - entity_id = entity.get("id") - if not entity_id: - raise Exception("wrong entity") - # Remove the entity from follows - follows = [e for e in follows if e["id"] != entity_id] + follower_id = follower.get("id") + if follower_id: + redis_key = f"author:{follower_id}:follows-{entity_type}s" + follows_str = await redis.execute("GET", redis_key) + if isinstance(follows_str, str): + follows = json.loads(follows_str) + if is_insert: + follows.append(entity) + else: + entity_id = entity.get("id") + if not entity_id: + raise Exception("wrong entity") + # Remove the entity from follows + follows = [e for e in follows if e["id"] != entity_id] - # update follows cache - updated_data = [t.dict() if isinstance(t, Topic) else t for t in follows] - payload = json.dumps(updated_data, cls=CustomJSONEncoder) - await redis.execute("SET", redis_key, payload) + # update follows cache + payload = json.dumps(follows, cls=CustomJSONEncoder) + await redis.execute("SET", redis_key, payload) - # update follower's stats everywhere - author_str = await redis.execute("GET", f"author:{follower.id}") - if isinstance(author_str, str): - author = json.loads(author_str) - author["stat"][f"{entity_type}s"] = len(updated_data) - await cache_author(author) + # update follower's stats everywhere + author_str = await redis.execute("GET", f"author:{follower_id}") + if isinstance(author_str, str): + author = json.loads(author_str) + author["stat"][f"{entity_type}s"] = len(follows) + await cache_author(author) return follows -async def cache_follower(follower: Author, author: Author, is_insert=True): - redis_key = f"author:{author.id}:followers" - followers_str = await redis.execute("GET", redis_key) +async def cache_follower(follower: dict, author: dict, is_insert=True): + author_id = author.get("id") + follower_id = follower.get("id") followers = [] - if isinstance(followers_str, str): - followers = json.loads(followers_str) - if is_insert: - # Remove the entity from followers - followers = [e for e in followers if e["id"] != author.id] - else: - followers.append(follower) - updated_followers = [ - f.dict() if isinstance(f, Author) else f for f in followers - ] - payload = json.dumps(updated_followers, cls=CustomJSONEncoder) - await redis.execute("SET", redis_key, payload) - author_str = await redis.execute("GET", f"author:{follower.id}") - if isinstance(author_str, str): - author = json.loads(author_str) - author["stat"]["followers"] = len(updated_followers) - await cache_author(author) + if author_id and follower_id: + redis_key = f"author:{author_id}:followers" + followers_str = await redis.execute("GET", redis_key) + followers = [] + if isinstance(followers_str, str): + followers = json.loads(followers_str) + if is_insert: + # Remove the entity from followers + followers = [e for e in followers if e["id"] != author_id] + else: + followers.append(follower) + payload = json.dumps(followers, cls=CustomJSONEncoder) + await redis.execute("SET", redis_key, payload) + author_str = await redis.execute("GET", f"author:{follower_id}") + if isinstance(author_str, str): + author = json.loads(author_str) + author["stat"]["followers"] = len(followers) + await cache_author(author) return followers diff --git a/services/db.py b/services/db.py index 7030f3bb..048333b7 100644 --- a/services/db.py +++ b/services/db.py @@ -5,7 +5,8 @@ import traceback import warnings from typing import Any, Callable, Dict, TypeVar -from sqlalchemy import JSON, Column, Engine, Integer, create_engine, event, exc, inspect +from sqlalchemy import (JSON, Column, Engine, Integer, create_engine, event, + exc, inspect) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import Session, configure_mappers from sqlalchemy.sql.schema import Table diff --git a/services/notify.py b/services/notify.py index b025faa4..f5cf28e0 100644 --- a/services/notify.py +++ b/services/notify.py @@ -2,8 +2,8 @@ import json from orm.notification import Notification from services.db import local_session -from services.rediscache import redis from services.logger import root_logger as logger +from services.rediscache import redis def save_notification(action: str, entity: str, payload): diff --git a/services/triggers.py b/services/triggers.py index 52044d85..6489fd2c 100644 --- a/services/triggers.py +++ b/services/triggers.py @@ -6,12 +6,12 @@ from sqlalchemy import event, select from orm.author import Author, AuthorFollower from orm.reaction import Reaction from orm.shout import Shout, ShoutAuthor -from orm.topic import TopicFollower, Topic +from orm.topic import Topic, TopicFollower from resolvers.stat import get_with_stat +from services.cache import cache_author, cache_follower, cache_follows from services.encoders import CustomJSONEncoder -from services.rediscache import redis from services.logger import root_logger as logger -from services.cache import cache_author, cache_follows, cache_follower +from services.rediscache import redis DEFAULT_FOLLOWS = { "topics": [], @@ -31,8 +31,8 @@ async def handle_author_follower_change( if follower and author: await cache_author(author.dict()) await cache_author(follower.dict()) - await cache_follows(follower, "author", author.dict(), is_insert) - await cache_follower(follower, author, is_insert) + await cache_follows(follower.dict(), "author", author.dict(), is_insert) + await cache_follower(follower.dict(), author.dict(), is_insert) async def handle_topic_follower_change( @@ -48,7 +48,7 @@ async def handle_topic_follower_change( await redis.execute( "SET", f"topic:{topic.id}", json.dumps(topic.dict(), cls=CustomJSONEncoder) ) - await cache_follows(follower, "topic", topic.dict(), is_insert) + await cache_follows(follower.dict(), "topic", topic.dict(), is_insert) # handle_author_follow and handle_topic_follow -> cache_author, cache_follows, cache_followers diff --git a/services/viewed.py b/services/viewed.py index abf0f22e..c6abfe0f 100644 --- a/services/viewed.py +++ b/services/viewed.py @@ -7,12 +7,8 @@ from typing import Dict # ga from google.analytics.data_v1beta import BetaAnalyticsDataClient -from google.analytics.data_v1beta.types import ( - DateRange, - Dimension, - Metric, - RunReportRequest, -) +from google.analytics.data_v1beta.types import (DateRange, Dimension, Metric, + RunReportRequest) from orm.author import Author from orm.shout import Shout, ShoutAuthor, ShoutTopic