diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd36c3e6..fec63909 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ fail_fast: true repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.5.0 hooks: - id: check-yaml - id: check-toml @@ -14,11 +14,9 @@ repos: - id: check-ast - id: check-merge-conflict - - repo: local + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.13 hooks: - - id: lint-python - name: Lint Python - entry: ruff check - types: [python] - language: system - pass_filenames: false + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/CHANGELOG.txt b/CHANGELOG.txt index e2f8780f..93709d2c 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,8 @@ +[0.2.22] +- added precommit hook +- fmt +- granian asgi + [0.2.21] - fix: rating logix - fix: load_top_random_shouts diff --git a/Dockerfile b/Dockerfile index 65a65816..0fd9d978 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,4 +9,5 @@ RUN apt-get update && apt-get install -y git gcc curl postgresql && \ poetry config virtualenvs.create false && \ poetry install --no-dev -CMD ["python", "server.py"] +# Run server when the container launches +CMD granian --no-ws --host 0.0.0.0 --port 8000 --interface asgi main:app diff --git a/README.md b/README.md index 40a9dd45..a783ba5b 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ mkdir .venv python3.12 -m venv .venv poetry env use .venv/bin/python3.12 poetry update -poetry run python server.py +poetry granian --no-ws --host 0.0.0.0 --port 8000 --interface asgi main:app ``` ## Services diff --git a/main.py b/main.py index cabb393d..df42d056 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,6 @@ from os.path import exists from ariadne import load_schema_from_path, make_executable_schema from ariadne.asgi import GraphQL - from sentry_sdk.integrations.aiohttp import AioHttpIntegration from sentry_sdk.integrations.ariadne import AriadneIntegration from sentry_sdk.integrations.redis import RedisIntegration @@ -16,28 +15,29 @@ from starlette.routing import Route from resolvers.webhook import WebhookEndpoint from services.rediscache import redis from services.schema import resolvers -from settings import DEV_SERVER_PID_FILE_NAME, MODE, SENTRY_DSN from services.viewed import ViewedStorage +from settings import DEV_SERVER_PID_FILE_NAME, MODE, SENTRY_DSN -import_module("resolvers") -schema = make_executable_schema(load_schema_from_path("schemas/core.graphql"), resolvers) # type: ignore + +import_module('resolvers') +schema = make_executable_schema(load_schema_from_path('schemas/core.graphql'), resolvers) # type: ignore async def start_up(): - print(f"[main] starting in {MODE} mode") + print(f'[main] starting in {MODE} mode') await redis.connect() # start viewed service await ViewedStorage.init() - if MODE == "development": + if MODE == 'development': # pid file management if not exists(DEV_SERVER_PID_FILE_NAME): - with open(DEV_SERVER_PID_FILE_NAME, "w", encoding="utf-8") as f: + with open(DEV_SERVER_PID_FILE_NAME, 'w', encoding='utf-8') as f: f.write(str(os.getpid())) - if MODE == "production": + if MODE == 'production': # sentry monitoring try: import sentry_sdk @@ -54,7 +54,7 @@ async def start_up(): ], ) except Exception as e: - print("[sentry] init error") + print('[sentry] init error') print(e) @@ -62,5 +62,5 @@ async def shutdown(): await redis.disconnect() -routes = [Route("/", GraphQL(schema, debug=True)), Route("/new-author", WebhookEndpoint)] +routes = [Route('/', GraphQL(schema, debug=True)), Route('/new-author', WebhookEndpoint)] app = Starlette(routes=routes, debug=True, on_startup=[start_up], on_shutdown=[shutdown]) diff --git a/orm/author.py b/orm/author.py index ba9fcc52..49bf6d18 100644 --- a/orm/author.py +++ b/orm/author.py @@ -1,46 +1,45 @@ import time -from sqlalchemy import JSON as JSONType -from sqlalchemy import Boolean, Column, ForeignKey, Integer, String +from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String from sqlalchemy.orm import relationship from services.db import Base class AuthorRating(Base): - __tablename__ = "author_rating" + __tablename__ = 'author_rating' id = None # type: ignore - rater = Column(ForeignKey("author.id"), primary_key=True, index=True) - author = Column(ForeignKey("author.id"), primary_key=True, index=True) + rater = Column(ForeignKey('author.id'), primary_key=True, index=True) + author = Column(ForeignKey('author.id'), primary_key=True, index=True) plus = Column(Boolean) class AuthorFollower(Base): - __tablename__ = "author_follower" + __tablename__ = 'author_follower' id = None # type: ignore - follower = Column(ForeignKey("author.id"), primary_key=True, index=True) - author = Column(ForeignKey("author.id"), primary_key=True, index=True) + follower = Column(ForeignKey('author.id'), primary_key=True, index=True) + author = Column(ForeignKey('author.id'), primary_key=True, index=True) created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) auto = Column(Boolean, nullable=False, default=False) class Author(Base): - __tablename__ = "author" + __tablename__ = 'author' user = Column(String, unique=True) # unbounded link with authorizer's User type - name = Column(String, nullable=True, comment="Display name") + name = Column(String, nullable=True, comment='Display name') slug = Column(String, unique=True, comment="Author's slug") - bio = Column(String, nullable=True, comment="Bio") # status description - about = Column(String, nullable=True, comment="About") # long and formatted - pic = Column(String, nullable=True, comment="Picture") - links = Column(JSONType, nullable=True, comment="Links") + bio = Column(String, nullable=True, comment='Bio') # status description + about = Column(String, nullable=True, comment='About') # long and formatted + pic = Column(String, nullable=True, comment='Picture') + links = Column(JSON, nullable=True, comment='Links') ratings = relationship(AuthorRating, foreign_keys=AuthorRating.author) created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) last_seen = Column(Integer, nullable=False, default=lambda: int(time.time())) updated_at = Column(Integer, nullable=False, default=lambda: int(time.time())) - deleted_at = Column(Integer, nullable=True, comment="Deleted at") + deleted_at = Column(Integer, nullable=True, comment='Deleted at') diff --git a/orm/collection.py b/orm/collection.py index 0fb6167b..87592bc8 100644 --- a/orm/collection.py +++ b/orm/collection.py @@ -6,20 +6,20 @@ from services.db import Base class ShoutCollection(Base): - __tablename__ = "shout_collection" + __tablename__ = 'shout_collection' id = None # type: ignore - shout = Column(ForeignKey("shout.id"), primary_key=True) - collection = Column(ForeignKey("collection.id"), primary_key=True) + shout = Column(ForeignKey('shout.id'), primary_key=True) + collection = Column(ForeignKey('collection.id'), primary_key=True) class Collection(Base): - __tablename__ = "collection" + __tablename__ = 'collection' slug = Column(String, unique=True) - title = Column(String, nullable=False, comment="Title") - body = Column(String, nullable=True, comment="Body") - pic = Column(String, nullable=True, comment="Picture") + title = Column(String, nullable=False, comment='Title') + body = Column(String, nullable=True, comment='Body') + 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") - publishedAt = 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())) diff --git a/orm/community.py b/orm/community.py index 09fa8359..ff156bd9 100644 --- a/orm/community.py +++ b/orm/community.py @@ -8,34 +8,34 @@ from services.db import Base, local_session class CommunityAuthor(Base): - __tablename__ = "community_author" + __tablename__ = 'community_author' id = None # type: ignore - author = Column(ForeignKey("author.id"), primary_key=True) - community = Column(ForeignKey("community.id"), primary_key=True) + author = Column(ForeignKey('author.id'), primary_key=True) + community = Column(ForeignKey('community.id'), primary_key=True) joined_at = Column(Integer, nullable=False, default=lambda: int(time.time())) role = Column(String, nullable=False) class Community(Base): - __tablename__ = "community" + __tablename__ = 'community' name = Column(String, nullable=False) slug = Column(String, nullable=False, unique=True) - desc = Column(String, nullable=False, default="") - pic = Column(String, nullable=False, default="") + desc = Column(String, nullable=False, default='') + pic = Column(String, nullable=False, default='') created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) authors = relationship(lambda: Author, secondary=CommunityAuthor.__tablename__) @staticmethod def init_table(): - with local_session("orm.community") as session: - d = session.query(Community).filter(Community.slug == "discours").first() + with local_session('orm.community') as session: + d = session.query(Community).filter(Community.slug == 'discours').first() if not d: - d = Community(name="Дискурс", slug="discours") + d = Community(name='Дискурс', slug='discours') session.add(d) session.commit() - print("[orm.community] created community %s" % d.slug) + print('[orm.community] created community %s' % d.slug) Community.default_community = d - print("[orm.community] default community is %s" % d.slug) + print('[orm.community] default community is %s' % d.slug) diff --git a/orm/invite.py b/orm/invite.py index c03c0f2c..972df370 100644 --- a/orm/invite.py +++ b/orm/invite.py @@ -9,17 +9,17 @@ from services.db import Base class InviteStatus(Enumeration): - PENDING = "PENDING" - ACCEPTED = "ACCEPTED" - REJECTED = "REJECTED" + PENDING = 'PENDING' + ACCEPTED = 'ACCEPTED' + REJECTED = 'REJECTED' class Invite(Base): - __tablename__ = "invite" + __tablename__ = 'invite' - inviter_id = Column(ForeignKey("author.id"), nullable=False, index=True) - author_id = Column(ForeignKey("author.id"), nullable=False, index=True) - shout_id = Column(ForeignKey("shout.id"), nullable=False, index=True) + inviter_id = Column(ForeignKey('author.id'), nullable=False, index=True) + author_id = Column(ForeignKey('author.id'), nullable=False, index=True) + shout_id = Column(ForeignKey('shout.id'), nullable=False, index=True) status = Column(String, default=InviteStatus.PENDING.value) inviter = relationship(Author, foreign_keys=[inviter_id]) diff --git a/orm/reaction.py b/orm/reaction.py index 0655c49d..5ceb27a3 100644 --- a/orm/reaction.py +++ b/orm/reaction.py @@ -10,34 +10,34 @@ class ReactionKind(Enumeration): # TYPE = # rating diff # editor mode - AGREE = "AGREE" # +1 - DISAGREE = "DISAGREE" # -1 - ASK = "ASK" # +0 - PROPOSE = "PROPOSE" # +0 - PROOF = "PROOF" # +1 - DISPROOF = "DISPROOF" # -1 - ACCEPT = "ACCEPT" # +1 - REJECT = "REJECT" # -1 + AGREE = 'AGREE' # +1 + DISAGREE = 'DISAGREE' # -1 + ASK = 'ASK' # +0 + PROPOSE = 'PROPOSE' # +0 + PROOF = 'PROOF' # +1 + DISPROOF = 'DISPROOF' # -1 + ACCEPT = 'ACCEPT' # +1 + REJECT = 'REJECT' # -1 # public feed - QUOTE = "QUOTE" # +0 TODO: use to bookmark in collection - COMMENT = "COMMENT" # +0 - LIKE = "LIKE" # +1 - DISLIKE = "DISLIKE" # -1 + QUOTE = 'QUOTE' # +0 TODO: use to bookmark in collection + COMMENT = 'COMMENT' # +0 + LIKE = 'LIKE' # +1 + DISLIKE = 'DISLIKE' # -1 class Reaction(Base): - __tablename__ = "reaction" + __tablename__ = 'reaction' - body = Column(String, default="", comment="Reaction Body") + body = Column(String, default='', comment='Reaction Body') created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) - updated_at = Column(Integer, nullable=True, comment="Updated at") - deleted_at = Column(Integer, nullable=True, comment="Deleted at") - deleted_by = Column(ForeignKey("author.id"), nullable=True, index=True) - reply_to = Column(ForeignKey("reaction.id"), nullable=True) - quote = Column(String, nullable=True, comment="Original quoted text") - shout = Column(ForeignKey("shout.id"), nullable=False, index=True) - created_by = Column(ForeignKey("author.id"), nullable=False, index=True) + updated_at = Column(Integer, nullable=True, comment='Updated at') + deleted_at = Column(Integer, nullable=True, comment='Deleted at') + deleted_by = Column(ForeignKey('author.id'), nullable=True, index=True) + reply_to = Column(ForeignKey('reaction.id'), nullable=True) + quote = Column(String, nullable=True, comment='Original quoted text') + shout = Column(ForeignKey('shout.id'), nullable=False, index=True) + created_by = Column(ForeignKey('author.id'), nullable=False, index=True) kind = Column(String, nullable=False, index=True) oid = Column(String) diff --git a/orm/shout.py b/orm/shout.py index 024be792..93c1cd0d 100644 --- a/orm/shout.py +++ b/orm/shout.py @@ -12,80 +12,80 @@ from services.db import Base class ShoutTopic(Base): - __tablename__ = "shout_topic" + __tablename__ = 'shout_topic' id = None # type: ignore - shout = Column(ForeignKey("shout.id"), primary_key=True, index=True) - topic = Column(ForeignKey("topic.id"), primary_key=True, index=True) + shout = Column(ForeignKey('shout.id'), primary_key=True, index=True) + topic = Column(ForeignKey('topic.id'), primary_key=True, index=True) main = Column(Boolean, nullable=True) class ShoutReactionsFollower(Base): - __tablename__ = "shout_reactions_followers" + __tablename__ = 'shout_reactions_followers' id = None # type: ignore - follower = Column(ForeignKey("author.id"), primary_key=True, index=True) - shout = Column(ForeignKey("shout.id"), primary_key=True, index=True) + follower = Column(ForeignKey('author.id'), primary_key=True, index=True) + shout = Column(ForeignKey('shout.id'), primary_key=True, index=True) auto = Column(Boolean, nullable=False, default=False) created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) deleted_at = Column(Integer, nullable=True) class ShoutAuthor(Base): - __tablename__ = "shout_author" + __tablename__ = 'shout_author' id = None # type: ignore - shout = Column(ForeignKey("shout.id"), primary_key=True, index=True) - author = Column(ForeignKey("author.id"), primary_key=True, index=True) - caption = Column(String, nullable=True, default="") + shout = Column(ForeignKey('shout.id'), primary_key=True, index=True) + author = Column(ForeignKey('author.id'), primary_key=True, index=True) + caption = Column(String, nullable=True, default='') class ShoutCommunity(Base): - __tablename__ = "shout_community" + __tablename__ = 'shout_community' id = None # type: ignore - shout = Column(ForeignKey("shout.id"), primary_key=True, index=True) - community = Column(ForeignKey("community.id"), primary_key=True, index=True) + shout = Column(ForeignKey('shout.id'), primary_key=True, index=True) + community = Column(ForeignKey('community.id'), primary_key=True, index=True) class ShoutVisibility(Enumeration): - AUTHORS = "AUTHORS" - COMMUNITY = "COMMUNITY" - PUBLIC = "PUBLIC" + AUTHORS = 'AUTHORS' + COMMUNITY = 'COMMUNITY' + PUBLIC = 'PUBLIC' class Shout(Base): - __tablename__ = "shout" + __tablename__ = 'shout' created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) updated_at = Column(Integer, nullable=True) published_at = Column(Integer, nullable=True) deleted_at = Column(Integer, nullable=True) - created_by = Column(ForeignKey("author.id"), nullable=False) - updated_by = Column(ForeignKey("author.id"), nullable=True) - deleted_by = Column(ForeignKey("author.id"), nullable=True) + created_by = Column(ForeignKey('author.id'), nullable=False) + updated_by = Column(ForeignKey('author.id'), nullable=True) + deleted_by = Column(ForeignKey('author.id'), nullable=True) - body = Column(String, nullable=False, comment="Body") + body = Column(String, nullable=False, comment='Body') slug = Column(String, unique=True) - cover = Column(String, nullable=True, comment="Cover image url") - cover_caption = Column(String, nullable=True, comment="Cover image alt caption") + cover = Column(String, nullable=True, comment='Cover image url') + cover_caption = Column(String, nullable=True, comment='Cover image alt caption') lead = Column(String, nullable=True) description = Column(String, nullable=True) title = Column(String, nullable=False) subtitle = Column(String, nullable=True) - layout = Column(String, nullable=False, default="article") + layout = Column(String, nullable=False, default='article') media = Column(JSON, nullable=True) - authors = relationship(lambda: Author, secondary="shout_author") - topics = relationship(lambda: Topic, secondary="shout_topic") - communities = relationship(lambda: Community, secondary="shout_community") + 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(String, default=ShoutVisibility.AUTHORS.value) - lang = Column(String, nullable=False, default="ru", comment="Language") - version_of = Column(ForeignKey("shout.id"), nullable=True) + lang = Column(String, nullable=False, default='ru', comment='Language') + version_of = Column(ForeignKey('shout.id'), nullable=True) oid = Column(String, nullable=True) seo = Column(String, nullable=True) # JSON diff --git a/orm/topic.py b/orm/topic.py index 96d4d186..928b0129 100644 --- a/orm/topic.py +++ b/orm/topic.py @@ -6,21 +6,21 @@ from services.db import Base class TopicFollower(Base): - __tablename__ = "topic_followers" + __tablename__ = 'topic_followers' id = None # type: ignore - follower = Column(ForeignKey("author.id"), primary_key=True, index=True) - topic = Column(ForeignKey("topic.id"), primary_key=True, index=True) + follower = Column(ForeignKey('author.id'), primary_key=True, index=True) + topic = Column(ForeignKey('topic.id'), primary_key=True, index=True) created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) auto = Column(Boolean, nullable=False, default=False) class Topic(Base): - __tablename__ = "topic" + __tablename__ = 'topic' slug = Column(String, unique=True) - title = Column(String, nullable=False, comment="Title") - body = Column(String, nullable=True, comment="Body") - pic = Column(String, nullable=True, comment="Picture") - community = Column(ForeignKey("community.id"), default=1) - oid = Column(String, nullable=True, comment="Old ID") + title = Column(String, nullable=False, comment='Title') + body = Column(String, nullable=True, comment='Body') + pic = Column(String, nullable=True, comment='Picture') + community = Column(ForeignKey('community.id'), default=1) + oid = Column(String, nullable=True, comment='Old ID') diff --git a/orm/user.py b/orm/user.py index 550813e6..6001b2ea 100644 --- a/orm/user.py +++ b/orm/user.py @@ -6,7 +6,7 @@ from services.db import Base class User(Base): - __tablename__ = "authorizer_users" + __tablename__ = 'authorizer_users' id = Column(String, primary_key=True, unique=True, nullable=False, default=None) key = Column(String) @@ -24,7 +24,7 @@ class User(Base): # preferred_username = Column(String, nullable=False) picture = Column(String) revoked_timestamp = Column(Integer) - roles = Column(String, default="author, reader") - signup_methods = Column(String, default="magic_link_login") + roles = Column(String, default='author, reader') + 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 f561d497..b708cf4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,9 +11,8 @@ python = "^3.12" SQLAlchemy = "^2.0.22" psycopg2-binary = "^2.9.9" redis = {extras = ["hiredis"], version = "^5.0.1"} -uvicorn = "^0.24" sentry-sdk = "^1.39.1" -starlette = "^0.34.0" +starlette = "^0.36.1" gql = "^3.4.1" ariadne = "^0.21" aiohttp = "^3.9.1" @@ -30,6 +29,7 @@ black = { version = "^23.12.0", python = ">=3.12" } ruff = { version = "^0.1.8", python = ">=3.12" } isort = "^5.13.2" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" @@ -44,7 +44,6 @@ extend-select = [ 'I', # isort 'N', # pep8-naming 'Q', # flake8-quotes - 'RUF100', # ruff (unused noqa) 'S', # flake8-bandit 'W', # pycodestyle ] @@ -56,6 +55,7 @@ extend-ignore = [ 'E501', # leave line length to black 'N818', # leave to us exceptions naming 'S101', # assert is fine + 'E712', # allow == True ] flake8-quotes = { inline-quotes = 'single', multiline-quotes = 'double' } mccabe = { max-complexity = 13 } @@ -64,14 +64,24 @@ target-version = "py312" [tool.ruff.format] quote-style = 'single' +[tool.black] +skip-string-normalization = true + [tool.ruff.isort] combine-as-imports = true lines-after-imports = 2 -known-first-party = ['granian', 'tests'] +known-first-party = ['resolvers', 'services', 'orm', 'tests'] [tool.ruff.per-file-ignores] 'tests/**' = ['B018', 'S110', 'S501'] +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true +exclude = ["nb"] + [tool.pytest.ini_options] asyncio_mode = 'auto' diff --git a/resolvers/__init__.py b/resolvers/__init__.py index 3b4621a0..64b4206e 100644 --- a/resolvers/__init__.py +++ b/resolvers/__init__.py @@ -23,50 +23,51 @@ from resolvers.reader import ( load_shouts_by, load_shouts_feed, load_shouts_random_top, + load_shouts_random_topic, load_shouts_search, load_shouts_unrated, - load_shouts_random_topic, ) from resolvers.topic import get_topic, get_topics_all, get_topics_by_author, get_topics_by_community + __all__ = [ # author - "get_author", - "get_author_id", - "get_authors_all", - "get_author_followers", - "get_author_followed", - "load_authors_by", - "rate_author", - "update_profile", + 'get_author', + 'get_author_id', + 'get_authors_all', + 'get_author_followers', + 'get_author_followed', + 'load_authors_by', + 'rate_author', + 'update_profile', # community - "get_community", - "get_communities_all", + 'get_community', + 'get_communities_all', # topic - "get_topic", - "get_topics_all", - "get_topics_by_community", - "get_topics_by_author", + 'get_topic', + 'get_topics_all', + 'get_topics_by_community', + 'get_topics_by_author', # reader - "get_shout", - "load_shouts_by", - "load_shouts_feed", - "load_shouts_search", - "load_shouts_followed", - "load_shouts_unrated", - "load_shouts_random_top", - "load_shouts_random_topic", + 'get_shout', + 'load_shouts_by', + 'load_shouts_feed', + 'load_shouts_search', + 'load_shouts_followed', + 'load_shouts_unrated', + 'load_shouts_random_top', + 'load_shouts_random_topic', # follower - "follow", - "unfollow", - "get_my_followed", + 'follow', + 'unfollow', + 'get_my_followed', # editor - "create_shout", - "update_shout", - "delete_shout", + 'create_shout', + 'update_shout', + 'delete_shout', # reaction - "create_reaction", - "update_reaction", - "delete_reaction", - "load_reactions_by", + 'create_reaction', + 'update_reaction', + 'delete_reaction', + 'load_reactions_by', ] diff --git a/resolvers/author.py b/resolvers/author.py index 7903c44e..79334f1e 100644 --- a/resolvers/author.py +++ b/resolvers/author.py @@ -1,14 +1,14 @@ +import logging import time from typing import List -import logging -from sqlalchemy import and_, case, distinct, func, literal, select, cast, Integer +from sqlalchemy import and_, distinct, func, select from sqlalchemy.orm import aliased from orm.author import Author, AuthorFollower, AuthorRating from orm.community import Community from orm.reaction import Reaction, ReactionKind -from orm.shout import ShoutAuthor, ShoutTopic, Shout +from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic from resolvers.community import followed_communities from resolvers.reaction import reacted_shouts_updates as followed_reactions @@ -19,25 +19,26 @@ from services.schema import mutation, query from services.unread import get_total_unread_counter from services.viewed import ViewedStorage + logging.basicConfig() -logger = logging.getLogger("\t[resolvers.author]\t") +logger = logging.getLogger('\t[resolvers.author]\t') logger.setLevel(logging.DEBUG) def add_author_stat_columns(q): shout_author_aliased = aliased(ShoutAuthor) q = q.outerjoin(shout_author_aliased, shout_author_aliased.author == Author.id).add_columns( - func.count(distinct(shout_author_aliased.shout)).label("shouts_stat") + func.count(distinct(shout_author_aliased.shout)).label('shouts_stat') ) followers_table = aliased(AuthorFollower) q = q.outerjoin(followers_table, followers_table.author == Author.id).add_columns( - func.count(distinct(followers_table.follower)).label("followers_stat") + func.count(distinct(followers_table.follower)).label('followers_stat') ) followings_table = aliased(AuthorFollower) q = q.outerjoin(followings_table, followings_table.follower == Author.id).add_columns( - func.count(distinct(followers_table.author)).label("followings_stat") + func.count(distinct(followers_table.author)).label('followings_stat') ) q = q.group_by(Author.id) @@ -49,10 +50,10 @@ async def get_authors_from_query(q): with local_session() as session: for [author, shouts_stat, followers_stat, followings_stat] in session.execute(q): author.stat = { - "shouts": shouts_stat, - "followers": followers_stat, - "followings": followings_stat, - "viewed": await ViewedStorage.get_author(author.slug), + 'shouts': shouts_stat, + 'followers': followers_stat, + 'followings': followings_stat, + 'viewed': await ViewedStorage.get_author(author.slug), } authors.append(author) return authors @@ -61,24 +62,24 @@ async def get_authors_from_query(q): async def author_followings(author_id: int): # NOTE: topics, authors, shout-reactions and communities slugs list return { - "unread": await get_total_unread_counter(author_id), - "topics": [t.slug for t in await followed_topics(author_id)], - "authors": [a.slug for a in await followed_authors(author_id)], - "reactions": [s.slug for s in await followed_reactions(author_id)], - "communities": [c.slug for c in [followed_communities(author_id)] if isinstance(c, Community)], + 'unread': await get_total_unread_counter(author_id), + 'topics': [t.slug for t in await followed_topics(author_id)], + 'authors': [a.slug for a in await followed_authors(author_id)], + 'reactions': [s.slug for s in await followed_reactions(author_id)], + 'communities': [c.slug for c in [followed_communities(author_id)] if isinstance(c, Community)], } -@mutation.field("update_profile") +@mutation.field('update_profile') @login_required async def update_profile(_, info, profile): - user_id = info.context["user_id"] + user_id = info.context['user_id'] with local_session() as session: author = session.query(Author).where(Author.user == user_id).first() Author.update(author, profile) session.add(author) session.commit() - return {"error": None, "author": author} + return {'error': None, 'author': author} # for mutation.field("follow") @@ -111,7 +112,7 @@ def author_unfollow(follower_id, slug): # TODO: caching query -@query.field("get_authors_all") +@query.field('get_authors_all') async def get_authors_all(_, _info): with local_session() as session: return session.query(Author).all() @@ -122,40 +123,59 @@ def count_author_comments_rating(session, author_id) -> int: replies_likes = ( session.query(replied_alias) .join(Reaction, replied_alias.id == Reaction.reply_to) - .where(and_(replied_alias.created_by == author_id, replied_alias.kind == ReactionKind.COMMENT.value)) + .where( + and_( + replied_alias.created_by == author_id, + replied_alias.kind == ReactionKind.COMMENT.value, + ) + ) .filter(replied_alias.kind == ReactionKind.LIKE.value) .count() ) or 0 replies_dislikes = ( session.query(replied_alias) .join(Reaction, replied_alias.id == Reaction.reply_to) - .where(and_(replied_alias.created_by == author_id, replied_alias.kind == ReactionKind.COMMENT.value)) + .where( + and_( + replied_alias.created_by == author_id, + replied_alias.kind == ReactionKind.COMMENT.value, + ) + ) .filter(replied_alias.kind == ReactionKind.DISLIKE.value) .count() ) or 0 - return replies_likes - replies_dislikes + return replies_likes - replies_dislikes def count_author_shouts_rating(session, author_id) -> int: shouts_likes = ( session.query(Reaction, Shout) .join(Shout, Shout.id == Reaction.shout) - .filter(and_(Shout.authors.any(id=author_id), Reaction.kind == ReactionKind.LIKE.value)) + .filter( + and_( + Shout.authors.any(id=author_id), + Reaction.kind == ReactionKind.LIKE.value, + ) + ) .count() or 0 ) shouts_dislikes = ( session.query(Reaction, Shout) .join(Shout, Shout.id == Reaction.shout) - .filter(and_(Shout.authors.any(id=author_id), Reaction.kind == ReactionKind.DISLIKE.value)) + .filter( + and_( + Shout.authors.any(id=author_id), + Reaction.kind == ReactionKind.DISLIKE.value, + ) + ) .count() or 0 ) return shouts_likes - shouts_dislikes - async def load_author_with_stats(q): q = add_author_stat_columns(q) @@ -175,25 +195,25 @@ async def load_author_with_stats(q): ) .count() ) - ratings_sum = ( - session.query( - func.sum( - case((AuthorRating.plus == True, cast(1, Integer)), - else_=cast(-1, Integer))).label("rating") - ) - .filter(AuthorRating.author == author.id) - .scalar() + likes_count = ( + session.query(AuthorRating) + .filter(and_(AuthorRating.author == author.id, AuthorRating.plus == True)) + .count() ) - - author.stat["rating"] = ratings_sum or 0 - author.stat["rating_shouts"] = count_author_shouts_rating(session, author.id) - author.stat["rating_comments"] = count_author_comments_rating(session, author.id) - author.stat["commented"] = comments_count + dislikes_count = ( + session.query(AuthorRating) + .filter(and_(AuthorRating.author == author.id, AuthorRating.plus != True)) + .count() + ) + author.stat['rating'] = likes_count - dislikes_count + author.stat['rating_shouts'] = count_author_shouts_rating(session, author.id) + author.stat['rating_comments'] = count_author_comments_rating(session, author.id) + author.stat['commented'] = comments_count return author -@query.field("get_author") -async def get_author(_, _info, slug="", author_id=None): +@query.field('get_author') +async def get_author(_, _info, slug='', author_id=None): q = None if slug or author_id: if bool(slug): @@ -204,38 +224,37 @@ async def get_author(_, _info, slug="", author_id=None): return await load_author_with_stats(q) -@query.field("get_author_id") +@query.field('get_author_id') async def get_author_id(_, _info, user: str): - with local_session() as session: - logger.info(f"getting author id for {user}") - q = select(Author).filter(Author.user == user) - return await load_author_with_stats(q) + logger.info(f'getting author id for {user}') + q = select(Author).filter(Author.user == user) + return await load_author_with_stats(q) -@query.field("load_authors_by") +@query.field('load_authors_by') async def load_authors_by(_, _info, by, limit, offset): q = select(Author) q = add_author_stat_columns(q) - if by.get("slug"): + if by.get('slug'): q = q.filter(Author.slug.ilike(f"%{by['slug']}%")) - elif by.get("name"): + 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"]) + elif by.get('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"] + if by.get('last_seen'): # in unixtime + before = int(time.time()) - by['last_seen'] q = q.filter(Author.last_seen > before) - elif by.get("created_at"): # in unixtime - before = int(time.time()) - by["created_at"] + elif by.get('created_at'): # in unixtime + before = int(time.time()) - by['created_at'] q = q.filter(Author.created_at > before) - q = q.order_by(by.get("order", Author.created_at)).limit(limit).offset(offset) + q = q.order_by(by.get('order', Author.created_at)).limit(limit).offset(offset) return await get_authors_from_query(q) -@query.field("get_author_followed") -async def get_author_followed(_, _info, slug="", user=None, author_id=None) -> List[Author]: +@query.field('get_author_followed') +async def get_author_followed(_, _info, slug='', user=None, author_id=None) -> List[Author]: author_id_query = None if slug: author_id_query = select(Author.id).where(Author.slug == slug) @@ -246,12 +265,12 @@ async def get_author_followed(_, _info, slug="", user=None, author_id=None) -> L author_id = session.execute(author_id_query).scalar() if author_id is None: - raise ValueError("Author not found") + raise ValueError('Author not found') else: return await followed_authors(author_id) # Author[] -@query.field("get_author_followers") +@query.field('get_author_followers') async def get_author_followers(_, _info, slug) -> List[Author]: q = select(Author) q = add_author_stat_columns(q) @@ -274,10 +293,10 @@ async def followed_authors(follower_id): return await get_authors_from_query(q) -@mutation.field("rate_author") +@mutation.field('rate_author') @login_required async def rate_author(_, info, rated_slug, value): - user_id = info.context["user_id"] + user_id = info.context['user_id'] with local_session() as session: rated_author = session.query(Author).filter(Author.slug == rated_slug).first() @@ -285,7 +304,12 @@ async def rate_author(_, info, rated_slug, value): if rater and rated_author: rating: AuthorRating = ( session.query(AuthorRating) - .filter(and_(AuthorRating.rater == rater.id, AuthorRating.author == rated_author.id)) + .filter( + and_( + AuthorRating.rater == rater.id, + AuthorRating.author == rated_author.id, + ) + ) .first() ) if rating: @@ -299,13 +323,13 @@ async def rate_author(_, info, rated_slug, value): session.add(rating) session.commit() except Exception as err: - return {"error": err} + return {'error': err} return {} -async def create_author(user_id: str, slug: str, name: str = ""): +async def create_author(user_id: str, slug: str, name: str = ''): with local_session() as session: new_author = Author(user=user_id, slug=slug, name=name) session.add(new_author) session.commit() - logger.info(f"author created by webhook {new_author.dict()}") + logger.info(f'author created by webhook {new_author.dict()}') diff --git a/resolvers/collab.py b/resolvers/collab.py index 1b5d5f56..68a45ce9 100644 --- a/resolvers/collab.py +++ b/resolvers/collab.py @@ -6,10 +6,10 @@ from services.db import local_session from services.schema import mutation -@mutation.field("accept_invite") +@mutation.field('accept_invite') @login_required async def accept_invite(_, info, invite_id: int): - user_id = info.context["user_id"] + user_id = info.context['user_id'] # Check if the user exists with local_session() as session: @@ -26,19 +26,19 @@ async def accept_invite(_, info, invite_id: int): session.delete(invite) session.add(shout) session.commit() - return {"success": True, "message": "Invite accepted"} + return {'success': True, 'message': 'Invite accepted'} else: - return {"error": "Shout not found"} + return {'error': 'Shout not found'} else: - return {"error": "Invalid invite or already accepted/rejected"} + return {'error': 'Invalid invite or already accepted/rejected'} else: - return {"error": "User not found"} + return {'error': 'User not found'} -@mutation.field("reject_invite") +@mutation.field('reject_invite') @login_required async def reject_invite(_, info, invite_id: int): - user_id = info.context["user_id"] + user_id = info.context['user_id'] # Check if the user exists with local_session() as session: @@ -50,17 +50,17 @@ async def reject_invite(_, info, invite_id: int): # Delete the invite session.delete(invite) session.commit() - return {"success": True, "message": "Invite rejected"} + return {'success': True, 'message': 'Invite rejected'} else: - return {"error": "Invalid invite or already accepted/rejected"} + return {'error': 'Invalid invite or already accepted/rejected'} else: - return {"error": "User not found"} + return {'error': 'User not found'} -@mutation.field("create_invite") +@mutation.field('create_invite') @login_required -async def create_invite(_, info, slug: str = "", author_id: int = 0): - user_id = info.context["user_id"] +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: @@ -82,7 +82,7 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0): .first() ) if existing_invite: - return {"error": "Invite already sent"} + return {'error': 'Invite already sent'} # Create a new invite new_invite = Invite( @@ -91,17 +91,17 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0): session.add(new_invite) session.commit() - return {"error": None, "invite": new_invite} + return {'error': None, 'invite': new_invite} else: - return {"error": "Invalid author"} + return {'error': 'Invalid author'} else: - return {"error": "Access denied"} + return {'error': 'Access denied'} -@mutation.field("remove_author") +@mutation.field('remove_author') @login_required -async def remove_author(_, info, slug: str = "", author_id: int = 0): - user_id = info.context["user_id"] +async def remove_author(_, info, slug: str = '', author_id: int = 0): + user_id = info.context['user_id'] with local_session() as session: author = session.query(Author).filter(Author.user == user_id).first() if author: @@ -111,13 +111,13 @@ async def remove_author(_, info, slug: str = "", author_id: int = 0): shout.authors = [author for author in shout.authors if author.id != author_id] session.commit() return {} - return {"error": "Access denied"} + return {'error': 'Access denied'} -@mutation.field("remove_invite") +@mutation.field('remove_invite') @login_required async def remove_invite(_, info, invite_id: int): - user_id = info.context["user_id"] + user_id = info.context['user_id'] # Check if the user exists with local_session() as session: @@ -135,6 +135,6 @@ async def remove_invite(_, info, invite_id: int): session.commit() return {} else: - return {"error": "Invalid invite or already accepted/rejected"} + return {'error': 'Invalid invite or already accepted/rejected'} else: - return {"error": "Author not found"} + return {'error': 'Author not found'} diff --git a/resolvers/community.py b/resolvers/community.py index ad29609d..80142034 100644 --- a/resolvers/community.py +++ b/resolvers/community.py @@ -1,4 +1,6 @@ -from sqlalchemy import and_, distinct, func, literal, select +import logging + +from sqlalchemy import and_, distinct, func, select from sqlalchemy.orm import aliased from orm.author import Author @@ -8,16 +10,19 @@ from services.db import local_session from services.schema import query +logger = logging.getLogger('\t[resolvers.community]\t') +logger.setLevel(logging.DEBUG) + + def add_community_stat_columns(q): community_followers = aliased(CommunityAuthor) shout_community_aliased = aliased(ShoutCommunity) - q = q.outerjoin(shout_community_aliased).add_columns( - func.count(distinct(shout_community_aliased.shout)).label("shouts_stat") + func.count(distinct(shout_community_aliased.shout)).label('shouts_stat') ) q = q.outerjoin(community_followers, community_followers.author == Author.id).add_columns( - func.count(distinct(community_followers.follower)).label("followers_stat") + func.count(distinct(community_followers.follower)).label('followers_stat') ) q = q.group_by(Author.id) @@ -30,8 +35,8 @@ def get_communities_from_query(q): with local_session() as session: for [c, shouts_stat, followers_stat] in session.execute(q): c.stat = { - "shouts": shouts_stat, - "followers": followers_stat, + 'shouts': shouts_stat, + 'followers': followers_stat, # "commented": commented_stat, } ccc.append(c) @@ -69,8 +74,8 @@ def community_follow(follower_id, slug): session.add(cf) session.commit() return True - except Exception: - pass + except Exception as ex: + logger.debug(ex) return False @@ -90,7 +95,7 @@ def community_unfollow(follower_id, slug): return False -@query.field("get_communities_all") +@query.field('get_communities_all') async def get_communities_all(_, _info): q = select(Author) q = add_community_stat_columns(q) @@ -98,7 +103,7 @@ async def get_communities_all(_, _info): return get_communities_from_query(q) -@query.field("get_community") +@query.field('get_community') async def get_community(_, _info, slug): q = select(Community).where(Community.slug == slug) q = add_community_stat_columns(q) diff --git a/resolvers/editor.py b/resolvers/editor.py index 8785d82d..3804e2a7 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -13,10 +13,10 @@ from services.notify import notify_shout from services.schema import mutation, query -@query.field("get_shouts_drafts") +@query.field('get_shouts_drafts') @login_required async def get_shouts_drafts(_, info): - user_id = info.context["user_id"] + user_id = info.context['user_id'] with local_session() as session: author = session.query(Author).filter(Author.user == user_id).first() if author: @@ -36,29 +36,29 @@ async def get_shouts_drafts(_, info): return shouts -@mutation.field("create_shout") +@mutation.field('create_shout') @login_required async def create_shout(_, info, inp): - user_id = info.context["user_id"] + user_id = info.context['user_id'] with local_session() as session: author = session.query(Author).filter(Author.user == user_id).first() shout_dict = None if author: current_time = int(time.time()) - slug = inp.get("slug") or f"draft-{current_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", []), - "visibility": ShoutVisibility.AUTHORS.value, - "created_at": current_time, # Set created_at as Unix timestamp + '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', []), + 'visibility': ShoutVisibility.AUTHORS.value, + 'created_at': current_time, # Set created_at as Unix timestamp } new_shout = Shout(**shout_dict) @@ -72,21 +72,23 @@ async def create_shout(_, info, inp): sa = ShoutAuthor(shout=shout.id, author=author.id) session.add(sa) - topics = session.query(Topic).filter(Topic.slug.in_(inp.get("topics", []))).all() + 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) reactions_follow(author.id, shout.id, True) - await notify_shout(shout_dict, "create") - return {"shout": shout_dict} + await notify_shout(shout_dict, 'create') + return {'shout': shout_dict} -@mutation.field("update_shout") +@mutation.field('update_shout') @login_required -async def update_shout(_, info, shout_id, shout_input=None, publish=False): - user_id = info.context["user_id"] +async def update_shout( # noqa: C901 + _, info, shout_id, shout_input=None, publish=False +): + user_id = info.context['user_id'] with local_session() as session: author = session.query(Author).filter(Author.user == user_id).first() shout_dict = None @@ -103,16 +105,16 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False): .first() ) if not shout: - return {"error": "shout not found"} + return {'error': 'shout not found'} if shout.created_by is not author.id and author.id not in shout.authors: - return {"error": "access denied"} + return {'error': 'access denied'} if shout_input is not None: - topics_input = shout_input["topics"] - del shout_input["topics"] + 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"] + del new_topic['id'] created_new_topic = Topic(**new_topic) session.add(created_new_topic) new_topics_to_link.append(created_new_topic) @@ -121,11 +123,11 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False): for new_topic_to_link in new_topics_to_link: 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"] + 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(shout=shout.id, topic=existing_topic_to_link_id) @@ -133,7 +135,7 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False): 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_( @@ -145,68 +147,68 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False): session.delete(shout_topic_to_remove) # Replace datetime with Unix timestamp - shout_input["updated_at"] = current_time # Set updated_at as Unix timestamp + shout_input['updated_at'] = current_time # Set updated_at as Unix timestamp Shout.update(shout, shout_input) session.add(shout) # main topic - if "main_topic" in shout_input: + if 'main_topic' in shout_input: old_main_topic = ( session.query(ShoutTopic) .filter(and_(ShoutTopic.shout == shout.id, ShoutTopic.main == True)) .first() ) - main_topic = session.query(Topic).filter(Topic.slug == shout_input["main_topic"]).first() + main_topic = session.query(Topic).filter(Topic.slug == shout_input['main_topic']).first() if isinstance(main_topic, Topic): new_main_topic = ( session.query(ShoutTopic) - .filter(and_(ShoutTopic.shout == shout.id, ShoutTopic.topic == main_topic.id)) + .filter( + and_( + ShoutTopic.shout == shout.id, + ShoutTopic.topic == main_topic.id, + ) + ) .first() ) - if isinstance(old_main_topic, ShoutTopic) and isinstance(new_main_topic, ShoutTopic) \ - and old_main_topic is not new_main_topic: - ShoutTopic.update(old_main_topic, {"main": False}) + if ( + isinstance(old_main_topic, ShoutTopic) + and isinstance(new_main_topic, ShoutTopic) + and old_main_topic is not new_main_topic + ): + ShoutTopic.update(old_main_topic, {'main': False}) session.add(old_main_topic) - ShoutTopic.update(new_main_topic, {"main": True}) + ShoutTopic.update(new_main_topic, {'main': True}) session.add(new_main_topic) - session.commit() - - if publish: - if shout.visibility is ShoutVisibility.AUTHORS.value: - shout_dict = shout.dict() - shout_dict["visibility"] = ShoutVisibility.COMMUNITY.value - shout_dict["published_at"] = current_time # Set published_at as Unix timestamp - Shout.update(shout, shout_dict) - session.add(shout) - await notify_shout(shout.dict(), "public") shout_dict = shout.dict() session.commit() + if not publish: - await notify_shout(shout_dict, "update") - return {"shout": shout_dict} + await notify_shout(shout_dict, 'update') + + return {'shout': shout_dict} -@mutation.field("delete_shout") +@mutation.field('delete_shout') @login_required async def delete_shout(_, info, shout_id): - user_id = info.context["user_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"} + return {'error': 'invalid shout id'} if isinstance(author, Author) and isinstance(shout, Shout): # TODO: add editor role allowed here if shout.created_by is not author.id and author.id not in shout.authors: - return {"error": "access denied"} + return {'error': 'access denied'} for author_id in shout.authors: reactions_unfollow(author_id, shout_id) shout_dict = shout.dict() - shout_dict["deleted_at"] = int(time.time()) + shout_dict['deleted_at'] = int(time.time()) Shout.update(shout, shout_dict) session.add(shout) session.commit() - await notify_shout(shout_dict, "delete") + await notify_shout(shout_dict, 'delete') return {} diff --git a/resolvers/follower.py b/resolvers/follower.py index e2f36288..637a02a5 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -1,9 +1,5 @@ -import time -from typing import List import logging - -from sqlalchemy import select -from sqlalchemy.orm import aliased +from typing import List from orm.author import Author, AuthorFollower from orm.community import Community @@ -22,84 +18,84 @@ from services.schema import mutation, query logging.basicConfig() -logger = logging.getLogger("\t[resolvers.reaction]\t") +logger = logging.getLogger('\t[resolvers.reaction]\t') logger.setLevel(logging.DEBUG) -@mutation.field("follow") +@mutation.field('follow') @login_required async def follow(_, info, what, slug): try: - user_id = info.context["user_id"] + user_id = info.context['user_id'] 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 what == 'AUTHOR': if author_follow(follower_id, slug): - result = FollowingResult("NEW", "author", slug) - await FollowingManager.push("author", result) + 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": + elif what == 'TOPIC': if topic_follow(follower_id, slug): - result = FollowingResult("NEW", "topic", slug) - await FollowingManager.push("topic", result) - elif what == "COMMUNITY": + 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": + 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) + result = FollowingResult('NEW', 'shout', slug) + await FollowingManager.push('shout', result) except Exception as e: logger.debug(info, what, slug) logger.error(e) - return {"error": str(e)} + return {'error': str(e)} return {} -@mutation.field("unfollow") +@mutation.field('unfollow') @login_required async def unfollow(_, info, what, slug): - user_id = info.context["user_id"] + user_id = info.context['user_id'] try: 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 what == 'AUTHOR': if author_unfollow(follower_id, slug): - result = FollowingResult("DELETED", "author", slug) - await FollowingManager.push("author", result) + 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": + 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": + 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": + 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) + result = FollowingResult('DELETED', 'shout', slug) + await FollowingManager.push('shout', result) except Exception as e: - return {"error": str(e)} + return {'error': str(e)} return {} -@query.field("get_my_followed") +@query.field('get_my_followed') @login_required async def get_my_followed(_, info): - user_id = info.context["user_id"] + user_id = info.context['user_id'] topics = [] authors = [] communities = [] @@ -114,10 +110,7 @@ async def get_my_followed(_, info): .filter(AuthorFollower.author == Author.id) ) - topics_query = ( - session.query(Topic) - .join(TopicFollower, TopicFollower.follower == author_id) - ) + topics_query = session.query(Topic).join(TopicFollower, TopicFollower.follower == author_id) for [author] in session.execute(authors_query): authors.append(author) @@ -127,12 +120,11 @@ async def get_my_followed(_, info): communities = session.query(Community).all() - return {"topics": topics, "authors": authors, "communities": communities} + return {'topics': topics, 'authors': authors, 'communities': communities} - -@query.field("get_shout_followers") -def get_shout_followers(_, _info, slug: str = "", shout_id: int | None = None) -> List[Author]: +@query.field('get_shout_followers') +def get_shout_followers(_, _info, slug: str = '', shout_id: int | None = None) -> List[Author]: followers = [] with local_session() as session: shout = None diff --git a/resolvers/reaction.py b/resolvers/reaction.py index 6400a612..e99f988c 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -1,32 +1,36 @@ +import logging import time from typing import List -import logging -from sqlalchemy import and_, asc, case, desc, func, select, text, or_ +from sqlalchemy import and_, case, desc, func, select from sqlalchemy.orm import aliased, joinedload from sqlalchemy.sql import union from orm.author import Author from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutReactionsFollower, ShoutVisibility -from services.auth import login_required, add_user_role +from services.auth import add_user_role, login_required from services.db import local_session from services.notify import notify_reaction from services.schema import mutation, query from services.viewed import ViewedStorage -logging.basicConfig() -logger = logging.getLogger("\t[resolvers.reaction]\t") +logger = logging.getLogger('\t[resolvers.reaction]\t') logger.setLevel(logging.DEBUG) -def add_stat_columns(q, aliased_reaction): +def add_stat_columns(q, aliased_reaction): q = q.outerjoin(aliased_reaction).add_columns( - func.sum(case((aliased_reaction.kind == ReactionKind.COMMENT.value, 1), else_=0)).label("comments_stat"), - func.sum(case((aliased_reaction.kind == ReactionKind.LIKE.value, 1), else_=0)).label("likes_stat"), - func.sum(case((aliased_reaction.kind == ReactionKind.DISLIKE.value, 1), else_=0)).label("dislikes_stat"), - func.max(case((aliased_reaction.kind != ReactionKind.COMMENT.value, None),else_=aliased_reaction.created_at)).label("last_comment"), + func.sum(case((aliased_reaction.kind == ReactionKind.COMMENT.value, 1), else_=0)).label('comments_stat'), + func.sum(case((aliased_reaction.kind == ReactionKind.LIKE.value, 1), else_=0)).label('likes_stat'), + func.sum(case((aliased_reaction.kind == ReactionKind.DISLIKE.value, 1), else_=0)).label('dislikes_stat'), + func.max( + case( + (aliased_reaction.kind != ReactionKind.COMMENT.value, None), + else_=aliased_reaction.created_at, + ) + ).label('last_comment'), ) return q @@ -77,8 +81,8 @@ def reactions_unfollow(author_id, shout_id: int): session.delete(following) session.commit() return True - except Exception: - pass + except Exception as ex: + logger.debug(ex) return False @@ -92,6 +96,7 @@ def is_published_author(session, author_id): > 0 ) + def is_negative(x): return x in [ ReactionKind.ACCEPT.value, @@ -99,6 +104,7 @@ def is_negative(x): ReactionKind.PROOF.value, ] + def is_positive(x): return x in [ ReactionKind.ACCEPT.value, @@ -106,6 +112,7 @@ def is_positive(x): ReactionKind.PROOF.value, ] + def check_to_publish(session, approver_id, reaction): """set shout to public if publicated approvers amount > 4""" if not reaction.reply_to and is_positive(reaction.kind): @@ -142,7 +149,7 @@ async def set_published(session, shout_id, approver_id): s = session.query(Shout).where(Shout.id == shout_id).first() s.published_at = int(time.time()) s.published_by = approver_id - Shout.update(s, {"visibility": ShoutVisibility.PUBLIC.value}) + Shout.update(s, {'visibility': ShoutVisibility.PUBLIC.value}) author = session.query(Author).filter(Author.id == s.created_by).first() if author: await add_user_role(str(author.user)) @@ -152,7 +159,7 @@ async def set_published(session, shout_id, approver_id): def set_hidden(session, shout_id): s = session.query(Shout).where(Shout.id == shout_id).first() - Shout.update(s, {"visibility": ShoutVisibility.COMMUNITY.value}) + Shout.update(s, {'visibility': ShoutVisibility.COMMUNITY.value}) session.add(s) session.commit() @@ -164,19 +171,19 @@ async def _create_reaction(session, shout, author, reaction): session.commit() # Proposal accepting logic - if rdict.get("reply_to"): - if r.kind in ["LIKE", "APPROVE"] and author.id in shout.authors: + if rdict.get('reply_to'): + if r.kind in ['LIKE', 'APPROVE'] and author.id in shout.authors: replied_reaction = session.query(Reaction).filter(Reaction.id == r.reply_to).first() if replied_reaction: if replied_reaction.kind is ReactionKind.PROPOSE.value: if replied_reaction.range: old_body = shout.body - start, end = replied_reaction.range.split(":") + start, end = replied_reaction.range.split(':') start = int(start) end = int(end) new_body = old_body[:start] + replied_reaction.body + old_body[end:] shout_dict = shout.dict() - shout_dict["body"] = new_body + shout_dict['body'] = new_body Shout.update(shout, shout_dict) session.add(shout) session.commit() @@ -188,43 +195,44 @@ async def _create_reaction(session, shout, author, reaction): await set_published(session, shout.id, author.id) # Reactions auto-following - reactions_follow(author.id, reaction["shout"], True) + reactions_follow(author.id, reaction['shout'], True) - rdict["shout"] = shout.dict() - rdict["created_by"] = author.dict() - rdict["stat"] = {"commented": 0, "reacted": 0, "rating": 0} + rdict['shout'] = shout.dict() + rdict['created_by'] = author.dict() + rdict['stat'] = {'commented': 0, 'reacted': 0, 'rating': 0} # Notifications call - await notify_reaction(rdict, "create") + await notify_reaction(rdict, 'create') return rdict -@mutation.field("create_reaction") + +@mutation.field('create_reaction') @login_required async def create_reaction(_, info, reaction): - user_id = info.context["user_id"] + user_id = info.context['user_id'] - shout_id = reaction.get("shout") + shout_id = reaction.get('shout') if not shout_id: - return {"error": "Shout ID is required to create a reaction."} + return {'error': 'Shout ID is required to create a reaction.'} try: with local_session() as session: shout = session.query(Shout).filter(Shout.id == shout_id).one() author = session.query(Author).filter(Author.user == user_id).first() if shout and author: - reaction["created_by"] = author.id - kind = reaction.get("kind") + reaction['created_by'] = author.id + kind = reaction.get('kind') shout_id = shout.id - if not kind and reaction.get("body"): + if not kind and reaction.get('body'): kind = ReactionKind.COMMENT.value if not kind: - return { "error": "cannot create reaction with this kind"} + return {'error': 'cannot create reaction with this kind'} - if kind in ["LIKE", "DISLIKE", "AGREE", "DISAGREE"]: + if kind in ['LIKE', 'DISLIKE', 'AGREE', 'DISAGREE']: same_reaction = ( session.query(Reaction) .filter( @@ -232,51 +240,51 @@ async def create_reaction(_, info, reaction): Reaction.shout == shout_id, Reaction.created_by == author.id, Reaction.kind == kind, - Reaction.reply_to == reaction.get("reply_to"), + Reaction.reply_to == reaction.get('reply_to'), ) ) .first() ) if same_reaction is not None: - return {"error": "You can't like or dislike same thing twice"} + return {'error': "You can't like or dislike same thing twice"} opposite_reaction_kind = ( ReactionKind.DISLIKE.value - if reaction["kind"] == ReactionKind.LIKE.value + if reaction['kind'] == ReactionKind.LIKE.value else ReactionKind.LIKE.value ) opposite_reaction = ( session.query(Reaction) .filter( and_( - Reaction.shout == reaction["shout"], + Reaction.shout == reaction['shout'], Reaction.created_by == author.id, Reaction.kind == opposite_reaction_kind, - Reaction.reply_to == reaction.get("reply_to"), + Reaction.reply_to == reaction.get('reply_to'), ) ) .first() ) if opposite_reaction is not None: - return {"error": "Remove opposite vote first"} + return {'error': 'Remove opposite vote first'} else: rdict = await _create_reaction(session, shout, author, reaction) - return {"reaction": rdict} + return {'reaction': rdict} except Exception as e: import traceback + traceback.print_exc() - logger.error(f"{type(e).__name__}: {e}") + logger.error(f'{type(e).__name__}: {e}') - return {"error": "Cannot create reaction."} + return {'error': 'Cannot create reaction.'} - -@mutation.field("update_reaction") +@mutation.field('update_reaction') @login_required async def update_reaction(_, info, rid, reaction): - user_id = info.context["user_id"] + user_id = info.context['user_id'] with local_session() as session: q = select(Reaction).filter(Reaction.id == rid) aliased_reaction = aliased(Reaction) @@ -286,83 +294,84 @@ async def update_reaction(_, info, rid, reaction): [r, commented_stat, likes_stat, dislikes_stat, _l] = session.execute(q).unique().one() if not r: - return {"error": "invalid reaction id"} + return {'error': 'invalid reaction id'} 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") + return {'error': 'access denied'} + body = reaction.get('body') if body: r.body = body r.updated_at = int(time.time()) - if r.kind != reaction["kind"]: + if r.kind != reaction['kind']: # TODO: change mind detection can be here pass session.commit() r.stat = { - "commented": commented_stat, - "rating": int(likes_stat or 0) - int(dislikes_stat or 0), + 'commented': commented_stat, + 'rating': int(likes_stat or 0) - int(dislikes_stat or 0), } - await notify_reaction(r.dict(), "update") + await notify_reaction(r.dict(), 'update') - return {"reaction": r} + return {'reaction': r} else: - return {"error": "not authorized"} - return {"error": "cannot create reaction"} + return {'error': 'not authorized'} + return {'error': 'cannot create reaction'} -@mutation.field("delete_reaction") + +@mutation.field('delete_reaction') @login_required async def delete_reaction(_, info, reaction_id): - user_id = info.context["user_id"] + user_id = info.context['user_id'] with local_session() as session: r = session.query(Reaction).filter(Reaction.id == reaction_id).first() if not r: - return {"error": "invalid reaction id"} + return {'error': 'invalid reaction id'} author = session.query(Author).filter(Author.user == user_id).first() if author: if r.created_by is author.id: - return {"error": "access denied"} + return {'error': 'access denied'} if r.kind in [ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]: session.delete(r) session.commit() - await notify_reaction(r.dict(), "delete") + await notify_reaction(r.dict(), 'delete') else: - return {"error": "access denied"} + return {'error': 'access denied'} return {} def apply_reaction_filters(by, q): - if by.get("shout"): - q = q.filter(Shout.slug == by["shout"]) + if by.get('shout'): + q = q.filter(Shout.slug == by['shout']) - elif by.get("shouts"): - q = q.filter(Shout.slug.in_(by["shouts"])) + elif by.get('shouts'): + q = q.filter(Shout.slug.in_(by['shouts'])) - if by.get("created_by"): - q = q.filter(Author.id == by["created_by"]) + if by.get('created_by'): + q = q.filter(Author.id == by['created_by']) - if by.get("topic"): - q = q.filter(Shout.topics.contains(by["topic"])) + if by.get('topic'): + q = q.filter(Shout.topics.contains(by['topic'])) - if by.get("comment"): + if by.get('comment'): q = q.filter(func.length(Reaction.body) > 0) # NOTE: not using ElasticSearch here - by_search = by.get("search", "") + by_search = by.get('search', '') if len(by_search) > 2: - q = q.filter(Reaction.body.ilike(f"%{by_search}%")) + q = q.filter(Reaction.body.ilike(f'%{by_search}%')) - if by.get("after"): - after = int(by["after"]) + if by.get('after'): + after = int(by['after']) q = q.filter(Reaction.created_at > after) return q -@query.field("load_reactions_by") +@query.field('load_reactions_by') async def load_reactions_by(_, info, by, limit=50, offset=0): """ :param info: graphql meta @@ -399,7 +408,7 @@ async def load_reactions_by(_, info, by, limit=50, offset=0): q = q.group_by(Reaction.id, Author.id, Shout.id, aliased_reaction.id) # order by - q = q.order_by(desc("created_at")) + q = q.order_by(desc('created_at')) # pagination q = q.limit(limit).offset(offset) @@ -414,38 +423,45 @@ async def load_reactions_by(_, info, by, limit=50, offset=0): commented_stat, likes_stat, dislikes_stat, - _last_comment + _last_comment, ] in result_rows: reaction.created_by = author reaction.shout = shout reaction.stat = { - "rating": int(likes_stat or 0) - int(dislikes_stat or 0), - "commented": commented_stat - } + 'rating': int(likes_stat or 0) - int(dislikes_stat or 0), + 'commented': commented_stat, + } reactions.append(reaction) # sort if by stat is present - stat_sort = by.get("stat") + stat_sort = by.get('stat') if stat_sort: - reactions = sorted(reactions, key=lambda r: r.stat.get(stat_sort) or r.created_at, reverse=stat_sort.startswith("-")) + reactions = sorted( + reactions, + key=lambda r: r.stat.get(stat_sort) or r.created_at, + reverse=stat_sort.startswith('-'), + ) return reactions - async def reacted_shouts_updates(follower_id: int, limit=50, offset=0) -> List[Shout]: shouts: List[Shout] = [] with local_session() as session: author = session.query(Author).filter(Author.id == follower_id).first() if author: # Shouts where follower is the author - q1 = select(Shout).outerjoin( - Reaction, and_(Reaction.shout_id == Shout.id, Reaction.created_by == follower_id) - ).outerjoin( - Author, Shout.authors.any(id=follower_id) - ).options( - joinedload(Shout.reactions), - joinedload(Shout.authors) + q1 = ( + select(Shout) + .outerjoin( + Reaction, + and_( + Reaction.shout_id == Shout.id, + Reaction.created_by == follower_id, + ), + ) + .outerjoin(Author, Shout.authors.any(id=follower_id)) + .options(joinedload(Shout.reactions), joinedload(Shout.authors)) ) q1 = add_stat_columns(q1, aliased(Reaction)) q1 = q1.filter(Author.id == follower_id).group_by(Shout.id) @@ -454,17 +470,14 @@ async def reacted_shouts_updates(follower_id: int, limit=50, offset=0) -> List[S q2 = ( select(Shout) .join(Reaction, Reaction.shout_id == Shout.id) - .options( - joinedload(Shout.reactions), - joinedload(Shout.authors) - ) + .options(joinedload(Shout.reactions), joinedload(Shout.authors)) .filter(Reaction.created_by == follower_id) .group_by(Shout.id) ) q2 = add_stat_columns(q2, aliased(Reaction)) # Sort shouts by the `last_comment` field - combined_query = union(q1, q2).order_by(desc("last_comment")).limit(limit).offset(offset) + combined_query = union(q1, q2).order_by(desc('last_comment')).limit(limit).offset(offset) results = session.execute(combined_query).scalars() with local_session() as session: for [ @@ -472,27 +485,28 @@ async def reacted_shouts_updates(follower_id: int, limit=50, offset=0) -> List[S commented_stat, likes_stat, dislikes_stat, - last_comment + last_comment, ] in results: shout.stat = { - "viewed": await ViewedStorage.get_shout(shout.slug), - "rating": int(likes_stat or 0) - int(dislikes_stat or 0), - "commented": commented_stat, - "last_comment": last_comment - } + 'viewed': await ViewedStorage.get_shout(shout.slug), + 'rating': int(likes_stat or 0) - int(dislikes_stat or 0), + 'commented': commented_stat, + 'last_comment': last_comment, + } shouts.append(shout) return shouts -@query.field("load_shouts_followed") + +@query.field('load_shouts_followed') @login_required async def load_shouts_followed(_, info, limit=50, offset=0) -> List[Shout]: - user_id = info.context["user_id"] + user_id = info.context['user_id'] with local_session() as session: author = session.query(Author).filter(Author.user == user_id).first() if author: try: - author_id: int = author.dict()["id"] + author_id: int = author.dict()['id'] shouts = await reacted_shouts_updates(author_id, limit, offset) return shouts except Exception as error: diff --git a/resolvers/reader.py b/resolvers/reader.py index 3066a6e8..b8969e59 100644 --- a/resolvers/reader.py +++ b/resolvers/reader.py @@ -1,4 +1,4 @@ -from sqlalchemy import bindparam, distinct, or_, literal +from sqlalchemy import bindparam, distinct, or_ from sqlalchemy.orm import aliased, joinedload from sqlalchemy.sql.expression import and_, asc, case, desc, func, nulls_last, select from starlette.exceptions import HTTPException @@ -7,32 +7,32 @@ from orm.author import Author, AuthorFollower from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutTopic, ShoutVisibility from orm.topic import Topic, TopicFollower +from resolvers.reaction import add_stat_columns +from resolvers.topic import get_random_topic from services.auth import login_required from services.db import local_session from services.schema import query from services.search import SearchService from services.viewed import ViewedStorage -from resolvers.topic import get_random_topic -from resolvers.reaction import add_stat_columns -def apply_filters(q, filters, author_id=None): # noqa: C901 - if filters.get("reacted") and author_id: +def apply_filters(q, filters, author_id=None): + if filters.get('reacted') and author_id: q.join(Reaction, Reaction.created_by == author_id) - by_published = filters.get("published") + by_published = filters.get('published') if by_published: q = q.filter(Shout.visibility == ShoutVisibility.PUBLIC.value) - by_layouts = filters.get("layouts") + by_layouts = filters.get('layouts') if by_layouts: q = q.filter(Shout.layout.in_(by_layouts)) - by_author = filters.get("author") + by_author = filters.get('author') if by_author: q = q.filter(Shout.authors.any(slug=by_author)) - by_topic = filters.get("topic") + by_topic = filters.get('topic') if by_topic: q = q.filter(Shout.topics.any(slug=by_topic)) - by_after = filters.get("after") + by_after = filters.get('after') if by_after: ts = int(by_after) q = q.filter(Shout.created_at > ts) @@ -40,7 +40,7 @@ def apply_filters(q, filters, author_id=None): # noqa: C901 return q -@query.field("get_shout") +@query.field('get_shout') async def get_shout(_, _info, slug=None, shout_id=None): with local_session() as session: q = select(Shout).options( @@ -61,13 +61,19 @@ async def get_shout(_, _info, slug=None, shout_id=None): try: results = session.execute(q).first() if results: - [shout, commented_stat, likes_stat, dislikes_stat, _last_comment] = results + [ + shout, + commented_stat, + likes_stat, + dislikes_stat, + _last_comment, + ] = results shout.stat = { - "viewed": await ViewedStorage.get_shout(shout.slug), + 'viewed': await ViewedStorage.get_shout(shout.slug), # "reacted": reacted_stat, - "commented": commented_stat, - "rating": int(likes_stat or 0) - int(dislikes_stat or 0), + 'commented': commented_stat, + 'rating': int(likes_stat or 0) - int(dislikes_stat or 0), } for author_caption in session.query(ShoutAuthor).join(Shout).where(Shout.slug == slug): @@ -78,7 +84,11 @@ async def get_shout(_, _info, slug=None, shout_id=None): session.query(Topic.slug) .join( ShoutTopic, - and_(ShoutTopic.topic == Topic.id, ShoutTopic.shout == shout.id, ShoutTopic.main == True), + and_( + ShoutTopic.topic == Topic.id, + ShoutTopic.shout == shout.id, + ShoutTopic.main == True, + ), ) .first() ) @@ -87,10 +97,10 @@ async def get_shout(_, _info, slug=None, shout_id=None): shout.main_topic = main_topic[0] return shout except Exception: - raise HTTPException(status_code=404, detail=f"shout {slug or shout_id} not found") + raise HTTPException(status_code=404, detail=f'shout {slug or shout_id} not found') -@query.field("load_shouts_by") +@query.field('load_shouts_by') async def load_shouts_by(_, _info, options): """ :param options: { @@ -126,31 +136,39 @@ async def load_shouts_by(_, _info, options): q = add_stat_columns(q, aliased_reaction) # filters - q = apply_filters(q, options.get("filters", {})) + q = apply_filters(q, options.get('filters', {})) # group q = q.group_by(Shout.id) # order - order_by = options.get("order_by", Shout.published_at) - query_order_by = desc(order_by) if options.get("order_by_desc", True) else asc(order_by) + order_by = options.get('order_by', Shout.published_at) + query_order_by = desc(order_by) if options.get('order_by_desc', True) else asc(order_by) q = q.order_by(nulls_last(query_order_by)) # limit offset - offset = options.get("offset", 0) - limit = options.get("limit", 10) + offset = options.get('offset', 0) + limit = options.get('limit', 10) q = q.limit(limit).offset(offset) shouts = [] with local_session() as session: - for [shout, commented_stat, likes_stat, dislikes_stat, _last_comment] in session.execute(q).unique(): + for [ + shout, + commented_stat, + likes_stat, + dislikes_stat, + _last_comment, + ] in session.execute(q).unique(): main_topic = ( session.query(Topic.slug) .join( ShoutTopic, and_( - ShoutTopic.topic == Topic.id, ShoutTopic.shout == shout.id, ShoutTopic.main == True - ), # noqa: E712 + ShoutTopic.topic == Topic.id, + ShoutTopic.shout == shout.id, + ShoutTopic.main == True, + ), ) .first() ) @@ -158,19 +176,19 @@ async def load_shouts_by(_, _info, options): if main_topic: shout.main_topic = main_topic[0] shout.stat = { - "viewed": await ViewedStorage.get_shout(shout.slug), - "commented": commented_stat, - "rating": int(likes_stat) - int(dislikes_stat), + 'viewed': await ViewedStorage.get_shout(shout.slug), + 'commented': commented_stat, + 'rating': int(likes_stat) - int(dislikes_stat), } shouts.append(shout) return shouts -@query.field("load_shouts_drafts") +@query.field('load_shouts_drafts') @login_required async def load_shouts_drafts(_, info): - user_id = info.context["user_id"] + user_id = info.context['user_id'] q = ( select(Shout) @@ -193,7 +211,11 @@ async def load_shouts_drafts(_, info): session.query(Topic.slug) .join( ShoutTopic, - and_(ShoutTopic.topic == Topic.id, ShoutTopic.shout == shout.id, ShoutTopic.main == True), + and_( + ShoutTopic.topic == Topic.id, + ShoutTopic.shout == shout.id, + ShoutTopic.main == True, + ), ) .first() ) @@ -205,10 +227,10 @@ async def load_shouts_drafts(_, info): return shouts -@query.field("load_shouts_feed") +@query.field('load_shouts_feed') @login_required async def load_shouts_feed(_, info, options): - user_id = info.context["user_id"] + user_id = info.context['user_id'] shouts = [] with local_session() as session: @@ -221,7 +243,9 @@ async def load_shouts_feed(_, info, options): 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))) + .where( + (ShoutAuthor.author.in_(reader_followed_authors)) | (ShoutTopic.topic.in_(reader_followed_topics)) + ) ) q = ( @@ -230,18 +254,24 @@ async def load_shouts_feed(_, info, options): joinedload(Shout.authors), joinedload(Shout.topics), ) - .where(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None), Shout.id.in_(subquery))) + .where( + and_( + Shout.published_at.is_not(None), + Shout.deleted_at.is_(None), + Shout.id.in_(subquery), + ) + ) ) aliased_reaction = aliased(Reaction) q = add_stat_columns(q, aliased_reaction) - q = apply_filters(q, options.get("filters", {}), reader.id) + q = apply_filters(q, options.get('filters', {}), reader.id) - order_by = options.get("order_by", Shout.published_at) + order_by = options.get('order_by', Shout.published_at) - query_order_by = desc(order_by) if options.get("order_by_desc", True) else asc(order_by) - offset = options.get("offset", 0) - limit = options.get("limit", 10) + query_order_by = desc(order_by) if options.get('order_by_desc', True) else asc(order_by) + offset = options.get('offset', 0) + limit = options.get('limit', 10) q = q.group_by(Shout.id).order_by(nulls_last(query_order_by)).limit(limit).offset(offset) @@ -252,7 +282,11 @@ async def load_shouts_feed(_, info, options): session.query(Topic.slug) .join( ShoutTopic, - and_(ShoutTopic.topic == Topic.id, ShoutTopic.shout == shout.id, ShoutTopic.main == True), + and_( + ShoutTopic.topic == Topic.id, + ShoutTopic.shout == shout.id, + ShoutTopic.main == True, + ), ) .first() ) @@ -260,20 +294,20 @@ async def load_shouts_feed(_, info, options): if main_topic: shout.main_topic = main_topic[0] shout.stat = { - "viewed": await ViewedStorage.get_shout(shout.slug), - "reacted": reacted_stat, - "commented": commented_stat, + 'viewed': await ViewedStorage.get_shout(shout.slug), + 'reacted': reacted_stat, + 'commented': commented_stat, } shouts.append(shout) return shouts -@query.field("load_shouts_search") +@query.field('load_shouts_search') async def load_shouts_search(_, _info, text, limit=50, offset=0): if text and len(text) > 2: results = await SearchService.search(text, limit, offset) - results_dict = {r["slug"]: r for r in results} + results_dict = {r['slug']: r for r in results} # print(results_dict) q = ( @@ -289,13 +323,13 @@ async def load_shouts_search(_, _info, text, limit=50, offset=0): with local_session() as session: results = list(session.execute(q).unique()) # print(results) - print(f"[resolvers.reader] searched, preparing {len(results)} results") + print(f'[resolvers.reader] searched, preparing {len(results)} results') for x in results: shout = x[0] - shout_slug = shout.dict().get("slug", "") - score = results_dict.get(shout_slug, {}).get("score", 0) + shout_slug = shout.dict().get('slug', '') + score = results_dict.get(shout_slug, {}).get('score', 0) shout_data = shout.dict() # Convert the Shout instance to a dictionary - shout_data["score"] = score # Add the score to the dictionary + shout_data['score'] = score # Add the score to the dictionary shouts_data.append(shout_data) return shouts_data @@ -304,7 +338,7 @@ async def load_shouts_search(_, _info, text, limit=50, offset=0): @login_required -@query.field("load_shouts_unrated") +@query.field('load_shouts_unrated') async def load_shouts_unrated(_, info, limit: int = 50, offset: int = 0): q = ( select(Shout) @@ -320,7 +354,7 @@ async def load_shouts_unrated(_, info, limit: int = 50, offset: int = 0): Reaction.kind.in_([ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]), ), ) - .outerjoin(Author, Author.user == bindparam("user_id")) + .outerjoin(Author, Author.user == bindparam('user_id')) .where( and_( Shout.deleted_at.is_(None), @@ -337,7 +371,7 @@ async def load_shouts_unrated(_, info, limit: int = 50, offset: int = 0): q = add_stat_columns(q, aliased_reaction) q = q.group_by(Shout.id).order_by(func.random()).limit(limit).offset(offset) - user_id = info.context.get("user_id") + user_id = info.context.get('user_id') if user_id: with local_session() as session: author = session.query(Author).filter(Author.user == user_id).first() @@ -350,19 +384,24 @@ async def load_shouts_unrated(_, info, limit: int = 50, offset: int = 0): async def get_shouts_from_query(q, author_id=None): shouts = [] with local_session() as session: - for [shout,commented_stat, likes_stat, dislikes_stat, last_comment] in session.execute( - q, {"author_id": author_id} - ).unique(): + for [ + shout, + commented_stat, + likes_stat, + dislikes_stat, + _last_comment, + ] in session.execute(q, {'author_id': author_id}).unique(): shouts.append(shout) shout.stat = { - "viewed": await ViewedStorage.get_shout(shout_slug=shout.slug), - "commented": commented_stat, - "rating": int(likes_stat or 0) - int(dislikes_stat or 0), + 'viewed': await ViewedStorage.get_shout(shout_slug=shout.slug), + 'commented': commented_stat, + 'rating': int(likes_stat or 0) - int(dislikes_stat or 0), } return shouts -@query.field("load_shouts_random_top") + +@query.field('load_shouts_random_top') async def load_shouts_random_top(_, _info, options): """ :param _ @@ -383,21 +422,22 @@ async def load_shouts_random_top(_, _info, options): subquery = select(Shout.id).outerjoin(aliased_reaction).where(Shout.deleted_at.is_(None)) - subquery = apply_filters(subquery, options.get("filters", {})) - subquery = subquery.group_by(Shout.id).order_by(desc( + subquery = apply_filters(subquery, options.get('filters', {})) + subquery = subquery.group_by(Shout.id).order_by( + desc( func.sum( case( (Reaction.kind == ReactionKind.LIKE.value, 1), (Reaction.kind == ReactionKind.AGREE.value, 1), (Reaction.kind == ReactionKind.DISLIKE.value, -1), (Reaction.kind == ReactionKind.DISAGREE.value, -1), - else_=0 + else_=0, ) ) ) ) - random_limit = options.get("random_limit") + random_limit = options.get('random_limit') if random_limit: subquery = subquery.limit(random_limit) @@ -412,7 +452,7 @@ async def load_shouts_random_top(_, _info, options): aliased_reaction = aliased(Reaction) q = add_stat_columns(q, aliased_reaction) - limit = options.get("limit", 10) + limit = options.get('limit', 10) q = q.group_by(Shout.id).order_by(func.random()).limit(limit) # print(q.compile(compile_kwargs={"literal_binds": True})) @@ -420,7 +460,7 @@ async def load_shouts_random_top(_, _info, options): return await get_shouts_from_query(q) -@query.field("load_shouts_random_topic") +@query.field('load_shouts_random_topic') async def load_shouts_random_topic(_, info, limit: int = 10): topic = get_random_topic() shouts = [] @@ -431,7 +471,13 @@ async def load_shouts_random_topic(_, info, limit: int = 10): joinedload(Shout.authors), joinedload(Shout.topics), ) - .filter(and_(Shout.deleted_at.is_(None), Shout.visibility == ShoutVisibility.PUBLIC.value, Shout.topics.any(slug=topic.slug))) + .filter( + and_( + Shout.deleted_at.is_(None), + Shout.visibility == ShoutVisibility.PUBLIC.value, + Shout.topics.any(slug=topic.slug), + ) + ) ) aliased_reaction = aliased(Reaction) @@ -441,4 +487,4 @@ async def load_shouts_random_topic(_, info, limit: int = 10): shouts = get_shouts_from_query(q) - return {"topic": topic, "shouts": shouts} + return {'topic': topic, 'shouts': shouts} diff --git a/resolvers/topic.py b/resolvers/topic.py index 277c6e60..73ebd049 100644 --- a/resolvers/topic.py +++ b/resolvers/topic.py @@ -1,3 +1,5 @@ +import logging + from sqlalchemy import and_, distinct, func, select from sqlalchemy.orm import aliased @@ -10,6 +12,10 @@ from services.schema import mutation, query from services.viewed import ViewedStorage +logger = logging.getLogger('\t[resolvers.topic]\t') +logger.setLevel(logging.DEBUG) + + async def followed_topics(follower_id): q = select(Author) q = add_topic_stat_columns(q) @@ -17,17 +23,18 @@ async def followed_topics(follower_id): # Pass the query to the get_topics_from_query function and return the results return await get_topics_from_query(q) + def add_topic_stat_columns(q): aliased_shout_author = aliased(ShoutAuthor) aliased_topic_follower = aliased(TopicFollower) q = ( q.outerjoin(ShoutTopic, Topic.id == ShoutTopic.topic) - .add_columns(func.count(distinct(ShoutTopic.shout)).label("shouts_stat")) + .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.author)).label("authors_stat")) + .add_columns(func.count(distinct(aliased_shout_author.author)).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) @@ -40,17 +47,17 @@ async def get_topics_from_query(q): with local_session() as session: for [topic, shouts_stat, authors_stat, followers_stat] in session.execute(q): topic.stat = { - "shouts": shouts_stat, - "authors": authors_stat, - "followers": followers_stat, - "viewed": await ViewedStorage.get_topic(topic.slug), + 'shouts': shouts_stat, + 'authors': authors_stat, + 'followers': followers_stat, + 'viewed': await ViewedStorage.get_topic(topic.slug), } topics.append(topic) return topics -@query.field("get_topics_all") +@query.field('get_topics_all') async def get_topics_all(_, _info): q = select(Topic) q = add_topic_stat_columns(q) @@ -66,7 +73,7 @@ async def topics_followed_by(author_id): return await get_topics_from_query(q) -@query.field("get_topics_by_community") +@query.field('get_topics_by_community') async def get_topics_by_community(_, _info, community_id: int): q = select(Topic).where(Topic.community == community_id) q = add_topic_stat_columns(q) @@ -74,8 +81,8 @@ async def get_topics_by_community(_, _info, community_id: int): return await get_topics_from_query(q) -@query.field("get_topics_by_author") -async def get_topics_by_author(_, _info, author_id=None, slug="", user=""): +@query.field('get_topics_by_author') +async def get_topics_by_author(_, _info, author_id=None, slug='', user=''): q = select(Topic) q = add_topic_stat_columns(q) if author_id: @@ -88,7 +95,7 @@ async def get_topics_by_author(_, _info, author_id=None, slug="", user=""): return await get_topics_from_query(q) -@query.field("get_topic") +@query.field('get_topic') async def get_topic(_, _info, slug): q = select(Topic).where(Topic.slug == slug) q = add_topic_stat_columns(q) @@ -97,7 +104,7 @@ async def get_topic(_, _info, slug): return topics[0] -@mutation.field("create_topic") +@mutation.field('create_topic') @login_required async def create_topic(_, _info, inp): with local_session() as session: @@ -106,44 +113,44 @@ async def create_topic(_, _info, inp): session.add(new_topic) session.commit() - return {"topic": new_topic} + return {'topic': new_topic} -@mutation.field("update_topic") +@mutation.field('update_topic') @login_required async def update_topic(_, _info, inp): - slug = inp["slug"] + slug = inp['slug'] with local_session() as session: topic = session.query(Topic).filter(Topic.slug == slug).first() if not topic: - return {"error": "topic not found"} + return {'error': 'topic not found'} else: Topic.update(topic, inp) session.add(topic) session.commit() - return {"topic": topic} + return {'topic': topic} -@mutation.field("delete_topic") +@mutation.field('delete_topic') @login_required async def delete_topic(_, info, slug: str): - user_id = info.context["user_id"] + user_id = info.context['user_id'] with local_session() as session: t: Topic = session.query(Topic).filter(Topic.slug == slug).first() if not t: - return {"error": "invalid topic slug"} + return {'error': 'invalid topic slug'} author = session.query(Author).filter(Author.user == user_id).first() if author: if t.created_by != author.id: - return {"error": "access denied"} + return {'error': 'access denied'} session.delete(t) session.commit() return {} else: - return {"error": "access denied"} + return {'error': 'access denied'} def topic_follow(follower_id, slug): @@ -169,12 +176,12 @@ def topic_unfollow(follower_id, slug): session.delete(sub) session.commit() return True - except Exception: - pass + except Exception as ex: + logger.debug(ex) return False -@query.field("get_topics_random") +@query.field('get_topics_random') async def get_topics_random(_, info, amount=12): q = select(Topic) q = q.join(ShoutTopic) diff --git a/resolvers/webhook.py b/resolvers/webhook.py index d0d111d2..23c67eed 100644 --- a/resolvers/webhook.py +++ b/resolvers/webhook.py @@ -15,22 +15,22 @@ class WebhookEndpoint(HTTPEndpoint): try: data = await request.json() if data: - auth = request.headers.get("Authorization") + auth = request.headers.get('Authorization') if auth: - if auth == os.environ.get("WEBHOOK_SECRET"): - user_id: str = data["user"]["id"] - name: str = data["user"]["given_name"] - slug: str = data["user"]["email"].split("@")[0] - slug: str = re.sub("[^0-9a-z]+", "-", slug.lower()) + if auth == os.environ.get('WEBHOOK_SECRET'): + user_id: str = data['user']['id'] + name: str = data['user']['given_name'] + slug: str = data['user']['email'].split('@')[0] + slug: str = re.sub('[^0-9a-z]+', '-', slug.lower()) with local_session() as session: author = session.query(Author).filter(Author.slug == slug).first() if author: - slug = slug + "-" + user_id.split("-").pop() + slug = slug + '-' + user_id.split('-').pop() await create_author(user_id, slug, name) - return JSONResponse({"status": "success"}) + return JSONResponse({'status': 'success'}) except Exception as e: import traceback traceback.print_exc() - return JSONResponse({"status": "error", "message": str(e)}, status_code=500) + return JSONResponse({'status': 'error', 'message': str(e)}, status_code=500) diff --git a/server.py b/server.py deleted file mode 100644 index 45a093b8..00000000 --- a/server.py +++ /dev/null @@ -1,59 +0,0 @@ -import sys - -import uvicorn -from uvicorn.main import logger - -from settings import PORT - -log_settings = { - "version": 1, - "disable_existing_loggers": True, - "formatters": { - "default": { - "()": "uvicorn.logging.DefaultFormatter", - "fmt": "%(levelprefix)s %(message)s", - "use_colors": None, - }, - "access": { - "()": "uvicorn.logging.AccessFormatter", - "fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s', - }, - }, - "handlers": { - "default": { - "formatter": "default", - "class": "logging.StreamHandler", - "stream": "ext://sys.stderr", - }, - "access": { - "formatter": "access", - "class": "logging.StreamHandler", - "stream": "ext://sys.stdout", - }, - }, - "loggers": { - "uvicorn": {"handlers": ["default"], "level": "INFO"}, - "uvicorn.error": {"level": "INFO", "handlers": ["default"], "propagate": True}, - "uvicorn.access": {"handlers": ["access"], "level": "INFO", "propagate": False}, - }, -} - -local_headers = [ - ("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD"), - ("Access-Control-Allow-Origin", "https://localhost:3000"), - ( - "Access-Control-Allow-Headers", - "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization", - ), - ("Access-Control-Expose-Headers", "Content-Length,Content-Range"), - ("Access-Control-Allow-Credentials", "true"), -] - - -def exception_handler(_et, exc, _tb): - logger.error(..., exc_info=(type(exc), exc, exc.__traceback__)) - - -if __name__ == "__main__": - sys.excepthook = exception_handler - uvicorn.run("main:app", host="0.0.0.0", port=PORT, proxy_headers=True, server_header=True) diff --git a/services/auth.py b/services/auth.py index 8106c825..88d278db 100644 --- a/services/auth.py +++ b/services/auth.py @@ -1,90 +1,95 @@ -from functools import wraps import logging +from functools import wraps from aiohttp import ClientSession from starlette.exceptions import HTTPException -from settings import AUTH_URL, AUTH_SECRET +from settings import AUTH_SECRET, AUTH_URL logging.basicConfig() -logger = logging.getLogger("\t[services.auth]\t") +logger = logging.getLogger('\t[services.auth]\t') logger.setLevel(logging.DEBUG) -async def request_data(gql, headers = { "Content-Type": "application/json" }): +async def request_data(gql, headers=None): + if headers is None: + headers = {'Content-Type': 'application/json'} try: # Asynchronous HTTP request to the authentication server async with ClientSession() as session: async with session.post(AUTH_URL, json=gql, headers=headers) as response: if response.status == 200: data = await response.json() - errors = data.get("errors") + errors = data.get('errors') if errors: - logger.error(f"[services.auth] errors: {errors}") + logger.error(f'[services.auth] errors: {errors}') else: return data except Exception as e: # Handling and logging exceptions during authentication check - logger.error(f"[services.auth] request_data error: {e}") + logger.error(f'[services.auth] request_data error: {e}') return None - async def check_auth(req) -> str | None: - token = req.headers.get("Authorization") - user_id = "" + token = req.headers.get('Authorization') + user_id = '' if token: # Logging the authentication token - logger.error(f"[services.auth] checking auth token: {token}") - query_name = "validate_jwt_token" - operation = "ValidateToken" + logger.error(f'[services.auth] checking auth token: {token}') + query_name = 'validate_jwt_token' + operation = 'ValidateToken' variables = { - "params": { - "token_type": "access_token", - "token": token, + 'params': { + 'token_type': 'access_token', + 'token': token, } } gql = { - "query": f"query {operation}($params: ValidateJWTTokenInput!) {{ {query_name}(params: $params) {{ is_valid claims }} }}", - "variables": variables, - "operationName": operation, + 'query': f'query {operation}($params: ValidateJWTTokenInput!) {{ {query_name}(params: $params) {{ is_valid claims }} }}', + 'variables': variables, + 'operationName': operation, } data = await request_data(gql) if data: - user_id = data.get("data", {}).get(query_name, {}).get("claims", {}).get("sub") + user_id = data.get('data', {}).get(query_name, {}).get('claims', {}).get('sub') return user_id if not user_id: - raise HTTPException(status_code=401, detail="Unauthorized") + raise HTTPException(status_code=401, detail='Unauthorized') async def add_user_role(user_id): - logger.info(f"[services.auth] add author role for user_id: {user_id}") - query_name = "_update_user" - operation = "UpdateUserRoles" - headers = {"Content-Type": "application/json", "x-authorizer-admin-secret": AUTH_SECRET} - variables = {"params": {"roles": "author, reader", "id": user_id}} + logger.info(f'[services.auth] add author role for user_id: {user_id}') + query_name = '_update_user' + operation = 'UpdateUserRoles' + headers = { + 'Content-Type': 'application/json', + 'x-authorizer-admin-secret': AUTH_SECRET, + } + variables = {'params': {'roles': 'author, reader', 'id': user_id}} gql = { - "query": f"mutation {operation}($params: UpdateUserInput!) {{ {query_name}(params: $params) {{ id roles }} }}", - "variables": variables, - "operationName": operation, + 'query': f'mutation {operation}($params: UpdateUserInput!) {{ {query_name}(params: $params) {{ id roles }} }}', + 'variables': variables, + 'operationName': operation, } data = await request_data(gql, headers) if data: - user_id = data.get("data", {}).get(query_name, {}).get("id") + user_id = data.get('data', {}).get(query_name, {}).get('id') return user_id + def login_required(f): @wraps(f) async def decorated_function(*args, **kwargs): info = args[1] context = info.context - req = context.get("request") + req = context.get('request') user_id = await check_auth(req) if user_id: - context["user_id"] = user_id.strip() + context['user_id'] = user_id.strip() return await f(*args, **kwargs) return decorated_function @@ -96,7 +101,7 @@ def auth_request(f): req = args[0] user_id = await check_auth(req) if user_id: - req["user_id"] = user_id.strip() + req['user_id'] = user_id.strip() return await f(*args, **kwargs) return decorated_function diff --git a/services/db.py b/services/db.py index ad682fc2..7d6b6808 100644 --- a/services/db.py +++ b/services/db.py @@ -1,47 +1,48 @@ +import logging import math import time -import logging # from contextlib import contextmanager from typing import Any, Callable, Dict, TypeVar # from psycopg2.errors import UniqueViolation from sqlalchemy import Column, Integer, create_engine, event +from sqlalchemy.engine import Engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import Session from sqlalchemy.sql.schema import Table -from sqlalchemy.engine import Engine from settings import DB_URL + logging.basicConfig() -logger = logging.getLogger("\t [sqlalchemy.profiler]\t") +logger = logging.getLogger('\t [sqlalchemy.profiler]\t') logger.setLevel(logging.DEBUG) -@event.listens_for(Engine, "before_cursor_execute") +@event.listens_for(Engine, 'before_cursor_execute') def before_cursor_execute(conn, cursor, statement, parameters, context, executemany): - conn.info.setdefault("query_start_time", []).append(time.time()) + conn.info.setdefault('query_start_time', []).append(time.time()) # logger.debug(f" {statement}") -@event.listens_for(Engine, "after_cursor_execute") +@event.listens_for(Engine, 'after_cursor_execute') def after_cursor_execute(conn, cursor, statement, parameters, context, executemany): - total = time.time() - conn.info["query_start_time"].pop(-1) + total = time.time() - conn.info['query_start_time'].pop(-1) total = math.floor(total * 10000) / 10000 if total > 35: - print(f"\n{statement}\n----------------- Finished in {total} s ") + print(f'\n{statement}\n----------------- Finished in {total} s ') engine = create_engine(DB_URL, echo=False, pool_size=10, max_overflow=20) -T = TypeVar("T") +T = TypeVar('T') REGISTRY: Dict[str, type] = {} # @contextmanager -def local_session(src=""): +def local_session(src=''): return Session(bind=engine, expire_on_commit=False) # try: @@ -69,7 +70,7 @@ class Base(declarative_base()): __init__: Callable __allow_unmapped__ = True __abstract__ = True - __table_args__ = {"extend_existing": True} + __table_args__ = {'extend_existing': True} id = Column(Integer, primary_key=True) @@ -78,12 +79,12 @@ class Base(declarative_base()): 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") + 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}") + print(f'[services.db] Error dict: {e}') return {} def update(self, values: Dict[str, Any]) -> None: diff --git a/services/following.py b/services/following.py index 4d04c27b..3ce3d06f 100644 --- a/services/following.py +++ b/services/following.py @@ -19,7 +19,7 @@ class Following: class FollowingManager: lock = asyncio.Lock() followers_by_kind = {} - data = {"author": [], "topic": [], "shout": [], "community": []} + data = {'author': [], 'topic': [], 'shout': [], 'community': []} @staticmethod async def register(kind, uid): @@ -41,7 +41,7 @@ class FollowingManager: async with FollowingManager.lock: entities = FollowingManager.followers_by_kind.get(kind, []) for entity in entities[:]: # Use a copy to iterate - if payload.shout["created_by"] == entity.uid: + if payload.shout['created_by'] == entity.uid: entity.queue.put_nowait(payload) except Exception as e: print(Exception(e)) diff --git a/services/notify.py b/services/notify.py index ff9b893d..597835b5 100644 --- a/services/notify.py +++ b/services/notify.py @@ -3,43 +3,43 @@ import json from services.rediscache import redis -async def notify_reaction(reaction, action: str = "create"): - channel_name = "reaction" - data = {"payload": reaction, "action": action} +async def notify_reaction(reaction, action: str = 'create'): + channel_name = 'reaction' + data = {'payload': reaction, 'action': action} try: await redis.publish(channel_name, json.dumps(data)) except Exception as e: - print(f"[services.notify] Failed to publish to channel {channel_name}: {e}") + print(f'[services.notify] Failed to publish to channel {channel_name}: {e}') -async def notify_shout(shout, action: str = "create"): - channel_name = "shout" - data = {"payload": shout, "action": action} +async def notify_shout(shout, action: str = 'create'): + channel_name = 'shout' + data = {'payload': shout, 'action': action} try: await redis.publish(channel_name, json.dumps(data)) except Exception as e: - print(f"[services.notify] Failed to publish to channel {channel_name}: {e}") + print(f'[services.notify] Failed to publish to channel {channel_name}: {e}') -async def notify_follower(follower: dict, author_id: int, action: str = "follow"): - channel_name = f"follower:{author_id}" +async def notify_follower(follower: dict, author_id: int, action: str = 'follow'): + channel_name = f'follower:{author_id}' try: # Simplify dictionary before publishing - simplified_follower = {k: follower[k] for k in ["id", "name", "slug", "pic"]} + simplified_follower = {k: follower[k] for k in ['id', 'name', 'slug', 'pic']} - data = {"payload": simplified_follower, "action": action} + data = {'payload': simplified_follower, 'action': action} # Convert data to JSON string json_data = json.dumps(data) # Ensure the data is not empty before publishing if not json_data: - raise ValueError("Empty data to publish.") + raise ValueError('Empty data to publish.') # Use the 'await' keyword when publishing await redis.publish(channel_name, json_data) except Exception as e: # Log the error and re-raise it - print(f"[services.notify] Failed to publish to channel {channel_name}: {e}") + print(f'[services.notify] Failed to publish to channel {channel_name}: {e}') raise diff --git a/services/rediscache.py b/services/rediscache.py index 2695ebb6..129213aa 100644 --- a/services/rediscache.py +++ b/services/rediscache.py @@ -1,9 +1,11 @@ +import logging + import redis.asyncio as aredis from settings import REDIS_URL -import logging -logger = logging.getLogger("[services.redis] ") + +logger = logging.getLogger('[services.redis] ') logger.setLevel(logging.DEBUG) @@ -23,7 +25,7 @@ class RedisCache: async def execute(self, command, *args, **kwargs): if self._client: try: - logger.debug(f"{command} {args} {kwargs}") + logger.debug(f'{command} {args} {kwargs}') r = await self._client.execute_command(command, *args, **kwargs) logger.debug(type(r)) logger.debug(r) @@ -51,6 +53,7 @@ class RedisCache: return await self._client.publish(channel, data) + redis = RedisCache() -__all__ = ["redis"] +__all__ = ['redis'] diff --git a/services/schema.py b/services/schema.py index 84364baa..b451d7f0 100644 --- a/services/schema.py +++ b/services/schema.py @@ -1,5 +1,6 @@ from ariadne import MutationType, QueryType # , ScalarType + # datetime_scalar = ScalarType("DateTime") query = QueryType() mutation = MutationType() diff --git a/services/search.py b/services/search.py index 4403f8f7..0db6ede6 100644 --- a/services/search.py +++ b/services/search.py @@ -15,28 +15,28 @@ class SearchService: @staticmethod async def init(session): async with SearchService.lock: - logging.info("[services.search] Initializing SearchService") + logging.info('[services.search] Initializing SearchService') @staticmethod async def search(text: str, limit: int = 50, offset: int = 0) -> List[Shout]: payload = [] try: # TODO: add ttl for redis cached search results - cached = await redis.execute("GET", text) + cached = await redis.execute('GET', text) if not cached: async with SearchService.lock: # Use aiohttp to send a request to ElasticSearch async with aiohttp.ClientSession() as session: - search_url = f"https://search.discours.io/search?q={text}" + search_url = f'https://search.discours.io/search?q={text}' async with session.get(search_url) as response: if response.status == 200: payload = await response.json() - await redis.execute("SET", text, json.dumps(payload)) # use redis as cache + await redis.execute('SET', text, json.dumps(payload)) # use redis as cache else: - logging.error(f"[services.search] response: {response.status} {await response.text()}") + logging.error(f'[services.search] response: {response.status} {await response.text()}') elif isinstance(cached, str): payload = json.loads(cached) except Exception as e: - logging.error(f"[services.search] Error during search: {e}") + logging.error(f'[services.search] Error during search: {e}') return payload[offset : offset + limit] diff --git a/services/unread.py b/services/unread.py index 01c7555f..e8b2a898 100644 --- a/services/unread.py +++ b/services/unread.py @@ -1,9 +1,10 @@ -from services.rediscache import redis import json +from services.rediscache import redis + async def get_unread_counter(chat_id: str, author_id: int) -> int: - r = await redis.execute("LLEN", f"chats/{chat_id}/unread/{author_id}") + r = await redis.execute('LLEN', f'chats/{chat_id}/unread/{author_id}') if isinstance(r, str): return int(r) elif isinstance(r, int): @@ -11,8 +12,9 @@ async def get_unread_counter(chat_id: str, author_id: int) -> int: else: return 0 + async def get_total_unread_counter(author_id: int) -> int: - chats_set = await redis.execute("SMEMBERS", f"chats_by_author/{author_id}") + chats_set = await redis.execute('SMEMBERS', f'chats_by_author/{author_id}') s = 0 if isinstance(chats_set, str): chats_set = json.loads(chats_set) diff --git a/services/viewed.py b/services/viewed.py index d62887a9..31d24afc 100644 --- a/services/viewed.py +++ b/services/viewed.py @@ -1,40 +1,39 @@ -import os -from typing import Dict, List -import logging -import time -import json import asyncio +import json +import logging +import os +import time from datetime import datetime, timedelta, timezone -from os import environ +from typing import Dict # ga from apiclient.discovery import build from google.oauth2.service_account import Credentials -import pandas as pd from orm.author import Author from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic from services.db import local_session + # Настройка журналирования logging.basicConfig(level=logging.DEBUG) -logger = logging.getLogger("\t[services.viewed]\t") +logger = logging.getLogger('\t[services.viewed]\t') logger.setLevel(logging.DEBUG) -# Пути к ключевым файлам и идентификатор представления в Google Analytics -GOOGLE_KEYFILE_PATH = os.environ.get("GOOGLE_KEYFILE_PATH", '/dump/google-service.json') -GOOGLE_GA_VIEW_ID = os.environ.get("GOOGLE_GA_VIEW_ID", "") -gaBaseUrl = "https://analyticsreporting.googleapis.com/v4" +GOOGLE_KEYFILE_PATH = os.environ.get('GOOGLE_KEYFILE_PATH', '/dump/google-service.json') +GOOGLE_GA_VIEW_ID = os.environ.get('GOOGLE_GA_VIEW_ID', '') +# GOOGLE_ANALYTICS_API = 'https://analyticsreporting.googleapis.com/v4' +GOOGLE_ANALYTICS_SCOPES = ['https://www.googleapis.com/auth/analytics.readonly'] # Функция для создания объекта службы Analytics Reporting API V4 def get_service(): - SCOPES = ['https://www.googleapis.com/auth/analytics.readonly'] - credentials = Credentials.from_service_account_file(GOOGLE_KEYFILE_PATH, scopes=SCOPES) + credentials = Credentials.from_service_account_file(GOOGLE_KEYFILE_PATH, scopes=GOOGLE_ANALYTICS_SCOPES) service = build(serviceName='analyticsreporting', version='v4', credentials=credentials) return service + class ViewedStorage: lock = asyncio.Lock() views_by_shout = {} @@ -45,7 +44,7 @@ class ViewedStorage: analytics_client = None auth_result = None disabled = False - date_range = "" + date_range = '' @staticmethod async def init(): @@ -54,13 +53,13 @@ class ViewedStorage: async with self.lock: if os.path.exists(GOOGLE_KEYFILE_PATH): self.analytics_client = get_service() - logger.info(f" * Постоянная авторизация в Google Analytics {self.analytics_client}") + logger.info(f' * Постоянная авторизация в Google Analytics {self.analytics_client}') # Загрузка предварительно подсчитанных просмотров из файла JSON self.load_precounted_views() # Установка диапазона дат на основе времени создания файла views.json - views_json_path = "/dump/views.json" + views_json_path = '/dump/views.json' creation_time = datetime.fromtimestamp(os.path.getctime(views_json_path)) end_date = datetime.now(timezone.utc).strftime('%Y-%m-%d') start_date = creation_time.strftime('%Y-%m-%d') @@ -69,7 +68,7 @@ class ViewedStorage: views_stat_task = asyncio.create_task(self.worker()) logger.info(views_stat_task) else: - logger.info(" * Пожалуйста, добавьте ключевой файл Google Analytics") + logger.info(' * Пожалуйста, добавьте ключевой файл Google Analytics') self.disabled = True @staticmethod @@ -77,33 +76,43 @@ class ViewedStorage: """Загрузка предварительно подсчитанных просмотров из файла JSON""" self = ViewedStorage try: - with open("/dump/views.json", "r") as file: + with open('/dump/views.json', 'r') as file: precounted_views = json.load(file) self.views_by_shout.update(precounted_views) - logger.info(f" * {len(precounted_views)} предварительно подсчитанных просмотров shouts успешно загружены.") + logger.info( + f' * {len(precounted_views)} предварительно подсчитанных просмотров shouts успешно загружены.' + ) except Exception as e: - logger.error(f"Ошибка загрузки предварительно подсчитанных просмотров: {e}") + logger.error(f'Ошибка загрузки предварительно подсчитанных просмотров: {e}') @staticmethod async def update_pages(): """Запрос всех страниц от Google Analytics, отсортированных по количеству просмотров""" self = ViewedStorage if not self.disabled and GOOGLE_GA_VIEW_ID: - logger.info(" ⎧ Обновление данных просмотров от Google Analytics ---") + logger.info(' ⎧ Обновление данных просмотров от Google Analytics ---') try: start = time.time() async with self.lock: if self.analytics_client: - data = self.analytics_client.reports().batchGet(body={ - 'reportRequests': [{ - 'viewId': GOOGLE_GA_VIEW_ID, - 'dateRanges': self.date_range, - 'metrics': [{'expression': 'ga:pageviews'}], - 'dimensions': [{'name': 'ga:pagePath'}], - }] - }).execute() + data = ( + self.analytics_client.reports() + .batchGet( + body={ + 'reportRequests': [ + { + 'viewId': GOOGLE_GA_VIEW_ID, + 'dateRanges': self.date_range, + 'metrics': [{'expression': 'ga:pageviews'}], + 'dimensions': [{'name': 'ga:pagePath'}], + } + ] + } + ) + .execute() + ) if isinstance(data, dict): - slugs = set([]) + slugs = set() reports = data.get('reports', []) if reports and isinstance(reports, list): rows = list(reports[0].get('data', {}).get('rows', [])) @@ -113,7 +122,7 @@ class ViewedStorage: dimensions = row.get('dimensions', []) if isinstance(dimensions, list) and dimensions: page_path = dimensions[0] - slug = page_path.split("discours.io/")[-1] + slug = page_path.split('discours.io/')[-1] views_count = int(row['metrics'][0]['values'][0]) # Обновление данных в хранилище @@ -124,15 +133,15 @@ class ViewedStorage: # Запись путей страниц для логирования slugs.add(slug) - logger.info(f" ⎪ Собрано страниц: {len(slugs)} ") + logger.info(f' ⎪ Собрано страниц: {len(slugs)} ') end = time.time() - logger.info(" ⎪ Обновление страниц заняло %fs " % (end - start)) + logger.info(' ⎪ Обновление страниц заняло %fs ' % (end - start)) except Exception: import traceback - traceback.print_exc() + traceback.print_exc() @staticmethod async def get_shout(shout_slug) -> int: @@ -178,10 +187,14 @@ class ViewedStorage: dictionary[key] = list(set(dictionary.get(key, []) + [value])) # Обновление тем и авторов с использованием вспомогательной функции - for [_shout_topic, topic] in session.query(ShoutTopic, Topic).join(Topic).join(Shout).where(Shout.slug == shout_slug).all(): + for [_shout_topic, topic] in ( + session.query(ShoutTopic, Topic).join(Topic).join(Shout).where(Shout.slug == shout_slug).all() + ): update_groups(self.shouts_by_topic, topic.slug, shout_slug) - for [_shout_topic, author] in session.query(ShoutAuthor, Author).join(Author).join(Shout).where(Shout.slug == shout_slug).all(): + for [_shout_topic, author] in ( + session.query(ShoutAuthor, Author).join(Author).join(Shout).where(Shout.slug == shout_slug).all() + ): update_groups(self.shouts_by_author, author.slug, shout_slug) @staticmethod @@ -194,20 +207,20 @@ class ViewedStorage: while True: try: - logger.info(" - Обновление записей...") + logger.info(' - Обновление записей...') await self.update_pages() failed = 0 except Exception: failed += 1 - logger.info(" - Обновление не удалось #%d, ожидание 10 секунд" % failed) + logger.info(' - Обновление не удалось #%d, ожидание 10 секунд' % failed) if failed > 3: - logger.info(" - Больше не пытаемся обновить") + logger.info(' - Больше не пытаемся обновить') break if failed == 0: when = datetime.now(timezone.utc) + timedelta(seconds=self.period) t = format(when.astimezone().isoformat()) - logger.info(" ⎩ Следующее обновление: %s" % (t.split("T")[0] + " " + t.split("T")[1].split(".")[0])) + logger.info(' ⎩ Следующее обновление: %s' % (t.split('T')[0] + ' ' + t.split('T')[1].split('.')[0])) await asyncio.sleep(self.period) else: await asyncio.sleep(10) - logger.info(" - Попытка снова обновить данные") + logger.info(' - Попытка снова обновить данные') diff --git a/settings.py b/settings.py index 87575826..e2828b3f 100644 --- a/settings.py +++ b/settings.py @@ -1,17 +1,18 @@ -from os import environ import sys +from os import environ + PORT = 8080 DB_URL = ( - environ.get("DATABASE_URL", "").replace("postgres://", "postgresql://") - or environ.get("DB_URL", "").replace("postgres://", "postgresql://") - or "postgresql://postgres@localhost:5432/discoursio" + environ.get('DATABASE_URL', '').replace('postgres://', 'postgresql://') + or environ.get('DB_URL', '').replace('postgres://', 'postgresql://') + or 'postgresql://postgres@localhost:5432/discoursio' ) -REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1" -API_BASE = environ.get("API_BASE") or "" -AUTH_URL = environ.get("AUTH_URL") or "" -SENTRY_DSN = environ.get("SENTRY_DSN") -DEV_SERVER_PID_FILE_NAME = "dev-server.pid" -MODE = "development" if "dev" in sys.argv else "production" +REDIS_URL = environ.get('REDIS_URL') or 'redis://127.0.0.1' +API_BASE = environ.get('API_BASE') or '' +AUTH_URL = environ.get('AUTH_URL') or '' +SENTRY_DSN = environ.get('SENTRY_DSN') +DEV_SERVER_PID_FILE_NAME = 'dev-server.pid' +MODE = 'development' if 'dev' in sys.argv else 'production' -AUTH_SECRET = environ.get("AUTH_SECRET") or "nothing" +AUTH_SECRET = environ.get('AUTH_SECRET') or 'nothing'