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:
+51
-22
@@ -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=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 و مانند آن) ----------
|
||||
|
||||
|
||||
|
||||
@@ -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)"))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { formatJalali } from '../utils/date'
|
||||
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import type { CategoryOption } from '../components/admin/LuxeSelect'
|
||||
import { ARTICLE_CATEGORIES } from './entities'
|
||||
@@ -391,6 +392,13 @@ export function ArticleWritePage() {
|
||||
</Link>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -443,7 +451,7 @@ export function ArticleWritePage() {
|
||||
</button>
|
||||
)}
|
||||
<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
|
||||
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">
|
||||
<ImageField
|
||||
label="تصویر شاخص مقاله"
|
||||
requireEditMode={false}
|
||||
value={draft.image}
|
||||
alt={imageAltField.value}
|
||||
altFallback={imageAltFallback}
|
||||
@@ -673,12 +682,17 @@ export function ArticleWritePage() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<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">
|
||||
<JalaliDatePicker
|
||||
{/* تاریخ را سرور در لحظهٔ انتشار ثبت میکند — دستی قابل تغییر نیست */}
|
||||
<input
|
||||
id="article-date"
|
||||
value={draft.date ?? ''}
|
||||
onChange={(isoDate) => queueSave({ date: isoDate })}
|
||||
type="text"
|
||||
disabled
|
||||
value={draft.date ? formatJalali(draft.date) : 'در لحظهٔ انتشار ثبت میشود'}
|
||||
className="w-full text-sm font-light opacity-70"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user