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
+51 -22
View File
@@ -124,8 +124,9 @@ async def admin_access_guard(
token: str = Depends(oauth2_scheme), token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> models.AdminUser: ) -> models.AdminUser:
"""نقش‌گارد — 'admin' همه‌کاره؛ 'creator' فقط مقاله‌نویسی: ساخت پیش‌نویس، """نقش‌گارد — 'admin' همه‌کاره؛ 'creator' عضو تیم پیش‌نویس مقاله: فهرست همهٔ
خواندن/ویرایش پیش‌نویس خودش، و آپلود تصویر. بقیهٔ مسیرهای مدیریتی ۴۰۳.""" مقاله‌ها، ساخت پیش‌نویس (نویسنده‌اش ثبت می‌شود)، خواندن همهٔ مقاله‌ها و
ویرایش پیش‌نویس‌ها. انتشار/حذف/ترتیب و بقیهٔ مسیرهای مدیریتی ۴۰۳."""
user = get_user_from_token(token, db) user = get_user_from_token(token, db)
if user.role == "admin": if user.role == "admin":
return user return user
@@ -141,14 +142,19 @@ async def admin_access_guard(
# فهرست و ساخت مقاله — مجاز (ساخت همیشه پیش‌نویس است و نویسنده‌اش ثبت می‌شود) # فهرست و ساخت مقاله — مجاز (ساخت همیشه پیش‌نویس است و نویسنده‌اش ثبت می‌شود)
if path == "/api/admin/articles" and method in ("GET", "POST"): if path == "/api/admin/articles" and method in ("GET", "POST"):
return user return user
# خواندن/ویرایش پیش‌نویس خودِ نویسنده — مالکیت در هندلر هم دوباره چک می‌شود # خواندن همهٔ مقاله‌ها آزاد است؛ ویرایش فقط پیش‌نویس — مقالهٔ منتشرشده برای
# نویسنده فقط‌خواندنی است و تغییرش یعنی انتشار غیرمستقیم محتوا
m = re.fullmatch(r"/api/admin/articles/(\d+)", path) m = re.fullmatch(r"/api/admin/articles/(\d+)", path)
if m and method in ("GET", "PUT", "PATCH"): if m:
article = db.get(models.Article, int(m.group(1))) if method == "GET":
if article is not None and article.author_id == user.id:
return user 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="مقاله یافت نشد") raise HTTPException(status_code=404, detail="مقاله یافت نشد")
if article.status != "draft":
raise HTTPException(status_code=403, detail="مقالهٔ منتشرشده فقط توسط مدیر ویرایش می‌شود")
return user
raise HTTPException(status_code=403, detail="دسترسی نویسنده محدود است") raise HTTPException(status_code=403, detail="دسترسی نویسنده محدود است")
@@ -196,14 +202,13 @@ def register_crud(
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی، """ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
حذف و ترتیب‌بندی) روی روتر مدیریت. حذف و ترتیب‌بندی) روی روتر مدیریت.
creator_scoped=True (فقط مقاله‌ها): نقش creator فقط رکوردهای خودش را می‌بیند، creator_scoped=True (فقط مقاله‌ها): نقش creator همهٔ رکوردها را می‌بیند،
ساختش همیشه پیش‌نویس است و ویرایشش هرگز منتشر نمی‌شود.""" ساختش همیشه پیش‌نویس است و ویرایشش هرگز وضعیت انتشار یا نویسنده را عوض نمی‌کند
(گارد بالا هم ویرایش مقالهٔ منتشرشده را رد می‌کند)."""
@router.get(f"/{path}", response_model=list[out_schema]) @router.get(f"/{path}", response_model=list[out_schema])
def list_items(db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)): def list_items(db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
query = db.query(model) query = db.query(model)
if creator_scoped and admin.role == "creator":
query = query.filter(model.author_id == admin.id)
if hasattr(model, "position"): if hasattr(model, "position"):
query = query.order_by(model.position, model.id) query = query.order_by(model.position, model.id)
else: else:
@@ -235,9 +240,6 @@ def register_crud(
item = db.get(model, item_id) item = db.get(model, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد") 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 return item
@router.put(f"/{path}/{{item_id}}", response_model=out_schema) @router.put(f"/{path}/{{item_id}}", response_model=out_schema)
@@ -245,12 +247,17 @@ def register_crud(
item = db.get(model, item_id) item = db.get(model, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد") raise HTTPException(status_code=404, detail="رکورد یافت نشد")
if creator_scoped and admin.role == "creator" and item.author_id != admin.id: previous_status = item.status
raise HTTPException(status_code=404, detail="مقاله یافت نشد") previous_author = item.author_id
for key, value in payload.model_dump().items(): for key, value in payload.model_dump().items():
setattr(item, key, value) setattr(item, key, value)
if creator_scoped and admin.role == "creator": 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: try:
db.commit() db.commit()
except IntegrityError: except IntegrityError:
@@ -267,12 +274,17 @@ def register_crud(
item = db.get(model, item_id) item = db.get(model, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد") raise HTTPException(status_code=404, detail="رکورد یافت نشد")
if creator_scoped and admin.role == "creator" and item.author_id != admin.id: changes = payload.model_dump(exclude_unset=True)
raise HTTPException(status_code=404, detail="مقاله یافت نشد") previous_status_patch = item.status
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(item, key, value)
if creator_scoped and admin.role == "creator": 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: try:
db.commit() db.commit()
except IntegrityError: 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 و مانند آن) ---------- # ---------- درون‌ریزی تصویر از نشانی خارجی (چسباندن از Outline و مانند آن) ----------
+4
View File
@@ -153,6 +153,10 @@ def run_ddl_migrations() -> None:
conn.execute( conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''") 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 TYPE VARCHAR(256)"))
conn.execute(text("ALTER TABLE projects ALTER COLUMN location_en 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( updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=True 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): class Testimonial(Base):
+2
View File
@@ -103,6 +103,8 @@ class ArticleOut(ORMModel):
author: str = "" author: str = ""
meta_description: str = "" meta_description: str = ""
meta_description_en: str = "" meta_description_en: str = ""
# سرور تنظیم می‌کند — فقط خواندنی در پنل
published_at: datetime | None = None
slug_en: str = "" slug_en: str = ""
seo_keyphrase_en: str = "" seo_keyphrase_en: str = ""
author_en: str = "" author_en: str = ""
+19 -5
View File
@@ -10,6 +10,7 @@ import { ArticleEditor } from '../components/admin/ArticleEditor'
import { SeoChecklist } from '../components/admin/SeoChecklist' import { SeoChecklist } from '../components/admin/SeoChecklist'
import { ImageField } from '../components/admin/ImageField' import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker' import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { formatJalali } from '../utils/date'
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect' import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import type { CategoryOption } from '../components/admin/LuxeSelect' import type { CategoryOption } from '../components/admin/LuxeSelect'
import { ARTICLE_CATEGORIES } from './entities' import { ARTICLE_CATEGORIES } from './entities'
@@ -391,6 +392,13 @@ export function ArticleWritePage() {
</Link> </Link>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link
to="/admin/articles"
data-admin-link
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
مقاله‌ها
</Link>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -443,7 +451,7 @@ export function ArticleWritePage() {
</button> </button>
)} )}
<Link <Link
to={`${editLang === 'en' ? '/en' : ''}/blog/${editLang === 'en' ? draft.slug_en || draft.slug : draft.slug}`} to={`${editLang === 'en' ? '/en' : ''}/blog/${encodeURIComponent(editLang === 'en' ? draft.slug_en || draft.slug : draft.slug)}`}
data-admin-link data-admin-link
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper" className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
> >
@@ -501,6 +509,7 @@ export function ArticleWritePage() {
<div className="mt-8"> <div className="mt-8">
<ImageField <ImageField
label="تصویر شاخص مقاله" label="تصویر شاخص مقاله"
requireEditMode={false}
value={draft.image} value={draft.image}
alt={imageAltField.value} alt={imageAltField.value}
altFallback={imageAltFallback} altFallback={imageAltFallback}
@@ -673,12 +682,17 @@ export function ArticleWritePage() {
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
<div> <div>
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label> <label htmlFor="article-date" className="text-xs font-light text-ivory/50">
تاریخ انتشار (خودکار)
</label>
<div className="mt-2"> <div className="mt-2">
<JalaliDatePicker {/* تاریخ را سرور در لحظهٔ انتشار ثبت می‌کند — دستی قابل تغییر نیست */}
<input
id="article-date" id="article-date"
value={draft.date ?? ''} type="text"
onChange={(isoDate) => queueSave({ date: isoDate })} disabled
value={draft.date ? formatJalali(draft.date) : 'در لحظهٔ انتشار ثبت می‌شود'}
className="w-full text-sm font-light opacity-70"
/> />
</div> </div>
</div> </div>