From a63cf24812b25fe4ed86db92bf1b79e4039d7c21 Mon Sep 17 00:00:00 2001 From: Untone Date: Fri, 24 Nov 2023 02:00:28 +0300 Subject: [PATCH] 0.2.15 --- CHANGELOG.txt | 9 ++++- pyproject.toml | 2 +- resolvers/__init__.py | 11 ++--- resolvers/author.py | 11 ++--- resolvers/editor.py | 50 +++++++++++++---------- resolvers/follower.py | 94 +++++++++++++++++++++++-------------------- resolvers/reaction.py | 79 ++++++++++++++++++++---------------- resolvers/reader.py | 33 +++++++++++---- resolvers/topic.py | 5 ++- schemas/core.graphql | 2 + services/auth.py | 10 ++--- services/search.py | 20 +++++---- 12 files changed, 187 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 1f9374ca..5c02d565 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,11 @@ +[0.2.15] +- schema: Shout.created_by removed +- schema: Shout.mainTopic removed +- services: cached elasticsearch connector +- services: auth is using user_id from authorizer +- resolvers: notify_* usage fixes +- resolvers: login_required usage fixes + [0.2.14] - schema: some fixes from migrator - schema: .days -> .time_ago @@ -5,7 +13,6 @@ - services: db access simpler, no contextmanager - services: removed Base.create() method - services: rediscache updated -- resolvers: many minor fixes - resolvers: get_reacted_shouts_updates as followedReactions query [0.2.13] diff --git a/pyproject.toml b/pyproject.toml index 9b5f4b4a..db97a9e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "discoursio-core" -version = "0.2.14" +version = "0.2.15" description = "core module for discours.io" authors = ["discoursio devteam"] license = "MIT" diff --git a/resolvers/__init__.py b/resolvers/__init__.py index b916d3d6..32da89a4 100644 --- a/resolvers/__init__.py +++ b/resolvers/__init__.py @@ -1,11 +1,6 @@ from resolvers.editor import create_shout, delete_shout, update_shout -from resolvers.author import ( - load_authors_by, - update_profile, - get_authors_all, - rate_author -) +from resolvers.author import load_authors_by, update_profile, get_authors_all, rate_author from resolvers.reaction import ( create_reaction, @@ -25,7 +20,7 @@ from resolvers.topic import ( ) from resolvers.follower import follow, unfollow -from resolvers.reader import load_shout, load_shouts_by +from resolvers.reader import load_shout, load_shouts_by, search from resolvers.community import get_community, get_communities_all __all__ = [ @@ -62,4 +57,6 @@ __all__ = [ # community "get_community", "get_communities_all", + # search + "search", ] diff --git a/resolvers/author.py b/resolvers/author.py index a8de242f..5df75e46 100644 --- a/resolvers/author.py +++ b/resolvers/author.py @@ -90,9 +90,9 @@ async def author_followings(author_id: int): @mutation.field("updateProfile") @login_required async def update_profile(_, info, profile): - author_id = info.context["author_id"] + user_id = info.context["user_id"] with local_session() as session: - author = session.query(Author).where(Author.id == author_id).first() + author = session.query(Author).where(Author.user == user_id).first() Author.update(author, profile) session.add(author) session.commit() @@ -206,12 +206,13 @@ async def followed_authors(follower_id): @mutation.field("rateAuthor") @login_required async def rate_author(_, info, rated_user_id, value): - author_id = info.context["author_id"] + user_id = info.context["user_id"] with local_session() as session: + rater = session.query(Author).filter(Author.user == user_id).first() rating = ( session.query(AuthorRating) - .filter(and_(AuthorRating.rater == author_id, AuthorRating.user == rated_user_id)) + .filter(and_(AuthorRating.rater == rater.id, AuthorRating.user == rated_user_id)) .first() ) if rating: @@ -221,7 +222,7 @@ async def rate_author(_, info, rated_user_id, value): return {} else: try: - rating = AuthorRating(rater=author_id, user=rated_user_id, value=value) + rating = AuthorRating(rater=rater.id, user=rated_user_id, value=value) session.add(rating) session.commit() except Exception as err: diff --git a/resolvers/editor.py b/resolvers/editor.py index 18860474..8a251ad0 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -1,6 +1,8 @@ import time # For Unix timestamps from sqlalchemy import and_, select from sqlalchemy.orm import joinedload + +from orm.author import Author from services.auth import login_required from services.db import local_session from services.schema import mutation, query @@ -11,19 +13,21 @@ from services.notify import notify_shout @query.field("loadDrafts") +@login_required async def get_drafts(_, info): - author = info.context["request"].author - q = ( - select(Shout) - .options( - joinedload(Shout.authors), - joinedload(Shout.topics), - ) - .where(and_(Shout.deleted_at.is_(None), Shout.created_by == author.id)) - ) - q = q.group_by(Shout.id) - shouts = [] + user_id = info.context["user_id"] with local_session() as session: + author = session.query(Author).filter(Author.user == user_id).first() + q = ( + select(Shout) + .options( + joinedload(Shout.authors), + joinedload(Shout.topics), + ) + .where(and_(Shout.deleted_at.is_(None), Shout.created_by == author.id)) + ) + q = q.group_by(Shout.id) + shouts = [] for [shout] in session.execute(q).unique(): shouts.append(shout) return shouts @@ -32,10 +36,13 @@ async def get_drafts(_, info): @mutation.field("createShout") @login_required async def create_shout(_, info, inp): - author_id = info.context["author_id"] + user_id = info.context["user_id"] with local_session() as session: + author = session.query(Author).filter(Author.user == user_id).first() topics = session.query(Topic).filter(Topic.slug.in_(inp.get("topics", []))).all() - # Replace datetime with Unix timestamp + authors = inp.get("authors", []) + if author.id not in authors: + authors.insert(0, author.id) current_time = int(time.time()) new_shout = Shout( **{ @@ -45,11 +52,10 @@ async def create_shout(_, info, inp): "description": inp.get("description"), "body": inp.get("body", ""), "layout": inp.get("layout"), - "authors": inp.get("authors", []), + "authors": authors, "slug": inp.get("slug"), "topics": inp.get("topics"), "visibility": ShoutVisibility.AUTHORS, - "created_id": author_id, "created_at": current_time, # Set created_at as Unix timestamp } ) @@ -57,10 +63,10 @@ async def create_shout(_, info, inp): t = ShoutTopic(topic=topic.id, shout=new_shout.id) session.add(t) # NOTE: shout made by one first author - sa = ShoutAuthor(shout=new_shout.id, author=author_id) + sa = ShoutAuthor(shout=new_shout.id, author=author.id) session.add(sa) session.add(new_shout) - reactions_follow(author_id, new_shout.id, True) + reactions_follow(author.id, new_shout.id, True) session.commit() if new_shout.slug is None: @@ -74,8 +80,9 @@ async def create_shout(_, info, inp): @mutation.field("updateShout") @login_required async def update_shout(_, info, shout_id, shout_input=None, publish=False): - author_id = info.context["author_id"] + user_id = info.context["user_id"] with local_session() as session: + author = session.query(Author).filter(Author.user == user_id).first() shout = ( session.query(Shout) .options( @@ -87,7 +94,7 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False): ) if not shout: return {"error": "shout not found"} - if shout.created_by != author_id: + if shout.created_by != author.id: return {"error": "access denied"} updated = False if shout_input is not None: @@ -154,12 +161,13 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False): @mutation.field("deleteShout") @login_required async def delete_shout(_, info, shout_id): - author_id = info.context["author_id"] + user_id = info.context["user_id"] with local_session() as session: + author = session.query(Author).filter(Author.id == user_id).first() shout = session.query(Shout).filter(Shout.id == shout_id).first() if not shout: return {"error": "invalid shout id"} - if author_id != shout.created_by: + if author.id not in shout.authors: return {"error": "access denied"} for author_id in shout.authors: reactions_unfollow(author_id, shout_id) diff --git a/resolvers/follower.py b/resolvers/follower.py index d565b148..03bcab9d 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -7,32 +7,37 @@ from services.following import FollowingManager, FollowingResult from services.db import local_session from orm.author import Author from services.notify import notify_follower +from services.schema import mutation @login_required +@mutation.field("follow") async def follow(_, info, what, slug): - follower_id = info.context["author_id"] + user_id = info.context["user_id"] try: - if what == "AUTHOR": - if author_follow(follower_id, slug): - result = FollowingResult("NEW", 'author', slug) - await FollowingManager.push('author', result) - with local_session() as session: - author = session.query(Author.id).where(Author.slug == slug).one() - follower = session.query(Author).where(Author.id == follower_id).one() - notify_follower(follower.dict(), author.id) - elif what == "TOPIC": - if topic_follow(follower_id, slug): - result = FollowingResult("NEW", 'topic', slug) - await FollowingManager.push('topic', result) - elif what == "COMMUNITY": - if community_follow(follower_id, slug): - result = FollowingResult("NEW", 'community', slug) - await FollowingManager.push('community', result) - elif what == "REACTIONS": - if reactions_follow(follower_id, slug): - result = FollowingResult("NEW", 'shout', slug) - await FollowingManager.push('shout', result) + with local_session() as session: + actor = session.query(Author).filter(Author.user == user_id).first() + if actor: + follower_id = actor.id + if what == "AUTHOR": + if author_follow(follower_id, slug): + result = FollowingResult("NEW", "author", slug) + await FollowingManager.push("author", result) + author = session.query(Author.id).where(Author.slug == slug).one() + follower = session.query(Author).where(Author.id == follower_id).one() + await notify_follower(follower.dict(), author.id) + elif what == "TOPIC": + if topic_follow(follower_id, slug): + result = FollowingResult("NEW", "topic", slug) + await FollowingManager.push("topic", result) + elif what == "COMMUNITY": + if community_follow(follower_id, slug): + result = FollowingResult("NEW", "community", slug) + await FollowingManager.push("community", result) + elif what == "REACTIONS": + if reactions_follow(follower_id, slug): + result = FollowingResult("NEW", "shout", slug) + await FollowingManager.push("shout", result) except Exception as e: print(Exception(e)) return {"error": str(e)} @@ -41,30 +46,33 @@ async def follow(_, info, what, slug): @login_required +@mutation.field("unfollow") async def unfollow(_, info, what, slug): - follower_id = info.context["author_id"] + user_id = info.context["user_id"] try: - if what == "AUTHOR": - if author_unfollow(follower_id, slug): - result = FollowingResult("DELETED", 'author', slug) - await FollowingManager.push('author', result) - - with local_session() as session: - author = session.query(Author.id).where(Author.slug == slug).one() - follower = session.query(Author).where(Author.id == follower_id).one() - notify_follower(follower.dict(), author.id, "unfollow") - elif what == "TOPIC": - if topic_unfollow(follower_id, slug): - result = FollowingResult("DELETED", 'topic', slug) - await FollowingManager.push('topic', result) - elif what == "COMMUNITY": - if community_unfollow(follower_id, slug): - result = FollowingResult("DELETED", 'community', slug) - await FollowingManager.push('community', result) - elif what == "REACTIONS": - if reactions_unfollow(follower_id, slug): - result = FollowingResult("DELETED", 'shout', slug) - await FollowingManager.push('shout', result) + with local_session() as session: + actor = session.query(Author).filter(Author.user == user_id).first() + if actor: + follower_id = actor.id + if what == "AUTHOR": + if author_unfollow(follower_id, slug): + result = FollowingResult("DELETED", "author", slug) + await FollowingManager.push("author", result) + author = session.query(Author.id).where(Author.slug == slug).one() + follower = session.query(Author).where(Author.id == follower_id).one() + await notify_follower(follower.dict(), author.id, "unfollow") + elif what == "TOPIC": + if topic_unfollow(follower_id, slug): + result = FollowingResult("DELETED", "topic", slug) + await FollowingManager.push("topic", result) + elif what == "COMMUNITY": + if community_unfollow(follower_id, slug): + result = FollowingResult("DELETED", "community", slug) + await FollowingManager.push("community", result) + elif what == "REACTIONS": + if reactions_unfollow(follower_id, slug): + result = FollowingResult("DELETED", "shout", slug) + await FollowingManager.push("shout", result) except Exception as e: return {"error": str(e)} diff --git a/resolvers/reaction.py b/resolvers/reaction.py index a157f0c9..edacba38 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -159,18 +159,18 @@ def set_hidden(session, shout_id): @mutation.field("createReaction") @login_required async def create_reaction(_, info, reaction): - author_id = info.context["author_id"] + user_id = info.context["user_id"] with local_session() as session: - reaction["created_by"] = author_id shout = session.query(Shout).where(Shout.id == reaction["shout"]).one() - + author = session.query(Author).where(Author.user == user_id).first() + reaction["created_by"] = author.id if reaction["kind"] in [ReactionKind.DISLIKE.name, ReactionKind.LIKE.name]: existing_reaction = ( session.query(Reaction) .where( and_( Reaction.shout == reaction["shout"], - Reaction.created_by == author_id, + Reaction.created_by == author.id, Reaction.kind == reaction["kind"], Reaction.reply_to == reaction.get("reply_to"), ) @@ -189,7 +189,7 @@ async def create_reaction(_, info, reaction): .where( and_( Reaction.shout == reaction["shout"], - Reaction.created_by == author_id, + Reaction.created_by == author.id, Reaction.kind == opposite_reaction_kind, Reaction.reply_to == reaction.get("reply_to"), ) @@ -203,7 +203,7 @@ async def create_reaction(_, info, reaction): r = Reaction(**reaction) # Proposal accepting logix - if r.reply_to is not None and r.kind == ReactionKind.ACCEPT and author_id in shout.dict()["authors"]: + if r.reply_to is not None and r.kind == ReactionKind.ACCEPT and author.id in shout.dict()["authors"]: replied_reaction = session.query(Reaction).where(Reaction.id == r.reply_to).first() if replied_reaction and replied_reaction.kind == ReactionKind.PROPOSE: if replied_reaction.range: @@ -218,18 +218,17 @@ async def create_reaction(_, info, reaction): session.commit() rdict = r.dict() rdict["shout"] = shout.dict() - author = session.query(Author).where(Author.id == author_id).first() rdict["created_by"] = author.dict() # self-regulation mechanics if check_to_hide(session, r): set_hidden(session, r.shout) - elif check_to_publish(session, author_id, r): + elif check_to_publish(session, author.id, r): set_published(session, r.shout) try: - reactions_follow(author_id, reaction["shout"], True) + reactions_follow(author.id, reaction["shout"], True) except Exception as e: print(f"[resolvers.reactions] error on reactions auto following: {e}") @@ -244,7 +243,7 @@ async def create_reaction(_, info, reaction): @mutation.field("updateReaction") @login_required async def update_reaction(_, info, rid, reaction): - author_id = info.context["author_id"] + user_id = info.context["user_id"] with local_session() as session: q = select(Reaction).filter(Reaction.id == rid) q = add_reaction_stat_columns(q) @@ -254,41 +253,46 @@ async def update_reaction(_, info, rid, reaction): if not r: return {"error": "invalid reaction id"} - if r.created_by != author_id: - return {"error": "access denied"} - body = reaction.get("body") - if body: - r.body = body - r.updated_at = int(time.time()) - if r.kind != reaction["kind"]: - # NOTE: change mind detection can be here - pass + author = session.query(Author).filter(Author.user == user_id).first() + if author: + if r.created_by != author.id: + return {"error": "access denied"} + body = reaction.get("body") + if body: + r.body = body + r.updated_at = int(time.time()) + if r.kind != reaction["kind"]: + # NOTE: change mind detection can be here + pass - # FIXME: range is not stable after body editing - if reaction.get("range"): - r.range = reaction.get("range") + # FIXME: range is not stable after body editing + if reaction.get("range"): + r.range = reaction.get("range") - session.commit() - r.stat = { - "commented": commented_stat, - "reacted": reacted_stat, - "rating": rating_stat, - } + session.commit() + r.stat = { + "commented": commented_stat, + "reacted": reacted_stat, + "rating": rating_stat, + } - await notify_reaction(r.dict(), "update") + await notify_reaction(r.dict(), "update") - return {"reaction": r} + return {"reaction": r} + else: + return {"error": "user"} @mutation.field("deleteReaction") @login_required async def delete_reaction(_, info, rid): - author_id = info.context["author_id"] + user_id = info.context["user_id"] with local_session() as session: r = session.query(Reaction).filter(Reaction.id == rid).first() if not r: return {"error": "invalid reaction id"} - if r.created_by != author_id: + author = session.query(Author).filter(Author.user == user_id).first() + if not author or r.created_by != author.id: return {"error": "access denied"} if r.kind in [ReactionKind.LIKE, ReactionKind.DISLIKE]: @@ -400,6 +404,11 @@ def reacted_shouts_updates(follower_id): @login_required @query.field("followedReactions") async def get_reacted_shouts(_, info) -> List[Shout]: - author_id = info.context["author_id"] - shouts = reacted_shouts_updates(author_id) - return shouts + user_id = info.context["user_id"] + with local_session() as session: + author = session.query(Author).filter(Author.user == user_id).first() + if author: + shouts = reacted_shouts_updates(author.id) + return shouts + else: + return [] diff --git a/resolvers/reader.py b/resolvers/reader.py index a5c0ce2a..1000dc2e 100644 --- a/resolvers/reader.py +++ b/resolvers/reader.py @@ -4,10 +4,12 @@ from sqlalchemy.sql.expression import desc, asc, select, func, case, and_, nulls from services.auth import login_required from services.db import local_session +from services.schema import query from orm.topic import TopicFollower from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutTopic -from orm.author import AuthorFollower +from orm.author import AuthorFollower, Author +from services.search import SearchService from services.viewed import ViewedStorage @@ -69,6 +71,7 @@ def apply_filters(q, filters, author_id=None): return q +@query.field("loadShout") async def load_shout(_, _info, slug=None, shout_id=None): with local_session() as session: q = select(Shout).options( @@ -111,6 +114,7 @@ async def load_shout(_, _info, slug=None, shout_id=None): return None +@query.field("loadShoutsBy") async def load_shouts_by(_, info, options): """ :param _: @@ -145,8 +149,13 @@ async def load_shouts_by(_, info, options): q = add_stat_columns(q) - author_id = info.context["author_id"] - q = apply_filters(q, options.get("filters", {}), author_id) + user_id = info.context["user_id"] + filters = options.get("filters") + if filters: + with local_session() as session: + author = session.query(Author).filter(Author.user == user_id).first() + if author: + q = apply_filters(q, filters, author.id) order_by = options.get("order_by", Shout.published_at) @@ -180,11 +189,13 @@ async def load_shouts_by(_, info, options): @login_required +@query.field("loadFeed") async def get_my_feed(_, info, options): - author_id = info.context["author_id"] + user_id = info.context["user_id"] with local_session() as session: - author_followed_authors = select(AuthorFollower.author).where(AuthorFollower.follower == author_id) - author_followed_topics = select(TopicFollower.topic).where(TopicFollower.follower == author_id) + author = session.query(Author).filter(Author.user == user_id).first() + author_followed_authors = select(AuthorFollower.author).where(AuthorFollower.follower == author.id) + author_followed_topics = select(TopicFollower.topic).where(TopicFollower.follower == author.id) subquery = ( select(Shout.id) @@ -209,7 +220,7 @@ async def get_my_feed(_, info, options): ) q = add_stat_columns(q) - q = apply_filters(q, options.get("filters", {}), author_id) + q = apply_filters(q, options.get("filters", {}), author.id) order_by = options.get("order_by", Shout.published_at) @@ -235,3 +246,11 @@ async def get_my_feed(_, info, options): } shouts.append(shout) return shouts + + +@query.field("search") +async def search(_, info, text, limit=50, offset=0): + if text and len(text) > 2: + return SearchService.search(text, limit, offset) + else: + return [] diff --git a/resolvers/topic.py b/resolvers/topic.py index 911eea95..b5a59071 100644 --- a/resolvers/topic.py +++ b/resolvers/topic.py @@ -135,13 +135,13 @@ def topic_follow(follower_id, slug): return False -def topic_unfollow(user_id, slug): +def topic_unfollow(follower_id, slug): try: with local_session() as session: sub = ( session.query(TopicFollower) .join(Topic) - .filter(and_(TopicFollower.follower == user_id, Topic.slug == slug)) + .filter(and_(TopicFollower.follower == follower_id, Topic.slug == slug)) .first() ) if sub: @@ -153,6 +153,7 @@ def topic_unfollow(user_id, slug): return False +@query.field("topicsRandom") async def topics_random(_, info, amount=12): q = select(Topic) q = q.join(ShoutTopic) diff --git a/schemas/core.graphql b/schemas/core.graphql index 13910162..757ef56a 100644 --- a/schemas/core.graphql +++ b/schemas/core.graphql @@ -334,4 +334,6 @@ type Query { communitiesAll: [Community] getCommunity: Community + + search(text: String!, limit: Int, offset: Int): [Shout] } diff --git a/services/auth.py b/services/auth.py index 154389e8..2b50e99f 100644 --- a/services/auth.py +++ b/services/auth.py @@ -11,7 +11,7 @@ async def check_auth(req): query_type = "query" operation = "GetUserId" - headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"} + headers = {"Authorization": token, "Content-Type": "application/json"} gql = { "query": query_type + " " + operation + " { " + query_name + " { user { id } } " + " }", @@ -26,9 +26,7 @@ async def check_auth(req): return False, None r = response.json() try: - user_id = ( - r.get("data", {}).get(query_name, {}).get("user", {}).get("id", None) - ) + user_id = r.get("data", {}).get(query_name, {}).get("user", {}).get("id", None) is_authenticated = user_id is not None return is_authenticated, user_id except Exception as e: @@ -47,7 +45,7 @@ def login_required(f): raise Exception("You are not logged in") else: # Добавляем author_id в контекст - context["author_id"] = user_id + context["user_id"] = user_id # Если пользователь аутентифицирован, выполняем резолвер return await f(*args, **kwargs) @@ -63,7 +61,7 @@ def auth_request(f): if not is_authenticated: raise HTTPError("please, login first") else: - req["author_id"] = user_id + req["user_id"] = user_id return await f(*args, **kwargs) return decorated_function diff --git a/services/search.py b/services/search.py index f482021f..21b341eb 100644 --- a/services/search.py +++ b/services/search.py @@ -1,8 +1,8 @@ import asyncio import json +import httpx from services.rediscache import redis from orm.shout import Shout -from resolvers.reader import load_shouts_by class SearchService: @@ -20,15 +20,13 @@ class SearchService: cached = await redis.execute("GET", text) if not cached: async with SearchService.lock: - options = { - "title": text, - "body": text, - "limit": limit, - "offset": offset, - } - # FIXME: use elastic request here - payload = await load_shouts_by(None, None, options) - await redis.execute("SET", text, json.dumps(payload)) - return payload + # Use httpx to send a request to ElasticSearch + async with httpx.AsyncClient() as client: + search_url = f"https://search.discours.io/search?q={text}" + response = await client.get(search_url) + if response.status_code == 200: + payload = response.json() + await redis.execute("SET", text, payload) + return json.loads(payload) else: return json.loads(cached)