merged-isolated-core
Some checks failed
deploy / deploy (push) Failing after 1m46s

This commit is contained in:
Untone 2023-10-23 17:47:11 +03:00
parent b675188013
commit bf241a8fbd
56 changed files with 1683 additions and 2784 deletions

View File

@ -1,6 +0,0 @@
[flake8]
ignore = E203,W504,W191,W503
exclude = .git,__pycache__,orm/rbac.py
max-complexity = 10
max-line-length = 108
indent-string = ' '

View File

@ -1,44 +0,0 @@
exclude: |
(?x)(
^tests/unit_tests/resource|
_grpc.py|
_pb2.py
)
default_language_version:
python: python3.12
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- id: check-added-large-files
- id: check-case-conflict
- id: check-docstring-first
- id: check-json
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/timothycrosley/isort
rev: 5.12.0
hooks:
- id: isort
- repo: https://github.com/ambv/black
rev: 23.9.1
hooks:
- id: black
args:
- --line-length=100
- --skip-string-normalization
- repo: https://github.com/PyCQA/flake8
rev: 6.1.0
hooks:
- id: flake8
args:
- --max-line-length=100
- --disable=protected-access

40
CHANGELOG.txt Normal file
View File

@ -0,0 +1,40 @@
[0.2.11]
- redis interface updated
- viewed interface updated
- presence interface updated
- notify on create, update, delete for reaction and shout
- notify on follow / unfollow author
- use pyproject
- devmode fixed
[0.2.10]
- community resolvers connected
[0.2.9]
- starlette is back, aiohttp removed
- aioredis replaced with aredis
[0.2.8]
- refactored
[0.2.7]
- loadFollowedReactions now with login_required
- notifier service api draft
- added shout visibility kind in schema
- community isolated from author in orm
[0.2.6]
- redis connection pool
- auth context fixes
- communities orm, resolvers, schema
[0.2.5]
- restructured
- all users have their profiles as authors in core
- gittask, inbox and auth logics removed
- settings moved to base and now smaller
- new outside auth schema
- removed gittask, auth, inbox, migration

View File

@ -1,10 +1,22 @@
# Use an official Python runtime as a parent image
FROM python:slim
# Set the working directory in the container to /app
WORKDIR /app
EXPOSE 8080
# ADD nginx.conf.sigil ./
COPY requirements.txt .
RUN apt-get update && apt-get install -y build-essential git
ENV GIT_SSH_COMMAND "ssh -v"
RUN pip install -r requirements.txt
COPY . .
# Add metadata to the image to describe that the container is listening on port 80
EXPOSE 80
# Copy the current directory contents into the container at /app
COPY . /app
# Install any needed packages specified in pyproject.toml
RUN apt-get update && apt-get install -y gcc curl && \
curl -sSL https://install.python-poetry.org | python - && \
echo "export PATH=$PATH:/root/.local/bin" >> ~/.bashrc && \
. ~/.bashrc && \
poetry config virtualenvs.create false && \
poetry install --no-dev
# Run server.py when the container launches
CMD ["python", "server.py"]

View File

@ -1,2 +0,0 @@
web: python server.py

View File

@ -1,4 +1,4 @@
# discoursio-api
# discoursio-core
- sqlalchemy
@ -22,16 +22,10 @@ on debian/ubuntu
apt install redis nginx
```
First, install Postgres. Then you'll need some data, so migrate it:
```
createdb discoursio
python server.py migrate
```
Then run nginx, redis and API server
```
redis-server
pip install -r requirements.txt
poetry install
python3 server.py dev
```
@ -43,5 +37,5 @@ Put the header 'Authorization' with token from signIn query or registerUser muta
Set ACKEE_TOKEN var
# test test
# test

View File

@ -1,110 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = %(DB_URL)
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -1,3 +0,0 @@
Generic single-database configuration.
https://alembic.sqlalchemy.org/en/latest/tutorial.html

View File

@ -1,79 +0,0 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from settings import DB_URL
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# override DB_URL
config.set_section_option(config.config_ini_section, "DB_URL", DB_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from services.db import Base
target_metadata = [Base.metadata]
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -1,26 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -1,26 +0,0 @@
"""init alembic
Revision ID: fe943b098418
Revises:
Create Date: 2023-08-19 01:37:57.031933
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'fe943b098418'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@ -1,95 +0,0 @@
from functools import wraps
from typing import Optional, Tuple
from graphql.type import GraphQLResolveInfo
from sqlalchemy.orm import joinedload, exc
from starlette.authentication import AuthenticationBackend
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials, AuthUser
from services.db import local_session
from orm.user import User, Role
from settings import SESSION_TOKEN_HEADER
from auth.tokenstorage import SessionToken
from services.exceptions import OperationNotAllowed
class JWTAuthenticate(AuthenticationBackend):
async def authenticate(
self, request: HTTPConnection
) -> Optional[Tuple[AuthCredentials, AuthUser]]:
if SESSION_TOKEN_HEADER not in request.headers:
return AuthCredentials(scopes={}), AuthUser(user_id=None, username="")
token = request.headers.get(SESSION_TOKEN_HEADER)
if not token:
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER)
return AuthCredentials(scopes={}, error_message=str("no token")), AuthUser(
user_id=None, username=""
)
token = token.split(" ")[-1]
if len(token.split(".")) > 1:
payload = await SessionToken.verify(token)
with local_session() as session:
try:
user = (
session.query(User)
.options(
joinedload(User.roles).options(
joinedload(Role.permissions)
),
joinedload(User.ratings),
)
.filter(User.id == payload.user_id)
.one()
)
scopes = {} # TODO: integrate await user.get_permission()
return (
AuthCredentials(
user_id=payload.user_id, scopes=scopes, logged_in=True
),
AuthUser(user_id=user.id, username=""),
)
except exc.NoResultFound:
pass
return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(
user_id=None, username=""
)
def login_required(func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
# print('[auth.authenticate] login required for %r with info %r' % (func, info)) # debug only
auth: AuthCredentials = info.context["request"].auth
# print(auth)
if not auth or not auth.logged_in:
# raise Unauthorized(auth.error_message or "Please login")
return {"error": "Please login first"}
return await func(parent, info, *args, **kwargs)
return wrap
def permission_required(resource, operation, func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
print(
"[auth.authenticate] permission_required for %r with info %r" % (func, info)
) # debug only
auth: AuthCredentials = info.context["request"].auth
if not auth.logged_in:
raise OperationNotAllowed(auth.error_message or "Please login")
# TODO: add actual check permission logix here
return await func(parent, info, *args, **kwargs)
return wrap

View File

@ -1,45 +0,0 @@
from typing import List, Optional, Text
from pydantic import BaseModel
# from base.exceptions import Unauthorized
class Permission(BaseModel):
name: Text
class AuthCredentials(BaseModel):
user_id: Optional[int] = None
scopes: Optional[dict] = {}
logged_in: bool = False
error_message: str = ""
@property
def is_admin(self):
# TODO: check admin logix
return True
async def permissions(self) -> List[Permission]:
if self.user_id is None:
# raise Unauthorized("Please login first")
return {
"error": "Please login first"
}
else:
# TODO: implement permissions logix
print(self.user_id)
return NotImplemented()
class AuthUser(BaseModel):
user_id: Optional[int]
username: Optional[str]
@property
def is_authenticated(self) -> bool:
return self.user_id is not None
@property
def display_id(self) -> int:
return self.user_id

View File

@ -1,27 +0,0 @@
import httpx
from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN
api_url = f"https://api.mailgun.net/v3/{MAILGUN_DOMAIN or 'discours.io'}/messages"
noreply = f"discours.io <noreply@{MAILGUN_DOMAIN or 'discours.io'}>"
lang_subject = {"ru": "Подтверждение почты", "en": "Confirm email"}
async def send_auth_email(user, token, lang="ru", template="email_confirmation"):
try:
to = f"{user.name} <{user.email}>"
if lang not in ["ru", "en"]:
lang = "ru"
subject = lang_subject.get(lang, lang_subject["en"])
template = template + "_" + lang
payload = {
"from": noreply,
"to": to,
"subject": subject,
"template": template,
"h:X-Mailgun-Variables": f'{{ "token": "{token}" }}',
}
print(f"[auth.email] payload: {payload}")
async with httpx.AsyncClient() as client:
response = await client.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload)
response.raise_for_status()
except Exception as e:
print(e)

View File

@ -1,108 +0,0 @@
from binascii import hexlify
from hashlib import sha256
from jwt import DecodeError, ExpiredSignatureError
from passlib.hash import bcrypt
from sqlalchemy import or_
from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage
# from base.exceptions import InvalidPassword, InvalidToken
from services.db import local_session
from orm import User
from auth.validators import AuthInput
class Password:
@staticmethod
def _to_bytes(data: str) -> bytes:
return bytes(data.encode())
@classmethod
def _get_sha256(cls, password: str) -> bytes:
bytes_password = cls._to_bytes(password)
return hexlify(sha256(bytes_password).digest())
@staticmethod
def encode(password: str) -> str:
password_sha256 = Password._get_sha256(password)
return bcrypt.using(rounds=10).hash(password_sha256)
@staticmethod
def verify(password: str, hashed: str) -> bool:
"""
Verify that password hash is equal to specified hash. Hash format:
$2a$10$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm
\__/\/ \____________________/\_____________________________/
| | Salt Hash
| Cost
Version
More info: https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html
:param password: clear text password
:param hashed: hash of the password
:return: True if clear text password matches specified hash
"""
hashed_bytes = Password._to_bytes(hashed)
password_sha256 = Password._get_sha256(password)
return bcrypt.verify(password_sha256, hashed_bytes)
class Identity:
@staticmethod
def password(orm_user: User, password: str) -> User:
user = User(**orm_user.dict())
if not user.password:
# raise InvalidPassword("User password is empty")
return {"error": "User password is empty"}
if not Password.verify(password, user.password):
# raise InvalidPassword("Wrong user password")
return {"error": "Wrong user password"}
return user
@staticmethod
def oauth(inp: AuthInput) -> User:
with local_session() as session:
user = (
session.query(User)
.filter(or_(User.oauth == inp["oauth"], User.email == inp["email"]))
.first()
)
if not user:
user = User.create(**inp)
if not user.oauth:
user.oauth = inp["oauth"]
session.commit()
user = User(**user.dict())
return user
@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}-{payload.username}-{token}"
):
# raise InvalidToken("Login token has expired, please login again")
return {"error": "Token has expired"}
except ExpiredSignatureError:
# raise InvalidToken("Login token has expired, please try again")
return {"error": "Token has expired"}
except DecodeError:
# raise InvalidToken("token format error") from e
return {"error": "Token format error"}
with local_session() as session:
user = session.query(User).filter_by(id=payload.user_id).first()
if not user:
# raise Exception("user not exist")
return {"error": "User does not exist"}
if not user.emailConfirmed:
user.emailConfirmed = True
session.commit()
return user

View File

@ -1,50 +0,0 @@
from datetime import datetime, timezone
import jwt
from services.exceptions import ExpiredToken, InvalidToken
from auth.validators import TokenPayload, AuthInput
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
class JWTCodec:
@staticmethod
def encode(user: AuthInput, exp: datetime) -> str:
payload = {
"user_id": user.id,
"username": user.email or user.phone,
"exp": exp,
"iat": datetime.now(tz=timezone.utc),
"iss": "discours",
}
try:
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
except Exception as e:
print("[auth.jwtcodec] JWT encode error %r" % e)
@staticmethod
def decode(token: str, verify_exp: bool = True) -> TokenPayload:
r = None
payload = None
try:
payload = jwt.decode(
token,
key=JWT_SECRET_KEY,
options={
"verify_exp": verify_exp,
# "verify_signature": False
},
algorithms=[JWT_ALGORITHM],
issuer="discours",
)
r = TokenPayload(**payload)
# print('[auth.jwtcodec] debug token %r' % r)
return r
except jwt.InvalidIssuedAtError:
print("[auth.jwtcodec] invalid issued at: %r" % payload)
raise ExpiredToken("check token issued time")
except jwt.ExpiredSignatureError:
print("[auth.jwtcodec] expired signature %r" % payload)
raise ExpiredToken("check token lifetime")
except jwt.InvalidTokenError:
raise InvalidToken("token is not valid")
except jwt.InvalidSignatureError:
raise InvalidToken("token is not valid")

View File

@ -1,89 +0,0 @@
from authlib.integrations.starlette_client import OAuth
from starlette.responses import RedirectResponse
from auth.identity import Identity
from auth.tokenstorage import TokenStorage
from settings import OAUTH_CLIENTS, FRONTEND_URL
oauth = OAuth()
oauth.register(
name="facebook",
client_id=OAUTH_CLIENTS["FACEBOOK"]["id"],
client_secret=OAUTH_CLIENTS["FACEBOOK"]["key"],
access_token_url="https://graph.facebook.com/v11.0/oauth/access_token",
access_token_params=None,
authorize_url="https://www.facebook.com/v11.0/dialog/oauth",
authorize_params=None,
api_base_url="https://graph.facebook.com/",
client_kwargs={"scope": "public_profile email"},
)
oauth.register(
name="github",
client_id=OAUTH_CLIENTS["GITHUB"]["id"],
client_secret=OAUTH_CLIENTS["GITHUB"]["key"],
access_token_url="https://github.com/login/oauth/access_token",
access_token_params=None,
authorize_url="https://github.com/login/oauth/authorize",
authorize_params=None,
api_base_url="https://api.github.com/",
client_kwargs={"scope": "user:email"},
)
oauth.register(
name="google",
client_id=OAUTH_CLIENTS["GOOGLE"]["id"],
client_secret=OAUTH_CLIENTS["GOOGLE"]["key"],
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
)
async def google_profile(client, request, token):
profile = await client.parse_id_token(request, token)
profile["id"] = profile["sub"]
return profile
async def facebook_profile(client, request, token):
profile = await client.get("me?fields=name,id,email", token=token)
return profile.json()
async def github_profile(client, request, token):
profile = await client.get("user", token=token)
return profile.json()
profile_callbacks = {
"google": google_profile,
"facebook": facebook_profile,
"github": github_profile,
}
async def oauth_login(request):
provider = request.path_params["provider"]
request.session["provider"] = provider
client = oauth.create_client(provider)
redirect_uri = "https://v2.discours.io/oauth-authorize"
return await client.authorize_redirect(request, redirect_uri)
async def oauth_authorize(request):
provider = request.session["provider"]
client = oauth.create_client(provider)
token = await client.authorize_access_token(request)
get_profile = profile_callbacks[provider]
profile = await get_profile(client, request, token)
user_oauth_info = "%s:%s" % (provider, profile["id"])
user_input = {
"oauth": user_oauth_info,
"email": profile["email"],
"username": profile["name"],
}
user = Identity.oauth(user_input)
session_token = await TokenStorage.create_session(user)
response = RedirectResponse(url=FRONTEND_URL + "/confirm")
response.set_cookie("token", session_token)
return response

View File

@ -1,75 +0,0 @@
from datetime import datetime, timedelta, timezone
from auth.jwtcodec import JWTCodec
from auth.validators import AuthInput
from services.redis import redis
from settings import SESSION_TOKEN_LIFE_SPAN, ONETIME_TOKEN_LIFE_SPAN
async def save(token_key, life_span, auto_delete=True):
await redis.execute("SET", token_key, "True")
if auto_delete:
expire_at = (
datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
).timestamp()
await redis.execute("EXPIREAT", token_key, int(expire_at))
class SessionToken:
@classmethod
async def verify(cls, token: str):
"""
Rules for a token to be valid.
- token format is legal
- token exists in redis database
- token is not expired
"""
try:
return JWTCodec.decode(token)
except Exception as e:
raise e
@classmethod
async def get(cls, payload, token):
return await TokenStorage.get(f"{payload.user_id}-{payload.username}-{token}")
class TokenStorage:
@staticmethod
async def get(token_key):
print("[tokenstorage.get] " + token_key)
# 2041-user@domain.zn-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyMDQxLCJ1c2VybmFtZSI6ImFudG9uLnJld2luK3Rlc3QtbG9hZGNoYXRAZ21haWwuY29tIiwiZXhwIjoxNjcxNzgwNjE2LCJpYXQiOjE2NjkxODg2MTYsImlzcyI6ImRpc2NvdXJzIn0.Nml4oV6iMjMmc6xwM7lTKEZJKBXvJFEIZ-Up1C1rITQ
return await redis.execute("GET", token_key)
@staticmethod
async def create_onetime(user: AuthInput) -> str:
life_span = ONETIME_TOKEN_LIFE_SPAN
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
one_time_token = JWTCodec.encode(user, exp)
await save(f"{user.id}-{user.username}-{one_time_token}", life_span)
return one_time_token
@staticmethod
async def create_session(user: AuthInput) -> str:
life_span = SESSION_TOKEN_LIFE_SPAN
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
session_token = JWTCodec.encode(user, exp)
await save(f"{user.id}-{user.username}-{session_token}", life_span)
return session_token
@staticmethod
async def revoke(token: str) -> bool:
payload = None
try:
print("[auth.tokenstorage] revoke token")
payload = JWTCodec.decode(token)
except: # noqa
pass
else:
await redis.execute("DEL", f"{payload.user_id}-{payload.username}-{token}")
return True
@staticmethod
async def revoke_all(user: AuthInput):
tokens = await redis.execute("KEYS", f"{user.id}-*")
await redis.execute("DEL", *tokens)

View File

@ -1,17 +0,0 @@
from typing import Optional, Text
from pydantic import BaseModel
class AuthInput(BaseModel):
id: Optional[int]
email: Optional[Text]
phone: Optional[Text]
password: Optional[Text]
class TokenPayload(BaseModel):
user_id: int
username: Optional[Text]
exp: int
iat: int
iss: Text

16
lint.sh
View File

@ -1,16 +0,0 @@
#!/usr/bin/env bash
set -e
find . -name "*.py[co]" -o -name __pycache__ -exec rm -rf {} +
#rm -rf .mypy_cache
echo "> isort"
isort --gitignore --settings-file=setup.cfg .
echo "> brunette"
brunette --config=setup.cfg .
echo "> flake8"
flake8 --config=setup.cfg .
echo "> mypy"
mypy --config-file=setup.cfg .
echo "> prettyjson"
python3 -m scripts.prettyjson

76
main.py
View File

@ -1,89 +1,39 @@
import asyncio
import os
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
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.sessions import SessionMiddleware
from starlette.routing import Route
from orm import init_tables
from auth.authenticate import JWTAuthenticate
from auth.oauth import oauth_login, oauth_authorize
from resolvers.auth import confirm_email_handler
from resolvers.upload import upload_handler
from services.redis import redis
from settings import DEV_SERVER_PID_FILE_NAME, SENTRY_DSN, SESSION_SECRET_KEY
from services.search import SearchService
from services.viewed import ViewedStorage
from services.db import local_session
from services.rediscache import redis
from services.schema import resolvers
from settings import DEV_SERVER_PID_FILE_NAME, SENTRY_DSN, MODE
import_module("resolvers")
schema = make_executable_schema(load_schema_from_path("schemas/core.graphql"), resolvers) # type: ignore
middleware = [
Middleware(AuthenticationMiddleware, backend=JWTAuthenticate()),
Middleware(SessionMiddleware, secret_key= SESSION_SECRET_KEY),
]
async def start_up():
init_tables()
await redis.connect()
with local_session() as session:
await SearchService.init(session)
await ViewedStorage.init()
_views_stat_task = asyncio.create_task(ViewedStorage().worker())
try:
import sentry_sdk
sentry_sdk.init(SENTRY_DSN)
print("[sentry] started")
except Exception as e:
print("[sentry] init error")
print(e)
print("[main] started")
async def dev_start_up():
if MODE == "development":
if exists(DEV_SERVER_PID_FILE_NAME):
await redis.connect()
return
else:
with open(DEV_SERVER_PID_FILE_NAME, "w", encoding="utf-8") as f:
f.write(str(os.getpid()))
else:
await redis.connect()
try:
import sentry_sdk
await start_up()
sentry_sdk.init(SENTRY_DSN)
except Exception as e:
print("[sentry] init error")
print(e)
async def shutdown():
await redis.disconnect()
routes = [
Route("/oauth/{provider}", endpoint=oauth_login),
Route("/oauth-authorize", endpoint=oauth_authorize),
Route("/confirm/{token}", endpoint=confirm_email_handler),
Route("/upload", endpoint=upload_handler, methods=["POST"]),
]
app = Starlette(
on_startup=[start_up],
on_shutdown=[shutdown],
middleware=middleware,
routes=routes,
)
app.mount("/", GraphQL( schema ))
dev_app = Starlette(
debug=True,
on_startup=[dev_start_up],
on_shutdown=[shutdown],
middleware=middleware,
routes=routes,
)
dev_app.mount("/", GraphQL(schema, debug=True))
app = Starlette(debug=True, on_startup=[start_up], on_shutdown=[shutdown])
app.mount("/", GraphQL(schema, debug=True))

View File

@ -1,34 +1,8 @@
from services.db import Base, engine
from orm.community import Community
from orm.rbac import Operation, Resource, Permission, Role
from orm.reaction import Reaction
from base.orm import Base, engine
from orm.shout import Shout
from orm.topic import Topic, TopicFollower
from orm.user import User, UserRating
def init_tables():
Base.metadata.create_all(engine)
Operation.init_table()
Resource.init_table()
User.init_table()
Community.init_table()
Role.init_table()
UserRating.init_table()
Shout.init_table()
print("[orm] tables initialized")
__all__ = [
"User",
"Role",
"Operation",
"Permission",
"Community",
"Shout",
"Topic",
"TopicFollower",
"Reaction",
"UserRating",
"init_tables"
]

67
orm/author.py Normal file
View File

@ -0,0 +1,67 @@
from datetime import datetime
from sqlalchemy import JSON as JSONType
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from base.orm import Base, local_session
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)
value = Column(Integer)
@staticmethod
def init_table():
pass
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)
createdAt = Column(DateTime, nullable=False, default=datetime.now)
auto = Column(Boolean, nullable=False, default=False)
class Author(Base):
__tablename__ = "author"
user = Column(Integer, nullable=False) # unbounded link with authorizer's User type
bio = Column(String, nullable=True, comment="Bio") # status description
about = Column(String, nullable=True, comment="About") # long and formatted
userpic = Column(String, nullable=True, comment="Userpic")
name = Column(String, nullable=True, comment="Display name")
slug = Column(String, unique=True, comment="Author's slug")
muted = Column(Boolean, default=False)
createdAt = Column(DateTime, nullable=False, default=datetime.now)
lastSeen = Column(DateTime, nullable=False, default=datetime.now) # Td se 0e
deletedAt = Column(DateTime, nullable=True, comment="Deleted at")
links = Column(JSONType, nullable=True, comment="Links")
ratings = relationship(AuthorRating, foreign_keys=AuthorRating.author)
@staticmethod
def init_table():
with local_session() as session:
default = session.query(Author).filter(Author.slug == "anonymous").first()
if not default:
default_dict = {
"user": 0,
"name": "Аноним",
"slug": "anonymous",
}
default = Author.create(**default_dict)
session.add(default)
discours_dict = {
"user": 1,
"name": "Дискурс",
"slug": "discours",
}
discours = Author.create(**discours_dict)
session.add(discours)
session.commit()
Author.default_author = default

View File

@ -1,8 +1,6 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, String
from services.db import Base
from base.orm import Base
class ShoutCollection(Base):
@ -21,5 +19,5 @@ class Collection(Base):
body = Column(String, nullable=True, comment="Body")
pic = Column(String, nullable=True, comment="Picture")
createdAt = Column(DateTime, default=datetime.now, comment="Created At")
createdBy = Column(ForeignKey("user.id"), comment="Created By")
createdBy = Column(ForeignKey("author.id"), comment="Created By")
publishedAt = Column(DateTime, default=datetime.now, comment="Published At")

View File

@ -1,39 +1,45 @@
from datetime import datetime
from sqlalchemy import Column, String, ForeignKey, DateTime
from services.db import Base, local_session
from sqlalchemy.orm import relationship
from base.orm import Base, local_session
from orm.author import Author
class CommunityFollower(Base):
__tablename__ = "community_followers"
class CommunityRole:
__tablename__ = "community_role"
name = Column(String, nullable=False)
class CommunityAuthor(Base):
__tablename__ = "community_author"
id = None # type: ignore
follower = Column(ForeignKey("user.id"), primary_key=True)
follower = Column(ForeignKey("author.id"), primary_key=True)
community = Column(ForeignKey("community.id"), primary_key=True)
joinedAt = Column(
DateTime, nullable=False, default=datetime.now, comment="Created at"
)
# role = Column(ForeignKey(Role.id), nullable=False, comment="Role for member")
joinedAt = Column(DateTime, nullable=False, default=datetime.now)
role = Column(ForeignKey("community_role.id"), nullable=False)
class Community(Base):
__tablename__ = "community"
name = Column(String, nullable=False, comment="Name")
slug = Column(String, nullable=False, unique=True, comment="Slug")
name = Column(String, nullable=False)
slug = Column(String, nullable=False, unique=True)
desc = Column(String, nullable=False, default="")
pic = Column(String, nullable=False, default="")
createdAt = Column(
DateTime, nullable=False, default=datetime.now, comment="Created at"
)
createdAt = Column(DateTime, nullable=False, default=datetime.now)
authors = relationship(lambda: Author, secondary=CommunityAuthor.__tablename__, nullable=True)
@staticmethod
def init_table():
with local_session() as session:
d = session.query(Community).filter(Community.slug == "discours").first()
d = (session.query(Community).filter(Community.slug == "discours").first())
if not d:
d = Community.create(name="Дискурс", slug="discours")
session.add(d)
session.commit()
Community.default_community = d
print("[orm] default community id: %s" % d.id)
print('[orm] default community id: %s' % d.id)

View File

@ -1,182 +0,0 @@
import warnings
from sqlalchemy import String, Column, ForeignKey, UniqueConstraint, TypeDecorator
from sqlalchemy.orm import relationship
from services.db import Base, REGISTRY, engine, local_session
# Role Based Access Control #
class ClassType(TypeDecorator):
impl = String
@property
def python_type(self):
return NotImplemented
def process_literal_param(self, value, dialect):
return NotImplemented
def process_bind_param(self, value, dialect):
return value.__name__ if isinstance(value, type) else str(value)
def process_result_value(self, value, dialect):
class_ = REGISTRY.get(value)
if class_ is None:
warnings.warn(f"Can't find class <{value}>,find it yourself!", stacklevel=2)
return class_
class Role(Base):
__tablename__ = "role"
name = Column(String, nullable=False, comment="Role Name")
desc = Column(String, nullable=True, comment="Role Description")
community = Column(
ForeignKey("community.id", ondelete="CASCADE"),
nullable=False,
comment="Community",
)
permissions = relationship(lambda: Permission)
@staticmethod
def init_table():
with local_session() as session:
r = session.query(Role).filter(Role.name == "author").first()
if r:
Role.default_role = r
return
r1 = Role.create(
name="author",
desc="Role for an author",
community=1,
)
session.add(r1)
Role.default_role = r1
r2 = Role.create(
name="reader",
desc="Role for a reader",
community=1,
)
session.add(r2)
r3 = Role.create(
name="expert",
desc="Role for an expert",
community=1,
)
session.add(r3)
r4 = Role.create(
name="editor",
desc="Role for an editor",
community=1,
)
session.add(r4)
class Operation(Base):
__tablename__ = "operation"
name = Column(String, nullable=False, unique=True, comment="Operation Name")
@staticmethod
def init_table():
with local_session() as session:
for name in ["create", "update", "delete", "load"]:
"""
* everyone can:
- load shouts
- load topics
- load reactions
- create an account to become a READER
* readers can:
- update and delete their account
- load chats
- load messages
- create reaction of some shout's author allowed kinds
- create shout to become an AUTHOR
* authors can:
- update and delete their shout
- invite other authors to edit shout and chat
- manage allowed reactions for their shout
* pros can:
- create/update/delete their community
- create/update/delete topics for their community
"""
op = session.query(Operation).filter(Operation.name == name).first()
if not op:
op = Operation.create(name=name)
session.add(op)
session.commit()
class Resource(Base):
__tablename__ = "resource"
resourceClass = Column(
String, nullable=False, unique=True, comment="Resource class"
)
name = Column(String, nullable=False, unique=True, comment="Resource name")
# TODO: community = Column(ForeignKey())
@staticmethod
def init_table():
with local_session() as session:
for res in [
"shout",
"topic",
"reaction",
"chat",
"message",
"invite",
"community",
"user",
]:
r = session.query(Resource).filter(Resource.name == res).first()
if not r:
r = Resource.create(name=res, resourceClass=res)
session.add(r)
session.commit()
class Permission(Base):
__tablename__ = "permission"
__table_args__ = (
UniqueConstraint("role", "operation", "resource"),
{"extend_existing": True},
)
role = Column(
ForeignKey("role.id", ondelete="CASCADE"), nullable=False, comment="Role"
)
operation = Column(
ForeignKey("operation.id", ondelete="CASCADE"),
nullable=False,
comment="Operation",
)
resource = Column(
ForeignKey("resource.id", ondelete="CASCADE"),
nullable=False,
comment="Resource",
)
if __name__ == "__main__":
Base.metadata.create_all(engine)
ops = [
Permission(role=1, operation=1, resource=1),
Permission(role=1, operation=2, resource=1),
Permission(role=1, operation=3, resource=1),
Permission(role=1, operation=4, resource=1),
Permission(role=2, operation=4, resource=1),
]
global_session.add_all(ops)
global_session.commit()

View File

@ -1,9 +1,7 @@
from datetime import datetime
from enum import Enum as Enumeration
from sqlalchemy import Column, DateTime, Enum, ForeignKey, String
from services.db import Base
from base.orm import Base
class ReactionKind(Enumeration):
@ -26,25 +24,15 @@ class ReactionKind(Enumeration):
class Reaction(Base):
__tablename__ = "reaction"
body = Column(String, nullable=True, comment="Reaction Body")
createdAt = Column(
DateTime, nullable=False, default=datetime.now, comment="Created at"
)
createdBy = Column(
ForeignKey("user.id"), nullable=False, index=True, comment="Sender"
)
createdAt = Column(DateTime, nullable=False, default=datetime.now)
createdBy = Column(ForeignKey("author.id"), nullable=False, index=True)
updatedAt = Column(DateTime, nullable=True, comment="Updated at")
updatedBy = Column(
ForeignKey("user.id"), nullable=True, index=True, comment="Last Editor"
)
updatedBy = Column(ForeignKey("author.id"), nullable=True, index=True)
deletedAt = Column(DateTime, nullable=True, comment="Deleted at")
deletedBy = Column(
ForeignKey("user.id"), nullable=True, index=True, comment="Deleted by"
)
deletedBy = Column(ForeignKey("author.id"), nullable=True, index=True)
shout = Column(ForeignKey("shout.id"), nullable=False, index=True)
replyTo = Column(
ForeignKey("reaction.id"), nullable=True, comment="Reply to reaction ID"
)
range = Column(String, nullable=True, comment="Range in format <start index>:<end>")
kind = Column(Enum(ReactionKind), nullable=False, comment="Reaction kind")
oid = Column(String, nullable=True, comment="Old ID")
replyTo = Column(ForeignKey("reaction.id"), nullable=True)
range = Column(String, nullable=True, comment="<start index>:<end>")
kind = Column(Enum(ReactionKind), nullable=False)

View File

@ -1,12 +1,21 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, JSON
from enum import Enum as Enumeration
from sqlalchemy import (
Enum,
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
JSON,
)
from sqlalchemy.orm import column_property, relationship
from services.db import Base, local_session
from base.orm import Base, local_session
from orm.community import Community
from orm.reaction import Reaction
from orm.topic import Topic
from orm.user import User
from orm.author import Author
class ShoutTopic(Base):
@ -21,7 +30,7 @@ class ShoutReactionsFollower(Base):
__tablename__ = "shout_reactions_followers"
id = None # type: ignore
follower = Column(ForeignKey("user.id"), primary_key=True, index=True)
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
auto = Column(Boolean, nullable=False, default=False)
createdAt = Column(
@ -35,48 +44,61 @@ class ShoutAuthor(Base):
id = None # type: ignore
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
user = Column(ForeignKey("user.id"), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
caption = Column(String, nullable=True, default="")
class ShoutCommunity:
__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 ShoutVisibility(Enumeration):
AUTHORS = 0
COMMUNITY = 1
PUBLIC = 2
class Shout(Base):
__tablename__ = "shout"
# timestamps
createdAt = Column(
DateTime, nullable=False, default=datetime.now, comment="Created at"
)
updatedAt = Column(DateTime, nullable=True, comment="Updated at")
createdAt = Column(DateTime, nullable=False, default=datetime.now)
updatedAt = Column(DateTime, nullable=True)
publishedAt = Column(DateTime, nullable=True)
deletedAt = Column(DateTime, nullable=True)
createdBy = Column(ForeignKey("user.id"), comment="Created By")
deletedBy = Column(ForeignKey("user.id"), nullable=True)
createdBy = Column(ForeignKey("author.id"), comment="Created By")
deletedBy = 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")
lead = Column(String, nullable=True)
description = Column(String, nullable=True)
body = Column(String, nullable=False, comment="Body")
title = Column(String, nullable=True)
subtitle = Column(String, nullable=True)
layout = Column(String, nullable=True)
media = Column(JSON, nullable=True)
authors = relationship(lambda: User, secondary=ShoutAuthor.__tablename__)
topics = relationship(lambda: Topic, secondary=ShoutTopic.__tablename__)
# views from the old Discours website
viewsOld = Column(Integer, default=0)
# views from Ackee tracker on the new Discours website
viewsAckee = Column(Integer, default=0)
views = column_property(viewsOld + viewsAckee)
authors = relationship(lambda: Author, secondary=ShoutAuthor.__tablename__)
topics = relationship(lambda: Topic, secondary=ShoutTopic.__tablename__)
communities = relationship(
lambda: Community, secondary=ShoutCommunity.__tablename__
)
reactions = relationship(lambda: Reaction)
viewsOld = Column(Integer, default=0)
viewsAckee = Column(Integer, default=0)
views = column_property(viewsOld + viewsAckee)
visibility = Column(Enum(ShoutVisibility), default=ShoutVisibility.AUTHORS)
# TODO: these field should be used or modified
community = Column(ForeignKey("community.id"), default=1)
lang = Column(String, nullable=False, default="ru", comment="Language")
mainTopic = Column(ForeignKey("topic.slug"), nullable=True)
visibility = Column(String, nullable=True) # owner authors community public
versionOf = Column(ForeignKey("shout.id"), nullable=True)
oid = Column(String, nullable=True)

View File

@ -1,19 +1,15 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String
from services.db import Base
from base.orm import Base
class TopicFollower(Base):
__tablename__ = "topic_followers"
id = None # type: ignore
follower = Column(ForeignKey("user.id"), primary_key=True, index=True)
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
createdAt = Column(
DateTime, nullable=False, default=datetime.now, comment="Created at"
)
createdAt = Column(DateTime, nullable=False, default=datetime.now)
auto = Column(Boolean, nullable=False, default=False)
@ -24,5 +20,5 @@ class Topic(Base):
title = Column(String, nullable=False, comment="Title")
body = Column(String, nullable=True, comment="Body")
pic = Column(String, nullable=True, comment="Picture")
community = Column(ForeignKey("community.id"), default=1, comment="Community")
community = Column(ForeignKey("community.id"), default=1)
oid = Column(String, nullable=True, comment="Old ID")

View File

@ -1,106 +0,0 @@
from datetime import datetime
from sqlalchemy import JSON as JSONType
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from services.db import Base, local_session
from orm.rbac import Role
class UserRating(Base):
__tablename__ = "user_rating"
id = None # type: ignore
rater = Column(ForeignKey("user.id"), primary_key=True, index=True)
user = Column(ForeignKey("user.id"), primary_key=True, index=True)
value = Column(Integer)
@staticmethod
def init_table():
pass
class UserRole(Base):
__tablename__ = "user_role"
id = None # type: ignore
user = Column(ForeignKey("user.id"), primary_key=True, index=True)
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
class AuthorFollower(Base):
__tablename__ = "author_follower"
id = None # type: ignore
follower = Column(ForeignKey("user.id"), primary_key=True, index=True)
author = Column(ForeignKey("user.id"), primary_key=True, index=True)
createdAt = Column(
DateTime, nullable=False, default=datetime.now, comment="Created at"
)
auto = Column(Boolean, nullable=False, default=False)
class User(Base):
__tablename__ = "user"
default_user = None
email = Column(String, unique=True, nullable=False, comment="Email")
username = Column(String, nullable=False, comment="Login")
password = Column(String, nullable=True, comment="Password")
bio = Column(String, nullable=True, comment="Bio") # status description
about = Column(String, nullable=True, comment="About") # long and formatted
userpic = Column(String, nullable=True, comment="Userpic")
name = Column(String, nullable=True, comment="Display name")
slug = Column(String, unique=True, comment="User's slug")
muted = Column(Boolean, default=False)
emailConfirmed = Column(Boolean, default=False)
createdAt = Column(
DateTime, nullable=False, default=datetime.now, comment="Created at"
)
lastSeen = Column(
DateTime, nullable=False, default=datetime.now, comment="Was online at"
)
deletedAt = Column(DateTime, nullable=True, comment="Deleted at")
links = Column(JSONType, nullable=True, comment="Links")
oauth = Column(String, nullable=True)
ratings = relationship(UserRating, foreign_keys=UserRating.user)
roles = relationship(lambda: Role, secondary=UserRole.__tablename__)
oid = Column(String, nullable=True)
@staticmethod
def init_table():
with local_session() as session:
default = session.query(User).filter(User.slug == "anonymous").first()
if not default:
default_dict = {
"email": "noreply@discours.io",
"username": "noreply@discours.io",
"name": "Аноним",
"slug": "anonymous",
}
default = User.create(**default_dict)
session.add(default)
discours_dict = {
"email": "welcome@discours.io",
"username": "welcome@discours.io",
"name": "Дискурс",
"slug": "discours",
}
discours = User.create(**discours_dict)
session.add(discours)
session.commit()
User.default_user = default
def get_permission(self):
scope = {}
for role in self.roles:
for p in role.permissions:
if p.resource not in scope:
scope[p.resource] = set()
scope[p.resource].add(p.operation)
print(scope)
return scope
# if __name__ == "__main__":
# print(User.get_permission(user_id=1)) # type: ignore

69
pyproject.toml Normal file
View File

@ -0,0 +1,69 @@
[tool.poetry]
name = "discoursio-core"
version = "0.2.11"
description = "core module for discours.io"
authors = ["discoursio devteam"]
license = "MIT"
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12"
SQLAlchemy = "^2.0.22"
httpx = "^0.25.0"
redis = {extras = ["hiredis"], version = "^5.0.1"}
uvicorn = "^0.23.2"
sentry-sdk = "^1.32.0"
gql = {git = "https://github.com/graphql-python/gql.git", rev = "master"}
starlette = {git = "https://github.com/encode/starlette.git", rev = "master"}
ariadne = {git = "https://github.com/tonyrewin/ariadne.git", rev = "master"}
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.dev-dependencies]
pytest = "^7.4.2"
black = { version = "^23.9.1", python = ">=3.12" }
ruff = { version = "^0.1.0", python = ">=3.12" }
[tool.black]
line-length = 120
target-version = ['py312']
include = '\.pyi?$'
exclude = '''
(
/(
\.eggs # exclude a few common directories in the
| \.git # root of the project
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
)/
| foo.py # also separately exclude a file named foo.py in
# the root of the project
)
'''
[tool.isort]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
ensure_newline_before_comments = true
line_length = 120
[tool.ruff]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
# McCabe complexity (`C901`) by default.
select = ["E4", "E7", "E9", "F"]
ignore = []
line-length = 120
target-version = "py312"

View File

@ -1,56 +0,0 @@
database_name="discoursio"
remote_backup_dir="/var/backups/mongodb"
user="root"
host="v2.discours.io"
server="$user@$host"
dump_dir="./dump"
local_backup_filename="discours-backup.bson.gz.tar"
echo "DATABASE RESET STARTED"
echo "server: $server"
echo "remote backup directory: $remote_backup_dir"
echo "Searching for last backup file..."
last_backup_filename=$(ssh $server "ls -t $remote_backup_dir | head -1")
if [ $? -ne 0 ]; then { echo "Failed to get last backup filename, aborting." ; exit 1; } fi
echo "Last backup file found: $last_backup_filename"
echo "Downloading..."
scp $server:$remote_backup_dir/"$last_backup_filename" "$local_backup_filename"
if [ $? -ne 0 ]; then { echo "Failed to download backup file, aborting." ; exit 1; } fi
echo "Backup file $local_backup_filename downloaded successfully"
echo "Creating dump directory: $dump_dir"
mkdir -p "$dump_dir"
if [ $? -ne 0 ]; then { echo "Failed to create dump directory, aborting." ; exit 1; } fi
echo "$dump_dir directory created"
echo "Unpacking backup file $local_backup_filename to $dump_dir"
tar -xzf "$local_backup_filename" --directory "$dump_dir" --strip-components 1
if [ $? -ne 0 ]; then { echo "Failed to unpack backup, aborting." ; exit 1; } fi
echo "Backup file $local_backup_filename successfully unpacked to $dump_dir"
echo "Removing backup file $local_backup_filename"
rm "$local_backup_filename"
if [ $? -ne 0 ]; then { echo "Failed to remove backup file, aborting." ; exit 1; } fi
echo "Backup file removed"
echo "Dropping database $database_name"
dropdb $database_name --force
if [ $? -ne 0 ]; then { echo "Failed to drop database, aborting." ; exit 1; } fi
echo "Database $database_name dropped"
echo "Creating database $database_name"
createdb $database_name
if [ $? -ne 0 ]; then { echo "Failed to create database, aborting." ; exit 1; } fi
echo "Database $database_name successfully created"
echo "BSON -> JSON"
python3 server.py bson
if [ $? -ne 0 ]; then { echo "BSON -> JSON failed, aborting." ; exit 1; } fi
echo "Start migration"
python3 server.py migrate
if [ $? -ne 0 ]; then { echo "Migration failed, aborting." ; exit 1; } fi
echo 'Done!'

View File

@ -1,36 +1,12 @@
from resolvers.auth import (
login,
sign_out,
is_email_used,
register_by_email,
confirm_email,
auth_send_link,
get_current_user,
)
from resolvers.editor import create_shout, delete_shout, update_shout
from resolvers.profile import (
from resolvers.author import (
load_authors_by,
rate_user,
update_profile,
get_authors_all,
author_followers,
author_followings,
get_followed_authors,
get_author,
get_author_by_id
)
from resolvers.topics import (
topics_all,
topics_by_community,
topics_by_author,
topic_follow,
topic_unfollow,
get_topic,
)
from resolvers.reactions import (
from resolvers.reaction import (
create_reaction,
delete_reaction,
update_reaction,
@ -38,52 +14,50 @@ from resolvers.reactions import (
reactions_follow,
load_reactions_by,
)
from resolvers.topic import (
topic_follow,
topic_unfollow,
topics_by_author,
topics_by_community,
topics_all,
get_topic,
)
from resolvers.following import follow, unfollow
from resolvers.load import load_shout, load_shouts_by
from resolvers.follower import follow, unfollow
from resolvers.reader import load_shout, load_shouts_by
from resolvers.community import get_community, get_communities_all
__all__ = [
# auth
"login",
"register_by_email",
"is_email_used",
"confirm_email",
"auth_send_link",
"sign_out",
"get_current_user",
# profile
# author
"load_authors_by",
"rate_user",
"update_profile",
"get_authors_all",
"author_followers",
"author_followings",
"get_followed_authors",
"get_author",
"get_author_by_id",
# load
# reader
"load_shout",
"load_shouts_by",
# zine.following
"rate_author",
# follower
"follow",
"unfollow",
# create
# editor
"create_shout",
"update_shout",
"delete_shout",
# topics
# topic
"topics_all",
"topics_by_community",
"topics_by_author",
"topic_follow",
"topic_unfollow",
"get_topic",
# zine.reactions
# reaction
"reactions_follow",
"reactions_unfollow",
"create_reaction",
"update_reaction",
"delete_reaction",
"load_reactions_by",
# community
"get_community",
"get_communities_all",
]

View File

@ -1,211 +0,0 @@
# -*- coding: utf-8 -*-
from datetime import datetime, timezone
from urllib.parse import quote_plus
from graphql.type import GraphQLResolveInfo
from starlette.responses import RedirectResponse
from transliterate import translit
import re
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from auth.email import send_auth_email
from auth.identity import Identity, Password
from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage
from services.exceptions import (
BaseHttpException,
InvalidPassword,
InvalidToken,
ObjectNotExist,
Unauthorized,
)
from services.db import local_session
from services.schema import mutation, query
from orm import Role, User
from resolvers.profile import user_subscriptions
from settings import SESSION_TOKEN_HEADER, FRONTEND_URL
@mutation.field("getSession")
@login_required
async def get_current_user(_, info):
auth: AuthCredentials = info.context["request"].auth
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER)
token = token.split(" ")[-1]
with local_session() as session:
user = session.query(User).where(User.id == auth.user_id).one()
user.lastSeen = datetime.now(tz=timezone.utc)
session.commit()
return {
"token": token,
"user": user,
"news": await user_subscriptions(user.id),
}
@mutation.field("confirmEmail")
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}-{payload.username}-{token}")
with local_session() as session:
user = session.query(User).where(User.id == user_id).first()
session_token = await TokenStorage.create_session(user)
user.emailConfirmed = True
user.lastSeen = datetime.now(tz=timezone.utc)
session.add(user)
session.commit()
return {
"token": session_token,
"user": user,
"news": await user_subscriptions(user.id),
}
except InvalidToken as e:
raise InvalidToken(e.message)
except Exception as e:
print(e) # FIXME: debug only
return {"error": "email is not confirmed"}
async def confirm_email_handler(request):
token = request.path_params["token"] # one time
request.session["token"] = token
res = await confirm_email(None, {}, token)
print("[resolvers.auth] confirm_email request: %r" % request)
if "error" in res:
raise BaseHttpException(res["error"])
else:
response = RedirectResponse(url=FRONTEND_URL)
response.set_cookie("token", res["token"]) # session token
return response
def create_user(user_dict):
user = User(**user_dict)
with local_session() as session:
user.roles.append(session.query(Role).first())
session.add(user)
session.commit()
return user
def generate_unique_slug(src):
print("[resolvers.auth] generating slug from: " + src)
slug = translit(src, "ru", reversed=True).replace(".", "-").lower()
slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
if slug != src:
print("[resolvers.auth] translited name: " + slug)
c = 1
with local_session() as session:
user = session.query(User).where(User.slug == slug).first()
while user:
user = session.query(User).where(User.slug == slug).first()
slug = slug + "-" + str(c)
c += 1
if not user:
unique_slug = slug
print("[resolvers.auth] " + unique_slug)
return quote_plus(unique_slug.replace("'", "")).replace("+", "-")
@mutation.field("registerUser")
async def register_by_email(_, _info, email: str, password: str = "", name: str = ""):
email = email.lower()
"""creates new user account"""
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
if user:
raise Unauthorized("User already exist")
else:
slug = generate_unique_slug(name)
user = session.query(User).where(User.slug == slug).first()
if user:
slug = generate_unique_slug(email.split("@")[0])
user_dict = {
"email": email,
"username": email, # will be used to store phone number or some messenger network id
"name": name,
"slug": slug,
}
if password:
user_dict["password"] = Password.encode(password)
user = create_user(user_dict)
user = await auth_send_link(_, _info, email)
return {"user": user}
@mutation.field("sendLink")
async def auth_send_link(_, _info, email, lang="ru", template="email_confirmation"):
email = email.lower()
"""send link with confirm code to email"""
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
if not user:
raise ObjectNotExist("User not found")
else:
token = await TokenStorage.create_onetime(user)
await send_auth_email(user, token, lang, template)
return user
@query.field("signIn")
async def login(_, info, email: str, password: str = "", lang: str = "ru"):
email = email.lower()
with local_session() as session:
orm_user = session.query(User).filter(User.email == email).first()
if orm_user is None:
print(f"[auth] {email}: email not found")
# return {"error": "email not found"}
raise ObjectNotExist("User not found") # contains webserver status
if not password:
print(f"[auth] send confirm link to {email}")
token = await TokenStorage.create_onetime(orm_user)
await send_auth_email(orm_user, token, lang)
# FIXME: not an error, warning
return {"error": "no password, email link was sent"}
else:
# sign in using password
if not orm_user.emailConfirmed:
# not an error, warns users
return {"error": "please, confirm email"}
else:
try:
user = Identity.password(orm_user, password)
session_token = await TokenStorage.create_session(user)
print(f"[auth] user {email} authorized")
return {
"token": session_token,
"user": user,
"news": await user_subscriptions(user.id),
}
except InvalidPassword:
print(f"[auth] {email}: invalid password")
raise InvalidPassword(
"invalid password"
) # contains webserver status
# return {"error": "invalid password"}
@query.field("signOut")
@login_required
async def sign_out(_, info: GraphQLResolveInfo):
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "")
status = await TokenStorage.revoke(token)
return status
@query.field("isEmailUsed")
async def is_email_used(_, _info, email):
email = email.lower()
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
return user is not None

242
resolvers/author.py Normal file
View File

@ -0,0 +1,242 @@
from typing import List
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, func, distinct, select, literal
from sqlalchemy.orm import aliased
from services.auth import login_required
from base.orm import local_session
from base.resolvers import mutation, query
from orm.shout import ShoutAuthor, ShoutTopic
from orm.topic import Topic
from orm.author import AuthorFollower, Author, AuthorRating
from community import followed_communities
from topic import followed_topics
from reaction import load_followed_reactions
def add_author_stat_columns(q):
followers_table = aliased(AuthorFollower)
followings_table = aliased(AuthorFollower)
shout_author_aliased = aliased(ShoutAuthor)
# author_rating_aliased = aliased(AuthorRating)
q = q.outerjoin(shout_author_aliased).add_columns(
func.count(distinct(shout_author_aliased.shout)).label("shouts_stat")
)
q = q.outerjoin(followers_table, followers_table.author == Author.id).add_columns(
func.count(distinct(followers_table.follower)).label("followers_stat")
)
q = q.outerjoin(
followings_table, followings_table.follower == Author.id
).add_columns(
func.count(distinct(followings_table.author)).label("followings_stat")
)
q = q.add_columns(literal(0).label("rating_stat"))
# FIXME
# q = q.outerjoin(author_rating_aliased, author_rating_aliased.user == Author.id).add_columns(
# # TODO: check
# func.sum(author_rating_aliased.value).label('rating_stat')
# )
q = q.add_columns(literal(0).label("commented_stat"))
# q = q.outerjoin(Reaction, and_(Reaction.createdBy == Author.id, Reaction.body.is_not(None))).add_columns(
# func.count(distinct(Reaction.id)).label('commented_stat')
# )
q = q.group_by(Author.id)
return q
def add_stat(author, stat_columns):
[
shouts_stat,
followers_stat,
followings_stat,
rating_stat,
commented_stat,
] = stat_columns
author.stat = {
"shouts": shouts_stat,
"followers": followers_stat,
"followings": followings_stat,
"rating": rating_stat,
"commented": commented_stat,
}
return author
def get_authors_from_query(q):
authors = []
with local_session() as session:
for [author, *stat_columns] in session.execute(q):
author = add_stat(author, stat_columns)
authors.append(author)
return authors
async def author_followings(author_id: int):
return {
# "unread": await get_total_unread_counter(author_id), # unread inbox messages counter
"topics": [
t.slug for t in await followed_topics(author_id)
], # followed topics slugs
"authors": [
a.slug for a in await followed_authors(author_id)
], # followed authors slugs
"reactions": await load_followed_reactions(author_id),
"communities": [
c.slug for c in await followed_communities(author_id)
], # communities
}
@mutation.field("updateProfile")
@login_required
async def update_profile(_, info, profile):
author_id = info.context["author_id"]
with local_session() as session:
author = session.query(Author).where(Author.id == author_id).first()
author.update(profile)
session.commit()
return {"error": None, "author": author}
# for mutation.field("follow")
def author_follow(follower_id, slug):
try:
with local_session() as session:
author = session.query(Author).where(Author.slug == slug).one()
af = AuthorFollower.create(follower=follower_id, author=author.id)
session.add(af)
session.commit()
return True
except Exception:
return False
# for mutation.field("unfollow")
def author_unfollow(follower_id, slug):
with local_session() as session:
flw = (
session.query(AuthorFollower)
.join(Author, Author.id == AuthorFollower.author)
.filter(and_(AuthorFollower.follower == follower_id, Author.slug == slug))
.first()
)
if flw:
session.delete(flw)
session.commit()
return True
return False
@query.field("authorsAll")
async def get_authors_all(_, _info):
q = select(Author)
q = add_author_stat_columns(q)
q = q.join(ShoutAuthor, Author.id == ShoutAuthor.author)
return get_authors_from_query(q)
@query.field("getAuthor")
async def get_author(_, _info, slug):
q = select(Author).where(Author.slug == slug)
q = add_author_stat_columns(q)
authors = get_authors_from_query(q)
return authors[0]
@query.field("loadAuthorsBy")
async def load_authors_by(_, _info, by, limit, offset):
q = select(Author)
q = add_author_stat_columns(q)
if by.get("slug"):
q = q.filter(Author.slug.ilike(f"%{by['slug']}%"))
elif by.get("name"):
q = q.filter(Author.name.ilike(f"%{by['name']}%"))
elif by.get("topic"):
q = (
q.join(ShoutAuthor)
.join(ShoutTopic)
.join(Topic)
.where(Topic.slug == by["topic"])
)
if by.get("lastSeen"): # in days
days_before = datetime.now(tz=timezone.utc) - timedelta(days=by["lastSeen"])
q = q.filter(Author.lastSeen > days_before)
elif by.get("createdAt"): # in days
days_before = datetime.now(tz=timezone.utc) - timedelta(days=by["createdAt"])
q = q.filter(Author.createdAt > days_before)
q = q.order_by(by.get("order", Author.createdAt)).limit(limit).offset(offset)
return get_authors_from_query(q)
async def get_followed_authors(_, _info, slug) -> List[Author]:
# First, we need to get the author_id for the given slug
with local_session() as session:
author_id_query = select(Author.id).where(Author.slug == slug)
author_id = session.execute(author_id_query).scalar()
if author_id is None:
raise ValueError("Author not found")
return await followed_authors(author_id)
async def author_followers(_, _info, slug) -> List[Author]:
q = select(Author)
q = add_author_stat_columns(q)
aliased_author = aliased(Author)
q = (
q.join(AuthorFollower, AuthorFollower.follower == Author.id)
.join(aliased_author, aliased_author.id == AuthorFollower.author)
.where(aliased_author.slug == slug)
)
return get_authors_from_query(q)
async def followed_authors(follower_id):
q = select(Author)
q = add_author_stat_columns(q)
q = q.join(AuthorFollower, AuthorFollower.author == Author.id).where(
AuthorFollower.follower == follower_id
)
# Pass the query to the get_authors_from_query function and return the results
return get_authors_from_query(q)
@mutation.field("rateAuthor")
@login_required
async def rate_author(_, info, rated_user_id, value):
author_id = info.context["author_id"]
with local_session() as session:
rating = (
session.query(AuthorRating)
.filter(
and_(
AuthorRating.rater == author_id, AuthorRating.user == rated_user_id
)
)
.first()
)
if rating:
rating.value = value
session.commit()
return {}
try:
AuthorRating.create(rater=author_id, user=rated_user_id, value=value)
except Exception as err:
return {"error": err}
return {}

View File

@ -1,8 +1,116 @@
from base.orm import local_session
from base.resolvers import query
from orm.author import Author
from orm.community import Community, CommunityAuthor
from orm.shout import ShoutCommunity
from sqlalchemy import select, distinct, func, literal, and_
from sqlalchemy.orm import aliased
def add_community_stat_columns(q):
community_followers = aliased(CommunityAuthor)
shout_community_aliased = aliased(ShoutCommunity)
q = q.outerjoin(shout_community_aliased).add_columns(
func.count(distinct(shout_community_aliased.shout)).label("shouts_stat")
)
q = q.outerjoin(
community_followers, community_followers.author == Author.id
).add_columns(
func.count(distinct(community_followers.follower)).label("followers_stat")
)
q = q.add_columns(literal(0).label("rating_stat"))
# FIXME
# q = q.outerjoin(author_rating_aliased, author_rating_aliased.user == Author.id).add_columns(
# # TODO: check
# func.sum(author_rating_aliased.value).label('rating_stat')
# )
q = q.add_columns(literal(0).label("commented_stat"))
# q = q.outerjoin(Reaction, and_(Reaction.createdBy == Author.id, Reaction.body.is_not(None))).add_columns(
# func.count(distinct(Reaction.id)).label('commented_stat')
# )
q = q.group_by(Author.id)
return q
def get_communities_from_query(q):
ccc = []
with local_session() as session:
for [c, *stat_columns] in session.execute(q):
[shouts_stat, followers_stat, rating_stat, commented_stat] = stat_columns
c.stat = {
"shouts": shouts_stat,
"followers": followers_stat,
"rating": rating_stat,
"commented": commented_stat,
}
ccc.append(c)
return ccc
def followed_communities(follower_id):
amount = select(Community).count()
if amount < 2:
# no need to run long query most of the cases
return [
select(Community).first(),
]
else:
q = select(Community)
q = add_community_stat_columns(q)
q = q.join(CommunityAuthor, CommunityAuthor.community == Community.id).where(
CommunityAuthor.follower == follower_id
)
# 3. Pass the query to the get_authors_from_query function and return the results
return get_communities_from_query(q)
# for mutation.field("follow")
def community_follow(follower_id, slug):
# TODO: implement when needed
return None
try:
with local_session() as session:
community = session.query(Community).where(Community.slug == slug).one()
cf = CommunityAuthor.create(author=follower_id, community=community.id)
session.add(cf)
session.commit()
return True
except Exception:
return False
# for mutation.field("unfollow")
def community_unfollow(follower_id, slug):
# TODO: implement
return None
with local_session() as session:
flw = (
session.query(CommunityAuthor)
.join(Community, Community.id == CommunityAuthor.community)
.filter(and_(CommunityAuthor.author == follower_id, Community.slug == slug))
.first()
)
if flw:
session.delete(flw)
session.commit()
return True
return False
@query.field("communitiesAll")
async def get_communities_all(_, _info):
q = select(Author)
q = add_community_stat_columns(q)
return get_communities_from_query(q)
@query.field("getCommunity")
async def get_community(_, _info, slug):
q = select(Community).where(Community.slug == slug)
q = add_community_stat_columns(q)
authors = get_communities_from_query(q)
return authors[0]

View File

@ -1,23 +1,44 @@
from datetime import datetime, timezone
from sqlalchemy import and_
from sqlalchemy import and_, select
from sqlalchemy.orm import joinedload
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from services.db import local_session
from services.schema import mutation
from services.auth import login_required
from base.orm import local_session
from base.resolvers import mutation, query
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
from resolvers.reactions import reactions_follow, reactions_unfollow
from reaction import reactions_follow, reactions_unfollow
from services.presence import notify_shout
@query.field("loadDrafts")
async def get_drafts(_, info):
author = info.context["request"].author
q = (
select(Shout)
.options(
joinedload(Shout.authors),
joinedload(Shout.topics),
)
.where(and_(Shout.deletedAt.is_(None), Shout.createdBy == author.id))
)
q = q.group_by(Shout.id)
shouts = []
with local_session() as session:
for [shout] in session.execute(q).unique():
shouts.append(shout)
return shouts
@mutation.field("createShout")
@login_required
async def create_shout(_, info, inp):
auth: AuthCredentials = info.context["request"].auth
author_id = info.context["author_id"]
with local_session() as session:
topics = (
session.query(Topic).filter(Topic.slug.in_(inp.get("topics", []))).all()
@ -34,8 +55,8 @@ async def create_shout(_, info, inp):
"authors": inp.get("authors", []),
"slug": inp.get("slug"),
"mainTopic": inp.get("mainTopic"),
"visibility": "owner",
"createdBy": auth.user_id,
"visibility": "authors",
"createdBy": author_id,
}
)
@ -44,12 +65,12 @@ async def create_shout(_, info, inp):
session.add(t)
# NOTE: shout made by one first author
sa = ShoutAuthor.create(shout=new_shout.id, user=auth.user_id)
sa = ShoutAuthor.create(shout=new_shout.id, author=author_id)
session.add(sa)
session.add(new_shout)
reactions_follow(auth.user_id, new_shout.id, True)
reactions_follow(author_id, new_shout.id, True)
session.commit()
@ -59,6 +80,8 @@ async def create_shout(_, info, inp):
if new_shout.slug is None:
new_shout.slug = f"draft-{new_shout.id}"
session.commit()
else:
notify_shout(new_shout.dict(), "create")
return {"shout": new_shout}
@ -66,7 +89,7 @@ async def create_shout(_, info, inp):
@mutation.field("updateShout")
@login_required
async def update_shout(_, info, shout_id, shout_input=None, publish=False):
auth: AuthCredentials = info.context["request"].auth
author_id = info.context["author_id"]
with local_session() as session:
shout = (
@ -82,7 +105,7 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False):
if not shout:
return {"error": "shout not found"}
if shout.createdBy != auth.user_id:
if shout.createdBy != author_id:
return {"error": "access denied"}
updated = False
@ -154,33 +177,39 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False):
shout.update(shout_input)
updated = True
if publish and shout.visibility == "owner":
# TODO: use visibility setting
if publish and shout.visibility == "authors":
shout.visibility = "community"
shout.publishedAt = datetime.now(tz=timezone.utc)
updated = True
# notify on publish
notify_shout(shout.dict())
if updated:
shout.updatedAt = datetime.now(tz=timezone.utc)
session.commit()
# GitTask(inp, user.username, user.email, "update shout %s" % slug)
notify_shout(shout.dict(), "update")
return {"shout": shout}
@mutation.field("deleteShout")
@login_required
async def delete_shout(_, info, shout_id):
auth: AuthCredentials = info.context["request"].auth
author_id = info.context["author_id"]
with local_session() as session:
shout = session.query(Shout).filter(Shout.id == shout_id).first()
if not shout:
return {"error": "invalid shout id"}
if auth.user_id != shout.createdBy:
if author_id != shout.createdBy:
return {"error": "access denied"}
for author_id in shout.authors:
@ -189,4 +218,7 @@ async def delete_shout(_, info, shout_id):
shout.deletedAt = datetime.now(tz=timezone.utc)
session.commit()
notify_shout(shout.dict(), "delete")
return {}

71
resolvers/follower.py Normal file
View File

@ -0,0 +1,71 @@
from services.auth import login_required
from resolvers.author import author_follow, author_unfollow
from resolvers.reaction import reactions_follow, reactions_unfollow
from resolvers.topic import topic_follow, topic_unfollow
from resolvers.community import community_follow, community_unfollow
from services.following import FollowingManager, FollowingResult
from services.db import local_session
from orm.author import Author
from services.presence import notify_follower
@login_required
async def follow(_, info, what, slug):
follower_id = info.context["author_id"]
try:
if what == "AUTHOR":
if author_follow(follower_id, slug):
result = FollowingResult("NEW", 'author', slug)
await FollowingManager.push('author', result)
with local_session() as session:
author = session.query(Author.id).where(Author.slug == slug).one()
follower = session.query(Author).where(Author.id == follower_id).one()
notify_follower(follower.dict(), author.id)
elif what == "TOPIC":
if topic_follow(follower_id, slug):
result = FollowingResult("NEW", 'topic', slug)
await FollowingManager.push('topic', result)
elif what == "COMMUNITY":
if community_follow(follower_id, slug):
result = FollowingResult("NEW", 'community', slug)
await FollowingManager.push('community', result)
elif what == "REACTIONS":
if reactions_follow(follower_id, slug):
result = FollowingResult("NEW", 'shout', slug)
await FollowingManager.push('shout', result)
except Exception as e:
print(Exception(e))
return {"error": str(e)}
return {}
@login_required
async def unfollow(_, info, what, slug):
follower_id = info.context["author_id"]
try:
if what == "AUTHOR":
if author_unfollow(follower_id, slug):
result = FollowingResult("DELETED", 'author', slug)
await FollowingManager.push('author', result)
with local_session() as session:
author = session.query(Author.id).where(Author.slug == slug).one()
follower = session.query(Author).where(Author.id == follower_id).one()
notify_follower(follower.dict(), author.id)
elif what == "TOPIC":
if topic_unfollow(follower_id, slug):
result = FollowingResult("DELETED", 'topic', slug)
await FollowingManager.push('topic', result)
elif what == "COMMUNITY":
if community_unfollow(follower_id, slug):
result = FollowingResult("DELETED", 'community', slug)
await FollowingManager.push('community', result)
elif what == "REACTIONS":
if reactions_unfollow(follower_id, slug):
result = FollowingResult("DELETED", 'shout', slug)
await FollowingManager.push('shout', result)
except Exception as e:
return {"error": str(e)}
return {}

View File

@ -1,72 +0,0 @@
from services.schema import mutation
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from resolvers.profile import author_follow, author_unfollow
from resolvers.reactions import reactions_follow, reactions_unfollow
from resolvers.topics import topic_follow, topic_unfollow
from services.following import FollowingManager, FollowingResult
from resolvers.community import community_follow, community_unfollow
from services.presence import notify_follower
from orm.user import User
from services.db import local_session
@mutation.field("follow")
@login_required
async def follow(_, info, what, slug):
auth: AuthCredentials = info.context["request"].auth
try:
if what == "AUTHOR":
if author_follow(auth.user_id, slug):
result = FollowingResult("NEW", "author", slug)
await FollowingManager.push("author", result)
with local_session() as session:
author = session.query(User.id).where(User.slug == slug).one()
follower = session.query(User.id).where(User.id == auth.user_id).one()
notify_follower(follower.dict(), author.id)
elif what == "TOPIC":
if topic_follow(auth.user_id, slug):
result = FollowingResult("NEW", "topic", slug)
await FollowingManager.push("topic", result)
elif what == "COMMUNITY":
if community_follow(auth.user_id, slug):
result = FollowingResult("NEW", "community", slug)
await FollowingManager.push("community", result)
elif what == "REACTIONS":
if reactions_follow(auth.user_id, slug):
result = FollowingResult("NEW", "shout", slug)
await FollowingManager.push("shout", result)
except Exception as e:
print(Exception(e))
return {"error": str(e)}
return {}
@mutation.field("unfollow")
@login_required
async def unfollow(_, info, what, slug):
auth: AuthCredentials = info.context["request"].auth
try:
if what == "AUTHOR":
if author_unfollow(auth.user_id, slug):
result = FollowingResult("DELETED", "author", slug)
await FollowingManager.push("author", result)
elif what == "TOPIC":
if topic_unfollow(auth.user_id, slug):
result = FollowingResult("DELETED", "topic", slug)
await FollowingManager.push("topic", result)
elif what == "COMMUNITY":
if community_unfollow(auth.user_id, slug):
result = FollowingResult("DELETED", "community", slug)
await FollowingManager.push("community", result)
elif what == "REACTIONS":
if reactions_unfollow(auth.user_id, slug):
result = FollowingResult("DELETED", "shout", slug)
await FollowingManager.push("shout", result)
except Exception as e:
return {"error": str(e)}
return {}

View File

@ -1,348 +0,0 @@
from typing import List
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, func, distinct, select, literal
from sqlalchemy.orm import aliased, joinedload
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from services.db import local_session
from services.schema import mutation, query
from orm.reaction import Reaction, ReactionKind
from orm.shout import ShoutAuthor, ShoutTopic
from orm.topic import Topic
from orm.user import AuthorFollower, Role, User, UserRating, UserRole
# from .community import followed_communities
from services.unread import get_total_unread_counter
from resolvers.topics import followed_by_user as followed_topics
def add_author_stat_columns(q, full=False):
author_followers = aliased(AuthorFollower)
author_following = aliased(AuthorFollower)
shout_author_aliased = aliased(ShoutAuthor)
q = q.outerjoin(shout_author_aliased).add_columns(
func.count(distinct(shout_author_aliased.shout)).label("shouts_stat")
)
q = q.outerjoin(author_followers, author_followers.author == User.id).add_columns(
func.count(distinct(author_followers.follower)).label("followers_stat")
)
q = q.outerjoin(author_following, author_following.follower == User.id).add_columns(
func.count(distinct(author_following.author)).label("followings_stat")
)
if full:
user_rating_aliased = aliased(UserRating)
q = q.outerjoin(
user_rating_aliased, user_rating_aliased.user == User.id
).add_columns(func.sum(user_rating_aliased.value).label("rating_stat"))
else:
q = q.add_columns(literal(-1).label("rating_stat"))
if full:
q = q.outerjoin(
Reaction, and_(Reaction.createdBy == User.id, Reaction.body.is_not(None))
).add_columns(func.count(distinct(Reaction.id)).label("commented_stat"))
else:
q = q.add_columns(literal(-1).label("commented_stat"))
q = q.group_by(User.id)
return q
def add_stat(author, stat_columns):
[
shouts_stat,
followers_stat,
followings_stat,
rating_stat,
commented_stat,
] = stat_columns
author.stat = {
"shouts": shouts_stat,
"followers": followers_stat,
"followings": followings_stat,
"rating": rating_stat,
"commented": commented_stat,
}
return author
def get_authors_from_query(q):
authors = []
with local_session() as session:
for [author, *stat_columns] in session.execute(q):
author = add_stat(author, stat_columns)
authors.append(author)
return authors
async def user_subscriptions(user_id: int):
return {
"unread": await get_total_unread_counter(
user_id
), # unread inbox messages counter
"topics": [
t.slug for t in followed_topics(user_id)
], # followed topics slugs
"authors": [
a.slug for a in followed_authors(user_id)
], # followed authors slugs
"reactions": await followed_reactions(user_id)
# "communities": [c.slug for c in followed_communities(slug)], # communities
}
# @query.field("userFollowedDiscussions")
# @login_required
async def followed_discussions(_, info, user_id) -> List[Topic]:
return await followed_reactions(user_id)
async def followed_reactions(user_id):
with local_session() as session:
user = session.query(User).where(User.id == user_id).first()
return (
session.query(Reaction.shout)
.where(Reaction.createdBy == user.id)
.filter(Reaction.createdAt > user.lastSeen)
.all()
)
# dufok mod (^*^') :
@query.field("userFollowedTopics")
async def get_followed_topics(_, info, slug) -> List[Topic]:
user_id_query = select(User.id).where(User.slug == slug)
with local_session() as session:
user_id = session.execute(user_id_query).scalar()
if user_id is None:
raise ValueError("User not found")
return followed_topics(user_id)
# dufok mod (^*^') :
@query.field("userFollowedAuthors")
async def get_followed_authors(_, _info, slug) -> List[User]:
# 1. First, we need to get the user_id for the given slug
user_id_query = select(User.id).where(User.slug == slug)
with local_session() as session:
user_id = session.execute(user_id_query).scalar()
if user_id is None:
raise ValueError("User not found")
return followed_authors(user_id)
@query.field("authorFollowings")
async def author_followings(_, info, author_id: int, limit: int = 20, offset: int = 0) -> List[User]:
return followed_authors(author_id)[offset:(limit+offset)]
@query.field("authorFollowers")
async def author_followers(_, info, author_id: int, limit: int = 20, offset: int = 0) -> List[User]:
q = select(User)
q = add_author_stat_columns(q)
aliased_user = aliased(User)
q = (
q.join(AuthorFollower, AuthorFollower.follower == User.id)
.join(aliased_user, aliased_user.id == AuthorFollower.author)
.where(aliased_user.id == author_id)
.limit(limit)
.offset(offset)
)
return get_authors_from_query(q)
# 2. Now, we can use the user_id to get the followed authors
def followed_authors(user_id):
q = select(User)
q = add_author_stat_columns(q)
q = q.join(AuthorFollower, AuthorFollower.author == User.id).where(
AuthorFollower.follower == user_id
)
# 3. Pass the query to the get_authors_from_query function and return the results
return get_authors_from_query(q)
@query.field("userFollowers")
async def user_followers(_, _info, slug) -> List[User]:
q = select(User)
q = add_author_stat_columns(q)
aliased_user = aliased(User)
q = (
q.join(AuthorFollower, AuthorFollower.follower == User.id)
.join(aliased_user, aliased_user.id == AuthorFollower.author)
.where(aliased_user.slug == slug)
)
return get_authors_from_query(q)
async def get_user_roles(slug):
with local_session() as session:
user = session.query(User).where(User.slug == slug).first()
roles = (
session.query(Role)
.options(joinedload(Role.permissions))
.join(UserRole)
.where(UserRole.user == user.id)
.all()
)
return roles
@mutation.field("updateProfile")
@login_required
async def update_profile(_, info, profile):
auth = info.context["request"].auth
user_id = auth.user_id
with local_session() as session:
user = session.query(User).filter(User.id == user_id).one()
if not user:
return {"error": "canoot find user"}
user.update(profile)
session.commit()
return {"error": None, "author": user}
@mutation.field("rateUser")
@login_required
async def rate_user(_, info, rated_userslug, value):
auth: AuthCredentials = info.context["request"].auth
with local_session() as session:
rating = (
session.query(UserRating)
.filter(
and_(
UserRating.rater == auth.user_id, UserRating.user == rated_userslug
)
)
.first()
)
if rating:
rating.value = value
session.commit()
return {}
try:
UserRating.create(rater=auth.user_id, user=rated_userslug, value=value)
except Exception as err:
return {"error": err}
return {}
# for mutation.field("follow")
def author_follow(user_id, slug):
try:
with local_session() as session:
author = session.query(User).where(User.slug == slug).one()
af = AuthorFollower.create(follower=user_id, author=author.id)
session.add(af)
session.commit()
return True
except:
return False
# for mutation.field("unfollow")
def author_unfollow(user_id, slug):
with local_session() as session:
flw = (
session.query(AuthorFollower)
.join(User, User.id == AuthorFollower.author)
.filter(and_(AuthorFollower.follower == user_id, User.slug == slug))
.first()
)
if flw:
session.delete(flw)
session.commit()
return True
return False
@query.field("authorsAll")
async def get_authors_all(_, _info):
q = select(User)
q = add_author_stat_columns(q)
q = q.join(ShoutAuthor, User.id == ShoutAuthor.user)
return get_authors_from_query(q)
@query.field("getAuthorById")
async def get_author_by_id(_, _info, author_id):
q = select(User).where(User.id == author_id)
q = add_author_stat_columns(q)
[author] = get_authors_from_query(q)
with local_session() as session:
comments_count = session.query(Reaction).where(
and_(
Reaction.createdBy == author.id,
Reaction.kind == ReactionKind.COMMENT
)
).count()
author.stat["commented"] = comments_count
return author
@query.field("getAuthor")
async def get_author(_, _info, slug):
q = select(User).where(User.slug == slug)
q = add_author_stat_columns(q)
[author] = get_authors_from_query(q)
with local_session() as session:
comments_count = session.query(Reaction).where(
and_(
Reaction.createdBy == author.id,
Reaction.kind == ReactionKind.COMMENT
)
).count()
author.stat["commented"] = comments_count
return author
@query.field("loadAuthorsBy")
async def load_authors_by(_, info, by, limit, offset):
q = select(User)
q = add_author_stat_columns(q)
if by.get("slug"):
q = q.filter(User.slug.ilike(f"%{by['slug']}%"))
elif by.get("name"):
q = q.filter(User.name.ilike(f"%{by['name']}%"))
elif by.get("topic"):
q = (
q.join(ShoutAuthor)
.join(ShoutTopic)
.join(Topic)
.where(Topic.slug == by["topic"])
)
if by.get("lastSeen"): # in days
days_before = datetime.now(tz=timezone.utc) - timedelta(days=by["lastSeen"])
q = q.filter(User.lastSeen > days_before)
elif by.get("createdAt"): # in days
days_before = datetime.now(tz=timezone.utc) - timedelta(days=by["createdAt"])
q = q.filter(User.createdAt > days_before)
q = q.order_by(by.get("order", User.createdAt)).limit(limit).offset(offset)
return get_authors_from_query(q)

View File

@ -1,16 +1,14 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, asc, desc, select, text, func, case
from sqlalchemy.orm import aliased
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from services.exceptions import OperationNotAllowed
from services.db import local_session
from services.schema import mutation, query
from services.presence import notify_reaction
from services.auth import login_required
from base.exceptions import OperationNotAllowed
from base.orm import local_session
from base.resolvers import mutation, query
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutReactionsFollower
from orm.user import User
from services.presence import notify_reaction
from orm.author import Author
def add_reaction_stat_columns(q):
@ -41,7 +39,7 @@ def add_reaction_stat_columns(q):
return q
def reactions_follow(user_id, shout_id: int, auto=False):
def reactions_follow(author_id, shout_id: int, auto=False):
try:
with local_session() as session:
shout = session.query(Shout).where(Shout.id == shout_id).one()
@ -50,7 +48,7 @@ def reactions_follow(user_id, shout_id: int, auto=False):
session.query(ShoutReactionsFollower)
.where(
and_(
ShoutReactionsFollower.follower == user_id,
ShoutReactionsFollower.follower == author_id,
ShoutReactionsFollower.shout == shout.id,
)
)
@ -59,7 +57,7 @@ def reactions_follow(user_id, shout_id: int, auto=False):
if not following:
following = ShoutReactionsFollower.create(
follower=user_id, shout=shout.id, auto=auto
follower=author_id, shout=shout.id, auto=auto
)
session.add(following)
session.commit()
@ -68,7 +66,7 @@ def reactions_follow(user_id, shout_id: int, auto=False):
return False
def reactions_unfollow(user_id: int, shout_id: int):
def reactions_unfollow(author_id: int, shout_id: int):
try:
with local_session() as session:
shout = session.query(Shout).where(Shout.id == shout_id).one()
@ -77,7 +75,7 @@ def reactions_unfollow(user_id: int, shout_id: int):
session.query(ShoutReactionsFollower)
.where(
and_(
ShoutReactionsFollower.follower == user_id,
ShoutReactionsFollower.follower == author_id,
ShoutReactionsFollower.shout == shout.id,
)
)
@ -93,31 +91,31 @@ def reactions_unfollow(user_id: int, shout_id: int):
return False
def is_published_author(session, user_id):
"""checks if user has at least one publication"""
def is_published_author(session, author_id):
"""checks if author has at least one publication"""
return (
session.query(Shout)
.where(Shout.authors.contains(user_id))
.where(Shout.authors.contains(author_id))
.filter(and_(Shout.publishedAt.is_not(None), Shout.deletedAt.is_(None)))
.count()
> 0
)
def check_to_publish(session, user_id, reaction):
def check_to_publish(session, author_id, reaction):
"""set shout to public if publicated approvers amount > 4"""
if not reaction.replyTo and reaction.kind in [
ReactionKind.ACCEPT,
ReactionKind.LIKE,
ReactionKind.PROOF,
]:
if is_published_author(user_id):
if is_published_author(author_id):
# now count how many approvers are voted already
approvers_reactions = (
session.query(Reaction).where(Reaction.shout == reaction.shout).all()
)
approvers = [
user_id,
author_id,
]
for ar in approvers_reactions:
a = ar.createdBy
@ -128,14 +126,14 @@ def check_to_publish(session, user_id, reaction):
return False
def check_to_hide(session, user_id, reaction):
def check_to_hide(session, reaction):
"""hides any shout if 20% of reactions are negative"""
if not reaction.replyTo and reaction.kind in [
ReactionKind.REJECT,
ReactionKind.DISLIKE,
ReactionKind.DISPROOF,
]:
# if is_published_author(user):
# if is_published_author(author_id):
approvers_reactions = (
session.query(Reaction).where(Reaction.shout == reaction.shout).all()
)
@ -170,12 +168,10 @@ def set_hidden(session, shout_id):
@mutation.field("createReaction")
@login_required
async def create_reaction(_, info, reaction):
auth: AuthCredentials = info.context["request"].auth
reaction["createdBy"] = auth.user_id
rdict = {}
author_id = info.context["author_id"]
with local_session() as session:
reaction["createdBy"] = author_id
shout = session.query(Shout).where(Shout.id == reaction["shout"]).one()
author = session.query(User).where(User.id == auth.user_id).one()
if reaction["kind"] in [ReactionKind.DISLIKE.name, ReactionKind.LIKE.name]:
existing_reaction = (
@ -183,7 +179,7 @@ async def create_reaction(_, info, reaction):
.where(
and_(
Reaction.shout == reaction["shout"],
Reaction.createdBy == auth.user_id,
Reaction.createdBy == author_id,
Reaction.kind == reaction["kind"],
Reaction.replyTo == reaction.get("replyTo"),
)
@ -204,7 +200,7 @@ async def create_reaction(_, info, reaction):
.where(
and_(
Reaction.shout == reaction["shout"],
Reaction.createdBy == auth.user_id,
Reaction.createdBy == author_id,
Reaction.kind == opposite_reaction_kind,
Reaction.replyTo == reaction.get("replyTo"),
)
@ -218,12 +214,10 @@ async def create_reaction(_, info, reaction):
r = Reaction.create(**reaction)
# Proposal accepting logix
# FIXME: will break if there will be 2 proposals
# FIXME: will break if shout will be changed
if (
r.replyTo is not None
and r.kind == ReactionKind.ACCEPT
and auth.user_id in shout.dict()["authors"]
and author_id in shout.dict()["authors"]
):
replied_reaction = (
session.query(Reaction).where(Reaction.id == r.replyTo).first()
@ -240,36 +234,37 @@ async def create_reaction(_, info, reaction):
session.add(r)
session.commit()
notify_reaction(r.dict())
rdict = r.dict()
rdict["shout"] = shout.dict()
author = session.query(Author).where(Author.id == author_id).first()
rdict["createdBy"] = author.dict()
# self-regulation mechanics
if check_to_hide(session, auth.user_id, r):
if check_to_hide(session, r):
set_hidden(session, r.shout)
elif check_to_publish(session, auth.user_id, r):
elif check_to_publish(session, author_id, r):
set_published(session, r.shout)
try:
reactions_follow(auth.user_id, reaction["shout"], True)
reactions_follow(author_id, reaction["shout"], True)
except Exception as e:
print(f"[resolvers.reactions] error on reactions autofollowing: {e}")
print(f"[resolvers.reactions] error on reactions auto following: {e}")
rdict["stat"] = {"commented": 0, "reacted": 0, "rating": 0}
# notification call
notify_reaction(rdict)
return {"reaction": rdict}
@mutation.field("updateReaction")
@login_required
async def update_reaction(_, info, id, reaction={}):
auth: AuthCredentials = info.context["request"].auth
async def update_reaction(_, info, rid, reaction={}):
author_id = info.context["author_id"]
with local_session() as session:
user = session.query(User).where(User.id == auth.user_id).first()
q = select(Reaction).filter(Reaction.id == id)
q = select(Reaction).filter(Reaction.id == rid)
q = add_reaction_stat_columns(q)
q = q.group_by(Reaction.id)
@ -279,7 +274,7 @@ async def update_reaction(_, info, id, reaction={}):
if not r:
return {"error": "invalid reaction id"}
if r.createdBy != user.id:
if r.createdBy != author_id:
return {"error": "access denied"}
r.body = reaction["body"]
@ -296,19 +291,20 @@ async def update_reaction(_, info, id, reaction={}):
"rating": rating_stat,
}
notify_reaction(r.dict(), "update")
return {"reaction": r}
@mutation.field("deleteReaction")
@login_required
async def delete_reaction(_, info, id):
auth: AuthCredentials = info.context["request"].auth
async def delete_reaction(_, info, rid):
author_id = info.context["author_id"]
with local_session() as session:
r = session.query(Reaction).filter(Reaction.id == id).first()
r = session.query(Reaction).filter(Reaction.id == rid).first()
if not r:
return {"error": "invalid reaction id"}
if r.createdBy != auth.user_id:
if r.createdBy != author_id:
return {"error": "access denied"}
if r.kind in [ReactionKind.LIKE, ReactionKind.DISLIKE]:
@ -316,12 +312,16 @@ async def delete_reaction(_, info, id):
else:
r.deletedAt = datetime.now(tz=timezone.utc)
session.commit()
notify_reaction(r.dict(), "delete")
return {"reaction": r}
@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 info: graphql meta
:param by: {
:shout - filter by slug
:shouts - filer by shout slug list
@ -338,8 +338,8 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
"""
q = (
select(Reaction, User, Shout)
.join(User, Reaction.createdBy == User.id)
select(Reaction, Author, Shout)
.join(Author, Reaction.createdBy == Author.id)
.join(Shout, Reaction.shout == Shout.id)
)
@ -349,7 +349,7 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
q = q.filter(Shout.slug.in_(by["shouts"]))
if by.get("createdBy"):
q = q.filter(User.slug == by.get("createdBy"))
q = q.filter(Author.id == by.get("createdBy"))
if by.get("topic"):
# TODO: check
@ -363,38 +363,32 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
if by.get("days"):
after = datetime.now(tz=timezone.utc) - timedelta(days=int(by["days"]) or 30)
q = q.filter(Reaction.createdAt > after)
q = q.filter(Reaction.createdAt > after) # FIXME: use comparing operator?
order_way = asc if by.get("sort", "").startswith("-") else desc
order_field = by.get("sort", "").replace("-", "") or Reaction.createdAt
q = q.group_by(Reaction.id, User.id, Shout.id).order_by(order_way(order_field))
q = q.group_by(Reaction.id, Author.id, Shout.id).order_by(order_way(order_field))
q = add_reaction_stat_columns(q)
q = q.where(Reaction.deletedAt.is_(None))
q = q.limit(limit).offset(offset)
reactions = []
with local_session() as session:
session = info.context["session"]
for [
reaction,
user,
author,
shout,
reacted_stat,
commented_stat,
rating_stat,
] in session.execute(q):
reaction.createdBy = user
reaction.createdBy = author
reaction.shout = shout
reaction.stat = {
"rating": rating_stat,
"commented": commented_stat,
"reacted": reacted_stat,
}
reaction.kind = reaction.kind.name
reactions.append(reaction)
# ?
@ -402,3 +396,20 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
reactions.sort(lambda r: r.stat.get(by["stat"]) or r.createdAt)
return reactions
@login_required
@query.field("followedReactions")
async def followed_reactions(_, info):
author_id = info.context["author_id"]
# FIXME: method should return array of shouts
with local_session() as session:
author = session.query(Author).where(Author.id == author_id).first()
reactions = (
session.query(Reaction.shout)
.where(Reaction.createdBy == author.id)
.filter(Reaction.createdAt > author.lastSeen)
.all()
)
return reactions

View File

@ -1,24 +1,15 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import joinedload, aliased
from sqlalchemy.sql.expression import (
desc,
asc,
select,
func,
case,
and_,
# text,
nulls_last,
)
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from aiohttp.web_exceptions import HTTPException
from sqlalchemy.orm import joinedload, aliased
from sqlalchemy.sql.expression import desc, asc, select, func, case, and_, nulls_last
from services.auth import login_required
from services.db import local_session
from services.schema import query
from orm import TopicFollower
from orm.topic import TopicFollower
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.user import AuthorFollower
from orm.author import AuthorFollower
def add_stat_columns(q):
@ -55,9 +46,9 @@ def add_stat_columns(q):
return q
def apply_filters(q, filters, user_id=None):
if filters.get("reacted") and user_id:
q.join(Reaction, Reaction.createdBy == user_id)
def apply_filters(q, filters, author_id=None):
if filters.get("reacted") and author_id:
q.join(Reaction, Reaction.createdBy == author_id)
v = filters.get("visibility")
if v == "public":
@ -67,8 +58,6 @@ def apply_filters(q, filters, user_id=None):
if filters.get("layout"):
q = q.filter(Shout.layout == filters.get("layout"))
if filters.get("excludeLayout"):
q = q.filter(Shout.layout != filters.get("excludeLayout"))
if filters.get("author"):
q = q.filter(Shout.authors.any(slug=filters.get("author")))
if filters.get("topic"):
@ -86,8 +75,7 @@ def apply_filters(q, filters, user_id=None):
return q
@query.field("loadShout")
async def load_shout(_, info, slug=None, shout_id=None):
async def load_shout(_, _info, slug=None, shout_id=None):
with local_session() as session:
q = select(Shout).options(
joinedload(Shout.authors),
@ -103,15 +91,14 @@ async def load_shout(_, info, slug=None, shout_id=None):
q = q.filter(Shout.deletedAt.is_(None)).group_by(Shout.id)
resp = session.execute(q).first()
if resp:
try:
[
shout,
reacted_stat,
commented_stat,
rating_stat,
last_comment,
] = resp
_last_comment,
] = session.execute(q).first()
shout.stat = {
"viewed": shout.views,
@ -124,21 +111,20 @@ async def load_shout(_, info, slug=None, shout_id=None):
session.query(ShoutAuthor).join(Shout).where(Shout.slug == slug)
):
for author in shout.authors:
if author.id == author_caption.user:
if author.id == author_caption.author:
author.caption = author_caption.caption
return shout
else:
print("Slug was not found: %s" % slug)
return
except Exception:
raise HTTPException(status_code=404, detail="Slug was not found: %s" % slug)
@query.field("loadShouts")
async def load_shouts_by(_, info, options):
"""
:param _:
:param info:GraphQLInfo
:param options: {
filters: {
layout: 'music',
excludeLayout: 'article',
layout: 'audio',
visibility: "public",
author: 'discours',
topic: 'culture',
@ -161,13 +147,13 @@ async def load_shouts_by(_, info, options):
joinedload(Shout.authors),
joinedload(Shout.topics),
)
.where(and_(Shout.deletedAt.is_(None), Shout.layout.is_not(None)))
.where(Shout.deletedAt.is_(None))
)
q = add_stat_columns(q)
auth: AuthCredentials = info.context["request"].auth
q = apply_filters(q, options.get("filters", {}), auth.user_id)
author_id = info.context["author_id"]
q = apply_filters(q, options.get("filters", {}), author_id)
order_by = options.get("order_by", Shout.publishedAt)
@ -185,15 +171,14 @@ async def load_shouts_by(_, info, options):
)
shouts = []
with local_session() as session:
shouts_map = {}
with local_session() as session:
for [
shout,
reacted_stat,
commented_stat,
rating_stat,
last_comment,
_last_comment,
] in session.execute(q).unique():
shouts.append(shout)
shout.stat = {
@ -207,43 +192,16 @@ async def load_shouts_by(_, info, options):
return shouts
@query.field("loadDrafts")
@login_required
async def get_drafts(_, info):
auth: AuthCredentials = info.context["request"].auth
user_id = auth.user_id
q = (
select(Shout)
.options(
joinedload(Shout.authors),
joinedload(Shout.topics),
)
.where(and_(Shout.deletedAt.is_(None), Shout.createdBy == user_id))
)
q = q.group_by(Shout.id)
shouts = []
with local_session() as session:
for [shout] in session.execute(q).unique():
shouts.append(shout)
return shouts
@query.field("myFeed")
@login_required
async def get_my_feed(_, info, options):
auth: AuthCredentials = info.context["request"].auth
user_id = auth.user_id
author_id = info.context["author_id"]
with local_session() as session:
subquery = (
select(Shout.id)
.join(ShoutAuthor)
.join(AuthorFollower, AuthorFollower.follower == user_id)
.join(AuthorFollower, AuthorFollower.follower._is(author_id))
.join(ShoutTopic)
.join(TopicFollower, TopicFollower.follower == user_id)
.join(TopicFollower, TopicFollower.follower._is(author_id))
)
q = (
@ -262,7 +220,7 @@ async def get_my_feed(_, info, options):
)
q = add_stat_columns(q)
q = apply_filters(q, options.get("filters", {}), user_id)
q = apply_filters(q, options.get("filters", {}), author_id)
order_by = options.get("order_by", Shout.publishedAt)
@ -280,14 +238,13 @@ async def get_my_feed(_, info, options):
)
shouts = []
with local_session() as session:
shouts_map = {}
for [
shout,
reacted_stat,
commented_stat,
rating_stat,
last_comment,
_last_comment,
] in session.execute(q).unique():
shouts.append(shout)
shout.stat = {
@ -297,5 +254,5 @@ async def get_my_feed(_, info, options):
"rating": rating_stat,
}
shouts_map[shout.id] = shout
# FIXME: shouts_map does not go anywhere?
return shouts

View File

@ -1,12 +1,22 @@
from sqlalchemy import and_, select, distinct, func
from sqlalchemy.orm import aliased
from auth.authenticate import login_required
from services.auth import login_required
from services.db import local_session
from services.schema import mutation, query
from resolvers import mutation, query
from orm.shout import ShoutTopic, ShoutAuthor
from orm.topic import Topic, TopicFollower
from orm import User
from orm.author import Author
async def followed_topics(follower_id):
q = select(Author)
q = add_topic_stat_columns(q)
q = q.join(TopicFollower, TopicFollower.author == Author.id).where(
TopicFollower.follower == follower_id
)
# Pass the query to the get_authors_from_query function and return the results
return get_topics_from_query(q)
def add_topic_stat_columns(q):
@ -54,10 +64,10 @@ def get_topics_from_query(q):
return topics
def followed_by_user(user_id):
def topics_followed_by(author_id):
q = select(Topic)
q = add_topic_stat_columns(q)
q = q.join(TopicFollower).where(TopicFollower.follower == user_id)
q = q.join(TopicFollower).where(TopicFollower.follower == author_id)
return get_topics_from_query(q)
@ -79,10 +89,10 @@ async def topics_by_community(_, info, community):
@query.field("topicsByAuthor")
async def topics_by_author(_, _info, author):
async def topics_by_author(_, _info, author_id):
q = select(Topic)
q = add_topic_stat_columns(q)
q = q.join(User).where(User.slug == author)
q = q.join(Author).where(Author.id == author_id)
return get_topics_from_query(q)
@ -108,7 +118,6 @@ async def create_topic(_, _info, inp):
return {"topic": new_topic}
@mutation.field("updateTopic")
@login_required
async def update_topic(_, _info, inp):
slug = inp["slug"]
@ -123,16 +132,16 @@ async def update_topic(_, _info, inp):
return {"topic": topic}
def topic_follow(user_id, slug):
def topic_follow(follower_id, slug):
try:
with local_session() as session:
topic = session.query(Topic).where(Topic.slug == slug).one()
following = TopicFollower.create(topic=topic.id, follower=user_id)
following = TopicFollower.create(topic=topic.id, follower=follower_id)
session.add(following)
session.commit()
return True
except:
except Exception:
return False
@ -149,12 +158,11 @@ def topic_unfollow(user_id, slug):
session.delete(sub)
session.commit()
return True
except:
except Exception:
pass
return False
@query.field("topicsRandom")
async def topics_random(_, info, amount=12):
q = select(Topic)
q = q.join(ShoutTopic)

View File

@ -1,56 +0,0 @@
import os
import shutil
import tempfile
import uuid
import boto3
from botocore.exceptions import BotoCoreError, ClientError
from starlette.responses import JSONResponse
STORJ_ACCESS_KEY = os.environ.get('STORJ_ACCESS_KEY')
STORJ_SECRET_KEY = os.environ.get('STORJ_SECRET_KEY')
STORJ_END_POINT = os.environ.get('STORJ_END_POINT')
STORJ_BUCKET_NAME = os.environ.get('STORJ_BUCKET_NAME')
CDN_DOMAIN = os.environ.get('CDN_DOMAIN')
async def upload_handler(request):
form = await request.form()
file = form.get('file')
if file is None:
return JSONResponse({'error': 'No file uploaded'}, status_code=400)
file_name, file_extension = os.path.splitext(file.filename)
key = str(uuid.uuid4()) + file_extension
# Create an S3 client with Storj configuration
s3 = boto3.client('s3',
aws_access_key_id=STORJ_ACCESS_KEY,
aws_secret_access_key=STORJ_SECRET_KEY,
endpoint_url=STORJ_END_POINT)
try:
# Save the uploaded file to a temporary file
with tempfile.NamedTemporaryFile() as tmp_file:
shutil.copyfileobj(file.file, tmp_file)
s3.upload_file(
Filename=tmp_file.name,
Bucket=STORJ_BUCKET_NAME,
Key=key,
ExtraArgs={
"ContentType": file.content_type
}
)
url = 'http://' + CDN_DOMAIN + '/' + key
return JSONResponse({'url': url, 'originalFilename': file.filename})
except (BotoCoreError, ClientError) as e:
print(e)
return JSONResponse({'error': 'Failed to upload file'}, status_code=500)

242
schemas/auth.graphql Normal file
View File

@ -0,0 +1,242 @@
scalar Dict
type ConfigType {
authorizerURL: String!
redirectURL: String!
clientID: String!
extraHeaders: [Header]
}
type User {
id: ID!
email: String!
preferred_username: String!
email_verified: Boolean!
signup_methods: String!
given_name: String
family_name: String
middle_name: String
nickname: String
picture: String
gender: String
birthdate: String
phone_number: String
phone_number_verified: Boolean
roles: [String]
created_at: Int!
updated_at: Int!
is_multi_factor_auth_enabled: Boolean
}
type AuthToken {
message: String
access_token: String!
expires_in: Int!
id_token: String!
refresh_token: String
user: User
should_show_email_otp_screen: Boolean
should_show_mobile_otp_screen: Boolean
}
type Response {
message: String!
}
type Header {
key: String!
value: String!
}
input HeaderIn {
key: String!
value: String!
}
input LoginInput {
email: String!
password: String!
roles: [String]
scope: [String]
state: String
}
input SignupInput {
email: String!
password: String!
confirm_password: String!
given_name: String
family_name: String
middle_name: String
nickname: String
picture: String
gender: String
birthdate: String
phone_number: String
roles: [String]
scope: [String]
redirect_uri: String
is_multi_factor_auth_enabled: Boolean
state: String
}
input MagicLinkLoginInput {
email: String!
roles: [String]
scopes: [String]
state: String
redirect_uri: String
}
input VerifyEmailInput {
token: String!
state: String
}
input VerifyOtpInput {
email: String
phone_number: String
otp: String!
state: String
}
input ResendOtpInput {
email: String
phone_number: String
}
input GraphqlQueryInput {
query: String!
variables: Dict
headers: [HeaderIn]
}
type MetaData {
version: String!
client_id: String!
is_google_login_enabled: Boolean!
is_facebook_login_enabled: Boolean!
is_github_login_enabled: Boolean!
is_linkedin_login_enabled: Boolean!
is_apple_login_enabled: Boolean!
is_twitter_login_enabled: Boolean!
is_microsoft_login_enabled: Boolean!
is_email_verification_enabled: Boolean!
is_basic_authentication_enabled: Boolean!
is_magic_link_login_enabled: Boolean!
is_sign_up_enabled: Boolean!
is_strong_password_enabled: Boolean!
}
input UpdateProfileInput {
old_password: String
new_password: String
confirm_new_password: String
email: String
given_name: String
family_name: String
middle_name: String
nickname: String
gender: String
birthdate: String
phone_number: String
picture: String
is_multi_factor_auth_enabled: Boolean
}
input ForgotPasswordInput {
email: String!
state: String
redirect_uri: String
}
input ResetPasswordInput {
token: String!
password: String!
confirm_password: String!
}
input SessionQueryInput {
roles: [String]
}
input IsValidJWTQueryInput {
jwt: String!
roles: [String]
}
type ValidJWTResponse {
valid: String!
message: String!
}
enum OAuthProviders {
Apple
Github
Google
Facebook
LinkedIn
}
enum ResponseTypes {
Code
Token
}
input AuthorizeInput {
response_type: ResponseTypes!
use_refresh_token: Boolean
response_mode: String
}
type AuthorizeResponse {
state: String!
code: String
error: String
error_description: String
}
input RevokeTokenInput {
refresh_token: String!
}
input GetTokenInput {
code: String
grant_type: String
refresh_token: String
}
type GetTokenResponse {
access_token: String!
expires_in: Int!
id_token: String!
refresh_token: String
}
input ValidateJWTTokenInput {
token_type: TokenType!
token: String!
roles: [String]
}
type ValidateJWTTokenResponse {
is_valid: Boolean!
claims: Dict
}
input ValidateSessionInput {
cookie: String
roles: [String]
}
type ValidateSessionResponse {
is_valid: Boolean!
user: User
}
enum TokenType {
access_token
id_token
refresh_token
}

View File

@ -1,63 +1,13 @@
# Скалярные типы данных
scalar DateTime
type _Service {
sdl: String
}
################################### Payload ###################################
# Перечисления
type UserFollowings {
unread: Int
topics: [String]
authors: [String]
reactions: [Int]
communities: [String]
}
type AuthResult {
error: String
token: String
user: User
news: UserFollowings
}
type AuthorStat {
followings: Int
followers: Int
rating: Int
commented: Int
shouts: Int
}
type Author {
id: Int!
slug: String!
name: String!
userpic: String
caption: String # only for full shout
bio: String
about: String
links: [String]
stat: AuthorStat
roles: [Role] # in different communities
lastSeen: DateTime
createdAt: DateTime
}
type Result {
error: String
slugs: [String]
shout: Shout
shouts: [Shout]
author: Author
authors: [Author]
reaction: Reaction
reactions: [Reaction]
topic: Topic
topics: [Topic]
community: Community
communities: [Community]
enum ShoutVisibility {
AUTHORS
COMMUNITY
PUBLIC
}
enum ReactionStatus {
@ -68,13 +18,33 @@ enum ReactionStatus {
DELETED
}
type ReactionUpdating {
error: String
status: ReactionStatus
reaction: Reaction
enum ReactionKind {
LIKE
DISLIKE
AGREE
DISAGREE
PROOF
DISPROOF
COMMENT
QUOTE
PROPOSE
ASK
REMARK
FOOTNOTE
ACCEPT
REJECT
}
################################### Inputs ###################################
enum FollowingEntity {
TOPIC
AUTHOR
COMMUNITY
REACTIONS
}
# Входные типы
input ShoutInput {
slug: String
@ -102,17 +72,13 @@ input ProfileInput {
}
input TopicInput {
id: Int,
id: Int
slug: String!
# community: String!
title: String
body: String
pic: String
# children: [String]
# parents: [String]
}
input ReactionInput {
kind: ReactionKind!
shout: Int!
@ -121,48 +87,6 @@ input ReactionInput {
replyTo: Int
}
enum FollowingEntity {
TOPIC
AUTHOR
COMMUNITY
REACTIONS
}
################################### Mutation
type Mutation {
# auth
getSession: AuthResult!
registerUser(email: String!, password: String, name: String): AuthResult!
sendLink(email: String!, lang: String, template: String): Result!
confirmEmail(token: String!): AuthResult!
# shout
createShout(inp: ShoutInput!): Result!
updateShout(shout_id: Int!, shout_input: ShoutInput, publish: Boolean): Result!
deleteShout(shout_id: Int!): Result!
# user profile
rateUser(slug: String!, value: Int!): Result!
updateProfile(profile: ProfileInput!): Result!
# topics
createTopic(input: TopicInput!): Result!
# TODO: mergeTopics(t1: String!, t2: String!): Result!
updateTopic(input: TopicInput!): Result!
destroyTopic(slug: String!): Result!
# reactions
createReaction(reaction: ReactionInput!): Result!
updateReaction(id: Int!, reaction: ReactionInput!): Result!
deleteReaction(id: Int!): Result!
# following
follow(what: FollowingEntity!, slug: String!): Result!
unfollow(what: FollowingEntity!, slug: String!): Result!
}
input AuthorsBy {
lastSeen: DateTime
createdAt: DateTime
@ -174,13 +98,26 @@ input AuthorsBy {
stat: String
}
input ShoutsFilterBy {
slug: String
title: String
body: String
topic: String
topics: [String]
author: String
authors: [String]
layout: String
visibility: String
days: Int
stat: String
}
input LoadShoutsFilters {
title: String
body: String
topic: String
author: String
layout: String
excludeLayout: String
visibility: String
days: Int
reacted: Boolean
@ -196,76 +133,70 @@ input LoadShoutsOptions {
}
input ReactionBy {
shout: String # slug
shout: String
shouts: [String]
search: String # fts on body
search: String
comment: Boolean
topic: String # topic.slug
createdBy: String # user.slug
days: Int # before
sort: String # how to sort, default createdAt
topic: String
createdBy: String
days: Int
sort: String
}
type Query {
# auth
isEmailUsed(email: String!): Boolean!
signIn(email: String!, password: String, lang: String): AuthResult!
signOut: AuthResult!
# Типы
# zine
loadAuthorsBy(by: AuthorsBy, limit: Int, offset: Int): [Author]!
loadShout(slug: String, shout_id: Int): Shout
loadShouts(options: LoadShoutsOptions): [Shout]!
loadDrafts: [Shout]!
loadReactionsBy(by: ReactionBy!, limit: Int, offset: Int): [Reaction]!
userFollowers(slug: String!): [Author]!
userFollowedAuthors(slug: String!): [Author]!
userFollowedTopics(slug: String!): [Topic]!
authorFollowers(author_id: Int!, limit: Int, offset: Int): [Author]!
authorFollowings(author_id: Int!, limit: Int, offset: Int): [Author]!
authorsAll: [Author]!
getAuthorById(author_id: Int!): Author
getAuthor(slug: String!): Author
myFeed(options: LoadShoutsOptions): [Shout]
# migrate
markdownBody(body: String!): String!
# topics
getTopic(slug: String!): Topic
topicsAll: [Topic]!
topicsRandom(amount: Int): [Topic]!
topicsByCommunity(community: String!): [Topic]!
topicsByAuthor(author: String!): [Topic]!
# Apollo SDL
_service: _Service!
type AuthorFollowings {
unread: Int
topics: [String]
authors: [String]
reactions: [Int]
communities: [String]
}
############################################ Entities
type AuthorStat {
followings: Int
followers: Int
rating: Int
commented: Int
shouts: Int
}
type Resource {
type Author {
id: Int!
name: String!
user: Int!
slug: String!
name: String
communities: [Community]
userpic: String
caption: String
bio: String
about: String
links: [String]
stat: AuthorStat
lastSeen: DateTime
}
type Operation {
id: Int!
name: String!
type Result {
error: String
slugs: [String]
shout: Shout
shouts: [Shout]
author: Author
authors: [Author]
reaction: Reaction
reactions: [Reaction]
topic: Topic
topics: [Topic]
community: Community
communities: [Community]
}
type Permission {
operation: Int!
resource: Int!
}
type Role {
id: Int!
name: String!
community: String!
desc: String
permissions: [Permission!]!
type ReactionUpdating {
error: String
status: ReactionStatus
reaction: Reaction
}
type Rating {
@ -273,60 +204,15 @@ type Rating {
value: Int!
}
type User {
id: Int!
username: String! # to login, ex. email, phone
createdAt: DateTime!
lastSeen: DateTime
slug: String!
name: String # to display
email: String
password: String
oauth: String # provider:token
userpic: String
links: [String]
emailConfirmed: Boolean # should contain all emails too
muted: Boolean
updatedAt: DateTime
ratings: [Rating]
bio: String
about: String
communities: [Int] # user participating communities
oid: String
}
enum ReactionKind {
LIKE
DISLIKE
AGREE
DISAGREE
PROOF
DISPROOF
COMMENT
QUOTE
PROPOSE
ASK
REMARK
FOOTNOTE
ACCEPT
REJECT
}
type Reaction {
id: Int!
shout: Shout!
createdAt: DateTime!
createdBy: User!
createdBy: Author!
updatedAt: DateTime
deletedAt: DateTime
deletedBy: User
range: String # full / 0:2340
deletedBy: Author
range: String
kind: ReactionKind!
body: String
replyTo: Int
@ -335,7 +221,6 @@ type Reaction {
old_thread: String
}
# is publication
type Shout {
id: Int!
slug: String!
@ -344,22 +229,23 @@ type Shout {
description: String
createdAt: DateTime!
topics: [Topic]
authors: [Author]
communities: [Community]
mainTopic: String
title: String
subtitle: String
authors: [Author]
lang: String
community: String
cover: String
layout: String # music video literature image
versionOf: String # for translations and re-telling the same story
visibility: String # owner authors community public
layout: String
versionOf: String
visibility: ShoutVisibility
updatedAt: DateTime
updatedBy: User
updatedBy: Author
deletedAt: DateTime
deletedBy: User
deletedBy: Author
publishedAt: DateTime
media: String # json [ { title pic url body }, .. ]
media: String
stat: Stat
}
@ -378,7 +264,7 @@ type Community {
desc: String
pic: String!
createdAt: DateTime!
createdBy: User!
createdBy: Author!
}
type Collection {
@ -389,17 +275,13 @@ type Collection {
amount: Int
publishedAt: DateTime
createdAt: DateTime!
createdBy: User!
createdBy: Author!
}
type TopicStat {
shouts: Int!
followers: Int!
authors: Int!
# viewed: Int
# reacted: Int!
# commented: Int
# rating: Int
}
type Topic {
@ -408,16 +290,52 @@ type Topic {
title: String
body: String
pic: String
# community: Community!
stat: TopicStat
oid: String
}
type Token {
createdAt: DateTime!
expiresAt: DateTime
id: Int!
ownerId: Int!
usedAt: DateTime
value: String!
# Мутации
type Mutation {
createShout(inp: ShoutInput!): Result!
updateShout(shout_id: Int!, shout_input: ShoutInput, publish: Boolean): Result!
deleteShout(shout_id: Int!): Result!
rateAuthor(slug: String!, value: Int!): Result!
updateOnlineStatus: Result!
updateProfile(profile: ProfileInput!): Result!
createTopic(input: TopicInput!): Result!
updateTopic(input: TopicInput!): Result!
destroyTopic(slug: String!): Result!
createReaction(reaction: ReactionInput!): Result!
updateReaction(id: Int!, reaction: ReactionInput!): Result!
deleteReaction(id: Int!): Result!
follow(what: FollowingEntity!, slug: String!): Result!
unfollow(what: FollowingEntity!, slug: String!): Result!
}
# Запросы
type Query {
loadShout(slug: String, shout_id: Int): Shout
loadShouts(options: LoadShoutsOptions): [Shout]
loadFeed(options: LoadShoutsOptions): [Shout]
loadDrafts: [Shout]
loadReactionsBy(by: ReactionBy!, limit: Int, offset: Int): [Reaction]
followedReactions(follower_id: Int!): [Shout]
authorFollowers(slug: String!): [Author]
authorFollowedAuthors(slug: String!): [Author]
authorFollowedTopics(slug: String!): [Topic]
loadAuthorsBy(by: AuthorsBy, limit: Int, offset: Int): [Author]
authorsAll: [Author]
getAuthor(slug: String!): Author
getTopic(slug: String!): Topic
topicsAll: [Topic]
topicsRandom(amount: Int): [Topic]
topicsByCommunity(community: String!): [Topic]
topicsByAuthor(author_id: Int!): [Topic]
}

View File

@ -1,13 +1,8 @@
import sys
import os
import uvicorn
from uvicorn.main import logger
from settings import PORT, DEV_SERVER_PID_FILE_NAME
def exception_handler(exception_type, exception, traceback, debug_hook=sys.excepthook):
print("%s: %s" % (exception_type.__name__, exception))
from settings import PORT
log_settings = {
"version": 1,
@ -53,36 +48,20 @@ local_headers = [
("Access-Control-Allow-Credentials", "true"),
]
if __name__ == "__main__":
x = ""
if len(sys.argv) > 1:
x = sys.argv[1]
if x == "dev":
if os.path.exists(DEV_SERVER_PID_FILE_NAME):
os.remove(DEV_SERVER_PID_FILE_NAME)
want_reload = False
if "reload" in sys.argv:
print("MODE: DEV + RELOAD")
want_reload = True
else:
print("MODE: DEV")
uvicorn.run(
"main:dev_app",
host="localhost",
port=8080,
headers=local_headers,
# log_config=log_settings,
log_level=None,
access_log=True,
reload=want_reload,
) # , ssl_keyfile="discours.key", ssl_certfile="discours.crt")
else:
def exception_handler(_et, exc, _tb):
logger.error(..., exc_info=(type(exc), exc, exc.__traceback__))
if __name__ == "__main__":
sys.excepthook = exception_handler
if "dev" in sys.argv:
import os
os.environ.set("MODE", "development")
uvicorn.run(
"main:app",
host="0.0.0.0",
port=PORT,
proxy_headers=True,
server_header=True,
server_header=True
)

69
services/auth.py Normal file
View File

@ -0,0 +1,69 @@
from functools import wraps
from httpx import AsyncClient, HTTPError
from settings import AUTH_URL
async def check_auth(req):
token = req.headers.get("Authorization")
print(f"[services.auth] checking auth token: {token}")
query_name = "session"
query_type = "query"
operation = "GetUserId"
headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
gql = {
"query": query_type + " " + operation + " { " + query_name + " { user { id } } " + " }",
"operationName": operation,
"variables": None,
}
async with AsyncClient(timeout=30.0) as client:
response = await client.post(AUTH_URL, headers=headers, json=gql)
print(f"[services.auth] response: {response.status_code} {response.text}")
if response.status_code != 200:
return False, None
r = response.json()
try:
user_id = (
r.get("data", {}).get(query_name, {}).get("user", {}).get("id", None)
)
is_authenticated = user_id is not None
return is_authenticated, user_id
except Exception as e:
print(f"{e}: {r}")
return False, None
def login_required(f):
@wraps(f)
async def decorated_function(*args, **kwargs):
info = args[1]
context = info.context
req = context.get("request")
is_authenticated, user_id = await check_auth(req)
if not is_authenticated:
raise Exception("You are not logged in")
else:
# Добавляем author_id в контекст
context["author_id"] = user_id
# Если пользователь аутентифицирован, выполняем резолвер
return await f(*args, **kwargs)
return decorated_function
def auth_request(f):
@wraps(f)
async def decorated_function(*args, **kwargs):
req = args[0]
is_authenticated, user_id = await check_auth(req)
if not is_authenticated:
raise HTTPError("please, login first")
else:
req["author_id"] = user_id
return await f(*args, **kwargs)
return decorated_function

View File

@ -1,12 +1,12 @@
import json
from services.redis import redis
from services.rediscache import redis
async def notify_reaction(reaction):
async def notify_reaction(reaction, action: str = "create"):
channel_name = "reaction"
data = {
"payload": reaction,
"action": "create"
"action": action
}
try:
await redis.publish(channel_name, json.dumps(data))
@ -14,11 +14,11 @@ async def notify_reaction(reaction):
print(f"Failed to publish to channel {channel_name}: {e}")
async def notify_shout(shout):
async def notify_shout(shout, action: str = "create"):
channel_name = "shout"
data = {
"payload": shout,
"action": "create"
"action": action
}
try:
await redis.publish(channel_name, json.dumps(data))
@ -26,7 +26,7 @@ async def notify_shout(shout):
print(f"Failed to publish to channel {channel_name}: {e}")
async def notify_follower(follower: dict, author_id: int):
async def notify_follower(follower: dict, author_id: int, action: str = "follow"):
fields = follower.keys()
for k in fields:
if k not in ["id", "name", "slug", "userpic"]:
@ -34,7 +34,7 @@ async def notify_follower(follower: dict, author_id: int):
channel_name = f"follower:{author_id}"
data = {
"payload": follower,
"action": "follow",
"action": action
}
try:
await redis.publish(channel_name, json.dumps(data))

View File

@ -1,6 +1,6 @@
import asyncio
import json
from services.redis import redis
from services.rediscache import redis
from orm.shout import Shout
from resolvers.load import load_shouts_by

67
services/server.py Normal file
View File

@ -0,0 +1,67 @@
import sys
import uvicorn
from uvicorn.main import logger
from settings import PORT
log_settings = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(message)s",
"use_colors": None,
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
},
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
},
"access": {
"formatter": "access",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
},
"loggers": {
"uvicorn": {"handlers": ["default"], "level": "INFO"},
"uvicorn.error": {"level": "INFO", "handlers": ["default"], "propagate": True},
"uvicorn.access": {"handlers": ["access"], "level": "INFO", "propagate": False},
},
}
local_headers = [
("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD"),
("Access-Control-Allow-Origin", "https://localhost:3000"),
(
"Access-Control-Allow-Headers",
"DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization",
),
("Access-Control-Expose-Headers", "Content-Length,Content-Range"),
("Access-Control-Allow-Credentials", "true"),
]
def exception_handler(_et, exc, _tb):
logger.error(..., exc_info=(type(exc), exc, exc.__traceback__))
if __name__ == "__main__":
sys.excepthook = exception_handler
if "dev" in sys.argv:
import os
os.environ.set("MODE", "development")
uvicorn.run(
"main:app",
host="0.0.0.0",
port=PORT,
proxy_headers=True,
server_header=True
)

14
services/settings.py Normal file
View File

@ -0,0 +1,14 @@
from os import environ
PORT = 8080
DB_URL = (
environ.get("DATABASE_URL")
or environ.get("DB_URL")
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 ""
AUTH_URL = environ.get("AUTH_URL") or ""
MODE = environ.get("MODE") or "production"
SENTRY_DSN = environ.get("SENTRY_DSN")
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"

View File

@ -1,6 +1,4 @@
import json
from services.redis import redis
from services.rediscache import redis
async def get_unread_counter(chat_id: str, author_id: int) -> int:

View File

@ -1,33 +1,14 @@
from os import environ
PORT = 8080
DB_URL = (
environ.get("DATABASE_URL") or environ.get("DB_URL") or
"postgresql://postgres@localhost:5432/discoursio"
environ.get("DATABASE_URL")
or environ.get("DB_URL")
or "postgresql://postgres@localhost:5432/discoursio"
)
JWT_ALGORITHM = "HS256"
JWT_SECRET_KEY = environ.get("JWT_SECRET_KEY") or "8f1bd7696ffb482d8486dfbc6e7d16dd-secret-key"
SESSION_TOKEN_LIFE_SPAN = 30 * 24 * 60 * 60 # 1 month in seconds
ONETIME_TOKEN_LIFE_SPAN = 24 * 60 * 60 # 1 day in seconds
REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1"
MAILGUN_API_KEY = environ.get("MAILGUN_API_KEY")
MAILGUN_DOMAIN = environ.get("MAILGUN_DOMAIN")
OAUTH_PROVIDERS = ("GITHUB", "FACEBOOK", "GOOGLE")
OAUTH_CLIENTS = {}
for provider in OAUTH_PROVIDERS:
OAUTH_CLIENTS[provider] = {
"id": environ.get(provider + "_OAUTH_ID"),
"key": environ.get(provider + "_OAUTH_KEY"),
}
FRONTEND_URL = environ.get("FRONTEND_URL") or "http://localhost:3000"
SHOUTS_REPO = "content"
SESSION_TOKEN_HEADER = "Authorization"
API_BASE = environ.get("API_BASE") or ""
AUTH_URL = environ.get("AUTH_URL") or ""
MODE = environ.get("MODE") or "production"
SENTRY_DSN = environ.get("SENTRY_DSN")
SESSION_SECRET_KEY = environ.get("SESSION_SECRET_KEY") or "!secret"
# for local development
DEV_SERVER_PID_FILE_NAME = 'dev-server.pid'
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"

View File

@ -1,39 +0,0 @@
[isort]
# https://github.com/PyCQA/isort
line_length = 120
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
force_alphabetical_sort = false
[tool:brunette]
# https://github.com/odwyersoftware/brunette
line-length = 120
single-quotes = false
[flake8]
# https://github.com/PyCQA/flake8
exclude = .git,__pycache__,.mypy_cache,.vercel
max-line-length = 120
max-complexity = 15
select = B,C,E,F,W,T4,B9
# E203: Whitespace before ':'
# E266: Too many leading '#' for block comment
# E501: Line too long (82 > 79 characters)
# E722: Do not use bare except, specify exception instead
# W503: Line break occurred before a binary operator
# F403: 'from module import *' used; unable to detect undefined names
# C901: Function is too complex
ignore = E203,E266,E501,E722,W503,F403,C901
[mypy]
# https://github.com/python/mypy
ignore_missing_imports = true
warn_return_any = false
warn_unused_configs = true
disallow_untyped_calls = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
[mypy-api.*]
ignore_errors = true