featured-id-patch
All checks were successful
Deploy to core / deploy (push) Successful in 1m40s

This commit is contained in:
Untone 2024-02-02 15:03:44 +03:00
parent bd5f910f8c
commit c00361b2ec
19 changed files with 640 additions and 798 deletions

View File

@ -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] [0.2.22]
- added precommit hook - added precommit hook
- fmt - fmt

View File

@ -13,16 +13,17 @@ from sentry_sdk.integrations.starlette import StarletteIntegration
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.routing import Route from starlette.routing import Route
from resolvers.webhook import WebhookEndpoint
from services.rediscache import redis from services.rediscache import redis
from services.schema import resolvers from services.schema import resolvers
from services.search import search_service from services.search import search_service
from services.viewed import ViewedStorage from services.viewed import ViewedStorage
from services.webhook import WebhookEndpoint
from settings import DEV_SERVER_PID_FILE_NAME, MODE, SENTRY_DSN from settings import DEV_SERVER_PID_FILE_NAME, MODE, SENTRY_DSN
import_module('resolvers') 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(): async def start_up():

View File

@ -51,7 +51,7 @@ class ShoutCommunity(Base):
class ShoutVisibility(Enumeration): class ShoutVisibility(Enumeration):
AUTHORS = 'AUTHORS' AUTHORS = 'AUTHORS'
COMMUNITY = 'COMMUNITY' COMMUNITY = 'COMMUNITY'
PUBLIC = 'PUBLIC' FEATURED = 'FEATURED'
class Shout(Base): class Shout(Base):
@ -60,6 +60,7 @@ class Shout(Base):
created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=True) updated_at = Column(Integer, nullable=True)
published_at = Column(Integer, nullable=True) published_at = Column(Integer, nullable=True)
featured_at = Column(Integer, nullable=True)
deleted_at = Column(Integer, nullable=True) deleted_at = Column(Integer, nullable=True)
created_by = Column(ForeignKey('author.id'), nullable=False) created_by = Column(ForeignKey('author.id'), nullable=False)

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "discoursio-core" name = "discoursio-core"
version = "0.2.22" version = "0.3.0"
description = "core module for discours.io" description = "core module for discours.io"
authors = ["discoursio devteam"] authors = ["discoursio devteam"]
license = "MIT" license = "MIT"
@ -26,14 +26,17 @@ setuptools = "^69.0.2"
pyright = "^1.1.341" pyright = "^1.1.341"
pytest = "^7.4.2" pytest = "^7.4.2"
black = { version = "^23.12.0", python = ">=3.12" } 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" isort = "^5.13.2"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.setuptools.dynamic]
version = {attr = "core.__version__"}
readme = {file = "README.md"}
[tool.ruff] [tool.ruff]
line-length = 120 line-length = 120
extend-select = [ extend-select = [

View File

@ -82,35 +82,6 @@ async def update_profile(_, info, profile):
return {'error': None, 'author': author} 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 # TODO: caching query
@query.field('get_authors_all') @query.field('get_authors_all')
async def get_authors_all(_, _info): async def get_authors_all(_, _info):

View File

@ -4,11 +4,14 @@ from sqlalchemy import and_, select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from orm.author import Author from orm.author import Author
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic, ShoutVisibility from orm.shout import Shout, ShoutAuthor, ShoutTopic, ShoutVisibility
from orm.topic import Topic 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.auth import login_required
from services.db import local_session from services.db import local_session
from services.diff import apply_diff, get_diff
from services.notify import notify_shout from services.notify import notify_shout
from services.schema import mutation, query from services.schema import mutation, query
from services.search import search_service from services.search import search_service
@ -187,7 +190,8 @@ async def update_shout( # noqa: C901
if not publish: if not publish:
await notify_shout(shout_dict, 'update') 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 indexing
search_service.index(shout) search_service.index(shout)
@ -217,3 +221,30 @@ async def delete_shout(_, info, shout_id):
session.commit() session.commit()
await notify_shout(shout_dict, 'delete') await notify_shout(shout_dict, 'delete')
return {} 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

View File

@ -2,15 +2,14 @@ import logging
from typing import List from typing import List
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.sql import and_
from orm.author import Author, AuthorFollower from orm.author import Author, AuthorFollower
from orm.community import Community from orm.community import Community
from orm.reaction import Reaction from orm.reaction import Reaction
from orm.shout import Shout from orm.shout import Shout, ShoutReactionsFollower
from orm.topic import Topic, TopicFollower from orm.topic import Topic, TopicFollower
from resolvers.author import author_follow, author_unfollow
from resolvers.community import community_follow, community_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 resolvers.topic import topic_follow, topic_unfollow
from services.auth import login_required from services.auth import login_required
from services.db import local_session 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) followers.append(r.created_by)
return followers 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

28
resolvers/rater.py Normal file
View File

@ -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,
]

View File

@ -8,7 +8,10 @@ from sqlalchemy.sql import union
from orm.author import Author from orm.author import Author
from orm.reaction import Reaction, ReactionKind 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.auth import add_user_role, login_required
from services.db import local_session from services.db import local_session
from services.notify import notify_reaction from services.notify import notify_reaction
@ -37,120 +40,52 @@ def add_stat_columns(q, aliased_reaction):
return q return q
def reactions_follow(author_id, shout_id, auto=False): def is_featured_author(session, author_id):
try: """checks if author has at least one featured publication"""
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"""
return ( return (
session.query(Shout) session.query(Shout)
.where(Shout.authors.any(id=author_id)) .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() .count()
> 0 > 0
) )
def check_to_feature(session, approver_id, reaction):
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):
"""set shout to public if publicated approvers amount > 4""" """set shout to public if publicated approvers amount > 4"""
if not reaction.reply_to and is_positive(reaction.kind): 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 # now count how many approvers are voted already
approvers_reactions = session.query(Reaction).where(Reaction.shout == reaction.shout).all() reacted_readers = session.query(Reaction).where(Reaction.shout == reaction.shout).all()
approvers = [ for reacted_reader in reacted_readers:
approver_id, if is_featured_author(session, reacted_reader.id):
] approvers.append(reacted_reader.id)
for ar in approvers_reactions:
a = ar.created_by
if is_published_author(session, a):
approvers.append(a)
if len(approvers) > 4: if len(approvers) > 4:
return True return True
return False return False
def check_to_hide(session, reaction): def check_to_unfeature(session, rejecter_id, reaction):
"""hides any shout if 20% of reactions are negative""" """unfeature any shout if 20% of reactions are negative"""
if not reaction.reply_to and is_negative(reaction.kind): if not reaction.reply_to and is_negative(reaction.kind):
# if is_published_author(author_id): if is_featured_author(session, rejecter_id):
approvers_reactions = session.query(Reaction).where(Reaction.shout == reaction.shout).all() reactions = session.query(Reaction).where(and_(Reaction.shout == reaction.shout, Reaction.kind.in_(RATING_REACTIONS))).all()
rejects = 0 rejects = 0
for r in approvers_reactions: for r in reactions:
if is_negative(r.kind): approver = session.query(Author).filter(Author.id == r.created_by).first()
rejects += 1 if is_featured_author(session, approver):
if len(approvers_reactions) / rejects < 5: if is_negative(r.kind):
return True rejects += 1
if len(reactions) / rejects < 5:
return True
return False 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 = session.query(Shout).where(Shout.id == shout_id).first()
s.published_at = int(time.time()) s.featured_at = int(time.time())
s.published_by = approver_id Shout.update(s, {'visibility': ShoutVisibility.FEATURED.value})
Shout.update(s, {'visibility': ShoutVisibility.PUBLIC.value})
author = session.query(Author).filter(Author.id == s.created_by).first() author = session.query(Author).filter(Author.id == s.created_by).first()
if author: if author:
await add_user_role(str(author.user)) await add_user_role(str(author.user))
@ -158,7 +93,7 @@ async def set_published(session, shout_id, approver_id):
session.commit() session.commit()
def set_hidden(session, shout_id): def set_unfeatured(session, shout_id):
s = session.query(Shout).where(Shout.id == shout_id).first() s = session.query(Shout).where(Shout.id == shout_id).first()
Shout.update(s, {'visibility': ShoutVisibility.COMMUNITY.value}) Shout.update(s, {'visibility': ShoutVisibility.COMMUNITY.value})
session.add(s) session.add(s)
@ -171,38 +106,24 @@ async def _create_reaction(session, shout, author, reaction):
session.commit() session.commit()
rdict = r.dict() rdict = r.dict()
# Proposal accepting logic # collaborative editing
if rdict.get('reply_to'): if rdict.get('reply_to') and r.kind in RATING_REACTIONS and author.id in shout.authors:
if r.kind in ['LIKE', 'APPROVE'] and author.id in shout.authors: handle_proposing(session, r, shout)
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()
# Self-regulation mechanics # self-regultaion mechanics
if check_to_hide(session, r): if check_to_unfeature(session, author.id, r):
set_hidden(session, shout.id) set_unfeatured(session, shout.id)
elif check_to_publish(session, author.id, r): elif check_to_feature(session, author.id, r):
await set_published(session, shout.id, author.id) await set_featured(session, shout.id)
# Reactions auto-following # reactions auto-following
reactions_follow(author.id, reaction['shout'], True) reactions_follow(author.id, reaction['shout'], True)
rdict['shout'] = shout.dict() rdict['shout'] = shout.dict()
rdict['created_by'] = author.dict() rdict['created_by'] = author.dict()
rdict['stat'] = {'commented': 0, 'reacted': 0, 'rating': 0} rdict['stat'] = {'commented': 0, 'reacted': 0, 'rating': 0}
# Notifications call # notifications call
await notify_reaction(rdict, 'create') await notify_reaction(rdict, 'create')
return rdict return rdict
@ -220,14 +141,14 @@ async def create_reaction(_, info, reaction):
try: try:
with local_session() as session: 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() author = session.query(Author).filter(Author.user == user_id).first()
if shout and author: if shout and author:
reaction['created_by'] = author.id reaction['created_by'] = author.id
kind = reaction.get('kind') kind = reaction.get('kind')
shout_id = shout.id shout_id = shout.id
if not kind and reaction.get('body'): if not kind and isinstance(reaction.get('body'), str):
kind = ReactionKind.COMMENT.value kind = ReactionKind.COMMENT.value
if not kind: if not kind:

View File

@ -26,9 +26,9 @@ def apply_filters(q, filters, author_id=None):
if filters.get('reacted') and author_id: if filters.get('reacted') and author_id:
q.join(Reaction, Reaction.created_by == author_id) q.join(Reaction, Reaction.created_by == author_id)
by_published = filters.get('published') by_featured = filters.get('featured')
if by_published: if by_featured:
q = q.filter(Shout.visibility == ShoutVisibility.PUBLIC.value) q = q.filter(Shout.visibility == ShoutVisibility.FEATURED.value)
by_layouts = filters.get('layouts') by_layouts = filters.get('layouts')
if by_layouts: if by_layouts:
q = q.filter(Shout.layout.in_(by_layouts)) q = q.filter(Shout.layout.in_(by_layouts))
@ -114,7 +114,7 @@ async def load_shouts_by(_, _info, options):
filters: { filters: {
layouts: ['audio', 'video', ..], layouts: ['audio', 'video', ..],
reacted: True, reacted: True,
published: True, // filter published-only featured: True, // filter featured-only
author: 'discours', author: 'discours',
topic: 'culture', topic: 'culture',
after: 1234567 // unixtime after: 1234567 // unixtime
@ -143,13 +143,14 @@ async def load_shouts_by(_, _info, options):
q = add_stat_columns(q, aliased_reaction) q = add_stat_columns(q, aliased_reaction)
# filters # filters
q = apply_filters(q, options.get('filters', {})) filters = options.get('filters', {})
q = apply_filters(q, filters)
# group # group
q = q.group_by(Shout.id) q = q.group_by(Shout.id)
# order # 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) 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)) q = q.order_by(nulls_last(query_order_by))
@ -274,9 +275,10 @@ async def load_shouts_feed(_, info, options):
aliased_reaction = aliased(Reaction) aliased_reaction = aliased(Reaction)
q = add_stat_columns(q, 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) query_order_by = desc(order_by) if options.get('order_by_desc', True) else asc(order_by)
offset = options.get('offset', 0) offset = options.get('offset', 0)
@ -464,7 +466,7 @@ async def load_shouts_random_topic(_, info, limit: int = 10):
.filter( .filter(
and_( and_(
Shout.deleted_at.is_(None), Shout.deleted_at.is_(None),
Shout.visibility == ShoutVisibility.PUBLIC.value, Shout.visibility == ShoutVisibility.FEATURED.value,
Shout.topics.any(slug=topic.slug), Shout.topics.any(slug=topic.slug),
) )
) )

46
schema/enum.graphql Normal file
View File

@ -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
}

80
schema/input.graphql Normal file
View File

@ -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
}

31
schema/mutation.graphql Normal file
View File

@ -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!
}

41
schema/query.graphql Normal file
View File

@ -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]
}

182
schema/type.graphql Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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]
}

46
services/diff.py Normal file
View File

@ -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)