diff --git a/.flake8 b/.flake8 deleted file mode 100644 index e82de95a..00000000 --- a/.flake8 +++ /dev/null @@ -1,6 +0,0 @@ -[flake8] -ignore = E203,W504,W191,W503 -exclude = .git,__pycache__,orm/rbac.py -max-complexity = 10 -max-line-length = 108 -indent-string = ' ' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 16d17721..00000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -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 diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 00000000..fc7ab0ae --- /dev/null +++ b/CHANGELOG.txt @@ -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 diff --git a/Dockerfile b/Dockerfile index a80e08b1..9ce2d47d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/Procfile b/Procfile deleted file mode 100644 index c5c1bfa8..00000000 --- a/Procfile +++ /dev/null @@ -1,2 +0,0 @@ -web: python server.py - diff --git a/README.md b/README.md index b193d56d..0030a29d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 1583e756..00000000 --- a/alembic.ini +++ /dev/null @@ -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 diff --git a/alembic/README b/alembic/README deleted file mode 100644 index 3084ff9c..00000000 --- a/alembic/README +++ /dev/null @@ -1,3 +0,0 @@ -Generic single-database configuration. - -https://alembic.sqlalchemy.org/en/latest/tutorial.html diff --git a/alembic/env.py b/alembic/env.py deleted file mode 100644 index 128945e0..00000000 --- a/alembic/env.py +++ /dev/null @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako deleted file mode 100644 index fbc4b07d..00000000 --- a/alembic/script.py.mako +++ /dev/null @@ -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"} diff --git a/alembic/versions/fe943b098418_init_alembic.py b/alembic/versions/fe943b098418_init_alembic.py deleted file mode 100644 index 4ec6d519..00000000 --- a/alembic/versions/fe943b098418_init_alembic.py +++ /dev/null @@ -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 diff --git a/auth/authenticate.py b/auth/authenticate.py deleted file mode 100644 index 255b4a5c..00000000 --- a/auth/authenticate.py +++ /dev/null @@ -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 diff --git a/auth/credentials.py b/auth/credentials.py deleted file mode 100644 index 9045b7a4..00000000 --- a/auth/credentials.py +++ /dev/null @@ -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 diff --git a/auth/email.py b/auth/email.py deleted file mode 100644 index 0c7af1ea..00000000 --- a/auth/email.py +++ /dev/null @@ -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 " -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) diff --git a/auth/identity.py b/auth/identity.py deleted file mode 100644 index 2c5ddb47..00000000 --- a/auth/identity.py +++ /dev/null @@ -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 diff --git a/auth/jwtcodec.py b/auth/jwtcodec.py deleted file mode 100644 index 5069acdc..00000000 --- a/auth/jwtcodec.py +++ /dev/null @@ -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") diff --git a/auth/oauth.py b/auth/oauth.py deleted file mode 100644 index 54b5f11a..00000000 --- a/auth/oauth.py +++ /dev/null @@ -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 diff --git a/auth/tokenstorage.py b/auth/tokenstorage.py deleted file mode 100644 index 1416baa2..00000000 --- a/auth/tokenstorage.py +++ /dev/null @@ -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) diff --git a/auth/validators.py b/auth/validators.py deleted file mode 100644 index 216d7dcb..00000000 --- a/auth/validators.py +++ /dev/null @@ -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 diff --git a/lint.sh b/lint.sh deleted file mode 100755 index 69fb32f7..00000000 --- a/lint.sh +++ /dev/null @@ -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 diff --git a/main.py b/main.py index f6250b85..bbc4e2e9 100644 --- a/main.py +++ b/main.py @@ -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()) + 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 + 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 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())) - - await start_up() - 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)) diff --git a/orm/__init__.py b/orm/__init__.py index 8e7e5dec..e701d202 100644 --- a/orm/__init__.py +++ b/orm/__init__.py @@ -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" -] diff --git a/orm/author.py b/orm/author.py new file mode 100644 index 00000000..2293a45d --- /dev/null +++ b/orm/author.py @@ -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 diff --git a/orm/collection.py b/orm/collection.py index e75daf1a..9a4fee3b 100644 --- a/orm/collection.py +++ b/orm/collection.py @@ -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") diff --git a/orm/community.py b/orm/community.py index 790957b6..e5040ed7 100644 --- a/orm/community.py +++ b/orm/community.py @@ -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) diff --git a/orm/rbac.py b/orm/rbac.py deleted file mode 100644 index f6667567..00000000 --- a/orm/rbac.py +++ /dev/null @@ -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() diff --git a/orm/reaction.py b/orm/reaction.py index f5af799a..a0fcf05a 100644 --- a/orm/reaction.py +++ b/orm/reaction.py @@ -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 :") - 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=":") + kind = Column(Enum(ReactionKind), nullable=False) diff --git a/orm/shout.py b/orm/shout.py index ef9be246..4178f02c 100644 --- a/orm/shout.py +++ b/orm/shout.py @@ -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) diff --git a/orm/topic.py b/orm/topic.py index 2ae85020..9cefd581 100644 --- a/orm/topic.py +++ b/orm/topic.py @@ -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") diff --git a/orm/user.py b/orm/user.py deleted file mode 100644 index f4d4fec7..00000000 --- a/orm/user.py +++ /dev/null @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..ef484887 --- /dev/null +++ b/pyproject.toml @@ -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" \ No newline at end of file diff --git a/resetdb.sh b/resetdb.sh deleted file mode 100755 index 39b3b9b2..00000000 --- a/resetdb.sh +++ /dev/null @@ -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!' - diff --git a/resolvers/__init__.py b/resolvers/__init__.py index 521ea98a..5577364c 100644 --- a/resolvers/__init__.py +++ b/resolvers/__init__.py @@ -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", ] diff --git a/resolvers/auth.py b/resolvers/auth.py deleted file mode 100644 index 2cb7058b..00000000 --- a/resolvers/auth.py +++ /dev/null @@ -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 diff --git a/resolvers/author.py b/resolvers/author.py new file mode 100644 index 00000000..2d1a7b40 --- /dev/null +++ b/resolvers/author.py @@ -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 {} diff --git a/resolvers/community.py b/resolvers/community.py index ae5da9e2..e29a3d0c 100644 --- a/resolvers/community.py +++ b/resolvers/community.py @@ -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] diff --git a/resolvers/editor.py b/resolvers/editor.py index 91faace2..36560fa8 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -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 {} diff --git a/resolvers/follower.py b/resolvers/follower.py new file mode 100644 index 00000000..fd274ef7 --- /dev/null +++ b/resolvers/follower.py @@ -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 {} diff --git a/resolvers/following.py b/resolvers/following.py deleted file mode 100644 index 573ad6bd..00000000 --- a/resolvers/following.py +++ /dev/null @@ -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 {} diff --git a/resolvers/profile.py b/resolvers/profile.py deleted file mode 100644 index 689566d0..00000000 --- a/resolvers/profile.py +++ /dev/null @@ -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) diff --git a/resolvers/reactions.py b/resolvers/reaction.py similarity index 73% rename from resolvers/reactions.py rename to resolvers/reaction.py index 22403b8a..21ff5a7a 100644 --- a/resolvers/reactions.py +++ b/resolvers/reaction.py @@ -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) - except Exception as e: - print(f"[resolvers.reactions] error on reactions autofollowing: {e}") + try: + reactions_follow(author_id, reaction["shout"], True) + except Exception as e: + print(f"[resolvers.reactions] error on reactions auto following: {e}") - rdict["stat"] = {"commented": 0, "reacted": 0, "rating": 0} - return {"reaction": rdict} + 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, } - return {"reaction": r} + 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,42 +363,53 @@ 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: - for [ - reaction, - user, - shout, - reacted_stat, - commented_stat, - rating_stat, - ] in session.execute(q): - reaction.createdBy = user - reaction.shout = shout - reaction.stat = { - "rating": rating_stat, - "commented": commented_stat, - "reacted": reacted_stat, - } - - reaction.kind = reaction.kind.name - - reactions.append(reaction) + session = info.context["session"] + for [ + reaction, + author, + shout, + reacted_stat, + commented_stat, + rating_stat, + ] in session.execute(q): + 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) # ? if by.get("stat"): 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 diff --git a/resolvers/load.py b/resolvers/reader.py similarity index 65% rename from resolvers/load.py rename to resolvers/reader.py index 823360f0..4b23c251 100644 --- a/resolvers/load.py +++ b/resolvers/reader.py @@ -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 = [] + shouts_map = {} 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 = { @@ -207,87 +192,59 @@ 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 - - subquery = ( - select(Shout.id) - .join(ShoutAuthor) - .join(AuthorFollower, AuthorFollower.follower == user_id) - .join(ShoutTopic) - .join(TopicFollower, TopicFollower.follower == user_id) - ) - - q = ( - select(Shout) - .options( - joinedload(Shout.authors), - joinedload(Shout.topics), + author_id = info.context["author_id"] + with local_session() as session: + subquery = ( + select(Shout.id) + .join(ShoutAuthor) + .join(AuthorFollower, AuthorFollower.follower._is(author_id)) + .join(ShoutTopic) + .join(TopicFollower, TopicFollower.follower._is(author_id)) ) - .where( - and_( - Shout.publishedAt.is_not(None), - Shout.deletedAt.is_(None), - Shout.id.in_(subquery), + + q = ( + select(Shout) + .options( + joinedload(Shout.authors), + joinedload(Shout.topics), + ) + .where( + and_( + Shout.publishedAt.is_not(None), + Shout.deletedAt.is_(None), + Shout.id.in_(subquery), + ) ) ) - ) - q = add_stat_columns(q) - q = apply_filters(q, options.get("filters", {}), user_id) + q = add_stat_columns(q) + q = apply_filters(q, options.get("filters", {}), author_id) - order_by = options.get("order_by", Shout.publishedAt) + order_by = options.get("order_by", Shout.publishedAt) - query_order_by = ( - desc(order_by) if options.get("order_by_desc", True) else asc(order_by) - ) - offset = options.get("offset", 0) - limit = options.get("limit", 10) + query_order_by = ( + desc(order_by) if options.get("order_by_desc", True) else asc(order_by) + ) + offset = options.get("offset", 0) + limit = options.get("limit", 10) - q = ( - q.group_by(Shout.id) - .order_by(nulls_last(query_order_by)) - .limit(limit) - .offset(offset) - ) + q = ( + q.group_by(Shout.id) + .order_by(nulls_last(query_order_by)) + .limit(limit) + .offset(offset) + ) - shouts = [] - with local_session() as session: + shouts = [] 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 diff --git a/resolvers/topics.py b/resolvers/topic.py similarity index 84% rename from resolvers/topics.py rename to resolvers/topic.py index cead9564..bfd97d45 100644 --- a/resolvers/topics.py +++ b/resolvers/topic.py @@ -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) diff --git a/resolvers/upload.py b/resolvers/upload.py deleted file mode 100644 index 6b66cb41..00000000 --- a/resolvers/upload.py +++ /dev/null @@ -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) - - - diff --git a/schemas/auth.graphql b/schemas/auth.graphql new file mode 100644 index 00000000..daad5061 --- /dev/null +++ b/schemas/auth.graphql @@ -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 +} \ No newline at end of file diff --git a/schemas/core.graphql b/schemas/core.graphql index 2777651e..dac32701 100644 --- a/schemas/core.graphql +++ b/schemas/core.graphql @@ -1,48 +1,181 @@ +# Скалярные типы данных scalar DateTime -type _Service { - sdl: String + +# Перечисления + +enum ShoutVisibility { + AUTHORS + COMMUNITY + PUBLIC } -################################### Payload ################################### - -type UserFollowings { - unread: Int - topics: [String] - authors: [String] - reactions: [Int] - communities: [String] +enum ReactionStatus { + NEW + UPDATED + CHANGED + EXPLAINED + DELETED } -type AuthResult { - error: String - token: String - user: User - news: UserFollowings +enum ReactionKind { + LIKE + DISLIKE + AGREE + DISAGREE + PROOF + DISPROOF + COMMENT + QUOTE + PROPOSE + ASK + REMARK + FOOTNOTE + ACCEPT + REJECT +} + +enum FollowingEntity { + TOPIC + AUTHOR + COMMUNITY + REACTIONS +} + + +# Входные типы + + +input ShoutInput { + slug: String + title: String + body: String + lead: String + description: String + layout: String + media: String + authors: [String] + topics: [TopicInput] + community: Int + mainTopic: TopicInput + subtitle: String + cover: String +} + +input ProfileInput { + slug: String + name: String + userpic: String + links: [String] + bio: String + about: String +} + +input TopicInput { + id: Int + slug: String! + title: String + body: String + pic: String +} + +input ReactionInput { + kind: ReactionKind! + shout: Int! + range: String + body: String + replyTo: Int +} + +input AuthorsBy { + lastSeen: DateTime + createdAt: DateTime + slug: String + name: String + topic: String + order: String + days: Int + 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 + visibility: String + days: Int + reacted: Boolean +} + +input LoadShoutsOptions { + filters: LoadShoutsFilters + with_author_captions: Boolean + limit: Int! + offset: Int + order_by: String + order_by_desc: Boolean +} + +input ReactionBy { + shout: String + shouts: [String] + search: String + comment: Boolean + topic: String + createdBy: String + days: Int + sort: String +} + + +# Типы + + +type AuthorFollowings { + unread: Int + topics: [String] + authors: [String] + reactions: [Int] + communities: [String] } type AuthorStat { - followings: Int - followers: Int - rating: Int - commented: Int - shouts: Int + 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 + id: Int! + user: Int! + slug: String! + name: String + communities: [Community] + userpic: String + caption: String + bio: String + about: String + links: [String] + stat: AuthorStat + lastSeen: DateTime } type Result { @@ -60,364 +193,149 @@ type Result { communities: [Community] } -enum ReactionStatus { - NEW - UPDATED - CHANGED - EXPLAINED - DELETED -} - type ReactionUpdating { - error: String - status: ReactionStatus - reaction: Reaction -} - -################################### Inputs ################################### - -input ShoutInput { - slug: String - title: String - body: String - lead: String - description: String - layout: String - media: String - authors: [String] - topics: [TopicInput] - community: Int - mainTopic: TopicInput - subtitle: String - cover: String -} - -input ProfileInput { - slug: String - name: String - userpic: String - links: [String] - bio: String - about: String -} - -input TopicInput { - id: Int, - slug: String! - # community: String! - title: String - body: String - pic: String - # children: [String] - # parents: [String] -} - - -input ReactionInput { - kind: ReactionKind! - shout: Int! - range: String - body: String - 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 - slug: String - name: String - topic: String - order: 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 -} - -input LoadShoutsOptions { - filters: LoadShoutsFilters - with_author_captions: Boolean - limit: Int! - offset: Int - order_by: String - order_by_desc: Boolean -} - -input ReactionBy { - shout: String # slug - shouts: [String] - search: String # fts on body - comment: Boolean - topic: String # topic.slug - createdBy: String # user.slug - days: Int # before - sort: String # how to sort, default createdAt -} - -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! -} - -############################################ Entities - -type Resource { - id: Int! - name: String! -} - -type Operation { - id: Int! - name: String! -} - -type Permission { - operation: Int! - resource: Int! -} - -type Role { - id: Int! - name: String! - community: String! - desc: String - permissions: [Permission!]! + error: String + status: ReactionStatus + reaction: Reaction } type Rating { - rater: String! - value: Int! -} - -type User { - id: Int! - username: String! # to login, ex. email, phone - createdAt: DateTime! - lastSeen: DateTime - slug: String! - name: String # to display - email: String - password: String - oauth: String # provider:token - userpic: String - links: [String] - emailConfirmed: Boolean # should contain all emails too - muted: Boolean - updatedAt: DateTime - ratings: [Rating] - bio: String - about: String - communities: [Int] # user participating communities - oid: String -} - -enum ReactionKind { - LIKE - DISLIKE - - AGREE - DISAGREE - - PROOF - DISPROOF - - COMMENT - QUOTE - - PROPOSE - ASK - - REMARK - FOOTNOTE - - ACCEPT - REJECT + rater: String! + value: Int! } type Reaction { - id: Int! - shout: Shout! - createdAt: DateTime! - createdBy: User! - updatedAt: DateTime - deletedAt: DateTime - deletedBy: User - range: String # full / 0:2340 - kind: ReactionKind! - body: String - replyTo: Int - stat: Stat - old_id: String - old_thread: String + id: Int! + shout: Shout! + createdAt: DateTime! + createdBy: Author! + updatedAt: DateTime + deletedAt: DateTime + deletedBy: Author + range: String + kind: ReactionKind! + body: String + replyTo: Int + stat: Stat + old_id: String + old_thread: String } -# is publication type Shout { - id: Int! - slug: String! - body: String! - lead: String - description: String - createdAt: DateTime! - topics: [Topic] - mainTopic: String - title: String - subtitle: String - authors: [Author] - lang: String - community: String - cover: String - layout: String # music video literature image - versionOf: String # for translations and re-telling the same story - visibility: String # owner authors community public - updatedAt: DateTime - updatedBy: User - deletedAt: DateTime - deletedBy: User - publishedAt: DateTime - media: String # json [ { title pic url body }, .. ] - stat: Stat + id: Int! + slug: String! + body: String! + lead: String + description: String + createdAt: DateTime! + topics: [Topic] + authors: [Author] + communities: [Community] + mainTopic: String + title: String + subtitle: String + lang: String + community: String + cover: String + layout: String + versionOf: String + visibility: ShoutVisibility + updatedAt: DateTime + updatedBy: Author + deletedAt: DateTime + deletedBy: Author + publishedAt: DateTime + media: String + stat: Stat } type Stat { - viewed: Int - reacted: Int - rating: Int - commented: Int - ranking: Int + viewed: Int + reacted: Int + rating: Int + commented: Int + ranking: Int } type Community { - id: Int! - slug: String! - name: String! - desc: String - pic: String! - createdAt: DateTime! - createdBy: User! + id: Int! + slug: String! + name: String! + desc: String + pic: String! + createdAt: DateTime! + createdBy: Author! } type Collection { - id: Int! - slug: String! - title: String! - desc: String - amount: Int - publishedAt: DateTime - createdAt: DateTime! - createdBy: User! + id: Int! + slug: String! + title: String! + desc: String + amount: Int + publishedAt: DateTime + createdAt: DateTime! + createdBy: Author! } type TopicStat { - shouts: Int! - followers: Int! - authors: Int! - # viewed: Int - # reacted: Int! - # commented: Int - # rating: Int + shouts: Int! + followers: Int! + authors: Int! } type Topic { - id: Int! - slug: String! - title: String - body: String - pic: String - # community: Community! - stat: TopicStat - oid: String + id: Int! + slug: String! + title: String + body: String + pic: String + 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] +} \ No newline at end of file diff --git a/server.py b/server.py index 0b469e3b..e9ae773f 100644 --- a/server.py +++ b/server.py @@ -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: - sys.excepthook = exception_handler - uvicorn.run( - "main:app", - host="0.0.0.0", - port=PORT, - proxy_headers=True, - server_header=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 + ) diff --git a/services/auth.py b/services/auth.py new file mode 100644 index 00000000..154389e8 --- /dev/null +++ b/services/auth.py @@ -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 diff --git a/services/presence.py b/services/presence.py index f39c3455..87c0caf2 100644 --- a/services/presence.py +++ b/services/presence.py @@ -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)) diff --git a/services/redis.py b/services/rediscache.py similarity index 100% rename from services/redis.py rename to services/rediscache.py diff --git a/services/search.py b/services/search.py index e79afe44..28494e94 100644 --- a/services/search.py +++ b/services/search.py @@ -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 diff --git a/services/server.py b/services/server.py new file mode 100644 index 00000000..e9ae773f --- /dev/null +++ b/services/server.py @@ -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 + ) diff --git a/services/settings.py b/services/settings.py new file mode 100644 index 00000000..edaaa36e --- /dev/null +++ b/services/settings.py @@ -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" diff --git a/services/unread.py b/services/unread.py index 0d72accc..87c4efa0 100644 --- a/services/unread.py +++ b/services/unread.py @@ -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: diff --git a/settings.py b/settings.py index 270b4551..edaaa36e 100644 --- a/settings.py +++ b/settings.py @@ -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" diff --git a/setup.cfg b/setup.cfg deleted file mode 100755 index 588918a1..00000000 --- a/setup.cfg +++ /dev/null @@ -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