- POST /api/admin/upload با احراز هویت JWT: ذخیره فایل در باکت S3 و برگرداندن URL نسبی /media/<سال>/<ماه>/<uuid>.<پسوند> - فرمتهای مجاز: jpg، png، webp، gif، avif — حداکثر ۱۰ مگابایت (قابل تنظیم) - افزودن boto3 و python-multipart به وابستگیها - تنظیمات جدید: S3_ENDPOINT، S3_ACCESS_KEY، S3_SECRET_KEY، S3_BUCKET، MEDIA_PUBLIC_BASE، MEDIA_MAX_UPLOAD_MB (قابل تعریف در .env) - URL نسبی برمیگردد تا بکاند ذخیرهسازی (MinIO/R2/هر S3) بدون تغییر محتوای دیتابیس قابل تعویض باشد - بهروزرسانی .env.example و README (بخش تصاویر و MinIO)
264 lines
9.7 KiB
Python
264 lines
9.7 KiB
Python
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
import boto3
|
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import models, schemas
|
|
from ..auth import create_access_token, get_current_admin, verify_password
|
|
from ..config import settings
|
|
from ..database import get_db
|
|
from ..schemas import (
|
|
ArticleIn,
|
|
ArticleOut,
|
|
BeforeAfterIn,
|
|
BeforeAfterOut,
|
|
BrandIn,
|
|
BrandOut,
|
|
ContactSectionIn,
|
|
ContactSectionOut,
|
|
ContactMessageOut,
|
|
FaqIn,
|
|
FaqOut,
|
|
FooterIn,
|
|
FooterOut,
|
|
GalleryImageIn,
|
|
GalleryImageOut,
|
|
HeroIn,
|
|
HeroOut,
|
|
InvestmentModelIn,
|
|
InvestmentModelOut,
|
|
LoginIn,
|
|
MessageReadIn,
|
|
NavItemIn,
|
|
NavItemOut,
|
|
NewsletterSectionIn,
|
|
NewsletterOut,
|
|
OfficeIn,
|
|
OfficeOut,
|
|
PageHeroIn,
|
|
PageHeroOut,
|
|
ProcessStepIn,
|
|
ProcessStepOut,
|
|
ProjectIn,
|
|
ProjectOut,
|
|
SectionHeadingIn,
|
|
SectionHeadingOut,
|
|
ServiceIn,
|
|
ServiceOut,
|
|
StatIn,
|
|
StatOut,
|
|
StatusLabelIn,
|
|
StatusLabelOut,
|
|
StoryIn,
|
|
StoryOut,
|
|
SubscriberOut,
|
|
TeamMemberIn,
|
|
TeamMemberOut,
|
|
TestimonialIn,
|
|
TestimonialOut,
|
|
TimelineItemIn,
|
|
TimelineItemOut,
|
|
TokenOut,
|
|
WhyUsItemIn,
|
|
WhyUsItemOut,
|
|
)
|
|
|
|
# روتر عمومی برای ورود — بدون نیاز به توکن
|
|
auth_router = APIRouter()
|
|
|
|
# روتر محافظتشده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند
|
|
router = APIRouter(dependencies=[Depends(get_current_admin)])
|
|
|
|
|
|
@auth_router.post("/auth/login", response_model=TokenOut)
|
|
def login(payload: LoginIn, db: Session = Depends(get_db)):
|
|
user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first()
|
|
if user is None or not verify_password(payload.password, user.password_hash):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="نام کاربری یا رمز عبور اشتباه است",
|
|
)
|
|
return TokenOut(access_token=create_access_token(user.username))
|
|
|
|
|
|
def register_crud(path: str, model, out_schema, create_schema) -> None:
|
|
"""ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت."""
|
|
|
|
@router.get(f"/{path}", response_model=list[out_schema])
|
|
def list_items(db: Session = Depends(get_db)):
|
|
return db.query(model).order_by(model.id).all()
|
|
|
|
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
|
def create_item(payload: create_schema, db: Session = Depends(get_db)):
|
|
item = model(**payload.model_dump())
|
|
db.add(item)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.get(f"/{path}/{{item_id}}", response_model=out_schema)
|
|
def get_item(item_id: int, db: Session = Depends(get_db)):
|
|
item = db.get(model, item_id)
|
|
if item is None:
|
|
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)):
|
|
item = db.get(model, item_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
|
for key, value in payload.model_dump().items():
|
|
setattr(item, key, value)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
|
def delete_item(item_id: int, db: Session = Depends(get_db)):
|
|
item = db.get(model, item_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
|
db.delete(item)
|
|
db.commit()
|
|
|
|
|
|
# CRUD محتوا
|
|
register_crud("brand", models.Brand, BrandOut, BrandIn)
|
|
register_crud("nav", models.NavItem, NavItemOut, NavItemIn)
|
|
register_crud("hero", models.Hero, HeroOut, HeroIn)
|
|
register_crud("story", models.StorySection, StoryOut, StoryIn)
|
|
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn)
|
|
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn)
|
|
register_crud("services", models.Service, ServiceOut, ServiceIn)
|
|
register_crud("projects", models.Project, ProjectOut, ProjectIn)
|
|
register_crud("articles", models.Article, ArticleOut, ArticleIn)
|
|
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn)
|
|
register_crud("faqs", models.Faq, FaqOut, FaqIn)
|
|
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn)
|
|
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn)
|
|
register_crud("stats", models.Stat, StatOut, StatIn)
|
|
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn)
|
|
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn)
|
|
register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn)
|
|
register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn)
|
|
register_crud("offices", models.Office, OfficeOut, OfficeIn)
|
|
register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn)
|
|
register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn)
|
|
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn)
|
|
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn)
|
|
register_crud("footer", models.FooterContent, FooterOut, FooterIn)
|
|
|
|
|
|
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمیگرداند تا بکاند ذخیرهسازی قابل تعویض باشد
|
|
ALLOWED_IMAGE_TYPES = {
|
|
"image/jpeg": "jpg",
|
|
"image/png": "png",
|
|
"image/webp": "webp",
|
|
"image/gif": "gif",
|
|
"image/avif": "avif",
|
|
}
|
|
|
|
|
|
@router.post("/upload")
|
|
async def upload_media(
|
|
file: UploadFile = File(...),
|
|
_admin: models.AdminUser = Depends(get_current_admin),
|
|
):
|
|
content_type = (file.content_type or "").lower()
|
|
ext = ALLOWED_IMAGE_TYPES.get(content_type)
|
|
if ext is None:
|
|
raise HTTPException(
|
|
status_code=415,
|
|
detail="فرمت فایل پشتیبانی نمیشود (مجاز: jpg، png، webp، gif، avif)",
|
|
)
|
|
|
|
max_bytes = settings.media_max_upload_mb * 1024 * 1024
|
|
data = await file.read()
|
|
if len(data) == 0:
|
|
raise HTTPException(status_code=400, detail="فایل خالی است")
|
|
if len(data) > max_bytes:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
|
|
|
|
s3 = boto3.client(
|
|
"s3",
|
|
endpoint_url=settings.s3_endpoint,
|
|
aws_access_key_id=settings.s3_access_key,
|
|
aws_secret_access_key=settings.s3_secret_key,
|
|
region_name="us-east-1",
|
|
)
|
|
try:
|
|
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
|
|
except Exception:
|
|
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
|
|
|
return {
|
|
"url": f"{settings.media_public_base}/{key}",
|
|
"key": key,
|
|
"size": len(data),
|
|
"content_type": content_type,
|
|
}
|
|
|
|
|
|
# پیامهای تماس
|
|
@router.get("/messages", response_model=list[ContactMessageOut])
|
|
def list_messages(unread_only: bool = False, db: Session = Depends(get_db)):
|
|
query = db.query(models.ContactMessage)
|
|
if unread_only:
|
|
query = query.filter(models.ContactMessage.is_read.is_(False))
|
|
return query.order_by(models.ContactMessage.created_at.desc()).all()
|
|
|
|
|
|
@router.patch("/messages/{message_id}", response_model=ContactMessageOut)
|
|
def update_message(message_id: int, payload: MessageReadIn, db: Session = Depends(get_db)):
|
|
message = db.get(models.ContactMessage, message_id)
|
|
if message is None:
|
|
raise HTTPException(status_code=404, detail="پیام یافت نشد")
|
|
message.is_read = payload.is_read
|
|
db.commit()
|
|
db.refresh(message)
|
|
return message
|
|
|
|
|
|
@router.delete("/messages/{message_id}", status_code=204)
|
|
def delete_message(message_id: int, db: Session = Depends(get_db)):
|
|
message = db.get(models.ContactMessage, message_id)
|
|
if message is None:
|
|
raise HTTPException(status_code=404, detail="پیام یافت نشد")
|
|
db.delete(message)
|
|
db.commit()
|
|
|
|
|
|
# مشترکین خبرنامه
|
|
@router.get("/subscribers", response_model=list[SubscriberOut])
|
|
def list_subscribers(db: Session = Depends(get_db)):
|
|
return db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all()
|
|
|
|
|
|
@router.delete("/subscribers/{subscriber_id}", status_code=204)
|
|
def delete_subscriber(subscriber_id: int, db: Session = Depends(get_db)):
|
|
subscriber = db.get(models.Subscriber, subscriber_id)
|
|
if subscriber is None:
|
|
raise HTTPException(status_code=404, detail="مشترک یافت نشد")
|
|
db.delete(subscriber)
|
|
db.commit()
|