Merge branch 'create-shout-2' into 'main'

New create shout flow

See merge request discoursio/discoursio-api!17
This commit is contained in:
Igor 2023-05-03 15:48:22 +00:00
commit c11c862d96
9 changed files with 98 additions and 292 deletions

19
migrate.sh Normal file
View File

@ -0,0 +1,19 @@
database_name="discoursio"
echo "DATABASE MIGRATION STARTED"
echo "Dropping database $database_name"
dropdb $database_name --force
if [ $? -ne 0 ]; then { echo "Failed to drop database, aborting." ; exit 1; } fi
echo "Database $database_name dropped"
echo "Creating database $database_name"
createdb $database_name
if [ $? -ne 0 ]; then { echo "Failed to create database, aborting." ; exit 1; } fi
echo "Database $database_name successfully created"
echo "Start migration"
python3 server.py migrate
if [ $? -ne 0 ]; then { echo "Migration failed, aborting." ; exit 1; } fi
echo 'Done!'

View File

@ -1,41 +0,0 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, ForeignKey, DateTime, String
from sqlalchemy.orm import relationship
from base.orm import Base
from orm.user import User
from orm.topic import Topic
class DraftTopic(Base):
__tablename__ = "draft_topic"
id = None # type: ignore
collab = Column(ForeignKey("draft_collab.id"), primary_key=True)
topic = Column(ForeignKey("topic.id"), primary_key=True)
main = Column(Boolean, default=False)
class DraftAuthor(Base):
__tablename__ = "draft_author"
id = None # type: ignore
collab = Column(ForeignKey("draft_collab.id"), primary_key=True)
author = Column(ForeignKey("user.id"), primary_key=True)
accepted = Column(Boolean, default=False)
class DraftCollab(Base):
__tablename__ = "draft_collab"
slug = Column(String, nullable=True, comment="Slug")
title = Column(String, nullable=True, comment="Title")
subtitle = Column(String, nullable=True, comment="Subtitle")
layout = Column(String, nullable=True, comment="Layout format")
body = Column(String, nullable=True, comment="Body")
cover = Column(String, nullable=True, comment="Cover")
authors = relationship(lambda: User, secondary=DraftAuthor.__tablename__)
topics = relationship(lambda: Topic, secondary=DraftTopic.__tablename__)
createdAt = Column(DateTime, default=datetime.now, comment="Created At")
updatedAt = Column(DateTime, default=datetime.now, comment="Updated At")
chat = Column(String, unique=True, nullable=True)

View File

@ -48,9 +48,11 @@ class Shout(Base):
publishedAt = Column(DateTime, nullable=True) publishedAt = Column(DateTime, nullable=True)
deletedAt = Column(DateTime, nullable=True) deletedAt = Column(DateTime, nullable=True)
# same with Draft createdBy = Column(ForeignKey("user.id"), comment="Created By")
deletedBy = Column(ForeignKey("user.id"), nullable=True)
slug = Column(String, unique=True) slug = Column(String, unique=True)
cover = Column(String, nullable=True, comment="Cover") cover = Column(String, nullable=True, comment="Cover image url")
body = Column(String, nullable=False, comment="Body") body = Column(String, nullable=False, comment="Body")
title = Column(String, nullable=True) title = Column(String, nullable=True)
subtitle = Column(String, nullable=True) subtitle = Column(String, nullable=True)

View File

@ -8,8 +8,6 @@ from resolvers.auth import (
get_current_user, get_current_user,
) )
from resolvers.create.drafts import load_drafts, create_draft, update_draft, delete_draft,\
accept_coauthor, invite_coauthor, draft_to_shout
from resolvers.create.migrate import markdown_body from resolvers.create.migrate import markdown_body
from resolvers.create.editor import create_shout, delete_shout, update_shout from resolvers.create.editor import create_shout, delete_shout, update_shout

View File

@ -1,189 +0,0 @@
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from base.orm import local_session
from base.resolvers import query, mutation
from orm.draft import DraftCollab, DraftAuthor
from orm.shout import Shout
from orm.topic import Topic
from orm.user import User
from datetime import datetime, timezone
from transliterate import translit
import re
@query.field("loadDrafts")
@login_required
async def load_drafts(_, info):
auth: AuthCredentials = info.context["request"].auth
drafts = []
with local_session() as session:
drafts = session.query(DraftCollab).filter(auth.user_id in DraftCollab.authors)
return drafts
@mutation.field("createDraft") # TODO
@login_required
async def create_draft(_, info, draft_input):
auth: AuthCredentials = info.context["request"].auth
draft_input['createdBy'] = auth.user_id
with local_session() as session:
collab = DraftCollab.create(**draft_input)
session.add(collab)
session.commit()
# TODO: email notify to all authors
return {}
@mutation.field("deleteDraft")
@login_required
async def delete_draft(_, info, draft: int = 0):
auth: AuthCredentials = info.context["request"].auth
with local_session() as session:
d = session.query(DraftCollab).where(DraftCollab.id == draft).one()
if auth.user_id not in d.authors:
# raise BaseHttpException("only owner can remove coauthors")
return {
"error": "Only authors can update a draft"
}
elif not d:
return {
"error": "There is no draft with this id"
}
else:
session.delete(d)
session.commit()
return {}
@mutation.field("updateDraft") # TODO: draft input type
@login_required
async def update_draft(_, info, draft_input):
auth: AuthCredentials = info.context["request"].auth
with local_session() as session:
d = session.query(
DraftCollab
).where(
DraftCollab.id == draft_input.id
).one() # raises Error when not found
if auth.user_id not in d.authors:
# raise BaseHttpException("only owner can remove coauthors")
return {
"error": "Only authors can update draft"
}
elif not d:
return {
"error": "There is no draft with this id"
}
else:
draft_input["updatedAt"] = datetime.now(tz=timezone.utc)
d.update(draft_input)
session.commit()
# TODO: email notify
return {}
@mutation.field("inviteAuthor")
@login_required
async def invite_coauthor(_, info, author: int = 0, draft: int = 0):
auth: AuthCredentials = info.context["request"].auth
with local_session() as session:
c = session.query(DraftCollab).where(DraftCollab.id == draft).one()
if auth.user_id not in c.authors:
# raise BaseHttpException("you are not in authors list")
return {
"error": "You are not in authors list"
}
elif c.id:
invited_user = session.query(User).where(User.id == author).one()
da = DraftAuthor.create({
"accepted": False,
"collab": c.id,
"author": invited_user.id
})
session.add(da)
session.commit()
else:
return {
"error": "Draft is not found"
}
# TODO: email notify
return {}
def get_slug(src):
slug = translit(src, "ru", reversed=True).replace(".", "-").lower()
slug = re.sub('[^0-9a-zA-Z]+', '-', slug)
return slug
@mutation.field("inviteAccept")
@login_required
async def accept_coauthor(_, info, draft: int):
auth: AuthCredentials = info.context["request"].auth
with local_session() as session:
d = session.query(DraftCollab).where(DraftCollab.id == draft).one()
if not d:
return {
"error": "Draft id was not found"
}
else:
a = session.query(DraftAuthor).where(DraftAuthor.collab == draft).filter(
DraftAuthor.author == auth.user_id).one()
if not a.accepted:
a.accepted = True
session.commit()
# TODO: email notify
return {}
elif a.accepted:
return {
"error": "You have accepted invite before"
}
else:
# raise BaseHttpException("only invited can accept")
return {
"error": "You don't have an invitation yet"
}
@mutation.field("draftToShout")
@login_required
async def draft_to_shout(_, info, draft: int = 0):
auth: AuthCredentials = info.context["request"].auth
with local_session() as session:
d = session.query(DraftCollab).where(DraftCollab.id == draft).one()
if auth.user_id not in d.authors:
# raise BaseHttpException("you are not in authors list")
return {
"error": "You are not in authors list"
}
elif d.id:
draft_authors = [a.author for a in d.authors]
draft_topics = [t.topic for t in d.topics]
authors = session.query(User).where(User.id._in(draft_authors)).all()
topics = session.query(Topic).where(Topic.id._in(draft_topics)).all()
new_shout = Shout.create({
"authors": authors,
"body": d.body,
"title": d.title,
"subtitle": d.subtitle or "",
"topics": topics,
"media": d.media,
"slug": d.slug or get_slug(d.title),
"layout": d.layout or "article"
})
session.add(new_shout)
session.commit()
else:
return {
"error": "Draft is not found"
}
# TODO: email notify
return {}

View File

@ -14,7 +14,6 @@ from resolvers.zine.reactions import reactions_follow, reactions_unfollow
from services.zine.gittask import GitTask from services.zine.gittask import GitTask
# from resolvers.inbox.chats import create_chat # from resolvers.inbox.chats import create_chat
# from services.inbox.storage import MessagesStorage # from services.inbox.storage import MessagesStorage
# from orm.draft import DraftCollab
@mutation.field("createShout") @mutation.field("createShout")
@ -28,12 +27,12 @@ async def create_shout(_, info, inp):
new_shout = Shout.create(**{ new_shout = Shout.create(**{
"title": inp.get("title"), "title": inp.get("title"),
"subtitle": inp.get('subtitle'), "subtitle": inp.get('subtitle'),
"body": inp.get("body"), "body": inp.get("body", ''),
"authors": inp.get("authors", []), "authors": inp.get("authors", []),
"slug": inp.get("slug"), "slug": inp.get("slug"),
"mainTopic": inp.get("mainTopic"), "mainTopic": inp.get("mainTopic"),
"visibility": "community", "visibility": "owner",
# "createdBy": auth.user_id "createdBy": auth.user_id
}) })
for topic in topics: for topic in topics:
@ -85,18 +84,21 @@ async def create_shout(_, info, inp):
# TODO # TODO
# GitTask(inp, user.username, user.email, "new shout %s" % new_shout.slug) # GitTask(inp, user.username, user.email, "new shout %s" % new_shout.slug)
if new_shout.slug is None:
new_shout.slug = f"draft-{new_shout.id}"
session.commit()
return {"shout": new_shout} return {"shout": new_shout}
@mutation.field("updateShout") @mutation.field("updateShout")
@login_required @login_required
async def update_shout(_, info, inp): async def update_shout(_, info, slug, inp):
auth: AuthCredentials = info.context["request"].auth auth: AuthCredentials = info.context["request"].auth
slug = inp["slug"]
with local_session() as session: with local_session() as session:
user = session.query(User).filter(User.id == auth.user_id).first()
shout = session.query(Shout).filter(Shout.slug == slug).first() shout = session.query(Shout).filter(Shout.slug == slug).first()
if not shout: if not shout:
return {"error": "shout not found"} return {"error": "shout not found"}
@ -109,18 +111,38 @@ async def update_shout(_, info, inp):
else: else:
shout.update(inp) shout.update(inp)
shout.updatedAt = datetime.now(tz=timezone.utc) shout.updatedAt = datetime.now(tz=timezone.utc)
session.add(shout)
if inp.get("topics"): if inp.get("topics"):
# remove old links # remove old links
links = session.query(ShoutTopic).where(ShoutTopic.shout == shout.id).all() links = session.query(ShoutTopic).where(ShoutTopic.shout == shout.id).all()
for topiclink in links: for topiclink in links:
session.delete(topiclink) session.delete(topiclink)
# add new topic links # add new topic links
for topic in inp.get("topics", []): # for topic_slug in inp.get("topics", []):
ShoutTopic.create(shout=slug, topic=topic) # topic = session.query(Topic).filter(Topic.slug == topic_slug).first()
# shout_topic = ShoutTopic.create(shout=shout.id, topic=topic.id)
# session.add(shout_topic)
session.commit() session.commit()
# GitTask(inp, user.username, user.email, "update shout %s" % slug)
GitTask(inp, user.username, user.email, "update shout %s" % slug) return {"shout": shout}
@mutation.field("publishShout")
@login_required
async def publish_shout(_, info, slug, inp):
auth: AuthCredentials = info.context["request"].auth
with local_session() as session:
shout = session.query(Shout).filter(Shout.slug == slug).first()
if not shout:
return {"error": "shout not found"}
else:
shout.update(inp)
shout.visibility = "community"
shout.updatedAt = datetime.now(tz=timezone.utc)
session.commit()
return {"shout": shout} return {"shout": shout}

View File

@ -1,11 +1,11 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import joinedload, aliased from sqlalchemy.orm import joinedload, aliased
from sqlalchemy.sql.expression import desc, asc, select, func, case from sqlalchemy.sql.expression import desc, asc, select, func, case, and_
from auth.authenticate import login_required from auth.authenticate import login_required
from auth.credentials import AuthCredentials from auth.credentials import AuthCredentials
from base.exceptions import ObjectNotExist from base.exceptions import ObjectNotExist, OperationNotAllowed
from base.orm import local_session from base.orm import local_session
from base.resolvers import query from base.resolvers import query
from orm import ViewedEntry, TopicFollower from orm import ViewedEntry, TopicFollower
@ -70,7 +70,6 @@ def apply_filters(q, filters, user_id=None):
return q return q
@query.field("loadShout") @query.field("loadShout")
async def load_shout(_, info, slug): async def load_shout(_, info, slug):
with local_session() as session: with local_session() as session:
@ -196,6 +195,38 @@ async def load_shouts_by(_, info, options):
return shouts return shouts
@query.field("loadDrafts")
async def get_drafts(_, info, options):
auth: AuthCredentials = info.context["request"].auth
user_id = auth.user_id
q = select(Shout).options(
joinedload(Shout.authors),
joinedload(Shout.topics),
).where(
and_(Shout.deletedAt.is_(None), Shout.createdBy == user_id)
)
q = apply_filters(q, options.get("filters", {}), user_id)
order_by = options.get("order_by", Shout.createdAt)
if order_by == 'reacted':
aliased_reaction = aliased(Reaction)
q.outerjoin(aliased_reaction).add_columns(func.max(aliased_reaction.createdAt).label('reacted'))
query_order_by = desc(order_by) if options.get('order_by_desc', True) else asc(order_by)
offset = options.get("offset", 0)
limit = options.get("limit", 10)
q = q.group_by(Shout.id).order_by(query_order_by).limit(limit).offset(offset)
shouts = []
with local_session() as session:
for [shout] in session.execute(q).unique():
shouts.append(shout)
return shouts
@query.field("myFeed") @query.field("myFeed")
@login_required @login_required

View File

@ -60,7 +60,6 @@ type Author {
type Result { type Result {
error: String error: String
uids: [String]
slugs: [String] slugs: [String]
chat: Chat chat: Chat
chats: [Chat] chats: [Chat]
@ -77,8 +76,6 @@ type Result {
topics: [Topic] topics: [Topic]
community: Community community: Community
communities: [Community] communities: [Community]
draft: DraftCollab
drafts: [DraftCollab]
} }
enum ReactionStatus { enum ReactionStatus {
@ -100,7 +97,7 @@ type ReactionUpdating {
input ShoutInput { input ShoutInput {
slug: String slug: String
title: String title: String
body: String! body: String
authors: [String] authors: [String]
topics: [String] topics: [String]
community: Int community: Int
@ -130,16 +127,6 @@ input TopicInput {
# parents: [String] # parents: [String]
} }
input DraftInput {
slug: String
topics: [Int]
authors: [Int]
title: String
subtitle: String
body: String
cover: String
}
input ReactionInput { input ReactionInput {
kind: ReactionKind! kind: ReactionKind!
@ -183,8 +170,9 @@ type Mutation {
# shout # shout
createShout(inp: ShoutInput!): Result! createShout(inp: ShoutInput!): Result!
updateShout(inp: ShoutInput!): Result! updateShout(slug: String!, inp: ShoutInput!): Result!
deleteShout(slug: String!): Result! deleteShout(slug: String!): Result!
publishShout(slug: String!, inp: ShoutInput!): Result!
# user profile # user profile
rateUser(slug: String!, value: Int!): Result! rateUser(slug: String!, value: Int!): Result!
@ -202,14 +190,6 @@ type Mutation {
updateReaction(id: Int!, reaction: ReactionInput!): Result! updateReaction(id: Int!, reaction: ReactionInput!): Result!
deleteReaction(id: Int!): Result! deleteReaction(id: Int!): Result!
# draft / collab
createDraft(draft: DraftInput!): Result!
updateDraft(draft: DraftInput!): Result!
deleteDraft(draft: Int!): Result!
inviteAccept(draft: Int!): Result!
inviteAuthor(draft: Int!, author: Int!): Result!
draftToShout(draft: Int!): Result!
# following # following
follow(what: FollowingEntity!, slug: String!): Result! follow(what: FollowingEntity!, slug: String!): Result!
unfollow(what: FollowingEntity!, slug: String!): Result! unfollow(what: FollowingEntity!, slug: String!): Result!
@ -298,6 +278,7 @@ type Query {
loadAuthorsBy(by: AuthorsBy, limit: Int, offset: Int): [Author]! loadAuthorsBy(by: AuthorsBy, limit: Int, offset: Int): [Author]!
loadShout(slug: String!): Shout loadShout(slug: String!): Shout
loadShouts(options: LoadShoutsOptions): [Shout]! loadShouts(options: LoadShoutsOptions): [Shout]!
loadDrafts(options: LoadShoutsOptions): [Shout]!
loadReactionsBy(by: ReactionBy!, limit: Int, offset: Int): [Reaction]! loadReactionsBy(by: ReactionBy!, limit: Int, offset: Int): [Reaction]!
userFollowers(slug: String!): [Author]! userFollowers(slug: String!): [Author]!
userFollowedAuthors(slug: String!): [Author]! userFollowedAuthors(slug: String!): [Author]!
@ -306,9 +287,6 @@ type Query {
getAuthor(slug: String!): User getAuthor(slug: String!): User
myFeed(options: LoadShoutsOptions): [Shout] myFeed(options: LoadShoutsOptions): [Shout]
# draft/collab
loadDrafts: [DraftCollab]!
# migrate # migrate
markdownBody(body: String!): String! markdownBody(body: String!): String!
@ -544,17 +522,3 @@ type Chat {
unread: Int unread: Int
private: Boolean private: Boolean
} }
type DraftCollab {
slug: String
title: String
subtitle: String
body: String
cover: String
layout: String
authors: [Int]!
topics: [String]
chat: Chat
createdAt: Int!
updatedAt: Int
}

View File

@ -169,9 +169,9 @@ class ViewedStorage:
viewed = session.query( viewed = session.query(
ViewedEntry ViewedEntry
).join( ).join(
Shout Shout, Shout.id == ViewedEntry.shout
).join( ).join(
User User, User.id == ViewedEntry.viewer
).filter( ).filter(
User.slug == viewer, User.slug == viewer,
Shout.slug == shout_slug Shout.slug == shout_slug