625 lines
20 KiB
Python
625 lines
20 KiB
Python
|
|
# app/db/repositories/articles.py
|
|||
|
|
from typing import List, Optional, Sequence, Tuple
|
|||
|
|
|
|||
|
|
from asyncpg import Connection, Record
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
from app.db.errors import EntityDoesNotExist
|
|||
|
|
from app.db.queries.queries import queries
|
|||
|
|
from app.db.repositories.base import BaseRepository
|
|||
|
|
from app.db.repositories.profiles import ProfilesRepository
|
|||
|
|
from app.db.repositories.tags import TagsRepository
|
|||
|
|
from app.models.domain.articles import Article
|
|||
|
|
from app.models.domain.users import User
|
|||
|
|
|
|||
|
|
AUTHOR_USERNAME_ALIAS = "author_username"
|
|||
|
|
SLUG_ALIAS = "slug"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ArticlesRepository(BaseRepository): # noqa: WPS214
|
|||
|
|
def __init__(self, conn: Connection) -> None:
|
|||
|
|
super().__init__(conn)
|
|||
|
|
self._profiles_repo = ProfilesRepository(conn)
|
|||
|
|
self._tags_repo = TagsRepository(conn)
|
|||
|
|
|
|||
|
|
# ===== 内部工具 =====
|
|||
|
|
|
|||
|
|
async def _ensure_article_flag_columns(self) -> None:
|
|||
|
|
"""
|
|||
|
|
给 articles 表补充置顶/推荐/权重字段,兼容旧库。
|
|||
|
|
多次执行使用 IF NOT EXISTS,不会抛错。
|
|||
|
|
"""
|
|||
|
|
await self.connection.execute(
|
|||
|
|
"""
|
|||
|
|
ALTER TABLE articles
|
|||
|
|
ADD COLUMN IF NOT EXISTS is_top BOOLEAN NOT NULL DEFAULT FALSE,
|
|||
|
|
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN NOT NULL DEFAULT FALSE,
|
|||
|
|
ADD COLUMN IF NOT EXISTS sort_weight INT NOT NULL DEFAULT 0;
|
|||
|
|
""",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def _try_delete_cover_file(self, cover: Optional[str]) -> None:
|
|||
|
|
"""
|
|||
|
|
清空 cover 时顺带删除 static/uploads 下的旧封面文件。
|
|||
|
|
"""
|
|||
|
|
if not cover:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
path_str = cover.lstrip("/")
|
|||
|
|
if not path_str.startswith("static/uploads/"):
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
p = Path(path_str)
|
|||
|
|
if not p.is_absolute():
|
|||
|
|
p = Path(".") / p
|
|||
|
|
|
|||
|
|
if p.is_file():
|
|||
|
|
p.unlink()
|
|||
|
|
except Exception:
|
|||
|
|
# 可以按需加日志,这里静默
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# ===== CRUD =====
|
|||
|
|
|
|||
|
|
async def create_article( # noqa: WPS211
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
slug: str,
|
|||
|
|
title: str,
|
|||
|
|
description: str,
|
|||
|
|
body: str,
|
|||
|
|
author: User,
|
|||
|
|
tags: Optional[Sequence[str]] = None,
|
|||
|
|
cover: Optional[str] = None,
|
|||
|
|
) -> Article:
|
|||
|
|
await self._ensure_article_flag_columns()
|
|||
|
|
|
|||
|
|
async with self.connection.transaction():
|
|||
|
|
article_row = await queries.create_new_article(
|
|||
|
|
self.connection,
|
|||
|
|
slug=slug,
|
|||
|
|
title=title,
|
|||
|
|
description=description,
|
|||
|
|
body=body,
|
|||
|
|
author_username=author.username,
|
|||
|
|
cover=cover,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if tags:
|
|||
|
|
await self._tags_repo.create_tags_that_dont_exist(tags=tags)
|
|||
|
|
await self._link_article_with_tags(slug=slug, tags=tags)
|
|||
|
|
|
|||
|
|
return await self._get_article_from_db_record(
|
|||
|
|
article_row=article_row,
|
|||
|
|
slug=slug,
|
|||
|
|
author_username=article_row[AUTHOR_USERNAME_ALIAS],
|
|||
|
|
requested_user=author,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def update_article( # noqa: WPS211
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
article: Article,
|
|||
|
|
slug: Optional[str] = None,
|
|||
|
|
title: Optional[str] = None,
|
|||
|
|
body: Optional[str] = None,
|
|||
|
|
description: Optional[str] = None,
|
|||
|
|
cover: Optional[str] = None,
|
|||
|
|
cover_provided: bool = False,
|
|||
|
|
) -> Article:
|
|||
|
|
"""
|
|||
|
|
cover_provided:
|
|||
|
|
- True 表示本次请求体里包含 cover 字段(可能是字符串/""/null)
|
|||
|
|
- False 表示前端没动 cover,保持不变
|
|||
|
|
"""
|
|||
|
|
await self._ensure_article_flag_columns()
|
|||
|
|
|
|||
|
|
updated_article = article.copy(deep=True)
|
|||
|
|
updated_article.slug = slug or updated_article.slug
|
|||
|
|
updated_article.title = title or article.title
|
|||
|
|
updated_article.body = body or article.body
|
|||
|
|
updated_article.description = description or article.description
|
|||
|
|
|
|||
|
|
old_cover = article.cover
|
|||
|
|
|
|||
|
|
if cover_provided:
|
|||
|
|
# 约定:None / "" 视为清空封面
|
|||
|
|
updated_article.cover = cover or None
|
|||
|
|
|
|||
|
|
async with self.connection.transaction():
|
|||
|
|
updated_row = await queries.update_article(
|
|||
|
|
self.connection,
|
|||
|
|
slug=article.slug,
|
|||
|
|
author_username=article.author.username,
|
|||
|
|
new_slug=updated_article.slug,
|
|||
|
|
new_title=updated_article.title,
|
|||
|
|
new_body=updated_article.body,
|
|||
|
|
new_description=updated_article.description,
|
|||
|
|
new_cover=updated_article.cover,
|
|||
|
|
)
|
|||
|
|
updated_article.updated_at = updated_row["updated_at"]
|
|||
|
|
|
|||
|
|
# 如果这次真的更新了 cover,并且旧值存在且发生变化,则尝试删除旧文件
|
|||
|
|
if cover_provided and old_cover and old_cover != updated_article.cover:
|
|||
|
|
self._try_delete_cover_file(old_cover)
|
|||
|
|
|
|||
|
|
return updated_article
|
|||
|
|
|
|||
|
|
async def delete_article(self, *, article: Article) -> None:
|
|||
|
|
await self._ensure_article_flag_columns()
|
|||
|
|
|
|||
|
|
async with self.connection.transaction():
|
|||
|
|
await queries.delete_article(
|
|||
|
|
self.connection,
|
|||
|
|
slug=article.slug,
|
|||
|
|
author_username=article.author.username,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if article.cover:
|
|||
|
|
self._try_delete_cover_file(article.cover)
|
|||
|
|
|
|||
|
|
async def filter_articles( # noqa: WPS211
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
tag: Optional[str] = None,
|
|||
|
|
tags: Optional[Sequence[str]] = None,
|
|||
|
|
author: Optional[str] = None,
|
|||
|
|
favorited: Optional[str] = None,
|
|||
|
|
search: Optional[str] = None,
|
|||
|
|
limit: int = 20,
|
|||
|
|
offset: int = 0,
|
|||
|
|
requested_user: Optional[User] = None,
|
|||
|
|
tag_mode: str = "and",
|
|||
|
|
) -> List[Article]:
|
|||
|
|
await self._ensure_article_flag_columns()
|
|||
|
|
|
|||
|
|
tag_list: List[str] = []
|
|||
|
|
if tags:
|
|||
|
|
tag_list.extend([t.strip() for t in tags if str(t).strip()])
|
|||
|
|
if tag:
|
|||
|
|
tag_list.append(tag.strip())
|
|||
|
|
# 去重,保留顺序
|
|||
|
|
seen = set()
|
|||
|
|
tag_list = [t for t in tag_list if not (t in seen or seen.add(t))]
|
|||
|
|
tag_mode = (tag_mode or "and").lower()
|
|||
|
|
if tag_mode not in ("and", "or"):
|
|||
|
|
tag_mode = "and"
|
|||
|
|
|
|||
|
|
params: List[object] = []
|
|||
|
|
joins: List[str] = ["LEFT JOIN users u ON u.id = a.author_id"]
|
|||
|
|
where_clauses: List[str] = []
|
|||
|
|
having_clause = ""
|
|||
|
|
|
|||
|
|
if author:
|
|||
|
|
params.append(author)
|
|||
|
|
where_clauses.append(f"u.username = ${len(params)}")
|
|||
|
|
|
|||
|
|
if favorited:
|
|||
|
|
params.append(favorited)
|
|||
|
|
joins.append(
|
|||
|
|
f"""JOIN favorites f
|
|||
|
|
ON f.article_id = a.id
|
|||
|
|
AND f.user_id = (SELECT id FROM users WHERE username = ${len(params)})""",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if tag_list:
|
|||
|
|
params.append(tag_list)
|
|||
|
|
joins.append(
|
|||
|
|
f"JOIN articles_to_tags att ON att.article_id = a.id AND att.tag = ANY(${len(params)})",
|
|||
|
|
)
|
|||
|
|
# AND 逻辑:命中全部 tag
|
|||
|
|
if tag_mode == "and":
|
|||
|
|
having_clause = f"HAVING COUNT(DISTINCT att.tag) >= {len(tag_list)}"
|
|||
|
|
|
|||
|
|
if search:
|
|||
|
|
params.append(f"%{search}%")
|
|||
|
|
where_clauses.append(
|
|||
|
|
f"(a.title ILIKE ${len(params)} OR a.description ILIKE ${len(params)} OR a.slug ILIKE ${len(params)})",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
|||
|
|
|
|||
|
|
limit_idx = len(params) + 1
|
|||
|
|
offset_idx = len(params) + 2
|
|||
|
|
params.extend([limit, offset])
|
|||
|
|
|
|||
|
|
group_cols = ", ".join(
|
|||
|
|
[
|
|||
|
|
"a.id",
|
|||
|
|
"a.slug",
|
|||
|
|
"a.title",
|
|||
|
|
"a.description",
|
|||
|
|
"a.body",
|
|||
|
|
"a.cover",
|
|||
|
|
"a.views",
|
|||
|
|
"a.created_at",
|
|||
|
|
"a.updated_at",
|
|||
|
|
"a.is_top",
|
|||
|
|
"a.is_featured",
|
|||
|
|
"a.sort_weight",
|
|||
|
|
"u.username",
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
sql = f"""
|
|||
|
|
SELECT
|
|||
|
|
a.id,
|
|||
|
|
a.slug,
|
|||
|
|
a.title,
|
|||
|
|
a.description,
|
|||
|
|
a.body,
|
|||
|
|
a.cover,
|
|||
|
|
a.views,
|
|||
|
|
a.created_at,
|
|||
|
|
a.updated_at,
|
|||
|
|
a.is_top,
|
|||
|
|
a.is_featured,
|
|||
|
|
a.sort_weight,
|
|||
|
|
u.username AS {AUTHOR_USERNAME_ALIAS}
|
|||
|
|
FROM articles a
|
|||
|
|
{' '.join(joins)}
|
|||
|
|
{where_sql}
|
|||
|
|
GROUP BY {group_cols}
|
|||
|
|
{having_clause}
|
|||
|
|
ORDER BY a.is_top DESC, a.sort_weight DESC, a.updated_at DESC, a.created_at DESC
|
|||
|
|
LIMIT ${limit_idx}
|
|||
|
|
OFFSET ${offset_idx}
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
articles_rows = await self.connection.fetch(sql, *params)
|
|||
|
|
|
|||
|
|
return [
|
|||
|
|
await self._get_article_from_db_record(
|
|||
|
|
article_row=article_row,
|
|||
|
|
slug=article_row[SLUG_ALIAS],
|
|||
|
|
author_username=article_row[AUTHOR_USERNAME_ALIAS],
|
|||
|
|
requested_user=requested_user,
|
|||
|
|
)
|
|||
|
|
for article_row in articles_rows
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
async def list_articles_by_slugs(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
slugs: Sequence[str],
|
|||
|
|
requested_user: Optional[User] = None,
|
|||
|
|
) -> List[Article]:
|
|||
|
|
"""
|
|||
|
|
按给定顺序批量获取文章;缺失的 slug 会被忽略。
|
|||
|
|
"""
|
|||
|
|
if not slugs:
|
|||
|
|
return []
|
|||
|
|
await self._ensure_article_flag_columns()
|
|||
|
|
unique_slugs: List[str] = []
|
|||
|
|
for slug in slugs:
|
|||
|
|
if slug not in unique_slugs:
|
|||
|
|
unique_slugs.append(slug)
|
|||
|
|
|
|||
|
|
rows = await self.connection.fetch(
|
|||
|
|
f"""
|
|||
|
|
SELECT
|
|||
|
|
a.id,
|
|||
|
|
a.slug,
|
|||
|
|
a.title,
|
|||
|
|
a.description,
|
|||
|
|
a.body,
|
|||
|
|
a.cover,
|
|||
|
|
a.views,
|
|||
|
|
a.is_top,
|
|||
|
|
a.is_featured,
|
|||
|
|
a.sort_weight,
|
|||
|
|
a.created_at,
|
|||
|
|
a.updated_at,
|
|||
|
|
u.username AS {AUTHOR_USERNAME_ALIAS}
|
|||
|
|
FROM articles a
|
|||
|
|
LEFT JOIN users u ON u.id = a.author_id
|
|||
|
|
WHERE a.slug = ANY($1::text[])
|
|||
|
|
ORDER BY array_position($1::text[], a.slug)
|
|||
|
|
""",
|
|||
|
|
unique_slugs,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
articles: List[Article] = []
|
|||
|
|
for row in rows:
|
|||
|
|
articles.append(
|
|||
|
|
await self._get_article_from_db_record(
|
|||
|
|
article_row=row,
|
|||
|
|
slug=row[SLUG_ALIAS],
|
|||
|
|
author_username=row[AUTHOR_USERNAME_ALIAS],
|
|||
|
|
requested_user=requested_user,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
return articles
|
|||
|
|
|
|||
|
|
async def get_articles_for_user_feed(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
user: User,
|
|||
|
|
limit: int = 20,
|
|||
|
|
offset: int = 0,
|
|||
|
|
) -> List[Article]:
|
|||
|
|
await self._ensure_article_flag_columns()
|
|||
|
|
|
|||
|
|
articles_rows = await queries.get_articles_for_feed(
|
|||
|
|
self.connection,
|
|||
|
|
follower_username=user.username,
|
|||
|
|
limit=limit,
|
|||
|
|
offset=offset,
|
|||
|
|
)
|
|||
|
|
return [
|
|||
|
|
await self._get_article_from_db_record(
|
|||
|
|
article_row=article_row,
|
|||
|
|
slug=article_row[SLUG_ALIAS],
|
|||
|
|
author_username=article_row[AUTHOR_USERNAME_ALIAS],
|
|||
|
|
requested_user=user,
|
|||
|
|
)
|
|||
|
|
for article_row in articles_rows
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
async def get_article_by_slug(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
slug: str,
|
|||
|
|
requested_user: Optional[User] = None,
|
|||
|
|
) -> Article:
|
|||
|
|
await self._ensure_article_flag_columns()
|
|||
|
|
|
|||
|
|
article_row = await queries.get_article_by_slug(self.connection, slug=slug)
|
|||
|
|
if article_row:
|
|||
|
|
return await self._get_article_from_db_record(
|
|||
|
|
article_row=article_row,
|
|||
|
|
slug=article_row[SLUG_ALIAS],
|
|||
|
|
author_username=article_row[AUTHOR_USERNAME_ALIAS],
|
|||
|
|
requested_user=requested_user,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
raise EntityDoesNotExist(f"article with slug {slug} does not exist")
|
|||
|
|
|
|||
|
|
async def get_tags_for_article_by_slug(self, *, slug: str) -> List[str]:
|
|||
|
|
tag_rows = await queries.get_tags_for_article_by_slug(
|
|||
|
|
self.connection,
|
|||
|
|
slug=slug,
|
|||
|
|
)
|
|||
|
|
return [row["tag"] for row in tag_rows]
|
|||
|
|
|
|||
|
|
async def get_favorites_count_for_article_by_slug(self, *, slug: str) -> int:
|
|||
|
|
return (
|
|||
|
|
await queries.get_favorites_count_for_article(self.connection, slug=slug)
|
|||
|
|
)["favorites_count"]
|
|||
|
|
|
|||
|
|
async def is_article_favorited_by_user(self, *, slug: str, user: User) -> bool:
|
|||
|
|
return (
|
|||
|
|
await queries.is_article_in_favorites(
|
|||
|
|
self.connection,
|
|||
|
|
username=user.username,
|
|||
|
|
slug=slug,
|
|||
|
|
)
|
|||
|
|
)["favorited"]
|
|||
|
|
|
|||
|
|
async def add_article_into_favorites(self, *, article: Article, user: User) -> None:
|
|||
|
|
await queries.add_article_to_favorites(
|
|||
|
|
self.connection,
|
|||
|
|
username=user.username,
|
|||
|
|
slug=article.slug,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def remove_article_from_favorites(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
article: Article,
|
|||
|
|
user: User,
|
|||
|
|
) -> None:
|
|||
|
|
await queries.remove_article_from_favorites(
|
|||
|
|
self.connection,
|
|||
|
|
username=user.username,
|
|||
|
|
slug=article.slug,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def _get_article_from_db_record(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
article_row: Record,
|
|||
|
|
slug: str,
|
|||
|
|
author_username: str,
|
|||
|
|
requested_user: Optional[User],
|
|||
|
|
) -> Article:
|
|||
|
|
cover = article_row.get("cover") if "cover" in article_row else None
|
|||
|
|
views = article_row.get("views", 0)
|
|||
|
|
is_top = bool(article_row.get("is_top", False))
|
|||
|
|
is_featured = bool(article_row.get("is_featured", False))
|
|||
|
|
sort_weight = int(article_row.get("sort_weight", 0) or 0)
|
|||
|
|
|
|||
|
|
return Article(
|
|||
|
|
id_=article_row["id"],
|
|||
|
|
slug=slug,
|
|||
|
|
title=article_row["title"],
|
|||
|
|
description=article_row["description"],
|
|||
|
|
body=article_row["body"],
|
|||
|
|
cover=cover,
|
|||
|
|
is_top=is_top,
|
|||
|
|
is_featured=is_featured,
|
|||
|
|
sort_weight=sort_weight,
|
|||
|
|
views=views,
|
|||
|
|
author=await self._profiles_repo.get_profile_by_username(
|
|||
|
|
username=author_username,
|
|||
|
|
requested_user=requested_user,
|
|||
|
|
),
|
|||
|
|
tags=await self.get_tags_for_article_by_slug(slug=slug),
|
|||
|
|
favorites_count=await self.get_favorites_count_for_article_by_slug(
|
|||
|
|
slug=slug,
|
|||
|
|
),
|
|||
|
|
favorited=await self.is_article_favorited_by_user(
|
|||
|
|
slug=slug,
|
|||
|
|
user=requested_user,
|
|||
|
|
)
|
|||
|
|
if requested_user
|
|||
|
|
else False,
|
|||
|
|
created_at=article_row["created_at"],
|
|||
|
|
updated_at=article_row["updated_at"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def increment_article_views(self, *, slug: str) -> int:
|
|||
|
|
result = await queries.increment_article_views(self.connection, slug=slug)
|
|||
|
|
return result["views"]
|
|||
|
|
|
|||
|
|
async def _link_article_with_tags(self, *, slug: str, tags: Sequence[str]) -> None:
|
|||
|
|
"""
|
|||
|
|
把 tag 列表绑定到文章。
|
|||
|
|
"""
|
|||
|
|
for tag in tags:
|
|||
|
|
await queries.add_tags_to_article(
|
|||
|
|
self.connection,
|
|||
|
|
slug=slug,
|
|||
|
|
tag=tag,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def list_articles_for_admin(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
search: Optional[str] = None,
|
|||
|
|
author: Optional[str] = None,
|
|||
|
|
limit: int = 20,
|
|||
|
|
offset: int = 0,
|
|||
|
|
) -> Tuple[List[Article], int]:
|
|||
|
|
await self._ensure_article_flag_columns()
|
|||
|
|
|
|||
|
|
clauses: List[str] = []
|
|||
|
|
params: List[object] = []
|
|||
|
|
|
|||
|
|
if author:
|
|||
|
|
placeholder = f"${len(params) + 1}"
|
|||
|
|
params.append(author)
|
|||
|
|
clauses.append(f"u.username = {placeholder}")
|
|||
|
|
|
|||
|
|
if search:
|
|||
|
|
placeholder = f"${len(params) + 1}"
|
|||
|
|
params.append(f"%{search}%")
|
|||
|
|
clauses.append(
|
|||
|
|
f"(a.title ILIKE {placeholder} OR a.slug ILIKE {placeholder} OR a.description ILIKE {placeholder})",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
where_sql = ""
|
|||
|
|
if clauses:
|
|||
|
|
where_sql = "WHERE " + " AND ".join(clauses)
|
|||
|
|
|
|||
|
|
count_sql = f"""
|
|||
|
|
SELECT COUNT(*)
|
|||
|
|
FROM articles a
|
|||
|
|
LEFT JOIN users u ON u.id = a.author_id
|
|||
|
|
{where_sql}
|
|||
|
|
"""
|
|||
|
|
total = await self.connection.fetchval(count_sql, *params)
|
|||
|
|
|
|||
|
|
list_params = list(params)
|
|||
|
|
list_params.extend([limit, offset])
|
|||
|
|
list_sql = f"""
|
|||
|
|
SELECT
|
|||
|
|
a.id,
|
|||
|
|
a.slug,
|
|||
|
|
a.title,
|
|||
|
|
a.description,
|
|||
|
|
a.body,
|
|||
|
|
a.cover,
|
|||
|
|
a.views,
|
|||
|
|
a.created_at,
|
|||
|
|
a.updated_at,
|
|||
|
|
a.is_top,
|
|||
|
|
a.is_featured,
|
|||
|
|
a.sort_weight,
|
|||
|
|
u.username AS {AUTHOR_USERNAME_ALIAS}
|
|||
|
|
FROM articles a
|
|||
|
|
LEFT JOIN users u ON u.id = a.author_id
|
|||
|
|
{where_sql}
|
|||
|
|
ORDER BY a.is_top DESC, a.sort_weight DESC, a.updated_at DESC, a.created_at DESC
|
|||
|
|
LIMIT ${len(params) + 1}
|
|||
|
|
OFFSET ${len(params) + 2}
|
|||
|
|
"""
|
|||
|
|
rows = await self.connection.fetch(list_sql, *list_params)
|
|||
|
|
articles = [
|
|||
|
|
await self._get_article_from_db_record(
|
|||
|
|
article_row=row,
|
|||
|
|
slug=row[SLUG_ALIAS],
|
|||
|
|
author_username=row[AUTHOR_USERNAME_ALIAS],
|
|||
|
|
requested_user=None,
|
|||
|
|
)
|
|||
|
|
for row in rows
|
|||
|
|
]
|
|||
|
|
return articles, int(total or 0)
|
|||
|
|
|
|||
|
|
async def admin_update_article(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
article: Article,
|
|||
|
|
slug: Optional[str] = None,
|
|||
|
|
title: Optional[str] = None,
|
|||
|
|
body: Optional[str] = None,
|
|||
|
|
description: Optional[str] = None,
|
|||
|
|
cover: Optional[str] = None,
|
|||
|
|
is_top: Optional[bool] = None,
|
|||
|
|
is_featured: Optional[bool] = None,
|
|||
|
|
sort_weight: Optional[int] = None,
|
|||
|
|
cover_provided: bool = False,
|
|||
|
|
) -> Article:
|
|||
|
|
await self._ensure_article_flag_columns()
|
|||
|
|
|
|||
|
|
updated_article = article.copy(deep=True)
|
|||
|
|
updated_article.slug = slug or updated_article.slug
|
|||
|
|
updated_article.title = title or article.title
|
|||
|
|
updated_article.body = body or article.body
|
|||
|
|
updated_article.description = description or article.description
|
|||
|
|
|
|||
|
|
if is_top is not None:
|
|||
|
|
updated_article.is_top = is_top
|
|||
|
|
if is_featured is not None:
|
|||
|
|
updated_article.is_featured = is_featured
|
|||
|
|
if sort_weight is not None:
|
|||
|
|
updated_article.sort_weight = sort_weight
|
|||
|
|
|
|||
|
|
old_cover = article.cover
|
|||
|
|
if cover_provided:
|
|||
|
|
updated_article.cover = cover or None
|
|||
|
|
|
|||
|
|
async with self.connection.transaction():
|
|||
|
|
updated_row = await self.connection.fetchrow(
|
|||
|
|
"""
|
|||
|
|
UPDATE articles
|
|||
|
|
SET slug = COALESCE($2, slug),
|
|||
|
|
title = COALESCE($3, title),
|
|||
|
|
body = COALESCE($4, body),
|
|||
|
|
description = COALESCE($5, description),
|
|||
|
|
cover = $6,
|
|||
|
|
is_top = COALESCE($7, is_top),
|
|||
|
|
is_featured = COALESCE($8, is_featured),
|
|||
|
|
sort_weight = COALESCE($9, sort_weight)
|
|||
|
|
WHERE id = $1
|
|||
|
|
RETURNING updated_at
|
|||
|
|
""",
|
|||
|
|
article.id_,
|
|||
|
|
updated_article.slug,
|
|||
|
|
updated_article.title,
|
|||
|
|
updated_article.body,
|
|||
|
|
updated_article.description,
|
|||
|
|
updated_article.cover,
|
|||
|
|
updated_article.is_top,
|
|||
|
|
updated_article.is_featured,
|
|||
|
|
updated_article.sort_weight,
|
|||
|
|
)
|
|||
|
|
updated_article.updated_at = updated_row["updated_at"]
|
|||
|
|
|
|||
|
|
if cover_provided and old_cover and old_cover != updated_article.cover:
|
|||
|
|
self._try_delete_cover_file(old_cover)
|
|||
|
|
|
|||
|
|
return updated_article
|
|||
|
|
|
|||
|
|
async def admin_delete_article(self, *, article: Article) -> None:
|
|||
|
|
await self._ensure_article_flag_columns()
|
|||
|
|
|
|||
|
|
async with self.connection.transaction():
|
|||
|
|
await self.connection.execute(
|
|||
|
|
"DELETE FROM articles WHERE id = $1",
|
|||
|
|
article.id_,
|
|||
|
|
)
|
|||
|
|
if article.cover:
|
|||
|
|
self._try_delete_cover_file(article.cover)
|