initial-draft
This commit is contained in:
parent
7304735041
commit
ec20a4ebcd
8
.editorconfig
Normal file
8
.editorconfig
Normal file
|
@ -0,0 +1,8 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
venv
|
||||
.DS_Store
|
||||
__pycache__
|
||||
.idea
|
||||
.vscode
|
||||
poetry.lock
|
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
@ -0,0 +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
|
||||
|
||||
# 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"]
|
41
main.py
Normal file
41
main.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
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 services.schema import resolvers
|
||||
from services.rediscache import redis
|
||||
from settings import DEV_SERVER_PID_FILE_NAME, SENTRY_DSN, MODE
|
||||
|
||||
import_module("resolvers")
|
||||
schema = make_executable_schema(load_schema_from_path("inbox.graphql"), resolvers) # type: ignore
|
||||
|
||||
|
||||
async def start_up():
|
||||
if MODE == "dev":
|
||||
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)
|
||||
except Exception as e:
|
||||
print("[sentry] init error")
|
||||
print(e)
|
||||
|
||||
|
||||
async def shutdown():
|
||||
await redis.disconnect()
|
||||
|
||||
|
||||
app = Starlette(debug=True, on_startup=[start_up], on_shutdown=[shutdown])
|
||||
app.mount("/", GraphQL(schema, debug=True))
|
114
nginx.conf.sigil
Normal file
114
nginx.conf.sigil
Normal file
|
@ -0,0 +1,114 @@
|
|||
# sigil ver 2.2 dufok 2022-10-15 (with location /connect nobuffer)
|
||||
# Proxy settings
|
||||
{{ $proxy_settings := "proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_set_header Host $http_host; proxy_set_header X-Request-Start $msec;" }}
|
||||
# GZIP settings
|
||||
{{ $gzip_settings := "gzip on; gzip_min_length 1100; gzip_buffers 4 32k; gzip_types text/css text/javascript text/xml text/plain text/x-component application/javascript application/x-javascript application/json application/xml application/rss+xml font/truetype application/x-font-ttf font/opentype application/vnd.ms-fontobject image/svg+xml; gzip_vary on; gzip_comp_level 6;" }}
|
||||
# CORS headers based on request methods
|
||||
{{ $cors_headers_options := "if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' '$allow_origin' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization'; add_header 'Access-Control-Allow-Credentials' 'true'; add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; }" }}
|
||||
{{ $cors_headers_post := "if ($request_method = 'POST') { add_header 'Access-Control-Allow-Origin' '$allow_origin' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; add_header 'Access-Control-Allow-Credentials' 'true' always; }" }}
|
||||
{{ $cors_headers_get := "if ($request_method = 'GET') { add_header 'Access-Control-Allow-Origin' '$allow_origin' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; add_header 'Access-Control-Allow-Credentials' 'true' always; }" }}
|
||||
|
||||
# Mapping for allowed origins
|
||||
map $http_origin $allow_origin {
|
||||
~^https?:\/\/((.*\.)?localhost(:\d+)?|discoursio-webapp(-(.*))?\.vercel\.app|(.*\.)?discours\.io)$ $http_origin;
|
||||
default "";
|
||||
}
|
||||
|
||||
# Server block setup
|
||||
{{ range $port_map := .PROXY_PORT_MAP | split " " }}
|
||||
{{ $port_map_list := $port_map | split ":" }}
|
||||
{{ $scheme := index $port_map_list 0 }}
|
||||
{{ $listen_port := index $port_map_list 1 }}
|
||||
{{ $upstream_port := index $port_map_list 2 }}
|
||||
|
||||
server {
|
||||
# HTTP/HTTPS settings
|
||||
{{ if eq $scheme "http" }}
|
||||
listen [::]:{{ $listen_port }};
|
||||
listen {{ $listen_port }};
|
||||
server_name {{ $.NOSSL_SERVER_NAME }};
|
||||
access_log /var/log/nginx/{{ $.APP }}-access.log;
|
||||
error_log /var/log/nginx/{{ $.APP }}-error.log;
|
||||
|
||||
{{ else if eq $scheme "https" }}
|
||||
|
||||
listen [::]:{{ $listen_port }} ssl http2;
|
||||
listen {{ $listen_port }} ssl http2;
|
||||
server_name {{ $.NOSSL_SERVER_NAME }};
|
||||
access_log /var/log/nginx/{{ $.APP }}-access.log;
|
||||
error_log /var/log/nginx/{{ $.APP }}-error.log;
|
||||
ssl_certificate {{ $.APP_SSL_PATH }}/server.crt;
|
||||
ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
keepalive_timeout 70;
|
||||
{{ end }}
|
||||
|
||||
# Default location block
|
||||
location / {
|
||||
proxy_pass http://{{ $.APP }}-{{ $upstream_port }};
|
||||
{{ $proxy_settings }}
|
||||
{{ $gzip_settings }}
|
||||
{{ $cors_headers_options }}
|
||||
{{ $cors_headers_post }}
|
||||
{{ $cors_headers_get }}
|
||||
}
|
||||
|
||||
# Custom location block for /connect
|
||||
location /connect/ {
|
||||
proxy_pass http://presence-8080/;
|
||||
add_header 'Cache-Control' 'no-cache';
|
||||
add_header 'Content-Type' 'text/event-stream';
|
||||
add_header 'Connection' 'keep-alive';
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 36000s;
|
||||
{{ $proxy_settings }}
|
||||
{{ $cors_headers_options }}
|
||||
{{ $cors_headers_post }}
|
||||
{{ $cors_headers_get }}
|
||||
}
|
||||
|
||||
# Error pages
|
||||
|
||||
error_page 400 401 402 403 405 406 407 408 409 410 411 412 413 414 415 416 417 418 420 422 423 424 426 428 429 431 444 449 450 451 /400-error.html;
|
||||
location /400-error.html {
|
||||
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
|
||||
internal;
|
||||
}
|
||||
|
||||
error_page 404 /404-error.html;
|
||||
location /404-error.html {
|
||||
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
|
||||
internal;
|
||||
}
|
||||
|
||||
error_page 500 501 503 504 505 506 507 508 509 510 511 /500-error.html;
|
||||
location /500-error.html {
|
||||
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
|
||||
internal;
|
||||
}
|
||||
|
||||
error_page 502 /502-error.html;
|
||||
location /502-error.html {
|
||||
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
|
||||
internal;
|
||||
}
|
||||
|
||||
include /home/dokku/gateway/nginx.conf.d/*.conf;
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
# Upstream setup
|
||||
{{ range $upstream_port := $.PROXY_UPSTREAM_PORTS | split " " }}
|
||||
upstream {{ $.APP }}-{{ $upstream_port }} {
|
||||
{{ range $listeners := $.DOKKU_APP_WEB_LISTENERS | split " " }}
|
||||
{{ $listener_list := $listeners | split ":" }}
|
||||
{{ $listener_ip := index $listener_list 0 }}
|
||||
{{ $listener_port := index $listener_list 1 }}
|
||||
server {{ $listener_ip }}:{{ $upstream_port }};
|
||||
{{ end }}
|
||||
}
|
||||
{{ end }}
|
46
orm/author.py
Normal file
46
orm/author.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
import time
|
||||
|
||||
from sqlalchemy import JSON as JSONType
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class AuthorRating(Base):
|
||||
__tablename__ = "author_rating"
|
||||
|
||||
id = None # type: ignore
|
||||
rater = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||
value = Column(Integer)
|
||||
|
||||
|
||||
class AuthorFollower(Base):
|
||||
__tablename__ = "author_follower"
|
||||
|
||||
id = None # type: ignore
|
||||
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
auto = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class Author(Base):
|
||||
__tablename__ = "author"
|
||||
|
||||
user = Column(String, unique=True) # unbounded link with authorizer's User type
|
||||
|
||||
name = Column(String, nullable=True, comment="Display name")
|
||||
slug = Column(String, unique=True, comment="Author's slug")
|
||||
bio = Column(String, nullable=True, comment="Bio") # status description
|
||||
about = Column(String, nullable=True, comment="About") # long and formatted
|
||||
pic = Column(String, nullable=True, comment="Picture")
|
||||
links = Column(JSONType, nullable=True, comment="Links")
|
||||
|
||||
ratings = relationship(AuthorRating, foreign_keys=AuthorRating.author)
|
||||
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
deleted_at = Column(Integer, nullable=True, comment="Deleted at")
|
25
orm/collection.py
Normal file
25
orm/collection.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
import time
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class ShoutCollection(Base):
|
||||
__tablename__ = "shout_collection"
|
||||
|
||||
id = None # type: ignore
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True)
|
||||
collection = Column(ForeignKey("collection.id"), primary_key=True)
|
||||
|
||||
|
||||
class Collection(Base):
|
||||
__tablename__ = "collection"
|
||||
|
||||
slug = Column(String, unique=True)
|
||||
title = Column(String, nullable=False, comment="Title")
|
||||
body = Column(String, nullable=True, comment="Body")
|
||||
pic = Column(String, nullable=True, comment="Picture")
|
||||
created_at = Column(Integer, default=lambda: int(time.time()))
|
||||
created_by = Column(ForeignKey("author.id"), comment="Created By")
|
||||
publishedAt = Column(Integer, default=lambda: int(time.time()))
|
41
orm/community.py
Normal file
41
orm/community.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
import time
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from orm.author import Author
|
||||
from services.db import Base, local_session
|
||||
|
||||
|
||||
class CommunityAuthor(Base):
|
||||
__tablename__ = "community_author"
|
||||
|
||||
id = None # type: ignore
|
||||
follower = Column(ForeignKey("author.id"), primary_key=True)
|
||||
community = Column(ForeignKey("community.id"), primary_key=True)
|
||||
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
role = Column(String, nullable=False)
|
||||
|
||||
|
||||
class Community(Base):
|
||||
__tablename__ = "community"
|
||||
|
||||
name = Column(String, nullable=False)
|
||||
slug = Column(String, nullable=False, unique=True)
|
||||
desc = Column(String, nullable=False, default="")
|
||||
pic = Column(String, nullable=False, default="")
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
|
||||
authors = relationship(lambda: Author, secondary=CommunityAuthor.__tablename__)
|
||||
|
||||
@staticmethod
|
||||
def init_table():
|
||||
with local_session("orm.community") as session:
|
||||
d = session.query(Community).filter(Community.slug == "discours").first()
|
||||
if not d:
|
||||
d = Community(name="Дискурс", slug="discours")
|
||||
session.add(d)
|
||||
session.commit()
|
||||
print("[orm.community] created community %s" % d.slug)
|
||||
Community.default_community = d
|
||||
print("[orm.community] default community is %s" % d.slug)
|
32
orm/notification.py
Normal file
32
orm/notification.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
from enum import Enum as Enumeration
|
||||
import time
|
||||
from sqlalchemy import Boolean, Column, Enum, Integer
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class NotificationEntity(Enumeration):
|
||||
REACTION = 1
|
||||
SHOUT = 2
|
||||
FOLLOWER = 3
|
||||
|
||||
|
||||
class NotificationAction(Enumeration):
|
||||
CREATE = 1
|
||||
UPDATE = 2
|
||||
DELETE = 3
|
||||
SEEN = 4
|
||||
FOLLOW = 5
|
||||
UNFOLLOW = 6
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notification"
|
||||
|
||||
created_at = Column(Integer, default=lambda: int(time.time()))
|
||||
seen = Column(Boolean, nullable=False, default=False, index=True)
|
||||
entity = Column(Enum(NotificationEntity), nullable=False)
|
||||
action = Column(Enum(NotificationAction), nullable=False)
|
||||
payload = Column(JSONB, nullable=True)
|
||||
# occurrences = Column(Integer, default=1)
|
41
orm/reaction.py
Normal file
41
orm/reaction.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
import time
|
||||
from enum import Enum as Enumeration
|
||||
|
||||
from sqlalchemy import Column, Enum, ForeignKey, Integer, String
|
||||
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class ReactionKind(Enumeration):
|
||||
AGREE = 1 # +1
|
||||
DISAGREE = 2 # -1
|
||||
PROOF = 3 # +1
|
||||
DISPROOF = 4 # -1
|
||||
ASK = 5 # +0
|
||||
PROPOSE = 6 # +0
|
||||
QUOTE = 7 # +0 bookmark
|
||||
COMMENT = 8 # +0
|
||||
ACCEPT = 9 # +1
|
||||
REJECT = 0 # -1
|
||||
LIKE = 11 # +1
|
||||
DISLIKE = 12 # -1
|
||||
REMARK = 13 # 0
|
||||
FOOTNOTE = 14 # 0
|
||||
# TYPE = <reaction index> # rating diff
|
||||
|
||||
|
||||
class Reaction(Base):
|
||||
__tablename__ = "reaction"
|
||||
|
||||
body = Column(String, nullable=True, comment="Reaction Body")
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
created_by = Column(ForeignKey("author.id"), nullable=False, index=True)
|
||||
updated_at = Column(Integer, nullable=True, comment="Updated at")
|
||||
deleted_at = Column(Integer, nullable=True, comment="Deleted at")
|
||||
deleted_by = Column(ForeignKey("author.id"), nullable=True, index=True)
|
||||
shout = Column(ForeignKey("shout.id"), nullable=False, index=True)
|
||||
reply_to = Column(ForeignKey("reaction.id"), nullable=True)
|
||||
range = Column(String, nullable=True, comment="<start index>:<end>")
|
||||
kind = Column(Enum(ReactionKind), nullable=False)
|
||||
|
||||
oid = Column(String)
|
86
orm/shout.py
Normal file
86
orm/shout.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
import time
|
||||
from enum import Enum as Enumeration
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, Enum, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from orm.author import Author
|
||||
from orm.community import Community
|
||||
from orm.reaction import Reaction
|
||||
from orm.topic import Topic
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class ShoutTopic(Base):
|
||||
__tablename__ = "shout_topic"
|
||||
|
||||
id = None # type: ignore
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
|
||||
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
|
||||
|
||||
|
||||
class ShoutReactionsFollower(Base):
|
||||
__tablename__ = "shout_reactions_followers"
|
||||
|
||||
id = None # type: ignore
|
||||
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
|
||||
auto = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
deleted_at = Column(Integer, nullable=True)
|
||||
|
||||
|
||||
class ShoutAuthor(Base):
|
||||
__tablename__ = "shout_author"
|
||||
|
||||
id = None # type: ignore
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||
caption = Column(String, nullable=True, default="")
|
||||
|
||||
|
||||
class ShoutCommunity(Base):
|
||||
__tablename__ = "shout_community"
|
||||
|
||||
id = None # type: ignore
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
|
||||
community = Column(ForeignKey("community.id"), primary_key=True, index=True)
|
||||
|
||||
|
||||
class ShoutVisibility(Enumeration):
|
||||
AUTHORS = 0
|
||||
COMMUNITY = 1
|
||||
PUBLIC = 2
|
||||
|
||||
|
||||
class Shout(Base):
|
||||
__tablename__ = "shout"
|
||||
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
updated_at = Column(Integer, nullable=True)
|
||||
published_at = Column(Integer, nullable=True)
|
||||
deleted_at = Column(Integer, nullable=True)
|
||||
|
||||
deleted_by = Column(ForeignKey("author.id"), nullable=True)
|
||||
|
||||
body = Column(String, nullable=False, comment="Body")
|
||||
slug = Column(String, unique=True)
|
||||
cover = Column(String, nullable=True, comment="Cover image url")
|
||||
lead = Column(String, nullable=True)
|
||||
description = Column(String, nullable=True)
|
||||
title = Column(String, nullable=True)
|
||||
subtitle = Column(String, nullable=True)
|
||||
layout = Column(String, nullable=True)
|
||||
media = Column(JSON, nullable=True)
|
||||
|
||||
authors = relationship(lambda: Author, secondary="shout_author")
|
||||
topics = relationship(lambda: Topic, secondary="shout_topic")
|
||||
communities = relationship(lambda: Community, secondary="shout_community")
|
||||
reactions = relationship(lambda: Reaction)
|
||||
|
||||
visibility = Column(Enum(ShoutVisibility), default=ShoutVisibility.AUTHORS)
|
||||
|
||||
lang = Column(String, nullable=False, default="ru", comment="Language")
|
||||
version_of = Column(ForeignKey("shout.id"), nullable=True)
|
||||
oid = Column(String, nullable=True)
|
||||
|
26
orm/topic.py
Normal file
26
orm/topic.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
import time
|
||||
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
|
||||
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class TopicFollower(Base):
|
||||
__tablename__ = "topic_followers"
|
||||
|
||||
id = None # type: ignore
|
||||
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
auto = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class Topic(Base):
|
||||
__tablename__ = "topic"
|
||||
|
||||
slug = Column(String, unique=True)
|
||||
title = Column(String, nullable=False, comment="Title")
|
||||
body = Column(String, nullable=True, comment="Body")
|
||||
pic = Column(String, nullable=True, comment="Picture")
|
||||
community = Column(ForeignKey("community.id"), default=1)
|
||||
oid = Column(String, nullable=True, comment="Old ID")
|
30
orm/user.py
Normal file
30
orm/user.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
import time
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, Integer, String
|
||||
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "authorizer_users"
|
||||
|
||||
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
|
||||
key = Column(String)
|
||||
email = Column(String, unique=True)
|
||||
email_verified_at = Column(Integer)
|
||||
family_name = Column(String)
|
||||
gender = Column(String)
|
||||
given_name = Column(String)
|
||||
is_multi_factor_auth_enabled = Column(Boolean)
|
||||
middle_name = Column(String)
|
||||
nickname = Column(String)
|
||||
password = Column(String)
|
||||
phone_number = Column(String, unique=True)
|
||||
phone_number_verified_at = Column(Integer)
|
||||
# preferred_username = Column(String, nullable=False)
|
||||
picture = Column(String)
|
||||
revoked_timestamp = Column(Integer)
|
||||
roles = Column(JSON)
|
||||
signup_methods = Column(String, default="magic_link_login")
|
||||
created_at = Column(Integer, default=lambda: int(time.time()))
|
||||
updated_at = Column(Integer, default=lambda: int(time.time()))
|
67
pyproject.toml
Normal file
67
pyproject.toml
Normal file
|
@ -0,0 +1,67 @@
|
|||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "discoursio-notifier"
|
||||
version = "0.0.1"
|
||||
description = "notifier server for discours.io"
|
||||
authors = ["discours.io devteam"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
SQLAlchemy = "^2.0.22"
|
||||
httpx = "^0.25.0"
|
||||
psycopg2-binary = "^2.9.9"
|
||||
redis = {extras = ["hiredis"], version = "^5.0.1"}
|
||||
sentry-sdk = "^1.32.0"
|
||||
gql = {git = "https://github.com/graphql-python/gql.git", rev = "master"}
|
||||
ariadne = {git = "https://github.com/tonyrewin/ariadne.git", rev = "master"}
|
||||
starlette = {git = "https://github.com/encode/starlette.git", rev = "master"}
|
||||
|
||||
[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"
|
84
resolvers/notifications.py
Normal file
84
resolvers/notifications.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
from sqlalchemy import and_, desc, select, update
|
||||
|
||||
from services.auth import login_required
|
||||
from services.db import local_session
|
||||
from services.schema import mutation, query
|
||||
from orm.notification import Notification
|
||||
|
||||
|
||||
@query.field("loadNotifications")
|
||||
@login_required
|
||||
async def load_notifications(_, info, params=None):
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
user_id = info.context["user_id"]
|
||||
|
||||
limit = params.get("limit", 50)
|
||||
offset = params.get("offset", 0)
|
||||
q = (
|
||||
select(Notification)
|
||||
.order_by(desc(Notification.created_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
|
||||
notifications = []
|
||||
with local_session() as session:
|
||||
total_count = session.query(Notification).where(Notification.author == author_id).count()
|
||||
|
||||
total_unread_count = (
|
||||
session.query(Notification)
|
||||
.where(and_(Notification.author == author_id, Notification.seen == False)) # noqa: E712
|
||||
.count()
|
||||
)
|
||||
|
||||
for [notification] in session.execute(q):
|
||||
notification.type = notification.type.name
|
||||
notifications.append(notification)
|
||||
|
||||
return {
|
||||
"notifications": notifications,
|
||||
"totalCount": total_count,
|
||||
"totalUnreadCount": total_unread_count,
|
||||
}
|
||||
|
||||
|
||||
@mutation.field("markNotificationAsRead")
|
||||
@login_required
|
||||
async def mark_notification_as_read(_, info, notification_id: int):
|
||||
author_id = info.context["author_id"]
|
||||
|
||||
with local_session() as session:
|
||||
notification = (
|
||||
session.query(Notification)
|
||||
.where(and_(Notification.id == notification_id, Notification.user == user_id))
|
||||
.one()
|
||||
)
|
||||
notification.seen = True
|
||||
session.commit()
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
@mutation.field("markAllNotificationsAsRead")
|
||||
@login_required
|
||||
async def mark_all_notifications_as_read(_, info):
|
||||
auth: AuthCredentials = info.context["request"].auth
|
||||
user_id = auth.user_id
|
||||
|
||||
statement = (
|
||||
update(Notification)
|
||||
.where(and_(Notification.user == user_id, Notification.seen == False)) # noqa: E712
|
||||
.values(seen=True)
|
||||
)
|
||||
|
||||
with local_session() as session:
|
||||
try:
|
||||
session.execute(statement)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
print(f"[mark_all_notifications_as_read] error: {str(e)}")
|
||||
|
||||
return {}
|
59
server.py
Normal file
59
server.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
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
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=PORT, proxy_headers=True, server_header=True)
|
64
services/auth.py
Normal file
64
services/auth.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
from functools import wraps
|
||||
from httpx import AsyncClient, HTTPError
|
||||
from settings import AUTH_URL
|
||||
|
||||
|
||||
async def check_auth(req):
|
||||
token = req.headers.get("Authorization")
|
||||
headers = {"Authorization": token, "Content-Type": "application/json"} # "Bearer " + removed
|
||||
print(f"[services.auth] checking auth token: {token}")
|
||||
|
||||
query_name = "getSession" if "v2." in AUTH_URL else "session"
|
||||
query_type = "mutation" if "v2." in AUTH_URL else "query"
|
||||
operation = "GetUserId"
|
||||
|
||||
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] {AUTH_URL} response: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
return False, None
|
||||
r = response.json()
|
||||
if r:
|
||||
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
|
||||
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["user_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
|
62
services/core.py
Normal file
62
services/core.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
from httpx import AsyncClient
|
||||
from settings import API_BASE
|
||||
from typing import List
|
||||
from models.member import ChatMember
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
async def get_all_authors() -> List[ChatMember]:
|
||||
query_name = "authorsAll"
|
||||
query_type = "query"
|
||||
operation = "AuthorsAll"
|
||||
query_fields = "id slug userpic name"
|
||||
|
||||
gql = {
|
||||
"query": query_type + " " + operation + " { " + query_name + " { " + query_fields + " } " + " }",
|
||||
"operationName": operation,
|
||||
"variables": None,
|
||||
}
|
||||
|
||||
async with AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(API_BASE, headers=headers, json=gql)
|
||||
print(f"[services.core] {query_name}: [{response.status_code}] {len(response.text)} bytes")
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
r = response.json()
|
||||
if r:
|
||||
return r.get("data", {}).get(query_name, [])
|
||||
except Exception:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
|
||||
async def get_my_followings() -> List[ChatMember]:
|
||||
query_name = "loadMySubscriptions"
|
||||
query_type = "query"
|
||||
operation = "LoadMySubscriptions"
|
||||
query_fields = "id slug userpic name"
|
||||
|
||||
gql = {
|
||||
"query": query_type + " " + operation + " { " + query_name + " { authors {" + query_fields + "} } " + " }",
|
||||
"operationName": operation,
|
||||
"variables": None,
|
||||
}
|
||||
|
||||
async with AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(API_BASE, headers=headers, json=gql)
|
||||
print(f"[services.core] {query_name}: [{response.status_code}] {len(response.text)} bytes")
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
r = response.json()
|
||||
if r:
|
||||
return r.get("data", {}).get(query_name, {}).get("authors", [])
|
||||
except Exception:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return []
|
67
services/db.py
Normal file
67
services/db.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
# from contextlib import contextmanager
|
||||
from typing import Any, Callable, Dict, TypeVar
|
||||
# from psycopg2.errors import UniqueViolation
|
||||
from sqlalchemy import Column, Integer, create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql.schema import Table
|
||||
|
||||
from settings import DB_URL
|
||||
|
||||
engine = create_engine(DB_URL, echo=False, pool_size=10, max_overflow=20)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
REGISTRY: Dict[str, type] = {}
|
||||
|
||||
|
||||
# @contextmanager
|
||||
def local_session(src=""):
|
||||
return Session(bind=engine, expire_on_commit=False)
|
||||
|
||||
# try:
|
||||
# yield session
|
||||
# session.commit()
|
||||
# except Exception as e:
|
||||
# if not (src == "create_shout" and isinstance(e, UniqueViolation)):
|
||||
# import traceback
|
||||
|
||||
# session.rollback()
|
||||
# print(f"[services.db] {src}: {e}")
|
||||
|
||||
# traceback.print_exc()
|
||||
|
||||
# raise Exception("[services.db] exception")
|
||||
|
||||
# finally:
|
||||
# session.close()
|
||||
|
||||
|
||||
class Base(declarative_base()):
|
||||
__table__: Table
|
||||
__tablename__: str
|
||||
__new__: Callable
|
||||
__init__: Callable
|
||||
__allow_unmapped__ = True
|
||||
__abstract__ = True
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
REGISTRY[cls.__name__] = cls
|
||||
|
||||
def dict(self) -> Dict[str, Any]:
|
||||
column_names = self.__table__.columns.keys()
|
||||
if "_sa_instance_state" in column_names:
|
||||
column_names.remove("_sa_instance_state")
|
||||
try:
|
||||
return {c: getattr(self, c) for c in column_names}
|
||||
except Exception as e:
|
||||
print(f"[services.db] Error dict: {e}")
|
||||
return {}
|
||||
|
||||
def update(self, values: Dict[str, Any]) -> None:
|
||||
for key, value in values.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
61
services/rediscache.py
Normal file
61
services/rediscache.py
Normal file
|
@ -0,0 +1,61 @@
|
|||
import redis.asyncio as aredis
|
||||
from settings import REDIS_URL
|
||||
|
||||
|
||||
class RedisCache:
|
||||
def __init__(self, uri=REDIS_URL):
|
||||
self._uri: str = uri
|
||||
self.pubsub_channels = []
|
||||
self._client = None
|
||||
|
||||
async def connect(self):
|
||||
self._client = aredis.Redis.from_url(self._uri, decode_responses=True)
|
||||
|
||||
async def disconnect(self):
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
|
||||
async def execute(self, command, *args, **kwargs):
|
||||
if self._client:
|
||||
try:
|
||||
print("[redis] " + command + " " + " ".join(args))
|
||||
r = await self._client.execute_command(command, *args, **kwargs)
|
||||
return r
|
||||
except Exception as e:
|
||||
print(f"[redis] error: {e}")
|
||||
return None
|
||||
|
||||
async def subscribe(self, *channels):
|
||||
if self._client:
|
||||
async with self._client.pubsub() as pubsub:
|
||||
for channel in channels:
|
||||
await pubsub.subscribe(channel)
|
||||
self.pubsub_channels.append(channel)
|
||||
|
||||
async def unsubscribe(self, *channels):
|
||||
if not self._client:
|
||||
return
|
||||
async with self._client.pubsub() as pubsub:
|
||||
for channel in channels:
|
||||
await pubsub.unsubscribe(channel)
|
||||
self.pubsub_channels.remove(channel)
|
||||
|
||||
async def publish(self, channel, data):
|
||||
if not self._client:
|
||||
return
|
||||
await self._client.publish(channel, data)
|
||||
|
||||
async def lrange(self, key, start, stop):
|
||||
if self._client:
|
||||
print(f"[redis] LRANGE {key} {start} {stop}")
|
||||
return await self._client.lrange(key, start, stop)
|
||||
|
||||
async def mget(self, key, *keys):
|
||||
if self._client:
|
||||
print(f"[redis] MGET {key} {keys}")
|
||||
return await self._client.mget(key, *keys)
|
||||
|
||||
|
||||
redis = RedisCache()
|
||||
|
||||
__all__ = ["redis"]
|
5
services/schema.py
Normal file
5
services/schema.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from ariadne import QueryType, MutationType
|
||||
|
||||
query = QueryType()
|
||||
mutation = MutationType()
|
||||
resolvers = [query, mutation]
|
9
settings.py
Normal file
9
settings.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from os import environ
|
||||
|
||||
PORT = 80
|
||||
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"
|
Loading…
Reference in New Issue
Block a user