"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند.""" import uuid from datetime import datetime, timezone import boto3 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status 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, verify_password from ..config import settings from ..database import get_db from ..schemas import ( ArticleIn, ArticleOut, ArticlePatch, BeforeAfterIn, BeforeAfterOut, BeforeAfterPatch, BrandIn, BrandOut, BrandPatch, 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, 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, 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", } @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()