This commit is contained in:
2026-08-24 15:54:08 +03:30
commit 236002a87c
133 changed files with 5946 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.env
.git/
.gitignore
.idea/
staticfiles/
logs/
README.md
+59
View File
@@ -0,0 +1,59 @@
# این فایل را کپی کن به .env و مقادیر واقعی را جایگزین کن:
# cp .env.example .env
# ── جنگو ───────────────────────────────────────────────
# یک مقدار یکتا و تصادفی بساز (این کلید امضای JWT هم هست):
# python -c "import secrets; print(secrets.token_urlsafe(50))"
SECRET_KEY=
DEBUG=False
# دامنه‌ی واقعی + لوکال‌هاست برای تست:
ALLOWED_HOSTS=example.com,www.example.com,localhost,127.0.0.1
# ── دیتابیس (PostgreSQL) ──────────────────────────────
# اگر این خط را خالی/حذف کنی، بدون هیچ تنظیم دیگری روی SQLite لوکال بالا
# می‌آید (برای توسعه‌ی سریع کافی است). برای PostgreSQL واقعی این خط را نگه
# دار و بقیه‌ی DB_* را پر کن. اگر پستگری‌ات در همین شبکه‌ی داکر با نام
# کانتینر دیگری بالاست، DB_HOST را همان نام کانتینر بگذار؛ اگر روی خودِ
# هاست است، از آدرس هاست استفاده کن (روی لینوکس معمولا: DB_HOST=172.17.0.1
# یا host.docker.internal).
DB_ENGINE=django.db.backends.postgresql
DB_NAME=sms_banker
DB_USER=
DB_PASSWORD=
DB_HOST=localhost
DB_PORT=5432
# ── JWT ────────────────────────────────────────────────
JWT_ACCESS_LIFETIME_DAYS=1
JWT_REFRESH_LIFETIME_DAYS=30
# ── CORS / CSRF ────────────────────────────────────────
# پشت یک دامنه‌ی واقعی هردو را با https:// پر کن (نه فقط اسم دامنه)؛
# CSRF_TRUSTED_ORIGINS لازم است وگرنه لاگین پنل ادمین با خطای CSRF رد می‌شود.
CORS_ALLOWED_ORIGINS=https://example.com,https://www.example.com
CORS_ALLOW_ALL_ORIGINS=False
CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
# ── پیامک OTP ──────────────────────────────────────────
# نام/کد قالبی که نزد سرویس‌دهنده‌ی پیامکت ثبت شده:
SMS_PATTERN_NAME=
# آدرسی که با POST، قالب+شماره+کد را برایش می‌فرستیم:
SMS_TOKEN_URL=
# ── ارسال گروهی چندکاناله (apps.messaging) ─────────────
# هرکدام خالی بماند، آن کانال فقط needs_manual برمی‌گرداند (صف deep-link
# سمت اپ) نه خطا — می‌توانی بعدا تک‌تک اضافه کنی.
# پیامک بالک با متن آزاد (کاوه‌نگار — جدا از SMS_PATTERN_NAME بالا که فقط OTP است)
KAVENEGAR_API_KEY=
KAVENEGAR_SENDER_LINE=
# WhatsApp Business Cloud API رسمی — نیاز به Template تاییدشده در Meta Business Manager
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_TEMPLATE_NAME=
# Bot API رسمی — فقط برای مخاطبینی که قبلا با بات چت را شروع کرده‌اند (opt-in)
TELEGRAM_BOT_TOKEN=
BALE_BOT_TOKEN=
EITAA_BOT_TOKEN=
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.env
/staticfiles/
/logs/
/.venv/
*.sqlite3
.idea/
+41
View File
@@ -0,0 +1,41 @@
# ============================================================
# Stage 1 — build: نصب وابستگی‌ها در یک virtualenv جدا
# ============================================================
FROM python:3.12-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
libpq-dev && \
rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ============================================================
# Stage 2 — runtime: فقط کتابخانه‌های اجرایی، بدون کامپایلر
# ============================================================
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libpq5 \
curl && \
rm -rf /var/lib/apt/lists/* && \
groupadd --system app && useradd --system --gid app --home-dir /app app
COPY --from=builder /opt/venv /opt/venv
COPY --chown=app:app . /app
RUN mkdir -p /app/staticfiles /app/logs && \
chown -R app:app /app/staticfiles /app/logs
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -f http://localhost:8000/admin/login/ || exit 1
# Gunicorn به‌عنوان فرمان پیش‌فرض
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
+207
View File
@@ -0,0 +1,207 @@
# پیامک‌بان — بک‌اند
بک‌اند DRF برای اپ پیامک‌بان: احراز هویت با شماره موبایل + کد یک‌بارمصرف (OTP)
و توکن JWT، به‌علاوه یک اندپوینت پشتیبان‌گیری تا مخاطبین/فیلدهای دلخواه/
گروه‌ها/قالب‌های پیام کاربر با تعویض گوشی از دست نرود.
## پشته‌ی فنی
- Django 5.1 + Django REST Framework
- djangorestframework-simplejwt (JWT، با blacklist برای logout واقعی)
- PostgreSQL
- کد تایید (OTP) با HMAC-SHA256 هش می‌شود و هرگز متن ساده در دیتابیس نیست
— دقیقا مثل الگویی که در `apps/account` پروژه‌ی فروشگاه استفاده شده،
چون این ماژول پلاگین توکار ندارد؛ به‌جای پکیج `django-otp` (که برای
دستگاه‌های TOTP/Authenticator ساخته شده، نه ارسال پیامک با پترن)، همان
الگوی OTP دستی و امتحان‌پس‌داده‌ی پروژه‌ی مرجع را پیاده کردم. اگر واقعا
خودِ پکیج `django-otp` را می‌خواهی، بگو تا جایگزینش کنم.
## ساختار
```
apps/account/ احراز هویت (User با شماره موبایل، OTP، JWT)
apps/banker/ مخاطبین، فیلدهای دلخواه per-group، گروه‌ها، قالب‌های پیام (CRUD + بکاپ)
apps/messaging/ ارسال گروهی چندکاناله (پیامک/واتساپ/تلگرام/بله/ایتا)
apps/bizapps/ ریزاپ‌های کسب‌وکار کوچک: مشتریان، دفتر درآمد/هزینه، انبار، نوبت‌دهی
config/ تنظیمات، urls، wsgi/asgi
```
### `apps/bizapps` — ریزاپ‌های کسب‌وکار کوچک
چهار مدل مستقل، هرکدام CRUD ساده‌ی خودش را دارد (مثل الگوی `apps/banker`:
id همان UUID تولیدشده در اپ فلاتر، رکورد به کاربر صاحبش محدود):
| مسیر | مدل |
|---|---|
| `/api/bizapps/customers/` | `Customer` — مشتریان |
| `/api/bizapps/ledger/` | `LedgerEntry` — دفتر درآمد و هزینه (`entry_type`: income/expense) |
| `/api/bizapps/products/` | `Product` — انبار (`quantity`, `low_stock_threshold`) |
| `/api/bizapps/appointments/` | `Appointment` — نوبت‌دهی (`status`: scheduled/done/cancelled) |
هرکدام `GET/POST` روی مسیر بالا و `GET/PATCH/DELETE` روی `<id>/`.
## راه‌اندازی
1. یک دیتابیس PostgreSQL از قبل آماده کن (این پروژه فقط Dockerfile دارد، نه
docker-compose — یعنی پستگری را خودت جدا بالا می‌آوری یا از یک سرویس
موجود استفاده می‌کنی).
2. فایل env بساز:
```bash
cp .env.example .env
```
و مقداردهی کن — نکته‌های مهم:
- `SECRET_KEY`: `python -c "import secrets; print(secrets.token_urlsafe(50))"`
- `DB_*`: اطلاعات همان پستگری که در مرحله‌ی ۱ آماده کردی
- `SMS_PATTERN_NAME` و `SMS_TOKEN_URL`: طبق پایین توضیح داده شده
3. ساخت ایمیج و اجرا:
```bash
docker build -t sms-banker-backend .
docker run -d --name sms-banker-backend \
--env-file .env \
-p 8000:8000 \
sms-banker-backend
```
اگر پستگری هم داخل داکر است (کانتینر جدا، بدون compose)، هر دو کانتینر
را در یک شبکه‌ی مشترک بگذار تا `DB_HOST` با نام کانتینر پستگری کار کند:
```bash
docker network create sms-banker-net
docker run -d --name sms-banker-db --network sms-banker-net \
-e POSTGRES_DB=sms_banker -e POSTGRES_USER=... -e POSTGRES_PASSWORD=... \
postgres:16
docker run -d --name sms-banker-backend --network sms-banker-net \
--env-file .env -p 8000:8000 sms-banker-backend
# و در .env: DB_HOST=sms-banker-db
```
ورود/خروج، migrate و collectstatic خودکار در `entrypoint.sh` هنگام
استارت کانتینر انجام می‌شود.
## پیامک OTP — فقط دو مقدار از .env
طبق خواسته‌ات، ماژول ارسال پیامک (`apps/account/send_sms.py`) فقط دو مقدار
را از `.env` می‌خواند:
- `SMS_PATTERN_NAME`: نام/کد قالبی که نزد سرویس‌دهنده‌ی پیامکت ثبت شده
- `SMS_TOKEN_URL`: آدرسی که یک درخواست `POST` با بدنه‌ی
`{"pattern": ..., "receptor": "09xxxxxxxxx", "token": "12345"}` برایش
فرستاده می‌شود
اگر ساختار واقعی سرویس‌دهنده‌ات با این payload فرق دارد (مثلا نیاز به
هدر Authorization یا شکل دیگری از پارامترها دارد)، فقط تابع
`_call_token_url` در همان فایل را با مستندات سرویس‌دهنده‌ات هماهنگ کن؛
بقیه‌ی پروژه فقط `send_otp_message(phone, code)` را صدا می‌زند و از این
جزئیات خبر ندارد.
## اندپوینت‌ها
### `apps/account` — احراز هویت
| متد | مسیر | توضیح |
|---|---|---|
| POST | `/api/account/auth/request-otp/` | ارسال کد تایید (کاربر جدید هم خودکار ساخته می‌شود) |
| POST | `/api/account/auth/verify-otp/` | تایید کد، دریافت `access`/`refresh` |
| POST | `/api/account/auth/token/refresh/` | گرفتن access token جدید از refresh token |
| POST | `/api/account/auth/logout/` | باطل کردن refresh token (نیاز به access token) |
| GET | `/api/account/auth/profile/` | دریافت پروفایل |
| PATCH | `/api/account/auth/profile/` | ویرایش پروفایل (فعلا فقط `name`) |
| DELETE | `/api/account/auth/profile/delete/` | حذف حساب — نیاز به تایید OTP در بدنه‌ی درخواست |
### `apps/banker` — پشتیبان‌گیری داده‌های اپ
| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/banker/backup/` | آخرین نسخه‌ی ذخیره‌شده (برای بازیابی روی گوشی جدید) |
| POST | `/api/banker/backup/` | جایگزینی کامل داده‌ی کاربر با آنچه از اپ فرستاده می‌شود |
هر دو، نیاز به هدر `Authorization: Bearer <access token>` دارند. بدنه‌ی
`POST` این شکلی است:
```json
{
"contact_fields": [{"id": "...", "group_id": "...", "label": "شماره گذرنامه", "sort_order": 0}],
"contacts": [{"id": "...", "name": "...", "phone": "...", "device_contact_id": null, "field_values": {"<field_id>": "پشمک"}, "channel_ids": {"telegram": "123456"}}],
"groups": [{"id": "...", "name": "تور", "color": 4283215696, "is_general": false, "member_ids": ["..."]}],
"templates": [{"id": "...", "group_id": null, "name": "...", "body": "سلام {نام}", "mode": "merge"}]
}
```
`id`ها همان UUID تولیدشده در خودِ اپ فلاتر هستند — منبع حقیقت خودِ اپ روی
گوشی است، این اندپوینت فقط بکاپ/ریستور می‌کند، نه sync دوطرفه‌ی هوشمند.
فیلدهای دلخواه و قالب‌ها اکنون **گروه‌محور**اند: هر `ContactField` به یک
`ContactGroup` تعلق دارد (مثلا گروه «تور» → فیلد «شماره گذرنامه»)، و هر
`MessageTemplate` یا به یک گروه تعلق دارد یا `group_id: null` (قالب عمومی —
فقط فیلدهای پایه‌ی نام/شماره). هر کاربر دقیقاً یک گروه با `is_general: true`
دارد که خودکار ساخته می‌شود و قابل حذف نیست.
### CRUD تکی (علاوه بر بکاپ بالا)
برای عملیات تکی (نه جایگزینی کامل)، به‌جای backup:
| متد | مسیر |
|---|---|
| GET/POST | `/api/banker/groups/` |
| GET/PATCH/DELETE | `/api/banker/groups/<id>/` |
| GET/POST | `/api/banker/groups/<group_id>/fields/` |
| GET/PATCH/DELETE | `/api/banker/fields/<id>/` |
| GET/POST | `/api/banker/contacts/` |
| GET/PATCH/DELETE | `/api/banker/contacts/<id>/` |
| GET/POST | `/api/banker/templates/` |
| GET/PATCH/DELETE | `/api/banker/templates/<id>/` |
### `apps/messaging` — ارسال گروهی چندکاناله
فقط از مسیرهای رسمی هر پلتفرم استفاده می‌شود: پیامک بالک از طریق کاوه‌نگار
(خودکار، سمت سرور)، واتساپ از طریق WhatsApp Business Cloud API رسمی،
تلگرام/بله/ایتا از طریق Bot API رسمی — فقط برای مخاطبینی که قبلا opt-in
کرده‌اند (چت با بات را شروع کرده‌اند، `Contact.channel_ids`). هر کانالی که
پیکربندی/opt-in نداشته باشد، به‌جای خطا یا دور زدن ضدهرزنامه‌ی پلتفرم،
`needs_manual` برمی‌گرداند تا اپ یک صف deep-link یک‌لمسی به کاربر نشان دهد.
| متد | مسیر | توضیح |
|---|---|---|
| POST | `/api/messaging/send/` | ساخت + dispatch فوری یک ارسال گروهی |
| GET | `/api/messaging/<id>/` | وضعیت/گزارش یک ارسال گروهی |
| POST | `/api/messaging/<bulk_id>/confirm/<recipient_id>/` | تایید ارسال دستی (بعد از تپ کاربر در واتساپ/تلگرام/بله/ایتا) |
بدنه‌ی `POST /send/`:
```json
{
"group_id": "...",
"contact_ids": [],
"template_id": "...",
"body_override": "",
"channels": ["sms", "whatsapp", "telegram", "bale", "eitaa"]
}
```
`group_id` خالی = همه‌ی مخاطبین کاربر. یا `template_id` بده یا `body_override`
(متن آزاد بدون قالب). env varهای مربوط به هر کانال در `.env.example`
مستندند؛ تا وقتی تنظیم نشوند، همان کانال فقط `needs_manual` برمی‌گرداند.
## نکته‌ی مهم — بدون Postgres واقعی تست شده
با `manage.py check` و `manage.py makemigrations` (بدون اتصال واقعی به
Postgres) تایید شد که مدل‌ها/اپ‌ها درست بار می‌شوند و مهاجرت‌های اولیه
(`0001_initial`) برای هر سه اپ ساخته شدند — این‌ها الان در ریپو هستند، نیازی
به `makemigrations` دوباره نیست مگر مدلی عوض شود. در همین بررسی یک باگ
از قبل موجود هم پیدا و رفع شد: `ContactGroup.user` بدون `related_name`
صریح با فیلد `groups` توکار Django (`PermissionsMixin`) تداخل داشت؛ الان
`related_name="contact_groups"` است.
قبل از اجرای واقعی روی سیستم خودت با یک Postgres واقعی:
```bash
pip install -r requirements.txt
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
```
View File
View File
+57
View File
@@ -0,0 +1,57 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.shortcuts import redirect
from .models import TermsAndConditions, User
@admin.register(User)
class UserAdmin(BaseUserAdmin):
model = User
list_display = ("phone", "name", "state", "gem_balance", "referral_code", "is_active", "is_staff", "created_at")
list_filter = ("state", "is_active", "is_staff")
search_fields = ("phone", "name", "referral_code")
ordering = ("-created_at",)
readonly_fields = (
"otp_code",
"otp_expiry",
"created_at",
"updated_at",
"last_login",
"gem_balance",
"referral_code",
"referral_bonus_granted",
)
autocomplete_fields = ["referred_by"]
fieldsets = (
(None, {"fields": ("phone", "password")}),
("اطلاعات شخصی", {"fields": ("name",)}),
("وضعیت", {"fields": ("state", "is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
(
"جم و معرفی",
{"fields": ("gem_balance", "trial_expires_at", "referral_code", "referred_by", "referral_bonus_granted")},
),
("OTP", {"fields": ("otp_code", "otp_expiry")}),
("تاریخ‌ها", {"fields": ("created_at", "updated_at", "last_login")}),
)
add_fieldsets = (
(None, {"classes": ("wide",), "fields": ("phone", "password1", "password2")}),
)
@admin.register(TermsAndConditions)
class TermsAndConditionsAdmin(admin.ModelAdmin):
"""تک‌ردیفی — لیست را رد می‌کنیم و مستقیم فرم ویرایش همان یک ردیف را نشان می‌دهیم."""
list_display = ("updated_at",)
def has_add_permission(self, request):
return not TermsAndConditions.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
def changelist_view(self, request, extra_context=None):
obj = TermsAndConditions.get_solo()
return redirect("admin:account_termsandconditions_change", obj.pk)
View File
+31
View File
@@ -0,0 +1,31 @@
from rest_framework_simplejwt.tokens import RefreshToken
from apps.account.models import User
class UserSelector:
@staticmethod
def get_user_profile(user: User) -> dict:
return {
"id": user.id,
"phone": user.phone,
"name": user.name,
"state": user.state,
"created_at": user.created_at,
}
@staticmethod
def get_tokens_for_user(user: User) -> dict:
refresh = RefreshToken.for_user(user)
return {
"refresh": str(refresh),
"access": str(refresh.access_token),
}
@staticmethod
def get_token_response(user: User) -> dict:
return {
"message": "ورود با موفقیت انجام شد.",
"tokens": UserSelector.get_tokens_for_user(user),
"user": UserSelector.get_user_profile(user),
}
+40
View File
@@ -0,0 +1,40 @@
from rest_framework import serializers
from apps.account.models import phone_validator
class RequestOTPSerializer(serializers.Serializer):
phone = serializers.CharField(max_length=15, required=True, label="شماره موبایل")
referral_code = serializers.CharField(max_length=8, required=False, allow_blank=True, label="کد معرف")
def validate_phone(self, value):
phone_validator(value)
return value
class VerifyOTPSerializer(serializers.Serializer):
phone = serializers.CharField(max_length=15, required=True, label="شماره موبایل")
otp = serializers.CharField(max_length=6, required=True, label="کد تایید")
def validate_phone(self, value):
phone_validator(value)
return value
def validate(self, attrs):
from .services import UserService
user = UserService.verify_user(attrs["phone"], attrs["otp"])
attrs["user"] = user
return attrs
class LogoutSerializer(serializers.Serializer):
refresh = serializers.CharField(required=True, label="توکن رفرش")
class ConfirmWithOTPSerializer(serializers.Serializer):
otp = serializers.CharField(max_length=6, required=True, label="کد تایید")
class ProfileUpdateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=100, allow_blank=True, required=False)
+135
View File
@@ -0,0 +1,135 @@
import hashlib
import hmac
import logging
import random
import string
from datetime import timedelta
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError as DRFValidationError
from apps.account.models import User, phone_validator
from apps.account.send_sms import send_otp_message
logger = logging.getLogger(__name__)
def _hash_otp(otp: str) -> str:
secret_key = settings.SECRET_KEY.encode("utf-8")
signature = hmac.new(secret_key, msg=otp.encode("utf-8"), digestmod=hashlib.sha256)
return signature.hexdigest()
class UserService:
OTP_EXPIRY_SECONDS = 120
OTP_LENGTH = 4
# فقط برای توسعه/تست — بدون نیاز به سرویس پیامک واقعی (که هنوز در .env
# تنظیم نشده)، کد تایید همیشه همین است. فقط زمانی که DEBUG=True باشد
# فعال است؛ در پروداکشن (DEBUG=False) خودکار به کد تصادفی واقعی برمی‌گردد
# — وگرنه هرکسی با دانستن شماره‌ی هرکس می‌توانست وارد حسابش شود.
DEV_FIXED_OTP = "1111"
@staticmethod
def _generate_otp() -> str:
if settings.DEBUG:
return UserService.DEV_FIXED_OTP
return "".join(random.choices(string.digits, k=UserService.OTP_LENGTH))
@staticmethod
def send_login_code(phone: str, referral_code: str | None = None) -> dict:
"""ساخت/یافتن کاربر بر اساس شماره و ارسال کد تایید"""
phone_validator(phone)
with transaction.atomic():
user, created = User.objects.get_or_create(
phone=phone,
defaults={"state": User.State.INACTIVE, "is_active": True},
)
if created and referral_code:
from apps.billing.api.services import ReferralService
ReferralService.link_referrer(user, referral_code)
if not created and user.state == User.State.BLOCKED:
raise DRFValidationError({"phone": "حساب کاربری مسدود شده است"})
if not created and user.otp_code and user.otp_expiry and timezone.now() < user.otp_expiry:
remaining = (user.otp_expiry - timezone.now()).seconds
raise DRFValidationError(
{"phone": f"کد تایید قبلی هنوز معتبر است. {remaining} ثانیه دیگر منقضی می‌شود."}
)
otp = UserService._generate_otp()
user.otp_code = _hash_otp(otp)
user.otp_expiry = timezone.now() + timedelta(seconds=UserService.OTP_EXPIRY_SECONDS)
user.save(update_fields=["otp_code", "otp_expiry"])
send_otp_message(phone, otp)
logger.info(f"OTP requested for {phone}")
return {"message": "کد تایید با موفقیت ارسال شد"}
@staticmethod
def verify_user(phone: str, otp: str) -> User:
try:
user = User.objects.get(phone=phone)
except User.DoesNotExist:
raise DRFValidationError({"phone": "کاربر یافت نشد"})
if not user.otp_code or not user.otp_expiry:
raise DRFValidationError({"otp": "کد تایید درخواست نشده است"})
if timezone.now() > user.otp_expiry:
user.otp_code = None
user.otp_expiry = None
user.save(update_fields=["otp_code", "otp_expiry"])
raise DRFValidationError({"otp": "کد تایید منقضی شده است"})
if not hmac.compare_digest(user.otp_code, _hash_otp(otp.strip())):
raise DRFValidationError({"otp": "کد وارد شده نامعتبر است"})
user.state = User.State.ACTIVE
user.last_login = timezone.now()
user.otp_code = None
user.otp_expiry = None
user.save(update_fields=["state", "last_login", "otp_code", "otp_expiry"])
from apps.billing.api.services import ReferralService
ReferralService.grant_bonus_if_needed(user)
logger.info(f"User {user.phone} verified successfully")
return user
@staticmethod
def verify_otp_for_action(user: User, otp: str) -> bool:
"""تایید OTP برای عملیات حساس (مثل حذف حساب)"""
if not user.otp_code or not user.otp_expiry:
raise DRFValidationError({"otp": "ابتدا کد تایید درخواست کنید"})
if timezone.now() > user.otp_expiry:
user.otp_code = None
user.otp_expiry = None
user.save(update_fields=["otp_code", "otp_expiry"])
raise DRFValidationError({"otp": "کد تایید منقضی شده است"})
if not hmac.compare_digest(user.otp_code, _hash_otp(otp.strip())):
raise DRFValidationError({"otp": "کد وارد شده نامعتبر است"})
user.otp_code = None
user.otp_expiry = None
user.save(update_fields=["otp_code", "otp_expiry"])
return True
@staticmethod
def update_user_profile(user: User, validated_data: dict) -> User:
update_fields = []
for key in ("name",):
if key in validated_data:
setattr(user, key, validated_data[key])
update_fields.append(key)
if update_fields:
user.save(update_fields=update_fields)
return user
+25
View File
@@ -0,0 +1,25 @@
from rest_framework.throttling import SimpleRateThrottle
class PhoneRateThrottle(SimpleRateThrottle):
"""محدودیت درخواست OTP بر اساس شماره موبایل (نه IP) تا با یک شماره ثابت اسپم نشود"""
scope = "otp_send"
def get_cache_key(self, request, view):
phone = request.data.get("phone")
if not phone:
return None
return self.cache_format % {"scope": self.scope, "ident": phone}
class OTPVerifyThrottle(SimpleRateThrottle):
"""محدودیت تلاش برای تایید OTP بر اساس شماره موبایل، برای جلوگیری از brute force"""
scope = "otp_verify"
def get_cache_key(self, request, view):
phone = request.data.get("phone")
if not phone:
return None
return self.cache_format % {"scope": self.scope, "ident": phone}
+100
View File
@@ -0,0 +1,100 @@
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from apps.account.models import TermsAndConditions
from .selectors import UserSelector
from .serializers import (
ConfirmWithOTPSerializer,
LogoutSerializer,
ProfileUpdateSerializer,
RequestOTPSerializer,
VerifyOTPSerializer,
)
from .services import UserService
from .throttles import OTPVerifyThrottle, PhoneRateThrottle
class RequestOTPView(APIView):
"""ارسال کد تایید به شماره موبایل (کاربر جدید هم همینجا خودکار ساخته می‌شود)"""
permission_classes = [AllowAny]
throttle_classes = [PhoneRateThrottle]
def post(self, request):
serializer = RequestOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
result = UserService.send_login_code(
serializer.validated_data["phone"], referral_code=serializer.validated_data.get("referral_code")
)
return Response(result, status=status.HTTP_200_OK)
class TermsView(APIView):
"""متن قوانین و مقررات — قبل از لاگین هم باید قابل‌خواندن باشد."""
permission_classes = [AllowAny]
def get(self, request):
return Response({"content": TermsAndConditions.get_solo().content})
class VerifyOTPView(APIView):
"""تایید کد و دریافت توکن‌های JWT"""
permission_classes = [AllowAny]
throttle_classes = [OTPVerifyThrottle]
def post(self, request):
serializer = VerifyOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data["user"]
return Response(UserSelector.get_token_response(user), status=status.HTTP_200_OK)
class LogoutView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = LogoutSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
RefreshToken(serializer.validated_data["refresh"]).blacklist()
except Exception:
pass
return Response({"message": "خروج با موفقیت انجام شد."})
class ProfileView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
return Response({"success": True, "data": UserSelector.get_user_profile(request.user)})
def patch(self, request):
serializer = ProfileUpdateSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
user = UserService.update_user_profile(request.user, serializer.validated_data)
return Response(
{
"success": True,
"message": "پروفایل با موفقیت بروزرسانی شد.",
"data": UserSelector.get_user_profile(user),
}
)
class ProfileDeleteView(APIView):
"""حذف حساب کاربری — نیاز به تایید OTP دارد (اول /auth/request-otp/ بزن)"""
permission_classes = [IsAuthenticated]
def delete(self, request):
serializer = ConfirmWithOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
UserService.verify_otp_for_action(request.user, serializer.validated_data["otp"])
request.user.delete()
return Response({"message": "حساب کاربری با موفقیت حذف شد."})
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccountConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.account"
verbose_name = "حساب کاربری"
+41
View File
@@ -0,0 +1,41 @@
# Generated by Django 5.1.15 on 2026-08-22 15:31
import apps.account.models
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('phone', models.CharField(max_length=11, unique=True, validators=[apps.account.models.phone_validator], verbose_name='شماره موبایل')),
('name', models.CharField(blank=True, max_length=100, verbose_name='نام')),
('state', models.CharField(choices=[('inactive', 'غیرفعال'), ('active', 'فعال'), ('blocked', 'مسدود شده')], default='inactive', max_length=10, verbose_name='وضعیت کاربر')),
('otp_code', models.CharField(blank=True, max_length=128, null=True, verbose_name='کد OTP (هش\u200cشده)')),
('otp_expiry', models.DateTimeField(blank=True, null=True, verbose_name='انقضای OTP')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_staff', models.BooleanField(default=False, verbose_name='کارمند')),
('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='تاریخ ایجاد')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'کاربر',
'verbose_name_plural': 'کاربران',
},
),
]
@@ -0,0 +1,40 @@
# Generated by Django 5.1.15 on 2026-08-23 12:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='gem_balance',
field=models.PositiveIntegerField(default=0, verbose_name='موجودی جم'),
),
migrations.AddField(
model_name='user',
name='referral_bonus_granted',
field=models.BooleanField(default=False, verbose_name='پاداش معرفی داده\u200cشد'),
),
migrations.AddField(
model_name='user',
name='referral_code',
field=models.CharField(blank=True, max_length=8, unique=True, verbose_name='کد معرف'),
),
migrations.AddField(
model_name='user',
name='referred_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referrals', to=settings.AUTH_USER_MODEL, verbose_name='معرف'),
),
migrations.AddField(
model_name='user',
name='trial_expires_at',
field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='پایان دوره\u200cی رایگان'),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.1.15 on 2026-08-23 20:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0002_user_gem_balance_user_referral_bonus_granted_and_more'),
]
operations = [
migrations.CreateModel(
name='TermsAndConditions',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, help_text='این متن در دیالوگ «قوانین و مقررات» صفحه\u200cی ورود نشان داده می\u200cشود.', verbose_name='متن قوانین و مقررات')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'قوانین و مقررات',
'verbose_name_plural': 'قوانین و مقررات',
},
),
]
View File
+184
View File
@@ -0,0 +1,184 @@
import random
import string
from datetime import timedelta
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
TRIAL_DAYS = 7
_REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
# -------------------- Manager --------------------
class UserManager(BaseUserManager):
def create_user(self, phone, password=None, **extra_fields):
if not phone:
raise ValueError(_("شماره موبایل الزامی است"))
phone = self.normalize_phone(phone)
user = self.model(phone=phone, **extra_fields)
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.save(using=self._db)
return user
def create_superuser(self, phone, password, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("is_active", True)
extra_fields.setdefault("state", User.State.ACTIVE)
if not password:
raise ValueError(_("سوپریوزر باید رمز عبور داشته باشد"))
return self.create_user(phone, password, **extra_fields)
@staticmethod
def normalize_phone(phone: str) -> str:
phone = phone.strip().replace(" ", "").replace("-", "")
if phone.startswith("+98"):
phone = "0" + phone[3:]
elif phone.startswith("98") and len(phone) == 12:
phone = "0" + phone[2:]
return phone
# -------------------- Validators --------------------
def phone_validator(phone: str):
"""شماره موبایل: فقط عدد، شروع با 09، طول ۱۱ رقم"""
if not phone.isdigit():
raise ValidationError(_("شماره موبایل باید فقط شامل اعداد باشد"))
if not phone.startswith("09"):
raise ValidationError(_("شماره موبایل باید با 09 شروع شود"))
if len(phone) != 11:
raise ValidationError(_("شماره موبایل باید ۱۱ رقم باشد"))
return phone
# -------------------- User Model --------------------
class User(AbstractBaseUser, PermissionsMixin):
class State(models.TextChoices):
INACTIVE = "inactive", _("غیرفعال")
ACTIVE = "active", _("فعال")
BLOCKED = "blocked", _("مسدود شده")
phone = models.CharField(
max_length=11,
unique=True,
validators=[phone_validator],
verbose_name=_("شماره موبایل"),
)
name = models.CharField(max_length=100, blank=True, verbose_name=_("نام"))
state = models.CharField(
max_length=10,
choices=State.choices,
default=State.INACTIVE,
verbose_name=_("وضعیت کاربر"),
)
# OTP — کد به‌صورت هش‌شده ذخیره می‌شود، هرگز متن ساده
otp_code = models.CharField(max_length=128, blank=True, null=True, verbose_name=_("کد OTP (هش‌شده)"))
otp_expiry = models.DateTimeField(blank=True, null=True, verbose_name=_("انقضای OTP"))
is_active = models.BooleanField(default=True, verbose_name=_("فعال"))
is_staff = models.BooleanField(default=False, verbose_name=_("کارمند"))
created_at = models.DateTimeField(default=timezone.now, verbose_name=_("تاریخ ایجاد"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("تاریخ بروزرسانی"))
# -------------------- جم / اشتراک / معرفی --------------------
gem_balance = models.PositiveIntegerField(default=0, verbose_name=_("موجودی جم"))
trial_expires_at = models.DateTimeField(
default=None, null=True, blank=True, verbose_name=_("پایان دوره‌ی رایگان")
)
referral_code = models.CharField(
max_length=8, unique=True, blank=True, verbose_name=_("کد معرف")
)
referred_by = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="referrals",
verbose_name=_("معرف"),
)
# جلوگیری از دوبار پاداش‌دادن وقتی همان کاربر چندبار verify می‌شود —
# پاداشِ معرف فقط یک‌بار، دقیقا سر اولین verify موفق کاربر جدید.
referral_bonus_granted = models.BooleanField(default=False, verbose_name=_("پاداش معرفی داده‌شد"))
objects = UserManager()
USERNAME_FIELD = "phone"
REQUIRED_FIELDS = []
class Meta:
verbose_name = _("کاربر")
verbose_name_plural = _("کاربران")
def __str__(self):
return f"{self.phone} - {self.name}".strip(" -")
def save(self, *args, **kwargs):
if not self.referral_code:
self.referral_code = self._generate_referral_code()
if self.trial_expires_at is None and self._state.adding:
self.trial_expires_at = timezone.now() + timedelta(days=TRIAL_DAYS)
super().save(*args, **kwargs)
@staticmethod
def _generate_referral_code() -> str:
for _attempt in range(20):
code = "".join(random.choices(_REFERRAL_CODE_ALPHABET, k=6))
if not User.objects.filter(referral_code=code).exists():
return code
# عملا در این مقیاس هرگز رخ نمی‌دهد؛ فقط یک fallback امن
return "".join(random.choices(_REFERRAL_CODE_ALPHABET, k=8))
@property
def is_in_trial(self) -> bool:
return bool(self.trial_expires_at and timezone.now() < self.trial_expires_at)
def activate(self):
self.state = self.State.ACTIVE
self.is_active = True
self.otp_code = None
self.otp_expiry = None
self.save(update_fields=["state", "is_active", "otp_code", "otp_expiry"])
def block(self):
self.state = self.State.BLOCKED
self.is_active = False
self.save(update_fields=["state", "is_active"])
class TermsAndConditions(models.Model):
"""متن قوانین و مقررات — تک‌ردیفی (singleton)، از پنل ادمین ویرایش
می‌شود و صفحه‌ی ورود قبل از لاگین آن را می‌خواند (بدون نیاز به توکن)."""
content = models.TextField(
blank=True, verbose_name=_("متن قوانین و مقررات"), help_text=_("این متن در دیالوگ «قوانین و مقررات» صفحه‌ی ورود نشان داده می‌شود.")
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("قوانین و مقررات")
verbose_name_plural = _("قوانین و مقررات")
def __str__(self):
return "قوانین و مقررات"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_solo(cls) -> "TermsAndConditions":
obj, _created = cls.objects.get_or_create(pk=1)
return obj
+59
View File
@@ -0,0 +1,59 @@
# apps/account/send_sms.py
"""
ارسال پیامک OTP.
فقط دو مقدار از .env خوانده می‌شود (تنظیمات در config/settings.py):
- SMS_PATTERN_NAME: نام/کد قالبی که نزد سرویس‌دهنده‌ی پیامک ثبت شده
- SMS_TOKEN_URL: آدرسی که با یک درخواست POST، قالب+شماره+کد را برایش
می‌فرستیم (سرویس‌دهنده‌ی تو باید چنین آدرسی در اختیارت گذاشته باشد)
اگر ساختار درخواست سرویس‌دهنده‌ی واقعی‌ات با payload زیر فرق دارد، فقط
تابع `_call_token_url` را با مستندات سرویس‌دهنده‌ات هماهنگ کن؛ بقیه‌ی
پروژه با این ماژول کاری ندارد و فقط send_otp_message را صدا می‌زند.
"""
import logging
from threading import Thread
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT_SECONDS = 10
def _call_token_url(phone: str, code: str) -> bool:
token_url = settings.SMS_TOKEN_URL
pattern_name = settings.SMS_PATTERN_NAME
if not token_url or not pattern_name:
logger.error("SMS_TOKEN_URL یا SMS_PATTERN_NAME در .env تنظیم نشده است")
return False
payload = {
"pattern": pattern_name,
"receptor": phone,
"token": code,
}
try:
response = requests.post(token_url, json=payload, timeout=REQUEST_TIMEOUT_SECONDS)
response.raise_for_status()
logger.info(f"✅ OTP sent to {phone} via {token_url}")
return True
except requests.RequestException as exc:
logger.error(f"❌ خطا در ارسال پیامک به {phone}: {exc}")
return False
def send_otp_message(phone: str, code: str) -> None:
"""ارسال غیربلاکینگ کد تایید (شکست ارسال، درخواست HTTP کاربر را کند نمی‌کند)"""
def _send():
try:
_call_token_url(phone, code)
except Exception as exc: # noqa: BLE001 - لاگ و رد شدن، نباید کل ترد را بترکاند
logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}")
Thread(target=_send, daemon=True).start()
+15
View File
@@ -0,0 +1,15 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView
from .api import views
app_name = "account"
urlpatterns = [
path("terms/", views.TermsView.as_view(), name="terms"),
path("auth/request-otp/", views.RequestOTPView.as_view(), name="request-otp"),
path("auth/verify-otp/", views.VerifyOTPView.as_view(), name="verify-otp"),
path("auth/token/refresh/", TokenRefreshView.as_view(), name="token-refresh"),
path("auth/logout/", views.LogoutView.as_view(), name="logout"),
path("auth/profile/", views.ProfileView.as_view(), name="profile"),
path("auth/profile/delete/", views.ProfileDeleteView.as_view(), name="profile-delete"),
]
View File
+49
View File
@@ -0,0 +1,49 @@
from django.contrib import admin
from .models import (
Contact,
ContactField,
ContactGroup,
MessageTemplate,
StructuredTemplate,
StructuredTemplateField,
)
@admin.register(ContactGroup)
class ContactGroupAdmin(admin.ModelAdmin):
list_display = ("name", "user", "is_general", "updated_at")
list_filter = ("is_general",)
search_fields = ("name", "user__phone")
@admin.register(ContactField)
class ContactFieldAdmin(admin.ModelAdmin):
list_display = ("label", "group", "user", "sort_order", "updated_at")
list_filter = ("group",)
search_fields = ("label", "user__phone", "group__name")
@admin.register(Contact)
class ContactAdmin(admin.ModelAdmin):
list_display = ("name", "phone", "user", "updated_at")
search_fields = ("name", "phone", "user__phone")
@admin.register(MessageTemplate)
class MessageTemplateAdmin(admin.ModelAdmin):
list_display = ("name", "mode", "group", "user", "updated_at")
list_filter = ("mode", "group")
search_fields = ("name", "user__phone")
class StructuredTemplateFieldInline(admin.TabularInline):
model = StructuredTemplateField
extra = 1
@admin.register(StructuredTemplate)
class StructuredTemplateAdmin(admin.ModelAdmin):
list_display = ("name", "user", "updated_at")
search_fields = ("name", "user__phone")
inlines = [StructuredTemplateFieldInline]
View File
+79
View File
@@ -0,0 +1,79 @@
from rest_framework import serializers
from apps.banker.models import (
Contact,
ContactField,
ContactGroup,
MessageTemplate,
StructuredTemplate,
StructuredTemplateField,
)
# سریالایزرهای CRUD واقعی (create/update/delete تکی) — برخلاف سریالایزرهای
# backup در serializers.py، این‌ها اعتبارسنجی یکتابودن id فعال دارند چون هر
# رکورد فقط یک‌بار create می‌شود، نه هر بار جایگزین کامل.
class ContactGroupCrudSerializer(serializers.ModelSerializer):
is_general = serializers.BooleanField(read_only=True)
class Meta:
model = ContactGroup
fields = ["id", "name", "color", "is_general", "member_ids"]
class ContactFieldCrudSerializer(serializers.ModelSerializer):
class Meta:
model = ContactField
fields = ["id", "group", "label", "sort_order"]
def validate_group(self, group: ContactGroup) -> ContactGroup:
# قبلا گروه عمومی فیلد سفارشی نداشت (برای کاربرانی که نیاز به
# پیچیدگی نمی‌خواستند)؛ چون الان کل مفهوم «گروه» از UX برداشته شده
# و همه‌چیز روی همان یک گروه عمومی کار می‌کند، این محدودیت دیگر
# معنی ندارد و مانع تعریف هر فیلدی می‌شد.
request = self.context["request"]
if group.user_id != request.user.id:
raise serializers.ValidationError("این گروه متعلق به شما نیست.")
return group
class ContactCrudSerializer(serializers.ModelSerializer):
class Meta:
model = Contact
fields = ["id", "name", "phone", "device_contact_id", "field_values", "channel_ids"]
class MessageTemplateCrudSerializer(serializers.ModelSerializer):
class Meta:
model = MessageTemplate
fields = ["id", "group", "name", "body", "mode"]
def validate_group(self, group: ContactGroup | None) -> ContactGroup | None:
if group is None:
return group
request = self.context["request"]
if group.user_id != request.user.id:
raise serializers.ValidationError("این گروه متعلق به شما نیست.")
return group
class StructuredTemplateFieldCrudSerializer(serializers.ModelSerializer):
class Meta:
model = StructuredTemplateField
fields = ["id", "template", "label", "sort_order"]
def validate_template(self, template: StructuredTemplate) -> StructuredTemplate:
request = self.context["request"]
if template.user_id != request.user.id:
raise serializers.ValidationError("این قالب متعلق به شما نیست.")
return template
class StructuredTemplateCrudSerializer(serializers.ModelSerializer):
fields = StructuredTemplateFieldCrudSerializer(many=True, read_only=True)
class Meta:
model = StructuredTemplate
fields = ["id", "name", "fields", "updated_at"]
read_only_fields = ["updated_at"]
+47
View File
@@ -0,0 +1,47 @@
from rest_framework import serializers
from apps.banker.models import Contact, ContactField, ContactGroup, MessageTemplate
# نکته: id هر رکورد را خودِ اپ (UUID) تولید می‌کند و روی سرور هم همان مقدار
# استفاده می‌شود. چون هر بار /backup/ کل رکوردهای کاربر جایگزین می‌شوند (نه
# create تکی)، اعتبارسنج یکتابودنِ خودکار DRF روی id باید خاموش شود؛ وگرنه
# از دومین بکاپ به بعد چون همان idها از قبل در دیتابیس هست، validation رد
# می‌شود.
_ID_NO_UNIQUE_CHECK = {"id": {"validators": []}}
class ContactGroupSerializer(serializers.ModelSerializer):
class Meta:
model = ContactGroup
fields = ["id", "name", "color", "is_general", "member_ids"]
extra_kwargs = _ID_NO_UNIQUE_CHECK
class ContactFieldSerializer(serializers.ModelSerializer):
class Meta:
model = ContactField
fields = ["id", "group_id", "label", "sort_order"]
extra_kwargs = _ID_NO_UNIQUE_CHECK
class ContactSerializer(serializers.ModelSerializer):
class Meta:
model = Contact
fields = ["id", "name", "phone", "device_contact_id", "field_values", "channel_ids"]
extra_kwargs = _ID_NO_UNIQUE_CHECK
class MessageTemplateSerializer(serializers.ModelSerializer):
class Meta:
model = MessageTemplate
fields = ["id", "group_id", "name", "body", "mode"]
extra_kwargs = _ID_NO_UNIQUE_CHECK
class BackupSerializer(serializers.Serializer):
"""عکس کامل وضعیت اپ روی یک کاربر — برای پشتیبان‌گیری/بازیابی"""
contact_fields = ContactFieldSerializer(many=True, required=False, default=list)
contacts = ContactSerializer(many=True, required=False, default=list)
groups = ContactGroupSerializer(many=True, required=False, default=list)
templates = MessageTemplateSerializer(many=True, required=False, default=list)
+74
View File
@@ -0,0 +1,74 @@
from django.db import transaction
from apps.account.models import User
from apps.banker.models import Contact, ContactField, ContactGroup, MessageTemplate
class GroupService:
@staticmethod
def get_or_create_general(user: User) -> ContactGroup:
"""گروه عمومی کاربر را برمی‌گرداند؛ اگر هنوز ندارد، می‌سازد.
هر کاربر دقیقاً یک گروه با is_general=True دارد — بدون فیلد سفارشی،
برای کاربرانی که نیاز به پیچیدگی ندارند.
"""
import uuid
general = ContactGroup.objects.filter(user=user, is_general=True).first()
if general:
return general
return ContactGroup.objects.create(
id=str(uuid.uuid4()),
user=user,
name="عمومی",
color=0xFF667EEA,
is_general=True,
)
class BackupService:
@staticmethod
def build_snapshot(user: User) -> dict:
return {
"contact_fields": list(
ContactField.objects.filter(user=user).values("id", "group_id", "label", "sort_order")
),
"contacts": list(
Contact.objects.filter(user=user).values(
"id", "name", "phone", "device_contact_id", "field_values", "channel_ids"
)
),
"groups": list(
ContactGroup.objects.filter(user=user).values(
"id", "name", "color", "is_general", "member_ids"
)
),
"templates": list(
MessageTemplate.objects.filter(user=user).values("id", "group_id", "name", "body", "mode")
),
}
@staticmethod
@transaction.atomic
def replace_snapshot(user: User, data: dict) -> dict:
"""جایگزینی کامل دیتای کاربر روی سرور با آنچه از اپ فرستاده شده (منبع حقیقت، خودِ اپ است)"""
ContactField.objects.filter(user=user).delete()
Contact.objects.filter(user=user).delete()
MessageTemplate.objects.filter(user=user).delete()
ContactGroup.objects.filter(user=user).delete()
groups = list(data.get("groups", []))
if groups:
ContactGroup.objects.bulk_create(ContactGroup(user=user, **item) for item in groups)
if not any(g.get("is_general") for g in groups):
GroupService.get_or_create_general(user)
ContactField.objects.bulk_create(
ContactField(user=user, **item) for item in data.get("contact_fields", [])
)
Contact.objects.bulk_create(Contact(user=user, **item) for item in data.get("contacts", []))
MessageTemplate.objects.bulk_create(
MessageTemplate(user=user, **item) for item in data.get("templates", [])
)
return BackupService.build_snapshot(user)
+202
View File
@@ -0,0 +1,202 @@
from rest_framework import generics, status
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.banker.models import (
Contact,
ContactField,
ContactGroup,
MessageTemplate,
StructuredTemplate,
StructuredTemplateField,
)
from apps.billing.permissions import HasActiveFeature
from .crud_serializers import (
ContactCrudSerializer,
ContactFieldCrudSerializer,
ContactGroupCrudSerializer,
MessageTemplateCrudSerializer,
StructuredTemplateCrudSerializer,
StructuredTemplateFieldCrudSerializer,
)
from .serializers import BackupSerializer
from .services import BackupService, GroupService
class BackupView(APIView):
"""
پشتیبان‌گیری/بازیابی کامل مخاطبین، فیلدهای دلخواه، گروه‌ها و قالب‌های کاربر.
GET -> آخرین نسخه‌ی ذخیره‌شده روی سرور را برمی‌گرداند (برای بازیابی روی گوشی جدید)
POST -> دیتای فعلی اپ را می‌فرستد و کامل جایگزین نسخه‌ی قبلی روی سرور می‌شود
(منبع حقیقت خود اپ روی گوشی است، نه سرور)
"""
# عمداً HasActiveFeature ندارد — بک‌آپ صادرات/وارد کردن خامِ داده‌ی خود
# کاربر است (مثلا موقع عوض‌کردن گوشی)، نه استفاده از ریزاپ؛ گروگان‌گرفتن
# دیتای کاربر پشت پولی‌بودن اشتراک درست نیست.
permission_classes = [IsAuthenticated]
throttle_scope = "backup"
def get(self, request):
snapshot = BackupService.build_snapshot(request.user)
return Response({"success": True, "data": snapshot})
def post(self, request):
serializer = BackupSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
snapshot = BackupService.replace_snapshot(request.user, serializer.validated_data)
return Response(
{"success": True, "message": "پشتیبان‌گیری با موفقیت انجام شد.", "data": snapshot},
status=status.HTTP_200_OK,
)
# -------------------- CRUD واقعی (تکی، نه replace کامل) --------------------
# همه‌ی ویوهای زیر روی request.user فیلتر می‌شوند و مالکیت رکورد را
# مستقل از ورودی کاربر تضمین می‌کنند.
class ContactGroupListCreateView(generics.ListCreateAPIView):
"""GET: لیست گروه‌های کاربر (گروه عمومی هم اگر نبود ساخته می‌شود). POST: گروه جدید."""
serializer_class = ContactGroupCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
GroupService.get_or_create_general(self.request.user)
return ContactGroup.objects.filter(user=self.request.user)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class ContactGroupDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = ContactGroupCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
return ContactGroup.objects.filter(user=self.request.user)
def perform_destroy(self, instance):
if instance.is_general:
raise ValidationError({"detail": "گروه عمومی قابل حذف نیست."})
instance.delete()
class GroupFieldListCreateView(generics.ListCreateAPIView):
"""فیلدهای سفارشی یک گروه مشخص — /groups/<group_id>/fields/"""
serializer_class = ContactFieldCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
return ContactField.objects.filter(
user=self.request.user, group_id=self.kwargs["group_id"]
)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class ContactFieldDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = ContactFieldCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
return ContactField.objects.filter(user=self.request.user)
class ContactListCreateView(generics.ListCreateAPIView):
serializer_class = ContactCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
return Contact.objects.filter(user=self.request.user)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class ContactDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = ContactCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
return Contact.objects.filter(user=self.request.user)
class MessageTemplateListCreateView(generics.ListCreateAPIView):
serializer_class = MessageTemplateCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
return MessageTemplate.objects.filter(user=self.request.user)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class MessageTemplateDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = MessageTemplateCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
return MessageTemplate.objects.filter(user=self.request.user)
class StructuredTemplateListCreateView(generics.ListCreateAPIView):
serializer_class = StructuredTemplateCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
return StructuredTemplate.objects.filter(user=self.request.user)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class StructuredTemplateDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = StructuredTemplateCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
return StructuredTemplate.objects.filter(user=self.request.user)
class StructuredTemplateFieldListCreateView(generics.ListCreateAPIView):
"""فیلدهای یک قالب کلید-مقدار مشخص — /structured-templates/<template_id>/fields/"""
serializer_class = StructuredTemplateFieldCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
return StructuredTemplateField.objects.filter(
template__user=self.request.user, template_id=self.kwargs["template_id"]
)
def perform_create(self, serializer):
serializer.save()
class StructuredTemplateFieldDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = StructuredTemplateFieldCrudSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get_queryset(self):
return StructuredTemplateField.objects.filter(template__user=self.request.user)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BankerConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.banker"
verbose_name = "داده‌های پیامک‌بان"
+89
View File
@@ -0,0 +1,89 @@
# Generated by Django 5.1.15 on 2026-08-22 15:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Contact',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, verbose_name='نام')),
('phone', models.CharField(max_length=20, verbose_name='شماره موبایل')),
('device_contact_id', models.CharField(blank=True, max_length=100, null=True)),
('field_values', models.JSONField(blank=True, default=dict, verbose_name='مقادیر فیلدهای دلخواه')),
('channel_ids', models.JSONField(blank=True, default=dict, verbose_name='شناسه\u200cی کانال\u200cها')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'مخاطب',
'verbose_name_plural': 'مخاطبین',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ContactGroup',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, verbose_name='نام گروه')),
('color', models.BigIntegerField(verbose_name='رنگ (ARGB)')),
('is_general', models.BooleanField(default=False, help_text='گروه پیش\u200cفرض بدون فیلد سفارشی؛ هر کاربر دقیقاً یکی از این\u200cها دارد.', verbose_name='گروه عمومی')),
('member_ids', models.JSONField(blank=True, default=list, verbose_name='اعضا')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_groups', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'گروه',
'verbose_name_plural': 'گروه\u200cها',
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='ContactField',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('label', models.CharField(max_length=100, verbose_name='عنوان فیلد')),
('sort_order', models.PositiveIntegerField(default=0)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_fields', to=settings.AUTH_USER_MODEL)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='banker.contactgroup')),
],
options={
'verbose_name': 'فیلد دلخواه مخاطب',
'verbose_name_plural': 'فیلدهای دلخواه مخاطبین',
'ordering': ['group', 'sort_order'],
},
),
migrations.CreateModel(
name='MessageTemplate',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, verbose_name='نام قالب')),
('body', models.TextField(verbose_name='متن قالب')),
('mode', models.CharField(choices=[('merge', 'قالب\u200cبندی'), ('quick', 'تمپلیت آماده')], max_length=10)),
('updated_at', models.DateTimeField(auto_now=True)),
('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='templates', to='banker.contactgroup')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='templates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'قالب پیام',
'verbose_name_plural': 'قالب\u200cهای پیام',
'ordering': ['-updated_at'],
},
),
migrations.AddConstraint(
model_name='contactgroup',
constraint=models.UniqueConstraint(condition=models.Q(('is_general', True)), fields=('user',), name='one_general_group_per_user'),
),
]
@@ -0,0 +1,44 @@
# Generated by Django 5.1.15 on 2026-08-23 21:04
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('banker', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StructuredTemplate',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, verbose_name='نام قالب')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='structured_templates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'قالب کلید-مقدار',
'verbose_name_plural': 'قالب\u200cهای کلید-مقدار',
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='StructuredTemplateField',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('label', models.CharField(max_length=100, verbose_name='عنوان')),
('sort_order', models.PositiveIntegerField(default=0)),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='banker.structuredtemplate')),
],
options={
'verbose_name': 'فیلد قالب کلید-مقدار',
'verbose_name_plural': 'فیلدهای قالب کلید-مقدار',
'ordering': ['template', 'sort_order'],
},
),
]
View File
+148
View File
@@ -0,0 +1,148 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# این مدل‌ها معادل دقیق دیتابیس محلی (SQLite) اپ فلاتر هستند تا اطلاعات
# مخاطبین/گروه‌ها/قالب‌های کاربر روی سرور هم پشتیبان‌گیری شود و با تعویض
# گوشی از دست نرود. آی‌دی‌ها همان UUID تولیدشده در خود اپ هستند (نه
# auto-increment) تا هماهنگی هنگام بکاپ/ریستور ساده باشد.
#
# فیلدهای دلخواه (ContactField) و قالب‌ها (MessageTemplate) به یک گروه
# (ContactGroup) تعلق دارند — نه سراسری. مثال: گروه «تور» فیلد «شماره
# گذرنامه» دارد، گروه دیگری «کد ملی». هر کاربر دقیقاً یک گروه با
# is_general=True دارد که فیلد سفارشی ندارد و قالب‌های آن فقط از فیلدهای
# پایه (نام/شماره) استفاده می‌کنند — برای کاربرانی که نیاز به پیچیدگی ندارند.
class ContactGroup(models.Model):
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="contact_groups")
name = models.CharField(max_length=150, verbose_name=_("نام گروه"))
color = models.BigIntegerField(verbose_name=_("رنگ (ARGB)"))
is_general = models.BooleanField(
default=False,
verbose_name=_("گروه عمومی"),
help_text=_("گروه پیش‌فرض بدون فیلد سفارشی؛ هر کاربر دقیقاً یکی از این‌ها دارد."),
)
# لیستی از contact.id های عضو این گروه
member_ids = models.JSONField(default=list, blank=True, verbose_name=_("اعضا"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("گروه")
verbose_name_plural = _("گروه‌ها")
ordering = ["-updated_at"]
constraints = [
models.UniqueConstraint(
fields=["user"],
condition=models.Q(is_general=True),
name="one_general_group_per_user",
)
]
def __str__(self):
return self.name
class ContactField(models.Model):
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="contact_fields"
)
group = models.ForeignKey(ContactGroup, on_delete=models.CASCADE, related_name="fields")
label = models.CharField(max_length=100, verbose_name=_("عنوان فیلد"))
sort_order = models.PositiveIntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("فیلد دلخواه مخاطب")
verbose_name_plural = _("فیلدهای دلخواه مخاطبین")
ordering = ["group", "sort_order"]
def __str__(self):
return f"{self.label} ({self.group.name})"
class Contact(models.Model):
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="contacts")
name = models.CharField(max_length=150, verbose_name=_("نام"))
phone = models.CharField(max_length=20, verbose_name=_("شماره موبایل"))
device_contact_id = models.CharField(max_length=100, blank=True, null=True)
# {field_id: value} — معادل جدول contact_field_values در اپ. فیلدهای
# مربوط به گروه‌هایی هستند که این مخاطب عضوشان است (member_ids گروه)
field_values = models.JSONField(default=dict, blank=True, verbose_name=_("مقادیر فیلدهای دلخواه"))
# {channel: external_id} — مثلا {"telegram": "123456789"} — برای ارسال
# خودکار از طریق Bot API فقط وقتی گیرنده قبلا چت با بات را شروع کرده
channel_ids = models.JSONField(default=dict, blank=True, verbose_name=_("شناسه‌ی کانال‌ها"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("مخاطب")
verbose_name_plural = _("مخاطبین")
ordering = ["name"]
def __str__(self):
return f"{self.name} ({self.phone})"
class MessageTemplate(models.Model):
class Mode(models.TextChoices):
MERGE = "merge", _("قالب‌بندی")
QUICK = "quick", _("تمپلیت آماده")
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="templates")
# null = قالب عمومی (فقط فیلدهای پایه‌ی نام/شماره)
group = models.ForeignKey(
ContactGroup, on_delete=models.CASCADE, related_name="templates", null=True, blank=True
)
name = models.CharField(max_length=150, verbose_name=_("نام قالب"))
body = models.TextField(verbose_name=_("متن قالب"))
mode = models.CharField(max_length=10, choices=Mode.choices)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("قالب پیام")
verbose_name_plural = _("قالب‌های پیام")
ordering = ["-updated_at"]
def __str__(self):
return self.name
class StructuredTemplate(models.Model):
"""قالب کلید-مقدار — برای گزارش‌های تکرارشونده مثل «امروز چقدر از هر
کالا موجود دارم». برخلاف MessageTemplate (متن آزاد با {فیلد})، این‌جا
فقط یک لیست از عنوان‌هاست (مثلا «کلم»، «کاهو») که اپ یکی‌یکی مقدارشان
را از کاربر می‌پرسد و در نهایت خط‌به‌خط به‌شکل «عنوان: مقدار» می‌فرستد."""
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="structured_templates"
)
name = models.CharField(max_length=150, verbose_name=_("نام قالب"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("قالب کلید-مقدار")
verbose_name_plural = _("قالب‌های کلید-مقدار")
ordering = ["-updated_at"]
def __str__(self):
return self.name
class StructuredTemplateField(models.Model):
id = models.CharField(max_length=36, primary_key=True)
template = models.ForeignKey(StructuredTemplate, on_delete=models.CASCADE, related_name="fields")
label = models.CharField(max_length=100, verbose_name=_("عنوان"))
sort_order = models.PositiveIntegerField(default=0)
class Meta:
verbose_name = _("فیلد قالب کلید-مقدار")
verbose_name_plural = _("فیلدهای قالب کلید-مقدار")
ordering = ["template", "sort_order"]
def __str__(self):
return f"{self.label} ({self.template.name})"
+48
View File
@@ -0,0 +1,48 @@
from django.urls import path
from .api.views import (
BackupView,
ContactDetailView,
ContactFieldDetailView,
ContactGroupDetailView,
ContactGroupListCreateView,
ContactListCreateView,
GroupFieldListCreateView,
MessageTemplateDetailView,
MessageTemplateListCreateView,
StructuredTemplateDetailView,
StructuredTemplateFieldDetailView,
StructuredTemplateFieldListCreateView,
StructuredTemplateListCreateView,
)
app_name = "banker"
urlpatterns = [
path("backup/", BackupView.as_view(), name="backup"),
path("groups/", ContactGroupListCreateView.as_view(), name="group-list"),
path("groups/<str:pk>/", ContactGroupDetailView.as_view(), name="group-detail"),
path("groups/<str:group_id>/fields/", GroupFieldListCreateView.as_view(), name="group-field-list"),
path("fields/<str:pk>/", ContactFieldDetailView.as_view(), name="field-detail"),
path("contacts/", ContactListCreateView.as_view(), name="contact-list"),
path("contacts/<str:pk>/", ContactDetailView.as_view(), name="contact-detail"),
path("templates/", MessageTemplateListCreateView.as_view(), name="template-list"),
path("templates/<str:pk>/", MessageTemplateDetailView.as_view(), name="template-detail"),
path(
"structured-templates/", StructuredTemplateListCreateView.as_view(), name="structured-template-list"
),
path(
"structured-templates/<str:pk>/",
StructuredTemplateDetailView.as_view(),
name="structured-template-detail",
),
path(
"structured-templates/<str:template_id>/fields/",
StructuredTemplateFieldListCreateView.as_view(),
name="structured-template-field-list",
),
path(
"structured-template-fields/<str:pk>/",
StructuredTemplateFieldDetailView.as_view(),
name="structured-template-field-detail",
),
]
View File
+66
View File
@@ -0,0 +1,66 @@
from django.contrib import admin
from django.shortcuts import redirect
from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction
@admin.register(BazaarSettings)
class BazaarSettingsAdmin(admin.ModelAdmin):
"""تک‌ردیفی — لیست را رد می‌کنیم و مستقیم فرم ویرایش همان یک ردیف را نشان می‌دهیم."""
list_display = ("package_name", "updated_at")
def has_add_permission(self, request):
return not BazaarSettings.objects.exists()
def changelist_view(self, request, extra_context=None):
obj = BazaarSettings.get_solo()
return redirect("admin:billing_bazaarsettings_change", obj.pk)
class FeaturePlanTierInline(admin.TabularInline):
model = FeaturePlanTier
extra = 1
@admin.register(FeaturePlan)
class FeaturePlanAdmin(admin.ModelAdmin):
list_display = ("name", "key", "is_purchasable")
search_fields = ("name", "key")
inlines = [FeaturePlanTierInline]
@admin.register(GemPack)
class GemPackAdmin(admin.ModelAdmin):
list_display = ("display_name", "bazaar_sku", "gem_amount", "is_active", "sort_order")
list_editable = ("is_active", "sort_order")
search_fields = ("display_name", "bazaar_sku")
@admin.register(FeatureActivation)
class FeatureActivationAdmin(admin.ModelAdmin):
list_display = ("user", "feature", "expires_at", "updated_at")
list_filter = ("feature",)
search_fields = ("user__phone",)
autocomplete_fields = ["user"]
@admin.register(GemTransaction)
class GemTransactionAdmin(admin.ModelAdmin):
list_display = ("user", "amount", "reason", "created_at")
list_filter = ("reason",)
search_fields = ("user__phone", "note")
readonly_fields = ("user", "amount", "reason", "note", "created_at")
def has_add_permission(self, request):
# تراکنش جم فقط از سرویس‌های داخلی ساخته می‌شود، نه دستی از ادمین —
# برای اصلاح دستی از GemTransaction.Reason.ADMIN_ADJUSTMENT در شل/سرویس استفاده کن.
return False
@admin.register(BazaarPurchase)
class BazaarPurchaseAdmin(admin.ModelAdmin):
list_display = ("user", "gem_pack", "status", "gems_granted", "created_at", "verified_at")
list_filter = ("status",)
search_fields = ("user__phone", "purchase_token")
readonly_fields = ("purchase_token", "created_at", "verified_at")
View File
+57
View File
@@ -0,0 +1,57 @@
from rest_framework import serializers
from apps.billing.api.services import REFERRAL_MILESTONES, FeatureAccessService
from apps.billing.models import FeaturePlan, FeaturePlanTier, GemPack
class WalletSerializer(serializers.Serializer):
gem_balance = serializers.IntegerField()
is_in_trial = serializers.BooleanField()
trial_expires_at = serializers.DateTimeField()
referral_code = serializers.CharField()
referral_count = serializers.IntegerField()
next_referral_milestone = serializers.SerializerMethodField()
def get_next_referral_milestone(self, obj):
count = obj["referral_count"]
for milestone, bonus in sorted(REFERRAL_MILESTONES.items()):
if count < milestone:
return {"referrals_needed": milestone, "referrals_remaining": milestone - count, "bonus_gems": bonus}
return None
class FeaturePlanTierSerializer(serializers.ModelSerializer):
class Meta:
model = FeaturePlanTier
fields = ["duration_months", "gem_price"]
class FeaturePlanSerializer(serializers.ModelSerializer):
tiers = FeaturePlanTierSerializer(many=True, read_only=True)
is_active = serializers.SerializerMethodField()
expires_at = serializers.SerializerMethodField()
class Meta:
model = FeaturePlan
fields = ["key", "name", "tiers", "is_active", "expires_at"]
def get_is_active(self, obj: FeaturePlan) -> bool:
return FeatureAccessService.is_active(self.context["request"].user, obj.key)
def get_expires_at(self, obj: FeaturePlan):
return FeatureAccessService.expires_at(self.context["request"].user, obj.key)
class GemPackSerializer(serializers.ModelSerializer):
class Meta:
model = GemPack
fields = ["id", "bazaar_sku", "display_name", "gem_amount"]
class ActivateFeatureSerializer(serializers.Serializer):
duration_months = serializers.IntegerField(min_value=1)
class CreatePurchaseSerializer(serializers.Serializer):
gem_pack_id = serializers.IntegerField()
purchase_token = serializers.CharField(max_length=255)
+135
View File
@@ -0,0 +1,135 @@
import logging
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError as DRFValidationError
from apps.account.models import User
from apps.billing.models import FeatureActivation, FeaturePlan, GemTransaction
logger = logging.getLogger(__name__)
# پاداش هر معرفیِ موفق (اولین verify واقعی کاربر جدید) + پاداش پلکانی سر
# رسیدن به نقاط عطف — دقیقا همان الگوی «گیمیفیکیشن» که خواسته شده بود.
REFERRAL_FLAT_BONUS = 200
REFERRAL_MILESTONES = {3: 500, 5: 1000, 10: 2500, 25: 7000, 50: 20000}
class GemWalletService:
@staticmethod
@transaction.atomic
def add_gems(user: User, amount: int, reason: str, note: str = "") -> User:
if amount <= 0:
raise ValueError("amount باید مثبت باشد")
user = User.objects.select_for_update().get(pk=user.pk)
user.gem_balance += amount
user.save(update_fields=["gem_balance"])
GemTransaction.objects.create(user=user, amount=amount, reason=reason, note=note)
return user
@staticmethod
@transaction.atomic
def spend_gems(user: User, amount: int, reason: str, note: str = "") -> User:
if amount <= 0:
raise ValueError("amount باید مثبت باشد")
user = User.objects.select_for_update().get(pk=user.pk)
if user.gem_balance < amount:
raise DRFValidationError({"detail": "موجودی جم کافی نیست."})
user.gem_balance -= amount
user.save(update_fields=["gem_balance"])
GemTransaction.objects.create(user=user, amount=-amount, reason=reason, note=note)
return user
class ReferralService:
@staticmethod
def link_referrer(new_user: User, referral_code: str | None) -> None:
"""موقع ساخت کاربر (send_login_code) صدا زده می‌شود — فقط لینک می‌کند،
پاداش نمی‌دهد؛ پاداش فقط سر verify واقعی داده می‌شود."""
if not referral_code or new_user.referred_by_id:
return
referrer = User.objects.filter(referral_code=referral_code.strip().upper()).exclude(pk=new_user.pk).first()
if referrer:
new_user.referred_by = referrer
new_user.save(update_fields=["referred_by"])
@staticmethod
def grant_bonus_if_needed(user: User) -> None:
"""بعد از اولین verify موفق کاربرِ معرفی‌شده صدا زده می‌شود."""
if user.referral_bonus_granted or not user.referred_by_id:
return
from apps.notifications.services import NotificationService
referrer = user.referred_by
GemWalletService.add_gems(
referrer, REFERRAL_FLAT_BONUS, GemTransaction.Reason.REFERRAL_BONUS, note=f"معرفی {user.phone}"
)
user.referral_bonus_granted = True
user.save(update_fields=["referral_bonus_granted"])
NotificationService.notify(
referrer,
title="🎉 یکی با کد معرفت پیوست!",
body=(
f"یک نفر با کد معرف تو توی کاسبیار ثبت‌نام کرد و {REFERRAL_FLAT_BONUS} جم جایزه گرفتی. "
"همین‌طوری ادامه بده تا جم‌های بیشتری جمع کنی!"
),
)
verified_referrals_count = User.objects.filter(referred_by=referrer, referral_bonus_granted=True).count()
milestone_bonus = REFERRAL_MILESTONES.get(verified_referrals_count)
if milestone_bonus:
GemWalletService.add_gems(
referrer,
milestone_bonus,
GemTransaction.Reason.REFERRAL_MILESTONE,
note=f"دستاورد {verified_referrals_count} معرفی موفق",
)
NotificationService.notify(
referrer,
title=f"🏆 به {verified_referrals_count} معرفی موفق رسیدی!",
body=f"دستاورد ویژه باز شد — {milestone_bonus} جم پاداش اضافه به کیف پولت اضافه شد.",
)
logger.info(f"Referral bonus granted: {referrer.phone} <- {user.phone}")
class FeatureAccessService:
@staticmethod
def is_active(user: User, feature_key: str) -> bool:
if user.is_in_trial:
return True
return FeatureActivation.objects.filter(
user=user, feature__key=feature_key, expires_at__gt=timezone.now()
).exists()
@staticmethod
def expires_at(user: User, feature_key: str):
activation = FeatureActivation.objects.filter(user=user, feature__key=feature_key).first()
return activation.expires_at if activation else None
@staticmethod
@transaction.atomic
def activate(user: User, feature_key: str, duration_months: int) -> FeatureActivation:
try:
feature = FeaturePlan.objects.get(key=feature_key, is_purchasable=True)
except FeaturePlan.DoesNotExist:
raise DRFValidationError({"detail": "ریزاپ یافت نشد."})
tier = feature.tiers.filter(duration_months=duration_months).first()
if not tier:
raise DRFValidationError({"detail": "این مدت برای این ریزاپ تعریف نشده."})
GemWalletService.spend_gems(
user, tier.gem_price, GemTransaction.Reason.FEATURE_ACTIVATION, note=f"{feature.name} — {duration_months} ماه"
)
activation, _created = FeatureActivation.objects.get_or_create(
user=user, feature=feature, defaults={"expires_at": timezone.now()}
)
# اگر از قبل فعال بود، از تاریخ انقضای فعلی جمع می‌زند (نه از الان)؛
# اگر منقضی شده بود، از الان شروع می‌شود.
base = activation.expires_at if activation.expires_at > timezone.now() else timezone.now()
activation.expires_at = base + timedelta(days=30 * duration_months)
activation.save(update_fields=["expires_at"])
return activation
+133
View File
@@ -0,0 +1,133 @@
from django.db import transaction
from django.utils import timezone
from rest_framework import generics, status
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.account.models import User
from apps.billing.models import BazaarPurchase, FeaturePlan, GemPack, GemTransaction
from apps.billing.providers.bazaar import verify_purchase
from .serializers import (
ActivateFeatureSerializer,
CreatePurchaseSerializer,
FeaturePlanSerializer,
GemPackSerializer,
WalletSerializer,
)
from .services import FeatureAccessService, GemWalletService
def _wallet_data(user: User) -> dict:
return {
"gem_balance": user.gem_balance,
"is_in_trial": user.is_in_trial,
"trial_expires_at": user.trial_expires_at,
"referral_code": user.referral_code,
"referral_count": User.objects.filter(referred_by=user, referral_bonus_granted=True).count(),
}
class WalletView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
return Response(WalletSerializer(_wallet_data(request.user)).data)
class FeatureListView(generics.ListAPIView):
serializer_class = FeaturePlanSerializer
permission_classes = [IsAuthenticated]
queryset = FeaturePlan.objects.filter(is_purchasable=True).prefetch_related("tiers")
class ActivateFeatureView(APIView):
permission_classes = [IsAuthenticated]
throttle_scope = "billing_write"
def post(self, request, key):
serializer = ActivateFeatureSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
FeatureAccessService.activate(request.user, key, serializer.validated_data["duration_months"])
return Response(
{
"success": True,
"feature": FeaturePlanSerializer(
FeaturePlan.objects.get(key=key), context={"request": request}
).data,
"wallet": WalletSerializer(_wallet_data(request.user)).data,
}
)
class GemPackListView(generics.ListAPIView):
serializer_class = GemPackSerializer
permission_classes = [IsAuthenticated]
queryset = GemPack.objects.filter(is_active=True)
class CreatePurchaseView(APIView):
"""کلاینت بعد از باز کردن لینک خرید کافه‌بازار و برگشتن، اینجا فقط
می‌گوید «این خرید را شروع کردم»؛ جمی اضافه نمی‌شود تا verify تایید کند."""
permission_classes = [IsAuthenticated]
throttle_scope = "billing_write"
def post(self, request):
serializer = CreatePurchaseSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
gem_pack = GemPack.objects.get(id=serializer.validated_data["gem_pack_id"], is_active=True)
except GemPack.DoesNotExist:
raise NotFound("بسته‌ی جم یافت نشد.")
purchase, _created = BazaarPurchase.objects.get_or_create(
purchase_token=serializer.validated_data["purchase_token"],
defaults={"user": request.user, "gem_pack": gem_pack},
)
return Response({"id": purchase.id, "status": purchase.status}, status=status.HTTP_201_CREATED)
class VerifyPurchaseView(APIView):
permission_classes = [IsAuthenticated]
throttle_scope = "billing_write"
def post(self, request, purchase_id):
# قفل ردیفِ همین خرید تا پایان تراکنش — بدون این، دو درخواست verify
# هم‌زمان (دابل‌تپ یا retry شبکه) هر دو می‌توانند از چک status=pending
# رد شوند و جم را دوبار اضافه کنند. با این قفل، دومی تا کامیت شدن
# اولی معطل می‌ماند و بعد status=verified را می‌بیند و کاری نمی‌کند.
with transaction.atomic():
try:
purchase = BazaarPurchase.objects.select_for_update().get(id=purchase_id, user=request.user)
except BazaarPurchase.DoesNotExist:
raise NotFound("خرید یافت نشد.")
if purchase.status == BazaarPurchase.Status.VERIFIED:
return Response(
{"status": purchase.status, "wallet": WalletSerializer(_wallet_data(request.user)).data}
)
is_valid = verify_purchase(purchase.gem_pack.bazaar_sku, purchase.purchase_token)
if not is_valid:
purchase.status = BazaarPurchase.Status.FAILED
purchase.save(update_fields=["status"])
return Response(
{"status": purchase.status, "detail": "تایید خرید ناموفق بود."},
status=status.HTTP_400_BAD_REQUEST,
)
GemWalletService.add_gems(
request.user,
purchase.gem_pack.gem_amount,
GemTransaction.Reason.PURCHASE,
note=f"خرید {purchase.gem_pack.display_name}",
)
purchase.status = BazaarPurchase.Status.VERIFIED
purchase.gems_granted = purchase.gem_pack.gem_amount
purchase.verified_at = timezone.now()
purchase.save(update_fields=["status", "gems_granted", "verified_at"])
return Response({"status": purchase.status, "wallet": WalletSerializer(_wallet_data(request.user)).data})
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.billing"
verbose_name = "جم، اشتراک و پرداخت"
+124
View File
@@ -0,0 +1,124 @@
# Generated by Django 5.1.15 on 2026-08-23 12:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BazaarSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_token', models.CharField(blank=True, max_length=255, verbose_name='Access Token')),
('refresh_token', models.CharField(blank=True, max_length=255, verbose_name='Refresh Token')),
('package_name', models.CharField(blank=True, max_length=150, verbose_name='application id اپ (مثلا ir.example.app)')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'تنظیمات کافه\u200cبازار',
'verbose_name_plural': 'تنظیمات کافه\u200cبازار',
},
),
migrations.CreateModel(
name='FeaturePlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.SlugField(unique=True, verbose_name='کلید ریزاپ')),
('name', models.CharField(max_length=150, verbose_name='نام نمایشی')),
('is_purchasable', models.BooleanField(default=True, verbose_name='قابل\u200cفروش')),
],
options={
'verbose_name': 'ریزاپ قابل\u200cفعال\u200cسازی',
'verbose_name_plural': 'ریزاپ\u200cهای قابل\u200cفعال\u200cسازی',
},
),
migrations.CreateModel(
name='GemPack',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bazaar_sku', models.CharField(max_length=100, unique=True, verbose_name='SKU کافه\u200cبازار')),
('display_name', models.CharField(max_length=150, verbose_name='نام نمایشی')),
('gem_amount', models.PositiveIntegerField(verbose_name='تعداد جم')),
('is_active', models.BooleanField(default=True, verbose_name='قابل\u200cخرید')),
('sort_order', models.PositiveIntegerField(default=0)),
],
options={
'verbose_name': 'بسته\u200cی جم',
'verbose_name_plural': 'بسته\u200cهای جم',
'ordering': ['sort_order', 'gem_amount'],
},
),
migrations.CreateModel(
name='BazaarPurchase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('purchase_token', models.CharField(max_length=255, unique=True, verbose_name='Purchase Token')),
('status', models.CharField(choices=[('pending', 'در انتظار تایید'), ('verified', 'تاییدشده'), ('failed', 'ناموفق')], default='pending', max_length=10)),
('gems_granted', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('verified_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bazaar_purchases', to=settings.AUTH_USER_MODEL)),
('gem_pack', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='billing.gempack')),
],
options={
'verbose_name': 'خرید کافه\u200cبازار',
'verbose_name_plural': 'خریدهای کافه\u200cبازار',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='GemTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.IntegerField(verbose_name='مقدار (مثبت/منفی)')),
('reason', models.CharField(choices=[('purchase', 'خرید از کافه\u200cبازار'), ('feature_activation', 'فعال\u200cسازی ریزاپ'), ('referral_bonus', 'پاداش معرفی'), ('referral_milestone', 'پاداش دستاورد معرفی'), ('admin_adjustment', 'اصلاح دستی ادمین')], max_length=30)),
('note', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gem_transactions', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'تراکنش جم',
'verbose_name_plural': 'تراکنش\u200cهای جم',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FeatureActivation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('expires_at', models.DateTimeField(verbose_name='تا تاریخ')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_activations', to=settings.AUTH_USER_MODEL)),
('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='billing.featureplan')),
],
options={
'verbose_name': 'فعال\u200cسازی ریزاپ',
'verbose_name_plural': 'فعال\u200cسازی\u200cهای ریزاپ',
'constraints': [models.UniqueConstraint(fields=('user', 'feature'), name='unique_user_feature_activation')],
},
),
migrations.CreateModel(
name='FeaturePlanTier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('duration_months', models.PositiveSmallIntegerField(verbose_name='مدت (ماه)')),
('gem_price', models.PositiveIntegerField(verbose_name='قیمت (جم)')),
('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tiers', to='billing.featureplan')),
],
options={
'verbose_name': 'پلن قیمتی',
'verbose_name_plural': 'پلن\u200cهای قیمتی',
'ordering': ['feature', 'duration_months'],
'constraints': [models.UniqueConstraint(fields=('feature', 'duration_months'), name='unique_feature_duration')],
},
),
]
@@ -0,0 +1,42 @@
from django.db import migrations
# قیمت پایه‌ی هرکدام + پلکان تخفیف مدت (۳ماهه ≈۱۰٪ کمتر از ۳×ماهانه،
# ۶ماهه ≈۱۷٪، ۱۲ماهه ۲۵٪) — دقیقا مطابق مثال خودِ کارفرما برای فاکتور
# (۱۰۰۰ / ۲۷۰۰ / ۵۰۰۰)، همان الگو برای بقیه هم تعمیم داده شده.
FEATURES = [
("sms_bank", "بانک اس‌ام‌اس", 1500),
("invoice", "فاکتور", 1000),
("customers", "مشتریان", 500),
("ledger", "دفتر درآمد و هزینه", 800),
("inventory", "انبارداری", 800),
("booking", "نوبت‌دهی", 600),
]
_DURATION_MULTIPLIERS = {1: 1, 3: 2.7, 6: 5, 12: 9} # ۱۰٪/۱۷٪/۲۵٪ تخفیف نسبت به ماهانه×مدت
def _tier_prices(monthly: int) -> dict:
return {months: round(monthly * multiplier) for months, multiplier in _DURATION_MULTIPLIERS.items()}
def seed(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlanTier = apps.get_model("billing", "FeaturePlanTier")
for key, name, monthly_price in FEATURES:
feature, _created = FeaturePlan.objects.get_or_create(key=key, defaults={"name": name})
for months, price in _tier_prices(monthly_price).items():
FeaturePlanTier.objects.get_or_create(
feature=feature, duration_months=months, defaults={"gem_price": price}
)
def unseed(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlan.objects.filter(key__in=[key for key, _, _ in FEATURES]).delete()
class Migration(migrations.Migration):
dependencies = [("billing", "0001_initial")]
operations = [migrations.RunPython(seed, unseed)]
@@ -0,0 +1,19 @@
from django.db import migrations
# ریزاپ کارگزاری کامل حذف شد — این migration پاک‌سازی می‌کند اگر روی یک DB
# که قبلاً 0002 را اجرا کرده بود (و رکورد "brokerage" را ساخته بود) اعمال شود.
# روی DB تازه (که 0002 دیگر "brokerage" را seed نمی‌کند) بی‌اثر است.
def remove_brokerage(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlan.objects.filter(key="brokerage").delete()
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [("billing", "0002_seed_feature_plans")]
operations = [migrations.RunPython(remove_brokerage, noop)]
@@ -0,0 +1,18 @@
from django.db import migrations
# اسم نمایشی ابزار «sms_bank» برای کاربر عادی (نه فنی) واضح‌تر شد.
def rename_forward(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlan.objects.filter(key="sms_bank").update(name="پیامک گروهی")
def rename_backward(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlan.objects.filter(key="sms_bank").update(name="بانک اس‌ام‌اس")
class Migration(migrations.Migration):
dependencies = [("billing", "0003_remove_brokerage_feature")]
operations = [migrations.RunPython(rename_forward, rename_backward)]
@@ -0,0 +1,27 @@
from django.db import migrations
_DURATION_MULTIPLIERS = {1: 1, 3: 2.7, 6: 5, 12: 9}
_MONTHLY_PRICE = 900
def _tier_prices() -> dict:
return {months: round(_MONTHLY_PRICE * multiplier) for months, multiplier in _DURATION_MULTIPLIERS.items()}
def seed(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlanTier = apps.get_model("billing", "FeaturePlanTier")
feature, _created = FeaturePlan.objects.get_or_create(key="orders", defaults={"name": "پیگیری سفارش"})
for months, price in _tier_prices().items():
FeaturePlanTier.objects.get_or_create(feature=feature, duration_months=months, defaults={"gem_price": price})
def unseed(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlan.objects.filter(key="orders").delete()
class Migration(migrations.Migration):
dependencies = [("billing", "0004_rename_sms_bank_feature")]
operations = [migrations.RunPython(seed, unseed)]
View File
+165
View File
@@ -0,0 +1,165 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# سیستم جم/اشتراک: هر ابزار (پیامک گروهی، فاکتور، مشتریان و...) با جم
# برای یک بازه‌ی زمانی فعال می‌شود. جم فقط از طریق خرید تاییدشده‌ی کافه‌بازار
# (سمت سرور، بعد از تماس واقعی با API کافه‌بازار) یا پاداش معرفی اضافه
# می‌شود — User.gem_balance هیچ‌وقت مستقیم از کلاینت نوشته نمی‌شود.
class BazaarSettings(models.Model):
"""تنظیمات کافه‌بازار — عمداً از پنل ادمین ویرایش می‌شود (نه .env)، چون
باید بدون ری‌دیپلوی قابل‌تغییر باشد. تک‌ردیفی (singleton)."""
access_token = models.CharField(max_length=255, blank=True, verbose_name=_("Access Token"))
refresh_token = models.CharField(max_length=255, blank=True, verbose_name=_("Refresh Token"))
package_name = models.CharField(
max_length=150, blank=True, verbose_name=_("application id اپ (مثلا ir.example.app)")
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("تنظیمات کافه‌بازار")
verbose_name_plural = _("تنظیمات کافه‌بازار")
def __str__(self):
return "تنظیمات کافه‌بازار"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_solo(cls) -> "BazaarSettings":
obj, _created = cls.objects.get_or_create(pk=1)
return obj
class GemPack(models.Model):
"""بسته‌ی جم قابل‌خرید — bazaar_sku باید دقیقا همان محصولی باشد که در
کنسول توسعه‌دهندگان کافه‌بازار ثبت کرده‌ای."""
bazaar_sku = models.CharField(max_length=100, unique=True, verbose_name=_("SKU کافه‌بازار"))
display_name = models.CharField(max_length=150, verbose_name=_("نام نمایشی"))
gem_amount = models.PositiveIntegerField(verbose_name=_("تعداد جم"))
is_active = models.BooleanField(default=True, verbose_name=_("قابل‌خرید"))
sort_order = models.PositiveIntegerField(default=0)
class Meta:
verbose_name = _("بسته‌ی جم")
verbose_name_plural = _("بسته‌های جم")
ordering = ["sort_order", "gem_amount"]
def __str__(self):
return f"{self.display_name} ({self.gem_amount} جم)"
class FeaturePlan(models.Model):
"""یک ریزاپ قابل‌فعال‌سازی با جم — key باید با کلید همان ریزاپ در اپ
فلاتر یکی باشد (مثلا invoice، customers، ledger، inventory، booking،
sms_bank)."""
key = models.SlugField(max_length=50, unique=True, verbose_name=_("کلید ریزاپ"))
name = models.CharField(max_length=150, verbose_name=_("نام نمایشی"))
is_purchasable = models.BooleanField(default=True, verbose_name=_("قابل‌فروش"))
class Meta:
verbose_name = _("ریزاپ قابل‌فعال‌سازی")
verbose_name_plural = _("ریزاپ‌های قابل‌فعال‌سازی")
def __str__(self):
return self.name
class FeaturePlanTier(models.Model):
"""قیمت هر مدت‌زمان برای یک ریزاپ — مثلا فاکتور: ۱ماهه=۱۰۰۰، ۳ماهه=۲۷۰۰،
۶ماهه=۵۰۰۰ جم (تخفیف پلکانی برای خرید مدت طولانی‌تر)."""
feature = models.ForeignKey(FeaturePlan, on_delete=models.CASCADE, related_name="tiers")
duration_months = models.PositiveSmallIntegerField(verbose_name=_("مدت (ماه)"))
gem_price = models.PositiveIntegerField(verbose_name=_("قیمت (جم)"))
class Meta:
verbose_name = _("پلن قیمتی")
verbose_name_plural = _("پلن‌های قیمتی")
constraints = [
models.UniqueConstraint(fields=["feature", "duration_months"], name="unique_feature_duration")
]
ordering = ["feature", "duration_months"]
def __str__(self):
return f"{self.feature.name} — {self.duration_months} ماهه — {self.gem_price} جم"
class FeatureActivation(models.Model):
"""فعال بودن یک ریزاپ برای یک کاربر تا چه زمانی. دوره‌ی رایگان جدا حساب
می‌شود (User.is_in_trial)؛ این مدل فقط اشتراکِ خریداری‌شده‌ی بعد از
دوره‌ی رایگان است."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="feature_activations")
feature = models.ForeignKey(FeaturePlan, on_delete=models.CASCADE, related_name="+")
expires_at = models.DateTimeField(verbose_name=_("تا تاریخ"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("فعال‌سازی ریزاپ")
verbose_name_plural = _("فعال‌سازی‌های ریزاپ")
constraints = [models.UniqueConstraint(fields=["user", "feature"], name="unique_user_feature_activation")]
def __str__(self):
return f"{self.user.phone} — {self.feature.name} تا {self.expires_at:%Y-%m-%d}"
class GemTransaction(models.Model):
"""دفتر تراکنش‌های جم — تنها منبع حقیقتِ تاریخچه؛ User.gem_balance فقط
یک مجموع cache‌شده است که هر تغییرش باید از همین‌جا هم رد شود."""
class Reason(models.TextChoices):
PURCHASE = "purchase", _("خرید از کافه‌بازار")
FEATURE_ACTIVATION = "feature_activation", _("فعال‌سازی ریزاپ")
REFERRAL_BONUS = "referral_bonus", _("پاداش معرفی")
REFERRAL_MILESTONE = "referral_milestone", _("پاداش دستاورد معرفی")
ADMIN_ADJUSTMENT = "admin_adjustment", _("اصلاح دستی ادمین")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="gem_transactions")
amount = models.IntegerField(verbose_name=_("مقدار (مثبت/منفی)"))
reason = models.CharField(max_length=30, choices=Reason.choices)
note = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("تراکنش جم")
verbose_name_plural = _("تراکنش‌های جم")
ordering = ["-created_at"]
def __str__(self):
return f"{self.user.phone} — {self.amount:+d} — {self.get_reason_display()}"
class BazaarPurchase(models.Model):
"""یک خرید بسته‌ی جم از کافه‌بازار. purchase_token یکتاست — همان توکنی
که کافه‌بازار به خریدار می‌دهد، برای جلوگیری از استفاده‌ی دوباره‌ی یک
توکن. تا وقتی status=verified نشود (با تماس واقعی سرور-به-سرور به API
کافه‌بازار)، هیچ جمی اضافه نمی‌شود."""
class Status(models.TextChoices):
PENDING = "pending", _("در انتظار تایید")
VERIFIED = "verified", _("تاییدشده")
FAILED = "failed", _("ناموفق")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bazaar_purchases")
gem_pack = models.ForeignKey(GemPack, on_delete=models.PROTECT, related_name="+")
purchase_token = models.CharField(max_length=255, unique=True, verbose_name=_("Purchase Token"))
status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING)
gems_granted = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
verified_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("خرید کافه‌بازار")
verbose_name_plural = _("خریدهای کافه‌بازار")
ordering = ["-created_at"]
def __str__(self):
return f"{self.user.phone} — {self.gem_pack.display_name} — {self.status}"
+20
View File
@@ -0,0 +1,20 @@
from rest_framework.permissions import BasePermission
class HasActiveFeature(BasePermission):
"""اجازه‌ی استفاده از یک ریزاپ را فقط به کاربری می‌دهد که آن را فعال دارد
(دوره‌ی آزمایشی یا خریداری‌شده با جم). گیت اصلی سمت اپ فلاتر (لانچر) فقط
برای تجربه‌ی کاربری است؛ بدون این چک سمت سرور، هرکسی با صدازدن مستقیم API
(مثلا با curl/Postman) می‌توانست کل مدل پرداختی را دور بزند. `view` باید
`feature_key` را ست کند؛ اگر نکرد، این پرمیشن چیزی را مسدود نمی‌کند."""
message = "برای استفاده از این بخش باید آن را از فروشگاه فعال کنی."
def has_permission(self, request, view) -> bool:
feature_key = getattr(view, "feature_key", None)
if not feature_key:
return True
from apps.billing.api.services import FeatureAccessService
return FeatureAccessService.is_active(request.user, feature_key)
View File
+56
View File
@@ -0,0 +1,56 @@
# apps/billing/providers/bazaar.py
"""
تایید خرید کافه‌بازار — سمت سرور، طبق مستندات عمومی devapi کافه‌بازار
(https://developers.cafebazaar.ir/fa/docs/iab). این تنها API ای است که
اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»، تا این
تابع True برنگرداند هیچ جمی اضافه نمی‌شود.
فرض مستنداتِ مسیر/پاسخ (اگر ساختار حساب توسعه‌دهنده‌ی خودت فرق دارد، فقط
همین یک تابع را با مستندات واقعی هماهنگ کن — بقیه‌ی پروژه فقط
`verify_purchase(product_id, purchase_token)` را صدا می‌زند):
GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}
/inapp/{product_id}/purchases/{purchase_token}/?access_token=...
پاسخ موفق شامل purchaseState=0 (خریداری‌شده، نه refund‌شده) است.
access_token از BazaarSettings (پنل ادمین) خوانده می‌شود، نه .env — چون باید
بدون ری‌دیپلوی قابل‌تعویض باشد (مثلا وقتی منقضی شد).
"""
import logging
import requests
from apps.billing.models import BazaarSettings
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT_SECONDS = 15
BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api"
def verify_purchase(product_id: str, purchase_token: str) -> bool:
settings_obj = BazaarSettings.get_solo()
if not settings_obj.access_token or not settings_obj.package_name:
logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده")
return False
url = f"{BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/"
try:
response = requests.get(
url,
params={"access_token": settings_obj.access_token},
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
body = response.json()
purchase_state = body.get("purchaseState")
is_valid = purchase_state == 0
if not is_valid:
logger.warning(f"❌ خرید کافه‌بازار نامعتبر: {body}")
return is_valid
except (requests.RequestException, ValueError) as exc:
logger.error(f"❌ خطا در تایید خرید کافه‌بازار ({product_id}/{purchase_token}): {exc}")
return False
+20
View File
@@ -0,0 +1,20 @@
from django.urls import path
from .api.views import (
ActivateFeatureView,
CreatePurchaseView,
FeatureListView,
GemPackListView,
VerifyPurchaseView,
WalletView,
)
app_name = "billing"
urlpatterns = [
path("wallet/", WalletView.as_view(), name="wallet"),
path("features/", FeatureListView.as_view(), name="feature-list"),
path("features/<slug:key>/activate/", ActivateFeatureView.as_view(), name="feature-activate"),
path("gem-packs/", GemPackListView.as_view(), name="gem-pack-list"),
path("purchases/", CreatePurchaseView.as_view(), name="purchase-create"),
path("purchases/<int:purchase_id>/verify/", VerifyPurchaseView.as_view(), name="purchase-verify"),
]
View File
+40
View File
@@ -0,0 +1,40 @@
from django.contrib import admin
from .models import Appointment, Customer, LedgerEntry, Product, StockMovement
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
list_display = ("name", "phone", "user", "updated_at")
search_fields = ("name", "phone", "email", "user__phone")
@admin.register(LedgerEntry)
class LedgerEntryAdmin(admin.ModelAdmin):
list_display = ("title", "entry_type", "amount", "entry_date", "user")
list_filter = ("entry_type", "category")
search_fields = ("title", "user__phone")
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "sku", "category", "quantity", "unit_price", "user")
list_filter = ("category",)
search_fields = ("name", "sku", "user__phone")
@admin.register(StockMovement)
class StockMovementAdmin(admin.ModelAdmin):
list_display = ("product", "delta", "quantity_after", "created_at")
search_fields = ("product__name", "product__user__phone")
readonly_fields = ("product", "delta", "quantity_after", "note", "created_at")
def has_add_permission(self, request):
return False
@admin.register(Appointment)
class AppointmentAdmin(admin.ModelAdmin):
list_display = ("customer_name", "title", "start_at", "status", "user")
list_filter = ("status",)
search_fields = ("customer_name", "customer_phone", "user__phone")
View File
+58
View File
@@ -0,0 +1,58 @@
from rest_framework import serializers
from apps.bizapps.models import Appointment, Customer, LedgerEntry, Product, StockMovement
class CustomerSerializer(serializers.ModelSerializer):
class Meta:
model = Customer
fields = ["id", "name", "phone", "email", "address", "notes", "updated_at"]
read_only_fields = ["updated_at"]
class LedgerEntrySerializer(serializers.ModelSerializer):
class Meta:
model = LedgerEntry
fields = ["id", "entry_type", "title", "amount", "category", "entry_date", "note", "updated_at"]
read_only_fields = ["updated_at"]
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = [
"id",
"name",
"sku",
"category",
"quantity",
"unit_price",
"low_stock_threshold",
"notes",
"updated_at",
]
read_only_fields = ["updated_at"]
class StockMovementSerializer(serializers.ModelSerializer):
class Meta:
model = StockMovement
fields = ["id", "delta", "quantity_after", "note", "created_at"]
read_only_fields = fields
class AdjustStockSerializer(serializers.Serializer):
delta = serializers.FloatField()
note = serializers.CharField(max_length=255, required=False, allow_blank=True, default="")
def validate_delta(self, value):
if value == 0:
raise serializers.ValidationError("مقدار تغییر نمی‌تواند صفر باشد.")
return value
class AppointmentSerializer(serializers.ModelSerializer):
class Meta:
model = Appointment
fields = ["id", "customer_name", "customer_phone", "title", "start_at", "status", "notes", "updated_at"]
read_only_fields = ["updated_at"]
+130
View File
@@ -0,0 +1,130 @@
from django.db import transaction
from rest_framework import generics, status
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.billing.permissions import HasActiveFeature
from apps.bizapps.models import Appointment, Customer, LedgerEntry, Product, StockMovement
from .serializers import (
AdjustStockSerializer,
AppointmentSerializer,
CustomerSerializer,
LedgerEntrySerializer,
ProductSerializer,
StockMovementSerializer,
)
# همه‌ی این ریزاپ‌ها با جم فعال می‌شوند (یا دوره‌ی آزمایشی) — `feature_key`
# روی هر ویو مشخص می‌کند HasActiveFeature باید چه چیزی را چک کند.
# permission_classes اینجا Base است تا زیرکلاس‌ها فراموش نکنند.
class _UserScopedListCreateView(generics.ListCreateAPIView):
permission_classes = [IsAuthenticated, HasActiveFeature]
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class _UserScopedDetailView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [IsAuthenticated, HasActiveFeature]
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
class CustomerListCreateView(_UserScopedListCreateView):
model = Customer
serializer_class = CustomerSerializer
feature_key = "customers"
class CustomerDetailView(_UserScopedDetailView):
model = Customer
serializer_class = CustomerSerializer
feature_key = "customers"
class LedgerEntryListCreateView(_UserScopedListCreateView):
model = LedgerEntry
serializer_class = LedgerEntrySerializer
feature_key = "ledger"
class LedgerEntryDetailView(_UserScopedDetailView):
model = LedgerEntry
serializer_class = LedgerEntrySerializer
feature_key = "ledger"
class ProductListCreateView(_UserScopedListCreateView):
model = Product
serializer_class = ProductSerializer
feature_key = "inventory"
class ProductDetailView(_UserScopedDetailView):
model = Product
serializer_class = ProductSerializer
feature_key = "inventory"
class ProductAdjustStockView(APIView):
"""تعدیل اتمیک موجودی (+/-) + یک ردیف تاریخچه — به‌جای این‌که کلاینت
مستقیم quantity جدید را PATCH کند (که هیچ تاریخچه‌ای نمی‌سازد و اگر دو
درخواست هم‌زمان بیایند می‌تواند یکی را گم کند)."""
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "inventory"
@transaction.atomic
def post(self, request, pk):
try:
product = Product.objects.select_for_update().get(pk=pk, user=request.user)
except Product.DoesNotExist:
raise NotFound("کالا یافت نشد.")
serializer = AdjustStockSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
delta = serializer.validated_data["delta"]
product.quantity += delta
product.save(update_fields=["quantity", "updated_at"])
StockMovement.objects.create(
product=product,
delta=delta,
quantity_after=product.quantity,
note=serializer.validated_data.get("note", ""),
)
return Response(ProductSerializer(product).data, status=status.HTTP_200_OK)
class ProductMovementsView(generics.ListAPIView):
"""تاریخچه‌ی تغییرات موجودی یک کالا — جدیدترین اول."""
serializer_class = StockMovementSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "inventory"
def get_queryset(self):
return StockMovement.objects.filter(
product_id=self.kwargs["pk"], product__user=self.request.user
)
class AppointmentListCreateView(_UserScopedListCreateView):
model = Appointment
serializer_class = AppointmentSerializer
feature_key = "booking"
class AppointmentDetailView(_UserScopedDetailView):
model = Appointment
serializer_class = AppointmentSerializer
feature_key = "booking"
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BizappsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.bizapps"
verbose_name = "ابزارهای کسب‌وکار"
+92
View File
@@ -0,0 +1,92 @@
# Generated by Django 5.1.15 on 2026-08-23 11:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Appointment',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('customer_name', models.CharField(max_length=150, verbose_name='نام مشتری')),
('customer_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره مشتری')),
('title', models.CharField(max_length=150, verbose_name='عنوان/خدمت')),
('start_at', models.DateTimeField(verbose_name='زمان نوبت')),
('status', models.CharField(choices=[('scheduled', 'زمان\u200cبندی\u200cشده'), ('done', 'انجام\u200cشده'), ('cancelled', 'لغوشده')], default='scheduled', max_length=10)),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='appointments', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'نوبت',
'verbose_name_plural': 'نوبت\u200cدهی',
'ordering': ['start_at'],
},
),
migrations.CreateModel(
name='Customer',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, verbose_name='نام')),
('phone', models.CharField(blank=True, max_length=20, verbose_name='شماره تماس')),
('email', models.CharField(blank=True, max_length=150, verbose_name='ایمیل')),
('address', models.CharField(blank=True, max_length=255, verbose_name='آدرس')),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customers', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'مشتری',
'verbose_name_plural': 'مشتریان',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='LedgerEntry',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('entry_type', models.CharField(choices=[('income', 'درآمد'), ('expense', 'هزینه')], max_length=10, verbose_name='نوع')),
('title', models.CharField(max_length=150, verbose_name='عنوان')),
('amount', models.FloatField(verbose_name='مبلغ')),
('category', models.CharField(blank=True, max_length=100, verbose_name='دسته')),
('entry_date', models.DateField(verbose_name='تاریخ')),
('note', models.TextField(blank=True, verbose_name='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ledger_entries', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'ردیف دفتر درآمد/هزینه',
'verbose_name_plural': 'دفتر درآمد و هزینه',
'ordering': ['-entry_date'],
},
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, verbose_name='نام کالا')),
('sku', models.CharField(blank=True, max_length=100, verbose_name='کد کالا')),
('quantity', models.FloatField(default=0, verbose_name='موجودی')),
('unit_price', models.FloatField(default=0, verbose_name='قیمت واحد')),
('low_stock_threshold', models.FloatField(default=0, verbose_name='آستانه\u200cی هشدار موجودی کم')),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'کالا',
'verbose_name_plural': 'انبار',
'ordering': ['name'],
},
),
]
@@ -0,0 +1,38 @@
# Generated by Django 5.1.15 on 2026-08-23 12:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BrokerageTransaction',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('client_name', models.CharField(max_length=150, verbose_name='نام مشتری')),
('client_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره مشتری')),
('symbol', models.CharField(max_length=50, verbose_name='نماد')),
('side', models.CharField(choices=[('buy', 'خرید'), ('sell', 'فروش')], max_length=4, verbose_name='نوع معامله')),
('quantity', models.FloatField(verbose_name='تعداد')),
('price', models.FloatField(verbose_name='قیمت واحد')),
('commission_percent', models.FloatField(default=0, verbose_name='درصد کارمزد')),
('transaction_date', models.DateField(verbose_name='تاریخ')),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='brokerage_transactions', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'معامله\u200cی بورسی',
'verbose_name_plural': 'کارگزاری',
'ordering': ['-transaction_date'],
},
),
]
@@ -0,0 +1,16 @@
# Generated by Django 5.1.15 on 2026-08-23 14:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0002_brokeragetransaction'),
]
operations = [
migrations.DeleteModel(
name='BrokerageTransaction',
),
]
@@ -0,0 +1,35 @@
# Generated by Django 5.1.15 on 2026-08-23 20:24
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0003_delete_brokeragetransaction'),
]
operations = [
migrations.AddField(
model_name='product',
name='category',
field=models.CharField(blank=True, max_length=100, verbose_name='دسته'),
),
migrations.CreateModel(
name='StockMovement',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('delta', models.FloatField(verbose_name='تغییر')),
('quantity_after', models.FloatField(verbose_name='موجودی بعد از تغییر')),
('note', models.CharField(blank=True, max_length=255, verbose_name='یادداشت')),
('created_at', models.DateTimeField(auto_now_add=True)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movements', to='bizapps.product')),
],
options={
'verbose_name': 'تغییر موجودی',
'verbose_name_plural': 'تاریخچه\u200cی موجودی',
'ordering': ['-created_at'],
},
),
]
View File
+116
View File
@@ -0,0 +1,116 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# ریزاپ‌های ساده‌ی کسب‌وکار کوچک — مشتریان، دفتر درآمد/هزینه، انبار،
# نوبت‌دهی. دقیقا هم‌الگو با apps.banker: id همان UUID تولیدشده در خودِ اپ
# فلاتر است (نه auto-increment)، هر رکورد به user صاحبش محدود می‌شود.
class Customer(models.Model):
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="customers")
name = models.CharField(max_length=150, verbose_name=_("نام"))
phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره تماس"))
email = models.CharField(max_length=150, blank=True, verbose_name=_("ایمیل"))
address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس"))
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("مشتری")
verbose_name_plural = _("مشتریان")
ordering = ["name"]
def __str__(self):
return self.name
class LedgerEntry(models.Model):
class EntryType(models.TextChoices):
INCOME = "income", _("درآمد")
EXPENSE = "expense", _("هزینه")
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ledger_entries")
entry_type = models.CharField(max_length=10, choices=EntryType.choices, verbose_name=_("نوع"))
title = models.CharField(max_length=150, verbose_name=_("عنوان"))
amount = models.FloatField(verbose_name=_("مبلغ"))
category = models.CharField(max_length=100, blank=True, verbose_name=_("دسته"))
entry_date = models.DateField(verbose_name=_("تاریخ"))
note = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("ردیف دفتر درآمد/هزینه")
verbose_name_plural = _("دفتر درآمد و هزینه")
ordering = ["-entry_date"]
def __str__(self):
return f"{self.title} ({self.get_entry_type_display()})"
class Product(models.Model):
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="products")
name = models.CharField(max_length=150, verbose_name=_("نام کالا"))
sku = models.CharField(max_length=100, blank=True, verbose_name=_("کد کالا"))
category = models.CharField(max_length=100, blank=True, verbose_name=_("دسته"))
quantity = models.FloatField(default=0, verbose_name=_("موجودی"))
unit_price = models.FloatField(default=0, verbose_name=_("قیمت واحد"))
low_stock_threshold = models.FloatField(default=0, verbose_name=_("آستانه‌ی هشدار موجودی کم"))
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("کالا")
verbose_name_plural = _("انبار")
ordering = ["name"]
def __str__(self):
return self.name
class StockMovement(models.Model):
"""تاریخچه‌ی تغییرات موجودی — هر تعدیل (دستیِ +/- یا ویرایش مستقیم فرم)
یک ردیف اینجا ثبت می‌کند تا معلوم باشد موجودی کِی و چقدر تغییر کرده."""
id = models.AutoField(primary_key=True)
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="movements")
delta = models.FloatField(verbose_name=_("تغییر"))
quantity_after = models.FloatField(verbose_name=_("موجودی بعد از تغییر"))
note = models.CharField(max_length=255, blank=True, verbose_name=_("یادداشت"))
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("تغییر موجودی")
verbose_name_plural = _("تاریخچه‌ی موجودی")
ordering = ["-created_at"]
def __str__(self):
return f"{self.product.name} — {self.delta:+g} — {self.created_at:%Y-%m-%d %H:%M}"
class Appointment(models.Model):
class Status(models.TextChoices):
SCHEDULED = "scheduled", _("زمان‌بندی‌شده")
DONE = "done", _("انجام‌شده")
CANCELLED = "cancelled", _("لغوشده")
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="appointments")
customer_name = models.CharField(max_length=150, verbose_name=_("نام مشتری"))
customer_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره مشتری"))
title = models.CharField(max_length=150, verbose_name=_("عنوان/خدمت"))
start_at = models.DateTimeField(verbose_name=_("زمان نوبت"))
status = models.CharField(max_length=10, choices=Status.choices, default=Status.SCHEDULED)
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("نوبت")
verbose_name_plural = _("نوبت‌دهی")
ordering = ["start_at"]
def __str__(self):
return f"{self.customer_name} — {self.title}"
+28
View File
@@ -0,0 +1,28 @@
from django.urls import path
from .api.views import (
AppointmentDetailView,
AppointmentListCreateView,
CustomerDetailView,
CustomerListCreateView,
LedgerEntryDetailView,
LedgerEntryListCreateView,
ProductAdjustStockView,
ProductDetailView,
ProductListCreateView,
ProductMovementsView,
)
app_name = "bizapps"
urlpatterns = [
path("customers/", CustomerListCreateView.as_view(), name="customer-list"),
path("customers/<str:pk>/", CustomerDetailView.as_view(), name="customer-detail"),
path("ledger/", LedgerEntryListCreateView.as_view(), name="ledger-list"),
path("ledger/<str:pk>/", LedgerEntryDetailView.as_view(), name="ledger-detail"),
path("products/", ProductListCreateView.as_view(), name="product-list"),
path("products/<str:pk>/", ProductDetailView.as_view(), name="product-detail"),
path("products/<str:pk>/adjust/", ProductAdjustStockView.as_view(), name="product-adjust"),
path("products/<str:pk>/movements/", ProductMovementsView.as_view(), name="product-movements"),
path("appointments/", AppointmentListCreateView.as_view(), name="appointment-list"),
path("appointments/<str:pk>/", AppointmentDetailView.as_view(), name="appointment-detail"),
]
View File
+21
View File
@@ -0,0 +1,21 @@
from django.contrib import admin
from .models import Article
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ("title", "slug", "is_published", "published_at")
list_filter = ("is_published",)
search_fields = ("title", "slug", "excerpt")
prepopulated_fields = {"slug": ("title",)}
fields = (
"title",
"slug",
"excerpt",
"meta_description",
"content_html",
"read_minutes",
"is_published",
"published_at",
)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class LandingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.landing"
verbose_name = "مقالات"
+49
View File
@@ -0,0 +1,49 @@
"""تبدیل تاریخ میلادی به شمسی برای نمایش تاریخ انتشار مقاله — بدون وابستگی
به پکیج خارجی (همان الگوریتم استاندارد تبدیل جلالی)."""
_PERSIAN_MONTHS = [
"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند",
]
_PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
def gregorian_to_jalali(date) -> tuple[int, int, int]:
gy, gm, gd = date.year, date.month, date.day
g_days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
gy2 = gy - 1600
gm2 = gm - 1
gd2 = gd - 1
g_day_no = 365 * gy2 + (gy2 + 3) // 4 - (gy2 + 99) // 100 + (gy2 + 399) // 400
for i in range(gm2):
g_day_no += g_days_in_month[i]
if gm2 > 1 and ((gy % 4 == 0 and gy % 100 != 0) or gy % 400 == 0):
g_day_no += 1
g_day_no += gd2
j_day_no = g_day_no - 79
j_np = j_day_no // 12053
j_day_no %= 12053
jy = 979 + 33 * j_np + 4 * (j_day_no // 1461)
j_day_no %= 1461
if j_day_no >= 366:
jy += (j_day_no - 1) // 365
j_day_no = (j_day_no - 1) % 365
j_days_in_month = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29]
jm = 0
for i, days in enumerate(j_days_in_month):
if j_day_no < days:
jm = i
break
j_day_no -= days
else:
jm = 11
jd = j_day_no + 1
return jy, jm + 1, jd
def format_jalali(date) -> str:
jy, jm, jd = gregorian_to_jalali(date)
text = f"{jd} {_PERSIAN_MONTHS[jm - 1]} {jy}"
return text.translate(_PERSIAN_DIGITS)
+35
View File
@@ -0,0 +1,35 @@
# Generated by Django 5.1.15 on 2026-08-23 21:51
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Article',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=120, unique=True, verbose_name='نامک (slug)')),
('title', models.CharField(max_length=200, verbose_name='عنوان')),
('meta_description', models.CharField(blank=True, help_text='در تگ\u200cهای meta description و og:description استفاده می\u200cشود.', max_length=300, verbose_name='توضیح متا (SEO)')),
('excerpt', models.CharField(help_text='همان متن کوتاهی که زیر عنوان در لیست مقالات نشان داده می\u200cشود.', max_length=300, verbose_name='خلاصه')),
('content_html', models.TextField(help_text='محتوای مقاله با تگ\u200cهای HTML ساده مثل <p>، <h2>، <strong>.', verbose_name='محتوا (HTML)')),
('read_minutes', models.PositiveSmallIntegerField(default=3, verbose_name='زمان مطالعه (دقیقه)')),
('is_published', models.BooleanField(default=True, verbose_name='منتشرشده')),
('published_at', models.DateField(verbose_name='تاریخ انتشار')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'مقاله',
'verbose_name_plural': 'مقالات',
'ordering': ('-published_at', '-created_at'),
},
),
]
@@ -0,0 +1,210 @@
import datetime
from django.db import migrations
ARTICLES = [
{
"slug": "peyam-goruhi-ba-shomare",
"title": "پیام گروهی با شماره چیست و چطور بدون مسدود شدن ارسال کنیم؟",
"meta_description": "راهنمای ارسال پیام گروهی با شماره: فرق روش رسمی (Business/Bot API) با روش‌های غیررسمی، و چرا روش دوم دیر یا زود منجر به مسدود شدن می‌شود.",
"excerpt": "فرق ارسال گروهی رسمی با روش‌های غیررسمی، و چرا مسیر Business/Bot API تنها راه پایدار است.",
"read_minutes": 4,
"content_html": """
<p>
«پیام گروهی با شماره» یعنی ارسال یک پیام (پیامک، واتساپ، تلگرام و…) به
لیستی از شماره‌ها، معمولاً با متنی که برای هر گیرنده کمی شخصی‌سازی
شده — مثلاً با نام یا یک اطلاعات اختصاصی دیگر. سوالی که همیشه پشت این
نیاز می‌آید این است: چطور این کار را انجام بدهیم بدون این‌که شماره یا
اکانت فرستنده مسدود شود؟
</p>
<h2>چرا اکانت‌ها مسدود می‌شوند</h2>
<p>
واتساپ، تلگرام، بله و ایتا همه یک سیستم ضدهرزنامه دارند که رفتار
غیرطبیعی (ارسال حجم بالا از یک شماره‌ی معمولی، چرخاندن چند دستگاه/سشن،
شبیه‌سازی رفتار انسانی برای فرار از تشخیص) را شناسایی و مسدود می‌کند.
این محدودیت تصادفی نیست — دقیقاً همان مکانیزمی است که جلوی اسپم واقعی
را می‌گیرد.
</p>
<h2>راه پایدار: مسیر رسمی هر پلتفرم</h2>
<p>
هر پلتفرم یک راه رسمی برای ارسال حجمی به کسب‌وکارها دارد:
</p>
<p>
<strong>پیامک</strong> از طریق درگاه‌های بالک اپراتور، <strong>واتساپ</strong>
از طریق WhatsApp Business Platform (نیاز به تایید Template پیام دارد)،
و <strong>تلگرام/بله/ایتا</strong> از طریق Bot API — با یک محدودیت مهم:
بات فقط می‌تواند به کسانی پیام بدهد که خودشان قبلاً چت را با بات شروع
کرده‌اند (opt-in).
</p>
<h2>یعنی چه چیزی امکان‌پذیر نیست؟</h2>
<p>
ارسال خودکار به هر شماره‌ی دلخواه در تلگرام/بله/ایتا بدون این‌که آن
شخص قبلاً با بات تعامل داشته باشد، از مسیر رسمی امکان‌پذیر نیست — و
هیچ روش «دور زدن» پایداری هم برایش وجود ندارد. کاسبیار برای این
موارد به‌جای اتوماسیون مخفی، یک صف ارسال دستیِ یک‌لمسی نشان می‌دهد:
اپلیکیشن مقصد را با متن آماده باز می‌کند، خودت دستی می‌زنی ارسال.
</p>
<h2>جمع‌بندی</h2>
<p>
پیام گروهی پایدار یعنی حجم را از مسیر رسمی هر پلتفرم رد کنی، نه این‌که
رفتار انسانی را شبیه‌سازی کنی. کندتر شروع می‌شود (چون باید Template یا
Bot تاییدشده داشته باشی) ولی هرگز اکانتت را به خطر نمی‌اندازد.
</p>
""",
},
{
"slug": "field-sefareshi-dar-template",
"title": "فیلد سفارشی در قالب پیام یعنی چه؟",
"meta_description": "چطور برای هر گروه مخاطب فیلدهای سفارشی مثل شماره گذرنامه یا کد ملی تعریف کنی و در قالب پیام همان گروه استفاده کنی.",
"excerpt": "چطور برای هر گروه مخاطب، فیلدهای مخصوص خودش را تعریف کنی و در متن پیام استفاده کنی.",
"read_minutes": 3,
"content_html": """
<p>
قالب پیام معمولی یک متن ثابت با چند جای‌خالی است، مثلاً «سلام {نام}
عزیز». اما خیلی از کسب‌وکارها به بیشتر از نام و شماره نیاز دارند — یک
آژانس مسافرتی به «شماره گذرنامه» نیاز دارد، یک کلینیک به «تاریخ نوبت»،
یک باشگاه به «کد عضویت». این‌جاست که فیلد سفارشی وارد می‌شود.
</p>
<h2>فیلد سفارشی مال یک گروه است، نه کل مخاطبین</h2>
<p>
در کاسبیار، مخاطبین را در گروه‌هایی مثل «تور»، «مشتریان VIP» یا
«دانشجویان» دسته‌بندی می‌کنی. هر گروه فیلدهای سفارشی مخصوص خودش را
دارد — یعنی گروه «تور» می‌تواند «شماره گذرنامه» داشته باشد بدون این‌که
این فیلد بی‌ربط در گروه «دانشجویان» هم ظاهر شود.
</p>
<h2>گروه عمومی برای وقتی که پیچیدگی لازم نیست</h2>
<p>
اگر نیازی به فیلد سفارشی نداری، همان گروه «عمومی» پیش‌فرض کافیست —
فقط نام و شماره. هیچ فیلدی مجبور نیستی اضافه کنی.
</p>
<h2>استفاده در قالب پیام</h2>
<p>
بعد از تعریف فیلد در یک گروه، هنگام نوشتن قالب پیام برای همان گروه،
اسم فیلد به‌عنوان یک تگ قابل‌درج (مثل <code>{شماره گذرنامه}</code>)
کنار فیلدهای پایه (نام، شماره) نمایش داده می‌شود. برای هر گیرنده، اپ
خودکار مقدار واقعی همان مخاطب را جایگزین می‌کند.
</p>
""",
},
{
"slug": "merge-mokhatebin-ganoshi",
"title": "مرج مخاطبین گوشی با اپ",
"meta_description": "سریع‌ترین راه برای شروع ارسال گروهی: مخاطبین گوشی‌ات را بدون تایپ دستی وارد کاسبیار کن و به گروه دلخواه اضافه کن.",
"excerpt": "سریع‌ترین راه برای شروع: مخاطبین گوشی‌ات را بدون تایپ دستی وارد کاسبیار کن.",
"read_minutes": 2,
"content_html": """
<p>
اگر لیست مخاطبینت همین حالا در دفترچه‌تلفن گوشی‌ات است، نیازی به
تایپ دوباره‌ی تک‌تک شماره‌ها نیست. کاسبیار می‌تواند مستقیم به
مخاطبین گوشی‌ات دسترسی بگیرد (با اجازه‌ی خودت) و هرکدام را که خواستی
یک‌جا وارد اپ کند.
</p>
<h2>چطور کار می‌کند</h2>
<p>
از صفحه‌ی «مخاطبین» گزینه‌ی «مرج از مخاطبین گوشی» را بزن. اپ لیست
مخاطبین گوشی‌ات را نشان می‌دهد (فقط آن‌هایی که هنوز در کاسبیار
نیستند)، هرکدام را انتخاب می‌کنی، یک گروه مقصد مشخص می‌کنی، و همه
یک‌جا اضافه می‌شوند.
</p>
<h2>دوباره‌کاری نمی‌شود</h2>
<p>
مخاطبینی که شماره‌شان از قبل در اپ ثبت شده، دوباره در لیست مرج نشان
داده نمی‌شوند — تطبیق بر اساس شماره‌ی نرمال‌شده انجام می‌شود، نه اسم.
</p>
<h2>حریم خصوصی</h2>
<p>
دسترسی به مخاطبین فقط برای همین عملیات استفاده می‌شود؛ چیزی خودکار یا
در پس‌زمینه آپلود نمی‌شود — فقط همان مخاطبینی که خودت تیک می‌زنی.
</p>
""",
},
{
"slug": "sms-ya-whatsapp-business",
"title": "پیامک یا واتساپ‌بیزینس؟ کدام برای ارسال گروهی بهتر است",
"meta_description": "مقایسه‌ی پیامک و واتساپ‌بیزینس برای ارسال پیام گروهی: هزینه، نرخ باز شدن، محدودیت Template، و کدام برای چه کسب‌وکاری مناسب‌تر است.",
"excerpt": "مقایسه‌ی هزینه، نرخ باز شدن، و محدودیت‌های هرکدام برای پیام گروهی کسب‌وکارها.",
"read_minutes": 5,
"content_html": """
<p>
هر دو کانال جای خودشان را دارند و در کاسبیار می‌توانی همزمان از
هردو (به‌علاوه‌ی تلگرام، بله و ایتا) استفاده کنی. اما اگر مجبوری یکی
را اول انتخاب کنی، این تفاوت‌ها کمک می‌کند:
</p>
<h2>پیامک</h2>
<p>
به هر شماره‌ای می‌رسد، حتی بدون اینترنت یا اپ نصب‌شده — قابل‌اطمینان‌ترین
کانال از نظر تحویل. هزینه‌ی هر پیام مشخص و قابل‌پیش‌بینی است. محدودیت:
طول پیام (پیامک‌های بلند چند-قسمتی حساب می‌شوند) و نداشتن تصویر/دکمه.
</p>
<h2>واتساپ‌بیزینس</h2>
<p>
نرخ باز شدن پیام معمولاً بالاتر از پیامک است و امکاناتی مثل دکمه و
رسانه دارد. اما برای ارسال گروهی (business-initiated) باید از یک
Template از پیش تاییدشده در Meta Business Manager استفاده کنی —
یعنی نمی‌توانی هر متن دلخواهی را بی‌محدودیت به هرکسی بفرستی؛ باید
ساختار پیام از قبل تایید شده باشد.
</p>
<h2>تلگرام، بله، ایتا</h2>
<p>
این‌ها فقط به کسانی می‌رسند که قبلاً با بات کسب‌وکارت چت را شروع
کرده‌اند. برای مخاطبینی که این کار را نکرده‌اند، کاسبیار یک صف
ارسال دستیِ یک‌لمسی نشان می‌دهد.
</p>
<h2>جمع‌بندی</h2>
<p>
اگر تحویل تضمینی برایت مهم‌تر است، پیامک را انتخاب کن. اگر مخاطبینت
بیشتر واتساپی هستند و Template تاییدشده داری، واتساپ‌بیزینس گزینه‌ی
بهتری است. بهترین نتیجه معمولاً ترکیب چند کانال است — دقیقاً همان
چیزی که کاسبیار از یک صفحه انجام می‌دهد.
</p>
""",
},
]
def seed_articles(apps, schema_editor):
Article = apps.get_model("landing", "Article")
published_at = datetime.date(2025, 8, 23)
for data in ARTICLES:
Article.objects.update_or_create(
slug=data["slug"],
defaults={
"title": data["title"],
"meta_description": data["meta_description"],
"excerpt": data["excerpt"],
"content_html": data["content_html"],
"read_minutes": data["read_minutes"],
"is_published": True,
"published_at": published_at,
},
)
def remove_articles(apps, schema_editor):
Article = apps.get_model("landing", "Article")
Article.objects.filter(slug__in=[a["slug"] for a in ARTICLES]).delete()
class Migration(migrations.Migration):
dependencies = [
("landing", "0001_initial"),
]
operations = [
migrations.RunPython(seed_articles, remove_articles),
]
View File
+35
View File
@@ -0,0 +1,35 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
class Article(models.Model):
"""مقاله‌ی لندینگ‌پیج — از پنل ادمین مدیریت می‌شود و در «/articles/» سرو
می‌شود (به‌جای فایل‌های HTML استاتیک قبلی)."""
slug = models.SlugField(unique=True, max_length=120, verbose_name=_("نامک (slug)"))
title = models.CharField(max_length=200, verbose_name=_("عنوان"))
meta_description = models.CharField(
max_length=300, blank=True, verbose_name=_("توضیح متا (SEO)"),
help_text=_("در تگ‌های meta description و og:description استفاده می‌شود."),
)
excerpt = models.CharField(
max_length=300, verbose_name=_("خلاصه"),
help_text=_("همان متن کوتاهی که زیر عنوان در لیست مقالات نشان داده می‌شود."),
)
content_html = models.TextField(
verbose_name=_("محتوا (HTML)"),
help_text=_("محتوای مقاله با تگ‌های HTML ساده مثل <p>، <h2>، <strong>."),
)
read_minutes = models.PositiveSmallIntegerField(default=3, verbose_name=_("زمان مطالعه (دقیقه)"))
is_published = models.BooleanField(default=True, verbose_name=_("منتشرشده"))
published_at = models.DateField(verbose_name=_("تاریخ انتشار"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("مقاله")
verbose_name_plural = _("مقالات")
ordering = ("-published_at", "-created_at")
def __str__(self):
return self.title
@@ -0,0 +1,59 @@
{% load landing_extras %}
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ article.title }} | کاسبیار</title>
<meta name="description" content="{{ article.meta_description }}">
<link rel="canonical" href="https://mobiniqow.ir/articles/{{ article.slug }}/">
<meta property="og:type" content="article">
<meta property="og:title" content="{{ article.title }}">
<meta property="og:description" content="{{ article.meta_description }}">
<meta name="theme-color" content="#107c41">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
<link rel="stylesheet" href="/assets/atlas.css">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "{{ article.title }}",
"datePublished": "{{ article.published_at|date:'Y-m-d' }}",
"author": { "@type": "Organization", "name": "کاسبیار" }
}
</script>
</head>
<body>
<header class="nav container">
<a class="nav-brand" href="/">کاسبیار</a>
<nav class="nav-links">
<a href="/#features">امکانات</a>
<a href="/articles/">مقالات</a>
</nav>
</header>
<main class="container" style="padding: var(--space-12) 0;">
<a class="back-link" href="/articles/">← بازگشت به مقالات</a>
<div class="article-header">
<div class="meta">{{ article.published_at|jalali }} · {{ article.read_minutes|fa_digits }} دقیقه مطالعه</div>
<h1>{{ article.title }}</h1>
</div>
<div class="article-body">
{{ article.content_html|safe }}
</div>
</main>
<footer>
<div class="container">
<span>© کاسبیار — ابزار ارسال پیام گروهی</span>
<span><a href="/">صفحه‌ی اصلی</a></span>
</div>
</footer>
</body>
</html>
@@ -0,0 +1,52 @@
{% load landing_extras %}
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>مقالات — کاسبیار | راهنمای ارسال پیام گروهی</title>
<meta name="description" content="راهنما و مقالات کاسبیار درباره‌ی ارسال پیام گروهی با شماره: پیامک، واتساپ‌بیزینس، فیلد سفارشی در قالب پیام، و مرج مخاطبین گوشی.">
<link rel="canonical" href="https://mobiniqow.ir/articles/">
<meta name="theme-color" content="#107c41">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
<link rel="stylesheet" href="/assets/atlas.css">
</head>
<body>
<header class="nav container">
<a class="nav-brand" href="/">کاسبیار</a>
<nav class="nav-links">
<a href="/#features">امکانات</a>
<a href="/#channels">کانال‌ها</a>
<a href="/articles/">مقالات</a>
</nav>
</header>
<main class="container" style="padding: var(--space-12) 0;">
<div class="section-head" style="text-align:right; margin-inline:0;">
<h1>مقالات</h1>
<p>راهنمای ارسال پیام گروهی با شماره — پیامک، واتساپ، فیلد سفارشی و مرج مخاطبین.</p>
</div>
<div class="article-list">
{% for article in articles %}
<article class="article-row">
<span class="meta">{{ article.published_at|jalali }} · {{ article.read_minutes|fa_digits }} دقیقه مطالعه</span>
<h2><a href="/articles/{{ article.slug }}/">{{ article.title }}</a></h2>
<p>{{ article.excerpt }}</p>
</article>
{% endfor %}
</div>
</main>
<footer>
<div class="container">
<span>© کاسبیار — ابزار ارسال پیام گروهی</span>
<span><a href="/">صفحه‌ی اصلی</a></span>
</div>
</footer>
</body>
</html>
@@ -0,0 +1,17 @@
from django import template
from ..jalali import _PERSIAN_DIGITS, format_jalali
register = template.Library()
@register.filter
def jalali(date):
if date is None:
return ""
return format_jalali(date)
@register.filter
def fa_digits(value):
return str(value).translate(_PERSIAN_DIGITS)
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "landing"
urlpatterns = [
path("", views.article_list, name="article_list"),
path("<slug:slug>/", views.article_detail, name="article_detail"),
]
+13
View File
@@ -0,0 +1,13 @@
from django.shortcuts import get_object_or_404, render
from .models import Article
def article_list(request):
articles = Article.objects.filter(is_published=True)
return render(request, "landing/article_list.html", {"articles": articles})
def article_detail(request, slug):
article = get_object_or_404(Article, slug=slug, is_published=True)
return render(request, "landing/article_detail.html", {"article": article})
View File
+25
View File
@@ -0,0 +1,25 @@
from django.contrib import admin
from .models import BulkMessage, MessageRecipient
class MessageRecipientInline(admin.TabularInline):
model = MessageRecipient
extra = 0
readonly_fields = ("contact", "channel", "status", "rendered_body", "error", "updated_at")
can_delete = False
@admin.register(BulkMessage)
class BulkMessageAdmin(admin.ModelAdmin):
list_display = ("id", "user", "group", "channels", "status", "created_at")
list_filter = ("status",)
search_fields = ("user__phone",)
inlines = [MessageRecipientInline]
@admin.register(MessageRecipient)
class MessageRecipientAdmin(admin.ModelAdmin):
list_display = ("contact", "channel", "status", "bulk_message", "updated_at")
list_filter = ("channel", "status")
search_fields = ("contact__name", "contact__phone")
View File
+47
View File
@@ -0,0 +1,47 @@
from rest_framework import serializers
from apps.messaging.models import BulkMessage, Channel, MessageRecipient
class SendBulkMessageSerializer(serializers.Serializer):
group_id = serializers.CharField(required=False, allow_null=True, default=None)
contact_ids = serializers.ListField(child=serializers.CharField(), required=False, default=list)
template_id = serializers.CharField(required=False, allow_null=True, default=None)
body_override = serializers.CharField(required=False, allow_blank=True, default="")
channels = serializers.ListField(child=serializers.ChoiceField(choices=Channel.choices), min_length=1)
def validate(self, attrs):
if not attrs.get("template_id") and not attrs.get("body_override"):
raise serializers.ValidationError("یا template_id بده یا body_override.")
return attrs
class MessageRecipientSerializer(serializers.ModelSerializer):
contact_name = serializers.CharField(source="contact.name", read_only=True)
contact_phone = serializers.CharField(source="contact.phone", read_only=True)
class Meta:
model = MessageRecipient
fields = [
"id",
"contact",
"contact_name",
"contact_phone",
"channel",
"status",
"rendered_body",
"error",
]
class BulkMessageSerializer(serializers.ModelSerializer):
recipients = MessageRecipientSerializer(many=True, read_only=True)
needs_manual = serializers.SerializerMethodField()
class Meta:
model = BulkMessage
fields = ["id", "group", "template", "channels", "status", "created_at", "recipients", "needs_manual"]
def get_needs_manual(self, obj: BulkMessage):
pending = obj.recipients.filter(status=MessageRecipient.Status.NEEDS_MANUAL)
return MessageRecipientSerializer(pending, many=True).data
+166
View File
@@ -0,0 +1,166 @@
import re
from django.db import transaction
from apps.account.models import User
from apps.banker.models import Contact, ContactGroup, MessageTemplate
from apps.messaging.models import BulkMessage, Channel, MessageRecipient
from apps.messaging.providers.bale import send_bale
from apps.messaging.providers.eitaa import send_eitaa
from apps.messaging.providers.sms import send_bulk_sms
from apps.messaging.providers.telegram import send_telegram
from apps.messaging.providers.whatsapp import send_whatsapp
_FIELD_PATTERN = re.compile(r"\{(.*?)\}")
_BOT_CHANNEL_SENDERS = {
Channel.TELEGRAM: send_telegram,
Channel.BALE: send_bale,
Channel.EITAA: send_eitaa,
}
class TemplateRenderer:
@staticmethod
def field_values(contact: Contact) -> dict[str, str]:
"""برچسب فیلد -> مقدار، برای جایگزینی {برچسب} در متن قالب.
فیلدهای پایه همیشه هستند؛ فیلدهای سفارشی از گروه(های)ی می‌آیند که
این مخاطب عضوشان است (ContactGroup.member_ids شامل id این مخاطب).
"""
values = {"نام": contact.name, "شماره": contact.phone}
# فیلتر JSON-level (member_ids__contains) روی SQLite اصلا قابل‌کامپایل
# نیست (فقط PostgreSQL پشتیبانی می‌کند) — چون تعداد گروه‌های هر کاربر
# کم است، اینجا فیلتر را سمت پایتون انجام می‌دهیم تا روی هر دو دیتابیس
# کار کند.
groups = [
g
for g in ContactGroup.objects.filter(user_id=contact.user_id)
if contact.id in g.member_ids
]
for group in groups:
for field in group.fields.all():
value = contact.field_values.get(field.id)
if value:
values[field.label] = value
return values
@staticmethod
def render(contact: Contact, template: MessageTemplate | None, body_override: str) -> str:
body = template.body if template else body_override
values = TemplateRenderer.field_values(contact)
return _FIELD_PATTERN.sub(lambda m: values.get(m.group(1), m.group(0)), body)
class BulkMessageService:
@staticmethod
def _resolve_contacts(user: User, group_id: str | None, contact_ids: list[str] | None) -> list[Contact]:
if contact_ids:
return list(Contact.objects.filter(user=user, id__in=contact_ids))
if group_id:
group = ContactGroup.objects.get(user=user, id=group_id)
return list(Contact.objects.filter(user=user, id__in=group.member_ids))
return list(Contact.objects.filter(user=user))
@staticmethod
@transaction.atomic
def create_and_dispatch(
user: User,
channels: list[str],
group_id: str | None = None,
contact_ids: list[str] | None = None,
template_id: str | None = None,
body_override: str = "",
) -> BulkMessage:
template = MessageTemplate.objects.filter(user=user, id=template_id).first() if template_id else None
contacts = BulkMessageService._resolve_contacts(user, group_id, contact_ids)
bulk = BulkMessage.objects.create(
user=user,
group_id=group_id or None,
template=template,
body_override=body_override,
channels=channels,
)
rendered_by_contact = {c.id: TemplateRenderer.render(c, template, body_override) for c in contacts}
if Channel.SMS in channels:
BulkMessageService._dispatch_sms(bulk, contacts, rendered_by_contact)
for channel in (Channel.TELEGRAM, Channel.BALE, Channel.EITAA):
if channel in channels:
BulkMessageService._dispatch_bot_channel(bulk, contacts, rendered_by_contact, channel)
if Channel.WHATSAPP in channels:
BulkMessageService._dispatch_whatsapp(bulk, contacts, rendered_by_contact)
has_manual = bulk.recipients.filter(status=MessageRecipient.Status.NEEDS_MANUAL).exists()
bulk.status = BulkMessage.Status.PARTIALLY_MANUAL if has_manual else BulkMessage.Status.DISPATCHED
bulk.save(update_fields=["status"])
return bulk
@staticmethod
def _dispatch_sms(bulk: BulkMessage, contacts: list[Contact], rendered_by_contact: dict) -> None:
pairs = [(c.phone, rendered_by_contact[c.id]) for c in contacts]
results = send_bulk_sms(pairs)
MessageRecipient.objects.bulk_create(
MessageRecipient(
bulk_message=bulk,
contact=c,
channel=Channel.SMS,
status=MessageRecipient.Status.SENT if results.get(c.phone) else MessageRecipient.Status.FAILED,
rendered_body=rendered_by_contact[c.id],
)
for c in contacts
)
@staticmethod
def _dispatch_bot_channel(
bulk: BulkMessage, contacts: list[Contact], rendered_by_contact: dict, channel: str
) -> None:
sender = _BOT_CHANNEL_SENDERS[channel]
recipients = []
for c in contacts:
chat_id = c.channel_ids.get(channel)
body = rendered_by_contact[c.id]
if not chat_id:
status = MessageRecipient.Status.NEEDS_MANUAL
else:
status = MessageRecipient.Status.SENT if sender(chat_id, body) else MessageRecipient.Status.FAILED
recipients.append(
MessageRecipient(
bulk_message=bulk, contact=c, channel=channel, status=status, rendered_body=body
)
)
MessageRecipient.objects.bulk_create(recipients)
@staticmethod
def _dispatch_whatsapp(bulk: BulkMessage, contacts: list[Contact], rendered_by_contact: dict) -> None:
from django.conf import settings
recipients = []
configured = bool(settings.WHATSAPP_ACCESS_TOKEN and settings.WHATSAPP_PHONE_NUMBER_ID)
for c in contacts:
body = rendered_by_contact[c.id]
if not configured:
status = MessageRecipient.Status.NEEDS_MANUAL
else:
status = (
MessageRecipient.Status.SENT
if send_whatsapp(c.phone, body)
else MessageRecipient.Status.FAILED
)
recipients.append(
MessageRecipient(
bulk_message=bulk, contact=c, channel=Channel.WHATSAPP, status=status, rendered_body=body
)
)
MessageRecipient.objects.bulk_create(recipients)
@staticmethod
def confirm_manual(user: User, bulk_id: int, recipient_id: int) -> MessageRecipient:
recipient = MessageRecipient.objects.get(
id=recipient_id, bulk_message_id=bulk_id, bulk_message__user=user
)
recipient.status = MessageRecipient.Status.MANUAL_CONFIRMED
recipient.save(update_fields=["status"])
return recipient
+92
View File
@@ -0,0 +1,92 @@
from django.db.models import Count
from rest_framework import status
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.generics import RetrieveAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.banker.models import ContactGroup
from apps.billing.permissions import HasActiveFeature
from apps.messaging.models import BulkMessage, MessageRecipient
from .serializers import BulkMessageSerializer, SendBulkMessageSerializer
from .services import BulkMessageService
class SendBulkMessageView(APIView):
"""POST: می‌سازد و بلافاصله dispatch می‌کند (پیامک/کانال‌های Business-API-دار
خودکار، بقیه needs_manual برای صف deep-link سمت اپ)."""
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
throttle_scope = "messaging_send"
def post(self, request):
serializer = SendBulkMessageSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
if data["group_id"] and not ContactGroup.objects.filter(user=request.user, id=data["group_id"]).exists():
raise ValidationError({"group_id": "گروه یافت نشد."})
bulk = BulkMessageService.create_and_dispatch(
user=request.user,
channels=data["channels"],
group_id=data["group_id"],
contact_ids=data["contact_ids"],
template_id=data["template_id"],
body_override=data["body_override"],
)
return Response(BulkMessageSerializer(bulk).data, status=status.HTTP_201_CREATED)
class BulkMessageDetailView(RetrieveAPIView):
serializer_class = BulkMessageSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return BulkMessage.objects.filter(user=self.request.user)
class MessagingStatsView(APIView):
"""آمار ارسال: چند پیام موفق به گیت‌وی/API فرستاده شده، چند تا ناموفق
بوده، چند تا نیاز به تایید دستی داشته/دارد — بر اساس MessageRecipient
(نه رسید تحویل واقعی گوشی، چون آن نیاز به webhook از سرویس‌دهنده‌ی
پیامک دارد که در این نسخه وصل نشده؛ «ارسال‌شده» یعنی سرور با موفقیت آن
را به API/گیت‌وی تحویل داده)."""
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get(self, request):
rows = (
MessageRecipient.objects.filter(bulk_message__user=request.user)
.values("status")
.annotate(count=Count("id"))
)
counts = {row["status"]: row["count"] for row in rows}
total = sum(counts.values())
return Response(
{
"total": total,
"sent": counts.get(MessageRecipient.Status.SENT, 0),
"failed": counts.get(MessageRecipient.Status.FAILED, 0),
"needs_manual": counts.get(MessageRecipient.Status.NEEDS_MANUAL, 0),
"manual_confirmed": counts.get(MessageRecipient.Status.MANUAL_CONFIRMED, 0),
}
)
class ConfirmManualSendView(APIView):
"""اپ بعد از این‌که کاربر دستی در واتساپ/تلگرام/بله/ایتا زد «ارسال»، این را صدا می‌زند."""
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def post(self, request, bulk_id, recipient_id):
try:
recipient = BulkMessageService.confirm_manual(request.user, bulk_id, recipient_id)
except MessageRecipient.DoesNotExist:
raise NotFound("گیرنده یافت نشد.")
return Response({"success": True, "status": recipient.status})
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class MessagingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.messaging"
verbose_name = "ارسال چندکاناله"
+54
View File
@@ -0,0 +1,54 @@
# Generated by Django 5.1.15 on 2026-08-22 15:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('banker', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BulkMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body_override', models.TextField(blank=True, verbose_name='متن آزاد (اگر قالب انتخاب نشده)')),
('channels', models.JSONField(default=list, verbose_name='کانال\u200cهای انتخاب\u200cشده')),
('status', models.CharField(choices=[('dispatched', 'ارسال\u200cشده'), ('partially_manual', 'بخشی نیاز به تایید دستی')], default='dispatched', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='banker.contactgroup')),
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='banker.messagetemplate')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'ارسال گروهی',
'verbose_name_plural': 'ارسال\u200cهای گروهی',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='MessageRecipient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel', models.CharField(choices=[('sms', 'پیامک'), ('whatsapp', 'واتساپ'), ('telegram', 'تلگرام'), ('bale', 'بله'), ('eitaa', 'ایتا')], max_length=10)),
('status', models.CharField(choices=[('sent', 'ارسال\u200cشده'), ('failed', 'ناموفق'), ('needs_manual', 'نیاز به تایید دستی'), ('manual_confirmed', 'تایید دستی شد')], max_length=20)),
('rendered_body', models.TextField()),
('error', models.CharField(blank=True, max_length=255)),
('updated_at', models.DateTimeField(auto_now=True)),
('bulk_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipients', to='messaging.bulkmessage')),
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='banker.contact')),
],
options={
'verbose_name': 'گیرنده\u200cی پیام',
'verbose_name_plural': 'گیرنده\u200cهای پیام',
'indexes': [models.Index(fields=['bulk_message', 'status'], name='messaging_m_bulk_me_a7a1eb_idx')],
},
),
]
+70
View File
@@ -0,0 +1,70 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.banker.models import Contact, ContactGroup, MessageTemplate
# این اپ فقط از مسیرهای رسمی هر پلتفرم برای ارسال استفاده می‌کند: پیامک از
# طریق درگاه بالک کاوه‌نگار (سمت سرور، خودکار)، واتساپ از طریق WhatsApp
# Business Cloud API رسمی، تلگرام/بله/ایتا از طریق Bot API رسمی هرکدام —
# فقط برای مخاطبینی که قبلا چت با بات را شروع کرده‌اند (opt-in). برای بقیه‌ی
# موارد، MessageRecipient با status=needs_manual برمی‌گردد تا اپ یک صف
# deep-link یک‌لمسی به کاربر نشان دهد؛ هیچ اتوماسیون/شبیه‌سازی رفتار انسانی
# برای دور زدن ضدهرزنامه‌ی این پلتفرم‌ها در این اپ وجود ندارد.
class Channel(models.TextChoices):
SMS = "sms", _("پیامک")
WHATSAPP = "whatsapp", _("واتساپ")
TELEGRAM = "telegram", _("تلگرام")
BALE = "bale", _("بله")
EITAA = "eitaa", _("ایتا")
class BulkMessage(models.Model):
class Status(models.TextChoices):
DISPATCHED = "dispatched", _("ارسال‌شده")
PARTIALLY_MANUAL = "partially_manual", _("بخشی نیاز به تایید دستی")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bulk_messages")
# null = همه‌ی مخاطبین کاربر
group = models.ForeignKey(ContactGroup, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
template = models.ForeignKey(
MessageTemplate, on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
)
body_override = models.TextField(blank=True, verbose_name=_("متن آزاد (اگر قالب انتخاب نشده)"))
channels = models.JSONField(default=list, verbose_name=_("کانال‌های انتخاب‌شده"))
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DISPATCHED)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("ارسال گروهی")
verbose_name_plural = _("ارسال‌های گروهی")
ordering = ["-created_at"]
def __str__(self):
return f"#{self.pk} — {self.user.phone} — {', '.join(self.channels)}"
class MessageRecipient(models.Model):
class Status(models.TextChoices):
SENT = "sent", _("ارسال‌شده")
FAILED = "failed", _("ناموفق")
NEEDS_MANUAL = "needs_manual", _("نیاز به تایید دستی")
MANUAL_CONFIRMED = "manual_confirmed", _("تایید دستی شد")
bulk_message = models.ForeignKey(BulkMessage, on_delete=models.CASCADE, related_name="recipients")
contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name="+")
channel = models.CharField(max_length=10, choices=Channel.choices)
status = models.CharField(max_length=20, choices=Status.choices)
rendered_body = models.TextField()
error = models.CharField(max_length=255, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("گیرنده‌ی پیام")
verbose_name_plural = _("گیرنده‌های پیام")
indexes = [models.Index(fields=["bulk_message", "status"])]
def __str__(self):
return f"{self.contact.name} — {self.channel} — {self.status}"
+11
View File
@@ -0,0 +1,11 @@
# بله Bot API تلگرام‌سازگار است. اگر base_url حساب تو فرق دارد، همین یک
# مقدار را عوض کن — بقیه‌ی منطق در bot_api.py مشترک است.
from django.conf import settings
from .bot_api import send_bot_message
BALE_BASE_URL = "https://tapi.bale.ai"
def send_bale(chat_id: str, text: str) -> bool:
return send_bot_message(BALE_BASE_URL, settings.BALE_BOT_TOKEN, chat_id, text)
+39
View File
@@ -0,0 +1,39 @@
# apps/messaging/providers/bot_api.py
"""
پایه‌ی مشترک ارسال از طریق Bot API — تلگرام، بله و ایتا هرکدام یک Bot API
تلگرام‌سازگار دارند (endpoint یکسان: POST {base}/bot{token}/sendMessage با
بدنه‌ی {chat_id, text}). این یعنی این سه پلتفرم فقط base_url و token
متفاوت دارند؛ منطق ارسال یکی است.
نکته‌ی مهم — opt-in: بات فقط می‌تواند به chat_id هایی پیام بدهد که قبلا
خودشان چت با بات را با /start شروع کرده‌اند (دقیقا مثل قانون خودِ تلگرام).
یعنی این تابع فقط برای مخاطبینی کار می‌کند که chat_id شان قبلا (از طریق
webhook خود بات، خارج از این فایل) در Contact.channel_ids ثبت شده باشد؛ برای
بقیه، صدازننده باید needs_manual برگرداند — نه اینکه اینجا حدس بزند.
"""
import logging
import requests
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT_SECONDS = 10
def send_bot_message(base_url: str, token: str, chat_id: str, text: str) -> bool:
if not base_url or not token:
logger.error("توکن یا آدرس Bot API تنظیم نشده است")
return False
url = f"{base_url.rstrip('/')}/bot{token}/sendMessage"
try:
response = requests.post(
url, json={"chat_id": chat_id, "text": text}, timeout=REQUEST_TIMEOUT_SECONDS
)
response.raise_for_status()
body = response.json()
return bool(body.get("ok", True))
except (requests.RequestException, ValueError) as exc:
logger.error(f"❌ خطا در ارسال Bot API به {url}: {exc}")
return False
+11
View File
@@ -0,0 +1,11 @@
# ایتا هم یک Bot API تلگرام‌سازگار (Eitaa Payam) دارد. اگر ساختار حساب تو
# فرق دارد (مثلا آدرس دیگری برای endpoint)، همین base_url را عوض کن.
from django.conf import settings
from .bot_api import send_bot_message
EITAA_BASE_URL = "https://eitaayar.ir/api"
def send_eitaa(chat_id: str, text: str) -> bool:
return send_bot_message(EITAA_BASE_URL, settings.EITAA_BOT_TOKEN, chat_id, text)
+64
View File
@@ -0,0 +1,64 @@
# apps/messaging/providers/sms.py
"""
ارسال پیامک گروهی با متن آزاد (نه پترن OTP) — از همان کاوه‌نگاری که
apps/account/send_sms.py برای OTP استفاده می‌کند، ولی از API متفاوتی: پترن
OTP فقط اجازه‌ی ارسال یک قالب از پیش تاییدشده را می‌دهد، برای متن دلخواه باید
از API عادی ارسال (sms/sendarray.json برای چندگیرنده‌ی هم‌زمان با متن‌های
مجزا) استفاده کرد. این دو مسیر کاملا مستقل‌اند؛ send_sms.py دست‌نخورده ماند.
فقط دو مقدار از .env لازم است: KAVENEGAR_API_KEY و KAVENEGAR_SENDER_LINE.
اگر ساختار پاسخ/درخواست کاوه‌نگار حساب تو فرق دارد، فقط تابع
`_call_sendarray` را با مستندات https://kavenegar.com/rest.html هماهنگ کن.
"""
import json
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT_SECONDS = 15
KAVENEGAR_BASE = "https://api.kavenegar.com/v1"
def _call_sendarray(receptors: list[str], messages: list[str]) -> dict[str, bool]:
"""ارسال بالک؛ خروجی: {receptor: True/False} برای هر گیرنده."""
api_key = settings.KAVENEGAR_API_KEY
sender = settings.KAVENEGAR_SENDER_LINE
result = {r: False for r in receptors}
if not api_key or not sender:
logger.error("KAVENEGAR_API_KEY یا KAVENEGAR_SENDER_LINE در .env تنظیم نشده است")
return result
url = f"{KAVENEGAR_BASE}/{api_key}/sms/sendarray.json"
payload = {
"receptor": json.dumps(receptors),
"sender": json.dumps([sender] * len(receptors)),
"message": json.dumps(messages),
}
try:
response = requests.post(url, data=payload, timeout=REQUEST_TIMEOUT_SECONDS)
response.raise_for_status()
body = response.json()
if body.get("return", {}).get("status") == 200:
for r in receptors:
result[r] = True
else:
logger.error(f"❌ کاوه‌نگار خطا برگرداند: {body.get('return')}")
except (requests.RequestException, ValueError) as exc:
logger.error(f"❌ خطا در ارسال پیامک بالک: {exc}")
return result
def send_bulk_sms(recipients: list[tuple[str, str]]) -> dict[str, bool]:
"""recipients: [(phone, message), ...] -> {phone: sent_ok}"""
if not recipients:
return {}
phones = [p for p, _ in recipients]
messages = [m for _, m in recipients]
return _call_sendarray(phones, messages)
+9
View File
@@ -0,0 +1,9 @@
from django.conf import settings
from .bot_api import send_bot_message
TELEGRAM_BASE_URL = "https://api.telegram.org"
def send_telegram(chat_id: str, text: str) -> bool:
return send_bot_message(TELEGRAM_BASE_URL, settings.TELEGRAM_BOT_TOKEN, chat_id, text)
+63
View File
@@ -0,0 +1,63 @@
# apps/messaging/providers/whatsapp.py
"""
ارسال از طریق WhatsApp Business Cloud API رسمی (Meta) —
https://developers.facebook.com/docs/whatsapp/cloud-api
برای ارسال گروهی/تبلیغاتی (business-initiated، نه پاسخ به پیام کاربر)،
واتساپ فقط اجازه‌ی ارسال «Template Message» از قبل تاییدشده را می‌دهد؛ پیام
آزاد فقط داخل پنجره‌ی ۲۴ ساعته‌ی پاسخ به مشتری مجاز است. یعنی این تابع یک
Template با یک متغیر ({{1}}) صدا می‌زند که همان متن رندرشده‌ی قالب داخل اپ
در آن جای می‌گیرد — این Template باید از قبل در Meta Business Manager ثبت و
تایید شده باشد.
سه مقدار از .env لازم است: WHATSAPP_ACCESS_TOKEN، WHATSAPP_PHONE_NUMBER_ID،
WHATSAPP_TEMPLATE_NAME. اگر ساختار Template حساب تو (تعداد متغیرها، زبان)
فرق دارد، فقط بدنه‌ی payload در send_whatsapp را هماهنگ کن.
"""
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT_SECONDS = 15
GRAPH_API_VERSION = "v20.0"
def send_whatsapp(phone: str, text: str) -> bool:
token = settings.WHATSAPP_ACCESS_TOKEN
phone_number_id = settings.WHATSAPP_PHONE_NUMBER_ID
template_name = settings.WHATSAPP_TEMPLATE_NAME
if not token or not phone_number_id or not template_name:
logger.error("تنظیمات WhatsApp Business API کامل نیست")
return False
url = f"https://graph.facebook.com/{GRAPH_API_VERSION}/{phone_number_id}/messages"
payload = {
"messaging_product": "whatsapp",
"to": phone,
"type": "template",
"template": {
"name": template_name,
"language": {"code": "fa"},
"components": [
{"type": "body", "parameters": [{"type": "text", "text": text}]},
],
},
}
try:
response = requests.post(
url,
json=payload,
headers={"Authorization": f"Bearer {token}"},
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
return True
except requests.RequestException as exc:
logger.error(f"❌ خطا در ارسال WhatsApp به {phone}: {exc}")
return False
+16
View File
@@ -0,0 +1,16 @@
from django.urls import path
from .api.views import (
BulkMessageDetailView,
ConfirmManualSendView,
MessagingStatsView,
SendBulkMessageView,
)
app_name = "messaging"
urlpatterns = [
path("send/", SendBulkMessageView.as_view(), name="send"),
path("stats/", MessagingStatsView.as_view(), name="stats"),
path("<int:pk>/", BulkMessageDetailView.as_view(), name="detail"),
path("<int:bulk_id>/confirm/<int:recipient_id>/", ConfirmManualSendView.as_view(), name="confirm"),
]
View File
+24
View File
@@ -0,0 +1,24 @@
from django.contrib import admin
from .models import Notification, NotificationRead
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = ("title", "user", "created_at")
list_filter = ("created_at",)
search_fields = ("title", "body", "user__phone")
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
form.base_fields["user"].help_text = "خالی بگذار تا برای همه‌ی کاربران نمایش داده شود."
return form
@admin.register(NotificationRead)
class NotificationReadAdmin(admin.ModelAdmin):
list_display = ("user", "notification", "read_at")
search_fields = ("user__phone", "notification__title")
def has_add_permission(self, request):
return False

Some files were not shown because too many files have changed in this diff Show More