"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند.""" import csv import io import uuid from datetime import datetime, timezone import boto3 from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status from PIL import Image, ImageOps from sqlalchemy import func 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 ..config import settings from ..database import get_db from ..rate_limit import clear as clear_rate_limit from ..rate_limit import ensure_allowed, record_failure from ..schemas import ( ArticleIn, ArticleOut, ArticlePatch, BeforeAfterIn, BeforeAfterOut, BeforeAfterPatch, BrandIn, BrandOut, BrandPatch, ChangePasswordIn, ContactSectionIn, ContactSectionOut, ContactSectionPatch, ContactMessageOut, FaqIn, FaqOut, FaqPatch, FooterIn, FooterOut, FooterPatch, GalleryImageIn, GalleryImageOut, GalleryImagePatch, HeroIn, HeroOut, HeroPatch, InvestmentModelIn, InvestmentModelOut, InvestmentModelPatch, LoginIn, MessageReadIn, NavItemIn, NavItemOut, NavItemPatch, NewsletterSectionIn, NewsletterOut, NewsletterSectionPatch, OfficeIn, OfficeOut, OfficePatch, PageHeroIn, PageHeroOut, PageHeroPatch, ProcessStepIn, ProcessStepOut, ProcessStepPatch, ProjectIn, ProjectOut, ProjectPatch, ReorderIn, SeoMetaIn, SeoMetaOut, SeoMetaPatch, SectionHeadingIn, SectionHeadingOut, SectionHeadingPatch, ServiceIn, ServiceOut, ServicePatch, StatIn, StatOut, StatPatch, StatusLabelIn, StatusLabelOut, StatusLabelPatch, StoryIn, StoryOut, StoryPatch, SubscriberOut, TeamMemberIn, TeamMemberOut, TeamMemberPatch, TestimonialIn, TestimonialOut, TestimonialPatch, TimelineItemIn, TimelineItemOut, TimelineItemPatch, TokenOut, WhyUsItemIn, WhyUsItemOut, WhyUsItemPatch, ) # روتر عمومی برای ورود — بدون نیاز به توکن auth_router = APIRouter() # روتر محافظت‌شده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند router = APIRouter(dependencies=[Depends(get_current_admin)]) @auth_router.post("/auth/login", response_model=TokenOut) def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)): ensure_allowed(request) 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): record_failure(request) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="نام کاربری یا رمز عبور اشتباه است", ) clear_rate_limit(request) return TokenOut(access_token=create_access_token(user.username)) @auth_router.post("/auth/change-password") def change_password( payload: ChangePasswordIn, admin: models.AdminUser = Depends(get_current_admin), db: Session = Depends(get_db), ): """تغییر رمز ادمین واردشده — رمز فعلی باید درست باشد.""" if not verify_password(payload.current_password, admin.password_hash): raise HTTPException(status_code=400, detail="رمز عبور فعلی اشتباه است") admin.password_hash = hash_password(payload.new_password) db.commit() return {"ok": True} def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None: """ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی، حذف و ترتیب‌بندی) روی روتر مدیریت.""" @router.get(f"/{path}", response_model=list[out_schema]) def list_items(db: Session = Depends(get_db)): query = db.query(model) if hasattr(model, "position"): query = query.order_by(model.position, model.id) else: query = query.order_by(model.id) 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)): item = model(**payload.model_dump()) if hasattr(model, "position"): # آیتم جدید در انتهای لیست قرار می‌گیرد item.position = (db.query(func.max(model.position)).scalar() or 0) + 1 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 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)): """ویرایش جزئی — فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند (برای ویرایش زنده).""" item = db.get(model, item_id) if item is None: raise HTTPException(status_code=404, detail="رکورد یافت نشد") for key, value in payload.model_dump(exclude_unset=True).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.post(f"/{path}/reorder", response_model=list[out_schema]) def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)): """ذخیره ترتیب جدید آیتم‌ها بر اساس لیست idهای ارسالی.""" if not hasattr(model, "position"): raise HTTPException(status_code=400, detail="این منبع از ترتیب‌بندی پشتیبانی نمی‌کند") items = db.query(model).filter(model.id.in_(payload.ids)).all() if len(items) != len(payload.ids): raise HTTPException(status_code=404, detail="برخی رکوردها یافت نشدند") by_id = {item.id: item for item in items} for pos, item_id in enumerate(payload.ids): by_id[item_id].position = pos db.commit() 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)): 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, BrandPatch) register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch) register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch) register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch) register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch) register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch) register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch) register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch) register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch) register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch) register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch) register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch) register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch) register_crud("stats", models.Stat, StatOut, StatIn, StatPatch) register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch) register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch) register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch) register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch) register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch) register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch) register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch) register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch) register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch) register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch) register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch) # آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد ALLOWED_IMAGE_TYPES = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif", "image/avif": "avif", } # بهینه‌سازی در زمان آپلود — ابعاد بزرگ و فشرده‌سازی ضعیف، اصلی‌ترین عامل کندی سایت است MAX_DIMENSION = 2000 REENCODE_TYPES = {"image/jpeg", "image/png", "image/webp"} def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, int]: """تصویر را به حداکثر ۲۰۰۰ پیکسل محدود و دوباره فشرده می‌کند؛ اگر خروجی بزرگ‌تر از اصلی شد، همان اصلی برگردد. (gif/avif دست‌نخورده)""" img = Image.open(io.BytesIO(data)) img = ImageOps.exif_transpose(img) orig_w, orig_h = img.size if max(img.size) > MAX_DIMENSION: img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS) buf = io.BytesIO() if content_type == "image/jpeg": if img.mode not in ("RGB", "L"): img = img.convert("RGB") img.save(buf, "JPEG", quality=82, optimize=True, progressive=True) elif content_type == "image/png": img.save(buf, "PNG", optimize=True) else: img.save(buf, "WEBP", quality=82, method=4) optimized = buf.getvalue() if len(optimized) < len(data): return optimized, content_type, *img.size return data, content_type, orig_w, orig_h @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} مگابایت است", ) width = height = None if content_type in REENCODE_TYPES: try: data, content_type, width, height = _optimize_image(data, content_type) except Exception: # تصویر خراب/ناشناخته — همان داده اصلی ذخیره می‌شود pass 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, "width": width, "height": height, } # ---------- خروجی CSV (برای تیم فروش) ---------- def _csv_response(filename: str, header: list[str], rows: list[list]) -> Response: buf = io.StringIO() writer = csv.writer(buf) writer.writerow(header) writer.writerows(rows) stamp = datetime.now(timezone.utc).strftime("%Y%m%d") return Response( # BOM ابتدای فایل — تا Excel متن فارسی را درست باز کند content="\ufeff" + buf.getvalue(), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}-{stamp}.csv"'}, ) @router.get("/messages/export.csv") def export_messages(db: Session = Depends(get_db)): rows = [ [m.id, m.name, m.phone, m.note, "yes" if m.is_read else "no", m.created_at.isoformat()] for m in db.query(models.ContactMessage).order_by(models.ContactMessage.created_at.desc()).all() ] return _csv_response( "contact-messages", ["id", "name", "phone", "note", "is_read", "created_at"], rows ) @router.get("/subscribers/export.csv") def export_subscribers(db: Session = Depends(get_db)): rows = [ [s.id, s.email, "yes" if s.is_active else "no", s.created_at.isoformat()] for s in db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all() ] return _csv_response( "newsletter-subscribers", ["id", "email", "is_active", "created_at"], rows ) # پیام‌های تماس @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()