diff --git a/.flake8 b/.flake8
deleted file mode 100644
index e82de95a..00000000
--- a/.flake8
+++ /dev/null
@@ -1,6 +0,0 @@
-[flake8]
-ignore = E203,W504,W191,W503
-exclude = .git,__pycache__,orm/rbac.py
-max-complexity = 10
-max-line-length = 108
-indent-string = ' '
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
new file mode 100644
index 00000000..794ec49c
--- /dev/null
+++ b/.github/workflows/checks.yml
@@ -0,0 +1,16 @@
+name: Checks
+on: [pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ name: Checks
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-python@v2
+ with:
+ python-version: 3.10.6
+ - run: pip install --upgrade pip
+ - run: pip install -r requirements.txt
+ - run: pip install -r requirements-dev.txt
+ - run: ./checks.sh
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index fc2116ab..09ad4e40 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,11 +6,11 @@ exclude: |
)
default_language_version:
- python: python3.8
+ python: python3.10
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v3.2.0
+ rev: v4.5.0
hooks:
- id: check-added-large-files
- id: check-case-conflict
@@ -21,6 +21,7 @@ repos:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
+ - id: requirements-txt-fixer
- repo: https://github.com/timothycrosley/isort
rev: 5.12.0
@@ -28,17 +29,16 @@ repos:
- id: isort
- repo: https://github.com/ambv/black
- rev: 23.9.1
+ rev: 23.10.1
hooks:
- id: black
- args:
- - --line-length=100
- - --skip-string-normalization
- repo: https://github.com/PyCQA/flake8
rev: 6.1.0
hooks:
- id: flake8
- args:
- - --max-line-length=100
- - --disable=protected-access
+
+# - repo: https://github.com/python/mypy
+# rev: v1.6.1
+# hooks:
+# - id: mypy
diff --git a/CHECKS b/CHECKS
index 738277fe..bdfcc6fe 100644
--- a/CHECKS
+++ b/CHECKS
@@ -1,5 +1,5 @@
WAIT=10
TIMEOUT=10
-ATTEMPTS=10
+ATTEMPTS=3
/
diff --git a/Procfile b/Procfile
index c5c1bfa8..ac9d762f 100644
--- a/Procfile
+++ b/Procfile
@@ -1,2 +1 @@
web: python server.py
-
diff --git a/README.md b/README.md
index 1a1ee0a4..12e902db 100644
--- a/README.md
+++ b/README.md
@@ -7,10 +7,6 @@
- starlette
- uvicorn
-# Local development
-
-Install deps first
-
on osx
```
brew install redis nginx postgres
@@ -22,16 +18,23 @@ on debian/ubuntu
apt install redis nginx
```
-First, install Postgres. Then you'll need some data, so migrate it:
+# Local development
+
+Install deps first
+
```
-createdb discoursio
-python server.py migrate
+pip install -r requirements.txt
+pip install -r requirements-dev.txt
+pre-commit install
```
-Then run nginx, redis and API server
+Create database from backup
+```
+./restdb.sh
+```
+
+Start local server
```
-redis-server
-pip install -r requirements.txt
python3 server.py dev
```
@@ -42,4 +45,3 @@ Put the header 'Authorization' with token from signIn query or registerUser muta
# How to debug Ackee
Set ACKEE_TOKEN var
-
diff --git a/ai/preprocess.py b/ai/preprocess.py
deleted file mode 100644
index afd8dbd8..00000000
--- a/ai/preprocess.py
+++ /dev/null
@@ -1,75 +0,0 @@
-import re
-import nltk
-from bs4 import BeautifulSoup
-from nltk.corpus import stopwords
-from pymystem3 import Mystem
-from string import punctuation
-from transformers import BertTokenizer
-
-nltk.download("stopwords")
-
-
-def get_clear_text(text):
- soup = BeautifulSoup(text, 'html.parser')
-
- # extract the plain text from the HTML document without tags
- clear_text = ''
- for tag in soup.find_all():
- clear_text += tag.string or ''
-
- clear_text = re.sub(pattern='[\u202F\u00A0\n]+', repl=' ', string=clear_text)
-
- # only words
- clear_text = re.sub(pattern='[^A-ZА-ЯЁ -]', repl='', string=clear_text, flags=re.IGNORECASE)
-
- clear_text = re.sub(pattern='\s+', repl=' ', string=clear_text)
-
- clear_text = clear_text.lower()
-
- mystem = Mystem()
- russian_stopwords = stopwords.words("russian")
-
- tokens = mystem.lemmatize(clear_text)
- tokens = [token for token in tokens if token not in russian_stopwords \
- and token != " " \
- and token.strip() not in punctuation]
-
- clear_text = " ".join(tokens)
-
- return clear_text
-
-
-# if __name__ == '__main__':
-#
-# # initialize the tokenizer with the pre-trained BERT model and vocabulary
-# tokenizer = BertTokenizer.from_pretrained('bert-base-multilingual-cased')
-#
-# # split each text into smaller segments of maximum length 512
-# max_length = 512
-# segmented_texts = []
-# for text in [clear_text1, clear_text2]:
-# segmented_text = []
-# for i in range(0, len(text), max_length):
-# segment = text[i:i+max_length]
-# segmented_text.append(segment)
-# segmented_texts.append(segmented_text)
-#
-# # tokenize each segment using the BERT tokenizer
-# tokenized_texts = []
-# for segmented_text in segmented_texts:
-# tokenized_text = []
-# for segment in segmented_text:
-# segment_tokens = tokenizer.tokenize(segment)
-# segment_tokens = ['[CLS]'] + segment_tokens + ['[SEP]']
-# tokenized_text.append(segment_tokens)
-# tokenized_texts.append(tokenized_text)
-#
-# input_ids = []
-# for tokenized_text in tokenized_texts:
-# input_id = []
-# for segment_tokens in tokenized_text:
-# segment_id = tokenizer.convert_tokens_to_ids(segment_tokens)
-# input_id.append(segment_id)
-# input_ids.append(input_id)
-#
-# print(input_ids)
diff --git a/alembic/env.py b/alembic/env.py
index c6d69a97..3256b308 100644
--- a/alembic/env.py
+++ b/alembic/env.py
@@ -1,10 +1,9 @@
from logging.config import fileConfig
-from sqlalchemy import engine_from_config
-from sqlalchemy import pool
+from sqlalchemy import engine_from_config, pool
from alembic import context
-
+from base.orm import Base
from settings import DB_URL
# this is the Alembic Config object, which provides
@@ -19,7 +18,6 @@ config.set_section_option(config.config_ini_section, "DB_URL", DB_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
-from base.orm import Base
target_metadata = [Base.metadata]
# other values from the config, defined by the needs of env.py,
@@ -66,9 +64,7 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
- context.configure(
- connection=connection, target_metadata=target_metadata
- )
+ context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
diff --git a/alembic/versions/fe943b098418_init_alembic.py b/alembic/versions/fe943b098418_init_alembic.py
index 4ec6d519..52796fea 100644
--- a/alembic/versions/fe943b098418_init_alembic.py
+++ b/alembic/versions/fe943b098418_init_alembic.py
@@ -1,18 +1,18 @@
"""init alembic
Revision ID: fe943b098418
-Revises:
+Revises:
Create Date: 2023-08-19 01:37:57.031933
"""
from typing import Sequence, Union
-from alembic import op
-import sqlalchemy as sa
+# import sqlalchemy as sa
+# from alembic import op
# revision identifiers, used by Alembic.
-revision: str = 'fe943b098418'
+revision: str = "fe943b098418"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
diff --git a/auth/authenticate.py b/auth/authenticate.py
index be4db2d2..aa5b308f 100644
--- a/auth/authenticate.py
+++ b/auth/authenticate.py
@@ -2,75 +2,71 @@ from functools import wraps
from typing import Optional, Tuple
from graphql.type import GraphQLResolveInfo
-from sqlalchemy.orm import joinedload, exc
+from sqlalchemy.orm import exc, joinedload
from starlette.authentication import AuthenticationBackend
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials, AuthUser
-from base.orm import local_session
-from orm.user import User, Role
-
-from settings import SESSION_TOKEN_HEADER
from auth.tokenstorage import SessionToken
from base.exceptions import OperationNotAllowed
+from base.orm import local_session
+from orm.user import Role, User
+from settings import SESSION_TOKEN_HEADER
class JWTAuthenticate(AuthenticationBackend):
async def authenticate(
self, request: HTTPConnection
) -> Optional[Tuple[AuthCredentials, AuthUser]]:
-
if SESSION_TOKEN_HEADER not in request.headers:
- return AuthCredentials(scopes={}), AuthUser(user_id=None, username='')
+ return AuthCredentials(scopes={}), AuthUser(user_id=None, username="")
token = request.headers.get(SESSION_TOKEN_HEADER)
if not token:
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER)
return AuthCredentials(scopes={}, error_message=str("no token")), AuthUser(
- user_id=None, username=''
+ user_id=None, username=""
)
- if len(token.split('.')) > 1:
+ if len(token.split(".")) > 1:
payload = await SessionToken.verify(token)
with local_session() as session:
try:
user = (
- session.query(User).options(
+ session.query(User)
+ .options(
joinedload(User.roles).options(joinedload(Role.permissions)),
- joinedload(User.ratings)
- ).filter(
- User.id == payload.user_id
- ).one()
+ joinedload(User.ratings),
+ )
+ .filter(User.id == payload.user_id)
+ .one()
)
scopes = {} # TODO: integrate await user.get_permission()
return (
- AuthCredentials(
- user_id=payload.user_id,
- scopes=scopes,
- logged_in=True
- ),
- AuthUser(user_id=user.id, username=''),
+ AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True),
+ AuthUser(user_id=user.id, username=""),
)
except exc.NoResultFound:
pass
- return AuthCredentials(scopes={}, error_message=str('Invalid token')), AuthUser(user_id=None, username='')
+ return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(
+ user_id=None, username=""
+ )
def login_required(func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
- # print('[auth.authenticate] login required for %r with info %r' % (func, info)) # debug only
+ # debug only
+ # print('[auth.authenticate] login required for %r with info %r' % (func, info))
auth: AuthCredentials = info.context["request"].auth
# print(auth)
if not auth or not auth.logged_in:
# raise Unauthorized(auth.error_message or "Please login")
- return {
- "error": "Please login first"
- }
+ return {"error": "Please login first"}
return await func(parent, info, *args, **kwargs)
return wrap
@@ -79,7 +75,9 @@ def login_required(func):
def permission_required(resource, operation, func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
- print('[auth.authenticate] permission_required for %r with info %r' % (func, info)) # debug only
+ print(
+ "[auth.authenticate] permission_required for %r with info %r" % (func, info)
+ ) # debug only
auth: AuthCredentials = info.context["request"].auth
if not auth.logged_in:
raise OperationNotAllowed(auth.error_message or "Please login")
diff --git a/auth/credentials.py b/auth/credentials.py
index 9045b7a4..3d7d5a36 100644
--- a/auth/credentials.py
+++ b/auth/credentials.py
@@ -23,13 +23,11 @@ class AuthCredentials(BaseModel):
async def permissions(self) -> List[Permission]:
if self.user_id is None:
# raise Unauthorized("Please login first")
- return {
- "error": "Please login first"
- }
+ return {"error": "Please login first"}
else:
# TODO: implement permissions logix
print(self.user_id)
- return NotImplemented()
+ return NotImplemented
class AuthUser(BaseModel):
@@ -40,6 +38,6 @@ class AuthUser(BaseModel):
def is_authenticated(self) -> bool:
return self.user_id is not None
- @property
- def display_id(self) -> int:
- return self.user_id
+ # @property
+ # def display_id(self) -> int:
+ # return self.user_id
diff --git a/auth/email.py b/auth/email.py
index 7ca5d9bf..a42cf1f7 100644
--- a/auth/email.py
+++ b/auth/email.py
@@ -2,19 +2,16 @@ import requests
from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN
-api_url = "https://api.mailgun.net/v3/%s/messages" % (MAILGUN_DOMAIN or 'discours.io')
-noreply = "discours.io \s*
",
r"
\s*
self.span_highlight = True
- elif (
- self.current_class == "lead"
- and not self.inheader
- and not self.span_highlight
- ):
+ elif self.current_class == "lead" and not self.inheader and not self.span_highlight:
# self.o("==") # NOTE: CriticMarkup {==
self.span_lead = True
else:
@@ -479,11 +471,7 @@ class HTML2Text(html.parser.HTMLParser):
and not self.span_lead
and not self.span_highlight
):
- if (
- start
- and self.preceding_data
- and self.preceding_data[-1] == self.strong_mark[0]
- ):
+ if start and self.preceding_data and self.preceding_data[-1] == self.strong_mark[0]:
strong = " " + self.strong_mark
self.preceding_data += " "
else:
@@ -548,13 +536,8 @@ class HTML2Text(html.parser.HTMLParser):
"href" in attrs
and not attrs["href"].startswith("#_ftn")
and attrs["href"] is not None
- and not (
- self.skip_internal_links and attrs["href"].startswith("#")
- )
- and not (
- self.ignore_mailto_links
- and attrs["href"].startswith("mailto:")
- )
+ and not (self.skip_internal_links and attrs["href"].startswith("#"))
+ and not (self.ignore_mailto_links and attrs["href"].startswith("mailto:"))
):
self.astack.append(attrs)
self.maybe_automatic_link = attrs["href"]
@@ -591,7 +574,7 @@ class HTML2Text(html.parser.HTMLParser):
if tag == "img" and start and not self.ignore_images:
# skip cloudinary images
- if "src" in attrs and "cloudinary" not in attrs["src"]:
+ if "src" in attrs and ("cloudinary" not in attrs["src"]):
assert attrs["src"] is not None
if not self.images_to_alt:
attrs["href"] = attrs["src"]
@@ -638,9 +621,7 @@ class HTML2Text(html.parser.HTMLParser):
self.o("![" + escape_md(alt) + "]")
if self.inline_links:
href = attrs.get("href") or ""
- self.o(
- "(" + escape_md(urlparse.urljoin(self.baseurl, href)) + ")"
- )
+ self.o("(" + escape_md(urlparse.urljoin(self.baseurl, href)) + ")")
else:
i = self.previousIndex(attrs)
if i is not None:
@@ -696,9 +677,7 @@ class HTML2Text(html.parser.HTMLParser):
# WARNING: does not line up - s > 9 correctly.
parent_list = None
for list in self.list:
- self.o(
- " " if parent_list == "ol" and list.name == "ul" else " "
- )
+ self.o(" " if parent_list == "ol" and list.name == "ul" else " ")
parent_list = list.name
if li.name == "ul":
@@ -787,7 +766,7 @@ class HTML2Text(html.parser.HTMLParser):
self.pbr()
self.br_toggle = " "
- def o(
+ def o( # noqa: C901
self, data: str, puredata: bool = False, force: Union[bool, str] = False
) -> None:
"""
@@ -864,9 +843,7 @@ class HTML2Text(html.parser.HTMLParser):
self.out(" ")
self.space = False
- if self.a and (
- (self.p_p == 2 and self.links_each_paragraph) or force == "end"
- ):
+ if self.a and ((self.p_p == 2 and self.links_each_paragraph) or force == "end"):
if force == "end":
self.out("\n")
@@ -925,11 +902,7 @@ class HTML2Text(html.parser.HTMLParser):
if self.maybe_automatic_link is not None:
href = self.maybe_automatic_link
- if (
- href == data
- and self.absolute_url_matcher.match(href)
- and self.use_automatic_links
- ):
+ if href == data and self.absolute_url_matcher.match(href) and self.use_automatic_links:
self.o("<" + data + ">")
self.empty_link = False
return
@@ -980,7 +953,7 @@ class HTML2Text(html.parser.HTMLParser):
return nest_count
- def optwrap(self, text: str) -> str:
+ def optwrap(self, text: str) -> str: # noqa: C901
"""
Wrap all paragraphs in the provided text.
@@ -1000,9 +973,7 @@ class HTML2Text(html.parser.HTMLParser):
self.inline_links = False
for para in text.split("\n"):
if len(para) > 0:
- if not skipwrap(
- para, self.wrap_links, self.wrap_list_items, self.wrap_tables
- ):
+ if not skipwrap(para, self.wrap_links, self.wrap_list_items, self.wrap_tables):
indent = ""
if para.startswith(" " + self.ul_item_mark):
# list item continuation: add a double indent to the
@@ -1043,12 +1014,10 @@ class HTML2Text(html.parser.HTMLParser):
return result
-def html2text(
- html: str, baseurl: str = "", bodywidth: Optional[int] = config.BODY_WIDTH
-) -> str:
+def html2text(html: str, baseurl: str = "", bodywidth: int = config.BODY_WIDTH) -> str:
h = html.strip() or ""
if h:
- h = HTML2Text(baseurl=baseurl, bodywidth=bodywidth)
- h = h.handle(html.strip())
+ h2t = HTML2Text(baseurl=baseurl, bodywidth=bodywidth)
+ h = h2t.handle(html.strip())
# print('[html2text] %d bytes' % len(html))
return h
diff --git a/migration/html2text/cli.py b/migration/html2text/cli.py
index dbaba28b..62e0738f 100644
--- a/migration/html2text/cli.py
+++ b/migration/html2text/cli.py
@@ -117,10 +117,7 @@ def main() -> None:
dest="images_with_size",
action="store_true",
default=config.IMAGES_WITH_SIZE,
- help=(
- "Write image tags with height and width attrs as raw html to retain "
- "dimensions"
- ),
+ help=("Write image tags with height and width attrs as raw html to retain " "dimensions"),
)
p.add_argument(
"-g",
@@ -260,9 +257,7 @@ def main() -> None:
default=config.CLOSE_QUOTE,
help="The character used to close quotes",
)
- p.add_argument(
- "--version", action="version", version=".".join(map(str, __version__))
- )
+ p.add_argument("--version", action="version", version=".".join(map(str, __version__)))
p.add_argument("filename", nargs="?")
p.add_argument("encoding", nargs="?", default="utf-8")
args = p.parse_args()
diff --git a/migration/html2text/utils.py b/migration/html2text/utils.py
index 1cf22b52..568e1fc5 100644
--- a/migration/html2text/utils.py
+++ b/migration/html2text/utils.py
@@ -4,9 +4,7 @@ from typing import Dict, List, Optional
from . import config
unifiable_n = {
- html.entities.name2codepoint[k]: v
- for k, v in config.UNIFIABLE.items()
- if k != "nbsp"
+ html.entities.name2codepoint[k]: v for k, v in config.UNIFIABLE.items() if k != "nbsp"
}
@@ -68,12 +66,14 @@ def element_style(
:rtype: dict
"""
style = parent_style.copy()
- if attrs.get("class"):
- for css_class in attrs["class"].split():
+ attrs_class = attrs.get("class")
+ if attrs_class:
+ for css_class in attrs_class.split():
css_style = style_def.get("." + css_class, {})
style.update(css_style)
- if attrs.get("style"):
- immediate_style = dumb_property_dict(attrs["style"])
+ attrs_style = attrs.get("style")
+ if attrs_style:
+ immediate_style = dumb_property_dict(attrs_style)
style.update(immediate_style)
return style
@@ -147,18 +147,17 @@ def list_numbering_start(attrs: Dict[str, Optional[str]]) -> int:
:rtype: int or None
"""
- if attrs.get("start"):
+ attrs_start = attrs.get("start")
+ if attrs_start:
try:
- return int(attrs["start"]) - 1
+ return int(attrs_start) - 1
except ValueError:
pass
return 0
-def skipwrap(
- para: str, wrap_links: bool, wrap_list_items: bool, wrap_tables: bool
-) -> bool:
+def skipwrap(para: str, wrap_links: bool, wrap_list_items: bool, wrap_tables: bool) -> bool:
# If it appears to contain a link
# don't wrap
if not wrap_links and config.RE_LINK.search(para):
@@ -236,9 +235,7 @@ def reformat_table(lines: List[str], right_margin: int) -> List[str]:
max_width += [len(x) + right_margin for x in cols[-(num_cols - max_cols) :]]
max_cols = num_cols
- max_width = [
- max(len(x) + right_margin, old_len) for x, old_len in zip(cols, max_width)
- ]
+ max_width = [max(len(x) + right_margin, old_len) for x, old_len in zip(cols, max_width)]
# reformat
new_lines = []
@@ -247,15 +244,13 @@ def reformat_table(lines: List[str], right_margin: int) -> List[str]:
if set(line.strip()) == set("-|"):
filler = "-"
new_cols = [
- x.rstrip() + (filler * (M - len(x.rstrip())))
- for x, M in zip(cols, max_width)
+ x.rstrip() + (filler * (M - len(x.rstrip()))) for x, M in zip(cols, max_width)
]
new_lines.append("|-" + "|".join(new_cols) + "|")
else:
filler = " "
new_cols = [
- x.rstrip() + (filler * (M - len(x.rstrip())))
- for x, M in zip(cols, max_width)
+ x.rstrip() + (filler * (M - len(x.rstrip()))) for x, M in zip(cols, max_width)
]
new_lines.append("| " + "|".join(new_cols) + "|")
return new_lines
diff --git a/migration/tables/__init__.py b/migration/tables/__init__.py
deleted file mode 100644
index 8e7ee938..00000000
--- a/migration/tables/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-__all__ = (["users", "topics", "content_items", "comments"],)
diff --git a/migration/tables/comments.py b/migration/tables/comments.py
index 82e32924..16c91228 100644
--- a/migration/tables/comments.py
+++ b/migration/tables/comments.py
@@ -5,61 +5,48 @@ from dateutil.parser import parse as date_parse
from base.orm import local_session
from migration.html2text import html2text
from orm.reaction import Reaction, ReactionKind
-from orm.shout import ShoutReactionsFollower
+from orm.shout import Shout, ShoutReactionsFollower
from orm.topic import TopicFollower
from orm.user import User
-from orm.shout import Shout
ts = datetime.now(tz=timezone.utc)
def auto_followers(session, topics, reaction_dict):
# creating shout's reactions following for reaction author
- following1 = session.query(
- ShoutReactionsFollower
- ).where(
- ShoutReactionsFollower.follower == reaction_dict["createdBy"]
- ).filter(
- ShoutReactionsFollower.shout == reaction_dict["shout"]
- ).first()
+ following1 = (
+ session.query(ShoutReactionsFollower)
+ .where(ShoutReactionsFollower.follower == reaction_dict["createdBy"])
+ .filter(ShoutReactionsFollower.shout == reaction_dict["shout"])
+ .first()
+ )
if not following1:
following1 = ShoutReactionsFollower.create(
- follower=reaction_dict["createdBy"],
- shout=reaction_dict["shout"],
- auto=True
+ follower=reaction_dict["createdBy"], shout=reaction_dict["shout"], auto=True
)
session.add(following1)
# creating topics followings for reaction author
for t in topics:
- tf = session.query(
- TopicFollower
- ).where(
- TopicFollower.follower == reaction_dict["createdBy"]
- ).filter(
- TopicFollower.topic == t['id']
- ).first()
+ tf = (
+ session.query(TopicFollower)
+ .where(TopicFollower.follower == reaction_dict["createdBy"])
+ .filter(TopicFollower.topic == t["id"])
+ .first()
+ )
if not tf:
topic_following = TopicFollower.create(
- follower=reaction_dict["createdBy"],
- topic=t['id'],
- auto=True
+ follower=reaction_dict["createdBy"], topic=t["id"], auto=True
)
session.add(topic_following)
def migrate_ratings(session, entry, reaction_dict):
for comment_rating_old in entry.get("ratings", []):
- rater = (
- session.query(User)
- .filter(User.oid == comment_rating_old["createdBy"])
- .first()
- )
+ rater = session.query(User).filter(User.oid == comment_rating_old["createdBy"]).first()
re_reaction_dict = {
"shout": reaction_dict["shout"],
"replyTo": reaction_dict["id"],
- "kind": ReactionKind.LIKE
- if comment_rating_old["value"] > 0
- else ReactionKind.DISLIKE,
+ "kind": ReactionKind.LIKE if comment_rating_old["value"] > 0 else ReactionKind.DISLIKE,
"createdBy": rater.id if rater else 1,
}
cts = comment_rating_old.get("createdAt")
@@ -68,18 +55,15 @@ def migrate_ratings(session, entry, reaction_dict):
try:
# creating reaction from old rating
rr = Reaction.create(**re_reaction_dict)
- following2 = session.query(
- ShoutReactionsFollower
- ).where(
- ShoutReactionsFollower.follower == re_reaction_dict['createdBy']
- ).filter(
- ShoutReactionsFollower.shout == rr.shout
- ).first()
+ following2 = (
+ session.query(ShoutReactionsFollower)
+ .where(ShoutReactionsFollower.follower == re_reaction_dict["createdBy"])
+ .filter(ShoutReactionsFollower.shout == rr.shout)
+ .first()
+ )
if not following2:
following2 = ShoutReactionsFollower.create(
- follower=re_reaction_dict['createdBy'],
- shout=rr.shout,
- auto=True
+ follower=re_reaction_dict["createdBy"], shout=rr.shout, auto=True
)
session.add(following2)
session.add(rr)
@@ -150,9 +134,7 @@ async def migrate(entry, storage):
else:
stage = "author and old id found"
try:
- shout = session.query(
- Shout
- ).where(Shout.slug == old_shout["slug"]).one()
+ shout = session.query(Shout).where(Shout.slug == old_shout["slug"]).one()
if shout:
reaction_dict["shout"] = shout.id
reaction_dict["createdBy"] = author.id if author else 1
@@ -178,9 +160,9 @@ async def migrate(entry, storage):
def migrate_2stage(old_comment, idmap):
- if old_comment.get('body'):
- new_id = idmap.get(old_comment.get('oid'))
- new_id = idmap.get(old_comment.get('_id'))
+ if old_comment.get("body"):
+ new_id = idmap.get(old_comment.get("oid"))
+ new_id = idmap.get(old_comment.get("_id"))
if new_id:
new_replyto_id = None
old_replyto_id = old_comment.get("replyTo")
@@ -190,17 +172,20 @@ def migrate_2stage(old_comment, idmap):
comment = session.query(Reaction).where(Reaction.id == new_id).first()
try:
if new_replyto_id:
- new_reply = session.query(Reaction).where(Reaction.id == new_replyto_id).first()
+ new_reply = (
+ session.query(Reaction).where(Reaction.id == new_replyto_id).first()
+ )
if not new_reply:
print(new_replyto_id)
raise Exception("cannot find reply by id!")
comment.replyTo = new_reply.id
session.add(comment)
- srf = session.query(ShoutReactionsFollower).where(
- ShoutReactionsFollower.shout == comment.shout
- ).filter(
- ShoutReactionsFollower.follower == comment.createdBy
- ).first()
+ srf = (
+ session.query(ShoutReactionsFollower)
+ .where(ShoutReactionsFollower.shout == comment.shout)
+ .filter(ShoutReactionsFollower.follower == comment.createdBy)
+ .first()
+ )
if not srf:
srf = ShoutReactionsFollower.create(
shout=comment.shout, follower=comment.createdBy, auto=True
diff --git a/migration/tables/content_items.py b/migration/tables/content_items.py
index 2e74f96e..5486b464 100644
--- a/migration/tables/content_items.py
+++ b/migration/tables/content_items.py
@@ -1,23 +1,25 @@
-from datetime import datetime, timezone
import json
+import re
+from datetime import datetime, timezone
+
from dateutil.parser import parse as date_parse
from sqlalchemy.exc import IntegrityError
from transliterate import translit
+
from base.orm import local_session
from migration.extract import extract_html, extract_media
from orm.reaction import Reaction, ReactionKind
-from orm.shout import Shout, ShoutTopic, ShoutReactionsFollower
+from orm.shout import Shout, ShoutReactionsFollower, ShoutTopic
+from orm.topic import Topic, TopicFollower
from orm.user import User
-from orm.topic import TopicFollower, Topic
from services.stat.viewed import ViewedStorage
-import re
OLD_DATE = "2016-03-05 22:22:00.350000"
ts = datetime.now(tz=timezone.utc)
type2layout = {
"Article": "article",
"Literature": "literature",
- "Music": "audio",
+ "Music": "music",
"Video": "video",
"Image": "image",
}
@@ -33,7 +35,7 @@ def get_shout_slug(entry):
slug = friend.get("slug", "")
if slug:
break
- slug = re.sub('[^0-9a-zA-Z]+', '-', slug)
+ slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
return slug
@@ -41,27 +43,27 @@ def create_author_from_app(app):
user = None
userdata = None
# check if email is used
- if app['email']:
+ if app["email"]:
with local_session() as session:
- user = session.query(User).where(User.email == app['email']).first()
+ user = session.query(User).where(User.email == app["email"]).first()
if not user:
# print('[migration] app %r' % app)
- name = app.get('name')
+ name = app.get("name")
if name:
slug = translit(name, "ru", reversed=True).lower()
- slug = re.sub('[^0-9a-zA-Z]+', '-', slug)
- print('[migration] created slug %s' % slug)
+ slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
+ print("[migration] created slug %s" % slug)
# check if slug is used
if slug:
user = session.query(User).where(User.slug == slug).first()
# get slug from email
if user:
- slug = app['email'].split('@')[0]
+ slug = app["email"].split("@")[0]
user = session.query(User).where(User.slug == slug).first()
# one more try
if user:
- slug += '-author'
+ slug += "-author"
user = session.query(User).where(User.slug == slug).first()
# create user with application data
@@ -70,7 +72,6 @@ def create_author_from_app(app):
"username": app["email"],
"email": app["email"],
"name": app.get("name", ""),
- "bio": app.get("bio", ""),
"emailConfirmed": False,
"slug": slug,
"createdAt": ts,
@@ -80,7 +81,7 @@ def create_author_from_app(app):
user = User.create(**userdata)
session.add(user)
session.commit()
- userdata['id'] = user.id
+ userdata["id"] = user.id
userdata = user.dict()
return userdata
@@ -92,11 +93,12 @@ async def create_shout(shout_dict):
s = Shout.create(**shout_dict)
author = s.authors[0]
with local_session() as session:
- srf = session.query(ShoutReactionsFollower).where(
- ShoutReactionsFollower.shout == s.id
- ).filter(
- ShoutReactionsFollower.follower == author.id
- ).first()
+ srf = (
+ session.query(ShoutReactionsFollower)
+ .where(ShoutReactionsFollower.shout == s.id)
+ .filter(ShoutReactionsFollower.follower == author.id)
+ .first()
+ )
if not srf:
srf = ShoutReactionsFollower.create(shout=s.id, follower=author.id, auto=True)
session.add(srf)
@@ -117,14 +119,14 @@ async def get_user(entry, storage):
elif user_oid:
userdata = storage["users"]["by_oid"].get(user_oid)
if not userdata:
- print('no userdata by oid, anonymous')
+ print("no userdata by oid, anonymous")
userdata = anondict
print(app)
# cleanup slug
if userdata:
slug = userdata.get("slug", "")
if slug:
- slug = re.sub('[^0-9a-zA-Z]+', '-', slug)
+ slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
userdata["slug"] = slug
else:
userdata = anondict
@@ -138,23 +140,27 @@ async def migrate(entry, storage):
r = {
"layout": type2layout[entry["type"]],
"title": entry["title"],
- "authors": [author, ],
+ "authors": [
+ author,
+ ],
"slug": get_shout_slug(entry),
"cover": (
- "https://assets.discours.io/unsafe/1600x/" +
- entry["thumborId"] if entry.get("thumborId") else entry.get("image", {}).get("url")
+ "https://images.discours.io/unsafe/" + entry["thumborId"]
+ if entry.get("thumborId")
+ else entry.get("image", {}).get("url")
),
- "visibility": "public" if entry.get("published") else "authors",
+ "visibility": "public" if entry.get("published") else "community",
"publishedAt": date_parse(entry.get("publishedAt")) if entry.get("published") else None,
"deletedAt": date_parse(entry.get("deletedAt")) if entry.get("deletedAt") else None,
"createdAt": date_parse(entry.get("createdAt", OLD_DATE)),
"updatedAt": date_parse(entry["updatedAt"]) if "updatedAt" in entry else ts,
+ "createdBy": author.id,
"topics": await add_topics_follower(entry, storage, author),
- "body": extract_html(entry, cleanup=True)
+ "body": extract_html(entry, cleanup=True),
}
# main topic patch
- r['mainTopic'] = r['topics'][0]
+ r["mainTopic"] = r["topics"][0]
# published author auto-confirm
if entry.get("published"):
@@ -177,14 +183,16 @@ async def migrate(entry, storage):
shout_dict["oid"] = entry.get("_id", "")
shout = await create_shout(shout_dict)
except IntegrityError as e:
- print('[migration] create_shout integrity error', e)
+ print("[migration] create_shout integrity error", e)
shout = await resolve_create_shout(shout_dict)
except Exception as e:
raise Exception(e)
# udpate data
shout_dict = shout.dict()
- shout_dict["authors"] = [author.dict(), ]
+ shout_dict["authors"] = [
+ author.dict(),
+ ]
# shout topics aftermath
shout_dict["topics"] = await topics_aftermath(r, storage)
@@ -193,7 +201,9 @@ async def migrate(entry, storage):
await content_ratings_to_reactions(entry, shout_dict["slug"])
# shout views
- await ViewedStorage.increment(shout_dict["slug"], amount=entry.get("views", 1), viewer='old-discours')
+ await ViewedStorage.increment(
+ shout_dict["slug"], amount=entry.get("views", 1), viewer="old-discours"
+ )
# del shout_dict['ratings']
storage["shouts"]["by_oid"][entry["_id"]] = shout_dict
@@ -205,7 +215,9 @@ async def add_topics_follower(entry, storage, user):
topics = set([])
category = entry.get("category")
topics_by_oid = storage["topics"]["by_oid"]
- oids = [category, ] + entry.get("tags", [])
+ oids = [
+ category,
+ ] + entry.get("tags", [])
for toid in oids:
tslug = topics_by_oid.get(toid, {}).get("slug")
if tslug:
@@ -217,23 +229,18 @@ async def add_topics_follower(entry, storage, user):
try:
tpc = session.query(Topic).where(Topic.slug == tpcslug).first()
if tpc:
- tf = session.query(
- TopicFollower
- ).where(
- TopicFollower.follower == user.id
- ).filter(
- TopicFollower.topic == tpc.id
- ).first()
+ tf = (
+ session.query(TopicFollower)
+ .where(TopicFollower.follower == user.id)
+ .filter(TopicFollower.topic == tpc.id)
+ .first()
+ )
if not tf:
- tf = TopicFollower.create(
- topic=tpc.id,
- follower=user.id,
- auto=True
- )
+ tf = TopicFollower.create(topic=tpc.id, follower=user.id, auto=True)
session.add(tf)
session.commit()
except IntegrityError:
- print('[migration.shout] hidden by topic ' + tpc.slug)
+ print("[migration.shout] hidden by topic " + tpc.slug)
# main topic
maintopic = storage["replacements"].get(topics_by_oid.get(category, {}).get("slug"))
if maintopic in ttt:
@@ -254,7 +261,7 @@ async def process_user(userdata, storage, oid):
if not user:
try:
slug = userdata["slug"].lower().strip()
- slug = re.sub('[^0-9a-zA-Z]+', '-', slug)
+ slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
userdata["slug"] = slug
user = User.create(**userdata)
session.add(user)
@@ -282,9 +289,9 @@ async def resolve_create_shout(shout_dict):
s = session.query(Shout).filter(Shout.slug == shout_dict["slug"]).first()
bump = False
if s:
- if s.createdAt != shout_dict['createdAt']:
+ if s.createdAt != shout_dict["createdAt"]:
# create new with different slug
- shout_dict["slug"] += '-' + shout_dict["layout"]
+ shout_dict["slug"] += "-" + shout_dict["layout"]
try:
await create_shout(shout_dict)
except IntegrityError as e:
@@ -295,10 +302,7 @@ async def resolve_create_shout(shout_dict):
for key in shout_dict:
if key in s.__dict__:
if s.__dict__[key] != shout_dict[key]:
- print(
- "[migration] shout already exists, but differs in %s"
- % key
- )
+ print("[migration] shout already exists, but differs in %s" % key)
bump = True
else:
print("[migration] shout already exists, but lacks %s" % key)
@@ -344,9 +348,7 @@ async def topics_aftermath(entry, storage):
)
if not shout_topic_new:
try:
- ShoutTopic.create(
- **{"shout": shout.id, "topic": new_topic.id}
- )
+ ShoutTopic.create(**{"shout": shout.id, "topic": new_topic.id})
except Exception:
print("[migration] shout topic error: " + newslug)
session.commit()
@@ -363,9 +365,7 @@ async def content_ratings_to_reactions(entry, slug):
with local_session() as session:
for content_rating in entry.get("ratings", []):
rater = (
- session.query(User)
- .filter(User.oid == content_rating["createdBy"])
- .first()
+ session.query(User).filter(User.oid == content_rating["createdBy"]).first()
) or User.default_user
shout = session.query(Shout).where(Shout.slug == slug).first()
cts = content_rating.get("createdAt")
@@ -375,7 +375,7 @@ async def content_ratings_to_reactions(entry, slug):
if content_rating["value"] > 0
else ReactionKind.DISLIKE,
"createdBy": rater.id,
- "shout": shout.id
+ "shout": shout.id,
}
reaction = (
session.query(Reaction)
diff --git a/migration/tables/remarks.py b/migration/tables/remarks.py
index 026b95c6..e09cf4fb 100644
--- a/migration/tables/remarks.py
+++ b/migration/tables/remarks.py
@@ -1,42 +1,35 @@
-from base.orm import local_session
-from migration.extract import extract_md
-from migration.html2text import html2text
-from orm.reaction import Reaction, ReactionKind
+# from base.orm import local_session
+
+# from migration.extract import extract_md
+# from migration.html2text import html2text
+# from orm.reaction import Reaction, ReactionKind
-def migrate(entry, storage):
- post_oid = entry['contentItem']
- print(post_oid)
- shout_dict = storage['shouts']['by_oid'].get(post_oid)
- if shout_dict:
- print(shout_dict['body'])
- remark = {
- "shout": shout_dict['id'],
- "body": extract_md(
- html2text(entry['body']),
- shout_dict
- ),
- "kind": ReactionKind.REMARK
- }
-
- if entry.get('textBefore'):
- remark['range'] = str(
- shout_dict['body']
- .index(
- entry['textBefore'] or ''
- )
- ) + ':' + str(
- shout_dict['body']
- .index(
- entry['textAfter'] or ''
- ) + len(
- entry['textAfter'] or ''
- )
- )
-
- with local_session() as session:
- rmrk = Reaction.create(**remark)
- session.commit()
- del rmrk["_sa_instance_state"]
- return rmrk
- return
+# def migrate(entry, storage):
+# post_oid = entry["contentItem"]
+# print(post_oid)
+# shout_dict = storage["shouts"]["by_oid"].get(post_oid)
+# if shout_dict:
+# print(shout_dict["body"])
+# remark = {
+# "shout": shout_dict["id"],
+# "body": extract_md(html2text(entry["body"]), shout_dict),
+# "kind": ReactionKind.REMARK,
+# }
+#
+# if entry.get("textBefore"):
+# remark["range"] = (
+# str(shout_dict["body"].index(entry["textBefore"] or ""))
+# + ":"
+# + str(
+# shout_dict["body"].index(entry["textAfter"] or "")
+# + len(entry["textAfter"] or "")
+# )
+# )
+#
+# with local_session() as session:
+# rmrk = Reaction.create(**remark)
+# session.commit()
+# del rmrk["_sa_instance_state"]
+# return rmrk
+# return
diff --git a/migration/tables/topics.py b/migration/tables/topics.py
index 17804376..e0e7e7a4 100644
--- a/migration/tables/topics.py
+++ b/migration/tables/topics.py
@@ -1,5 +1,4 @@
from base.orm import local_session
-from migration.extract import extract_md
from migration.html2text import html2text
from orm import Topic
@@ -10,7 +9,7 @@ def migrate(entry):
"slug": entry["slug"],
"oid": entry["_id"],
"title": entry["title"].replace(" ", " "),
- "body": extract_md(html2text(body_orig))
+ "body": html2text(body_orig),
}
with local_session() as session:
diff --git a/migration/tables/users.py b/migration/tables/users.py
index d7a0f260..300c2e03 100644
--- a/migration/tables/users.py
+++ b/migration/tables/users.py
@@ -8,7 +8,7 @@ from base.orm import local_session
from orm.user import AuthorFollower, User, UserRating
-def migrate(entry):
+def migrate(entry): # noqa: C901
if "subscribedTo" in entry:
del entry["subscribedTo"]
email = entry["emails"][0]["address"]
@@ -21,22 +21,25 @@ def migrate(entry):
"createdAt": parse(entry["createdAt"]),
"emailConfirmed": ("@discours.io" in email) or bool(entry["emails"][0]["verified"]),
"muted": False, # amnesty
- "bio": entry["profile"].get("bio", ""),
"links": [],
"name": "anonymous",
- "password": entry["services"]["password"].get("bcrypt")
+ "password": entry["services"]["password"].get("bcrypt"),
}
if "updatedAt" in entry:
user_dict["updatedAt"] = parse(entry["updatedAt"])
- if "wasOnineAt" in entry:
+ if "wasOnlineAt" in entry:
user_dict["lastSeen"] = parse(entry["wasOnlineAt"])
if entry.get("profile"):
# slug
slug = entry["profile"].get("path").lower()
- slug = re.sub('[^0-9a-zA-Z]+', '-', slug).strip()
+ slug = re.sub("[^0-9a-zA-Z]+", "-", slug).strip()
user_dict["slug"] = slug
- bio = (entry.get("profile", {"bio": ""}).get("bio") or "").replace('\(', '(').replace('\)', ')')
+ bio = (
+ (entry.get("profile", {"bio": ""}).get("bio") or "")
+ .replace(r"\(", "(")
+ .replace(r"\)", ")")
+ )
bio_text = BeautifulSoup(bio, features="lxml").text
if len(bio_text) > 120:
@@ -47,8 +50,7 @@ def migrate(entry):
# userpic
try:
user_dict["userpic"] = (
- "https://assets.discours.io/unsafe/100x/"
- + entry["profile"]["thumborId"]
+ "https://images.discours.io/unsafe/" + entry["profile"]["thumborId"]
)
except KeyError:
try:
@@ -63,11 +65,7 @@ def migrate(entry):
name = (name + " " + ln) if ln else name
if not name:
name = slug if slug else "anonymous"
- name = (
- entry["profile"]["path"].lower().strip().replace(" ", "-")
- if len(name) < 2
- else name
- )
+ name = entry["profile"]["path"].lower().strip().replace(" ", "-") if len(name) < 2 else name
user_dict["name"] = name
# links
@@ -96,9 +94,7 @@ def migrate(entry):
except IntegrityError:
print("[migration] cannot create user " + user_dict["slug"])
with local_session() as session:
- old_user = (
- session.query(User).filter(User.slug == user_dict["slug"]).first()
- )
+ old_user = session.query(User).filter(User.slug == user_dict["slug"]).first()
old_user.oid = oid
old_user.password = user_dict["password"]
session.commit()
@@ -115,7 +111,7 @@ def post_migrate():
"slug": "old-discours",
"username": "old-discours",
"email": "old@discours.io",
- "name": "Просмотры на старой версии сайта"
+ "name": "Просмотры на старой версии сайта",
}
with local_session() as session:
@@ -148,12 +144,8 @@ def migrate_2stage(entry, id_map):
}
user_rating = UserRating.create(**user_rating_dict)
- if user_rating_dict['value'] > 0:
- af = AuthorFollower.create(
- author=user.id,
- follower=rater.id,
- auto=True
- )
+ if user_rating_dict["value"] > 0:
+ af = AuthorFollower.create(author=user.id, follower=rater.id, auto=True)
session.add(af)
session.add(user_rating)
session.commit()
diff --git a/orm/__init__.py b/orm/__init__.py
index bd2c9fb7..9f66f85c 100644
--- a/orm/__init__.py
+++ b/orm/__init__.py
@@ -1,13 +1,24 @@
from base.orm import Base, engine
from orm.community import Community
from orm.notification import Notification
-from orm.rbac import Operation, Resource, Permission, Role
+from orm.rbac import Operation, Permission, Resource, Role
from orm.reaction import Reaction
from orm.shout import Shout
from orm.topic import Topic, TopicFollower
from orm.user import User, UserRating
-# NOTE: keep orm module isolated
+
+def init_tables():
+ Base.metadata.create_all(engine)
+ Operation.init_table()
+ Resource.init_table()
+ User.init_table()
+ Community.init_table()
+ Role.init_table()
+ UserRating.init_table()
+ Shout.init_table()
+ print("[orm] tables initialized")
+
__all__ = [
"User",
@@ -21,16 +32,5 @@ __all__ = [
"Notification",
"Reaction",
"UserRating",
+ "init_tables",
]
-
-
-def init_tables():
- Base.metadata.create_all(engine)
- Operation.init_table()
- Resource.init_table()
- User.init_table()
- Community.init_table()
- Role.init_table()
- UserRating.init_table()
- Shout.init_table()
- print("[orm] tables initialized")
diff --git a/orm/collection.py b/orm/collection.py
index c9975b62..a8078867 100644
--- a/orm/collection.py
+++ b/orm/collection.py
@@ -1,6 +1,4 @@
-from datetime import datetime
-
-from sqlalchemy import Column, DateTime, ForeignKey, String
+from sqlalchemy import Column, DateTime, ForeignKey, String, func
from base.orm import Base
@@ -8,7 +6,7 @@ from base.orm import Base
class ShoutCollection(Base):
__tablename__ = "shout_collection"
- id = None # type: ignore
+ id = None
shout = Column(ForeignKey("shout.id"), primary_key=True)
collection = Column(ForeignKey("collection.id"), primary_key=True)
@@ -20,6 +18,6 @@ class Collection(Base):
title = Column(String, nullable=False, comment="Title")
body = Column(String, nullable=True, comment="Body")
pic = Column(String, nullable=True, comment="Picture")
- createdAt = Column(DateTime, default=datetime.now, comment="Created At")
+ createdAt = Column(DateTime(timezone=True), server_default=func.now(), comment="Created At")
createdBy = Column(ForeignKey("user.id"), comment="Created By")
- publishedAt = Column(DateTime, default=datetime.now, comment="Published At")
+ publishedAt = Column(DateTime(timezone=True), server_default=func.now(), comment="Published At")
diff --git a/orm/community.py b/orm/community.py
index b55b857f..762fe154 100644
--- a/orm/community.py
+++ b/orm/community.py
@@ -1,17 +1,16 @@
-from datetime import datetime
+from sqlalchemy import Column, DateTime, ForeignKey, String, func
-from sqlalchemy import Column, String, ForeignKey, DateTime
from base.orm import Base, local_session
class CommunityFollower(Base):
__tablename__ = "community_followers"
- id = None # type: ignore
- follower = Column(ForeignKey("user.id"), primary_key=True)
- community = Column(ForeignKey("community.id"), primary_key=True)
+ id = None
+ follower: Column = Column(ForeignKey("user.id"), primary_key=True)
+ community: Column = Column(ForeignKey("community.id"), primary_key=True)
joinedAt = Column(
- DateTime, nullable=False, default=datetime.now, comment="Created at"
+ DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at"
)
# role = Column(ForeignKey(Role.id), nullable=False, comment="Role for member")
@@ -24,18 +23,16 @@ class Community(Base):
desc = Column(String, nullable=False, default="")
pic = Column(String, nullable=False, default="")
createdAt = Column(
- DateTime, nullable=False, default=datetime.now, comment="Created at"
+ DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at"
)
@staticmethod
def init_table():
with local_session() as session:
- d = (
- session.query(Community).filter(Community.slug == "discours").first()
- )
+ d = session.query(Community).filter(Community.slug == "discours").first()
if not d:
d = Community.create(name="Дискурс", slug="discours")
session.add(d)
session.commit()
Community.default_community = d
- print('[orm] default community id: %s' % d.id)
+ print("[orm] default community id: %s" % d.id)
diff --git a/orm/notification.py b/orm/notification.py
index 41914983..8130b0bb 100644
--- a/orm/notification.py
+++ b/orm/notification.py
@@ -1,13 +1,26 @@
-from datetime import datetime
-from sqlalchemy import Column, String, JSON, ForeignKey, DateTime, Boolean
+from enum import Enum as Enumeration
+
+from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, func
+from sqlalchemy.dialects.postgresql import JSONB
+
from base.orm import Base
+class NotificationType(Enumeration):
+ NEW_COMMENT = 1
+ NEW_REPLY = 2
+
+
class Notification(Base):
__tablename__ = "notification"
- user = Column(ForeignKey("user.id"), index=True)
- createdAt = Column(DateTime, nullable=False, default=datetime.now, index=True)
+ shout: Column = Column(ForeignKey("shout.id"), index=True)
+ reaction: Column = Column(ForeignKey("reaction.id"), index=True)
+ user: Column = Column(ForeignKey("user.id"), index=True)
+ createdAt = Column(
+ DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
+ )
seen = Column(Boolean, nullable=False, default=False, index=True)
- type = Column(String, nullable=False)
- data = Column(JSON, nullable=True)
+ type = Column(Enum(NotificationType), nullable=False)
+ data = Column(JSONB, nullable=True)
+ occurrences = Column(Integer, default=1)
diff --git a/orm/rbac.py b/orm/rbac.py
index 29ade72e..47abfb74 100644
--- a/orm/rbac.py
+++ b/orm/rbac.py
@@ -1,9 +1,9 @@
import warnings
-from sqlalchemy import String, Column, ForeignKey, UniqueConstraint, TypeDecorator
+from sqlalchemy import Column, ForeignKey, String, TypeDecorator, UniqueConstraint
from sqlalchemy.orm import relationship
-from base.orm import Base, REGISTRY, engine, local_session
+from base.orm import REGISTRY, Base, local_session
# Role Based Access Control #
@@ -121,16 +121,23 @@ class Operation(Base):
class Resource(Base):
__tablename__ = "resource"
- resourceClass = Column(
- String, nullable=False, unique=True, comment="Resource class"
- )
+ resourceClass = Column(String, nullable=False, unique=True, comment="Resource class")
name = Column(String, nullable=False, unique=True, comment="Resource name")
# TODO: community = Column(ForeignKey())
@staticmethod
def init_table():
with local_session() as session:
- for res in ["shout", "topic", "reaction", "chat", "message", "invite", "community", "user"]:
+ for res in [
+ "shout",
+ "topic",
+ "reaction",
+ "chat",
+ "message",
+ "invite",
+ "community",
+ "user",
+ ]:
r = session.query(Resource).filter(Resource.name == res).first()
if not r:
r = Resource.create(name=res, resourceClass=res)
@@ -145,29 +152,27 @@ class Permission(Base):
{"extend_existing": True},
)
- role = Column(
- ForeignKey("role.id", ondelete="CASCADE"), nullable=False, comment="Role"
- )
- operation = Column(
+ role: Column = Column(ForeignKey("role.id", ondelete="CASCADE"), nullable=False, comment="Role")
+ operation: Column = Column(
ForeignKey("operation.id", ondelete="CASCADE"),
nullable=False,
comment="Operation",
)
- resource = Column(
+ resource: Column = Column(
ForeignKey("resource.id", ondelete="CASCADE"),
nullable=False,
comment="Resource",
)
-if __name__ == "__main__":
- Base.metadata.create_all(engine)
- ops = [
- Permission(role=1, operation=1, resource=1),
- Permission(role=1, operation=2, resource=1),
- Permission(role=1, operation=3, resource=1),
- Permission(role=1, operation=4, resource=1),
- Permission(role=2, operation=4, resource=1),
- ]
- global_session.add_all(ops)
- global_session.commit()
+# if __name__ == "__main__":
+# Base.metadata.create_all(engine)
+# ops = [
+# Permission(role=1, operation=1, resource=1),
+# Permission(role=1, operation=2, resource=1),
+# Permission(role=1, operation=3, resource=1),
+# Permission(role=1, operation=4, resource=1),
+# Permission(role=2, operation=4, resource=1),
+# ]
+# global_session.add_all(ops)
+# global_session.commit()
diff --git a/orm/reaction.py b/orm/reaction.py
index 1c129e23..d5ed55cb 100644
--- a/orm/reaction.py
+++ b/orm/reaction.py
@@ -1,7 +1,6 @@
-from datetime import datetime
from enum import Enum as Enumeration
-from sqlalchemy import Column, DateTime, Enum, ForeignKey, String
+from sqlalchemy import Column, DateTime, Enum, ForeignKey, String, func
from base.orm import Base
@@ -28,15 +27,19 @@ class Reaction(Base):
__tablename__ = "reaction"
body = Column(String, nullable=True, comment="Reaction Body")
createdAt = Column(
- DateTime, nullable=False, default=datetime.now, comment="Created at"
+ DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at"
)
- createdBy = Column(ForeignKey("user.id"), nullable=False, index=True, comment="Sender")
- updatedAt = Column(DateTime, nullable=True, comment="Updated at")
- updatedBy = Column(ForeignKey("user.id"), nullable=True, index=True, comment="Last Editor")
- deletedAt = Column(DateTime, nullable=True, comment="Deleted at")
- deletedBy = Column(ForeignKey("user.id"), nullable=True, index=True, comment="Deleted by")
- shout = Column(ForeignKey("shout.id"), nullable=False, index=True)
- replyTo = Column(
+ createdBy: Column = Column(ForeignKey("user.id"), nullable=False, index=True, comment="Sender")
+ updatedAt = Column(DateTime(timezone=True), nullable=True, comment="Updated at")
+ updatedBy: Column = Column(
+ ForeignKey("user.id"), nullable=True, index=True, comment="Last Editor"
+ )
+ deletedAt = Column(DateTime(timezone=True), nullable=True, comment="Deleted at")
+ deletedBy: Column = Column(
+ ForeignKey("user.id"), nullable=True, index=True, comment="Deleted by"
+ )
+ shout: Column = Column(ForeignKey("shout.id"), nullable=False, index=True)
+ replyTo: Column = Column(
ForeignKey("reaction.id"), nullable=True, comment="Reply to reaction ID"
)
range = Column(String, nullable=True, comment="Range in format
:")
diff --git a/orm/shout.py b/orm/shout.py
index 22381d4c..e753faa5 100644
--- a/orm/shout.py
+++ b/orm/shout.py
@@ -1,6 +1,13 @@
-from datetime import datetime
-
-from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, JSON
+from sqlalchemy import (
+ JSON,
+ Boolean,
+ Column,
+ DateTime,
+ ForeignKey,
+ Integer,
+ String,
+ func,
+)
from sqlalchemy.orm import column_property, relationship
from base.orm import Base, local_session
@@ -12,44 +19,46 @@ from orm.user import User
class ShoutTopic(Base):
__tablename__ = "shout_topic"
- id = None # type: ignore
- shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
- topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
+ id = None
+ shout: Column = Column(ForeignKey("shout.id"), primary_key=True, index=True)
+ topic: Column = Column(ForeignKey("topic.id"), primary_key=True, index=True)
class ShoutReactionsFollower(Base):
__tablename__ = "shout_reactions_followers"
- id = None # type: ignore
- follower = Column(ForeignKey("user.id"), primary_key=True, index=True)
- shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
+ id = None
+ follower: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
+ shout: Column = Column(ForeignKey("shout.id"), primary_key=True, index=True)
auto = Column(Boolean, nullable=False, default=False)
createdAt = Column(
- DateTime, nullable=False, default=datetime.now, comment="Created at"
+ DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at"
)
- deletedAt = Column(DateTime, nullable=True)
+ deletedAt = Column(DateTime(timezone=True), nullable=True)
class ShoutAuthor(Base):
__tablename__ = "shout_author"
- id = None # type: ignore
- shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
- user = Column(ForeignKey("user.id"), primary_key=True, index=True)
- caption = Column(String, nullable=True, default="")
+ id = None
+ shout: Column = Column(ForeignKey("shout.id"), primary_key=True, index=True)
+ user: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
+ caption: Column = Column(String, nullable=True, default="")
class Shout(Base):
__tablename__ = "shout"
# timestamps
- createdAt = Column(DateTime, nullable=False, default=datetime.now, comment="Created at")
- updatedAt = Column(DateTime, nullable=True, comment="Updated at")
- publishedAt = Column(DateTime, nullable=True)
- deletedAt = Column(DateTime, nullable=True)
+ createdAt = Column(
+ DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at"
+ )
+ updatedAt = Column(DateTime(timezone=True), nullable=True, comment="Updated at")
+ publishedAt = Column(DateTime(timezone=True), nullable=True)
+ deletedAt = Column(DateTime(timezone=True), nullable=True)
- createdBy = Column(ForeignKey("user.id"), comment="Created By")
- deletedBy = Column(ForeignKey("user.id"), nullable=True)
+ createdBy: Column = Column(ForeignKey("user.id"), comment="Created By")
+ deletedBy: Column = Column(ForeignKey("user.id"), nullable=True)
slug = Column(String, unique=True)
cover = Column(String, nullable=True, comment="Cover image url")
@@ -71,11 +80,11 @@ class Shout(Base):
reactions = relationship(lambda: Reaction)
# TODO: these field should be used or modified
- community = Column(ForeignKey("community.id"), default=1)
- lang = Column(String, nullable=False, default='ru', comment="Language")
- mainTopic = Column(ForeignKey("topic.slug"), nullable=True)
+ community: Column = Column(ForeignKey("community.id"), default=1)
+ lang = Column(String, nullable=False, default="ru", comment="Language")
+ mainTopic: Column = Column(ForeignKey("topic.slug"), nullable=True)
visibility = Column(String, nullable=True) # owner authors community public
- versionOf = Column(ForeignKey("shout.id"), nullable=True)
+ versionOf: Column = Column(ForeignKey("shout.id"), nullable=True)
oid = Column(String, nullable=True)
@staticmethod
@@ -83,12 +92,7 @@ class Shout(Base):
with local_session() as session:
s = session.query(Shout).first()
if not s:
- entry = {
- "slug": "genesis-block",
- "body": "",
- "title": "Ничего",
- "lang": "ru"
- }
+ entry = {"slug": "genesis-block", "body": "", "title": "Ничего", "lang": "ru"}
s = Shout.create(**entry)
session.add(s)
session.commit()
diff --git a/orm/topic.py b/orm/topic.py
index a37dc69a..0b42d3cb 100644
--- a/orm/topic.py
+++ b/orm/topic.py
@@ -1,6 +1,4 @@
-from datetime import datetime
-
-from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, func
from base.orm import Base
@@ -8,11 +6,11 @@ from base.orm import Base
class TopicFollower(Base):
__tablename__ = "topic_followers"
- id = None # type: ignore
- follower = Column(ForeignKey("user.id"), primary_key=True, index=True)
- topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
+ id = None
+ follower: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
+ topic: Column = Column(ForeignKey("topic.id"), primary_key=True, index=True)
createdAt = Column(
- DateTime, nullable=False, default=datetime.now, comment="Created at"
+ DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at"
)
auto = Column(Boolean, nullable=False, default=False)
@@ -24,7 +22,5 @@ class Topic(Base):
title = Column(String, nullable=False, comment="Title")
body = Column(String, nullable=True, comment="Body")
pic = Column(String, nullable=True, comment="Picture")
- community = Column(
- ForeignKey("community.id"), default=1, comment="Community"
- )
+ community: Column = Column(ForeignKey("community.id"), default=1, comment="Community")
oid = Column(String, nullable=True, comment="Old ID")
diff --git a/orm/user.py b/orm/user.py
index 5aeab90e..b95891a7 100644
--- a/orm/user.py
+++ b/orm/user.py
@@ -1,8 +1,7 @@
-from datetime import datetime
-
from sqlalchemy import JSON as JSONType
-from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import relationship
+
from base.orm import Base, local_session
from orm.rbac import Role
@@ -10,10 +9,10 @@ from orm.rbac import Role
class UserRating(Base):
__tablename__ = "user_rating"
- id = None # type: ignore
- rater = Column(ForeignKey("user.id"), primary_key=True, index=True)
- user = Column(ForeignKey("user.id"), primary_key=True, index=True)
- value = Column(Integer)
+ id = None
+ rater: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
+ user: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
+ value: Column = Column(Integer)
@staticmethod
def init_table():
@@ -23,7 +22,7 @@ class UserRating(Base):
class UserRole(Base):
__tablename__ = "user_role"
- id = None # type: ignore
+ id = None
user = Column(ForeignKey("user.id"), primary_key=True, index=True)
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
@@ -31,11 +30,11 @@ class UserRole(Base):
class AuthorFollower(Base):
__tablename__ = "author_follower"
- id = None # type: ignore
- follower = Column(ForeignKey("user.id"), primary_key=True, index=True)
- author = Column(ForeignKey("user.id"), primary_key=True, index=True)
+ id = None
+ follower: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
+ author: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
createdAt = Column(
- DateTime, nullable=False, default=datetime.now, comment="Created at"
+ DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at"
)
auto = Column(Boolean, nullable=False, default=False)
@@ -55,12 +54,12 @@ class User(Base):
muted = Column(Boolean, default=False)
emailConfirmed = Column(Boolean, default=False)
createdAt = Column(
- DateTime, nullable=False, default=datetime.now, comment="Created at"
+ DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at"
)
lastSeen = Column(
- DateTime, nullable=False, default=datetime.now, comment="Was online at"
+ DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Was online at"
)
- deletedAt = Column(DateTime, nullable=True, comment="Deleted at")
+ deletedAt = Column(DateTime(timezone=True), nullable=True, comment="Deleted at")
links = Column(JSONType, nullable=True, comment="Links")
oauth = Column(String, nullable=True)
ratings = relationship(UserRating, foreign_keys=UserRating.user)
@@ -103,4 +102,4 @@ class User(Base):
# if __name__ == "__main__":
-# print(User.get_permission(user_id=1)) # type: ignore
+# print(User.get_permission(user_id=1))
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..aa4949aa
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,2 @@
+[tool.black]
+line-length = 100
diff --git a/requirements-dev.txt b/requirements-dev.txt
index d221f3b0..9ff65109 100755
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,4 +1,8 @@
-isort
-brunette
-flake8
-mypy
+black==23.10.1
+flake8==6.1.0
+gql_schema_codegen==1.0.1
+isort==5.12.0
+mypy==1.6.1
+pre-commit==3.5.0
+pymongo-stubs==0.2.0
+sqlalchemy-stubs==0.4
diff --git a/requirements.txt b/requirements.txt
index fe076fdd..6ab7bcef 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,40 +1,37 @@
-python-frontmatter~=1.0.0
-aioredis~=2.0.1
aiohttp
+aioredis~=2.0.1
+alembic==1.11.3
ariadne>=0.17.0
-PyYAML>=5.4
-pyjwt>=2.6.0
-starlette~=0.23.1
-sqlalchemy>=1.4.41
-graphql-core>=3.0.3
-gql~=3.4.0
-uvicorn>=0.18.3
-pydantic>=1.10.2
-passlib~=1.7.4
-itsdangerous
-authlib>=1.1.0
-httpx>=0.23.0
-psycopg2-binary
-transliterate~=1.10.2
-requests~=2.28.1
-bcrypt>=4.0.0
-websockets
-bson~=0.5.10
-flake8
-DateTime~=4.7
asyncio~=3.4.3
-python-dateutil~=2.8.2
+authlib==1.2.1
+bcrypt>=4.0.0
beautifulsoup4~=4.11.1
-lxml
-sentry-sdk>=1.14.0
-# sse_starlette
-graphql-ws
-nltk~=3.8.1
-pymystem3~=0.2.0
-transformers~=4.28.1
boto3~=1.28.2
botocore~=1.31.2
-python-multipart~=0.0.6
-alembic==1.11.3
+bson~=0.5.10
+DateTime~=4.7
+gql~=3.4.0
+graphql-core>=3.0.3
+httpx>=0.23.0
+itsdangerous
+lxml
Mako==1.2.4
MarkupSafe==2.1.3
+nltk~=3.8.1
+passlib~=1.7.4
+psycopg2-binary
+pydantic>=1.10.2
+pyjwt>=2.6.0
+pymystem3~=0.2.0
+python-dateutil~=2.8.2
+python-frontmatter~=1.0.0
+python-multipart~=0.0.6
+PyYAML>=5.4
+requests~=2.28.1
+sentry-sdk>=1.14.0
+sqlalchemy>=1.4.41
+sse-starlette==1.6.5
+starlette~=0.23.1
+transformers~=4.28.1
+transliterate~=1.10.2
+uvicorn>=0.18.3
diff --git a/resetdb.sh b/resetdb.sh
old mode 100644
new mode 100755
index 39b3b9b2..40ba2e37
--- a/resetdb.sh
+++ b/resetdb.sh
@@ -53,4 +53,3 @@ echo "Start migration"
python3 server.py migrate
if [ $? -ne 0 ]; then { echo "Migration failed, aborting." ; exit 1; } fi
echo 'Done!'
-
diff --git a/resolvers/__init__.py b/resolvers/__init__.py
index b7ba1e1e..9f4bf0bd 100644
--- a/resolvers/__init__.py
+++ b/resolvers/__init__.py
@@ -1,120 +1,46 @@
+# flake8: noqa
+
from resolvers.auth import (
- login,
- sign_out,
- is_email_used,
- register_by_email,
- confirm_email,
auth_send_link,
+ confirm_email,
get_current_user,
+ is_email_used,
+ login,
+ register_by_email,
+ sign_out,
)
-
-from resolvers.create.migrate import markdown_body
from resolvers.create.editor import create_shout, delete_shout, update_shout
-
-from resolvers.zine.profile import (
- load_authors_by,
- rate_user,
- update_profile,
- get_authors_all
-)
-
-from resolvers.zine.reactions import (
- create_reaction,
- delete_reaction,
- update_reaction,
- reactions_unfollow,
- reactions_follow,
- load_reactions_by
-)
-from resolvers.zine.topics import (
- topic_follow,
- topic_unfollow,
- topics_by_author,
- topics_by_community,
- topics_all,
- get_topic
-)
-
-from resolvers.zine.following import (
- follow,
- unfollow
-)
-
-from resolvers.zine.load import (
- load_shout,
- load_shouts_by
-)
-
-from resolvers.inbox.chats import (
- create_chat,
- delete_chat,
- update_chat
-
-)
+from resolvers.inbox.chats import create_chat, delete_chat, update_chat
+from resolvers.inbox.load import load_chats, load_messages_by, load_recipients
from resolvers.inbox.messages import (
create_message,
delete_message,
+ mark_as_read,
update_message,
- message_generator,
- mark_as_read
-)
-from resolvers.inbox.load import (
- load_chats,
- load_messages_by,
- load_recipients
)
from resolvers.inbox.search import search_recipients
-
-__all__ = [
- # auth
- "login",
- "register_by_email",
- "is_email_used",
- "confirm_email",
- "auth_send_link",
- "sign_out",
- "get_current_user",
- # zine.profile
- "load_authors_by",
- "rate_user",
- "update_profile",
- "get_authors_all",
- # zine.load
- "load_shout",
- "load_shouts_by",
- # zine.following
- "follow",
- "unfollow",
- # create
- "create_shout",
- "update_shout",
- "delete_shout",
- "markdown_body",
- # zine.topics
- "topics_all",
- "topics_by_community",
- "topics_by_author",
- "topic_follow",
- "topic_unfollow",
- "get_topic",
- # zine.reactions
- "reactions_follow",
- "reactions_unfollow",
- "create_reaction",
- "update_reaction",
- "delete_reaction",
- "load_reactions_by",
- # inbox
- "load_chats",
- "load_messages_by",
- "create_chat",
- "delete_chat",
- "update_chat",
- "create_message",
- "delete_message",
- "update_message",
- "message_generator",
- "mark_as_read",
- "load_recipients",
- "search_recipients"
-]
+from resolvers.notifications import load_notifications
+from resolvers.zine.following import follow, unfollow
+from resolvers.zine.load import load_shout, load_shouts_by
+from resolvers.zine.profile import (
+ get_authors_all,
+ load_authors_by,
+ rate_user,
+ update_profile,
+)
+from resolvers.zine.reactions import (
+ create_reaction,
+ delete_reaction,
+ load_reactions_by,
+ reactions_follow,
+ reactions_unfollow,
+ update_reaction,
+)
+from resolvers.zine.topics import (
+ get_topic,
+ topic_follow,
+ topic_unfollow,
+ topics_all,
+ topics_by_author,
+ topics_by_community,
+)
diff --git a/resolvers/auth.py b/resolvers/auth.py
index 669a56d1..ed754044 100644
--- a/resolvers/auth.py
+++ b/resolvers/auth.py
@@ -1,25 +1,23 @@
# -*- coding: utf-8 -*-
+import re
from datetime import datetime, timezone
from urllib.parse import quote_plus
from graphql.type import GraphQLResolveInfo
-from starlette.responses import RedirectResponse
from transliterate import translit
-import re
+
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from auth.email import send_auth_email
from auth.identity import Identity, Password
from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage
-from base.exceptions import (BaseHttpException, InvalidPassword, InvalidToken,
- ObjectNotExist, Unauthorized)
+from base.exceptions import InvalidPassword, InvalidToken, ObjectNotExist, Unauthorized
from base.orm import local_session
from base.resolvers import mutation, query
from orm import Role, User
-from resolvers.zine.profile import user_subscriptions
-from settings import SESSION_TOKEN_HEADER, FRONTEND_URL
+from settings import SESSION_TOKEN_HEADER
@mutation.field("getSession")
@@ -33,18 +31,14 @@ async def get_current_user(_, info):
user.lastSeen = datetime.now(tz=timezone.utc)
session.commit()
- return {
- "token": token,
- "user": user,
- "news": await user_subscriptions(user.id),
- }
+ return {"token": token, "user": user}
@mutation.field("confirmEmail")
async def confirm_email(_, info, token):
"""confirm owning email address"""
try:
- print('[resolvers.auth] confirm email by token')
+ print("[resolvers.auth] confirm email by token")
payload = JWTCodec.decode(token)
user_id = payload.user_id
await TokenStorage.get(f"{user_id}-{payload.username}-{token}")
@@ -55,11 +49,7 @@ async def confirm_email(_, info, token):
user.lastSeen = datetime.now(tz=timezone.utc)
session.add(user)
session.commit()
- return {
- "token": session_token,
- "user": user,
- "news": await user_subscriptions(user.id)
- }
+ return {"token": session_token, "user": user}
except InvalidToken as e:
raise InvalidToken(e.message)
except Exception as e:
@@ -67,19 +57,6 @@ async def confirm_email(_, info, token):
return {"error": "email is not confirmed"}
-async def confirm_email_handler(request):
- token = request.path_params["token"] # one time
- request.session["token"] = token
- res = await confirm_email(None, {}, token)
- print('[resolvers.auth] confirm_email request: %r' % request)
- if "error" in res:
- raise BaseHttpException(res['error'])
- else:
- response = RedirectResponse(url=FRONTEND_URL)
- response.set_cookie("token", res["token"]) # session token
- return response
-
-
def create_user(user_dict):
user = User(**user_dict)
with local_session() as session:
@@ -90,22 +67,22 @@ def create_user(user_dict):
def generate_unique_slug(src):
- print('[resolvers.auth] generating slug from: ' + src)
+ print("[resolvers.auth] generating slug from: " + src)
slug = translit(src, "ru", reversed=True).replace(".", "-").lower()
- slug = re.sub('[^0-9a-zA-Z]+', '-', slug)
+ slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
if slug != src:
- print('[resolvers.auth] translited name: ' + slug)
+ print("[resolvers.auth] translited name: " + slug)
c = 1
with local_session() as session:
user = session.query(User).where(User.slug == slug).first()
while user:
user = session.query(User).where(User.slug == slug).first()
- slug = slug + '-' + str(c)
+ slug = slug + "-" + str(c)
c += 1
if not user:
unique_slug = slug
- print('[resolvers.auth] ' + unique_slug)
- return quote_plus(unique_slug.replace('\'', '')).replace('+', '-')
+ print("[resolvers.auth] " + unique_slug)
+ return quote_plus(unique_slug.replace("'", "")).replace("+", "-")
@mutation.field("registerUser")
@@ -120,12 +97,12 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str
slug = generate_unique_slug(name)
user = session.query(User).where(User.slug == slug).first()
if user:
- slug = generate_unique_slug(email.split('@')[0])
+ slug = generate_unique_slug(email.split("@")[0])
user_dict = {
"email": email,
"username": email, # will be used to store phone number or some messenger network id
"name": name,
- "slug": slug
+ "slug": slug,
}
if password:
user_dict["password"] = Password.encode(password)
@@ -175,11 +152,7 @@ async def login(_, info, email: str, password: str = "", lang: str = "ru"):
user = Identity.password(orm_user, password)
session_token = await TokenStorage.create_session(user)
print(f"[auth] user {email} authorized")
- return {
- "token": session_token,
- "user": user,
- "news": await user_subscriptions(user.id),
- }
+ return {"token": session_token, "user": user}
except InvalidPassword:
print(f"[auth] {email}: invalid password")
raise InvalidPassword("invalid password") # contains webserver status
diff --git a/resolvers/create/editor.py b/resolvers/create/editor.py
index c81ff404..6266a95b 100644
--- a/resolvers/create/editor.py
+++ b/resolvers/create/editor.py
@@ -18,21 +18,23 @@ async def create_shout(_, info, inp):
auth: AuthCredentials = info.context["request"].auth
with local_session() as session:
- topics = session.query(Topic).filter(Topic.slug.in_(inp.get('topics', []))).all()
+ topics = session.query(Topic).filter(Topic.slug.in_(inp.get("topics", []))).all()
- new_shout = Shout.create(**{
- "title": inp.get("title"),
- "subtitle": inp.get('subtitle'),
- "lead": inp.get('lead'),
- "description": inp.get('description'),
- "body": inp.get("body", ''),
- "layout": inp.get("layout"),
- "authors": inp.get("authors", []),
- "slug": inp.get("slug"),
- "mainTopic": inp.get("mainTopic"),
- "visibility": "owner",
- "createdBy": auth.user_id
- })
+ new_shout = Shout.create(
+ **{
+ "title": inp.get("title"),
+ "subtitle": inp.get("subtitle"),
+ "lead": inp.get("lead"),
+ "description": inp.get("description"),
+ "body": inp.get("body", ""),
+ "layout": inp.get("layout"),
+ "authors": inp.get("authors", []),
+ "slug": inp.get("slug"),
+ "mainTopic": inp.get("mainTopic"),
+ "visibility": "owner",
+ "createdBy": auth.user_id,
+ }
+ )
for topic in topics:
t = ShoutTopic.create(topic=topic.id, shout=new_shout.id)
@@ -60,14 +62,19 @@ async def create_shout(_, info, inp):
@mutation.field("updateShout")
@login_required
-async def update_shout(_, info, shout_id, shout_input=None, publish=False):
+async def update_shout(_, info, shout_id, shout_input=None, publish=False): # noqa: C901
auth: AuthCredentials = info.context["request"].auth
with local_session() as session:
- shout = session.query(Shout).options(
- joinedload(Shout.authors),
- joinedload(Shout.topics),
- ).filter(Shout.id == shout_id).first()
+ shout = (
+ session.query(Shout)
+ .options(
+ joinedload(Shout.authors),
+ joinedload(Shout.topics),
+ )
+ .filter(Shout.id == shout_id)
+ .first()
+ )
if not shout:
return {"error": "shout not found"}
@@ -94,25 +101,34 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False):
session.commit()
for new_topic_to_link in new_topics_to_link:
- created_unlinked_topic = ShoutTopic.create(shout=shout.id, topic=new_topic_to_link.id)
+ created_unlinked_topic = ShoutTopic.create(
+ shout=shout.id, topic=new_topic_to_link.id
+ )
session.add(created_unlinked_topic)
- existing_topics_input = [topic_input for topic_input in topics_input if topic_input.get("id", 0) > 0]
- existing_topic_to_link_ids = [existing_topic_input["id"] for existing_topic_input in existing_topics_input
- if existing_topic_input["id"] not in [topic.id for topic in shout.topics]]
+ existing_topics_input = [
+ topic_input for topic_input in topics_input if topic_input.get("id", 0) > 0
+ ]
+ existing_topic_to_link_ids = [
+ existing_topic_input["id"]
+ for existing_topic_input in existing_topics_input
+ if existing_topic_input["id"] not in [topic.id for topic in shout.topics]
+ ]
for existing_topic_to_link_id in existing_topic_to_link_ids:
- created_unlinked_topic = ShoutTopic.create(shout=shout.id, topic=existing_topic_to_link_id)
+ created_unlinked_topic = ShoutTopic.create(
+ shout=shout.id, topic=existing_topic_to_link_id
+ )
session.add(created_unlinked_topic)
- topic_to_unlink_ids = [topic.id for topic in shout.topics
- if topic.id not in [topic_input["id"] for topic_input in existing_topics_input]]
+ topic_to_unlink_ids = [
+ topic.id
+ for topic in shout.topics
+ if topic.id not in [topic_input["id"] for topic_input in existing_topics_input]
+ ]
shout_topics_to_remove = session.query(ShoutTopic).filter(
- and_(
- ShoutTopic.shout == shout.id,
- ShoutTopic.topic.in_(topic_to_unlink_ids)
- )
+ and_(ShoutTopic.shout == shout.id, ShoutTopic.topic.in_(topic_to_unlink_ids))
)
for shout_topic_to_remove in shout_topics_to_remove:
@@ -120,13 +136,13 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False):
shout_input["mainTopic"] = shout_input["mainTopic"]["slug"]
- if shout_input["mainTopic"] == '':
+ if shout_input["mainTopic"] == "":
del shout_input["mainTopic"]
shout.update(shout_input)
updated = True
- if publish and shout.visibility == 'owner':
+ if publish and shout.visibility == "owner":
shout.visibility = "community"
shout.publishedAt = datetime.now(tz=timezone.utc)
updated = True
diff --git a/resolvers/create/migrate.py b/resolvers/create/migrate.py
deleted file mode 100644
index f16341f0..00000000
--- a/resolvers/create/migrate.py
+++ /dev/null
@@ -1,11 +0,0 @@
-
-from base.resolvers import query
-from resolvers.auth import login_required
-from migration.extract import extract_md
-
-
-@login_required
-@query.field("markdownBody")
-def markdown_body(_, info, body: str):
- body = extract_md(body)
- return body
diff --git a/resolvers/inbox/chats.py b/resolvers/inbox/chats.py
index 853defab..1a246b1c 100644
--- a/resolvers/inbox/chats.py
+++ b/resolvers/inbox/chats.py
@@ -24,27 +24,24 @@ async def update_chat(_, info, chat_new: Chat):
chat_id = chat_new["id"]
chat = await redis.execute("GET", f"chats/{chat_id}")
if not chat:
- return {
- "error": "chat not exist"
- }
+ return {"error": "chat not exist"}
chat = dict(json.loads(chat))
# TODO
if auth.user_id in chat["admins"]:
- chat.update({
- "title": chat_new.get("title", chat["title"]),
- "description": chat_new.get("description", chat["description"]),
- "updatedAt": int(datetime.now(tz=timezone.utc).timestamp()),
- "admins": chat_new.get("admins", chat.get("admins") or []),
- "users": chat_new.get("users", chat["users"])
- })
+ chat.update(
+ {
+ "title": chat_new.get("title", chat["title"]),
+ "description": chat_new.get("description", chat["description"]),
+ "updatedAt": int(datetime.now(tz=timezone.utc).timestamp()),
+ "admins": chat_new.get("admins", chat.get("admins") or []),
+ "users": chat_new.get("users", chat["users"]),
+ }
+ )
await redis.execute("SET", f"chats/{chat.id}", json.dumps(chat))
await redis.execute("COMMIT")
- return {
- "error": None,
- "chat": chat
- }
+ return {"error": None, "chat": chat}
@mutation.field("createChat")
@@ -52,7 +49,7 @@ async def update_chat(_, info, chat_new: Chat):
async def create_chat(_, info, title="", members=[]):
auth: AuthCredentials = info.context["request"].auth
chat = {}
- print('create_chat members: %r' % members)
+ print("create_chat members: %r" % members)
if auth.user_id not in members:
members.append(int(auth.user_id))
@@ -74,15 +71,12 @@ async def create_chat(_, info, title="", members=[]):
chat = await redis.execute("GET", f"chats/{c.decode('utf-8')}")
if chat:
chat = json.loads(chat)
- if chat['title'] == "":
- print('[inbox] createChat found old chat')
+ if chat["title"] == "":
+ print("[inbox] createChat found old chat")
print(chat)
break
if chat:
- return {
- "chat": chat,
- "error": "existed"
- }
+ return {"chat": chat, "error": "existed"}
chat_id = str(uuid.uuid4())
chat = {
@@ -92,7 +86,7 @@ async def create_chat(_, info, title="", members=[]):
"createdBy": auth.user_id,
"createdAt": int(datetime.now(tz=timezone.utc).timestamp()),
"updatedAt": int(datetime.now(tz=timezone.utc).timestamp()),
- "admins": members if (len(members) == 2 and title == "") else []
+ "admins": members if (len(members) == 2 and title == "") else [],
}
for m in members:
@@ -100,10 +94,7 @@ async def create_chat(_, info, title="", members=[]):
await redis.execute("SET", f"chats/{chat_id}", json.dumps(chat))
await redis.execute("SET", f"chats/{chat_id}/next_message_id", str(0))
await redis.execute("COMMIT")
- return {
- "error": None,
- "chat": chat
- }
+ return {"error": None, "chat": chat}
@mutation.field("deleteChat")
@@ -114,11 +105,9 @@ async def delete_chat(_, info, chat_id: str):
chat = await redis.execute("GET", f"/chats/{chat_id}")
if chat:
chat = dict(json.loads(chat))
- if auth.user_id in chat['admins']:
+ if auth.user_id in chat["admins"]:
await redis.execute("DEL", f"chats/{chat_id}")
await redis.execute("SREM", "chats_by_user/" + str(auth.user_id), chat_id)
await redis.execute("COMMIT")
else:
- return {
- "error": "chat not exist"
- }
+ return {"error": "chat not exist"}
diff --git a/resolvers/inbox/load.py b/resolvers/inbox/load.py
index a0d41721..4322da11 100644
--- a/resolvers/inbox/load.py
+++ b/resolvers/inbox/load.py
@@ -1,28 +1,27 @@
import json
-# from datetime import datetime, timedelta, timezone
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
-from base.redis import redis
from base.orm import local_session
+from base.redis import redis
from base.resolvers import query
from orm.user import User
from resolvers.zine.profile import followed_authors
+
from .unread import get_unread_counter
+# from datetime import datetime, timedelta, timezone
+
async def load_messages(chat_id: str, limit: int = 5, offset: int = 0, ids=[]):
- ''' load :limit messages for :chat_id with :offset '''
+ """load :limit messages for :chat_id with :offset"""
messages = []
message_ids = []
if ids:
message_ids += ids
try:
if limit:
- mids = await redis.lrange(f"chats/{chat_id}/message_ids",
- offset,
- offset + limit
- )
+ mids = await redis.lrange(f"chats/{chat_id}/message_ids", offset, offset + limit)
mids = [mid.decode("utf-8") for mid in mids]
message_ids += mids
except Exception as e:
@@ -30,10 +29,10 @@ async def load_messages(chat_id: str, limit: int = 5, offset: int = 0, ids=[]):
if message_ids:
message_keys = [f"chats/{chat_id}/messages/{mid}" for mid in message_ids]
messages = await redis.mget(*message_keys)
- messages = [json.loads(msg.decode('utf-8')) for msg in messages]
+ messages = [json.loads(msg.decode("utf-8")) for msg in messages]
replies = []
for m in messages:
- rt = m.get('replyTo')
+ rt = m.get("replyTo")
if rt:
rt = int(rt)
if rt not in message_ids:
@@ -46,14 +45,14 @@ async def load_messages(chat_id: str, limit: int = 5, offset: int = 0, ids=[]):
@query.field("loadChats")
@login_required
async def load_chats(_, info, limit: int = 50, offset: int = 0):
- """ load :limit chats of current user with :offset """
+ """load :limit chats of current user with :offset"""
auth: AuthCredentials = info.context["request"].auth
cids = await redis.execute("SMEMBERS", "chats_by_user/" + str(auth.user_id))
if cids:
- cids = list(cids)[offset:offset + limit]
+ cids = list(cids)[offset : offset + limit]
if not cids:
- print('[inbox.load] no chats were found')
+ print("[inbox.load] no chats were found")
cids = []
onliners = await redis.execute("SMEMBERS", "users-online")
if not onliners:
@@ -64,62 +63,50 @@ async def load_chats(_, info, limit: int = 50, offset: int = 0):
c = await redis.execute("GET", "chats/" + cid)
if c:
c = dict(json.loads(c))
- c['messages'] = await load_messages(cid, 5, 0)
- c['unread'] = await get_unread_counter(cid, auth.user_id)
+ c["messages"] = await load_messages(cid, 5, 0)
+ c["unread"] = await get_unread_counter(cid, auth.user_id)
with local_session() as session:
- c['members'] = []
+ c["members"] = []
for uid in c["users"]:
a = session.query(User).where(User.id == uid).first()
if a:
- c['members'].append({
- "id": a.id,
- "slug": a.slug,
- "userpic": a.userpic,
- "name": a.name,
- "lastSeen": a.lastSeen,
- "online": a.id in onliners
- })
+ c["members"].append(
+ {
+ "id": a.id,
+ "slug": a.slug,
+ "userpic": a.userpic,
+ "name": a.name,
+ "lastSeen": a.lastSeen,
+ "online": a.id in onliners,
+ }
+ )
chats.append(c)
- return {
- "chats": chats,
- "error": None
- }
+ return {"chats": chats, "error": None}
@query.field("loadMessagesBy")
@login_required
async def load_messages_by(_, info, by, limit: int = 10, offset: int = 0):
- ''' load :limit messages of :chat_id with :offset '''
+ """load :limit messages of :chat_id with :offset"""
auth: AuthCredentials = info.context["request"].auth
userchats = await redis.execute("SMEMBERS", "chats_by_user/" + str(auth.user_id))
- userchats = [c.decode('utf-8') for c in userchats]
+ userchats = [c.decode("utf-8") for c in userchats]
# print('[inbox] userchats: %r' % userchats)
if userchats:
# print('[inbox] loading messages by...')
messages = []
- by_chat = by.get('chat')
+ by_chat = by.get("chat")
if by_chat in userchats:
chat = await redis.execute("GET", f"chats/{by_chat}")
# print(chat)
if not chat:
- return {
- "messages": [],
- "error": "chat not exist"
- }
+ return {"messages": [], "error": "chat not exist"}
# everyone's messages in filtered chat
messages = await load_messages(by_chat, limit, offset)
- return {
- "messages": sorted(
- list(messages),
- key=lambda m: m['createdAt']
- ),
- "error": None
- }
+ return {"messages": sorted(list(messages), key=lambda m: m["createdAt"]), "error": None}
else:
- return {
- "error": "Cannot access messages of this chat"
- }
+ return {"error": "Cannot access messages of this chat"}
@query.field("loadRecipients")
@@ -138,15 +125,14 @@ async def load_recipients(_, info, limit=50, offset=0):
chat_users += session.query(User).where(User.emailConfirmed).limit(limit).offset(offset)
members = []
for a in chat_users:
- members.append({
- "id": a.id,
- "slug": a.slug,
- "userpic": a.userpic,
- "name": a.name,
- "lastSeen": a.lastSeen,
- "online": a.id in onliners
- })
- return {
- "members": members,
- "error": None
- }
+ members.append(
+ {
+ "id": a.id,
+ "slug": a.slug,
+ "userpic": a.userpic,
+ "name": a.name,
+ "lastSeen": a.lastSeen,
+ "online": a.id in onliners,
+ }
+ )
+ return {"members": members, "error": None}
diff --git a/resolvers/inbox/messages.py b/resolvers/inbox/messages.py
index 44ff1f03..c4d36c48 100644
--- a/resolvers/inbox/messages.py
+++ b/resolvers/inbox/messages.py
@@ -1,62 +1,54 @@
-import asyncio
import json
-from typing import Any
from datetime import datetime, timezone
-from graphql.type import GraphQLResolveInfo
+
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from base.redis import redis
-from base.resolvers import mutation, subscription
-from services.following import FollowingManager, FollowingResult, Following
-from validations.inbox import Message
+from base.resolvers import mutation
+from services.following import FollowingManager, FollowingResult
@mutation.field("createMessage")
@login_required
async def create_message(_, info, chat: str, body: str, replyTo=None):
- """ create message with :body for :chat_id replying to :replyTo optionally """
+ """create message with :body for :chat_id replying to :replyTo optionally"""
auth: AuthCredentials = info.context["request"].auth
chat = await redis.execute("GET", f"chats/{chat}")
if not chat:
- return {
- "error": "chat is not exist"
- }
+ return {"error": "chat is not exist"}
else:
- chat = dict(json.loads(chat))
- message_id = await redis.execute("GET", f"chats/{chat['id']}/next_message_id")
+ chat_dict = dict(json.loads(chat))
+ message_id = await redis.execute("GET", f"chats/{chat_dict['id']}/next_message_id")
message_id = int(message_id)
new_message = {
- "chatId": chat['id'],
+ "chatId": chat_dict["id"],
"id": message_id,
"author": auth.user_id,
"body": body,
- "createdAt": int(datetime.now(tz=timezone.utc).timestamp())
+ "createdAt": int(datetime.now(tz=timezone.utc).timestamp()),
}
if replyTo:
- new_message['replyTo'] = replyTo
- chat['updatedAt'] = new_message['createdAt']
- await redis.execute("SET", f"chats/{chat['id']}", json.dumps(chat))
+ new_message["replyTo"] = replyTo
+ chat_dict["updatedAt"] = new_message["createdAt"]
+ await redis.execute("SET", f"chats/{chat_dict['id']}", json.dumps(chat))
print(f"[inbox] creating message {new_message}")
await redis.execute(
- "SET", f"chats/{chat['id']}/messages/{message_id}", json.dumps(new_message)
+ "SET", f"chats/{chat_dict['id']}/messages/{message_id}", json.dumps(new_message)
)
- await redis.execute("LPUSH", f"chats/{chat['id']}/message_ids", str(message_id))
- await redis.execute("SET", f"chats/{chat['id']}/next_message_id", str(message_id + 1))
+ await redis.execute("LPUSH", f"chats/{chat_dict['id']}/message_ids", str(message_id))
+ await redis.execute("SET", f"chats/{chat_dict['id']}/next_message_id", str(message_id + 1))
- users = chat["users"]
+ users = chat_dict["users"]
for user_slug in users:
await redis.execute(
- "LPUSH", f"chats/{chat['id']}/unread/{user_slug}", str(message_id)
+ "LPUSH", f"chats/{chat_dict['id']}/unread/{user_slug}", str(message_id)
)
- result = FollowingResult("NEW", 'chat', new_message)
- await FollowingManager.push('chat', result)
+ result = FollowingResult("NEW", "chat", new_message)
+ await FollowingManager.push("chat", result)
- return {
- "message": new_message,
- "error": None
- }
+ return {"message": new_message, "error": None}
@mutation.field("updateMessage")
@@ -81,13 +73,10 @@ async def update_message(_, info, chat_id: str, message_id: int, body: str):
await redis.execute("SET", f"chats/{chat_id}/messages/{message_id}", json.dumps(message))
- result = FollowingResult("UPDATED", 'chat', message)
- await FollowingManager.push('chat', result)
+ result = FollowingResult("UPDATED", "chat", message)
+ await FollowingManager.push("chat", result)
- return {
- "message": message,
- "error": None
- }
+ return {"message": message, "error": None}
@mutation.field("deleteMessage")
@@ -114,7 +103,7 @@ async def delete_message(_, info, chat_id: str, message_id: int):
for user_id in users:
await redis.execute("LREM", f"chats/{chat_id}/unread/{user_id}", 0, str(message_id))
- result = FollowingResult("DELETED", 'chat', message)
+ result = FollowingResult("DELETED", "chat", message)
await FollowingManager.push(result)
return {}
@@ -137,43 +126,4 @@ async def mark_as_read(_, info, chat_id: str, messages: [int]):
for message_id in messages:
await redis.execute("LREM", f"chats/{chat_id}/unread/{auth.user_id}", 0, str(message_id))
- return {
- "error": None
- }
-
-
-@subscription.source("newMessage")
-async def message_generator(_, info: GraphQLResolveInfo):
- print(f"[resolvers.messages] generator {info}")
- auth: AuthCredentials = info.context["request"].auth
- user_id = auth.user_id
- try:
- user_following_chats = await redis.execute("GET", f"chats_by_user/{user_id}")
- if user_following_chats:
- user_following_chats = list(json.loads(user_following_chats)) # chat ids
- else:
- user_following_chats = []
- tasks = []
- updated = {}
- for chat_id in user_following_chats:
- chat = await redis.execute("GET", f"chats/{chat_id}")
- updated[chat_id] = chat['updatedAt']
- user_following_chats_sorted = sorted(user_following_chats, key=lambda x: updated[x], reverse=True)
-
- for chat_id in user_following_chats_sorted:
- following_chat = Following('chat', chat_id)
- await FollowingManager.register('chat', following_chat)
- chat_task = following_chat.queue.get()
- tasks.append(chat_task)
-
- while True:
- msg = await asyncio.gather(*tasks)
- yield msg
- finally:
- await FollowingManager.remove('chat', following_chat)
-
-
-@subscription.field("newMessage")
-@login_required
-async def message_resolver(message: Message, info: Any):
- return message
+ return {"error": None}
diff --git a/resolvers/inbox/search.py b/resolvers/inbox/search.py
index 1ca340e5..6b9a5f1a 100644
--- a/resolvers/inbox/search.py
+++ b/resolvers/inbox/search.py
@@ -1,10 +1,11 @@
import json
-from datetime import datetime, timezone, timedelta
+from datetime import datetime, timedelta, timezone
+
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
+from base.orm import local_session
from base.redis import redis
from base.resolvers import query
-from base.orm import local_session
from orm.user import AuthorFollower, User
from resolvers.inbox.load import load_messages
@@ -17,7 +18,7 @@ async def search_recipients(_, info, query: str, limit: int = 50, offset: int =
auth: AuthCredentials = info.context["request"].auth
talk_before = await redis.execute("GET", f"/chats_by_user/{auth.user_id}")
if talk_before:
- talk_before = list(json.loads(talk_before))[offset:offset + limit]
+ talk_before = list(json.loads(talk_before))[offset : offset + limit]
for chat_id in talk_before:
members = await redis.execute("GET", f"/chats/{chat_id}/users")
if members:
@@ -31,23 +32,24 @@ async def search_recipients(_, info, query: str, limit: int = 50, offset: int =
with local_session() as session:
# followings
- result += session.query(AuthorFollower.author).join(
- User, User.id == AuthorFollower.follower
- ).where(
- User.slug.startswith(query)
- ).offset(offset + len(result)).limit(more_amount)
+ result += (
+ session.query(AuthorFollower.author)
+ .join(User, User.id == AuthorFollower.follower)
+ .where(User.slug.startswith(query))
+ .offset(offset + len(result))
+ .limit(more_amount)
+ )
more_amount = limit
# followers
- result += session.query(AuthorFollower.follower).join(
- User, User.id == AuthorFollower.author
- ).where(
- User.slug.startswith(query)
- ).offset(offset + len(result)).limit(offset + len(result) + limit)
- return {
- "members": list(result),
- "error": None
- }
+ result += (
+ session.query(AuthorFollower.follower)
+ .join(User, User.id == AuthorFollower.author)
+ .where(User.slug.startswith(query))
+ .offset(offset + len(result))
+ .limit(offset + len(result) + limit)
+ )
+ return {"members": list(result), "error": None}
@query.field("searchMessages")
@@ -57,22 +59,22 @@ async def search_user_chats(by, messages, user_id: int, limit, offset):
cids.union(set(await redis.execute("SMEMBERS", "chats_by_user/" + str(user_id))))
messages = []
- by_author = by.get('author')
+ by_author = by.get("author")
if by_author:
# all author's messages
cids.union(set(await redis.execute("SMEMBERS", f"chats_by_user/{by_author}")))
# author's messages in filtered chat
messages.union(set(filter(lambda m: m["author"] == by_author, list(messages))))
for c in cids:
- c = c.decode('utf-8')
+ c = c.decode("utf-8")
messages = await load_messages(c, limit, offset)
- body_like = by.get('body')
+ body_like = by.get("body")
if body_like:
# search in all messages in all user's chats
for c in cids:
# FIXME: use redis scan here
- c = c.decode('utf-8')
+ c = c.decode("utf-8")
mmm = await load_messages(c, limit, offset)
for m in mmm:
if body_like in m["body"]:
@@ -83,13 +85,12 @@ async def search_user_chats(by, messages, user_id: int, limit, offset):
days = by.get("days")
if days:
- messages.extend(filter(
- list(messages),
- key=lambda m: (
- datetime.now(tz=timezone.utc) - int(m["createdAt"]) < timedelta(days=by["days"])
+ messages.extend(
+ filter(
+ list(messages),
+ key=lambda m: (
+ datetime.now(tz=timezone.utc) - int(m["createdAt"]) < timedelta(days=by["days"])
+ ),
)
- ))
- return {
- "messages": messages,
- "error": None
- }
+ )
+ return {"messages": messages, "error": None}
diff --git a/resolvers/inbox/unread.py b/resolvers/inbox/unread.py
index 7380f7ac..62aa873c 100644
--- a/resolvers/inbox/unread.py
+++ b/resolvers/inbox/unread.py
@@ -1,5 +1,4 @@
from base.redis import redis
-import json
async def get_unread_counter(chat_id: str, user_id: int):
@@ -9,14 +8,3 @@ async def get_unread_counter(chat_id: str, user_id: int):
return unread
except Exception:
return 0
-
-
-async def get_total_unread_counter(user_id: int):
- chats = await redis.execute("GET", f"chats_by_user/{str(user_id)}")
- unread = 0
- if chats:
- chats = json.loads(chats)
- for chat_id in chats:
- n = await get_unread_counter(chat_id.decode('utf-8'), user_id)
- unread += n
- return unread
diff --git a/resolvers/notifications.py b/resolvers/notifications.py
new file mode 100644
index 00000000..9bc83c69
--- /dev/null
+++ b/resolvers/notifications.py
@@ -0,0 +1,89 @@
+from sqlalchemy import and_, desc, select, update
+
+from auth.authenticate import login_required
+from auth.credentials import AuthCredentials
+from base.orm import local_session
+from base.resolvers import mutation, query
+from orm import Notification
+
+
+@query.field("loadNotifications")
+@login_required
+async def load_notifications(_, info, params=None):
+ if params is None:
+ params = {}
+
+ auth: AuthCredentials = info.context["request"].auth
+ user_id = auth.user_id
+
+ limit = params.get("limit", 50)
+ offset = params.get("offset", 0)
+
+ q = (
+ select(Notification)
+ .where(Notification.user == user_id)
+ .order_by(desc(Notification.createdAt))
+ .limit(limit)
+ .offset(offset)
+ )
+
+ notifications = []
+ with local_session() as session:
+ total_count = session.query(Notification).where(Notification.user == user_id).count()
+
+ total_unread_count = (
+ session.query(Notification)
+ .where(and_(Notification.user == user_id, Notification.seen == False)) # noqa: E712
+ .count()
+ )
+
+ for [notification] in session.execute(q):
+ notification.type = notification.type.name
+ notifications.append(notification)
+
+ return {
+ "notifications": notifications,
+ "totalCount": total_count,
+ "totalUnreadCount": total_unread_count,
+ }
+
+
+@mutation.field("markNotificationAsRead")
+@login_required
+async def mark_notification_as_read(_, info, notification_id: int):
+ auth: AuthCredentials = info.context["request"].auth
+ user_id = auth.user_id
+
+ with local_session() as session:
+ notification = (
+ session.query(Notification)
+ .where(and_(Notification.id == notification_id, Notification.user == user_id))
+ .one()
+ )
+ notification.seen = True
+ session.commit()
+
+ return {}
+
+
+@mutation.field("markAllNotificationsAsRead")
+@login_required
+async def mark_all_notifications_as_read(_, info):
+ auth: AuthCredentials = info.context["request"].auth
+ user_id = auth.user_id
+
+ statement = (
+ update(Notification)
+ .where(and_(Notification.user == user_id, Notification.seen == False)) # noqa: E712
+ .values(seen=True)
+ )
+
+ with local_session() as session:
+ try:
+ session.execute(statement)
+ session.commit()
+ except Exception as e:
+ session.rollback()
+ print(f"[mark_all_notifications_as_read] error: {str(e)}")
+
+ return {}
diff --git a/resolvers/upload.py b/resolvers/upload.py
index 6b66cb41..4205f473 100644
--- a/resolvers/upload.py
+++ b/resolvers/upload.py
@@ -2,33 +2,36 @@ import os
import shutil
import tempfile
import uuid
+
import boto3
from botocore.exceptions import BotoCoreError, ClientError
from starlette.responses import JSONResponse
-STORJ_ACCESS_KEY = os.environ.get('STORJ_ACCESS_KEY')
-STORJ_SECRET_KEY = os.environ.get('STORJ_SECRET_KEY')
-STORJ_END_POINT = os.environ.get('STORJ_END_POINT')
-STORJ_BUCKET_NAME = os.environ.get('STORJ_BUCKET_NAME')
-CDN_DOMAIN = os.environ.get('CDN_DOMAIN')
+STORJ_ACCESS_KEY = os.environ.get("STORJ_ACCESS_KEY")
+STORJ_SECRET_KEY = os.environ.get("STORJ_SECRET_KEY")
+STORJ_END_POINT = os.environ.get("STORJ_END_POINT")
+STORJ_BUCKET_NAME = os.environ.get("STORJ_BUCKET_NAME")
+CDN_DOMAIN = os.environ.get("CDN_DOMAIN")
async def upload_handler(request):
form = await request.form()
- file = form.get('file')
+ file = form.get("file")
if file is None:
- return JSONResponse({'error': 'No file uploaded'}, status_code=400)
+ return JSONResponse({"error": "No file uploaded"}, status_code=400)
file_name, file_extension = os.path.splitext(file.filename)
- key = str(uuid.uuid4()) + file_extension
+ key = "files/" + str(uuid.uuid4()) + file_extension
# Create an S3 client with Storj configuration
- s3 = boto3.client('s3',
- aws_access_key_id=STORJ_ACCESS_KEY,
- aws_secret_access_key=STORJ_SECRET_KEY,
- endpoint_url=STORJ_END_POINT)
+ s3 = boto3.client(
+ "s3",
+ aws_access_key_id=STORJ_ACCESS_KEY,
+ aws_secret_access_key=STORJ_SECRET_KEY,
+ endpoint_url=STORJ_END_POINT,
+ )
try:
# Save the uploaded file to a temporary file
@@ -39,18 +42,13 @@ async def upload_handler(request):
Filename=tmp_file.name,
Bucket=STORJ_BUCKET_NAME,
Key=key,
- ExtraArgs={
- "ContentType": file.content_type
- }
+ ExtraArgs={"ContentType": file.content_type},
)
- url = 'http://' + CDN_DOMAIN + '/' + key
+ url = "https://" + CDN_DOMAIN + "/" + key
- return JSONResponse({'url': url, 'originalFilename': file.filename})
+ return JSONResponse({"url": url, "originalFilename": file.filename})
except (BotoCoreError, ClientError) as e:
print(e)
- return JSONResponse({'error': 'Failed to upload file'}, status_code=500)
-
-
-
+ return JSONResponse({"error": "Failed to upload file"}, status_code=500)
diff --git a/resolvers/zine/following.py b/resolvers/zine/following.py
index b2e039f1..fc3656f8 100644
--- a/resolvers/zine/following.py
+++ b/resolvers/zine/following.py
@@ -1,41 +1,36 @@
-import asyncio
-from base.orm import local_session
-from base.resolvers import mutation, subscription
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
+from base.resolvers import mutation
+
# from resolvers.community import community_follow, community_unfollow
-from orm.user import AuthorFollower
-from orm.topic import TopicFollower
-from orm.shout import ShoutReactionsFollower
from resolvers.zine.profile import author_follow, author_unfollow
from resolvers.zine.reactions import reactions_follow, reactions_unfollow
from resolvers.zine.topics import topic_follow, topic_unfollow
-from services.following import Following, FollowingManager, FollowingResult
-from graphql.type import GraphQLResolveInfo
+from services.following import FollowingManager, FollowingResult
@mutation.field("follow")
@login_required
-async def follow(_, info, what, slug):
+async def follow(_, info, what, slug): # noqa: C901
auth: AuthCredentials = info.context["request"].auth
try:
if what == "AUTHOR":
if author_follow(auth.user_id, slug):
- result = FollowingResult("NEW", 'author', slug)
- await FollowingManager.push('author', result)
+ result = FollowingResult("NEW", "author", slug)
+ await FollowingManager.push("author", result)
elif what == "TOPIC":
if topic_follow(auth.user_id, slug):
- result = FollowingResult("NEW", 'topic', slug)
- await FollowingManager.push('topic', result)
+ result = FollowingResult("NEW", "topic", slug)
+ await FollowingManager.push("topic", result)
elif what == "COMMUNITY":
if False: # TODO: use community_follow(auth.user_id, slug):
- result = FollowingResult("NEW", 'community', slug)
- await FollowingManager.push('community', result)
+ result = FollowingResult("NEW", "community", slug)
+ await FollowingManager.push("community", result)
elif what == "REACTIONS":
if reactions_follow(auth.user_id, slug):
- result = FollowingResult("NEW", 'shout', slug)
- await FollowingManager.push('shout', result)
+ result = FollowingResult("NEW", "shout", slug)
+ await FollowingManager.push("shout", result)
except Exception as e:
print(Exception(e))
return {"error": str(e)}
@@ -45,103 +40,27 @@ async def follow(_, info, what, slug):
@mutation.field("unfollow")
@login_required
-async def unfollow(_, info, what, slug):
+async def unfollow(_, info, what, slug): # noqa: C901
auth: AuthCredentials = info.context["request"].auth
try:
if what == "AUTHOR":
if author_unfollow(auth.user_id, slug):
- result = FollowingResult("DELETED", 'author', slug)
- await FollowingManager.push('author', result)
+ result = FollowingResult("DELETED", "author", slug)
+ await FollowingManager.push("author", result)
elif what == "TOPIC":
if topic_unfollow(auth.user_id, slug):
- result = FollowingResult("DELETED", 'topic', slug)
- await FollowingManager.push('topic', result)
+ result = FollowingResult("DELETED", "topic", slug)
+ await FollowingManager.push("topic", result)
elif what == "COMMUNITY":
if False: # TODO: use community_unfollow(auth.user_id, slug):
- result = FollowingResult("DELETED", 'community', slug)
- await FollowingManager.push('community', result)
+ result = FollowingResult("DELETED", "community", slug)
+ await FollowingManager.push("community", result)
elif what == "REACTIONS":
if reactions_unfollow(auth.user_id, slug):
- result = FollowingResult("DELETED", 'shout', slug)
- await FollowingManager.push('shout', result)
+ result = FollowingResult("DELETED", "shout", slug)
+ await FollowingManager.push("shout", result)
except Exception as e:
return {"error": str(e)}
return {}
-
-
-# by author and by topic
-@subscription.source("newShout")
-@login_required
-async def shout_generator(_, info: GraphQLResolveInfo):
- print(f"[resolvers.zine] shouts generator {info}")
- auth: AuthCredentials = info.context["request"].auth
- user_id = auth.user_id
- try:
- tasks = []
-
- with local_session() as session:
-
- # notify new shout by followed authors
- following_topics = session.query(TopicFollower).where(TopicFollower.follower == user_id).all()
-
- for topic_id in following_topics:
- following_topic = Following('topic', topic_id)
- await FollowingManager.register('topic', following_topic)
- following_topic_task = following_topic.queue.get()
- tasks.append(following_topic_task)
-
- # by followed topics
- following_authors = session.query(AuthorFollower).where(
- AuthorFollower.follower == user_id).all()
-
- for author_id in following_authors:
- following_author = Following('author', author_id)
- await FollowingManager.register('author', following_author)
- following_author_task = following_author.queue.get()
- tasks.append(following_author_task)
-
- # TODO: use communities
- # by followed communities
- # following_communities = session.query(CommunityFollower).where(
- # CommunityFollower.follower == user_id).all()
-
- # for community_id in following_communities:
- # following_community = Following('community', author_id)
- # await FollowingManager.register('community', following_community)
- # following_community_task = following_community.queue.get()
- # tasks.append(following_community_task)
-
- while True:
- shout = await asyncio.gather(*tasks)
- yield shout
- finally:
- pass
-
-
-@subscription.source("newReaction")
-@login_required
-async def reaction_generator(_, info):
- print(f"[resolvers.zine] reactions generator {info}")
- auth: AuthCredentials = info.context["request"].auth
- user_id = auth.user_id
- try:
- with local_session() as session:
- followings = session.query(ShoutReactionsFollower.shout).where(
- ShoutReactionsFollower.follower == user_id).unique()
-
- # notify new reaction
-
- tasks = []
- for shout_id in followings:
- following_shout = Following('shout', shout_id)
- await FollowingManager.register('shout', following_shout)
- following_author_task = following_shout.queue.get()
- tasks.append(following_author_task)
-
- while True:
- reaction = await asyncio.gather(*tasks)
- yield reaction
- finally:
- pass
diff --git a/resolvers/zine/load.py b/resolvers/zine/load.py
index 3f91b92d..4db893a9 100644
--- a/resolvers/zine/load.py
+++ b/resolvers/zine/load.py
@@ -1,11 +1,12 @@
+import json
from datetime import datetime, timedelta, timezone
-from sqlalchemy.orm import joinedload, aliased
-from sqlalchemy.sql.expression import desc, asc, select, func, case, and_, text, nulls_last
+from sqlalchemy.orm import aliased, joinedload
+from sqlalchemy.sql.expression import and_, asc, case, desc, func, nulls_last, select
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
-from base.exceptions import ObjectNotExist, OperationNotAllowed
+from base.exceptions import ObjectNotExist
from base.orm import local_session
from base.resolvers import query
from orm import TopicFollower
@@ -18,37 +19,37 @@ def add_stat_columns(q):
aliased_reaction = aliased(Reaction)
q = q.outerjoin(aliased_reaction).add_columns(
- func.sum(
- aliased_reaction.id
- ).label('reacted_stat'),
+ func.sum(aliased_reaction.id).label("reacted_stat"),
+ func.sum(case((aliased_reaction.kind == ReactionKind.COMMENT, 1), else_=0)).label(
+ "commented_stat"
+ ),
func.sum(
case(
- (aliased_reaction.kind == ReactionKind.COMMENT, 1),
- else_=0
+ # do not count comments' reactions
+ (aliased_reaction.replyTo.is_not(None), 0),
+ (aliased_reaction.kind == ReactionKind.AGREE, 1),
+ (aliased_reaction.kind == ReactionKind.DISAGREE, -1),
+ (aliased_reaction.kind == ReactionKind.PROOF, 1),
+ (aliased_reaction.kind == ReactionKind.DISPROOF, -1),
+ (aliased_reaction.kind == ReactionKind.ACCEPT, 1),
+ (aliased_reaction.kind == ReactionKind.REJECT, -1),
+ (aliased_reaction.kind == ReactionKind.LIKE, 1),
+ (aliased_reaction.kind == ReactionKind.DISLIKE, -1),
+ else_=0,
)
- ).label('commented_stat'),
- func.sum(case(
- # do not count comments' reactions
- (aliased_reaction.replyTo.is_not(None), 0),
- (aliased_reaction.kind == ReactionKind.AGREE, 1),
- (aliased_reaction.kind == ReactionKind.DISAGREE, -1),
- (aliased_reaction.kind == ReactionKind.PROOF, 1),
- (aliased_reaction.kind == ReactionKind.DISPROOF, -1),
- (aliased_reaction.kind == ReactionKind.ACCEPT, 1),
- (aliased_reaction.kind == ReactionKind.REJECT, -1),
- (aliased_reaction.kind == ReactionKind.LIKE, 1),
- (aliased_reaction.kind == ReactionKind.DISLIKE, -1),
- else_=0)
- ).label('rating_stat'),
- func.max(case(
- (aliased_reaction.kind != ReactionKind.COMMENT, None),
- else_=aliased_reaction.createdAt
- )).label('last_comment'))
+ ).label("rating_stat"),
+ func.max(
+ case(
+ (aliased_reaction.kind != ReactionKind.COMMENT, None),
+ else_=aliased_reaction.createdAt,
+ )
+ ).label("last_comment"),
+ )
return q
-def apply_filters(q, filters, user_id=None):
+def apply_filters(q, filters, user_id=None): # noqa: C901
if filters.get("reacted") and user_id:
q.join(Reaction, Reaction.createdBy == user_id)
@@ -60,7 +61,7 @@ def apply_filters(q, filters, user_id=None):
if filters.get("layout"):
q = q.filter(Shout.layout == filters.get("layout"))
- if filters.get('excludeLayout'):
+ if filters.get("excludeLayout"):
q = q.filter(Shout.layout != filters.get("excludeLayout"))
if filters.get("author"):
q = q.filter(Shout.authors.any(slug=filters.get("author")))
@@ -79,6 +80,14 @@ def apply_filters(q, filters, user_id=None):
@query.field("loadShout")
async def load_shout(_, info, slug=None, shout_id=None):
+ # for testing, soon will be removed
+ if slug == "testtesttest":
+ with open("test/test.json") as json_file:
+ test_shout = json.load(json_file)["data"]["loadShout"]
+ test_shout["createdAt"] = datetime.fromisoformat(test_shout["createdAt"])
+ test_shout["publishedAt"] = datetime.fromisoformat(test_shout["publishedAt"])
+ return test_shout
+
with local_session() as session:
q = select(Shout).options(
joinedload(Shout.authors),
@@ -87,27 +96,23 @@ async def load_shout(_, info, slug=None, shout_id=None):
q = add_stat_columns(q)
if slug is not None:
- q = q.filter(
- Shout.slug == slug
- )
+ q = q.filter(Shout.slug == slug)
if shout_id is not None:
- q = q.filter(
- Shout.id == shout_id
- )
+ q = q.filter(Shout.id == shout_id)
- q = q.filter(
- Shout.deletedAt.is_(None)
- ).group_by(Shout.id)
+ q = q.filter(Shout.deletedAt.is_(None)).group_by(Shout.id)
try:
- [shout, reacted_stat, commented_stat, rating_stat, last_comment] = session.execute(q).first()
+ [shout, reacted_stat, commented_stat, rating_stat, last_comment] = session.execute(
+ q
+ ).first()
shout.stat = {
"viewed": shout.views,
"reacted": reacted_stat,
"commented": commented_stat,
- "rating": rating_stat
+ "rating": rating_stat,
}
for author_caption in session.query(ShoutAuthor).join(Shout).where(Shout.slug == slug):
@@ -124,7 +129,7 @@ async def load_shouts_by(_, info, options):
"""
:param options: {
filters: {
- layout: 'audio',
+ layout: 'music',
excludeLayout: 'article',
visibility: "public",
author: 'discours',
@@ -142,14 +147,13 @@ async def load_shouts_by(_, info, options):
:return: Shout[]
"""
- q = select(Shout).options(
- joinedload(Shout.authors),
- joinedload(Shout.topics),
- ).where(
- and_(
- Shout.deletedAt.is_(None),
- Shout.layout.is_not(None)
+ q = (
+ select(Shout)
+ .options(
+ joinedload(Shout.authors),
+ joinedload(Shout.topics),
)
+ .where(and_(Shout.deletedAt.is_(None), Shout.layout.is_not(None)))
)
q = add_stat_columns(q)
@@ -159,7 +163,7 @@ async def load_shouts_by(_, info, options):
order_by = options.get("order_by", Shout.publishedAt)
- 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)
limit = options.get("limit", 10)
@@ -169,13 +173,15 @@ async def load_shouts_by(_, info, options):
with local_session() as session:
shouts_map = {}
- for [shout, reacted_stat, commented_stat, rating_stat, last_comment] in session.execute(q).unique():
+ for [shout, reacted_stat, commented_stat, rating_stat, last_comment] in session.execute(
+ q
+ ).unique():
shouts.append(shout)
shout.stat = {
"viewed": shout.views,
"reacted": reacted_stat,
"commented": commented_stat,
- "rating": rating_stat
+ "rating": rating_stat,
}
shouts_map[shout.id] = shout
@@ -183,15 +189,18 @@ async def load_shouts_by(_, info, options):
@query.field("loadDrafts")
+@login_required
async def get_drafts(_, info):
auth: AuthCredentials = info.context["request"].auth
user_id = auth.user_id
- q = select(Shout).options(
- joinedload(Shout.authors),
- joinedload(Shout.topics),
- ).where(
- and_(Shout.deletedAt.is_(None), Shout.createdBy == user_id)
+ q = (
+ select(Shout)
+ .options(
+ joinedload(Shout.authors),
+ joinedload(Shout.topics),
+ )
+ .where(and_(Shout.deletedAt.is_(None), Shout.createdBy == user_id))
)
q = q.group_by(Shout.id)
@@ -210,24 +219,27 @@ async def get_my_feed(_, info, options):
auth: AuthCredentials = info.context["request"].auth
user_id = auth.user_id
- subquery = select(Shout.id).join(
- ShoutAuthor
- ).join(
- AuthorFollower, AuthorFollower.follower == user_id
- ).join(
- ShoutTopic
- ).join(
- TopicFollower, TopicFollower.follower == user_id
+ user_followed_authors = select(AuthorFollower.author).where(AuthorFollower.follower == user_id)
+ user_followed_topics = select(TopicFollower.topic).where(TopicFollower.follower == user_id)
+
+ subquery = (
+ select(Shout.id)
+ .where(Shout.id == ShoutAuthor.shout)
+ .where(Shout.id == ShoutTopic.shout)
+ .where(
+ (ShoutAuthor.user.in_(user_followed_authors))
+ | (ShoutTopic.topic.in_(user_followed_topics))
+ )
)
- q = select(Shout).options(
- joinedload(Shout.authors),
- joinedload(Shout.topics),
- ).where(
- and_(
- Shout.publishedAt.is_not(None),
- Shout.deletedAt.is_(None),
- Shout.id.in_(subquery)
+ q = (
+ select(Shout)
+ .options(
+ joinedload(Shout.authors),
+ joinedload(Shout.topics),
+ )
+ .where(
+ and_(Shout.publishedAt.is_not(None), Shout.deletedAt.is_(None), Shout.id.in_(subquery))
)
)
@@ -236,23 +248,25 @@ async def get_my_feed(_, info, options):
order_by = options.get("order_by", Shout.publishedAt)
- 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)
limit = options.get("limit", 10)
q = q.group_by(Shout.id).order_by(nulls_last(query_order_by)).limit(limit).offset(offset)
+ # print(q.compile(compile_kwargs={"literal_binds": True}))
+
shouts = []
with local_session() as session:
- shouts_map = {}
- for [shout, reacted_stat, commented_stat, rating_stat, last_comment] in session.execute(q).unique():
+ for [shout, reacted_stat, commented_stat, rating_stat, last_comment] in session.execute(
+ q
+ ).unique():
shouts.append(shout)
shout.stat = {
"viewed": shout.views,
"reacted": reacted_stat,
"commented": commented_stat,
- "rating": rating_stat
+ "rating": rating_stat,
}
- shouts_map[shout.id] = shout
return shouts
diff --git a/resolvers/zine/profile.py b/resolvers/zine/profile.py
index 98041b2c..5edb1c4b 100644
--- a/resolvers/zine/profile.py
+++ b/resolvers/zine/profile.py
@@ -1,19 +1,17 @@
-from typing import List
from datetime import datetime, timedelta, timezone
-from sqlalchemy import and_, func, distinct, select, literal
+from typing import List
+
+from sqlalchemy import and_, distinct, func, literal, select
from sqlalchemy.orm import aliased, joinedload
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from base.orm import local_session
from base.resolvers import mutation, query
-from orm.reaction import Reaction
+from orm.reaction import Reaction, ReactionKind
from orm.shout import ShoutAuthor, ShoutTopic
-from orm.topic import Topic
+from orm.topic import Topic, TopicFollower
from orm.user import AuthorFollower, Role, User, UserRating, UserRole
-
-# from .community import followed_communities
-from resolvers.inbox.unread import get_total_unread_counter
from resolvers.zine.topics import followed_by_user
@@ -24,27 +22,27 @@ def add_author_stat_columns(q):
# user_rating_aliased = aliased(UserRating)
q = q.outerjoin(shout_author_aliased).add_columns(
- func.count(distinct(shout_author_aliased.shout)).label('shouts_stat')
+ func.count(distinct(shout_author_aliased.shout)).label("shouts_stat")
)
q = q.outerjoin(author_followers, author_followers.author == User.id).add_columns(
- func.count(distinct(author_followers.follower)).label('followers_stat')
+ func.count(distinct(author_followers.follower)).label("followers_stat")
)
q = q.outerjoin(author_following, author_following.follower == User.id).add_columns(
- func.count(distinct(author_following.author)).label('followings_stat')
+ func.count(distinct(author_following.author)).label("followings_stat")
)
- q = q.add_columns(literal(0).label('rating_stat'))
+ q = q.add_columns(literal(0).label("rating_stat"))
# FIXME
# q = q.outerjoin(user_rating_aliased, user_rating_aliased.user == User.id).add_columns(
# # TODO: check
# func.sum(user_rating_aliased.value).label('rating_stat')
# )
- q = q.add_columns(literal(0).label('commented_stat'))
- # q = q.outerjoin(Reaction, and_(Reaction.createdBy == User.id, Reaction.body.is_not(None))).add_columns(
- # func.count(distinct(Reaction.id)).label('commented_stat')
- # )
+ q = q.add_columns(literal(0).label("commented_stat"))
+ # q = q.outerjoin(
+ # Reaction, and_(Reaction.createdBy == User.id, Reaction.body.is_not(None))
+ # ).add_columns(func.count(distinct(Reaction.id)).label("commented_stat"))
q = q.group_by(User.id)
@@ -58,7 +56,7 @@ def add_stat(author, stat_columns):
"followers": followers_stat,
"followings": followings_stat,
"rating": rating_stat,
- "commented": commented_stat
+ "commented": commented_stat,
}
return author
@@ -74,33 +72,6 @@ def get_authors_from_query(q):
return authors
-async def user_subscriptions(user_id: int):
- return {
- "unread": await get_total_unread_counter(user_id), # unread inbox messages counter
- "topics": [t.slug for t in await followed_topics(user_id)], # followed topics slugs
- "authors": [a.slug for a in await followed_authors(user_id)], # followed authors slugs
- "reactions": await followed_reactions(user_id)
- # "communities": [c.slug for c in followed_communities(slug)], # communities
- }
-
-
-# @query.field("userFollowedDiscussions")
-# @login_required
-async def followed_discussions(_, info, user_id) -> List[Topic]:
- return await followed_reactions(user_id)
-
-
-async def followed_reactions(user_id):
- with local_session() as session:
- user = session.query(User).where(User.id == user_id).first()
- return session.query(
- Reaction.shout
- ).where(
- Reaction.createdBy == user.id
- ).filter(
- Reaction.createdAt > user.lastSeen
- ).all()
-
# dufok mod (^*^') :
@query.field("userFollowedTopics")
async def get_followed_topics(_, info, slug) -> List[Topic]:
@@ -117,6 +88,7 @@ async def get_followed_topics(_, info, slug) -> List[Topic]:
async def followed_topics(user_id):
return followed_by_user(user_id)
+
# dufok mod (^*^') :
@query.field("userFollowedAuthors")
async def get_followed_authors(_, _info, slug) -> List[User]:
@@ -130,6 +102,7 @@ async def get_followed_authors(_, _info, slug) -> List[User]:
return await followed_authors(user_id)
+
# 2. Now, we can use the user_id to get the followed authors
async def followed_authors(user_id):
q = select(User)
@@ -147,10 +120,10 @@ async def user_followers(_, _info, slug) -> List[User]:
q = add_author_stat_columns(q)
aliased_user = aliased(User)
- q = q.join(AuthorFollower, AuthorFollower.follower == User.id).join(
- aliased_user, aliased_user.id == AuthorFollower.author
- ).where(
- aliased_user.slug == slug
+ q = (
+ q.join(AuthorFollower, AuthorFollower.follower == User.id)
+ .join(aliased_user, aliased_user.id == AuthorFollower.author)
+ .where(aliased_user.slug == slug)
)
return get_authors_from_query(q)
@@ -178,15 +151,10 @@ async def update_profile(_, info, profile):
with local_session() as session:
user = session.query(User).filter(User.id == user_id).one()
if not user:
- return {
- "error": "canoot find user"
- }
+ return {"error": "canoot find user"}
user.update(profile)
session.commit()
- return {
- "error": None,
- "author": user
- }
+ return {"error": None, "author": user}
@mutation.field("rateUser")
@@ -220,7 +188,8 @@ def author_follow(user_id, slug):
session.add(af)
session.commit()
return True
- except:
+ except Exception as e:
+ print(e)
return False
@@ -228,13 +197,10 @@ def author_follow(user_id, slug):
def author_unfollow(user_id, slug):
with local_session() as session:
flw = (
- session.query(
- AuthorFollower
- ).join(User, User.id == AuthorFollower.author).filter(
- and_(
- AuthorFollower.follower == user_id, User.slug == slug
- )
- ).first()
+ session.query(AuthorFollower)
+ .join(User, User.id == AuthorFollower.author)
+ .filter(and_(AuthorFollower.follower == user_id, User.slug == slug))
+ .first()
)
if flw:
session.delete(flw)
@@ -257,8 +223,17 @@ async def get_author(_, _info, slug):
q = select(User).where(User.slug == slug)
q = add_author_stat_columns(q)
- authors = get_authors_from_query(q)
- return authors[0]
+ [author] = get_authors_from_query(q)
+
+ with local_session() as session:
+ comments_count = (
+ session.query(Reaction)
+ .where(and_(Reaction.createdBy == author.id, Reaction.kind == ReactionKind.COMMENT))
+ .count()
+ )
+ author.stat["commented"] = comments_count
+
+ return author
@query.field("loadAuthorsBy")
@@ -278,8 +253,33 @@ async def load_authors_by(_, info, by, limit, offset):
days_before = datetime.now(tz=timezone.utc) - timedelta(days=by["createdAt"])
q = q.filter(User.createdAt > days_before)
- q = q.order_by(
- by.get("order", User.createdAt)
- ).limit(limit).offset(offset)
+ q = q.order_by(by.get("order", User.createdAt)).limit(limit).offset(offset)
return get_authors_from_query(q)
+
+
+@query.field("loadMySubscriptions")
+@login_required
+async def load_my_subscriptions(_, info):
+ auth = info.context["request"].auth
+ user_id = auth.user_id
+
+ authors_query = (
+ select(User)
+ .join(AuthorFollower, AuthorFollower.author == User.id)
+ .where(AuthorFollower.follower == user_id)
+ )
+
+ topics_query = select(Topic).join(TopicFollower).where(TopicFollower.follower == user_id)
+
+ topics = []
+ authors = []
+
+ with local_session() as session:
+ for [author] in session.execute(authors_query):
+ authors.append(author)
+
+ for [topic] in session.execute(topics_query):
+ topics.append(topic)
+
+ return {"topics": topics, "authors": authors}
diff --git a/resolvers/zine/reactions.py b/resolvers/zine/reactions.py
index 9ee2f098..46059c4e 100644
--- a/resolvers/zine/reactions.py
+++ b/resolvers/zine/reactions.py
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
-from sqlalchemy import and_, asc, desc, select, text, func, case
+
+from sqlalchemy import and_, asc, case, desc, func, select, text
from sqlalchemy.orm import aliased
from auth.authenticate import login_required
@@ -10,32 +11,29 @@ from base.resolvers import mutation, query
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutReactionsFollower
from orm.user import User
+from services.notifications.notification_service import notification_service
def add_reaction_stat_columns(q):
aliased_reaction = aliased(Reaction)
q = q.outerjoin(aliased_reaction, Reaction.id == aliased_reaction.replyTo).add_columns(
- func.sum(
- aliased_reaction.id
- ).label('reacted_stat'),
+ func.sum(aliased_reaction.id).label("reacted_stat"),
+ func.sum(case((aliased_reaction.body.is_not(None), 1), else_=0)).label("commented_stat"),
func.sum(
case(
- (aliased_reaction.body.is_not(None), 1),
- else_=0
+ (aliased_reaction.kind == ReactionKind.AGREE, 1),
+ (aliased_reaction.kind == ReactionKind.DISAGREE, -1),
+ (aliased_reaction.kind == ReactionKind.PROOF, 1),
+ (aliased_reaction.kind == ReactionKind.DISPROOF, -1),
+ (aliased_reaction.kind == ReactionKind.ACCEPT, 1),
+ (aliased_reaction.kind == ReactionKind.REJECT, -1),
+ (aliased_reaction.kind == ReactionKind.LIKE, 1),
+ (aliased_reaction.kind == ReactionKind.DISLIKE, -1),
+ else_=0,
)
- ).label('commented_stat'),
- func.sum(case(
- (aliased_reaction.kind == ReactionKind.AGREE, 1),
- (aliased_reaction.kind == ReactionKind.DISAGREE, -1),
- (aliased_reaction.kind == ReactionKind.PROOF, 1),
- (aliased_reaction.kind == ReactionKind.DISPROOF, -1),
- (aliased_reaction.kind == ReactionKind.ACCEPT, 1),
- (aliased_reaction.kind == ReactionKind.REJECT, -1),
- (aliased_reaction.kind == ReactionKind.LIKE, 1),
- (aliased_reaction.kind == ReactionKind.DISLIKE, -1),
- else_=0)
- ).label('rating_stat'))
+ ).label("rating_stat"),
+ )
return q
@@ -46,22 +44,25 @@ def reactions_follow(user_id, shout_id: int, auto=False):
shout = session.query(Shout).where(Shout.id == shout_id).one()
following = (
- session.query(ShoutReactionsFollower).where(and_(
- ShoutReactionsFollower.follower == user_id,
- ShoutReactionsFollower.shout == shout.id,
- )).first()
+ session.query(ShoutReactionsFollower)
+ .where(
+ and_(
+ ShoutReactionsFollower.follower == user_id,
+ ShoutReactionsFollower.shout == shout.id,
+ )
+ )
+ .first()
)
if not following:
following = ShoutReactionsFollower.create(
- follower=user_id,
- shout=shout.id,
- auto=auto
+ follower=user_id, shout=shout.id, auto=auto
)
session.add(following)
session.commit()
return True
- except:
+ except Exception as e:
+ print(e)
return False
@@ -71,46 +72,52 @@ def reactions_unfollow(user_id: int, shout_id: int):
shout = session.query(Shout).where(Shout.id == shout_id).one()
following = (
- session.query(ShoutReactionsFollower).where(and_(
- ShoutReactionsFollower.follower == user_id,
- ShoutReactionsFollower.shout == shout.id
- )).first()
+ session.query(ShoutReactionsFollower)
+ .where(
+ and_(
+ ShoutReactionsFollower.follower == user_id,
+ ShoutReactionsFollower.shout == shout.id,
+ )
+ )
+ .first()
)
if following:
session.delete(following)
session.commit()
return True
- except:
+ except Exception as e:
+ print(e)
pass
return False
def is_published_author(session, user_id):
- ''' checks if user has at least one publication '''
- return session.query(
- Shout
- ).where(
- Shout.authors.contains(user_id)
- ).filter(
- and_(
- Shout.publishedAt.is_not(None),
- Shout.deletedAt.is_(None)
- )
- ).count() > 0
+ """checks if user has at least one publication"""
+ return (
+ session.query(Shout)
+ .where(Shout.authors.contains(user_id))
+ .filter(and_(Shout.publishedAt.is_not(None), Shout.deletedAt.is_(None)))
+ .count()
+ > 0
+ )
def check_to_publish(session, user_id, reaction):
- ''' set shout to public if publicated approvers amount > 4 '''
+ """set shout to public if publicated approvers amount > 4"""
if not reaction.replyTo and reaction.kind in [
ReactionKind.ACCEPT,
ReactionKind.LIKE,
- ReactionKind.PROOF
+ ReactionKind.PROOF,
]:
if is_published_author(user_id):
# now count how many approvers are voted already
- approvers_reactions = session.query(Reaction).where(Reaction.shout == reaction.shout).all()
- approvers = [user_id, ]
+ approvers_reactions = (
+ session.query(Reaction).where(Reaction.shout == reaction.shout).all()
+ )
+ approvers = [
+ user_id,
+ ]
for ar in approvers_reactions:
a = ar.createdBy
if is_published_author(session, a):
@@ -121,21 +128,17 @@ def check_to_publish(session, user_id, reaction):
def check_to_hide(session, user_id, reaction):
- ''' hides any shout if 20% of reactions are negative '''
+ """hides any shout if 20% of reactions are negative"""
if not reaction.replyTo and reaction.kind in [
ReactionKind.REJECT,
ReactionKind.DISLIKE,
- ReactionKind.DISPROOF
+ ReactionKind.DISPROOF,
]:
# if is_published_author(user):
approvers_reactions = session.query(Reaction).where(Reaction.shout == reaction.shout).all()
rejects = 0
for r in approvers_reactions:
- if r.kind in [
- ReactionKind.REJECT,
- ReactionKind.DISLIKE,
- ReactionKind.DISPROOF
- ]:
+ if r.kind in [ReactionKind.REJECT, ReactionKind.DISLIKE, ReactionKind.DISPROOF]:
rejects += 1
if len(approvers_reactions) / rejects < 5:
return True
@@ -145,14 +148,14 @@ def check_to_hide(session, user_id, reaction):
def set_published(session, shout_id):
s = session.query(Shout).where(Shout.id == shout_id).first()
s.publishedAt = datetime.now(tz=timezone.utc)
- s.visibility = text('public')
+ s.visibility = text("public")
session.add(s)
session.commit()
def set_hidden(session, shout_id):
s = session.query(Shout).where(Shout.id == shout_id).first()
- s.visibility = text('community')
+ s.visibility = text("community")
session.add(s)
session.commit()
@@ -161,66 +164,78 @@ def set_hidden(session, shout_id):
@login_required
async def create_reaction(_, info, reaction):
auth: AuthCredentials = info.context["request"].auth
- reaction['createdBy'] = auth.user_id
+ reaction["createdBy"] = auth.user_id
rdict = {}
with local_session() as session:
shout = session.query(Shout).where(Shout.id == reaction["shout"]).one()
author = session.query(User).where(User.id == auth.user_id).one()
- if reaction["kind"] in [
- ReactionKind.DISLIKE.name,
- ReactionKind.LIKE.name
- ]:
- existing_reaction = session.query(Reaction).where(
- and_(
- Reaction.shout == reaction["shout"],
- Reaction.createdBy == auth.user_id,
- Reaction.kind == reaction["kind"],
- Reaction.replyTo == reaction.get("replyTo")
+ if reaction["kind"] in [ReactionKind.DISLIKE.name, ReactionKind.LIKE.name]:
+ existing_reaction = (
+ session.query(Reaction)
+ .where(
+ and_(
+ Reaction.shout == reaction["shout"],
+ Reaction.createdBy == auth.user_id,
+ Reaction.kind == reaction["kind"],
+ Reaction.replyTo == reaction.get("replyTo"),
+ )
)
- ).first()
+ .first()
+ )
if existing_reaction is not None:
raise OperationNotAllowed("You can't vote twice")
- opposite_reaction_kind = ReactionKind.DISLIKE if reaction["kind"] == ReactionKind.LIKE.name else ReactionKind.LIKE
- opposite_reaction = session.query(Reaction).where(
+ opposite_reaction_kind = (
+ ReactionKind.DISLIKE
+ if reaction["kind"] == ReactionKind.LIKE.name
+ else ReactionKind.LIKE
+ )
+ opposite_reaction = (
+ session.query(Reaction)
+ .where(
and_(
Reaction.shout == reaction["shout"],
Reaction.createdBy == auth.user_id,
Reaction.kind == opposite_reaction_kind,
- Reaction.replyTo == reaction.get("replyTo")
+ Reaction.replyTo == reaction.get("replyTo"),
)
- ).first()
+ )
+ .first()
+ )
if opposite_reaction is not None:
session.delete(opposite_reaction)
r = Reaction.create(**reaction)
- # Proposal accepting logix
- if r.replyTo is not None and \
- r.kind == ReactionKind.ACCEPT and \
- auth.user_id in shout.dict()['authors']:
- replied_reaction = session.query(Reaction).where(Reaction.id == r.replyTo).first()
- if replied_reaction and replied_reaction.kind == ReactionKind.PROPOSE:
- if replied_reaction.range:
- old_body = shout.body
- start, end = replied_reaction.range.split(':')
- start = int(start)
- end = int(end)
- new_body = old_body[:start] + replied_reaction.body + old_body[end:]
- shout.body = new_body
- # TODO: update git version control
+ # # Proposal accepting logix
+ # FIXME: will break if there will be 2 proposals, will break if shout will be changed
+ # if r.replyTo is not None and \
+ # r.kind == ReactionKind.ACCEPT and \
+ # auth.user_id in shout.dict()['authors']:
+ # replied_reaction = session.query(Reaction).where(Reaction.id == r.replyTo).first()
+ # if replied_reaction and replied_reaction.kind == ReactionKind.PROPOSE:
+ # if replied_reaction.range:
+ # old_body = shout.body
+ # start, end = replied_reaction.range.split(':')
+ # start = int(start)
+ # end = int(end)
+ # new_body = old_body[:start] + replied_reaction.body + old_body[end:]
+ # shout.body = new_body
+ # # TODO: update git version control
session.add(r)
session.commit()
+
+ await notification_service.handle_new_reaction(r.id)
+
rdict = r.dict()
- rdict['shout'] = shout.dict()
- rdict['createdBy'] = author.dict()
+ rdict["shout"] = shout.dict()
+ rdict["createdBy"] = author.dict()
# self-regulation mechanics
-
if check_to_hide(session, auth.user_id, r):
set_hidden(session, r.shout)
elif check_to_publish(session, auth.user_id, r):
@@ -231,11 +246,7 @@ async def create_reaction(_, info, reaction):
except Exception as e:
print(f"[resolvers.reactions] error on reactions autofollowing: {e}")
- rdict['stat'] = {
- "commented": 0,
- "reacted": 0,
- "rating": 0
- }
+ rdict["stat"] = {"commented": 0, "reacted": 0, "rating": 0}
return {"reaction": rdict}
@@ -265,11 +276,7 @@ async def update_reaction(_, info, id, reaction={}):
if reaction.get("range"):
r.range = reaction.get("range")
session.commit()
- r.stat = {
- "commented": commented_stat,
- "reacted": reacted_stat,
- "rating": rating_stat
- }
+ r.stat = {"commented": commented_stat, "reacted": reacted_stat, "rating": rating_stat}
return {"reaction": r}
@@ -286,17 +293,12 @@ async def delete_reaction(_, info, id):
if r.createdBy != auth.user_id:
return {"error": "access denied"}
- if r.kind in [
- ReactionKind.LIKE,
- ReactionKind.DISLIKE
- ]:
+ if r.kind in [ReactionKind.LIKE, ReactionKind.DISLIKE]:
session.delete(r)
else:
r.deletedAt = datetime.now(tz=timezone.utc)
session.commit()
- return {
- "reaction": r
- }
+ return {"reaction": r}
@query.field("loadReactionsBy")
@@ -317,12 +319,10 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
:return: Reaction[]
"""
- q = select(
- Reaction, User, Shout
- ).join(
- User, Reaction.createdBy == User.id
- ).join(
- Shout, Reaction.shout == Shout.id
+ q = (
+ select(Reaction, User, Shout)
+ .join(User, Reaction.createdBy == User.id)
+ .join(Shout, Reaction.shout == Shout.id)
)
if by.get("shout"):
@@ -340,7 +340,7 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
if by.get("comment"):
q = q.filter(func.length(Reaction.body) > 0)
- if len(by.get('search', '')) > 2:
+ if len(by.get("search", "")) > 2:
q = q.filter(Reaction.body.ilike(f'%{by["body"]}%'))
if by.get("days"):
@@ -348,13 +348,9 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
q = q.filter(Reaction.createdAt > after)
order_way = asc if by.get("sort", "").startswith("-") else desc
- order_field = by.get("sort", "").replace('-', '') or Reaction.createdAt
+ order_field = by.get("sort", "").replace("-", "") or Reaction.createdAt
- q = q.group_by(
- Reaction.id, User.id, Shout.id
- ).order_by(
- order_way(order_field)
- )
+ q = q.group_by(Reaction.id, User.id, Shout.id).order_by(order_way(order_field))
q = add_reaction_stat_columns(q)
@@ -363,13 +359,15 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
reactions = []
with local_session() as session:
- for [reaction, user, shout, reacted_stat, commented_stat, rating_stat] in session.execute(q):
+ for [reaction, user, shout, reacted_stat, commented_stat, rating_stat] in session.execute(
+ q
+ ):
reaction.createdBy = user
reaction.shout = shout
reaction.stat = {
"rating": rating_stat,
"commented": commented_stat,
- "reacted": reacted_stat
+ "reacted": reacted_stat,
}
reaction.kind = reaction.kind.name
diff --git a/resolvers/zine/topics.py b/resolvers/zine/topics.py
index f354a7b4..ad4f59fc 100644
--- a/resolvers/zine/topics.py
+++ b/resolvers/zine/topics.py
@@ -1,24 +1,25 @@
-from sqlalchemy import and_, select, distinct, func
+from sqlalchemy import and_, distinct, func, select
from sqlalchemy.orm import aliased
from auth.authenticate import login_required
from base.orm import local_session
from base.resolvers import mutation, query
-from orm.shout import ShoutTopic, ShoutAuthor
-from orm.topic import Topic, TopicFollower
from orm import User
+from orm.shout import ShoutAuthor, ShoutTopic
+from orm.topic import Topic, TopicFollower
def add_topic_stat_columns(q):
aliased_shout_author = aliased(ShoutAuthor)
aliased_topic_follower = aliased(TopicFollower)
- q = q.outerjoin(ShoutTopic, Topic.id == ShoutTopic.topic).add_columns(
- func.count(distinct(ShoutTopic.shout)).label('shouts_stat')
- ).outerjoin(aliased_shout_author, ShoutTopic.shout == aliased_shout_author.shout).add_columns(
- func.count(distinct(aliased_shout_author.user)).label('authors_stat')
- ).outerjoin(aliased_topic_follower).add_columns(
- func.count(distinct(aliased_topic_follower.follower)).label('followers_stat')
+ q = (
+ q.outerjoin(ShoutTopic, Topic.id == ShoutTopic.topic)
+ .add_columns(func.count(distinct(ShoutTopic.shout)).label("shouts_stat"))
+ .outerjoin(aliased_shout_author, ShoutTopic.shout == aliased_shout_author.shout)
+ .add_columns(func.count(distinct(aliased_shout_author.user)).label("authors_stat"))
+ .outerjoin(aliased_topic_follower)
+ .add_columns(func.count(distinct(aliased_topic_follower.follower)).label("followers_stat"))
)
q = q.group_by(Topic.id)
@@ -28,11 +29,7 @@ def add_topic_stat_columns(q):
def add_stat(topic, stat_columns):
[shouts_stat, authors_stat, followers_stat] = stat_columns
- topic.stat = {
- "shouts": shouts_stat,
- "authors": authors_stat,
- "followers": followers_stat
- }
+ topic.stat = {"shouts": shouts_stat, "authors": authors_stat, "followers": followers_stat}
return topic
@@ -125,7 +122,8 @@ def topic_follow(user_id, slug):
session.add(following)
session.commit()
return True
- except:
+ except Exception as e:
+ print(e)
return False
@@ -133,18 +131,17 @@ def topic_unfollow(user_id, slug):
try:
with local_session() as session:
sub = (
- session.query(TopicFollower).join(Topic).filter(
- and_(
- TopicFollower.follower == user_id,
- Topic.slug == slug
- )
- ).first()
+ session.query(TopicFollower)
+ .join(Topic)
+ .filter(and_(TopicFollower.follower == user_id, Topic.slug == slug))
+ .first()
)
if sub:
session.delete(sub)
session.commit()
return True
- except:
+ except Exception as e:
+ print(e)
pass
return False
diff --git a/schema.graphql b/schema.graphql
index fbb837d0..79b26c0b 100644
--- a/schema.graphql
+++ b/schema.graphql
@@ -3,24 +3,15 @@ scalar DateTime
################################### Payload ###################################
enum MessageStatus {
- NEW
- UPDATED
- DELETED
-}
-
-type UserFollowings {
- unread: Int
- topics: [String]
- authors: [String]
- reactions: [Int]
- communities: [String]
+ NEW
+ UPDATED
+ DELETED
}
type AuthResult {
- error: String
- token: String
- user: User
- news: UserFollowings
+ error: String
+ token: String
+ user: User
}
type ChatMember {
@@ -60,84 +51,84 @@ type Author {
}
type Result {
- error: String
- slugs: [String]
- chat: Chat
- chats: [Chat]
- message: Message
- messages: [Message]
- members: [ChatMember]
- shout: Shout
- shouts: [Shout]
- author: Author
- authors: [Author]
- reaction: Reaction
- reactions: [Reaction]
- topic: Topic
- topics: [Topic]
- community: Community
- communities: [Community]
+ error: String
+ slugs: [String]
+ chat: Chat
+ chats: [Chat]
+ message: Message
+ messages: [Message]
+ members: [ChatMember]
+ shout: Shout
+ shouts: [Shout]
+ author: Author
+ authors: [Author]
+ reaction: Reaction
+ reactions: [Reaction]
+ topic: Topic
+ topics: [Topic]
+ community: Community
+ communities: [Community]
}
enum ReactionStatus {
- NEW
- UPDATED
- CHANGED
- EXPLAINED
- DELETED
+ NEW
+ UPDATED
+ CHANGED
+ EXPLAINED
+ DELETED
}
type ReactionUpdating {
- error: String
- status: ReactionStatus
- reaction: Reaction
+ error: String
+ status: ReactionStatus
+ reaction: Reaction
}
################################### Inputs ###################################
input ShoutInput {
- slug: String
- title: String
- body: String
- lead: String
- description: String
- layout: String
- media: String
- authors: [String]
- topics: [TopicInput]
- community: Int
- mainTopic: TopicInput
- subtitle: String
- cover: String
+ slug: String
+ title: String
+ body: String
+ lead: String
+ description: String
+ layout: String
+ media: String
+ authors: [String]
+ topics: [TopicInput]
+ community: Int
+ mainTopic: TopicInput
+ subtitle: String
+ cover: String
}
input ProfileInput {
- slug: String
- name: String
- userpic: String
- links: [String]
- bio: String
- about: String
+ slug: String
+ name: String
+ userpic: String
+ links: [String]
+ bio: String
+ about: String
}
input TopicInput {
- id: Int,
- slug: String!
- # community: String!
- title: String
- body: String
- pic: String
- # children: [String]
- # parents: [String]
+ id: Int,
+ slug: String!
+ # community: String!
+ title: String
+ body: String
+ pic: String
+ # children: [String]
+ # parents: [String]
}
input ReactionInput {
- kind: ReactionKind!
- shout: Int!
- range: String
- body: String
- replyTo: Int
+ kind: ReactionKind!
+ shout: Int!
+ range: String
+ body: String
+ replyTo: Int
}
input ChatInput {
@@ -147,55 +138,57 @@ input ChatInput {
}
enum FollowingEntity {
- TOPIC
- AUTHOR
- COMMUNITY
- REACTIONS
+ TOPIC
+ AUTHOR
+ COMMUNITY
+ REACTIONS
}
################################### Mutation
type Mutation {
- # inbox
- createChat(title: String, members: [Int]!): Result!
- updateChat(chat: ChatInput!): Result!
- deleteChat(chatId: String!): Result!
+ # inbox
+ createChat(title: String, members: [Int]!): Result!
+ updateChat(chat: ChatInput!): Result!
+ deleteChat(chatId: String!): Result!
- createMessage(chat: String!, body: String!, replyTo: Int): Result!
- updateMessage(chatId: String!, id: Int!, body: String!): Result!
- deleteMessage(chatId: String!, id: Int!): Result!
- markAsRead(chatId: String!, ids: [Int]!): Result!
+ createMessage(chat: String!, body: String!, replyTo: Int): Result!
+ updateMessage(chatId: String!, id: Int!, body: String!): Result!
+ deleteMessage(chatId: String!, id: Int!): Result!
+ markAsRead(chatId: String!, ids: [Int]!): Result!
- # auth
- getSession: AuthResult!
- registerUser(email: String!, password: String, name: String): AuthResult!
- sendLink(email: String!, lang: String, template: String): Result!
- confirmEmail(token: String!): AuthResult!
+ # auth
+ getSession: AuthResult!
+ registerUser(email: String!, password: String, name: String): AuthResult!
+ sendLink(email: String!, lang: String, template: String): Result!
+ confirmEmail(token: String!): AuthResult!
- # shout
- createShout(inp: ShoutInput!): Result!
- updateShout(shout_id: Int!, shout_input: ShoutInput, publish: Boolean): Result!
- deleteShout(shout_id: Int!): Result!
+ # shout
+ createShout(inp: ShoutInput!): Result!
+ updateShout(shout_id: Int!, shout_input: ShoutInput, publish: Boolean): Result!
+ deleteShout(shout_id: Int!): Result!
- # user profile
- rateUser(slug: String!, value: Int!): Result!
- updateOnlineStatus: Result!
- updateProfile(profile: ProfileInput!): Result!
+ # user profile
+ rateUser(slug: String!, value: Int!): Result!
+ updateProfile(profile: ProfileInput!): Result!
- # topics
- createTopic(input: TopicInput!): Result!
- # TODO: mergeTopics(t1: String!, t2: String!): Result!
- updateTopic(input: TopicInput!): Result!
- destroyTopic(slug: String!): Result!
+ # topics
+ createTopic(input: TopicInput!): Result!
+ # TODO: mergeTopics(t1: String!, t2: String!): Result!
+ updateTopic(input: TopicInput!): Result!
+ destroyTopic(slug: String!): Result!
- # reactions
- createReaction(reaction: ReactionInput!): Result!
- updateReaction(id: Int!, reaction: ReactionInput!): Result!
- deleteReaction(id: Int!): Result!
+ # reactions
+ createReaction(reaction: ReactionInput!): Result!
+ updateReaction(id: Int!, reaction: ReactionInput!): Result!
+ deleteReaction(id: Int!): Result!
- # following
- follow(what: FollowingEntity!, slug: String!): Result!
- unfollow(what: FollowingEntity!, slug: String!): Result!
+ # following
+ follow(what: FollowingEntity!, slug: String!): Result!
+ unfollow(what: FollowingEntity!, slug: String!): Result!
+
+ markNotificationAsRead(notification_id: Int!): Result!
+ markAllNotificationsAsRead: Result!
}
input MessagesBy {
@@ -219,24 +212,24 @@ input AuthorsBy {
}
input LoadShoutsFilters {
- title: String
- body: String
- topic: String
- author: String
- layout: String
- excludeLayout: String
- visibility: String
- days: Int
- reacted: Boolean
+ title: String
+ body: String
+ topic: String
+ author: String
+ layout: String
+ excludeLayout: String
+ visibility: String
+ days: Int
+ reacted: Boolean
}
input LoadShoutsOptions {
- filters: LoadShoutsFilters
- with_author_captions: Boolean
- limit: Int!
- offset: Int
- order_by: String
- order_by_desc: Boolean
+ filters: LoadShoutsFilters
+ with_author_captions: Boolean
+ limit: Int!
+ offset: Int
+ order_by: String
+ order_by_desc: Boolean
}
input ReactionBy {
@@ -249,254 +242,281 @@ input ReactionBy {
days: Int # before
sort: String # how to sort, default createdAt
}
-################################### Query
-type Query {
- # inbox
- loadChats( limit: Int, offset: Int): Result! # your chats
- loadMessagesBy(by: MessagesBy!, limit: Int, offset: Int): Result!
- loadRecipients(limit: Int, offset: Int): Result!
- searchRecipients(query: String!, limit: Int, offset: Int): Result!
- searchMessages(by: MessagesBy!, limit: Int, offset: Int): Result!
-
- # auth
- isEmailUsed(email: String!): Boolean!
- signIn(email: String!, password: String, lang: String): AuthResult!
- signOut: AuthResult!
-
- # zine
- loadAuthorsBy(by: AuthorsBy, limit: Int, offset: Int): [Author]!
- loadShout(slug: String, shout_id: Int): Shout
- loadShouts(options: LoadShoutsOptions): [Shout]!
- loadDrafts: [Shout]!
- loadReactionsBy(by: ReactionBy!, limit: Int, offset: Int): [Reaction]!
- userFollowers(slug: String!): [Author]!
- userFollowedAuthors(slug: String!): [Author]!
- userFollowedTopics(slug: String!): [Topic]!
- authorsAll: [Author]!
- getAuthor(slug: String!): Author
- myFeed(options: LoadShoutsOptions): [Shout]
-
- # migrate
- markdownBody(body: String!): String!
-
- # topics
- getTopic(slug: String!): Topic
- topicsAll: [Topic]!
- topicsRandom(amount: Int): [Topic]!
- topicsByCommunity(community: String!): [Topic]!
- topicsByAuthor(author: String!): [Topic]!
+input NotificationsQueryParams {
+ limit: Int
+ offset: Int
}
-############################################ Subscription
+type NotificationsQueryResult {
+ notifications: [Notification]!
+ totalCount: Int!
+ totalUnreadCount: Int!
+}
-type Subscription {
- newMessage: Message # new messages in inbox
- newShout: Shout # personal feed new shout
- newReaction: Reaction # new reactions to notify
+type MySubscriptionsQueryResult {
+ topics: [Topic]!
+ authors: [Author]!
+}
+
+type Query {
+ # inbox
+ loadChats( limit: Int, offset: Int): Result! # your chats
+ loadMessagesBy(by: MessagesBy!, limit: Int, offset: Int): Result!
+ loadRecipients(limit: Int, offset: Int): Result!
+ searchRecipients(query: String!, limit: Int, offset: Int): Result!
+ searchMessages(by: MessagesBy!, limit: Int, offset: Int): Result!
+
+ # auth
+ isEmailUsed(email: String!): Boolean!
+ signIn(email: String!, password: String, lang: String): AuthResult!
+ signOut: AuthResult!
+
+ # zine
+ loadAuthorsBy(by: AuthorsBy, limit: Int, offset: Int): [Author]!
+ loadShout(slug: String, shout_id: Int): Shout
+ loadShouts(options: LoadShoutsOptions): [Shout]!
+ loadDrafts: [Shout]!
+ loadReactionsBy(by: ReactionBy!, limit: Int, offset: Int): [Reaction]!
+ userFollowers(slug: String!): [Author]!
+ userFollowedAuthors(slug: String!): [Author]!
+ userFollowedTopics(slug: String!): [Topic]!
+ authorsAll: [Author]!
+ getAuthor(slug: String!): Author
+ myFeed(options: LoadShoutsOptions): [Shout]
+
+ # migrate
+ markdownBody(body: String!): String!
+
+ # topics
+ getTopic(slug: String!): Topic
+ topicsAll: [Topic]!
+ topicsRandom(amount: Int): [Topic]!
+ topicsByCommunity(community: String!): [Topic]!
+ topicsByAuthor(author: String!): [Topic]!
+
+ loadNotifications(params: NotificationsQueryParams!): NotificationsQueryResult!
+
+ loadMySubscriptions: MySubscriptionsQueryResult
}
############################################ Entities
type Resource {
- id: Int!
- name: String!
+ id: Int!
+ name: String!
}
type Operation {
- id: Int!
- name: String!
+ id: Int!
+ name: String!
}
type Permission {
- operation: Int!
- resource: Int!
+ operation: Int!
+ resource: Int!
}
type Role {
- id: Int!
- name: String!
- community: String!
- desc: String
- permissions: [Permission!]!
+ id: Int!
+ name: String!
+ community: String!
+ desc: String
+ permissions: [Permission!]!
}
type Rating {
- rater: String!
- value: Int!
+ rater: String!
+ value: Int!
}
type User {
- id: Int!
- username: String! # to login, ex. email, phone
- createdAt: DateTime!
- lastSeen: DateTime
- slug: String!
- name: String # to display
- email: String
- password: String
- oauth: String # provider:token
- userpic: String
- links: [String]
- emailConfirmed: Boolean # should contain all emails too
- muted: Boolean
- updatedAt: DateTime
- ratings: [Rating]
- bio: String
- about: String
- communities: [Int] # user participating communities
- oid: String
+ id: Int!
+ username: String! # to login, ex. email, phone
+ createdAt: DateTime!
+ lastSeen: DateTime
+ slug: String!
+ name: String # to display
+ email: String
+ password: String
+ oauth: String # provider:token
+ userpic: String
+ links: [String]
+ emailConfirmed: Boolean # should contain all emails too
+ muted: Boolean
+ updatedAt: DateTime
+ ratings: [Rating]
+ bio: String
+ about: String
+ communities: [Int] # user participating communities
+ oid: String
}
enum ReactionKind {
- LIKE
- DISLIKE
+ LIKE
+ DISLIKE
- AGREE
- DISAGREE
+ AGREE
+ DISAGREE
- PROOF
- DISPROOF
+ PROOF
+ DISPROOF
- COMMENT
- QUOTE
+ COMMENT
+ QUOTE
- PROPOSE
- ASK
+ PROPOSE
+ ASK
- REMARK
- FOOTNOTE
+ REMARK
+ FOOTNOTE
- ACCEPT
- REJECT
+ ACCEPT
+ REJECT
}
type Reaction {
- id: Int!
- shout: Shout!
- createdAt: DateTime!
- createdBy: User!
- updatedAt: DateTime
- deletedAt: DateTime
- deletedBy: User
- range: String # full / 0:2340
- kind: ReactionKind!
- body: String
- replyTo: Int
- stat: Stat
- old_id: String
- old_thread: String
+ id: Int!
+ shout: Shout!
+ createdAt: DateTime!
+ createdBy: User!
+ updatedAt: DateTime
+ deletedAt: DateTime
+ deletedBy: User
+ range: String # full / 0:2340
+ kind: ReactionKind!
+ body: String
+ replyTo: Int
+ stat: Stat
+ old_id: String
+ old_thread: String
}
# is publication
type Shout {
- id: Int!
- slug: String!
- body: String!
- lead: String
- description: String
- createdAt: DateTime!
- topics: [Topic]
- mainTopic: String
- title: String
- subtitle: String
- authors: [Author]
- lang: String
- community: String
- cover: String
- layout: String # audio video literature image
- versionOf: String # for translations and re-telling the same story
- visibility: String # owner authors community public
- updatedAt: DateTime
- updatedBy: User
- deletedAt: DateTime
- deletedBy: User
- publishedAt: DateTime
- media: String # json [ { title pic url body }, .. ]
- stat: Stat
+ id: Int!
+ slug: String!
+ body: String!
+ lead: String
+ description: String
+ createdAt: DateTime!
+ topics: [Topic]
+ mainTopic: String
+ title: String
+ subtitle: String
+ authors: [Author]
+ lang: String
+ community: String
+ cover: String
+ layout: String # music video literature image
+ versionOf: String # for translations and re-telling the same story
+ visibility: String # owner authors community public
+ updatedAt: DateTime
+ updatedBy: User
+ deletedAt: DateTime
+ deletedBy: User
+ publishedAt: DateTime
+ media: String # json [ { title pic url body }, .. ]
+ stat: Stat
}
type Stat {
- viewed: Int
- reacted: Int
- rating: Int
- commented: Int
- ranking: Int
+ viewed: Int
+ reacted: Int
+ rating: Int
+ commented: Int
+ ranking: Int
}
type Community {
- id: Int!
- slug: String!
- name: String!
- desc: String
- pic: String!
- createdAt: DateTime!
- createdBy: User!
+ id: Int!
+ slug: String!
+ name: String!
+ desc: String
+ pic: String!
+ createdAt: DateTime!
+ createdBy: User!
}
type Collection {
- id: Int!
- slug: String!
- title: String!
- desc: String
- amount: Int
- publishedAt: DateTime
- createdAt: DateTime!
- createdBy: User!
+ id: Int!
+ slug: String!
+ title: String!
+ desc: String
+ amount: Int
+ publishedAt: DateTime
+ createdAt: DateTime!
+ createdBy: User!
}
type TopicStat {
- shouts: Int!
- followers: Int!
- authors: Int!
- # viewed: Int
- # reacted: Int!
- # commented: Int
- # rating: Int
+ shouts: Int!
+ followers: Int!
+ authors: Int!
+ # viewed: Int
+ # reacted: Int!
+ # commented: Int
+ # rating: Int
}
type Topic {
- id: Int!
- slug: String!
- title: String
- body: String
- pic: String
- # community: Community!
- stat: TopicStat
- oid: String
+ id: Int!
+ slug: String!
+ title: String
+ body: String
+ pic: String
+ # community: Community!
+ stat: TopicStat
+ oid: String
}
type Token {
- createdAt: DateTime!
- expiresAt: DateTime
- id: Int!
- ownerId: Int!
- usedAt: DateTime
- value: String!
+ createdAt: DateTime!
+ expiresAt: DateTime
+ id: Int!
+ ownerId: Int!
+ usedAt: DateTime
+ value: String!
}
type Message {
- author: Int!
- chatId: String!
- body: String!
- createdAt: Int!
- id: Int!
- replyTo: Int
- updatedAt: Int
- seen: Boolean
+ author: Int!
+ chatId: String!
+ body: String!
+ createdAt: Int!
+ id: Int!
+ replyTo: Int
+ updatedAt: Int
+ seen: Boolean
}
type Chat {
- id: String!
- createdAt: Int!
- createdBy: Int!
- updatedAt: Int!
- title: String
- description: String
- users: [Int]
- members: [ChatMember]
- admins: [Int]
- messages: [Message]
- unread: Int
- private: Boolean
+ id: String!
+ createdAt: Int!
+ createdBy: Int!
+ updatedAt: Int!
+ title: String
+ description: String
+ users: [Int]
+ members: [ChatMember]
+ admins: [Int]
+ messages: [Message]
+ unread: Int
+ private: Boolean
+}
+
+enum NotificationType {
+ NEW_COMMENT,
+ NEW_REPLY
+}
+
+type Notification {
+ id: Int!
+ shout: Int
+ reaction: Int
+ type: NotificationType!
+ createdAt: DateTime!
+ seen: Boolean!
+ data: String # JSON
+ occurrences: Int!
}
diff --git a/server.py b/server.py
index 9f0f9cc1..db7157dd 100644
--- a/server.py
+++ b/server.py
@@ -1,8 +1,9 @@
-import sys
import os
+import sys
+
import uvicorn
-from settings import PORT, DEV_SERVER_PID_FILE_NAME
+from settings import DEV_SERVER_PID_FILE_NAME, PORT
def exception_handler(exception_type, exception, traceback, debug_hook=sys.excepthook):
@@ -10,55 +11,45 @@ def exception_handler(exception_type, exception, traceback, debug_hook=sys.excep
log_settings = {
- 'version': 1,
- 'disable_existing_loggers': True,
- 'formatters': {
- 'default': {
- '()': 'uvicorn.logging.DefaultFormatter',
- 'fmt': '%(levelprefix)s %(message)s',
- 'use_colors': None
+ "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',
},
- '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'
+ "handlers": {
+ "default": {
+ "formatter": "default",
+ "class": "logging.StreamHandler",
+ "stream": "ext://sys.stderr",
+ },
+ "access": {
+ "formatter": "access",
+ "class": "logging.StreamHandler",
+ "stream": "ext://sys.stdout",
},
- '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
- }
- }
+ "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", "http://localhost:3000"),
+ ("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",
+ "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"),
@@ -86,24 +77,20 @@ if __name__ == "__main__":
# log_config=log_settings,
log_level=None,
access_log=True,
- reload=want_reload
+ reload=want_reload,
) # , ssl_keyfile="discours.key", ssl_certfile="discours.crt")
elif x == "migrate":
from migration import process
+
print("MODE: MIGRATE")
process()
elif x == "bson":
from migration.bson2json import json_tables
+
print("MODE: BSON")
json_tables()
else:
sys.excepthook = exception_handler
- uvicorn.run(
- "main:app",
- host="0.0.0.0",
- port=PORT,
- proxy_headers=True,
- server_header=True
- )
+ uvicorn.run("main:app", host="0.0.0.0", port=PORT, proxy_headers=True, server_header=True)
diff --git a/services/following.py b/services/following.py
index 8410eb2d..a2be6af4 100644
--- a/services/following.py
+++ b/services/following.py
@@ -18,12 +18,7 @@ class Following:
class FollowingManager:
lock = asyncio.Lock()
- data = {
- 'author': [],
- 'topic': [],
- 'shout': [],
- 'chat': []
- }
+ data = {"author": [], "topic": [], "shout": [], "chat": []}
@staticmethod
async def register(kind, uid):
@@ -39,13 +34,13 @@ class FollowingManager:
async def push(kind, payload):
try:
async with FollowingManager.lock:
- if kind == 'chat':
- for chat in FollowingManager['chat']:
+ if kind == "chat":
+ for chat in FollowingManager["chat"]:
if payload.message["chatId"] == chat.uid:
chat.queue.put_nowait(payload)
else:
for entity in FollowingManager[kind]:
- if payload.shout['createdBy'] == entity.uid:
+ if payload.shout["createdBy"] == entity.uid:
entity.queue.put_nowait(payload)
except Exception as e:
print(Exception(e))
diff --git a/services/inbox/presence.py b/services/inbox/presence.py
deleted file mode 100644
index 2815c998..00000000
--- a/services/inbox/presence.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# from base.exceptions import Unauthorized
-from auth.tokenstorage import SessionToken
-from base.redis import redis
-
-
-async def set_online_status(user_id, status):
- if user_id:
- if status:
- await redis.execute("SADD", "users-online", user_id)
- else:
- await redis.execute("SREM", "users-online", user_id)
-
-
-async def on_connect(req, params):
- if not isinstance(params, dict):
- req.scope["connection_params"] = {}
- return
- token = params.get('token')
- if not token:
- # raise Unauthorized("Please login")
- return {
- "error": "Please login first"
- }
- else:
- payload = await SessionToken.verify(token)
- if payload and payload.user_id:
- req.scope["user_id"] = payload.user_id
- await set_online_status(payload.user_id, True)
-
-
-async def on_disconnect(req):
- user_id = req.scope.get("user_id")
- await set_online_status(user_id, False)
-
-
-# FIXME: not used yet
-def context_value(request):
- context = {}
- print(f"[inbox.presense] request debug: {request}")
- if request.scope["type"] == "websocket":
- # request is an instance of WebSocket
- context.update(request.scope["connection_params"])
- else:
- context["token"] = request.META.get("authorization")
-
- return context
diff --git a/services/inbox/sse.py b/services/inbox/sse.py
deleted file mode 100644
index a73af840..00000000
--- a/services/inbox/sse.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from sse_starlette.sse import EventSourceResponse
-from starlette.requests import Request
-from graphql.type import GraphQLResolveInfo
-from resolvers.inbox.messages import message_generator
-# from base.exceptions import Unauthorized
-
-# https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md
-
-
-async def sse_messages(request: Request):
- print(f'[SSE] request\n{request}\n')
- info = GraphQLResolveInfo()
- info.context['request'] = request.scope
- user_id = request.scope['user'].user_id
- if user_id:
- event_generator = await message_generator(None, info)
- return EventSourceResponse(event_generator)
- else:
- # raise Unauthorized("Please login")
- return {
- "error": "Please login first"
- }
diff --git a/services/main.py b/services/main.py
index 10301b86..6397a5e5 100644
--- a/services/main.py
+++ b/services/main.py
@@ -1,13 +1,13 @@
+from base.orm import local_session
from services.search import SearchService
from services.stat.viewed import ViewedStorage
-from base.orm import local_session
async def storages_init():
with local_session() as session:
- print('[main] initialize SearchService')
+ print("[main] initialize SearchService")
await SearchService.init(session)
- print('[main] SearchService initialized')
- print('[main] initialize storages')
+ print("[main] SearchService initialized")
+ print("[main] initialize storages")
await ViewedStorage.init()
- print('[main] storages initialized')
+ print("[main] storages initialized")
diff --git a/services/notifications/notification_service.py b/services/notifications/notification_service.py
new file mode 100644
index 00000000..1d94ab9c
--- /dev/null
+++ b/services/notifications/notification_service.py
@@ -0,0 +1,160 @@
+import asyncio
+import json
+from datetime import datetime, timezone
+
+from sqlalchemy import and_
+
+from base.orm import local_session
+from orm import Notification, Reaction, Shout, User
+from orm.notification import NotificationType
+from orm.reaction import ReactionKind
+from services.notifications.sse import connection_manager
+
+
+def shout_to_shout_data(shout):
+ return {"title": shout.title, "slug": shout.slug}
+
+
+def user_to_user_data(user):
+ return {"id": user.id, "name": user.name, "slug": user.slug, "userpic": user.userpic}
+
+
+def update_prev_notification(notification, user, reaction):
+ notification_data = json.loads(notification.data)
+
+ notification_data["users"] = [u for u in notification_data["users"] if u["id"] != user.id]
+ notification_data["users"].append(user_to_user_data(user))
+
+ if notification_data["reactionIds"] is None:
+ notification_data["reactionIds"] = []
+ notification_data["reactionIds"].append(reaction.id)
+
+ notification.data = json.dumps(notification_data, ensure_ascii=False)
+ notification.seen = False
+ notification.occurrences = notification.occurrences + 1
+ notification.createdAt = datetime.now(tz=timezone.utc)
+
+
+class NewReactionNotificator:
+ def __init__(self, reaction_id):
+ self.reaction_id = reaction_id
+
+ async def run(self):
+ with local_session() as session:
+ reaction = session.query(Reaction).where(Reaction.id == self.reaction_id).one()
+ shout = session.query(Shout).where(Shout.id == reaction.shout).one()
+ user = session.query(User).where(User.id == reaction.createdBy).one()
+ notify_user_ids = []
+
+ if reaction.kind == ReactionKind.COMMENT:
+ parent_reaction = None
+ if reaction.replyTo:
+ parent_reaction = (
+ session.query(Reaction).where(Reaction.id == reaction.replyTo).one()
+ )
+ if parent_reaction.createdBy != reaction.createdBy:
+ prev_new_reply_notification = (
+ session.query(Notification)
+ .where(
+ and_(
+ Notification.user == shout.createdBy,
+ Notification.type == NotificationType.NEW_REPLY,
+ Notification.shout == shout.id,
+ Notification.reaction == parent_reaction.id,
+ Notification.seen == False, # noqa: E712
+ )
+ )
+ .first()
+ )
+
+ if prev_new_reply_notification:
+ update_prev_notification(prev_new_reply_notification, user, reaction)
+ else:
+ reply_notification_data = json.dumps(
+ {
+ "shout": shout_to_shout_data(shout),
+ "users": [user_to_user_data(user)],
+ "reactionIds": [reaction.id],
+ },
+ ensure_ascii=False,
+ )
+
+ reply_notification = Notification.create(
+ **{
+ "user": parent_reaction.createdBy,
+ "type": NotificationType.NEW_REPLY,
+ "shout": shout.id,
+ "reaction": parent_reaction.id,
+ "data": reply_notification_data,
+ }
+ )
+
+ session.add(reply_notification)
+
+ notify_user_ids.append(parent_reaction.createdBy)
+
+ if reaction.createdBy != shout.createdBy and (
+ parent_reaction is None or parent_reaction.createdBy != shout.createdBy
+ ):
+ prev_new_comment_notification = (
+ session.query(Notification)
+ .where(
+ and_(
+ Notification.user == shout.createdBy,
+ Notification.type == NotificationType.NEW_COMMENT,
+ Notification.shout == shout.id,
+ Notification.seen == False, # noqa: E712
+ )
+ )
+ .first()
+ )
+
+ if prev_new_comment_notification:
+ update_prev_notification(prev_new_comment_notification, user, reaction)
+ else:
+ notification_data_string = json.dumps(
+ {
+ "shout": shout_to_shout_data(shout),
+ "users": [user_to_user_data(user)],
+ "reactionIds": [reaction.id],
+ },
+ ensure_ascii=False,
+ )
+
+ author_notification = Notification.create(
+ **{
+ "user": shout.createdBy,
+ "type": NotificationType.NEW_COMMENT,
+ "shout": shout.id,
+ "data": notification_data_string,
+ }
+ )
+
+ session.add(author_notification)
+
+ notify_user_ids.append(shout.createdBy)
+
+ session.commit()
+
+ for user_id in notify_user_ids:
+ await connection_manager.notify_user(user_id)
+
+
+class NotificationService:
+ def __init__(self):
+ self._queue = asyncio.Queue(maxsize=1000)
+
+ async def handle_new_reaction(self, reaction_id):
+ notificator = NewReactionNotificator(reaction_id)
+ await self._queue.put(notificator)
+
+ async def worker(self):
+ while True:
+ notificator = await self._queue.get()
+ try:
+ await notificator.run()
+ except Exception as e:
+ print(f"[NotificationService.worker] error: {str(e)}")
+
+
+notification_service = NotificationService()
diff --git a/services/notifications/sse.py b/services/notifications/sse.py
new file mode 100644
index 00000000..55cae575
--- /dev/null
+++ b/services/notifications/sse.py
@@ -0,0 +1,70 @@
+import asyncio
+import json
+
+from sse_starlette.sse import EventSourceResponse
+from starlette.requests import Request
+
+
+class ConnectionManager:
+ def __init__(self):
+ self.connections_by_user_id = {}
+
+ def add_connection(self, user_id, connection):
+ if user_id not in self.connections_by_user_id:
+ self.connections_by_user_id[user_id] = []
+ self.connections_by_user_id[user_id].append(connection)
+
+ def remove_connection(self, user_id, connection):
+ if user_id not in self.connections_by_user_id:
+ return
+
+ self.connections_by_user_id[user_id].remove(connection)
+
+ if len(self.connections_by_user_id[user_id]) == 0:
+ del self.connections_by_user_id[user_id]
+
+ async def notify_user(self, user_id):
+ if user_id not in self.connections_by_user_id:
+ return
+
+ for connection in self.connections_by_user_id[user_id]:
+ data = {"type": "newNotifications"}
+ data_string = json.dumps(data, ensure_ascii=False)
+ await connection.put(data_string)
+
+ async def broadcast(self, data: str):
+ for user_id in self.connections_by_user_id:
+ for connection in self.connections_by_user_id[user_id]:
+ await connection.put(data)
+
+
+class Connection:
+ def __init__(self):
+ self._queue = asyncio.Queue()
+
+ async def put(self, data: str):
+ await self._queue.put(data)
+
+ async def listen(self):
+ data = await self._queue.get()
+ return data
+
+
+connection_manager = ConnectionManager()
+
+
+async def sse_subscribe_handler(request: Request):
+ user_id = int(request.path_params["user_id"])
+ connection = Connection()
+ connection_manager.add_connection(user_id, connection)
+
+ async def event_publisher():
+ try:
+ while True:
+ data = await connection.listen()
+ yield data
+ except asyncio.CancelledError as e:
+ connection_manager.remove_connection(user_id, connection)
+ raise e
+
+ return EventSourceResponse(event_publisher())
diff --git a/services/search.py b/services/search.py
index 834e5bf7..610dd775 100644
--- a/services/search.py
+++ b/services/search.py
@@ -1,5 +1,7 @@
import asyncio
import json
+from typing import List
+
from base.redis import redis
from orm.shout import Shout
from resolvers.zine.load import load_shouts_by
@@ -7,25 +9,20 @@ from resolvers.zine.load import load_shouts_by
class SearchService:
lock = asyncio.Lock()
- cache = {}
+ # cache = {}
@staticmethod
async def init(session):
async with SearchService.lock:
- print('[search.service] did nothing')
- SearchService.cache = {}
+ print("[search.service] did nothing")
+ # SearchService.cache = {}
@staticmethod
- async def search(text, limit, offset) -> [Shout]:
+ async def search(text, limit, offset) -> List[Shout]:
cached = await redis.execute("GET", text)
if not cached:
async with SearchService.lock:
- options = {
- "title": text,
- "body": text,
- "limit": limit,
- "offset": offset
- }
+ options = {"title": text, "body": text, "limit": limit, "offset": offset}
payload = await load_shouts_by(None, None, options)
await redis.execute("SET", text, json.dumps(payload))
return payload
diff --git a/services/stat/viewed.py b/services/stat/viewed.py
index 905ade43..213440d9 100644
--- a/services/stat/viewed.py
+++ b/services/stat/viewed.py
@@ -1,18 +1,18 @@
import asyncio
import time
-from datetime import timedelta, timezone, datetime
+from datetime import datetime, timedelta, timezone
from os import environ, path
from ssl import create_default_context
from gql import Client, gql
from gql.transport.aiohttp import AIOHTTPTransport
-from sqlalchemy import func
from base.orm import local_session
-from orm import User, Topic
-from orm.shout import ShoutTopic, Shout
+from orm import Topic
+from orm.shout import Shout, ShoutTopic
-load_facts = gql("""
+load_facts = gql(
+ """
query getDomains {
domains {
id
@@ -25,9 +25,11 @@ query getDomains {
}
}
}
-""")
+"""
+)
-load_pages = gql("""
+load_pages = gql(
+ """
query getDomains {
domains {
title
@@ -41,8 +43,9 @@ query getDomains {
}
}
}
-""")
-schema_str = open(path.dirname(__file__) + '/ackee.graphql').read()
+"""
+)
+schema_str = open(path.dirname(__file__) + "/ackee.graphql").read()
token = environ.get("ACKEE_TOKEN", "")
@@ -50,10 +53,8 @@ def create_client(headers=None, schema=None):
return Client(
schema=schema,
transport=AIOHTTPTransport(
- url="https://ackee.discours.io/api",
- ssl=create_default_context(),
- headers=headers
- )
+ url="https://ackee.discours.io/api", ssl=create_default_context(), headers=headers
+ ),
)
@@ -71,13 +72,13 @@ class ViewedStorage:
@staticmethod
async def init():
- """ graphql client connection using permanent token """
+ """graphql client connection using permanent token"""
self = ViewedStorage
async with self.lock:
if token:
- self.client = create_client({
- "Authorization": "Bearer %s" % str(token)
- }, schema=schema_str)
+ self.client = create_client(
+ {"Authorization": "Bearer %s" % str(token)}, schema=schema_str
+ )
print("[stat.viewed] * authorized permanentely by ackee.discours.io: %s" % token)
else:
print("[stat.viewed] * please set ACKEE_TOKEN")
@@ -85,7 +86,7 @@ class ViewedStorage:
@staticmethod
async def update_pages():
- """ query all the pages from ackee sorted by views count """
+ """query all the pages from ackee sorted by views count"""
print("[stat.viewed] ⎧ updating ackee pages data ---")
start = time.time()
self = ViewedStorage
@@ -96,7 +97,7 @@ class ViewedStorage:
try:
for page in self.pages:
p = page["value"].split("?")[0]
- slug = p.split('discours.io/')[-1]
+ slug = p.split("discours.io/")[-1]
shouts[slug] = page["count"]
for slug in shouts.keys():
await ViewedStorage.increment(slug, shouts[slug])
@@ -118,7 +119,7 @@ class ViewedStorage:
# unused yet
@staticmethod
async def get_shout(shout_slug):
- """ getting shout views metric by slug """
+ """getting shout views metric by slug"""
self = ViewedStorage
async with self.lock:
shout_views = self.by_shouts.get(shout_slug)
@@ -136,7 +137,7 @@ class ViewedStorage:
@staticmethod
async def get_topic(topic_slug):
- """ getting topic views value summed """
+ """getting topic views value summed"""
self = ViewedStorage
topic_views = 0
async with self.lock:
@@ -146,24 +147,28 @@ class ViewedStorage:
@staticmethod
def update_topics(session, shout_slug):
- """ updates topics counters by shout slug """
+ """updates topics counters by shout slug"""
self = ViewedStorage
- 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()
+ ):
if not self.by_topics.get(topic.slug):
self.by_topics[topic.slug] = {}
self.by_topics[topic.slug][shout_slug] = self.by_shouts[shout_slug]
@staticmethod
- async def increment(shout_slug, amount=1, viewer='ackee'):
- """ the only way to change views counter """
+ async def increment(shout_slug, amount=1, viewer="ackee"):
+ """the only way to change views counter"""
self = ViewedStorage
async with self.lock:
# TODO optimize, currenty we execute 1 DB transaction per shout
with local_session() as session:
shout = session.query(Shout).where(Shout.slug == shout_slug).one()
- if viewer == 'old-discours':
+ if viewer == "old-discours":
# this is needed for old db migration
if shout.viewsOld == amount:
print(f"viewsOld amount: {amount}")
@@ -185,7 +190,7 @@ class ViewedStorage:
@staticmethod
async def worker():
- """ async task worker """
+ """async task worker"""
failed = 0
self = ViewedStorage
if self.disabled:
@@ -205,9 +210,10 @@ class ViewedStorage:
if failed == 0:
when = datetime.now(timezone.utc) + timedelta(seconds=self.period)
t = format(when.astimezone().isoformat())
- print("[stat.viewed] ⎩ next update: %s" % (
- t.split("T")[0] + " " + t.split("T")[1].split(".")[0]
- ))
+ print(
+ "[stat.viewed] ⎩ next update: %s"
+ % (t.split("T")[0] + " " + t.split("T")[1].split(".")[0])
+ )
await asyncio.sleep(self.period)
else:
await asyncio.sleep(10)
diff --git a/settings.py b/settings.py
index 54897dfa..89739c80 100644
--- a/settings.py
+++ b/settings.py
@@ -3,8 +3,9 @@ from os import environ
PORT = 8080
DB_URL = (
- environ.get("DATABASE_URL") or environ.get("DB_URL") or
- "postgresql://postgres@localhost:5432/discoursio"
+ environ.get("DATABASE_URL")
+ or environ.get("DB_URL")
+ or "postgresql://postgres@localhost:5432/discoursio"
)
JWT_ALGORITHM = "HS256"
JWT_SECRET_KEY = environ.get("JWT_SECRET_KEY") or "8f1bd7696ffb482d8486dfbc6e7d16dd-secret-key"
@@ -22,11 +23,12 @@ for provider in OAUTH_PROVIDERS:
"id": environ.get(provider + "_OAUTH_ID"),
"key": environ.get(provider + "_OAUTH_KEY"),
}
-FRONTEND_URL = environ.get("FRONTEND_URL") or "http://localhost:3000"
+FRONTEND_URL = environ.get("FRONTEND_URL") or "https://localhost:3000"
SHOUTS_REPO = "content"
SESSION_TOKEN_HEADER = "Authorization"
SENTRY_DSN = environ.get("SENTRY_DSN")
+SESSION_SECRET_KEY = environ.get("SESSION_SECRET_KEY") or "!secret"
# for local development
-DEV_SERVER_PID_FILE_NAME = 'dev-server.pid'
+DEV_SERVER_PID_FILE_NAME = "dev-server.pid"
diff --git a/setup.cfg b/setup.cfg
index 588918a1..dde3b963 100755
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,23 +1,13 @@
[isort]
# https://github.com/PyCQA/isort
-line_length = 120
-multi_line_output = 3
-include_trailing_comma = true
-force_grid_wrap = 0
-use_parentheses = true
-force_alphabetical_sort = false
-
-[tool:brunette]
-# https://github.com/odwyersoftware/brunette
-line-length = 120
-single-quotes = false
+profile = black
[flake8]
# https://github.com/PyCQA/flake8
-exclude = .git,__pycache__,.mypy_cache,.vercel
-max-line-length = 120
-max-complexity = 15
-select = B,C,E,F,W,T4,B9
+exclude = .git,.mypy_cache,schema_types.py
+max-line-length = 100
+max-complexity = 10
+# select = B,C,E,F,W,T4,B9
# E203: Whitespace before ':'
# E266: Too many leading '#' for block comment
# E501: Line too long (82 > 79 characters)
@@ -25,15 +15,12 @@ select = B,C,E,F,W,T4,B9
# W503: Line break occurred before a binary operator
# F403: 'from module import *' used; unable to detect undefined names
# C901: Function is too complex
-ignore = E203,E266,E501,E722,W503,F403,C901
+# ignore = E203,E266,E501,E722,W503,F403,C901
+extend-ignore = E203
[mypy]
# https://github.com/python/mypy
-ignore_missing_imports = true
-warn_return_any = false
-warn_unused_configs = true
-disallow_untyped_calls = true
-disallow_untyped_defs = true
-disallow_incomplete_defs = true
-[mypy-api.*]
-ignore_errors = true
+exclude = schema_types.py
+explicit_package_bases = true
+check_untyped_defs = true
+plugins = sqlmypy
diff --git a/test/test.json b/test/test.json
new file mode 100644
index 00000000..09b55665
--- /dev/null
+++ b/test/test.json
@@ -0,0 +1,43 @@
+{
+ "data": {
+ "loadShout": {
+ "id": 4774,
+ "title": "Как танки в цирке застревали и рылись ямы под Москвой.",
+ "lead": "Военный мятеж Пригожина вызвал панику в обществе и породил множество конспирологических теорий о заранее спланированной акции Кремля. Почему сначала президент называл мятежников предателями и обещал неминуемое наказание, но через сутки все просто разошлись, а уголовное дело свернули? Из-за чего в стране вообще стал возможен вооруженный бунт и в чем он оказался успешен? Каковы последствия мятежа дл
",
+ "description": "Оглавление Многообещающее начало Тухлый финал Предыстория конфликта Пригожина и Минобороны Расклад сил в момент мятежа Теории заговора Сообщники Пригожина в элитах О чем договорились с Пригожиным Разочарование со всех сторон Как закрывали уголовное дело Последствия для Пригожина Последствия для ЧВК Последствия для российской бюрократии и Путина Вышел...",
+ "visibility": "community",
+ "subtitle": "Исчерпывающий разбор причин, хода и последствий",
+ "slug": "testtesttest",
+ "layout": "article",
+ "cover": "https://cdn.discours.io/caf24deb-c415-49ef-8404-418455c57c5c.webp",
+ "body": "Вышел Путин на крыльцо,
Потеряв вконец лицо.
Об опасности конца
Говорил с того крыльца,
Про предателей, про бунт,
О вреде военных хунт,
Про гражданскую войну,
Про несчастную страну,
Положив на музыкантов
Вот за это всю вину.
К сожаленью президент,
Запилив такой контент,
Не сдержавшись в выраженьях,
Упустил такой момент:
Чтобы кресло сохранить,
Нужно меньше говорить,
Как тебя на этом кресле
Не проблемно заменить.
Автор неизвестен
В России вещи, о которых трубят из каждого утюга, все равно происходят неожиданно. Долго говорили, насколько невероятна война с Украиной, а это случилось. Говорили о том, что частные армии опасны для государственной бюрократии, — начался военный мятеж. Шутили «будем бомбить Воронеж» (не смотри, что в анекдоте) — и это тоже случилось. Говорили, что рано или поздно люди из системы начнут жрать друг друга, — и вот вчерашний герой Пригожин уже вымарывается из российской истории.
Многообещающее начало
23 июня Евгений Пригожин начал вооруженный мятеж после того, как министр обороны Сергей Шойгу потребовал, чтобы наемники ЧВК «Вагнер» подписали контракты с Минобороны до 1 июля. То есть попытался лишить Пригожина его кормовой и силовой базы в виде частной армии.
По версии Пригожина, Минобороны нанесло ракетный удар по лагерю «Вагнера», а также направило спецназ для захвата его самого. Однако, как выяснилось, о начавшемся отходе «вагнеров» из захваченного Бахмута и готовящемся мятеже уже 22 июня знала ФСБ из официального письма заместителя Пригожина в ЧВК Андрея Трошева. В США и вовсе заявили, что наблюдали за подготовкой мятежа две недели. О том же сообщила немецкая разведка. И, наконец, провалившееся задержание Пригожина должно было состояться не в лагере наемников, а в Санкт-Петербурге.
Военный мятеж предварялся обращением Пригожина в телеграм, в котором он открыл общественности секрет Полишинеля. В частности, обвинил руководство Минобороны в развале армии, рассказал, что захват Украины нужен был для распила российскими олигархами бизнеса на новых территориях, как это было на Донбассе, заявил, что пора покончить с обманом и коррупцией в стране, и потребовал выдать ему министра обороны Шойгу и главу генштаба Герасимова.
Шойгу спешно свалил из Ростова. Сам город и военные объекты Ростовской области были заняты «Вагнером».
Нужно ли говорить, что все полицейские разбежались, решив, что на этом их полномочия — всё. Такой серьезный митинг разогнать шокерами и дубинками решительно нельзя.
В Кремле едва успевали подносить и опорожнять чемоданчики. Ведь Путин не испытывал подобных стрессов со времен Болотной площади, когда реально испугался потери власти, после чего стал превращать правоохранительную систему в политическую полицию, создал Росгвардию и «заболел цифровизацией» как инструментом тотальной слежки за гражданами. Гражданское общество с белыми ленточками подавили, но беда пришла со стороны людей с шевронами «Наш бизнес — смерть, и бизнес идет хорошо». Страшно, очень страшно.
Путин записал обращение, в котором назвал наемников предателями, обещал неминуемое наказание (которое таки минуло) и вспомнил 1917 год.
Услышав про 1917 год, все, кроме «болота», в течение суток ждали досрочного прекращения полномочий президента. Правящая элита, включая Путина, покинула Москву. Косплеить украинское руководство и записывать ролики на Красной площади не стали. В Москве остался только Володин. Когда все утихло, он решил повысить свой аппаратный вес и призвал наказать бежавших. То есть почти всю верхушку страны. А в ней, между прочим, олигархи путинской волны, друзья детства, кооператив «Озеро» и всё, что навевает теплые воспоминания из прошлого.
Отвечая на обращение Путина, Пригожин неосторожно заявил, что президент ошибается, и мятеж — это не мятеж, а «марш справедливости». При этом глава ЧВК требовал, чтобы никто не сопротивлялся колоннам наемников, движущимся на Москву, а любой, кто встанет на пути, будет уничтожен. Потому что никто не встанет на пути у справедливости.
Глава ЧВК требовал, чтобы никто не сопротивлялся колоннам наемников, движущимся на Москву, а любой, кто встанет на пути, будет уничтожен / Скриншот из обращения Пригожина из Ростова / fedpress.ru 
После некоторой фрустрации ФСБ очухалась и забегала по военкоматам, собирая информацию о женах и родственниках «вагнеров». Под Москвой начали разрывать экскаваторами дороги и выставлять грузовики с песком. Кадыров заверил Путина в своей преданности и отправил в направлении Ростова батальон «Ахмат», который в очередной раз весьма благоразумно не доехал до точки соприкосновения.
Тухлый финал
Вечером 24 июня, когда колонна «Вагнера» была в 200 км от Москвы, Пригожин решил развернуть колонну и вернуться в полевые лагеря во избежание кровопролития (умолчав о куче перебитой российской авиации с РЭБ и ее экипажах).
Ответственность за срыв мятежа взял на себя Лукашенко и сымитировал переговоры с Пригожиным, передав тому предложения Путина, который не осмелился лично ответить на звонок мятежника. Лукашенко с радостью вписался во что-то более легитимирующее его шаткую власть, чем осмотр «обосранных» коров в колхозах.
Позже Песков сообщил, что Пригожин уезжает в Беларусь, а те «вагнера», которые на участвовали в мятеже, могут заключить контракты с Минобороны. В Беларуси был раскинут лагерь на 8 тысяч человек.
У Путина от избытка адреналина развязался язык. Он провел открытое совещание Совбеза, записывал обращения, рассказывал о попытке начать гражданскую войну, клеймил предателей, благодарил всех, кто не разбежался. И, наконец, сдал все пароли и явки, заявив, что за год государство потратило на «Вагнер» и Пригожина 276 млрд рублей. Позже пропагандист Дмитрий Киселев назвал цифру в 858 млрд, которые Пригожин получил через холдинг «Конкорд».
Одна из перекопанный дорог, которая должна была усложнить поход «вагнеровцев» на Москву / Фото: соцсети, Липецкая область 
Все бы ничего, ведь активная часть гражданского общества обо всем и так знала. И о Сирии, и об Африке, и об Украине. Но Путин забылся и разоткровенничался перед своим ядерным электоратом, тем самым «болотом», которое смотрит телик, мало осведомлено о ЧВК, верит в сильного президента и патриотическую сплоченность. А теперь им рассказали, что государство финансирует через левые схемы частные военизированные формирования, которые ставят страну на грань гражданской войны.
Президент теперь не находится над схваткой, а является ее частью, и спасает его Лукашенко, который всеми силами демонстрирует, что его яйца крепче, чем картофель и покрышка БелАЗа.
Главу Росгвардии Золотова наградили за защиту Москвы, которая не состоялась. А самой Росгвардии обещали выдать танки и прочую тяжелую технику, которая теперь не отправится на фронт. Если будет выдана. Видимо, ожидают повторного марша государственных и полугосударственных военных на Москву.
Так феодализм оформился и в военной сфере: армия против Украины, другая армия против этой армии, региональные армии на случай войны с федералами и частные армии на случай войны с конкурирующими корпорациями за активы. Не удивительно, что Пригожина возмутило, что его хотят лишить своей армии, когда у всех уважаемых людей она есть.
Уголовное дело против Пригожина было юридически неграмотно прекращено, несмотря на убитых «вагнерами» летчиков, которых Путин почтил минутой молчания, выступая на крыльце Грановитой палаты Кремля перед сотрудниками ФСО и военным руководством.
В частности, 28 июня сообщили, что арестован генерал Суровикин, лоббист «Вагнера» в Министерстве обороны, несмотря на то что осудил мятеж после его начала, записав соответствующее видеообращение при неустановленных обстоятельствах. Правозащитник Ольга Романова рассказала, что в СИЗО «Лефортово» была принята и передана задержанному открытка, отправленная на имя Суровикина С. В. Предположительно, сейчас Суровикин находится под другой мерой пресечения — запретом на совершение определенных действий.
Неизвестна судьба генерала Мизинцева, который до увольнения из Минобороны обеспечивал серые поставки «вагнерам» боеприпасов во время войны с Украиной, за что был уволен и немедленно трудоустроен заместителем в ЧВК «Вагнер».
В течение недели после мятежа начались чистки в Минобороны.
Бизнес-империю Пригожина начали рушить, включая его силовые, медийные и чисто коммерческие ресурсы. Его репутацию тоже уничтожают. Пропагандисты на федеральных каналах развернулись на 180 градусов, клеймят предателя и рассказывают от том, насколько преувеличена роль «Вагнера» на фронте.
И, конечно же, показывают «глубинному народу» материалы обысков во дворце Пригожина с найденными в нем наградным оружием, париками для маскировки и, по неподтвержденным данным, костюмом Папы Римского.
Утверждается, что в ходе обысков у Пригожина нашли его фотографии в различных обличьях / Коллаж: topcor.ru 
Предыстория конфликта Пригожина и Минобороны
На протяжении 2023 года в военной и чекистской бюрократии устоялась концепция того, что зарвавшегося Пригожина (выскочку, человека не из системы, с чрезмерными политическими амбициями) готовят на заклание. Слишком быстрый рост популярности при отсутствии аппаратного веса. Или, если короче, «кто он вообще такой, чтобы так борзеть?».
Минобороны ограничивало снабжение ЧВК боеприпасами, минировало пути отхода «Вагнера» из Бахмута и принуждало наемников заключить контракты с Минобороны. То есть пыталось лишить Пригожина его собственной пирамиды, на вершине которой он таки имел аппаратный вес. Но этот аппарат слишком обособился от военной бюрократии. Нарушил пресловутую монополию государства на легальное насилие. Опасно.
Обнулять «Вагнер» Шойгу начал еще во время сирийской кампании, где Россия помогала Башару Асаду сохранить свою диктаторскую власть.
По воспоминаниям корреспондента пригожинской пропагандистской помойки РИА «ФАН» Кирилла Романовского, весной 2016 года, после взятия наемниками Пальмиры, Шойгу заявил, что какие-то гопники не могут получать государственные награды РФ. И раздал награды своим гопникам из Минобороны.
Во времена этой же кампании случилось уничтожение 200 «вагнеров», шедших на захват нефтеперерабатывающего завода. На запрос США: «Это ваши?» — Минобороны ответило: «Не, не наши». Американцы пожали плечами и нанесли по колонне авиаудар, полностью очистивший ландшафт от всей имеющейся на нем фауны.
Список
Список
Список
Раз
Два
Три
Понимая, куда все движется, длительное время Пригожин как когда-то генералиссимус Валленштейн (тоже владевший частной армией) находился в полевых лагерях, откуда критиковал государственную армию, заверяя императора в том, что будет воевать в его интересах, но по своему усмотрению.
Как и для Валленштейна, для Пригожина частная армия являлась единственным гарантом выживания в борьбе с тяжеловесами из государственной бюрократии — Шойгу и Герасимовым. Те не забыли оскорблений Пригожина и долго низводили численный состав «Вагнера» к минимуму, перекрыв доступ к вербовке зеков, держа наемников на передней линии фронта для перемалывания их руками ВСУ и, наконец, требуя перейти на контракты с Минобороны.
Сообщники Пригожина в элитах
А что насчет сообщников, единомышленников или по крайней мере сочувствующих Пригожину в государственной бюрократии? Можно говорить о ситуативном содействии отдельных чиновников Пригожину, но не о спланированном мятеже с целью смены высших должностных лиц, включая президента.
Поскольку государство авторитарное, кажется, что у него единый центр принятия решений. Эта иллюзия заставляет думать, что все происходящее — это часть некоего плана.
Тут случился треш)))

Читайте также
Право народа на восстание. Можно ли защищать демократию силой?
Как Пригожин вербовал заключенных на войну. Репортаж из колонии о приезде основателя ЧВК «Вагнер»
«Вы — пушечное мясо». Почему российские власти творят всякий треш?
«Они хотят вырваться из русской тюрьмы». Ольга Романова о заключенных на фронте и новых законах после мятежа Пригожина
«Я не могу желать поражения русской армии». Почему националисты и нацболы не выступают против войны в Украине?
Цитата любопытно смещает эмбед
А текст после цитаты пишется здесь
",
+ "media": null,
+ "mainTopic": "politics",
+ "topics": [
+ {
+ "id": 200,
+ "title": "политика",
+ "body": "",
+ "slug": "politics",
+ "stat": null
+ }
+ ],
+ "authors": [
+ {
+ "id": 2,
+ "name": "Дискурс",
+ "slug": "discours",
+ "userpic": null
+ }
+ ],
+ "createdAt": "2023-09-04T10:15:08.666569",
+ "publishedAt": "2023-09-04T12:35:20.024954",
+ "stat": {
+ "viewed": 6,
+ "reacted": null,
+ "rating": 0,
+ "commented": 0
+ }
+ }
+ }
+}
diff --git a/validations/auth.py b/validations/auth.py
index 216d7dcb..73b83079 100644
--- a/validations/auth.py
+++ b/validations/auth.py
@@ -1,4 +1,5 @@
from typing import Optional, Text
+
from pydantic import BaseModel
diff --git a/validations/inbox.py b/validations/inbox.py
index d03cca05..cf90da6f 100644
--- a/validations/inbox.py
+++ b/validations/inbox.py
@@ -1,4 +1,5 @@
-from typing import Optional, Text, List
+from typing import List, Optional, Text
+
from pydantic import BaseModel
@@ -20,6 +21,7 @@ class Member(BaseModel):
class Chat(BaseModel):
+ id: int
createdAt: int
createdBy: int
users: List[int]