Merge pull request #46 from Discours/prepare-comments

Prepare comments WIP
This commit is contained in:
Tony 2022-11-24 11:25:23 +03:00 committed by GitHub
commit 1bb13eb1e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 217 additions and 175 deletions

3
.gitignore vendored
View File

@ -146,4 +146,5 @@ migration/content/**/*.md
dump
.vscode
*dump.sql
*.csv
*.csv
dev-server-status.txt

View File

@ -9,7 +9,7 @@ from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials, AuthUser
from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage
from base.exceptions import InvalidToken
from base.exceptions import ExpiredToken, InvalidToken
from services.auth.users import UserStorage
from settings import SESSION_TOKEN_HEADER
@ -28,16 +28,17 @@ class SessionToken:
token is of specified type
"""
try:
print('[auth.authenticate] session token verify')
payload = JWTCodec.decode(token)
except ExpiredSignatureError:
payload = JWTCodec.decode(token, verify_exp=False)
if not await cls.get(payload.user_id, token):
raise InvalidToken("Session token has expired, please try again")
raise ExpiredToken("Token signature has expired, please try again")
except DecodeError as e:
raise InvalidToken("token format error") from e
else:
if not await cls.get(payload.user_id, token):
raise InvalidToken("Session token has expired, please login again")
raise ExpiredToken("Session token has expired, please login again")
return payload
@classmethod
@ -58,6 +59,8 @@ class JWTAuthenticate(AuthenticationBackend):
try:
payload = await SessionToken.verify(token)
except Exception as exc:
print("[auth.authenticate] session token verify error")
print(exc)
return AuthCredentials(scopes=[], error_message=str(exc)), AuthUser(
user_id=None
)

View File

@ -81,6 +81,7 @@ class Identity:
@staticmethod
async def onetime(token: str) -> User:
try:
print('[auth.identity] using one time token')
payload = JWTCodec.decode(token)
if not await TokenStorage.exist(f"{payload.user_id}-{token}"):
raise InvalidToken("Login token has expired, please login again")

View File

@ -8,10 +8,8 @@ from settings import JWT_ALGORITHM, JWT_SECRET_KEY
class JWTCodec:
@staticmethod
def encode(user: AuthInput, exp: datetime) -> str:
issued = int(datetime.now().timestamp())
print('[jwtcodec] issued at %r' % issued)
expires = int(exp.timestamp())
print('[jwtcodec] expires at %r' % expires)
expires = int(exp.timestamp() * 1000)
issued = int(datetime.now().timestamp() * 1000)
payload = {
"user_id": user.id,
"username": user.email or user.phone,
@ -23,7 +21,7 @@ class JWTCodec:
try:
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
except Exception as e:
print('[jwtcodec] JWT encode error %r' % e)
print('[auth.jwtcodec] JWT encode error %r' % e)
@staticmethod
def decode(token: str, verify_exp: bool = True) -> TokenPayload:
@ -39,11 +37,13 @@ class JWTCodec:
issuer="discours"
)
r = TokenPayload(**payload)
print('[jwtcodec] debug payload %r' % r)
print('[auth.jwtcodec] debug payload %r' % r)
return r
except jwt.InvalidIssuedAtError:
print('[auth.jwtcodec] invalid issued at: %r' % r)
raise ExpiredToken('check token issued time')
except jwt.ExpiredSignatureError:
print('[auth.jwtcodec] expired signature %r' % r)
raise ExpiredToken('check token lifetime')
except jwt.InvalidTokenError:
raise InvalidToken('token is not valid')

View File

@ -36,7 +36,9 @@ class TokenStorage:
@staticmethod
async def revoke(token: str) -> bool:
payload = None
try:
print("[auth.tokenstorage] revoke token")
payload = JWTCodec.decode(token)
except: # noqa
pass

View File

@ -1,5 +1,5 @@
from aioredis import from_url
from asyncio import sleep
from settings import REDIS_URL
@ -21,7 +21,12 @@ class RedisCache:
self._instance = None
async def execute(self, command, *args, **kwargs):
return await self._instance.execute_command(command, *args, **kwargs)
while not self._instance:
await sleep(1)
try:
await self._instance.execute_command(command, *args, **kwargs)
except Exception:
pass
async def lrange(self, key, start, stop):
return await self._instance.lrange(key, start, stop)

21
main.py
View File

@ -1,6 +1,6 @@
import asyncio
from importlib import import_module
from os.path import exists
from ariadne import load_schema_from_path, make_executable_schema
from ariadne.asgi import GraphQL
from starlette.applications import Starlette
@ -21,6 +21,8 @@ from services.stat.topicstat import TopicStat
from services.stat.viewed import ViewedStorage
from services.zine.gittask import GitTask
from services.zine.shoutauthor import ShoutAuthorStorage
from settings import DEV_SERVER_STATUS_FILE_NAME
import_module("resolvers")
schema = make_executable_schema(load_schema_from_path("schema.graphql"), resolvers) # type: ignore
@ -45,6 +47,15 @@ async def start_up():
git_task = asyncio.create_task(GitTask.git_task_worker())
print(git_task)
async def dev_start_up():
if exists(DEV_SERVER_STATUS_FILE_NAME):
return
else:
with open(DEV_SERVER_STATUS_FILE_NAME, 'w', encoding='utf-8') as f:
f.write('running')
await start_up()
async def shutdown():
await redis.disconnect()
@ -64,3 +75,11 @@ app = Starlette(
routes=routes,
)
app.mount("/", GraphQL(schema, debug=True))
dev_app = app = Starlette(
debug=True,
on_startup=[dev_start_up],
middleware=middleware,
routes=routes,
)
dev_app.mount("/", GraphQL(schema, debug=True))

View File

@ -42,6 +42,7 @@ async def get_current_user(_, info):
async def confirm_email(_, info, token):
"""confirm owning email address"""
try:
print('[resolvers.auth] confirm email by token')
payload = JWTCodec.decode(token)
user_id = payload.user_id
await TokenStorage.get(f"{user_id}-{token}")
@ -175,7 +176,7 @@ async def login(_, info, email: str, password: str = "", lang: str = "ru"):
}
except InvalidPassword:
print(f"[auth] {email}: invalid password")
raise InvalidPassword("invalid passoword") # contains webserver status
raise InvalidPassword("invalid password") # contains webserver status
# return {"error": "invalid password"}

View File

@ -33,18 +33,24 @@ async def load_messages(chatId: str, limit: int, offset: int):
async def load_chats(_, info, limit: int, offset: int):
""" load :limit chats of current user with :offset """
user = info.context["request"].user
chats = await redis.execute("GET", f"chats_by_user/{user.slug}")
if chats:
chats = list(json.loads(chats))[offset:offset + limit]
if not chats:
chats = []
for c in chats:
c['messages'] = await load_messages(c['id'], limit, offset)
c['unread'] = await get_unread_counter(c['id'], user.slug)
return {
"chats": chats,
"error": None
}
if user:
chats = await redis.execute("GET", f"chats_by_user/{user.slug}")
if chats:
chats = list(json.loads(chats))[offset:offset + limit]
if not chats:
chats = []
for c in chats:
c['messages'] = await load_messages(c['id'], limit, offset)
c['unread'] = await get_unread_counter(c['id'], user.slug)
return {
"chats": chats,
"error": None
}
else:
return {
"error": "please login",
"chats": []
}
@query.field("loadMessagesBy")

View File

@ -1,6 +1,6 @@
from datetime import datetime, timedelta
import sqlalchemy as sa
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import desc, asc, select, case
from base.orm import local_session
from base.resolvers import query
@ -32,42 +32,13 @@ def apply_filters(q, filters, user=None):
return q
def extract_order(o, q):
if o:
q = q.add_columns(sa.func.count(Reaction.id).label(o))
if o == 'comments':
q = q.join(Reaction, Shout.slug == Reaction.shout)
q = q.filter(Reaction.body.is_not(None))
elif o == 'reacted':
q = q.join(
Reaction
).add_columns(
sa.func.max(Reaction.createdAt).label(o)
)
elif o == "rating":
q = q.join(Reaction).add_columns(sa.func.sum(case(
(Reaction.kind == ReactionKind.AGREE, 1),
(Reaction.kind == ReactionKind.DISAGREE, -1),
(Reaction.kind == ReactionKind.PROOF, 1),
(Reaction.kind == ReactionKind.DISPROOF, -1),
(Reaction.kind == ReactionKind.ACCEPT, 1),
(Reaction.kind == ReactionKind.REJECT, -1),
(Reaction.kind == ReactionKind.LIKE, 1),
(Reaction.kind == ReactionKind.DISLIKE, -1),
else_=0
)).label(o))
return o
else:
return 'createdAt'
@query.field("loadShout")
async def load_shout(_, info, slug):
with local_session() as session:
shout = session.query(Shout).options(
# TODO add cation
selectinload(Shout.authors),
selectinload(Shout.topics),
joinedload(Shout.authors),
joinedload(Shout.topics),
).filter(
Shout.slug == slug
).filter(
@ -77,6 +48,12 @@ async def load_shout(_, info, slug):
return shout
def map_result_item(result_item):
shout = result_item[0]
shout.rating = result_item[1]
return shout
@query.field("loadShouts")
async def load_shouts_by(_, info, options):
"""
@ -100,16 +77,40 @@ async def load_shouts_by(_, info, options):
"""
q = select(Shout).options(
# TODO add caption
selectinload(Shout.authors),
selectinload(Shout.topics),
joinedload(Shout.authors),
joinedload(Shout.topics),
).where(
Shout.deletedAt.is_(None)
)
user = info.context["request"].user
q = apply_filters(q, options.get("filters"), user)
q = q.join(Reaction).add_columns(sa.func.sum(case(
(Reaction.kind == ReactionKind.AGREE, 1),
(Reaction.kind == ReactionKind.DISAGREE, -1),
(Reaction.kind == ReactionKind.PROOF, 1),
(Reaction.kind == ReactionKind.DISPROOF, -1),
(Reaction.kind == ReactionKind.ACCEPT, 1),
(Reaction.kind == ReactionKind.REJECT, -1),
(Reaction.kind == ReactionKind.LIKE, 1),
(Reaction.kind == ReactionKind.DISLIKE, -1),
else_=0
)).label('rating'))
order_by = extract_order(options.get("order_by"), q)
o = options.get("order_by")
if o:
q = q.add_columns(sa.func.count(Reaction.id).label(o))
if o == 'comments':
q = q.join(Reaction, Shout.slug == Reaction.shout)
q = q.filter(Reaction.body.is_not(None))
elif o == 'reacted':
q = q.join(
Reaction
).add_columns(
sa.func.max(Reaction.createdAt).label(o)
)
order_by = o
else:
order_by = Shout.createdAt
order_by_desc = True if options.get('order_by_desc') is None else options.get('order_by_desc')
@ -119,10 +120,13 @@ async def load_shouts_by(_, info, options):
q = q.group_by(Shout.id).order_by(query_order_by).limit(limit).offset(offset)
with local_session() as session:
shouts = list(map(lambda r: r.Shout, session.execute(q)))
for s in shouts:
s.stat = await ReactedStorage.get_shout_stat(s.slug)
for a in s.authors:
a.caption = await ShoutAuthorStorage.get_author_caption(s.slug, a.slug)
shouts = list(map(map_result_item, session.execute(q).unique()))
for shout in shouts:
shout.stat = await ReactedStorage.get_shout_stat(shout.slug, shout.rating)
del shout.rating
for author in shout.authors:
author.caption = await ShoutAuthorStorage.get_author_caption(shout.slug, author.slug)
return shouts

View File

@ -1,7 +1,7 @@
from datetime import datetime, timedelta
from sqlalchemy import and_, asc, desc, select, text, func
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import aliased
from auth.authenticate import login_required
from base.orm import local_session
from base.resolvers import mutation, query
@ -23,12 +23,10 @@ async def get_reaction_stat(reaction_id):
def reactions_follow(user: User, slug: str, auto=False):
with local_session() as session:
following = (
session.query(ShoutReactionsFollower)
.where(and_(
session.query(ShoutReactionsFollower).where(and_(
ShoutReactionsFollower.follower == user.slug,
ShoutReactionsFollower.shout == slug
))
.first()
)).first()
)
if not following:
following = ShoutReactionsFollower.create(
@ -43,12 +41,10 @@ def reactions_follow(user: User, slug: str, auto=False):
def reactions_unfollow(user, slug):
with local_session() as session:
following = (
session.query(ShoutReactionsFollower)
.where(and_(
session.query(ShoutReactionsFollower).where(and_(
ShoutReactionsFollower.follower == user.slug,
ShoutReactionsFollower.shout == slug
))
.first()
)).first()
)
if following:
session.delete(following)
@ -200,43 +196,23 @@ async def delete_reaction(_, info, rid):
return {}
def prepare_reactions(q, by):
""" query filters and order """
if by.get("shout"):
q = q.filter(Shout.slug == by["shout"])
elif by.get("shouts"):
q = q.filter(Shout.slug.in_(by["shouts"]))
if by.get("createdBy"):
q = q.filter(Reaction.createdBy == by.get("createdBy"))
if by.get("topic"):
q = q.filter(Shout.topics.contains(by["topic"]))
if by.get("body"):
if by["body"] is True:
q = q.filter(func.length(Reaction.body) > 0)
else:
q = q.filter(Reaction.body.ilike(f'%{by["body"]}%'))
if by.get("days"):
before = datetime.now() - timedelta(days=int(by["days"]) or 30)
q = q.filter(Reaction.createdAt > before)
order_way = asc if by.get("sort", "").startswith("-") else desc
order_field = by.get("sort") or Reaction.createdAt
q = q.group_by(
Reaction.id
).order_by(
order_way(order_field)
)
return q
def map_result_item(result_item):
reaction = result_item[0]
user = result_item[1]
reaction.createdBy = user
return reaction
@query.field("loadReactionsBy")
async def load_reactions_by(_, info, by, limit=50, offset=0):
async def load_reactions_by(_, _info, by, limit=50, offset=0):
"""
:param by: {
:shout - filter by slug
:shouts - filer by shouts luglist
:createdBy - to filter by author
:topic - to filter by topic
:body - to search by body
:search - to search by reactions' body
:comment - true if body.length > 0
:days - a number of days ago
:sort - a fieldname to sort desc by default
}
@ -244,33 +220,45 @@ async def load_reactions_by(_, info, by, limit=50, offset=0):
:param offset: int offset in this order
:return: Reaction[]
"""
user = None
try:
user = info.context["request"].user
except Exception:
pass
CreatedByUser = aliased(User)
q = select(
Reaction
).options(
selectinload(Reaction.createdBy),
selectinload(Reaction.shout)
).join(
User, Reaction.createdBy == User.slug
).join(
Shout, Reaction.shout == Shout.slug
).where(
Reaction.deletedAt.is_(None)
Reaction, CreatedByUser
).join(CreatedByUser, Reaction.createdBy == CreatedByUser.slug)
if by.get("shout"):
q = q.filter(Reaction.shout == by["shout"])
elif by.get("shouts"):
q = q.filter(Reaction.shout.in_(by["shouts"]))
if by.get("createdBy"):
q = q.filter(Reaction.createdBy == by.get("createdBy"))
if by.get("topic"):
q = q.filter(Shout.topics.contains(by["topic"]))
if by.get("comment"):
q = q.filter(func.length(Reaction.body) > 0)
if by.get('search', 0) > 2:
q = q.filter(Reaction.body.ilike(f'%{by["body"]}%'))
if by.get("days"):
after = datetime.now() - timedelta(days=int(by["days"]) or 30)
q = q.filter(Reaction.createdAt > after)
order_way = asc if by.get("sort", "").startswith("-") else desc
order_field = by.get("sort") or Reaction.createdAt
q = q.group_by(
Reaction.id, CreatedByUser.id
).order_by(
order_way(order_field)
)
q = prepare_reactions(q, by, user)
q = q.where(Reaction.deletedAt.is_(None))
q = q.limit(limit).offset(offset)
rrr = []
with local_session() as session:
# post query stats and author's captions
for r in list(map(lambda r: r.Reaction, session.execute(q))):
r.stat = await get_reaction_stat(r.id)
rrr.append(r)
reactions = list(map(map_result_item, session.execute(q)))
for reaction in reactions:
reaction.stat = await get_reaction_stat(reaction.id)
if by.get("stat"):
rrr.sort(lambda r: r.stat.get(by["stat"]) or r.createdAt)
return rrr
reactions.sort(lambda r: r.stat.get(by["stat"]) or r.createdAt)
return reactions

View File

@ -1,14 +1,15 @@
import random
from sqlalchemy import and_
import sqlalchemy as sa
from sqlalchemy import and_, select
from auth.authenticate import login_required
from base.orm import local_session
from base.resolvers import mutation, query
from orm import Shout
from orm.topic import Topic, TopicFollower
from services.zine.topics import TopicStorage
from services.stat.reacted import ReactedStorage
# from services.stat.reacted import ReactedStorage
from services.stat.topicstat import TopicStat
# from services.stat.viewed import ViewedStorage
@ -18,9 +19,9 @@ async def get_topic_stat(slug):
"authors": len(TopicStat.authors_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)
# "reacted": len(await ReactedStorage.get_topic(slug)),
# "commented": len(await ReactedStorage.get_topic_comments(slug)),
# "rating": await ReactedStorage.get_topic_rating(slug)
}
@ -98,10 +99,10 @@ async def topic_unfollow(user, slug):
with local_session() as session:
sub = (
session.query(TopicFollower)
.filter(
.filter(
and_(TopicFollower.follower == user.slug, TopicFollower.topic == slug)
)
.first()
.first()
)
if not sub:
raise Exception("[resolvers.topics] follower not exist")
@ -113,11 +114,8 @@ async def topic_unfollow(user, slug):
@query.field("topicsRandom")
async def topics_random(_, info, amount=12):
topics = await TopicStorage.get_topics_all()
normalized_topics = []
for topic in topics:
topic.stat = await get_topic_stat(topic.slug)
if topic.stat["shouts"] > 2:
normalized_topics.append(topic)
sample_length = min(len(normalized_topics), amount)
return random.sample(normalized_topics, sample_length)
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(amount)
random_topics = list(map(lambda result_item: result_item.Topic, session.execute(q)))
return random_topics

View File

@ -248,13 +248,14 @@ input LoadShoutsOptions {
}
input ReactionBy {
shout: String
shout: String # slug
shouts: [String]
body: String
topic: String
createdBy: String
days: Int
sort: String
search: String # fts on body
comment: Boolean
topic: String # topic.slug
createdBy: String # user.slug
days: Int # before
sort: String # how to sort, default createdAt
}
################################### Query
@ -476,9 +477,9 @@ type TopicStat {
followers: Int!
authors: Int!
# viewed: Int
reacted: Int!
commented: Int
rating: Int
# reacted: Int!
#commented: Int
# rating: Int
}
type Topic {

View File

@ -1,8 +1,8 @@
import sys
import os
import uvicorn
from settings import PORT
from settings import PORT, DEV_SERVER_STATUS_FILE_NAME
log_settings = {
'version': 1,
@ -54,6 +54,9 @@ if __name__ == "__main__":
x = sys.argv[1]
if x == "dev":
print("DEV MODE")
if os.path.exists(DEV_SERVER_STATUS_FILE_NAME):
os.remove(DEV_SERVER_STATUS_FILE_NAME)
headers = [
("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD"),
("Access-Control-Allow-Origin", "http://localhost:3000"),
@ -65,14 +68,15 @@ if __name__ == "__main__":
("Access-Control-Allow-Credentials", "true"),
]
uvicorn.run(
"main:app",
"main:dev_app",
host="localhost",
port=8080,
headers=headers,
# log_config=LOGGING_CONFIG,
log_level=None,
access_log=True
) # , ssl_keyfile="discours.key", ssl_certfile="discours.crt", reload=True)
access_log=False,
reload=True
) # , ssl_keyfile="discours.key", ssl_certfile="discours.crt")
elif x == "migrate":
from migration import migrate

View File

@ -23,10 +23,12 @@ class UserStorage:
async def get_user(id):
with local_session() as session:
user = (
session.query(User)
.options(selectinload(User.roles), selectinload(User.ratings))
.filter(User.id == id)
.one()
session.query(User).options(
selectinload(User.roles),
selectinload(User.ratings)
).filter(
User.id == id
).one()
)
return user

View File

@ -34,13 +34,15 @@ class ReactedStorage:
modified_shouts = set([])
@staticmethod
async def get_shout_stat(slug):
async def get_shout_stat(slug, rating):
viewed = int(await ViewedStorage.get_shout(slug))
# print(viewed)
return {
# TODO
"viewed": 0, # await ViewedStorage.get_shout(slug),
"viewed": viewed,
"reacted": len(await ReactedStorage.get_shout(slug)),
"commented": len(await ReactedStorage.get_comments(slug)),
"rating": await ReactedStorage.get_rating(slug),
# "rating": await ReactedStorage.get_rating(slug),
"rating": rating
}
@staticmethod

View File

@ -3,7 +3,7 @@ from datetime import timedelta, timezone, datetime
from gql import Client, gql
from gql.transport.aiohttp import AIOHTTPTransport
from base.orm import local_session
from sqlalchemy import func, select
from sqlalchemy import func
from orm.shout import ShoutTopic
from orm.viewed import ViewedEntry
from ssl import create_default_context
@ -119,12 +119,14 @@ class ViewedStorage:
if not shout_views:
shout_views = 0
with local_session() as session:
shout_views_q = select(func.sum(ViewedEntry.amount)).where(
ViewedEntry.shout == shout_slug
)
shout_views = session.execute(shout_views_q)
self.by_shouts[shout_slug] = shout_views
self.update_topics(session, shout_slug)
try:
shout_views = session.query(func.sum(ViewedEntry.amount)).where(
ViewedEntry.shout == shout_slug
).all()[0][0]
self.by_shouts[shout_slug] = shout_views
self.update_topics(session, shout_slug)
except Exception as e:
raise e
return shout_views

View File

@ -25,3 +25,6 @@ for provider in OAUTH_PROVIDERS:
SHOUTS_REPO = "content"
SESSION_TOKEN_HEADER = "Authorization"
# for local development
DEV_SERVER_STATUS_FILE_NAME = 'dev-server-status.txt'