granian+precommit

This commit is contained in:
Untone 2024-01-25 22:41:27 +03:00
parent ad3fd32a6e
commit 4a5f1d634a
35 changed files with 835 additions and 764 deletions

View File

@ -2,7 +2,7 @@ fail_fast: true
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1 rev: v4.5.0
hooks: hooks:
- id: check-yaml - id: check-yaml
- id: check-toml - id: check-toml
@ -14,11 +14,9 @@ repos:
- id: check-ast - id: check-ast
- id: check-merge-conflict - id: check-merge-conflict
- repo: local - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.13
hooks: hooks:
- id: lint-python - id: ruff
name: Lint Python args: [--fix]
entry: ruff check - id: ruff-format
types: [python]
language: system
pass_filenames: false

View File

@ -1,3 +1,8 @@
[0.2.22]
- added precommit hook
- fmt
- granian asgi
[0.2.21] [0.2.21]
- fix: rating logix - fix: rating logix
- fix: load_top_random_shouts - fix: load_top_random_shouts

View File

@ -9,4 +9,5 @@ RUN apt-get update && apt-get install -y git gcc curl postgresql && \
poetry config virtualenvs.create false && \ poetry config virtualenvs.create false && \
poetry install --no-dev 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

View File

@ -29,7 +29,7 @@ mkdir .venv
python3.12 -m venv .venv python3.12 -m venv .venv
poetry env use .venv/bin/python3.12 poetry env use .venv/bin/python3.12
poetry update poetry update
poetry run python server.py poetry granian --no-ws --host 0.0.0.0 --port 8000 --interface asgi main:app
``` ```
## Services ## Services

20
main.py
View File

@ -4,7 +4,6 @@ from os.path import exists
from ariadne import load_schema_from_path, make_executable_schema from ariadne import load_schema_from_path, make_executable_schema
from ariadne.asgi import GraphQL from ariadne.asgi import GraphQL
from sentry_sdk.integrations.aiohttp import AioHttpIntegration from sentry_sdk.integrations.aiohttp import AioHttpIntegration
from sentry_sdk.integrations.ariadne import AriadneIntegration from sentry_sdk.integrations.ariadne import AriadneIntegration
from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.integrations.redis import RedisIntegration
@ -16,28 +15,29 @@ from starlette.routing import Route
from resolvers.webhook import WebhookEndpoint from resolvers.webhook import WebhookEndpoint
from services.rediscache import redis from services.rediscache import redis
from services.schema import resolvers from services.schema import resolvers
from settings import DEV_SERVER_PID_FILE_NAME, MODE, SENTRY_DSN
from services.viewed import ViewedStorage 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(): async def start_up():
print(f"[main] starting in {MODE} mode") print(f'[main] starting in {MODE} mode')
await redis.connect() await redis.connect()
# start viewed service # start viewed service
await ViewedStorage.init() await ViewedStorage.init()
if MODE == "development": if MODE == 'development':
# pid file management # pid file management
if not exists(DEV_SERVER_PID_FILE_NAME): 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())) f.write(str(os.getpid()))
if MODE == "production": if MODE == 'production':
# sentry monitoring # sentry monitoring
try: try:
import sentry_sdk import sentry_sdk
@ -54,7 +54,7 @@ async def start_up():
], ],
) )
except Exception as e: except Exception as e:
print("[sentry] init error") print('[sentry] init error')
print(e) print(e)
@ -62,5 +62,5 @@ async def shutdown():
await redis.disconnect() 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]) app = Starlette(routes=routes, debug=True, on_startup=[start_up], on_shutdown=[shutdown])

View File

@ -1,46 +1,45 @@
import time import time
from sqlalchemy import JSON as JSONType from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from services.db import Base from services.db import Base
class AuthorRating(Base): class AuthorRating(Base):
__tablename__ = "author_rating" __tablename__ = 'author_rating'
id = None # type: ignore id = None # type: ignore
rater = 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) author = Column(ForeignKey('author.id'), primary_key=True, index=True)
plus = Column(Boolean) plus = Column(Boolean)
class AuthorFollower(Base): class AuthorFollower(Base):
__tablename__ = "author_follower" __tablename__ = 'author_follower'
id = None # type: ignore id = None # type: ignore
follower = 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) author = Column(ForeignKey('author.id'), primary_key=True, index=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
auto = Column(Boolean, nullable=False, default=False) auto = Column(Boolean, nullable=False, default=False)
class Author(Base): class Author(Base):
__tablename__ = "author" __tablename__ = 'author'
user = Column(String, unique=True) # unbounded link with authorizer's User type 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") slug = Column(String, unique=True, comment="Author's slug")
bio = Column(String, nullable=True, comment="Bio") # status description bio = Column(String, nullable=True, comment='Bio') # status description
about = Column(String, nullable=True, comment="About") # long and formatted about = Column(String, nullable=True, comment='About') # long and formatted
pic = Column(String, nullable=True, comment="Picture") pic = Column(String, nullable=True, comment='Picture')
links = Column(JSONType, nullable=True, comment="Links") links = Column(JSON, nullable=True, comment='Links')
ratings = relationship(AuthorRating, foreign_keys=AuthorRating.author) ratings = relationship(AuthorRating, foreign_keys=AuthorRating.author)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
last_seen = 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())) 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')

View File

@ -6,20 +6,20 @@ from services.db import Base
class ShoutCollection(Base): class ShoutCollection(Base):
__tablename__ = "shout_collection" __tablename__ = 'shout_collection'
id = None # type: ignore id = None # type: ignore
shout = Column(ForeignKey("shout.id"), primary_key=True) shout = Column(ForeignKey('shout.id'), primary_key=True)
collection = Column(ForeignKey("collection.id"), primary_key=True) collection = Column(ForeignKey('collection.id'), primary_key=True)
class Collection(Base): class Collection(Base):
__tablename__ = "collection" __tablename__ = 'collection'
slug = Column(String, unique=True) slug = Column(String, unique=True)
title = Column(String, nullable=False, comment="Title") title = Column(String, nullable=False, comment='Title')
body = Column(String, nullable=True, comment="Body") body = Column(String, nullable=True, comment='Body')
pic = Column(String, nullable=True, comment="Picture") pic = Column(String, nullable=True, comment='Picture')
created_at = Column(Integer, default=lambda: int(time.time())) created_at = Column(Integer, default=lambda: int(time.time()))
created_by = Column(ForeignKey("author.id"), comment="Created By") created_by = Column(ForeignKey('author.id'), comment='Created By')
publishedAt = Column(Integer, default=lambda: int(time.time())) published_at = Column(Integer, default=lambda: int(time.time()))

View File

@ -8,34 +8,34 @@ from services.db import Base, local_session
class CommunityAuthor(Base): class CommunityAuthor(Base):
__tablename__ = "community_author" __tablename__ = 'community_author'
id = None # type: ignore id = None # type: ignore
author = Column(ForeignKey("author.id"), primary_key=True) author = Column(ForeignKey('author.id'), primary_key=True)
community = Column(ForeignKey("community.id"), primary_key=True) community = Column(ForeignKey('community.id'), primary_key=True)
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time())) joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
role = Column(String, nullable=False) role = Column(String, nullable=False)
class Community(Base): class Community(Base):
__tablename__ = "community" __tablename__ = 'community'
name = Column(String, nullable=False) name = Column(String, nullable=False)
slug = Column(String, nullable=False, unique=True) slug = Column(String, nullable=False, unique=True)
desc = Column(String, nullable=False, default="") desc = Column(String, nullable=False, default='')
pic = Column(String, nullable=False, default="") pic = Column(String, nullable=False, default='')
created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
authors = relationship(lambda: Author, secondary=CommunityAuthor.__tablename__) authors = relationship(lambda: Author, secondary=CommunityAuthor.__tablename__)
@staticmethod @staticmethod
def init_table(): def init_table():
with local_session("orm.community") as session: with local_session('orm.community') as session:
d = session.query(Community).filter(Community.slug == "discours").first() d = session.query(Community).filter(Community.slug == 'discours').first()
if not d: if not d:
d = Community(name="Дискурс", slug="discours") d = Community(name='Дискурс', slug='discours')
session.add(d) session.add(d)
session.commit() session.commit()
print("[orm.community] created community %s" % d.slug) print('[orm.community] created community %s' % d.slug)
Community.default_community = d Community.default_community = d
print("[orm.community] default community is %s" % d.slug) print('[orm.community] default community is %s' % d.slug)

View File

@ -9,17 +9,17 @@ from services.db import Base
class InviteStatus(Enumeration): class InviteStatus(Enumeration):
PENDING = "PENDING" PENDING = 'PENDING'
ACCEPTED = "ACCEPTED" ACCEPTED = 'ACCEPTED'
REJECTED = "REJECTED" REJECTED = 'REJECTED'
class Invite(Base): class Invite(Base):
__tablename__ = "invite" __tablename__ = 'invite'
inviter_id = Column(ForeignKey("author.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) author_id = Column(ForeignKey('author.id'), nullable=False, index=True)
shout_id = Column(ForeignKey("shout.id"), nullable=False, index=True) shout_id = Column(ForeignKey('shout.id'), nullable=False, index=True)
status = Column(String, default=InviteStatus.PENDING.value) status = Column(String, default=InviteStatus.PENDING.value)
inviter = relationship(Author, foreign_keys=[inviter_id]) inviter = relationship(Author, foreign_keys=[inviter_id])

View File

@ -10,34 +10,34 @@ class ReactionKind(Enumeration):
# TYPE = <reaction index> # rating diff # TYPE = <reaction index> # rating diff
# editor mode # editor mode
AGREE = "AGREE" # +1 AGREE = 'AGREE' # +1
DISAGREE = "DISAGREE" # -1 DISAGREE = 'DISAGREE' # -1
ASK = "ASK" # +0 ASK = 'ASK' # +0
PROPOSE = "PROPOSE" # +0 PROPOSE = 'PROPOSE' # +0
PROOF = "PROOF" # +1 PROOF = 'PROOF' # +1
DISPROOF = "DISPROOF" # -1 DISPROOF = 'DISPROOF' # -1
ACCEPT = "ACCEPT" # +1 ACCEPT = 'ACCEPT' # +1
REJECT = "REJECT" # -1 REJECT = 'REJECT' # -1
# public feed # public feed
QUOTE = "QUOTE" # +0 TODO: use to bookmark in collection QUOTE = 'QUOTE' # +0 TODO: use to bookmark in collection
COMMENT = "COMMENT" # +0 COMMENT = 'COMMENT' # +0
LIKE = "LIKE" # +1 LIKE = 'LIKE' # +1
DISLIKE = "DISLIKE" # -1 DISLIKE = 'DISLIKE' # -1
class Reaction(Base): 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())) created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=True, comment="Updated at") updated_at = Column(Integer, nullable=True, comment='Updated at')
deleted_at = Column(Integer, nullable=True, comment="Deleted at") deleted_at = Column(Integer, nullable=True, comment='Deleted at')
deleted_by = Column(ForeignKey("author.id"), nullable=True, index=True) deleted_by = Column(ForeignKey('author.id'), nullable=True, index=True)
reply_to = Column(ForeignKey("reaction.id"), nullable=True) reply_to = Column(ForeignKey('reaction.id'), nullable=True)
quote = Column(String, nullable=True, comment="Original quoted text") quote = Column(String, nullable=True, comment='Original quoted text')
shout = Column(ForeignKey("shout.id"), nullable=False, index=True) shout = Column(ForeignKey('shout.id'), nullable=False, index=True)
created_by = Column(ForeignKey("author.id"), nullable=False, index=True) created_by = Column(ForeignKey('author.id'), nullable=False, index=True)
kind = Column(String, nullable=False, index=True) kind = Column(String, nullable=False, index=True)
oid = Column(String) oid = Column(String)

View File

@ -12,80 +12,80 @@ from services.db import Base
class ShoutTopic(Base): class ShoutTopic(Base):
__tablename__ = "shout_topic" __tablename__ = 'shout_topic'
id = None # type: ignore id = None # type: ignore
shout = Column(ForeignKey("shout.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) topic = Column(ForeignKey('topic.id'), primary_key=True, index=True)
main = Column(Boolean, nullable=True) main = Column(Boolean, nullable=True)
class ShoutReactionsFollower(Base): class ShoutReactionsFollower(Base):
__tablename__ = "shout_reactions_followers" __tablename__ = 'shout_reactions_followers'
id = None # type: ignore id = None # type: ignore
follower = Column(ForeignKey("author.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) shout = Column(ForeignKey('shout.id'), primary_key=True, index=True)
auto = Column(Boolean, nullable=False, default=False) auto = Column(Boolean, nullable=False, default=False)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at = Column(Integer, nullable=True) deleted_at = Column(Integer, nullable=True)
class ShoutAuthor(Base): class ShoutAuthor(Base):
__tablename__ = "shout_author" __tablename__ = 'shout_author'
id = None # type: ignore id = None # type: ignore
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True) shout = Column(ForeignKey('shout.id'), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True) author = Column(ForeignKey('author.id'), primary_key=True, index=True)
caption = Column(String, nullable=True, default="") caption = Column(String, nullable=True, default='')
class ShoutCommunity(Base): class ShoutCommunity(Base):
__tablename__ = "shout_community" __tablename__ = 'shout_community'
id = None # type: ignore id = None # type: ignore
shout = Column(ForeignKey("shout.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) community = Column(ForeignKey('community.id'), primary_key=True, index=True)
class ShoutVisibility(Enumeration): class ShoutVisibility(Enumeration):
AUTHORS = "AUTHORS" AUTHORS = 'AUTHORS'
COMMUNITY = "COMMUNITY" COMMUNITY = 'COMMUNITY'
PUBLIC = "PUBLIC" PUBLIC = 'PUBLIC'
class Shout(Base): class Shout(Base):
__tablename__ = "shout" __tablename__ = 'shout'
created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=True) updated_at = Column(Integer, nullable=True)
published_at = Column(Integer, nullable=True) published_at = Column(Integer, nullable=True)
deleted_at = Column(Integer, nullable=True) deleted_at = Column(Integer, nullable=True)
created_by = Column(ForeignKey("author.id"), nullable=False) created_by = Column(ForeignKey('author.id'), nullable=False)
updated_by = Column(ForeignKey("author.id"), nullable=True) updated_by = Column(ForeignKey('author.id'), nullable=True)
deleted_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) slug = Column(String, unique=True)
cover = Column(String, nullable=True, comment="Cover image url") cover = Column(String, nullable=True, comment='Cover image url')
cover_caption = Column(String, nullable=True, comment="Cover image alt caption") cover_caption = Column(String, nullable=True, comment='Cover image alt caption')
lead = Column(String, nullable=True) lead = Column(String, nullable=True)
description = Column(String, nullable=True) description = Column(String, nullable=True)
title = Column(String, nullable=False) title = Column(String, nullable=False)
subtitle = Column(String, nullable=True) subtitle = Column(String, nullable=True)
layout = Column(String, nullable=False, default="article") layout = Column(String, nullable=False, default='article')
media = Column(JSON, nullable=True) media = Column(JSON, nullable=True)
authors = relationship(lambda: Author, secondary="shout_author") authors = relationship(lambda: Author, secondary='shout_author')
topics = relationship(lambda: Topic, secondary="shout_topic") topics = relationship(lambda: Topic, secondary='shout_topic')
communities = relationship(lambda: Community, secondary="shout_community") communities = relationship(lambda: Community, secondary='shout_community')
reactions = relationship(lambda: Reaction) reactions = relationship(lambda: Reaction)
visibility = Column(String, default=ShoutVisibility.AUTHORS.value) visibility = Column(String, default=ShoutVisibility.AUTHORS.value)
lang = Column(String, nullable=False, default="ru", comment="Language") lang = Column(String, nullable=False, default='ru', comment='Language')
version_of = Column(ForeignKey("shout.id"), nullable=True) version_of = Column(ForeignKey('shout.id'), nullable=True)
oid = Column(String, nullable=True) oid = Column(String, nullable=True)
seo = Column(String, nullable=True) # JSON seo = Column(String, nullable=True) # JSON

View File

@ -6,21 +6,21 @@ from services.db import Base
class TopicFollower(Base): class TopicFollower(Base):
__tablename__ = "topic_followers" __tablename__ = 'topic_followers'
id = None # type: ignore id = None # type: ignore
follower = Column(ForeignKey("author.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) topic = Column(ForeignKey('topic.id'), primary_key=True, index=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
auto = Column(Boolean, nullable=False, default=False) auto = Column(Boolean, nullable=False, default=False)
class Topic(Base): class Topic(Base):
__tablename__ = "topic" __tablename__ = 'topic'
slug = Column(String, unique=True) slug = Column(String, unique=True)
title = Column(String, nullable=False, comment="Title") title = Column(String, nullable=False, comment='Title')
body = Column(String, nullable=True, comment="Body") body = Column(String, nullable=True, comment='Body')
pic = Column(String, nullable=True, comment="Picture") pic = Column(String, nullable=True, comment='Picture')
community = Column(ForeignKey("community.id"), default=1) community = Column(ForeignKey('community.id'), default=1)
oid = Column(String, nullable=True, comment="Old ID") oid = Column(String, nullable=True, comment='Old ID')

View File

@ -6,7 +6,7 @@ from services.db import Base
class User(Base): class User(Base):
__tablename__ = "authorizer_users" __tablename__ = 'authorizer_users'
id = Column(String, primary_key=True, unique=True, nullable=False, default=None) id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
key = Column(String) key = Column(String)
@ -24,7 +24,7 @@ class User(Base):
# preferred_username = Column(String, nullable=False) # preferred_username = Column(String, nullable=False)
picture = Column(String) picture = Column(String)
revoked_timestamp = Column(Integer) revoked_timestamp = Column(Integer)
roles = Column(String, default="author, reader") roles = Column(String, default='author, reader')
signup_methods = Column(String, default="magic_link_login") signup_methods = Column(String, default='magic_link_login')
created_at = Column(Integer, default=lambda: int(time.time())) created_at = Column(Integer, default=lambda: int(time.time()))
updated_at = Column(Integer, default=lambda: int(time.time())) updated_at = Column(Integer, default=lambda: int(time.time()))

View File

@ -11,9 +11,8 @@ python = "^3.12"
SQLAlchemy = "^2.0.22" SQLAlchemy = "^2.0.22"
psycopg2-binary = "^2.9.9" psycopg2-binary = "^2.9.9"
redis = {extras = ["hiredis"], version = "^5.0.1"} redis = {extras = ["hiredis"], version = "^5.0.1"}
uvicorn = "^0.24"
sentry-sdk = "^1.39.1" sentry-sdk = "^1.39.1"
starlette = "^0.34.0" starlette = "^0.36.1"
gql = "^3.4.1" gql = "^3.4.1"
ariadne = "^0.21" ariadne = "^0.21"
aiohttp = "^3.9.1" aiohttp = "^3.9.1"
@ -30,6 +29,7 @@ black = { version = "^23.12.0", python = ">=3.12" }
ruff = { version = "^0.1.8", python = ">=3.12" } ruff = { version = "^0.1.8", python = ">=3.12" }
isort = "^5.13.2" isort = "^5.13.2"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
@ -44,7 +44,6 @@ extend-select = [
'I', # isort 'I', # isort
'N', # pep8-naming 'N', # pep8-naming
'Q', # flake8-quotes 'Q', # flake8-quotes
'RUF100', # ruff (unused noqa)
'S', # flake8-bandit 'S', # flake8-bandit
'W', # pycodestyle 'W', # pycodestyle
] ]
@ -56,6 +55,7 @@ extend-ignore = [
'E501', # leave line length to black 'E501', # leave line length to black
'N818', # leave to us exceptions naming 'N818', # leave to us exceptions naming
'S101', # assert is fine 'S101', # assert is fine
'E712', # allow == True
] ]
flake8-quotes = { inline-quotes = 'single', multiline-quotes = 'double' } flake8-quotes = { inline-quotes = 'single', multiline-quotes = 'double' }
mccabe = { max-complexity = 13 } mccabe = { max-complexity = 13 }
@ -64,14 +64,24 @@ target-version = "py312"
[tool.ruff.format] [tool.ruff.format]
quote-style = 'single' quote-style = 'single'
[tool.black]
skip-string-normalization = true
[tool.ruff.isort] [tool.ruff.isort]
combine-as-imports = true combine-as-imports = true
lines-after-imports = 2 lines-after-imports = 2
known-first-party = ['granian', 'tests'] known-first-party = ['resolvers', 'services', 'orm', 'tests']
[tool.ruff.per-file-ignores] [tool.ruff.per-file-ignores]
'tests/**' = ['B018', 'S110', 'S501'] '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] [tool.pytest.ini_options]
asyncio_mode = 'auto' asyncio_mode = 'auto'

View File

@ -23,50 +23,51 @@ from resolvers.reader import (
load_shouts_by, load_shouts_by,
load_shouts_feed, load_shouts_feed,
load_shouts_random_top, load_shouts_random_top,
load_shouts_random_topic,
load_shouts_search, load_shouts_search,
load_shouts_unrated, load_shouts_unrated,
load_shouts_random_topic,
) )
from resolvers.topic import get_topic, get_topics_all, get_topics_by_author, get_topics_by_community from resolvers.topic import get_topic, get_topics_all, get_topics_by_author, get_topics_by_community
__all__ = [ __all__ = [
# author # author
"get_author", 'get_author',
"get_author_id", 'get_author_id',
"get_authors_all", 'get_authors_all',
"get_author_followers", 'get_author_followers',
"get_author_followed", 'get_author_followed',
"load_authors_by", 'load_authors_by',
"rate_author", 'rate_author',
"update_profile", 'update_profile',
# community # community
"get_community", 'get_community',
"get_communities_all", 'get_communities_all',
# topic # topic
"get_topic", 'get_topic',
"get_topics_all", 'get_topics_all',
"get_topics_by_community", 'get_topics_by_community',
"get_topics_by_author", 'get_topics_by_author',
# reader # reader
"get_shout", 'get_shout',
"load_shouts_by", 'load_shouts_by',
"load_shouts_feed", 'load_shouts_feed',
"load_shouts_search", 'load_shouts_search',
"load_shouts_followed", 'load_shouts_followed',
"load_shouts_unrated", 'load_shouts_unrated',
"load_shouts_random_top", 'load_shouts_random_top',
"load_shouts_random_topic", 'load_shouts_random_topic',
# follower # follower
"follow", 'follow',
"unfollow", 'unfollow',
"get_my_followed", 'get_my_followed',
# editor # editor
"create_shout", 'create_shout',
"update_shout", 'update_shout',
"delete_shout", 'delete_shout',
# reaction # reaction
"create_reaction", 'create_reaction',
"update_reaction", 'update_reaction',
"delete_reaction", 'delete_reaction',
"load_reactions_by", 'load_reactions_by',
] ]

View File

@ -1,14 +1,14 @@
import logging
import time import time
from typing import List 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 sqlalchemy.orm import aliased
from orm.author import Author, AuthorFollower, AuthorRating from orm.author import Author, AuthorFollower, AuthorRating
from orm.community import Community from orm.community import Community
from orm.reaction import Reaction, ReactionKind 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 orm.topic import Topic
from resolvers.community import followed_communities from resolvers.community import followed_communities
from resolvers.reaction import reacted_shouts_updates as followed_reactions 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.unread import get_total_unread_counter
from services.viewed import ViewedStorage from services.viewed import ViewedStorage
logging.basicConfig() logging.basicConfig()
logger = logging.getLogger("\t[resolvers.author]\t") logger = logging.getLogger('\t[resolvers.author]\t')
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
def add_author_stat_columns(q): def add_author_stat_columns(q):
shout_author_aliased = aliased(ShoutAuthor) shout_author_aliased = aliased(ShoutAuthor)
q = q.outerjoin(shout_author_aliased, shout_author_aliased.author == Author.id).add_columns( 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) followers_table = aliased(AuthorFollower)
q = q.outerjoin(followers_table, followers_table.author == Author.id).add_columns( 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) followings_table = aliased(AuthorFollower)
q = q.outerjoin(followings_table, followings_table.follower == Author.id).add_columns( q = q.outerjoin(followings_table, followings_table.follower == Author.id).add_columns(
func.count(distinct(followers_table.author)).label("followings_stat") func.count(distinct(followers_table.author)).label('followings_stat')
) )
q = q.group_by(Author.id) q = q.group_by(Author.id)
@ -49,10 +50,10 @@ async def get_authors_from_query(q):
with local_session() as session: with local_session() as session:
for [author, shouts_stat, followers_stat, followings_stat] in session.execute(q): for [author, shouts_stat, followers_stat, followings_stat] in session.execute(q):
author.stat = { author.stat = {
"shouts": shouts_stat, 'shouts': shouts_stat,
"followers": followers_stat, 'followers': followers_stat,
"followings": followings_stat, 'followings': followings_stat,
"viewed": await ViewedStorage.get_author(author.slug), 'viewed': await ViewedStorage.get_author(author.slug),
} }
authors.append(author) authors.append(author)
return authors return authors
@ -61,24 +62,24 @@ async def get_authors_from_query(q):
async def author_followings(author_id: int): async def author_followings(author_id: int):
# NOTE: topics, authors, shout-reactions and communities slugs list # NOTE: topics, authors, shout-reactions and communities slugs list
return { return {
"unread": await get_total_unread_counter(author_id), 'unread': await get_total_unread_counter(author_id),
"topics": [t.slug for t in await followed_topics(author_id)], 'topics': [t.slug for t in await followed_topics(author_id)],
"authors": [a.slug for a in await followed_authors(author_id)], 'authors': [a.slug for a in await followed_authors(author_id)],
"reactions": [s.slug for s in await followed_reactions(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)], 'communities': [c.slug for c in [followed_communities(author_id)] if isinstance(c, Community)],
} }
@mutation.field("update_profile") @mutation.field('update_profile')
@login_required @login_required
async def update_profile(_, info, profile): async def update_profile(_, info, profile):
user_id = info.context["user_id"] user_id = info.context['user_id']
with local_session() as session: with local_session() as session:
author = session.query(Author).where(Author.user == user_id).first() author = session.query(Author).where(Author.user == user_id).first()
Author.update(author, profile) Author.update(author, profile)
session.add(author) session.add(author)
session.commit() session.commit()
return {"error": None, "author": author} return {'error': None, 'author': author}
# for mutation.field("follow") # for mutation.field("follow")
@ -111,7 +112,7 @@ def author_unfollow(follower_id, slug):
# TODO: caching query # TODO: caching query
@query.field("get_authors_all") @query.field('get_authors_all')
async def get_authors_all(_, _info): async def get_authors_all(_, _info):
with local_session() as session: with local_session() as session:
return session.query(Author).all() return session.query(Author).all()
@ -122,40 +123,59 @@ def count_author_comments_rating(session, author_id) -> int:
replies_likes = ( replies_likes = (
session.query(replied_alias) session.query(replied_alias)
.join(Reaction, replied_alias.id == Reaction.reply_to) .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) .filter(replied_alias.kind == ReactionKind.LIKE.value)
.count() .count()
) or 0 ) or 0
replies_dislikes = ( replies_dislikes = (
session.query(replied_alias) session.query(replied_alias)
.join(Reaction, replied_alias.id == Reaction.reply_to) .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) .filter(replied_alias.kind == ReactionKind.DISLIKE.value)
.count() .count()
) or 0 ) or 0
return replies_likes - replies_dislikes return replies_likes - replies_dislikes
def count_author_shouts_rating(session, author_id) -> int: def count_author_shouts_rating(session, author_id) -> int:
shouts_likes = ( shouts_likes = (
session.query(Reaction, Shout) session.query(Reaction, Shout)
.join(Shout, Shout.id == 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() .count()
or 0 or 0
) )
shouts_dislikes = ( shouts_dislikes = (
session.query(Reaction, Shout) session.query(Reaction, Shout)
.join(Shout, Shout.id == 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() .count()
or 0 or 0
) )
return shouts_likes - shouts_dislikes return shouts_likes - shouts_dislikes
async def load_author_with_stats(q): async def load_author_with_stats(q):
q = add_author_stat_columns(q) q = add_author_stat_columns(q)
@ -175,25 +195,25 @@ async def load_author_with_stats(q):
) )
.count() .count()
) )
ratings_sum = ( likes_count = (
session.query( session.query(AuthorRating)
func.sum( .filter(and_(AuthorRating.author == author.id, AuthorRating.plus == True))
case((AuthorRating.plus == True, cast(1, Integer)), .count()
else_=cast(-1, Integer))).label("rating")
)
.filter(AuthorRating.author == author.id)
.scalar()
) )
dislikes_count = (
author.stat["rating"] = ratings_sum or 0 session.query(AuthorRating)
author.stat["rating_shouts"] = count_author_shouts_rating(session, author.id) .filter(and_(AuthorRating.author == author.id, AuthorRating.plus != True))
author.stat["rating_comments"] = count_author_comments_rating(session, author.id) .count()
author.stat["commented"] = comments_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 return author
@query.field("get_author") @query.field('get_author')
async def get_author(_, _info, slug="", author_id=None): async def get_author(_, _info, slug='', author_id=None):
q = None q = None
if slug or author_id: if slug or author_id:
if bool(slug): if bool(slug):
@ -204,38 +224,37 @@ async def get_author(_, _info, slug="", author_id=None):
return await load_author_with_stats(q) 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): async def get_author_id(_, _info, user: str):
with local_session() as session: logger.info(f'getting author id for {user}')
logger.info(f"getting author id for {user}") q = select(Author).filter(Author.user == user)
q = select(Author).filter(Author.user == user) return await load_author_with_stats(q)
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): async def load_authors_by(_, _info, by, limit, offset):
q = select(Author) q = select(Author)
q = add_author_stat_columns(q) q = add_author_stat_columns(q)
if by.get("slug"): if by.get('slug'):
q = q.filter(Author.slug.ilike(f"%{by['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']}%")) q = q.filter(Author.name.ilike(f"%{by['name']}%"))
elif by.get("topic"): elif by.get('topic'):
q = q.join(ShoutAuthor).join(ShoutTopic).join(Topic).where(Topic.slug == by["topic"]) q = q.join(ShoutAuthor).join(ShoutTopic).join(Topic).where(Topic.slug == by['topic'])
if by.get("last_seen"): # in unixtime if by.get('last_seen'): # in unixtime
before = int(time.time()) - by["last_seen"] before = int(time.time()) - by['last_seen']
q = q.filter(Author.last_seen > before) q = q.filter(Author.last_seen > before)
elif by.get("created_at"): # in unixtime elif by.get('created_at'): # in unixtime
before = int(time.time()) - by["created_at"] before = int(time.time()) - by['created_at']
q = q.filter(Author.created_at > before) 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) return await get_authors_from_query(q)
@query.field("get_author_followed") @query.field('get_author_followed')
async def get_author_followed(_, _info, slug="", user=None, author_id=None) -> List[Author]: async def get_author_followed(_, _info, slug='', user=None, author_id=None) -> List[Author]:
author_id_query = None author_id_query = None
if slug: if slug:
author_id_query = select(Author.id).where(Author.slug == 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() author_id = session.execute(author_id_query).scalar()
if author_id is None: if author_id is None:
raise ValueError("Author not found") raise ValueError('Author not found')
else: else:
return await followed_authors(author_id) # Author[] 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]: async def get_author_followers(_, _info, slug) -> List[Author]:
q = select(Author) q = select(Author)
q = add_author_stat_columns(q) q = add_author_stat_columns(q)
@ -274,10 +293,10 @@ async def followed_authors(follower_id):
return await get_authors_from_query(q) return await get_authors_from_query(q)
@mutation.field("rate_author") @mutation.field('rate_author')
@login_required @login_required
async def rate_author(_, info, rated_slug, value): 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: with local_session() as session:
rated_author = session.query(Author).filter(Author.slug == rated_slug).first() 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: if rater and rated_author:
rating: AuthorRating = ( rating: AuthorRating = (
session.query(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() .first()
) )
if rating: if rating:
@ -299,13 +323,13 @@ async def rate_author(_, info, rated_slug, value):
session.add(rating) session.add(rating)
session.commit() session.commit()
except Exception as err: except Exception as err:
return {"error": err} return {'error': err}
return {} 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: with local_session() as session:
new_author = Author(user=user_id, slug=slug, name=name) new_author = Author(user=user_id, slug=slug, name=name)
session.add(new_author) session.add(new_author)
session.commit() session.commit()
logger.info(f"author created by webhook {new_author.dict()}") logger.info(f'author created by webhook {new_author.dict()}')

View File

@ -6,10 +6,10 @@ from services.db import local_session
from services.schema import mutation from services.schema import mutation
@mutation.field("accept_invite") @mutation.field('accept_invite')
@login_required @login_required
async def accept_invite(_, info, invite_id: int): 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 # Check if the user exists
with local_session() as session: with local_session() as session:
@ -26,19 +26,19 @@ async def accept_invite(_, info, invite_id: int):
session.delete(invite) session.delete(invite)
session.add(shout) session.add(shout)
session.commit() session.commit()
return {"success": True, "message": "Invite accepted"} return {'success': True, 'message': 'Invite accepted'}
else: else:
return {"error": "Shout not found"} return {'error': 'Shout not found'}
else: else:
return {"error": "Invalid invite or already accepted/rejected"} return {'error': 'Invalid invite or already accepted/rejected'}
else: else:
return {"error": "User not found"} return {'error': 'User not found'}
@mutation.field("reject_invite") @mutation.field('reject_invite')
@login_required @login_required
async def reject_invite(_, info, invite_id: int): 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 # Check if the user exists
with local_session() as session: with local_session() as session:
@ -50,17 +50,17 @@ async def reject_invite(_, info, invite_id: int):
# Delete the invite # Delete the invite
session.delete(invite) session.delete(invite)
session.commit() session.commit()
return {"success": True, "message": "Invite rejected"} return {'success': True, 'message': 'Invite rejected'}
else: else:
return {"error": "Invalid invite or already accepted/rejected"} return {'error': 'Invalid invite or already accepted/rejected'}
else: else:
return {"error": "User not found"} return {'error': 'User not found'}
@mutation.field("create_invite") @mutation.field('create_invite')
@login_required @login_required
async def create_invite(_, info, slug: str = "", author_id: int = 0): async def create_invite(_, info, slug: str = '', author_id: int = 0):
user_id = info.context["user_id"] user_id = info.context['user_id']
# Check if the inviter is the owner of the shout # Check if the inviter is the owner of the shout
with local_session() as session: with local_session() as session:
@ -82,7 +82,7 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0):
.first() .first()
) )
if existing_invite: if existing_invite:
return {"error": "Invite already sent"} return {'error': 'Invite already sent'}
# Create a new invite # Create a new invite
new_invite = Invite( new_invite = Invite(
@ -91,17 +91,17 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0):
session.add(new_invite) session.add(new_invite)
session.commit() session.commit()
return {"error": None, "invite": new_invite} return {'error': None, 'invite': new_invite}
else: else:
return {"error": "Invalid author"} return {'error': 'Invalid author'}
else: else:
return {"error": "Access denied"} return {'error': 'Access denied'}
@mutation.field("remove_author") @mutation.field('remove_author')
@login_required @login_required
async def remove_author(_, info, slug: str = "", author_id: int = 0): async def remove_author(_, info, slug: str = '', author_id: int = 0):
user_id = info.context["user_id"] user_id = info.context['user_id']
with local_session() as session: with local_session() as session:
author = session.query(Author).filter(Author.user == user_id).first() author = session.query(Author).filter(Author.user == user_id).first()
if author: 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] shout.authors = [author for author in shout.authors if author.id != author_id]
session.commit() session.commit()
return {} return {}
return {"error": "Access denied"} return {'error': 'Access denied'}
@mutation.field("remove_invite") @mutation.field('remove_invite')
@login_required @login_required
async def remove_invite(_, info, invite_id: int): 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 # Check if the user exists
with local_session() as session: with local_session() as session:
@ -135,6 +135,6 @@ async def remove_invite(_, info, invite_id: int):
session.commit() session.commit()
return {} return {}
else: else:
return {"error": "Invalid invite or already accepted/rejected"} return {'error': 'Invalid invite or already accepted/rejected'}
else: else:
return {"error": "Author not found"} return {'error': 'Author not found'}

View File

@ -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 sqlalchemy.orm import aliased
from orm.author import Author from orm.author import Author
@ -8,16 +10,19 @@ from services.db import local_session
from services.schema import query from services.schema import query
logger = logging.getLogger('\t[resolvers.community]\t')
logger.setLevel(logging.DEBUG)
def add_community_stat_columns(q): def add_community_stat_columns(q):
community_followers = aliased(CommunityAuthor) community_followers = aliased(CommunityAuthor)
shout_community_aliased = aliased(ShoutCommunity) shout_community_aliased = aliased(ShoutCommunity)
q = q.outerjoin(shout_community_aliased).add_columns( 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( 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) q = q.group_by(Author.id)
@ -30,8 +35,8 @@ def get_communities_from_query(q):
with local_session() as session: with local_session() as session:
for [c, shouts_stat, followers_stat] in session.execute(q): for [c, shouts_stat, followers_stat] in session.execute(q):
c.stat = { c.stat = {
"shouts": shouts_stat, 'shouts': shouts_stat,
"followers": followers_stat, 'followers': followers_stat,
# "commented": commented_stat, # "commented": commented_stat,
} }
ccc.append(c) ccc.append(c)
@ -69,8 +74,8 @@ def community_follow(follower_id, slug):
session.add(cf) session.add(cf)
session.commit() session.commit()
return True return True
except Exception: except Exception as ex:
pass logger.debug(ex)
return False return False
@ -90,7 +95,7 @@ def community_unfollow(follower_id, slug):
return False return False
@query.field("get_communities_all") @query.field('get_communities_all')
async def get_communities_all(_, _info): async def get_communities_all(_, _info):
q = select(Author) q = select(Author)
q = add_community_stat_columns(q) q = add_community_stat_columns(q)
@ -98,7 +103,7 @@ async def get_communities_all(_, _info):
return get_communities_from_query(q) return get_communities_from_query(q)
@query.field("get_community") @query.field('get_community')
async def get_community(_, _info, slug): async def get_community(_, _info, slug):
q = select(Community).where(Community.slug == slug) q = select(Community).where(Community.slug == slug)
q = add_community_stat_columns(q) q = add_community_stat_columns(q)

View File

@ -13,10 +13,10 @@ from services.notify import notify_shout
from services.schema import mutation, query from services.schema import mutation, query
@query.field("get_shouts_drafts") @query.field('get_shouts_drafts')
@login_required @login_required
async def get_shouts_drafts(_, info): async def get_shouts_drafts(_, info):
user_id = info.context["user_id"] user_id = info.context['user_id']
with local_session() as session: with local_session() as session:
author = session.query(Author).filter(Author.user == user_id).first() author = session.query(Author).filter(Author.user == user_id).first()
if author: if author:
@ -36,29 +36,29 @@ async def get_shouts_drafts(_, info):
return shouts return shouts
@mutation.field("create_shout") @mutation.field('create_shout')
@login_required @login_required
async def create_shout(_, info, inp): async def create_shout(_, info, inp):
user_id = info.context["user_id"] user_id = info.context['user_id']
with local_session() as session: with local_session() as session:
author = session.query(Author).filter(Author.user == user_id).first() author = session.query(Author).filter(Author.user == user_id).first()
shout_dict = None shout_dict = None
if author: if author:
current_time = int(time.time()) 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 = { shout_dict = {
"title": inp.get("title", ""), 'title': inp.get('title', ''),
"subtitle": inp.get("subtitle", ""), 'subtitle': inp.get('subtitle', ''),
"lead": inp.get("lead", ""), 'lead': inp.get('lead', ''),
"description": inp.get("description", ""), 'description': inp.get('description', ''),
"body": inp.get("body", ""), 'body': inp.get('body', ''),
"layout": inp.get("layout", "article"), 'layout': inp.get('layout', 'article'),
"created_by": author.id, 'created_by': author.id,
"authors": [], 'authors': [],
"slug": slug, 'slug': slug,
"topics": inp.get("topics", []), 'topics': inp.get('topics', []),
"visibility": ShoutVisibility.AUTHORS.value, 'visibility': ShoutVisibility.AUTHORS.value,
"created_at": current_time, # Set created_at as Unix timestamp 'created_at': current_time, # Set created_at as Unix timestamp
} }
new_shout = Shout(**shout_dict) new_shout = Shout(**shout_dict)
@ -72,21 +72,23 @@ async def create_shout(_, info, inp):
sa = ShoutAuthor(shout=shout.id, author=author.id) sa = ShoutAuthor(shout=shout.id, author=author.id)
session.add(sa) 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: for topic in topics:
t = ShoutTopic(topic=topic.id, shout=shout.id) t = ShoutTopic(topic=topic.id, shout=shout.id)
session.add(t) session.add(t)
reactions_follow(author.id, shout.id, True) reactions_follow(author.id, shout.id, True)
await notify_shout(shout_dict, "create") await notify_shout(shout_dict, 'create')
return {"shout": shout_dict} return {'shout': shout_dict}
@mutation.field("update_shout") @mutation.field('update_shout')
@login_required @login_required
async def update_shout(_, info, shout_id, shout_input=None, publish=False): async def update_shout( # noqa: C901
user_id = info.context["user_id"] _, info, shout_id, shout_input=None, publish=False
):
user_id = info.context['user_id']
with local_session() as session: with local_session() as session:
author = session.query(Author).filter(Author.user == user_id).first() author = session.query(Author).filter(Author.user == user_id).first()
shout_dict = None shout_dict = None
@ -103,16 +105,16 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False):
.first() .first()
) )
if not shout: 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: 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: if shout_input is not None:
topics_input = shout_input["topics"] topics_input = shout_input['topics']
del shout_input["topics"] del shout_input['topics']
new_topics_to_link = [] 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: for new_topic in new_topics:
del new_topic["id"] del new_topic['id']
created_new_topic = Topic(**new_topic) created_new_topic = Topic(**new_topic)
session.add(created_new_topic) session.add(created_new_topic)
new_topics_to_link.append(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: for new_topic_to_link in new_topics_to_link:
created_unlinked_topic = ShoutTopic(shout=shout.id, topic=new_topic_to_link.id) created_unlinked_topic = ShoutTopic(shout=shout.id, topic=new_topic_to_link.id)
session.add(created_unlinked_topic) 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_to_link_ids = [
existing_topic_input["id"] existing_topic_input['id']
for existing_topic_input in existing_topics_input 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: 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) 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_to_unlink_ids = [
topic.id topic.id
for topic in shout.topics 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( shout_topics_to_remove = session.query(ShoutTopic).filter(
and_( and_(
@ -145,68 +147,68 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False):
session.delete(shout_topic_to_remove) session.delete(shout_topic_to_remove)
# Replace datetime with Unix timestamp # 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) Shout.update(shout, shout_input)
session.add(shout) session.add(shout)
# main topic # main topic
if "main_topic" in shout_input: if 'main_topic' in shout_input:
old_main_topic = ( old_main_topic = (
session.query(ShoutTopic) session.query(ShoutTopic)
.filter(and_(ShoutTopic.shout == shout.id, ShoutTopic.main == True)) .filter(and_(ShoutTopic.shout == shout.id, ShoutTopic.main == True))
.first() .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): if isinstance(main_topic, Topic):
new_main_topic = ( new_main_topic = (
session.query(ShoutTopic) 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() .first()
) )
if isinstance(old_main_topic, ShoutTopic) and isinstance(new_main_topic, ShoutTopic) \ if (
and old_main_topic is not new_main_topic: isinstance(old_main_topic, ShoutTopic)
ShoutTopic.update(old_main_topic, {"main": False}) 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) 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.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() shout_dict = shout.dict()
session.commit() session.commit()
if not publish: if not publish:
await notify_shout(shout_dict, "update") await notify_shout(shout_dict, 'update')
return {"shout": shout_dict}
return {'shout': shout_dict}
@mutation.field("delete_shout") @mutation.field('delete_shout')
@login_required @login_required
async def delete_shout(_, info, shout_id): async def delete_shout(_, info, shout_id):
user_id = info.context["user_id"] user_id = info.context['user_id']
with local_session() as session: with local_session() as session:
author = session.query(Author).filter(Author.id == user_id).first() author = session.query(Author).filter(Author.id == user_id).first()
shout = session.query(Shout).filter(Shout.id == shout_id).first() shout = session.query(Shout).filter(Shout.id == shout_id).first()
if not shout: if not shout:
return {"error": "invalid shout id"} return {'error': 'invalid shout id'}
if isinstance(author, Author) and isinstance(shout, Shout): if isinstance(author, Author) and isinstance(shout, Shout):
# TODO: add editor role allowed here # TODO: add editor role allowed here
if shout.created_by is not author.id and author.id not in shout.authors: 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: for author_id in shout.authors:
reactions_unfollow(author_id, shout_id) reactions_unfollow(author_id, shout_id)
shout_dict = shout.dict() shout_dict = shout.dict()
shout_dict["deleted_at"] = int(time.time()) shout_dict['deleted_at'] = int(time.time())
Shout.update(shout, shout_dict) Shout.update(shout, shout_dict)
session.add(shout) session.add(shout)
session.commit() session.commit()
await notify_shout(shout_dict, "delete") await notify_shout(shout_dict, 'delete')
return {} return {}

View File

@ -1,9 +1,5 @@
import time
from typing import List
import logging import logging
from typing import List
from sqlalchemy import select
from sqlalchemy.orm import aliased
from orm.author import Author, AuthorFollower from orm.author import Author, AuthorFollower
from orm.community import Community from orm.community import Community
@ -22,84 +18,84 @@ from services.schema import mutation, query
logging.basicConfig() logging.basicConfig()
logger = logging.getLogger("\t[resolvers.reaction]\t") logger = logging.getLogger('\t[resolvers.reaction]\t')
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
@mutation.field("follow") @mutation.field('follow')
@login_required @login_required
async def follow(_, info, what, slug): async def follow(_, info, what, slug):
try: try:
user_id = info.context["user_id"] user_id = info.context['user_id']
with local_session() as session: with local_session() as session:
actor = session.query(Author).filter(Author.user == user_id).first() actor = session.query(Author).filter(Author.user == user_id).first()
if actor: if actor:
follower_id = actor.id follower_id = actor.id
if what == "AUTHOR": if what == 'AUTHOR':
if author_follow(follower_id, slug): if author_follow(follower_id, slug):
result = FollowingResult("NEW", "author", slug) result = FollowingResult('NEW', 'author', slug)
await FollowingManager.push("author", result) await FollowingManager.push('author', result)
author = session.query(Author.id).where(Author.slug == slug).one() author = session.query(Author.id).where(Author.slug == slug).one()
follower = session.query(Author).where(Author.id == follower_id).one() follower = session.query(Author).where(Author.id == follower_id).one()
await notify_follower(follower.dict(), author.id) await notify_follower(follower.dict(), author.id)
elif what == "TOPIC": elif what == 'TOPIC':
if topic_follow(follower_id, slug): if topic_follow(follower_id, slug):
result = FollowingResult("NEW", "topic", slug) result = FollowingResult('NEW', 'topic', slug)
await FollowingManager.push("topic", result) await FollowingManager.push('topic', result)
elif what == "COMMUNITY": elif what == 'COMMUNITY':
if community_follow(follower_id, slug): if community_follow(follower_id, slug):
result = FollowingResult("NEW", "community", slug) result = FollowingResult('NEW', 'community', slug)
await FollowingManager.push("community", result) await FollowingManager.push('community', result)
elif what == "REACTIONS": elif what == 'REACTIONS':
if reactions_follow(follower_id, slug): if reactions_follow(follower_id, slug):
result = FollowingResult("NEW", "shout", slug) result = FollowingResult('NEW', 'shout', slug)
await FollowingManager.push("shout", result) await FollowingManager.push('shout', result)
except Exception as e: except Exception as e:
logger.debug(info, what, slug) logger.debug(info, what, slug)
logger.error(e) logger.error(e)
return {"error": str(e)} return {'error': str(e)}
return {} return {}
@mutation.field("unfollow") @mutation.field('unfollow')
@login_required @login_required
async def unfollow(_, info, what, slug): async def unfollow(_, info, what, slug):
user_id = info.context["user_id"] user_id = info.context['user_id']
try: try:
with local_session() as session: with local_session() as session:
actor = session.query(Author).filter(Author.user == user_id).first() actor = session.query(Author).filter(Author.user == user_id).first()
if actor: if actor:
follower_id = actor.id follower_id = actor.id
if what == "AUTHOR": if what == 'AUTHOR':
if author_unfollow(follower_id, slug): if author_unfollow(follower_id, slug):
result = FollowingResult("DELETED", "author", slug) result = FollowingResult('DELETED', 'author', slug)
await FollowingManager.push("author", result) await FollowingManager.push('author', result)
author = session.query(Author.id).where(Author.slug == slug).one() author = session.query(Author.id).where(Author.slug == slug).one()
follower = session.query(Author).where(Author.id == follower_id).one() follower = session.query(Author).where(Author.id == follower_id).one()
await notify_follower(follower.dict(), author.id, "unfollow") await notify_follower(follower.dict(), author.id, 'unfollow')
elif what == "TOPIC": elif what == 'TOPIC':
if topic_unfollow(follower_id, slug): if topic_unfollow(follower_id, slug):
result = FollowingResult("DELETED", "topic", slug) result = FollowingResult('DELETED', 'topic', slug)
await FollowingManager.push("topic", result) await FollowingManager.push('topic', result)
elif what == "COMMUNITY": elif what == 'COMMUNITY':
if community_unfollow(follower_id, slug): if community_unfollow(follower_id, slug):
result = FollowingResult("DELETED", "community", slug) result = FollowingResult('DELETED', 'community', slug)
await FollowingManager.push("community", result) await FollowingManager.push('community', result)
elif what == "REACTIONS": elif what == 'REACTIONS':
if reactions_unfollow(follower_id, slug): if reactions_unfollow(follower_id, slug):
result = FollowingResult("DELETED", "shout", slug) result = FollowingResult('DELETED', 'shout', slug)
await FollowingManager.push("shout", result) await FollowingManager.push('shout', result)
except Exception as e: except Exception as e:
return {"error": str(e)} return {'error': str(e)}
return {} return {}
@query.field("get_my_followed") @query.field('get_my_followed')
@login_required @login_required
async def get_my_followed(_, info): async def get_my_followed(_, info):
user_id = info.context["user_id"] user_id = info.context['user_id']
topics = [] topics = []
authors = [] authors = []
communities = [] communities = []
@ -114,10 +110,7 @@ async def get_my_followed(_, info):
.filter(AuthorFollower.author == Author.id) .filter(AuthorFollower.author == Author.id)
) )
topics_query = ( topics_query = session.query(Topic).join(TopicFollower, TopicFollower.follower == author_id)
session.query(Topic)
.join(TopicFollower, TopicFollower.follower == author_id)
)
for [author] in session.execute(authors_query): for [author] in session.execute(authors_query):
authors.append(author) authors.append(author)
@ -127,12 +120,11 @@ async def get_my_followed(_, info):
communities = session.query(Community).all() communities = session.query(Community).all()
return {"topics": topics, "authors": authors, "communities": communities} return {'topics': topics, 'authors': authors, 'communities': communities}
@query.field('get_shout_followers')
@query.field("get_shout_followers") def get_shout_followers(_, _info, slug: str = '', shout_id: int | None = None) -> List[Author]:
def get_shout_followers(_, _info, slug: str = "", shout_id: int | None = None) -> List[Author]:
followers = [] followers = []
with local_session() as session: with local_session() as session:
shout = None shout = None

View File

@ -1,32 +1,36 @@
import logging
import time import time
from typing import List 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.orm import aliased, joinedload
from sqlalchemy.sql import union from sqlalchemy.sql import union
from orm.author import Author from orm.author import Author
from orm.reaction import Reaction, ReactionKind from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutReactionsFollower, ShoutVisibility 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.db import local_session
from services.notify import notify_reaction from services.notify import notify_reaction
from services.schema import mutation, query from services.schema import mutation, query
from services.viewed import ViewedStorage 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) 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( 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.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.LIKE.value, 1), else_=0)).label('likes_stat'),
func.sum(case((aliased_reaction.kind == ReactionKind.DISLIKE.value, 1), else_=0)).label("dislikes_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.max(
case(
(aliased_reaction.kind != ReactionKind.COMMENT.value, None),
else_=aliased_reaction.created_at,
)
).label('last_comment'),
) )
return q return q
@ -77,8 +81,8 @@ def reactions_unfollow(author_id, shout_id: int):
session.delete(following) session.delete(following)
session.commit() session.commit()
return True return True
except Exception: except Exception as ex:
pass logger.debug(ex)
return False return False
@ -92,6 +96,7 @@ def is_published_author(session, author_id):
> 0 > 0
) )
def is_negative(x): def is_negative(x):
return x in [ return x in [
ReactionKind.ACCEPT.value, ReactionKind.ACCEPT.value,
@ -99,6 +104,7 @@ def is_negative(x):
ReactionKind.PROOF.value, ReactionKind.PROOF.value,
] ]
def is_positive(x): def is_positive(x):
return x in [ return x in [
ReactionKind.ACCEPT.value, ReactionKind.ACCEPT.value,
@ -106,6 +112,7 @@ def is_positive(x):
ReactionKind.PROOF.value, ReactionKind.PROOF.value,
] ]
def check_to_publish(session, approver_id, reaction): def check_to_publish(session, approver_id, reaction):
"""set shout to public if publicated approvers amount > 4""" """set shout to public if publicated approvers amount > 4"""
if not reaction.reply_to and is_positive(reaction.kind): 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 = session.query(Shout).where(Shout.id == shout_id).first()
s.published_at = int(time.time()) s.published_at = int(time.time())
s.published_by = approver_id 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() author = session.query(Author).filter(Author.id == s.created_by).first()
if author: if author:
await add_user_role(str(author.user)) 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): def set_hidden(session, shout_id):
s = session.query(Shout).where(Shout.id == shout_id).first() 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.add(s)
session.commit() session.commit()
@ -164,19 +171,19 @@ async def _create_reaction(session, shout, author, reaction):
session.commit() session.commit()
# Proposal accepting logic # Proposal accepting logic
if rdict.get("reply_to"): if rdict.get('reply_to'):
if r.kind in ["LIKE", "APPROVE"] and author.id in shout.authors: if r.kind in ['LIKE', 'APPROVE'] and author.id in shout.authors:
replied_reaction = session.query(Reaction).filter(Reaction.id == r.reply_to).first() replied_reaction = session.query(Reaction).filter(Reaction.id == r.reply_to).first()
if replied_reaction: if replied_reaction:
if replied_reaction.kind is ReactionKind.PROPOSE.value: if replied_reaction.kind is ReactionKind.PROPOSE.value:
if replied_reaction.range: if replied_reaction.range:
old_body = shout.body old_body = shout.body
start, end = replied_reaction.range.split(":") start, end = replied_reaction.range.split(':')
start = int(start) start = int(start)
end = int(end) end = int(end)
new_body = old_body[:start] + replied_reaction.body + old_body[end:] new_body = old_body[:start] + replied_reaction.body + old_body[end:]
shout_dict = shout.dict() shout_dict = shout.dict()
shout_dict["body"] = new_body shout_dict['body'] = new_body
Shout.update(shout, shout_dict) Shout.update(shout, shout_dict)
session.add(shout) session.add(shout)
session.commit() session.commit()
@ -188,43 +195,44 @@ async def _create_reaction(session, shout, author, reaction):
await set_published(session, shout.id, author.id) await set_published(session, shout.id, author.id)
# Reactions auto-following # Reactions auto-following
reactions_follow(author.id, reaction["shout"], True) reactions_follow(author.id, reaction['shout'], True)
rdict["shout"] = shout.dict() rdict['shout'] = shout.dict()
rdict["created_by"] = author.dict() rdict['created_by'] = author.dict()
rdict["stat"] = {"commented": 0, "reacted": 0, "rating": 0} rdict['stat'] = {'commented': 0, 'reacted': 0, 'rating': 0}
# Notifications call # Notifications call
await notify_reaction(rdict, "create") await notify_reaction(rdict, 'create')
return rdict return rdict
@mutation.field("create_reaction")
@mutation.field('create_reaction')
@login_required @login_required
async def create_reaction(_, info, reaction): 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: 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: try:
with local_session() as session: with local_session() as session:
shout = session.query(Shout).filter(Shout.id == shout_id).one() shout = session.query(Shout).filter(Shout.id == shout_id).one()
author = session.query(Author).filter(Author.user == user_id).first() author = session.query(Author).filter(Author.user == user_id).first()
if shout and author: if shout and author:
reaction["created_by"] = author.id reaction['created_by'] = author.id
kind = reaction.get("kind") kind = reaction.get('kind')
shout_id = shout.id shout_id = shout.id
if not kind and reaction.get("body"): if not kind and reaction.get('body'):
kind = ReactionKind.COMMENT.value kind = ReactionKind.COMMENT.value
if not kind: 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 = ( same_reaction = (
session.query(Reaction) session.query(Reaction)
.filter( .filter(
@ -232,51 +240,51 @@ async def create_reaction(_, info, reaction):
Reaction.shout == shout_id, Reaction.shout == shout_id,
Reaction.created_by == author.id, Reaction.created_by == author.id,
Reaction.kind == kind, Reaction.kind == kind,
Reaction.reply_to == reaction.get("reply_to"), Reaction.reply_to == reaction.get('reply_to'),
) )
) )
.first() .first()
) )
if same_reaction is not None: 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 = ( opposite_reaction_kind = (
ReactionKind.DISLIKE.value ReactionKind.DISLIKE.value
if reaction["kind"] == ReactionKind.LIKE.value if reaction['kind'] == ReactionKind.LIKE.value
else ReactionKind.LIKE.value else ReactionKind.LIKE.value
) )
opposite_reaction = ( opposite_reaction = (
session.query(Reaction) session.query(Reaction)
.filter( .filter(
and_( and_(
Reaction.shout == reaction["shout"], Reaction.shout == reaction['shout'],
Reaction.created_by == author.id, Reaction.created_by == author.id,
Reaction.kind == opposite_reaction_kind, Reaction.kind == opposite_reaction_kind,
Reaction.reply_to == reaction.get("reply_to"), Reaction.reply_to == reaction.get('reply_to'),
) )
) )
.first() .first()
) )
if opposite_reaction is not None: if opposite_reaction is not None:
return {"error": "Remove opposite vote first"} return {'error': 'Remove opposite vote first'}
else: else:
rdict = await _create_reaction(session, shout, author, reaction) rdict = await _create_reaction(session, shout, author, reaction)
return {"reaction": rdict} return {'reaction': rdict}
except Exception as e: except Exception as e:
import traceback import traceback
traceback.print_exc() 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 @login_required
async def update_reaction(_, info, rid, reaction): async def update_reaction(_, info, rid, reaction):
user_id = info.context["user_id"] user_id = info.context['user_id']
with local_session() as session: with local_session() as session:
q = select(Reaction).filter(Reaction.id == rid) q = select(Reaction).filter(Reaction.id == rid)
aliased_reaction = aliased(Reaction) 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() [r, commented_stat, likes_stat, dislikes_stat, _l] = session.execute(q).unique().one()
if not r: if not r:
return {"error": "invalid reaction id"} return {'error': 'invalid reaction id'}
author = session.query(Author).filter(Author.user == user_id).first() author = session.query(Author).filter(Author.user == user_id).first()
if author: if author:
if r.created_by != author.id: if r.created_by != author.id:
return {"error": "access denied"} return {'error': 'access denied'}
body = reaction.get("body") body = reaction.get('body')
if body: if body:
r.body = body r.body = body
r.updated_at = int(time.time()) r.updated_at = int(time.time())
if r.kind != reaction["kind"]: if r.kind != reaction['kind']:
# TODO: change mind detection can be here # TODO: change mind detection can be here
pass pass
session.commit() session.commit()
r.stat = { r.stat = {
"commented": commented_stat, 'commented': commented_stat,
"rating": int(likes_stat or 0) - int(dislikes_stat or 0), '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: else:
return {"error": "not authorized"} return {'error': 'not authorized'}
return {"error": "cannot create reaction"} return {'error': 'cannot create reaction'}
@mutation.field("delete_reaction")
@mutation.field('delete_reaction')
@login_required @login_required
async def delete_reaction(_, info, reaction_id): async def delete_reaction(_, info, reaction_id):
user_id = info.context["user_id"] user_id = info.context['user_id']
with local_session() as session: with local_session() as session:
r = session.query(Reaction).filter(Reaction.id == reaction_id).first() r = session.query(Reaction).filter(Reaction.id == reaction_id).first()
if not r: if not r:
return {"error": "invalid reaction id"} return {'error': 'invalid reaction id'}
author = session.query(Author).filter(Author.user == user_id).first() author = session.query(Author).filter(Author.user == user_id).first()
if author: if author:
if r.created_by is author.id: 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]: if r.kind in [ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]:
session.delete(r) session.delete(r)
session.commit() session.commit()
await notify_reaction(r.dict(), "delete") await notify_reaction(r.dict(), 'delete')
else: else:
return {"error": "access denied"} return {'error': 'access denied'}
return {} return {}
def apply_reaction_filters(by, q): def apply_reaction_filters(by, q):
if by.get("shout"): if by.get('shout'):
q = q.filter(Shout.slug == by["shout"]) q = q.filter(Shout.slug == by['shout'])
elif by.get("shouts"): elif by.get('shouts'):
q = q.filter(Shout.slug.in_(by["shouts"])) q = q.filter(Shout.slug.in_(by['shouts']))
if by.get("created_by"): if by.get('created_by'):
q = q.filter(Author.id == by["created_by"]) q = q.filter(Author.id == by['created_by'])
if by.get("topic"): if by.get('topic'):
q = q.filter(Shout.topics.contains(by["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) q = q.filter(func.length(Reaction.body) > 0)
# NOTE: not using ElasticSearch here # NOTE: not using ElasticSearch here
by_search = by.get("search", "") by_search = by.get('search', '')
if len(by_search) > 2: 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"): if by.get('after'):
after = int(by["after"]) after = int(by['after'])
q = q.filter(Reaction.created_at > after) q = q.filter(Reaction.created_at > after)
return q return q
@query.field("load_reactions_by") @query.field('load_reactions_by')
async def load_reactions_by(_, info, by, limit=50, offset=0): async def load_reactions_by(_, info, by, limit=50, offset=0):
""" """
:param info: graphql meta :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) q = q.group_by(Reaction.id, Author.id, Shout.id, aliased_reaction.id)
# order by # order by
q = q.order_by(desc("created_at")) q = q.order_by(desc('created_at'))
# pagination # pagination
q = q.limit(limit).offset(offset) q = q.limit(limit).offset(offset)
@ -414,38 +423,45 @@ async def load_reactions_by(_, info, by, limit=50, offset=0):
commented_stat, commented_stat,
likes_stat, likes_stat,
dislikes_stat, dislikes_stat,
_last_comment _last_comment,
] in result_rows: ] in result_rows:
reaction.created_by = author reaction.created_by = author
reaction.shout = shout reaction.shout = shout
reaction.stat = { reaction.stat = {
"rating": int(likes_stat or 0) - int(dislikes_stat or 0), 'rating': int(likes_stat or 0) - int(dislikes_stat or 0),
"commented": commented_stat 'commented': commented_stat,
} }
reactions.append(reaction) reactions.append(reaction)
# sort if by stat is present # sort if by stat is present
stat_sort = by.get("stat") stat_sort = by.get('stat')
if stat_sort: 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 return reactions
async def reacted_shouts_updates(follower_id: int, limit=50, offset=0) -> List[Shout]: async def reacted_shouts_updates(follower_id: int, limit=50, offset=0) -> List[Shout]:
shouts: List[Shout] = [] shouts: List[Shout] = []
with local_session() as session: with local_session() as session:
author = session.query(Author).filter(Author.id == follower_id).first() author = session.query(Author).filter(Author.id == follower_id).first()
if author: if author:
# Shouts where follower is the author # Shouts where follower is the author
q1 = select(Shout).outerjoin( q1 = (
Reaction, and_(Reaction.shout_id == Shout.id, Reaction.created_by == follower_id) select(Shout)
).outerjoin( .outerjoin(
Author, Shout.authors.any(id=follower_id) Reaction,
).options( and_(
joinedload(Shout.reactions), Reaction.shout_id == Shout.id,
joinedload(Shout.authors) 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 = add_stat_columns(q1, aliased(Reaction))
q1 = q1.filter(Author.id == follower_id).group_by(Shout.id) 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 = ( q2 = (
select(Shout) select(Shout)
.join(Reaction, Reaction.shout_id == Shout.id) .join(Reaction, Reaction.shout_id == Shout.id)
.options( .options(joinedload(Shout.reactions), joinedload(Shout.authors))
joinedload(Shout.reactions),
joinedload(Shout.authors)
)
.filter(Reaction.created_by == follower_id) .filter(Reaction.created_by == follower_id)
.group_by(Shout.id) .group_by(Shout.id)
) )
q2 = add_stat_columns(q2, aliased(Reaction)) q2 = add_stat_columns(q2, aliased(Reaction))
# Sort shouts by the `last_comment` field # 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() results = session.execute(combined_query).scalars()
with local_session() as session: with local_session() as session:
for [ for [
@ -472,27 +485,28 @@ async def reacted_shouts_updates(follower_id: int, limit=50, offset=0) -> List[S
commented_stat, commented_stat,
likes_stat, likes_stat,
dislikes_stat, dislikes_stat,
last_comment last_comment,
] in results: ] in results:
shout.stat = { shout.stat = {
"viewed": await ViewedStorage.get_shout(shout.slug), 'viewed': await ViewedStorage.get_shout(shout.slug),
"rating": int(likes_stat or 0) - int(dislikes_stat or 0), 'rating': int(likes_stat or 0) - int(dislikes_stat or 0),
"commented": commented_stat, 'commented': commented_stat,
"last_comment": last_comment 'last_comment': last_comment,
} }
shouts.append(shout) shouts.append(shout)
return shouts return shouts
@query.field("load_shouts_followed")
@query.field('load_shouts_followed')
@login_required @login_required
async def load_shouts_followed(_, info, limit=50, offset=0) -> List[Shout]: 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: with local_session() as session:
author = session.query(Author).filter(Author.user == user_id).first() author = session.query(Author).filter(Author.user == user_id).first()
if author: if author:
try: try:
author_id: int = author.dict()["id"] author_id: int = author.dict()['id']
shouts = await reacted_shouts_updates(author_id, limit, offset) shouts = await reacted_shouts_updates(author_id, limit, offset)
return shouts return shouts
except Exception as error: except Exception as error:

View File

@ -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.orm import aliased, joinedload
from sqlalchemy.sql.expression import and_, asc, case, desc, func, nulls_last, select from sqlalchemy.sql.expression import and_, asc, case, desc, func, nulls_last, select
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
@ -7,32 +7,32 @@ from orm.author import Author, AuthorFollower
from orm.reaction import Reaction, ReactionKind from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic, ShoutVisibility from orm.shout import Shout, ShoutAuthor, ShoutTopic, ShoutVisibility
from orm.topic import Topic, TopicFollower 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.auth import login_required
from services.db import local_session from services.db import local_session
from services.schema import query from services.schema import query
from services.search import SearchService from services.search import SearchService
from services.viewed import ViewedStorage 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 def apply_filters(q, filters, author_id=None):
if filters.get("reacted") and author_id: if filters.get('reacted') and author_id:
q.join(Reaction, Reaction.created_by == author_id) q.join(Reaction, Reaction.created_by == author_id)
by_published = filters.get("published") by_published = filters.get('published')
if by_published: if by_published:
q = q.filter(Shout.visibility == ShoutVisibility.PUBLIC.value) q = q.filter(Shout.visibility == ShoutVisibility.PUBLIC.value)
by_layouts = filters.get("layouts") by_layouts = filters.get('layouts')
if by_layouts: if by_layouts:
q = q.filter(Shout.layout.in_(by_layouts)) q = q.filter(Shout.layout.in_(by_layouts))
by_author = filters.get("author") by_author = filters.get('author')
if by_author: if by_author:
q = q.filter(Shout.authors.any(slug=by_author)) q = q.filter(Shout.authors.any(slug=by_author))
by_topic = filters.get("topic") by_topic = filters.get('topic')
if by_topic: if by_topic:
q = q.filter(Shout.topics.any(slug=by_topic)) q = q.filter(Shout.topics.any(slug=by_topic))
by_after = filters.get("after") by_after = filters.get('after')
if by_after: if by_after:
ts = int(by_after) ts = int(by_after)
q = q.filter(Shout.created_at > ts) q = q.filter(Shout.created_at > ts)
@ -40,7 +40,7 @@ def apply_filters(q, filters, author_id=None): # noqa: C901
return q return q
@query.field("get_shout") @query.field('get_shout')
async def get_shout(_, _info, slug=None, shout_id=None): async def get_shout(_, _info, slug=None, shout_id=None):
with local_session() as session: with local_session() as session:
q = select(Shout).options( q = select(Shout).options(
@ -61,13 +61,19 @@ async def get_shout(_, _info, slug=None, shout_id=None):
try: try:
results = session.execute(q).first() results = session.execute(q).first()
if results: if results:
[shout, commented_stat, likes_stat, dislikes_stat, _last_comment] = results [
shout,
commented_stat,
likes_stat,
dislikes_stat,
_last_comment,
] = results
shout.stat = { shout.stat = {
"viewed": await ViewedStorage.get_shout(shout.slug), 'viewed': await ViewedStorage.get_shout(shout.slug),
# "reacted": reacted_stat, # "reacted": reacted_stat,
"commented": commented_stat, 'commented': commented_stat,
"rating": int(likes_stat or 0) - int(dislikes_stat or 0), 'rating': int(likes_stat or 0) - int(dislikes_stat or 0),
} }
for author_caption in session.query(ShoutAuthor).join(Shout).where(Shout.slug == slug): 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) session.query(Topic.slug)
.join( .join(
ShoutTopic, 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() .first()
) )
@ -87,10 +97,10 @@ async def get_shout(_, _info, slug=None, shout_id=None):
shout.main_topic = main_topic[0] shout.main_topic = main_topic[0]
return shout return shout
except Exception: 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): async def load_shouts_by(_, _info, options):
""" """
:param options: { :param options: {
@ -126,31 +136,39 @@ async def load_shouts_by(_, _info, options):
q = add_stat_columns(q, aliased_reaction) q = add_stat_columns(q, aliased_reaction)
# filters # filters
q = apply_filters(q, options.get("filters", {})) q = apply_filters(q, options.get('filters', {}))
# group # group
q = q.group_by(Shout.id) q = q.group_by(Shout.id)
# order # order
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) 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)) q = q.order_by(nulls_last(query_order_by))
# limit offset # limit offset
offset = options.get("offset", 0) offset = options.get('offset', 0)
limit = options.get("limit", 10) limit = options.get('limit', 10)
q = q.limit(limit).offset(offset) q = q.limit(limit).offset(offset)
shouts = [] shouts = []
with local_session() as session: 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 = ( main_topic = (
session.query(Topic.slug) session.query(Topic.slug)
.join( .join(
ShoutTopic, ShoutTopic,
and_( and_(
ShoutTopic.topic == Topic.id, ShoutTopic.shout == shout.id, ShoutTopic.main == True ShoutTopic.topic == Topic.id,
), # noqa: E712 ShoutTopic.shout == shout.id,
ShoutTopic.main == True,
),
) )
.first() .first()
) )
@ -158,19 +176,19 @@ async def load_shouts_by(_, _info, options):
if main_topic: if main_topic:
shout.main_topic = main_topic[0] shout.main_topic = main_topic[0]
shout.stat = { shout.stat = {
"viewed": await ViewedStorage.get_shout(shout.slug), 'viewed': await ViewedStorage.get_shout(shout.slug),
"commented": commented_stat, 'commented': commented_stat,
"rating": int(likes_stat) - int(dislikes_stat), 'rating': int(likes_stat) - int(dislikes_stat),
} }
shouts.append(shout) shouts.append(shout)
return shouts return shouts
@query.field("load_shouts_drafts") @query.field('load_shouts_drafts')
@login_required @login_required
async def load_shouts_drafts(_, info): async def load_shouts_drafts(_, info):
user_id = info.context["user_id"] user_id = info.context['user_id']
q = ( q = (
select(Shout) select(Shout)
@ -193,7 +211,11 @@ async def load_shouts_drafts(_, info):
session.query(Topic.slug) session.query(Topic.slug)
.join( .join(
ShoutTopic, 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() .first()
) )
@ -205,10 +227,10 @@ async def load_shouts_drafts(_, info):
return shouts return shouts
@query.field("load_shouts_feed") @query.field('load_shouts_feed')
@login_required @login_required
async def load_shouts_feed(_, info, options): async def load_shouts_feed(_, info, options):
user_id = info.context["user_id"] user_id = info.context['user_id']
shouts = [] shouts = []
with local_session() as session: with local_session() as session:
@ -221,7 +243,9 @@ async def load_shouts_feed(_, info, options):
select(Shout.id) select(Shout.id)
.where(Shout.id == ShoutAuthor.shout) .where(Shout.id == ShoutAuthor.shout)
.where(Shout.id == ShoutTopic.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 = ( q = (
@ -230,18 +254,24 @@ async def load_shouts_feed(_, info, options):
joinedload(Shout.authors), joinedload(Shout.authors),
joinedload(Shout.topics), 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) aliased_reaction = aliased(Reaction)
q = add_stat_columns(q, 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) query_order_by = desc(order_by) if options.get('order_by_desc', True) else asc(order_by)
offset = options.get("offset", 0) offset = options.get('offset', 0)
limit = options.get("limit", 10) limit = options.get('limit', 10)
q = q.group_by(Shout.id).order_by(nulls_last(query_order_by)).limit(limit).offset(offset) 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) session.query(Topic.slug)
.join( .join(
ShoutTopic, 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() .first()
) )
@ -260,20 +294,20 @@ async def load_shouts_feed(_, info, options):
if main_topic: if main_topic:
shout.main_topic = main_topic[0] shout.main_topic = main_topic[0]
shout.stat = { shout.stat = {
"viewed": await ViewedStorage.get_shout(shout.slug), 'viewed': await ViewedStorage.get_shout(shout.slug),
"reacted": reacted_stat, 'reacted': reacted_stat,
"commented": commented_stat, 'commented': commented_stat,
} }
shouts.append(shout) shouts.append(shout)
return shouts return shouts
@query.field("load_shouts_search") @query.field('load_shouts_search')
async def load_shouts_search(_, _info, text, limit=50, offset=0): async def load_shouts_search(_, _info, text, limit=50, offset=0):
if text and len(text) > 2: if text and len(text) > 2:
results = await SearchService.search(text, limit, offset) 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) # print(results_dict)
q = ( q = (
@ -289,13 +323,13 @@ async def load_shouts_search(_, _info, text, limit=50, offset=0):
with local_session() as session: with local_session() as session:
results = list(session.execute(q).unique()) results = list(session.execute(q).unique())
# print(results) # print(results)
print(f"[resolvers.reader] searched, preparing {len(results)} results") print(f'[resolvers.reader] searched, preparing {len(results)} results')
for x in results: for x in results:
shout = x[0] shout = x[0]
shout_slug = shout.dict().get("slug", "") shout_slug = shout.dict().get('slug', '')
score = results_dict.get(shout_slug, {}).get("score", 0) score = results_dict.get(shout_slug, {}).get('score', 0)
shout_data = shout.dict() # Convert the Shout instance to a dictionary 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) shouts_data.append(shout_data)
return shouts_data return shouts_data
@ -304,7 +338,7 @@ async def load_shouts_search(_, _info, text, limit=50, offset=0):
@login_required @login_required
@query.field("load_shouts_unrated") @query.field('load_shouts_unrated')
async def load_shouts_unrated(_, info, limit: int = 50, offset: int = 0): async def load_shouts_unrated(_, info, limit: int = 50, offset: int = 0):
q = ( q = (
select(Shout) 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]), Reaction.kind.in_([ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]),
), ),
) )
.outerjoin(Author, Author.user == bindparam("user_id")) .outerjoin(Author, Author.user == bindparam('user_id'))
.where( .where(
and_( and_(
Shout.deleted_at.is_(None), 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 = add_stat_columns(q, aliased_reaction)
q = q.group_by(Shout.id).order_by(func.random()).limit(limit).offset(offset) 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: if user_id:
with local_session() as session: with local_session() as session:
author = session.query(Author).filter(Author.user == user_id).first() 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): async def get_shouts_from_query(q, author_id=None):
shouts = [] shouts = []
with local_session() as session: with local_session() as session:
for [shout,commented_stat, likes_stat, dislikes_stat, last_comment] in session.execute( for [
q, {"author_id": author_id} shout,
).unique(): commented_stat,
likes_stat,
dislikes_stat,
_last_comment,
] in session.execute(q, {'author_id': author_id}).unique():
shouts.append(shout) shouts.append(shout)
shout.stat = { shout.stat = {
"viewed": await ViewedStorage.get_shout(shout_slug=shout.slug), 'viewed': await ViewedStorage.get_shout(shout_slug=shout.slug),
"commented": commented_stat, 'commented': commented_stat,
"rating": int(likes_stat or 0) - int(dislikes_stat or 0), 'rating': int(likes_stat or 0) - int(dislikes_stat or 0),
} }
return shouts return shouts
@query.field("load_shouts_random_top")
@query.field('load_shouts_random_top')
async def load_shouts_random_top(_, _info, options): async def load_shouts_random_top(_, _info, options):
""" """
:param _ :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 = select(Shout.id).outerjoin(aliased_reaction).where(Shout.deleted_at.is_(None))
subquery = apply_filters(subquery, options.get("filters", {})) subquery = apply_filters(subquery, options.get('filters', {}))
subquery = subquery.group_by(Shout.id).order_by(desc( subquery = subquery.group_by(Shout.id).order_by(
desc(
func.sum( func.sum(
case( case(
(Reaction.kind == ReactionKind.LIKE.value, 1), (Reaction.kind == ReactionKind.LIKE.value, 1),
(Reaction.kind == ReactionKind.AGREE.value, 1), (Reaction.kind == ReactionKind.AGREE.value, 1),
(Reaction.kind == ReactionKind.DISLIKE.value, -1), (Reaction.kind == ReactionKind.DISLIKE.value, -1),
(Reaction.kind == ReactionKind.DISAGREE.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: if random_limit:
subquery = subquery.limit(random_limit) subquery = subquery.limit(random_limit)
@ -412,7 +452,7 @@ async def load_shouts_random_top(_, _info, options):
aliased_reaction = aliased(Reaction) aliased_reaction = aliased(Reaction)
q = add_stat_columns(q, 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) q = q.group_by(Shout.id).order_by(func.random()).limit(limit)
# print(q.compile(compile_kwargs={"literal_binds": True})) # 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) 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): async def load_shouts_random_topic(_, info, limit: int = 10):
topic = get_random_topic() topic = get_random_topic()
shouts = [] shouts = []
@ -431,7 +471,13 @@ async def load_shouts_random_topic(_, info, limit: int = 10):
joinedload(Shout.authors), joinedload(Shout.authors),
joinedload(Shout.topics), 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) aliased_reaction = aliased(Reaction)
@ -441,4 +487,4 @@ async def load_shouts_random_topic(_, info, limit: int = 10):
shouts = get_shouts_from_query(q) shouts = get_shouts_from_query(q)
return {"topic": topic, "shouts": shouts} return {'topic': topic, 'shouts': shouts}

View File

@ -1,3 +1,5 @@
import logging
from sqlalchemy import and_, distinct, func, select from sqlalchemy import and_, distinct, func, select
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
@ -10,6 +12,10 @@ from services.schema import mutation, query
from services.viewed import ViewedStorage from services.viewed import ViewedStorage
logger = logging.getLogger('\t[resolvers.topic]\t')
logger.setLevel(logging.DEBUG)
async def followed_topics(follower_id): async def followed_topics(follower_id):
q = select(Author) q = select(Author)
q = add_topic_stat_columns(q) 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 # Pass the query to the get_topics_from_query function and return the results
return await get_topics_from_query(q) return await get_topics_from_query(q)
def add_topic_stat_columns(q): def add_topic_stat_columns(q):
aliased_shout_author = aliased(ShoutAuthor) aliased_shout_author = aliased(ShoutAuthor)
aliased_topic_follower = aliased(TopicFollower) aliased_topic_follower = aliased(TopicFollower)
q = ( q = (
q.outerjoin(ShoutTopic, Topic.id == ShoutTopic.topic) 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) .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) .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) q = q.group_by(Topic.id)
@ -40,17 +47,17 @@ async def get_topics_from_query(q):
with local_session() as session: with local_session() as session:
for [topic, shouts_stat, authors_stat, followers_stat] in session.execute(q): for [topic, shouts_stat, authors_stat, followers_stat] in session.execute(q):
topic.stat = { topic.stat = {
"shouts": shouts_stat, 'shouts': shouts_stat,
"authors": authors_stat, 'authors': authors_stat,
"followers": followers_stat, 'followers': followers_stat,
"viewed": await ViewedStorage.get_topic(topic.slug), 'viewed': await ViewedStorage.get_topic(topic.slug),
} }
topics.append(topic) topics.append(topic)
return topics return topics
@query.field("get_topics_all") @query.field('get_topics_all')
async def get_topics_all(_, _info): async def get_topics_all(_, _info):
q = select(Topic) q = select(Topic)
q = add_topic_stat_columns(q) q = add_topic_stat_columns(q)
@ -66,7 +73,7 @@ async def topics_followed_by(author_id):
return await get_topics_from_query(q) 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): async def get_topics_by_community(_, _info, community_id: int):
q = select(Topic).where(Topic.community == community_id) q = select(Topic).where(Topic.community == community_id)
q = add_topic_stat_columns(q) 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) return await get_topics_from_query(q)
@query.field("get_topics_by_author") @query.field('get_topics_by_author')
async def get_topics_by_author(_, _info, author_id=None, slug="", user=""): async def get_topics_by_author(_, _info, author_id=None, slug='', user=''):
q = select(Topic) q = select(Topic)
q = add_topic_stat_columns(q) q = add_topic_stat_columns(q)
if author_id: 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) return await get_topics_from_query(q)
@query.field("get_topic") @query.field('get_topic')
async def get_topic(_, _info, slug): async def get_topic(_, _info, slug):
q = select(Topic).where(Topic.slug == slug) q = select(Topic).where(Topic.slug == slug)
q = add_topic_stat_columns(q) q = add_topic_stat_columns(q)
@ -97,7 +104,7 @@ async def get_topic(_, _info, slug):
return topics[0] return topics[0]
@mutation.field("create_topic") @mutation.field('create_topic')
@login_required @login_required
async def create_topic(_, _info, inp): async def create_topic(_, _info, inp):
with local_session() as session: with local_session() as session:
@ -106,44 +113,44 @@ async def create_topic(_, _info, inp):
session.add(new_topic) session.add(new_topic)
session.commit() session.commit()
return {"topic": new_topic} return {'topic': new_topic}
@mutation.field("update_topic") @mutation.field('update_topic')
@login_required @login_required
async def update_topic(_, _info, inp): async def update_topic(_, _info, inp):
slug = inp["slug"] slug = inp['slug']
with local_session() as session: with local_session() as session:
topic = session.query(Topic).filter(Topic.slug == slug).first() topic = session.query(Topic).filter(Topic.slug == slug).first()
if not topic: if not topic:
return {"error": "topic not found"} return {'error': 'topic not found'}
else: else:
Topic.update(topic, inp) Topic.update(topic, inp)
session.add(topic) session.add(topic)
session.commit() session.commit()
return {"topic": topic} return {'topic': topic}
@mutation.field("delete_topic") @mutation.field('delete_topic')
@login_required @login_required
async def delete_topic(_, info, slug: str): async def delete_topic(_, info, slug: str):
user_id = info.context["user_id"] user_id = info.context['user_id']
with local_session() as session: with local_session() as session:
t: Topic = session.query(Topic).filter(Topic.slug == slug).first() t: Topic = session.query(Topic).filter(Topic.slug == slug).first()
if not t: if not t:
return {"error": "invalid topic slug"} return {'error': 'invalid topic slug'}
author = session.query(Author).filter(Author.user == user_id).first() author = session.query(Author).filter(Author.user == user_id).first()
if author: if author:
if t.created_by != author.id: if t.created_by != author.id:
return {"error": "access denied"} return {'error': 'access denied'}
session.delete(t) session.delete(t)
session.commit() session.commit()
return {} return {}
else: else:
return {"error": "access denied"} return {'error': 'access denied'}
def topic_follow(follower_id, slug): def topic_follow(follower_id, slug):
@ -169,12 +176,12 @@ def topic_unfollow(follower_id, slug):
session.delete(sub) session.delete(sub)
session.commit() session.commit()
return True return True
except Exception: except Exception as ex:
pass logger.debug(ex)
return False return False
@query.field("get_topics_random") @query.field('get_topics_random')
async def get_topics_random(_, info, amount=12): async def get_topics_random(_, info, amount=12):
q = select(Topic) q = select(Topic)
q = q.join(ShoutTopic) q = q.join(ShoutTopic)

View File

@ -15,22 +15,22 @@ class WebhookEndpoint(HTTPEndpoint):
try: try:
data = await request.json() data = await request.json()
if data: if data:
auth = request.headers.get("Authorization") auth = request.headers.get('Authorization')
if auth: if auth:
if auth == os.environ.get("WEBHOOK_SECRET"): if auth == os.environ.get('WEBHOOK_SECRET'):
user_id: str = data["user"]["id"] user_id: str = data['user']['id']
name: str = data["user"]["given_name"] name: str = data['user']['given_name']
slug: str = data["user"]["email"].split("@")[0] slug: str = data['user']['email'].split('@')[0]
slug: str = re.sub("[^0-9a-z]+", "-", slug.lower()) slug: str = re.sub('[^0-9a-z]+', '-', slug.lower())
with local_session() as session: with local_session() as session:
author = session.query(Author).filter(Author.slug == slug).first() author = session.query(Author).filter(Author.slug == slug).first()
if author: if author:
slug = slug + "-" + user_id.split("-").pop() slug = slug + '-' + user_id.split('-').pop()
await create_author(user_id, slug, name) await create_author(user_id, slug, name)
return JSONResponse({"status": "success"}) return JSONResponse({'status': 'success'})
except Exception as e: except Exception as e:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return JSONResponse({"status": "error", "message": str(e)}, status_code=500) return JSONResponse({'status': 'error', 'message': str(e)}, status_code=500)

View File

@ -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)

View File

@ -1,90 +1,95 @@
from functools import wraps
import logging import logging
from functools import wraps
from aiohttp import ClientSession from aiohttp import ClientSession
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from settings import AUTH_URL, AUTH_SECRET from settings import AUTH_SECRET, AUTH_URL
logging.basicConfig() logging.basicConfig()
logger = logging.getLogger("\t[services.auth]\t") logger = logging.getLogger('\t[services.auth]\t')
logger.setLevel(logging.DEBUG) 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: try:
# Asynchronous HTTP request to the authentication server # Asynchronous HTTP request to the authentication server
async with ClientSession() as session: async with ClientSession() as session:
async with session.post(AUTH_URL, json=gql, headers=headers) as response: async with session.post(AUTH_URL, json=gql, headers=headers) as response:
if response.status == 200: if response.status == 200:
data = await response.json() data = await response.json()
errors = data.get("errors") errors = data.get('errors')
if errors: if errors:
logger.error(f"[services.auth] errors: {errors}") logger.error(f'[services.auth] errors: {errors}')
else: else:
return data return data
except Exception as e: except Exception as e:
# Handling and logging exceptions during authentication check # 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 return None
async def check_auth(req) -> str | None: async def check_auth(req) -> str | None:
token = req.headers.get("Authorization") token = req.headers.get('Authorization')
user_id = "" user_id = ''
if token: if token:
# Logging the authentication token # Logging the authentication token
logger.error(f"[services.auth] checking auth token: {token}") logger.error(f'[services.auth] checking auth token: {token}')
query_name = "validate_jwt_token" query_name = 'validate_jwt_token'
operation = "ValidateToken" operation = 'ValidateToken'
variables = { variables = {
"params": { 'params': {
"token_type": "access_token", 'token_type': 'access_token',
"token": token, 'token': token,
} }
} }
gql = { gql = {
"query": f"query {operation}($params: ValidateJWTTokenInput!) {{ {query_name}(params: $params) {{ is_valid claims }} }}", 'query': f'query {operation}($params: ValidateJWTTokenInput!) {{ {query_name}(params: $params) {{ is_valid claims }} }}',
"variables": variables, 'variables': variables,
"operationName": operation, 'operationName': operation,
} }
data = await request_data(gql) data = await request_data(gql)
if data: 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 return user_id
if not 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): async def add_user_role(user_id):
logger.info(f"[services.auth] add author role for user_id: {user_id}") logger.info(f'[services.auth] add author role for user_id: {user_id}')
query_name = "_update_user" query_name = '_update_user'
operation = "UpdateUserRoles" operation = 'UpdateUserRoles'
headers = {"Content-Type": "application/json", "x-authorizer-admin-secret": AUTH_SECRET} headers = {
variables = {"params": {"roles": "author, reader", "id": user_id}} 'Content-Type': 'application/json',
'x-authorizer-admin-secret': AUTH_SECRET,
}
variables = {'params': {'roles': 'author, reader', 'id': user_id}}
gql = { gql = {
"query": f"mutation {operation}($params: UpdateUserInput!) {{ {query_name}(params: $params) {{ id roles }} }}", 'query': f'mutation {operation}($params: UpdateUserInput!) {{ {query_name}(params: $params) {{ id roles }} }}',
"variables": variables, 'variables': variables,
"operationName": operation, 'operationName': operation,
} }
data = await request_data(gql, headers) data = await request_data(gql, headers)
if data: 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 return user_id
def login_required(f): def login_required(f):
@wraps(f) @wraps(f)
async def decorated_function(*args, **kwargs): async def decorated_function(*args, **kwargs):
info = args[1] info = args[1]
context = info.context context = info.context
req = context.get("request") req = context.get('request')
user_id = await check_auth(req) user_id = await check_auth(req)
if user_id: if user_id:
context["user_id"] = user_id.strip() context['user_id'] = user_id.strip()
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated_function return decorated_function
@ -96,7 +101,7 @@ def auth_request(f):
req = args[0] req = args[0]
user_id = await check_auth(req) user_id = await check_auth(req)
if user_id: if user_id:
req["user_id"] = user_id.strip() req['user_id'] = user_id.strip()
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated_function return decorated_function

View File

@ -1,47 +1,48 @@
import logging
import math import math
import time import time
import logging
# from contextlib import contextmanager # from contextlib import contextmanager
from typing import Any, Callable, Dict, TypeVar from typing import Any, Callable, Dict, TypeVar
# from psycopg2.errors import UniqueViolation # from psycopg2.errors import UniqueViolation
from sqlalchemy import Column, Integer, create_engine, event from sqlalchemy import Column, Integer, create_engine, event
from sqlalchemy.engine import Engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.sql.schema import Table from sqlalchemy.sql.schema import Table
from sqlalchemy.engine import Engine
from settings import DB_URL from settings import DB_URL
logging.basicConfig() logging.basicConfig()
logger = logging.getLogger("\t [sqlalchemy.profiler]\t") logger = logging.getLogger('\t [sqlalchemy.profiler]\t')
logger.setLevel(logging.DEBUG) 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): 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}") # 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): 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 total = math.floor(total * 10000) / 10000
if total > 35: 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) engine = create_engine(DB_URL, echo=False, pool_size=10, max_overflow=20)
T = TypeVar("T") T = TypeVar('T')
REGISTRY: Dict[str, type] = {} REGISTRY: Dict[str, type] = {}
# @contextmanager # @contextmanager
def local_session(src=""): def local_session(src=''):
return Session(bind=engine, expire_on_commit=False) return Session(bind=engine, expire_on_commit=False)
# try: # try:
@ -69,7 +70,7 @@ class Base(declarative_base()):
__init__: Callable __init__: Callable
__allow_unmapped__ = True __allow_unmapped__ = True
__abstract__ = True __abstract__ = True
__table_args__ = {"extend_existing": True} __table_args__ = {'extend_existing': True}
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
@ -78,12 +79,12 @@ class Base(declarative_base()):
def dict(self) -> Dict[str, Any]: def dict(self) -> Dict[str, Any]:
column_names = self.__table__.columns.keys() column_names = self.__table__.columns.keys()
if "_sa_instance_state" in column_names: if '_sa_instance_state' in column_names:
column_names.remove("_sa_instance_state") column_names.remove('_sa_instance_state')
try: try:
return {c: getattr(self, c) for c in column_names} return {c: getattr(self, c) for c in column_names}
except Exception as e: except Exception as e:
print(f"[services.db] Error dict: {e}") print(f'[services.db] Error dict: {e}')
return {} return {}
def update(self, values: Dict[str, Any]) -> None: def update(self, values: Dict[str, Any]) -> None:

View File

@ -19,7 +19,7 @@ class Following:
class FollowingManager: class FollowingManager:
lock = asyncio.Lock() lock = asyncio.Lock()
followers_by_kind = {} followers_by_kind = {}
data = {"author": [], "topic": [], "shout": [], "community": []} data = {'author': [], 'topic': [], 'shout': [], 'community': []}
@staticmethod @staticmethod
async def register(kind, uid): async def register(kind, uid):
@ -41,7 +41,7 @@ class FollowingManager:
async with FollowingManager.lock: async with FollowingManager.lock:
entities = FollowingManager.followers_by_kind.get(kind, []) entities = FollowingManager.followers_by_kind.get(kind, [])
for entity in entities[:]: # Use a copy to iterate 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) entity.queue.put_nowait(payload)
except Exception as e: except Exception as e:
print(Exception(e)) print(Exception(e))

View File

@ -3,43 +3,43 @@ import json
from services.rediscache import redis from services.rediscache import redis
async def notify_reaction(reaction, action: str = "create"): async def notify_reaction(reaction, action: str = 'create'):
channel_name = "reaction" channel_name = 'reaction'
data = {"payload": reaction, "action": action} data = {'payload': reaction, 'action': action}
try: try:
await redis.publish(channel_name, json.dumps(data)) await redis.publish(channel_name, json.dumps(data))
except Exception as e: 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"): async def notify_shout(shout, action: str = 'create'):
channel_name = "shout" channel_name = 'shout'
data = {"payload": shout, "action": action} data = {'payload': shout, 'action': action}
try: try:
await redis.publish(channel_name, json.dumps(data)) await redis.publish(channel_name, json.dumps(data))
except Exception as e: 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"): async def notify_follower(follower: dict, author_id: int, action: str = 'follow'):
channel_name = f"follower:{author_id}" channel_name = f'follower:{author_id}'
try: try:
# Simplify dictionary before publishing # 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 # Convert data to JSON string
json_data = json.dumps(data) json_data = json.dumps(data)
# Ensure the data is not empty before publishing # Ensure the data is not empty before publishing
if not json_data: if not json_data:
raise ValueError("Empty data to publish.") raise ValueError('Empty data to publish.')
# Use the 'await' keyword when publishing # Use the 'await' keyword when publishing
await redis.publish(channel_name, json_data) await redis.publish(channel_name, json_data)
except Exception as e: except Exception as e:
# Log the error and re-raise it # 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 raise

View File

@ -1,9 +1,11 @@
import logging
import redis.asyncio as aredis import redis.asyncio as aredis
from settings import REDIS_URL from settings import REDIS_URL
import logging
logger = logging.getLogger("[services.redis] ")
logger = logging.getLogger('[services.redis] ')
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
@ -23,7 +25,7 @@ class RedisCache:
async def execute(self, command, *args, **kwargs): async def execute(self, command, *args, **kwargs):
if self._client: if self._client:
try: try:
logger.debug(f"{command} {args} {kwargs}") logger.debug(f'{command} {args} {kwargs}')
r = await self._client.execute_command(command, *args, **kwargs) r = await self._client.execute_command(command, *args, **kwargs)
logger.debug(type(r)) logger.debug(type(r))
logger.debug(r) logger.debug(r)
@ -51,6 +53,7 @@ class RedisCache:
return return
await self._client.publish(channel, data) await self._client.publish(channel, data)
redis = RedisCache() redis = RedisCache()
__all__ = ["redis"] __all__ = ['redis']

View File

@ -1,5 +1,6 @@
from ariadne import MutationType, QueryType # , ScalarType from ariadne import MutationType, QueryType # , ScalarType
# datetime_scalar = ScalarType("DateTime") # datetime_scalar = ScalarType("DateTime")
query = QueryType() query = QueryType()
mutation = MutationType() mutation = MutationType()

View File

@ -15,28 +15,28 @@ class SearchService:
@staticmethod @staticmethod
async def init(session): async def init(session):
async with SearchService.lock: async with SearchService.lock:
logging.info("[services.search] Initializing SearchService") logging.info('[services.search] Initializing SearchService')
@staticmethod @staticmethod
async def search(text: str, limit: int = 50, offset: int = 0) -> List[Shout]: async def search(text: str, limit: int = 50, offset: int = 0) -> List[Shout]:
payload = [] payload = []
try: try:
# TODO: add ttl for redis cached search results # TODO: add ttl for redis cached search results
cached = await redis.execute("GET", text) cached = await redis.execute('GET', text)
if not cached: if not cached:
async with SearchService.lock: async with SearchService.lock:
# Use aiohttp to send a request to ElasticSearch # Use aiohttp to send a request to ElasticSearch
async with aiohttp.ClientSession() as session: 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: async with session.get(search_url) as response:
if response.status == 200: if response.status == 200:
payload = await response.json() 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: 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): elif isinstance(cached, str):
payload = json.loads(cached) payload = json.loads(cached)
except Exception as e: 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] return payload[offset : offset + limit]

View File

@ -1,9 +1,10 @@
from services.rediscache import redis
import json import json
from services.rediscache import redis
async def get_unread_counter(chat_id: str, author_id: int) -> int: 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): if isinstance(r, str):
return int(r) return int(r)
elif isinstance(r, int): elif isinstance(r, int):
@ -11,8 +12,9 @@ async def get_unread_counter(chat_id: str, author_id: int) -> int:
else: else:
return 0 return 0
async def get_total_unread_counter(author_id: int) -> int: 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 s = 0
if isinstance(chats_set, str): if isinstance(chats_set, str):
chats_set = json.loads(chats_set) chats_set = json.loads(chats_set)

View File

@ -1,40 +1,39 @@
import os
from typing import Dict, List
import logging
import time
import json
import asyncio import asyncio
import json
import logging
import os
import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from os import environ from typing import Dict
# ga # ga
from apiclient.discovery import build from apiclient.discovery import build
from google.oauth2.service_account import Credentials from google.oauth2.service_account import Credentials
import pandas as pd
from orm.author import Author from orm.author import Author
from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic from orm.topic import Topic
from services.db import local_session from services.db import local_session
# Настройка журналирования # Настройка журналирования
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("\t[services.viewed]\t") logger = logging.getLogger('\t[services.viewed]\t')
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
# Пути к ключевым файлам и идентификатор представления в Google Analytics GOOGLE_KEYFILE_PATH = os.environ.get('GOOGLE_KEYFILE_PATH', '/dump/google-service.json')
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_GA_VIEW_ID = os.environ.get("GOOGLE_GA_VIEW_ID", "") # GOOGLE_ANALYTICS_API = 'https://analyticsreporting.googleapis.com/v4'
gaBaseUrl = "https://analyticsreporting.googleapis.com/v4" GOOGLE_ANALYTICS_SCOPES = ['https://www.googleapis.com/auth/analytics.readonly']
# Функция для создания объекта службы Analytics Reporting API V4 # Функция для создания объекта службы Analytics Reporting API V4
def get_service(): def get_service():
SCOPES = ['https://www.googleapis.com/auth/analytics.readonly'] credentials = Credentials.from_service_account_file(GOOGLE_KEYFILE_PATH, scopes=GOOGLE_ANALYTICS_SCOPES)
credentials = Credentials.from_service_account_file(GOOGLE_KEYFILE_PATH, scopes=SCOPES)
service = build(serviceName='analyticsreporting', version='v4', credentials=credentials) service = build(serviceName='analyticsreporting', version='v4', credentials=credentials)
return service return service
class ViewedStorage: class ViewedStorage:
lock = asyncio.Lock() lock = asyncio.Lock()
views_by_shout = {} views_by_shout = {}
@ -45,7 +44,7 @@ class ViewedStorage:
analytics_client = None analytics_client = None
auth_result = None auth_result = None
disabled = False disabled = False
date_range = "" date_range = ''
@staticmethod @staticmethod
async def init(): async def init():
@ -54,13 +53,13 @@ class ViewedStorage:
async with self.lock: async with self.lock:
if os.path.exists(GOOGLE_KEYFILE_PATH): if os.path.exists(GOOGLE_KEYFILE_PATH):
self.analytics_client = get_service() self.analytics_client = get_service()
logger.info(f" * Постоянная авторизация в Google Analytics {self.analytics_client}") logger.info(f' * Постоянная авторизация в Google Analytics {self.analytics_client}')
# Загрузка предварительно подсчитанных просмотров из файла JSON # Загрузка предварительно подсчитанных просмотров из файла JSON
self.load_precounted_views() self.load_precounted_views()
# Установка диапазона дат на основе времени создания файла views.json # Установка диапазона дат на основе времени создания файла views.json
views_json_path = "/dump/views.json" views_json_path = '/dump/views.json'
creation_time = datetime.fromtimestamp(os.path.getctime(views_json_path)) creation_time = datetime.fromtimestamp(os.path.getctime(views_json_path))
end_date = datetime.now(timezone.utc).strftime('%Y-%m-%d') end_date = datetime.now(timezone.utc).strftime('%Y-%m-%d')
start_date = creation_time.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()) views_stat_task = asyncio.create_task(self.worker())
logger.info(views_stat_task) logger.info(views_stat_task)
else: else:
logger.info(" * Пожалуйста, добавьте ключевой файл Google Analytics") logger.info(' * Пожалуйста, добавьте ключевой файл Google Analytics')
self.disabled = True self.disabled = True
@staticmethod @staticmethod
@ -77,33 +76,43 @@ class ViewedStorage:
"""Загрузка предварительно подсчитанных просмотров из файла JSON""" """Загрузка предварительно подсчитанных просмотров из файла JSON"""
self = ViewedStorage self = ViewedStorage
try: try:
with open("/dump/views.json", "r") as file: with open('/dump/views.json', 'r') as file:
precounted_views = json.load(file) precounted_views = json.load(file)
self.views_by_shout.update(precounted_views) 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: except Exception as e:
logger.error(f"Ошибка загрузки предварительно подсчитанных просмотров: {e}") logger.error(f'Ошибка загрузки предварительно подсчитанных просмотров: {e}')
@staticmethod @staticmethod
async def update_pages(): async def update_pages():
"""Запрос всех страниц от Google Analytics, отсортированных по количеству просмотров""" """Запрос всех страниц от Google Analytics, отсортированных по количеству просмотров"""
self = ViewedStorage self = ViewedStorage
if not self.disabled and GOOGLE_GA_VIEW_ID: if not self.disabled and GOOGLE_GA_VIEW_ID:
logger.info(" ⎧ Обновление данных просмотров от Google Analytics ---") logger.info(' ⎧ Обновление данных просмотров от Google Analytics ---')
try: try:
start = time.time() start = time.time()
async with self.lock: async with self.lock:
if self.analytics_client: if self.analytics_client:
data = self.analytics_client.reports().batchGet(body={ data = (
'reportRequests': [{ self.analytics_client.reports()
'viewId': GOOGLE_GA_VIEW_ID, .batchGet(
'dateRanges': self.date_range, body={
'metrics': [{'expression': 'ga:pageviews'}], 'reportRequests': [
'dimensions': [{'name': 'ga:pagePath'}], {
}] 'viewId': GOOGLE_GA_VIEW_ID,
}).execute() 'dateRanges': self.date_range,
'metrics': [{'expression': 'ga:pageviews'}],
'dimensions': [{'name': 'ga:pagePath'}],
}
]
}
)
.execute()
)
if isinstance(data, dict): if isinstance(data, dict):
slugs = set([]) slugs = set()
reports = data.get('reports', []) reports = data.get('reports', [])
if reports and isinstance(reports, list): if reports and isinstance(reports, list):
rows = list(reports[0].get('data', {}).get('rows', [])) rows = list(reports[0].get('data', {}).get('rows', []))
@ -113,7 +122,7 @@ class ViewedStorage:
dimensions = row.get('dimensions', []) dimensions = row.get('dimensions', [])
if isinstance(dimensions, list) and dimensions: if isinstance(dimensions, list) and dimensions:
page_path = dimensions[0] 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]) views_count = int(row['metrics'][0]['values'][0])
# Обновление данных в хранилище # Обновление данных в хранилище
@ -124,15 +133,15 @@ class ViewedStorage:
# Запись путей страниц для логирования # Запись путей страниц для логирования
slugs.add(slug) slugs.add(slug)
logger.info(f" ⎪ Собрано страниц: {len(slugs)} ") logger.info(f' ⎪ Собрано страниц: {len(slugs)} ')
end = time.time() end = time.time()
logger.info(" ⎪ Обновление страниц заняло %fs " % (end - start)) logger.info(' ⎪ Обновление страниц заняло %fs ' % (end - start))
except Exception: except Exception:
import traceback import traceback
traceback.print_exc()
traceback.print_exc()
@staticmethod @staticmethod
async def get_shout(shout_slug) -> int: async def get_shout(shout_slug) -> int:
@ -178,10 +187,14 @@ class ViewedStorage:
dictionary[key] = list(set(dictionary.get(key, []) + [value])) 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) 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) update_groups(self.shouts_by_author, author.slug, shout_slug)
@staticmethod @staticmethod
@ -194,20 +207,20 @@ class ViewedStorage:
while True: while True:
try: try:
logger.info(" - Обновление записей...") logger.info(' - Обновление записей...')
await self.update_pages() await self.update_pages()
failed = 0 failed = 0
except Exception: except Exception:
failed += 1 failed += 1
logger.info(" - Обновление не удалось #%d, ожидание 10 секунд" % failed) logger.info(' - Обновление не удалось #%d, ожидание 10 секунд' % failed)
if failed > 3: if failed > 3:
logger.info(" - Больше не пытаемся обновить") logger.info(' - Больше не пытаемся обновить')
break break
if failed == 0: if failed == 0:
when = datetime.now(timezone.utc) + timedelta(seconds=self.period) when = datetime.now(timezone.utc) + timedelta(seconds=self.period)
t = format(when.astimezone().isoformat()) 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) await asyncio.sleep(self.period)
else: else:
await asyncio.sleep(10) await asyncio.sleep(10)
logger.info(" - Попытка снова обновить данные") logger.info(' - Попытка снова обновить данные')

View File

@ -1,17 +1,18 @@
from os import environ
import sys import sys
from os import environ
PORT = 8080 PORT = 8080
DB_URL = ( DB_URL = (
environ.get("DATABASE_URL", "").replace("postgres://", "postgresql://") environ.get('DATABASE_URL', '').replace('postgres://', 'postgresql://')
or environ.get("DB_URL", "").replace("postgres://", "postgresql://") or environ.get('DB_URL', '').replace('postgres://', 'postgresql://')
or "postgresql://postgres@localhost:5432/discoursio" or 'postgresql://postgres@localhost:5432/discoursio'
) )
REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1" REDIS_URL = environ.get('REDIS_URL') or 'redis://127.0.0.1'
API_BASE = environ.get("API_BASE") or "" API_BASE = environ.get('API_BASE') or ''
AUTH_URL = environ.get("AUTH_URL") or "" AUTH_URL = environ.get('AUTH_URL') or ''
SENTRY_DSN = environ.get("SENTRY_DSN") SENTRY_DSN = environ.get('SENTRY_DSN')
DEV_SERVER_PID_FILE_NAME = "dev-server.pid" DEV_SERVER_PID_FILE_NAME = 'dev-server.pid'
MODE = "development" if "dev" in sys.argv else "production" 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'