This commit is contained in:
2026-08-31 15:11:52 +03:30
parent 236002a87c
commit 463b812e3e
63 changed files with 3254 additions and 1476 deletions
-41
View File
@@ -1,41 +0,0 @@
# 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': 'کاربران',
},
),
]
@@ -1,40 +0,0 @@
# 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ی رایگان'),
),
]
@@ -1,25 +0,0 @@
# 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': 'قوانین و مقررات',
},
),
]
+41 -21
View File
@@ -1,15 +1,6 @@
# apps/account/send_sms.py # apps/account/send_sms.py
""" """
ارسال پیامک OTP. ارسال پیامک OTP با کاوه‌نگار
فقط دو مقدار از .env خوانده می‌شود (تنظیمات در config/settings.py):
- SMS_PATTERN_NAME: نام/کد قالبی که نزد سرویس‌دهنده‌ی پیامک ثبت شده
- SMS_TOKEN_URL: آدرسی که با یک درخواست POST، قالب+شماره+کد را برایش
می‌فرستیم (سرویس‌دهنده‌ی تو باید چنین آدرسی در اختیارت گذاشته باشد)
اگر ساختار درخواست سرویس‌دهنده‌ی واقعی‌ات با payload زیر فرق دارد، فقط
تابع `_call_token_url` را با مستندات سرویس‌دهنده‌ات هماهنگ کن؛ بقیه‌ی
پروژه با این ماژول کاری ندارد و فقط send_otp_message را صدا می‌زند.
""" """
import logging import logging
@@ -24,36 +15,65 @@ REQUEST_TIMEOUT_SECONDS = 10
def _call_token_url(phone: str, code: str) -> bool: def _call_token_url(phone: str, code: str) -> bool:
token_url = settings.SMS_TOKEN_URL """
pattern_name = settings.SMS_PATTERN_NAME ارسال OTP با استفاده از API کاوه‌نگار
"""
api_key = settings.KAVENEGAR_API_KEY
template = settings.SMS_PATTERN_NAME
if not token_url or not pattern_name: # ✅ لاگ برای دیباگ
logger.error("SMS_TOKEN_URL یا SMS_PATTERN_NAME در .env تنظیم نشده است") logger.info(f"📤 Sending OTP to {phone} with template: {template}")
if not api_key or not template:
logger.error("❌ KAVENEGAR_API_KEY یا SMS_PATTERN_NAME در .env تنظیم نشده است")
logger.error(f" KAVENEGAR_API_KEY: {'✅' if api_key else '❌'}")
logger.error(f" SMS_PATTERN_NAME: {'✅' if template else '❌'}")
return False return False
# ساخت آدرس کامل
url = f"https://api.kavenegar.com/v1/{api_key}/verify/lookup.json"
# پیلود مورد نیاز کاوه‌نگار
payload = { payload = {
"pattern": pattern_name,
"receptor": phone, "receptor": phone,
"token": code, "token": code,
"template": template,
} }
try: try:
response = requests.post(token_url, json=payload, timeout=REQUEST_TIMEOUT_SECONDS) logger.info(f"📤 POST {url}")
response.raise_for_status() response = requests.post(url, data=payload, timeout=REQUEST_TIMEOUT_SECONDS)
logger.info(f"✅ OTP sent to {phone} via {token_url}") response_data = response.json()
return True
# بررسی پاسخ
if response.status_code == 200:
return_status = response_data.get("return", {}).get("status")
if return_status == 200:
logger.info(f"✅ OTP sent to {phone} via Kavenegar")
return True
else:
error_msg = response_data.get("return", {}).get("message", "Unknown error")
logger.error(f"❌ Kavenegar error: {error_msg}")
return False
else:
logger.error(f"❌ Kavenegar HTTP error: {response.status_code} - {response.text}")
return False
except requests.Timeout:
logger.error(f"❌ Kavenegar timeout for {phone}")
return False
except requests.RequestException as exc: except requests.RequestException as exc:
logger.error(f"❌ خطا در ارسال پیامک به {phone}: {exc}") logger.error(f"❌ خطا در ارسال پیامک به {phone}: {exc}")
return False return False
def send_otp_message(phone: str, code: str) -> None: def send_otp_message(phone: str, code: str) -> None:
"""ارسال غیربلاکینگ کد تایید (شکست ارسال، درخواست HTTP کاربر را کند نمی‌کند)""" """ارسال غیربلاکینگ کد تایید"""
def _send(): def _send():
try: try:
_call_token_url(phone, code) _call_token_url(phone, code)
except Exception as exc: # noqa: BLE001 - لاگ و رد شدن، نباید کل ترد را بترکاند except Exception as exc:
logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}") logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}")
Thread(target=_send, daemon=True).start() Thread(target=_send, daemon=True).start()
-89
View File
@@ -1,89 +0,0 @@
# 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'),
),
]
@@ -1,44 +0,0 @@
# 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'],
},
),
]
+92 -2
View File
@@ -1,14 +1,39 @@
from django.contrib import admin from django.contrib import admin, messages
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import path, reverse
from django.utils.html import format_html
from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction
from .providers.bazaar import BazaarAuthError, build_authorize_url, exchange_code_for_tokens
@admin.register(BazaarSettings) @admin.register(BazaarSettings)
class BazaarSettingsAdmin(admin.ModelAdmin): class BazaarSettingsAdmin(admin.ModelAdmin):
"""تک‌ردیفی — لیست را رد می‌کنیم و مستقیم فرم ویرایش همان یک ردیف را نشان می‌دهیم.""" """تک‌ردیفی — لیست را رد می‌کنیم و مستقیم فرم ویرایش همان یک ردیف را نشان می‌دهیم.
برای اتصال با روش OAuth (تمدید خودکار access_token): این سه فیلد را پر
کن — Client ID، Client Secret، application id اپ — ذخیره کن، بعد از
«وضعیت اتصال کافه‌بازار» پایین فرم روی «شروع اتصال» بزن. اگر Redirect URI
که موقع ساخت Client در پیشخان ثبت کرده‌ای با آدرسِ نشان‌داده‌شده اینجا فرق
دارد، بعد از تایید در کافه‌بازار، مقدار code= را از آدرسِ برگشتی کپی و
خودت به همان آدرس callback اضافه کن (?code=...) — نیازی به فیلد جداگانه نیست.
یا ساده‌تر: اگر همین حالا یک توکن ثابتِ «روش جدید» پیشخان داری، مستقیم در
Access Token بچسبان و Access Token Expires At را خالی بگذار — کارش تمام
است، فقط اگر روزی باطل شد باید دستی توکن تازه بگیری."""
list_display = ("package_name", "updated_at") list_display = ("package_name", "updated_at")
readonly_fields = ("oauth_status",)
fields = (
"package_name",
"access_token",
"access_token_expires_at",
"refresh_token",
"client_id",
"client_secret",
"dynamic_discount_secret",
"oauth_status",
)
def has_add_permission(self, request): def has_add_permission(self, request):
return not BazaarSettings.objects.exists() return not BazaarSettings.objects.exists()
@@ -17,6 +42,71 @@ class BazaarSettingsAdmin(admin.ModelAdmin):
obj = BazaarSettings.get_solo() obj = BazaarSettings.get_solo()
return redirect("admin:billing_bazaarsettings_change", obj.pk) return redirect("admin:billing_bazaarsettings_change", obj.pk)
def get_urls(self):
custom = [
path(
"oauth/connect/",
self.admin_site.admin_view(self.oauth_connect_view),
name="billing_bazaarsettings_oauth_connect",
),
path(
"oauth/callback/",
self.admin_site.admin_view(self.oauth_callback_view),
name="billing_bazaarsettings_oauth_callback",
),
]
return custom + super().get_urls()
def _callback_url(self, request) -> str:
return request.build_absolute_uri(reverse("admin:billing_bazaarsettings_oauth_callback"))
def oauth_connect_view(self, request):
obj = BazaarSettings.get_solo()
try:
authorize_url = build_authorize_url(self._callback_url(request))
except BazaarAuthError as exc:
messages.error(request, str(exc))
return redirect("admin:billing_bazaarsettings_change", obj.pk)
return redirect(authorize_url)
def oauth_callback_view(self, request):
obj = BazaarSettings.get_solo()
code = request.GET.get("code")
if not code:
messages.error(request, "پارامتر code در آدرس بازگشتی پیدا نشد.")
return redirect("admin:billing_bazaarsettings_change", obj.pk)
try:
exchange_code_for_tokens(code, self._callback_url(request))
except BazaarAuthError as exc:
messages.error(request, f"اتصال به کافه‌بازار ناموفق بود: {exc}")
else:
messages.success(request, "اتصال به کافه‌بازار موفق بود — access_token از این پس خودکار تمدید می‌شود.")
return redirect("admin:billing_bazaarsettings_change", obj.pk)
@admin.display(description="وضعیت اتصال کافه‌بازار")
def oauth_status(self, obj):
if not obj or not obj.pk:
return "ابتدا ذخیره کن."
callback_hint = reverse("admin:billing_bazaarsettings_oauth_callback")
connect_url = reverse("admin:billing_bazaarsettings_oauth_connect")
if obj.access_token_expires_at:
status_line = f"متصل (OAuth) — انقضای فعلی: {obj.access_token_expires_at:%Y-%m-%d %H:%M}"
elif obj.access_token:
status_line = "متصل با توکن ثابتِ روش جدید (بدون تمدید خودکار)."
else:
status_line = "هنوز متصل نیست."
return format_html(
"<p>{}</p>"
"<p>Redirect URI برای ثبت در پیشخان (اگر می‌خواهی OAuth را وصل کنی) — این مسیر را با "
"آدرس دامنه‌ی سرورت جلو ببر، مثلا https://api.example.com{} :</p>"
"<p><code>{}</code></p>"
'<p><a class="button" href="{}">شروع اتصال به کافه‌بازار (OAuth)</a></p>',
status_line,
callback_hint,
callback_hint,
connect_url,
)
class FeaturePlanTierInline(admin.TabularInline): class FeaturePlanTierInline(admin.TabularInline):
model = FeaturePlanTier model = FeaturePlanTier
View File
@@ -0,0 +1,98 @@
"""
جایگزینِ خط‌فرمانیِ پر کردنِ دستیِ فرمِ ادمین برای BazaarSettings — همان چند
مقداری که کافه‌بازار می‌خواهد (Application id، Access Token، Client ID/Secret،
کلید تخفیف پویا) را این‌جا می‌پرسد و مستقیم در دیتابیس ذخیره می‌کند.
اجرا:
python manage.py setup_bazaar_settings
هیچ مقداری در خط‌فرمان به‌عنوان آرگومان پاس داده نمی‌شود (چون در تاریخچه‌ی
شل/history ذخیره می‌شد) — همه چیز تعاملی و با getpass پرسیده می‌شود (پیست‌کردن
در ترمینال کار می‌کند، فقط روی صفحه نمایش داده نمی‌شود).
Enter خالی یعنی «مقدارِ فعلی را دست نزن» — پس اجرای دوباره‌ی این دستور فقط
برای عوض‌کردنِ یکی-دو فیلد هم امن است، بقیه‌ی فیلدها دست‌نخورده می‌مانند.
"""
import getpass
from django.core.management.base import BaseCommand
from apps.billing.models import BazaarSettings
def _mask(value: str) -> str:
if not value:
return "(خالی)"
if len(value) <= 8:
return "•" * len(value)
return f"{value[:4]}…{value[-4:]} ({len(value)} کاراکتر)"
def _prompt_secret(label: str, current: str) -> str:
hint = f" [فعلی: {_mask(current)} — Enter برای بدون‌تغییر]" if current else " [Enter برای رد کردن]"
value = getpass.getpass(f"{label}{hint}: ").strip()
return value or current
def _prompt_plain(label: str, current: str, default: str = "") -> str:
shown_current = current or default
hint = f" [فعلی: {shown_current} — Enter برای بدون‌تغییر]" if shown_current else ""
value = input(f"{label}{hint}: ").strip()
return value or shown_current
class Command(BaseCommand):
help = "پر کردنِ تعاملیِ تنظیمات کافه‌بازار (BazaarSettings) بدون نیاز به پنل ادمین."
def handle(self, *args, **options):
settings_obj = BazaarSettings.get_solo()
self.stdout.write(self.style.NOTICE("تنظیمات کافه‌بازار — Enter خالی یعنی مقدار فعلی دست‌نخورده بماند.\n"))
package_name = _prompt_plain(
"Application id اپ (مثلا ir.niqow.kangelo)", settings_obj.package_name, default="ir.niqow.kangelo"
)
access_token = _prompt_secret(
"Access Token (توکن ثابتِ «روش جدید» پیشخان، یا هر access_token دیگر)",
settings_obj.access_token,
)
client_id = _prompt_plain("Client ID (روش قدیمی/OAuth — اختیاری)", settings_obj.client_id)
client_secret = _prompt_secret("Client Secret (روش قدیمی/OAuth — اختیاری)", settings_obj.client_secret)
dynamic_discount_secret = _prompt_secret(
"کلید امضای تخفیف پویا (اختیاری، فقط اگر می‌خواهی این ویژگی را استفاده کنی)",
settings_obj.dynamic_discount_secret,
)
# عمدا دست‌نخورده می‌مانند: refresh_token و access_token_expires_at فقط
# با دکمه‌ی «اتصال به کافه‌بازار» (OAuth) در پنل ادمین پر می‌شوند —
# پر کردنِ دستی‌شان این‌جا معنی ندارد چون بدونِ چرخه‌ی واقعیِ OAuth
# (redirect + code) هیچ access_token واقعی‌ای برایشان وجود ندارد.
settings_obj.package_name = package_name
settings_obj.access_token = access_token
settings_obj.client_id = client_id
settings_obj.client_secret = client_secret
settings_obj.dynamic_discount_secret = dynamic_discount_secret
settings_obj.save()
self.stdout.write("")
self.stdout.write(self.style.SUCCESS("ذخیره شد. خلاصه‌ی مقادیر فعلی:"))
self.stdout.write(f" Application id: {settings_obj.package_name or '(خالی)'}")
self.stdout.write(f" Access Token: {_mask(settings_obj.access_token)}")
self.stdout.write(f" Client ID: {settings_obj.client_id or '(خالی)'}")
self.stdout.write(f" Client Secret: {_mask(settings_obj.client_secret)}")
self.stdout.write(f" کلید تخفیف پویا: {_mask(settings_obj.dynamic_discount_secret)}")
self.stdout.write(f" Refresh Token: {_mask(settings_obj.refresh_token)} (دست‌نخورده)")
self.stdout.write(
f" انقضای Access Token: {settings_obj.access_token_expires_at or '(خالی — یعنی توکن ثابت، بدون تمدید خودکار)'}"
)
if settings_obj.access_token and not settings_obj.access_token_expires_at:
self.stdout.write("")
self.stdout.write(
self.style.NOTICE(
"توکن ثابت ذخیره شد — از همین الان خرید باید کار کند. برای فعال‌کردنِ تمدیدِ "
"خودکار (اختیاری)، از پنل ادمین دکمه‌ی «اتصال به کافه‌بازار» را بزن."
)
)
-124
View File
@@ -1,124 +0,0 @@
# 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')],
},
),
]
@@ -1,42 +0,0 @@
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)]
@@ -1,19 +0,0 @@
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)]
@@ -1,18 +0,0 @@
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)]
@@ -1,27 +0,0 @@
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)]
+50 -3
View File
@@ -10,13 +10,60 @@ from django.utils.translation import gettext_lazy as _
class BazaarSettings(models.Model): class BazaarSettings(models.Model):
"""تنظیمات کافه‌بازار — عمداً از پنل ادمین ویرایش می‌شود (نه .env)، چون """تنظیمات کافه‌بازار — عمداً از پنل ادمین ویرایش می‌شود (نه .env)، چون
باید بدون ری‌دیپلوی قابل‌تغییر باشد. تک‌ردیفی (singleton).""" باید بدون ری‌دیپلوی قابل‌تغییر باشد. تک‌ردیفی (singleton).
access_token = models.CharField(max_length=255, blank=True, verbose_name=_("Access Token")) دو روش احراز هویت با devapi کافه‌بازار پشتیبانی می‌شود:
refresh_token = models.CharField(max_length=255, blank=True, verbose_name=_("Refresh Token")) ۱) روش جدید (ساده): توکن ثابت از پیشخان → «API پیشخان بازار» → «دریافت
توکن جدید»، مستقیم در access_token جای‌گذاری می‌شود؛ چون access_token_
expires_at خالی می‌ماند، هیچ‌وقت سعی در refresh نمی‌شود (این توکن API
رفرش ندارد؛ اگر باطل شد باید دستی از پیشخان توکن تازه گرفت و همینجا
جایگزین کرد).
۲) روش قدیمی (OAuth با رفرش خودکار): client_id/client_secret را اینجا
بگذار، از دکمه‌ی «اتصال به کافه‌بازار» در همین صفحه استفاده کن؛ از آن
به بعد access_token هر بار که نزدیک انقضا باشد، خودش با refresh_token
تمدید می‌شود — بدون نیاز به مراجعه‌ی دستی دوباره."""
access_token = models.CharField(
max_length=255,
blank=True,
verbose_name=_("Access Token"),
help_text=_(
"یا توکن ثابت «روش جدید» پیشخان را مستقیم اینجا بچسبان، یا با دکمه‌ی اتصال زیر "
"بگذار خودش (روش قدیمی/OAuth) پر و بعدا خودکار تمدید شود."
),
)
refresh_token = models.CharField(
max_length=255, blank=True, verbose_name=_("Refresh Token"),
help_text=_("فقط برای روش OAuth — با دکمه‌ی اتصال خودش پر می‌شود، دستی لازم نیست."),
)
access_token_expires_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("انقضای Access Token (فقط OAuth)"),
help_text=_(
"اگر خالی باشد یعنی توکن ثابتِ روش جدید است و هیچ‌وقت خودکار تمدید نمی‌شود. "
"اگر پر باشد یعنی از OAuth آمده و پیش از انقضا خودش تمدید خواهد شد."
),
)
client_id = models.CharField(
max_length=255, blank=True, verbose_name=_("Client ID (روش قدیمی/OAuth)")
)
client_secret = models.CharField(
max_length=255, blank=True, verbose_name=_("Client Secret (روش قدیمی/OAuth)")
)
package_name = models.CharField( package_name = models.CharField(
max_length=150, blank=True, verbose_name=_("application id اپ (مثلا ir.example.app)") max_length=150, blank=True, verbose_name=_("application id اپ (مثلا ir.example.app)")
) )
dynamic_discount_secret = models.CharField(
max_length=255,
blank=True,
verbose_name=_("کلید امضای تخفیف پویا"),
help_text=_(
"از پیشخان → برنامه‌ات → پرداخت درون‌برنامه‌ای → تخفیف پویا. فقط سمت سرور استفاده "
"می‌شود (apps.billing.providers.dynamic_discount) — هیچ‌وقت نباید در اپ فلاتر ذخیره شود، "
"وگرنه کلید افشا می‌شود و کافه‌بازار مسئولیتی نمی‌پذیرد."
),
)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
+129 -18
View File
@@ -1,33 +1,142 @@
# apps/billing/providers/bazaar.py # apps/billing/providers/bazaar.py
""" """
تایید خرید کافه‌بازار — سمت سرور، طبق مستندات عمومی devapi کافه‌بازار تایید خرید کافه‌بازار — سمت سرور، طبق مستندات رسمی کافه‌بازار
(https://developers.cafebazaar.ir/fa/docs/iab). این تنها API ای است که (developers.cafebazaar.ir/fa/guidelines/in-app-billing). این تنها API ای
اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»، تا این است که اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»،
تابع True برنگرداند هیچ جمی اضافه نمی‌شود. تا این تابع True برنگرداند هیچ جمی اضافه نمی‌شود.
فرض مستنداتِ مسیر/پاسخ (اگر ساختار حساب توسعه‌دهنده‌ی خودت فرق دارد، فقط
همین یک تابع را با مستندات واقعی هماهنگ کن — بقیه‌ی پروژه فقط
`verify_purchase(product_id, purchase_token)` را صدا می‌زند):
GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name} GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}
/inapp/{product_id}/purchases/{purchase_token}/?access_token=... /inapp/{product_id}/purchases/{purchase_token}/
پاسخ موفق شامل purchaseState=0 (خریداری‌شده، نه refund‌شده) است. پاسخ موفق شامل purchaseState=0 (خریداری‌شده، نه refund‌شده) است.
access_token از BazaarSettings (پنل ادمین) خوانده می‌شود، نه .env — چون باید دو روش احراز هویت (هر دو طبق مستندات رسمی):
بدون ری‌دیپلوی قابل‌تعویض باشد (مثلا وقتی منقضی شد).
«روش جدید» — توکن ثابتِ پیشخان (.../api/setting-up-new)، در هدر:
CAFEBAZAAR-PISHKHAN-API-SECRET: <token>
این توکن API رفرش ندارد (اگر باطل شود باید دستی از پیشخان توکن تازه گرفت).
«روش قدیمی» — OAuth2 (.../api/setting-up)، در پارامتر URL:
?access_token=<token>
با client_id/client_secret/refresh_token قابل تمدید خودکار است — دقیقا
همین رفرش خودکار اینجا با auto_refresh_access_token_if_needed پیاده شده.
تشخیص این‌که کدام روش فعال است صرفا از روی BazaarSettings.access_token_
expires_at: خالی=روش جدید (هدر)، پرشده=روش قدیمی/OAuth (پارامتر URL، با
رفرش خودکار پیش از انقضا).
همه‌چیز از BazaarSettings (پنل ادمین) خوانده می‌شود، نه .env — چون باید بدون
ری‌دیپلوی قابل‌تعویض باشد.
""" """
import logging import logging
import requests import requests
from django.utils import timezone
from apps.billing.models import BazaarSettings from apps.billing.models import BazaarSettings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REQUEST_TIMEOUT_SECONDS = 15 REQUEST_TIMEOUT_SECONDS = 15
BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api" DEVAPI_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api"
OAUTH_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth"
PISHKHAN_TOKEN_HEADER = "CAFEBAZAAR-PISHKHAN-API-SECRET"
# پیش از این فاصله تا انقضا، توکن OAuth را تمدید می‌کنیم تا وسط یک درخواست
# واقعی منقضی نشود (access_token های کافه‌بازار معمولا ۳۶۰۰ ثانیه عمر دارند).
REFRESH_SAFETY_MARGIN_SECONDS = 120
class BazaarAuthError(Exception):
"""احراز هویت با کافه‌بازار ممکن نیست (تنظیمات ناقص یا رفرش ناموفق)."""
def build_authorize_url(redirect_uri: str) -> str:
"""لینک شروع فلوی OAuth (روش قدیمی) — کاربر باید یک‌بار در مرورگر باز و
تایید کند. redirect_uri باید دقیقا همانی باشد که موقع ساخت Client در
پیشخان ثبت شده."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.client_id:
raise BazaarAuthError("Client ID در تنظیمات کافه‌بازار خالی است.")
return (
f"{OAUTH_BASE_URL}/authorize/"
f"?response_type=code&access_type=offline"
f"&redirect_uri={redirect_uri}&client_id={settings_obj.client_id}"
)
def exchange_code_for_tokens(code: str, redirect_uri: str) -> None:
"""گام آخر فلوی OAuth — code گرفته‌شده از ریدایرکت (یا دستی کپی‌شده) را
به access_token/refresh_token تبدیل و در BazaarSettings ذخیره می‌کند."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.client_id or not settings_obj.client_secret:
raise BazaarAuthError("Client ID یا Client Secret در تنظیمات کافه‌بازار خالی است.")
response = requests.post(
f"{OAUTH_BASE_URL}/token/",
data={
"grant_type": "authorization_code",
"code": code,
"client_id": settings_obj.client_id,
"client_secret": settings_obj.client_secret,
"redirect_uri": redirect_uri,
},
timeout=REQUEST_TIMEOUT_SECONDS,
)
if not response.ok:
raise BazaarAuthError(f"تبادل کد ناموفق بود: {response.status_code} {response.text}")
_save_token_response(settings_obj, response.json())
def _refresh_access_token(settings_obj: BazaarSettings) -> None:
if not settings_obj.client_id or not settings_obj.client_secret or not settings_obj.refresh_token:
raise BazaarAuthError(
"برای تمدید خودکار access_token باید client_id/client_secret/refresh_token هر سه ست باشند."
)
response = requests.post(
f"{OAUTH_BASE_URL}/token/",
data={
"grant_type": "refresh_token",
"client_id": settings_obj.client_id,
"client_secret": settings_obj.client_secret,
"refresh_token": settings_obj.refresh_token,
},
timeout=REQUEST_TIMEOUT_SECONDS,
)
if not response.ok:
raise BazaarAuthError(f"تمدید access_token ناموفق بود: {response.status_code} {response.text}")
_save_token_response(settings_obj, response.json())
def _save_token_response(settings_obj: BazaarSettings, body: dict) -> None:
settings_obj.access_token = body["access_token"]
# رفرش‌گرنت گاهی refresh_token جدید برنمی‌گرداند — قبلی را نگه می‌داریم.
if body.get("refresh_token"):
settings_obj.refresh_token = body["refresh_token"]
expires_in = body.get("expires_in", 3600)
settings_obj.access_token_expires_at = timezone.now() + timezone.timedelta(seconds=expires_in)
settings_obj.save(
update_fields=["access_token", "refresh_token", "access_token_expires_at", "updated_at"]
)
def _auth_kwargs_for_request(settings_obj: BazaarSettings) -> dict:
"""توکنِ سالم برای یک درخواست devapi را برمی‌گرداند — هدر یا پارامتر URL،
بسته به این‌که توکن ثابتِ پیشخان است یا OAuth (و اگر OAuth و نزدیک
انقضاست، همین‌جا اول تمدیدش می‌کند)."""
is_oauth_managed = settings_obj.access_token_expires_at is not None
if is_oauth_managed:
margin = timezone.timedelta(seconds=REFRESH_SAFETY_MARGIN_SECONDS)
if timezone.now() >= settings_obj.access_token_expires_at - margin:
_refresh_access_token(settings_obj)
return {"params": {"access_token": settings_obj.access_token}}
return {"headers": {PISHKHAN_TOKEN_HEADER: settings_obj.access_token}}
def verify_purchase(product_id: str, purchase_token: str) -> bool: def verify_purchase(product_id: str, purchase_token: str) -> bool:
@@ -36,14 +145,16 @@ def verify_purchase(product_id: str, purchase_token: str) -> bool:
logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده") logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده")
return False return False
url = f"{BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/" url = f"{DEVAPI_BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/"
try: try:
response = requests.get( auth_kwargs = _auth_kwargs_for_request(settings_obj)
url, except BazaarAuthError as exc:
params={"access_token": settings_obj.access_token}, logger.error(f"❌ احراز هویت کافه‌بازار ناموفق: {exc}")
timeout=REQUEST_TIMEOUT_SECONDS, return False
)
try:
response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS, **auth_kwargs)
response.raise_for_status() response.raise_for_status()
body = response.json() body = response.json()
purchase_state = body.get("purchaseState") purchase_state = body.get("purchaseState")
@@ -0,0 +1,57 @@
# apps/billing/providers/dynamic_discount.py
"""
تولید توکن «تخفیف پویا» کافه‌بازار
(developers.cafebazaar.ir/fa/guidelines/in-app-billing/dynamic-discount).
فقط یک تابع خام است، بدون endpoint/کمپین — چون فعلا کمپین تخفیف مشخصی در
کار نیست؛ هر وقت لازم شد (مثلا یک ویو که برای یک GemPack با قیمت پایین‌تر
توکن می‌سازد)، همین تابع را صدا بزن. کلید امضا (BazaarSettings.
dynamic_discount_secret) عمدا فقط همین‌جا (سمت سرور) استفاده می‌شود و
هیچ‌وقت نباید به کلاینت/اپ فلاتر فرستاده شود — طبق مستندات، ذخیره‌ی این کلید
در اپ یعنی افشای آن، و کافه‌بازار مسئولیتی در قبال سوءاستفاده نمی‌پذیرد.
خروجی این تابع مستقیم در فیلد dynamicPriceToken کلاس PurchaseRequest
(سمت اپ، هنگام FlutterPoolakey.purchase) استفاده می‌شود.
"""
import time
import uuid
import jwt
from apps.billing.models import BazaarSettings
class DynamicDiscountNotConfigured(Exception):
"""کلید امضای تخفیف پویا در BazaarSettings تنظیم نشده."""
def generate_dynamic_discount_token(
*,
sku: str,
price_rial: int,
ttl_seconds: int = 300,
account_id: str | None = None,
) -> str:
"""یک توکن یک‌بارمصرف طبق مشخصات مستندات کافه‌بازار می‌سازد.
price_rial باید <= قیمت ثبت‌شده‌ی همین sku در پنل پیشخان باشد، وگرنه
کافه‌بازار خودش موقع خرید خطای «قیمت بیش از حد مجاز» (کد ۱۰) برمی‌گرداند."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.dynamic_discount_secret or not settings_obj.package_name:
raise DynamicDiscountNotConfigured(
"dynamic_discount_secret یا package_name در تنظیمات کافه‌بازار خالی است."
)
now = int(time.time())
payload = {
"price": price_rial,
"package_name": settings_obj.package_name,
"sku": sku,
"exp": now + ttl_seconds,
"nonce": uuid.uuid4().hex,
}
if account_id:
payload["account_id"] = account_id
return jwt.encode(payload, settings_obj.dynamic_discount_secret, algorithm="HS256")
+301
View File
@@ -0,0 +1,301 @@
from unittest.mock import Mock, patch
import jwt
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from apps.account.models import User
from apps.billing.models import BazaarPurchase, BazaarSettings, GemPack, GemTransaction
from apps.billing.providers.bazaar import (
BazaarAuthError,
build_authorize_url,
exchange_code_for_tokens,
verify_purchase,
)
from apps.billing.providers.dynamic_discount import (
DynamicDiscountNotConfigured,
generate_dynamic_discount_token,
)
def _make_response(status_ok=True, json_body=None, status_code=200):
response = Mock()
response.ok = status_ok
response.status_code = status_code
response.json.return_value = json_body or {}
response.text = str(json_body)
response.raise_for_status = Mock()
if not status_ok:
response.raise_for_status.side_effect = Exception("http error")
return response
class BazaarStaticTokenVerifyTests(TestCase):
"""روش جدید — توکن ثابتِ پیشخان، بدون هیچ منطق تمدید."""
def setUp(self):
self.settings_obj = BazaarSettings.get_solo()
self.settings_obj.access_token = "static-pishkhan-token"
self.settings_obj.package_name = "ir.niqow.kangelo"
self.settings_obj.save()
@patch("apps.billing.providers.bazaar.requests.get")
def test_sends_static_token_via_pishkhan_header_not_query_param(self, mock_get):
mock_get.return_value = _make_response(json_body={"purchaseState": 0})
result = verify_purchase("gem_pack_100", "purchase-token-abc")
self.assertTrue(result)
_, kwargs = mock_get.call_args
self.assertEqual(kwargs["headers"], {"CAFEBAZAAR-PISHKHAN-API-SECRET": "static-pishkhan-token"})
self.assertNotIn("params", kwargs)
@patch("apps.billing.providers.bazaar.requests.get")
def test_refund_purchase_state_is_rejected(self, mock_get):
mock_get.return_value = _make_response(json_body={"purchaseState": 1})
self.assertFalse(verify_purchase("gem_pack_100", "tok"))
@patch("apps.billing.providers.bazaar.requests.get")
def test_network_error_fails_closed(self, mock_get):
import requests
mock_get.side_effect = requests.RequestException("boom")
self.assertFalse(verify_purchase("gem_pack_100", "tok"))
def test_missing_settings_fails_closed_without_network_call(self):
BazaarSettings.objects.all().delete()
with patch("apps.billing.providers.bazaar.requests.get") as mock_get:
self.assertFalse(verify_purchase("gem_pack_100", "tok"))
mock_get.assert_not_called()
class BazaarOAuthTests(TestCase):
"""روش قدیمی — OAuth با client_id/client_secret و تمدید خودکار."""
def setUp(self):
self.settings_obj = BazaarSettings.get_solo()
self.settings_obj.package_name = "ir.niqow.kangelo"
self.settings_obj.client_id = "client-123"
self.settings_obj.client_secret = "secret-456"
self.settings_obj.save()
def test_build_authorize_url_requires_client_id(self):
self.settings_obj.client_id = ""
self.settings_obj.save()
with self.assertRaises(BazaarAuthError):
build_authorize_url("https://example.com/callback/")
def test_build_authorize_url_shape(self):
url = build_authorize_url("https://example.com/callback/")
self.assertIn("client_id=client-123", url)
self.assertIn("redirect_uri=https://example.com/callback/", url)
self.assertIn("response_type=code", url)
@patch("apps.billing.providers.bazaar.requests.post")
def test_exchange_code_saves_tokens_and_expiry(self, mock_post):
mock_post.return_value = _make_response(
json_body={"access_token": "fresh-token", "refresh_token": "refresh-token", "expires_in": 3600}
)
exchange_code_for_tokens("auth-code", "https://example.com/callback/")
self.settings_obj.refresh_from_db()
self.assertEqual(self.settings_obj.access_token, "fresh-token")
self.assertEqual(self.settings_obj.refresh_token, "refresh-token")
self.assertIsNotNone(self.settings_obj.access_token_expires_at)
self.assertGreater(self.settings_obj.access_token_expires_at, timezone.now())
@patch("apps.billing.providers.bazaar.requests.post")
def test_exchange_code_failure_raises_and_does_not_save(self, mock_post):
mock_post.return_value = _make_response(status_ok=False, status_code=400)
with self.assertRaises(BazaarAuthError):
exchange_code_for_tokens("bad-code", "https://example.com/callback/")
self.settings_obj.refresh_from_db()
self.assertEqual(self.settings_obj.access_token, "")
@patch("apps.billing.providers.bazaar.requests.get")
@patch("apps.billing.providers.bazaar.requests.post")
def test_verify_purchase_refreshes_expired_token_then_uses_query_param(self, mock_post, mock_get):
self.settings_obj.access_token = "stale-token"
self.settings_obj.refresh_token = "refresh-token"
self.settings_obj.access_token_expires_at = timezone.now() - timezone.timedelta(seconds=5)
self.settings_obj.save()
mock_post.return_value = _make_response(
json_body={"access_token": "brand-new-token", "expires_in": 3600}
)
mock_get.return_value = _make_response(json_body={"purchaseState": 0})
result = verify_purchase("sku", "tok")
self.assertTrue(result)
mock_post.assert_called_once()
_, get_kwargs = mock_get.call_args
self.assertEqual(get_kwargs["params"], {"access_token": "brand-new-token"})
self.settings_obj.refresh_from_db()
self.assertEqual(self.settings_obj.access_token, "brand-new-token")
@patch("apps.billing.providers.bazaar.requests.get")
def test_verify_purchase_does_not_refresh_when_token_still_fresh(self, mock_get):
self.settings_obj.access_token = "still-good"
self.settings_obj.refresh_token = "refresh-token"
self.settings_obj.access_token_expires_at = timezone.now() + timezone.timedelta(hours=1)
self.settings_obj.save()
mock_get.return_value = _make_response(json_body={"purchaseState": 0})
with patch("apps.billing.providers.bazaar.requests.post") as mock_post:
verify_purchase("sku", "tok")
mock_post.assert_not_called()
@patch("apps.billing.providers.bazaar.requests.get")
def test_verify_purchase_fails_closed_when_refresh_credentials_missing(self, mock_get):
self.settings_obj.access_token = "stale"
self.settings_obj.refresh_token = "" # نداریم؛ تمدید ممکن نیست
self.settings_obj.access_token_expires_at = timezone.now() - timezone.timedelta(seconds=5)
self.settings_obj.save()
self.assertFalse(verify_purchase("sku", "tok"))
mock_get.assert_not_called()
class DynamicDiscountTokenTests(TestCase):
def setUp(self):
self.settings_obj = BazaarSettings.get_solo()
self.settings_obj.package_name = "ir.niqow.kangelo"
self.settings_obj.dynamic_discount_secret = "top-secret-signing-key"
self.settings_obj.save()
def test_raises_when_not_configured(self):
self.settings_obj.dynamic_discount_secret = ""
self.settings_obj.save()
with self.assertRaises(DynamicDiscountNotConfigured):
generate_dynamic_discount_token(sku="gem_pack_100", price_rial=50000)
def test_token_payload_matches_bazaar_spec(self):
token = generate_dynamic_discount_token(sku="gem_pack_100", price_rial=50000, account_id="acc-1")
payload = jwt.decode(token, "top-secret-signing-key", algorithms=["HS256"])
self.assertEqual(payload["price"], 50000)
self.assertEqual(payload["package_name"], "ir.niqow.kangelo")
self.assertEqual(payload["sku"], "gem_pack_100")
self.assertEqual(payload["account_id"], "acc-1")
self.assertIn("nonce", payload)
self.assertIn("exp", payload)
def test_two_tokens_for_same_input_have_different_nonce(self):
token_a = generate_dynamic_discount_token(sku="sku", price_rial=1000)
token_b = generate_dynamic_discount_token(sku="sku", price_rial=1000)
self.assertNotEqual(token_a, token_b)
class BazaarSettingsAdminOAuthViewTests(TestCase):
"""کانکت/کال‌بک OAuth از پنل ادمین — فقط staff، و کد از querystring هم
اگر دستی چسبانده شود (redirect_uri متفاوت) کار می‌کند."""
def setUp(self):
self.staff = User.objects.create_user(phone="+989120000000", password="pass")
self.staff.is_staff = True
self.staff.is_superuser = True
self.staff.save()
self.client.force_login(self.staff)
self.settings_obj = BazaarSettings.get_solo()
self.settings_obj.client_id = "client-123"
self.settings_obj.client_secret = "secret-456"
self.settings_obj.save()
def test_connect_view_redirects_to_cafebazaar_when_configured(self):
response = self.client.get(reverse("admin:billing_bazaarsettings_oauth_connect"))
self.assertEqual(response.status_code, 302)
self.assertIn("pardakht.cafebazaar.ir/devapi/v2/auth/authorize", response.url)
def test_connect_view_errors_gracefully_when_client_id_missing(self):
self.settings_obj.client_id = ""
self.settings_obj.save()
response = self.client.get(reverse("admin:billing_bazaarsettings_oauth_connect"))
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/", response.url)
def test_callback_without_code_shows_error_not_crash(self):
response = self.client.get(reverse("admin:billing_bazaarsettings_oauth_callback"))
self.assertEqual(response.status_code, 302)
@patch("apps.billing.admin.exchange_code_for_tokens")
def test_callback_with_code_exchanges_and_redirects_back(self, mock_exchange):
response = self.client.get(reverse("admin:billing_bazaarsettings_oauth_callback"), {"code": "abc"})
self.assertEqual(response.status_code, 302)
mock_exchange.assert_called_once()
self.assertEqual(mock_exchange.call_args[0][0], "abc")
def test_anonymous_cannot_reach_oauth_views(self):
self.client.logout()
response = self.client.get(reverse("admin:billing_bazaarsettings_oauth_connect"))
self.assertNotEqual(response.status_code, 200)
class PurchaseFlowIntegrationTests(TestCase):
"""مسیر کامل و واقعی: ساخت خرید → verify → جم اضافه شود، دوبار-verify
دوبار جم ندهد، خرید نامعتبر جم ندهد."""
def setUp(self):
self.user = User.objects.create_user(phone="+989121234567", password="pass")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.gem_pack = GemPack.objects.create(
bazaar_sku="gem_pack_100", display_name="۱۰۰ جم", gem_amount=100
)
settings_obj = BazaarSettings.get_solo()
settings_obj.access_token = "static-token"
settings_obj.package_name = "ir.niqow.kangelo"
settings_obj.save()
def _create_purchase(self, token="purchase-token-1"):
response = self.client.post(
reverse("billing:purchase-create"),
{"gem_pack_id": self.gem_pack.id, "purchase_token": token},
)
self.assertEqual(response.status_code, 201)
return response.data["id"]
@patch("apps.billing.api.views.verify_purchase", return_value=True)
def test_verified_purchase_grants_gems_exactly_once(self, mock_verify):
purchase_id = self._create_purchase()
response = self.client.post(reverse("billing:purchase-verify", args=[purchase_id]))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], BazaarPurchase.Status.VERIFIED)
self.user.refresh_from_db()
self.assertEqual(self.user.gem_balance, 100)
self.assertEqual(
GemTransaction.objects.filter(user=self.user, reason=GemTransaction.Reason.PURCHASE).count(), 1
)
@patch("apps.billing.api.views.verify_purchase", return_value=True)
def test_double_verify_does_not_double_credit(self, mock_verify):
purchase_id = self._create_purchase()
self.client.post(reverse("billing:purchase-verify", args=[purchase_id]))
self.client.post(reverse("billing:purchase-verify", args=[purchase_id]))
self.user.refresh_from_db()
self.assertEqual(self.user.gem_balance, 100)
self.assertEqual(GemTransaction.objects.filter(user=self.user).count(), 1)
@patch("apps.billing.api.views.verify_purchase", return_value=False)
def test_invalid_purchase_grants_no_gems(self, mock_verify):
purchase_id = self._create_purchase()
response = self.client.post(reverse("billing:purchase-verify", args=[purchase_id]))
self.assertEqual(response.status_code, 400)
self.user.refresh_from_db()
self.assertEqual(self.user.gem_balance, 0)
purchase = BazaarPurchase.objects.get(id=purchase_id)
self.assertEqual(purchase.status, BazaarPurchase.Status.FAILED)
def test_reusing_same_purchase_token_is_idempotent_at_create(self):
first_id = self._create_purchase(token="same-token")
second_id = self._create_purchase(token="same-token")
self.assertEqual(first_id, second_id)
self.assertEqual(BazaarPurchase.objects.filter(purchase_token="same-token").count(), 1)
+412
View File
@@ -0,0 +1,412 @@
import csv
import io
import logging
import uuid
from django.db import transaction
from django.utils import timezone
from apps.account.models import User
from apps.banker.models import Contact
from apps.bizapps.models import (
Customer,
Invoice,
InvoiceItem,
LedgerEntry,
LedgerInstallment,
Product,
StockMovement,
Transaction,
)
logger = logging.getLogger(__name__)
# کدهای حساب — دفترکل ساده طبق سند فنی (بدون نیاز به جدول Chart of Accounts
# جدا، چون این اپ برای کسب‌وکارهای خرد است نه حسابداری شرکتی کامل).
ACCOUNT_CASH = "1000" # صندوق
ACCOUNT_RECEIVABLE = "1100" # مطالبات (فروش اعتباری — نسخه‌ی آتی)
ACCOUNT_SALES = "4000" # فروش
class InvoiceService:
@staticmethod
def _next_sequenced_number(user: User, model, field_name: str, prefix: str) -> str:
"""فرمت PREFIX-YYMM-NNNN با سکانسِ ماهانه‌ی جداگانه برای هر کاربر —
سطر آخرِ همان ماه را قفل می‌کند (select_for_update) تا دو درخواستِ
هم‌زمان شماره‌ی تکراری نگیرند."""
now = timezone.now()
month_prefix = f"{prefix}-{now:%y%m}-"
last = (
model.objects.select_for_update()
.filter(user=user, **{f"{field_name}__startswith": month_prefix})
.order_by(f"-{field_name}")
.first()
)
if last:
last_seq = int(getattr(last, field_name).rsplit("-", 1)[-1])
else:
last_seq = 0
return f"{month_prefix}{last_seq + 1:04d}"
@staticmethod
def _upsert_customer_contact(user: User, phone: str, name: str) -> Contact | None:
phone = (phone or "").strip()
if not phone:
return None
contact = Contact.objects.filter(user=user, phone=phone).first()
if contact:
return contact
return Contact.objects.create(id=str(uuid.uuid4()), user=user, name=name or phone, phone=phone)
@staticmethod
@transaction.atomic
def create_invoice(user: User, data: dict, send_sms: bool = False) -> Invoice:
items_data = data["items"]
invoice_number = InvoiceService._next_sequenced_number(user, Invoice, "invoice_number", "INV")
customer: Customer | None = data.get("customer")
payment_type = data.get("payment_type", Invoice.PaymentType.CASH)
order_status = (
Invoice.OrderStatus.PENDING_PAYMENT
if payment_type == Invoice.PaymentType.CREDIT
else Invoice.OrderStatus.READY_TO_SHIP
)
invoice = Invoice.objects.create(
id=str(uuid.uuid4()),
user=user,
invoice_number=invoice_number,
invoice_date=data.get("invoice_date") or timezone.now(),
customer=customer,
buyer_name=data.get("buyer_name", "") or (customer.name if customer else ""),
buyer_phone=data.get("buyer_phone", "") or (customer.phone if customer else ""),
buyer_address=data.get("buyer_address", "") or (customer.address if customer else ""),
seller_name=data.get("seller_name", ""),
seller_phone=data.get("seller_phone", ""),
seller_address=data.get("seller_address", ""),
discount_amount=data.get("discount_amount", 0),
tax_lines=data.get("tax_lines", []),
payment_type=payment_type,
shipping_method=data.get("shipping_method", Invoice.ShippingMethod.PICKUP),
order_status=order_status,
tracking_code=data.get("tracking_code", ""),
notes=data.get("notes", ""),
)
subtotal = 0.0
for item_data in items_data:
product: Product | None = item_data.get("product")
quantity = item_data["quantity"]
# طبق سند: قیمت واحد در زمان فروش از SellingPrice کالا کپی می‌شود
# (سمت کلاینت قابل‌دستکاری نیست) — فقط برای ردیف‌های آزاد (بدون
# کالا) قیمتِ ارسالی کلاینت به‌عنوان قیمت واحد پذیرفته می‌شود.
unit_price = product.unit_price if product else item_data["unit_price"]
description = item_data.get("description") or (product.name if product else "")
item = InvoiceItem(
id=str(uuid.uuid4()),
invoice=invoice,
product=product,
description=description,
quantity=quantity,
unit_price=unit_price,
discount_percent=item_data.get("discount_percent", 0),
)
item.compute_total()
item.save()
subtotal += item.total_price
if product is not None:
product = Product.objects.select_for_update().get(pk=product.pk)
product.quantity -= quantity
product.recompute_purchase_value()
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
StockMovement.objects.create(
product=product,
delta=-quantity,
quantity_after=product.quantity,
note=f"فروش فاکتور {invoice_number}",
)
tax_amount = sum(subtotal * float(line.get("percent", 0)) / 100 for line in invoice.tax_lines)
invoice.total_amount = subtotal
invoice.final_amount = subtotal - invoice.discount_amount + tax_amount
invoice.save(update_fields=["total_amount", "final_amount"])
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
is_credit = payment_type == Invoice.PaymentType.CREDIT
Transaction.objects.create(
id=str(uuid.uuid4()),
user=user,
transaction_number=transaction_number,
transaction_date=invoice.invoice_date,
reference_invoice=invoice,
transaction_type=(
Transaction.TransactionType.CREDIT_SALE if is_credit else Transaction.TransactionType.CASH_SALE
),
debit_account=ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH,
credit_account=ACCOUNT_SALES,
amount=invoice.final_amount,
description=f"فروش فاکتور {invoice_number}",
)
if customer is not None:
customer = Customer.objects.select_for_update().get(pk=customer.pk)
customer.total_purchases_count += 1
customer.total_purchase_amount += invoice.final_amount
customer.last_purchase_date = invoice.invoice_date
customer.save(
update_fields=["total_purchases_count", "total_purchase_amount", "last_purchase_date", "updated_at"]
)
# طبق درخواست: فروشِ قسطی/نسیه باید خودکار به دفتر درآمد/هزینه به‌عنوان
# یک ردیفِ بدهی‌دار (نه صرفا یک عدد در لیست فاکتورها) وصل شود — همان
# مثالِ «۱۰۰ تومن فروش قسطی از نظر عادی درآمد، از نظر حرفه‌ای بدهکاریه».
if is_credit:
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=user,
entry_type=LedgerEntry.EntryType.INCOME,
title=f"فاکتور {invoice_number}",
amount=invoice.final_amount,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=invoice.invoice_date.date(),
total_amount=invoice.final_amount,
counterparty_name=invoice.buyer_name,
counterparty_phone=invoice.buyer_phone,
linked_invoice=invoice,
)
if invoice.buyer_phone:
LedgerService.recompute_customer_balance(user, invoice.buyer_phone)
if send_sms and invoice.buyer_phone:
InvoiceService._send_invoice_sms(user, invoice)
logger.info(
"invoice created: user=%s invoice=%s final_amount=%s transaction=%s",
user.phone,
invoice_number,
invoice.final_amount,
transaction_number,
)
return invoice
@staticmethod
def _send_invoice_sms(user: User, invoice: Invoice) -> None:
# ایمپورت دیرهنگام برای جلوگیری از وابستگی چرخه‌ای در import-time
# بین اپ‌های bizapps و messaging.
from apps.messaging.api.services import BulkMessageService
from apps.messaging.models import Channel
contact = InvoiceService._upsert_customer_contact(user, invoice.buyer_phone, invoice.buyer_name)
if contact is None:
return
body = f"فاکتور {invoice.invoice_number} به مبلغ {invoice.final_amount:,.0f} تومان ثبت شد."
BulkMessageService.create_and_dispatch(
user=user,
channels=[Channel.SMS],
contact_ids=[contact.id],
body_override=body,
)
@staticmethod
@transaction.atomic
def cancel_invoice(user: User, invoice_id: str) -> Invoice:
invoice = Invoice.objects.select_for_update().get(pk=invoice_id, user=user)
if invoice.order_status == Invoice.OrderStatus.CANCELLED:
raise ValueError("این فاکتور قبلا لغو شده است.")
for item in invoice.items.select_related("product"):
if item.product is None:
continue
product = Product.objects.select_for_update().get(pk=item.product_id)
product.quantity += item.quantity
product.recompute_purchase_value()
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
StockMovement.objects.create(
product=product,
delta=item.quantity,
quantity_after=product.quantity,
note=f"لغو فاکتور {invoice.invoice_number}",
)
# ردیفِ دفترِ بدهی‌دارِ متصل به این فاکتور: اگر هنوز هیچ قسطی روی آن
# ثبت نشده، با لغوِ فاکتور آن بدهی هم دیگر معنی ندارد — حذفش می‌کنیم.
# اگر قسطی ثبت شده (یعنی پولی واقعا رد و بدل شده)، تاریخچه‌ی مالی را
# پاک نمی‌کنیم؛ همان‌جا برای رسیدگیِ دستیِ کاربر می‌ماند.
linked_entry = invoice.ledger_entries.first()
if linked_entry is not None and not linked_entry.installments.exists():
phone = linked_entry.counterparty_phone
linked_entry.delete()
if phone:
LedgerService.recompute_customer_balance(user, phone)
original = invoice.transactions.order_by("created_at").first()
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
Transaction.objects.create(
id=str(uuid.uuid4()),
user=user,
transaction_number=transaction_number,
transaction_date=timezone.now(),
reference_invoice=invoice,
transaction_type=original.transaction_type if original else Transaction.TransactionType.CASH_SALE,
debit_account=original.credit_account if original else ACCOUNT_SALES,
credit_account=original.debit_account if original else ACCOUNT_CASH,
amount=-(original.amount if original else invoice.final_amount),
description=f"لغو فاکتور {invoice.invoice_number}",
)
invoice.order_status = Invoice.OrderStatus.CANCELLED
invoice.save(update_fields=["order_status", "updated_at"])
logger.info(
"invoice cancelled: user=%s invoice=%s reversal_transaction=%s",
user.phone,
invoice.invoice_number,
transaction_number,
)
return invoice
@staticmethod
@transaction.atomic
def update_status(
user: User,
invoice_id: str,
order_status: str | None = None,
payment_status: str | None = None,
tracking_code: str | None = None,
) -> Invoice:
invoice = Invoice.objects.select_for_update().get(pk=invoice_id, user=user)
update_fields = ["updated_at"]
status_changed = order_status is not None and order_status != invoice.order_status
if order_status is not None:
invoice.order_status = order_status
update_fields.append("order_status")
if payment_status is not None:
invoice.payment_status = payment_status
if payment_status == Invoice.PaymentStatus.PAID and invoice.paid_at is None:
invoice.paid_at = timezone.now()
update_fields.append("paid_at")
update_fields.append("payment_status")
if tracking_code is not None:
invoice.tracking_code = tracking_code
update_fields.append("tracking_code")
invoice.save(update_fields=update_fields)
logger.info(
"invoice status updated: user=%s invoice=%s order_status=%s payment_status=%s",
user.phone,
invoice.invoice_number,
invoice.order_status,
invoice.payment_status,
)
# اگر فاکتور به «پرداخت‌شده» رفت و به یک ردیفِ بدهی‌دار وصل بود، همان
# مانده را به‌عنوان یک قسطِ تسویه‌ی کامل ثبت می‌کنیم — کاربر مجبور
# نیست دستی برود توی دفتر و قسط بزند.
if payment_status == Invoice.PaymentStatus.PAID:
linked_entry = invoice.ledger_entries.first()
if linked_entry is not None and linked_entry.remaining_amount > 0:
LedgerInstallment.objects.create(
id=str(uuid.uuid4()),
ledger_entry=linked_entry,
amount=linked_entry.remaining_amount,
paid_date=timezone.now().date(),
reference=f"تسویه‌ی کامل فاکتور {invoice.invoice_number}",
)
if linked_entry.counterparty_phone:
LedgerService.recompute_customer_balance(user, linked_entry.counterparty_phone)
# طبق سند: «پس از هر تغییر وضعیت سفارش، یک پیامک ارسال شود» — برخلاف
# پیامکِ ثبت فاکتور (که opt-in است)، این یکی خودکار است، چون همان
# رفتاری‌ست که apps.orders از قبل برای مراحل پیگیری سفارش دارد و
# کاربر با آن آشناست.
if status_changed and invoice.buyer_phone:
InvoiceService._send_status_sms(user, invoice)
return invoice
@staticmethod
def _send_status_sms(user: User, invoice: Invoice) -> None:
from apps.messaging.api.services import BulkMessageService
from apps.messaging.models import Channel
contact = InvoiceService._upsert_customer_contact(user, invoice.buyer_phone, invoice.buyer_name)
if contact is None:
return
status_label = Invoice.OrderStatus(invoice.order_status).label
body = f"وضعیت سفارش فاکتور {invoice.invoice_number}: {status_label}"
BulkMessageService.create_and_dispatch(
user=user,
channels=[Channel.SMS],
contact_ids=[contact.id],
body_override=body,
)
class LedgerService:
"""هماهنگیِ لایه‌ی «عادی» (دسته‌بندی ساده) با لایه‌ی «حرفه‌ای» (بدهکاری/
بستانکاری واقعی) — طبق درخواست: کاربرِ معمولی فقط دسته را انتخاب می‌کند،
این سرویس تضمین می‌کند مانده‌ی بدهیِ هر مشتری (Customer.outstanding_balance)
همیشه با جمعِ واقعیِ ردیف‌های «فروش قسطی»ِ همان شماره‌تماس هم‌خوان بماند."""
@staticmethod
def recompute_customer_balance(user: User, phone: str) -> None:
phone = (phone or "").strip()
if not phone:
return
customer = Customer.objects.filter(user=user, phone=phone).first()
if customer is None:
return
receivables = LedgerEntry.objects.filter(
user=user, category=LedgerEntry.Category.SALE_CREDIT, counterparty_phone=phone
).prefetch_related("installments")
balance = sum(e.remaining_amount for e in receivables)
if customer.outstanding_balance != balance:
customer.outstanding_balance = balance
customer.save(update_fields=["outstanding_balance", "updated_at"])
@staticmethod
def export_csv(user: User, start=None, end=None) -> str:
"""خروجیِ CSV حرفه‌ای — UTF-8 BOM برای بازشدنِ درستِ فارسی در اکسل."""
qs = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related("installments")
if start:
qs = qs.filter(entry_date__gte=start)
if end:
qs = qs.filter(entry_date__lte=end)
buf = io.StringIO()
buf.write("") # BOM
writer = csv.writer(buf)
writer.writerow(
[
"تاریخ",
"نوع",
"دسته",
"عنوان",
"مبلغ",
"طرف حساب",
"شماره طرف حساب",
"مبلغ کل تعهد",
"پرداخت‌شده",
"مانده",
"یادداشت",
]
)
for e in qs:
writer.writerow(
[
e.entry_date.isoformat(),
e.get_entry_type_display(),
e.get_category_display() if e.category else "",
e.title,
f"{e.amount:.0f}",
e.counterparty_name,
e.counterparty_phone,
f"{e.total_amount:.0f}" if e.is_debt else "",
f"{e.paid_amount:.0f}" if e.is_debt else "",
f"{e.remaining_amount:.0f}" if e.is_debt else "",
e.note,
]
)
return buf.getvalue()
-92
View File
@@ -1,92 +0,0 @@
# 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'],
},
),
]
@@ -1,38 +0,0 @@
# 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'],
},
),
]
@@ -1,16 +0,0 @@
# 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',
),
]
@@ -1,35 +0,0 @@
# 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'],
},
),
]
+296
View File
@@ -0,0 +1,296 @@
import uuid
from datetime import date
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.account.models import User
from apps.bizapps.api.services import InvoiceService, LedgerService
from apps.bizapps.models import Customer, LedgerEntry, LedgerInstallment
# این ماژول لایه‌ی «هماهنگیِ عادی/حرفه‌ای»ِ دفتر درآمد و هزینه را پوشش می‌دهد —
# دقیقا همان بخشی که کاربر صریحا خواسته «تست‌نویسی برایش خیلی مهم است»، چون
# باگ اینجا یعنی مانده‌ی بدهیِ نمایش‌داده‌شده به کاربر با واقعیت فرق کند.
def _make_user(phone="09120000000") -> User:
return User.objects.create_user(phone=phone, is_active=True, state=User.State.ACTIVE)
class LedgerEntryModelTests(TestCase):
"""دسته‌بندیِ هر ردیف باید دقیقا به نوعِ حرفه‌ایِ درستش نگاشت شود — این
همان «هماهنگی» است که کاربر مثال زد (فروش قسطی = طلب، نه درآمدِ صرف)."""
def setUp(self):
self.user = _make_user()
def _entry(self, **kwargs):
defaults = dict(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="تست",
amount=100,
entry_date=date.today(),
)
defaults.update(kwargs)
return LedgerEntry.objects.create(**defaults)
def test_sale_credit_is_receivable_debt(self):
entry = self._entry(category=LedgerEntry.Category.SALE_CREDIT, total_amount=100)
self.assertTrue(entry.is_debt)
self.assertEqual(entry.professional_type, "receivable")
def test_purchase_credit_is_payable_debt(self):
entry = self._entry(
entry_type=LedgerEntry.EntryType.EXPENSE,
category=LedgerEntry.Category.PURCHASE_CREDIT,
total_amount=100,
)
self.assertTrue(entry.is_debt)
self.assertEqual(entry.professional_type, "payable")
def test_simple_categories_are_not_debt(self):
for category in [
LedgerEntry.Category.SALE_CASH,
LedgerEntry.Category.SERVICE_INCOME,
LedgerEntry.Category.OTHER_INCOME,
]:
entry = self._entry(category=category)
self.assertFalse(entry.is_debt, category)
self.assertEqual(entry.professional_type, "simple", category)
def test_remaining_amount_tracks_installments(self):
entry = self._entry(category=LedgerEntry.Category.SALE_CREDIT, total_amount=1000)
self.assertEqual(entry.remaining_amount, 1000)
LedgerInstallment.objects.create(
id=str(uuid.uuid4()), ledger_entry=entry, amount=400, paid_date=date.today()
)
entry.refresh_from_db()
self.assertEqual(entry.paid_amount, 400)
self.assertEqual(entry.remaining_amount, 600)
LedgerInstallment.objects.create(
id=str(uuid.uuid4()), ledger_entry=entry, amount=600, paid_date=date.today()
)
entry.refresh_from_db()
self.assertEqual(entry.remaining_amount, 0)
def test_remaining_amount_is_zero_for_non_debt_entries(self):
entry = self._entry(category=LedgerEntry.Category.SALE_CASH)
self.assertEqual(entry.remaining_amount, 0)
class LedgerServiceTests(TestCase):
def setUp(self):
self.user = _make_user()
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="مشتری", phone="09121112222"
)
def test_recompute_balance_sums_open_receivables(self):
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="فاکتور ۱",
amount=500,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=500,
counterparty_phone="09121112222",
)
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="فاکتور ۲",
amount=300,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=300,
counterparty_phone="09121112222",
)
LedgerService.recompute_customer_balance(self.user, "09121112222")
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 800)
def test_balance_drops_as_installments_are_paid(self):
entry = LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="فاکتور",
amount=1000,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=1000,
counterparty_phone="09121112222",
)
LedgerService.recompute_customer_balance(self.user, "09121112222")
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 1000)
LedgerInstallment.objects.create(
id=str(uuid.uuid4()), ledger_entry=entry, amount=1000, paid_date=date.today()
)
LedgerService.recompute_customer_balance(self.user, "09121112222")
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 0)
def test_ignores_other_users_entries(self):
other = _make_user(phone="09129998888")
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=other,
entry_type=LedgerEntry.EntryType.INCOME,
title="فاکتور مالِ یکی دیگه",
amount=999,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=999,
counterparty_phone="09121112222",
)
LedgerService.recompute_customer_balance(self.user, "09121112222")
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 0)
def test_export_csv_filters_by_date_range(self):
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="داخل بازه",
amount=100,
category=LedgerEntry.Category.SALE_CASH,
entry_date=date(2026, 1, 15),
)
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.EXPENSE,
title="خارج از بازه",
amount=50,
category=LedgerEntry.Category.RENT,
entry_date=date(2026, 3, 1),
)
csv_text = LedgerService.export_csv(self.user, start=date(2026, 1, 1), end=date(2026, 1, 31))
self.assertIn("داخل بازه", csv_text)
self.assertNotIn("خارج از بازه", csv_text)
class InvoiceLedgerIntegrationTests(TestCase):
"""اتصالِ فاکتور <-> دفتر: طبق درخواست، فقط فروشِ قسطی باید خودکار یک
ردیفِ بدهی‌دار در دفتر بسازد؛ فروشِ نقدی نباید دفتر را شلوغ کند."""
def setUp(self):
self.user = _make_user()
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="مشتری قسطی", phone="09123334444"
)
def _invoice_data(self, payment_type):
return {
"customer": self.customer,
"buyer_name": self.customer.name,
"buyer_phone": self.customer.phone,
"buyer_address": "",
"items": [
{"product": None, "description": "کالای آزاد", "quantity": 1, "unit_price": 100, "discount_percent": 0}
],
"payment_type": payment_type,
}
def test_credit_invoice_creates_linked_receivable_entry(self):
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
entry = LedgerEntry.objects.get(linked_invoice=invoice)
self.assertEqual(entry.category, LedgerEntry.Category.SALE_CREDIT)
self.assertEqual(entry.total_amount, invoice.final_amount)
self.assertEqual(entry.remaining_amount, invoice.final_amount)
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, invoice.final_amount)
def test_cash_invoice_does_not_create_ledger_entry(self):
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("cash"))
self.assertFalse(LedgerEntry.objects.filter(linked_invoice=invoice).exists())
def test_marking_invoice_paid_settles_the_linked_entry(self):
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
InvoiceService.update_status(self.user, invoice.id, payment_status="paid")
entry = LedgerEntry.objects.get(linked_invoice=invoice)
self.assertEqual(entry.remaining_amount, 0)
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 0)
def test_cancelling_unpaid_credit_invoice_removes_the_entry(self):
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
InvoiceService.cancel_invoice(self.user, invoice.id)
self.assertFalse(LedgerEntry.objects.filter(linked_invoice=invoice).exists())
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 0)
def test_cancelling_partially_paid_credit_invoice_keeps_the_entry(self):
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
entry = LedgerEntry.objects.get(linked_invoice=invoice)
LedgerInstallment.objects.create(
id=str(uuid.uuid4()), ledger_entry=entry, amount=50, paid_date=date.today()
)
invoice.order_status = invoice.OrderStatus.PENDING_PAYMENT
invoice.save(update_fields=["order_status"])
InvoiceService.cancel_invoice(self.user, invoice.id)
# قسطی که واقعا پرداخت شده نباید محو شود — تاریخچه‌ی مالی دست‌نخورده می‌ماند.
self.assertTrue(LedgerEntry.objects.filter(linked_invoice=invoice).exists())
class LedgerApiTests(TestCase):
"""چند نقطه‌ی حیاتیِ سطح API — اعتبارسنجی اضافه‌شدنِ قسطِ بیش از حد،
و این‌که خروجیِ CSV واقعا با content-type درست برمی‌گردد."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
token = str(RefreshToken.for_user(self.user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
def test_create_debt_entry_without_total_amount_is_rejected(self):
response = self.client.post(
reverse("bizapps:ledger-list"),
{
"entry_type": "income",
"title": "فروش قسطی بدون مبلغ کل",
"amount": 100,
"category": "sale_credit",
"entry_date": date.today().isoformat(),
},
format="json",
)
self.assertEqual(response.status_code, 400)
def test_installment_exceeding_remaining_amount_is_rejected(self):
entry = LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="فروش قسطی",
amount=1000,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=1000,
)
response = self.client.post(
reverse("bizapps:ledger-installment-list", kwargs={"entry_id": entry.id}),
{"amount": 5000, "paid_date": date.today().isoformat()},
format="json",
)
self.assertEqual(response.status_code, 400)
def test_export_returns_csv(self):
response = self.client.get(reverse("bizapps:ledger-export"))
self.assertEqual(response.status_code, 200)
self.assertIn("text/csv", response["Content-Type"])
-35
View File
@@ -1,35 +0,0 @@
# 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'),
},
),
]
@@ -1,210 +0,0 @@
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),
]
@@ -3,14 +3,32 @@
<html lang="fa" dir="rtl"> <html lang="fa" dir="rtl">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{ article.title }} | کاسبیار</title> <title>{{ article.title }} | شاپ شیپ</title>
<meta name="description" content="{{ article.meta_description }}"> <meta name="description" content="{{ article.meta_description|default:article.excerpt }}">
<link rel="canonical" href="https://mobiniqow.ir/articles/{{ article.slug }}/"> <meta name="robots" content="index, follow">
<meta name="author" content="niqow.ir">
<link rel="canonical" href="https://shopship.ir/articles/{{ article.slug }}/">
<link rel="alternate" hreflang="fa-ir" href="https://shopship.ir/articles/{{ article.slug }}/">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/img/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/img/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/img/apple-touch-icon.png">
<meta property="og:type" content="article"> <meta property="og:type" content="article">
<meta property="og:site_name" content="شاپ شیپ">
<meta property="og:title" content="{{ article.title }}"> <meta property="og:title" content="{{ article.title }}">
<meta property="og:description" content="{{ article.meta_description }}"> <meta property="og:description" content="{{ article.meta_description|default:article.excerpt }}">
<meta name="theme-color" content="#107c41"> <meta property="og:url" content="https://shopship.ir/articles/{{ article.slug }}/">
<meta property="og:locale" content="fa_IR">
<meta property="og:image" content="https://shopship.ir/assets/img/og-image.png">
<meta property="article:published_time" content="{{ article.published_at|date:'Y-m-d' }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ article.title }}">
<meta name="twitter:image" content="https://shopship.ir/assets/img/og-image.png">
<meta name="theme-color" content="#2d2a55">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
@@ -19,20 +37,50 @@
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
"headline": "{{ article.title }}", "headline": "{{ article.title|escapejs }}",
"description": "{{ article.meta_description|default:article.excerpt|escapejs }}",
"url": "https://shopship.ir/articles/{{ article.slug }}/",
"datePublished": "{{ article.published_at|date:'Y-m-d' }}", "datePublished": "{{ article.published_at|date:'Y-m-d' }}",
"author": { "@type": "Organization", "name": "کاسبیار" } "dateModified": "{{ article.updated_at|date:'Y-m-d' }}",
"inLanguage": "fa-IR",
"image": "https://shopship.ir/assets/img/og-image.png",
"publisher": {
"@type": "Organization",
"name": "niqow.ir",
"url": "https://niqow.ir",
"logo": { "@type": "ImageObject", "url": "https://shopship.ir/assets/img/icon-512.png" }
},
"author": { "@type": "Organization", "name": "شاپ شیپ", "url": "https://shopship.ir/" },
"mainEntityOfPage": { "@type": "WebPage", "@id": "https://shopship.ir/articles/{{ article.slug }}/" }
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "شاپ شیپ", "item": "https://shopship.ir/" },
{ "@type": "ListItem", "position": 2, "name": "مقالات", "item": "https://shopship.ir/articles/" },
{ "@type": "ListItem", "position": 3, "name": "{{ article.title|escapejs }}", "item": "https://shopship.ir/articles/{{ article.slug }}/" }
]
} }
</script> </script>
</head> </head>
<body> <body>
<header class="nav container"> <header class="nav-wrap" id="siteHeader">
<a class="nav-brand" href="/">کاسبیار</a> <div class="nav container">
<nav class="nav-links"> <a class="nav-brand" href="/">
<a href="/#features">امکانات</a> <img src="/assets/img/icon-192.png" alt="" width="28" height="28" class="nav-brand-icon">
<a href="/articles/">مقالات</a> شاپ شیپ
</nav> </a>
<nav class="nav-links">
<a href="/#tools">بخش‌های اپ</a>
<a href="/#pricing">قیمت‌گذاری</a>
<a href="/articles/">مقالات</a>
</nav>
<a class="btn btn-primary btn-sm" href="/#cta">دریافت اپ</a>
</div>
</header> </header>
<main class="container" style="padding: var(--space-12) 0;"> <main class="container" style="padding: var(--space-12) 0;">
@@ -46,14 +94,39 @@
<div class="article-body"> <div class="article-body">
{{ article.content_html|safe }} {{ article.content_html|safe }}
</div> </div>
{% if related %}
<div class="related-articles">
<h2>مقالات مرتبط</h2>
<ul>
{% for item in related %}
<li><a href="/articles/{{ item.slug }}/">{{ item.title }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
</main> </main>
<footer> <footer>
<div class="container"> <div class="container">
<span>© کاسبیار — ابزار ارسال پیام گروهی</span> <div class="footer-brand">
<span><a href="/">صفحه‌ی اصلی</a></span> <strong>شاپ شیپ</strong>
<span>محصولی از <a href="https://niqow.ir" target="_blank" rel="noopener">niqow.ir</a></span>
</div>
<span><a href="/articles/">همه‌ی مقالات</a></span>
<span>© شاپ شیپ</span>
</div> </div>
</footer> </footer>
<script>
var header = document.getElementById('siteHeader');
if (header) {
var onScroll = function () {
header.classList.toggle('is-scrolled', window.scrollY > 8);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
}
</script>
</body> </body>
</html> </html>
@@ -3,31 +3,69 @@
<html lang="fa" dir="rtl"> <html lang="fa" dir="rtl">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>مقالات — کاسبیار | راهنمای ارسال پیام گروهی</title> <title>مقالات — شاپ شیپ | راهنمای مدیریت کسب‌وکار کوچک</title>
<meta name="description" content="راهنما و مقالات کاسبیار درباره‌ی ارسال پیام گروهی با شماره: پیامک، واتساپ‌بیزینس، فیلد سفارشی در قالب پیام، و مرج مخاطبین گوشی."> <meta name="description" content="راهنما و مقالات شاپ شیپ درباره‌ی مدیریت کسب‌وکار کوچک: فاکتور رسمی، دفتر درآمد و هزینه و فروش قسطی، انبارداری، پیگیری سفارش، و ارسال پیام گروهی با شماره.">
<link rel="canonical" href="https://mobiniqow.ir/articles/"> <meta name="robots" content="index, follow">
<meta name="theme-color" content="#107c41"> <meta name="author" content="niqow.ir">
<link rel="canonical" href="https://shopship.ir/articles/">
<link rel="alternate" hreflang="fa-ir" href="https://shopship.ir/articles/">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/img/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/img/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/img/apple-touch-icon.png">
<meta property="og:type" content="website">
<meta property="og:site_name" content="شاپ شیپ">
<meta property="og:title" content="مقالات — شاپ شیپ">
<meta property="og:description" content="راهنمای مدیریت کسب‌وکار کوچک: فاکتور، دفتر درآمد و هزینه، انبارداری، پیگیری سفارش و پیام گروهی.">
<meta property="og:url" content="https://shopship.ir/articles/">
<meta property="og:locale" content="fa_IR">
<meta property="og:image" content="https://shopship.ir/assets/img/og-image.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="مقالات — شاپ شیپ">
<meta name="twitter:image" content="https://shopship.ir/assets/img/og-image.png">
<meta name="theme-color" content="#2d2a55">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
<link rel="stylesheet" href="/assets/atlas.css"> <link rel="stylesheet" href="/assets/atlas.css">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "CollectionPage",
"name": "مقالات شاپ شیپ",
"url": "https://shopship.ir/articles/",
"inLanguage": "fa-IR",
"isPartOf": { "@type": "WebSite", "name": "شاپ شیپ", "url": "https://shopship.ir/" },
"publisher": { "@type": "Organization", "name": "niqow.ir", "url": "https://niqow.ir" }
}
</script>
</head> </head>
<body> <body>
<header class="nav container"> <header class="nav-wrap" id="siteHeader">
<a class="nav-brand" href="/">کاسبیار</a> <div class="nav container">
<nav class="nav-links"> <a class="nav-brand" href="/">
<a href="/#features">امکانات</a> <img src="/assets/img/icon-192.png" alt="" width="28" height="28" class="nav-brand-icon">
<a href="/#channels">کانال‌ها</a> شاپ شیپ
<a href="/articles/">مقالات</a> </a>
</nav> <nav class="nav-links">
<a href="/#tools">بخش‌های اپ</a>
<a href="/#who">برای کیه</a>
<a href="/#pricing">قیمت‌گذاری</a>
<a href="/articles/">مقالات</a>
</nav>
<a class="btn btn-primary btn-sm" href="/#cta">دریافت اپ</a>
</div>
</header> </header>
<main class="container" style="padding: var(--space-12) 0;"> <main class="container" style="padding: var(--space-12) 0;">
<div class="section-head" style="text-align:right; margin-inline:0;"> <div class="section-head" style="text-align:right; margin-inline:0;">
<h1>مقالات</h1> <h1>مقالات</h1>
<p>راهنمای ارسال پیام گروهی با شماره — پیامک، واتساپ، فیلد سفارشی و مرج مخاطبین.</p> <p>راهنمای مدیریت کسب‌وکار کوچک — فاکتور، دفتر درآمد و هزینه، انبارداری، پیگیری سفارش و پیام گروهی با شماره.</p>
</div> </div>
<div class="article-list"> <div class="article-list">
@@ -37,16 +75,32 @@
<h2><a href="/articles/{{ article.slug }}/">{{ article.title }}</a></h2> <h2><a href="/articles/{{ article.slug }}/">{{ article.title }}</a></h2>
<p>{{ article.excerpt }}</p> <p>{{ article.excerpt }}</p>
</article> </article>
{% empty %}
<p>هنوز مقاله‌ای منتشر نشده — به‌زودی برمی‌گردیم.</p>
{% endfor %} {% endfor %}
</div> </div>
</main> </main>
<footer> <footer>
<div class="container"> <div class="container">
<span>© کاسبیار — ابزار ارسال پیام گروهی</span> <div class="footer-brand">
<strong>شاپ شیپ</strong>
<span>محصولی از <a href="https://niqow.ir" target="_blank" rel="noopener">niqow.ir</a></span>
</div>
<span><a href="/">صفحه‌ی اصلی</a></span> <span><a href="/">صفحه‌ی اصلی</a></span>
<span>© شاپ شیپ</span>
</div> </div>
</footer> </footer>
<script>
var header = document.getElementById('siteHeader');
if (header) {
var onScroll = function () {
header.classList.toggle('is-scrolled', window.scrollY > 8);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
}
</script>
</body> </body>
</html> </html>
+51
View File
@@ -0,0 +1,51 @@
import datetime
from django.test import TestCase
from django.urls import reverse
from apps.landing.models import Article
def _make_article(**overrides):
defaults = {
"slug": "test-article",
"title": "عنوان تست",
"excerpt": "خلاصه تست",
"content_html": "<p>متن تست</p>",
"published_at": datetime.date(2020, 1, 1),
"is_published": True,
}
defaults.update(overrides)
return Article.objects.create(**defaults)
class ArticlePublishScheduleTests(TestCase):
"""published_at یک gate واقعی است — مقاله‌ی is_published=True با تاریخِ
آینده نباید تا رسیدن آن تاریخ در سایت دیده شود (انتشار پلکانی/زمان‌بندی‌شده)."""
def test_past_dated_article_appears_in_list(self):
_make_article(slug="past-one", published_at=datetime.date(2020, 1, 1))
response = self.client.get(reverse("landing:article_list"))
self.assertContains(response, "عنوان تست")
def test_future_dated_article_hidden_from_list(self):
future = datetime.date.today() + datetime.timedelta(days=10)
_make_article(slug="future-one", published_at=future)
response = self.client.get(reverse("landing:article_list"))
self.assertNotContains(response, "عنوان تست")
def test_future_dated_article_detail_is_404(self):
future = datetime.date.today() + datetime.timedelta(days=10)
article = _make_article(slug="future-two", published_at=future)
response = self.client.get(reverse("landing:article_detail", args=[article.slug]))
self.assertEqual(response.status_code, 404)
def test_unpublished_article_hidden_regardless_of_date(self):
_make_article(slug="draft-one", is_published=False, published_at=datetime.date(2020, 1, 1))
response = self.client.get(reverse("landing:article_list"))
self.assertNotContains(response, "عنوان تست")
def test_today_dated_article_is_visible(self):
_make_article(slug="today-one", published_at=datetime.date.today())
response = self.client.get(reverse("landing:article_list"))
self.assertContains(response, "عنوان تست")
+13 -3
View File
@@ -1,13 +1,23 @@
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.utils import timezone
from .models import Article from .models import Article
def _visible_articles():
# published_at یعنی «از این تاریخ به بعد دیده شود» — نه فقط یک برچسبِ
# نمایشی. همین باعث می‌شود بشود چند مقاله را از قبل با is_published=True
# و published_at آینده ثبت کرد و هرکدام سرِ تاریخِ خودش خودکار ظاهر شود
# (مثلا برای انتشار پلکانیِ هر چند روز یک‌بار، بدون نیاز به کرون/تسک جدا).
return Article.objects.filter(is_published=True, published_at__lte=timezone.localdate())
def article_list(request): def article_list(request):
articles = Article.objects.filter(is_published=True) articles = _visible_articles()
return render(request, "landing/article_list.html", {"articles": articles}) return render(request, "landing/article_list.html", {"articles": articles})
def article_detail(request, slug): def article_detail(request, slug):
article = get_object_or_404(Article, slug=slug, is_published=True) article = get_object_or_404(_visible_articles(), slug=slug)
return render(request, "landing/article_detail.html", {"article": article}) related = _visible_articles().exclude(pk=article.pk)[:3]
return render(request, "landing/article_detail.html", {"article": article, "related": related})
-54
View File
@@ -1,54 +0,0 @@
# 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')],
},
),
]
@@ -1,46 +0,0 @@
# Generated by Django 5.1.15 on 2026-08-23 20:54
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='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=150, verbose_name='عنوان')),
('body', models.TextField(blank=True, verbose_name='متن')),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='کاربر (خالی = عمومی برای همه)')),
],
options={
'verbose_name': 'اعلان',
'verbose_name_plural': 'اعلان\u200cها',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='NotificationRead',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('read_at', models.DateTimeField(auto_now_add=True)),
('notification', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reads', to='notifications.notification')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_reads', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'خوانده\u200cشدنِ اعلان',
'verbose_name_plural': 'خوانده\u200cشدنِ اعلان\u200cها',
'constraints': [models.UniqueConstraint(fields=('user', 'notification'), name='unique_user_notification_read')],
},
),
]
-69
View File
@@ -1,69 +0,0 @@
# Generated by Django 5.1.15 on 2026-08-23 21:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('banker', '0002_structuredtemplate_structuredtemplatefield'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='OrderStage',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('label', models.CharField(max_length=150, verbose_name='عنوان مرحله')),
('sort_order', models.PositiveIntegerField(default=0, verbose_name='ترتیب')),
('updated_at', models.DateTimeField(auto_now=True)),
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_stages', to='banker.messagetemplate', verbose_name='قالب پیامک این مرحله')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='order_stages', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'مرحله\u200cی پیگیری سفارش',
'verbose_name_plural': 'مراحل پیگیری سفارش',
'ordering': ['sort_order'],
},
),
migrations.CreateModel(
name='Order',
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(max_length=20, verbose_name='شماره مشتری')),
('tracking_code', models.CharField(blank=True, max_length=100, verbose_name='کد پیگیری پستی')),
('is_completed', models.BooleanField(default=False, verbose_name='تکمیل\u200cشده')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to=settings.AUTH_USER_MODEL)),
('current_stage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.orderstage')),
],
options={
'verbose_name': 'سفارش',
'verbose_name_plural': 'سفارش\u200cها',
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='OrderStageLog',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('stage_label', models.CharField(max_length=150, verbose_name='عنوان مرحله')),
('message_body', models.TextField(blank=True, verbose_name='متن پیامک ارسالی')),
('created_at', models.DateTimeField(auto_now_add=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stage_logs', to='orders.order')),
('stage', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='orders.orderstage')),
],
options={
'verbose_name': 'تاریخچه\u200cی مرحله\u200cی سفارش',
'verbose_name_plural': 'تاریخچه\u200cی مراحل سفارش',
'ordering': ['created_at'],
},
),
]
@@ -1,28 +0,0 @@
# Generated by Django 5.1.15 on 2026-08-24 12:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='orderstage',
name='extra_input_label',
field=models.CharField(blank=True, help_text='مثلا: کد تسویه، کد محصول — وقتی سفارش به این مرحله می\u200cرسد از کاربر پرسیده می\u200cشود.', max_length=100, verbose_name='عنوان ورودی اضافه'),
),
migrations.AddField(
model_name='orderstage',
name='requires_extra_input',
field=models.BooleanField(default=False, verbose_name='این مرحله به یک ورودی اضافه نیاز دارد'),
),
migrations.AddField(
model_name='orderstagelog',
name='extra_input_value',
field=models.CharField(blank=True, max_length=255, verbose_name='ورودی اضافه\u200cی این مرحله'),
),
]
View File
+183
View File
@@ -0,0 +1,183 @@
from datetime import timedelta
from django.contrib import admin
from django.db.models import Avg, Count, DurationField, ExpressionWrapper, F, Max, Q, Sum
from django.template.response import TemplateResponse
from django.utils import timezone
from unfold.admin import ModelAdmin
from apps.account.models import User
from .models import ApiCallLog, AppSession, ClickEvent, ClientErrorLog, PageView
# مانیتورینگِ کلیِ اپ توسط پنل ادمین: صفحه‌ی changelist مدلِ AppSession
# جایگزینِ یک داشبوردِ تجمیعی می‌شود (نه لیستِ خامِ ردیف‌ها) — برای دیدنِ
# لیستِ خام هر رکورد، لینکِ «نمای خام» بالای همان صفحه با ?raw=1 به همان
# changelist معمولیِ جنگو می‌رود.
_WINDOW_DAYS = 30
@admin.register(AppSession)
class AppSessionAdmin(ModelAdmin):
list_display = ("user", "platform", "started_at", "ended_at", "ip_address")
list_filter = ("platform",)
search_fields = ("user__phone", "ip_address")
date_hierarchy = "started_at"
def changelist_view(self, request, extra_context=None):
if request.GET.get("raw") == "1":
return super().changelist_view(request, extra_context)
return self._dashboard_view(request)
def _dashboard_view(self, request):
context = {
**self.admin_site.each_context(request),
"title": "مانیتورینگ اپ",
"opts": self.model._meta,
**self._build_dashboard_context(),
}
return TemplateResponse(request, "admin/telemetry/dashboard.html", context)
def _build_dashboard_context(self):
now = timezone.now()
cutoff = now - timedelta(days=_WINDOW_DAYS)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
sessions_qs = AppSession.objects.filter(started_at__gte=cutoff)
api_qs = ApiCallLog.objects.filter(created_at__gte=cutoff)
pageviews_qs = PageView.objects.filter(entered_at__gte=cutoff)
errors_qs = ClientErrorLog.objects.filter(created_at__gte=cutoff)
overview = {
"active_users_today": sessions_qs.filter(started_at__gte=today_start)
.values("user")
.distinct()
.count(),
"sessions_today": sessions_qs.filter(started_at__gte=today_start).count(),
"sessions_window": sessions_qs.count(),
"page_views_today": pageviews_qs.filter(entered_at__gte=today_start).count(),
"api_calls_today": api_qs.filter(created_at__gte=today_start).count(),
"errors_today": errors_qs.filter(created_at__gte=today_start).count(),
"avg_response_ms": round(api_qs.aggregate(v=Avg("duration_ms"))["v"] or 0, 1),
}
ended = sessions_qs.exclude(ended_at__isnull=True).annotate(
dur=ExpressionWrapper(F("ended_at") - F("started_at"), output_field=DurationField())
)
avg_duration = ended.aggregate(v=Avg("dur"))["v"]
overview["avg_session_minutes"] = round(avg_duration.total_seconds() / 60, 1) if avg_duration else 0
top_screens = list(
pageviews_qs.values("screen_name")
.annotate(count=Count("id"), avg_duration=Avg("duration_seconds"))
.order_by("-count")[:10]
)
max_screen_count = max((s["count"] for s in top_screens), default=1) or 1
for s in top_screens:
s["bar_percent"] = round(100 * s["count"] / max_screen_count)
s["avg_duration"] = round(s["avg_duration"], 1) if s["avg_duration"] else None
endpoints = list(
api_qs.values("path", "method")
.annotate(
count=Count("id"),
avg_ms=Avg("duration_ms"),
max_ms=Max("duration_ms"),
error_count=Count("id", filter=Q(status_code__gte=400)),
)
.order_by("-count")[:20]
)
for e in endpoints:
e["avg_ms"] = round(e["avg_ms"], 1) if e["avg_ms"] else 0
recent_errors = list(errors_qs.select_related("session__user")[:20])
active_user_ids = sessions_qs.values_list("user_id", flat=True).distinct()
user_rows = []
for user in User.objects.filter(id__in=active_user_ids):
user_sessions = sessions_qs.filter(user=user)
ended_user = user_sessions.exclude(ended_at__isnull=True).annotate(
dur=ExpressionWrapper(F("ended_at") - F("started_at"), output_field=DurationField())
)
total_dur = ended_user.aggregate(v=Sum("dur"))["v"]
ips = list(
user_sessions.exclude(ip_address__isnull=True)
.values_list("ip_address", flat=True)
.distinct()
)
top_screen = (
pageviews_qs.filter(session__user=user)
.values("screen_name")
.annotate(c=Count("id"))
.order_by("-c")
.first()
)
last_session = user_sessions.order_by("-started_at").first()
user_rows.append(
{
"user": user,
"session_count": user_sessions.count(),
"total_minutes": round(total_dur.total_seconds() / 60, 1) if total_dur else 0,
"page_view_count": pageviews_qs.filter(session__user=user).count(),
"api_call_count": api_qs.filter(user=user).count(),
"error_count": errors_qs.filter(session__user=user).count(),
"ips": ips,
"referral_count": User.objects.filter(
referred_by=user, referral_bonus_granted=True
).count(),
"top_screen": top_screen["screen_name"] if top_screen else "—",
"last_seen": last_session.started_at if last_session else None,
}
)
user_rows.sort(key=lambda r: r["total_minutes"], reverse=True)
overview_cards = [
("کاربران فعال امروز", overview["active_users_today"]),
("جلسه‌های امروز", overview["sessions_today"]),
(f"جلسه‌ها ({_WINDOW_DAYS} روز اخیر)", overview["sessions_window"]),
("میانگین طول جلسه (دقیقه)", overview["avg_session_minutes"]),
("صفحه‌بازدید امروز", overview["page_views_today"]),
("فراخوانی API امروز", overview["api_calls_today"]),
("میانگین زمان پاسخ (ms)", overview["avg_response_ms"]),
("خطای امروز", overview["errors_today"]),
]
return {
"overview": overview,
"overview_cards": overview_cards,
"top_screens": top_screens,
"endpoints": endpoints,
"recent_errors": recent_errors,
"user_rows": user_rows[:50],
"window_days": _WINDOW_DAYS,
}
@admin.register(ApiCallLog)
class ApiCallLogAdmin(ModelAdmin):
list_display = ("method", "path", "status_code", "duration_ms", "ip_address", "user", "created_at")
list_filter = ("method", "status_code")
search_fields = ("path", "user__phone", "ip_address")
date_hierarchy = "created_at"
@admin.register(PageView)
class PageViewAdmin(ModelAdmin):
list_display = ("screen_name", "session", "entered_at", "duration_seconds")
list_filter = ("screen_name",)
search_fields = ("screen_name",)
@admin.register(ClickEvent)
class ClickEventAdmin(ModelAdmin):
list_display = ("screen_name", "element", "session", "created_at")
list_filter = ("screen_name",)
search_fields = ("screen_name", "element")
@admin.register(ClientErrorLog)
class ClientErrorLogAdmin(ModelAdmin):
list_display = ("screen_name", "message", "session", "created_at")
list_filter = ("screen_name",)
search_fields = ("screen_name", "message")
View File
+31
View File
@@ -0,0 +1,31 @@
from rest_framework import serializers
class TelemetryEventSerializer(serializers.Serializer):
"""یک رویدادِ خام از اپ — نوع‌اش تعیین می‌کند کدام فیلدها معنی دارند.
عمداً یک سریالایزرِ آزاد (نه یکی برای هر مدل) چون این اندپوینت دسته‌ای
(batch) کار می‌کند و ترکیبِ چند نوع رویداد را با هم می‌گیرد."""
type = serializers.ChoiceField(
choices=["session_start", "session_end", "page_view", "click", "error"]
)
at = serializers.DateTimeField()
screen = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
duration_seconds = serializers.FloatField(required=False, allow_null=True, default=None)
element = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
x = serializers.FloatField(required=False, allow_null=True, default=None)
y = serializers.FloatField(required=False, allow_null=True, default=None)
message = serializers.CharField(required=False, allow_blank=True, default="")
stack = serializers.CharField(required=False, allow_blank=True, default="")
class TelemetryBatchSerializer(serializers.Serializer):
session_id = serializers.CharField(max_length=36)
platform = serializers.CharField(max_length=30, required=False, allow_blank=True, default="")
app_version = serializers.CharField(max_length=30, required=False, allow_blank=True, default="")
events = TelemetryEventSerializer(many=True)
def validate_events(self, value):
if not value:
raise serializers.ValidationError("حداقل یک رویداد لازم است.")
return value
+60
View File
@@ -0,0 +1,60 @@
from django.db import transaction
from apps.account.models import User
from apps.telemetry.models import AppSession, ClickEvent, ClientErrorLog, PageView
class TelemetryIngestService:
@staticmethod
@transaction.atomic
def ingest(user: User, ip_address: str | None, data: dict) -> None:
session, _created = AppSession.objects.get_or_create(
id=data["session_id"],
defaults={
"user": user,
"platform": data.get("platform", ""),
"app_version": data.get("app_version", ""),
"ip_address": ip_address,
"started_at": data["events"][0]["at"],
},
)
# اگر آی‌پی از قبل ثبت نشده یا عوض شده، آخرین مقدار را نگه می‌داریم —
# همان چیزی که «لیست آی‌پی‌هایی که کاربر باهاش اومده» را می‌سازد (از
# طریق تجمیعِ همه‌ی session های همان کاربر، نه فقط این یکی).
if ip_address and session.ip_address != ip_address:
session.ip_address = ip_address
session.save(update_fields=["ip_address"])
for event in data["events"]:
event_type = event["type"]
if event_type == "session_start":
if session.started_at != event["at"]:
session.started_at = min(session.started_at, event["at"])
session.save(update_fields=["started_at"])
elif event_type == "session_end":
session.ended_at = event["at"]
session.save(update_fields=["ended_at"])
elif event_type == "page_view":
PageView.objects.create(
session=session,
screen_name=event.get("screen", ""),
entered_at=event["at"],
duration_seconds=event.get("duration_seconds"),
)
elif event_type == "click":
ClickEvent.objects.create(
session=session,
screen_name=event.get("screen", ""),
element=event.get("element", ""),
x=event.get("x"),
y=event.get("y"),
created_at=event["at"],
)
elif event_type == "error":
ClientErrorLog.objects.create(
session=session,
screen_name=event.get("screen", ""),
message=event.get("message", ""),
stack_trace=event.get("stack", ""),
created_at=event["at"],
)
+24
View File
@@ -0,0 +1,24 @@
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .serializers import TelemetryBatchSerializer
from .services import TelemetryIngestService
class TelemetryEventsView(APIView):
"""اپ فلاتر دوره‌ای (و موقعِ بستن) یک دسته رویداد اینجا می‌فرستد —
صفحه‌بازدید، کلیک، خطا، شروع/پایانِ جلسه. لاگین لازم است چون این
مانیتورینگ «به‌ازای کاربر» است، نه ترافیکِ ناشناس."""
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = TelemetryBatchSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
ip = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() or request.META.get(
"REMOTE_ADDR"
)
TelemetryIngestService.ingest(request.user, ip, serializer.validated_data)
return Response({"success": True}, status=status.HTTP_201_CREATED)
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class TelemetryConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.telemetry"
verbose_name = _("مانیتورینگ و آمار اپ")
+60
View File
@@ -0,0 +1,60 @@
import time
# مانیتورینگ خودکار همه‌ی فراخوانی‌های API — بدون نیازِ به هیچ تغییری توی
# اپ فلاتر، چون توی همان سطحِ میدل‌ور جنگو کار می‌کند. کاربر از JWT توی
# هدرِ Authorization (اگر باشد) استخراج می‌شود — در این لحظه هنوز
# AuthenticationMiddleware کاربر واقعی را روی request نگذاشته (آن فقط برای
# auth مبتنی‌بر session است، نه JWT)، پس خودمان با JWTAuthentication همان
# توکن را می‌خوانیم.
class ApiCallLoggingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
start = time.monotonic()
response = self.get_response(request)
if request.path.startswith("/api/"):
duration_ms = (time.monotonic() - start) * 1000
try:
self._log(request, response, duration_ms)
except Exception:
# لاگ‌گیریِ مانیتورینگ هیچ‌وقت نباید پاسخِ واقعی به کاربر را
# خراب کند.
pass
return response
def _log(self, request, response, duration_ms):
from .models import ApiCallLog
ApiCallLog.objects.create(
user=self._resolve_user(request),
method=request.method,
path=request.path[:255],
status_code=response.status_code,
duration_ms=duration_ms,
ip_address=self._client_ip(request),
)
@staticmethod
def _resolve_user(request):
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if not auth_header.startswith("Bearer "):
return None
try:
from rest_framework_simplejwt.authentication import JWTAuthentication
result = JWTAuthentication().authenticate(request)
return result[0] if result else None
except Exception:
return None
@staticmethod
def _client_ip(request):
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
if forwarded:
return forwarded.split(",")[0].strip()
return request.META.get("REMOTE_ADDR") or None
+122
View File
@@ -0,0 +1,122 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# مانیتورینگ اپ: هر فراخوانیِ API خودکار (سمت سرور، بدون نیاز به تغییر اپ
# فلاتر) لاگ می‌شود؛ رویدادهای سمت کلاینت (صفحه‌بازدید/کلیک/خطا) از یک
# اندپوینتِ دسته‌ای ثبت می‌شوند که اپ فلاتر دوره‌ای می‌فرستد.
class ApiCallLog(models.Model):
"""هر درخواستِ API — خودکار توسط ApiCallLoggingMiddleware ثبت می‌شود."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="api_calls",
verbose_name=_("کاربر"),
)
method = models.CharField(max_length=10, verbose_name=_("متد"))
path = models.CharField(max_length=255, verbose_name=_("مسیر"))
status_code = models.PositiveSmallIntegerField(verbose_name=_("کد وضعیت"))
duration_ms = models.FloatField(verbose_name=_("زمان پاسخ (میلی‌ثانیه)"))
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("آی‌پی"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("زمان"))
class Meta:
verbose_name = _("لاگ فراخوانی API")
verbose_name_plural = _("لاگ‌های فراخوانی API")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "created_at"]),
models.Index(fields=["path", "created_at"]),
]
def __str__(self):
return f"{self.method} {self.path} ({self.status_code})"
class AppSession(models.Model):
"""یک دوره‌ی «باز بودنِ اپ» — از سمت کلاینت (اپ فلاتر) با یک UUID شروع
می‌شود، با رویدادِ session_end یا انقضای بی‌فعالیت بسته می‌شود."""
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="app_sessions", verbose_name=_("کاربر")
)
platform = models.CharField(max_length=30, blank=True, verbose_name=_("پلتفرم"))
app_version = models.CharField(max_length=30, blank=True, verbose_name=_("نسخه‌ی اپ"))
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("آی‌پی"))
started_at = models.DateTimeField(verbose_name=_("شروع"))
ended_at = models.DateTimeField(null=True, blank=True, verbose_name=_("پایان"))
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("جلسه‌ی استفاده از اپ")
verbose_name_plural = _("جلسه‌های استفاده از اپ")
ordering = ["-started_at"]
indexes = [models.Index(fields=["user", "started_at"])]
def __str__(self):
return f"{self.user} — {self.started_at:%Y-%m-%d %H:%M}"
@property
def duration_seconds(self) -> float | None:
if not self.ended_at:
return None
return (self.ended_at - self.started_at).total_seconds()
class PageView(models.Model):
"""یک بازدید از یک صفحه/مسیر توی اپ — با ورود و مدت‌زمانِ ماندن."""
session = models.ForeignKey(AppSession, on_delete=models.CASCADE, related_name="page_views")
screen_name = models.CharField(max_length=150, verbose_name=_("نام صفحه"))
entered_at = models.DateTimeField(verbose_name=_("زمان ورود"))
duration_seconds = models.FloatField(null=True, blank=True, verbose_name=_("مدت ماندن (ثانیه)"))
class Meta:
verbose_name = _("بازدید صفحه")
verbose_name_plural = _("بازدیدهای صفحه")
ordering = ["entered_at"]
indexes = [models.Index(fields=["screen_name"])]
def __str__(self):
return self.screen_name
class ClickEvent(models.Model):
"""یک لمس/کلیک — مختصات نسبی (۰ تا ۱) نسبت به عرض/ارتفاع صفحه، برای
این‌که مستقل از سایز گوشی قابل‌مقایسه باشد."""
session = models.ForeignKey(AppSession, on_delete=models.CASCADE, related_name="clicks")
screen_name = models.CharField(max_length=150, blank=True, verbose_name=_("نام صفحه"))
element = models.CharField(max_length=150, blank=True, verbose_name=_("برچسبِ عنصر (اختیاری)"))
x = models.FloatField(null=True, blank=True)
y = models.FloatField(null=True, blank=True)
created_at = models.DateTimeField(verbose_name=_("زمان"))
class Meta:
verbose_name = _("رویداد کلیک")
verbose_name_plural = _("رویدادهای کلیک")
ordering = ["-created_at"]
indexes = [models.Index(fields=["screen_name"])]
class ClientErrorLog(models.Model):
"""خطای سمت کلاینت (uncaught exception توی اپ فلاتر) — «کجا مشکل خورده»."""
session = models.ForeignKey(
AppSession, on_delete=models.SET_NULL, null=True, blank=True, related_name="errors"
)
screen_name = models.CharField(max_length=150, blank=True, verbose_name=_("نام صفحه"))
message = models.TextField(verbose_name=_("پیام خطا"))
stack_trace = models.TextField(blank=True, verbose_name=_("Stack trace"))
created_at = models.DateTimeField(verbose_name=_("زمان"))
class Meta:
verbose_name = _("خطای سمت کاربر")
verbose_name_plural = _("خطاهای سمت کاربر")
ordering = ["-created_at"]
@@ -0,0 +1,143 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<div dir="rtl" class="space-y-6">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
آمار {{ window_days }} روزِ اخیر — برای دیدنِ لیستِ خامِ جلسه‌ها
<a href="?raw=1" class="text-primary-600 dark:text-primary-400 underline">این‌جا</a> را بزن.
</p>
</div>
{# ---------------- خلاصه‌ی کلی ---------------- #}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{% for label, value in overview_cards %}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-800 p-4">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ label }}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-50 mt-1">{{ value }}</p>
</div>
{% endfor %}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{# ---------------- صفحات پربازدید ---------------- #}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-800 p-4">
<h2 class="font-bold text-gray-900 dark:text-gray-50 mb-3">صفحاتِ پربازدید</h2>
{% if top_screens %}
<div class="space-y-2">
{% for s in top_screens %}
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-700 dark:text-gray-200">{{ s.screen_name|default:"—" }}</span>
<span class="text-gray-500 dark:text-gray-400">
{{ s.count }} بازدید
{% if s.avg_duration %}· میانگین {{ s.avg_duration }} ثانیه{% endif %}
</span>
</div>
<div class="h-2 rounded-full bg-gray-100 dark:bg-base-700 overflow-hidden">
<div class="h-full bg-primary-500" style="width: {{ s.bar_percent }}%"></div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-gray-500 dark:text-gray-400">هنوز داده‌ای ثبت نشده.</p>
{% endif %}
</div>
{# ---------------- خطاهای اخیر ---------------- #}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-800 p-4">
<h2 class="font-bold text-gray-900 dark:text-gray-50 mb-3">خطاهای اخیرِ سمتِ کاربر</h2>
{% if recent_errors %}
<div class="space-y-2 max-h-80 overflow-y-auto">
{% for e in recent_errors %}
<div class="text-sm border-b border-gray-100 dark:border-gray-700 pb-2">
<div class="flex justify-between text-gray-500 dark:text-gray-400 text-xs">
<span>{{ e.screen_name|default:"—" }}</span>
<span>{{ e.created_at|date:"Y-m-d H:i" }}</span>
</div>
<p class="text-gray-800 dark:text-gray-200 truncate">{{ e.message }}</p>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-gray-500 dark:text-gray-400">خطایی ثبت نشده — خوب است.</p>
{% endif %}
</div>
</div>
{# ---------------- عملکرد اندپوینت‌ها ---------------- #}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-800 p-4 overflow-x-auto">
<h2 class="font-bold text-gray-900 dark:text-gray-50 mb-3">عملکردِ اندپوینت‌ها</h2>
<table class="w-full text-sm">
<thead>
<tr class="text-gray-500 dark:text-gray-400 text-xs border-b border-gray-200 dark:border-gray-700">
<th class="text-right py-2">مسیر</th>
<th class="text-right py-2">متد</th>
<th class="text-right py-2">تعداد فراخوانی</th>
<th class="text-right py-2">میانگین زمان (ms)</th>
<th class="text-right py-2">بیشترین زمان (ms)</th>
<th class="text-right py-2">تعداد خطا</th>
</tr>
</thead>
<tbody>
{% for e in endpoints %}
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 text-gray-800 dark:text-gray-200" dir="ltr">{{ e.path }}</td>
<td class="py-2">{{ e.method }}</td>
<td class="py-2">{{ e.count }}</td>
<td class="py-2">{{ e.avg_ms }}</td>
<td class="py-2">{{ e.max_ms|floatformat:1 }}</td>
<td class="py-2 {% if e.error_count %}text-red-600 dark:text-red-400{% endif %}">{{ e.error_count }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="py-4 text-center text-gray-500 dark:text-gray-400">هنوز داده‌ای ثبت نشده.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{# ---------------- به‌ازای هر کاربر ---------------- #}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-800 p-4 overflow-x-auto">
<h2 class="font-bold text-gray-900 dark:text-gray-50 mb-3">وضعیتِ هر کاربر</h2>
<table class="w-full text-sm">
<thead>
<tr class="text-gray-500 dark:text-gray-400 text-xs border-b border-gray-200 dark:border-gray-700">
<th class="text-right py-2">کاربر</th>
<th class="text-right py-2">زمان در اپ (دقیقه)</th>
<th class="text-right py-2">تعداد جلسه</th>
<th class="text-right py-2">صفحه‌بازدید</th>
<th class="text-right py-2">فراخوانی API</th>
<th class="text-right py-2">خطا</th>
<th class="text-right py-2">صفحه‌ی محبوب</th>
<th class="text-right py-2">تعداد دعوت</th>
<th class="text-right py-2">آی‌پی‌ها</th>
<th class="text-right py-2">آخرین حضور</th>
</tr>
</thead>
<tbody>
{% for row in user_rows %}
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 text-gray-800 dark:text-gray-200" dir="ltr">{{ row.user.phone }}</td>
<td class="py-2">{{ row.total_minutes }}</td>
<td class="py-2">{{ row.session_count }}</td>
<td class="py-2">{{ row.page_view_count }}</td>
<td class="py-2">{{ row.api_call_count }}</td>
<td class="py-2 {% if row.error_count %}text-red-600 dark:text-red-400{% endif %}">{{ row.error_count }}</td>
<td class="py-2">{{ row.top_screen }}</td>
<td class="py-2">{{ row.referral_count }}</td>
<td class="py-2" dir="ltr">{{ row.ips|join:", "|default:"—" }}</td>
<td class="py-2">{{ row.last_seen|date:"Y-m-d H:i"|default:"—" }}</td>
</tr>
{% empty %}
<tr><td colspan="10" class="py-4 text-center text-gray-500 dark:text-gray-400">هنوز کاربری در این بازه فعال نبوده.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+209
View File
@@ -0,0 +1,209 @@
from datetime import datetime, timedelta
from datetime import timezone as dt_timezone
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.account.models import User
from apps.telemetry.admin import AppSessionAdmin
from apps.telemetry.api.services import TelemetryIngestService
from apps.telemetry.models import ApiCallLog, AppSession, ClickEvent, ClientErrorLog, PageView
def _make_user(phone="09120000000") -> User:
return User.objects.create_user(phone=phone, is_active=True, state=User.State.ACTIVE)
def _iso(text: str) -> datetime:
return datetime.fromisoformat(text).replace(tzinfo=dt_timezone.utc)
class TelemetryIngestServiceTests(TestCase):
def setUp(self):
self.user = _make_user()
def test_ingest_creates_session_and_events(self):
TelemetryIngestService.ingest(
self.user,
"5.6.7.8",
{
"session_id": "sess-1",
"platform": "android",
"app_version": "1.0.0",
"events": [
{"type": "session_start", "at": _iso("2026-01-01T10:00:00")},
{
"type": "page_view",
"screen": "LauncherScreen",
"at": _iso("2026-01-01T10:00:05"),
"duration_seconds": 12.5,
"element": "",
"x": None,
"y": None,
"message": "",
"stack": "",
},
{
"type": "click",
"screen": "LauncherScreen",
"element": "invoice_tile",
"at": _iso("2026-01-01T10:00:10"),
"x": 0.4,
"y": 0.5,
"duration_seconds": None,
"message": "",
"stack": "",
},
{
"type": "error",
"screen": "InvoiceHomeScreen",
"message": "boom",
"stack": "trace...",
"at": _iso("2026-01-01T10:01:00"),
"duration_seconds": None,
"element": "",
"x": None,
"y": None,
},
{"type": "session_end", "at": _iso("2026-01-01T10:05:00")},
],
},
)
session = AppSession.objects.get(id="sess-1")
self.assertEqual(session.user, self.user)
self.assertEqual(session.ip_address, "5.6.7.8")
self.assertEqual(session.duration_seconds, 300)
self.assertEqual(PageView.objects.filter(session=session).count(), 1)
self.assertEqual(ClickEvent.objects.filter(session=session).count(), 1)
self.assertEqual(ClientErrorLog.objects.filter(session=session).count(), 1)
def test_ingest_is_idempotent_for_same_session_id(self):
payload = {
"session_id": "sess-2",
"platform": "ios",
"app_version": "1.0.0",
"events": [{"type": "session_start", "at": _iso("2026-01-01T10:00:00")}],
}
TelemetryIngestService.ingest(self.user, "1.1.1.1", payload)
TelemetryIngestService.ingest(self.user, "1.1.1.1", payload)
self.assertEqual(AppSession.objects.filter(id="sess-2").count(), 1)
class TelemetryApiTests(TestCase):
def setUp(self):
self.user = _make_user()
self.client = APIClient()
token = str(RefreshToken.for_user(self.user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
def test_requires_authentication(self):
anon = APIClient()
response = anon.post(reverse("telemetry:events"), {"session_id": "x", "events": []}, format="json")
self.assertEqual(response.status_code, 401)
def test_rejects_empty_events(self):
response = self.client.post(
reverse("telemetry:events"), {"session_id": "sess-3", "events": []}, format="json"
)
self.assertEqual(response.status_code, 400)
def test_accepts_valid_batch(self):
response = self.client.post(
reverse("telemetry:events"),
{
"session_id": "sess-4",
"platform": "android",
"events": [
{"type": "session_start", "at": "2026-01-01T10:00:00Z"},
{"type": "page_view", "screen": "LedgerHomeScreen", "at": "2026-01-01T10:00:05Z"},
],
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(AppSession.objects.filter(id="sess-4", user=self.user).exists())
class ApiCallLoggingMiddlewareTests(TestCase):
"""میدل‌ور باید خودکار (بدون هیچ تغییری توی اپ فلاتر) هر فراخوانی API را
با کاربر، آی‌پی و زمانِ پاسخ لاگ کند."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
token = str(RefreshToken.for_user(self.user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
def test_api_call_is_logged_with_user_and_status(self):
ApiCallLog.objects.all().delete()
self.client.get("/api/bizapps/customers/")
log = ApiCallLog.objects.filter(path="/api/bizapps/customers/").first()
self.assertIsNotNone(log)
self.assertEqual(log.user, self.user)
self.assertEqual(log.method, "GET")
self.assertGreaterEqual(log.duration_ms, 0)
def test_non_api_paths_are_not_logged(self):
ApiCallLog.objects.all().delete()
self.client.get("/admin/login/")
self.assertEqual(ApiCallLog.objects.count(), 0)
class DashboardContextTests(TestCase):
"""داشبوردِ پنل ادمین — همان چیزی که کاربر خواسته: زمانِ هر کاربر توی
اپ، صفحاتِ بازدیدشده، آی‌پی‌ها، تعداد دعوت، و عملکردِ اندپوینت‌ها."""
def setUp(self):
self.user = _make_user(phone="09121110000")
self.invited = _make_user(phone="09121110001")
self.invited.referred_by = self.user
self.invited.referral_bonus_granted = True
self.invited.save()
def test_dashboard_aggregates_per_user_stats(self):
# عمداً نسبت‌به الان (نه یک تاریخِ ثابت) — داشبورد فقط بازه‌ی ۳۰
# روزِ اخیر را نشان می‌دهد، پس تاریخِ ثابتِ گذشته باعثِ فیلترشدنِ
# همین دیتای تست می‌شد.
base = timezone.now() - timedelta(hours=1)
TelemetryIngestService.ingest(
self.user,
"9.9.9.9",
{
"session_id": "sess-dash",
"platform": "android",
"app_version": "1.0.0",
"events": [
{"type": "session_start", "at": base},
{
"type": "page_view",
"screen": "InvoiceHomeScreen",
"at": base,
"duration_seconds": 20,
"element": "",
"x": None,
"y": None,
"message": "",
"stack": "",
},
{"type": "session_end", "at": base + timedelta(minutes=10)},
],
},
)
ApiCallLog.objects.create(
user=self.user, method="GET", path="/api/bizapps/invoices/", status_code=200, duration_ms=50
)
context = AppSessionAdmin(AppSession, None)._build_dashboard_context()
rows = {r["user"].id: r for r in context["user_rows"]}
self.assertIn(self.user.id, rows)
row = rows[self.user.id]
self.assertEqual(row["session_count"], 1)
self.assertEqual(row["total_minutes"], 10.0)
self.assertEqual(row["page_view_count"], 1)
self.assertEqual(row["api_call_count"], 1)
self.assertEqual(row["referral_count"], 1)
self.assertEqual(row["ips"], ["9.9.9.9"])
self.assertEqual(context["overview"]["sessions_window"], 1)
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from .api.views import TelemetryEventsView
app_name = "telemetry"
urlpatterns = [
path("events/", TelemetryEventsView.as_view(), name="events"),
]
+87 -69
View File
@@ -1,11 +1,10 @@
# config/settings.py # config/settings.py
""" """
تنظیمات جنگو برای بک‌اند «پیامک‌بان». تنظیمات جنگو برای پروژه «شاپ‌شیپ».
همه‌ی مقادیر حساس/محیطی از .env خوانده می‌شوند (با python-decouple) تا برای همه‌ی مقادیر حساس/محیطی از .env خوانده می‌شوند.
بالا آوردن روی هر سروری فقط کافی باشد یک فایل .env ساخته شود؛ نیازی به تغییر
کد نیست.
""" """
import os
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
@@ -13,24 +12,26 @@ from decouple import Config, Csv, RepositoryEnv
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
# مسیر .env را عمداً صریح می‌دهیم (نه decouple.config پیش‌فرض که بر اساس # ============================================
# فریم صدازننده حدس می‌زند) — آن حدسِ مبتنی‌بر عمقِ استک، بسته به این‌که # بارگذاری فایل .env
# جنگو از کجا اجرا شود (manage.py check در برابر gunicorn در برابر # ============================================
# manage.py shell)، می‌تواند مسیر اشتباه را پیدا کند و بی‌صدا از مقادیر
# default() استفاده کند — یعنی .env واقعی هیچ‌وقت خوانده نمی‌شود.
_env_path = BASE_DIR / ".env" _env_path = BASE_DIR / ".env"
if not _env_path.exists(): if not _env_path.exists():
raise RuntimeError( raise RuntimeError(
f"فایل .env پیدا نشد ({_env_path}) — یک‌بار «cp .env.example .env» بزن و مقادیر واقعی را پر کن." f"❌ فایل .env پیدا نشد ({_env_path}) — لطفاً یک فایل .env ایجاد کنید."
) )
config = Config(RepositoryEnv(str(_env_path))) config = Config(RepositoryEnv(str(_env_path)))
# -------------------- امنیت و محیط اجرا -------------------- # ============================================
SECRET_KEY = config("SECRET_KEY", default="django-insecure-change-me-in-production") # امنیت و محیط اجرا
DEBUG = config("DEBUG", default=False, cast=bool) # ============================================
SECRET_KEY = config("SECRET_KEY")
DEBUG = False
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=Csv()) ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=Csv())
# -------------------- اپلیکیشن‌ها -------------------- # ============================================
# اپلیکیشن‌ها
# ============================================
DJANGO_APPS = [ DJANGO_APPS = [
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
@@ -60,6 +61,9 @@ LOCAL_APPS = [
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
# ============================================
# Middleware
# ============================================
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware",
@@ -75,6 +79,9 @@ MIDDLEWARE = [
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
# ============================================
# Templates
# ============================================
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
@@ -91,44 +98,43 @@ TEMPLATES = [
}, },
] ]
# -------------------- دیتابیس -------------------- # ============================================
# پیش‌فرض SQLite (بدون نیاز به .env برای اجرای محلی)؛ برای پروداکشن/داکر با # Database
# گذاشتن DB_ENGINE=django.db.backends.postgresql در .env به PostgreSQL سوییچ کن. # ============================================
if config("DB_ENGINE", default="django.db.backends.sqlite3") == "django.db.backends.sqlite3": DATABASES = {
DATABASES = { "default": {
"default": { "ENGINE": config("DB_ENGINE", default="django.db.backends.sqlite3"),
"ENGINE": "django.db.backends.sqlite3", "NAME": config("DB_NAME", default=BASE_DIR / "db.sqlite3"),
"NAME": BASE_DIR / "db.sqlite3", "USER": config("DB_USER", default=""),
} "PASSWORD": config("DB_PASSWORD", default=""),
} "HOST": config("DB_HOST", default=""),
else: "PORT": config("DB_PORT", default=""),
DATABASES = {
"default": {
"ENGINE": config("DB_ENGINE"),
"NAME": config("DB_NAME"),
"USER": config("DB_USER"),
"PASSWORD": config("DB_PASSWORD"),
"HOST": config("DB_HOST", default="localhost"),
"PORT": config("DB_PORT", default="5432"),
}
} }
}
# ============================================
# Auth
# ============================================
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
] ]
AUTH_USER_MODEL = "account.User" AUTH_USER_MODEL = "account.User"
# -------------------- بین‌الملل -------------------- # ============================================
# بین‌الملل
# ============================================
LANGUAGE_CODE = "fa-ir" LANGUAGE_CODE = "fa-ir"
TIME_ZONE = "Asia/Tehran" TIME_ZONE = "Asia/Tehran"
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# -------------------- فایل‌های استاتیک -------------------- # ============================================
# استاتیک (CSS/JS پنل ادمین و غیره) را WhiteNoise middleware سرو می‌کند — # فایل‌های استاتیک
# چه DEBUG=True چه False، بدون نیاز به تغییری در urls.py. # ============================================
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = { STORAGES = {
@@ -137,34 +143,30 @@ STORAGES = {
}, },
} }
# -------------------- لندینگ‌پیج -------------------- # ============================================
# پوشه‌ی landing/ (HTML/CSS ساده، بدون build step) دقیقا از ریشه‌ی دامنه # Landing Page
# سرو می‌شود — یعنی landing/index.html روی «/»، landing/articles/index.html # ============================================
# روی «/articles/» و غیره؛ همان WhiteNoise که استاتیک را سرو می‌کند، این
# را هم سرو می‌کند، بدون نیاز به هیچ view یا route دستی در urls.py.
WHITENOISE_ROOT = BASE_DIR / "landing" WHITENOISE_ROOT = BASE_DIR / "landing"
WHITENOISE_INDEX_FILE = True WHITENOISE_INDEX_FILE = True
# -------------------- فایل‌های رسانه (آپلودی) -------------------- # ============================================
# فعلا هیچ مدلی ImageField/FileField ندارد، ولی مسیرش را از الان آماده # فایل‌های رسانه
# می‌کنیم. برخلاف استاتیک، WhiteNoise رسانه را سرو نمی‌کند — سروش در # ============================================
# urls.py و فقط برای DEBUG=True است (dev)؛ در production باید Nginx/سرور
# وب مستقیم از MEDIA_ROOT سرو کند، نه خودِ جنگو.
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# -------------------- CORS -------------------- # ============================================
# CORS
# ============================================
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default="", cast=Csv()) CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default="", cast=Csv())
CORS_ALLOW_ALL_ORIGINS = config("CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool) CORS_ALLOW_ALL_ORIGINS = config("CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool)
# دامنه‌های مجاز برای پنل ادمین (فرم‌های session-cookie/CSRF جنگو، برخلاف
# JWT API که این محدودیت را ندارد) — بدون این، پشت HTTPS/دامنه‌ی واقعی، لاگین
# ادمین با خطای «CSRF verification failed» رد می‌شود.
CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS", default="", cast=Csv()) CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS", default="", cast=Csv())
# -------------------- Django REST Framework -------------------- # ============================================
# Django REST Framework
# ============================================
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ( "DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication", "rest_framework_simplejwt.authentication.JWTAuthentication",
@@ -182,6 +184,9 @@ REST_FRAMEWORK = {
}, },
} }
# ============================================
# Simple JWT
# ============================================
SIMPLE_JWT = { SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(days=config("JWT_ACCESS_LIFETIME_DAYS", default=1, cast=int)), "ACCESS_TOKEN_LIFETIME": timedelta(days=config("JWT_ACCESS_LIFETIME_DAYS", default=1, cast=int)),
"REFRESH_TOKEN_LIFETIME": timedelta(days=config("JWT_REFRESH_LIFETIME_DAYS", default=30, cast=int)), "REFRESH_TOKEN_LIFETIME": timedelta(days=config("JWT_REFRESH_LIFETIME_DAYS", default=30, cast=int)),
@@ -191,35 +196,48 @@ SIMPLE_JWT = {
"USER_ID_CLAIM": "user_id", "USER_ID_CLAIM": "user_id",
} }
# -------------------- پیامک (OTP) -------------------- # ============================================
# فقط دو مقدار لازم است: نام قالب/پترن پیام و آدرس سرویس ارسال (توکن). # ✅ تنظیمات پیامک (فقط OTP با کاوه‌نگار)
# اگر بخواهی خودت payload را با سرویس‌دهنده‌ات هماهنگ کنی، apps/account/send_sms.py را ببین. # ============================================
SMS_PATTERN_NAME = config("SMS_PATTERN_NAME", default="") KAVENEGAR_API_KEY = config("KAVENEGAR_API_KEY", default="")
SMS_PATTERN_NAME = config("SMS_PATTERN_NAME", default="shopshipotp")
# برای سازگاری با کدهای قدیمی (استفاده نمی‌شود)
SMS_TOKEN_URL = config("SMS_TOKEN_URL", default="") SMS_TOKEN_URL = config("SMS_TOKEN_URL", default="")
# -------------------- ارسال گروهی چندکاناله (apps.messaging) -------------------- # ============================================
# هرکدام خالی بماند، همان کانال به‌جای ارسال خودکار needs_manual برمی‌گرداند # تنظیمات کانال‌های پیام‌رسان (در صورت نیاز)
# (صف deep-link سمت اپ) — نه خطا، دقیقا مثل رفتار SMS_TOKEN_URL بالا. # ============================================
# پیامک بالک با متن آزاد — جدا از SMS_PATTERN_NAME/SMS_TOKEN_URL که فقط OTP است.
KAVENEGAR_API_KEY = config("KAVENEGAR_API_KEY", default="")
KAVENEGAR_SENDER_LINE = config("KAVENEGAR_SENDER_LINE", default="") KAVENEGAR_SENDER_LINE = config("KAVENEGAR_SENDER_LINE", default="")
# WhatsApp Business Cloud API رسمی (Meta) — نیاز به Template تاییدشده دارد.
WHATSAPP_ACCESS_TOKEN = config("WHATSAPP_ACCESS_TOKEN", default="") WHATSAPP_ACCESS_TOKEN = config("WHATSAPP_ACCESS_TOKEN", default="")
WHATSAPP_PHONE_NUMBER_ID = config("WHATSAPP_PHONE_NUMBER_ID", default="") WHATSAPP_PHONE_NUMBER_ID = config("WHATSAPP_PHONE_NUMBER_ID", default="")
WHATSAPP_TEMPLATE_NAME = config("WHATSAPP_TEMPLATE_NAME", default="") WHATSAPP_TEMPLATE_NAME = config("WHATSAPP_TEMPLATE_NAME", default="")
# Bot API رسمی هرکدام — فقط برای مخاطبینی که قبلا با بات چت را شروع کرده‌اند.
TELEGRAM_BOT_TOKEN = config("TELEGRAM_BOT_TOKEN", default="") TELEGRAM_BOT_TOKEN = config("TELEGRAM_BOT_TOKEN", default="")
BALE_BOT_TOKEN = config("BALE_BOT_TOKEN", default="") BALE_BOT_TOKEN = config("BALE_BOT_TOKEN", default="")
EITAA_BOT_TOKEN = config("EITAA_BOT_TOKEN", default="") EITAA_BOT_TOKEN = config("EITAA_BOT_TOKEN", default="")
# ============================================
# لاگینگ
# ============================================
LOGGING = { LOGGING = {
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,
"handlers": { "handlers": {
"console": {"class": "logging.StreamHandler"}, "console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"apps.account": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": True,
},
"django": {
"handlers": ["console"],
"level": "INFO",
"propagate": True,
},
}, },
"root": { "root": {
"handlers": ["console"], "handlers": ["console"],
+307 -101
View File
@@ -1,24 +1,30 @@
/* Atlas — توکن‌های دیزاین‌سیستم (از claude.ai/design، پروژه‌ی /* Atlas — توکن‌های دیزاین‌سیستمِ فعلیِ اپ شاپ شیپ (سرمه‌ای/صورتی/مرجانی/کرم،
"Flutter Design System"، برند accent: کاسبیار — سبز اکسل) برای لندینگ گردیِ زیاد، سایه‌ی نرم) برای لندینگ بازاستفاده شده تا ظاهر وب دقیقا با اپ
بازاستفاده شده تا ظاهر وب با اپ یکی باشد. بدون build step، بدون فریم‌ورک — یکی باشد. بدون build step، بدون فریم‌ورک. فونت Vazirmatn با <link> در
دقیقا روحیه‌ی خودِ سیستم: "Performance یک تصمیم طراحی است». <head> صفحه بارگذاری می‌شود (نه @import اینجا) — @import رندر CSS را
فونت Vazirmatn با <link> در <head> صفحه بارگذاری می‌شود (نه @import اینجا) قفل می‌کند. */
— @import رندر CSS را قفل می‌کند تا فونت خارجی هم دانلود شود. */
:root { :root {
--color-bg: #f3f2f2; --color-bg: #fdf8f3;
--color-surface: #eae9e9; --color-bg-secondary: #f6efe4;
--color-surface-elevated: #f8f4f4; --color-surface: #ffffff;
--color-text: #201e1d; --color-surface-interactive: #f6efe4;
--color-text-secondary: #605d5d; --color-text: #201e3d;
--color-text-tertiary: #7d7979; --color-text-secondary: #6b6889;
--color-accent: #107c41; --color-text-tertiary: #9591ac;
--color-accent-600: #0e6837; --color-brand: #2d2a55;
--color-accent-700: #185c37; --color-on-brand: #ffffff;
--color-accent-100: #e2efda; --color-pink: #f2467d;
--color-accent-800: #0b3d21; --color-coral: #e8635b;
--color-divider: rgba(32, 30, 29, 0.4); --color-cream: #fbeedd;
--color-divider-subtle: #d7d3d3; --color-peach: #fadec0;
--color-lavender: #e3e1f7;
--color-blue: #4c6fe0;
--color-teal: #2e9e9e;
--color-amber: #e0a93b;
--color-divider: #e3d9c7;
--color-divider-subtle: #eee6d8;
--shadow-color: rgba(32, 30, 61, 0.12);
--font-heading: 'Vazirmatn', system-ui, sans-serif; --font-heading: 'Vazirmatn', system-ui, sans-serif;
--font-body: 'Vazirmatn', system-ui, sans-serif; --font-body: 'Vazirmatn', system-ui, sans-serif;
@@ -27,50 +33,69 @@
--space-2: 8px; --space-2: 8px;
--space-3: 12px; --space-3: 12px;
--space-4: 16px; --space-4: 16px;
--space-5: 20px;
--space-6: 24px; --space-6: 24px;
--space-8: 32px; --space-8: 32px;
--space-12: 48px; --space-12: 48px;
--space-16: 64px; --space-16: 64px;
--radius: 0px; /* امضای بصری سیستم: گوشه‌ی صفر همه‌جا */ --radius-sm: 12px;
--radius-md: 16px;
--radius-lg: 20px;
--radius-xl: 28px;
--radius-pill: 999px;
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--color-bg: #161514; --color-bg: #14132a;
--color-surface: #201e1d; --color-bg-secondary: #1c1a3b;
--color-surface-elevated: #2d2b2b; --color-surface: #262452;
--color-text: #f5f3f2; --color-surface-interactive: #383468;
--color-text-secondary: #bab6b6; --color-text: #f5f3fc;
--color-text-tertiary: #9b9797; --color-text-secondary: #c3bfe2;
--color-accent: #21a366; --color-text-tertiary: #9793bc;
--color-accent-600: #34b378; --color-brand: #b3aeff;
--color-accent-700: #6bcb9a; --color-on-brand: #1b1938;
--color-accent-100: #112a1c; --color-pink: #ff7cae;
--color-accent-800: #c6efce; --color-coral: #ff9789;
--color-divider: #2d2b2b; --color-cream: #423c74;
--color-divider-subtle: #2d2b2b; --color-peach: #4e4780;
--color-lavender: #423c74;
--color-blue: #7c8cf5;
--color-teal: #4fc7c7;
--color-amber: #e0a93b;
--color-divider: #3a366e;
--color-divider-subtle: #2e2b60;
--shadow-color: rgba(0, 0, 0, 0.35);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { :root:not([data-theme="light"]) {
--color-bg: #161514; --color-bg: #14132a;
--color-surface: #201e1d; --color-bg-secondary: #1c1a3b;
--color-surface-elevated: #2d2b2b; --color-surface: #262452;
--color-text: #f5f3f2; --color-surface-interactive: #383468;
--color-text-secondary: #bab6b6; --color-text: #f5f3fc;
--color-text-tertiary: #9b9797; --color-text-secondary: #c3bfe2;
--color-accent: #21a366; --color-text-tertiary: #9793bc;
--color-accent-600: #34b378; --color-brand: #b3aeff;
--color-accent-700: #6bcb9a; --color-on-brand: #1b1938;
--color-accent-100: #112a1c; --color-pink: #ff7cae;
--color-accent-800: #c6efce; --color-coral: #ff9789;
--color-divider: #2d2b2b; --color-cream: #423c74;
--color-divider-subtle: #2d2b2b; --color-peach: #4e4780;
--color-lavender: #423c74;
--color-blue: #7c8cf5;
--color-teal: #4fc7c7;
--color-amber: #e0a93b;
--color-divider: #3a366e;
--color-divider-subtle: #2e2b60;
--shadow-color: rgba(0, 0, 0, 0.35);
} }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html { direction: rtl; } html { direction: rtl; scroll-behavior: smooth; }
body { body {
margin: 0; margin: 0;
background: var(--color-bg); background: var(--color-bg);
@@ -78,122 +103,303 @@ body {
font-family: var(--font-body); font-family: var(--font-body);
font-size: 16px; font-size: 16px;
line-height: 1.7; line-height: 1.7;
overflow-x: hidden;
} }
h1, h2, h3, h4 { h1, h2, h3, h4 {
font-family: var(--font-heading); font-family: var(--font-heading);
font-weight: 800; font-weight: 800;
line-height: 1.25; line-height: 1.3;
margin: 0 0 var(--space-3); margin: 0 0 var(--space-3);
color: var(--color-text);
} }
h1 { font-size: 40px; letter-spacing: -0.02em; } h1 { font-size: clamp(25px, 4.6vw + 10px, 42px); letter-spacing: -0.01em; }
h2 { font-size: 28px; } h2 { font-size: clamp(22px, 1.6vw + 16px, 30px); }
h3 { font-size: 20px; } h3 { font-size: 19px; }
p { margin: 0 0 var(--space-4); color: var(--color-text-secondary); } p { margin: 0 0 var(--space-4); color: var(--color-text-secondary); }
a { color: var(--color-accent); text-decoration: none; } a { color: var(--color-brand); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
img { max-width: 100%; display: block; } img, svg { max-width: 100%; display: block; }
.container { max-width: 1080px; margin: 0 auto; padding: 0 var(--space-6); } .container { max-width: 1100px; margin: 0 auto; padding: 0 var(--space-6); }
.hairline { border: none; border-top: 2px solid var(--color-divider); margin: 0; } :focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 3px;
border-radius: 4px;
}
/* بخش‌هایی که ناوبریِ چسبان با اسکرول به آن‌ها می‌پرد، باید زیرِ هدر پنهان
نشوند — همان مقدارِ ارتفاعِ هدر به‌عنوان فاصله‌ی امن اسکرول رزرو می‌شود. */
#tools, #who, #how, #pricing, #cta { scroll-margin-top: 84px; }
/* — nav — */ /* — nav — */
.nav-wrap {
position: sticky; top: 0; z-index: 40;
background: transparent;
transition: background-color 0.25s ease, box-shadow 0.25s ease, backdrop-filter 0.25s ease;
}
.nav-wrap.is-scrolled {
background: color-mix(in srgb, var(--color-bg) 88%, transparent);
backdrop-filter: blur(12px) saturate(1.2);
-webkit-backdrop-filter: blur(12px) saturate(1.2);
box-shadow: 0 4px 24px var(--shadow-color);
}
.nav { .nav {
display: flex; align-items: center; gap: var(--space-6); display: flex; align-items: center; gap: var(--space-6);
padding: var(--space-4) 0; border-bottom: 2px solid var(--color-divider); padding: var(--space-4) 0;
} }
.nav-brand { font-family: var(--font-heading); font-weight: 800; font-size: 19px; color: var(--color-text); } .nav-brand { display: inline-flex; align-items: center; gap: var(--space-2); font-family: var(--font-heading); font-weight: 800; font-size: 20px; color: var(--color-brand); }
.nav-links { display: flex; gap: var(--space-6); margin-inline-start: auto; font-size: 14px; flex-wrap: wrap; } .nav-brand:hover { text-decoration: none; }
.nav-links a { color: var(--color-text); } .nav-brand-icon { border-radius: 8px; }
.nav-links a:hover { color: var(--color-accent); } .nav-links { display: flex; gap: var(--space-6); margin-inline-start: auto; font-size: 14px; font-weight: 500; flex-wrap: wrap; }
.nav-links a { color: var(--color-text); position: relative; }
.nav-links a:hover { color: var(--color-brand); text-decoration: none; }
/* — buttons — */ /* — buttons — */
.btn { .btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
font-family: var(--font-heading); font-weight: 700; font-size: 15px; font-family: var(--font-heading); font-weight: 700; font-size: 15px;
padding: 12px 22px; border: 2px solid transparent; cursor: pointer; padding: 14px 26px; border: 2px solid transparent; cursor: pointer;
border-radius: var(--radius); border-radius: var(--radius-pill);
transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
} }
.btn-primary { background: var(--color-accent); color: #f3f2f2; } .btn:hover { text-decoration: none; }
.btn-primary:hover { background: var(--color-accent-600); text-decoration: none; } .btn:active { transform: scale(0.97); }
.btn-secondary { border-color: var(--color-divider); color: var(--color-text); } .btn-sm { padding: 10px 20px; font-size: 14px; }
.btn-secondary:hover { background: var(--color-surface); text-decoration: none; } .btn-primary {
background: var(--color-brand); color: var(--color-on-brand);
box-shadow: 0 8px 24px var(--shadow-color);
}
.btn-primary:hover { box-shadow: 0 10px 28px var(--shadow-color); transform: translateY(-1px); }
.btn-secondary { border-color: var(--color-divider); color: var(--color-text); background: var(--color-surface); }
.btn-secondary:hover { background: var(--color-surface-interactive); transform: translateY(-1px); }
/* — hero — */ /* — hero — */
.hero { padding: var(--space-16) 0 var(--space-12); text-align: center; } .hero {
position: relative;
padding: var(--space-16) 0 var(--space-12);
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: var(--space-12);
align-items: center;
overflow: hidden;
}
.hero-copy { position: relative; z-index: 1; text-align: start; }
.hero .kicker { .hero .kicker {
display: inline-block; font-size: 12px; font-weight: 700; letter-spacing: 0.04em; display: inline-block; font-size: 12px; font-weight: 700; letter-spacing: 0.04em;
color: var(--color-accent); background: var(--color-accent-100); color: var(--color-brand); background: var(--color-lavender);
padding: 4px 12px; margin-bottom: var(--space-4); padding: 6px 16px; border-radius: var(--radius-pill); margin-bottom: var(--space-4);
} }
.hero h1 { max-width: 18ch; margin-inline: auto; } .hero h1 { max-width: 16ch; }
.hero p { max-width: 56ch; margin-inline: auto; font-size: 18px; } .hero-copy p { max-width: 48ch; font-size: 18px; }
.hero-actions { display: flex; gap: var(--space-3); justify-content: center; margin-top: var(--space-6); flex-wrap: wrap; } .hero-actions { display: flex; gap: var(--space-3); margin-top: var(--space-6); flex-wrap: wrap; }
/* بلاب‌های تزئینیِ پشتِ هیرو — همان زبانِ بصریِ AtlasBlobHeader توی اپ */
.hero-blobs { position: absolute; inset: -10% -10% auto auto; width: 60%; height: 140%; z-index: 0; pointer-events: none; }
.blob { position: absolute; border-radius: 50%; filter: blur(2px); opacity: 0.9; }
/* موکاپِ گوشی — نمایِ ساده‌شده و انتزاعیِ صفحه‌ی اول اپ، بدون دیتای واقعیِ
هیچ کاربری؛ فقط برای نشان‌دادنِ زبانِ بصری (کاشی‌های رنگی، کارتِ سرمه‌ای). */
.phone-mock {
position: relative; z-index: 1;
width: 260px; margin: 0 auto;
background: var(--color-text);
border-radius: 40px;
padding: 14px;
box-shadow: 0 30px 60px var(--shadow-color);
}
.phone-screen {
background: var(--color-bg);
border-radius: 28px;
padding: var(--space-4);
overflow: hidden;
}
.phone-hero-card {
background: var(--color-brand);
border-radius: var(--radius-lg);
padding: var(--space-4);
color: var(--color-on-brand);
margin-bottom: var(--space-3);
}
.phone-hero-card .line { height: 10px; background: rgba(255,255,255,0.85); border-radius: var(--radius-pill); width: 60%; margin-bottom: 8px; }
.phone-hero-card .line.short { width: 35%; background: rgba(255,255,255,0.5); }
.phone-stats {
background: var(--color-surface); border-radius: var(--radius-lg);
padding: var(--space-3); display: flex; justify-content: space-around;
margin-bottom: var(--space-3); box-shadow: 0 8px 20px var(--shadow-color);
}
.phone-stats .dot { width: 28px; height: 28px; border-radius: var(--radius-sm); margin: 0 auto 4px; }
.phone-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.phone-tile { aspect-ratio: 1; border-radius: var(--radius-md); }
/* — sections — */ /* — sections — */
section { padding: var(--space-12) 0; } section { padding: var(--space-12) 0; position: relative; }
section.alt { background: var(--color-surface); } section.alt { background: var(--color-bg-secondary); }
.section-head { text-align: center; max-width: 60ch; margin: 0 auto var(--space-8); } .section-head { text-align: center; max-width: 60ch; margin: 0 auto var(--space-8); }
.section-head .kicker { display: inline-block; font-size: 12px; font-weight: 700; color: var(--color-pink); margin-bottom: var(--space-2); }
/* — grid / cards — */ /* — grid / cards — */
.grid { display: grid; gap: 2px; background: var(--color-divider-subtle); } .grid { display: grid; gap: var(--space-4); }
.grid-3 { grid-template-columns: repeat(3, 1fr); } .grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); } .grid-4 { grid-template-columns: repeat(4, 1fr); }
.grid-5 { grid-template-columns: repeat(5, 1fr); } .grid-5 { grid-template-columns: repeat(5, 1fr); }
.card { background: var(--color-bg); padding: var(--space-6); } .card {
.card .icon { font-size: 28px; margin-bottom: var(--space-3); } background: var(--color-surface);
border: 1px solid var(--color-divider-subtle);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: 0 12px 28px var(--shadow-color);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 18px 36px var(--shadow-color);
}
.card .icon-tile {
width: 52px; height: 52px; border-radius: var(--radius-md);
display: grid; place-items: center; font-size: 24px;
margin-bottom: var(--space-4); color: #fff;
}
.card h3 { font-size: 17px; margin-bottom: var(--space-2); } .card h3 { font-size: 17px; margin-bottom: var(--space-2); }
.card p { font-size: 14px; margin: 0; } .card p { font-size: 14px; margin: 0; }
/* — channels — */ /* چرخه‌ی رنگِ کاشی — دقیقا هم‌راستا با atlasTileAccents توی اپ */
.channels { display: flex; flex-wrap: wrap; gap: var(--space-3); justify-content: center; } .accent-0 { background: var(--color-pink); }
.channel-tag { .accent-1 { background: var(--color-blue); }
display: inline-flex; align-items: center; gap: 6px; .accent-2 { background: var(--color-coral); }
padding: 8px 16px; border: 2px solid var(--color-divider); font-size: 14px; font-weight: 500; .accent-3 { background: var(--color-teal); }
} .accent-4 { background: var(--color-brand); }
.accent-5 { background: var(--color-amber); }
/* — stats — */
.stats-band { background: var(--color-brand); color: var(--color-on-brand); }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-4); text-align: center; }
.stat-num { font-family: var(--font-heading); font-weight: 800; font-size: 32px; }
.stat-label { font-size: 13px; opacity: 0.8; }
/* — steps — */ /* — steps — */
.steps { counter-reset: step; display: flex; flex-direction: column; gap: var(--space-4); max-width: 640px; margin: 0 auto; } .steps { display: flex; flex-direction: column; gap: var(--space-4); max-width: 640px; margin: 0 auto; }
.step { display: flex; gap: var(--space-4); align-items: flex-start; } .step { display: flex; gap: var(--space-4); align-items: flex-start; background: var(--color-surface); border: 1px solid var(--color-divider-subtle); border-radius: var(--radius-lg); padding: var(--space-5); box-shadow: 0 8px 20px var(--shadow-color); transition: transform 0.2s ease, box-shadow 0.2s ease; }
.step::before { .step:hover { transform: translateY(-3px); box-shadow: 0 14px 28px var(--shadow-color); }
counter-increment: step; content: counter(step); .step-num {
flex: none; width: 36px; height: 36px; display: grid; place-items: center; flex: none; width: 40px; height: 40px; display: grid; place-items: center;
background: var(--color-accent); color: #f3f2f2; font-weight: 800; font-family: var(--font-heading); background: var(--color-pink); color: #fff; font-weight: 800; font-family: var(--font-heading);
border-radius: var(--radius-pill);
} }
.step h3 { margin-bottom: 4px; font-size: 16px; } .step h3 { margin-bottom: 4px; font-size: 16px; }
.step p { margin: 0; font-size: 14px; } .step p { margin: 0; font-size: 14px; }
/* — trust row (به‌جای تستیمونیال ساختگی) — */
.trust-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); }
.trust-item { text-align: center; padding: var(--space-4); border-radius: var(--radius-lg); transition: transform 0.2s ease, background-color 0.2s ease; }
.trust-item:hover { transform: translateY(-3px); background: var(--color-surface); }
.trust-item .icon-tile { margin-inline: auto; }
/* — pricing explainer (بدون رقمِ ساختگی، چون قیمتِ واقعی جم‌محور و پویاست) — */
.pricing-card {
background: var(--color-surface); border: 1px solid var(--color-divider-subtle);
border-radius: var(--radius-xl); padding: var(--space-8); text-align: center;
box-shadow: 0 16px 36px var(--shadow-color);
max-width: 560px; margin: 0 auto;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.pricing-card:hover { transform: translateY(-3px); box-shadow: 0 22px 44px var(--shadow-color); }
.pricing-card .badge {
display: inline-flex; align-items: center; gap: 6px; background: var(--color-cream);
color: var(--color-coral); padding: 6px 16px; border-radius: var(--radius-pill);
font-weight: 700; font-size: 13px; margin-bottom: var(--space-4);
}
/* — CTA band — */ /* — CTA band — */
.cta-band { background: var(--color-text); color: var(--color-bg); text-align: center; } .cta-band { background: var(--color-brand); color: var(--color-on-brand); text-align: center; border-radius: var(--radius-xl); margin: 0 var(--space-6); padding: var(--space-12) var(--space-6); }
.cta-band h2 { color: var(--color-bg); } .cta-band h2 { color: var(--color-on-brand); }
.cta-band p { color: rgba(243, 242, 242, 0.75); } .cta-band p { color: rgba(255,255,255,0.8); max-width: 50ch; margin-inline: auto; }
.cta-band .btn-secondary { border-color: rgba(255,255,255,0.35); color: #fff; background: transparent; }
.cta-band .btn-secondary:hover { background: rgba(255,255,255,0.1); }
/* — footer — */ /* — footer — */
footer { border-top: 2px solid var(--color-divider); padding: var(--space-8) 0; } footer { padding: var(--space-8) 0; }
footer .container { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-4); font-size: 13px; color: var(--color-text-tertiary); } footer .container { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-4); font-size: 13px; color: var(--color-text-tertiary); }
footer a { color: var(--color-text-secondary); } footer a { color: var(--color-text-secondary); }
footer .footer-brand { display: flex; flex-direction: column; gap: 2px; }
footer .footer-brand strong { color: var(--color-text-secondary); font-weight: 600; }
/* — articles — */ /* — articles — */
.article-list { display: flex; flex-direction: column; gap: 2px; background: var(--color-divider-subtle); } .article-list { display: flex; flex-direction: column; gap: var(--space-3); }
.article-row { background: var(--color-bg); padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-2); } .article-row { background: var(--color-surface); border: 1px solid var(--color-divider-subtle); border-radius: var(--radius-lg); padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-2); box-shadow: 0 8px 20px var(--shadow-color); transition: transform 0.2s ease, box-shadow 0.2s ease; }
.article-row:hover { transform: translateY(-3px); box-shadow: 0 14px 28px var(--shadow-color); }
.article-row .meta { font-size: 12px; color: var(--color-text-tertiary); } .article-row .meta { font-size: 12px; color: var(--color-text-tertiary); }
.article-row h2 { font-size: 20px; margin-bottom: 0; } .article-row h2 { font-size: 20px; margin-bottom: 0; }
.article-row h2 a { color: var(--color-text); } .article-row h2 a { color: var(--color-text); }
.article-row h2 a:hover { color: var(--color-brand); text-decoration: none; }
.article-body { max-width: 68ch; margin: 0 auto; font-size: 16px; } .article-body { max-width: 68ch; margin: 0 auto; font-size: 16px; }
.article-body h2 { font-size: 22px; margin-top: var(--space-8); } .article-body h2 { font-size: 22px; margin-top: var(--space-8); }
.article-body strong { color: var(--color-text); }
.article-header { max-width: 68ch; margin: 0 auto var(--space-8); } .article-header { max-width: 68ch; margin: 0 auto var(--space-8); }
.article-header .meta { font-size: 13px; color: var(--color-text-tertiary); margin-bottom: var(--space-3); } .article-header .meta { font-size: 13px; color: var(--color-text-tertiary); margin-bottom: var(--space-3); }
.back-link { display: inline-block; margin-bottom: var(--space-6); font-size: 14px; } .back-link { display: inline-block; margin-bottom: var(--space-6); font-size: 14px; }
.related-articles { max-width: 68ch; margin: var(--space-12) auto 0; padding-top: var(--space-8); border-top: 1px solid var(--color-divider-subtle); }
.related-articles h2 { font-size: 18px; }
.related-articles ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.related-articles a { font-weight: 600; }
@media (max-width: 760px) { /* — ورودِ تدریجیِ بخش‌ها هنگامِ اسکرول — با جاوااسکریپتِ صفحه کلاسِ reveal
اضافه و بعد از دیده‌شدن reveal-visible را ست می‌کند؛ اگر جاوااسکریپت
غیرفعال باشد یا کاربر حرکتِ کم‌تر خواسته باشد، محتوا همیشه دیده می‌ماند. */
.reveal { opacity: 0; transform: translateY(16px); transition: opacity 0.5s ease, transform 0.5s ease; }
.reveal-visible { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
.reveal { opacity: 1; transform: none; transition: none; }
.card, .step, .trust-item, .pricing-card, .btn, .article-row { transition: none; }
}
@media (max-width: 900px) {
.hero { grid-template-columns: 1fr; text-align: center; padding-top: var(--space-12); gap: var(--space-8); }
.hero-copy { text-align: center; }
.hero h1, .hero-copy p { margin-inline: auto; }
.hero-blobs { display: none; }
.phone-mock { order: -1; }
.grid-3, .grid-4, .grid-5 { grid-template-columns: repeat(2, 1fr); }
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: var(--space-6); }
.trust-grid { grid-template-columns: 1fr; max-width: 420px; margin-inline: auto; }
}
/* بین تبلت و موبایل — فاصله‌های درشتِ دسکتاپ روی این سایز زیادی باز به‌نظر
می‌رسند؛ همه‌جا یک پله جمع‌تر می‌شوند، نه فقط شکستنِ گرید. */
@media (max-width: 700px) {
section { padding: 40px 0; }
.hero { padding: 32px 0 40px; }
.section-head { margin-bottom: var(--space-6); }
.card { padding: var(--space-5); }
.card .icon-tile { width: 46px; height: 46px; font-size: 21px; margin-bottom: var(--space-3); }
.phone-mock { width: min(240px, 62vw); }
.steps { gap: var(--space-3); }
.step { padding: var(--space-4); gap: var(--space-3); }
.pricing-card { padding: var(--space-6); }
.cta-band { padding: var(--space-8) var(--space-6); }
}
@media (max-width: 560px) {
.grid-3, .grid-4, .grid-5 { grid-template-columns: 1fr; } .grid-3, .grid-4, .grid-5 { grid-template-columns: 1fr; }
.hero h1 { font-size: 30px; } .hero-copy p { font-size: 16px; }
footer .container { flex-direction: column; } .kicker { font-size: 11px; }
footer .container { flex-direction: column; text-align: center; }
footer .footer-brand { align-items: center; }
.nav { flex-wrap: wrap; row-gap: var(--space-3); } .nav { flex-wrap: wrap; row-gap: var(--space-3); }
.nav-links { order: 3; width: 100%; gap: var(--space-4); } .nav-links { order: 3; width: 100%; gap: var(--space-4); justify-content: center; }
.cta-band { margin: 0 var(--space-4); padding: var(--space-8) var(--space-4); }
.hero-actions { gap: var(--space-2); }
.hero-actions .btn { flex: 1 1 200px; }
.stats-grid { gap: var(--space-4); }
.stat-num { font-size: 26px; }
} }
@media (max-width: 400px) { @media (max-width: 400px) {
.container { padding: 0 var(--space-4); } .container { padding: 0 var(--space-4); }
.hero h1 { font-size: 26px; } .hero-copy p { font-size: 15px; }
.hero p { font-size: 16px; } .btn { padding: 12px 20px; font-size: 14px; }
.hero-actions .btn, .cta-band .hero-actions .btn { flex: 1 1 100%; }
.phone-mock { width: min(210px, 58vw); padding: 10px; border-radius: 32px; }
.phone-screen { border-radius: 22px; padding: var(--space-3); }
.pricing-card { padding: var(--space-5); }
.step { flex-direction: column; align-items: flex-start; }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+285 -128
View File
@@ -2,18 +2,43 @@
<html lang="fa" dir="rtl"> <html lang="fa" dir="rtl">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>کاسبیار — اپ مدیریت کسب‌وکارهای کوچک (فاکتور، پیامک گروهی، مشتریان، انبار، نوبت‌دهی)</title> <title>شاپ شیپ — اپ مدیریت کسب‌وکارهای کوچک | فاکتور، پیامک گروهی، انبار، دفتر درآمد و هزینه</title>
<meta name="description" content="کاسبیار یک اپ برای اداره‌ی کسب‌وکار کوچک است: فاکتور رسمی، پیام گروهی با شماره (پیامک/واتساپ/تلگرام/بله/ایتا)، مشتریان، دفتر درآمد و هزینه، انبارداری و نوبت‌دهی — همه در یک اپ ساده. یک هفته کاملا رایگان."> <meta name="description" content="شاپ شیپ اپ مدیریت کسب‌وکار کوچک است: صدور فاکتور رسمی، ارسال پیامک/واتساپ/تلگرام گروهی، مدیریت مشتریان، دفتر درآمد و هزینه‌ی حرفه‌ای با تفکیک فروش قسطی، انبارداری و پیگیری سفارش آنلاین‌شاپ — همه در یک اپ ساده با تقویم شمسی. یک هفته کاملا رایگان.">
<meta name="keywords" content="اپ مدیریت کسب و کار, پیام گروهی با شماره, نرم افزار فاکتور, نرم افزار انبارداری, نرم افزار نوبت دهی, دفتر درآمد و هزینه, نرم افزار مدیریت مشتریان"> <meta name="keywords" content="شاپ شیپ, shopship, نرم افزار مدیریت کسب و کار, اپ مدیریت کسب و کار, نرم افزار فروشگاهی, نرم افزار حسابداری کوچک, نرم افزار صدور فاکتور, فاکتور رسمی با لوگو, ارسال پیامک انبوه, پیامک تبلیغاتی گروهی, ارسال واتساپ گروهی, نرم افزار مدیریت مشتریان, دفتر درآمد و هزینه, نرم افزار حسابداری فروش قسطی, پیگیری بدهی مشتری, نرم افزار انبارداری, کنترل موجودی انبار, پیگیری سفارش آنلاین شاپ, نرم افزار مغازه, اپ کسب و کار خرد, حسابداری فروشگاه با موبایل">
<link rel="canonical" href="https://mobiniqow.ir/"> <meta name="robots" content="index, follow">
<meta name="author" content="niqow.ir">
<link rel="canonical" href="https://shopship.ir/">
<link rel="alternate" hreflang="fa-ir" href="https://shopship.ir/">
<link rel="alternate" hreflang="x-default" href="https://shopship.ir/">
<link rel="icon" type="image/png" sizes="32x32" href="assets/img/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="assets/img/favicon-16.png">
<link rel="icon" type="image/png" sizes="192x192" href="assets/img/icon-192.png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/img/apple-touch-icon.png">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="کاسبیار — اپ مدیریت کسب‌وکارهای کوچک"> <meta property="og:site_name" content="شاپ شیپ">
<meta property="og:description" content="فاکتور، پیام گروهی، مشتریان، دفتر درآمد و هزینه، انبارداری و نوبت‌دهی — همه در یک اپ. یک هفته کاملا رایگان."> <meta property="og:title" content="شاپ شیپ — اپ مدیریت کسب‌وکارهای کوچک">
<meta property="og:url" content="https://mobiniqow.ir/"> <meta property="og:description" content="فاکتور، پیام گروهی، مشتریان، دفتر درآمد و هزینه‌ی حرفه‌ای، انبارداری و پیگیری سفارش — همه در یک اپ. یک هفته کاملا رایگان.">
<meta property="og:url" content="https://shopship.ir/">
<meta property="og:locale" content="fa_IR"> <meta property="og:locale" content="fa_IR">
<meta name="twitter:card" content="summary"> <meta property="og:image" content="https://shopship.ir/assets/img/og-image.png">
<meta name="theme-color" content="#107c41"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="شاپ شیپ — اپ مدیریت کسب‌وکارهای کوچک">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="شاپ شیپ — اپ مدیریت کسب‌وکارهای کوچک">
<meta name="twitter:description" content="فاکتور، پیام گروهی، مشتریان، دفتر درآمد و هزینه، انبارداری و پیگیری سفارش — همه در یک اپ.">
<meta name="twitter:image" content="https://shopship.ir/assets/img/og-image.png">
<meta name="theme-color" content="#2d2a55">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="شاپ شیپ">
<meta name="mobile-web-app-capable" content="yes">
<meta name="format-detection" content="telephone=no">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
@@ -22,100 +47,124 @@
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "SoftwareApplication", "@type": "SoftwareApplication",
"name": "کاسبیار", "name": "شاپ شیپ",
"alternateName": "ShopShip",
"applicationCategory": "BusinessApplication", "applicationCategory": "BusinessApplication",
"operatingSystem": "Android, iOS", "applicationSubCategory": "Invoicing, CRM, Inventory, Messaging",
"description": "اپ مدیریت کسب‌وکارهای کوچک: فاکتور، پیام گروهی با شماره، مشتریان، دفتر درآمد و هزینه، انبارداری و نوبت‌دهی.", "operatingSystem": "Android",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "IRR", "description": "یک هفته اول رایگان" } "inLanguage": "fa-IR",
"url": "https://shopship.ir/",
"installUrl": "https://cafebazaar.ir/app/ir.niqow.kangelo",
"description": "اپ مدیریت کسب‌وکارهای کوچک: فاکتور، پیام گروهی با شماره، مشتریان، دفتر درآمد و هزینه، انبارداری و پیگیری سفارش.",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "IRR", "description": "یک هفته اول رایگان" },
"publisher": { "@type": "Organization", "name": "niqow.ir", "url": "https://niqow.ir" },
"author": { "@type": "Organization", "name": "niqow.ir", "url": "https://niqow.ir" }
} }
</script> </script>
</head> </head>
<body> <body>
<header class="nav container"> <header class="nav-wrap" id="siteHeader">
<span class="nav-brand">کاسبیار</span> <div class="nav container">
<nav class="nav-links"> <a class="nav-brand" href="#top">
<a href="#tools">بخش‌های اپ</a> <img src="assets/img/icon-192.png" alt="" width="28" height="28" class="nav-brand-icon">
<a href="#who">برای کیه</a> شاپ شیپ
<a href="#how">نحوه‌ی کار</a> </a>
<a href="/articles/">مقالات</a> <nav class="nav-links">
</nav> <a href="#tools">بخش‌های اپ</a>
<a class="btn btn-primary" href="#cta">دریافت اپ</a> <a href="#who">برای کیه</a>
<a href="#how">نحوه‌ی کار</a>
<a href="#pricing">قیمت‌گذاری</a>
<a href="/articles/">مقالات</a>
</nav>
<a class="btn btn-primary btn-sm" href="#cta">دریافت اپ</a>
</div>
</header> </header>
<main> <main>
<section class="hero container"> <section class="hero container" id="top">
<span class="kicker">اپ مدیریت کسب‌وکار کوچک</span> <div class="hero-blobs" aria-hidden="true">
<h1>هرچی برای اداره‌ی کسب‌وکارت لازمه، توی یک اپ</h1> <svg viewBox="0 0 400 500" width="100%" height="100%">
<p> <circle cx="330" cy="70" r="120" fill="var(--color-pink)" opacity="0.55"/>
کاسبیار فاکتور رسمی صادر می‌کند، پیام گروهی با شماره می‌فرستد <circle cx="260" cy="40" r="60" fill="var(--color-coral)" opacity="0.5"/>
(پیامک، واتساپ، تلگرام، بله، ایتا)، حساب مشتری‌ها و دخل‌وخرجت را نگه <circle cx="360" cy="260" r="90" fill="var(--color-lavender)" opacity="0.6"/>
می‌دارد، انبارت را زیر نظر می‌گیرد و نوبت‌دهی‌ات را مدیریت می‌کند — </svg>
بدون این‌که کاسبی که تازه‌کار با موبایل است گیج شود. </div>
</p> <div class="hero-copy">
<div class="hero-actions"> <span class="kicker">اپ مدیریت کسب‌وکار کوچک</span>
<a class="btn btn-primary" href="#cta">یک هفته رایگان امتحان کن</a> <h1>هرچی برای اداره‌ی کسب‌وکارت لازمه، توی یک اپ</h1>
<a class="btn btn-secondary" href="#tools">ببین چی داره</a> <p>
شاپ شیپ فاکتور رسمی صادر می‌کند، پیام گروهی با شماره می‌فرستد
(پیامک، واتساپ، تلگرام، بله، ایتا)، حساب مشتری‌ها و دخل‌وخرجت را نگه
می‌دارد، انبارت را زیر نظر می‌گیرد و سفارش‌هایت را پیگیری می‌کند —
بدون این‌که کاسبی که تازه‌کار با موبایل است گیج شود.
</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#cta">یک هفته رایگان امتحان کن</a>
<a class="btn btn-secondary" href="#tools">ببین چی داره</a>
</div>
</div>
<div class="phone-mock" aria-hidden="true">
<div class="phone-screen">
<div class="phone-hero-card">
<div class="line"></div>
<div class="line short"></div>
</div>
<div class="phone-stats">
<div><div class="dot accent-0"></div></div>
<div><div class="dot accent-1"></div></div>
<div><div class="dot accent-2"></div></div>
</div>
<div class="phone-tiles">
<div class="phone-tile accent-0"></div>
<div class="phone-tile accent-1"></div>
<div class="phone-tile accent-2"></div>
<div class="phone-tile accent-3"></div>
<div class="phone-tile accent-4"></div>
<div class="phone-tile accent-5"></div>
</div>
</div>
</div> </div>
</section> </section>
<hr class="hairline"> <section id="tools">
<div class="container">
<section id="tools" class="container"> <div class="section-head">
<div class="section-head"> <span class="kicker">بخش‌های شاپ شیپ</span>
<h2>بخش‌های کاسبیار</h2> <h2>هرکدام جدا فعال می‌شود</h2>
<p>هرکدام جدا فعال می‌شود، پس فقط برای چیزی که لازم داری هزینه می‌کنی.</p> <p>فقط برای چیزی که کسب‌وکارت لازم دارد هزینه می‌کنی — نه یک بسته‌ی سنگین که نصفش را استفاده نمی‌کنی.</p>
</div>
<div class="grid grid-3">
<div class="card">
<div class="icon">🧾</div>
<h3>فاکتور</h3>
<p>فاکتور رسمی با لوگو، چند نوع مالیات، تخفیف روی هر قلم کالا، و پیگیری پرداخت‌شده/نشده — کاملا محلی روی گوشی.</p>
</div> </div>
<div class="card"> <div class="grid grid-3">
<div class="icon">📨</div> <div class="card">
<h3>پیام گروهی</h3> <div class="icon-tile accent-1">🧾</div>
<p>یک قالب بنویس، به مخاطبینت پیامک/واتساپ/تلگرام/بله/ایتا بفرست — از مسیر رسمی هر پلتفرم، بدون ترفندهای دور زدن قانون.</p> <h3>فاکتور</h3>
</div> <p>فاکتور رسمی با لوگو، چند نوع مالیات، تخفیف روی هر قلم کالا، اتصال به انبار، و پیگیری پرداخت‌شده/نشده.</p>
<div class="card"> </div>
<div class="icon">👥</div> <div class="card">
<h3>مشتریان</h3> <div class="icon-tile accent-3">📨</div>
<p>برای هر مشتری تاریخچه‌ی خریدش را ببین — خودش از فاکتورهایی که برایش صادر کردی ساخته می‌شود.</p> <h3>پیام گروهی</h3>
</div> <p>یک قالب بنویس، به مخاطبینت پیامک/واتساپ/تلگرام/بله/ایتا بفرست — از مسیر رسمی هر پلتفرم، بدون ترفندهای دور زدن قانون.</p>
<div class="card"> </div>
<div class="icon">💰</div> <div class="card">
<h3>دفتر درآمد و هزینه</h3> <div class="icon-tile accent-0">👥</div>
<p>هرچه وارد و خارج می‌شود را ثبت کن، نمودار هفتگی‌اش را ببین، بر اساس درآمد یا هزینه فیلتر کن.</p> <h3>مشتریان</h3>
</div> <p>برای هر مشتری تاریخچه‌ی خریدش و مانده‌ی بدهی‌اش را ببین — خودش از فاکتورها و دفترت ساخته می‌شود.</p>
<div class="card"> </div>
<div class="icon">📦</div> <div class="card">
<h3>انبارداری</h3> <div class="icon-tile accent-4">💰</div>
<p>موجودی کالاهایت را دسته‌بندی کن، با یک ضربه کم‌وزیاد کن، و وقتی چیزی کم شد هشدار بگیر.</p> <h3>دفتر درآمد و هزینه</h3>
</div> <p>ثبتِ ساده برای خودت، حسابداریِ درست پشتِ صحنه — فروش قسطی را جدا از نقدی می‌شناسد، اقساط را پیگیری می‌کند و خروجی CSV می‌دهد.</p>
<div class="card"> </div>
<div class="icon">📅</div> <div class="card">
<h3>نوبت‌دهی</h3> <div class="icon-tile accent-5">📦</div>
<p>برای آرایشگاه، مطب، کارگاه یا هر جای دیگری که نوبت می‌گیرد — لیست نوبت‌های امروز و فردا همیشه دم دست.</p> <h3>انبارداری</h3>
</div> <p>موجودی کالاهایت را دسته‌بندی کن، با یک ضربه کم‌وزیاد کن، ارزش کل انبارت را ببین و وقتی چیزی کم شد هشدار بگیر.</p>
<div class="card"> </div>
<div class="icon">✅</div> <div class="card">
<h3>گزارش سریع</h3> <div class="icon-tile accent-2">🚚</div>
<p>قالب کلید-مقدار بساز (مثلا لیست کالاهای روزانه)؛ اپ یکی‌یکی مقدارش را می‌پرسد و برایت آماده می‌فرستد.</p> <h3>پیگیری سفارش</h3>
</div> <p>مراحل کارت را تعریف کن (آماده‌سازی، بسته‌بندی، ارسال، تحویل) و با هر «مرحله‌ی بعد» یک پیامک خودکار از روی قالب دلخواهت برای مشتری می‌رود.</p>
<div class="card"> </div>
<div class="icon">🚚</div>
<h3>پیگیری سفارش</h3>
<p>برای آنلاین‌شاپ‌های کوچک: مراحل کارت را تعریف کن (آماده‌سازی، بسته‌بندی، ارسال، تحویل) و با هر «مرحله‌ی بعد» یک پیامک خودکار از روی قالب دلخواهت برای مشتری می‌رود.</p>
</div>
<div class="card">
<div class="icon">🎁</div>
<h3>دعوت دوستان</h3>
<p>کد معرف خودت را با دوستانت به اشتراک بگذار — هر دوتون جم جایزه می‌گیرید و می‌توانید اشتراک را با آن تمدید کنید.</p>
</div>
<div class="card">
<div class="icon">🔒</div>
<h3>امن و ساده</h3>
<p>قفل اثرانگشت برای اطلاعات حساس کسب‌وکارت، تاریخ شمسی همه‌جا، و ظاهری که کار کردن باهاش راحت است.</p>
</div> </div>
</div> </div>
</section> </section>
@@ -123,84 +172,192 @@
<section id="who" class="alt"> <section id="who" class="alt">
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2>کاسبیار برای کیه؟</h2> <span class="kicker">برای کی مناسبه</span>
<h2>شاپ شیپ برای کیه؟</h2>
<p>برای هر کسی که یک کسب‌وکار کوچک دارد و نمی‌خواهد وقتش را صرف نرم‌افزارهای پیچیده کند.</p> <p>برای هر کسی که یک کسب‌وکار کوچک دارد و نمی‌خواهد وقتش را صرف نرم‌افزارهای پیچیده کند.</p>
</div> </div>
<div class="grid grid-5"> <div class="grid grid-5">
<div class="card"> <div class="card">
<div class="icon">🛍️</div> <div class="icon-tile accent-0">🛍️</div>
<h3>فروشگاه‌دارها</h3> <h3>فروشگاه‌دارها</h3>
<p>فاکتور بزن، انبارت را زیر نظر بگیر، به مشتری‌هایت خبر بده.</p> <p>فاکتور بزن، انبارت را زیر نظر بگیر، به مشتری‌هایت خبر بده.</p>
</div> </div>
<div class="card"> <div class="card">
<div class="icon">💇</div> <div class="icon-tile accent-1">🛒</div>
<h3>آرایشگاه و سالن‌ها</h3> <h3>آنلاین‌شاپ‌های کوچک</h3>
<p>نوبت‌دهی منظم و یادآوری نوبت با پیامک.</p> <p>برای هر سفارش، شماره‌ی مشتری را وارد کن و با «پیگیری سفارش» در هر مرحله خودکار برایش پیامک بفرست.</p>
</div> </div>
<div class="card"> <div class="card">
<div class="icon">🥬</div> <div class="icon-tile accent-2">🥬</div>
<h3>میوه/سبزی و توزیع‌کننده‌ها</h3> <h3>میوه/سبزی و توزیع‌کننده‌ها</h3>
<p>هر روز با «گزارش سریع» موجودی را به مشتری‌های ثابت اعلام کن.</p> <p>گزارش روزانه‌ی موجودی را با یک قالبِ آماده به مشتری‌های ثابتت بفرست.</p>
</div> </div>
<div class="card"> <div class="card">
<div class="icon">🧰</div> <div class="icon-tile accent-3">🧰</div>
<h3>کسب‌وکارهای خدماتی</h3> <h3>کسب‌وکارهای خدماتی</h3>
<p>از تعمیرکار تا مشاور — حساب مشتری، دخل‌وخرج و پیام گروهی یک‌جا.</p> <p>از تعمیرکار تا مشاور — حساب مشتری، دخل‌وخرج و پیام گروهی یک‌جا.</p>
</div> </div>
<div class="card"> <div class="card">
<div class="icon">🛒</div> <div class="icon-tile accent-4">💼</div>
<h3>آنلاین‌شاپ‌های کوچک</h3> <h3>فروش قسطی/نسیه</h3>
<p>برای هر سفارش، شماره‌ی مشتری را وارد کن و با «پیگیری سفارش» در هر مرحله (آماده‌سازی، بسته‌بندی، ارسال، تحویل + کد پیگیری پستی) خودکار برایش پیامک بفرست.</p> <p>وقتی جنسی را قسطی می‌دهی، دفترت خودش می‌فهمد این یک نوع بدهکاری است، نه فقط یک عدد در لیست درآمد.</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section id="how" class="container"> <section class="stats-band">
<div class="section-head"> <div class="container">
<h2>در سه قدم شروع کن</h2> <div class="stats-grid">
<div>
<div class="stat-num">۶</div>
<div class="stat-label">ابزار در یک اپ</div>
</div>
<div>
<div class="stat-num">۵</div>
<div class="stat-label">کانال پیام‌رسانی</div>
</div>
<div>
<div class="stat-num">۷</div>
<div class="stat-label">روز اول رایگان</div>
</div>
<div>
<div class="stat-num">۱</div>
<div class="stat-label">اپ به‌جای چندتا نرم‌افزار جدا</div>
</div>
</div>
</div> </div>
<div class="steps"> </section>
<div class="step">
<div> <section id="how">
<h3>با شماره‌ات وارد شو</h3> <div class="container">
<p>فقط شماره موبایل و یک کد تایید — همان لحظه یک هفته رایگان برایت فعال می‌شود.</p> <div class="section-head">
<span class="kicker">شروع سریع</span>
<h2>در سه قدم شروع کن</h2>
</div>
<div class="steps">
<div class="step">
<div class="step-num">۱</div>
<div>
<h3>با شماره‌ات وارد شو</h3>
<p>فقط شماره موبایل و یک کد تایید — همان لحظه یک هفته رایگان برایت فعال می‌شود.</p>
</div>
</div>
<div class="step">
<div class="step-num">۲</div>
<div>
<h3>بخشی که لازم داری را فعال کن</h3>
<p>هر بخش قیمت جدا دارد؛ فقط برای همانی که کسب‌وکارت لازم دارد هزینه کن.</p>
</div>
</div>
<div class="step">
<div class="step-num">۳</div>
<div>
<h3>کارت را شروع کن</h3>
<p>فاکتور بزن، پیام بفرست، انبار و دخل‌وخرجت را ثبت کن — همه از یک اپ.</p>
</div>
</div> </div>
</div> </div>
<div class="step"> </div>
<div> </section>
<h3>بخشی که لازم داری را فعال کن</h3>
<p>هر بخش قیمت جدا دارد؛ فقط برای همانی که کسب‌وکارت لازم دارد هزینه کن.</p> <section class="alt">
<div class="container">
<div class="section-head">
<span class="kicker">چرا شاپ شیپ</span>
<h2>ساده برای تو، درست پشتِ صحنه</h2>
</div>
<div class="trust-grid">
<div class="trust-item">
<div class="icon-tile accent-4">🔒</div>
<h3>امن و شخصی</h3>
<p>قفل اثرانگشت برای اطلاعات حساس کسب‌وکارت، و دیتای تو فقط مالِ خودت است.</p>
</div>
<div class="trust-item">
<div class="icon-tile accent-0">📆</div>
<h3>بدون قرارداد بلندمدت</h3>
<p>هر ابزار را ماهانه یا چندماهه فعال می‌کنی — هر وقت خواستی تمدید نکن.</p>
</div>
<div class="trust-item">
<div class="icon-tile accent-3">🗓️</div>
<h3>تاریخ شمسی همه‌جا</h3>
<p>فاکتور، دفتر، پیگیری سفارش — همه‌جا با تقویم شمسیِ خودمان.</p>
</div> </div>
</div> </div>
<div class="step"> </div>
<div> </section>
<h3>کارت را شروع کن</h3>
<p>فاکتور بزن، پیام بفرست، انبار و دخل‌وخرجت را ثبت کن — همه از یک اپ.</p> <section id="pricing">
</div> <div class="container">
<div class="section-head">
<span class="kicker">قیمت‌گذاری</span>
<h2>فقط برای همون ابزاری که استفاده می‌کنی</h2>
</div>
<div class="pricing-card">
<span class="badge">۷ روز اول، همه‌چیز رایگان</span>
<p style="font-size:16px; margin-bottom: var(--space-6);">
شاپ شیپ اشتراکِ همه‌یا‌هیچ نمی‌فروشد. هر بخش (فاکتور، پیام گروهی،
دفتر، انبار، مشتریان، پیگیری سفارش) را جدا و فقط وقتی لازمش داری
با جم فعال می‌کنی — قیمتِ هر بخش و مدتش داخل خودِ اپ، توی فروشگاه،
شفاف نوشته شده.
</p>
<a class="btn btn-primary" href="#cta">شروع رایگان</a>
</div> </div>
</div> </div>
</section> </section>
<section id="cta" class="cta-band"> <section id="cta" class="cta-band">
<div class="container"> <h2>یک هفته کاملا رایگان امتحانش کن</h2>
<h2>یک هفته کاملا رایگان امتحانش کن</h2> <p>شاپ شیپ را نصب کن، با شماره‌ات وارد شو، و همین امروز اولین فاکتورت را بزن.</p>
<p>کاسبیار را نصب کن، با شماره‌ات وارد شو، و همین امروز اولین فاکتورت را بزن.</p> <div class="hero-actions" style="justify-content:center;">
<div class="hero-actions"> <a class="btn btn-primary" href="https://cafebazaar.ir/app/ir.niqow.kangelo" target="_blank" rel="noopener" style="background:#fff; color:var(--color-brand);">دانلود از کافه‌بازار</a>
<a class="btn btn-primary" href="#">دانلود برای اندروید</a>
<a class="btn btn-secondary" href="#" style="border-color:rgba(243,242,242,0.3); color:#f3f2f2;">دانلود برای iOS</a>
</div>
</div> </div>
</section> </section>
</main> </main>
<footer> <footer>
<div class="container"> <div class="container">
<span>© کاسبیار — اپ مدیریت کسب‌وکارهای کوچک</span> <div class="footer-brand">
<strong>شاپ شیپ</strong>
<span>محصولی از <a href="https://niqow.ir" target="_blank" rel="noopener">niqow.ir</a></span>
</div>
<span><a href="/articles/">مقالات</a></span> <span><a href="/articles/">مقالات</a></span>
<span>قدرت گرفته از <a href="https://niqow.ir" target="_blank" rel="noopener">niqow.ir</a></span> <span>© شاپ شیپ — اپ مدیریت کسب‌وکارهای کوچک</span>
</div> </div>
</footer> </footer>
<script>
var header = document.getElementById('siteHeader');
if (header) {
var onScroll = function () {
header.classList.toggle('is-scrolled', window.scrollY > 8);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
}
var prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion && 'IntersectionObserver' in window) {
var revealTargets = document.querySelectorAll(
'.card, .step, .trust-item, .pricing-card, .section-head, .stats-grid > div'
);
revealTargets.forEach(function (el, i) {
el.classList.add('reveal');
el.style.transitionDelay = (i % 6) * 60 + 'ms';
});
var observer = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('reveal-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.15, rootMargin: '0px 0px -40px 0px' }
);
revealTargets.forEach(function (el) { observer.observe(el); });
}
</script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -1,4 +1,4 @@
User-agent: * User-agent: *
Allow: / Allow: /
Sitemap: https://mobiniqow.ir/sitemap.xml Sitemap: https://shopship.ir/sitemap.xml
+10 -6
View File
@@ -1,9 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://mobiniqow.ir/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url> <url><loc>https://shopship.ir/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>
<url><loc>https://mobiniqow.ir/articles/</loc><changefreq>weekly</changefreq><priority>0.8</priority></url> <url><loc>https://shopship.ir/articles/</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>
<url><loc>https://mobiniqow.ir/articles/peyam-goruhi-ba-shomare/</loc><priority>0.6</priority></url> <url><loc>https://shopship.ir/articles/peyam-goruhi-ba-shomare/</loc><lastmod>2025-08-23</lastmod><priority>0.6</priority></url>
<url><loc>https://mobiniqow.ir/articles/field-sefareshi-dar-template/</loc><priority>0.6</priority></url> <url><loc>https://shopship.ir/articles/field-sefareshi-dar-template/</loc><lastmod>2025-08-23</lastmod><priority>0.6</priority></url>
<url><loc>https://mobiniqow.ir/articles/merge-mokhatebin-ganoshi/</loc><priority>0.6</priority></url> <url><loc>https://shopship.ir/articles/merge-mokhatebin-ganoshi/</loc><lastmod>2025-08-23</lastmod><priority>0.6</priority></url>
<url><loc>https://mobiniqow.ir/articles/sms-ya-whatsapp-business/</loc><priority>0.6</priority></url> <url><loc>https://shopship.ir/articles/sms-ya-whatsapp-business/</loc><lastmod>2025-08-23</lastmod><priority>0.6</priority></url>
<url><loc>https://shopship.ir/articles/forosh-ghesti-hesabdari/</loc><lastmod>2026-08-30</lastmod><priority>0.7</priority></url>
<url><loc>https://shopship.ir/articles/fatoore-rasmi-baraye-maghaze/</loc><lastmod>2026-09-02</lastmod><priority>0.7</priority></url>
<url><loc>https://shopship.ir/articles/kontorol-mojoodi-anbar-ba-mobile/</loc><lastmod>2026-09-05</lastmod><priority>0.7</priority></url>
<url><loc>https://shopship.ir/articles/peygiri-sefaresh-online-shop-etemad/</loc><lastmod>2026-09-08</lastmod><priority>0.7</priority></url>
</urlset> </urlset>
+1
View File
@@ -1,6 +1,7 @@
Django>=5.1,<5.2 Django>=5.1,<5.2
djangorestframework>=3.15,<4.0 djangorestframework>=3.15,<4.0
djangorestframework-simplejwt>=5.3,<6.0 djangorestframework-simplejwt>=5.3,<6.0
PyJWT>=2.8,<3.0
psycopg2-binary>=2.9,<3.0 psycopg2-binary>=2.9,<3.0
python-decouple>=3.8,<4.0 python-decouple>=3.8,<4.0
django-cors-headers>=4.4,<5.0 django-cors-headers>=4.4,<5.0
+16
View File
@@ -0,0 +1,16 @@
/* پوسته‌ی «کاسبیار» روی Unfold — فونت وزیرمتن و لمس‌های ریز هماهنگ با اپ موبایل. */
@import url("https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap");
:root,
:host {
--font-sans: "Vazirmatn", "Inter", sans-serif;
}
body {
font-family: "Vazirmatn", "Inter", sans-serif;
}
/* اعداد لاتین/انگلیسی توی اعداد و تاریخ‌ها با همان فونت خواناتر بمانند. */
[dir="rtl"] .font-mono {
font-family: "Vazirmatn", ui-monospace, monospace;
}