Merge remote-tracking branch 'origin/main' into storages-to-qeuries

This commit is contained in:
Igor Lobanov 2022-11-28 13:54:22 +01:00
commit 70744966fa
30 changed files with 605 additions and 430 deletions

View File

@ -24,10 +24,8 @@ apt install redis nginx
First, install Postgres. Then you'll need some data First, install Postgres. Then you'll need some data
``` ```
brew install postgres
psql -U postgres createdb discoursio
> create database discoursio;
> \q
python server.py migrate python server.py migrate
``` ```
@ -42,3 +40,7 @@ python3 server.py dev
Put the header 'Authorization' with token from signIn query or registerUser mutation. Put the header 'Authorization' with token from signIn query or registerUser mutation.
# How to debug Ackee
Set ACKEE_TOKEN var

View File

@ -9,7 +9,7 @@ from auth.credentials import AuthCredentials, AuthUser
from services.auth.users import UserStorage from services.auth.users import UserStorage
from settings import SESSION_TOKEN_HEADER from settings import SESSION_TOKEN_HEADER
from auth.tokenstorage import SessionToken from auth.tokenstorage import SessionToken
from base.exceptions import InvalidToken from base.exceptions import InvalidToken, OperationNotAllowed, Unauthorized
class JWTAuthenticate(AuthenticationBackend): class JWTAuthenticate(AuthenticationBackend):
@ -30,27 +30,26 @@ class JWTAuthenticate(AuthenticationBackend):
try: try:
if len(token.split('.')) > 1: if len(token.split('.')) > 1:
payload = await SessionToken.verify(token) payload = await SessionToken.verify(token)
if payload is None:
return AuthCredentials(scopes=[]), AuthUser(user_id=None)
user = await UserStorage.get_user(payload.user_id)
if not user:
return AuthCredentials(scopes=[]), AuthUser(user_id=None)
scopes = await user.get_permission()
return (
AuthCredentials(
user_id=payload.user_id,
scopes=scopes,
logged_in=True
),
user,
)
else: else:
InvalidToken("please try again") InvalidToken("please try again")
except Exception as exc: except Exception as exc:
print("[auth.authenticate] session token verify error") print("[auth.authenticate] session token verify error")
print(exc) print(exc)
return AuthCredentials(scopes=[], error_message=str(exc)), AuthUser( return AuthCredentials(scopes=[], error_message=str(exc)), AuthUser(user_id=None)
user_id=None
)
if payload is None:
return AuthCredentials(scopes=[]), AuthUser(user_id=None)
user = await UserStorage.get_user(payload.user_id)
if not user:
return AuthCredentials(scopes=[]), AuthUser(user_id=None)
scopes = await user.get_permission()
return (
AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True),
user,
)
def login_required(func): def login_required(func):
@ -58,10 +57,9 @@ def login_required(func):
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
# print('[auth.authenticate] login required for %r with info %r' % (func, info)) # debug only # print('[auth.authenticate] login required for %r with info %r' % (func, info)) # debug only
auth: AuthCredentials = info.context["request"].auth auth: AuthCredentials = info.context["request"].auth
if auth and auth.user_id: # print(auth)
print(auth) # debug only
if not auth.logged_in: if not auth.logged_in:
return {"error": auth.error_message or "Please login"} raise OperationNotAllowed(auth.error_message or "Please login")
return await func(parent, info, *args, **kwargs) return await func(parent, info, *args, **kwargs)
return wrap return wrap
@ -73,9 +71,9 @@ def permission_required(resource, operation, func):
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 auth: AuthCredentials = info.context["request"].auth
if not auth.logged_in: if not auth.logged_in:
return {"error": auth.error_message or "Please login"} raise Unauthorized(auth.error_message or "Please login")
# TODO: add check permission logix # TODO: add actual check permission logix here
return await func(parent, info, *args, **kwargs) return await func(parent, info, *args, **kwargs)

View File

@ -2,7 +2,7 @@ from typing import List, Optional, Text
from pydantic import BaseModel from pydantic import BaseModel
from base.exceptions import OperationNotAllowed from base.exceptions import Unauthorized
class Permission(BaseModel): class Permission(BaseModel):
@ -17,11 +17,13 @@ class AuthCredentials(BaseModel):
@property @property
def is_admin(self): def is_admin(self):
# TODO: check admin logix
return True return True
async def permissions(self) -> List[Permission]: async def permissions(self) -> List[Permission]:
if self.user_id is None: if self.user_id is None:
raise OperationNotAllowed("Please login first") raise Unauthorized("Please login first")
# TODO: implement permissions logix
return NotImplemented() return NotImplemented()

View File

@ -10,13 +10,13 @@ lang_subject = {
} }
async def send_auth_email(user, token, lang="ru"): async def send_auth_email(user, token, template="email_confirmation", lang="ru"):
try: try:
to = "%s <%s>" % (user.name, user.email) to = "%s <%s>" % (user.name, user.email)
if lang not in ['ru', 'en']: if lang not in ['ru', 'en']:
lang = 'ru' lang = 'ru'
subject = lang_subject.get(lang, lang_subject["en"]) subject = lang_subject.get(lang, lang_subject["en"])
template = "email_confirmation_" + lang template = template + "_" + lang
payload = { payload = {
"from": noreply, "from": noreply,
"to": to, "to": to,

View File

@ -34,7 +34,7 @@ class JWTCodec:
issuer="discours" issuer="discours"
) )
r = TokenPayload(**payload) r = TokenPayload(**payload)
print('[auth.jwtcodec] debug payload %r' % r) # print('[auth.jwtcodec] debug payload %r' % r)
return r return r
except jwt.InvalidIssuedAtError: except jwt.InvalidIssuedAtError:
print('[auth.jwtcodec] invalid issued at: %r' % r) print('[auth.jwtcodec] invalid issued at: %r' % r)

View File

@ -2,7 +2,7 @@ from authlib.integrations.starlette_client import OAuth
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
from auth.identity import Identity from auth.identity import Identity
from auth.tokenstorage import TokenStorage from auth.tokenstorage import TokenStorage
from settings import OAUTH_CLIENTS from settings import OAUTH_CLIENTS, FRONTEND_URL
oauth = OAuth() oauth = OAuth()
@ -84,6 +84,6 @@ async def oauth_authorize(request):
} }
user = Identity.oauth(user_input) user = Identity.oauth(user_input)
session_token = await TokenStorage.create_session(user) session_token = await TokenStorage.create_session(user)
response = RedirectResponse(url="https://new.discours.io/confirm") response = RedirectResponse(url=FRONTEND_URL + "/confirm")
response.set_cookie("token", session_token) response.set_cookie("token", session_token)
return response return response

View File

@ -12,6 +12,7 @@ class RedisCache:
if self._instance is not None: if self._instance is not None:
return return
self._instance = await from_url(self._uri, encoding="utf-8") self._instance = await from_url(self._uri, encoding="utf-8")
# print(self._instance)
async def disconnect(self): async def disconnect(self):
if self._instance is None: if self._instance is None:
@ -24,7 +25,8 @@ class RedisCache:
while not self._instance: while not self._instance:
await sleep(1) await sleep(1)
try: try:
await self._instance.execute_command(command, *args, **kwargs) print("[redis] " + command + ' ' + ' '.join(args))
return await self._instance.execute_command(command, *args, **kwargs)
except Exception: except Exception:
pass pass

View File

@ -314,9 +314,6 @@ async def handle_auto():
async def main(): async def main():
if len(sys.argv) > 1: if len(sys.argv) > 1:
cmd = sys.argv[1]
if type(cmd) == str:
print("[migration] command: " + cmd)
init_tables() init_tables()
await handle_auto() await handle_auto()
else: else:

View File

@ -4,7 +4,7 @@ from datetime import datetime, timezone
import frontmatter import frontmatter
from .extract import extract_html, prepare_html_body from .extract import extract_html, extract_media
from .utils import DateTimeEncoder from .utils import DateTimeEncoder
OLD_DATE = "2016-03-05 22:22:00.350000" OLD_DATE = "2016-03-05 22:22:00.350000"
@ -50,11 +50,12 @@ def export_mdx(r):
def export_body(shout, storage): def export_body(shout, storage):
entry = storage["content_items"]["by_oid"][shout["oid"]] entry = storage["content_items"]["by_oid"][shout["oid"]]
if entry: if entry:
shout["body"], media = prepare_html_body(entry) # prepare_md_body(entry) body = extract_html(entry)
media = extract_media(entry)
shout["body"] = body # prepare_html_body(entry) # prepare_md_body(entry)
shout["media"] = media shout["media"] = media
export_mdx(shout) export_mdx(shout)
print("[export] html for %s" % shout["slug"]) print("[export] html for %s" % shout["slug"])
body, _media = extract_html(entry)
open(contentDir + shout["slug"] + ".html", "w").write(body) open(contentDir + shout["slug"] + ".html", "w").write(body)
else: else:
raise Exception("no content_items entry found") raise Exception("no content_items entry found")

View File

@ -3,7 +3,8 @@ import os
import re import re
import uuid import uuid
from .html2text import html2text from bs4 import BeautifulSoup
TOOLTIP_REGEX = r"(\/\/\/(.+)\/\/\/)" TOOLTIP_REGEX = r"(\/\/\/(.+)\/\/\/)"
contentDir = os.path.join( contentDir = os.path.join(
@ -258,47 +259,44 @@ def extract_md(body, oid=""):
return newbody return newbody
def prepare_md_body(entry): def extract_media(entry):
# body modifications ''' normalized media extraction method '''
body = "" # media [ { title pic url body } ]}
kind = entry.get("type") kind = entry.get("type")
addon = "" if not kind:
if kind == "Video": print(entry)
addon = "" raise Exception("shout no layout")
for m in entry.get("media", []): media = []
if "youtubeId" in m: for m in entry.get("media") or []:
addon += "<VideoPlayer youtubeId='" + m["youtubeId"] + "' />\n" # title
elif "vimeoId" in m: title = m.get("title", "").replace("\n", " ").replace("&nbsp;", " ")
addon += "<VideoPlayer vimeoId='" + m["vimeoId"] + "' />\n" artist = m.get("performer") or m.get("artist")
else:
print("[extract] media is not supported")
print(m)
body = "import VideoPlayer from '$/components/Article/VideoPlayer'\n\n" + addon
elif kind == "Music":
addon = ""
for m in entry.get("media", []):
artist = m.get("performer")
trackname = ""
if artist: if artist:
trackname += artist + " - " title = artist + " - " + title
if "title" in m:
trackname += m.get("title", "")
addon += (
'<AudioPlayer src="'
+ m.get("fileUrl", "")
+ '" title="'
+ trackname
+ '" />\n'
)
body = "import AudioPlayer from '$/components/Article/AudioPlayer'\n\n" + addon
body_orig, media = extract_html(entry) # pic
if body_orig: url = m.get("fileUrl") or m.get("url", "")
body += extract_md(html2text(body_orig), entry["_id"]) pic = ""
if not body: if m.get("thumborId"):
print("[extract] empty MDX body") pic = cdn + "/unsafe/1600x/" + m["thumborId"]
return body, media
# url
if not url:
if kind == "Image":
url = pic
elif "youtubeId" in m:
url = "https://youtube.com/?watch=" + m["youtubeId"]
elif "vimeoId" in m:
url = "https://vimeo.com/" + m["vimeoId"]
# body
body = m.get("body") or m.get("literatureBody") or ""
media.append({
"url": url,
"pic": pic,
"title": title,
"body": body
})
return media
def prepare_html_body(entry): def prepare_html_body(entry):
@ -308,7 +306,7 @@ def prepare_html_body(entry):
addon = "" addon = ""
if kind == "Video": if kind == "Video":
addon = "" addon = ""
for m in entry.get("media", []): for m in entry.get("media") or []:
if "youtubeId" in m: if "youtubeId" in m:
addon += '<iframe width="420" height="345" src="http://www.youtube.com/embed/' addon += '<iframe width="420" height="345" src="http://www.youtube.com/embed/'
addon += m["youtubeId"] addon += m["youtubeId"]
@ -325,7 +323,7 @@ def prepare_html_body(entry):
elif kind == "Music": elif kind == "Music":
addon = "" addon = ""
for m in entry.get("media", []): for m in entry.get("media") or []:
artist = m.get("performer") artist = m.get("performer")
trackname = "" trackname = ""
if artist: if artist:
@ -339,68 +337,12 @@ def prepare_html_body(entry):
addon += '"></audio></figure>' addon += '"></audio></figure>'
body += addon body += addon
body, media = extract_html(entry) body = extract_html(entry)
# if body_orig: body += extract_md(html2text(body_orig), entry['_id']) # if body_orig: body += extract_md(html2text(body_orig), entry['_id'])
if not body: return body
print("[extract] empty HTML body")
return body, media
def extract_html(entry): def extract_html(entry):
body_orig = (entry.get("body") or "").replace('\(', '(').replace('\)', ')') body_orig = (entry.get("body") or "").replace('\(', '(').replace('\)', ')')
media = entry.get("media", []) body_html = str(BeautifulSoup(body_orig, features="html.parser"))
kind = entry.get("type") or "" return body_html
print("[extract] kind: " + kind)
mbodies = set([])
if media:
# print('[extract] media is found')
for m in media:
mbody = m.get("body", "")
addon = ""
if kind == "Literature":
mbody = m.get("literatureBody") or m.get("body", "")
elif kind == "Image":
cover = ""
if "thumborId" in entry:
cover = cdn + "/unsafe/1600x/" + entry["thumborId"]
if not cover:
if "image" in entry:
cover = entry["image"].get("url", "")
if "cloudinary" in cover:
cover = ""
# else: print('[extract] cover: ' + cover)
title = m.get("title", "").replace("\n", " ").replace("&nbsp;", " ")
u = m.get("thumborId") or cover or ""
if title:
addon += "<h4>" + title + "</h4>\n"
if not u.startswith("http"):
u = s3 + u
if not u:
print("[extract] no image url for " + str(m))
if "cloudinary" in u:
u = "img/lost.svg"
if u != cover or (u == cover and media.index(m) == 0):
addon += '<img src="' + u + '" alt="' + title + '" />\n'
if addon:
body_orig += addon
# print('[extract] item addon: ' + addon)
# if addon: print('[extract] addon: %s' % addon)
if mbody and mbody not in mbodies:
mbodies.add(mbody)
body_orig += mbody
if len(list(mbodies)) != len(media):
print(
"[extract] %d/%d media item bodies appended"
% (len(list(mbodies)), len(media))
)
# print('[extract] media items body: \n' + body_orig)
if not body_orig:
for up in entry.get("bodyHistory", []) or []:
body_orig = up.get("text", "") or ""
if body_orig:
print("[extract] got html body from history")
break
if not body_orig:
print("[extract] empty HTML body")
# body_html = str(BeautifulSoup(body_orig, features="html.parser"))
return body_orig, media

View File

@ -4,7 +4,7 @@ from dateutil.parser import parse as date_parse
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from transliterate import translit from transliterate import translit
from base.orm import local_session from base.orm import local_session
from migration.extract import prepare_html_body from migration.extract import extract_html, extract_media
from orm.reaction import Reaction, ReactionKind from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutTopic, ShoutReactionsFollower from orm.shout import Shout, ShoutTopic, ShoutReactionsFollower
from orm.user import User from orm.user import User
@ -103,11 +103,11 @@ async def migrate(entry, storage):
"authors": [], "authors": [],
"topics": set([]) "topics": set([])
} }
topics_by_oid = storage["topics"]["by_oid"]
users_by_oid = storage["users"]["by_oid"]
# author # author
oid = entry.get("createdBy", entry.get("_id", entry.get("oid"))) users_by_oid = storage["users"]["by_oid"]
userdata = users_by_oid.get(oid) user_oid = entry.get("createdBy", "")
userdata = users_by_oid.get(user_oid)
user = None user = None
if not userdata: if not userdata:
app = entry.get("application") app = entry.get("application")
@ -139,6 +139,8 @@ async def migrate(entry, storage):
# timestamps # timestamps
r["createdAt"] = date_parse(entry.get("createdAt", OLD_DATE)) r["createdAt"] = date_parse(entry.get("createdAt", OLD_DATE))
r["updatedAt"] = date_parse(entry["updatedAt"]) if "updatedAt" in entry else ts r["updatedAt"] = date_parse(entry["updatedAt"]) if "updatedAt" in entry else ts
# visibility
if entry.get("published"): if entry.get("published"):
r["publishedAt"] = date_parse(entry.get("publishedAt", OLD_DATE)) r["publishedAt"] = date_parse(entry.get("publishedAt", OLD_DATE))
r["visibility"] = "public" r["visibility"] = "public"
@ -150,25 +152,67 @@ async def migrate(entry, storage):
session.commit() session.commit()
else: else:
r["visibility"] = "authors" r["visibility"] = "authors"
if "deletedAt" in entry: if "deletedAt" in entry:
r["deletedAt"] = date_parse(entry["deletedAt"]) r["deletedAt"] = date_parse(entry["deletedAt"])
# topics # topics
category = entry.get("category") r['topics'] = await add_topics_follower(entry, storage, userslug)
for oid in [category, ] + entry.get("tags", []): r['mainTopic'] = r['topics'][0]
t = storage["topics"]["by_oid"].get(oid)
if t:
tslug = storage["topics"]["by_oid"][oid]["slug"]
r["topics"].add(tslug)
r["topics"] = list(r["topics"])
# main topic
mt = topics_by_oid.get(category)
if mt and mt.get("slug"):
r["mainTopic"] = storage["replacements"].get(mt["slug"]) or r["topics"][0]
entry["topics"] = r["topics"]
entry["cover"] = r["cover"]
# body
r["body"] = extract_html(entry)
media = extract_media(entry)
if media:
r["media"] = json.dumps(media, ensure_ascii=True)
shout_dict = r.copy()
# user
user = await get_user(userslug, userdata, storage, user_oid)
shout_dict["authors"] = [user, ]
del shout_dict["topics"]
try:
# save shout to db
await create_shout(shout_dict, userslug)
except IntegrityError as e:
print(e)
await resolve_create_shout(shout_dict, userslug)
except Exception as e:
raise Exception(e)
# shout topics aftermath
shout_dict["topics"] = await topics_aftermath(r, storage)
# content_item ratings to reactions
await content_ratings_to_reactions(entry, shout_dict["slug"])
# shout views
await ViewedStorage.increment(shout_dict["slug"], amount=entry.get("views", 1))
# del shout_dict['ratings']
shout_dict["oid"] = entry.get("_id", "")
storage["shouts"]["by_oid"][entry["_id"]] = shout_dict
storage["shouts"]["by_slug"][slug] = shout_dict
return shout_dict
async def add_topics_follower(entry, storage, userslug):
topics = set([])
category = entry.get("category")
topics_by_oid = storage["topics"]["by_oid"]
oids = [category, ] + entry.get("tags", [])
for toid in oids:
tslug = topics_by_oid.get(toid, {}).get("slug")
if tslug:
topics.add(tslug)
ttt = list(topics)
# add author as TopicFollower # add author as TopicFollower
with local_session() as session: with local_session() as session:
for tpc in r['topics']: for tpc in topics:
try: try:
tf = session.query( tf = session.query(
TopicFollower TopicFollower
@ -184,24 +228,19 @@ async def migrate(entry, storage):
auto=True auto=True
) )
session.add(tf) session.add(tf)
session.commit()
except IntegrityError: except IntegrityError:
print('[migration.shout] hidden by topic ' + tpc) print('[migration.shout] hidden by topic ' + tpc)
r["visibility"] = "authors" # main topic
r["publishedAt"] = None maintopic = storage["replacements"].get(topics_by_oid.get(category, {}).get("slug"))
r["topics"].remove(tpc) if maintopic in ttt:
ttt.remove(maintopic)
ttt.insert(0, maintopic)
return ttt
entry["topics"] = r["topics"]
entry["cover"] = r["cover"]
# body async def get_user(userslug, userdata, storage, oid):
r["body"], media = prepare_html_body(entry)
if media:
r["media"] = json.dumps(media, ensure_ascii=True)
# save shout to db
s = object()
shout_dict = r.copy()
user = None user = None
del shout_dict["topics"]
with local_session() as session: with local_session() as session:
if not user and userslug: if not user and userslug:
user = session.query(User).filter(User.slug == userslug).first() user = session.query(User).filter(User.slug == userslug).first()
@ -216,14 +255,13 @@ async def migrate(entry, storage):
userdata["id"] = user.id userdata["id"] = user.id
userdata["createdAt"] = user.createdAt userdata["createdAt"] = user.createdAt
storage["users"]["by_slug"][userdata["slug"]] = userdata storage["users"]["by_slug"][userdata["slug"]] = userdata
storage["users"]["by_oid"][entry["_id"]] = userdata storage["users"]["by_oid"][oid] = userdata
if not user: if not user:
raise Exception("could not get a user") raise Exception("could not get a user")
shout_dict["authors"] = [user, ] return user
try:
await create_shout(shout_dict, userslug)
except IntegrityError as e: async def resolve_create_shout(shout_dict, userslug):
with local_session() as session: with local_session() as session:
s = session.query(Shout).filter(Shout.slug == shout_dict["slug"]).first() s = session.query(Shout).filter(Shout.slug == shout_dict["slug"]).first()
bump = False bump = False
@ -253,23 +291,20 @@ async def migrate(entry, storage):
s.update(shout_dict) s.update(shout_dict)
else: else:
print("[migration] something went wrong with shout: \n%r" % shout_dict) print("[migration] something went wrong with shout: \n%r" % shout_dict)
raise e raise Exception("")
session.commit() session.commit()
except Exception as e:
print(e)
print(s)
raise Exception
# shout topics aftermath
shout_dict["topics"] = [] async def topics_aftermath(entry, storage):
for tpc in r["topics"]: r = []
for tpc in filter(lambda x: bool(x), entry["topics"]):
oldslug = tpc oldslug = tpc
newslug = storage["replacements"].get(oldslug, oldslug) newslug = storage["replacements"].get(oldslug, oldslug)
if newslug: if newslug:
with local_session() as session: with local_session() as session:
shout_topic_old = ( shout_topic_old = (
session.query(ShoutTopic) session.query(ShoutTopic)
.filter(ShoutTopic.shout == shout_dict["slug"]) .filter(ShoutTopic.shout == entry["slug"])
.filter(ShoutTopic.topic == oldslug) .filter(ShoutTopic.topic == oldslug)
.first() .first()
) )
@ -278,25 +313,27 @@ async def migrate(entry, storage):
else: else:
shout_topic_new = ( shout_topic_new = (
session.query(ShoutTopic) session.query(ShoutTopic)
.filter(ShoutTopic.shout == shout_dict["slug"]) .filter(ShoutTopic.shout == entry["slug"])
.filter(ShoutTopic.topic == newslug) .filter(ShoutTopic.topic == newslug)
.first() .first()
) )
if not shout_topic_new: if not shout_topic_new:
try: try:
ShoutTopic.create( ShoutTopic.create(
**{"shout": shout_dict["slug"], "topic": newslug} **{"shout": entry["slug"], "topic": newslug}
) )
except Exception: except Exception:
print("[migration] shout topic error: " + newslug) print("[migration] shout topic error: " + newslug)
session.commit() session.commit()
if newslug not in shout_dict["topics"]: if newslug not in r:
shout_dict["topics"].append(newslug) r.append(newslug)
else: else:
print("[migration] ignored topic slug: \n%r" % tpc["slug"]) print("[migration] ignored topic slug: \n%r" % tpc["slug"])
# raise Exception # raise Exception
return r
# content_item ratings to reactions
async def content_ratings_to_reactions(entry, slug):
try: try:
with local_session() as session: with local_session() as session:
for content_rating in entry.get("ratings", []): for content_rating in entry.get("ratings", []):
@ -316,7 +353,7 @@ async def migrate(entry, storage):
if content_rating["value"] > 0 if content_rating["value"] > 0
else ReactionKind.DISLIKE, else ReactionKind.DISLIKE,
"createdBy": reactedBy.slug, "createdBy": reactedBy.slug,
"shout": shout_dict["slug"], "shout": slug,
} }
cts = content_rating.get("createdAt") cts = content_rating.get("createdAt")
if cts: if cts:
@ -340,11 +377,3 @@ async def migrate(entry, storage):
session.commit() session.commit()
except Exception: except Exception:
raise Exception("[migration] content_item.ratings error: \n%r" % content_rating) raise Exception("[migration] content_item.ratings error: \n%r" % content_rating)
# shout views
await ViewedStorage.increment(shout_dict["slug"], amount=entry.get("views", 1))
# del shout_dict['ratings']
shout_dict["oid"] = entry.get("_id")
storage["shouts"]["by_oid"][entry["_id"]] = shout_dict
storage["shouts"]["by_slug"][slug] = shout_dict
return shout_dict

View File

@ -547,6 +547,7 @@
"poetry-slam": "poetry-slam", "poetry-slam": "poetry-slam",
"pokoy": "peace", "pokoy": "peace",
"police": "police", "police": "police",
"politicheskoe-fentezi": "political-fantasy",
"politics": "politics", "politics": "politics",
"politzaklyuchennye": "political-prisoners", "politzaklyuchennye": "political-prisoners",
"polsha": "poland", "polsha": "poland",

View File

@ -1,5 +1,6 @@
from base.orm import local_session from base.orm import local_session
from migration.extract import extract_md, html2text from migration.extract import extract_md
from migration.html2text import html2text
from orm import Topic from orm import Topic

View File

@ -17,7 +17,7 @@ def migrate(entry):
"username": email, "username": email,
"email": email, "email": email,
"createdAt": parse(entry["createdAt"]), "createdAt": parse(entry["createdAt"]),
"emailConfirmed": bool(entry["emails"][0]["verified"]), "emailConfirmed": ("@discours.io" in email) or bool(entry["emails"][0]["verified"]),
"muted": False, # amnesty "muted": False, # amnesty
"bio": entry["profile"].get("bio", ""), "bio": entry["profile"].get("bio", ""),
"notifications": [], "notifications": [],

View File

@ -5,7 +5,7 @@
{{ $upstream_port := index $port_map_list 2 }} {{ $upstream_port := index $port_map_list 2 }}
map $http_origin $allow_origin { map $http_origin $allow_origin {
~^https?:\/\/((.*\.)?localhost(:\d+)?|discoursio-webapp(-(.*))?\.vercel\.app|(.*\.)?discours\.io)$ $http_origin; ~^https?:\/\/((.*\.)?localhost(:\d+)?|discoursio-webapp-git(.*)?\.vercel\.app|(.*\.)?discours\.io(:\d+)?)$ $http_origin;
default ""; default "";
} }
@ -114,7 +114,7 @@ server {
{{ if $.PROXY_X_FORWARDED_SSL }}proxy_set_header X-Forwarded-Ssl {{ $.PROXY_X_FORWARDED_SSL }};{{ end }} {{ if $.PROXY_X_FORWARDED_SSL }}proxy_set_header X-Forwarded-Ssl {{ $.PROXY_X_FORWARDED_SSL }};{{ end }}
if ($request_method = 'OPTIONS') { if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$allow_origin' always; add_header 'Access-Control-Allow-Origin' $allow_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
# #
# Custom headers and headers various browsers *should* be OK with but aren't # Custom headers and headers various browsers *should* be OK with but aren't
@ -131,7 +131,7 @@ server {
} }
if ($request_method = 'POST') { if ($request_method = 'POST') {
add_header 'Access-Control-Allow-Origin' '$allow_origin' always; add_header 'Access-Control-Allow-Origin' $allow_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
@ -139,7 +139,7 @@ server {
} }
if ($request_method = 'GET') { if ($request_method = 'GET') {
add_header 'Access-Control-Allow-Origin' '$allow_origin' always; add_header 'Access-Control-Allow-Origin' $allow_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;

View File

@ -49,8 +49,8 @@ from resolvers.zine.load import (
from resolvers.inbox.chats import ( from resolvers.inbox.chats import (
create_chat, create_chat,
delete_chat, delete_chat,
update_chat, update_chat
invite_to_chat
) )
from resolvers.inbox.messages import ( from resolvers.inbox.messages import (
create_message, create_message,
@ -112,7 +112,6 @@ __all__ = [
# inbox # inbox
"load_chats", "load_chats",
"load_messages_by", "load_messages_by",
"invite_to_chat",
"create_chat", "create_chat",
"delete_chat", "delete_chat",
"update_chat", "update_chat",

View File

@ -13,12 +13,12 @@ from auth.identity import Identity, Password
from auth.jwtcodec import JWTCodec from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage from auth.tokenstorage import TokenStorage
from base.exceptions import (BaseHttpException, InvalidPassword, InvalidToken, from base.exceptions import (BaseHttpException, InvalidPassword, InvalidToken,
ObjectNotExist, OperationNotAllowed) ObjectNotExist, OperationNotAllowed, Unauthorized)
from base.orm import local_session from base.orm import local_session
from base.resolvers import mutation, query from base.resolvers import mutation, query
from orm import Role, User from orm import Role, User
from resolvers.zine.profile import user_subscriptions from resolvers.zine.profile import user_subscriptions
from settings import SESSION_TOKEN_HEADER from settings import SESSION_TOKEN_HEADER, FRONTEND_URL
@mutation.field("getSession") @mutation.field("getSession")
@ -37,7 +37,7 @@ async def get_current_user(_, info):
"news": await user_subscriptions(user.slug), "news": await user_subscriptions(user.slug),
} }
else: else:
raise OperationNotAllowed("No session token present in request, try to login") raise Unauthorized("No session token present in request, try to login")
@mutation.field("confirmEmail") @mutation.field("confirmEmail")
@ -75,7 +75,7 @@ async def confirm_email_handler(request):
if "error" in res: if "error" in res:
raise BaseHttpException(res['error']) raise BaseHttpException(res['error'])
else: else:
response = RedirectResponse(url="https://new.discours.io") response = RedirectResponse(url=FRONTEND_URL)
response.set_cookie("token", res["token"]) # session token response.set_cookie("token", res["token"]) # session token
return response return response
@ -133,7 +133,7 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str
@mutation.field("sendLink") @mutation.field("sendLink")
async def auth_send_link(_, _info, email, lang="ru"): async def auth_send_link(_, _info, email, lang="ru", template="email_confirmation"):
"""send link with confirm code to email""" """send link with confirm code to email"""
with local_session() as session: with local_session() as session:
user = session.query(User).filter(User.email == email).first() user = session.query(User).filter(User.email == email).first()
@ -141,7 +141,7 @@ async def auth_send_link(_, _info, email, lang="ru"):
raise ObjectNotExist("User not found") raise ObjectNotExist("User not found")
else: else:
token = await TokenStorage.create_onetime(user) token = await TokenStorage.create_onetime(user)
await send_auth_email(user, token, lang) await send_auth_email(user, token, lang, template)
return user return user

View File

@ -7,43 +7,6 @@ from base.redis import redis
from base.resolvers import mutation from base.resolvers import mutation
async def add_user_to_chat(user_slug: str, chat_id: str, chat=None):
for member in chat["users"]:
chats_ids = await redis.execute("GET", f"chats_by_user/{member}")
if chats_ids:
chats_ids = list(json.loads(chats_ids))
else:
chats_ids = []
if chat_id not in chats_ids:
chats_ids.append(chat_id)
await redis.execute("SET", f"chats_by_user/{member}", json.dumps(chats_ids))
@mutation.field("inviteChat")
async def invite_to_chat(_, info, invited: str, chat_id: str):
''' invite user with :slug to chat with :chat_id '''
user = info.context["request"].user
chat = await redis.execute("GET", f"chats/{chat_id}")
if not chat:
return {
"error": "chat not exist"
}
chat = dict(json.loads(chat))
if not chat['private'] and user.slug not in chat['admins']:
return {
"error": "only admins can invite to private chat",
"chat": chat
}
else:
chat["users"].append(invited)
await add_user_to_chat(user.slug, chat_id, chat)
await redis.execute("SET", f"chats/{chat_id}", json.dumps(chat))
return {
"error": None,
"chat": chat
}
@mutation.field("updateChat") @mutation.field("updateChat")
@login_required @login_required
async def update_chat(_, info, chat_new: dict): async def update_chat(_, info, chat_new: dict):
@ -68,12 +31,11 @@ async def update_chat(_, info, chat_new: dict):
"title": chat_new.get("title", chat["title"]), "title": chat_new.get("title", chat["title"]),
"description": chat_new.get("description", chat["description"]), "description": chat_new.get("description", chat["description"]),
"updatedAt": int(datetime.now(tz=timezone.utc).timestamp()), "updatedAt": int(datetime.now(tz=timezone.utc).timestamp()),
"admins": chat_new.get("admins", chat["admins"]), "admins": chat_new.get("admins", chat.get("admins") or []),
"users": chat_new.get("users", chat["users"]) "users": chat_new.get("users", chat["users"])
}) })
await add_user_to_chat(user.slug, chat_id, chat)
await redis.execute("SET", f"chats/{chat.id}", json.dumps(chat)) await redis.execute("SET", f"chats/{chat.id}", json.dumps(chat))
await redis.execute("SET", f"chats/{chat.id}/next_message_id", 0) await redis.execute("COMMIT")
return { return {
"error": None, "error": None,
@ -85,23 +47,43 @@ async def update_chat(_, info, chat_new: dict):
@login_required @login_required
async def create_chat(_, info, title="", members=[]): async def create_chat(_, info, title="", members=[]):
user = info.context["request"].user user = info.context["request"].user
chat_id = str(uuid.uuid4()) chat = {}
if user.slug not in members: if user.slug not in members:
members.append(user.slug) members.append(user.slug)
chat = {
"title": title, # reuse chat craeted before if exists
"createdAt": int(datetime.now(tz=timezone.utc).timestamp()), if len(members) == 2 and title == "":
"updatedAt": int(datetime.now(tz=timezone.utc).timestamp()), chats1 = await redis.execute("SMEMBERS", f"chats_by_user/{members[0].slug}")
"createdBy": user.slug, chats2 = await redis.execute("SMEMBERS", f"chats_by_user/{members[1].slug}")
"id": chat_id, chat = None
"users": members, for c in chats1.intersection(chats2):
"admins": [user.slug, ] chat = await redis.execute("GET", f"chats/{c.decode('utf-8')}")
if chat:
chat = json.loads(chat)
if chat.title == "":
break
if chat:
return {
"chat": chat,
"error": "existed"
} }
await add_user_to_chat(user.slug, chat_id, chat) chat_id = str(uuid.uuid4())
chat = {
"id": chat_id,
"users": members,
"title": title,
"createdBy": user.slug,
"createdAt": int(datetime.now(tz=timezone.utc).timestamp()),
"updatedAt": int(datetime.now(tz=timezone.utc).timestamp()),
"admins": []
}
for m in members:
await redis.execute("SADD", f"chats_by_user/{m}", chat_id)
await redis.execute("SET", f"chats/{chat_id}", json.dumps(chat)) 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("SET", f"chats/{chat_id}/next_message_id", str(0))
await redis.execute("COMMIT")
return { return {
"error": None, "error": None,
"chat": chat "chat": chat
@ -117,6 +99,8 @@ async def delete_chat(_, info, chat_id: str):
chat = dict(json.loads(chat)) chat = dict(json.loads(chat))
if user.slug in chat['admins']: if user.slug in chat['admins']:
await redis.execute("DEL", f"chats/{chat_id}") await redis.execute("DEL", f"chats/{chat_id}")
await redis.execute("SREM", "chats_by_user/" + user, chat_id)
await redis.execute("COMMIT")
else: else:
return { return {
"error": "chat not exist" "error": "chat not exist"

View File

@ -5,89 +5,120 @@ from auth.authenticate import login_required
from base.redis import redis from base.redis import redis
from base.orm import local_session from base.orm import local_session
from base.resolvers import query from base.resolvers import query
from base.exceptions import ObjectNotExist, Unauthorized
from orm.user import User from orm.user import User
from resolvers.zine.profile import followed_authors from resolvers.zine.profile import followed_authors
from .unread import get_unread_counter from .unread import get_unread_counter
async def load_messages(chatId: str, limit: int, offset: int): async def load_messages(chat_id: str, limit: int, offset: int):
''' load :limit messages for :chatId with :offset ''' ''' load :limit messages for :chat_id with :offset '''
messages = [] messages = []
message_ids = await redis.lrange( message_ids = await redis.lrange(
f"chats/{chatId}/message_ids", 0 - offset - limit, 0 - offset f"chats/{chat_id}/message_ids", offset + limit, offset
) )
if message_ids: if message_ids:
message_keys = [ message_keys = [
f"chats/{chatId}/messages/{mid}" for mid in message_ids f"chats/{chat_id}/messages/{mid}" for mid in message_ids
] ]
messages = await redis.mget(*message_keys) messages = await redis.mget(*message_keys)
messages = [json.loads(msg) for msg in messages] messages = [json.loads(msg) for msg in messages]
return { return messages
"messages": messages,
"error": None
}
@query.field("loadChats") @query.field("loadChats")
@login_required @login_required
async def load_chats(_, info, limit: int, offset: int): 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 """
user = info.context["request"].user user = info.context["request"].user
if user: if user:
chats = await redis.execute("GET", f"chats_by_user/{user.slug}") print('[inbox] load user\'s chats %s' % user.slug)
if chats: else:
chats = list(json.loads(chats))[offset:offset + limit] raise Unauthorized("Please login to load chats")
if not chats: cids = await redis.execute("SMEMBERS", "chats_by_user/" + user.slug)
if cids:
cids = list(cids)[offset:offset + limit]
if not cids:
print('[inbox.load] no chats were found')
cids = []
chats = [] chats = []
for c in chats: for cid in cids:
c['messages'] = await load_messages(c['id'], limit, offset) c = await redis.execute("GET", "chats/" + cid.decode("utf-8"))
c['unread'] = await get_unread_counter(c['id'], user.slug) if c:
c = dict(json.loads(c))
c['messages'] = await load_messages(cid, 5, 0)
c['unread'] = await get_unread_counter(cid, user.slug)
with local_session() as session:
c['members'] = []
for userslug in c["users"]:
a = session.query(User).where(User.slug == userslug).first().dict()
c['members'].append({
"slug": userslug,
"userpic": a["userpic"],
"name": a["name"],
"lastSeen": a["lastSeen"],
})
chats.append(c)
return { return {
"chats": chats, "chats": chats,
"error": None "error": None
} }
async def search_user_chats(by, messages: set, slug: str, limit, offset):
cids = set([])
by_author = by.get('author')
body_like = by.get('body')
cids.unioin(set(await redis.execute("SMEMBERS", "chats_by_user/" + slug)))
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:
messages.union(set(await load_messages(c, limit, offset)))
if body_like:
# search in all messages in all user's chats
for c in cids:
# FIXME: user redis scan here
mmm = set(await load_messages(c, limit, offset))
for m in mmm:
if body_like in m["body"]:
messages.add(m)
else: else:
return { # search in chat's messages
"error": "please login", messages.union(set(filter(lambda m: body_like in m["body"], list(messages))))
"chats": [] return messages
}
@query.field("loadMessagesBy") @query.field("loadMessagesBy")
@login_required @login_required
async def load_messages_by(_, info, by, limit: int = 50, offset: int = 0): async def load_messages_by(_, info, by, limit: int = 10, offset: int = 0):
''' load :amolimitunt messages of :chat_id with :offset ''' ''' load :limit messages of :chat_id with :offset '''
user = info.context["request"].user messages = set([])
my_chats = await redis.execute("GET", f"chats_by_user/{user.slug}") by_chat = by.get('chat')
chat_id = by.get('chat') if by_chat:
if chat_id: chat = await redis.execute("GET", f"chats/{by_chat}")
chat = await redis.execute("GET", f"chats/{chat_id}")
if not chat: if not chat:
return { raise ObjectNotExist("Chat not exists")
"error": "chat not exist" # everyone's messages in filtered chat
} messages.union(set(await load_messages(by_chat, limit, offset)))
messages = await load_messages(chat_id, limit, offset)
user_id = by.get('author') user = info.context["request"].user
if user_id: if user and len(messages) == 0:
chats = await redis.execute("GET", f"chats_by_user/{user_id}") messages.union(search_user_chats(by, messages, user.slug, limit, offset))
our_chats = list(set(chats) & set(my_chats))
for c in our_chats:
messages += await load_messages(c, limit, offset)
body_like = by.get('body')
if body_like:
for c in my_chats:
mmm = await load_messages(c, limit, offset)
for m in mmm:
if body_like in m["body"]:
messages.append(m)
days = by.get("days") days = by.get("days")
if days: if days:
messages = filter( messages.union(set(filter(
lambda m: datetime.now(tz=timezone.utc) - int(m["createdAt"]) < timedelta(days=by.get("days")), lambda m: datetime.now(tz=timezone.utc) - int(m["createdAt"]) < timedelta(days=by.get("days")),
messages list(messages)
) )))
return { return {
"messages": messages, "messages": sorted(
lambda m: m.createdAt,
list(messages)
),
"error": None "error": None
} }

View File

@ -17,6 +17,6 @@ async def get_total_unread_counter(user_slug: str):
if chats: if chats:
chats = json.loads(chats) chats = json.loads(chats)
for chat_id in chats: for chat_id in chats:
n = await get_unread_counter(chat_id, user_slug) n = await get_unread_counter(chat_id.decode('utf-8'), user_slug)
unread += n unread += n
return unread return unread

View File

@ -44,8 +44,11 @@ def apply_filters(q, filters, user=None):
filters = {} if filters is None else filters filters = {} if filters is None else filters
if filters.get("reacted") and user: if filters.get("reacted") and user:
q.join(Reaction, Reaction.createdBy == user.slug) q.join(Reaction, Reaction.createdBy == user.slug)
if filters.get("visibility"): v = filters.get("visibility")
if v == "public":
q = q.filter(Shout.visibility == filters.get("visibility")) q = q.filter(Shout.visibility == filters.get("visibility"))
if v == "community":
q = q.filter(Shout.visibility.in_(["public", "community"]))
if filters.get("layout"): if filters.get("layout"):
q = q.filter(Shout.layout == filters.get("layout")) q = q.filter(Shout.layout == filters.get("layout"))
if filters.get("author"): if filters.get("author"):
@ -74,7 +77,6 @@ def add_stat_columns(q):
async def load_shout(_, info, slug): async def load_shout(_, info, slug):
with local_session() as session: with local_session() as session:
q = select(Shout).options( q = select(Shout).options(
# TODO add cation
joinedload(Shout.authors), joinedload(Shout.authors),
joinedload(Shout.topics), joinedload(Shout.topics),
) )

View File

@ -13,21 +13,18 @@ from orm.user import AuthorFollower, Role, User, UserRating, UserRole
# from .community import followed_communities # from .community import followed_communities
from resolvers.inbox.unread import get_total_unread_counter from resolvers.inbox.unread import get_total_unread_counter
from .topics import get_topic_stat
async def user_subscriptions(slug: str): async def user_subscriptions(slug: str):
return { return {
"unread": await get_total_unread_counter(slug), # unread inbox messages counter "unread": await get_total_unread_counter(slug), # unread inbox messages counter
"topics": [t.slug for t in await followed_topics(slug)], # followed topics slugs "topics": [t.slug for t in await followed_topics(slug)], # followed topics slugs
"authors": [a.slug for a in await followed_authors(slug)], # followed authors slugs "authors": [a.slug for a in await followed_authors(slug)], # followed authors slugs
"reactions": await ReactedStorage.get_shouts_by_author(slug), "reactions": await followed_reactions(slug)
# "communities": [c.slug for c in followed_communities(slug)], # communities # "communities": [c.slug for c in followed_communities(slug)], # communities
} }
async def get_author_stat(slug): async def get_author_stat(slug):
# TODO: implement author stat
with local_session() as session: with local_session() as session:
return { return {
"shouts": session.query(ShoutAuthor).where(ShoutAuthor.user == slug).count(), "shouts": session.query(ShoutAuthor).where(ShoutAuthor.user == slug).count(),
@ -39,11 +36,29 @@ async def get_author_stat(slug):
).where( ).where(
Reaction.createdBy == slug Reaction.createdBy == slug
).filter( ).filter(
func.length(Reaction.body) > 0 Reaction.body.is_not(None)
).count() ).count()
} }
# @query.field("userFollowedDiscussions")
@login_required
async def followed_discussions(_, info, slug) -> List[Topic]:
return await followed_reactions(slug)
async def followed_reactions(slug):
with local_session() as session:
user = session.query(User).where(User.slug == slug).first()
return session.query(
Reaction.shout
).where(
Reaction.createdBy == slug
).filter(
Reaction.createdAt > user.lastSeen
).all()
@query.field("userFollowedTopics") @query.field("userFollowedTopics")
@login_required @login_required
async def get_followed_topics(_, info, slug) -> List[Topic]: async def get_followed_topics(_, info, slug) -> List[Topic]:

View File

@ -146,7 +146,11 @@ async def create_reaction(_, info, inp):
except Exception as e: except Exception as e:
print(f"[resolvers.reactions] error on reactions autofollowing: {e}") print(f"[resolvers.reactions] error on reactions autofollowing: {e}")
reaction.stat = await get_reaction_stat(reaction.id) reaction.stat = {
"commented": 0,
"reacted": 0,
"rating": 0
}
return {"reaction": reaction} return {"reaction": reaction}
@ -158,11 +162,16 @@ async def update_reaction(_, info, inp):
with local_session() as session: with local_session() as session:
user = session.query(User).where(User.id == user_id).first() user = session.query(User).where(User.id == user_id).first()
reaction = session.query(Reaction).filter(Reaction.id == inp.id).first() q = select(Reaction).filter(Reaction.id == inp.id)
q = calc_reactions(q)
[reaction, rating, commented, reacted] = session.execute(q).unique().one()
if not reaction: if not reaction:
return {"error": "invalid reaction id"} return {"error": "invalid reaction id"}
if reaction.createdBy != user.slug: if reaction.createdBy != user.slug:
return {"error": "access denied"} return {"error": "access denied"}
reaction.body = inp["body"] reaction.body = inp["body"]
reaction.updatedAt = datetime.now(tz=timezone.utc) reaction.updatedAt = datetime.now(tz=timezone.utc)
if reaction.kind != inp["kind"]: if reaction.kind != inp["kind"]:
@ -171,8 +180,11 @@ async def update_reaction(_, info, inp):
if inp.get("range"): if inp.get("range"):
reaction.range = inp.get("range") reaction.range = inp.get("range")
session.commit() session.commit()
reaction.stat = {
reaction.stat = await get_reaction_stat(reaction.id) "commented": commented,
"reacted": reacted,
"rating": rating
}
return {"reaction": reaction} return {"reaction": reaction}
@ -195,9 +207,11 @@ async def delete_reaction(_, info, rid):
def map_result_item(result_item): def map_result_item(result_item):
reaction = result_item[0] [user, shout, reaction] = result_item
user = result_item[1] print(reaction)
reaction.createdBy = user reaction.createdBy = user
reaction.shout = shout
reaction.replyTo = reaction
return reaction return reaction
@ -220,10 +234,17 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
""" """
CreatedByUser = aliased(User) CreatedByUser = aliased(User)
ReactedShout = aliased(Shout)
RepliedReaction = aliased(Reaction)
q = select( q = select(
Reaction, CreatedByUser Reaction, CreatedByUser, ReactedShout, RepliedReaction
).join(CreatedByUser, Reaction.createdBy == CreatedByUser.slug) ).join(
CreatedByUser, Reaction.createdBy == CreatedByUser.slug
).join(
ReactedShout, Reaction.shout == ReactedShout.slug
).join(
RepliedReaction, Reaction.replyTo == RepliedReaction.id
)
if by.get("shout"): if by.get("shout"):
q = q.filter(Reaction.shout == by["shout"]) q = q.filter(Reaction.shout == by["shout"])
@ -243,18 +264,26 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
order_way = asc if by.get("sort", "").startswith("-") else desc order_way = asc if by.get("sort", "").startswith("-") else desc
order_field = by.get("sort") or Reaction.createdAt order_field = by.get("sort") or Reaction.createdAt
q = q.group_by( q = q.group_by(
Reaction.id, CreatedByUser.id Reaction.id, CreatedByUser.id, ReactedShout.id
).order_by( ).order_by(
order_way(order_field) order_way(order_field)
) )
q = calc_reactions(q)
q = q.where(Reaction.deletedAt.is_(None)) q = q.where(Reaction.deletedAt.is_(None))
q = q.limit(limit).offset(offset) q = q.limit(limit).offset(offset)
reactions = []
with local_session() as session: with local_session() as session:
reactions = list(map(map_result_item, session.execute(q))) for [
for reaction in reactions: [reaction, rating, commented, reacted], shout, reply
reaction.stat = await get_reaction_stat(reaction.id) ] in list(map(map_result_item, session.execute(q))):
reaction.shout = shout
reaction.replyTo = reply
reaction.stat = {
"rating": rating,
"commented": commented,
"reacted": reacted
}
reactions.append(reaction)
if by.get("stat"): if by.get("stat"):
reactions.sort(lambda r: r.stat.get(by["stat"]) or r.createdAt) reactions.sort(lambda r: r.stat.get(by["stat"]) or r.createdAt)

View File

@ -3,23 +3,14 @@ from sqlalchemy import and_, select
from auth.authenticate import login_required from auth.authenticate import login_required
from base.orm import local_session from base.orm import local_session
from base.resolvers import mutation, query from base.resolvers import mutation, query
from orm import Shout
from orm.topic import Topic, TopicFollower from orm.topic import Topic, TopicFollower
# from services.stat.reacted import ReactedStorage from orm import Shout
# from services.stat.viewed import ViewedStorage
async def get_topic_stat(slug): async def get_topic_stat(slug):
return { return {
"shouts": len(TopicStat.shouts_by_topic.get(slug, {}).keys()), "shouts": len(TopicStat.shouts_by_topic.get(slug, {}).keys()),
"authors": len(TopicStat.authors_by_topic.get(slug, {}).keys()), "authors": len(TopicStat.authors_by_topic.get(slug, {}).keys()),
"followers": len(TopicStat.followers_by_topic.get(slug, {}).keys()), "followers": len(TopicStat.followers_by_topic.get(slug, {}).keys())
# "viewed": await ViewedStorage.get_topic(slug),
# "reacted": len(await ReactedStorage.get_topic(slug)),
# "commented": len(await ReactedStorage.get_topic_comments(slug)),
# "rating": await ReactedStorage.get_topic_rating(slug)
} }
@ -96,11 +87,12 @@ async def topic_follow(user, slug):
async def topic_unfollow(user, slug): async def topic_unfollow(user, slug):
with local_session() as session: with local_session() as session:
sub = ( sub = (
session.query(TopicFollower) session.query(TopicFollower).filter(
.filter( and_(
and_(TopicFollower.follower == user.slug, TopicFollower.topic == slug) TopicFollower.follower == user.slug,
TopicFollower.topic == slug
) )
.first() ).first()
) )
if not sub: if not sub:
raise Exception("[resolvers.topics] follower not exist") raise Exception("[resolvers.topics] follower not exist")

View File

@ -29,9 +29,9 @@ type ChatMember {
name: String! name: String!
userpic: String userpic: String
lastSeen: DateTime lastSeen: DateTime
invitedAt: DateTime # invitedAt: DateTime
invitedBy: String # user slug # invitedBy: String # user slug
# TODO: add more # TODO: keep invite databit
} }
type AuthorStat { type AuthorStat {
@ -151,7 +151,6 @@ type Mutation {
createChat(title: String, members: [String]!): Result! createChat(title: String, members: [String]!): Result!
updateChat(chat: ChatInput!): Result! updateChat(chat: ChatInput!): Result!
deleteChat(chatId: String!): Result! deleteChat(chatId: String!): Result!
inviteChat(chatId: String!, userslug: String!): Result!
createMessage(chat: String!, body: String!, replyTo: String): Result! createMessage(chat: String!, body: String!, replyTo: String): Result!
updateMessage(chatId: String!, id: Int!, body: String!): Result! updateMessage(chatId: String!, id: Int!, body: String!): Result!
@ -161,7 +160,7 @@ type Mutation {
# auth # auth
getSession: AuthResult! getSession: AuthResult!
registerUser(email: String!, password: String, name: String): AuthResult! registerUser(email: String!, password: String, name: String): AuthResult!
sendLink(email: String!, lang: String): Result! sendLink(email: String!, lang: String, template: String): Result!
confirmEmail(token: String!): AuthResult! confirmEmail(token: String!): AuthResult!
# shout # shout
@ -440,7 +439,7 @@ type Shout {
deletedBy: User deletedBy: User
publishedBy: User publishedBy: User
publishedAt: DateTime publishedAt: DateTime
media: String media: String # json [ { title pic url body }, .. ]
stat: Stat stat: Stat
} }
@ -515,13 +514,14 @@ type Message {
type Chat { type Chat {
id: String! id: String!
createdAt: Int! createdAt: Int!
createdBy: User! createdBy: String!
updatedAt: Int! updatedAt: Int!
title: String title: String
description: String description: String
users: [User]! users: [String]
admins: [User] members: [ChatMember]
messages: [Message]! admins: [String]
messages: [Message]
unread: Int unread: Int
private: Boolean private: Boolean
} }

View File

@ -20,11 +20,13 @@ class SearchService:
cached = await redis.execute("GET", text) cached = await redis.execute("GET", text)
if not cached: if not cached:
async with SearchService.lock: async with SearchService.lock:
by = { options = {
"title": text, "title": text,
"body": text "body": text,
"limit": limit,
"offset": offset
} }
payload = await load_shouts_by(None, None, by, limit, offset) payload = await load_shouts_by(None, None, options)
await redis.execute("SET", text, json.dumps(payload)) await redis.execute("SET", text, json.dumps(payload))
return payload return payload
else: else:

View File

@ -76,9 +76,9 @@ class ViewedStorage:
self.client = create_client({ self.client = create_client({
"Authorization": "Bearer %s" % str(token) "Authorization": "Bearer %s" % str(token)
}, schema=schema_str) }, schema=schema_str)
print("[stat.viewed] authorized permanentely by ackee.discours.io: %s" % token) print("[stat.viewed] * authorized permanentely by ackee.discours.io: %s" % token)
else: else:
print("[stat.viewed] please set ACKEE_TOKEN") print("[stat.viewed] * please set ACKEE_TOKEN")
self.disabled = True self.disabled = True
@staticmethod @staticmethod
@ -86,11 +86,10 @@ class ViewedStorage:
""" query all the pages from ackee sorted by views count """ """ query all the pages from ackee sorted by views count """
start = time.time() start = time.time()
self = ViewedStorage self = ViewedStorage
async with self.lock:
try: try:
self.pages = await self.client.execute_async(load_pages) self.pages = await self.client.execute_async(load_pages)
self.pages = self.pages["domains"][0]["statistics"]["pages"] self.pages = self.pages["domains"][0]["statistics"]["pages"]
print("[stat.viewed] ackee pages updated") print("[stat.viewed] ackee pages updated")
shouts = {} shouts = {}
try: try:
for page in self.pages: for page in self.pages:
@ -101,12 +100,12 @@ class ViewedStorage:
await ViewedStorage.increment(slug, v) await ViewedStorage.increment(slug, v)
except Exception: except Exception:
pass pass
print("[stat.viewed] %d pages collected " % len(shouts.keys())) print("[stat.viewed] %d pages collected " % len(shouts.keys()))
except Exception as e: except Exception as e:
raise e raise e
end = time.time() end = time.time()
print("[stat.viewed] update_pages took %fs " % (end - start)) print("[stat.viewed] update_pages took %fs " % (end - start))
@staticmethod @staticmethod
async def get_facts(): async def get_facts():
@ -180,21 +179,22 @@ class ViewedStorage:
async with self.lock: async with self.lock:
while True: while True:
try: try:
print("[stat.viewed] ⎧ updating views...")
await self.update_pages() await self.update_pages()
failed = 0 failed = 0
except Exception: except Exception:
failed += 1 failed += 1
print("[stat.viewed] update failed #%d, wait 10 seconds" % failed) print("[stat.viewed] update failed #%d, wait 10 seconds" % failed)
if failed > 3: if failed > 3:
print("[stat.viewed] not trying to update anymore") print("[stat.viewed] not trying to update anymore")
break break
if failed == 0: if failed == 0:
when = datetime.now(timezone.utc) + timedelta(seconds=self.period) when = datetime.now(timezone.utc) + timedelta(seconds=self.period)
t = format(when.astimezone().isoformat()) t = format(when.astimezone().isoformat())
print("[stat.viewed] next update: %s" % ( print("[stat.viewed] next update: %s" % (
t.split("T")[0] + " " + t.split("T")[1].split(".")[0] t.split("T")[0] + " " + t.split("T")[1].split(".")[0]
)) ))
await asyncio.sleep(self.period) await asyncio.sleep(self.period)
else: else:
await asyncio.sleep(10) await asyncio.sleep(10)
print("[stat.viewed] trying to update data again...") print("[stat.viewed] trying to update data again...")

View File

@ -0,0 +1,49 @@
import asyncio
import time
from base.orm import local_session
from orm.shout import ShoutAuthor
class ShoutAuthorStorage:
authors_by_shout = {}
lock = asyncio.Lock()
# period = 30 * 60 # sec
@staticmethod
async def load_captions(session):
self = ShoutAuthorStorage
sas = session.query(ShoutAuthor).all()
for sa in sas:
self.authors_by_shout[sa.shout] = self.authors_by_shout.get(sa.shout, {})
self.authors_by_shout[sa.shout][sa.user] = sa.caption
print("[zine.authors] ⎧ %d shouts indexed by authors" % len(self.authors_by_shout))
@staticmethod
async def get_author_caption(shout, author):
self = ShoutAuthorStorage
async with self.lock:
return self.authors_by_shout.get(shout, {}).get(author)
@staticmethod
async def set_author_caption(shout, author, caption):
self = ShoutAuthorStorage
async with self.lock:
self.authors_by_shout[shout] = self.authors_by_shout.get(shout, {})
self.authors_by_shout[shout][author] = caption
return {
"error": None,
}
@staticmethod
async def worker():
self = ShoutAuthorStorage
async with self.lock:
# while True:
try:
with local_session() as session:
ts = time.time()
await self.load_captions(session)
print("[zine.authors] ⎩ load_captions took %fs " % (time.time() - ts))
except Exception as err:
print("[zine.authors] ⎩ error indexing by author: %s" % (err))
# await asyncio.sleep(self.period)

97
services/zine/topics.py Normal file
View File

@ -0,0 +1,97 @@
import asyncio
from base.orm import local_session
from orm.topic import Topic
from orm.shout import Shout
import sqlalchemy as sa
from sqlalchemy import select
class TopicStorage:
topics = {}
lock = asyncio.Lock()
random_topics = []
@staticmethod
def init(session):
self = TopicStorage
topics = session.query(Topic)
self.topics = dict([(topic.slug, topic) for topic in topics])
for tpc in self.topics.values():
# self.load_parents(tpc)
pass
print("[zine.topics] %d precached" % len(self.topics.keys()))
# @staticmethod
# def load_parents(topic):
# self = TopicStorage
# parents = []
# for parent in self.topics.values():
# if topic.slug in parent.children:
# parents.append(parent.slug)
# topic.parents = parents
# return topic
@staticmethod
def get_random_topics(amount):
return TopicStorage.random_topics[0:amount]
@staticmethod
def renew_topics_random():
with local_session() as session:
q = select(Topic).join(Shout).group_by(Topic.id).having(sa.func.count(Shout.id) > 2).order_by(
sa.func.random()).limit(50)
TopicStorage.random_topics = list(map(
lambda result_item: result_item.Topic, session.execute(q)
))
@staticmethod
async def worker():
self = TopicStorage
async with self.lock:
while True:
try:
self.renew_topics_random()
except Exception as err:
print("[zine.topics] error %s" % (err))
await asyncio.sleep(300) # 5 mins
@staticmethod
async def get_topics_all():
self = TopicStorage
async with self.lock:
return list(self.topics.values())
@staticmethod
async def get_topics_by_slugs(slugs):
self = TopicStorage
async with self.lock:
if not slugs:
return self.topics.values()
topics = filter(lambda topic: topic.slug in slugs, self.topics.values())
return list(topics)
@staticmethod
async def get_topics_by_community(community):
self = TopicStorage
async with self.lock:
topics = filter(
lambda topic: topic.community == community, self.topics.values()
)
return list(topics)
@staticmethod
async def get_topics_by_author(author):
self = TopicStorage
async with self.lock:
topics = filter(
lambda topic: topic.community == author, self.topics.values()
)
return list(topics)
@staticmethod
async def update_topic(topic):
self = TopicStorage
async with self.lock:
self.topics[topic.slug] = topic
# self.load_parents(topic)

View File

@ -22,7 +22,7 @@ for provider in OAUTH_PROVIDERS:
"id": environ.get(provider + "_OAUTH_ID"), "id": environ.get(provider + "_OAUTH_ID"),
"key": environ.get(provider + "_OAUTH_KEY"), "key": environ.get(provider + "_OAUTH_KEY"),
} }
FRONTEND_URL = environ.get("FRONTEND_URL") or "http://localhost:3000"
SHOUTS_REPO = "content" SHOUTS_REPO = "content"
SESSION_TOKEN_HEADER = "Authorization" SESSION_TOKEN_HEADER = "Authorization"