diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 93709d2c..e5fe46f7 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,9 @@ +[0.3.0] +- Shout.featured_at timestamp of the frontpage featuring event +- added proposal accepting logics +- schema modulized +- + [0.2.22] - added precommit hook - fmt diff --git a/main.py b/main.py index 7c8d7610..f0e2503b 100644 --- a/main.py +++ b/main.py @@ -13,16 +13,17 @@ from sentry_sdk.integrations.starlette import StarletteIntegration from starlette.applications import Starlette from starlette.routing import Route -from resolvers.webhook import WebhookEndpoint from services.rediscache import redis from services.schema import resolvers from services.search import search_service from services.viewed import ViewedStorage +from services.webhook import WebhookEndpoint from settings import DEV_SERVER_PID_FILE_NAME, MODE, SENTRY_DSN import_module('resolvers') -schema = make_executable_schema(load_schema_from_path('schemas/core.graphql'), resolvers) # type: ignore + +schema = make_executable_schema(load_schema_from_path('schema/'), resolvers) async def start_up(): diff --git a/orm/shout.py b/orm/shout.py index 93c1cd0d..4685b2d5 100644 --- a/orm/shout.py +++ b/orm/shout.py @@ -51,7 +51,7 @@ class ShoutCommunity(Base): class ShoutVisibility(Enumeration): AUTHORS = 'AUTHORS' COMMUNITY = 'COMMUNITY' - PUBLIC = 'PUBLIC' + FEATURED = 'FEATURED' class Shout(Base): @@ -60,6 +60,7 @@ class Shout(Base): created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) updated_at = Column(Integer, nullable=True) published_at = Column(Integer, nullable=True) + featured_at = Column(Integer, nullable=True) deleted_at = Column(Integer, nullable=True) created_by = Column(ForeignKey('author.id'), nullable=False) diff --git a/pyproject.toml b/pyproject.toml index fdfe1e64..f0faa2ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "discoursio-core" -version = "0.2.22" +version = "0.3.0" description = "core module for discours.io" authors = ["discoursio devteam"] license = "MIT" @@ -26,14 +26,17 @@ setuptools = "^69.0.2" pyright = "^1.1.341" pytest = "^7.4.2" black = { version = "^23.12.0", python = ">=3.12" } -ruff = { version = "^0.1.8", python = ">=3.12" } +ruff = { version = "^0.1.15", python = ">=3.12" } isort = "^5.13.2" - [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" +[tool.setuptools.dynamic] +version = {attr = "core.__version__"} +readme = {file = "README.md"} + [tool.ruff] line-length = 120 extend-select = [ diff --git a/resolvers/author.py b/resolvers/author.py index dce67d9e..223e9636 100644 --- a/resolvers/author.py +++ b/resolvers/author.py @@ -82,35 +82,6 @@ async def update_profile(_, info, profile): return {'error': None, 'author': author} -# for mutation.field("follow") -def author_follow(follower_id, slug): - try: - with local_session() as session: - author = session.query(Author).where(Author.slug == slug).one() - af = AuthorFollower(follower=follower_id, author=author.id) - session.add(af) - session.commit() - return True - except Exception: - return False - - -# for mutation.field("unfollow") -def author_unfollow(follower_id, slug): - with local_session() as session: - flw = ( - session.query(AuthorFollower) - .join(Author, Author.id == AuthorFollower.author) - .filter(and_(AuthorFollower.follower == follower_id, Author.slug == slug)) - .first() - ) - if flw: - session.delete(flw) - session.commit() - return True - return False - - # TODO: caching query @query.field('get_authors_all') async def get_authors_all(_, _info): diff --git a/resolvers/editor.py b/resolvers/editor.py index d611ceac..356d49ad 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -4,11 +4,14 @@ from sqlalchemy import and_, select from sqlalchemy.orm import joinedload from orm.author import Author +from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutTopic, ShoutVisibility from orm.topic import Topic -from resolvers.reaction import reactions_follow, reactions_unfollow +from resolvers.follower import reactions_follow, reactions_unfollow +from resolvers.rater import is_negative, is_positive from services.auth import login_required from services.db import local_session +from services.diff import apply_diff, get_diff from services.notify import notify_shout from services.schema import mutation, query from services.search import search_service @@ -187,7 +190,8 @@ async def update_shout( # noqa: C901 if not publish: await notify_shout(shout_dict, 'update') - if shout.visibility is ShoutVisibility.COMMUNITY.value or shout.visibility is ShoutVisibility.PUBLIC.value: + else: + await notify_shout(shout_dict, 'published') # search service indexing search_service.index(shout) @@ -217,3 +221,30 @@ async def delete_shout(_, info, shout_id): session.commit() await notify_shout(shout_dict, 'delete') return {} + + +def handle_proposing(session, r, shout): + if is_positive(r.kind): + # Proposal accepting logic + replied_reaction = session.query(Reaction).filter(Reaction.id == r.reply_to).first() + if replied_reaction and replied_reaction.kind is ReactionKind.PROPOSE.value and replied_reaction.quote: + # patch all the proposals' quotes + proposals = session.query(Reaction).filter(and_(Reaction.shout == r.shout, Reaction.kind == ReactionKind.PROPOSE.value)).all() + for proposal in proposals: + if proposal.quote: + proposal_diff = get_diff(shout.body, proposal.quote) + proposal_dict = proposal.dict() + proposal_dict['quote'] = apply_diff(replied_reaction.quote, proposal_diff) + Reaction.update(proposal, proposal_dict) + session.add(proposal) + + # patch shout's body + shout_dict = shout.dict() + shout_dict['body'] = replied_reaction.quote + Shout.update(shout, shout_dict) + session.add(shout) + session.commit() + + if is_negative(r.kind): + # TODO: rejection logic + pass diff --git a/resolvers/follower.py b/resolvers/follower.py index 5177f153..7eddddf8 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -2,15 +2,14 @@ import logging from typing import List from sqlalchemy.orm import aliased +from sqlalchemy.sql import and_ from orm.author import Author, AuthorFollower from orm.community import Community from orm.reaction import Reaction -from orm.shout import Shout +from orm.shout import Shout, ShoutReactionsFollower from orm.topic import Topic, TopicFollower -from resolvers.author import author_follow, author_unfollow from resolvers.community import community_follow, community_unfollow -from resolvers.reaction import reactions_follow, reactions_unfollow from resolvers.topic import topic_follow, topic_unfollow from services.auth import login_required from services.db import local_session @@ -140,3 +139,83 @@ def get_shout_followers(_, _info, slug: str = '', shout_id: int | None = None) - followers.append(r.created_by) return followers + + + +def reactions_follow(author_id, shout_id, auto=False): + try: + with local_session() as session: + shout = session.query(Shout).where(Shout.id == shout_id).one() + + following = ( + session.query(ShoutReactionsFollower) + .where( + and_( + ShoutReactionsFollower.follower == author_id, + ShoutReactionsFollower.shout == shout.id, + ) + ) + .first() + ) + + if not following: + following = ShoutReactionsFollower(follower=author_id, shout=shout.id, auto=auto) + session.add(following) + session.commit() + return True + except Exception: + return False + + +def reactions_unfollow(author_id, shout_id: int): + try: + with local_session() as session: + shout = session.query(Shout).where(Shout.id == shout_id).one() + + following = ( + session.query(ShoutReactionsFollower) + .where( + and_( + ShoutReactionsFollower.follower == author_id, + ShoutReactionsFollower.shout == shout.id, + ) + ) + .first() + ) + + if following: + session.delete(following) + session.commit() + return True + except Exception as ex: + logger.debug(ex) + return False + + +# for mutation.field("follow") +def author_follow(follower_id, slug): + try: + with local_session() as session: + author = session.query(Author).where(Author.slug == slug).one() + af = AuthorFollower(follower=follower_id, author=author.id) + session.add(af) + session.commit() + return True + except Exception: + return False + + +# for mutation.field("unfollow") +def author_unfollow(follower_id, slug): + with local_session() as session: + flw = ( + session.query(AuthorFollower) + .join(Author, Author.id == AuthorFollower.author) + .filter(and_(AuthorFollower.follower == follower_id, Author.slug == slug)) + .first() + ) + if flw: + session.delete(flw) + session.commit() + return True + return False diff --git a/resolvers/rater.py b/resolvers/rater.py new file mode 100644 index 00000000..4e349176 --- /dev/null +++ b/resolvers/rater.py @@ -0,0 +1,28 @@ + +from orm.reaction import ReactionKind + + +RATING_REACTIONS = [ + ReactionKind.LIKE.value, + ReactionKind.ACCEPT.value, + ReactionKind.AGREE.value, + ReactionKind.DISLIKE.value, + ReactionKind.REJECT.value, + ReactionKind.DISAGREE.value] + + + +def is_negative(x): + return x in [ + ReactionKind.ACCEPT.value, + ReactionKind.LIKE.value, + ReactionKind.PROOF.value, + ] + + +def is_positive(x): + return x in [ + ReactionKind.ACCEPT.value, + ReactionKind.LIKE.value, + ReactionKind.PROOF.value, + ] diff --git a/resolvers/reaction.py b/resolvers/reaction.py index dfce982d..3745ac76 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -8,7 +8,10 @@ from sqlalchemy.sql import union from orm.author import Author from orm.reaction import Reaction, ReactionKind -from orm.shout import Shout, ShoutReactionsFollower, ShoutVisibility +from orm.shout import Shout, ShoutVisibility +from resolvers.editor import handle_proposing +from resolvers.follower import reactions_follow +from resolvers.rater import RATING_REACTIONS, is_negative, is_positive from services.auth import add_user_role, login_required from services.db import local_session from services.notify import notify_reaction @@ -37,120 +40,52 @@ def add_stat_columns(q, aliased_reaction): return q -def reactions_follow(author_id, shout_id, auto=False): - try: - with local_session() as session: - shout = session.query(Shout).where(Shout.id == shout_id).one() - - following = ( - session.query(ShoutReactionsFollower) - .where( - and_( - ShoutReactionsFollower.follower == author_id, - ShoutReactionsFollower.shout == shout.id, - ) - ) - .first() - ) - - if not following: - following = ShoutReactionsFollower(follower=author_id, shout=shout.id, auto=auto) - session.add(following) - session.commit() - return True - except Exception: - return False - - -def reactions_unfollow(author_id, shout_id: int): - try: - with local_session() as session: - shout = session.query(Shout).where(Shout.id == shout_id).one() - - following = ( - session.query(ShoutReactionsFollower) - .where( - and_( - ShoutReactionsFollower.follower == author_id, - ShoutReactionsFollower.shout == shout.id, - ) - ) - .first() - ) - - if following: - session.delete(following) - session.commit() - return True - except Exception as ex: - logger.debug(ex) - return False - - -def is_published_author(session, author_id): - """checks if author has at least one publication""" +def is_featured_author(session, author_id): + """checks if author has at least one featured publication""" return ( session.query(Shout) .where(Shout.authors.any(id=author_id)) - .filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None))) + .filter(and_(Shout.featured_at.is_not(None), Shout.deleted_at.is_(None))) .count() > 0 ) - -def is_negative(x): - return x in [ - ReactionKind.ACCEPT.value, - ReactionKind.LIKE.value, - ReactionKind.PROOF.value, - ] - - -def is_positive(x): - return x in [ - ReactionKind.ACCEPT.value, - ReactionKind.LIKE.value, - ReactionKind.PROOF.value, - ] - - -def check_to_publish(session, approver_id, reaction): +def check_to_feature(session, approver_id, reaction): """set shout to public if publicated approvers amount > 4""" if not reaction.reply_to and is_positive(reaction.kind): - if is_published_author(session, approver_id): + if is_featured_author(session, approver_id): + approvers = [] + approvers.append(approver_id) # now count how many approvers are voted already - approvers_reactions = session.query(Reaction).where(Reaction.shout == reaction.shout).all() - approvers = [ - approver_id, - ] - for ar in approvers_reactions: - a = ar.created_by - if is_published_author(session, a): - approvers.append(a) + reacted_readers = session.query(Reaction).where(Reaction.shout == reaction.shout).all() + for reacted_reader in reacted_readers: + if is_featured_author(session, reacted_reader.id): + approvers.append(reacted_reader.id) if len(approvers) > 4: return True return False -def check_to_hide(session, reaction): - """hides any shout if 20% of reactions are negative""" +def check_to_unfeature(session, rejecter_id, reaction): + """unfeature any shout if 20% of reactions are negative""" if not reaction.reply_to and is_negative(reaction.kind): - # if is_published_author(author_id): - approvers_reactions = session.query(Reaction).where(Reaction.shout == reaction.shout).all() - rejects = 0 - for r in approvers_reactions: - if is_negative(r.kind): - rejects += 1 - if len(approvers_reactions) / rejects < 5: - return True + if is_featured_author(session, rejecter_id): + reactions = session.query(Reaction).where(and_(Reaction.shout == reaction.shout, Reaction.kind.in_(RATING_REACTIONS))).all() + rejects = 0 + for r in reactions: + approver = session.query(Author).filter(Author.id == r.created_by).first() + if is_featured_author(session, approver): + if is_negative(r.kind): + rejects += 1 + if len(reactions) / rejects < 5: + return True return False -async def set_published(session, shout_id, approver_id): +async def set_featured(session, shout_id): s = session.query(Shout).where(Shout.id == shout_id).first() - s.published_at = int(time.time()) - s.published_by = approver_id - Shout.update(s, {'visibility': ShoutVisibility.PUBLIC.value}) + s.featured_at = int(time.time()) + Shout.update(s, {'visibility': ShoutVisibility.FEATURED.value}) author = session.query(Author).filter(Author.id == s.created_by).first() if author: await add_user_role(str(author.user)) @@ -158,7 +93,7 @@ async def set_published(session, shout_id, approver_id): session.commit() -def set_hidden(session, shout_id): +def set_unfeatured(session, shout_id): s = session.query(Shout).where(Shout.id == shout_id).first() Shout.update(s, {'visibility': ShoutVisibility.COMMUNITY.value}) session.add(s) @@ -171,38 +106,24 @@ async def _create_reaction(session, shout, author, reaction): session.commit() rdict = r.dict() - # Proposal accepting logic - if rdict.get('reply_to'): - if r.kind in ['LIKE', 'APPROVE'] and author.id in shout.authors: - replied_reaction = session.query(Reaction).filter(Reaction.id == r.reply_to).first() - if replied_reaction: - if replied_reaction.kind is ReactionKind.PROPOSE.value: - 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_dict = shout.dict() - shout_dict['body'] = new_body - Shout.update(shout, shout_dict) - session.add(shout) - session.commit() + # collaborative editing + if rdict.get('reply_to') and r.kind in RATING_REACTIONS and author.id in shout.authors: + handle_proposing(session, r, shout) - # Self-regulation mechanics - if check_to_hide(session, r): - set_hidden(session, shout.id) - elif check_to_publish(session, author.id, r): - await set_published(session, shout.id, author.id) + # self-regultaion mechanics + if check_to_unfeature(session, author.id, r): + set_unfeatured(session, shout.id) + elif check_to_feature(session, author.id, r): + await set_featured(session, shout.id) - # Reactions auto-following + # reactions auto-following reactions_follow(author.id, reaction['shout'], True) rdict['shout'] = shout.dict() rdict['created_by'] = author.dict() rdict['stat'] = {'commented': 0, 'reacted': 0, 'rating': 0} - # Notifications call + # notifications call await notify_reaction(rdict, 'create') return rdict @@ -220,14 +141,14 @@ async def create_reaction(_, info, reaction): try: with local_session() as session: - shout = session.query(Shout).filter(Shout.id == shout_id).one() + shout = session.query(Shout).filter(Shout.id == shout_id).first() author = session.query(Author).filter(Author.user == user_id).first() if shout and author: reaction['created_by'] = author.id kind = reaction.get('kind') shout_id = shout.id - if not kind and reaction.get('body'): + if not kind and isinstance(reaction.get('body'), str): kind = ReactionKind.COMMENT.value if not kind: diff --git a/resolvers/reader.py b/resolvers/reader.py index d142ca44..ebc619c3 100644 --- a/resolvers/reader.py +++ b/resolvers/reader.py @@ -26,9 +26,9 @@ def apply_filters(q, filters, author_id=None): if filters.get('reacted') and author_id: q.join(Reaction, Reaction.created_by == author_id) - by_published = filters.get('published') - if by_published: - q = q.filter(Shout.visibility == ShoutVisibility.PUBLIC.value) + by_featured = filters.get('featured') + if by_featured: + q = q.filter(Shout.visibility == ShoutVisibility.FEATURED.value) by_layouts = filters.get('layouts') if by_layouts: q = q.filter(Shout.layout.in_(by_layouts)) @@ -114,7 +114,7 @@ async def load_shouts_by(_, _info, options): filters: { layouts: ['audio', 'video', ..], reacted: True, - published: True, // filter published-only + featured: True, // filter featured-only author: 'discours', topic: 'culture', after: 1234567 // unixtime @@ -143,13 +143,14 @@ async def load_shouts_by(_, _info, options): q = add_stat_columns(q, aliased_reaction) # filters - q = apply_filters(q, options.get('filters', {})) + filters = options.get('filters', {}) + q = apply_filters(q, filters) # group q = q.group_by(Shout.id) # order - order_by = options.get('order_by', Shout.published_at) + order_by = options.get('order_by', Shout.featured_at if filters.get('featured') else Shout.published_at) query_order_by = desc(order_by) if options.get('order_by_desc', True) else asc(order_by) q = q.order_by(nulls_last(query_order_by)) @@ -274,9 +275,10 @@ async def load_shouts_feed(_, info, options): aliased_reaction = aliased(Reaction) q = add_stat_columns(q, aliased_reaction) - q = apply_filters(q, options.get('filters', {}), reader.id) + filters = options.get('filters', {}) + q = apply_filters(q, filters, reader.id) - order_by = options.get('order_by', Shout.published_at) + order_by = options.get('order_by', Shout.featured_at if filters.get('featured') else Shout.published_at) query_order_by = desc(order_by) if options.get('order_by_desc', True) else asc(order_by) offset = options.get('offset', 0) @@ -464,7 +466,7 @@ async def load_shouts_random_topic(_, info, limit: int = 10): .filter( and_( Shout.deleted_at.is_(None), - Shout.visibility == ShoutVisibility.PUBLIC.value, + Shout.visibility == ShoutVisibility.FEATURED.value, Shout.topics.any(slug=topic.slug), ) ) diff --git a/schema/enum.graphql b/schema/enum.graphql new file mode 100644 index 00000000..a219ae00 --- /dev/null +++ b/schema/enum.graphql @@ -0,0 +1,46 @@ +enum ShoutVisibility { + AUTHORS + COMMUNITY + FEATURED +} + +enum ReactionStatus { + NEW + UPDATED + CHANGED + EXPLAINED + DELETED +} + +enum ReactionKind { + + # collabs + AGREE + DISAGREE + ASK + PROPOSE + PROOF + DISPROOF + ACCEPT + REJECT + + # public feed + QUOTE + COMMENT + LIKE + DISLIKE + +} + +enum FollowingEntity { + TOPIC + AUTHOR + COMMUNITY + REACTIONS +} + +enum InviteStatus { + PENDING + ACCEPTED + REJECTED +} diff --git a/schema/input.graphql b/schema/input.graphql new file mode 100644 index 00000000..d3ae80ea --- /dev/null +++ b/schema/input.graphql @@ -0,0 +1,80 @@ +input ShoutInput { + slug: String + title: String + body: String + lead: String + description: String + layout: String + media: String + authors: [String] + topics: [TopicInput] + community: Int + subtitle: String + cover: String +} + +input ProfileInput { + slug: String + name: String + pic: String + links: [String] + bio: String + about: String +} + +input TopicInput { + id: Int + slug: String! + title: String + body: String + pic: String +} + +input ReactionInput { + kind: ReactionKind! + shout: Int! + quote: String + body: String + reply_to: Int +} + +input AuthorsBy { + last_seen: Int + created_at: Int + slug: String + name: String + topic: String + order: String + after: Int + stat: String +} + +input LoadShoutsFilters { + topic: String + author: String + layouts: [String] + featured: Boolean + reacted: Boolean + after: Int +} + +input LoadShoutsOptions { + filters: LoadShoutsFilters + with_author_captions: Boolean + limit: Int! + random_limit: Int + offset: Int + order_by: String + order_by_desc: Boolean +} + +input ReactionBy { + shout: String + shouts: [String] + search: String + comment: Boolean + topic: String + created_by: Int + after: Int + sort: String +} diff --git a/schema/mutation.graphql b/schema/mutation.graphql new file mode 100644 index 00000000..ef056937 --- /dev/null +++ b/schema/mutation.graphql @@ -0,0 +1,31 @@ +type Mutation { + # author + rate_author(rated_slug: String!, value: Int!): Result! + update_profile(profile: ProfileInput!): Result! + + # editor + create_shout(inp: ShoutInput!): Result! + update_shout(shout_id: Int!, shout_input: ShoutInput, publish: Boolean): Result! + delete_shout(shout_id: Int!): Result! + + # follower + follow(what: FollowingEntity!, slug: String!): Result! + unfollow(what: FollowingEntity!, slug: String!): Result! + + # topic + create_topic(input: TopicInput!): Result! + update_topic(input: TopicInput!): Result! + delete_topic(slug: String!): Result! + + # reaction + create_reaction(reaction: ReactionInput!): Result! + update_reaction(id: Int!, reaction: ReactionInput!): Result! + delete_reaction(reaction_id: Int!): Result! + + # collab + create_invite(slug: String, author_id: Int): Result! + remove_author(slug: String, author_id: Int): Result! + remove_invite(invite_id: Int!): Result! + accept_invite(invite_id: Int!): Result! + reject_invite(invite_id: Int!): Result! +} diff --git a/schema/query.graphql b/schema/query.graphql new file mode 100644 index 00000000..93ca2220 --- /dev/null +++ b/schema/query.graphql @@ -0,0 +1,41 @@ +type Query { + # author + get_author(slug: String, author_id: Int): Author + get_author_id(user: String!): Author + get_authors_all: [Author] + get_author_followers(slug: String, user: String, author_id: Int): [Author] + get_author_followed(slug: String, user: String, author_id: Int): [Author] + load_authors_by(by: AuthorsBy!, limit: Int, offset: Int): [Author] + + # community + get_community: Community + get_communities_all: [Community] + + # editor + get_shouts_drafts: [Shout] + + # follower + get_my_followed: Result # { authors topics communities } + get_shout_followers(slug: String, shout_id: Int): [Author] + + # reaction + load_reactions_by(by: ReactionBy!, limit: Int, offset: Int): [Reaction] + + # reader + get_shout(slug: String, shout_id: Int): Shout + load_shouts_followed(follower_id: Int!, limit: Int, offset: Int): [Shout] # userReactedShouts + load_shouts_by(options: LoadShoutsOptions): [Shout] + load_shouts_search(text: String!, limit: Int, offset: Int): [SearchResult] + load_shouts_feed(options: LoadShoutsOptions): [Shout] + load_shouts_unrated(limit: Int, offset: Int): [Shout] + load_shouts_random_top(options: LoadShoutsOptions): [Shout] + load_shouts_random_topic(limit: Int!): Result! # { topic shouts } + load_shouts_drafts: [Shout] + + # topic + get_topic(slug: String!): Topic + get_topics_all: [Topic] + get_topics_random(amount: Int): [Topic] + get_topics_by_author(slug: String, user: String, author_id: Int): [Topic] + get_topics_by_community(slug: String, community_id: Int): [Topic] +} diff --git a/schema/type.graphql b/schema/type.graphql new file mode 100644 index 00000000..3b4c01ef --- /dev/null +++ b/schema/type.graphql @@ -0,0 +1,182 @@ +type AuthorFollowings { + unread: Int + topics: [String] + authors: [String] + reactions: [Int] + communities: [String] +} + +type AuthorStat { + shouts: Int + followings: Int + followers: Int + rating: Int + rating_shouts: Int + rating_comments: Int + commented: Int + viewed: Int +} + +type Author { + id: Int! + user: String! # user.id + slug: String! # user.nickname + name: String # user.preferred_username + pic: String + bio: String + about: String + links: [String] + created_at: Int + last_seen: Int + updated_at: Int + deleted_at: Int + seo: String + # synthetic + stat: AuthorStat # ratings inside + communities: [Community] +} + +type ReactionUpdating { + error: String + status: ReactionStatus + reaction: Reaction +} + +type Rating { + rater: String! + value: Int! +} + +type Reaction { + id: Int! + shout: Shout! + created_at: Int! + created_by: Author! + updated_at: Int + deleted_at: Int + deleted_by: Author + range: String + kind: ReactionKind! + body: String + reply_to: Int + stat: Stat + oid: String + # old_thread: String +} + +type Shout { + id: Int! + slug: String! + body: String! + lead: String + description: String + main_topic: String + topics: [Topic] + created_by: Author! + updated_by: Author + deleted_by: Author + authors: [Author] + communities: [Community] + title: String! + subtitle: String + lang: String + community: String + cover: String + cover_caption: String + layout: String! + visibility: String + + created_at: Int! + updated_at: Int + published_at: Int + featured_at: Int + deleted_at: Int + + version_of: Shout # TODO: use version_of somewhere + + media: String + stat: Stat + score: Float +} + +type Stat { + viewed: Int + reacted: Int + rating: Int + commented: Int + ranking: Int +} + +type Community { + id: Int! + slug: String! + name: String! + desc: String + pic: String! + created_at: Int! + created_by: Author! +} + +type Collection { + id: Int! + slug: String! + title: String! + desc: String + amount: Int + published_at: Int + created_at: Int! + created_by: Author! +} + +type TopicStat { + shouts: Int! + followers: Int! + authors: Int! + viewed: Int +} + +type Topic { + id: Int! + slug: String! + title: String + body: String + pic: String + stat: TopicStat + oid: String +} + +# output type + +type Result { + error: String + slugs: [String] + shout: Shout + shouts: [Shout] + author: Author + authors: [Author] + reaction: Reaction + reactions: [Reaction] + topic: Topic + topics: [Topic] + community: Community + communities: [Community] +} + +type SearchResult { + slug: String! + title: String! + cover: String + main_topic: String + created_at: Int + authors: [Author] + topics: [Topic] + score: Float! +} + +type Invite { + id: Int! + inviter_id: Int! + author_id: Int! + shout_id: Int! + status: InviteStatus +} diff --git a/schemas/auth.graphql b/schemas/auth.graphql deleted file mode 100644 index daad5061..00000000 --- a/schemas/auth.graphql +++ /dev/null @@ -1,242 +0,0 @@ -scalar Dict - -type ConfigType { - authorizerURL: String! - redirectURL: String! - clientID: String! - extraHeaders: [Header] -} - -type User { - id: ID! - email: String! - preferred_username: String! - email_verified: Boolean! - signup_methods: String! - given_name: String - family_name: String - middle_name: String - nickname: String - picture: String - gender: String - birthdate: String - phone_number: String - phone_number_verified: Boolean - roles: [String] - created_at: Int! - updated_at: Int! - is_multi_factor_auth_enabled: Boolean -} - -type AuthToken { - message: String - access_token: String! - expires_in: Int! - id_token: String! - refresh_token: String - user: User - should_show_email_otp_screen: Boolean - should_show_mobile_otp_screen: Boolean -} - -type Response { - message: String! -} - -type Header { - key: String! - value: String! -} - -input HeaderIn { - key: String! - value: String! -} - -input LoginInput { - email: String! - password: String! - roles: [String] - scope: [String] - state: String -} - -input SignupInput { - email: String! - password: String! - confirm_password: String! - given_name: String - family_name: String - middle_name: String - nickname: String - picture: String - gender: String - birthdate: String - phone_number: String - roles: [String] - scope: [String] - redirect_uri: String - is_multi_factor_auth_enabled: Boolean - state: String -} - -input MagicLinkLoginInput { - email: String! - roles: [String] - scopes: [String] - state: String - redirect_uri: String -} - -input VerifyEmailInput { - token: String! - state: String -} - -input VerifyOtpInput { - email: String - phone_number: String - otp: String! - state: String -} - -input ResendOtpInput { - email: String - phone_number: String -} - -input GraphqlQueryInput { - query: String! - variables: Dict - headers: [HeaderIn] -} - - -type MetaData { - version: String! - client_id: String! - is_google_login_enabled: Boolean! - is_facebook_login_enabled: Boolean! - is_github_login_enabled: Boolean! - is_linkedin_login_enabled: Boolean! - is_apple_login_enabled: Boolean! - is_twitter_login_enabled: Boolean! - is_microsoft_login_enabled: Boolean! - is_email_verification_enabled: Boolean! - is_basic_authentication_enabled: Boolean! - is_magic_link_login_enabled: Boolean! - is_sign_up_enabled: Boolean! - is_strong_password_enabled: Boolean! -} - -input UpdateProfileInput { - old_password: String - new_password: String - confirm_new_password: String - email: String - given_name: String - family_name: String - middle_name: String - nickname: String - gender: String - birthdate: String - phone_number: String - picture: String - is_multi_factor_auth_enabled: Boolean -} - -input ForgotPasswordInput { - email: String! - state: String - redirect_uri: String -} - -input ResetPasswordInput { - token: String! - password: String! - confirm_password: String! -} - -input SessionQueryInput { - roles: [String] -} - -input IsValidJWTQueryInput { - jwt: String! - roles: [String] -} - -type ValidJWTResponse { - valid: String! - message: String! -} - -enum OAuthProviders { - Apple - Github - Google - Facebook - LinkedIn -} - -enum ResponseTypes { - Code - Token -} - -input AuthorizeInput { - response_type: ResponseTypes! - use_refresh_token: Boolean - response_mode: String -} - -type AuthorizeResponse { - state: String! - code: String - error: String - error_description: String -} - -input RevokeTokenInput { - refresh_token: String! -} - -input GetTokenInput { - code: String - grant_type: String - refresh_token: String -} - -type GetTokenResponse { - access_token: String! - expires_in: Int! - id_token: String! - refresh_token: String -} - -input ValidateJWTTokenInput { - token_type: TokenType! - token: String! - roles: [String] -} - -type ValidateJWTTokenResponse { - is_valid: Boolean! - claims: Dict -} - -input ValidateSessionInput { - cookie: String - roles: [String] -} - -type ValidateSessionResponse { - is_valid: Boolean! - user: User -} - -enum TokenType { - access_token - id_token - refresh_token -} \ No newline at end of file diff --git a/schemas/core.graphql b/schemas/core.graphql deleted file mode 100644 index c2d1514b..00000000 --- a/schemas/core.graphql +++ /dev/null @@ -1,385 +0,0 @@ -enum ShoutVisibility { - AUTHORS - COMMUNITY - PUBLIC -} - -enum ReactionStatus { - NEW - UPDATED - CHANGED - EXPLAINED - DELETED -} - -enum ReactionKind { - # collabs - AGREE - DISAGREE - ASK - PROPOSE - PROOF - DISPROOF - ACCEPT - REJECT - # public feed - QUOTE - COMMENT - LIKE - DISLIKE -} - -enum FollowingEntity { - TOPIC - AUTHOR - COMMUNITY - REACTIONS -} - -enum InviteStatus { - PENDING - ACCEPTED - REJECTED -} - -# Типы - -type AuthorFollowings { - unread: Int - topics: [String] - authors: [String] - reactions: [Int] - communities: [String] -} - -type AuthorStat { - shouts: Int - followings: Int - followers: Int - rating: Int - rating_shouts: Int - rating_comments: Int - commented: Int - viewed: Int -} - -type Author { - id: Int! - user: String! # user.id - slug: String! # user.nickname - name: String # user.preferred_username - pic: String - bio: String - about: String - links: [String] - created_at: Int - last_seen: Int - updated_at: Int - deleted_at: Int - seo: String - # synthetic - stat: AuthorStat # ratings inside - communities: [Community] -} - -type ReactionUpdating { - error: String - status: ReactionStatus - reaction: Reaction -} - -type Rating { - rater: String! - value: Int! -} - -type Reaction { - id: Int! - shout: Shout! - created_at: Int! - created_by: Author! - updated_at: Int - deleted_at: Int - deleted_by: Author - range: String - kind: ReactionKind! - body: String - reply_to: Int - stat: Stat - oid: String - # old_thread: String -} - -type Shout { - id: Int! - slug: String! - body: String! - lead: String - description: String - created_at: Int! - main_topic: String - topics: [Topic] - created_by: Author! - updated_by: Author - deleted_by: Author - authors: [Author] - communities: [Community] - title: String! - subtitle: String - lang: String - community: String - cover: String - cover_caption: String - layout: String! - version_of: String - visibility: String - updated_at: Int - deleted_at: Int - published_at: Int - media: String - stat: Stat - score: Float -} - -type Stat { - viewed: Int - reacted: Int - rating: Int - commented: Int - ranking: Int -} - -type Community { - id: Int! - slug: String! - name: String! - desc: String - pic: String! - created_at: Int! - created_by: Author! -} - -type Collection { - id: Int! - slug: String! - title: String! - desc: String - amount: Int - published_at: Int - created_at: Int! - created_by: Author! -} - -type TopicStat { - shouts: Int! - followers: Int! - authors: Int! - viewed: Int -} - -type Topic { - id: Int! - slug: String! - title: String - body: String - pic: String - stat: TopicStat - oid: String -} - -type Invite { - id: Int! - inviter_id: Int! - author_id: Int! - shout_id: Int! - status: InviteStatus -} - -# Входные типы - -input ShoutInput { - slug: String - title: String - body: String - lead: String - description: String - layout: String - media: String - authors: [String] - topics: [TopicInput] - community: Int - subtitle: String - cover: String -} - -input ProfileInput { - slug: String - name: String - pic: String - links: [String] - bio: String - about: String -} - -input TopicInput { - id: Int - slug: String! - title: String - body: String - pic: String -} - -input ReactionInput { - kind: ReactionKind! - shout: Int! - quote: String - body: String - reply_to: Int -} - -input AuthorsBy { - last_seen: Int - created_at: Int - slug: String - name: String - topic: String - order: String - after: Int - stat: String -} - -input LoadShoutsFilters { - topic: String - author: String - layouts: [String] - published: Boolean - after: Int - reacted: Boolean -} - -input LoadShoutsOptions { - filters: LoadShoutsFilters - with_author_captions: Boolean - limit: Int! - random_limit: Int - offset: Int - order_by: String - order_by_desc: Boolean -} - -input ReactionBy { - shout: String - shouts: [String] - search: String - comment: Boolean - topic: String - created_by: Int - after: Int - sort: String -} - -# output type - -type Result { - error: String - slugs: [String] - shout: Shout - shouts: [Shout] - author: Author - authors: [Author] - reaction: Reaction - reactions: [Reaction] - topic: Topic - topics: [Topic] - community: Community - communities: [Community] -} - -type SearchResult { - slug: String! - title: String! - cover: String - main_topic: String - created_at: Int - authors: [Author] - topics: [Topic] - score: Float! -} - -# Мутации - -type Mutation { - # author - rate_author(rated_slug: String!, value: Int!): Result! - update_profile(profile: ProfileInput!): Result! - - # editor - create_shout(inp: ShoutInput!): Result! - update_shout(shout_id: Int!, shout_input: ShoutInput, publish: Boolean): Result! - delete_shout(shout_id: Int!): Result! - - # follower - follow(what: FollowingEntity!, slug: String!): Result! - unfollow(what: FollowingEntity!, slug: String!): Result! - - # topic - create_topic(input: TopicInput!): Result! - update_topic(input: TopicInput!): Result! - delete_topic(slug: String!): Result! - - # reaction - create_reaction(reaction: ReactionInput!): Result! - update_reaction(id: Int!, reaction: ReactionInput!): Result! - delete_reaction(reaction_id: Int!): Result! - - # collab - create_invite(slug: String, author_id: Int): Result! - remove_author(slug: String, author_id: Int): Result! - remove_invite(invite_id: Int!): Result! - accept_invite(invite_id: Int!): Result! - reject_invite(invite_id: Int!): Result! -} - -# Запросы - -type Query { - # author - get_author(slug: String, author_id: Int): Author - get_author_id(user: String!): Author - get_authors_all: [Author] - get_author_followers(slug: String, user: String, author_id: Int): [Author] - get_author_followed(slug: String, user: String, author_id: Int): [Author] - load_authors_by(by: AuthorsBy!, limit: Int, offset: Int): [Author] - - # community - get_community: Community - get_communities_all: [Community] - - # editor - get_shouts_drafts: [Shout] - - # follower - get_my_followed: Result # { authors topics communities } - get_shout_followers(slug: String, shout_id: Int): [Author] - - # reaction - load_reactions_by(by: ReactionBy!, limit: Int, offset: Int): [Reaction] - - # reader - get_shout(slug: String, shout_id: Int): Shout - load_shouts_followed(follower_id: Int!, limit: Int, offset: Int): [Shout] # userReactedShouts - load_shouts_by(options: LoadShoutsOptions): [Shout] - load_shouts_search(text: String!, limit: Int, offset: Int): [SearchResult] - load_shouts_feed(options: LoadShoutsOptions): [Shout] - load_shouts_unrated(limit: Int, offset: Int): [Shout] - load_shouts_random_top(options: LoadShoutsOptions): [Shout] - load_shouts_random_topic(limit: Int!): Result! # { topic shouts } - load_shouts_drafts: [Shout] - - # topic - get_topic(slug: String!): Topic - get_topics_all: [Topic] - get_topics_random(amount: Int): [Topic] - get_topics_by_author(slug: String, user: String, author_id: Int): [Topic] - get_topics_by_community(slug: String, community_id: Int): [Topic] -} diff --git a/services/diff.py b/services/diff.py new file mode 100644 index 00000000..44ac353b --- /dev/null +++ b/services/diff.py @@ -0,0 +1,46 @@ +import re +from difflib import ndiff + + +def get_diff(original, modified): + """ + Get the difference between two strings using difflib. + + Parameters: + - original: The original string. + - modified: The modified string. + + Returns: + A list of differences. + """ + diff = list(ndiff(original.split(), modified.split())) + return diff + +def apply_diff(original, diff): + """ + Apply the difference to the original string. + + Parameters: + - original: The original string. + - diff: The difference obtained from get_diff function. + + Returns: + The modified string. + """ + result = [] + pattern = re.compile(r'^(\+|-) ') + + for line in diff: + match = pattern.match(line) + if match: + op = match.group(1) + content = line[2:] + if op == '+': + result.append(content) + elif op == '-': + # Ignore deleted lines + pass + else: + result.append(line) + + return ' '.join(result) diff --git a/resolvers/webhook.py b/services/webhook.py similarity index 100% rename from resolvers/webhook.py rename to services/webhook.py