diff --git a/auth/jwtcodec.py b/auth/jwtcodec.py index c0943bf9..5069acdc 100644 --- a/auth/jwtcodec.py +++ b/auth/jwtcodec.py @@ -36,7 +36,7 @@ class JWTCodec: issuer="discours", ) r = TokenPayload(**payload) - print("[auth.jwtcodec] debug token %r" % r) + # print('[auth.jwtcodec] debug token %r' % r) return r except jwt.InvalidIssuedAtError: print("[auth.jwtcodec] invalid issued at: %r" % payload) diff --git a/base/redis.py b/base/redis.py new file mode 100644 index 00000000..e69de29b diff --git a/base/resolvers.py b/base/resolvers.py new file mode 100644 index 00000000..e69de29b diff --git a/main.py b/main.py index 9d652939..7f159649 100644 --- a/main.py +++ b/main.py @@ -13,8 +13,6 @@ from orm import init_tables from auth.authenticate import JWTAuthenticate from auth.oauth import oauth_login, oauth_authorize -from services.redis import redis -from services.schema import resolvers from resolvers.auth import confirm_email_handler from resolvers.upload import upload_handler from settings import DEV_SERVER_PID_FILE_NAME, SENTRY_DSN @@ -26,7 +24,7 @@ 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="!secret"), + Middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY), ] @@ -39,7 +37,6 @@ async def start_up(): _views_stat_task = asyncio.create_task(ViewedStorage().worker()) try: import sentry_sdk - sentry_sdk.init(SENTRY_DSN) print("[sentry] started") except Exception as e: @@ -78,14 +75,12 @@ app = Starlette( middleware=middleware, routes=routes, ) -app.mount( - "/", - GraphQL(schema, debug=True), -) +app.mount("/", GraphQL( + schema, + debug=True +)) -print("[main] app mounted") - -dev_app = app = Starlette( +dev_app = Starlette( debug=True, on_startup=[dev_start_up], on_shutdown=[shutdown], diff --git a/migration/tables/content_items.py b/migration/tables/content_items.py new file mode 100644 index 00000000..e69de29b diff --git a/migration/tables/users.py b/migration/tables/users.py new file mode 100644 index 00000000..e69de29b diff --git a/orm/__init__.py b/orm/__init__.py index 6501baf4..7be9953a 100644 --- a/orm/__init__.py +++ b/orm/__init__.py @@ -7,7 +7,18 @@ from orm.shout import Shout from orm.topic import Topic, TopicFollower from orm.user import User, UserRating -# NOTE: keep orm module isolated + +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", @@ -21,16 +32,5 @@ __all__ = [ "Notification", "Reaction", "UserRating", + "init_tables" ] - - -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") diff --git a/orm/notification.py b/orm/notification.py index d0b6d563..48d4413d 100644 --- a/orm/notification.py +++ b/orm/notification.py @@ -1,13 +1,25 @@ from datetime import datetime -from sqlalchemy import Column, String, JSON, ForeignKey, DateTime, Boolean -from services.db import Base +from sqlalchemy import Column, Enum, ForeignKey, DateTime, Boolean, Integer +from sqlalchemy.dialects.postgresql import JSONB + +from base.orm import Base +from enum import Enum as Enumeration + + +class NotificationType(Enumeration): + NEW_REACTION = 1 + NEW_SHOUT = 2 + NEW_FOLLOWER = 3 class Notification(Base): __tablename__ = "notification" + shout = Column(ForeignKey("shout.id"), index=True) + reaction = Column(ForeignKey("reaction.id"), index=True) user = Column(ForeignKey("user.id"), index=True) createdAt = Column(DateTime, nullable=False, default=datetime.now, index=True) seen = Column(Boolean, nullable=False, default=False, index=True) - type = Column(String, nullable=False) - data = Column(JSON, nullable=True) + type = Column(Enum(NotificationType), nullable=False) + data = Column(JSONB, nullable=True) + occurrences = Column(Integer, default=1) diff --git a/resetdb.sh b/resetdb.sh old mode 100644 new mode 100755 diff --git a/resolvers/inbox/messages.py b/resolvers/inbox/messages.py new file mode 100644 index 00000000..e69de29b diff --git a/resolvers/load.py b/resolvers/load.py index ebb6dd86..823360f0 100644 --- a/resolvers/load.py +++ b/resolvers/load.py @@ -137,7 +137,7 @@ async def load_shouts_by(_, info, options): """ :param options: { filters: { - layout: 'audio', + layout: 'music', excludeLayout: 'article', visibility: "public", author: 'discours', @@ -208,6 +208,7 @@ async def load_shouts_by(_, info, options): @query.field("loadDrafts") +@login_required async def get_drafts(_, info): auth: AuthCredentials = info.context["request"].auth user_id = auth.user_id diff --git a/resolvers/notifications.py b/resolvers/notifications.py new file mode 100644 index 00000000..e11277c6 --- /dev/null +++ b/resolvers/notifications.py @@ -0,0 +1,84 @@ +from sqlalchemy import select, desc, and_, update + +from auth.credentials import AuthCredentials +from base.resolvers import query, mutation +from auth.authenticate import login_required +from base.orm import local_session +from orm import Notification + + +@query.field("loadNotifications") +@login_required +async def load_notifications(_, info, params=None): + if params is None: + params = {} + + auth: AuthCredentials = info.context["request"].auth + user_id = auth.user_id + + limit = params.get('limit', 50) + offset = params.get('offset', 0) + + q = select(Notification).where( + Notification.user == user_id + ).order_by(desc(Notification.createdAt)).limit(limit).offset(offset) + + with local_session() as session: + total_count = session.query(Notification).where( + Notification.user == user_id + ).count() + + total_unread_count = session.query(Notification).where( + and_( + Notification.user == user_id, + Notification.seen is False + ) + ).count() + + notifications = session.execute(q).fetchall() + + 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): + auth: AuthCredentials = info.context["request"].auth + user_id = auth.user_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 + ) + ).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 {} diff --git a/resolvers/profile.py b/resolvers/profile.py index 364848de..5091e6c5 100644 --- a/resolvers/profile.py +++ b/resolvers/profile.py @@ -266,10 +266,20 @@ async def get_authors_all(_, _info): @query.field("getAuthor") async def get_author(_, _info, slug): q = select(User).where(User.slug == slug) - q = add_author_stat_columns(q, True) + q = add_author_stat_columns(q) - authors = get_authors_from_query(q) - return authors[0] + [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") diff --git a/resolvers/reactions.py b/resolvers/reactions.py index 8e7fec21..f232b0ed 100644 --- a/resolvers/reactions.py +++ b/resolvers/reactions.py @@ -10,6 +10,7 @@ from services.schema import mutation, query from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutReactionsFollower from orm.user import User +from services.notifications.notification_service import notification_service def add_reaction_stat_columns(q): @@ -217,6 +218,8 @@ 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 @@ -237,12 +240,14 @@ async def create_reaction(_, info, reaction): session.add(r) session.commit() + + await notification_service.handle_new_reaction(r.id) + rdict = r.dict() rdict["shout"] = shout.dict() rdict["createdBy"] = author.dict() # self-regulation mechanics - if check_to_hide(session, auth.user_id, r): set_hidden(session, r.shout) elif check_to_publish(session, auth.user_id, r): diff --git a/resolvers/zine/following.py b/resolvers/zine/following.py new file mode 100644 index 00000000..e69de29b diff --git a/schemas/core.graphql b/schemas/core.graphql index 7d2ac3a3..3d884956 100644 --- a/schemas/core.graphql +++ b/schemas/core.graphql @@ -7,18 +7,18 @@ type _Service { ################################### Payload ################################### type UserFollowings { - unread: Int - topics: [String] - authors: [String] - reactions: [Int] - communities: [String] + unread: Int + topics: [String] + authors: [String] + reactions: [Int] + communities: [String] } type AuthResult { - error: String - token: String - user: User - news: UserFollowings + error: String + token: String + user: User + news: UserFollowings } type AuthorStat { @@ -61,107 +61,118 @@ type Result { } enum ReactionStatus { - NEW - UPDATED - CHANGED - EXPLAINED - DELETED + NEW + UPDATED + CHANGED + EXPLAINED + DELETED } type ReactionUpdating { - error: String - status: ReactionStatus - reaction: Reaction + 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 + 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 + 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] + 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 + kind: ReactionKind! + shout: Int! + range: String + body: String + replyTo: Int } enum FollowingEntity { - TOPIC - AUTHOR - COMMUNITY - REACTIONS + TOPIC + AUTHOR + COMMUNITY + REACTIONS } ################################### Mutation type Mutation { + # inbox + createChat(title: String, members: [Int]!): Result! + updateChat(chat: ChatInput!): Result! + deleteChat(chatId: String!): Result! - # auth - getSession: AuthResult! - registerUser(email: String!, password: String, name: String): AuthResult! - sendLink(email: String!, lang: String, template: String): Result! - confirmEmail(token: String!): AuthResult! + createMessage(chat: String!, body: String!, replyTo: Int): Result! + updateMessage(chatId: String!, id: Int!, body: String!): Result! + deleteMessage(chatId: String!, id: Int!): Result! + markAsRead(chatId: String!, ids: [Int]!): Result! - # shout - createShout(inp: ShoutInput!): Result! - updateShout(shout_id: Int!, shout_input: ShoutInput, publish: Boolean): Result! - deleteShout(shout_id: Int!): Result! + # auth + getSession: AuthResult! + registerUser(email: String!, password: String, name: String): AuthResult! + sendLink(email: String!, lang: String, template: String): Result! + confirmEmail(token: String!): AuthResult! - # user profile - rateUser(slug: String!, value: Int!): Result! - updateOnlineStatus: Result! - updateProfile(profile: ProfileInput!): Result! + # shout + createShout(inp: ShoutInput!): Result! + updateShout(shout_id: Int!, shout_input: ShoutInput, publish: Boolean): Result! + deleteShout(shout_id: Int!): Result! - # topics - createTopic(input: TopicInput!): Result! - # TODO: mergeTopics(t1: String!, t2: String!): Result! - updateTopic(input: TopicInput!): Result! - destroyTopic(slug: String!): Result! + # user profile + rateUser(slug: String!, value: Int!): Result! + updateProfile(profile: ProfileInput!): Result! - # reactions - createReaction(reaction: ReactionInput!): Result! - updateReaction(id: Int!, reaction: ReactionInput!): Result! - deleteReaction(id: Int!): Result! + # topics + createTopic(input: TopicInput!): Result! + # TODO: mergeTopics(t1: String!, t2: String!): Result! + updateTopic(input: TopicInput!): Result! + destroyTopic(slug: String!): Result! - # following - follow(what: FollowingEntity!, slug: String!): Result! - unfollow(what: FollowingEntity!, 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! + + markNotificationAsRead(notification_id: Int!): Result! + markAllNotificationsAsRead: Result! } input AuthorsBy { @@ -176,24 +187,24 @@ input AuthorsBy { } input LoadShoutsFilters { - title: String - body: String - topic: String - author: String - layout: String - excludeLayout: String - visibility: String - days: Int - reacted: Boolean + 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 + filters: LoadShoutsFilters + with_author_captions: Boolean + limit: Int! + offset: Int + order_by: String + order_by_desc: Boolean } input ReactionBy { @@ -206,7 +217,17 @@ input ReactionBy { days: Int # before sort: String # how to sort, default createdAt } -################################### Query + +input NotificationsQueryParams { + limit: Int + offset: Int +} + +type NotificationsQueryResult { + notifications: [Notification]! + totalCount: Int! + totalUnreadCount: Int! +} type Query { @@ -245,178 +266,194 @@ type Query { ############################################ Entities type Resource { - id: Int! - name: String! + id: Int! + name: String! } type Operation { - id: Int! - name: String! + id: Int! + name: String! } type Permission { - operation: Int! - resource: Int! + operation: Int! + resource: Int! } type Role { - id: Int! - name: String! - community: String! - desc: String - permissions: [Permission!]! + id: Int! + name: String! + community: String! + desc: String + permissions: [Permission!]! } type Rating { - rater: String! - value: Int! + 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 + 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 + LIKE + DISLIKE - AGREE - DISAGREE + AGREE + DISAGREE - PROOF - DISPROOF + PROOF + DISPROOF - COMMENT - QUOTE + COMMENT + QUOTE - PROPOSE - ASK + PROPOSE + ASK - REMARK - FOOTNOTE + REMARK + FOOTNOTE - ACCEPT - REJECT + ACCEPT + REJECT } 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: 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 } # 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 # audio 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] + 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 } 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: User! } 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: User! } type TopicStat { - shouts: Int! - followers: Int! - authors: Int! - # viewed: Int - # reacted: Int! - # commented: Int - # rating: Int + shouts: Int! + followers: Int! + authors: Int! + # viewed: Int + # reacted: Int! + # commented: Int + # rating: 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 + # community: Community! + stat: TopicStat + oid: String } type Token { - createdAt: DateTime! - expiresAt: DateTime - id: Int! - ownerId: Int! - usedAt: DateTime - value: String! + createdAt: DateTime! + expiresAt: DateTime + id: Int! + ownerId: Int! + usedAt: DateTime + value: String! +} + +enum NotificationType { + NEW_COMMENT, + NEW_REPLY +} + +type Notification { + id: Int! + shout: Int + reaction: Int + type: NotificationType + createdAt: DateTime! + seen: Boolean! + data: String # JSON + occurrences: Int! } diff --git a/server.py b/server.py index a2a9546c..0b469e3b 100644 --- a/server.py +++ b/server.py @@ -44,7 +44,7 @@ log_settings = { local_headers = [ ("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD"), - ("Access-Control-Allow-Origin", "http://localhost:3000"), + ("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", diff --git a/services/notifications/notification_service.py b/services/notifications/notification_service.py new file mode 100644 index 00000000..bfaf8e79 --- /dev/null +++ b/services/notifications/notification_service.py @@ -0,0 +1,137 @@ +import asyncio +import json +from datetime import datetime, timezone + +from sqlalchemy import and_ + +from base.orm import local_session +from orm import Reaction, Shout, Notification, User +from orm.notification import NotificationType +from orm.reaction import ReactionKind +from services.notifications.sse import connection_manager + + +def update_prev_notification(notification, user): + notification_data = json.loads(notification.data) + + notification_data["users"] = [ + user for user in notification_data["users"] if user['id'] != user.id + ] + notification_data["users"].append({ + "id": user.id, + "name": user.name + }) + + notification.data = json.dumps(notification_data, ensure_ascii=False) + notification.seen = False + notification.occurrences = notification.occurrences + 1 + notification.createdAt = datetime.now(tz=timezone.utc) + + +class NewReactionNotificator: + def __init__(self, reaction_id): + self.reaction_id = reaction_id + + async def run(self): + with local_session() as session: + reaction = session.query(Reaction).where(Reaction.id == self.reaction_id).one() + shout = session.query(Shout).where(Shout.id == reaction.shout).one() + user = session.query(User).where(User.id == reaction.createdBy).one() + notify_user_ids = [] + + if reaction.kind == ReactionKind.COMMENT: + parent_reaction = None + if reaction.replyTo: + parent_reaction = session.query(Reaction).where(Reaction.id == reaction.replyTo).one() + if parent_reaction.createdBy != reaction.createdBy: + prev_new_reply_notification = session.query(Notification).where( + and_( + Notification.user == shout.createdBy, + Notification.type == NotificationType.NEW_REPLY, + Notification.shout == shout.id, + Notification.reaction == parent_reaction.id + ) + ).first() + + if prev_new_reply_notification: + update_prev_notification(prev_new_reply_notification, user) + else: + reply_notification_data = json.dumps({ + "shout": { + "title": shout.title + }, + "users": [ + {"id": user.id, "name": user.name} + ] + }, ensure_ascii=False) + + reply_notification = Notification.create(**{ + "user": parent_reaction.createdBy, + "type": NotificationType.NEW_REPLY.name, + "shout": shout.id, + "reaction": parent_reaction.id, + "data": reply_notification_data + }) + + session.add(reply_notification) + + notify_user_ids.append(parent_reaction.createdBy) + + if reaction.createdBy != shout.createdBy and ( + parent_reaction is None or parent_reaction.createdBy != shout.createdBy + ): + prev_new_comment_notification = session.query(Notification).where( + and_( + Notification.user == shout.createdBy, + Notification.type == NotificationType.NEW_COMMENT, + Notification.shout == shout.id + ) + ).first() + + if prev_new_comment_notification: + update_prev_notification(prev_new_comment_notification, user) + else: + notification_data_string = json.dumps({ + "shout": { + "title": shout.title + }, + "users": [ + {"id": user.id, "name": user.name} + ] + }, ensure_ascii=False) + + author_notification = Notification.create(**{ + "user": shout.createdBy, + "type": NotificationType.NEW_COMMENT.name, + "shout": shout.id, + "data": notification_data_string + }) + + session.add(author_notification) + + notify_user_ids.append(shout.createdBy) + + session.commit() + + for user_id in notify_user_ids: + await connection_manager.notify_user(user_id) + + +class NotificationService: + def __init__(self): + self._queue = asyncio.Queue() + + async def handle_new_reaction(self, reaction_id): + notificator = NewReactionNotificator(reaction_id) + await self._queue.put(notificator) + + async def worker(self): + while True: + notificator = await self._queue.get() + try: + await notificator.run() + except Exception as e: + print(f'[NotificationService.worker] error: {str(e)}') + + +notification_service = NotificationService() diff --git a/services/notifications/sse.py b/services/notifications/sse.py new file mode 100644 index 00000000..085dbde0 --- /dev/null +++ b/services/notifications/sse.py @@ -0,0 +1,72 @@ +import json + +from sse_starlette.sse import EventSourceResponse +from starlette.requests import Request +import asyncio + + +class ConnectionManager: + def __init__(self): + self.connections_by_user_id = {} + + def add_connection(self, user_id, connection): + if user_id not in self.connections_by_user_id: + self.connections_by_user_id[user_id] = [] + self.connections_by_user_id[user_id].append(connection) + + def remove_connection(self, user_id, connection): + if user_id not in self.connections_by_user_id: + return + + self.connections_by_user_id[user_id].remove(connection) + + if len(self.connections_by_user_id[user_id]) == 0: + del self.connections_by_user_id[user_id] + + async def notify_user(self, user_id): + if user_id not in self.connections_by_user_id: + return + + for connection in self.connections_by_user_id[user_id]: + data = { + "type": "newNotifications" + } + data_string = json.dumps(data, ensure_ascii=False) + await connection.put(data_string) + + async def broadcast(self, data: str): + for user_id in self.connections_by_user_id: + for connection in self.connections_by_user_id[user_id]: + await connection.put(data) + + +class Connection: + def __init__(self): + self._queue = asyncio.Queue() + + async def put(self, data: str): + await self._queue.put(data) + + async def listen(self): + data = await self._queue.get() + return data + + +connection_manager = ConnectionManager() + + +async def sse_subscribe_handler(request: Request): + user_id = int(request.path_params["user_id"]) + connection = Connection() + connection_manager.add_connection(user_id, connection) + + async def event_publisher(): + try: + while True: + data = await connection.listen() + yield data + except asyncio.CancelledError as e: + connection_manager.remove_connection(user_id, connection) + raise e + + return EventSourceResponse(event_publisher()) diff --git a/settings.py b/settings.py index 54897dfa..270b4551 100644 --- a/settings.py +++ b/settings.py @@ -27,6 +27,7 @@ SHOUTS_REPO = "content" SESSION_TOKEN_HEADER = "Authorization" 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' diff --git a/test/test.json b/test/test.json new file mode 100644 index 00000000..3ba053e7 --- /dev/null +++ b/test/test.json @@ -0,0 +1,43 @@ +{ + "data": { + "loadShout": { + "id": 4774, + "title": "Как танки в цирке застревали и рылись ямы под Москвой.", + "lead": "

Военный мятеж Пригожина вызвал панику в обществе и породил множество конспирологических теорий о заранее спланированной акции Кремля. Почему сначала президент называл мятежников предателями и обещал неминуемое наказание, но через сутки все просто разошлись, а уголовное дело свернули? Из-за чего в стране вообще стал возможен вооруженный бунт и в чем он оказался успешен? Каковы последствия мятежа дл

", + "description": "Оглавление Многообещающее начало Тухлый финал Предыстория конфликта Пригожина и Минобороны Расклад сил в момент мятежа Теории заговора Сообщники Пригожина в элитах О чем договорились с Пригожиным Разочарование со всех сторон Как закрывали уголовное дело Последствия для Пригожина Последствия для ЧВК Последствия для российской бюрократии и Путина Вышел...", + "visibility": "community", + "subtitle": "Исчерпывающий разбор причин, хода и последствий", + "slug": "kaktankivtsirkezastrevaliirylisiyamypodmoskvoy", + "layout": "article", + "cover": "http://cdn.discours.io/caf24deb-c415-49ef-8404-418455c57c5c.webp", + "body": "
  1. Оглавление

    Многообещающее начало

    Тухлый финал

    Предыстория конфликта Пригожина и Минобороны

    Расклад сил в момент мятежа

    Теории заговора

    Сообщники Пригожина в элитах

    О чем договорились с Пригожиным

    Разочарование со всех сторон

    Как закрывали уголовное дело

    Последствия для Пригожина

    Последствия для ЧВК

    Последствия для российской бюрократии и Путина

Вышел Путин на крыльцо,

Потеряв вконец лицо.

Об опасности конца

Говорил с того крыльца,

Про предателей, про бунт,

О вреде военных хунт,

Про гражданскую войну,

Про несчастную страну,

Положив на музыкантов

Вот за это всю вину.

К сожаленью президент,

Запилив такой контент,

Не сдержавшись в выраженьях,

Упустил такой момент:

Чтобы кресло сохранить,

Нужно меньше говорить,

Как тебя на этом кресле

Не проблемно заменить.

Автор неизвестен

В России вещи, о которых трубят из каждого утюга, все равно происходят неожиданно. Долго говорили, насколько невероятна война с Украиной, а это случилось. Говорили о том, что частные армии опасны для государственной бюрократии, — начался военный мятеж. Шутили «будем бомбить Воронеж» (не смотри, что в анекдоте) — и это тоже случилось. Говорили, что рано или поздно люди из системы начнут жрать друг друга, — и вот вчерашний герой Пригожин уже вымарывается из российской истории.

Многообещающее начало

23 июня Евгений Пригожин начал вооруженный мятеж после того, как министр обороны Сергей Шойгу потребовал, чтобы наемники ЧВК «Вагнер» подписали контракты с Минобороны до 1 июля. То есть попытался лишить Пригожина его кормовой и силовой базы в виде частной армии.

По версии Пригожина, Минобороны нанесло ракетный удар по лагерю «Вагнера», а также направило спецназ для захвата его самого. Однако, как выяснилось, о начавшемся отходе «вагнеров» из захваченного Бахмута и готовящемся мятеже уже 22 июня знала ФСБ из официального письма заместителя Пригожина в ЧВК Андрея Трошева. В США и вовсе заявили, что наблюдали за подготовкой мятежа две недели. О том же сообщила немецкая разведка. И, наконец, провалившееся задержание Пригожина должно было состояться не в лагере наемников, а в Санкт-Петербурге.

Военный мятеж предварялся обращением Пригожина в телеграм, в котором он открыл общественности секрет Полишинеля. В частности, обвинил руководство Минобороны в развале армии, рассказал, что захват Украины нужен был для распила российскими олигархами бизнеса на новых территориях, как это было на Донбассе, заявил, что пора покончить с обманом и коррупцией в стране, и потребовал выдать ему министра обороны Шойгу и главу генштаба Герасимова.

Шойгу спешно свалил из Ростова. Сам город и военные объекты Ростовской области были заняты «Вагнером».

Нужно ли говорить, что все полицейские разбежались, решив, что на этом их полномочия — всё. Такой серьезный митинг разогнать шокерами и дубинками решительно нельзя.

В Кремле едва успевали подносить и опорожнять чемоданчики. Ведь Путин не испытывал подобных стрессов со времен Болотной площади, когда реально испугался потери власти, после чего стал превращать правоохранительную систему в политическую полицию, создал Росгвардию и «заболел цифровизацией» как инструментом тотальной слежки за гражданами. Гражданское общество с белыми ленточками подавили, но беда пришла со стороны людей с шевронами «Наш бизнес — смерть, и бизнес идет хорошо». Страшно, очень страшно.

Путин записал обращение, в котором назвал наемников предателями, обещал неминуемое наказание (которое таки минуло) и вспомнил 1917 год.

Услышав про 1917 год, все, кроме «болота», в течение суток ждали досрочного прекращения полномочий президента. Правящая элита, включая Путина, покинула Москву. Косплеить украинское руководство и записывать ролики на Красной площади не стали. В Москве остался только Володин. Когда все утихло, он решил повысить свой аппаратный вес и призвал наказать бежавших. То есть почти всю верхушку страны. А в ней, между прочим, олигархи путинской волны, друзья детства, кооператив «Озеро» и всё, что навевает теплые воспоминания из прошлого.

Отвечая на обращение Путина, Пригожин неосторожно заявил, что президент ошибается, и мятеж — это не мятеж, а «марш справедливости». При этом глава ЧВК требовал, чтобы никто не сопротивлялся колоннам наемников, движущимся на Москву, а любой, кто встанет на пути, будет уничтожен. Потому что никто не встанет на пути у справедливости.

Глава ЧВК требовал, чтобы никто не сопротивлялся колоннам наемников, движущимся на Москву, а любой, кто встанет на пути, будет уничтожен / Скриншот из обращения Пригожина из Ростова / fedpress.ru

​После некоторой фрустрации ФСБ очухалась и забегала по военкоматам, собирая информацию о женах и родственниках «вагнеров». Под Москвой начали разрывать экскаваторами дороги и выставлять грузовики с песком. Кадыров заверил Путина в своей преданности и отправил в направлении Ростова батальон «Ахмат», который в очередной раз весьма благоразумно не доехал до точки соприкосновения.

Тухлый финал

Вечером 24 июня, когда колонна «Вагнера» была в 200 км от Москвы, Пригожин решил развернуть колонну и вернуться в полевые лагеря во избежание кровопролития (умолчав о куче перебитой российской авиации с РЭБ и ее экипажах).

Ответственность за срыв мятежа взял на себя Лукашенко и сымитировал переговоры с Пригожиным, передав тому предложения Путина, который не осмелился лично ответить на звонок мятежника. Лукашенко с радостью вписался во что-то более легитимирующее его шаткую власть, чем осмотр «обосранных» коров в колхозах.

Позже Песков сообщил, что Пригожин уезжает в Беларусь, а те «вагнера», которые на участвовали в мятеже, могут заключить контракты с Минобороны. В Беларуси был раскинут лагерь на 8 тысяч человек.

У Путина от избытка адреналина развязался язык. Он провел открытое совещание Совбеза, записывал обращения, рассказывал о попытке начать гражданскую войну, клеймил предателей, благодарил всех, кто не разбежался. И, наконец, сдал все пароли и явки, заявив, что за год государство потратило на «Вагнер» и Пригожина 276 млрд рублей. Позже пропагандист Дмитрий Киселев назвал цифру в 858 млрд, которые Пригожин получил через холдинг «Конкорд».

Одна из перекопанный дорог, которая должна была усложнить поход «вагнеровцев» на Москву / Фото: соцсети, Липецкая область

Все бы ничего, ведь активная часть гражданского общества обо всем и так знала. И о Сирии, и об Африке, и об Украине. Но Путин забылся и разоткровенничался перед своим ядерным электоратом, тем самым «болотом», которое смотрит телик, мало осведомлено о ЧВК, верит в сильного президента и патриотическую сплоченность. А теперь им рассказали, что государство финансирует через левые схемы частные военизированные формирования, которые ставят страну на грань гражданской войны. 

Президент теперь не находится над схваткой, а является ее частью, и спасает его Лукашенко, который всеми силами демонстрирует, что его яйца крепче, чем картофель и покрышка БелАЗа.

Главу Росгвардии Золотова наградили за защиту Москвы, которая не состоялась. А самой Росгвардии обещали выдать танки и прочую тяжелую технику, которая теперь не отправится на фронт. Если будет выдана. Видимо, ожидают повторного марша государственных и полугосударственных военных на Москву.

Так феодализм оформился и в военной сфере: армия против Украины, другая армия против этой армии, региональные армии на случай войны с федералами и частные армии на случай войны с конкурирующими корпорациями за активы. Не удивительно, что Пригожина возмутило, что его хотят лишить своей армии, когда у всех уважаемых людей она есть.

Уголовное дело против Пригожина было юридически неграмотно прекращено, несмотря на убитых «вагнерами» летчиков, которых Путин почтил минутой молчания, выступая на крыльце Грановитой палаты Кремля перед сотрудниками ФСО и военным руководством.

В частности, 28 июня сообщили, что арестован генерал Суровикин, лоббист «Вагнера» в Министерстве обороны, несмотря на то что осудил мятеж после его начала, записав соответствующее видеообращение при неустановленных обстоятельствах. Правозащитник Ольга Романова рассказала, что в СИЗО «Лефортово» была принята и передана задержанному открытка, отправленная на имя Суровикина С. В. Предположительно, сейчас Суровикин находится под другой мерой пресечения — запретом на совершение определенных действий.

Неизвестна судьба генерала Мизинцева, который до увольнения из Минобороны обеспечивал серые поставки «вагнерам» боеприпасов во время войны с Украиной, за что был уволен и немедленно трудоустроен заместителем в ЧВК «Вагнер».

В течение недели после мятежа начались чистки в Минобороны.

Бизнес-империю Пригожина начали рушить, включая его силовые, медийные и чисто коммерческие ресурсы. Его репутацию тоже уничтожают. Пропагандисты на федеральных каналах развернулись на 180 градусов, клеймят предателя и рассказывают от том, насколько преувеличена роль «Вагнера» на фронте. 

И, конечно же, показывают «глубинному народу» материалы обысков во дворце Пригожина с найденными в нем наградным оружием, париками для маскировки и, по неподтвержденным данным, костюмом Папы Римского.

Утверждается, что в ходе обысков у Пригожина нашли его фотографии в различных обличьях / Коллаж: topcor.ru

Предыстория конфликта Пригожина и Минобороны

На протяжении 2023 года в военной и чекистской бюрократии устоялась концепция того, что зарвавшегося Пригожина (выскочку, человека не из системы, с чрезмерными политическими амбициями) готовят на заклание. Слишком быстрый рост популярности при отсутствии аппаратного веса. Или, если короче, «кто он вообще такой, чтобы так борзеть?».

Минобороны ограничивало снабжение ЧВК боеприпасами, минировало пути отхода «Вагнера» из Бахмута и принуждало наемников заключить контракты с Минобороны. То есть пыталось лишить Пригожина его собственной пирамиды, на вершине которой он таки имел аппаратный вес. Но этот аппарат слишком обособился от военной бюрократии. Нарушил пресловутую монополию государства на легальное насилие. Опасно. 

Обнулять «Вагнер» Шойгу начал еще во время сирийской кампании, где Россия помогала Башару Асаду сохранить свою диктаторскую власть. 


По воспоминаниям корреспондента пригожинской пропагандистской помойки РИА «ФАН» Кирилла Романовского, весной 2016 года, после взятия наемниками Пальмиры, Шойгу заявил, что какие-то гопники не могут получать государственные награды РФ. И раздал награды своим гопникам из Минобороны.

Во времена этой же кампании случилось уничтожение 200 «вагнеров», шедших на захват нефтеперерабатывающего завода. На запрос США: «Это ваши?» — Минобороны ответило: «Не, не наши». Американцы пожали плечами и нанесли по колонне авиаудар, полностью очистивший ландшафт от всей имеющейся на нем фауны.

Список

Список

Список

Понимая, куда все движется, длительное время Пригожин как когда-то генералиссимус Валленштейн (тоже владевший частной армией) находился в полевых лагерях, откуда критиковал государственную армию, заверяя императора в том, что будет воевать в его интересах, но по своему усмотрению. 

Как и для Валленштейна, для Пригожина частная армия являлась единственным гарантом выживания в борьбе с тяжеловесами из государственной бюрократии — Шойгу и Герасимовым. Те не забыли оскорблений Пригожина и долго низводили численный состав «Вагнера» к минимуму, перекрыв доступ к вербовке зеков, держа наемников на передней линии фронта для перемалывания их руками ВСУ и, наконец, требуя перейти на контракты с Минобороны.

Сообщники Пригожина в элитах

А что насчет сообщников, единомышленников или по крайней мере сочувствующих Пригожину в государственной бюрократии? Можно говорить о ситуативном содействии отдельных чиновников Пригожину, но не о спланированном мятеже с целью смены высших должностных лиц, включая президента.

Поскольку государство авторитарное, кажется, что у него единый центр принятия решений. Эта иллюзия заставляет думать, что все происходящее — это часть некоего плана.

Тут случился треш)))

\"В

Читайте также

Право народа на восстание. Можно ли защищать демократию силой? 

Как Пригожин вербовал заключенных на войну. Репортаж из колонии о приезде основателя ЧВК «Вагнер» 

«Вы — пушечное мясо». Почему российские власти творят всякий треш? 

«Они хотят вырваться из русской тюрьмы». Ольга Романова о заключенных на фронте и новых законах после мятежа Пригожина

«Я не могу желать поражения русской армии». Почему националисты и нацболы не выступают против войны в Украине?

Цитата любопытно смещает эмбед

А текст после цитаты пишется здесь

", + "media": null, + "mainTopic": "politics", + "topics": [ + { + "id": 200, + "title": "политика", + "body": "", + "slug": "politics", + "stat": null + } + ], + "authors": [ + { + "id": 2, + "name": "Дискурс", + "slug": "discours", + "userpic": null + } + ], + "createdAt": "2023-09-04T10:15:08.666569", + "publishedAt": "2023-09-04T12:35:20.024954", + "stat": { + "viewed": 6, + "reacted": null, + "rating": 0, + "commented": 0 + } + } + } +}