diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index a563592..2041aed 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -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 و مانند آن) ---------- diff --git a/backend/app/migrations.py b/backend/app/migrations.py index ea558ef..0c0b2f2 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -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)")) diff --git a/backend/app/models.py b/backend/app/models.py index 32d29dc..b1fe3c0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index fa7e228..bf282a7 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 = "" diff --git a/frontend/src/admin/ArticleWritePage.tsx b/frontend/src/admin/ArticleWritePage.tsx index df6696c..53ec229 100644 --- a/frontend/src/admin/ArticleWritePage.tsx +++ b/frontend/src/admin/ArticleWritePage.tsx @@ -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() {
+ + مقاله‌ها + )} @@ -501,6 +509,7 @@ export function ArticleWritePage() {
- +
- queueSave({ date: isoDate })} + type="text" + disabled + value={draft.date ? formatJalali(draft.date) : 'در لحظهٔ انتشار ثبت می‌شود'} + className="w-full text-sm font-light opacity-70" />