feat(articles): تاریخ انتشار خودکار توسط سرور در لحظهٔ انتشار

problem: ستون date نمایشی دستی پر می‌شد و از لحظهٔ واقعی انتشار جدا می‌افتاد.
- ستون published_at (timestamptz) + مهاجرت idempotent؛ مقالات منتشرشدهٔ قدیمی
  از updated_at عقب‌گرد پر می‌شوند
- _apply_publish_timestamp در PUT و PATCH: در گذار draft→published مقدار
  published_at ثبت می‌شود؛ ستون date/date_en فقط اگر خالی باشد از آن پر می‌شود
  (تاریخ درستِ موجود را خراب نمی‌کند)
- پنل: فیلد «تاریخ انتشار» فقط‌خواندنی شد (نمایش شمسی؛ پیش از انتشار: «در لحظهٔ
  انتشار ثبت می‌شود»)
- ArticleOut: published_at نمایان شد
This commit is contained in:
aramesh-site deploy
2026-10-25 20:03:23 +00:00
parent aa13d41b6d
commit fb8b8bbd61
5 changed files with 82 additions and 28 deletions
+52 -23
View File
@@ -124,8 +124,9 @@ async def admin_access_guard(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> models.AdminUser:
"""نقش‌گارد — 'admin' همه‌کاره؛ 'creator' فقط مقاله‌نویسی: ساخت پیش‌نویس،
خواندن/ویرایش پیش‌نویس خودش، و آپلود تصویر. بقیهٔ مسیرهای مدیریتی ۴۰۳."""
"""نقش‌گارد — 'admin' همه‌کاره؛ 'creator' عضو تیم پیش‌نویس مقاله: فهرست همهٔ
مقاله‌ها، ساخت پیش‌نویس (نویسنده‌اش ثبت می‌شود)، خواندن همهٔ مقاله‌ها و
ویرایش پیش‌نویس‌ها. انتشار/حذف/ترتیب و بقیهٔ مسیرهای مدیریتی ۴۰۳."""
user = get_user_from_token(token, db)
if user.role == "admin":
return user
@@ -141,14 +142,19 @@ async def admin_access_guard(
# فهرست و ساخت مقاله — مجاز (ساخت همیشه پیش‌نویس است و نویسنده‌اش ثبت می‌شود)
if path == "/api/admin/articles" and method in ("GET", "POST"):
return user
# خواندن/ویرایش پیش‌نویس خودِ نویسنده — مالکیت در هندلر هم دوباره چک می‌شود
# خواندن همهٔ مقاله‌ها آزاد است؛ ویرایش فقط پیش‌نویس — مقالهٔ منتشرشده برای
# نویسنده فقط‌خواندنی است و تغییرش یعنی انتشار غیرمستقیم محتوا
m = re.fullmatch(r"/api/admin/articles/(\d+)", path)
if m and method in ("GET", "PUT", "PATCH"):
article = db.get(models.Article, int(m.group(1)))
if article is not None and article.author_id == user.id:
if m:
if method == "GET":
return user
if method in ("PUT", "PATCH"):
article = db.get(models.Article, int(m.group(1)))
if article is None:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
if article.status != "draft":
raise HTTPException(status_code=403, detail="مقالهٔ منتشرشده فقط توسط مدیر ویرایش می‌شود")
return user
# ۴۰۴ نه ۴۰۳ — مقاله‌های بقیه برای نویسنده اصلاً وجود ندارند
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
raise HTTPException(status_code=403, detail="دسترسی نویسنده محدود است")
@@ -196,14 +202,13 @@ def register_crud(
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
حذف و ترتیب‌بندی) روی روتر مدیریت.
creator_scoped=True (فقط مقاله‌ها): نقش creator فقط رکوردهای خودش را می‌بیند،
ساختش همیشه پیش‌نویس است و ویرایشش هرگز منتشر نمی‌شود."""
creator_scoped=True (فقط مقاله‌ها): نقش creator همهٔ رکوردها را می‌بیند،
ساختش همیشه پیش‌نویس است و ویرایشش هرگز وضعیت انتشار یا نویسنده را عوض نمی‌کند
(گارد بالا هم ویرایش مقالهٔ منتشرشده را رد می‌کند)."""
@router.get(f"/{path}", response_model=list[out_schema])
def list_items(db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
query = db.query(model)
if creator_scoped and admin.role == "creator":
query = query.filter(model.author_id == admin.id)
if hasattr(model, "position"):
query = query.order_by(model.position, model.id)
else:
@@ -235,9 +240,6 @@ def register_crud(
item = db.get(model, item_id)
if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
# مقالهٔ بقیه برای creator وجود ندارد
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
return item
@router.put(f"/{path}/{{item_id}}", response_model=out_schema)
@@ -245,12 +247,17 @@ def register_crud(
item = db.get(model, item_id)
if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
previous_status = item.status
previous_author = item.author_id
for key, value in payload.model_dump().items():
setattr(item, key, value)
if creator_scoped and admin.role == "creator":
item.status = "draft" # نویسنده حق انتشار ندارد
# نویسنده نه وضعیت انتشار را عوض می‌کند (ویرایش پیش‌نویس هرگز منتشرش
# نمی‌کند) و نه نویسندهٔ مقاله را؛ تغییر خودکار به draft هم انجام
# نمی‌شود تا ویرایش‌های هم‌تیمی وضعیت را خراب نکند
item.status = previous_status
item.author_id = previous_author
_apply_publish_timestamp(item, previous_status)
try:
db.commit()
except IntegrityError:
@@ -267,12 +274,17 @@ def register_crud(
item = db.get(model, item_id)
if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(item, key, value)
changes = payload.model_dump(exclude_unset=True)
previous_status_patch = item.status
if creator_scoped and admin.role == "creator":
item.status = "draft" # نویسنده حق انتشار ندارد
# وضعیت انتشار و نویسنده در اختیار نویسنده نیست — ذخیره‌های او
# فقط محتوا را عوض می‌کند (گارد بالادست هم ویرایش مقالهٔ
# منتشرشده را رد می‌کند)
changes.pop("status", None)
changes.pop("author_id", None)
for key, value in changes.items():
setattr(item, key, value)
_apply_publish_timestamp(item, previous_status_patch)
try:
db.commit()
except IntegrityError:
@@ -502,6 +514,23 @@ async def upload_media(
}
def _apply_publish_timestamp(item, previous_status) -> None:
"""تاریخ انتشار را سرور تعیین می‌کند، نه ادمین.
فقط در گذار draft→published (یا انتشار دوبارهٔ مقاله‌ای بدون تاریخ) ثبت می‌شود؛
تاریخ شمسی نمایشی (ستون date) از همین لحظه ساخته می‌شود تا دستی و نادرست نباشد."""
if getattr(item, "status", None) != "published":
return
if not hasattr(item, "published_at"):
return
if item.published_at is None:
item.published_at = datetime.now(timezone.utc)
# تاریخ نمایشی فقط اگر خالی باشد پر می‌شود (تاریخ تاریخیِ درست را خراب نکن)
if not (getattr(item, "date", "") or "").strip():
item.date = item.published_at.strftime("%Y-%m-%d")
if not (getattr(item, "date_en", "") or "").strip():
item.date_en = item.published_at.strftime("%Y-%m-%d")
# ---------- درون‌ریزی تصویر از نشانی خارجی (چسباندن از Outline و مانند آن) ----------
+4
View File
@@ -153,6 +153,10 @@ def run_ddl_migrations() -> None:
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
)
# لحظهٔ انتشار مقاله — تاریخ نمایشی از آن مشتق می‌شود (نه دستی)
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS published_at TIMESTAMP WITH TIME ZONE"))
# عقب‌گرد یک‌باره: مقالات منتشرشده‌ای که تاریخ ندارند، از updated_at پر می‌شوند
conn.execute(text("UPDATE articles SET published_at = COALESCE(updated_at, NOW()) WHERE status='published' AND published_at IS NULL"))
# نشانی/کاربری پروژه: نشانی انگلیسی (ترانویسی) از ۱۲۸ نویسه بلندتر می‌شود
conn.execute(text("ALTER TABLE projects ALTER COLUMN location TYPE VARCHAR(256)"))
conn.execute(text("ALTER TABLE projects ALTER COLUMN location_en TYPE VARCHAR(256)"))
+5
View File
@@ -148,6 +148,11 @@ class Article(Base):
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=True
)
# لحظهٔ دقیق انتشار — سرور در گذار draft→published تنظیم می‌کند؛
# تاریخ نمایشی (ستون date) از همین ساخته می‌شود تا دستی و اشتباه ثبت نشود
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class Testimonial(Base):
+2
View File
@@ -103,6 +103,8 @@ class ArticleOut(ORMModel):
author: str = ""
meta_description: str = ""
meta_description_en: str = ""
# سرور تنظیم می‌کند — فقط خواندنی در پنل
published_at: datetime | None = None
slug_en: str = ""
seo_keyphrase_en: str = ""
author_en: str = ""