feat(auth): نقش سوم «creator» — نویسندهٔ محتوا با دسترسی محدود
- بکاند: ستون role برای admin_users (admin/creator) + author_id برای مقالهها - نقشگارد روی کل روتر مدیریتی: creator فقط لیست/ساخت/خواندن/ویرایش پیشنویسهای «خودش» را میبیند؛ بقیهٔ مسیرهای مدیریتی ۴۰۳ - ساخت توسط creator همیشه پیشنویس است و ویرایشش هرگز منتشر نمیشود (حتی تلاش برای publish در payload نادیده گرفته میشود) - حذف/ترتیببندی/پیامها/خبرنامه/سایر بخشها برای creator ممنوع - لاگین نقش را برمیگرداند؛ پنل: AdminBar نویسنده فقط «نوشتن مقاله» + زبان ویرایش + رمز + خروج دارد؛ دکمههای انتشار/حذف مقاله فقط برای admin - ورود creator مستقیم به صفحهٔ نوشتن مقاله هدایت میشود
This commit is contained in:
+78
-12
@@ -19,7 +19,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models, schemas
|
||||
from ..auth import create_access_token, get_current_admin, hash_password, verify_password
|
||||
from ..auth import create_access_token, get_current_admin, get_user_from_token, hash_password, verify_password
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..rate_limit import clear as clear_rate_limit
|
||||
@@ -113,7 +113,45 @@ from ..schemas import (
|
||||
auth_router = APIRouter()
|
||||
|
||||
# روتر محافظتشده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند
|
||||
router = APIRouter(dependencies=[Depends(get_current_admin)])
|
||||
router = APIRouter(
|
||||
dependencies=[
|
||||
Depends(get_current_admin),
|
||||
Depends(admin_access_guard),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def admin_access_guard(
|
||||
request: Request,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> models.AdminUser:
|
||||
"""نقشگارد — 'admin' همهکاره؛ 'creator' فقط مقالهنویسی: ساخت پیشنویس،
|
||||
خواندن/ویرایش پیشنویس خودش، و آپلود تصویر. بقیهٔ مسیرهای مدیریتی ۴۰۳."""
|
||||
user = get_user_from_token(token, db)
|
||||
if user.role == "admin":
|
||||
return user
|
||||
if user.role != "creator":
|
||||
raise HTTPException(status_code=403, detail="نقش ناشناخته")
|
||||
|
||||
path = request.url.path.rstrip("/")
|
||||
method = request.method
|
||||
|
||||
# آپلود تصویر مقالهها — مجاز
|
||||
if path in ("/api/admin/upload", "/api/admin/import-image") and method == "POST":
|
||||
return user
|
||||
# فهرست و ساخت مقاله — مجاز (ساخت همیشه پیشنویس است و نویسندهاش ثبت میشود)
|
||||
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:
|
||||
return user
|
||||
# ۴۰۴ نه ۴۰۳ — مقالههای بقیه برای نویسنده اصلاً وجود ندارند
|
||||
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||
raise HTTPException(status_code=403, detail="دسترسی نویسنده محدود است")
|
||||
|
||||
|
||||
@auth_router.post("/auth/login", response_model=TokenOut)
|
||||
@@ -127,7 +165,7 @@ def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
|
||||
detail="نام کاربری یا رمز عبور اشتباه است",
|
||||
)
|
||||
clear_rate_limit(request)
|
||||
return TokenOut(access_token=create_access_token(user.username))
|
||||
return TokenOut(access_token=create_access_token(user.username, user.role), role=user.role)
|
||||
|
||||
|
||||
@auth_router.post("/auth/change-password")
|
||||
@@ -144,13 +182,21 @@ def change_password(
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
|
||||
def register_crud(
|
||||
path: str, model, out_schema, create_schema, patch_schema=None,
|
||||
creator_scoped: bool = False,
|
||||
) -> None:
|
||||
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
||||
حذف و ترتیببندی) روی روتر مدیریت."""
|
||||
حذف و ترتیببندی) روی روتر مدیریت.
|
||||
|
||||
creator_scoped=True (فقط مقالهها): نقش creator فقط رکوردهای خودش را میبیند،
|
||||
ساختش همیشه پیشنویس است و ویرایشش هرگز منتشر نمیشود."""
|
||||
|
||||
@router.get(f"/{path}", response_model=list[out_schema])
|
||||
def list_items(db: Session = Depends(get_db)):
|
||||
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:
|
||||
@@ -158,8 +204,13 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
return query.all()
|
||||
|
||||
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
||||
def create_item(payload: create_schema, db: Session = Depends(get_db)):
|
||||
def create_item(payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
item = model(**payload.model_dump())
|
||||
if creator_scoped:
|
||||
# نویسنده ثبت میشود؛ ساخت توسط creator همیشه پیشنویس است
|
||||
item.author_id = admin.id
|
||||
if admin.role == "creator":
|
||||
item.status = "draft"
|
||||
if hasattr(model, "position"):
|
||||
# آیتم جدید در انتهای لیست قرار میگیرد
|
||||
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
|
||||
@@ -173,19 +224,26 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
return item
|
||||
|
||||
@router.get(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||
def get_item(item_id: int, db: Session = Depends(get_db)):
|
||||
def get_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
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)
|
||||
def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db)):
|
||||
def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
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().items():
|
||||
setattr(item, key, value)
|
||||
if creator_scoped and admin.role == "creator":
|
||||
item.status = "draft" # نویسنده حق انتشار ندارد
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
@@ -197,13 +255,17 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
if patch_schema is not None:
|
||||
|
||||
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)):
|
||||
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
"""ویرایش جزئی — فقط فیلدهای ارسالشده بهروزرسانی میشوند (برای ویرایش زنده)."""
|
||||
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)
|
||||
if creator_scoped and admin.role == "creator":
|
||||
item.status = "draft" # نویسنده حق انتشار ندارد
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
@@ -213,7 +275,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
return item
|
||||
|
||||
@router.post(f"/{path}/reorder", response_model=list[out_schema])
|
||||
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
|
||||
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
if creator_scoped and admin.role == "creator":
|
||||
raise HTTPException(status_code=403, detail="ترتیب مقالهها فقط توسط مدیر تغییر میکند")
|
||||
"""ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
|
||||
if not hasattr(model, "position"):
|
||||
raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
|
||||
@@ -227,7 +291,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
return db.query(model).order_by(model.position, model.id).all()
|
||||
|
||||
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
||||
def delete_item(item_id: int, db: Session = Depends(get_db)):
|
||||
def delete_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
if creator_scoped and admin.role == "creator":
|
||||
raise HTTPException(status_code=403, detail="حذف مقاله فقط توسط مدیر انجام میشود")
|
||||
item = db.get(model, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||
|
||||
+12
-7
@@ -21,17 +21,14 @@ def verify_password(password: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
||||
|
||||
|
||||
def create_access_token(username: str) -> str:
|
||||
def create_access_token(username: str, role: str = "admin") -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {"sub": username, "exp": expire}
|
||||
payload = {"sub": username, "role": role, "exp": expire}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AdminUser:
|
||||
"""وابستگی محافظتشده — فقط با توکن JWT معتبر دسترسی میدهد."""
|
||||
def get_user_from_token(token: str, db: Session) -> AdminUser:
|
||||
"""decode توکن و برگرداندن کاربر — هم برای get_current_admin و هم نقشگارد"""
|
||||
credentials_error = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="احراز هویت نامعتبر است",
|
||||
@@ -50,3 +47,11 @@ def get_current_admin(
|
||||
if user is None:
|
||||
raise credentials_error
|
||||
return user
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AdminUser:
|
||||
"""وابستگی محافظتشده — فقط با توکن JWT معتبر دسترسی میدهد."""
|
||||
return get_user_from_token(token, db)
|
||||
|
||||
@@ -153,6 +153,14 @@ def run_ddl_migrations() -> None:
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
||||
)
|
||||
# نقشها — 'admin' همهکاره، 'creator' فقط نویسندهٔ محتوا
|
||||
conn.execute(
|
||||
text("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'")
|
||||
)
|
||||
# نویسندهٔ مقاله — نقش creator فقط مقالههای خودش را میبیند/ویرایش میکند
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author_id INTEGER")
|
||||
)
|
||||
# متادیتای تصویر شاخص مقاله + پرسشوپاسخهای پایان مقاله (سئو)
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
|
||||
|
||||
@@ -21,6 +21,8 @@ class AdminUser(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
# 'admin' همهکاره — 'creator' فقط نویسندهٔ محتوا (پیشنویس مقالههای خودش)
|
||||
role: Mapped[str] = mapped_column(String(16), default='admin')
|
||||
|
||||
|
||||
class Brand(Base):
|
||||
@@ -116,6 +118,7 @@ class Article(Base):
|
||||
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
|
||||
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
||||
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
||||
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقالههای ادمین NULL
|
||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||
|
||||
@@ -421,6 +421,7 @@ class LoginIn(BaseModel):
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
role: str = "admin"
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user