From 53d1ccb15f28c228c393f1287471bef3f174039e Mon Sep 17 00:00:00 2001 From: ilya-bkv Date: Thu, 5 Oct 2023 20:34:23 +0300 Subject: [PATCH 1/6] getAuthor add stat --- schema.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema.graphql b/schema.graphql index 86f6f8c6..fbb837d0 100644 --- a/schema.graphql +++ b/schema.graphql @@ -274,7 +274,7 @@ type Query { userFollowedAuthors(slug: String!): [Author]! userFollowedTopics(slug: String!): [Topic]! authorsAll: [Author]! - getAuthor(slug: String!): User + getAuthor(slug: String!): Author myFeed(options: LoadShoutsOptions): [Shout] # migrate From 650d2fa2ff117d7c3e78a864b976174813ac1b46 Mon Sep 17 00:00:00 2001 From: Ilya Y <75578537+ilya-bkv@users.noreply.github.com> Date: Sat, 7 Oct 2023 18:17:48 +0300 Subject: [PATCH 2/6] Revert "added heavy stat for getAuthor" --- resolvers/zine/profile.py | 37 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/resolvers/zine/profile.py b/resolvers/zine/profile.py index 2856553b..98041b2c 100644 --- a/resolvers/zine/profile.py +++ b/resolvers/zine/profile.py @@ -17,10 +17,11 @@ from resolvers.inbox.unread import get_total_unread_counter from resolvers.zine.topics import followed_by_user -def add_author_stat_columns(q, include_heavy_stat=False): +def add_author_stat_columns(q): author_followers = aliased(AuthorFollower) author_following = aliased(AuthorFollower) shout_author_aliased = aliased(ShoutAuthor) + # user_rating_aliased = aliased(UserRating) q = q.outerjoin(shout_author_aliased).add_columns( func.count(distinct(shout_author_aliased.shout)).label('shouts_stat') @@ -33,26 +34,17 @@ def add_author_stat_columns(q, include_heavy_stat=False): func.count(distinct(author_following.author)).label('followings_stat') ) - if include_heavy_stat: - 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') - ) + q = q.add_columns(literal(0).label('rating_stat')) + # FIXME + # q = q.outerjoin(user_rating_aliased, user_rating_aliased.user == User.id).add_columns( + # # TODO: check + # func.sum(user_rating_aliased.value).label('rating_stat') + # ) - else: - q = q.add_columns(literal(-1).label('rating_stat')) - - if include_heavy_stat: - 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.add_columns(literal(0).label('commented_stat')) + # q = q.outerjoin(Reaction, and_(Reaction.createdBy == User.id, Reaction.body.is_not(None))).add_columns( + # func.count(distinct(Reaction.id)).label('commented_stat') + # ) q = q.group_by(User.id) @@ -109,7 +101,6 @@ async def followed_reactions(user_id): Reaction.createdAt > user.lastSeen ).all() - # dufok mod (^*^') : @query.field("userFollowedTopics") async def get_followed_topics(_, info, slug) -> List[Topic]: @@ -126,7 +117,6 @@ async def get_followed_topics(_, info, slug) -> List[Topic]: async def followed_topics(user_id): return followed_by_user(user_id) - # dufok mod (^*^') : @query.field("userFollowedAuthors") async def get_followed_authors(_, _info, slug) -> List[User]: @@ -140,7 +130,6 @@ async def get_followed_authors(_, _info, slug) -> List[User]: return await followed_authors(user_id) - # 2. Now, we can use the user_id to get the followed authors async def followed_authors(user_id): q = select(User) @@ -266,7 +255,7 @@ 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] From 6e149432c1593dc938ba076ea8386a1c5d2a5360 Mon Sep 17 00:00:00 2001 From: Igor Lobanov Date: Mon, 9 Oct 2023 12:52:13 +0200 Subject: [PATCH 3/6] Migration fix, notification schema update --- migration/tables/content_items.py | 2 +- migration/tables/users.py | 3 +- orm/notification.py | 13 +- schema.graphql | 614 +++++++++++++++--------------- 4 files changed, 328 insertions(+), 304 deletions(-) diff --git a/migration/tables/content_items.py b/migration/tables/content_items.py index 2e74f96e..a170e0bf 100644 --- a/migration/tables/content_items.py +++ b/migration/tables/content_items.py @@ -70,7 +70,6 @@ def create_author_from_app(app): "username": app["email"], "email": app["email"], "name": app.get("name", ""), - "bio": app.get("bio", ""), "emailConfirmed": False, "slug": slug, "createdAt": ts, @@ -149,6 +148,7 @@ async def migrate(entry, storage): "deletedAt": date_parse(entry.get("deletedAt")) if entry.get("deletedAt") else None, "createdAt": date_parse(entry.get("createdAt", OLD_DATE)), "updatedAt": date_parse(entry["updatedAt"]) if "updatedAt" in entry else ts, + "createdBy": author.id, "topics": await add_topics_follower(entry, storage, author), "body": extract_html(entry, cleanup=True) } diff --git a/migration/tables/users.py b/migration/tables/users.py index d7a0f260..5c70fab1 100644 --- a/migration/tables/users.py +++ b/migration/tables/users.py @@ -21,7 +21,6 @@ def migrate(entry): "createdAt": parse(entry["createdAt"]), "emailConfirmed": ("@discours.io" in email) or bool(entry["emails"][0]["verified"]), "muted": False, # amnesty - "bio": entry["profile"].get("bio", ""), "links": [], "name": "anonymous", "password": entry["services"]["password"].get("bcrypt") @@ -29,7 +28,7 @@ def migrate(entry): if "updatedAt" in entry: user_dict["updatedAt"] = parse(entry["updatedAt"]) - if "wasOnineAt" in entry: + if "wasOnlineAt" in entry: user_dict["lastSeen"] = parse(entry["wasOnlineAt"]) if entry.get("profile"): # slug diff --git a/orm/notification.py b/orm/notification.py index 41914983..d41a0283 100644 --- a/orm/notification.py +++ b/orm/notification.py @@ -1,13 +1,22 @@ from datetime import datetime -from sqlalchemy import Column, String, JSON, ForeignKey, DateTime, Boolean +from sqlalchemy import Column, Enum, JSON, ForeignKey, DateTime, Boolean, Integer from base.orm import Base +from enum import Enum as Enumeration + + +class NotificationType(Enumeration): + NEW_COMMENT = 1 + NEW_REPLY = 2 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) + type = Column(Enum(NotificationType), nullable=False) data = Column(JSON, nullable=True) + occurrences = Column(Integer, default=1) diff --git a/schema.graphql b/schema.graphql index fbb837d0..0aac9afd 100644 --- a/schema.graphql +++ b/schema.graphql @@ -3,24 +3,24 @@ scalar DateTime ################################### Payload ################################### enum MessageStatus { - NEW - UPDATED - DELETED + NEW + UPDATED + DELETED } 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 ChatMember { @@ -60,84 +60,84 @@ type Author { } type Result { - error: String - slugs: [String] - chat: Chat - chats: [Chat] - message: Message - messages: [Message] - members: [ChatMember] - shout: Shout - shouts: [Shout] - author: Author - authors: [Author] - reaction: Reaction - reactions: [Reaction] - topic: Topic - topics: [Topic] - community: Community - communities: [Community] + error: String + slugs: [String] + chat: Chat + chats: [Chat] + message: Message + messages: [Message] + members: [ChatMember] + shout: Shout + shouts: [Shout] + author: Author + authors: [Author] + reaction: Reaction + reactions: [Reaction] + topic: Topic + topics: [Topic] + community: Community + communities: [Community] } 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 } input ChatInput { @@ -147,55 +147,55 @@ input ChatInput { } 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! + # inbox + createChat(title: String, members: [Int]!): Result! + updateChat(chat: ChatInput!): Result! + deleteChat(chatId: String!): Result! - 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! + 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! - # auth - getSession: AuthResult! - registerUser(email: String!, password: String, name: String): AuthResult! - sendLink(email: String!, lang: String, template: String): Result! - confirmEmail(token: String!): AuthResult! + # 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! + # 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! - updateOnlineStatus: Result! - updateProfile(profile: ProfileInput!): Result! + # user profile + rateUser(slug: String!, value: Int!): Result! + updateOnlineStatus: 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! + # 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! + # 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! + # following + follow(what: FollowingEntity!, slug: String!): Result! + unfollow(what: FollowingEntity!, slug: String!): Result! } input MessagesBy { @@ -219,24 +219,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 { @@ -252,251 +252,267 @@ input ReactionBy { ################################### Query type Query { - # inbox - loadChats( limit: Int, offset: Int): Result! # your chats - loadMessagesBy(by: MessagesBy!, limit: Int, offset: Int): Result! - loadRecipients(limit: Int, offset: Int): Result! - searchRecipients(query: String!, limit: Int, offset: Int): Result! - searchMessages(by: MessagesBy!, limit: Int, offset: Int): Result! + # inbox + loadChats( limit: Int, offset: Int): Result! # your chats + loadMessagesBy(by: MessagesBy!, limit: Int, offset: Int): Result! + loadRecipients(limit: Int, offset: Int): Result! + searchRecipients(query: String!, limit: Int, offset: Int): Result! + searchMessages(by: MessagesBy!, limit: Int, offset: Int): Result! - # auth - isEmailUsed(email: String!): Boolean! - signIn(email: String!, password: String, lang: String): AuthResult! - signOut: AuthResult! + # 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]! - authorsAll: [Author]! - getAuthor(slug: String!): Author - myFeed(options: LoadShoutsOptions): [Shout] + # 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]! + authorsAll: [Author]! + getAuthor(slug: String!): Author + myFeed(options: LoadShoutsOptions): [Shout] - # migrate - markdownBody(body: String!): String! + # migrate + markdownBody(body: String!): String! - # topics - getTopic(slug: String!): Topic - topicsAll: [Topic]! - topicsRandom(amount: Int): [Topic]! - topicsByCommunity(community: String!): [Topic]! - topicsByAuthor(author: String!): [Topic]! + # topics + getTopic(slug: String!): Topic + topicsAll: [Topic]! + topicsRandom(amount: Int): [Topic]! + topicsByCommunity(community: String!): [Topic]! + topicsByAuthor(author: String!): [Topic]! } ############################################ Subscription type Subscription { - newMessage: Message # new messages in inbox - newShout: Shout # personal feed new shout - newReaction: Reaction # new reactions to notify + newMessage: Message # new messages in inbox + newShout: Shout # personal feed new shout + newReaction: Reaction # new reactions to notify } ############################################ 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 # 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 } 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! } type Message { - author: Int! - chatId: String! - body: String! - createdAt: Int! - id: Int! - replyTo: Int - updatedAt: Int - seen: Boolean + author: Int! + chatId: String! + body: String! + createdAt: Int! + id: Int! + replyTo: Int + updatedAt: Int + seen: Boolean } type Chat { - id: String! - createdAt: Int! - createdBy: Int! - updatedAt: Int! - title: String - description: String - users: [Int] - members: [ChatMember] - admins: [Int] - messages: [Message] - unread: Int - private: Boolean + id: String! + createdAt: Int! + createdBy: Int! + updatedAt: Int! + title: String + description: String + users: [Int] + members: [ChatMember] + admins: [Int] + messages: [Message] + unread: Int + private: Boolean +} + +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! } From d360e3c88d6ba1886860f4791ab5b886f28b5c10 Mon Sep 17 00:00:00 2001 From: Igor Lobanov Date: Mon, 9 Oct 2023 18:15:26 +0200 Subject: [PATCH 4/6] comments count added to stat["commented"] in getAuthor query --- resolvers/zine/profile.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/resolvers/zine/profile.py b/resolvers/zine/profile.py index 98041b2c..53be3e4a 100644 --- a/resolvers/zine/profile.py +++ b/resolvers/zine/profile.py @@ -7,7 +7,7 @@ from auth.authenticate import login_required from auth.credentials import AuthCredentials from base.orm import local_session from base.resolvers import mutation, query -from orm.reaction import Reaction +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 @@ -101,6 +101,7 @@ async def followed_reactions(user_id): Reaction.createdAt > user.lastSeen ).all() + # dufok mod (^*^') : @query.field("userFollowedTopics") async def get_followed_topics(_, info, slug) -> List[Topic]: @@ -117,6 +118,7 @@ async def get_followed_topics(_, info, slug) -> List[Topic]: async def followed_topics(user_id): return followed_by_user(user_id) + # dufok mod (^*^') : @query.field("userFollowedAuthors") async def get_followed_authors(_, _info, slug) -> List[User]: @@ -130,6 +132,7 @@ async def get_followed_authors(_, _info, slug) -> List[User]: return await followed_authors(user_id) + # 2. Now, we can use the user_id to get the followed authors async def followed_authors(user_id): q = select(User) @@ -257,8 +260,18 @@ async def get_author(_, _info, slug): q = select(User).where(User.slug == slug) 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") From 889f80242919e160cbab6cc40936c2c863d06b1f Mon Sep 17 00:00:00 2001 From: Ilya Y <75578537+ilya-bkv@users.noreply.github.com> Date: Tue, 10 Oct 2023 09:35:27 +0300 Subject: [PATCH 5/6] Feature/notifications (#77) feature - notifications Co-authored-by: Igor Lobanov --- auth/jwtcodec.py | 2 +- base/resolvers.py | 6 +- main.py | 36 ++--- orm/__init__.py | 26 ++-- orm/notification.py | 6 +- requirements.txt | 3 +- resetdb.sh | 0 resolvers/__init__.py | 55 +------ resolvers/inbox/messages.py | 39 +---- resolvers/notifications.py | 84 +++++++++++ resolvers/zine/following.py | 78 +--------- resolvers/zine/load.py | 1 + resolvers/zine/reactions.py | 34 +++-- schema.graphql | 24 +-- server.py | 2 +- services/inbox/presence.py | 46 ------ services/inbox/sse.py | 22 --- .../notifications/notification_service.py | 137 ++++++++++++++++++ services/notifications/sse.py | 72 +++++++++ settings.py | 1 + test/test.json | 43 ++++++ 21 files changed, 412 insertions(+), 305 deletions(-) mode change 100644 => 100755 resetdb.sh create mode 100644 resolvers/notifications.py delete mode 100644 services/inbox/presence.py delete mode 100644 services/inbox/sse.py create mode 100644 services/notifications/notification_service.py create mode 100644 services/notifications/sse.py create mode 100644 test/test.json diff --git a/auth/jwtcodec.py b/auth/jwtcodec.py index d4d2116f..ac561adb 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/resolvers.py b/base/resolvers.py index 4c771976..4a01e270 100644 --- a/base/resolvers.py +++ b/base/resolvers.py @@ -1,5 +1,4 @@ -from ariadne import MutationType, QueryType, SubscriptionType, ScalarType - +from ariadne import MutationType, QueryType, ScalarType datetime_scalar = ScalarType("DateTime") @@ -11,5 +10,4 @@ def serialize_datetime(value): query = QueryType() mutation = MutationType() -subscription = SubscriptionType() -resolvers = [query, mutation, subscription, datetime_scalar] +resolvers = [query, mutation, datetime_scalar] diff --git a/main.py b/main.py index 5b3e5c49..fcd159f8 100644 --- a/main.py +++ b/main.py @@ -18,20 +18,18 @@ from base.resolvers import resolvers from resolvers.auth import confirm_email_handler from resolvers.upload import upload_handler from services.main import storages_init +from services.notifications.notification_service import notification_service from services.stat.viewed import ViewedStorage -from services.zine.gittask import GitTask -from settings import DEV_SERVER_PID_FILE_NAME, SENTRY_DSN -# from sse.transport import GraphQLSSEHandler -from services.inbox.presence import on_connect, on_disconnect -# from services.inbox.sse import sse_messages -from ariadne.asgi.handlers import GraphQLTransportWSHandler +# from services.zine.gittask import GitTask +from settings import DEV_SERVER_PID_FILE_NAME, SENTRY_DSN, SESSION_SECRET_KEY +from services.notifications.sse import sse_subscribe_handler import_module("resolvers") schema = make_executable_schema(load_schema_from_path("schema.graphql"), resolvers) # type: ignore middleware = [ Middleware(AuthenticationMiddleware, backend=JWTAuthenticate()), - Middleware(SessionMiddleware, secret_key="!secret"), + Middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY), ] @@ -41,8 +39,11 @@ async def start_up(): await storages_init() views_stat_task = asyncio.create_task(ViewedStorage().worker()) print(views_stat_task) - git_task = asyncio.create_task(GitTask.git_task_worker()) - print(git_task) + # git_task = asyncio.create_task(GitTask.git_task_worker()) + # print(git_task) + notification_service_task = asyncio.create_task(notification_service.worker()) + print(notification_service_task) + try: import sentry_sdk sentry_sdk.init(SENTRY_DSN) @@ -71,7 +72,8 @@ 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']) + Route("/upload", endpoint=upload_handler, methods=['POST']), + Route("/subscribe/{user_id}", endpoint=sse_subscribe_handler), ] app = Starlette( @@ -83,14 +85,10 @@ app = Starlette( ) app.mount("/", GraphQL( schema, - debug=True, - websocket_handler=GraphQLTransportWSHandler( - on_connect=on_connect, - on_disconnect=on_disconnect - ) + debug=True )) -dev_app = app = Starlette( +dev_app = Starlette( debug=True, on_startup=[dev_start_up], on_shutdown=[shutdown], @@ -99,9 +97,5 @@ dev_app = app = Starlette( ) dev_app.mount("/", GraphQL( schema, - debug=True, - websocket_handler=GraphQLTransportWSHandler( - on_connect=on_connect, - on_disconnect=on_disconnect - ) + debug=True )) diff --git a/orm/__init__.py b/orm/__init__.py index bd2c9fb7..53b13951 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 d41a0283..25f4e4f3 100644 --- a/orm/notification.py +++ b/orm/notification.py @@ -1,5 +1,7 @@ from datetime import datetime -from sqlalchemy import Column, Enum, JSON, ForeignKey, DateTime, Boolean, Integer +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 @@ -18,5 +20,5 @@ class Notification(Base): createdAt = Column(DateTime, nullable=False, default=datetime.now, index=True) seen = Column(Boolean, nullable=False, default=False, index=True) type = Column(Enum(NotificationType), nullable=False) - data = Column(JSON, nullable=True) + data = Column(JSONB, nullable=True) occurrences = Column(Integer, default=1) diff --git a/requirements.txt b/requirements.txt index fe076fdd..745353ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,14 +11,12 @@ gql~=3.4.0 uvicorn>=0.18.3 pydantic>=1.10.2 passlib~=1.7.4 -itsdangerous authlib>=1.1.0 httpx>=0.23.0 psycopg2-binary transliterate~=1.10.2 requests~=2.28.1 bcrypt>=4.0.0 -websockets bson~=0.5.10 flake8 DateTime~=4.7 @@ -38,3 +36,4 @@ python-multipart~=0.0.6 alembic==1.11.3 Mako==1.2.4 MarkupSafe==2.1.3 +sse-starlette=1.6.5 diff --git a/resetdb.sh b/resetdb.sh old mode 100644 new mode 100755 diff --git a/resolvers/__init__.py b/resolvers/__init__.py index b7ba1e1e..5d753ac4 100644 --- a/resolvers/__init__.py +++ b/resolvers/__init__.py @@ -55,7 +55,6 @@ from resolvers.inbox.messages import ( create_message, delete_message, update_message, - message_generator, mark_as_read ) from resolvers.inbox.load import ( @@ -65,56 +64,4 @@ from resolvers.inbox.load import ( ) from resolvers.inbox.search import search_recipients -__all__ = [ - # auth - "login", - "register_by_email", - "is_email_used", - "confirm_email", - "auth_send_link", - "sign_out", - "get_current_user", - # zine.profile - "load_authors_by", - "rate_user", - "update_profile", - "get_authors_all", - # zine.load - "load_shout", - "load_shouts_by", - # zine.following - "follow", - "unfollow", - # create - "create_shout", - "update_shout", - "delete_shout", - "markdown_body", - # zine.topics - "topics_all", - "topics_by_community", - "topics_by_author", - "topic_follow", - "topic_unfollow", - "get_topic", - # zine.reactions - "reactions_follow", - "reactions_unfollow", - "create_reaction", - "update_reaction", - "delete_reaction", - "load_reactions_by", - # inbox - "load_chats", - "load_messages_by", - "create_chat", - "delete_chat", - "update_chat", - "create_message", - "delete_message", - "update_message", - "message_generator", - "mark_as_read", - "load_recipients", - "search_recipients" -] +from resolvers.notifications import load_notifications diff --git a/resolvers/inbox/messages.py b/resolvers/inbox/messages.py index 44ff1f03..56187edf 100644 --- a/resolvers/inbox/messages.py +++ b/resolvers/inbox/messages.py @@ -6,7 +6,7 @@ from graphql.type import GraphQLResolveInfo from auth.authenticate import login_required from auth.credentials import AuthCredentials from base.redis import redis -from base.resolvers import mutation, subscription +from base.resolvers import mutation from services.following import FollowingManager, FollowingResult, Following from validations.inbox import Message @@ -140,40 +140,3 @@ async def mark_as_read(_, info, chat_id: str, messages: [int]): return { "error": None } - - -@subscription.source("newMessage") -async def message_generator(_, info: GraphQLResolveInfo): - print(f"[resolvers.messages] generator {info}") - auth: AuthCredentials = info.context["request"].auth - user_id = auth.user_id - try: - user_following_chats = await redis.execute("GET", f"chats_by_user/{user_id}") - if user_following_chats: - user_following_chats = list(json.loads(user_following_chats)) # chat ids - else: - user_following_chats = [] - tasks = [] - updated = {} - for chat_id in user_following_chats: - chat = await redis.execute("GET", f"chats/{chat_id}") - updated[chat_id] = chat['updatedAt'] - user_following_chats_sorted = sorted(user_following_chats, key=lambda x: updated[x], reverse=True) - - for chat_id in user_following_chats_sorted: - following_chat = Following('chat', chat_id) - await FollowingManager.register('chat', following_chat) - chat_task = following_chat.queue.get() - tasks.append(chat_task) - - while True: - msg = await asyncio.gather(*tasks) - yield msg - finally: - await FollowingManager.remove('chat', following_chat) - - -@subscription.field("newMessage") -@login_required -async def message_resolver(message: Message, info: Any): - return message 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/zine/following.py b/resolvers/zine/following.py index b2e039f1..99481571 100644 --- a/resolvers/zine/following.py +++ b/resolvers/zine/following.py @@ -1,6 +1,6 @@ import asyncio from base.orm import local_session -from base.resolvers import mutation, subscription +from base.resolvers import mutation from auth.authenticate import login_required from auth.credentials import AuthCredentials # from resolvers.community import community_follow, community_unfollow @@ -69,79 +69,3 @@ async def unfollow(_, info, what, slug): return {"error": str(e)} return {} - - -# by author and by topic -@subscription.source("newShout") -@login_required -async def shout_generator(_, info: GraphQLResolveInfo): - print(f"[resolvers.zine] shouts generator {info}") - auth: AuthCredentials = info.context["request"].auth - user_id = auth.user_id - try: - tasks = [] - - with local_session() as session: - - # notify new shout by followed authors - following_topics = session.query(TopicFollower).where(TopicFollower.follower == user_id).all() - - for topic_id in following_topics: - following_topic = Following('topic', topic_id) - await FollowingManager.register('topic', following_topic) - following_topic_task = following_topic.queue.get() - tasks.append(following_topic_task) - - # by followed topics - following_authors = session.query(AuthorFollower).where( - AuthorFollower.follower == user_id).all() - - for author_id in following_authors: - following_author = Following('author', author_id) - await FollowingManager.register('author', following_author) - following_author_task = following_author.queue.get() - tasks.append(following_author_task) - - # TODO: use communities - # by followed communities - # following_communities = session.query(CommunityFollower).where( - # CommunityFollower.follower == user_id).all() - - # for community_id in following_communities: - # following_community = Following('community', author_id) - # await FollowingManager.register('community', following_community) - # following_community_task = following_community.queue.get() - # tasks.append(following_community_task) - - while True: - shout = await asyncio.gather(*tasks) - yield shout - finally: - pass - - -@subscription.source("newReaction") -@login_required -async def reaction_generator(_, info): - print(f"[resolvers.zine] reactions generator {info}") - auth: AuthCredentials = info.context["request"].auth - user_id = auth.user_id - try: - with local_session() as session: - followings = session.query(ShoutReactionsFollower.shout).where( - ShoutReactionsFollower.follower == user_id).unique() - - # notify new reaction - - tasks = [] - for shout_id in followings: - following_shout = Following('shout', shout_id) - await FollowingManager.register('shout', following_shout) - following_author_task = following_shout.queue.get() - tasks.append(following_author_task) - - while True: - reaction = await asyncio.gather(*tasks) - yield reaction - finally: - pass diff --git a/resolvers/zine/load.py b/resolvers/zine/load.py index 3f91b92d..59a029fb 100644 --- a/resolvers/zine/load.py +++ b/resolvers/zine/load.py @@ -183,6 +183,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/zine/reactions.py b/resolvers/zine/reactions.py index 9ee2f098..1c132b69 100644 --- a/resolvers/zine/reactions.py +++ b/resolvers/zine/reactions.py @@ -10,6 +10,7 @@ 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.notifications.notification_service import notification_service def add_reaction_stat_columns(q): @@ -198,29 +199,32 @@ async def create_reaction(_, info, reaction): r = Reaction.create(**reaction) - # Proposal accepting logix - if r.replyTo is not None and \ - r.kind == ReactionKind.ACCEPT and \ - auth.user_id in shout.dict()['authors']: - replied_reaction = session.query(Reaction).where(Reaction.id == r.replyTo).first() - if replied_reaction and replied_reaction.kind == ReactionKind.PROPOSE: - if replied_reaction.range: - old_body = shout.body - start, end = replied_reaction.range.split(':') - start = int(start) - end = int(end) - new_body = old_body[:start] + replied_reaction.body + old_body[end:] - shout.body = new_body - # TODO: update git version control + # # Proposal accepting logix + # FIXME: will break if there will be 2 proposals, 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']: + # replied_reaction = session.query(Reaction).where(Reaction.id == r.replyTo).first() + # if replied_reaction and replied_reaction.kind == ReactionKind.PROPOSE: + # if replied_reaction.range: + # old_body = shout.body + # start, end = replied_reaction.range.split(':') + # start = int(start) + # end = int(end) + # new_body = old_body[:start] + replied_reaction.body + old_body[end:] + # shout.body = new_body + # # TODO: update git version control 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/schema.graphql b/schema.graphql index 0aac9afd..d21c4364 100644 --- a/schema.graphql +++ b/schema.graphql @@ -179,7 +179,6 @@ type Mutation { # user profile rateUser(slug: String!, value: Int!): Result! - updateOnlineStatus: Result! updateProfile(profile: ProfileInput!): Result! # topics @@ -196,6 +195,9 @@ type Mutation { # following follow(what: FollowingEntity!, slug: String!): Result! unfollow(what: FollowingEntity!, slug: String!): Result! + + markNotificationAsRead(notification_id: Int!): Result! + markAllNotificationsAsRead: Result! } input MessagesBy { @@ -249,7 +251,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 { # inbox @@ -286,14 +298,8 @@ type Query { topicsRandom(amount: Int): [Topic]! topicsByCommunity(community: String!): [Topic]! topicsByAuthor(author: String!): [Topic]! -} -############################################ Subscription - -type Subscription { - newMessage: Message # new messages in inbox - newShout: Shout # personal feed new shout - newReaction: Reaction # new reactions to notify + loadNotifications(params: NotificationsQueryParams!): NotificationsQueryResult! } ############################################ Entities diff --git a/server.py b/server.py index 9f0f9cc1..753c60ae 100644 --- a/server.py +++ b/server.py @@ -55,7 +55,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/inbox/presence.py b/services/inbox/presence.py deleted file mode 100644 index 2815c998..00000000 --- a/services/inbox/presence.py +++ /dev/null @@ -1,46 +0,0 @@ -# from base.exceptions import Unauthorized -from auth.tokenstorage import SessionToken -from base.redis import redis - - -async def set_online_status(user_id, status): - if user_id: - if status: - await redis.execute("SADD", "users-online", user_id) - else: - await redis.execute("SREM", "users-online", user_id) - - -async def on_connect(req, params): - if not isinstance(params, dict): - req.scope["connection_params"] = {} - return - token = params.get('token') - if not token: - # raise Unauthorized("Please login") - return { - "error": "Please login first" - } - else: - payload = await SessionToken.verify(token) - if payload and payload.user_id: - req.scope["user_id"] = payload.user_id - await set_online_status(payload.user_id, True) - - -async def on_disconnect(req): - user_id = req.scope.get("user_id") - await set_online_status(user_id, False) - - -# FIXME: not used yet -def context_value(request): - context = {} - print(f"[inbox.presense] request debug: {request}") - if request.scope["type"] == "websocket": - # request is an instance of WebSocket - context.update(request.scope["connection_params"]) - else: - context["token"] = request.META.get("authorization") - - return context diff --git a/services/inbox/sse.py b/services/inbox/sse.py deleted file mode 100644 index a73af840..00000000 --- a/services/inbox/sse.py +++ /dev/null @@ -1,22 +0,0 @@ -from sse_starlette.sse import EventSourceResponse -from starlette.requests import Request -from graphql.type import GraphQLResolveInfo -from resolvers.inbox.messages import message_generator -# from base.exceptions import Unauthorized - -# https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md - - -async def sse_messages(request: Request): - print(f'[SSE] request\n{request}\n') - info = GraphQLResolveInfo() - info.context['request'] = request.scope - user_id = request.scope['user'].user_id - if user_id: - event_generator = await message_generator(None, info) - return EventSourceResponse(event_generator) - else: - # raise Unauthorized("Please login") - return { - "error": "Please login first" - } 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 + } + } + } +} From de04c47120717f1ae29bd58f366fbf1b170c2f7d Mon Sep 17 00:00:00 2001 From: Ilya Y <75578537+ilya-bkv@users.noreply.github.com> Date: Tue, 10 Oct 2023 16:37:28 +0300 Subject: [PATCH 6/6] fixed layout name in migration ('audio' -> 'music') (#89) Co-authored-by: Igor Lobanov --- base/redis.py | 6 +++--- migration/tables/content_items.py | 2 +- resolvers/zine/load.py | 2 +- schema.graphql | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/base/redis.py b/base/redis.py index e1a4b903..52a49caa 100644 --- a/base/redis.py +++ b/base/redis.py @@ -25,17 +25,17 @@ class RedisCache: while not self._instance: await sleep(1) try: - print("[redis] " + command + ' ' + ' '.join(args)) + # print("[redis] " + command + ' ' + ' '.join(args)) return await self._instance.execute_command(command, *args, **kwargs) except Exception: pass async def lrange(self, key, start, stop): - print(f"[redis] LRANGE {key} {start} {stop}") + # print(f"[redis] LRANGE {key} {start} {stop}") return await self._instance.lrange(key, start, stop) async def mget(self, key, *keys): - print(f"[redis] MGET {key} {keys}") + # print(f"[redis] MGET {key} {keys}") return await self._instance.mget(key, *keys) diff --git a/migration/tables/content_items.py b/migration/tables/content_items.py index a170e0bf..8ac50224 100644 --- a/migration/tables/content_items.py +++ b/migration/tables/content_items.py @@ -17,7 +17,7 @@ ts = datetime.now(tz=timezone.utc) type2layout = { "Article": "article", "Literature": "literature", - "Music": "audio", + "Music": "music", "Video": "video", "Image": "image", } diff --git a/resolvers/zine/load.py b/resolvers/zine/load.py index 59a029fb..4619efa6 100644 --- a/resolvers/zine/load.py +++ b/resolvers/zine/load.py @@ -124,7 +124,7 @@ async def load_shouts_by(_, info, options): """ :param options: { filters: { - layout: 'audio', + layout: 'music', excludeLayout: 'article', visibility: "public", author: 'discours', diff --git a/schema.graphql b/schema.graphql index d21c4364..57147169 100644 --- a/schema.graphql +++ b/schema.graphql @@ -410,7 +410,7 @@ type Shout { lang: String community: String cover: String - layout: String # audio video literature image + layout: String # music video literature image versionOf: String # for translations and re-telling the same story visibility: String # owner authors community public updatedAt: DateTime