new-version-0-2-13
Some checks failed
deploy / deploy (push) Failing after 1m54s

This commit is contained in:
Untone 2023-11-03 13:10:22 +03:00
parent 1f5e5472c9
commit 435d1e4505
21 changed files with 392 additions and 436 deletions

View File

@ -1,3 +1,14 @@
[0.2.13]
- services: db context manager
- services: ViewedStorage fixes
- services: views are not stored in core db anymore
- schema: snake case in model fields names
- schema: no DateTime scalar
- resolvers: get_my_feed comments filter reactions body.is_not("")
- resolvers: get_my_feed query fix
- resolvers: LoadReactionsBy.days -> LoadReactionsBy.time_ago
- resolvers: LoadShoutsBy.days -> LoadShoutsBy.time_ago
[0.2.12]
- Author.userpic -> Author.pic
- CommunityAuthor.role is string now

View File

@ -2,8 +2,9 @@ from services.db import Base, engine
from orm.shout import Shout
from orm.community import Community
def init_tables():
Base.metadata.create_all(engine)
Community.init_table()
Shout.init_table()
Community.init_table()
print("[orm] tables initialized")

View File

@ -1,6 +1,6 @@
from datetime import datetime
import time
from sqlalchemy import JSON as JSONType
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from services.db import Base
@ -13,10 +13,6 @@ class AuthorRating(Base):
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
value = Column(Integer)
@staticmethod
def init_table():
pass
class AuthorFollower(Base):
__tablename__ = "author_follower"
@ -24,23 +20,25 @@ class AuthorFollower(Base):
id = None # type: ignore
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
createdAt = Column(DateTime, nullable=False, default=datetime.now)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
auto = Column(Boolean, nullable=False, default=False)
class Author(Base):
__tablename__ = "author"
user = Column(String, nullable=False) # unbounded link with authorizer's User type
bio = Column(String, nullable=True, comment="Bio") # status description
about = Column(String, nullable=True, comment="About") # long and formatted
pic = Column(String, nullable=True, comment="Userpic")
user = Column(String, unique=True) # unbounded link with authorizer's User type
name = Column(String, nullable=True, comment="Display name")
slug = Column(String, unique=True, comment="Author's slug")
createdAt = Column(DateTime, nullable=False, default=datetime.now)
lastSeen = Column(DateTime, nullable=False, default=datetime.now) # Td se 0e
deletedAt = Column(DateTime, nullable=True, comment="Deleted at")
bio = Column(String, nullable=True, comment="Bio") # status description
about = Column(String, nullable=True, comment="About") # long and formatted
pic = Column(String, nullable=True, comment="Picture")
links = Column(JSONType, nullable=True, comment="Links")
ratings = relationship(AuthorRating, foreign_keys=AuthorRating.author)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at = Column(Integer, nullable=True, comment="Deleted at")

View File

@ -1,5 +1,5 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, String
import time
from sqlalchemy import Column, Integer, ForeignKey, String
from services.db import Base
@ -18,6 +18,6 @@ class Collection(Base):
title = Column(String, nullable=False, comment="Title")
body = Column(String, nullable=True, comment="Body")
pic = Column(String, nullable=True, comment="Picture")
createdAt = Column(DateTime, default=datetime.now, comment="Created At")
createdBy = Column(ForeignKey("author.id"), comment="Created By")
publishedAt = Column(DateTime, default=datetime.now, comment="Published At")
created_at = Column(Integer, default=lambda: int(time.time()))
created_by = Column(ForeignKey("author.id"), comment="Created By")
published_at = Column(Integer, default=lambda: int(time.time()))

View File

@ -1,5 +1,5 @@
from datetime import datetime
from sqlalchemy import Column, String, ForeignKey, DateTime
import time
from sqlalchemy import Column, String, ForeignKey, Integer
from sqlalchemy.orm import relationship
from services.db import Base, local_session
@ -12,7 +12,7 @@ class CommunityAuthor(Base):
id = None # type: ignore
follower = Column(ForeignKey("author.id"), primary_key=True)
community = Column(ForeignKey("community.id"), primary_key=True)
joinedAt = Column(DateTime, nullable=False, default=datetime.now)
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
role = Column(String, nullable=False)
@ -23,17 +23,16 @@ class Community(Base):
slug = Column(String, nullable=False, unique=True)
desc = Column(String, nullable=False, default="")
pic = Column(String, nullable=False, default="")
createdAt = Column(DateTime, nullable=False, default=datetime.now)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
authors = relationship(lambda: Author, secondary=CommunityAuthor.__tablename__, nullable=True)
authors = relationship(lambda: Author, secondary=CommunityAuthor.__tablename__)
@staticmethod
def init_table():
with local_session() as session:
d = (session.query(Community).filter(Community.slug == "discours").first())
d = session.query(Community).filter(Community.slug == "discours").first()
if not d:
d = Community.create(name="Дискурс", slug="discours")
session.add(d)
session.commit()
print("[orm] created community %s" % d.slug)
Community.default_community = d
print('[orm] default community id: %s' % d.id)
print("[orm] default community is %s" % d.slug)

View File

@ -1,7 +1,7 @@
from datetime import datetime
from enum import Enum as Enumeration
from sqlalchemy import Column, DateTime, Enum, ForeignKey, String
from sqlalchemy import Column, Integer, Enum, ForeignKey, String
from services.db import Base
import time
class ReactionKind(Enumeration):
@ -26,13 +26,12 @@ class Reaction(Base):
__tablename__ = "reaction"
body = Column(String, nullable=True, comment="Reaction Body")
createdAt = Column(DateTime, nullable=False, default=datetime.now)
createdBy = Column(ForeignKey("author.id"), nullable=False, index=True)
updatedAt = Column(DateTime, nullable=True, comment="Updated at")
updatedBy = Column(ForeignKey("author.id"), nullable=True, index=True)
deletedAt = Column(DateTime, nullable=True, comment="Deleted at")
deletedBy = Column(ForeignKey("author.id"), nullable=True, index=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
created_by = Column(ForeignKey("author.id"), nullable=False, index=True)
updated_at = Column(Integer, nullable=True, comment="Updated at")
deleted_at = Column(Integer, nullable=True, comment="Deleted at")
deleted_by = Column(ForeignKey("author.id"), nullable=True, index=True)
shout = Column(ForeignKey("shout.id"), nullable=False, index=True)
replyTo = Column(ForeignKey("reaction.id"), nullable=True)
range = Column(String, nullable=True, comment="<start index>:<end>")
reply_to = Column(ForeignKey("reaction.id"), nullable=True)
quote = Column(String, nullable=True, comment="a quoted fragment")
kind = Column(Enum(ReactionKind), nullable=False)

View File

@ -1,10 +1,9 @@
from datetime import datetime
import time
from enum import Enum as Enumeration
from sqlalchemy import (
Enum,
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
@ -33,10 +32,8 @@ class ShoutReactionsFollower(Base):
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
auto = Column(Boolean, nullable=False, default=False)
createdAt = Column(
DateTime, nullable=False, default=datetime.now, comment="Created at"
)
deletedAt = Column(DateTime, nullable=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at = Column(Integer, nullable=True)
class ShoutAuthor(Base):
@ -65,13 +62,13 @@ class ShoutVisibility(Enumeration):
class Shout(Base):
__tablename__ = "shout"
createdAt = Column(DateTime, nullable=False, default=datetime.now)
updatedAt = Column(DateTime, nullable=True)
publishedAt = Column(DateTime, nullable=True)
deletedAt = Column(DateTime, nullable=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=True)
published_at = Column(Integer, nullable=True)
deleted_at = Column(Integer, nullable=True)
createdBy = Column(ForeignKey("author.id"), comment="Created By")
deletedBy = Column(ForeignKey("author.id"), nullable=True)
created_by = Column(ForeignKey("author.id"), comment="Created By")
deleted_by = Column(ForeignKey("author.id"), nullable=True)
body = Column(String, nullable=False, comment="Body")
slug = Column(String, unique=True)
@ -85,21 +82,17 @@ class Shout(Base):
authors = relationship(lambda: Author, secondary=ShoutAuthor.__tablename__)
topics = relationship(lambda: Topic, secondary=ShoutTopic.__tablename__)
communities = relationship(
lambda: Community, secondary=ShoutCommunity.__tablename__
)
communities = relationship(lambda: Community, secondary=ShoutCommunity.__tablename__)
reactions = relationship(lambda: Reaction)
viewsOld = Column(Integer, default=0)
viewsAckee = Column(Integer, default=0)
views = column_property(viewsOld + viewsAckee)
views_old = Column(Integer, default=0)
views_ackee = Column(Integer, default=0)
views = column_property(views_old + views_ackee)
visibility = Column(Enum(ShoutVisibility), default=ShoutVisibility.AUTHORS)
# TODO: these field should be used or modified
lang = Column(String, nullable=False, default="ru", comment="Language")
mainTopic = Column(ForeignKey("topic.slug"), nullable=True)
versionOf = Column(ForeignKey("shout.id"), nullable=True)
version_of = Column(ForeignKey("shout.id"), nullable=True)
oid = Column(String, nullable=True)
@staticmethod
@ -114,5 +107,3 @@ class Shout(Base):
"lang": "ru",
}
s = Shout.create(**entry)
session.add(s)
session.commit()

View File

@ -1,5 +1,5 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String
import time
from sqlalchemy import Boolean, Column, Integer, ForeignKey, String
from services.db import Base
@ -9,7 +9,7 @@ class TopicFollower(Base):
id = None # type: ignore
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
createdAt = Column(DateTime, nullable=False, default=datetime.now)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
auto = Column(Boolean, nullable=False, default=False)

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "discoursio-core"
version = "0.2.12"
version = "0.2.13"
description = "core module for discours.io"
authors = ["discoursio devteam"]
license = "MIT"

View File

@ -1,10 +1,11 @@
import time
from typing import List
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, func, distinct, select, literal
from sqlalchemy.orm import aliased
from services.auth import login_required
from services.db import local_session
from services.unread import get_total_unread_counter
from services.schema import mutation, query
from orm.shout import ShoutAuthor, ShoutTopic
from orm.topic import Topic
@ -41,7 +42,7 @@ def add_author_stat_columns(q):
# )
q = q.add_columns(literal(0).label("commented_stat"))
# q = q.outerjoin(Reaction, and_(Reaction.createdBy == Author.id, Reaction.body.is_not(None))).add_columns(
# q = q.outerjoin(Reaction, and_(Reaction.created_by == Author.id, Reaction.body.is_not(None))).add_columns(
# func.count(distinct(Reaction.id)).label('commented_stat')
# )
@ -81,7 +82,7 @@ def get_authors_from_query(q):
async def author_followings(author_id: int):
return {
# "unread": await get_total_unread_counter(author_id), # unread inbox messages counter
"unread": await get_total_unread_counter(author_id), # unread inbox messages counter
"topics": [
t.slug for t in await followed_topics(author_id)
], # followed topics slugs
@ -168,14 +169,15 @@ async def load_authors_by(_, _info, by, limit, offset):
.join(Topic)
.where(Topic.slug == by["topic"])
)
if by.get("lastSeen"): # in days
days_before = datetime.now(tz=timezone.utc) - timedelta(days=by["lastSeen"])
q = q.filter(Author.lastSeen > days_before)
elif by.get("createdAt"): # in days
days_before = datetime.now(tz=timezone.utc) - timedelta(days=by["createdAt"])
q = q.filter(Author.createdAt > days_before)
if by.get("last_seen"): # in unixtime
before = int(time.time()) - by["last_seen"]
q = q.filter(Author.last_seen > before)
elif by.get("created_at"): # in unixtime
before = int(time.time()) - by["created_at"]
q = q.filter(Author.created_at > before)
q = q.order_by(by.get("order", Author.createdAt)).limit(limit).offset(offset)
q = q.order_by(by.get("order", Author.created_at)).limit(limit).offset(offset)
return get_authors_from_query(q)
@ -226,7 +228,8 @@ async def rate_author(_, info, rated_user_id, value):
session.query(AuthorRating)
.filter(
and_(
AuthorRating.rater == author_id, AuthorRating.user == rated_user_id
AuthorRating.rater == author_id,
AuthorRating.user == rated_user_id
)
)
.first()

View File

@ -28,7 +28,7 @@ def add_community_stat_columns(q):
# )
q = q.add_columns(literal(0).label("commented_stat"))
# q = q.outerjoin(Reaction, and_(Reaction.createdBy == Author.id, Reaction.body.is_not(None))).add_columns(
# q = q.outerjoin(Reaction, and_(Reaction.created_by == Author.id, Reaction.body.is_not(None))).add_columns(
# func.count(distinct(Reaction.id)).label('commented_stat')
# )

View File

@ -1,40 +1,32 @@
from datetime import datetime, timezone
import time # For Unix timestamps
from sqlalchemy import and_, select
from sqlalchemy.orm import joinedload
from services.auth import login_required
from services.db import local_session
from services.schema import mutation, query
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.shout import Shout, ShoutAuthor, ShoutTopic, ShoutVisibility
from orm.topic import Topic
from reaction import reactions_follow, reactions_unfollow
from services.notify import notify_shout
@query.field("loadDrafts")
async def get_drafts(_, info):
author = info.context["request"].author
q = (
select(Shout)
.options(
joinedload(Shout.authors),
joinedload(Shout.topics),
)
.where(and_(Shout.deletedAt.is_(None), Shout.createdBy == author.id))
.where(and_(Shout.deleted_at.is_(None), Shout.created_by == author.id))
)
q = q.group_by(Shout.id)
shouts = []
with local_session() as session:
for [shout] in session.execute(q).unique():
shouts.append(shout)
return shouts
@mutation.field("createShout")
@login_required
async def create_shout(_, info, inp):
@ -43,7 +35,8 @@ async def create_shout(_, info, inp):
topics = (
session.query(Topic).filter(Topic.slug.in_(inp.get("topics", []))).all()
)
# Replace datetime with Unix timestamp
current_time = int(time.time())
new_shout = Shout.create(
**{
"title": inp.get("title"),
@ -54,43 +47,33 @@ async def create_shout(_, info, inp):
"layout": inp.get("layout"),
"authors": inp.get("authors", []),
"slug": inp.get("slug"),
"mainTopic": inp.get("mainTopic"),
"visibility": "authors",
"createdBy": author_id,
"topics": inp.get("topics"),
"visibility": ShoutVisibility.AUTHORS,
"created_id": author_id,
"created_at": current_time, # Set created_at as Unix timestamp
}
)
for topic in topics:
t = ShoutTopic.create(topic=topic.id, shout=new_shout.id)
session.add(t)
# NOTE: shout made by one first author
sa = ShoutAuthor.create(shout=new_shout.id, author=author_id)
session.add(sa)
session.add(new_shout)
reactions_follow(author_id, new_shout.id, True)
session.commit()
# TODO
# GitTask(inp, user.username, user.email, "new shout %s" % new_shout.slug)
# TODO: 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()
else:
notify_shout(new_shout.dict(), "create")
return {"shout": new_shout}
@mutation.field("updateShout")
@login_required
async def update_shout(_, info, shout_id, shout_input=None, publish=False):
author_id = info.context["author_id"]
with local_session() as session:
shout = (
session.query(Shout)
@ -101,39 +84,30 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False):
.filter(Shout.id == shout_id)
.first()
)
if not shout:
return {"error": "shout not found"}
if shout.createdBy != author_id:
if shout.created_by != author_id:
return {"error": "access denied"}
updated = False
if shout_input is not None:
topics_input = shout_input["topics"]
del shout_input["topics"]
new_topics_to_link = []
new_topics = [
topic_input for topic_input in topics_input if topic_input["id"] < 0
]
for new_topic in new_topics:
del new_topic["id"]
created_new_topic = Topic.create(**new_topic)
session.add(created_new_topic)
new_topics_to_link.append(created_new_topic)
if len(new_topics) > 0:
session.commit()
for new_topic_to_link in new_topics_to_link:
created_unlinked_topic = ShoutTopic.create(
shout=shout.id, topic=new_topic_to_link.id
)
session.add(created_unlinked_topic)
existing_topics_input = [
topic_input
for topic_input in topics_input
@ -145,80 +119,61 @@ async def update_shout(_, info, shout_id, shout_input=None, publish=False):
if existing_topic_input["id"]
not in [topic.id for topic in shout.topics]
]
for existing_topic_to_link_id in existing_topic_to_link_ids:
created_unlinked_topic = ShoutTopic.create(
shout=shout.id, topic=existing_topic_to_link_id
)
session.add(created_unlinked_topic)
topic_to_unlink_ids = [
topic.id
for topic in shout.topics
if topic.id
not in [topic_input["id"] for topic_input in existing_topics_input]
]
shout_topics_to_remove = session.query(ShoutTopic).filter(
and_(
ShoutTopic.shout == shout.id,
ShoutTopic.topic.in_(topic_to_unlink_ids),
)
)
for shout_topic_to_remove in shout_topics_to_remove:
session.delete(shout_topic_to_remove)
shout_input["mainTopic"] = shout_input["mainTopic"]["slug"]
if shout_input["mainTopic"] == "":
del shout_input["mainTopic"]
# Replace datetime with Unix timestamp
current_time = int(time.time())
shout_input["updated_at"] = current_time # Set updated_at as Unix timestamp
shout.update(shout_input)
updated = True
# TODO: use visibility setting
if publish and shout.visibility == "authors":
shout.visibility = "community"
shout.publishedAt = datetime.now(tz=timezone.utc)
shout.published_at = current_time # Set published_at as Unix timestamp
updated = True
# notify on publish
notify_shout(shout.dict())
if updated:
shout.updatedAt = datetime.now(tz=timezone.utc)
session.commit()
session.commit()
# GitTask(inp, user.username, user.email, "update shout %s" % slug)
notify_shout(shout.dict(), "update")
return {"shout": shout}
@mutation.field("deleteShout")
@login_required
async def delete_shout(_, info, shout_id):
author_id = info.context["author_id"]
with local_session() as session:
shout = session.query(Shout).filter(Shout.id == shout_id).first()
if not shout:
return {"error": "invalid shout id"}
if author_id != shout.createdBy:
if author_id != shout.created_by:
return {"error": "access denied"}
for author_id in shout.authors:
reactions_unfollow(author_id, shout_id)
shout.deletedAt = datetime.now(tz=timezone.utc)
# Replace datetime with Unix timestamp
current_time = int(time.time())
shout.deleted_at = current_time # Set deleted_at as Unix timestamp
session.commit()
notify_shout(shout.dict(), "delete")
return {}

View File

@ -1,4 +1,4 @@
from datetime import datetime, timedelta, timezone
import time
from sqlalchemy import and_, asc, desc, select, text, func, case
from sqlalchemy.orm import aliased
from services.notify import notify_reaction
@ -15,7 +15,7 @@ def add_reaction_stat_columns(q):
aliased_reaction = aliased(Reaction)
q = q.outerjoin(
aliased_reaction, Reaction.id == aliased_reaction.replyTo
aliased_reaction, Reaction.id == aliased_reaction.reply_to
).add_columns(
func.sum(aliased_reaction.id).label("reacted_stat"),
func.sum(case((aliased_reaction.body.is_not(None), 1), else_=0)).label(
@ -96,7 +96,7 @@ def is_published_author(session, author_id):
return (
session.query(Shout)
.where(Shout.authors.contains(author_id))
.filter(and_(Shout.publishedAt.is_not(None), Shout.deletedAt.is_(None)))
.filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
.count()
> 0
)
@ -104,7 +104,7 @@ def is_published_author(session, author_id):
def check_to_publish(session, author_id, reaction):
"""set shout to public if publicated approvers amount > 4"""
if not reaction.replyTo and reaction.kind in [
if not reaction.reply_to and reaction.kind in [
ReactionKind.ACCEPT,
ReactionKind.LIKE,
ReactionKind.PROOF,
@ -118,7 +118,7 @@ def check_to_publish(session, author_id, reaction):
author_id,
]
for ar in approvers_reactions:
a = ar.createdBy
a = ar.created_by
if is_published_author(session, a):
approvers.append(a)
if len(approvers) > 4:
@ -128,7 +128,7 @@ def check_to_publish(session, author_id, reaction):
def check_to_hide(session, reaction):
"""hides any shout if 20% of reactions are negative"""
if not reaction.replyTo and reaction.kind in [
if not reaction.reply_to and reaction.kind in [
ReactionKind.REJECT,
ReactionKind.DISLIKE,
ReactionKind.DISPROOF,
@ -152,7 +152,7 @@ def check_to_hide(session, reaction):
def set_published(session, shout_id):
s = session.query(Shout).where(Shout.id == shout_id).first()
s.publishedAt = datetime.now(tz=timezone.utc)
s.published_at = int(time.time())
s.visibility = text("public")
session.add(s)
session.commit()
@ -170,7 +170,7 @@ def set_hidden(session, shout_id):
async def create_reaction(_, info, reaction):
author_id = info.context["author_id"]
with local_session() as session:
reaction["createdBy"] = author_id
reaction["created_by"] = author_id
shout = session.query(Shout).where(Shout.id == reaction["shout"]).one()
if reaction["kind"] in [ReactionKind.DISLIKE.name, ReactionKind.LIKE.name]:
@ -179,9 +179,9 @@ async def create_reaction(_, info, reaction):
.where(
and_(
Reaction.shout == reaction["shout"],
Reaction.createdBy == author_id,
Reaction.created_by == author_id,
Reaction.kind == reaction["kind"],
Reaction.replyTo == reaction.get("replyTo"),
Reaction.reply_to == reaction.get("reply_to"),
)
)
.first()
@ -200,9 +200,9 @@ async def create_reaction(_, info, reaction):
.where(
and_(
Reaction.shout == reaction["shout"],
Reaction.createdBy == author_id,
Reaction.created_by == author_id,
Reaction.kind == opposite_reaction_kind,
Reaction.replyTo == reaction.get("replyTo"),
Reaction.reply_to == reaction.get("reply_to"),
)
)
.first()
@ -215,12 +215,12 @@ async def create_reaction(_, info, reaction):
# Proposal accepting logix
if (
r.replyTo is not None
r.reply_to is not None
and r.kind == ReactionKind.ACCEPT
and author_id in shout.dict()["authors"]
):
replied_reaction = (
session.query(Reaction).where(Reaction.id == r.replyTo).first()
session.query(Reaction).where(Reaction.id == r.reply_to).first()
)
if replied_reaction and replied_reaction.kind == ReactionKind.PROPOSE:
if replied_reaction.range:
@ -237,7 +237,7 @@ async def create_reaction(_, info, reaction):
rdict = r.dict()
rdict["shout"] = shout.dict()
author = session.query(Author).where(Author.id == author_id).first()
rdict["createdBy"] = author.dict()
rdict["created_by"] = author.dict()
# self-regulation mechanics
@ -274,11 +274,11 @@ async def update_reaction(_, info, rid, reaction={}):
if not r:
return {"error": "invalid reaction id"}
if r.createdBy != author_id:
if r.created_by != author_id:
return {"error": "access denied"}
r.body = reaction["body"]
r.updatedAt = datetime.now(tz=timezone.utc)
r.updated_at = int(time.time())
if r.kind != reaction["kind"]:
# NOTE: change mind detection can be here
pass
@ -304,13 +304,13 @@ async def delete_reaction(_, info, rid):
r = session.query(Reaction).filter(Reaction.id == rid).first()
if not r:
return {"error": "invalid reaction id"}
if r.createdBy != author_id:
if r.created_by != author_id:
return {"error": "access denied"}
if r.kind in [ReactionKind.LIKE, ReactionKind.DISLIKE]:
session.delete(r)
else:
r.deletedAt = datetime.now(tz=timezone.utc)
r.deleted_at = int(time.time())
session.commit()
notify_reaction(r.dict(), "delete")
@ -325,11 +325,11 @@ async def load_reactions_by(_, info, by, limit=50, offset=0):
:param by: {
:shout - filter by slug
:shouts - filer by shout slug list
:createdBy - to filter by author
:created_by - to filter by author
:topic - to filter by topic
:search - to search by reactions' body
:comment - true if body.length > 0
:days - a number of days ago
:time_ago - amount of time ago
:sort - a fieldname to sort desc by default
}
:param limit: int amount of shouts
@ -339,7 +339,7 @@ async def load_reactions_by(_, info, by, limit=50, offset=0):
q = (
select(Reaction, Author, Shout)
.join(Author, Reaction.createdBy == Author.id)
.join(Author, Reaction.created_by == Author.id)
.join(Shout, Reaction.shout == Shout.id)
)
@ -348,8 +348,8 @@ async def load_reactions_by(_, info, by, limit=50, offset=0):
elif by.get("shouts"):
q = q.filter(Shout.slug.in_(by["shouts"]))
if by.get("createdBy"):
q = q.filter(Author.id == by.get("createdBy"))
if by.get("created_by"):
q = q.filter(Author.id == by.get("created_by"))
if by.get("topic"):
# TODO: check
@ -361,15 +361,15 @@ async def load_reactions_by(_, info, by, limit=50, offset=0):
if len(by.get("search", "")) > 2:
q = q.filter(Reaction.body.ilike(f'%{by["body"]}%'))
if by.get("days"):
after = datetime.now(tz=timezone.utc) - timedelta(days=int(by["days"]) or 30)
q = q.filter(Reaction.createdAt > after) # FIXME: use comparing operator?
if by.get("time_ago"):
after = int(time.time()) - int(by.get("time_ago", 0))
q = q.filter(Reaction.created_at > after)
order_way = asc if by.get("sort", "").startswith("-") else desc
order_field = by.get("sort", "").replace("-", "") or Reaction.createdAt
order_field = by.get("sort", "").replace("-", "") or Reaction.created_at
q = q.group_by(Reaction.id, Author.id, Shout.id).order_by(order_way(order_field))
q = add_reaction_stat_columns(q)
q = q.where(Reaction.deletedAt.is_(None))
q = q.where(Reaction.deleted_at.is_(None))
q = q.limit(limit).offset(offset)
reactions = []
session = info.context["session"]
@ -381,7 +381,7 @@ async def load_reactions_by(_, info, by, limit=50, offset=0):
commented_stat,
rating_stat,
] in session.execute(q):
reaction.createdBy = author
reaction.created_by = author
reaction.shout = shout
reaction.stat = {
"rating": rating_stat,
@ -393,7 +393,7 @@ async def load_reactions_by(_, info, by, limit=50, offset=0):
# ?
if by.get("stat"):
reactions.sort(lambda r: r.stat.get(by["stat"]) or r.createdAt)
reactions.sort(lambda r: r.stat.get(by["stat"]) or r.created_at)
return reactions
@ -407,8 +407,8 @@ async def followed_reactions(_, info):
author = session.query(Author).where(Author.id == author_id).first()
reactions = (
session.query(Reaction.shout)
.where(Reaction.createdBy == author.id)
.filter(Reaction.createdAt > author.lastSeen)
.where(Reaction.created_by == author.id)
.filter(Reaction.created_at > author.last_seen)
.all()
)

View File

@ -1,5 +1,4 @@
from datetime import datetime, timedelta, timezone
import time
from aiohttp.web_exceptions import HTTPException
from sqlalchemy.orm import joinedload, aliased
from sqlalchemy.sql.expression import desc, asc, select, func, case, and_, nulls_last
@ -10,11 +9,11 @@ from orm.topic import TopicFollower
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.author import AuthorFollower
from servies.viewed import ViewedStorage
def add_stat_columns(q):
aliased_reaction = aliased(Reaction)
q = q.outerjoin(aliased_reaction).add_columns(
func.sum(aliased_reaction.id).label("reacted_stat"),
func.sum(
@ -23,7 +22,7 @@ def add_stat_columns(q):
func.sum(
case(
# do not count comments' reactions
(aliased_reaction.replyTo.is_not(None), 0),
(aliased_reaction.body.is_not(""), 0),
(aliased_reaction.kind == ReactionKind.AGREE, 1),
(aliased_reaction.kind == ReactionKind.DISAGREE, -1),
(aliased_reaction.kind == ReactionKind.PROOF, 1),
@ -38,7 +37,7 @@ def add_stat_columns(q):
func.max(
case(
(aliased_reaction.kind != ReactionKind.COMMENT, None),
else_=aliased_reaction.createdAt,
else_=aliased_reaction.created_at,
)
).label("last_comment"),
)
@ -48,7 +47,7 @@ def add_stat_columns(q):
def apply_filters(q, filters, author_id=None):
if filters.get("reacted") and author_id:
q.join(Reaction, Reaction.createdBy == author_id)
q.join(Reaction, Reaction.created_by == author_id)
v = filters.get("visibility")
if v == "public":
@ -66,11 +65,9 @@ def apply_filters(q, filters, author_id=None):
q = q.filter(Shout.title.ilike(f'%{filters.get("title")}%'))
if filters.get("body"):
q = q.filter(Shout.body.ilike(f'%{filters.get("body")}%s'))
if filters.get("days"):
before = datetime.now(tz=timezone.utc) - timedelta(
days=int(filters.get("days")) or 30
)
q = q.filter(Shout.createdAt > before)
if filters.get("time_ago"):
before = int(time.time()) - int(filters.get("time_ago"))
q = q.filter(Shout.created_at > before)
return q
@ -89,11 +86,12 @@ async def load_shout(_, _info, slug=None, shout_id=None):
if shout_id is not None:
q = q.filter(Shout.id == shout_id)
q = q.filter(Shout.deletedAt.is_(None)).group_by(Shout.id)
q = q.filter(Shout.deleted_at.is_(None)).group_by(Shout.id)
try:
[
shout,
viewed_stat,
reacted_stat,
commented_stat,
rating_stat,
@ -101,7 +99,7 @@ async def load_shout(_, _info, slug=None, shout_id=None):
] = session.execute(q).first()
shout.stat = {
"viewed": shout.views,
"viewed": viewed_stat,
"reacted": reacted_stat,
"commented": commented_stat,
"rating": rating_stat,
@ -134,7 +132,7 @@ async def load_shouts_by(_, info, options):
}
offset: 0
limit: 50
order_by: 'createdAt' | 'commented' | 'reacted' | 'rating'
order_by: 'created_at' | 'commented' | 'reacted' | 'rating'
order_by_desc: true
}
@ -147,7 +145,7 @@ async def load_shouts_by(_, info, options):
joinedload(Shout.authors),
joinedload(Shout.topics),
)
.where(Shout.deletedAt.is_(None))
.where(Shout.deleted_at.is_(None))
)
q = add_stat_columns(q)
@ -155,7 +153,7 @@ async def load_shouts_by(_, info, options):
author_id = info.context["author_id"]
q = apply_filters(q, options.get("filters", {}), author_id)
order_by = options.get("order_by", Shout.publishedAt)
order_by = options.get("order_by", Shout.published_at)
query_order_by = (
desc(order_by) if options.get("order_by_desc", True) else asc(order_by)
@ -175,6 +173,7 @@ async def load_shouts_by(_, info, options):
with local_session() as session:
for [
shout,
viewed_stat,
reacted_stat,
commented_stat,
rating_stat,
@ -182,7 +181,7 @@ async def load_shouts_by(_, info, options):
] in session.execute(q).unique():
shouts.append(shout)
shout.stat = {
"viewed": shout.views,
"viewed": viewed_stat,
"reacted": reacted_stat,
"commented": commented_stat,
"rating": rating_stat,
@ -196,12 +195,17 @@ async def load_shouts_by(_, info, options):
async def get_my_feed(_, info, options):
author_id = info.context["author_id"]
with local_session() as session:
author_followed_authors = select(AuthorFollower.author).where(AuthorFollower.follower == author_id)
author_followed_topics = select(TopicFollower.topic).where(TopicFollower.follower == author_id)
subquery = (
select(Shout.id)
.join(ShoutAuthor)
.join(AuthorFollower, AuthorFollower.follower._is(author_id))
.join(ShoutTopic)
.join(TopicFollower, TopicFollower.follower._is(author_id))
.where(Shout.id == ShoutAuthor.shout)
.where(Shout.id == ShoutTopic.shout)
.where(
(ShoutAuthor.author.in_(author_followed_authors))
| (ShoutTopic.topic.in_(author_followed_topics))
)
)
q = (
@ -212,8 +216,8 @@ async def get_my_feed(_, info, options):
)
.where(
and_(
Shout.publishedAt.is_not(None),
Shout.deletedAt.is_(None),
Shout.published_at.is_not(None),
Shout.deleted_at.is_(None),
Shout.id.in_(subquery),
)
)
@ -222,7 +226,7 @@ async def get_my_feed(_, info, options):
q = add_stat_columns(q)
q = apply_filters(q, options.get("filters", {}), author_id)
order_by = options.get("order_by", Shout.publishedAt)
order_by = options.get("order_by", Shout.published_at)
query_order_by = (
desc(order_by) if options.get("order_by_desc", True) else asc(order_by)
@ -238,7 +242,6 @@ async def get_my_feed(_, info, options):
)
shouts = []
shouts_map = {}
for [
shout,
reacted_stat,
@ -246,13 +249,11 @@ async def get_my_feed(_, info, options):
rating_stat,
_last_comment,
] in session.execute(q).unique():
shouts.append(shout)
shout.stat = {
"viewed": shout.views,
"viewed": ViewedStorage.get_shout(shout.slug),
"reacted": reacted_stat,
"commented": commented_stat,
"rating": rating_stat,
}
shouts_map[shout.id] = shout
# FIXME: shouts_map does not go anywhere?
shouts.append(shout)
return shouts

View File

@ -136,10 +136,7 @@ def topic_follow(follower_id, slug):
try:
with local_session() as session:
topic = session.query(Topic).where(Topic.slug == slug).one()
following = TopicFollower.create(topic=topic.id, follower=follower_id)
session.add(following)
session.commit()
_following = TopicFollower.create(topic=topic.id, follower=follower_id)
return True
except Exception:
return False

View File

@ -1,9 +1,3 @@
# Скалярные типы данных
scalar DateTime
# Перечисления
enum ShoutVisibility {
AUTHORS
COMMUNITY
@ -42,10 +36,145 @@ enum FollowingEntity {
REACTIONS
}
# Типы
type AuthorFollowings {
unread: Int
topics: [String]
authors: [String]
reactions: [Int]
communities: [String]
}
type AuthorStat {
followings: Int
followers: Int
rating: Int
commented: Int
shouts: 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
# ratings
stat: AuthorStat # synthetic
}
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
old_id: String
old_thread: String
}
type Shout {
id: Int!
slug: String!
body: String!
lead: String
description: String
created_at: Int!
topics: [Topic]
authors: [Author]
communities: [Community]
# mainTopic: String
title: String
subtitle: String
lang: String
community: String
cover: String
layout: String
version_of: String
visibility: ShoutVisibility
updated_at: Int
updated_by: Author
deleted_at: Int
deleted_by: Author
published_at: Int
media: String
stat: Stat
}
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!
}
type Topic {
id: Int!
slug: String!
title: String
body: String
pic: String
stat: TopicStat
oid: String
}
# Входные типы
input ShoutInput {
slug: String
title: String
@ -57,7 +186,6 @@ input ShoutInput {
authors: [String]
topics: [TopicInput]
community: Int
mainTopic: TopicInput
subtitle: String
cover: String
}
@ -65,7 +193,7 @@ input ShoutInput {
input ProfileInput {
slug: String
name: String
userpic: String
pic: String
links: [String]
bio: String
about: String
@ -84,12 +212,12 @@ input ReactionInput {
shout: Int!
range: String
body: String
replyTo: Int
reply_to: Int
}
input AuthorsBy {
lastSeen: DateTime
createdAt: DateTime
last_seen: Int
created_at: Int
slug: String
name: String
topic: String
@ -138,43 +266,12 @@ input ReactionBy {
search: String
comment: Boolean
topic: String
createdBy: String
created_by: String
days: Int
sort: String
}
# Типы
type AuthorFollowings {
unread: Int
topics: [String]
authors: [String]
reactions: [Int]
communities: [String]
}
type AuthorStat {
followings: Int
followers: Int
rating: Int
commented: Int
shouts: Int
}
type Author {
id: Int!
user: String!
slug: String!
name: String
pic: String
bio: String
about: String
links: [String]
stat: AuthorStat
lastSeen: DateTime
}
# output type
type Result {
error: String
@ -191,108 +288,6 @@ type Result {
communities: [Community]
}
type ReactionUpdating {
error: String
status: ReactionStatus
reaction: Reaction
}
type Rating {
rater: String!
value: Int!
}
type Reaction {
id: Int!
shout: Shout!
createdAt: DateTime!
createdBy: Author!
updatedAt: DateTime
deletedAt: DateTime
deletedBy: Author
range: String
kind: ReactionKind!
body: String
replyTo: Int
stat: Stat
old_id: String
old_thread: String
}
type Shout {
id: Int!
slug: String!
body: String!
lead: String
description: String
createdAt: DateTime!
topics: [Topic]
authors: [Author]
communities: [Community]
mainTopic: String
title: String
subtitle: String
lang: String
community: String
cover: String
layout: String
versionOf: String
visibility: ShoutVisibility
updatedAt: DateTime
updatedBy: Author
deletedAt: DateTime
deletedBy: Author
publishedAt: DateTime
media: String
stat: Stat
}
type Stat {
viewed: Int
reacted: Int
rating: Int
commented: Int
ranking: Int
}
type Community {
id: Int!
slug: String!
name: String!
desc: String
pic: String!
createdAt: DateTime!
createdBy: Author!
}
type Collection {
id: Int!
slug: String!
title: String!
desc: String
amount: Int
publishedAt: DateTime
createdAt: DateTime!
createdBy: Author!
}
type TopicStat {
shouts: Int!
followers: Int!
authors: Int!
}
type Topic {
id: Int!
slug: String!
title: String
body: String
pic: String
stat: TopicStat
oid: String
}
# Мутации
type Mutation {

View File

@ -1,23 +1,35 @@
from contextlib import contextmanager
import logging
from typing import TypeVar, Any, Dict, Generic, Callable
from sqlalchemy import create_engine, Column, Integer
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session
from sqlalchemy.orm import sessionmaker
from sqlalchemy.sql.schema import Table
from settings import DB_URL
engine = create_engine(
DB_URL, echo=False, pool_size=10, max_overflow=20
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
engine = create_engine(DB_URL, echo=False, pool_size=10, max_overflow=20)
Session = sessionmaker(bind=engine, expire_on_commit=False)
T = TypeVar("T")
REGISTRY: Dict[str, type] = {}
@contextmanager
def local_session():
return Session(bind=engine, expire_on_commit=False)
session = Session()
try:
yield session
session.commit()
except Exception as e:
print(f"[services.db] Error session: {e}")
session.rollback()
raise
finally:
session.close()
class Base(declarative_base()):
@ -36,21 +48,36 @@ class Base(declarative_base()):
@classmethod
def create(cls: Generic[T], **kwargs) -> Generic[T]:
instance = cls(**kwargs)
return instance.save()
try:
instance = cls(**kwargs)
return instance.save()
except Exception as e:
print(f"[services.db] Error create: {e}")
return None
def save(self) -> Generic[T]:
with local_session() as session:
session.add(self)
session.commit()
try:
session.add(self)
except Exception as e:
print(f"[services.db] Error save: {e}")
return self
def update(self, input):
column_names = self.__table__.columns.keys()
for (name, value) in input.items():
for name, value in input.items():
if name in column_names:
setattr(self, name, value)
with local_session() as session:
try:
session.commit()
except Exception as e:
print(f"[services.db] Error update: {e}")
def dict(self) -> Dict[str, Any]:
column_names = self.__table__.columns.keys()
return {c: getattr(self, c) for c in column_names}
try:
return {c: getattr(self, c) for c in column_names}
except Exception as e:
print(f"[services.db] Error dict: {e}")
return {}

View File

@ -40,7 +40,7 @@ class FollowingManager:
try:
async with FollowingManager.lock:
for entity in FollowingManager[kind]:
if payload.shout['createdBy'] == entity.uid:
if payload.shout['created_by'] == entity.uid:
entity.queue.put_nowait(payload)
except Exception as e:
print(Exception(e))

View File

@ -29,7 +29,7 @@ async def notify_shout(shout, action: str = "create"):
async def notify_follower(follower: dict, author_id: int, action: str = "follow"):
fields = follower.keys()
for k in fields:
if k not in ["id", "name", "slug", "userpic"]:
if k not in ["id", "name", "slug", "pic"]:
del follower[k]
channel_name = f"follower:{author_id}"
data = {

View File

@ -57,6 +57,7 @@ class ViewedStorage:
lock = asyncio.Lock()
by_shouts = {}
by_topics = {}
by_reactions = {}
views = None
pages = None
domains = None
@ -75,16 +76,16 @@ class ViewedStorage:
{"Authorization": "Bearer %s" % str(token)}, schema=schema_str
)
print(
"[stat] * authorized permanentely by ackee.discours.io: %s" % token
"[services.viewed] * authorized permanentely by ackee.discours.io: %s" % token
)
else:
print("[stat] * please set ACKEE_TOKEN")
print("[services.viewed] * please set ACKEE_TOKEN")
self.disabled = True
@staticmethod
async def update_pages():
"""query all the pages from ackee sorted by views count"""
print("[stat] ⎧ updating ackee pages data ---")
print("[services.viewed] ⎧ updating ackee pages data ---")
start = time.time()
self = ViewedStorage
try:
@ -100,12 +101,12 @@ class ViewedStorage:
await ViewedStorage.increment(slug, shouts[slug])
except Exception:
pass
print("[stat] ⎪ %d pages collected " % len(shouts.keys()))
print("[services.viewed] ⎪ %d pages collected " % len(shouts.keys()))
except Exception as e:
raise e
end = time.time()
print("[stat] ⎪ update_pages took %fs " % (end - start))
print("[services.viewed] ⎪ update_pages took %fs " % (end - start))
@staticmethod
async def get_facts():
@ -113,26 +114,19 @@ class ViewedStorage:
async with self.lock:
return self.client.execute_async(load_facts)
# unused yet
@staticmethod
async def get_shout(shout_slug):
"""getting shout views metric by slug"""
self = ViewedStorage
async with self.lock:
shout_views = self.by_shouts.get(shout_slug)
if not shout_views:
shout_views = 0
with local_session() as session:
try:
shout = (
session.query(Shout).where(Shout.slug == shout_slug).one()
)
self.by_shouts[shout_slug] = shout.views
self.update_topics(session, shout_slug)
except Exception as e:
raise e
return self.by_shouts.get(shout_slug, 0)
return shout_views
@staticmethod
async def get_reaction(shout_slug, reaction_id):
"""getting reaction views metric by slug"""
self = ViewedStorage
async with self.lock:
return self.by_reactions.get(shout_slug, {}).get(reaction_id, 0)
@staticmethod
async def get_topic(topic_slug):
@ -145,51 +139,36 @@ class ViewedStorage:
return topic_views
@staticmethod
def update_topics(session, shout_slug):
def update_topics( shout_slug):
"""updates topics counters by shout slug"""
self = ViewedStorage
for [shout_topic, topic] in (
session.query(ShoutTopic, Topic)
.join(Topic)
.join(Shout)
.where(Shout.slug == shout_slug)
.all()
):
if not self.by_topics.get(topic.slug):
self.by_topics[topic.slug] = {}
self.by_topics[topic.slug][shout_slug] = self.by_shouts[shout_slug]
with local_session() as session:
for [shout_topic, topic] in (
session.query(ShoutTopic, Topic)
.join(Topic)
.join(Shout)
.where(Shout.slug == shout_slug)
.all()
):
if not self.by_topics.get(topic.slug):
self.by_topics[topic.slug] = {}
self.by_topics[topic.slug][shout_slug] = self.by_shouts[shout_slug]
@staticmethod
async def increment(shout_slug, amount=1, viewer="ackee"):
"""the only way to change views counter"""
self = ViewedStorage
async with self.lock:
# TODO optimize, currenty we execute 1 DB transaction per shout
with local_session() as session:
shout = session.query(Shout).where(Shout.slug == shout_slug).one()
if viewer == "old-discours":
# this is needed for old db migration
if shout.viewsOld == amount:
print(f"[stat] ⎪ viewsOld amount: {amount}")
else:
print(
f"[stat] ⎪ viewsOld amount changed: {shout.viewsOld} --> {amount}"
)
shout.viewsOld = amount
else:
if shout.viewsAckee == amount:
print(f"[stat] ⎪ viewsAckee amount: {amount}")
else:
print(
f"[stat] ⎪ viewsAckee amount changed: {shout.viewsAckee} --> {amount}"
)
shout.viewsAckee = amount
self.by_shouts[shout_slug] = self.by_shouts.get(shout_slug, 0) + amount
self.update_topics(shout_slug)
session.commit()
# this part is currently unused
self.by_shouts[shout_slug] = self.by_shouts.get(shout_slug, 0) + amount
self.update_topics(session, shout_slug)
@staticmethod
async def increment_reaction(shout_slug, reaction_id, amount=1, viewer="ackee"):
"""the only way to change views counter"""
self = ViewedStorage
async with self.lock:
self.by_reactions[shout_slug][reaction_id] = self.by_reactions[shout_slug].get(reaction_id, 0) + amount
self.update_topics(shout_slug)
@staticmethod
async def worker():
@ -201,23 +180,23 @@ class ViewedStorage:
while True:
try:
print("[stat] - updating views...")
print("[services.viewed] - updating views...")
await self.update_pages()
failed = 0
except Exception:
failed += 1
print("[stat] - update failed #%d, wait 10 seconds" % failed)
print("[services.viewed] - update failed #%d, wait 10 seconds" % failed)
if failed > 3:
print("[stat] - not trying to update anymore")
print("[services.viewed] - not trying to update anymore")
break
if failed == 0:
when = datetime.now(timezone.utc) + timedelta(seconds=self.period)
t = format(when.astimezone().isoformat())
print(
"[stat] ⎩ next update: %s"
"[services.viewed] ⎩ next update: %s"
% (t.split("T")[0] + " " + t.split("T")[1].split(".")[0])
)
await asyncio.sleep(self.period)
else:
await asyncio.sleep(10)
print("[stat] - trying to update data again")
print("[services.viewed] - trying to update data again")

View File

@ -27,11 +27,11 @@
"id": 2,
"name": "Дискурс",
"slug": "discours",
"userpic": null
"pic": null
}
],
"createdAt": "2023-09-04T10:15:08.666569",
"publishedAt": "2023-09-04T12:35:20.024954",
"created_at": "2023-09-04T10:15:08.666569",
"published_at": "2023-09-04T12:35:20.024954",
"stat": {
"viewed": 6,
"reacted": null,