3965 lines
214 KiB
Plaintext
3965 lines
214 KiB
Plaintext
{
|
||
"cells": [
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# مقدمه\n",
|
||
"\n",
|
||
"توی این استایلگاید، قراره با هم یاد بگیریم چطور پروژههای Django رو\n",
|
||
"طوری طراحی کنیم که **هم تمیزتر، هم مقیاسپذیرتر و هم لذتبخشتر** باشن.\n",
|
||
"\n",
|
||
"ایدهی اصلی اینه که بجای اینکه همهچیز رو توی View یا Model بنویسیم،\n",
|
||
"بیایم پروژه رو به چند **لایهی منطقی (Logic Layers)** تقسیم کنیم،\n",
|
||
"که هرکدوم وظیفهی خودش رو داره و کدها از هم **جدا و قابل تست** میشن.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## هدف نهایی\n",
|
||
"قراره به جایی برسیم که:\n",
|
||
"\n",
|
||
"- بدونیم هر بخش از منطق برنامه کجا باید نوشته بشه.\n",
|
||
"- بتونیم با خیال راحت تست بنویسیم و خطاها رو هندل کنیم.\n",
|
||
"- وظیفهی هر لایه (Service, Selector, API, Serializer و ...) مشخص باشه.\n",
|
||
"- اگه یه بخشی از سیستم بزرگ شد، بتونیم راحت جداش کنیم و مدیریت کنیم.\n",
|
||
"- کدها هم برای خودمون، هم برای همتیمیهامون **خوانا، امن و توسعهپذیر** باشن.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## ساختار کلی این راهنما\n",
|
||
"\n",
|
||
"در ادامه قراره با این مباحث آشنا بشیم:\n",
|
||
"\n",
|
||
"1. **API Layer** – نقطهی ورود درخواستها، مسئول دریافت و پاسخ HTTP.\n",
|
||
"2. **Service Layer** – مسئول منطق اصلی کسبوکار (Business Logic).\n",
|
||
"3. **Selector Layer** – لایهی مخصوص خواندن داده از دیتابیس.\n",
|
||
"4. **Serializer Layer** – آمادهسازی دادهها برای API، با prefetch و caching هوشمند.\n",
|
||
"5. **URLs Structure** – سازماندهی مسیرها به روش قابل نگهداری در پروژههای بزرگ.\n",
|
||
"6. **Settings Structure** – تقسیم تنظیمات Django و ۳rd partyها برای محیطهای مختلف.\n",
|
||
"7. **Error Handling** – چطور خطاها رو یکدست و قابل پیشبینی مدیریت کنیم.\n",
|
||
"8. **Celery Integration** – اجرای کارهای پسزمینه و Async به روش تمیز.\n",
|
||
"9. **Testing Strategy** – چطور هر لایه رو جداگانه و مطمئن تست کنیم.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"در نهایت، هدف ما اینه که:\n",
|
||
"> «کدی بنویسیم که هم زیبا باشه، هم قابل فهم، هم انعطافپذیر —\n",
|
||
"> و موقع خوندنش حس نکنیم تو جهنم افتادیم »\n"
|
||
],
|
||
"id": "2f68eed541fac7be"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Django Styleguide — Explained My Way\n",
|
||
"\n",
|
||
"این خلاصهایه از **Django Styleguide** نوشتهی HackSoft — ولی به زبون خودمونیتر.\n",
|
||
"\n",
|
||
"اینجا با نتیجهی سالها پروژههای کوچیک و بزرگ طرفی و سه تا راه داری برای برخورد باهاش:\n",
|
||
"\n",
|
||
"1. مو به مو ازش پیروی کن.\n",
|
||
"2. هرچی برات معنیدار بود انتخاب کن .\n",
|
||
"3. یا کلاً بیخیالش شو — ولی اگه گزینهی سهای… چرا اصلاً اینجایی؟\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## ایدهی اصلی\n",
|
||
"\n",
|
||
"هرچی ابزار و امکانات پروژه بیشتر بشن، نیازت به ظرفهای منظمتر هم بیشتر میشه.\n",
|
||
"هرچی پروژه بزرگتر، لایهها هم باید واضحتر باشن.\n",
|
||
"\n",
|
||
"تو Django منطق پروژه (Business Logic) باید داخل اینا باشه:\n",
|
||
"\n",
|
||
"- **Services** → توابعی که بیشتر کار نوشتن توی دیتابیس رو انجام میدن.\n",
|
||
"- **Selectors** → توابعی که بیشتر کار خوندن از دیتابیس رو میکنن.\n",
|
||
"- **Model Properties** (در موارد خاص).\n",
|
||
"- **متد `clean()`** برای اعتبارسنجی اضافه (در موارد خاص).\n",
|
||
"\n",
|
||
"و نباید داخل اینا باشه:\n",
|
||
"\n",
|
||
"- APIها و Viewها\n",
|
||
"- Serializerها و Formها\n",
|
||
"- تگهای Form\n",
|
||
"- متد `save()` مدل\n",
|
||
"- Custom Managerها یا QuerySetها\n",
|
||
"- Signalها\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## ️ Model Property یا Selector؟\n",
|
||
"\n",
|
||
"- اگه Property مربوط به چند رابطه (multi-relation) باشه → ببرش تو Selector.\n",
|
||
"- اگه Property پیچیدهست و ممکنه باعث **N + 1 query problem** بشه → باز هم Selector.\n",
|
||
"\n",
|
||
"هدف کلی:\n",
|
||
"**\"Separate Concerns\"** — یعنی هر بخش فقط کار خودش رو انجام بده تا بشه راحتتر نگهش داشت و تستش کرد.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## 🤔 چرا نه View یا Serializer؟\n",
|
||
"\n",
|
||
"چون وقتی منطق رو پخش کنی بین View و Serializer و Form،\n",
|
||
"کد تیکهتیکه میشه، جریان دیتا گم میشه،\n",
|
||
"و برای یه تغییر ساده باید بری دل abstractionها ببینی چی به چیه.\n",
|
||
"\n",
|
||
"بله، generic View و serializerها برای CRUD ساده عالیان،\n",
|
||
"ولی وقتی از مسیر خوش و خرم CRUD خارج شدی، وارد باسفایت میشی\n",
|
||
"اونجاست که اوضاع شلخته میشه و کمکم یه **لگسی ریپو** ازت جا میمونه.\n",
|
||
"\n",
|
||
"پس این Styleguide اومده تا برات **جعبه** تعریف کنه:\n",
|
||
"تا بتونی منطق رو توی جای درستش بچینی، برای پروژهی خودت.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## Core در برابر Interface\n",
|
||
"\n",
|
||
"رفتار اصلی برنامه (business logic) نباید قاطی لایهی ارتباطش با بیرون بشه.\n",
|
||
"API، CLI، management command — اینا فقط Interface ان.\n",
|
||
"مرز بین **core** و **interface** باید واضح باشه.\n",
|
||
"بعضی وقتا قاطی میشن، ولی داشتن این مرز یعنی پروژهات تمیز، تستپذیر و قابلگسترشه.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## چرا نه Custom Manager یا QuerySet؟\n",
|
||
"\n",
|
||
"ایدهی بدی نیست، اما کافی نیست.\n",
|
||
"\n",
|
||
"ممکنه Manager/QuerySet بهتر برای مدل بسازی،\n",
|
||
"ولی نمیتونی کل منطق پروژه رو اونجا بچپونی، چون:\n",
|
||
"\n",
|
||
"- منطق پروژه همیشه دقیقاً با مدل یکی نیست.\n",
|
||
"- معمولاً بین چند تا مدل پخش میشه (A و B و C و D و …).\n",
|
||
"- بعضی وقتا باید با سرویسهای خارجی کار کنه — و اونجا جاش توی Manager نیست.\n",
|
||
"\n",
|
||
"پس منطق رو از لایهی دیتا جدا کن و بذار توی **Service Layer**.\n",
|
||
"\n",
|
||
"Service ممکنه تابع، کلاس، یا حتی ماژول باشه — هرچی منطقیتره برای مورد تو.\n",
|
||
"\n",
|
||
"Manager و QuerySet ابزارهای قدرتمندن،\n",
|
||
"ولی کارشون فقط ارائهی **interface بهتر** برای مدلهاست، نه جای منطق اصلی.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## ⚠️ چرا نه Signal؟\n",
|
||
"\n",
|
||
"Signalها ابزار خوبیان برای **چیزایی که نباید همدیگه رو بشناسن**\n",
|
||
"ولی میخوای وصلشون کنی — مثلاً برای invalidate کردن cache.\n",
|
||
"\n",
|
||
"اما اگه منطق اصلی رو ببری اونجا،\n",
|
||
"اتصالها **پنهون و غیرقابلردیابی** میشن.\n",
|
||
"در نتیجه، دیباگش به جهنم ختم میشه 🔥\n",
|
||
"\n",
|
||
"بنابراین Signal فقط برای موارد خاص،\n",
|
||
"نه برای ساختار دادن به لایهی domain/business.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## خلاصهی داستان\n",
|
||
"\n",
|
||
"هرچی پروژه بزرگتر، ساختار باید حرفهایتر.\n",
|
||
"منطق رو بذار توی Service و Selector، نه توی View و Serializer.\n",
|
||
"Core رو از Interface جدا کن.\n",
|
||
"و لطفاً، لطفاً Signal رو فقط وقتی استفاده کن که واقعاً لازمه. 😅\n"
|
||
],
|
||
"id": "a0d4b30c7f3312b9"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# 🍪 Cookiecutter — پروژه رو با ساختار درست شروع کن\n",
|
||
"\n",
|
||
"اوه، در ضمن...\n",
|
||
"بیشتر دردسرهای اول پروژه معمولاً سر **فولدربندی و فایلمنیجمنت** شروع میشن\n",
|
||
"ولی خدا رو شکر یه چیز آماده هست به اسم **Cookiecutter** که کارت رو راحت میکنه.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## Cookiecutter چیه؟\n",
|
||
"\n",
|
||
"یه ابزار برای ساختن **اسکلت اولیه پروژه** ـه.\n",
|
||
"به جای اینکه هر بار بری دستی فولدر بسازی و `settings.py` و `config` و `tests` رو از صفر درست کنی،\n",
|
||
"با یه دستور ساده برات یه ساختار حرفهای و آماده میسازه.\n",
|
||
"\n",
|
||
"مثلاً:\n",
|
||
"\n",
|
||
"```bash\n",
|
||
"cookiecutter https://github.com/cookiecutter/cookiecutter-django\n"
|
||
],
|
||
"id": "1d42ac476244445c"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Model\n",
|
||
"\n",
|
||
"مدل فقط باید مسئول **داده (Data Model)** باشه — نه چیز دیگه.\n",
|
||
"یعنی کاری به منطق (Business Logic)، API یا سایر لایهها نداره.\n",
|
||
"مدل فقط باید تعریف کنه **چی ذخیره میکنیم** و **چطور ذخیره میکنیم**.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## BaseModel\n",
|
||
"\n",
|
||
"ما معمولاً از **تکرار کد** خوشمون نمیاد (شما هم احتمالاً همینطور ).\n",
|
||
"پس بهترین کار اینه که برای همهی مدلها **یه مدل پایه (Parent Model)** تعریف کنیم.\n",
|
||
"\n",
|
||
"این مدل پایه، فیلدهایی رو نگه میداره که تقریباً همهی مدلها بهش نیاز دارن.\n",
|
||
"معمولترینش هم ایناست:\n",
|
||
"\n",
|
||
"- `created_at` → تاریخ ساخت\n",
|
||
"- `updated_at` → تاریخ آخرین تغییر\n",
|
||
"\n",
|
||
"به این ترتیب لازم نیست توی هر مدل جداگانه این فیلدها رو تکرار کنیم.\n",
|
||
"کد تمیزتر، کوتاهتر و قابلنگهداریتر میمونه.\n",
|
||
"\n"
|
||
],
|
||
"id": "f45895968b2f2150"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"source": [
|
||
"from django.db import models\n",
|
||
"from django.utils import timezone\n",
|
||
"\n",
|
||
"\n",
|
||
"class BaseModel(models.Model):\n",
|
||
" created_at = models.DateTimeField(db_index=True, default=timezone.now)\n",
|
||
" updated_at = models.DateTimeField(auto_now=True)\n",
|
||
"\n",
|
||
" class Meta:\n",
|
||
" abstract = True"
|
||
],
|
||
"id": "863c92fa490a623",
|
||
"outputs": [],
|
||
"execution_count": null
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " هر موقع هم مودل جدید خواستی فقط از `BaseModel` ارث بری کن",
|
||
"id": "b4ba7889997addfb"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class SomeModel(BaseModel):\n",
|
||
" pass"
|
||
],
|
||
"id": "57747f8d45cf2c67"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# ✅ Validation — `clean()` & `full_clean()`\n",
|
||
" گاهی نیازه که در مودل خود مطمعن بشیم که دیتایی که در حال ارسال ذخیره سازی هست درسته یا نه"
|
||
],
|
||
"id": "c35ef5d471064d6a"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class Course(BaseModel):\n",
|
||
" name = models.CharField(unique=True, max_length=255)\n",
|
||
" start_date = models.DateField()\n",
|
||
" end_date = models.DateField()\n",
|
||
"\n",
|
||
" def clean(self):\n",
|
||
" if self.start_date >= self.end_date:\n",
|
||
" raise ValidationError(\"End date cannot be before start date\")\n"
|
||
],
|
||
"id": "7f2183666c9579ee"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"اینجا با متد `clean()` داریم مطمئن میشیم که دادهی اشتباه وارد دیتابیس نشه.\n",
|
||
"اما دقت کن! برای اینکه `clean()` صدا زده بشه باید قبل از `save()`،\n",
|
||
"یه بار `full_clean()` روی شیء اجرا بشه 👇"
|
||
],
|
||
"id": "2a40301792435642"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def course_create(*, name: str, start_date: date, end_date: date) -> Course:\n",
|
||
" obj = Course(name=name, start_date=start_date, end_date=end_date)\n",
|
||
"\n",
|
||
" obj.full_clean() # اعتبارسنجی\n",
|
||
" obj.save() # ذخیره در دیتابیس\n",
|
||
"\n",
|
||
" return obj\n"
|
||
],
|
||
"id": "8e9df84a2740ada5"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"این روش هم با Django Admin هماهنگه،\n",
|
||
"چون اونجا فرمها خودشون `full_clean()` رو صدا میزنن.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## 📋 قانون کلی\n",
|
||
"\n",
|
||
"✅ وقتی اعتبارسنجی فقط روی چند تا **فیلد سادهی مدل** هست → توی `clean()` بنویس.\n",
|
||
"⚙️ وقتی اعتبارسنجی پیچیدهست یا نیاز به **داده از مدلهای دیگه** داری → ببرش توی **service layer**.\n"
|
||
],
|
||
"id": "4349f0b2b2e08b97"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Validation — Constraints\n",
|
||
"\n",
|
||
"اگر بشه با Constraint انجامش داد، همیشه اون راه بهتره 👇\n",
|
||
"چون دیتابیس خودش جلوی دادهی اشتباه رو میگیره (حتی از بیرون Django).\n"
|
||
],
|
||
"id": "8a6e4f232c7ae222"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class Course(BaseModel):\n",
|
||
" name = models.CharField(unique=True, max_length=255)\n",
|
||
" start_date = models.DateField()\n",
|
||
" end_date = models.DateField()\n",
|
||
"\n",
|
||
" class Meta:\n",
|
||
" constraints = [\n",
|
||
" models.CheckConstraint(\n",
|
||
" name=\"start_date_before_end_date\",\n",
|
||
" check=Q(start_date__lt=F(\"end_date\"))\n",
|
||
" )\n",
|
||
" ]\n"
|
||
],
|
||
"id": "ff2dab1ff2abd89"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"از Django 4.1 به بعد،\n",
|
||
"`full_clean()` حتی **constraints**ها رو هم چک میکنه 👀\n",
|
||
"پس به جای `IntegrityError`، `ValidationError` میگیری.\n",
|
||
"\n",
|
||
"📚 [Docs](https://docs.djangoproject.com/en/4.1/ref/models/instances/#validating-objects)\n"
|
||
],
|
||
"id": "da2002f4149ecdc7"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Properties\n",
|
||
"\n",
|
||
"گاهی لازمه مقدارهای سادهی مشتقشده از فیلدها رو سریع بگیریم.\n",
|
||
"اونجا بهترین گزینه، `@property` هست.\n"
|
||
],
|
||
"id": "c28b41382d9cc6f3"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.utils import timezone\n",
|
||
"\n",
|
||
"class Course(BaseModel):\n",
|
||
" ...\n",
|
||
" @property\n",
|
||
" def has_started(self) -> bool:\n",
|
||
" now = timezone.now()\n",
|
||
" return self.start_date <= now.date()\n",
|
||
"\n",
|
||
" @property\n",
|
||
" def has_finished(self) -> bool:\n",
|
||
" now = timezone.now()\n",
|
||
" return self.end_date <= now.date()\n"
|
||
],
|
||
"id": "46be994e2cb5c266"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"الان میتونی توی serializer یا template بنویسی:\n",
|
||
"\n",
|
||
"```python\n",
|
||
"course.has_started\n",
|
||
"course.has_finished\n"
|
||
],
|
||
"id": "8237eece2f794240"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Methods\n",
|
||
"\n",
|
||
"متدهای مدل ابزار قدرتمندی هستن که میتونن روی properties ساخته بشن یا دادههای مدل رو تغییر بدن.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## 🧩 مثال ساده\n",
|
||
"\n",
|
||
"در این مثال، متد `is_within(self, x)` بررسی میکنه که آیا تاریخ `x` بین `start_date` و `end_date` هست یا نه.\n"
|
||
],
|
||
"id": "464c1da129b3874"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.core.exceptions import ValidationError\n",
|
||
"from django.utils import timezone\n",
|
||
"from datetime import date\n",
|
||
"\n",
|
||
"\n",
|
||
"class Course(BaseModel):\n",
|
||
" name = models.CharField(unique=True, max_length=255)\n",
|
||
" start_date = models.DateField()\n",
|
||
" end_date = models.DateField()\n",
|
||
"\n",
|
||
" def clean(self):\n",
|
||
" if self.start_date >= self.end_date:\n",
|
||
" raise ValidationError(\"End date cannot be before start date\")\n",
|
||
"\n",
|
||
" @property\n",
|
||
" def has_started(self) -> bool:\n",
|
||
" now = timezone.now()\n",
|
||
" return self.start_date <= now.date()\n",
|
||
"\n",
|
||
" @property\n",
|
||
" def has_finished(self) -> bool:\n",
|
||
" now = timezone.now()\n",
|
||
" return self.end_date <= now.date()\n",
|
||
"\n",
|
||
" def is_within(self, x: date) -> bool:\n",
|
||
" return self.start_date <= x <= self.end_date\n"
|
||
],
|
||
"id": "9f0b439a9f9d0884"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"متد `is_within` نمیتونه property باشه چون نیاز به آرگومان داره، پس بهصورت متد تعریف میشه.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## 🔁 مثال دوم: ستکردن چند مقدار با هم\n",
|
||
"\n",
|
||
"گاهی وقتا وقتی یک attribute تغییر میکنه، باید attribute دیگهای هم بر اساس اون آپدیت بشه.\n",
|
||
"در این مواقع میتونیم از یک متد استفاده کنیم.\n",
|
||
"اینجا چون `x` ورودی داره، دیگه property نیست، method حساب میشه.\n"
|
||
],
|
||
"id": "87ee9f1eb34fa889"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.utils.crypto import get_random_string\n",
|
||
"from django.conf import settings\n",
|
||
"from django.utils import timezone\n",
|
||
"\n",
|
||
"\n",
|
||
"class Token(BaseModel):\n",
|
||
" secret = models.CharField(max_length=255, unique=True)\n",
|
||
" expiry = models.DateTimeField(blank=True, null=True)\n",
|
||
"\n",
|
||
" def set_new_secret(self):\n",
|
||
" now = timezone.now()\n",
|
||
" self.secret = get_random_string(255)\n",
|
||
" self.expiry = now + settings.TOKEN_EXPIRY_TIMEDELTA\n",
|
||
" return self\n"
|
||
],
|
||
"id": "f3f24d65fb578fc2"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"با صدا زدن `set_new_secret()`، هر دو مقدار `secret` و `expiry` بهدرستی مقداردهی میشن.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## 📜 قانون کلی\n",
|
||
"\n",
|
||
"✅ اگه مقدار جدید فقط از خود فیلدهای مدل حساب میشه → متد.\n",
|
||
"⚙️ اگه باید از مدلهای دیگه داده بگیری یا محاسبه پیچیدهست → سرویس (Service).\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"# 🧪 Testing\n",
|
||
"\n",
|
||
"مدلها فقط وقتی تست نیاز دارن که منطق خاصی داشته باشن (مثل validation یا property یا method).\n"
|
||
],
|
||
"id": "6ea8b2ecd4ec994b"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from datetime import timedelta\n",
|
||
"from django.test import TestCase\n",
|
||
"from django.core.exceptions import ValidationError\n",
|
||
"from django.utils import timezone\n",
|
||
"from project.some_app.models import Course\n",
|
||
"\n",
|
||
"\n",
|
||
"class CourseTests(TestCase):\n",
|
||
" def test_course_end_date_cannot_be_before_start_date(self):\n",
|
||
" start_date = timezone.now()\n",
|
||
" end_date = timezone.now() - timedelta(days=1)\n",
|
||
"\n",
|
||
" course = Course(start_date=start_date, end_date=end_date)\n",
|
||
"\n",
|
||
" with self.assertRaises(ValidationError):\n",
|
||
" course.full_clean()\n"
|
||
],
|
||
"id": "592b86eae5ab75f8"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"✅ نکات مهم:\n",
|
||
"- `full_clean()` بررسی میکنه که اعتبار دادهها درست باشه.\n",
|
||
"- نیازی نیست به دیتابیس وصل بشیم، چون فقط منطق مدل تست میشه → تست سریعتر میشه.\n"
|
||
],
|
||
"id": "d7153c4801ac18f9"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Services\n",
|
||
"\n",
|
||
"سرویسها جایی هستن که **منطق تجاری (Business Logic)** پروژه در اونها قرار میگیره.\n",
|
||
"یعنی هر چیزی که به قوانین یا رفتار اصلی سیستم مربوطه — نه صرفاً ذخیرهسازی یا نمایش داده.\n",
|
||
"\n",
|
||
"لایهی Service زبانی متناسب با **دامنهی پروژه (Domain Language)** صحبت میکنه،\n",
|
||
"به دیتابیس و منابع خارجی دسترسی داره،\n",
|
||
"و میتونه با بخشهای دیگهی سیستم تعامل کنه.\n",
|
||
"\n",
|
||
"## جایگاه در ساختار پروژه\n",
|
||
"\n",
|
||
"لایهی Service معمولاً بین **View** و **Model** قرار میگیره:\n",
|
||
"\n",
|
||
"[ Views ] → [ Services ] → [ Models ]"
|
||
],
|
||
"id": "47bfa5655b35cfcd"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": "",
|
||
"id": "810e866eefd4da64",
|
||
"attachments": {
|
||
"a56fbb8b-78e7-4442-bb46-e3974577e458.png": {
|
||
"image/png": "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"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"## ساختار کلی Service\n",
|
||
"\n",
|
||
"یک سرویس میتونه هرکدوم از اینا باشه:\n",
|
||
"\n",
|
||
"- یک تابع ساده\n",
|
||
"- یک کلاس\n",
|
||
"- حتی یک ماژول کامل\n",
|
||
"- یا هر چیزی که **برای پروژهت منطقی باشه**\n",
|
||
"\n",
|
||
"\n",
|
||
"## معمولترین حالت\n",
|
||
"\n",
|
||
"در بیشتر موارد، سرویسها فقط **توابع ساده** هستن که:\n",
|
||
"\n",
|
||
"- داخل فایل `<your_app>/services.py` قرار میگیرن\n",
|
||
"- ورودیهاشون **keyword-only** هست (مگر اینکه فقط یه آرگومان نیاز داشته باشن)\n",
|
||
"- با **type hint** نوشته میشن، حتی اگه فعلاً از `mypy` استفاده نکنی\n",
|
||
"- با دیتابیس، فایلها، APIها یا سرویسهای دیگه ارتباط دارن\n",
|
||
"- منطق اصلی بیزینس رو انجام میدن — از ساخت سادهی مدلها گرفته تا عملیات پیچیده یا فراخوانی سرویسهای خارجی\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"Example - function-based service"
|
||
],
|
||
"id": "7217107a64af0d96"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def user_create(\n",
|
||
" *,\n",
|
||
" email: str,\n",
|
||
" name: str\n",
|
||
") -> User:\n",
|
||
" user = User(email=email)\n",
|
||
" user.full_clean()\n",
|
||
" user.save()\n",
|
||
"\n",
|
||
" profile_create(user=user, name=name)\n",
|
||
" confirmation_email_send(user=user)\n",
|
||
"\n",
|
||
" return user"
|
||
],
|
||
"id": "b6bd9661823ed54c"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": "Example - class-based service\n",
|
||
"id": "8975bbd5ce5df8a"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"# https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/files/services.py\n",
|
||
"\n",
|
||
"\n",
|
||
"class FileStandardUploadService:\n",
|
||
" \"\"\"\n",
|
||
" This also serves as an example of a service class,\n",
|
||
" which encapsulates 2 different behaviors (create & update) under a namespace.\n",
|
||
"\n",
|
||
" Meaning, we use the class here for:\n",
|
||
"\n",
|
||
" 1. The namespace\n",
|
||
" 2. The ability to reuse `_infer_file_name_and_type` (which can also be an util)\n",
|
||
" \"\"\"\n",
|
||
" def __init__(self, user: BaseUser, file_obj):\n",
|
||
" self.user = user\n",
|
||
" self.file_obj = file_obj\n",
|
||
"\n",
|
||
" def _infer_file_name_and_type(self, file_name: str = \"\", file_type: str = \"\") -> Tuple[str, str]:\n",
|
||
" file_name = file_name or self.file_obj.name\n",
|
||
"\n",
|
||
" if not file_type:\n",
|
||
" guessed_file_type, encoding = mimetypes.guess_type(file_name)\n",
|
||
" file_type = guessed_file_type or \"\"\n",
|
||
"\n",
|
||
" return file_name, file_type\n",
|
||
"\n",
|
||
" @transaction.atomic\n",
|
||
" def create(self, file_name: str = \"\", file_type: str = \"\") -> File:\n",
|
||
" _validate_file_size(self.file_obj)\n",
|
||
"\n",
|
||
" file_name, file_type = self._infer_file_name_and_type(file_name, file_type)\n",
|
||
"\n",
|
||
" obj = File(\n",
|
||
" file=self.file_obj,\n",
|
||
" original_file_name=file_name,\n",
|
||
" file_name=file_generate_name(file_name),\n",
|
||
" file_type=file_type,\n",
|
||
" uploaded_by=self.user,\n",
|
||
" upload_finished_at=timezone.now()\n",
|
||
" )\n",
|
||
"\n",
|
||
" obj.full_clean()\n",
|
||
" obj.save()\n",
|
||
"\n",
|
||
" return obj\n",
|
||
"\n",
|
||
" @transaction.atomic\n",
|
||
" def update(self, file: File, file_name: str = \"\", file_type: str = \"\") -> File:\n",
|
||
" _validate_file_size(self.file_obj)\n",
|
||
"\n",
|
||
" file_name, file_type = self._infer_file_name_and_type(file_name, file_type)\n",
|
||
"\n",
|
||
" file.file = self.file_obj\n",
|
||
" file.original_file_name = file_name\n",
|
||
" file.file_name = file_generate_name(file_name)\n",
|
||
" file.file_type = file_type\n",
|
||
" file.uploaded_by = self.user\n",
|
||
" file.upload_finished_at = timezone.now()\n",
|
||
"\n",
|
||
" file.full_clean()\n",
|
||
" file.save()\n",
|
||
"\n",
|
||
" return file"
|
||
],
|
||
"id": "c8e3aaa27bfb4ee3"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"\n",
|
||
"\n",
|
||
"همونطور که تو کامنت گفته شد، ما این روش رو به دو دلیل اصلی استفاده میکنیم:\n",
|
||
"\n",
|
||
"1. **Namespace**\n",
|
||
" - یک فضای نام واحد داریم برای `create` و `update`.\n",
|
||
"\n",
|
||
"2. **Reuse**\n",
|
||
" - میخوایم از منطق `_infer_file_name_and_type` دوباره استفاده کنیم.\n",
|
||
"\n",
|
||
"در ادامه، نمونهای از نحوهی استفادهی این سرویس رو میبینیم:\n"
|
||
],
|
||
"id": "cecbfe320f4741ae"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"# https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/files/apis.py\n",
|
||
"\n",
|
||
"class FileDirectUploadApi(ApiAuthMixin, APIView):\n",
|
||
" def post(self, request):\n",
|
||
" service = FileDirectUploadService(\n",
|
||
" user=request.user,\n",
|
||
" file_obj=request.FILES[\"file\"]\n",
|
||
" )\n",
|
||
" file = service.create()\n",
|
||
"\n",
|
||
" return Response(data={\"id\": file.id}, status=status.HTTP_201_CREATED)"
|
||
],
|
||
"id": "e067d685d21197af"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"@admin.register(File)\n",
|
||
"class FileAdmin(admin.ModelAdmin):\n",
|
||
" # ... other code here ...\n",
|
||
" # https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/files/admin.py\n",
|
||
"\n",
|
||
" def save_model(self, request, obj, form, change):\n",
|
||
" try:\n",
|
||
" cleaned_data = form.cleaned_data\n",
|
||
"\n",
|
||
" service = FileDirectUploadService(\n",
|
||
" file_obj=cleaned_data[\"file\"],\n",
|
||
" user=cleaned_data[\"uploaded_by\"]\n",
|
||
" )\n",
|
||
"\n",
|
||
" if change:\n",
|
||
" service.update(file=obj)\n",
|
||
" else:\n",
|
||
" service.create()\n",
|
||
" except ValidationError as exc:\n",
|
||
" self.message_user(request, str(exc), messages.ERROR)"
|
||
],
|
||
"id": "52d5d1cf07308729"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " استفاده از classbased خیلی برای کار ها دارای جریان مفیده مثل کار های مولتی استیج",
|
||
"id": "47a806c2ff87b03a"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"# https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/files/services.py\n",
|
||
"\n",
|
||
"\n",
|
||
"class FileDirectUploadService:\n",
|
||
" \"\"\"\n",
|
||
" This also serves as an example of a service class,\n",
|
||
" which encapsulates a flow (start & finish) + one-off action (upload_local) into a namespace.\n",
|
||
"\n",
|
||
" Meaning, we use the class here for:\n",
|
||
"\n",
|
||
" 1. The namespace\n",
|
||
" \"\"\"\n",
|
||
" def __init__(self, user: BaseUser):\n",
|
||
" self.user = user\n",
|
||
"\n",
|
||
" @transaction.atomic\n",
|
||
" def start(self, *, file_name: str, file_type: str) -> Dict[str, Any]:\n",
|
||
" file = File(\n",
|
||
" original_file_name=file_name,\n",
|
||
" file_name=file_generate_name(file_name),\n",
|
||
" file_type=file_type,\n",
|
||
" uploaded_by=self.user,\n",
|
||
" file=None\n",
|
||
" )\n",
|
||
" file.full_clean()\n",
|
||
" file.save()\n",
|
||
"\n",
|
||
" upload_path = file_generate_upload_path(file, file.file_name)\n",
|
||
"\n",
|
||
" \"\"\"\n",
|
||
" We are doing this in order to have an associated file for the field.\n",
|
||
" \"\"\"\n",
|
||
" file.file = file.file.field.attr_class(file, file.file.field, upload_path)\n",
|
||
" file.save()\n",
|
||
"\n",
|
||
" presigned_data: Dict[str, Any] = {}\n",
|
||
"\n",
|
||
" if settings.FILE_UPLOAD_STORAGE == FileUploadStorage.S3:\n",
|
||
" presigned_data = s3_generate_presigned_post(\n",
|
||
" file_path=upload_path, file_type=file.file_type\n",
|
||
" )\n",
|
||
"\n",
|
||
" else:\n",
|
||
" presigned_data = {\n",
|
||
" \"url\": file_generate_local_upload_url(file_id=str(file.id)),\n",
|
||
" }\n",
|
||
"\n",
|
||
" return {\"id\": file.id, **presigned_data}\n",
|
||
"\n",
|
||
" @transaction.atomic\n",
|
||
" def finish(self, *, file: File) -> File:\n",
|
||
" # Potentially, check against user\n",
|
||
" file.upload_finished_at = timezone.now()\n",
|
||
" file.full_clean()\n",
|
||
" file.save()\n",
|
||
"\n",
|
||
" return file"
|
||
],
|
||
"id": "f44e1ac4b30189b1"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"\n",
|
||
"نامگذاری سرویسها به سلیقهی شما بستگی داره، اما مهمه که **یکدست و قابل پیشبینی** باشه.\n",
|
||
"\n",
|
||
"اگر به مثال قبلی نگاه کنیم، سرویس ما `user_create` نام داره.\n",
|
||
"الگوی پیشنهادی: `<entity>_<action>`.\n",
|
||
"\n",
|
||
"1. **Namespace**\n",
|
||
" - راحت میتونیم همهی سرویسهایی که با `user_` شروع میشن رو پیدا کنیم.\n",
|
||
" - همچنین میتونیم اونها رو در یک ماژول جداگانه مثل `users.py` قرار بدیم.\n",
|
||
"\n",
|
||
"2. **Greppability**\n",
|
||
" - اگر بخوایم همهی عملیات مرتبط با یک موجودیت خاص رو ببینیم، کافیه grep کنیم روی `user_`."
|
||
],
|
||
"id": "f9066ce8d21bd233"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Modules\n",
|
||
"\n",
|
||
"اگه یک اپ سادهی Django داری و تعداد کمی سرویس داری، میتونی همه رو داخل یک فایل `services.py` نگه داری.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## ️ وقتی پروژه بزرگ میشه\n",
|
||
"\n",
|
||
"وقتی سرویسها زیاد شدن، بهتره `services.py` رو به یک **پکیج (فولدر) با زیرماژولها** تبدیل کنی،\n",
|
||
"بسته به **سابدامینها** یا بخشهای مختلفی که در اپ داری.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"فرض کنیم یک اپ Authentication داریم:\n",
|
||
"\n",
|
||
"- یک زیرماژول برای **JWT**\n",
|
||
"- یک زیرماژول برای **OAuth**\n",
|
||
"\n",
|
||
"```text\n",
|
||
"services\n",
|
||
"├── __init__.py\n",
|
||
"├── jwt.py\n",
|
||
"└── oauth.py"
|
||
],
|
||
"id": "bf2e2bda65226330"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Flavors of Service Modules\n",
|
||
"\n",
|
||
"چند روش مختلف برای ساختاردهی سرویسها وجود داره:\n",
|
||
"\n",
|
||
"1. **Import-export dance**\n",
|
||
" - میتونی داخل `services/__init__.py` همهی سرویسها رو ایمپورت کنی،\n",
|
||
" - تا بتونی از هر جای پروژه، فقط با `from project.authentication.services import ...` دسترسی داشته باشی.\n",
|
||
"\n",
|
||
"2. **Folder-module**\n",
|
||
" - میتونی یک فولدر مثل `jwt/` بسازی و `__init__.py` داشته باشه،\n",
|
||
" - و تمام کدهای مرتبط با JWT رو اونجا بذاری.\n",
|
||
"\n",
|
||
"3. **Flexibility**\n",
|
||
" - در نهایت، ساختار کاملاً به خودت بستگی داره.\n",
|
||
" - اگه حس کردی وقتشه بازسازی و ریفکتور انجام بدی — همین کار رو بکن"
|
||
],
|
||
"id": "f43a82fe12fd62c0"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Selectors\n",
|
||
"\n",
|
||
"در بیشتر پروژههامون، بین **پوش دادن (Push)** و **پول کشیدن (Pull)** دادهها از دیتابیس تفاوت قائل میشیم:\n",
|
||
"\n",
|
||
"- 🧩 **Services** مسئول \"Push\" هستن — یعنی وارد کردن، تغییر دادن یا حذف دادهها در دیتابیس.\n",
|
||
"- 🔍 **Selectors** مسئول \"Pull\" هستن — یعنی واکشی و خواندن دادهها از دیتابیس.\n",
|
||
"\n",
|
||
"به عبارتی، **Selectors** یک «زیرلایه» از سرویسها هستن که تخصصش در گرفتن دادههاست.\n",
|
||
"\n",
|
||
"اگه با این ایده خیلی ارتباط نمیگیری، هیچ اشکالی نداره — میتونی برای هر دو نوع عملیات فقط از سرویسها استفاده کنی.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## قواعد Selector\n",
|
||
"\n",
|
||
"یک Selector دقیقاً از همان قوانین Service پیروی میکند:\n",
|
||
"\n",
|
||
"- میتونه تابع، کلاس یا ماژول باشه.\n",
|
||
"- در `<your_app>/selectors.py` قرار میگیره.\n",
|
||
"- ورودیهاش باید شفاف و keyword-only باشن.\n",
|
||
"- خروجی معمولاً queryset یا object برمیگردونه.\n",
|
||
"\n",
|
||
"---"
|
||
],
|
||
"id": "14fffe05635a6b5a"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def user_list(*, fetched_by: User) -> Iterable[User]:\n",
|
||
" user_ids = user_get_visible_for(user=fetched_by)\n",
|
||
"\n",
|
||
" query = Q(id__in=user_ids)\n",
|
||
"\n",
|
||
" return User.objects.filter(query)"
|
||
],
|
||
"id": "11a6a260e9e93772"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Testing\n",
|
||
"\n",
|
||
"از اونجایی که **Service Layer** منطق اصلی کسبوکار (Business Logic) رو در خودش داره،\n",
|
||
"بهترین نقطه برای نوشتن تستهای دقیق و هدفمند هم هست.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## قوانین کلی برای تست سرویسها\n",
|
||
"\n",
|
||
"اگر تصمیم گرفتی **service layer** رو با تست پوشش بدی، این چند قانون کلی رو در نظر بگیر:\n",
|
||
"\n",
|
||
"1. **تستها باید تمام منطق کسبوکار رو بهصورت کامل پوشش بدن.**\n",
|
||
" یعنی تمام مسیرهای ممکن در تابع یا کلاس تست بشن.\n",
|
||
"\n",
|
||
"2. **تستها باید به دیتابیس واقعی (در محیط تست) دسترسی داشته باشن.**\n",
|
||
" یعنی داده بسازن، ذخیره کنن و دوباره بخونن.\n",
|
||
"\n",
|
||
"3. **هر چیزی که از پروژه خارج میشه باید Mock بشه.**\n",
|
||
" مثل:\n",
|
||
" - فراخوانی async taskها\n",
|
||
" - درخواست به APIهای خارجی\n",
|
||
" - ایمیلزدن یا پیامفرستادن\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## ساخت وضعیت مورد نیاز برای تست\n",
|
||
"\n",
|
||
"برای ساخت حالت اولیه (state) مورد نیاز هر تست، میتونی از ترکیب روشهای زیر استفاده کنی:\n",
|
||
"\n",
|
||
"- **Fakes** → (مثل کتابخانه `faker`) برای ساخت دادهی تصادفی و طبیعی.\n",
|
||
"- **سرویسهای دیگر** → از سرویسهای موجود برای ساخت آبجکتهای مورد نیاز استفاده کن.\n",
|
||
"- **Test utilities & helpers** → متدهای کمکی مخصوص تستها.\n",
|
||
"- **Factories** → پیشنهاد میکنیم از `factory_boy` استفاده کنی.\n",
|
||
"- **Plain `.objects.create()` calls** → اگر هنوز factory نداری، مستقیم با ORM بساز.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"در نهایت، هر روشی که بهت کمک کنه تستهایت تمیز، خوانا و پایدار باشن، بهترین انتخابه.\n"
|
||
],
|
||
"id": "46c62fdd47f9f14e"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.contrib.auth.models import User\n",
|
||
"from django.core.exceptions import ValidationError\n",
|
||
"from django.db import transaction\n",
|
||
"\n",
|
||
"from project.payments.selectors import items_get_for_user\n",
|
||
"from project.payments.models import Item, Payment\n",
|
||
"from project.payments.tasks import payment_charge\n",
|
||
"\n",
|
||
"\n",
|
||
"@transaction.atomic\n",
|
||
"def item_buy(\n",
|
||
" *,\n",
|
||
" item: Item,\n",
|
||
" user: User,\n",
|
||
") -> Payment:\n",
|
||
" if item in items_get_for_user(user=user):\n",
|
||
" raise ValidationError(f'Item {item} already in {user} items.')\n",
|
||
"\n",
|
||
" payment = Payment(\n",
|
||
" item=item,\n",
|
||
" user=user,\n",
|
||
" successful=False\n",
|
||
" )\n",
|
||
" payment.full_clean()\n",
|
||
" payment.save()\n",
|
||
"\n",
|
||
" # Run the task once the transaction has commited,\n",
|
||
" # guaranteeing the object has been created.\n",
|
||
" transaction.on_commit(\n",
|
||
" lambda: payment_charge.delay(payment_id=payment.id)\n",
|
||
" )\n",
|
||
"\n",
|
||
" return payment"
|
||
],
|
||
"id": "cafdccd628863788"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"1. **فراخوانی یک Selector برای اعتبارسنجی (Validation)**\n",
|
||
" ابتدا دادهها رو از دیتابیس واکشی میکنه یا بررسی میکنه.\n",
|
||
"\n",
|
||
"2. **ایجاد یک آبجکت**\n",
|
||
" بعد از اعتبارسنجی، یک آبجکت جدید ایجاد میکنه.\n",
|
||
"\n",
|
||
"3. **تاخیر یک تسک**\n",
|
||
" در نهایت، یک تسک async رو به صورت delayed فراخوانی میکنه برای انجام کارهای پسزمینه."
|
||
],
|
||
"id": "e516af97cdcd05aa"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from unittest.mock import patch, Mock\n",
|
||
"\n",
|
||
"from django.test import TestCase\n",
|
||
"from django.contrib.auth.models import User\n",
|
||
"from django.core.exceptions import ValidationError\n",
|
||
"\n",
|
||
"from django_styleguide.payments.services import item_buy\n",
|
||
"from django_styleguide.payments.models import Payment, Item\n",
|
||
"\n",
|
||
"\n",
|
||
"class ItemBuyTests(TestCase):\n",
|
||
" @patch('project.payments.services.items_get_for_user')\n",
|
||
" def test_buying_item_that_is_already_bought_fails(\n",
|
||
" self, items_get_for_user_mock: Mock\n",
|
||
" ):\n",
|
||
" \"\"\"\n",
|
||
" Since we already have tests for `items_get_for_user`,\n",
|
||
" we can safely mock it here and give it a proper return value.\n",
|
||
" \"\"\"\n",
|
||
" user = User(username='Test User')\n",
|
||
" item = Item(\n",
|
||
" name='Test Item',\n",
|
||
" description='Test Item description',\n",
|
||
" price=10.15\n",
|
||
" )\n",
|
||
"\n",
|
||
" items_get_for_user_mock.return_value = [item]\n",
|
||
"\n",
|
||
" with self.assertRaises(ValidationError):\n",
|
||
" item_buy(user=user, item=item)\n",
|
||
"\n",
|
||
" @patch('project.payments.services.payment_charge.delay')\n",
|
||
" def test_buying_item_creates_a_payment_and_calls_charge_task(\n",
|
||
" self,\n",
|
||
" payment_charge_mock: Mock\n",
|
||
" ):\n",
|
||
" # How we prepare our tests is a topic for a different discussion\n",
|
||
" user = given_a_user(username=\"Test user\")\n",
|
||
" item = given_a_item(\n",
|
||
" name='Test Item',\n",
|
||
" description='Test Item description',\n",
|
||
" price=10.15\n",
|
||
" )\n",
|
||
"\n",
|
||
" self.assertEqual(0, Payment.objects.count())\n",
|
||
"\n",
|
||
" payment = item_buy(user=user, item=item)\n",
|
||
"\n",
|
||
" self.assertEqual(1, Payment.objects.count())\n",
|
||
" self.assertEqual(payment, Payment.objects.first())\n",
|
||
"\n",
|
||
" self.assertFalse(payment.successful)\n",
|
||
"\n",
|
||
" payment_charge_mock.assert_called_once()"
|
||
],
|
||
"id": "18e48e3a427946f3"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# APIs & Serializers\n",
|
||
"\n",
|
||
"وقتی از **Services** و **Selectors** استفاده میکنیم، تمام APIهامون باید ظاهری ساده و یکسان داشته باشن.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## اصول کلی در ساخت APIها\n",
|
||
"\n",
|
||
"1. **هر عملیات = یک API**\n",
|
||
" برای مثال در CRUD، چهار API جدا برای Create, Read, Update, Delete داریم.\n",
|
||
"\n",
|
||
"2. **ارثبری از سادهترین کلاس ممکن**\n",
|
||
" از `APIView` یا `GenericAPIView` استفاده کنید.\n",
|
||
" کلاسهای خیلی انتزاعیتر (مثل ViewSets) کارها رو با Serializer هندل میکنن، در حالی که ما میخوایم منطق اصلی رو در Service و Selector نگه داریم.\n",
|
||
"\n",
|
||
"3. **عدم وجود Business Logic در API**\n",
|
||
" نباید منطق اصلی در API نوشته بشه.\n",
|
||
"\n",
|
||
"4. **مدیریت داده درون API مجازه**\n",
|
||
" کارهایی مثل واکشی آبجکت یا دستکاری داده میتونه داخل API انجام بشه (البته میتونید اون رو به تابع یا Service جداگانه منتقل کنید).\n",
|
||
"\n",
|
||
"5. **سادگی در طراحی APIها**\n",
|
||
" API فقط باید رابطی باشه بین کاربر و منطق اصلی برنامه.\n",
|
||
" در صورت نیاز، میتونید از توابعی مثل `some_service_parse` برای جداسازی مرحلهی آمادهسازی دادهها استفاده کنید.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## Serializers\n",
|
||
"\n",
|
||
"در کنار APIها، ما برای کار با دادهها — چه **ورودی** و چه **خروجی** — به Serializer نیاز داریم.\n",
|
||
"\n",
|
||
"Serializer وظیفهی زیر رو داره:\n",
|
||
"- اعتبارسنجی دادههای ورودی (Validation)\n",
|
||
"- تبدیل دادهها به ساختارهای Python برای استفاده در Service یا Database\n",
|
||
"- آمادهسازی دادههای خروجی برای ارسال به Client\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
" در حالت کلی:\n",
|
||
"- **Service** و **Selector** منطق اصلی (Business Logic) رو انجام میدن.\n",
|
||
"- **Serializer** فقط وظیفهی تبدیل و اعتبارسنجی داده رو داره.\n",
|
||
"- **API** نقش واسطهی بین این دو رو بازی میکنه.\n"
|
||
],
|
||
"id": "74789ff48a6ed3a0"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# API Serialization Rules\n",
|
||
"\n",
|
||
"وقتی صحبت از **سریالایزرها** میشه، چند قانون کلی داریم که کار رو تمیز و قابل پیشبینی نگه میدارن:\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## اصول پایه\n",
|
||
"\n",
|
||
"1. برای هر API باید **دو سریالایزر جداگانه** وجود داشته باشه:\n",
|
||
" - `InputSerializer` → دادههای ورودی رو اعتبارسنجی و آماده میکنه.\n",
|
||
" - `OutputSerializer` → دادههای خروجی رو برای نمایش آماده میکنه.\n",
|
||
"\n",
|
||
"2. از هر انتزاعی (abstraction) که دوست داری استفاده کن — هر چیزی که برات کارآمده.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## ️ اگر از DRF (Django REST Framework) استفاده میکنی\n",
|
||
"\n",
|
||
"1. **سریالایزرها باید داخل API تعریف بشن**\n",
|
||
" و با نامهای `InputSerializer` و `OutputSerializer` شناخته بشن.\n",
|
||
" این باعث میشه ساختار کد واضحتر و محلیتر باشه.\n",
|
||
"\n",
|
||
"2. **ترجیحاً از `Serializer` بهجای `ModelSerializer` استفاده کن**\n",
|
||
" `Serializer` کنترل بیشتری بهت میده و API رو از مدل جدا نگه میداره.\n",
|
||
" اما اگر `ModelSerializer` برای پروژهت جواب میده، هیچ اشکالی نداره ازش استفاده کنی.\n",
|
||
"\n",
|
||
"3. **در صورت نیاز به nested serializer**\n",
|
||
" از ابزار `inline_serializer` استفاده کن تا ساختار کد تمیز و ایزوله بمونه.\n",
|
||
"\n",
|
||
"4. **از Reuse بیشازحد سریالایزرها خودداری کن**\n",
|
||
" چون تغییر در یک سریالایزر پایه ممکنه رفتار APIهای دیگه رو هم ناخواسته تغییر بده.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"خلاصه:\n",
|
||
"> هر API = ۱ سریالایزر ورودی + ۱ سریالایزر خروجی\n",
|
||
"> و هر دو داخل همان View تعریف بشن تا ساختار کد شفاف و محلی باقی بمونه.\n"
|
||
],
|
||
"id": "a717874e312d26ad"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Class-based vs. Function-based APIs\n",
|
||
"\n",
|
||
"انتخاب بین **Class-based** و **Function-based** معمولاً به سلیقه و عادت تیم بستگی داره،\n",
|
||
"چون هر دو روش میتونن دقیقا همون نتایج رو بدن.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"- بهصورت پیشفرض از **Class-based APIs / Views** استفاده میکنیم.\n",
|
||
"- اگر بقیه اعضای تیم با **Function-based** راحتتر هستن، از اون روش استفاده کنید.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## مزایای استفاده از Class-based\n",
|
||
"\n",
|
||
"1. میتونی از `BaseApi` ارثبری کنی یا از **mixins** استفاده کنی.\n",
|
||
"2. اگر Function-based باشی، باید همین رفتار رو با **decorator**ها بسازی.\n",
|
||
"3. کلاس خودش یک **namespace** ایجاد میکنه، که میتونی داخلش\n",
|
||
" متدها، اتربیوتها و تنظیمات مختلف رو بنویسی.\n",
|
||
"4. در کلاسها میتونی پیکربندی API رو از طریق **class attributes** انجام بدی.\n",
|
||
" ولی در حالت تابعی، باید **decorator**ها رو روی هم بچینی.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
" **خلاصه:**\n",
|
||
"کلاسها ساختار تمیزتری برای گسترش، تست و پیکربندی فراهم میکنن،\n",
|
||
"اما اگر تیم با توابع احساس راحتی بیشتری داره، مهمتر از همه **یکنواختی و هماهنگی در کد**ه.\n"
|
||
],
|
||
"id": "2c6c3c80abaf825b"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": "با کلاس مثال `BaseApi`",
|
||
"id": "9f4ad8234dabaf2a"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class SomeApi(BaseApi):\n",
|
||
" def get(self, request):\n",
|
||
" data = something()\n",
|
||
"\n",
|
||
" return Response(data)"
|
||
],
|
||
"id": "9f94608de6174efa"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " یک مثال `functionBase`",
|
||
"id": "e94df60d317d3e3"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class SomeApi(BaseApi):\n",
|
||
" def get(self, request):\n",
|
||
" data = something()\n",
|
||
"\n",
|
||
" return Response(data)"
|
||
],
|
||
"id": "b49c79da5f621ecb"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " با `BaseApi decorator`",
|
||
"id": "4b5346f62f553f0e"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"@base_api([\"GET\"])\n",
|
||
"def some_api(request):\n",
|
||
" data = something()\n",
|
||
" return Response(data)"
|
||
],
|
||
"id": "671ce724fddcdd05"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# List APIs\n",
|
||
"\n",
|
||
"بریم سراغ سادهترین نوع API یعنی **List API** — اونایی که فقط یه لیست از دیتا برمیگردونن، بدون دردسر خاص.\n",
|
||
"\n",
|
||
"خب، یه **plain list API** معمولاً خیلی تمیز و جمعوجوره. چیزی شبیه به مثال زیره"
|
||
],
|
||
"id": "159ba4d84086654f"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from rest_framework.views import APIView\n",
|
||
"from rest_framework import serializers\n",
|
||
"from rest_framework.response import Response\n",
|
||
"\n",
|
||
"from styleguide_example.users.selectors import user_list\n",
|
||
"from styleguide_example.users.models import BaseUser\n",
|
||
"\n",
|
||
"\n",
|
||
"class UserListApi(APIView):\n",
|
||
" class OutputSerializer(serializers.Serializer):\n",
|
||
" id = serializers.CharField()\n",
|
||
" email = serializers.CharField()\n",
|
||
"\n",
|
||
" def get(self, request):\n",
|
||
" users = user_list()\n",
|
||
"\n",
|
||
" data = self.OutputSerializer(users, many=True).data\n",
|
||
"\n",
|
||
" return Response(data)"
|
||
],
|
||
"id": "beb124af9c72d8b"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " >یادت باشه این پابلیکه Authentication اش با خودته",
|
||
"id": "dc5f66e599ce8eca"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Filters + Pagination\n",
|
||
"\n",
|
||
"اولش شاید به نظر سخت بیاد، چون API ما از همون plain `APIView` تو DRF ارث میبره،\n",
|
||
"در حالی که filtering و pagination تو کلاسهای generic خودشون تعبیه شدن.\n",
|
||
"\n",
|
||
"- **DRF Filtering**\n",
|
||
"- **DRF Pagination**\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## ️ رویکرد ما\n",
|
||
"\n",
|
||
"1. **Selectors** مسئول فیلتر واقعی هستن.\n",
|
||
" یعنی کد اصلی که دیتا رو فیلتر میکنه، توی selector میره.\n",
|
||
"\n",
|
||
"2. **APIs** مسئول **serialize کردن پارامترهای فیلتر** هستن.\n",
|
||
" یعنی ورودی رو میگیرن و آماده میکنن که selector بتونه استفاده کنه.\n",
|
||
"\n",
|
||
"3. اگر میخوای از paginationهای آماده DRF استفاده کنی،\n",
|
||
" خود API اون رو هندل میکنه.\n",
|
||
"\n",
|
||
"4. اگر دنبال یه pagination متفاوتی، یا پیادهسازی خودت هستی:\n",
|
||
" - میتونی یه لایه جدید بسازی برای pagination،\n",
|
||
" - یا بذاری selector این کار رو انجام بده.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"نکته اصلی:\n",
|
||
"**API فقط رابط باشه، منطق و دیتا رو بذار توی selector و service** —\n",
|
||
"نه اینکه همه چیز توی API جمع بشه و کد شلخته شه.\n"
|
||
],
|
||
"id": "6be4e4c7a3811b29"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from rest_framework.views import APIView\n",
|
||
"from rest_framework import serializers\n",
|
||
"\n",
|
||
"from styleguide_example.api.mixins import ApiErrorsMixin\n",
|
||
"from styleguide_example.api.pagination import get_paginated_response, LimitOffsetPagination\n",
|
||
"\n",
|
||
"from styleguide_example.users.selectors import user_list\n",
|
||
"from styleguide_example.users.models import BaseUser\n",
|
||
"\n",
|
||
"\n",
|
||
"class UserListApi(ApiErrorsMixin, APIView):\n",
|
||
" class Pagination(LimitOffsetPagination):\n",
|
||
" default_limit = 1\n",
|
||
"\n",
|
||
" class FilterSerializer(serializers.Serializer):\n",
|
||
" id = serializers.IntegerField(required=False)\n",
|
||
" # Important: If we use BooleanField, it will default to False\n",
|
||
" is_admin = serializers.NullBooleanField(required=False)\n",
|
||
" email = serializers.EmailField(required=False)\n",
|
||
"\n",
|
||
" class OutputSerializer(serializers.Serializer):\n",
|
||
" id = serializers.CharField()\n",
|
||
" email = serializers.CharField()\n",
|
||
" is_admin = serializers.BooleanField()\n",
|
||
"\n",
|
||
" def get(self, request):\n",
|
||
" # Make sure the filters are valid, if passed\n",
|
||
" filters_serializer = self.FilterSerializer(data=request.query_params)\n",
|
||
" filters_serializer.is_valid(raise_exception=True)\n",
|
||
"\n",
|
||
" users = user_list(filters=filters_serializer.validated_data)\n",
|
||
"\n",
|
||
" return get_paginated_response(\n",
|
||
" pagination_class=self.Pagination,\n",
|
||
" serializer_class=self.OutputSerializer,\n",
|
||
" queryset=users,\n",
|
||
" request=request,\n",
|
||
" view=self\n",
|
||
" )"
|
||
],
|
||
"id": "7c855fe41f81d7b0"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# API Walkthrough\n",
|
||
"\n",
|
||
"وقتی به API نگاه میکنیم، چند تا چیز مشخص میشه:\n",
|
||
"\n",
|
||
"1. **FilterSerializer** داریم، که پارامترهای query رو هندل میکنه.\n",
|
||
" اگه این کارو اینجا نکنیم، باید جای دیگه هندل بشه و خب،\n",
|
||
" DRF serializers تو این کار عالی هستن.\n",
|
||
"\n",
|
||
"2. بعد از serialize کردن، فیلترها رو میفرستیم به **user_list selector**.\n",
|
||
" یعنی دیتا واقعا از اینجا فیلتر میشه.\n",
|
||
"\n",
|
||
"3. برای خروجی، از **get_paginated_response** استفاده میکنیم،\n",
|
||
" تا یه پاسخ paginated (صفحهبندی شده) به کلاینت بدیم.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
" خلاصه:\n",
|
||
"API نقش **واسطه** بین پارامترهای کاربر و منطق اصلی (selector) رو داره،\n",
|
||
"نه اینکه خودش دیتا رو دستکاری کنه یا فیلترهای پیچیده بسازه.\n"
|
||
],
|
||
"id": "307e1ab2f3730fcd"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"import django_filters\n",
|
||
"\n",
|
||
"from styleguide_example.users.models import BaseUser\n",
|
||
"\n",
|
||
"\n",
|
||
"class BaseUserFilter(django_filters.FilterSet):\n",
|
||
" class Meta:\n",
|
||
" model = BaseUser\n",
|
||
" fields = ('id', 'email', 'is_admin')\n",
|
||
"\n",
|
||
"\n",
|
||
"def user_list(*, filters=None):\n",
|
||
" filters = filters or {}\n",
|
||
"\n",
|
||
" qs = BaseUser.objects.all()\n",
|
||
"\n",
|
||
" return BaseUserFilter(filters, qs).qs"
|
||
],
|
||
"id": "997a2cc32be54ba2"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Using django-filter\n",
|
||
"\n",
|
||
"همونطور که میبینی، ما داریم از **کتابخونه قدرتمند `django-filter`** استفاده میکنیم.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
" نکته اصلی:\n",
|
||
"**selector مسئول فیلتر واقعی هستش**.\n",
|
||
"میتونی همیشه از چیز دیگهای به عنوان abstraction برای فیلتر استفاده کنی،\n",
|
||
"اما برای بیشتر موارد، `django-filter` کامل کافی و راحت هست.\n"
|
||
],
|
||
"id": "2fbd676f6c42fa9"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " get_paginated_response :",
|
||
"id": "e5843d5c23bf4c94"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from rest_framework.response import Response\n",
|
||
"\n",
|
||
"\n",
|
||
"def get_paginated_response(*, pagination_class, serializer_class, queryset, request, view):\n",
|
||
" paginator = pagination_class()\n",
|
||
"\n",
|
||
" page = paginator.paginate_queryset(queryset, request, view=view)\n",
|
||
"\n",
|
||
" if page is not None:\n",
|
||
" serializer = serializer_class(page, many=True)\n",
|
||
" return paginator.get_paginated_response(serializer.data)\n",
|
||
"\n",
|
||
" serializer = serializer_class(queryset, many=True)\n",
|
||
"\n",
|
||
" return Response(data=serializer.data)"
|
||
],
|
||
"id": "e735db438d1a09ea"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": "LimitOffsetPagination :",
|
||
"id": "daae4b408a8d2df"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from collections import OrderedDict\n",
|
||
"\n",
|
||
"from rest_framework.pagination import LimitOffsetPagination as _LimitOffsetPagination\n",
|
||
"from rest_framework.response import Response\n",
|
||
"\n",
|
||
"\n",
|
||
"class LimitOffsetPagination(_LimitOffsetPagination):\n",
|
||
" default_limit = 10\n",
|
||
" max_limit = 50\n",
|
||
"\n",
|
||
" def get_paginated_data(self, data):\n",
|
||
" return OrderedDict([\n",
|
||
" ('limit', self.limit),\n",
|
||
" ('offset', self.offset),\n",
|
||
" ('count', self.count),\n",
|
||
" ('next', self.get_next_link()),\n",
|
||
" ('previous', self.get_previous_link()),\n",
|
||
" ('results', data)\n",
|
||
" ])\n",
|
||
"\n",
|
||
" def get_paginated_response(self, data):\n",
|
||
" \"\"\"\n",
|
||
" We redefine this method in order to return `limit` and `offset`.\n",
|
||
" This is used by the frontend to construct the pagination itself.\n",
|
||
" \"\"\"\n",
|
||
" return Response(OrderedDict([\n",
|
||
" ('limit', self.limit),\n",
|
||
" ('offset', self.offset),\n",
|
||
" ('count', self.count),\n",
|
||
" ('next', self.get_next_link()),\n",
|
||
" ('previous', self.get_previous_link()),\n",
|
||
" ('results', data)\n",
|
||
" ]))"
|
||
],
|
||
"id": "2f088f36fd6c1a25"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Reverse-Engineering Generic APIs\n",
|
||
"\n",
|
||
"در واقع کاری که کردیم اینه که **generic APIs رو reverse-engineer کردیم** و فهمیدیم چطور کار میکنن.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
" نکته مهم:\n",
|
||
"اگر دنبال یه pagination متفاوت هستی، میتونی همیشه خودت پیادهسازی کنی و همون روش رو استفاده کنی.\n",
|
||
"گاهی اوقات لازم میشه selector خودش pagination رو هندل کنه،\n",
|
||
"و ما با همین روش filtering اون رو انجام میدیم.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
" برای مثال عملی:\n",
|
||
"کد یک **List API با filters و pagination** رو میتونی توی پروژه **Styleguide Example** پیدا کنی.\n"
|
||
],
|
||
"id": "f7f562c9f6eed1b2"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": "# Detail API",
|
||
"id": "7f7dbf4ea0ef2fb5"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class CourseDetailApi(SomeAuthenticationMixin, APIView):\n",
|
||
" class OutputSerializer(serializers.Serializer):\n",
|
||
" id = serializers.CharField()\n",
|
||
" name = serializers.CharField()\n",
|
||
" start_date = serializers.DateField()\n",
|
||
" end_date = serializers.DateField()\n",
|
||
"\n",
|
||
" def get(self, request, course_id):\n",
|
||
" course = course_get(id=course_id)\n",
|
||
"\n",
|
||
" serializer = self.OutputSerializer(course)\n",
|
||
"\n",
|
||
" return Response(serializer.data)"
|
||
],
|
||
"id": "2ef277dfbab647d"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": "# Create API",
|
||
"id": "302129ac4d79bb9b"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class CourseCreateApi(SomeAuthenticationMixin, APIView):\n",
|
||
" class InputSerializer(serializers.Serializer):\n",
|
||
" name = serializers.CharField()\n",
|
||
" start_date = serializers.DateField()\n",
|
||
" end_date = serializers.DateField()\n",
|
||
"\n",
|
||
" def post(self, request):\n",
|
||
" serializer = self.InputSerializer(data=request.data)\n",
|
||
" serializer.is_valid(raise_exception=True)\n",
|
||
"\n",
|
||
" course_create(**serializer.validated_data)\n",
|
||
"\n",
|
||
" return Response(status=status.HTTP_201_CREATED)"
|
||
],
|
||
"id": "a1d089fc6aefa951"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": "# Update API",
|
||
"id": "c62cb632347346a0"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class CourseUpdateApi(SomeAuthenticationMixin, APIView):\n",
|
||
" class InputSerializer(serializers.Serializer):\n",
|
||
" name = serializers.CharField(required=False)\n",
|
||
" start_date = serializers.DateField(required=False)\n",
|
||
" end_date = serializers.DateField(required=False)\n",
|
||
"\n",
|
||
" def post(self, request, course_id):\n",
|
||
" serializer = self.InputSerializer(data=request.data)\n",
|
||
" serializer.is_valid(raise_exception=True)\n",
|
||
"\n",
|
||
" course_update(course_id=course_id, **serializer.validated_data)\n",
|
||
"\n",
|
||
" return Response(status=status.HTTP_200_OK)"
|
||
],
|
||
"id": "13675cdd9219e1a9"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Fetching Objects\n",
|
||
"\n",
|
||
"وقتی API ما یه `object_id` میگیره، سوال اینه: **کجا باید اون object رو fetch کنیم؟**\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## گزینهها\n",
|
||
"\n",
|
||
"1. میتونیم اون object رو به **serializer** بدیم، که خودش یه `PrimaryKeyRelatedField` یا حتی `SlugRelatedField` داره.\n",
|
||
"2. میتونیم یه جور object fetching توی **API** انجام بدیم و بعد object رو به **service یا selector** بدیم.\n",
|
||
"3. میتونیم همون `id` رو به **service/selector** بدیم و fetch اونجا انجام بشه.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
" تصمیم اینکه کدوم روش رو استفاده کنیم، **بستگی به context پروژه و سلیقه تیم** داره.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## روشی که ما معمولا استفاده میکنیم\n",
|
||
"\n",
|
||
"معمولا objectها رو تو سطح **API** fetch میکنیم، با استفاده از یه util مخصوص به اسم `get_object`.\n"
|
||
],
|
||
"id": "81c67183d7f2c7f5"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def get_object(model_or_queryset, **kwargs):\n",
|
||
" \"\"\"\n",
|
||
" Reuse get_object_or_404 since the implementation supports both Model && queryset.\n",
|
||
" Catch Http404 & return None\n",
|
||
" \"\"\"\n",
|
||
" try:\n",
|
||
" return get_object_or_404(model_or_queryset, **kwargs)\n",
|
||
" except Http404:\n",
|
||
" return None"
|
||
],
|
||
"id": "6a1bfe827e5afc3"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " این یک یوتیلیتی ساده است که استثنا رو هندل میکنه فقط حواست باشه که جوابا یک دست باشه",
|
||
"id": "57f45ca644fbac86"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": "# Nested serializers",
|
||
"id": "89cceae953a1532c"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class Serializer(serializers.Serializer):\n",
|
||
" weeks = inline_serializer(many=True, fields={\n",
|
||
" 'id': serializers.IntegerField(),\n",
|
||
" 'number': serializers.IntegerField(),\n",
|
||
" })"
|
||
],
|
||
"id": "a8fe5e4ad714459a"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Advanced Serialization\n",
|
||
"\n",
|
||
"گاهی اوقات، نتیجه نهایی یک API میتونه خیلی پیچیده باشه.\n",
|
||
"گاهی وقتا میخواهیم **queryها رو بهینه کنیم** و خود این بهینهسازیها میتونه خیلی پیچیده بشه.\n",
|
||
"\n",
|
||
"\n",
|
||
"\n",
|
||
"**اگر فقط بخواهیم از یه OutputSerializer استفاده کنیم، ممکنه انتخابها و گزینههایمون محدود بشه.**\n",
|
||
"\n",
|
||
"\n",
|
||
"\n",
|
||
"در این مواقع، میتونیم **serialization خروجی رو به صورت یک تابع پیادهسازی کنیم** و تمام بهینهسازیهایی که نیاز داریم رو اونجا انجام بدیم، به جای اینکه همهی بهینهسازیها رو توی selector انجام بدیم.\n",
|
||
"\n",
|
||
"\n"
|
||
],
|
||
"id": "5b00ef8732d49c97"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class SomeGenericFeedApi(BaseApi):\n",
|
||
" def get(self, request):\n",
|
||
" feed = some_feed_get(\n",
|
||
" user=request.user,\n",
|
||
" )\n",
|
||
"\n",
|
||
" data = some_feed_serialize(feed)\n",
|
||
"\n",
|
||
" return Response(data)"
|
||
],
|
||
"id": "a19c6a1f49270cee"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"## Example: Advanced Serialization Function\n",
|
||
"\n",
|
||
"در این سناریو، `some_feed_get` مسئول بازگرداندن یک لیست از آیتمهای feed هستش (ممکنه ORM objectها باشن، ممکنه فقط IDها باشن، یا هر چی که برای شما مناسب باشه).\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"ما میخواهیم پیچیدگی **serialization** این feed رو، به روشی بهینه، به تابع serializer بسپاریم - یعنی `some_feed_serialize`.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"این به این معنیه که دیگه نیازی نیست که **پیشفرضها (prefetch)** و بهینهسازیهای غیرضروری رو توی `some_feed_get` انجام بدیم.\n",
|
||
"\n",
|
||
"---\n"
|
||
],
|
||
"id": "40d0e10f57d465bc"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class FeedItemSerializer(serializers.Serializer):\n",
|
||
" ... some fields here ...\n",
|
||
" calculated_field = serializers.IntegerField(source=\"_calculated_field\")\n",
|
||
"\n",
|
||
"\n",
|
||
"def some_feed_serialize(feed: List[FeedItem]):\n",
|
||
" feed_ids = [feed_item.id for feed_item in feed]\n",
|
||
"\n",
|
||
" # Refetch items with more optimizations\n",
|
||
" # Based on the relations that are going in\n",
|
||
" objects = FeedItem.objects.select_related(\n",
|
||
" # ... as complex as you want ...\n",
|
||
" ).prefetch_related(\n",
|
||
" # ... as complex as you want ...\n",
|
||
" ).filter(\n",
|
||
" id__in=feed_ids\n",
|
||
" ).order_by(\n",
|
||
" \"-some_timestamp\"\n",
|
||
" )\n",
|
||
"\n",
|
||
" some_cache = get_some_cache(feed_ids)\n",
|
||
"\n",
|
||
" result = []\n",
|
||
"\n",
|
||
" for feed_item in objects:\n",
|
||
" # An example, adding additional fields for the serializer\n",
|
||
" # That are based on values outside of our current object\n",
|
||
" # This may be some optimization to save queries\n",
|
||
" feed_item._calculated_field = some_cache.get(feed_item.id)\n",
|
||
"\n",
|
||
" result.append(FeedItemSerializer(feed_item).data)\n",
|
||
"\n",
|
||
" return result"
|
||
],
|
||
"id": "40921757eeb3e20d"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"همونطور که میبینید، این یه مثال خیلی **همگانی** هستش، ولی ایده خیلی سادهست:\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"- دادههاتون رو دوباره با **joins** و **prefetch** لازم میگیرید.\n",
|
||
"- کشهای درونحافظهای (in-memory caches) رو میسازید که برای مقادیر محاسبه شده، تعداد queryها رو کاهش میده.\n",
|
||
"- در نهایت یه **نتیجهای که آمادهی پاسخدهی API باشه** برمیگردونید.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"حتی با اینکه به این روش اسم \"**advanced serialization**\" داده شده، این الگو خیلی قدرتمنده و میتونید برای همهی انواع serialization ها ازش استفاده کنید.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"این تابعهای serializer معمولاً در **ماژول serializers.py** در اپ مربوطهی **Django** قرار میگیرن.\n"
|
||
],
|
||
"id": "23edc698dc71ffbc"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def user_list(*, fetched_by: User) -> Iterable[User]:\n",
|
||
" user_ids = user_get_visible_for(user=fetched_by)\n",
|
||
"\n",
|
||
" query = Q(id__in=user_ids)\n",
|
||
"\n",
|
||
" return User.objects.filter(query)"
|
||
],
|
||
"id": "2f05efa0947ede97"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# URLs\n",
|
||
"\n",
|
||
"ما معمولاً **URLها رو دقیقاً مثل APIها** سازماندهی میکنیم — یعنی **هر API یک URL مخصوص خودش داره**.\n",
|
||
"به زبون سادهتر:\n",
|
||
"> هر \"action\" توی سیستم، یه مسیر (route) جداگانه داره.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## نکتهی مهم\n",
|
||
"بهتره URLهای مربوط به هر دامین (domain یا بخش از پروژه) رو داخل یه لیست جدا مثل `domain_patterns` بنویسیم\n",
|
||
"و بعد با `include()` از `urlpatterns` اصلی فراخوانی کنیم.\n",
|
||
"\n",
|
||
"---\n"
|
||
],
|
||
"id": "95a62e4d7f565154"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.urls import path, include\n",
|
||
"\n",
|
||
"from project.education.apis import (\n",
|
||
" CourseCreateApi,\n",
|
||
" CourseUpdateApi,\n",
|
||
" CourseListApi,\n",
|
||
" CourseDetailApi,\n",
|
||
" CourseSpecificActionApi,\n",
|
||
")\n",
|
||
"\n",
|
||
"\n",
|
||
"course_patterns = [\n",
|
||
" path('', CourseListApi.as_view(), name='list'),\n",
|
||
" path('<int:course_id>/', CourseDetailApi.as_view(), name='detail'),\n",
|
||
" path('create/', CourseCreateApi.as_view(), name='create'),\n",
|
||
" path('<int:course_id>/update/', CourseUpdateApi.as_view(), name='update'),\n",
|
||
" path(\n",
|
||
" '<int:course_id>/specific-action/',\n",
|
||
" CourseSpecificActionApi.as_view(),\n",
|
||
" name='specific-action'\n",
|
||
" ),\n",
|
||
"]\n",
|
||
"\n",
|
||
"urlpatterns = [\n",
|
||
" path('courses/', include((course_patterns, 'courses'))),\n",
|
||
"]"
|
||
],
|
||
"id": "45b1d0ffeadb4a80"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Splitting URLs\n",
|
||
"\n",
|
||
"تقسیم کردن URLها به این شکل، بهت **انعطاف بیشتری** میده.\n",
|
||
"بهخصوص توی پروژههای بزرگ که فایل `urls.py` شلوغ میشه و حتی ممکنه دائم **conflict موقع merge** پیش بیاد.\n",
|
||
"با جدا کردن هر domain به یه ماژول مستقل، میتونی راحتتر کنترلش کنی یا حتی بعداً جابهجاش کنی.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## دیدن کل درخت URL\n",
|
||
"\n",
|
||
"اگه دوست داری **ساختار کامل درخت URL** پروژه رو یکجا ببینی،\n",
|
||
"کافیه متغیرهای جداگانه برای `include()` تعریف نکنی و مستقیماً اونها رو بنویسی.\n",
|
||
"\n",
|
||
"---\n"
|
||
],
|
||
"id": "8b910c7f578207ea"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.urls import path, include\n",
|
||
"\n",
|
||
"from styleguide_example.files.apis import (\n",
|
||
" FileDirectUploadApi,\n",
|
||
"\n",
|
||
" FilePassThruUploadStartApi,\n",
|
||
" FilePassThruUploadFinishApi,\n",
|
||
" FilePassThruUploadLocalApi,\n",
|
||
")\n",
|
||
"\n",
|
||
"\n",
|
||
"urlpatterns = [\n",
|
||
" path(\n",
|
||
" \"upload/\",\n",
|
||
" include(([\n",
|
||
" path(\n",
|
||
" \"direct/\",\n",
|
||
" FileDirectUploadApi.as_view(),\n",
|
||
" name=\"direct\"\n",
|
||
" ),\n",
|
||
" path(\n",
|
||
" \"pass-thru/\",\n",
|
||
" include(([\n",
|
||
" path(\n",
|
||
" \"start/\",\n",
|
||
" FilePassThruUploadStartApi.as_view(),\n",
|
||
" name=\"start\"\n",
|
||
" ),\n",
|
||
" path(\n",
|
||
" \"finish/\",\n",
|
||
" FilePassThruUploadFinishApi.as_view(),\n",
|
||
" name=\"finish\"\n",
|
||
" ),\n",
|
||
" path(\n",
|
||
" \"local/<str:file_id>/\",\n",
|
||
" FilePassThruUploadLocalApi.as_view(),\n",
|
||
" name=\"local\"\n",
|
||
" )\n",
|
||
" ], \"pass-thru\"))\n",
|
||
" )\n",
|
||
" ], \"upload\"))\n",
|
||
" )\n",
|
||
"]"
|
||
],
|
||
"id": "73c1f15760ae7ea8"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# ️ Settings Structure\n",
|
||
"\n",
|
||
"وقتی صحبت از تنظیمات (Settings) جنگو میشه، ما معمولاً از ساختار پوشهای **cookiecutter-django** پیروی میکنیم،\n",
|
||
"با چند تغییر کوچک که کار رو تمیزتر میکنه\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## اصول کلی\n",
|
||
"\n",
|
||
"- تنظیمات خاص جنگو رو از بقیه تنظیمات جدا میکنیم.\n",
|
||
"- همهچیز باید در `base.py` تعریف و **در اون فایل import** بشه.\n",
|
||
"- فایل `production.py` نباید تنظیمات خاص خودش رو داشته باشه.\n",
|
||
"- هرچیزی که فقط باید تو محیط production فعال باشه،\n",
|
||
" باید **با environment variable** کنترل بشه، نه با شرط مستقیم داخل کد.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## ساختار پیشنهادی پوشهها\n",
|
||
"\n",
|
||
"یه نمونه از ساختار پروژهی ما (برگرفته از *Styleguide Example*):\n",
|
||
"\n",
|
||
"```bash\n",
|
||
"config\n",
|
||
"├── __init__.py\n",
|
||
"├── django\n",
|
||
"│ ├── __init__.py\n",
|
||
"│ ├── base.py\n",
|
||
"│ ├── local.py\n",
|
||
"│ ├── production.py\n",
|
||
"│ └── test.py\n",
|
||
"├── settings\n",
|
||
"│ ├── __init__.py\n",
|
||
"│ ├── celery.py\n",
|
||
"│ ├── cors.py\n",
|
||
"│ ├── sentry.py\n",
|
||
"│ └── sessions.py\n",
|
||
"├── urls.py\n",
|
||
"├── env.py\n",
|
||
"└── wsgi.py\n",
|
||
"├── asgi.py"
|
||
],
|
||
"id": "215f2485c32a69ca"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Django Settings Layout\n",
|
||
"\n",
|
||
"وقتی ساختار تنظیمات رو طراحی میکنیم، هدف اینه که **چیزهای مربوط به جنگو** از **بقیه تنظیمات پروژه** جدا باشن.\n",
|
||
"ما معمولاً این تفکیک رو به دو بخش انجام میدیم:\n",
|
||
"`config/django` برای تنظیمات خود جنگو و `config/settings` برای تنظیمات جانبی.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## داخل `config/django`\n",
|
||
"\n",
|
||
"اینجا همهچیز مستقیماً به جنگو مربوطه:\n",
|
||
"\n",
|
||
"- **`base.py`**\n",
|
||
" شامل بیشتر تنظیماته و بقیه فایلها ازش import میگیرن.\n",
|
||
" (قلب تنظیمات اصلی پروژهست )\n",
|
||
"\n",
|
||
"- **`production.py`**\n",
|
||
" از `base.py` import میکنه و فقط مقادیر خاص محیط production رو **بازنویسی (override)** میکنه.\n",
|
||
"\n",
|
||
"- **`test.py`**\n",
|
||
" از `base.py` import میکنه و تنظیمات مخصوص تستها رو تغییر میده.\n",
|
||
" این فایل باید به عنوان ماژول تنظیمات توی `pytest.ini` ست بشه.\n",
|
||
"\n",
|
||
"- **`local.py`**\n",
|
||
" از `base.py` import میکنه و برای توسعهی لوکال استفاده میشه.\n",
|
||
" اگه خواستی ازش استفاده کنی، باید توی `manage.py` بهش اشاره کنی،\n",
|
||
" وگرنه همون `base.py` کفایت میکنه.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## داخل `config/settings`\n",
|
||
"\n",
|
||
"اینجا تنظیمات **غیرمستقیم جنگویی** قرار میگیرن — مثل:\n",
|
||
"\n",
|
||
"- تنظیمات **Celery**\n",
|
||
"- تنظیمات **third-party packages**\n",
|
||
"- هر نوع تنظیم عمومی دیگهای که به خود جنگو وابسته نیست\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
" **نتیجه:**\n",
|
||
"با این ساختار، یه تفکیک تمیز بین هستهی جنگو و تنظیمات خارجی داری.\n",
|
||
"یعنی هر بخش دقیقاً میدونه جاش کجاست و چی رو باید کنترل کنه\n"
|
||
],
|
||
"id": "d5cd6a887170fd0"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " به علاوه ما از `cogig/env.py`",
|
||
"id": "ea4618b8cf736e41"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"import environ\n",
|
||
"\n",
|
||
"env = environ.Env()"
|
||
],
|
||
"id": "eaa69de4400fe7cd"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " و اگر ما از محیط بخواهیم چیزی دریافت کنیم",
|
||
"id": "831eed74d468d413"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": "from config.env import env",
|
||
"id": "8c49910f9c5639ad"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"معمولاً در انتهای فایل **`base.py`**،\n",
|
||
"تمام تنظیمات دیگه رو از مسیر **`config/settings`** ایمپورت میکنیم.\n",
|
||
"اینجوری همهی کانفیگهای جانبی (مثل Celery یا تنظیمات تردپارتیها)\n",
|
||
"بهصورت خودکار لود میشن و توی محیط جنگو قابل استفادهان."
|
||
],
|
||
"id": "31306ba2b26f769c"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from config.settings.cors import * # noqa\n",
|
||
"from config.settings.sessions import * # noqa\n",
|
||
"from config.settings.celery import * # noqa\n",
|
||
"from config.settings.sentry import * # noqa"
|
||
],
|
||
"id": "f743350624d75c39"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Prefix کردن Environment Variables با DJANGO_\n",
|
||
"\n",
|
||
"شاید توی خیلی مثالها دیده باشی که Environment Variables رو با **DJANGO_** شروع میکنن.\n",
|
||
"این کار مخصوصاً وقتی مفیده که چند تا برنامهی دیگه هم روی همون محیط اجرا میشن.\n",
|
||
"با این prefix، راحت میتونی تشخیص بدی کدوم متغیر برای جنگوئه و کدوم برای بقیه برنامهها.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"تو **HackSoft** معمولاً چند تا اپ روی یه محیط نداریم،\n",
|
||
"پس ما معمولاً فقط متغیرهای خاص جنگو رو با **DJANGO_** شروع میکنیم،\n",
|
||
"و بقیه چیزها مثل AWS یا Celery بدون prefix میمونن.\n",
|
||
"\n",
|
||
"مثال:\n",
|
||
"\n",
|
||
"- Prefix میکنیم:\n",
|
||
" `DJANGO_SETTINGS_MODULE`, `DJANGO_DEBUG`, `DJANGO_ALLOWED_HOSTS`, `DJANGO_CORS_ORIGIN_WHITELIST`\n",
|
||
"- Prefix نمیکنیم:\n",
|
||
" `AWS_SECRET_KEY`, `CELERY_BROKER_URL`, `EMAILS_ENABLED`\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"⚠️ این کاملاً سلیقهایه، ولی **یکسان بودنش تو پروژه مهمه**.\n",
|
||
"یک قانون مشخص داشته باش و همه جا رعایتش کن.\n"
|
||
],
|
||
"id": "16b9c1a5440f5f00"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Integrations\n",
|
||
"\n",
|
||
"خب، از اونجایی که همه چیز باید توی **base.py** ایمپورت بشه،\n",
|
||
"ولی بعضی وقتها نمیخوایم یه integration خاص رو برای توسعه لوکال کانفیگ کنیم،\n",
|
||
"ما این روش رو گرفتیم:\n",
|
||
"\n",
|
||
"1. تنظیمات مربوط به هر integration رو میذاریم توی\n",
|
||
" `config/settings/some_integration.py`\n",
|
||
"2. همیشه یه boolean داریم به اسم **`USE_SOME_INTEGRATION`**\n",
|
||
" که از environment میخونه و پیشفرضش **False** هست.\n",
|
||
"3. اگه مقدارش **True** بود، بقیهی تنظیمات رو هم میخونه و اگه چیزی کم بود، خطا میده.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
" خلاصهش اینکه:\n",
|
||
"- لوکال راحت باشه، همه چیز default باشه.\n",
|
||
"- وقتی بخوای production یا staging یه integration فعال کنی، همه چیز آماده باشه و خطاها واضح باشن.\n"
|
||
],
|
||
"id": "ea341bb03febfa6"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from config.env import env\n",
|
||
"\n",
|
||
"SENTRY_DSN = env('SENTRY_DSN', default='')\n",
|
||
"\n",
|
||
"if SENTRY_DSN:\n",
|
||
" import sentry_sdk\n",
|
||
" from sentry_sdk.integrations.django import DjangoIntegration\n",
|
||
" from sentry_sdk.integrations.celery import CeleryIntegration\n",
|
||
"\n",
|
||
" # ... we proceed with sentry settings here ...\n",
|
||
" # View the full file here - https://github.com/HackSoftware/Styleguide-Example/blob/master/config/settings/sentry.py"
|
||
],
|
||
"id": "5475466b1bb09713"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# .env & Environment Variables\n",
|
||
"\n",
|
||
"حالا میتونی یه فایل **`.env`** توی روت پروژه داشته باشی\n",
|
||
"و مقادیر تنظیماتت رو اونجا بذاری (ولی اجباری نیست).\n",
|
||
"\n",
|
||
"چند نکته مهم:\n",
|
||
"\n",
|
||
"1. **هیچوقت `.env` رو توی source control نذار**\n",
|
||
" چون اطلاعات حساس و credentials میره بیرون\n",
|
||
"2. بهتره یه **`.env.example`** داشته باشی\n",
|
||
" با مقادیر خالی برای همه چیز، تا توسعهدهندههای جدید بفهمن چی استفاده میشه.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"# Errors & Exception Handling\n",
|
||
"\n",
|
||
"اوه پسر، این موضوع بزرگه\n",
|
||
"جزئیاتش معمولاً مخصوص هر پروژه هست.\n",
|
||
"\n",
|
||
"به همین دلیل ما دو بخش میکنیم:\n",
|
||
"\n",
|
||
"1. **Guidelines عمومی**\n",
|
||
" - بدون که exception handling چطور کار میکنه (با DRF مثال میزنیم)\n",
|
||
" - مشخص کن که خطاهای API قراره چطوری به کاربر نشون داده بشن\n",
|
||
" - بدون چطور میتونی رفتار پیشفرض exception handling رو تغییر بدی\n",
|
||
"\n",
|
||
"2. **روشهای خاص**\n",
|
||
" - از default exceptions خود DRF استفاده کن، با کمترین تغییر\n",
|
||
" - روش پیشنهادی HackSoft\n",
|
||
" - اگر دنبال یه استاندارد جهانی هستی، RFC7807 رو نگاه کن:\n",
|
||
" [https://datatracker.ietf.org/doc/html/rfc7807](https://datatracker.ietf.org/doc/html/rfc7807)\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"👀 برای کد عملی، میتونی به این لینک نگاه کنی:\n",
|
||
"[Styleguide-Example exception handlers](https://github.com/HackSoftware/Styleguide-Example/blob/master/styleguide_example/api/exception_handlers.py)\n"
|
||
],
|
||
"id": "86f17356585f990a"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# How Exception Handling Works (DRF Style)\n",
|
||
"\n",
|
||
"خب پسر، DRF یه **راهنمای خیلی خوب** داره برای اینکه بفهمیم exceptionها چطور هندل میشن:\n",
|
||
"[DRF Exceptions Guide](https://www.django-rest-framework.org/api-guide/exceptions/)\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"علاوه بر راهنما، یه **diagram مرتب** داریم که روند کار رو نشون میده:\n",
|
||
"(میتونی اینو توی notebook با تصویر یا SVG بذاری)\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
" نکته: ایده اینه که اول بفهمیم جریان exceptionها چطوریه،\n",
|
||
"بعد تازه تصمیم بگیریم چه تغییراتی لازمه برای پروژه خودمون.\n"
|
||
],
|
||
"id": "cc6b0fa3336db0dc"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": "",
|
||
"id": "61aab097368adbbf",
|
||
"attachments": {
|
||
"0538973d-4fb2-44e8-968e-444832f70ac6.png": {
|
||
"image/png": "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"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"خب، اگر **exception handler نتونه exception مورد نظر رو هندل کنه** و `None` برگردونه،\n",
|
||
"این باعث میشه که یه **Unhandled Exception** رخ بده و نتیجهش **500 Server Error** باشه 😬\n",
|
||
"\n",
|
||
"این معمولاً خوبه، چون حداقل **خطاها رو سایلنس نمیکنه** و مجبور میشی بهشون توجه کنی.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"حالا، چند **quirk و نکته ریز** هست که باید مراقبش باشیم:"
|
||
],
|
||
"id": "fa8d6844a3db7151"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# DRF's ValidationError\n",
|
||
"\n",
|
||
"مثلاً، اگر ساده یه `rest_framework.exceptions.ValidationError` پر کنیم مثل این:\n",
|
||
"\n",
|
||
"```python\n",
|
||
"from rest_framework.exceptions import ValidationError\n",
|
||
"\n",
|
||
"raise ValidationError(\"این یک خطای ولیدیشن ساده است\")\n"
|
||
],
|
||
"id": "74d9605d847ad80d"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from rest_framework import exceptions\n",
|
||
"\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" raise exceptions.ValidationError(\"Some message\")"
|
||
],
|
||
"id": "e38176cab9be0570"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " محتوای پاسخ قراره یه چی شبیه به این بشه",
|
||
"id": "ee1de05a74d35bf"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": "[\"Some message\"]",
|
||
"id": "26c1681f0a63cfe2"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " و اگه اینجوری بنویسیمش",
|
||
"id": "929e80c337cd090d"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from rest_framework import exceptions\n",
|
||
"\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" raise exceptions.ValidationError({\"error\": \"Some message\"})"
|
||
],
|
||
"id": "94adc2896853431"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " جوابش قراره این شکلی باشه",
|
||
"id": "736cefa52474d89"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"error\": \"Some message\"\n",
|
||
"}"
|
||
],
|
||
"id": "35a1bb24fe817d9c"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
" این همون `detail` هست که تحویل `validation error` دادیم ولی با نوع داده متفاوت\n",
|
||
"\n",
|
||
"و حالا یخوایم از استثناعات دیکه استقاده کنیم مثل نمونه های داخل خود DRF"
|
||
],
|
||
"id": "d0102e7e20c2bdcc"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from rest_framework import exceptions\n",
|
||
"\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" raise exceptions.NotFound()"
|
||
],
|
||
"id": "f2e3745c32e37d9e"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " نمونه جواب قراره شبیه به این بشه",
|
||
"id": "f5236e5c8fb631af"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": \"Not found.\"\n",
|
||
"}"
|
||
],
|
||
"id": "9def3c6d953bf4d7"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"این کاملا با چیزی که از رفتار ValidationError دیدیم فرق داره و میتونه مشکلاتی ایجاد کنه.\n",
|
||
"\n",
|
||
"تا الان، رفتار پیشفرض DRF میتونه برامون تولید کنه:\n",
|
||
"\n",
|
||
"- یه آرایه.\n",
|
||
"- یه دیکشنری.\n",
|
||
"- یه نتیجه خاص به شکل {\"detail\": \"something\"}.\n",
|
||
"\n",
|
||
"پس اگه بخوایم از رفتار پیشفرض DRF استفاده کنیم، باید حواسمون به این ناسازگاری باشه.\n"
|
||
],
|
||
"id": "b017d18fbb868baa"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"### ValidationError خود Django\n",
|
||
"\n",
|
||
"حالا، رفتار پیشفرض DRF با ValidationError خود Django زیاد خوشاخلاق نیست.\n",
|
||
"\n",
|
||
"این قطعه کد:\n"
|
||
],
|
||
"id": "94964a1331d33618"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.core.exceptions import ValidationError as DjangoValidationError\n",
|
||
"\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" raise DjangoValidationError(\"Some error message\")"
|
||
],
|
||
"id": "ae173904c412798e"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"این باعث میشه یه استثنا بدون هندل شدن رخ بده و یه خطای ۵۰۰ سرور بدیم.\n",
|
||
"\n",
|
||
"همین اتفاق وقتی میافته که این ValidationError از اعتبارسنجی مدل اومده باشه، مثلا:\n"
|
||
],
|
||
"id": "7cf5e261f31bc6e8"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def some_service():\n",
|
||
" user = BaseUser()\n",
|
||
" user.full_clean() # Throws ValidationError\n",
|
||
" user.save()"
|
||
],
|
||
"id": "8af6ba3e918c32ff"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"این هم باعث میشه ۵۰۰ سرور بده.\n",
|
||
"\n",
|
||
"اگه بخوایم شروع کنیم به هندل کردنش، مثل rest_framework.exceptions.ValidationError، باید یه exception handler سفارشی خودمون بسازیم:\n"
|
||
],
|
||
"id": "89d3e0ef3c12da21"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.core.exceptions import ValidationError as DjangoValidationError\n",
|
||
"\n",
|
||
"from rest_framework.views import exception_handler\n",
|
||
"from rest_framework.serializers import as_serializer_error\n",
|
||
"from rest_framework import exceptions\n",
|
||
"\n",
|
||
"\n",
|
||
"def custom_exception_handler(exc, ctx):\n",
|
||
" if isinstance(exc, DjangoValidationError):\n",
|
||
" exc = exceptions.ValidationError(as_serializer_error(exc))\n",
|
||
"\n",
|
||
" response = exception_handler(exc, ctx)\n",
|
||
"\n",
|
||
" # If unexpected error occurs (server error, etc.)\n",
|
||
" if response is None:\n",
|
||
" return response\n",
|
||
"\n",
|
||
" return response"
|
||
],
|
||
"id": "62985ff85e2cf595"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": "این عملاً پیادهسازی پیشفرضه، با اضافه شدن این قطعه کد:\n",
|
||
"id": "fa6a43b1aca6593a"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"if isinstance(exc, DjangoValidationError):\n",
|
||
" exc = exceptions.ValidationError(as_serializer_error(exc))"
|
||
],
|
||
"id": "5711a2bf024527d3"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"چون ما باید بین `django.core.exceptions.ValidationError` و `rest_framework.exceptions.ValidationError` نگاشت انجام بدیم، داریم از `as_serializer_error` خود DRF استفاده میکنیم، که توی serializerها هم داخلی استفاده میشه، فقط برای همین کار.\n",
|
||
"\n",
|
||
"با این کار، حالا میتونیم ValidationError خود Django رو خوشرفتار کنیم و با exception handler خود DRF کار کنه.\n"
|
||
],
|
||
"id": "b24224f93d41ed23"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# توصیف کنید که خطاهای API شما قراره چه شکلی باشن.\n",
|
||
"این خیلی مهمه و بهتره هر چه زودتر توی پروژه مشخص بشه.\n",
|
||
"\n",
|
||
"به زبان ساده یعنی: با هم توافق کنیم که اینترفیس خطاهای APIمون چه شکلیه – وقتی خطایی پیش اومد، خروجی API چه شکلی باشه؟\n",
|
||
"\n",
|
||
"این خیلی وابسته به پروژه است، میتونید از APIهای معروف برای الهام گرفتن استفاده کنید:\n",
|
||
"\n",
|
||
"- Stripe - [Docs](https://stripe.com/docs/api/errors)\n",
|
||
"\n",
|
||
"مثلا میتونیم تصمیم بگیریم که خطاهای ما اینطوری باشن:\n",
|
||
"\n",
|
||
"- کد وضعیت 4** و 5** برای انواع مختلف خطاها.\n",
|
||
"- هر خطا یه دیکشنری باشه با یه کلید `message` که پیام خطا داخلش باشه.\n"
|
||
],
|
||
"id": "12ba78fec9982631"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"Some error message here\"\n",
|
||
"}"
|
||
],
|
||
"id": "70e26bf54bdb10bc"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"خیلی سادهست:\n",
|
||
"\n",
|
||
"- 400 برای خطاهای اعتبارسنجی (validation errors)\n",
|
||
"- 401 برای خطاهای احراز هویت (auth errors)\n",
|
||
"- 403 برای خطاهای دسترسی (permission errors)\n",
|
||
"- 404 برای خطاهای پیدا نشدن (not found errors)\n",
|
||
"- 429 برای خطاهای محدودسازی درخواستها (throttling errors)\n",
|
||
"- 500 برای خطاهای سرور (server errors) – اینو باید مواظب باشیم که هیچ استثنایی که باعث 500 میشه رو ساکت نکنیم و همیشه گزارش بدیم، مثلا تو سرویسهایی مثل Sentry\n",
|
||
"\n",
|
||
"باز هم میگم، این بستگی به پروژه داره و شما میتونید تغییرش بدید. برای یکی از رویکردهای خاص، یه پیشنهاد مشابه هم ارائه میکنیم.\n"
|
||
],
|
||
"id": "26e4435c50b87b73"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# تغییر رفتار پیشفرض مدیریت خطاها\n",
|
||
"\n",
|
||
"خب ، این یه بخش مهمه: وقتی که تصمیم میگیرید خطاهای API شما چه شکلی باشن، باید بتونید رفتار پیشفرض مدیریت خطاها رو تغییر بدید.\n",
|
||
"\n",
|
||
"تو مثال قبل که درباره **ValidationError در Django** صحبت کردیم، یه نمونهی custom exception handler گذاشتیم که دقیقاً همین کارو میکنه.\n",
|
||
"\n",
|
||
"همچنین، تو بخشهای بعدی چند مثال دیگه هم داریم که میتونید ببینید چطوری میشه خطاها رو مدیریت کرد.\n"
|
||
],
|
||
"id": "4783d4ae3b78dc4b"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Approach 1: استفاده از استثناهای پیشفرض DRF با تغییرات کم\n",
|
||
"\n",
|
||
" DRF خودش مدیریت خطاهاش خوبه. ولی یه مشکلی هست: خروجی همیشه consistent نیست.\n",
|
||
"پس ما یه سری تغییرات کوچیک میزنیم تا همه چیز یکدست بشه.\n",
|
||
"\n",
|
||
"هدفمون اینه که خطاها همیشه یه شکلی شبیه به این باشن:\n"
|
||
],
|
||
"id": "81e4e5b0007f52cd"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": \"Some error\"\n",
|
||
"}"
|
||
],
|
||
"id": "ba94876d894cb827"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " یا",
|
||
"id": "2249263e32b295f4"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": [\"Some error\", \"Another error\"]\n",
|
||
"}"
|
||
],
|
||
"id": "89d17de795e5abb8"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " یا",
|
||
"id": "bc9ea365a5ad64c3"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": { \"key\": \"... some arbitrary nested structure ...\" }\n",
|
||
"}"
|
||
],
|
||
"id": "edbdc83c654e913f"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
" به هرحال مطمعن بشو که ما یک دیکچنری با کلید `detail` داریم\n",
|
||
"به علاوه اون ما میخوایم دجنگو `validation error` رو هم حل بکنیم\n",
|
||
"برا حل اون مثال کاستوم ما باید این شکلی باشه"
|
||
],
|
||
"id": "fa0278135c5c78f"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.core.exceptions import ValidationError as DjangoValidationError, PermissionDenied\n",
|
||
"from django.http import Http404\n",
|
||
"\n",
|
||
"from rest_framework.views import exception_handler\n",
|
||
"from rest_framework import exceptions\n",
|
||
"from rest_framework.serializers import as_serializer_error\n",
|
||
"\n",
|
||
"\n",
|
||
"def drf_default_with_modifications_exception_handler(exc, ctx):\n",
|
||
" if isinstance(exc, DjangoValidationError):\n",
|
||
" exc = exceptions.ValidationError(as_serializer_error(exc))\n",
|
||
"\n",
|
||
" if isinstance(exc, Http404):\n",
|
||
" exc = exceptions.NotFound()\n",
|
||
"\n",
|
||
" if isinstance(exc, PermissionDenied):\n",
|
||
" exc = exceptions.PermissionDenied()\n",
|
||
"\n",
|
||
" response = exception_handler(exc, ctx)\n",
|
||
"\n",
|
||
" # If unexpected error occurs (server error, etc.)\n",
|
||
" if response is None:\n",
|
||
" return response\n",
|
||
"\n",
|
||
" if isinstance(exc.detail, (list, dict)):\n",
|
||
" response.data = {\n",
|
||
" \"detail\": response.data\n",
|
||
" }\n",
|
||
"\n",
|
||
" return response"
|
||
],
|
||
"id": "5c2d3bb7cde30bd1"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"خب، ما عملاً یه چیزی شبیه همون exception handler اصلی DRF رو بازسازی میکنیم،\n",
|
||
"تا بعدش بتونیم با APIException درست کار کنیم (مثلاً دنبال `detail` باشیم).\n",
|
||
"\n",
|
||
"حالا بزنیم یه سری تستها رو اجرا کنیم:\n"
|
||
],
|
||
"id": "acb0559469a24997"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def some_service():\n",
|
||
" raise DjangoValidationError(\"Some error message\")"
|
||
],
|
||
"id": "a919a07d7ae367ab"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " جواب",
|
||
"id": "9cfc71df3ec7715b"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": {\n",
|
||
" \"non_field_errors\": [\"Some error message\"]\n",
|
||
" }\n",
|
||
"}"
|
||
],
|
||
"id": "ec89bc4639bac76a"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " کد :",
|
||
"id": "f227bc5464940514"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.core.exceptions import PermissionDenied\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" raise PermissionDenied()"
|
||
],
|
||
"id": "1c7c107c73c29f86"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " جواب:",
|
||
"id": "274a6489eb0727fa"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": \"You do not have permission to perform this action.\"\n",
|
||
"}"
|
||
],
|
||
"id": "3a94f4871bb34"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " کد :",
|
||
"id": "47b8822c1b55164f"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.http import Http404\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" raise Http404()"
|
||
],
|
||
"id": "c4062fca5646c5d0"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " جواب :",
|
||
"id": "38d0de7c93edaf10"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": \"Not found.\"\n",
|
||
"}"
|
||
],
|
||
"id": "757df861f03caa79"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " کد :",
|
||
"id": "fa809e12cd4eed71"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def some_service():\n",
|
||
" raise RestValidationError(\"Some error message\")"
|
||
],
|
||
"id": "2e538c011629ab88"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " جواب :",
|
||
"id": "bd409ed104b9c15a"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": [\"Some error message\"]\n",
|
||
"}"
|
||
],
|
||
"id": "83bd5a0aee82662c"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " کد :",
|
||
"id": "80b01be1ae215e5c"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def some_service():\n",
|
||
" raise RestValidationError(detail={\"error\": \"Some error message\"})"
|
||
],
|
||
"id": "6b835d50db6bc099"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " جواب :",
|
||
"id": "38d71423cc829577"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": {\n",
|
||
" \"error\": \"Some error message\"\n",
|
||
" }\n",
|
||
"}"
|
||
],
|
||
"id": "2a1056196d4cd527"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " کد :",
|
||
"id": "95c063f93c357a9f"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class NestedSerializer(serializers.Serializer):\n",
|
||
" bar = serializers.CharField()\n",
|
||
"\n",
|
||
"\n",
|
||
"class PlainSerializer(serializers.Serializer):\n",
|
||
" foo = serializers.CharField()\n",
|
||
" email = serializers.EmailField(min_length=200)\n",
|
||
"\n",
|
||
" nested = NestedSerializer()\n",
|
||
"\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" serializer = PlainSerializer(data={\n",
|
||
" \"email\": \"foo\",\n",
|
||
" \"nested\": {}\n",
|
||
" })\n",
|
||
" serializer.is_valid(raise_exception=True)\n"
|
||
],
|
||
"id": "14e08afb10a1b39c"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " جواب :",
|
||
"id": "96caaabe2eac48b1"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": {\n",
|
||
" \"foo\": [\"This field is required.\"],\n",
|
||
" \"email\": [\n",
|
||
" \"Ensure this field has at least 200 characters.\",\n",
|
||
" \"Enter a valid email address.\"\n",
|
||
" ],\n",
|
||
" \"nested\": {\n",
|
||
" \"bar\": [\"This field is required.\"]\n",
|
||
" }\n",
|
||
" }\n",
|
||
"}"
|
||
],
|
||
"id": "12c5732281d27322"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " کد :",
|
||
"id": "9ecf43a33dba5f7b"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from rest_framework import exceptions\n",
|
||
"\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" raise exceptions.Throttled()"
|
||
],
|
||
"id": "db5507641f949da5"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " جواب :",
|
||
"id": "df93ed692f322a95"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": \"Request was throttled.\"\n",
|
||
"}"
|
||
],
|
||
"id": "73653427ce868ee0"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " کد :",
|
||
"id": "a3aa36d0e92e8ae0"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def some_service():\n",
|
||
" user = BaseUser()\n",
|
||
" user.full_clean()"
|
||
],
|
||
"id": "581c582fe3a391f0"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " جواب :",
|
||
"id": "f78ce4fcdb582fdb"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"detail\": {\n",
|
||
" \"password\": [\"This field cannot be blank.\"],\n",
|
||
" \"email\": [\"This field cannot be blank.\"]\n",
|
||
" }\n",
|
||
"}"
|
||
],
|
||
"id": "5b5fa54a20959264"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"## Approach 2 - روش پیشنهادی HackSoft\n",
|
||
"\n",
|
||
"خب، میخوایم یه روش معرفی کنیم که راحت قابل توسعه باشه و با پروژه شما جور در بیاد.\n",
|
||
"\n",
|
||
"ایدههای کلیدی:\n",
|
||
"\n",
|
||
"- اپلیکیشن شما یه سلسلهمراتب از استثناها (exceptions) داره که توسط منطق کسبوکار پرتاب میشن.\n",
|
||
"- برای ساده بودن، فرض کنیم فقط یه خطا داریم: `ApplicationError`.\n",
|
||
"- این خطا تو یه اپ core تعریف میشه، داخل ماژول exceptions، یعنی چیزی شبیه `project.core.exceptions.ApplicationError`.\n",
|
||
"- بقیه موارد رو بذارید DRF به طور پیشفرض هندل کنه.\n",
|
||
"- `ValidationError` حالا خاصه و قراره متفاوت هندل بشه.\n",
|
||
"- `ValidationError` فقط باید از serializer یا اعتبارسنجی مدل بیاد.\n"
|
||
],
|
||
"id": "815336649f316a13"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " قراره یه ساختار به شکل زیر برای ارور ها به وجود بیاریم",
|
||
"id": "4ddd46fff45b17ae"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"The error message here\",\n",
|
||
" \"extra\": {}\n",
|
||
"}"
|
||
],
|
||
"id": "9bce28ef60ccd1de"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"\n",
|
||
"کلید اضافه (`extra`) میتونه هر دیتایی رو نگه داره — هر چیزی که لازم داریم به فرانت منتقل بشه.\n",
|
||
"برای مثال، وقتی با یه `ValidationError` سروکار داریم (که معمولاً از یه **Serializer** یا **Model** میاد)،\n",
|
||
"میخوایم ارور رو به این شکل نمایش بدیم:\n"
|
||
],
|
||
"id": "b5752591b23367ea"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"Validation error.\",\n",
|
||
" \"extra\": {\n",
|
||
" \"fields\": {\n",
|
||
" \"password\": [\"This field cannot be blank.\"],\n",
|
||
" \"email\": [\"This field cannot be blank.\"]\n",
|
||
" }\n",
|
||
" }\n",
|
||
"}"
|
||
],
|
||
"id": "c878c0d2b254c324"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"\n",
|
||
"اینجا هدف اینه که فرانتاند بتونه بفهمه کدوم فیلدها خطا دارن،\n",
|
||
"مثلاً از طریق `extra.fields` تا بتونه ارور هر فیلد رو جدا جدا به کاربر نشون بده.\n",
|
||
"\n",
|
||
"برای رسیدن به این هدف، یه **exception handler سفارشی** میسازیم.\n",
|
||
"کدی شبیه به این:"
|
||
],
|
||
"id": "155b1e5cbaea876f"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.core.exceptions import ValidationError as DjangoValidationError, PermissionDenied\n",
|
||
"from django.http import Http404\n",
|
||
"\n",
|
||
"from rest_framework.views import exception_handler\n",
|
||
"from rest_framework import exceptions\n",
|
||
"from rest_framework.serializers import as_serializer_error\n",
|
||
"from rest_framework.response import Response\n",
|
||
"\n",
|
||
"from styleguide_example.core.exceptions import ApplicationError\n",
|
||
"\n",
|
||
"\n",
|
||
"def hacksoft_proposed_exception_handler(exc, ctx):\n",
|
||
" \"\"\"\n",
|
||
" {\n",
|
||
" \"message\": \"Error message\",\n",
|
||
" \"extra\": {}\n",
|
||
" }\n",
|
||
" \"\"\"\n",
|
||
" if isinstance(exc, DjangoValidationError):\n",
|
||
" exc = exceptions.ValidationError(as_serializer_error(exc))\n",
|
||
"\n",
|
||
" if isinstance(exc, Http404):\n",
|
||
" exc = exceptions.NotFound()\n",
|
||
"\n",
|
||
" if isinstance(exc, PermissionDenied):\n",
|
||
" exc = exceptions.PermissionDenied()\n",
|
||
"\n",
|
||
" response = exception_handler(exc, ctx)\n",
|
||
"\n",
|
||
" # If unexpected error occurs (server error, etc.)\n",
|
||
" if response is None:\n",
|
||
" if isinstance(exc, ApplicationError):\n",
|
||
" data = {\n",
|
||
" \"message\": exc.message,\n",
|
||
" \"extra\": exc.extra\n",
|
||
" }\n",
|
||
" return Response(data, status=400)\n",
|
||
"\n",
|
||
" return response\n",
|
||
"\n",
|
||
" if isinstance(exc.detail, (list, dict)):\n",
|
||
" response.data = {\n",
|
||
" \"detail\": response.data\n",
|
||
" }\n",
|
||
"\n",
|
||
" if isinstance(exc, exceptions.ValidationError):\n",
|
||
" response.data[\"message\"] = \"Validation error\"\n",
|
||
" response.data[\"extra\"] = {\n",
|
||
" \"fields\": response.data[\"detail\"]\n",
|
||
" }\n",
|
||
" else:\n",
|
||
" response.data[\"message\"] = response.data[\"detail\"]\n",
|
||
" response.data[\"extra\"] = {}\n",
|
||
"\n",
|
||
" del response.data[\"detail\"]\n",
|
||
"\n",
|
||
" return response"
|
||
],
|
||
"id": "e71e81f779674049"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " استراتژی ما اینجا استفاده کردن از DRF تا جای ممکن و تغییر اونه",
|
||
"id": "6c26c56fdd27247e"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from styleguide_example.core.exceptions import ApplicationError\n",
|
||
"\n",
|
||
"\n",
|
||
"def trigger_application_error():\n",
|
||
" raise ApplicationError(message=\"Something is not correct\", extra={\"type\": \"RANDOM\"})"
|
||
],
|
||
"id": "6f75653b5d35375e"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " جواب :",
|
||
"id": "aeb2828ae1397936"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"Something is not correct\",\n",
|
||
" \"extra\": {\n",
|
||
" \"type\": \"RANDOM\"\n",
|
||
" }\n",
|
||
"}"
|
||
],
|
||
"id": "998ed2fc11844799"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def some_service():\n",
|
||
" raise DjangoValidationError(\"Some error message\")"
|
||
],
|
||
"id": "61759c185aa72e38"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"Validation error\",\n",
|
||
" \"extra\": {\n",
|
||
" \"fields\": {\n",
|
||
" \"non_field_errors\": [\"Some error message\"]\n",
|
||
" }\n",
|
||
" }\n",
|
||
"}"
|
||
],
|
||
"id": "4c26f5f575fa4dd9"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.core.exceptions import PermissionDenied\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" raise PermissionDenied()"
|
||
],
|
||
"id": "e7636b8f054e207b"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"You do not have permission to perform this action.\",\n",
|
||
" \"extra\": {}\n",
|
||
"}"
|
||
],
|
||
"id": "a6fe417cd1060d6b"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.http import Http404\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" raise Http404()"
|
||
],
|
||
"id": "e6044ff129b213e2"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"Not found.\",\n",
|
||
" \"extra\": {}\n",
|
||
"}"
|
||
],
|
||
"id": "343ef03b1bfb194e"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def some_service():\n",
|
||
" raise RestValidationError(\"Some error message\")"
|
||
],
|
||
"id": "55956678fb2ff180"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"Validation error\",\n",
|
||
" \"extra\": {\n",
|
||
" \"fields\": [\"Some error message\"]\n",
|
||
" }\n",
|
||
"}"
|
||
],
|
||
"id": "58da4ed3ef327982"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def some_service():\n",
|
||
" raise RestValidationError(detail={\"error\": \"Some error message\"})"
|
||
],
|
||
"id": "3d7dedd3453f6249"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"Validation error\",\n",
|
||
" \"extra\": {\n",
|
||
" \"fields\": {\n",
|
||
" \"error\": \"Some error message\"\n",
|
||
" }\n",
|
||
" }\n",
|
||
"}"
|
||
],
|
||
"id": "bbdc9c4e79b9ae0c"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"class NestedSerializer(serializers.Serializer):\n",
|
||
" bar = serializers.CharField()\n",
|
||
"\n",
|
||
"\n",
|
||
"class PlainSerializer(serializers.Serializer):\n",
|
||
" foo = serializers.CharField()\n",
|
||
" email = serializers.EmailField(min_length=200)\n",
|
||
"\n",
|
||
" nested = NestedSerializer()\n",
|
||
"\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" serializer = PlainSerializer(data={\n",
|
||
" \"email\": \"foo\",\n",
|
||
" \"nested\": {}\n",
|
||
" })\n",
|
||
" serializer.is_valid(raise_exception=True)\n"
|
||
],
|
||
"id": "748221458f29ffd5"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"Validation error\",\n",
|
||
" \"extra\": {\n",
|
||
" \"fields\": {\n",
|
||
" \"foo\": [\"This field is required.\"],\n",
|
||
" \"email\": [\n",
|
||
" \"Ensure this field has at least 200 characters.\",\n",
|
||
" \"Enter a valid email address.\"\n",
|
||
" ],\n",
|
||
" \"nested\": {\n",
|
||
" \"bar\": [\"This field is required.\"]\n",
|
||
" }\n",
|
||
" }\n",
|
||
" }\n",
|
||
"}"
|
||
],
|
||
"id": "8d54fd9e3332fec"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from rest_framework import exceptions\n",
|
||
"\n",
|
||
"\n",
|
||
"def some_service():\n",
|
||
" raise exceptions.Throttled()"
|
||
],
|
||
"id": "b1aa8fb83fd13572"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"Request was throttled.\",\n",
|
||
" \"extra\": {}\n",
|
||
"}"
|
||
],
|
||
"id": "5d9561f655ffaa91"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"def some_service():\n",
|
||
" user = BaseUser()\n",
|
||
" user.full_clean()"
|
||
],
|
||
"id": "1647b7f6c5f81444"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"{\n",
|
||
" \"message\": \"Validation error\",\n",
|
||
" \"extra\": {\n",
|
||
" \"fields\": {\n",
|
||
" \"password\": [\"This field cannot be blank.\"],\n",
|
||
" \"email\": [\"This field cannot be blank.\"]\n",
|
||
" }\n",
|
||
" }\n",
|
||
"}"
|
||
],
|
||
"id": "b2b3a68f0ce46d1e"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"# Celery — مغز پشت صحنهی پروژه\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## کاربردهای اصلی\n",
|
||
"\n",
|
||
"سلری رو معمولاً واسه ۳ مدل کار استفاده میکنیم:\n",
|
||
"\n",
|
||
"1. حرف زدن با سرویسهای خارجی (ایمیل، نوتیف، پیام و از این داستانا)\n",
|
||
"2. انداختن کارای سنگین بیرون از چرخهی HTTP (تا یوزر منتظر نمونه)\n",
|
||
"3. اجرای کارای زمانبندیشده با **Celery Beat**\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## منطق کلی\n",
|
||
"\n",
|
||
"ما با **Celery** مثل یه رابط رفتار میکنیم، نه یه جای منطق اصلی.\n",
|
||
"یعنی چی؟ یعنی:\n",
|
||
"\n",
|
||
"> هیچ کاری که مربوط به منطق پروژهست نباید مستقیم داخل تسک نوشته بشه.\n",
|
||
"\n",
|
||
"سلری فقط یه مجریه. منطق اصلی باید توی سرویسها (services) نوشته بشه، نه تو تسکها.\n",
|
||
"\n",
|
||
"\n"
|
||
],
|
||
"id": "d5e01e664b4c6edb"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.db import transaction\n",
|
||
"from django.core.mail import EmailMultiAlternatives\n",
|
||
"\n",
|
||
"from styleguide_example.core.exceptions import ApplicationError\n",
|
||
"from styleguide_example.common.services import model_update\n",
|
||
"from styleguide_example.emails.models import Email\n",
|
||
"\n",
|
||
"\n",
|
||
"@transaction.atomic\n",
|
||
"def email_send(email: Email) -> Email:\n",
|
||
" if email.status != Email.Status.SENDING:\n",
|
||
" raise ApplicationError(f\"Cannot send non-ready emails. Current status is {email.status}\")\n",
|
||
"\n",
|
||
" subject = email.subject\n",
|
||
" from_email = \"styleguide-example@hacksoft.io\"\n",
|
||
" to = email.to\n",
|
||
"\n",
|
||
" html = email.html\n",
|
||
" plain_text = email.plain_text\n",
|
||
"\n",
|
||
" msg = EmailMultiAlternatives(subject, plain_text, from_email, [to])\n",
|
||
" msg.attach_alternative(html, \"text/html\")\n",
|
||
"\n",
|
||
" msg.send()\n",
|
||
"\n",
|
||
" email, _ = model_update(\n",
|
||
" instance=email,\n",
|
||
" fields=[\"status\", \"sent_at\"],\n",
|
||
" data={\n",
|
||
" \"status\": Email.Status.SENT,\n",
|
||
" \"sent_at\": timezone.now()\n",
|
||
" }\n",
|
||
" )\n",
|
||
" return email"
|
||
],
|
||
"id": "e3fb09af2d6d3a9b"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": " ارسال ایمیل لاجیک بیزینسی داره ولی ما میخوایم این رو با سلری تسک انجام بدیم و یک سرویس رو تریگر کنیم",
|
||
"id": "163e071644009da0"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from celery import shared_task\n",
|
||
"\n",
|
||
"from styleguide_example.emails.models import Email\n",
|
||
"\n",
|
||
"\n",
|
||
"@shared_task\n",
|
||
"def email_send(email_id):\n",
|
||
" email = Email.objects.get(id=email_id)\n",
|
||
"\n",
|
||
" from styleguide_example.emails.services import email_send\n",
|
||
" email_send(email)"
|
||
],
|
||
"id": "8c0b1f1d9ca626c5"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"همونطور که میبینی، ما با **تسک (task)** مثل یه **API** برخورد میکنیم:\n",
|
||
"\n",
|
||
"1. دادههای لازم رو میگیریم.\n",
|
||
"2. سرویس مورد نظر رو صدا میزنیم.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"حالا تصور کن یه **سرویس دیگه** داریم که خودش قراره **ارسال ایمیل** رو فعال کنه.\n",
|
||
"کدش ممکنه شبیه این باشه:"
|
||
],
|
||
"id": "9f90a92ea9055870"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.db import transaction\n",
|
||
"\n",
|
||
"# ... more imports here ...\n",
|
||
"\n",
|
||
"from styleguide_example.emails.tasks import email_send as email_send_task\n",
|
||
"\n",
|
||
"\n",
|
||
"@transaction.atomic\n",
|
||
"def user_complete_onboarding(user: User) -> User:\n",
|
||
" # ... some code here\n",
|
||
"\n",
|
||
" email = email_get_onboarding_template(user=user)\n",
|
||
"\n",
|
||
" transaction.on_commit(lambda: email_send_task.delay(email.id))\n",
|
||
"\n",
|
||
" return user"
|
||
],
|
||
"id": "e723aeab9f78f962"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"\n",
|
||
"1. ما تسک (task) رو که **اسمش دقیقاً مثل سرویسه** ایمپورت میکنیم،\n",
|
||
" ولی یه پسوند `_task` بهش اضافه میکنیم تا از هم تفکیک بشن.\n",
|
||
"\n",
|
||
"2. وقتی **تراکنش (transaction)** تموم میشه (commit میکنه)،\n",
|
||
" اون موقع تسک رو صدا میزنیم.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"### در کل، طرز کار ما با Celery به این شکله:\n",
|
||
"\n",
|
||
"- **تسکها سرویسها رو صدا میزنن.**\n",
|
||
"- **سرویس** رو داخل بدنهی تابع تسک ایمپورت میکنیم (نه در بالای فایل).\n",
|
||
"- **وقتی میخوایم یه تسک رو اجرا کنیم،** اون رو در سطح ماژول ایمپورت میکنیم و بهش پسوند `_task` میدیم.\n",
|
||
"- **تسکها رو بعد از commit تراکنش اجرا میکنیم،** بهعنوان یه اثر جانبی از منطق اصلی.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"### نکتهی طراحی\n",
|
||
"این ترکیب بین **tasks و services** باعث میشه **دایرهی ایمپورتها (circular imports)** اتفاق نیفته،\n",
|
||
"که یکی از مشکلات رایج موقع استفاده از Celery هست.\n"
|
||
],
|
||
"id": "576f7039d9619769"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"## Error Handling\n",
|
||
"\n",
|
||
"گاهی وقتا ممکنه **سرویس ما fail کنه**،\n",
|
||
"و بخوایم اون خطا رو در سطح **تسک (task)** هندل کنیم.\n",
|
||
"مثلاً بخوایم **تسک رو دوباره retry کنیم**.\n",
|
||
"\n",
|
||
"اینجور منطق مربوط به خطا **باید داخل خود تسک نوشته بشه**،\n",
|
||
"نه داخل سرویس.\n"
|
||
],
|
||
"id": "706ae47d0958814e"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from celery import shared_task\n",
|
||
"from celery.utils.log import get_task_logger\n",
|
||
"\n",
|
||
"from styleguide_example.emails.models import Email\n",
|
||
"\n",
|
||
"\n",
|
||
"logger = get_task_logger(__name__)\n",
|
||
"\n",
|
||
"\n",
|
||
"def _email_send_failure(self, exc, task_id, args, kwargs, einfo):\n",
|
||
" email_id = args[0]\n",
|
||
" email = Email.objects.get(id=email_id)\n",
|
||
"\n",
|
||
" from styleguide_example.emails.services import email_failed\n",
|
||
"\n",
|
||
" email_failed(email)\n",
|
||
"\n",
|
||
"\n",
|
||
"@shared_task(bind=True, on_failure=_email_send_failure)\n",
|
||
"def email_send(self, email_id):\n",
|
||
" email = Email.objects.get(id=email_id)\n",
|
||
"\n",
|
||
" from styleguide_example.emails.services import email_send\n",
|
||
"\n",
|
||
" try:\n",
|
||
" email_send(email)\n",
|
||
" except Exception as exc:\n",
|
||
" # https://docs.celeryq.dev/en/stable/userguide/tasks.html#retrying\n",
|
||
" logger.warning(f\"Exception occurred while sending email: {exc}\")\n",
|
||
" self.retry(exc=exc, countdown=5)"
|
||
],
|
||
"id": "7ae1c321d101abe"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"## Handling Failure\n",
|
||
"\n",
|
||
"همونطور که میبینی، تسک چند بار **retry** میکنه،\n",
|
||
"ولی اگه **همهی تلاشها fail بشن**، ما این وضعیت رو\n",
|
||
"در **callback مخصوص شکست (on_failure)** هندل میکنیم.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"### منطقش اینجوریه:\n",
|
||
"اسم callback بر اساس یه الگو ساخته میشه\n",
|
||
"> `_ {task_name}_failure`\n",
|
||
"\n",
|
||
"و این تابع هم مثل یه تسک معمولی،\n",
|
||
"**سرویس مربوطه رو صدا میزنه** (service layer).\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"### مثال ساده:\n",
|
||
"\n",
|
||
"```python\n",
|
||
"from celery import shared_task\n",
|
||
"from styleguide_example.emails.models import Email\n",
|
||
"\n",
|
||
"@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, max_retries=5)\n",
|
||
"def email_send(self, email_id):\n",
|
||
" try:\n",
|
||
" email = Email.objects.get(id=email_id)\n",
|
||
" from styleguide_example.emails.services import email_send\n",
|
||
" email_send(email)\n",
|
||
" except Exception as exc:\n",
|
||
" raise self.retry(exc=exc)\n",
|
||
"\n",
|
||
"\n",
|
||
"@shared_task\n",
|
||
"def email_send_failure(email_id):\n",
|
||
" # اینجا منطق مربوط به fail شدن رو هندل میکنیم\n",
|
||
" email = Email.objects.get(id=email_id)\n",
|
||
" from styleguide_example.emails.services import email_mark_failed\n",
|
||
" email_mark_failed(email)\n"
|
||
],
|
||
"id": "9848bf1f9a58f460"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"## Structure — ساختار تسکها\n",
|
||
"\n",
|
||
"تسکها معمولا توی **tasks.py** هر اپ قرار میگیرن.\n",
|
||
"\n",
|
||
"قوانین همون قوانین بقیه چیزها هستن (APIs, services, selectors):\n",
|
||
"اگه تعداد تسکها برای یه اپ زیاد شد، بهتره اونها رو **بر اساس domain جدا کنیم**.\n",
|
||
"\n",
|
||
"مثلا:\n",
|
||
"- `tasks/domain_a.py`\n",
|
||
"- `tasks/domain_b.py`\n",
|
||
"\n",
|
||
"و کافیه اونا رو توی `tasks/__init__.py` ایمپورت کنیم تا **Celery autodiscover** انجام بده.\n",
|
||
"\n",
|
||
" نکتهی کلی: تسکها رو طوری تقسیم کن که **برای خودت منطقی باشه**.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## Periodic Tasks — تسکهای دورهای\n",
|
||
"\n",
|
||
"مدیریت تسکهای دورهای خیلی مهمه، مخصوصا وقتی **دهها یا صدها تسک** داشته باشی.\n",
|
||
"\n",
|
||
"ما از ترکیب زیر استفاده میکنیم:\n",
|
||
"- `Celery Beat`\n",
|
||
"- `django_celery_beat.schedulers:DatabaseScheduler`\n",
|
||
"- `django-celery-beat`\n",
|
||
"\n",
|
||
"اضافه بر این، یه **management command** داریم به اسم:\n",
|
||
"`setup_periodic_tasks`\n",
|
||
"\n",
|
||
"این کامند **تعریف همهی تسکهای دورهای سیستم** رو نگه میداره و داخل اپ tasks قرار داره.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"### مسیر فایل مثال:\n",
|
||
"`project/tasks/management/commands/setup_periodic_tasks.py`\n"
|
||
],
|
||
"id": "10a20c6ad31a61fe"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "code",
|
||
"outputs": [],
|
||
"execution_count": null,
|
||
"source": [
|
||
"from django.core.management.base import BaseCommand\n",
|
||
"from django.db import transaction\n",
|
||
"\n",
|
||
"from django_celery_beat.models import IntervalSchedule, CrontabSchedule, PeriodicTask\n",
|
||
"\n",
|
||
"from project.app.tasks import some_periodic_task\n",
|
||
"\n",
|
||
"\n",
|
||
"class Command(BaseCommand):\n",
|
||
" help = f\"\"\"\n",
|
||
" Setup celery beat periodic tasks.\n",
|
||
"\n",
|
||
" Following tasks will be created:\n",
|
||
"\n",
|
||
" - {some_periodic_task.name}\n",
|
||
" \"\"\"\n",
|
||
"\n",
|
||
" @transaction.atomic\n",
|
||
" def handle(self, *args, **kwargs):\n",
|
||
" print('Deleting all periodic tasks and schedules...\\n')\n",
|
||
"\n",
|
||
" IntervalSchedule.objects.all().delete()\n",
|
||
" CrontabSchedule.objects.all().delete()\n",
|
||
" PeriodicTask.objects.all().delete()\n",
|
||
"\n",
|
||
" periodic_tasks_data = [\n",
|
||
" {\n",
|
||
" 'task': some_periodic_task\n",
|
||
" 'name': 'Do some peridoic stuff',\n",
|
||
" # https://crontab.guru/#15_*_*_*_*\n",
|
||
" 'cron': {\n",
|
||
" 'minute': '15',\n",
|
||
" 'hour': '*',\n",
|
||
" 'day_of_week': '*',\n",
|
||
" 'day_of_month': '*',\n",
|
||
" 'month_of_year': '*',\n",
|
||
" },\n",
|
||
" 'enabled': True\n",
|
||
" },\n",
|
||
" ]\n",
|
||
"\n",
|
||
" for periodic_task in periodic_tasks_data:\n",
|
||
" print(f'Setting up {periodic_task[\"task\"].name}')\n",
|
||
"\n",
|
||
" cron = CrontabSchedule.objects.create(\n",
|
||
" **periodic_task['cron']\n",
|
||
" )\n",
|
||
"\n",
|
||
" PeriodicTask.objects.create(\n",
|
||
" name=periodic_task['name'],\n",
|
||
" task=periodic_task['task'].name,\n",
|
||
" crontab=cron,\n",
|
||
" enabled=periodic_task['enabled']\n",
|
||
" )"
|
||
],
|
||
"id": "e100be6acfe2dfef"
|
||
},
|
||
{
|
||
"metadata": {},
|
||
"cell_type": "markdown",
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"# جمعبندی\n",
|
||
"\n",
|
||
"این استایلگاید قرار نیست یه قانون خشک باشه،\n",
|
||
"بلکه قراره بهت کمک کنه که **کدهایی بنویسی که خودت ازش لذت ببری.**\n",
|
||
"\n",
|
||
"دنیا پر از فریمورکها و ابزارهای مختلفه،\n",
|
||
"اما در نهایت چیزی که یه پروژه رو ماندگار میکنه\n",
|
||
"**طرز فکر و نظم ذهنی توسعهدهندههاشه.**\n",
|
||
"\n",
|
||
"اگر این ساختار رو تو پروژههات پیاده کنی،\n",
|
||
"هم کار گروهی راحتتر میشه، هم رفع باگها و توسعههای بعدی تبدیل میشه به یه بازی\n",
|
||
"\n",
|
||
"پس اینو به عنوان یه «نقشه راه شخصی» برای جنگو در نظر بگیر —\n",
|
||
"یه سبکی که قراره زندگیتو سادهتر کنه، نه سختتر.\n",
|
||
"\n",
|
||
"موفق باشی ایلیا قادری آبان 404\n"
|
||
],
|
||
"id": "286c22de50c62a62"
|
||
}
|
||
],
|
||
"metadata": {
|
||
"kernelspec": {
|
||
"display_name": "Python 3",
|
||
"language": "python",
|
||
"name": "python3"
|
||
},
|
||
"language_info": {
|
||
"codemirror_mode": {
|
||
"name": "ipython",
|
||
"version": 2
|
||
},
|
||
"file_extension": ".py",
|
||
"mimetype": "text/x-python",
|
||
"name": "python",
|
||
"nbconvert_exporter": "python",
|
||
"pygments_lexer": "ipython2",
|
||
"version": "2.7.6"
|
||
}
|
||
},
|
||
"nbformat": 4,
|
||
"nbformat_minor": 5
|
||
}
|