adding
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.idea/
|
||||||
|
staticfiles/
|
||||||
|
logs/
|
||||||
|
README.md
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
/staticfiles/
|
||||||
|
/logs/
|
||||||
|
/.venv/
|
||||||
|
*.sqlite3
|
||||||
|
.idea/
|
||||||
+41
@@ -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"]
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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": "حساب کاربری با موفقیت حذف شد."})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.account"
|
||||||
|
verbose_name = "حساب کاربری"
|
||||||
@@ -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': 'قوانین و مقررات',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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]
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BankerConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.banker"
|
||||||
|
verbose_name = "دادههای پیامکبان"
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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})"
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BillingConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.billing"
|
||||||
|
verbose_name = "جم، اشتراک و پرداخت"
|
||||||
@@ -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)]
|
||||||
@@ -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}"
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BizappsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.bizapps"
|
||||||
|
verbose_name = "ابزارهای کسبوکار"
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class LandingConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.landing"
|
||||||
|
verbose_name = "مقالات"
|
||||||
@@ -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)
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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})
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MessagingConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.messaging"
|
||||||
|
verbose_name = "ارسال چندکاناله"
|
||||||
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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
Reference in New Issue
Block a user