پروژه کامل: فرانت متصل به API + داکرایز

- اتصال کامل فرانت به API: همه محتوای سایت (پروژه‌ها، مقالات، خدمات، برند و...) از بک‌اند خوانده می‌شود (SiteProvider + useSite)
- فرم تماس و خبرنامه متصل به بک‌اند و تست‌شده در مرورگر
- ساختار مونوریپو: frontend/ + backend/
- Dockerfile برای هر دو بخش + docker-compose (PostgreSQL + FastAPI + nginx)
- nginx با پروکسی /api — بدون نیاز به CORS در حالت داکر
- README کامل فارسی
This commit is contained in:
Behrad
2026-08-29 13:26:58 +03:30
parent ed14435485
commit ad75f7a7bd
78 changed files with 8242 additions and 133 deletions
View File
+163
View File
@@ -0,0 +1,163 @@
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
from fastapi import APIRouter, Depends, HTTPException, 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 ..database import get_db
from ..schemas import (
ArticleIn,
ArticleOut,
BeforeAfterIn,
BeforeAfterOut,
BrandIn,
BrandOut,
ContactMessageOut,
FaqIn,
FaqOut,
LoginIn,
MessageReadIn,
ProjectIn,
ProjectOut,
ServiceIn,
ServiceOut,
StatIn,
StatOut,
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("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)
# پیام‌های تماس
@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()
+42
View File
@@ -0,0 +1,42 @@
"""انتهای فرم‌ها — ثبت پیام تماس و عضویت در خبرنامه."""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..database import get_db
from .. import models
from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut
router = APIRouter()
@router.post("/contact", response_model=ContactMessageOut, status_code=201)
def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
message = models.ContactMessage(
name=payload.name.strip(),
phone=payload.phone.strip(),
note=payload.note.strip(),
)
db.add(message)
db.commit()
db.refresh(message)
return message
@router.post("/newsletter", response_model=SubscriberOut, status_code=201)
def subscribe(payload: NewsletterIn, db: Session = Depends(get_db)):
email = str(payload.email).lower()
subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first()
if subscriber is not None:
if not subscriber.is_active:
subscriber.is_active = True
db.commit()
db.refresh(subscriber)
return subscriber
subscriber = models.Subscriber(email=email)
db.add(subscriber)
db.commit()
db.refresh(subscriber)
return subscriber
+122
View File
@@ -0,0 +1,122 @@
"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from .. import models
from ..schemas import (
ArticleOut,
BeforeAfterOut,
BrandOut,
FaqOut,
ProjectOut,
ServiceOut,
StatOut,
TeamMemberOut,
TestimonialOut,
TimelineItemOut,
WhyUsItemOut,
)
router = APIRouter()
@router.get("/health")
def health():
return {"status": "ok", "app": "ArameshPlaza API"}
@router.get("/brand", response_model=BrandOut)
def get_brand(db: Session = Depends(get_db)):
brand = db.query(models.Brand).first()
if brand is None:
raise HTTPException(status_code=404, detail="اطلاعات برند یافت نشد")
return brand
@router.get("/services", response_model=list[ServiceOut])
def get_services(db: Session = Depends(get_db)):
return db.query(models.Service).all()
@router.get("/projects", response_model=list[ProjectOut])
def get_projects(db: Session = Depends(get_db)):
return db.query(models.Project).order_by(models.Project.id).all()
@router.get("/projects/{slug}", response_model=ProjectOut)
def get_project(slug: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.slug == slug).first()
if project is None:
raise HTTPException(status_code=404, detail="پروژه یافت نشد")
return project
@router.get("/articles", response_model=list[ArticleOut])
def get_articles(db: Session = Depends(get_db)):
return db.query(models.Article).order_by(models.Article.id).all()
@router.get("/articles/{slug}", response_model=ArticleOut)
def get_article(slug: str, db: Session = Depends(get_db)):
article = db.query(models.Article).filter(models.Article.slug == slug).first()
if article is None:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
return article
@router.get("/testimonials", response_model=list[TestimonialOut])
def get_testimonials(db: Session = Depends(get_db)):
return db.query(models.Testimonial).all()
@router.get("/faqs", response_model=list[FaqOut])
def get_faqs(db: Session = Depends(get_db)):
return db.query(models.Faq).all()
@router.get("/team", response_model=list[TeamMemberOut])
def get_team(db: Session = Depends(get_db)):
return db.query(models.TeamMember).all()
@router.get("/timeline", response_model=list[TimelineItemOut])
def get_timeline(db: Session = Depends(get_db)):
return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all()
@router.get("/stats", response_model=list[StatOut])
def get_stats(db: Session = Depends(get_db)):
return db.query(models.Stat).all()
@router.get("/why-us", response_model=list[WhyUsItemOut])
def get_why_us(db: Session = Depends(get_db)):
return db.query(models.WhyUsItem).all()
@router.get("/before-after", response_model=BeforeAfterOut)
def get_before_after(db: Session = Depends(get_db)):
item = db.query(models.BeforeAfter).first()
if item is None:
raise HTTPException(status_code=404, detail="بخش قبل/بعد یافت نشد")
return item
@router.get("/site")
def get_site(db: Session = Depends(get_db)):
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند."""
return {
"brand": db.query(models.Brand).first(),
"services": db.query(models.Service).all(),
"projects": db.query(models.Project).order_by(models.Project.id).all(),
"articles": db.query(models.Article).order_by(models.Article.id).all(),
"testimonials": db.query(models.Testimonial).all(),
"faqs": db.query(models.Faq).all(),
"team": db.query(models.TeamMember).all(),
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(),
"stats": db.query(models.Stat).all(),
"why_us": db.query(models.WhyUsItem).all(),
"before_after": db.query(models.BeforeAfter).first(),
}