Add slug support for Article
This commit is contained in:
parent
fd5293a05c
commit
3d049da2e5
4 changed files with 188 additions and 41 deletions
|
@ -0,0 +1,48 @@
|
||||||
|
"""Add a slug field for outbox objects
|
||||||
|
|
||||||
|
Revision ID: b28c0551c236
|
||||||
|
Revises: 604d125ea2fb
|
||||||
|
Create Date: 2022-10-30 14:09:14.540461+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b28c0551c236'
|
||||||
|
down_revision = '604d125ea2fb'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('outbox', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('slug', sa.String(), nullable=True))
|
||||||
|
batch_op.create_index(batch_op.f('ix_outbox_slug'), ['slug'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
# Backfill the slug for existing articles
|
||||||
|
from app.models import OutboxObject
|
||||||
|
from app.utils.text import slugify
|
||||||
|
sess = Session(op.get_bind())
|
||||||
|
articles = sess.execute(select(OutboxObject).where(
|
||||||
|
OutboxObject.ap_type == "Article")
|
||||||
|
).scalars()
|
||||||
|
for article in articles:
|
||||||
|
title = article.ap_object["name"]
|
||||||
|
article.slug = slugify(title)
|
||||||
|
sess.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('outbox', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_outbox_slug'))
|
||||||
|
batch_op.drop_column('slug')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
11
app/boxes.py
11
app/boxes.py
|
@ -41,6 +41,7 @@ from app.utils import webmentions
|
||||||
from app.utils.datetime import as_utc
|
from app.utils.datetime import as_utc
|
||||||
from app.utils.datetime import now
|
from app.utils.datetime import now
|
||||||
from app.utils.datetime import parse_isoformat
|
from app.utils.datetime import parse_isoformat
|
||||||
|
from app.utils.text import slugify
|
||||||
|
|
||||||
AnyboxObject = models.InboxObject | models.OutboxObject
|
AnyboxObject = models.InboxObject | models.OutboxObject
|
||||||
|
|
||||||
|
@ -63,6 +64,7 @@ async def save_outbox_object(
|
||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
is_transient: bool = False,
|
is_transient: bool = False,
|
||||||
conversation: str | None = None,
|
conversation: str | None = None,
|
||||||
|
slug: str | None = None,
|
||||||
) -> models.OutboxObject:
|
) -> models.OutboxObject:
|
||||||
ro = await RemoteObject.from_raw_object(raw_object)
|
ro = await RemoteObject.from_raw_object(raw_object)
|
||||||
|
|
||||||
|
@ -82,6 +84,7 @@ async def save_outbox_object(
|
||||||
source=source,
|
source=source,
|
||||||
is_transient=is_transient,
|
is_transient=is_transient,
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
|
slug=slug,
|
||||||
)
|
)
|
||||||
db_session.add(outbox_object)
|
db_session.add(outbox_object)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
|
@ -614,6 +617,9 @@ async def send_create(
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unhandled visibility {visibility}")
|
raise ValueError(f"Unhandled visibility {visibility}")
|
||||||
|
|
||||||
|
slug = None
|
||||||
|
url = outbox_object_id(note_id)
|
||||||
|
|
||||||
extra_obj_attrs = {}
|
extra_obj_attrs = {}
|
||||||
if ap_type == "Question":
|
if ap_type == "Question":
|
||||||
if not poll_answers or len(poll_answers) < 2:
|
if not poll_answers or len(poll_answers) < 2:
|
||||||
|
@ -643,6 +649,8 @@ async def send_create(
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError("Article must have a name")
|
raise ValueError("Article must have a name")
|
||||||
|
|
||||||
|
slug = slugify(name)
|
||||||
|
url = f"{BASE_URL}/articles/{note_id[:7]}/{slug}"
|
||||||
extra_obj_attrs = {"name": name}
|
extra_obj_attrs = {"name": name}
|
||||||
|
|
||||||
obj = {
|
obj = {
|
||||||
|
@ -656,7 +664,7 @@ async def send_create(
|
||||||
"published": published,
|
"published": published,
|
||||||
"context": context,
|
"context": context,
|
||||||
"conversation": context,
|
"conversation": context,
|
||||||
"url": outbox_object_id(note_id),
|
"url": url,
|
||||||
"tag": dedup_tags(tags),
|
"tag": dedup_tags(tags),
|
||||||
"summary": content_warning,
|
"summary": content_warning,
|
||||||
"inReplyTo": in_reply_to,
|
"inReplyTo": in_reply_to,
|
||||||
|
@ -670,6 +678,7 @@ async def send_create(
|
||||||
obj,
|
obj,
|
||||||
source=source,
|
source=source,
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
|
slug=slug,
|
||||||
)
|
)
|
||||||
if not outbox_object.id:
|
if not outbox_object.id:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
|
162
app/main.py
162
app/main.py
|
@ -632,13 +632,75 @@ async def _check_outbox_object_acl(
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_likes(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
outbox_object: models.OutboxObject,
|
||||||
|
) -> list[models.InboxObject]:
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
await db_session.scalars(
|
||||||
|
select(models.InboxObject)
|
||||||
|
.where(
|
||||||
|
models.InboxObject.ap_type == "Like",
|
||||||
|
models.InboxObject.activity_object_ap_id == outbox_object.ap_id,
|
||||||
|
models.InboxObject.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.options(joinedload(models.InboxObject.actor))
|
||||||
|
.order_by(models.InboxObject.ap_published_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_shares(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
outbox_object: models.OutboxObject,
|
||||||
|
) -> list[models.InboxObject]:
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
await db_session.scalars(
|
||||||
|
select(models.InboxObject)
|
||||||
|
.filter(
|
||||||
|
models.InboxObject.ap_type == "Announce",
|
||||||
|
models.InboxObject.activity_object_ap_id == outbox_object.ap_id,
|
||||||
|
models.InboxObject.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.options(joinedload(models.InboxObject.actor))
|
||||||
|
.order_by(models.InboxObject.ap_published_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_webmentions(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
outbox_object: models.OutboxObject,
|
||||||
|
) -> list[models.Webmention]:
|
||||||
|
return (
|
||||||
|
await db_session.scalars(
|
||||||
|
select(models.Webmention)
|
||||||
|
.filter(
|
||||||
|
models.Webmention.outbox_object_id == outbox_object.id,
|
||||||
|
models.Webmention.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/o/{public_id}")
|
@app.get("/o/{public_id}")
|
||||||
async def outbox_by_public_id(
|
async def outbox_by_public_id(
|
||||||
public_id: str,
|
public_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||||
) -> ActivityPubResponse | templates.TemplateResponse:
|
) -> ActivityPubResponse | templates.TemplateResponse | RedirectResponse:
|
||||||
maybe_object = (
|
maybe_object = (
|
||||||
(
|
(
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
|
@ -665,59 +727,79 @@ async def outbox_by_public_id(
|
||||||
if is_activitypub_requested(request):
|
if is_activitypub_requested(request):
|
||||||
return ActivityPubResponse(maybe_object.ap_object)
|
return ActivityPubResponse(maybe_object.ap_object)
|
||||||
|
|
||||||
|
if maybe_object.ap_type == "Article":
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/articles/{public_id[:7]}/{maybe_object.slug}",
|
||||||
|
status_code=301,
|
||||||
|
)
|
||||||
|
|
||||||
replies_tree = await boxes.get_replies_tree(
|
replies_tree = await boxes.get_replies_tree(
|
||||||
db_session,
|
db_session,
|
||||||
maybe_object,
|
maybe_object,
|
||||||
is_current_user_admin=is_current_user_admin(request),
|
is_current_user_admin=is_current_user_admin(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
likes = (
|
likes = await _fetch_likes(db_session, maybe_object)
|
||||||
|
shares = await _fetch_shares(db_session, maybe_object)
|
||||||
|
webmentions = await _fetch_webmentions(db_session, maybe_object)
|
||||||
|
return await templates.render_template(
|
||||||
|
db_session,
|
||||||
|
request,
|
||||||
|
"object.html",
|
||||||
|
{
|
||||||
|
"replies_tree": replies_tree,
|
||||||
|
"outbox_object": maybe_object,
|
||||||
|
"likes": likes,
|
||||||
|
"shares": shares,
|
||||||
|
"webmentions": webmentions,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/articles/{short_id}/{slug}")
|
||||||
|
async def article_by_slug(
|
||||||
|
short_id: str,
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||||
|
) -> ActivityPubResponse | templates.TemplateResponse | RedirectResponse:
|
||||||
|
maybe_object = (
|
||||||
(
|
(
|
||||||
await db_session.scalars(
|
await db_session.execute(
|
||||||
select(models.InboxObject)
|
select(models.OutboxObject)
|
||||||
|
.options(
|
||||||
|
joinedload(models.OutboxObject.outbox_object_attachments).options(
|
||||||
|
joinedload(models.OutboxObjectAttachment.upload)
|
||||||
|
)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
models.InboxObject.ap_type == "Like",
|
models.OutboxObject.public_id.like(f"{short_id}%"),
|
||||||
models.InboxObject.activity_object_ap_id == maybe_object.ap_id,
|
models.OutboxObject.slug == slug,
|
||||||
models.InboxObject.is_deleted.is_(False),
|
models.OutboxObject.is_deleted.is_(False),
|
||||||
)
|
)
|
||||||
.options(joinedload(models.InboxObject.actor))
|
|
||||||
.order_by(models.InboxObject.ap_published_at.desc())
|
|
||||||
.limit(10)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.unique()
|
.unique()
|
||||||
.all()
|
.scalar_one_or_none()
|
||||||
|
)
|
||||||
|
if not maybe_object:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
await _check_outbox_object_acl(request, db_session, maybe_object, httpsig_info)
|
||||||
|
|
||||||
|
if is_activitypub_requested(request):
|
||||||
|
return ActivityPubResponse(maybe_object.ap_object)
|
||||||
|
|
||||||
|
replies_tree = await boxes.get_replies_tree(
|
||||||
|
db_session,
|
||||||
|
maybe_object,
|
||||||
|
is_current_user_admin=is_current_user_admin(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
shares = (
|
likes = await _fetch_likes(db_session, maybe_object)
|
||||||
(
|
shares = await _fetch_shares(db_session, maybe_object)
|
||||||
await db_session.scalars(
|
webmentions = await _fetch_webmentions(db_session, maybe_object)
|
||||||
select(models.InboxObject)
|
|
||||||
.filter(
|
|
||||||
models.InboxObject.ap_type == "Announce",
|
|
||||||
models.InboxObject.activity_object_ap_id == maybe_object.ap_id,
|
|
||||||
models.InboxObject.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
.options(joinedload(models.InboxObject.actor))
|
|
||||||
.order_by(models.InboxObject.ap_published_at.desc())
|
|
||||||
.limit(10)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.unique()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
webmentions = (
|
|
||||||
await db_session.scalars(
|
|
||||||
select(models.Webmention)
|
|
||||||
.filter(
|
|
||||||
models.Webmention.outbox_object_id == maybe_object.id,
|
|
||||||
models.Webmention.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
.limit(10)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
return await templates.render_template(
|
return await templates.render_template(
|
||||||
db_session,
|
db_session,
|
||||||
request,
|
request,
|
||||||
|
|
|
@ -158,6 +158,7 @@ class OutboxObject(Base, BaseObject):
|
||||||
is_hidden_from_homepage = Column(Boolean, nullable=False, default=False)
|
is_hidden_from_homepage = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
public_id = Column(String, nullable=False, index=True)
|
public_id = Column(String, nullable=False, index=True)
|
||||||
|
slug = Column(String, nullable=True, index=True)
|
||||||
|
|
||||||
ap_type = Column(String, nullable=False, index=True)
|
ap_type = Column(String, nullable=False, index=True)
|
||||||
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
|
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
@ -281,6 +282,13 @@ class OutboxObject(Base, BaseObject):
|
||||||
def is_from_outbox(self) -> bool:
|
def is_from_outbox(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str | None:
|
||||||
|
# XXX: rewrite old URL here for compat
|
||||||
|
if self.ap_type == "Article" and self.slug and self.public_id:
|
||||||
|
return f"{BASE_URL}/articles/{self.public_id[:7]}/{self.slug}"
|
||||||
|
return super().url
|
||||||
|
|
||||||
|
|
||||||
class Follower(Base):
|
class Follower(Base):
|
||||||
__tablename__ = "follower"
|
__tablename__ = "follower"
|
||||||
|
|
Loading…
Reference in a new issue