init
This commit is contained in:
commit
393910be4c
45
orm/author.py
Normal file
45
orm/author.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorRating(Base):
|
||||||
|
__tablename__ = 'author_rating'
|
||||||
|
|
||||||
|
id = None # type: ignore
|
||||||
|
rater = Column(ForeignKey('author.id'), primary_key=True, index=True)
|
||||||
|
author = Column(ForeignKey('author.id'), primary_key=True, index=True)
|
||||||
|
plus = Column(Boolean)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorFollower(Base):
|
||||||
|
__tablename__ = 'author_follower'
|
||||||
|
|
||||||
|
id = None # type: ignore
|
||||||
|
follower = Column(ForeignKey('author.id'), primary_key=True, index=True)
|
||||||
|
author = Column(ForeignKey('author.id'), primary_key=True, index=True)
|
||||||
|
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
|
auto = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Author(Base):
|
||||||
|
__tablename__ = 'author'
|
||||||
|
|
||||||
|
user = Column(String, unique=True) # unbounded link with authorizer's User type
|
||||||
|
|
||||||
|
name = Column(String, nullable=True, comment='Display name')
|
||||||
|
slug = Column(String, unique=True, comment="Author's slug")
|
||||||
|
bio = Column(String, nullable=True, comment='Bio') # status description
|
||||||
|
about = Column(String, nullable=True, comment='About') # long and formatted
|
||||||
|
pic = Column(String, nullable=True, comment='Picture')
|
||||||
|
links = Column(JSON, nullable=True, comment='Links')
|
||||||
|
|
||||||
|
ratings = relationship(AuthorRating, foreign_keys=AuthorRating.author)
|
||||||
|
|
||||||
|
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
|
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
|
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
|
deleted_at = Column(Integer, nullable=True, comment='Deleted at')
|
25
orm/collection.py
Normal file
25
orm/collection.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||||
|
|
||||||
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ShoutCollection(Base):
|
||||||
|
__tablename__ = 'shout_collection'
|
||||||
|
|
||||||
|
id = None # type: ignore
|
||||||
|
shout = Column(ForeignKey('shout.id'), primary_key=True)
|
||||||
|
collection = Column(ForeignKey('collection.id'), primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Collection(Base):
|
||||||
|
__tablename__ = 'collection'
|
||||||
|
|
||||||
|
slug = Column(String, unique=True)
|
||||||
|
title = Column(String, nullable=False, comment='Title')
|
||||||
|
body = Column(String, nullable=True, comment='Body')
|
||||||
|
pic = Column(String, nullable=True, comment='Picture')
|
||||||
|
created_at = Column(Integer, default=lambda: int(time.time()))
|
||||||
|
created_by = Column(ForeignKey('author.id'), comment='Created By')
|
||||||
|
published_at = Column(Integer, default=lambda: int(time.time()))
|
41
orm/community.py
Normal file
41
orm/community.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from orm.author import Author
|
||||||
|
from services.db import Base, local_session
|
||||||
|
|
||||||
|
|
||||||
|
class CommunityAuthor(Base):
|
||||||
|
__tablename__ = 'community_author'
|
||||||
|
|
||||||
|
id = None # type: ignore
|
||||||
|
author = Column(ForeignKey('author.id'), primary_key=True)
|
||||||
|
community = Column(ForeignKey('community.id'), primary_key=True)
|
||||||
|
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
|
role = Column(String, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Community(Base):
|
||||||
|
__tablename__ = 'community'
|
||||||
|
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
slug = Column(String, nullable=False, unique=True)
|
||||||
|
desc = Column(String, nullable=False, default='')
|
||||||
|
pic = Column(String, nullable=False, default='')
|
||||||
|
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
|
|
||||||
|
authors = relationship(lambda: Author, secondary=CommunityAuthor.__tablename__)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def init_table():
|
||||||
|
with local_session('orm.community') as session:
|
||||||
|
d = session.query(Community).filter(Community.slug == 'discours').first()
|
||||||
|
if not d:
|
||||||
|
d = Community(name='Дискурс', slug='discours')
|
||||||
|
session.add(d)
|
||||||
|
session.commit()
|
||||||
|
print('[orm.community] created community %s' % d.slug)
|
||||||
|
Community.default_community = d
|
||||||
|
print('[orm.community] default community is %s' % d.slug)
|
27
orm/invite.py
Normal file
27
orm/invite.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
from enum import Enum as Enumeration
|
||||||
|
|
||||||
|
from sqlalchemy import Column, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from orm.author import Author
|
||||||
|
from orm.shout import Shout
|
||||||
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class InviteStatus(Enumeration):
|
||||||
|
PENDING = 'PENDING'
|
||||||
|
ACCEPTED = 'ACCEPTED'
|
||||||
|
REJECTED = 'REJECTED'
|
||||||
|
|
||||||
|
|
||||||
|
class Invite(Base):
|
||||||
|
__tablename__ = 'invite'
|
||||||
|
|
||||||
|
inviter_id = Column(ForeignKey('author.id'), nullable=False, index=True)
|
||||||
|
author_id = Column(ForeignKey('author.id'), nullable=False, index=True)
|
||||||
|
shout_id = Column(ForeignKey('shout.id'), nullable=False, index=True)
|
||||||
|
status = Column(String, default=InviteStatus.PENDING.value)
|
||||||
|
|
||||||
|
inviter = relationship(Author, foreign_keys=[inviter_id])
|
||||||
|
author = relationship(Author, foreign_keys=[author_id])
|
||||||
|
shout = relationship(Shout)
|
41
orm/notification.py
Normal file
41
orm/notification.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import time
|
||||||
|
from enum import Enum as Enumeration
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Column, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from orm.author import Author
|
||||||
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationEntity(Enumeration):
|
||||||
|
REACTION = 'reaction'
|
||||||
|
SHOUT = 'shout'
|
||||||
|
FOLLOWER = 'follower'
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationAction(Enumeration):
|
||||||
|
CREATE = 'create'
|
||||||
|
UPDATE = 'update'
|
||||||
|
DELETE = 'delete'
|
||||||
|
SEEN = 'seen'
|
||||||
|
FOLLOW = 'follow'
|
||||||
|
UNFOLLOW = 'unfollow'
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSeen(Base):
|
||||||
|
__tablename__ = 'notification_seen'
|
||||||
|
|
||||||
|
viewer = Column(ForeignKey('author.id'))
|
||||||
|
notification = Column(ForeignKey('notification.id'))
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base):
|
||||||
|
__tablename__ = 'notification'
|
||||||
|
|
||||||
|
created_at = Column(Integer, server_default=str(int(time.time())))
|
||||||
|
entity = Column(String, nullable=False)
|
||||||
|
action = Column(String, nullable=False)
|
||||||
|
payload = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
seen = relationship(lambda: Author, secondary='notification_seen')
|
43
orm/reaction.py
Normal file
43
orm/reaction.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import time
|
||||||
|
from enum import Enum as Enumeration
|
||||||
|
|
||||||
|
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||||
|
|
||||||
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionKind(Enumeration):
|
||||||
|
# TYPE = <reaction index> # rating diff
|
||||||
|
|
||||||
|
# editor mode
|
||||||
|
AGREE = 'AGREE' # +1
|
||||||
|
DISAGREE = 'DISAGREE' # -1
|
||||||
|
ASK = 'ASK' # +0
|
||||||
|
PROPOSE = 'PROPOSE' # +0
|
||||||
|
PROOF = 'PROOF' # +1
|
||||||
|
DISPROOF = 'DISPROOF' # -1
|
||||||
|
ACCEPT = 'ACCEPT' # +1
|
||||||
|
REJECT = 'REJECT' # -1
|
||||||
|
|
||||||
|
# public feed
|
||||||
|
QUOTE = 'QUOTE' # +0 TODO: use to bookmark in collection
|
||||||
|
COMMENT = 'COMMENT' # +0
|
||||||
|
LIKE = 'LIKE' # +1
|
||||||
|
DISLIKE = 'DISLIKE' # -1
|
||||||
|
|
||||||
|
|
||||||
|
class Reaction(Base):
|
||||||
|
__tablename__ = 'reaction'
|
||||||
|
|
||||||
|
body = Column(String, default='', comment='Reaction Body')
|
||||||
|
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
|
updated_at = Column(Integer, nullable=True, comment='Updated at')
|
||||||
|
deleted_at = Column(Integer, nullable=True, comment='Deleted at')
|
||||||
|
deleted_by = Column(ForeignKey('author.id'), nullable=True, index=True)
|
||||||
|
reply_to = Column(ForeignKey('reaction.id'), nullable=True)
|
||||||
|
quote = Column(String, nullable=True, comment='Original quoted text')
|
||||||
|
shout = Column(ForeignKey('shout.id'), nullable=False, index=True)
|
||||||
|
created_by = Column(ForeignKey('author.id'), nullable=False, index=True)
|
||||||
|
kind = Column(String, nullable=False, index=True)
|
||||||
|
|
||||||
|
oid = Column(String)
|
83
orm/shout.py
Normal file
83
orm/shout.py
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from orm.author import Author
|
||||||
|
from orm.community import Community
|
||||||
|
from orm.reaction import Reaction
|
||||||
|
from orm.topic import Topic
|
||||||
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
main = Column(Boolean, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoutReactionsFollower(Base):
|
||||||
|
__tablename__ = 'shout_reactions_followers'
|
||||||
|
|
||||||
|
id = None # type: ignore
|
||||||
|
follower = Column(ForeignKey('author.id'), primary_key=True, index=True)
|
||||||
|
shout = Column(ForeignKey('shout.id'), primary_key=True, index=True)
|
||||||
|
auto = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
|
deleted_at = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoutAuthor(Base):
|
||||||
|
__tablename__ = 'shout_author'
|
||||||
|
|
||||||
|
id = None # type: ignore
|
||||||
|
shout = Column(ForeignKey('shout.id'), primary_key=True, index=True)
|
||||||
|
author = Column(ForeignKey('author.id'), primary_key=True, index=True)
|
||||||
|
caption = Column(String, nullable=True, default='')
|
||||||
|
|
||||||
|
|
||||||
|
class ShoutCommunity(Base):
|
||||||
|
__tablename__ = 'shout_community'
|
||||||
|
|
||||||
|
id = None # type: ignore
|
||||||
|
shout = Column(ForeignKey('shout.id'), primary_key=True, index=True)
|
||||||
|
community = Column(ForeignKey('community.id'), primary_key=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Shout(Base):
|
||||||
|
__tablename__ = 'shout'
|
||||||
|
|
||||||
|
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
|
updated_at = Column(Integer, nullable=True)
|
||||||
|
published_at = Column(Integer, nullable=True)
|
||||||
|
featured_at = Column(Integer, nullable=True)
|
||||||
|
deleted_at = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
created_by = Column(ForeignKey('author.id'), nullable=False)
|
||||||
|
updated_by = Column(ForeignKey('author.id'), nullable=True)
|
||||||
|
deleted_by = Column(ForeignKey('author.id'), nullable=True)
|
||||||
|
|
||||||
|
body = Column(String, nullable=False, comment='Body')
|
||||||
|
slug = Column(String, unique=True)
|
||||||
|
cover = Column(String, nullable=True, comment='Cover image url')
|
||||||
|
cover_caption = Column(String, nullable=True, comment='Cover image alt caption')
|
||||||
|
lead = Column(String, nullable=True)
|
||||||
|
description = Column(String, nullable=True)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
subtitle = Column(String, nullable=True)
|
||||||
|
layout = Column(String, nullable=False, default='article')
|
||||||
|
media = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
authors = relationship(lambda: Author, secondary='shout_author')
|
||||||
|
topics = relationship(lambda: Topic, secondary='shout_topic')
|
||||||
|
communities = relationship(lambda: Community, secondary='shout_community')
|
||||||
|
reactions = relationship(lambda: Reaction)
|
||||||
|
|
||||||
|
lang = Column(String, nullable=False, default='ru', comment='Language')
|
||||||
|
version_of = Column(ForeignKey('shout.id'), nullable=True)
|
||||||
|
oid = Column(String, nullable=True)
|
||||||
|
|
||||||
|
seo = Column(String, nullable=True) # JSON
|
26
orm/topic.py
Normal file
26
orm/topic.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
|
||||||
|
|
||||||
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TopicFollower(Base):
|
||||||
|
__tablename__ = 'topic_followers'
|
||||||
|
|
||||||
|
id = None # type: ignore
|
||||||
|
follower = Column(ForeignKey('author.id'), primary_key=True, index=True)
|
||||||
|
topic = Column(ForeignKey('topic.id'), primary_key=True, index=True)
|
||||||
|
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
|
auto = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Topic(Base):
|
||||||
|
__tablename__ = 'topic'
|
||||||
|
|
||||||
|
slug = Column(String, unique=True)
|
||||||
|
title = Column(String, nullable=False, comment='Title')
|
||||||
|
body = Column(String, nullable=True, comment='Body')
|
||||||
|
pic = Column(String, nullable=True, comment='Picture')
|
||||||
|
community = Column(ForeignKey('community.id'), default=1)
|
||||||
|
oid = Column(String, nullable=True, comment='Old ID')
|
30
orm/user.py
Normal file
30
orm/user.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, Integer, String
|
||||||
|
|
||||||
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = 'authorizer_users'
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
|
||||||
|
key = Column(String)
|
||||||
|
email = Column(String, unique=True)
|
||||||
|
email_verified_at = Column(Integer)
|
||||||
|
family_name = Column(String)
|
||||||
|
gender = Column(String)
|
||||||
|
given_name = Column(String)
|
||||||
|
is_multi_factor_auth_enabled = Column(Boolean)
|
||||||
|
middle_name = Column(String)
|
||||||
|
nickname = Column(String)
|
||||||
|
password = Column(String)
|
||||||
|
phone_number = Column(String, unique=True)
|
||||||
|
phone_number_verified_at = Column(Integer)
|
||||||
|
# preferred_username = Column(String, nullable=False)
|
||||||
|
picture = Column(String)
|
||||||
|
revoked_timestamp = Column(Integer)
|
||||||
|
roles = Column(String, default='author, reader')
|
||||||
|
signup_methods = Column(String, default='magic_link_login')
|
||||||
|
created_at = Column(Integer, default=lambda: int(time.time()))
|
||||||
|
updated_at = Column(Integer, default=lambda: int(time.time()))
|
24
pyproject.toml
Normal file
24
pyproject.toml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
[tool.poetry]
|
||||||
|
name = "discoursio"
|
||||||
|
version = "0.3.0"
|
||||||
|
description = "shared code for discours.io"
|
||||||
|
authors = ["discours.io devteam"]
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.12"
|
||||||
|
SQLAlchemy = "^2.0.22"
|
||||||
|
psycopg2-binary = "^2.9.9"
|
||||||
|
redis = {extras = ["hiredis"], version = "^5.0.1"}
|
||||||
|
sentry-sdk = { version = "^1.4.1", extras = ["starlette", "aiohttp", "ariadne", "sqlalchemy"] }
|
||||||
|
starlette = "^0.36.1"
|
||||||
|
aiohttp = "^3.9.1"
|
||||||
|
google-analytics-data = "^0.18.3"
|
||||||
|
opensearch-py = "^2.4.2"
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
ruff = "^0.2.1"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
113
services/auth.py
Normal file
113
services/auth.py
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from settings import ADMIN_SECRET, AUTH_URL
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('\t[services.auth]\t')
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
async def request_data(gql, headers=None):
|
||||||
|
if headers is None:
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
try:
|
||||||
|
# Asynchronous HTTP request to the authentication server
|
||||||
|
async with ClientSession() as session:
|
||||||
|
async with session.post(AUTH_URL, json=gql, headers=headers) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
data = await response.json()
|
||||||
|
errors = data.get('errors')
|
||||||
|
if errors:
|
||||||
|
logger.error(f'HTTP Errors: {errors}')
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
# Handling and logging exceptions during authentication check
|
||||||
|
logger.error(f'[services.auth] request_data error: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_auth(req):
|
||||||
|
token = req.headers.get('Authorization')
|
||||||
|
user_id = ''
|
||||||
|
if token:
|
||||||
|
# Logging the authentication token
|
||||||
|
logger.debug(f'{token}')
|
||||||
|
query_name = 'validate_jwt_token'
|
||||||
|
operation = 'ValidateToken'
|
||||||
|
variables = {
|
||||||
|
'params': {
|
||||||
|
'token_type': 'access_token',
|
||||||
|
'token': token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gql = {
|
||||||
|
'query': f'query {operation}($params: ValidateJWTTokenInput!) {{'
|
||||||
|
+ f'{query_name}(params: $params) {{ is_valid claims }} '
|
||||||
|
+ '}',
|
||||||
|
'variables': variables,
|
||||||
|
'operationName': operation,
|
||||||
|
}
|
||||||
|
data = await request_data(gql)
|
||||||
|
if data:
|
||||||
|
user_data = data.get('data', {}).get(query_name, {}).get('claims', {})
|
||||||
|
user_id = user_data.get('sub')
|
||||||
|
user_roles = user_data.get('allowed_roles')
|
||||||
|
return [user_id, user_roles]
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail='Unauthorized')
|
||||||
|
|
||||||
|
|
||||||
|
async def add_user_role(user_id):
|
||||||
|
logger.info(f'[services.auth] add author role for user_id: {user_id}')
|
||||||
|
query_name = '_update_user'
|
||||||
|
operation = 'UpdateUserRoles'
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-authorizer-admin-secret': ADMIN_SECRET,
|
||||||
|
}
|
||||||
|
variables = {'params': {'roles': 'author, reader', 'id': user_id}}
|
||||||
|
gql = {
|
||||||
|
'query': f'mutation {operation}($params: UpdateUserInput!) {{ {query_name}(params: $params) {{ id roles }} }}',
|
||||||
|
'variables': variables,
|
||||||
|
'operationName': operation,
|
||||||
|
}
|
||||||
|
data = await request_data(gql, headers)
|
||||||
|
if data:
|
||||||
|
user_id = data.get('data', {}).get(query_name, {}).get('id')
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
async def decorated_function(*args, **kwargs):
|
||||||
|
info = args[1]
|
||||||
|
context = info.context
|
||||||
|
req = context.get('request')
|
||||||
|
[user_id, user_roles] = (await check_auth(req)) or []
|
||||||
|
if user_id and user_roles:
|
||||||
|
logger.info(f' got {user_id} roles: {user_roles}')
|
||||||
|
context['user_id'] = user_id.strip()
|
||||||
|
context['roles'] = user_roles
|
||||||
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def auth_request(f):
|
||||||
|
@wraps(f)
|
||||||
|
async def decorated_function(*args, **kwargs):
|
||||||
|
req = args[0]
|
||||||
|
[user_id, user_roles] = (await check_auth(req)) or []
|
||||||
|
if user_id:
|
||||||
|
req['user_id'] = user_id.strip()
|
||||||
|
req['roles'] = user_roles
|
||||||
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
153
services/core.py
Normal file
153
services/core.py
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from models.member import ChatMember
|
||||||
|
from settings import API_BASE
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('[services.core] ')
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: rewrite to orm usage
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_endpoint(query_name, body) -> Any:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(API_BASE, headers=headers, json=body) as response:
|
||||||
|
print(f'[services.core] {query_name} HTTP Response {response.status} {await response.text()}')
|
||||||
|
if response.status == 200:
|
||||||
|
r = await response.json()
|
||||||
|
if r:
|
||||||
|
return r.get('data', {}).get(query_name, {})
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def get_followed_shouts(author_id: int):
|
||||||
|
query_name = 'load_shouts_followed'
|
||||||
|
operation = 'GetFollowedShouts'
|
||||||
|
|
||||||
|
query = f"""query {operation}($author_id: Int!, limit: Int, offset: Int) {{
|
||||||
|
{query_name}(author_id: $author_id, limit: $limit, offset: $offset) {{ id slug title }}
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
gql = {
|
||||||
|
'query': query,
|
||||||
|
'operationName': operation,
|
||||||
|
'variables': {'author_id': author_id, 'limit': 1000, 'offset': 0}, # FIXME: too big limit
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _request_endpoint(query_name, gql)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_shout(shout_id):
|
||||||
|
query_name = 'get_shout'
|
||||||
|
operation = 'GetShout'
|
||||||
|
|
||||||
|
query = f"""query {operation}($slug: String, $shout_id: Int) {{
|
||||||
|
{query_name}(slug: $slug, shout_id: $shout_id) {{ id slug title authors {{ id slug name pic }} }}
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
gql = {'query': query, 'operationName': operation, 'variables': {'slug': None, 'shout_id': shout_id}}
|
||||||
|
|
||||||
|
return await _request_endpoint(query_name, gql)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_authors():
|
||||||
|
query_name = 'get_authors_all'
|
||||||
|
|
||||||
|
gql = {
|
||||||
|
'query': 'query { ' + query_name + '{ id slug pic name user } }',
|
||||||
|
'variables': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return _request_endpoint(query_name, gql)
|
||||||
|
|
||||||
|
|
||||||
|
def get_author_by_user(user: str):
|
||||||
|
operation = 'GetAuthorId'
|
||||||
|
query_name = 'get_author_id'
|
||||||
|
gql = {
|
||||||
|
'query': f'query {operation}($user: String!) {{ {query_name}(user: $user){{ id }} }}', # noqa E201, E202
|
||||||
|
'operationName': operation,
|
||||||
|
'variables': {'user': user.strip()},
|
||||||
|
}
|
||||||
|
|
||||||
|
return _request_endpoint(query_name, gql)
|
||||||
|
|
||||||
|
|
||||||
|
def get_my_followed() -> List[ChatMember]:
|
||||||
|
query_name = 'get_my_followed'
|
||||||
|
|
||||||
|
gql = {
|
||||||
|
'query': 'query { ' + query_name + ' { authors { id slug pic name } } }',
|
||||||
|
'variables': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _request_endpoint(query_name, gql)
|
||||||
|
return result.get('authors', [])
|
||||||
|
|
||||||
|
|
||||||
|
class CacheStorage:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
period = 5 * 60 # every 5 mins
|
||||||
|
client = None
|
||||||
|
authors = []
|
||||||
|
authors_by_user = {}
|
||||||
|
authors_by_id = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def init():
|
||||||
|
"""graphql client connection using permanent token"""
|
||||||
|
self = CacheStorage
|
||||||
|
async with self.lock:
|
||||||
|
task = asyncio.create_task(self.worker())
|
||||||
|
logger.info(task)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_authors():
|
||||||
|
self = CacheStorage
|
||||||
|
async with self.lock:
|
||||||
|
result = get_all_authors()
|
||||||
|
logger.info(f'cache loaded {len(result)}')
|
||||||
|
if result:
|
||||||
|
CacheStorage.authors = result
|
||||||
|
for a in result:
|
||||||
|
user_id = a.get('user')
|
||||||
|
author_id = str(a.get('id'))
|
||||||
|
self.authors_by_user[user_id] = a
|
||||||
|
self.authors_by_id[author_id] = a
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def worker():
|
||||||
|
"""async task worker"""
|
||||||
|
failed = 0
|
||||||
|
self = CacheStorage
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
logger.info(' - updating profiles data...')
|
||||||
|
await self.update_authors()
|
||||||
|
failed = 0
|
||||||
|
except Exception as er:
|
||||||
|
failed += 1
|
||||||
|
logger.error(f'{er} - update failed #{failed}, wait 10 seconds')
|
||||||
|
if failed > 3:
|
||||||
|
logger.error(' - not trying to update anymore')
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
break
|
||||||
|
if failed == 0:
|
||||||
|
when = datetime.now(timezone.utc) + timedelta(seconds=self.period)
|
||||||
|
t = format(when.astimezone().isoformat())
|
||||||
|
logger.info(' ⎩ next update: %s' % (t.split('T')[0] + ' ' + t.split('T')[1].split('.')[0]))
|
||||||
|
await asyncio.sleep(self.period)
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
logger.info(' - trying to update data again')
|
72
services/db.py
Normal file
72
services/db.py
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Dict, TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, create_engine, event
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.sql.schema import Table
|
||||||
|
|
||||||
|
from settings import DB_URL
|
||||||
|
|
||||||
|
|
||||||
|
# Настройка журнала
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
# Создание обработчика журнала для записи сообщений в файл
|
||||||
|
logger = logging.getLogger('sqlalchemy.profiler')
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(Engine, 'before_cursor_execute')
|
||||||
|
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||||
|
conn.info.setdefault('query_start_time', []).append(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(Engine, 'after_cursor_execute')
|
||||||
|
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||||
|
total = time.time() - conn.info['query_start_time'].pop(-1)
|
||||||
|
total = math.floor(total * 10000) / 10000
|
||||||
|
if total > 25:
|
||||||
|
logger.debug(f'Long running query: {statement}, Execution Time: {total} s')
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_engine(DB_URL, echo=False, pool_size=10, max_overflow=20)
|
||||||
|
T = TypeVar('T')
|
||||||
|
REGISTRY: Dict[str, type] = {}
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def local_session(src=''):
|
||||||
|
return Session(bind=engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(declarative_base()):
|
||||||
|
__table__: Table
|
||||||
|
__tablename__: str
|
||||||
|
__new__: Callable
|
||||||
|
__init__: Callable
|
||||||
|
__allow_unmapped__ = True
|
||||||
|
__abstract__ = True
|
||||||
|
__table_args__ = {'extend_existing': True}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs):
|
||||||
|
REGISTRY[cls.__name__] = cls
|
||||||
|
|
||||||
|
def dict(self) -> Dict[str, Any]:
|
||||||
|
column_names = self.__table__.columns.keys()
|
||||||
|
if '_sa_instance_state' in column_names:
|
||||||
|
column_names.remove('_sa_instance_state')
|
||||||
|
try:
|
||||||
|
return {c: getattr(self, c) for c in column_names}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error occurred while converting object to dictionary: {e}')
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def update(self, values: Dict[str, Any]) -> None:
|
||||||
|
for key, value in values.items():
|
||||||
|
if hasattr(self, key):
|
||||||
|
setattr(self, key, value)
|
47
services/diff.py
Normal file
47
services/diff.py
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import re
|
||||||
|
from difflib import ndiff
|
||||||
|
|
||||||
|
|
||||||
|
def get_diff(original, modified):
|
||||||
|
"""
|
||||||
|
Get the difference between two strings using difflib.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- original: The original string.
|
||||||
|
- modified: The modified string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of differences.
|
||||||
|
"""
|
||||||
|
diff = list(ndiff(original.split(), modified.split()))
|
||||||
|
return diff
|
||||||
|
|
||||||
|
|
||||||
|
def apply_diff(original, diff):
|
||||||
|
"""
|
||||||
|
Apply the difference to the original string.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- original: The original string.
|
||||||
|
- diff: The difference obtained from get_diff function.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The modified string.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
pattern = re.compile(r'^(\+|-) ')
|
||||||
|
|
||||||
|
for line in diff:
|
||||||
|
match = pattern.match(line)
|
||||||
|
if match:
|
||||||
|
op = match.group(1)
|
||||||
|
content = line[2:]
|
||||||
|
if op == '+':
|
||||||
|
result.append(content)
|
||||||
|
elif op == '-':
|
||||||
|
# Ignore deleted lines
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
result.append(line)
|
||||||
|
|
||||||
|
return ' '.join(result)
|
45
services/notify.py
Normal file
45
services/notify.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from services.rediscache import redis
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_reaction(reaction, action: str = 'create'):
|
||||||
|
channel_name = 'reaction'
|
||||||
|
data = {'payload': reaction, 'action': action}
|
||||||
|
try:
|
||||||
|
await redis.publish(channel_name, json.dumps(data))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[services.notify] Failed to publish to channel {channel_name}: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_shout(shout, action: str = 'update'):
|
||||||
|
channel_name = 'shout'
|
||||||
|
data = {'payload': shout, 'action': action}
|
||||||
|
try:
|
||||||
|
await redis.publish(channel_name, json.dumps(data))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[services.notify] Failed to publish to channel {channel_name}: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_follower(follower: dict, author_id: int, action: str = 'follow'):
|
||||||
|
channel_name = f'follower:{author_id}'
|
||||||
|
try:
|
||||||
|
# Simplify dictionary before publishing
|
||||||
|
simplified_follower = {k: follower[k] for k in ['id', 'name', 'slug', 'pic']}
|
||||||
|
|
||||||
|
data = {'payload': simplified_follower, 'action': action}
|
||||||
|
|
||||||
|
# Convert data to JSON string
|
||||||
|
json_data = json.dumps(data)
|
||||||
|
|
||||||
|
# Ensure the data is not empty before publishing
|
||||||
|
if not json_data:
|
||||||
|
raise ValueError('Empty data to publish.')
|
||||||
|
|
||||||
|
# Use the 'await' keyword when publishing
|
||||||
|
await redis.publish(channel_name, json_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log the error and re-raise it
|
||||||
|
print(f'[services.notify] Failed to publish to channel {channel_name}: {e}')
|
||||||
|
raise
|
24
services/presence.py
Normal file
24
services/presence.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from models.chat import ChatUpdate, Message
|
||||||
|
from services.rediscache import redis
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_message(message: Message, action='create'):
|
||||||
|
channel_name = f"message:{message['chat_id']}"
|
||||||
|
data = {'payload': message, 'action': action}
|
||||||
|
try:
|
||||||
|
await redis.publish(channel_name, json.dumps(data))
|
||||||
|
print(f'[services.presence] ok {data}')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Failed to publish to channel {channel_name}: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_chat(chat: ChatUpdate, member_id: int, action='create'):
|
||||||
|
channel_name = f'chat:{member_id}'
|
||||||
|
data = {'payload': chat, 'action': action}
|
||||||
|
try:
|
||||||
|
await redis.publish(channel_name, json.dumps(data))
|
||||||
|
print(f'[services.presence] ok {data}')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Failed to publish to channel {channel_name}: {e}')
|
59
services/rediscache.py
Normal file
59
services/rediscache.py
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import redis.asyncio as aredis
|
||||||
|
|
||||||
|
from settings import REDIS_URL
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('[services.redis] ')
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCache:
|
||||||
|
def __init__(self, uri=REDIS_URL):
|
||||||
|
self._uri: str = uri
|
||||||
|
self.pubsub_channels = []
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
self._client = aredis.Redis.from_url(self._uri, decode_responses=True)
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
if self._client:
|
||||||
|
await self._client.close()
|
||||||
|
|
||||||
|
async def execute(self, command, *args, **kwargs):
|
||||||
|
if self._client:
|
||||||
|
try:
|
||||||
|
logger.debug(f'{command} {args} {kwargs}')
|
||||||
|
r = await self._client.execute_command(command, *args, **kwargs)
|
||||||
|
logger.debug(type(r))
|
||||||
|
logger.debug(r)
|
||||||
|
return r
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
|
||||||
|
async def subscribe(self, *channels):
|
||||||
|
if self._client:
|
||||||
|
async with self._client.pubsub() as pubsub:
|
||||||
|
for channel in channels:
|
||||||
|
await pubsub.subscribe(channel)
|
||||||
|
self.pubsub_channels.append(channel)
|
||||||
|
|
||||||
|
async def unsubscribe(self, *channels):
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
async with self._client.pubsub() as pubsub:
|
||||||
|
for channel in channels:
|
||||||
|
await pubsub.unsubscribe(channel)
|
||||||
|
self.pubsub_channels.remove(channel)
|
||||||
|
|
||||||
|
async def publish(self, channel, data):
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
await self._client.publish(channel, data)
|
||||||
|
|
||||||
|
|
||||||
|
redis = RedisCache()
|
||||||
|
|
||||||
|
__all__ = ['redis']
|
184
services/search.py
Normal file
184
services/search.py
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from multiprocessing import Manager
|
||||||
|
|
||||||
|
from opensearchpy import OpenSearch
|
||||||
|
|
||||||
|
from services.rediscache import redis
|
||||||
|
|
||||||
|
|
||||||
|
os_logger = logging.getLogger(name='opensearch')
|
||||||
|
os_logger.setLevel(logging.INFO)
|
||||||
|
logger = logging.getLogger('\t[services.search]\t')
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
ELASTIC_HOST = os.environ.get('ELASTIC_HOST', '').replace('https://', '')
|
||||||
|
ELASTIC_USER = os.environ.get('ELASTIC_USER', '')
|
||||||
|
ELASTIC_PASSWORD = os.environ.get('ELASTIC_PASSWORD', '')
|
||||||
|
ELASTIC_PORT = os.environ.get('ELASTIC_PORT', 9200)
|
||||||
|
ELASTIC_AUTH = f'{ELASTIC_USER}:{ELASTIC_PASSWORD}' if ELASTIC_USER else ''
|
||||||
|
ELASTIC_URL = os.environ.get('ELASTIC_URL', f'https://{ELASTIC_AUTH}@{ELASTIC_HOST}:{ELASTIC_PORT}')
|
||||||
|
REDIS_TTL = 86400 # 1 day in seconds
|
||||||
|
|
||||||
|
|
||||||
|
index_settings = {
|
||||||
|
'settings': {
|
||||||
|
'index': {
|
||||||
|
'number_of_shards': 1,
|
||||||
|
'auto_expand_replicas': '0-all',
|
||||||
|
},
|
||||||
|
'analysis': {
|
||||||
|
'analyzer': {
|
||||||
|
'ru': {
|
||||||
|
'tokenizer': 'standard',
|
||||||
|
'filter': ['lowercase', 'ru_stop', 'ru_stemmer'],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'filter': {
|
||||||
|
'ru_stemmer': {
|
||||||
|
'type': 'stemmer',
|
||||||
|
'language': 'russian',
|
||||||
|
},
|
||||||
|
'ru_stop': {
|
||||||
|
'type': 'stop',
|
||||||
|
'stopwords': '_russian_',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'mappings': {
|
||||||
|
'properties': {
|
||||||
|
'body': {'type': 'text', 'analyzer': 'ru'},
|
||||||
|
'title': {'type': 'text', 'analyzer': 'ru'},
|
||||||
|
# 'author': {'type': 'text'},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_mapping = index_settings['mappings']
|
||||||
|
|
||||||
|
|
||||||
|
class SearchService:
|
||||||
|
def __init__(self, index_name='search_index'):
|
||||||
|
self.index_name = index_name
|
||||||
|
self.manager = Manager()
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
# Используем менеджер для создания Lock и Value
|
||||||
|
self.lock = self.manager.Lock()
|
||||||
|
self.initialized_flag = self.manager.Value('i', 0)
|
||||||
|
|
||||||
|
# Only initialize the instance if it's not already initialized
|
||||||
|
if not self.initialized_flag.value and ELASTIC_HOST:
|
||||||
|
try:
|
||||||
|
self.client = OpenSearch(
|
||||||
|
hosts=[{'host': ELASTIC_HOST, 'port': ELASTIC_PORT}],
|
||||||
|
http_compress=True,
|
||||||
|
http_auth=(ELASTIC_USER, ELASTIC_PASSWORD),
|
||||||
|
use_ssl=True,
|
||||||
|
verify_certs=False,
|
||||||
|
ssl_assert_hostname=False,
|
||||||
|
ssl_show_warn=False,
|
||||||
|
# ca_certs = ca_certs_path
|
||||||
|
)
|
||||||
|
logger.info(' Клиент OpenSearch.org подключен')
|
||||||
|
if self.lock.acquire(blocking=False):
|
||||||
|
try:
|
||||||
|
self.check_index()
|
||||||
|
finally:
|
||||||
|
self.lock.release()
|
||||||
|
else:
|
||||||
|
logger.debug(' проверка пропущена')
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f' {exc}')
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
def info(self):
|
||||||
|
if isinstance(self.client, OpenSearch):
|
||||||
|
logger.info(f' Поиск подключен: {self.client.info()}')
|
||||||
|
else:
|
||||||
|
logger.info(' * Задайте переменные среды для подключения к серверу поиска')
|
||||||
|
|
||||||
|
def delete_index(self):
|
||||||
|
if self.client:
|
||||||
|
logger.debug(f' Удаляем индекс {self.index_name}')
|
||||||
|
self.client.indices.delete(index=self.index_name, ignore_unavailable=True)
|
||||||
|
|
||||||
|
def create_index(self):
|
||||||
|
if self.client:
|
||||||
|
if self.lock.acquire(blocking=False):
|
||||||
|
try:
|
||||||
|
logger.debug(f' Создаём новый индекс: {self.index_name} ')
|
||||||
|
self.client.indices.create(index=self.index_name, body=index_settings)
|
||||||
|
self.client.indices.close(index=self.index_name)
|
||||||
|
self.client.indices.open(index=self.index_name)
|
||||||
|
finally:
|
||||||
|
self.lock.release()
|
||||||
|
else:
|
||||||
|
logger.debug(' ..')
|
||||||
|
|
||||||
|
def put_mapping(self):
|
||||||
|
if self.client:
|
||||||
|
logger.debug(f' Разметка индекации {self.index_name}')
|
||||||
|
self.client.indices.put_mapping(index=self.index_name, body=expected_mapping)
|
||||||
|
|
||||||
|
def check_index(self):
|
||||||
|
if self.client:
|
||||||
|
if not self.client.indices.exists(index=self.index_name):
|
||||||
|
self.create_index()
|
||||||
|
self.put_mapping()
|
||||||
|
else:
|
||||||
|
# Check if the mapping is correct, and recreate the index if needed
|
||||||
|
mapping = self.client.indices.get_mapping(index=self.index_name)
|
||||||
|
if mapping != expected_mapping:
|
||||||
|
self.recreate_index()
|
||||||
|
|
||||||
|
def recreate_index(self):
|
||||||
|
if self.lock.acquire(blocking=False):
|
||||||
|
try:
|
||||||
|
self.delete_index()
|
||||||
|
self.check_index()
|
||||||
|
finally:
|
||||||
|
self.lock.release()
|
||||||
|
else:
|
||||||
|
logger.debug(' ..')
|
||||||
|
|
||||||
|
def index(self, shout):
|
||||||
|
if self.client:
|
||||||
|
id_ = str(shout.id)
|
||||||
|
logger.debug(f' Индексируем пост {id_}')
|
||||||
|
self.client.index(index=self.index_name, id=id_, body=shout.dict())
|
||||||
|
|
||||||
|
async def search(self, text, limit, offset):
|
||||||
|
logger.debug(f' Ищем: {text}')
|
||||||
|
search_body = {
|
||||||
|
'query': {'match': {'_all': text}},
|
||||||
|
}
|
||||||
|
if self.client:
|
||||||
|
search_response = self.client.search(index=self.index_name, body=search_body, size=limit, from_=offset)
|
||||||
|
hits = search_response['hits']['hits']
|
||||||
|
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
**hit['_source'],
|
||||||
|
'score': hit['_score'],
|
||||||
|
}
|
||||||
|
for hit in hits
|
||||||
|
]
|
||||||
|
|
||||||
|
# Use Redis as cache with TTL
|
||||||
|
redis_key = f'search:{text}'
|
||||||
|
await redis.execute('SETEX', redis_key, REDIS_TTL, json.dumps(results))
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
search_service = SearchService()
|
||||||
|
|
||||||
|
|
||||||
|
async def search_text(text: str, limit: int = 50, offset: int = 0):
|
||||||
|
payload = []
|
||||||
|
if search_service.client:
|
||||||
|
# Use OpenSearchService.search_post method
|
||||||
|
payload = await search_service.search(text, limit, offset)
|
||||||
|
return payload
|
33
services/sentry.py
Normal file
33
services/sentry.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.aiohttp import AioHttpIntegration
|
||||||
|
from sentry_sdk.integrations.ariadne import AriadneIntegration
|
||||||
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
|
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
|
|
||||||
|
from settings import SENTRY_DSN
|
||||||
|
|
||||||
|
|
||||||
|
def start_sentry():
|
||||||
|
# sentry monitoring
|
||||||
|
try:
|
||||||
|
sentry_sdk.init(
|
||||||
|
SENTRY_DSN,
|
||||||
|
# Set traces_sample_rate to 1.0 to capture 100%
|
||||||
|
# of transactions for performance monitoring.
|
||||||
|
traces_sample_rate=1.0,
|
||||||
|
# Set profiles_sample_rate to 1.0 to profile 100%
|
||||||
|
# of sampled transactions.
|
||||||
|
# We recommend adjusting this value in production.
|
||||||
|
profiles_sample_rate=1.0,
|
||||||
|
enable_tracing=True,
|
||||||
|
integrations=[
|
||||||
|
StarletteIntegration(),
|
||||||
|
AriadneIntegration(),
|
||||||
|
SqlalchemyIntegration(),
|
||||||
|
# RedisIntegration(),
|
||||||
|
AioHttpIntegration()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print('[lib.services.sentry] init error')
|
||||||
|
print(e)
|
24
services/unread.py
Normal file
24
services/unread.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from services.rediscache import redis
|
||||||
|
|
||||||
|
|
||||||
|
async def get_unread_counter(chat_id: str, author_id: int) -> int:
|
||||||
|
r = await redis.execute('LLEN', f'chats/{chat_id}/unread/{author_id}')
|
||||||
|
if isinstance(r, str):
|
||||||
|
return int(r)
|
||||||
|
elif isinstance(r, int):
|
||||||
|
return r
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_total_unread_counter(author_id: int) -> int:
|
||||||
|
chats_set = await redis.execute('SMEMBERS', f'chats_by_author/{author_id}')
|
||||||
|
s = 0
|
||||||
|
if isinstance(chats_set, str):
|
||||||
|
chats_set = json.loads(chats_set)
|
||||||
|
if isinstance(chats_set, list):
|
||||||
|
for chat_id in chats_set:
|
||||||
|
s += await get_unread_counter(chat_id, author_id)
|
||||||
|
return s
|
213
services/viewed.py
Normal file
213
services/viewed.py
Normal file
|
@ -0,0 +1,213 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
# ga
|
||||||
|
from google.analytics.data_v1beta import BetaAnalyticsDataClient
|
||||||
|
from google.analytics.data_v1beta.types import (
|
||||||
|
DateRange,
|
||||||
|
Dimension,
|
||||||
|
Metric,
|
||||||
|
RunReportRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
from orm.author import Author
|
||||||
|
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
|
from orm.topic import Topic
|
||||||
|
from services.db import local_session
|
||||||
|
|
||||||
|
|
||||||
|
# Настройка журналирования
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
logger = logging.getLogger('\t[services.viewed]\t')
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
GOOGLE_KEYFILE_PATH = os.environ.get('GOOGLE_KEYFILE_PATH', '/dump/google-service.json')
|
||||||
|
GOOGLE_PROPERTY_ID = os.environ.get('GOOGLE_PROPERTY_ID', '')
|
||||||
|
VIEWS_FILEPATH = '/dump/views.json'
|
||||||
|
|
||||||
|
|
||||||
|
class ViewedStorage:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
views_by_shout = {}
|
||||||
|
shouts_by_topic = {}
|
||||||
|
shouts_by_author = {}
|
||||||
|
views = None
|
||||||
|
period = 60 * 60 # каждый час
|
||||||
|
analytics_client: BetaAnalyticsDataClient | None = None
|
||||||
|
auth_result = None
|
||||||
|
disabled = False
|
||||||
|
start_date = int(time.time())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def init():
|
||||||
|
"""Подключение к клиенту Google Analytics с использованием аутентификации"""
|
||||||
|
self = ViewedStorage
|
||||||
|
async with self.lock:
|
||||||
|
os.environ.setdefault('GOOGLE_APPLICATION_CREDENTIALS', GOOGLE_KEYFILE_PATH)
|
||||||
|
if GOOGLE_KEYFILE_PATH and os.path.isfile(GOOGLE_KEYFILE_PATH):
|
||||||
|
# Using a default constructor instructs the client to use the credentials
|
||||||
|
# specified in GOOGLE_APPLICATION_CREDENTIALS environment variable.
|
||||||
|
self.analytics_client = BetaAnalyticsDataClient()
|
||||||
|
logger.info(' * Клиент Google Analytics успешно авторизован')
|
||||||
|
|
||||||
|
# Загрузка предварительно подсчитанных просмотров из файла JSON
|
||||||
|
self.load_precounted_views()
|
||||||
|
|
||||||
|
if os.path.exists(VIEWS_FILEPATH):
|
||||||
|
file_timestamp = os.path.getctime(VIEWS_FILEPATH)
|
||||||
|
self.start_date = datetime.fromtimestamp(file_timestamp).strftime('%Y-%m-%d')
|
||||||
|
now_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
if now_date == self.start_date:
|
||||||
|
logger.info(' * Данные актуализованы!')
|
||||||
|
else:
|
||||||
|
logger.info(f' * Миграция проводилась: {self.start_date}')
|
||||||
|
|
||||||
|
# Запуск фоновой задачи
|
||||||
|
asyncio.create_task(self.worker())
|
||||||
|
else:
|
||||||
|
logger.info(' * Пожалуйста, добавьте ключевой файл Google Analytics')
|
||||||
|
self.disabled = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_precounted_views():
|
||||||
|
"""Загрузка предварительно подсчитанных просмотров из файла JSON"""
|
||||||
|
self = ViewedStorage
|
||||||
|
try:
|
||||||
|
with open(VIEWS_FILEPATH, 'r') as file:
|
||||||
|
precounted_views = json.load(file)
|
||||||
|
self.views_by_shout.update(precounted_views)
|
||||||
|
logger.info(f' * {len(precounted_views)} публикаций с просмотрами успешно загружены.')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Ошибка загрузки предварительно подсчитанных просмотров: {e}')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_pages():
|
||||||
|
"""Запрос всех страниц от Google Analytics, отсортированных по количеству просмотров"""
|
||||||
|
self = ViewedStorage
|
||||||
|
logger.info(' ⎧ Обновление данных просмотров от Google Analytics ---')
|
||||||
|
if not self.disabled:
|
||||||
|
try:
|
||||||
|
start = time.time()
|
||||||
|
async with self.lock:
|
||||||
|
if self.analytics_client:
|
||||||
|
request = RunReportRequest(
|
||||||
|
property=f'properties/{GOOGLE_PROPERTY_ID}',
|
||||||
|
dimensions=[Dimension(name='pagePath')],
|
||||||
|
metrics=[Metric(name='screenPageViews')],
|
||||||
|
date_ranges=[DateRange(start_date=self.start_date, end_date='today')],
|
||||||
|
)
|
||||||
|
response = self.analytics_client.run_report(request)
|
||||||
|
if response and isinstance(response.rows, list):
|
||||||
|
slugs = set()
|
||||||
|
for row in response.rows:
|
||||||
|
print(
|
||||||
|
row.dimension_values[0].value,
|
||||||
|
row.metric_values[0].value,
|
||||||
|
)
|
||||||
|
# Извлечение путей страниц из ответа Google Analytics
|
||||||
|
if isinstance(row.dimension_values, list):
|
||||||
|
page_path = row.dimension_values[0].value
|
||||||
|
slug = page_path.split('discours.io/')[-1]
|
||||||
|
views_count = int(row.metric_values[0].value)
|
||||||
|
|
||||||
|
# Обновление данных в хранилище
|
||||||
|
self.views_by_shout[slug] = self.views_by_shout.get(slug, 0)
|
||||||
|
self.views_by_shout[slug] += views_count
|
||||||
|
self.update_topics(slug)
|
||||||
|
|
||||||
|
# Запись путей страниц для логирования
|
||||||
|
slugs.add(slug)
|
||||||
|
|
||||||
|
logger.info(f' ⎪ Собрано страниц: {len(slugs)} ')
|
||||||
|
|
||||||
|
end = time.time()
|
||||||
|
logger.info(' ⎪ Обновление страниц заняло %fs ' % (end - start))
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(error)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_shout(shout_slug) -> int:
|
||||||
|
"""Получение метрики просмотров shout по slug"""
|
||||||
|
self = ViewedStorage
|
||||||
|
async with self.lock:
|
||||||
|
return self.views_by_shout.get(shout_slug, 0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_shout_media(shout_slug) -> Dict[str, int]:
|
||||||
|
"""Получение метрики воспроизведения shout по slug"""
|
||||||
|
self = ViewedStorage
|
||||||
|
async with self.lock:
|
||||||
|
return self.views_by_shout.get(shout_slug, 0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_topic(topic_slug) -> int:
|
||||||
|
"""Получение суммарного значения просмотров темы"""
|
||||||
|
self = ViewedStorage
|
||||||
|
topic_views = 0
|
||||||
|
async with self.lock:
|
||||||
|
for shout_slug in self.shouts_by_topic.get(topic_slug, []):
|
||||||
|
topic_views += self.views_by_shout.get(shout_slug, 0)
|
||||||
|
return topic_views
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_author(author_slug) -> int:
|
||||||
|
"""Получение суммарного значения просмотров автора"""
|
||||||
|
self = ViewedStorage
|
||||||
|
author_views = 0
|
||||||
|
async with self.lock:
|
||||||
|
for shout_slug in self.shouts_by_author.get(author_slug, []):
|
||||||
|
author_views += self.views_by_shout.get(shout_slug, 0)
|
||||||
|
return author_views
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_topics(shout_slug):
|
||||||
|
"""Обновление счетчиков темы по slug shout"""
|
||||||
|
self = ViewedStorage
|
||||||
|
with local_session() as session:
|
||||||
|
# Определение вспомогательной функции для избежания повторения кода
|
||||||
|
def update_groups(dictionary, key, value):
|
||||||
|
dictionary[key] = list(set(dictionary.get(key, []) + [value]))
|
||||||
|
|
||||||
|
# Обновление тем и авторов с использованием вспомогательной функции
|
||||||
|
for [_shout_topic, topic] in (
|
||||||
|
session.query(ShoutTopic, Topic).join(Topic).join(Shout).where(Shout.slug == shout_slug).all()
|
||||||
|
):
|
||||||
|
update_groups(self.shouts_by_topic, topic.slug, shout_slug)
|
||||||
|
|
||||||
|
for [_shout_topic, author] in (
|
||||||
|
session.query(ShoutAuthor, Author).join(Author).join(Shout).where(Shout.slug == shout_slug).all()
|
||||||
|
):
|
||||||
|
update_groups(self.shouts_by_author, author.slug, shout_slug)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def worker():
|
||||||
|
"""Асинхронная задача обновления"""
|
||||||
|
failed = 0
|
||||||
|
self = ViewedStorage
|
||||||
|
if self.disabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self.update_pages()
|
||||||
|
failed = 0
|
||||||
|
except Exception as _exc:
|
||||||
|
failed += 1
|
||||||
|
logger.info(' - Обновление не удалось #%d, ожидание 10 секунд' % failed)
|
||||||
|
if failed > 3:
|
||||||
|
logger.info(' - Больше не пытаемся обновить')
|
||||||
|
break
|
||||||
|
if failed == 0:
|
||||||
|
when = datetime.now(timezone.utc) + timedelta(seconds=self.period)
|
||||||
|
t = format(when.astimezone().isoformat())
|
||||||
|
logger.info(' ⎩ Следующее обновление: %s' % (t.split('T')[0] + ' ' + t.split('T')[1].split('.')[0]))
|
||||||
|
await asyncio.sleep(self.period)
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
logger.info(' - Попытка снова обновить данные')
|
36
services/webhook.py
Normal file
36
services/webhook.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from starlette.endpoints import HTTPEndpoint
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from orm.author import Author
|
||||||
|
from resolvers.author import create_author
|
||||||
|
from services.db import local_session
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookEndpoint(HTTPEndpoint):
|
||||||
|
async def post(self, request: Request) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
if data:
|
||||||
|
auth = request.headers.get('Authorization')
|
||||||
|
if auth:
|
||||||
|
if auth == os.environ.get('WEBHOOK_SECRET'):
|
||||||
|
user_id: str = data['user']['id']
|
||||||
|
name: str = data['user']['given_name']
|
||||||
|
slug: str = data['user']['email'].split('@')[0]
|
||||||
|
slug: str = re.sub('[^0-9a-z]+', '-', slug.lower())
|
||||||
|
with local_session() as session:
|
||||||
|
author = session.query(Author).filter(Author.slug == slug).first()
|
||||||
|
if author:
|
||||||
|
slug = slug + '-' + user_id.split('-').pop()
|
||||||
|
await create_author(user_id, slug, name)
|
||||||
|
|
||||||
|
return JSONResponse({'status': 'success'})
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
return JSONResponse({'status': 'error', 'message': str(e)}, status_code=500)
|
18
settings.py
Normal file
18
settings.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import sys
|
||||||
|
from os import environ
|
||||||
|
|
||||||
|
|
||||||
|
PORT = 8080
|
||||||
|
DB_URL = (
|
||||||
|
environ.get('DATABASE_URL', '').replace('postgres://', 'postgresql://')
|
||||||
|
or environ.get('DB_URL', '').replace('postgres://', 'postgresql://')
|
||||||
|
or 'postgresql://postgres@localhost:5432/discoursio'
|
||||||
|
)
|
||||||
|
REDIS_URL = environ.get('REDIS_URL') or 'redis://127.0.0.1'
|
||||||
|
API_BASE = environ.get('API_BASE') or 'http://127.0.0.1:8001'
|
||||||
|
AUTH_URL = environ.get('AUTH_URL') or 'http://127.0.0.1:8080/graphql'
|
||||||
|
SENTRY_DSN = environ.get('SENTRY_DSN')
|
||||||
|
DEV_SERVER_PID_FILE_NAME = 'dev-server.pid'
|
||||||
|
MODE = 'development' if 'dev' in sys.argv else 'production'
|
||||||
|
|
||||||
|
ADMIN_SECRET = environ.get('ADMIN_SECRET') or 'nothing'
|
Loading…
Reference in New Issue
Block a user