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),
|
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 و مانند آن) ----------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)"))
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user