From db76ba3733b21ca753a81750df1a61078a2b4997 Mon Sep 17 00:00:00 2001 From: Untone Date: Wed, 22 Nov 2023 19:38:39 +0300 Subject: [PATCH] 0.2.14 --- CHANGELOG.txt | 10 ++++- README.md | 26 ++++++++--- orm/__init__.py | 10 ----- orm/author.py | 2 + orm/collection.py | 6 ++- orm/community.py | 15 ++++--- orm/reaction.py | 12 +++-- orm/shout.py | 39 +++++----------- orm/topic.py | 4 +- orm/user.py | 30 +++++++++++++ pyproject.toml | 6 +-- requirements.txt | 24 ---------- resolvers/author.py | 58 +++++++++--------------- resolvers/community.py | 6 +-- resolvers/editor.py | 60 +++++++++++-------------- resolvers/reaction.py | 100 +++++++++++++++++++---------------------- resolvers/reader.py | 5 +-- resolvers/topic.py | 23 ++++------ schemas/core.graphql | 8 ++-- services/db.py | 83 ++++++++++++++-------------------- services/rediscache.py | 43 +++++++++--------- services/schema.py | 15 ++++--- 22 files changed, 271 insertions(+), 314 deletions(-) delete mode 100644 orm/__init__.py create mode 100644 orm/user.py delete mode 100644 requirements.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index aa1751c8..cd3d93d6 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,10 +1,18 @@ +[0.2.14] +- schema: some fixes from migrator +- 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] - services: db context manager - services: ViewedStorage fixes - services: views are not stored in core db anymore - schema: snake case in model fields names - schema: no DateTime scalar -- resolvers: get_my_feed comments filter reactions body.is_not("") +- resolvers: get_my_feed comments filter reactions body.is_not("") - resolvers: get_my_feed query fix - resolvers: LoadReactionsBy.days -> LoadReactionsBy.time_ago - resolvers: LoadShoutsBy.days -> LoadShoutsBy.time_ago diff --git a/README.md b/README.md index 0030a29d..2074d768 100644 --- a/README.md +++ b/README.md @@ -25,17 +25,33 @@ apt install redis nginx Then run nginx, redis and API server ``` redis-server +poetry env use 3.12 poetry install -python3 server.py dev +poetry run python server.py dev ``` +## Services -# How to do an authorized request +### Auth Put the header 'Authorization' with token from signIn query or registerUser mutation. -# How to debug Ackee +### Viewed -Set ACKEE_TOKEN var +Set ACKEE_TOKEN var to collect stats -# test +### Seacrh +ElasticSearch + +### Notifications + +Connected using Redis PubSub channels + +### Inbox + +To get unread counter raw redis query to Inbox's data is used + + +### Following Manager + +Internal service with async access to storage diff --git a/orm/__init__.py b/orm/__init__.py deleted file mode 100644 index 77097285..00000000 --- a/orm/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from services.db import Base, engine -from orm.shout import Shout -from orm.community import Community - - -def init_tables(): - Base.metadata.create_all(engine) - Shout.init_table() - Community.init_table() - print("[orm] tables initialized") diff --git a/orm/author.py b/orm/author.py index c98e7192..649a7d04 100644 --- a/orm/author.py +++ b/orm/author.py @@ -1,7 +1,9 @@ import time + from sqlalchemy import JSON as JSONType from sqlalchemy import Boolean, Column, ForeignKey, Integer, String from sqlalchemy.orm import relationship + from services.db import Base diff --git a/orm/collection.py b/orm/collection.py index 8c90f9b0..0fb6167b 100644 --- a/orm/collection.py +++ b/orm/collection.py @@ -1,5 +1,7 @@ import time -from sqlalchemy import Column, Integer, ForeignKey, String + +from sqlalchemy import Column, ForeignKey, Integer, String + from services.db import Base @@ -20,4 +22,4 @@ class Collection(Base): pic = Column(String, nullable=True, comment="Picture") created_at = Column(Integer, default=lambda: int(time.time())) created_by = Column(ForeignKey("author.id"), comment="Created By") - published_at = Column(Integer, default=lambda: int(time.time())) + publishedAt = Column(Integer, default=lambda: int(time.time())) diff --git a/orm/community.py b/orm/community.py index ce400f13..b549725b 100644 --- a/orm/community.py +++ b/orm/community.py @@ -1,9 +1,10 @@ import time -from sqlalchemy import Column, String, ForeignKey, Integer + +from sqlalchemy import Column, ForeignKey, Integer, String from sqlalchemy.orm import relationship -from services.db import Base, local_session from orm.author import Author +from services.db import Base, local_session class CommunityAuthor(Base): @@ -29,10 +30,12 @@ class Community(Base): @staticmethod def init_table(): - with local_session() as session: + with local_session("orm.community") as session: d = session.query(Community).filter(Community.slug == "discours").first() if not d: - d = Community.create(name="Дискурс", slug="discours") - print("[orm] created community %s" % d.slug) + d = Community(name="Дискурс", slug="discours") + session.add(d) + session.commit() + print("[orm.community] created community %s" % d.slug) Community.default_community = d - print("[orm] default community is %s" % d.slug) + print("[orm.community] default community is %s" % d.slug) diff --git a/orm/reaction.py b/orm/reaction.py index 79b7760b..26757091 100644 --- a/orm/reaction.py +++ b/orm/reaction.py @@ -1,7 +1,9 @@ -from enum import Enum as Enumeration -from sqlalchemy import Column, Integer, Enum, ForeignKey, String -from services.db import Base import time +from enum import Enum as Enumeration + +from sqlalchemy import Column, Enum, ForeignKey, Integer, String + +from services.db import Base class ReactionKind(Enumeration): @@ -33,5 +35,7 @@ class Reaction(Base): deleted_by = Column(ForeignKey("author.id"), nullable=True, index=True) shout = Column(ForeignKey("shout.id"), nullable=False, index=True) reply_to = Column(ForeignKey("reaction.id"), nullable=True) - quote = Column(String, nullable=True, comment="a quoted fragment") + range = Column(String, nullable=True, comment=":") kind = Column(Enum(ReactionKind), nullable=False) + + oid = Column(String) diff --git a/orm/shout.py b/orm/shout.py index e4882507..f402d7c1 100644 --- a/orm/shout.py +++ b/orm/shout.py @@ -1,20 +1,14 @@ import time from enum import Enum as Enumeration -from sqlalchemy import ( - Enum, - Boolean, - Column, - ForeignKey, - Integer, - String, - JSON, -) -from sqlalchemy.orm import column_property, relationship -from services.db import Base, local_session + +from sqlalchemy import JSON, Boolean, Column, Enum, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from orm.author import Author from orm.community import Community from orm.reaction import Reaction from orm.topic import Topic -from orm.author import Author +from services.db import Base class ShoutTopic(Base): @@ -66,8 +60,7 @@ class Shout(Base): updated_at = Column(Integer, nullable=True) published_at = Column(Integer, nullable=True) deleted_at = Column(Integer, nullable=True) - - created_by = Column(ForeignKey("author.id"), comment="Created By") + deleted_by = Column(ForeignKey("author.id"), nullable=True) body = Column(String, nullable=False, comment="Body") @@ -80,9 +73,9 @@ class Shout(Base): layout = Column(String, nullable=True) media = Column(JSON, nullable=True) - authors = relationship(lambda: Author, secondary=ShoutAuthor.__tablename__) - topics = relationship(lambda: Topic, secondary=ShoutTopic.__tablename__) - communities = relationship(lambda: Community, secondary=ShoutCommunity.__tablename__) + authors = relationship(lambda: Author, secondary="shout_author") + topics = relationship(lambda: Topic, secondary="shout_topic") + communities = relationship(lambda: Community, secondary="shout_community") reactions = relationship(lambda: Reaction) visibility = Column(Enum(ShoutVisibility), default=ShoutVisibility.AUTHORS) @@ -91,15 +84,3 @@ class Shout(Base): version_of = Column(ForeignKey("shout.id"), nullable=True) oid = Column(String, nullable=True) - @staticmethod - def init_table(): - with local_session() as session: - s = session.query(Shout).first() - if not s: - entry = { - "slug": "genesis-block", - "body": "", - "title": "Ничего", - "lang": "ru", - } - s = Shout.create(**entry) diff --git a/orm/topic.py b/orm/topic.py index 1c8d48ef..96d4d186 100644 --- a/orm/topic.py +++ b/orm/topic.py @@ -1,5 +1,7 @@ import time -from sqlalchemy import Boolean, Column, Integer, ForeignKey, String + +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String + from services.db import Base diff --git a/orm/user.py b/orm/user.py new file mode 100644 index 00000000..f6735528 --- /dev/null +++ b/orm/user.py @@ -0,0 +1,30 @@ +import time + +from sqlalchemy import JSON, Boolean, Column, Integer, String + +from services.db import Base + + +class User(Base): + __tablename__ = "authorizer_users" + + id = Column(String, primary_key=True, unique=True, nullable=False, default=None) + key = Column(String) + email = Column(String, unique=True) + email_verified_at = Column(Integer) + family_name = Column(String) + gender = Column(String) + given_name = Column(String) + is_multi_factor_auth_enabled = Column(Boolean) + middle_name = Column(String) + nickname = Column(String) + password = Column(String) + phone_number = Column(String, unique=True) + phone_number_verified_at = Column(Integer) + # preferred_username = Column(String, nullable=False) + picture = Column(String) + revoked_timestamp = Column(Integer) + roles = Column(JSON) + signup_methods = Column(String, default="magic_link_login") + created_at = Column(Integer, default=lambda: int(time.time())) + updated_at = Column(Integer, default=lambda: int(time.time())) diff --git a/pyproject.toml b/pyproject.toml index dce730a9..811be739 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "discoursio-core" -version = "0.2.13" +version = "0.2.14" description = "core module for discours.io" authors = ["discoursio devteam"] license = "MIT" @@ -11,7 +11,7 @@ python = "^3.12" SQLAlchemy = "^2.0.22" httpx = "^0.25.0" redis = {extras = ["hiredis"], version = "^5.0.1"} -uvicorn = "^0.23.2" +uvicorn = "^0.24" sentry-sdk = "^1.32.0" gql = {git = "https://github.com/graphql-python/gql.git", rev = "master"} starlette = {git = "https://github.com/encode/starlette.git", rev = "master"} @@ -66,4 +66,4 @@ line_length = 120 select = ["E4", "E7", "E9", "F"] ignore = [] line-length = 120 -target-version = "py312" \ No newline at end of file +target-version = "py312" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 1af33c8f..00000000 --- a/requirements.txt +++ /dev/null @@ -1,24 +0,0 @@ -git+https://github.com/tonyrewin/ariadne.git#master -git+https://github.com/encode/starlette.git#master -git+https://github.com/graphql-python/gql.git#master -SQLAlchemy -uvicorn -redis[hiredis] -itsdangerous -Authlib -PyJWT -PyYAML -httpx -psycopg2-binary -bcrypt -sentry-sdk -boto3 -botocore -transliterate -passlib -pydantic - -flake8 -isort -brunette -mypy diff --git a/resolvers/author.py b/resolvers/author.py index 86161db0..98ba8f34 100644 --- a/resolvers/author.py +++ b/resolvers/author.py @@ -12,7 +12,7 @@ from orm.topic import Topic from orm.author import AuthorFollower, Author, AuthorRating from community import followed_communities from topic import followed_topics -from reaction import load_followed_reactions +from reaction import reacted_shouts_updates as followed_reactions def add_author_stat_columns(q): @@ -28,16 +28,13 @@ def add_author_stat_columns(q): func.count(distinct(followers_table.follower)).label("followers_stat") ) - q = q.outerjoin( - followings_table, followings_table.follower == Author.id - ).add_columns( + q = q.outerjoin(followings_table, followings_table.follower == Author.id).add_columns( func.count(distinct(followings_table.author)).label("followings_stat") ) q = q.add_columns(literal(0).label("rating_stat")) # FIXME # q = q.outerjoin(author_rating_aliased, author_rating_aliased.user == Author.id).add_columns( - # # TODO: check # func.sum(author_rating_aliased.value).label('rating_stat') # ) @@ -83,16 +80,10 @@ def get_authors_from_query(q): async def author_followings(author_id: int): return { "unread": await get_total_unread_counter(author_id), # unread inbox messages counter - "topics": [ - t.slug for t in await followed_topics(author_id) - ], # followed topics slugs - "authors": [ - a.slug for a in await followed_authors(author_id) - ], # followed authors slugs - "reactions": await load_followed_reactions(author_id), - "communities": [ - c.slug for c in await followed_communities(author_id) - ], # communities + "topics": [t.slug for t in await followed_topics(author_id)], # followed topics slugs + "authors": [a.slug for a in await followed_authors(author_id)], # followed authors slugs + "reactions": [s.slug for s in await followed_reactions(author_id)], # fresh reacted shouts slugs + "communities": [c.slug for c in await followed_communities(author_id)], # communities } @@ -102,7 +93,8 @@ async def update_profile(_, info, profile): author_id = info.context["author_id"] with local_session() as session: author = session.query(Author).where(Author.id == author_id).first() - author.update(profile) + Author.update(author, profile) + session.add(author) session.commit() return {"error": None, "author": author} @@ -112,7 +104,7 @@ def author_follow(follower_id, slug): try: with local_session() as session: author = session.query(Author).where(Author.slug == slug).one() - af = AuthorFollower.create(follower=follower_id, author=author.id) + af = AuthorFollower(follower=follower_id, author=author.id) session.add(af) session.commit() return True @@ -163,13 +155,8 @@ async def load_authors_by(_, _info, by, limit, offset): elif by.get("name"): q = q.filter(Author.name.ilike(f"%{by['name']}%")) elif by.get("topic"): - q = ( - q.join(ShoutAuthor) - .join(ShoutTopic) - .join(Topic) - .where(Topic.slug == by["topic"]) - ) - + q = q.join(ShoutAuthor).join(ShoutTopic).join(Topic).where(Topic.slug == by["topic"]) + if by.get("last_seen"): # in unixtime before = int(time.time()) - by["last_seen"] q = q.filter(Author.last_seen > before) @@ -211,9 +198,7 @@ async def author_followers(_, _info, slug) -> List[Author]: async def followed_authors(follower_id): q = select(Author) q = add_author_stat_columns(q) - q = q.join(AuthorFollower, AuthorFollower.author == Author.id).where( - AuthorFollower.follower == follower_id - ) + q = q.join(AuthorFollower, AuthorFollower.author == Author.id).where(AuthorFollower.follower == follower_id) # Pass the query to the get_authors_from_query function and return the results return get_authors_from_query(q) @@ -226,20 +211,19 @@ async def rate_author(_, info, rated_user_id, value): with local_session() as session: rating = ( session.query(AuthorRating) - .filter( - and_( - AuthorRating.rater == author_id, - AuthorRating.user == rated_user_id - ) - ) + .filter(and_(AuthorRating.rater == author_id, AuthorRating.user == rated_user_id)) .first() ) if rating: rating.value = value + session.add(rating) session.commit() return {} - try: - AuthorRating.create(rater=author_id, user=rated_user_id, value=value) - except Exception as err: - return {"error": err} + else: + try: + rating = AuthorRating(rater=author_id, user=rated_user_id, value=value) + session.add(rating) + session.commit() + except Exception as err: + return {"error": err} return {} diff --git a/resolvers/community.py b/resolvers/community.py index bb3a1b04..30d191d0 100644 --- a/resolvers/community.py +++ b/resolvers/community.py @@ -14,9 +14,7 @@ def add_community_stat_columns(q): q = q.outerjoin(shout_community_aliased).add_columns( func.count(distinct(shout_community_aliased.shout)).label("shouts_stat") ) - q = q.outerjoin( - community_followers, community_followers.author == Author.id - ).add_columns( + q = q.outerjoin(community_followers, community_followers.author == Author.id).add_columns( func.count(distinct(community_followers.follower)).label("followers_stat") ) @@ -75,7 +73,7 @@ def community_follow(follower_id, slug): try: with local_session() as session: community = session.query(Community).where(Community.slug == slug).one() - cf = CommunityAuthor.create(author=follower_id, community=community.id) + cf = CommunityAuthor(author=follower_id, community=community.id) session.add(cf) session.commit() return True diff --git a/resolvers/editor.py b/resolvers/editor.py index b3e34114..17aeb848 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -9,6 +9,7 @@ from orm.topic import Topic from reaction import reactions_follow, reactions_unfollow from services.notify import notify_shout + @query.field("loadDrafts") async def get_drafts(_, info): author = info.context["request"].author @@ -27,17 +28,16 @@ async def get_drafts(_, info): shouts.append(shout) return shouts + @mutation.field("createShout") @login_required async def create_shout(_, info, inp): author_id = info.context["author_id"] with local_session() as session: - topics = ( - session.query(Topic).filter(Topic.slug.in_(inp.get("topics", []))).all() - ) + topics = session.query(Topic).filter(Topic.slug.in_(inp.get("topics", []))).all() # Replace datetime with Unix timestamp current_time = int(time.time()) - new_shout = Shout.create( + new_shout = Shout( **{ "title": inp.get("title"), "subtitle": inp.get("subtitle"), @@ -54,22 +54,23 @@ async def create_shout(_, info, inp): } ) for topic in topics: - t = ShoutTopic.create(topic=topic.id, shout=new_shout.id) + t = ShoutTopic(topic=topic.id, shout=new_shout.id) session.add(t) # NOTE: shout made by one first author - sa = ShoutAuthor.create(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) session.commit() - # TODO: GitTask(inp, user.username, user.email, "new shout %s" % new_shout.slug) + if new_shout.slug is None: new_shout.slug = f"draft-{new_shout.id}" session.commit() else: - notify_shout(new_shout.dict(), "create") + await notify_shout(new_shout.dict(), "create") return {"shout": new_shout} + @mutation.field("updateShout") @login_required async def update_shout(_, info, shout_id, shout_input=None, publish=False): @@ -93,42 +94,30 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False): topics_input = shout_input["topics"] del shout_input["topics"] new_topics_to_link = [] - new_topics = [ - topic_input for topic_input in topics_input if topic_input["id"] < 0 - ] + new_topics = [topic_input for topic_input in topics_input if topic_input["id"] < 0] for new_topic in new_topics: del new_topic["id"] - created_new_topic = Topic.create(**new_topic) + created_new_topic = Topic(**new_topic) session.add(created_new_topic) new_topics_to_link.append(created_new_topic) if len(new_topics) > 0: session.commit() for new_topic_to_link in new_topics_to_link: - created_unlinked_topic = ShoutTopic.create( - shout=shout.id, topic=new_topic_to_link.id - ) + created_unlinked_topic = ShoutTopic(shout=shout.id, topic=new_topic_to_link.id) session.add(created_unlinked_topic) - existing_topics_input = [ - topic_input - for topic_input in topics_input - if topic_input.get("id", 0) > 0 - ] + existing_topics_input = [topic_input for topic_input in topics_input if topic_input.get("id", 0) > 0] existing_topic_to_link_ids = [ existing_topic_input["id"] for existing_topic_input in existing_topics_input - if existing_topic_input["id"] - not in [topic.id for topic in shout.topics] + if existing_topic_input["id"] not in [topic.id for topic in shout.topics] ] for existing_topic_to_link_id in existing_topic_to_link_ids: - created_unlinked_topic = ShoutTopic.create( - shout=shout.id, topic=existing_topic_to_link_id - ) + created_unlinked_topic = ShoutTopic(shout=shout.id, topic=existing_topic_to_link_id) session.add(created_unlinked_topic) topic_to_unlink_ids = [ topic.id for topic in shout.topics - if topic.id - not in [topic_input["id"] for topic_input in existing_topics_input] + if topic.id not in [topic_input["id"] for topic_input in existing_topics_input] ] shout_topics_to_remove = session.query(ShoutTopic).filter( and_( @@ -144,21 +133,24 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False): # Replace datetime with Unix timestamp current_time = int(time.time()) shout_input["updated_at"] = current_time # Set updated_at as Unix timestamp - shout.update(shout_input) + Shout.update(shout, shout_input) + session.add(shout) updated = True # TODO: use visibility setting - if publish and shout.visibility == "authors": - shout.visibility = "community" + if publish and shout.visibility == ShoutVisibility.AUTHORS: + shout.visibility = ShoutVisibility.COMMUNITY shout.published_at = current_time # Set published_at as Unix timestamp + session.add(shout) updated = True # notify on publish - notify_shout(shout.dict()) + await notify_shout(shout.dict(), "public") if updated: session.commit() - # GitTask(inp, user.username, user.email, "update shout %s" % slug) - notify_shout(shout.dict(), "update") + if not publish: + await notify_shout(shout.dict(), "update") return {"shout": shout} + @mutation.field("deleteShout") @login_required async def delete_shout(_, info, shout_id): @@ -175,5 +167,5 @@ async def delete_shout(_, info, shout_id): current_time = int(time.time()) shout.deleted_at = current_time # Set deleted_at as Unix timestamp session.commit() - notify_shout(shout.dict(), "delete") + await notify_shout(shout.dict(), "delete") return {} diff --git a/resolvers/reaction.py b/resolvers/reaction.py index 5fe727a7..a157f0c9 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -1,9 +1,10 @@ import time +from typing import List + from sqlalchemy import and_, asc, desc, select, text, func, case from sqlalchemy.orm import aliased from services.notify import notify_reaction from services.auth import login_required -from base.exceptions import OperationNotAllowed from services.db import local_session from services.schema import mutation, query from orm.reaction import Reaction, ReactionKind @@ -14,13 +15,9 @@ from orm.author import Author def add_reaction_stat_columns(q): aliased_reaction = aliased(Reaction) - q = q.outerjoin( - aliased_reaction, Reaction.id == aliased_reaction.reply_to - ).add_columns( + q = q.outerjoin(aliased_reaction, Reaction.id == aliased_reaction.reply_to).add_columns( func.sum(aliased_reaction.id).label("reacted_stat"), - func.sum(case((aliased_reaction.body.is_not(None), 1), else_=0)).label( - "commented_stat" - ), + func.sum(case((aliased_reaction.body.is_not(None), 1), else_=0)).label("commented_stat"), func.sum( case( (aliased_reaction.kind == ReactionKind.AGREE, 1), @@ -56,9 +53,7 @@ def reactions_follow(author_id, shout_id: int, auto=False): ) if not following: - following = ShoutReactionsFollower.create( - follower=author_id, shout=shout.id, auto=auto - ) + following = ShoutReactionsFollower(follower=author_id, shout=shout.id, auto=auto) session.add(following) session.commit() return True @@ -109,11 +104,9 @@ def check_to_publish(session, author_id, reaction): ReactionKind.LIKE, ReactionKind.PROOF, ]: - if is_published_author(author_id): + if is_published_author(session, author_id): # now count how many approvers are voted already - approvers_reactions = ( - session.query(Reaction).where(Reaction.shout == reaction.shout).all() - ) + approvers_reactions = session.query(Reaction).where(Reaction.shout == reaction.shout).all() approvers = [ author_id, ] @@ -134,9 +127,7 @@ def check_to_hide(session, reaction): ReactionKind.DISPROOF, ]: # if is_published_author(author_id): - approvers_reactions = ( - session.query(Reaction).where(Reaction.shout == reaction.shout).all() - ) + approvers_reactions = session.query(Reaction).where(Reaction.shout == reaction.shout).all() rejects = 0 for r in approvers_reactions: if r.kind in [ @@ -188,12 +179,10 @@ async def create_reaction(_, info, reaction): ) if existing_reaction is not None: - raise OperationNotAllowed("You can't vote twice") + return {"error": "You can't vote twice"} opposite_reaction_kind = ( - ReactionKind.DISLIKE - if reaction["kind"] == ReactionKind.LIKE.name - else ReactionKind.LIKE + ReactionKind.DISLIKE if reaction["kind"] == ReactionKind.LIKE.name else ReactionKind.LIKE ) opposite_reaction = ( session.query(Reaction) @@ -211,17 +200,11 @@ async def create_reaction(_, info, reaction): if opposite_reaction is not None: session.delete(opposite_reaction) - r = Reaction.create(**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"] - ): - replied_reaction = ( - session.query(Reaction).where(Reaction.id == r.reply_to).first() - ) + 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: old_body = shout.body @@ -230,7 +213,6 @@ async def create_reaction(_, info, reaction): end = int(end) new_body = old_body[:start] + replied_reaction.body + old_body[end:] shout.body = new_body - # TODO: update git version control session.add(r) session.commit() @@ -253,37 +235,39 @@ async def create_reaction(_, info, reaction): rdict["stat"] = {"commented": 0, "reacted": 0, "rating": 0} - # notification call - notify_reaction(rdict) + # notifications call + await notify_reaction(rdict, "create") return {"reaction": rdict} @mutation.field("updateReaction") @login_required -async def update_reaction(_, info, rid, reaction={}): +async def update_reaction(_, info, rid, reaction): author_id = info.context["author_id"] with local_session() as session: q = select(Reaction).filter(Reaction.id == rid) q = add_reaction_stat_columns(q) q = q.group_by(Reaction.id) - [r, reacted_stat, commented_stat, rating_stat] = ( - session.execute(q).unique().one() - ) + [r, reacted_stat, commented_stat, rating_stat] = session.execute(q).unique().one() if not r: return {"error": "invalid reaction id"} if r.created_by != author_id: return {"error": "access denied"} - - r.body = reaction["body"] + 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") + session.commit() r.stat = { "commented": commented_stat, @@ -291,7 +275,7 @@ async def update_reaction(_, info, rid, reaction={}): "rating": rating_stat, } - notify_reaction(r.dict(), "update") + await notify_reaction(r.dict(), "update") return {"reaction": r} @@ -313,7 +297,7 @@ async def delete_reaction(_, info, rid): r.deleted_at = int(time.time()) session.commit() - notify_reaction(r.dict(), "delete") + await notify_reaction(r.dict(), "delete") return {"reaction": r} @@ -391,25 +375,31 @@ async def load_reactions_by(_, info, by, limit=50, offset=0): reaction.kind = reaction.kind.name reactions.append(reaction) - # ? + # sort if by stat is present if by.get("stat"): - reactions.sort(lambda r: r.stat.get(by["stat"]) or r.created_at) + reactions = sorted(reactions, key=lambda r: r.stat.get(by["stat"]) or r.created_at) return reactions +def reacted_shouts_updates(follower_id): + shouts = [] + with local_session() as session: + author = session.query(Author).where(Author.id == follower_id).first() + if author: + shouts = ( + session.query(Reaction.shout) + .join(Shout) + .filter(Reaction.created_by == author.id) + .filter(Reaction.created_at > author.last_seen) + .all() + ) + return shouts + + @login_required @query.field("followedReactions") -async def followed_reactions(_, info): +async def get_reacted_shouts(_, info) -> List[Shout]: author_id = info.context["author_id"] - # FIXME: method should return array of shouts - with local_session() as session: - author = session.query(Author).where(Author.id == author_id).first() - reactions = ( - session.query(Reaction.shout) - .where(Reaction.created_by == author.id) - .filter(Reaction.created_at > author.last_seen) - .all() - ) - - return reactions + shouts = reacted_shouts_updates(author_id) + return shouts diff --git a/resolvers/reader.py b/resolvers/reader.py index 5ea93268..f55a7ef6 100644 --- a/resolvers/reader.py +++ b/resolvers/reader.py @@ -1,5 +1,4 @@ import time -from aiohttp.web_exceptions import HTTPException from sqlalchemy.orm import joinedload, aliased from sqlalchemy.sql.expression import desc, asc, select, func, case, and_, nulls_last @@ -9,7 +8,7 @@ from orm.topic import TopicFollower from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.author import AuthorFollower -from servies.viewed import ViewedStorage +from services.viewed import ViewedStorage def add_stat_columns(q): @@ -113,7 +112,7 @@ async def load_shout(_, _info, slug=None, shout_id=None): author.caption = author_caption.caption return shout except Exception: - raise HTTPException(status_code=404, detail="Slug was not found: %s" % slug) + return None async def load_shouts_by(_, info, options): diff --git a/resolvers/topic.py b/resolvers/topic.py index 7b179dc5..911eea95 100644 --- a/resolvers/topic.py +++ b/resolvers/topic.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import aliased from services.auth import login_required from services.db import local_session -from resolvers import mutation, query +from services.schema import mutation, query from orm.shout import ShoutTopic, ShoutAuthor from orm.topic import Topic, TopicFollower from orm.author import Author @@ -12,9 +12,7 @@ from orm.author import Author async def followed_topics(follower_id): q = select(Author) q = add_topic_stat_columns(q) - q = q.join(TopicFollower, TopicFollower.author == Author.id).where( - TopicFollower.follower == follower_id - ) + q = q.join(TopicFollower, TopicFollower.author == Author.id).where(TopicFollower.follower == follower_id) # Pass the query to the get_authors_from_query function and return the results return get_topics_from_query(q) @@ -27,15 +25,9 @@ def add_topic_stat_columns(q): q.outerjoin(ShoutTopic, Topic.id == ShoutTopic.topic) .add_columns(func.count(distinct(ShoutTopic.shout)).label("shouts_stat")) .outerjoin(aliased_shout_author, ShoutTopic.shout == aliased_shout_author.shout) - .add_columns( - func.count(distinct(aliased_shout_author.user)).label("authors_stat") - ) + .add_columns(func.count(distinct(aliased_shout_author.user)).label("authors_stat")) .outerjoin(aliased_topic_follower) - .add_columns( - func.count(distinct(aliased_topic_follower.follower)).label( - "followers_stat" - ) - ) + .add_columns(func.count(distinct(aliased_topic_follower.follower)).label("followers_stat")) ) q = q.group_by(Topic.id) @@ -111,7 +103,7 @@ async def get_topic(_, _info, slug): async def create_topic(_, _info, inp): with local_session() as session: # TODO: check user permissions to create topic for exact community - new_topic = Topic.create(**inp) + new_topic = Topic(**inp) session.add(new_topic) session.commit() @@ -126,7 +118,8 @@ async def update_topic(_, _info, inp): if not topic: return {"error": "topic not found"} else: - topic.update(**inp) + Topic.update(topic, inp) + session.add(topic) session.commit() return {"topic": topic} @@ -136,7 +129,7 @@ def topic_follow(follower_id, slug): try: with local_session() as session: topic = session.query(Topic).where(Topic.slug == slug).one() - _following = TopicFollower.create(topic=topic.id, follower=follower_id) + _following = TopicFollower(topic=topic.id, follower=follower_id) return True except Exception: return False diff --git a/schemas/core.graphql b/schemas/core.graphql index 4cbc4d31..8c282760 100644 --- a/schemas/core.graphql +++ b/schemas/core.graphql @@ -96,8 +96,8 @@ type Reaction { body: String reply_to: Int stat: Stat - old_id: String - old_thread: String + oid: String + # old_thread: String } type Shout { @@ -212,7 +212,7 @@ input ReactionInput { shout: Int! range: String body: String - reply_to: Int + replyTo: Int } input AuthorsBy { @@ -266,7 +266,7 @@ input ReactionBy { search: String comment: Boolean topic: String - created_by: String + created_by: Int days: Int sort: String } diff --git a/services/db.py b/services/db.py index 1c16d22e..e9b15d6d 100644 --- a/services/db.py +++ b/services/db.py @@ -1,35 +1,41 @@ -from contextlib import contextmanager -import logging -from typing import TypeVar, Any, Dict, Generic, Callable -from sqlalchemy import create_engine, Column, Integer +# from contextlib import contextmanager +from typing import Any, Callable, Dict, TypeVar + +# from psycopg2.errors import UniqueViolation +from sqlalchemy import Column, Integer, create_engine from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import Session from sqlalchemy.sql.schema import Table + from settings import DB_URL -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - engine = create_engine(DB_URL, echo=False, pool_size=10, max_overflow=20) -Session = sessionmaker(bind=engine, expire_on_commit=False) T = TypeVar("T") REGISTRY: Dict[str, type] = {} -@contextmanager -def local_session(): - session = Session() - try: - yield session - session.commit() - except Exception as e: - print(f"[services.db] Error session: {e}") - session.rollback() - raise - finally: - session.close() +# @contextmanager +def local_session(src=""): + return Session(bind=engine, expire_on_commit=False) + + # try: + # yield session + # session.commit() + # except Exception as e: + # if not (src == "create_shout" and isinstance(e, UniqueViolation)): + # import traceback + + # session.rollback() + # print(f"[services.db] {src}: {e}") + + # traceback.print_exc() + + # raise Exception("[services.db] exception") + + # finally: + # session.close() class Base(declarative_base()): @@ -46,38 +52,17 @@ class Base(declarative_base()): def __init_subclass__(cls, **kwargs): REGISTRY[cls.__name__] = cls - @classmethod - def create(cls: Generic[T], **kwargs) -> Generic[T]: - try: - instance = cls(**kwargs) - return instance.save() - except Exception as e: - print(f"[services.db] Error create: {e}") - return None - - def save(self) -> Generic[T]: - with local_session() as session: - try: - session.add(self) - except Exception as e: - print(f"[services.db] Error save: {e}") - return self - - def update(self, input): - column_names = self.__table__.columns.keys() - for name, value in input.items(): - if name in column_names: - setattr(self, name, value) - with local_session() as session: - try: - session.commit() - except Exception as e: - print(f"[services.db] Error update: {e}") - def dict(self) -> Dict[str, Any]: column_names = self.__table__.columns.keys() + if "_sa_instance_state" in column_names: + column_names.remove("_sa_instance_state") try: return {c: getattr(self, c) for c in column_names} except Exception as e: print(f"[services.db] Error dict: {e}") return {} + + def update(self, values: Dict[str, Any]) -> None: + for key, value in values.items(): + if hasattr(self, key): + setattr(self, key, value) diff --git a/services/rediscache.py b/services/rediscache.py index 547bc371..52824713 100644 --- a/services/rediscache.py +++ b/services/rediscache.py @@ -1,6 +1,7 @@ import redis.asyncio as aredis from settings import REDIS_URL + class RedisCache: def __init__(self, uri=REDIS_URL): self._uri: str = uri @@ -11,28 +12,25 @@ class RedisCache: self._client = aredis.Redis.from_url(self._uri, decode_responses=True) async def disconnect(self): - await self._client.aclose() + if self._client: + await self._client.close() async def execute(self, command, *args, **kwargs): - if not self._client: - await self.connect() - try: - print(f"[redis] {command} {args}") - return await self._client.execute_command(command, *args, **kwargs) - except Exception as e: - print(f"[redis] ERROR: {e} with: {command} {args}") - import traceback - - traceback.print_exc() + if self._client: + try: + print("[redis] " + command + " " + " ".join(args)) + r = await self._client.execute_command(command, *args, **kwargs) + return r + except Exception as e: + print(f"[redis] error: {e}") return None async def subscribe(self, *channels): - if not self._client: - await self.connect() - async with self._client.pubsub() as pubsub: - for channel in channels: - await pubsub.subscribe(channel) - self.pubsub_channels.append(channel) + if self._client: + async with self._client.pubsub() as pubsub: + for channel in channels: + await pubsub.subscribe(channel) + self.pubsub_channels.append(channel) async def unsubscribe(self, *channels): if not self._client: @@ -48,12 +46,15 @@ class RedisCache: await self._client.publish(channel, data) async def lrange(self, key, start, stop): - print(f"[redis] LRANGE {key} {start} {stop}") - return await self._client.lrange(key, start, stop) + if self._client: + print(f"[redis] LRANGE {key} {start} {stop}") + return await self._client.lrange(key, start, stop) async def mget(self, key, *keys): - print(f"[redis] MGET {key} {keys}") - return await self._client.mget(key, *keys) + if self._client: + print(f"[redis] MGET {key} {keys}") + return await self._client.mget(key, *keys) + redis = RedisCache() diff --git a/services/schema.py b/services/schema.py index 126a8852..f42c9242 100644 --- a/services/schema.py +++ b/services/schema.py @@ -11,13 +11,14 @@ def serialize_datetime(value): return value.isoformat() -@query.field("_service") -def resolve_service(*_): - # Load the full SDL from your SDL file - with open("schemas/core.graphql", "r") as file: - full_sdl = file.read() - - return {"sdl": full_sdl} +# NOTE: was used by studio +# @query.field("_service") +# def resolve_service(*_): +# # Load the full SDL from your SDL file +# with open("schemas/core.graphql", "r") as file: +# full_sdl = file.read() +# +# return {"sdl": full_sdl} resolvers = [query, mutation, datetime_scalar]