adding
@@ -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': 'قوانین و مقررات',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,15 +1,6 @@
|
||||
# apps/account/send_sms.py
|
||||
"""
|
||||
ارسال پیامک OTP.
|
||||
|
||||
فقط دو مقدار از .env خوانده میشود (تنظیمات در config/settings.py):
|
||||
- SMS_PATTERN_NAME: نام/کد قالبی که نزد سرویسدهندهی پیامک ثبت شده
|
||||
- SMS_TOKEN_URL: آدرسی که با یک درخواست POST، قالب+شماره+کد را برایش
|
||||
میفرستیم (سرویسدهندهی تو باید چنین آدرسی در اختیارت گذاشته باشد)
|
||||
|
||||
اگر ساختار درخواست سرویسدهندهی واقعیات با payload زیر فرق دارد، فقط
|
||||
تابع `_call_token_url` را با مستندات سرویسدهندهات هماهنگ کن؛ بقیهی
|
||||
پروژه با این ماژول کاری ندارد و فقط send_otp_message را صدا میزند.
|
||||
ارسال پیامک OTP با کاوهنگار
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -24,36 +15,65 @@ REQUEST_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
def _call_token_url(phone: str, code: str) -> bool:
|
||||
token_url = settings.SMS_TOKEN_URL
|
||||
pattern_name = settings.SMS_PATTERN_NAME
|
||||
"""
|
||||
ارسال 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
|
||||
|
||||
# ساخت آدرس کامل
|
||||
url = f"https://api.kavenegar.com/v1/{api_key}/verify/lookup.json"
|
||||
|
||||
# پیلود مورد نیاز کاوهنگار
|
||||
payload = {
|
||||
"pattern": pattern_name,
|
||||
"receptor": phone,
|
||||
"token": code,
|
||||
"template": template,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(token_url, json=payload, timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
response.raise_for_status()
|
||||
logger.info(f"✅ OTP sent to {phone} via {token_url}")
|
||||
return True
|
||||
logger.info(f"📤 POST {url}")
|
||||
response = requests.post(url, data=payload, timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
response_data = response.json()
|
||||
|
||||
# بررسی پاسخ
|
||||
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:
|
||||
logger.error(f"❌ خطا در ارسال پیامک به {phone}: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def send_otp_message(phone: str, code: str) -> None:
|
||||
"""ارسال غیربلاکینگ کد تایید (شکست ارسال، درخواست HTTP کاربر را کند نمیکند)"""
|
||||
"""ارسال غیربلاکینگ کد تایید"""
|
||||
|
||||
def _send():
|
||||
try:
|
||||
_call_token_url(phone, code)
|
||||
except Exception as exc: # noqa: BLE001 - لاگ و رد شدن، نباید کل ترد را بترکاند
|
||||
except Exception as exc:
|
||||
logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}")
|
||||
|
||||
Thread(target=_send, daemon=True).start()
|
||||
Thread(target=_send, daemon=True).start()
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,14 +1,39 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib import admin, messages
|
||||
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 .providers.bazaar import BazaarAuthError, build_authorize_url, exchange_code_for_tokens
|
||||
|
||||
|
||||
@admin.register(BazaarSettings)
|
||||
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")
|
||||
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):
|
||||
return not BazaarSettings.objects.exists()
|
||||
@@ -17,6 +42,71 @@ class BazaarSettingsAdmin(admin.ModelAdmin):
|
||||
obj = BazaarSettings.get_solo()
|
||||
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):
|
||||
model = FeaturePlanTier
|
||||
|
||||
@@ -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(
|
||||
"توکن ثابت ذخیره شد — از همین الان خرید باید کار کند. برای فعالکردنِ تمدیدِ "
|
||||
"خودکار (اختیاری)، از پنل ادمین دکمهی «اتصال به کافهبازار» را بزن."
|
||||
)
|
||||
)
|
||||
@@ -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)]
|
||||
@@ -10,13 +10,60 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class BazaarSettings(models.Model):
|
||||
"""تنظیمات کافهبازار — عمداً از پنل ادمین ویرایش میشود (نه .env)، چون
|
||||
باید بدون ریدیپلوی قابلتغییر باشد. تکردیفی (singleton)."""
|
||||
باید بدون ریدیپلوی قابلتغییر باشد. تکردیفی (singleton).
|
||||
|
||||
access_token = models.CharField(max_length=255, blank=True, verbose_name=_("Access Token"))
|
||||
refresh_token = models.CharField(max_length=255, blank=True, verbose_name=_("Refresh Token"))
|
||||
دو روش احراز هویت با devapi کافهبازار پشتیبانی میشود:
|
||||
۱) روش جدید (ساده): توکن ثابت از پیشخان → «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(
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1,33 +1,142 @@
|
||||
# apps/billing/providers/bazaar.py
|
||||
"""
|
||||
تایید خرید کافهبازار — سمت سرور، طبق مستندات عمومی devapi کافهبازار
|
||||
(https://developers.cafebazaar.ir/fa/docs/iab). این تنها API ای است که
|
||||
اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»، تا این
|
||||
تابع True برنگرداند هیچ جمی اضافه نمیشود.
|
||||
|
||||
فرض مستنداتِ مسیر/پاسخ (اگر ساختار حساب توسعهدهندهی خودت فرق دارد، فقط
|
||||
همین یک تابع را با مستندات واقعی هماهنگ کن — بقیهی پروژه فقط
|
||||
`verify_purchase(product_id, purchase_token)` را صدا میزند):
|
||||
تایید خرید کافهبازار — سمت سرور، طبق مستندات رسمی کافهبازار
|
||||
(developers.cafebazaar.ir/fa/guidelines/in-app-billing). این تنها API ای
|
||||
است که اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»،
|
||||
تا این تابع True برنگرداند هیچ جمی اضافه نمیشود.
|
||||
|
||||
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شده) است.
|
||||
|
||||
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 requests
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.billing.models import BazaarSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
@@ -36,14 +145,16 @@ def verify_purchase(product_id: str, purchase_token: str) -> bool:
|
||||
logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده")
|
||||
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:
|
||||
response = requests.get(
|
||||
url,
|
||||
params={"access_token": settings_obj.access_token},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
auth_kwargs = _auth_kwargs_for_request(settings_obj)
|
||||
except BazaarAuthError as exc:
|
||||
logger.error(f"❌ احراز هویت کافهبازار ناموفق: {exc}")
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS, **auth_kwargs)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"])
|
||||
@@ -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">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ article.title }} | کاسبیار</title>
|
||||
<meta name="description" content="{{ article.meta_description }}">
|
||||
<link rel="canonical" href="https://mobiniqow.ir/articles/{{ article.slug }}/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{{ article.title }} | شاپ شیپ</title>
|
||||
<meta name="description" content="{{ article.meta_description|default:article.excerpt }}">
|
||||
<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:site_name" content="شاپ شیپ">
|
||||
<meta property="og:title" content="{{ article.title }}">
|
||||
<meta property="og:description" content="{{ article.meta_description }}">
|
||||
<meta name="theme-color" content="#107c41">
|
||||
<meta property="og:description" content="{{ article.meta_description|default:article.excerpt }}">
|
||||
<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.gstatic.com" crossorigin>
|
||||
<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",
|
||||
"@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' }}",
|
||||
"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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="nav container">
|
||||
<a class="nav-brand" href="/">کاسبیار</a>
|
||||
<nav class="nav-links">
|
||||
<a href="/#features">امکانات</a>
|
||||
<a href="/articles/">مقالات</a>
|
||||
</nav>
|
||||
<header class="nav-wrap" id="siteHeader">
|
||||
<div class="nav container">
|
||||
<a class="nav-brand" href="/">
|
||||
<img src="/assets/img/icon-192.png" alt="" width="28" height="28" class="nav-brand-icon">
|
||||
شاپ شیپ
|
||||
</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>
|
||||
|
||||
<main class="container" style="padding: var(--space-12) 0;">
|
||||
@@ -46,14 +94,39 @@
|
||||
<div class="article-body">
|
||||
{{ article.content_html|safe }}
|
||||
</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>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<span>© کاسبیار — ابزار ارسال پیام گروهی</span>
|
||||
<span><a href="/">صفحهی اصلی</a></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>© شاپ شیپ</span>
|
||||
</div>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -3,31 +3,69 @@
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>مقالات — کاسبیار | راهنمای ارسال پیام گروهی</title>
|
||||
<meta name="description" content="راهنما و مقالات کاسبیار دربارهی ارسال پیام گروهی با شماره: پیامک، واتساپبیزینس، فیلد سفارشی در قالب پیام، و مرج مخاطبین گوشی.">
|
||||
<link rel="canonical" href="https://mobiniqow.ir/articles/">
|
||||
<meta name="theme-color" content="#107c41">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>مقالات — شاپ شیپ | راهنمای مدیریت کسبوکار کوچک</title>
|
||||
<meta name="description" content="راهنما و مقالات شاپ شیپ دربارهی مدیریت کسبوکار کوچک: فاکتور رسمی، دفتر درآمد و هزینه و فروش قسطی، انبارداری، پیگیری سفارش، و ارسال پیام گروهی با شماره.">
|
||||
<meta name="robots" content="index, follow">
|
||||
<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.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/atlas.css">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "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>
|
||||
<body>
|
||||
|
||||
<header class="nav container">
|
||||
<a class="nav-brand" href="/">کاسبیار</a>
|
||||
<nav class="nav-links">
|
||||
<a href="/#features">امکانات</a>
|
||||
<a href="/#channels">کانالها</a>
|
||||
<a href="/articles/">مقالات</a>
|
||||
</nav>
|
||||
<header class="nav-wrap" id="siteHeader">
|
||||
<div class="nav container">
|
||||
<a class="nav-brand" href="/">
|
||||
<img src="/assets/img/icon-192.png" alt="" width="28" height="28" class="nav-brand-icon">
|
||||
شاپ شیپ
|
||||
</a>
|
||||
<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>
|
||||
|
||||
<main class="container" style="padding: var(--space-12) 0;">
|
||||
<div class="section-head" style="text-align:right; margin-inline:0;">
|
||||
<h1>مقالات</h1>
|
||||
<p>راهنمای ارسال پیام گروهی با شماره — پیامک، واتساپ، فیلد سفارشی و مرج مخاطبین.</p>
|
||||
<p>راهنمای مدیریت کسبوکار کوچک — فاکتور، دفتر درآمد و هزینه، انبارداری، پیگیری سفارش و پیام گروهی با شماره.</p>
|
||||
</div>
|
||||
|
||||
<div class="article-list">
|
||||
@@ -37,16 +75,32 @@
|
||||
<h2><a href="/articles/{{ article.slug }}/">{{ article.title }}</a></h2>
|
||||
<p>{{ article.excerpt }}</p>
|
||||
</article>
|
||||
{% empty %}
|
||||
<p>هنوز مقالهای منتشر نشده — بهزودی برمیگردیم.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<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>© شاپ شیپ</span>
|
||||
</div>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -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, "عنوان تست")
|
||||
@@ -1,13 +1,23 @@
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.utils import timezone
|
||||
|
||||
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):
|
||||
articles = Article.objects.filter(is_published=True)
|
||||
articles = _visible_articles()
|
||||
return render(request, "landing/article_list.html", {"articles": articles})
|
||||
|
||||
|
||||
def article_detail(request, slug):
|
||||
article = get_object_or_404(Article, slug=slug, is_published=True)
|
||||
return render(request, "landing/article_detail.html", {"article": article})
|
||||
article = get_object_or_404(_visible_articles(), slug=slug)
|
||||
related = _visible_articles().exclude(pk=article.pk)[:3]
|
||||
return render(request, "landing/article_detail.html", {"article": article, "related": related})
|
||||
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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ی این مرحله'),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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 = _("مانیتورینگ و آمار اپ")
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -1,11 +1,10 @@
|
||||
# config/settings.py
|
||||
"""
|
||||
تنظیمات جنگو برای بکاند «پیامکبان».
|
||||
همهی مقادیر حساس/محیطی از .env خوانده میشوند (با python-decouple) تا برای
|
||||
بالا آوردن روی هر سروری فقط کافی باشد یک فایل .env ساخته شود؛ نیازی به تغییر
|
||||
کد نیست.
|
||||
تنظیمات جنگو برای پروژه «شاپشیپ».
|
||||
همهی مقادیر حساس/محیطی از .env خوانده میشوند.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
@@ -13,24 +12,26 @@ from decouple import Config, Csv, RepositoryEnv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# مسیر .env را عمداً صریح میدهیم (نه decouple.config پیشفرض که بر اساس
|
||||
# فریم صدازننده حدس میزند) — آن حدسِ مبتنیبر عمقِ استک، بسته به اینکه
|
||||
# جنگو از کجا اجرا شود (manage.py check در برابر gunicorn در برابر
|
||||
# manage.py shell)، میتواند مسیر اشتباه را پیدا کند و بیصدا از مقادیر
|
||||
# default() استفاده کند — یعنی .env واقعی هیچوقت خوانده نمیشود.
|
||||
# ============================================
|
||||
# بارگذاری فایل .env
|
||||
# ============================================
|
||||
_env_path = BASE_DIR / ".env"
|
||||
if not _env_path.exists():
|
||||
raise RuntimeError(
|
||||
f"فایل .env پیدا نشد ({_env_path}) — یکبار «cp .env.example .env» بزن و مقادیر واقعی را پر کن."
|
||||
f"❌ فایل .env پیدا نشد ({_env_path}) — لطفاً یک فایل .env ایجاد کنید."
|
||||
)
|
||||
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())
|
||||
|
||||
# -------------------- اپلیکیشنها --------------------
|
||||
# ============================================
|
||||
# اپلیکیشنها
|
||||
# ============================================
|
||||
DJANGO_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
@@ -60,6 +61,9 @@ LOCAL_APPS = [
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
# ============================================
|
||||
# Middleware
|
||||
# ============================================
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
@@ -75,6 +79,9 @@ MIDDLEWARE = [
|
||||
ROOT_URLCONF = "config.urls"
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
# ============================================
|
||||
# Templates
|
||||
# ============================================
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
@@ -91,44 +98,43 @@ TEMPLATES = [
|
||||
},
|
||||
]
|
||||
|
||||
# -------------------- دیتابیس --------------------
|
||||
# پیشفرض SQLite (بدون نیاز به .env برای اجرای محلی)؛ برای پروداکشن/داکر با
|
||||
# گذاشتن DB_ENGINE=django.db.backends.postgresql در .env به PostgreSQL سوییچ کن.
|
||||
if config("DB_ENGINE", default="django.db.backends.sqlite3") == "django.db.backends.sqlite3":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
else:
|
||||
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"),
|
||||
}
|
||||
# ============================================
|
||||
# Database
|
||||
# ============================================
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": config("DB_ENGINE", default="django.db.backends.sqlite3"),
|
||||
"NAME": config("DB_NAME", default=BASE_DIR / "db.sqlite3"),
|
||||
"USER": config("DB_USER", default=""),
|
||||
"PASSWORD": config("DB_PASSWORD", default=""),
|
||||
"HOST": config("DB_HOST", default=""),
|
||||
"PORT": config("DB_PORT", default=""),
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Auth
|
||||
# ============================================
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"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"
|
||||
|
||||
# -------------------- بینالملل --------------------
|
||||
# ============================================
|
||||
# بینالملل
|
||||
# ============================================
|
||||
LANGUAGE_CODE = "fa-ir"
|
||||
TIME_ZONE = "Asia/Tehran"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# -------------------- فایلهای استاتیک --------------------
|
||||
# استاتیک (CSS/JS پنل ادمین و غیره) را WhiteNoise middleware سرو میکند —
|
||||
# چه DEBUG=True چه False، بدون نیاز به تغییری در urls.py.
|
||||
# ============================================
|
||||
# فایلهای استاتیک
|
||||
# ============================================
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STORAGES = {
|
||||
@@ -137,34 +143,30 @@ STORAGES = {
|
||||
},
|
||||
}
|
||||
|
||||
# -------------------- لندینگپیج --------------------
|
||||
# پوشهی landing/ (HTML/CSS ساده، بدون build step) دقیقا از ریشهی دامنه
|
||||
# سرو میشود — یعنی landing/index.html روی «/»، landing/articles/index.html
|
||||
# روی «/articles/» و غیره؛ همان WhiteNoise که استاتیک را سرو میکند، این
|
||||
# را هم سرو میکند، بدون نیاز به هیچ view یا route دستی در urls.py.
|
||||
# ============================================
|
||||
# Landing Page
|
||||
# ============================================
|
||||
WHITENOISE_ROOT = BASE_DIR / "landing"
|
||||
WHITENOISE_INDEX_FILE = True
|
||||
|
||||
# -------------------- فایلهای رسانه (آپلودی) --------------------
|
||||
# فعلا هیچ مدلی ImageField/FileField ندارد، ولی مسیرش را از الان آماده
|
||||
# میکنیم. برخلاف استاتیک، WhiteNoise رسانه را سرو نمیکند — سروش در
|
||||
# urls.py و فقط برای DEBUG=True است (dev)؛ در production باید Nginx/سرور
|
||||
# وب مستقیم از MEDIA_ROOT سرو کند، نه خودِ جنگو.
|
||||
# ============================================
|
||||
# فایلهای رسانه
|
||||
# ============================================
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# -------------------- CORS --------------------
|
||||
# ============================================
|
||||
# CORS
|
||||
# ============================================
|
||||
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default="", cast=Csv())
|
||||
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())
|
||||
|
||||
# -------------------- Django REST Framework --------------------
|
||||
# ============================================
|
||||
# Django REST Framework
|
||||
# ============================================
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
@@ -182,6 +184,9 @@ REST_FRAMEWORK = {
|
||||
},
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Simple JWT
|
||||
# ============================================
|
||||
SIMPLE_JWT = {
|
||||
"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)),
|
||||
@@ -191,38 +196,51 @@ SIMPLE_JWT = {
|
||||
"USER_ID_CLAIM": "user_id",
|
||||
}
|
||||
|
||||
# -------------------- پیامک (OTP) --------------------
|
||||
# فقط دو مقدار لازم است: نام قالب/پترن پیام و آدرس سرویس ارسال (توکن).
|
||||
# اگر بخواهی خودت payload را با سرویسدهندهات هماهنگ کنی، apps/account/send_sms.py را ببین.
|
||||
SMS_PATTERN_NAME = config("SMS_PATTERN_NAME", default="")
|
||||
# ============================================
|
||||
# ✅ تنظیمات پیامک (فقط OTP با کاوهنگار)
|
||||
# ============================================
|
||||
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="")
|
||||
|
||||
# -------------------- ارسال گروهی چندکاناله (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="")
|
||||
|
||||
# WhatsApp Business Cloud API رسمی (Meta) — نیاز به Template تاییدشده دارد.
|
||||
WHATSAPP_ACCESS_TOKEN = config("WHATSAPP_ACCESS_TOKEN", default="")
|
||||
WHATSAPP_PHONE_NUMBER_ID = config("WHATSAPP_PHONE_NUMBER_ID", default="")
|
||||
WHATSAPP_TEMPLATE_NAME = config("WHATSAPP_TEMPLATE_NAME", default="")
|
||||
|
||||
# Bot API رسمی هرکدام — فقط برای مخاطبینی که قبلا با بات چت را شروع کردهاند.
|
||||
TELEGRAM_BOT_TOKEN = config("TELEGRAM_BOT_TOKEN", default="")
|
||||
BALE_BOT_TOKEN = config("BALE_BOT_TOKEN", default="")
|
||||
EITAA_BOT_TOKEN = config("EITAA_BOT_TOKEN", default="")
|
||||
|
||||
# ============================================
|
||||
# لاگینگ
|
||||
# ============================================
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"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": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,30 @@
|
||||
/* Atlas — توکنهای دیزاینسیستم (از claude.ai/design، پروژهی
|
||||
"Flutter Design System"، برند accent: کاسبیار — سبز اکسل) برای لندینگ
|
||||
بازاستفاده شده تا ظاهر وب با اپ یکی باشد. بدون build step، بدون فریمورک —
|
||||
دقیقا روحیهی خودِ سیستم: "Performance یک تصمیم طراحی است».
|
||||
فونت Vazirmatn با <link> در <head> صفحه بارگذاری میشود (نه @import اینجا)
|
||||
— @import رندر CSS را قفل میکند تا فونت خارجی هم دانلود شود. */
|
||||
/* Atlas — توکنهای دیزاینسیستمِ فعلیِ اپ شاپ شیپ (سرمهای/صورتی/مرجانی/کرم،
|
||||
گردیِ زیاد، سایهی نرم) برای لندینگ بازاستفاده شده تا ظاهر وب دقیقا با اپ
|
||||
یکی باشد. بدون build step، بدون فریمورک. فونت Vazirmatn با <link> در
|
||||
<head> صفحه بارگذاری میشود (نه @import اینجا) — @import رندر CSS را
|
||||
قفل میکند. */
|
||||
|
||||
:root {
|
||||
--color-bg: #f3f2f2;
|
||||
--color-surface: #eae9e9;
|
||||
--color-surface-elevated: #f8f4f4;
|
||||
--color-text: #201e1d;
|
||||
--color-text-secondary: #605d5d;
|
||||
--color-text-tertiary: #7d7979;
|
||||
--color-accent: #107c41;
|
||||
--color-accent-600: #0e6837;
|
||||
--color-accent-700: #185c37;
|
||||
--color-accent-100: #e2efda;
|
||||
--color-accent-800: #0b3d21;
|
||||
--color-divider: rgba(32, 30, 29, 0.4);
|
||||
--color-divider-subtle: #d7d3d3;
|
||||
--color-bg: #fdf8f3;
|
||||
--color-bg-secondary: #f6efe4;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-interactive: #f6efe4;
|
||||
--color-text: #201e3d;
|
||||
--color-text-secondary: #6b6889;
|
||||
--color-text-tertiary: #9591ac;
|
||||
--color-brand: #2d2a55;
|
||||
--color-on-brand: #ffffff;
|
||||
--color-pink: #f2467d;
|
||||
--color-coral: #e8635b;
|
||||
--color-cream: #fbeedd;
|
||||
--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-body: 'Vazirmatn', system-ui, sans-serif;
|
||||
@@ -27,50 +33,69 @@
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
|
||||
--radius: 0px; /* امضای بصری سیستم: گوشهی صفر همهجا */
|
||||
--radius-sm: 12px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 20px;
|
||||
--radius-xl: 28px;
|
||||
--radius-pill: 999px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--color-bg: #161514;
|
||||
--color-surface: #201e1d;
|
||||
--color-surface-elevated: #2d2b2b;
|
||||
--color-text: #f5f3f2;
|
||||
--color-text-secondary: #bab6b6;
|
||||
--color-text-tertiary: #9b9797;
|
||||
--color-accent: #21a366;
|
||||
--color-accent-600: #34b378;
|
||||
--color-accent-700: #6bcb9a;
|
||||
--color-accent-100: #112a1c;
|
||||
--color-accent-800: #c6efce;
|
||||
--color-divider: #2d2b2b;
|
||||
--color-divider-subtle: #2d2b2b;
|
||||
--color-bg: #14132a;
|
||||
--color-bg-secondary: #1c1a3b;
|
||||
--color-surface: #262452;
|
||||
--color-surface-interactive: #383468;
|
||||
--color-text: #f5f3fc;
|
||||
--color-text-secondary: #c3bfe2;
|
||||
--color-text-tertiary: #9793bc;
|
||||
--color-brand: #b3aeff;
|
||||
--color-on-brand: #1b1938;
|
||||
--color-pink: #ff7cae;
|
||||
--color-coral: #ff9789;
|
||||
--color-cream: #423c74;
|
||||
--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) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--color-bg: #161514;
|
||||
--color-surface: #201e1d;
|
||||
--color-surface-elevated: #2d2b2b;
|
||||
--color-text: #f5f3f2;
|
||||
--color-text-secondary: #bab6b6;
|
||||
--color-text-tertiary: #9b9797;
|
||||
--color-accent: #21a366;
|
||||
--color-accent-600: #34b378;
|
||||
--color-accent-700: #6bcb9a;
|
||||
--color-accent-100: #112a1c;
|
||||
--color-accent-800: #c6efce;
|
||||
--color-divider: #2d2b2b;
|
||||
--color-divider-subtle: #2d2b2b;
|
||||
--color-bg: #14132a;
|
||||
--color-bg-secondary: #1c1a3b;
|
||||
--color-surface: #262452;
|
||||
--color-surface-interactive: #383468;
|
||||
--color-text: #f5f3fc;
|
||||
--color-text-secondary: #c3bfe2;
|
||||
--color-text-tertiary: #9793bc;
|
||||
--color-brand: #b3aeff;
|
||||
--color-on-brand: #1b1938;
|
||||
--color-pink: #ff7cae;
|
||||
--color-coral: #ff9789;
|
||||
--color-cream: #423c74;
|
||||
--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; }
|
||||
html { direction: rtl; }
|
||||
html { direction: rtl; scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
@@ -78,122 +103,303 @@ body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 var(--space-3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
h1 { font-size: 40px; letter-spacing: -0.02em; }
|
||||
h2 { font-size: 28px; }
|
||||
h3 { font-size: 20px; }
|
||||
h1 { font-size: clamp(25px, 4.6vw + 10px, 42px); letter-spacing: -0.01em; }
|
||||
h2 { font-size: clamp(22px, 1.6vw + 16px, 30px); }
|
||||
h3 { font-size: 19px; }
|
||||
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; }
|
||||
img { max-width: 100%; display: block; }
|
||||
.container { max-width: 1080px; margin: 0 auto; padding: 0 var(--space-6); }
|
||||
img, svg { max-width: 100%; display: block; }
|
||||
.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-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 {
|
||||
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-links { display: flex; gap: var(--space-6); margin-inline-start: auto; font-size: 14px; flex-wrap: wrap; }
|
||||
.nav-links a { color: var(--color-text); }
|
||||
.nav-links a:hover { color: var(--color-accent); }
|
||||
.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-brand:hover { text-decoration: none; }
|
||||
.nav-brand-icon { border-radius: 8px; }
|
||||
.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 — */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
font-family: var(--font-heading); font-weight: 700; font-size: 15px;
|
||||
padding: 12px 22px; border: 2px solid transparent; cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 26px; border: 2px solid transparent; cursor: pointer;
|
||||
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-primary:hover { background: var(--color-accent-600); text-decoration: none; }
|
||||
.btn-secondary { border-color: var(--color-divider); color: var(--color-text); }
|
||||
.btn-secondary:hover { background: var(--color-surface); text-decoration: none; }
|
||||
.btn:hover { text-decoration: none; }
|
||||
.btn:active { transform: scale(0.97); }
|
||||
.btn-sm { padding: 10px 20px; font-size: 14px; }
|
||||
.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 { 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 {
|
||||
display: inline-block; font-size: 12px; font-weight: 700; letter-spacing: 0.04em;
|
||||
color: var(--color-accent); background: var(--color-accent-100);
|
||||
padding: 4px 12px; margin-bottom: var(--space-4);
|
||||
color: var(--color-brand); background: var(--color-lavender);
|
||||
padding: 6px 16px; border-radius: var(--radius-pill); margin-bottom: var(--space-4);
|
||||
}
|
||||
.hero h1 { max-width: 18ch; margin-inline: auto; }
|
||||
.hero p { max-width: 56ch; margin-inline: auto; font-size: 18px; }
|
||||
.hero-actions { display: flex; gap: var(--space-3); justify-content: center; margin-top: var(--space-6); flex-wrap: wrap; }
|
||||
.hero h1 { max-width: 16ch; }
|
||||
.hero-copy p { max-width: 48ch; font-size: 18px; }
|
||||
.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 — */
|
||||
section { padding: var(--space-12) 0; }
|
||||
section.alt { background: var(--color-surface); }
|
||||
section { padding: var(--space-12) 0; position: relative; }
|
||||
section.alt { background: var(--color-bg-secondary); }
|
||||
.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 { 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-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.grid-5 { grid-template-columns: repeat(5, 1fr); }
|
||||
.card { background: var(--color-bg); padding: var(--space-6); }
|
||||
.card .icon { font-size: 28px; margin-bottom: var(--space-3); }
|
||||
.card {
|
||||
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 p { font-size: 14px; margin: 0; }
|
||||
|
||||
/* — channels — */
|
||||
.channels { display: flex; flex-wrap: wrap; gap: var(--space-3); justify-content: center; }
|
||||
.channel-tag {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; border: 2px solid var(--color-divider); font-size: 14px; font-weight: 500;
|
||||
}
|
||||
/* چرخهی رنگِ کاشی — دقیقا همراستا با atlasTileAccents توی اپ */
|
||||
.accent-0 { background: var(--color-pink); }
|
||||
.accent-1 { background: var(--color-blue); }
|
||||
.accent-2 { background: var(--color-coral); }
|
||||
.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 { counter-reset: step; 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::before {
|
||||
counter-increment: step; content: counter(step);
|
||||
flex: none; width: 36px; height: 36px; display: grid; place-items: center;
|
||||
background: var(--color-accent); color: #f3f2f2; font-weight: 800; font-family: var(--font-heading);
|
||||
.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; 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:hover { transform: translateY(-3px); box-shadow: 0 14px 28px var(--shadow-color); }
|
||||
.step-num {
|
||||
flex: none; width: 40px; height: 40px; display: grid; place-items: center;
|
||||
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 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 { background: var(--color-text); color: var(--color-bg); text-align: center; }
|
||||
.cta-band h2 { color: var(--color-bg); }
|
||||
.cta-band p { color: rgba(243, 242, 242, 0.75); }
|
||||
.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-on-brand); }
|
||||
.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 { 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 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 — */
|
||||
.article-list { display: flex; flex-direction: column; gap: 2px; background: var(--color-divider-subtle); }
|
||||
.article-row { background: var(--color-bg); padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.article-list { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.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 h2 { font-size: 20px; margin-bottom: 0; }
|
||||
.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 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 .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; }
|
||||
.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; }
|
||||
.hero h1 { font-size: 30px; }
|
||||
footer .container { flex-direction: column; }
|
||||
.hero-copy p { font-size: 16px; }
|
||||
.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-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) {
|
||||
.container { padding: 0 var(--space-4); }
|
||||
.hero h1 { font-size: 26px; }
|
||||
.hero p { font-size: 16px; }
|
||||
.hero-copy p { font-size: 15px; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 820 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
@@ -2,18 +2,43 @@
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>کاسبیار — اپ مدیریت کسبوکارهای کوچک (فاکتور، پیامک گروهی، مشتریان، انبار، نوبتدهی)</title>
|
||||
<meta name="description" content="کاسبیار یک اپ برای ادارهی کسبوکار کوچک است: فاکتور رسمی، پیام گروهی با شماره (پیامک/واتساپ/تلگرام/بله/ایتا)، مشتریان، دفتر درآمد و هزینه، انبارداری و نوبتدهی — همه در یک اپ ساده. یک هفته کاملا رایگان.">
|
||||
<meta name="keywords" content="اپ مدیریت کسب و کار, پیام گروهی با شماره, نرم افزار فاکتور, نرم افزار انبارداری, نرم افزار نوبت دهی, دفتر درآمد و هزینه, نرم افزار مدیریت مشتریان">
|
||||
<link rel="canonical" href="https://mobiniqow.ir/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>شاپ شیپ — اپ مدیریت کسبوکارهای کوچک | فاکتور، پیامک گروهی، انبار، دفتر درآمد و هزینه</title>
|
||||
<meta name="description" content="شاپ شیپ اپ مدیریت کسبوکار کوچک است: صدور فاکتور رسمی، ارسال پیامک/واتساپ/تلگرام گروهی، مدیریت مشتریان، دفتر درآمد و هزینهی حرفهای با تفکیک فروش قسطی، انبارداری و پیگیری سفارش آنلاینشاپ — همه در یک اپ ساده با تقویم شمسی. یک هفته کاملا رایگان.">
|
||||
<meta name="keywords" content="شاپ شیپ, shopship, نرم افزار مدیریت کسب و کار, اپ مدیریت کسب و کار, نرم افزار فروشگاهی, نرم افزار حسابداری کوچک, نرم افزار صدور فاکتور, فاکتور رسمی با لوگو, ارسال پیامک انبوه, پیامک تبلیغاتی گروهی, ارسال واتساپ گروهی, نرم افزار مدیریت مشتریان, دفتر درآمد و هزینه, نرم افزار حسابداری فروش قسطی, پیگیری بدهی مشتری, نرم افزار انبارداری, کنترل موجودی انبار, پیگیری سفارش آنلاین شاپ, نرم افزار مغازه, اپ کسب و کار خرد, حسابداری فروشگاه با موبایل">
|
||||
<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:title" content="کاسبیار — اپ مدیریت کسبوکارهای کوچک">
|
||||
<meta property="og:description" content="فاکتور، پیام گروهی، مشتریان، دفتر درآمد و هزینه، انبارداری و نوبتدهی — همه در یک اپ. یک هفته کاملا رایگان.">
|
||||
<meta property="og:url" content="https://mobiniqow.ir/">
|
||||
<meta property="og:site_name" content="شاپ شیپ">
|
||||
<meta property="og:title" content="شاپ شیپ — اپ مدیریت کسبوکارهای کوچک">
|
||||
<meta property="og:description" content="فاکتور، پیام گروهی، مشتریان، دفتر درآمد و هزینهی حرفهای، انبارداری و پیگیری سفارش — همه در یک اپ. یک هفته کاملا رایگان.">
|
||||
<meta property="og:url" content="https://shopship.ir/">
|
||||
<meta property="og:locale" content="fa_IR">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="theme-color" content="#107c41">
|
||||
<meta property="og:image" content="https://shopship.ir/assets/img/og-image.png">
|
||||
<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.gstatic.com" crossorigin>
|
||||
<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",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "کاسبیار",
|
||||
"name": "شاپ شیپ",
|
||||
"alternateName": "ShopShip",
|
||||
"applicationCategory": "BusinessApplication",
|
||||
"operatingSystem": "Android, iOS",
|
||||
"description": "اپ مدیریت کسبوکارهای کوچک: فاکتور، پیام گروهی با شماره، مشتریان، دفتر درآمد و هزینه، انبارداری و نوبتدهی.",
|
||||
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "IRR", "description": "یک هفته اول رایگان" }
|
||||
"applicationSubCategory": "Invoicing, CRM, Inventory, Messaging",
|
||||
"operatingSystem": "Android",
|
||||
"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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="nav container">
|
||||
<span class="nav-brand">کاسبیار</span>
|
||||
<nav class="nav-links">
|
||||
<a href="#tools">بخشهای اپ</a>
|
||||
<a href="#who">برای کیه</a>
|
||||
<a href="#how">نحوهی کار</a>
|
||||
<a href="/articles/">مقالات</a>
|
||||
</nav>
|
||||
<a class="btn btn-primary" href="#cta">دریافت اپ</a>
|
||||
<header class="nav-wrap" id="siteHeader">
|
||||
<div class="nav container">
|
||||
<a class="nav-brand" href="#top">
|
||||
<img src="assets/img/icon-192.png" alt="" width="28" height="28" class="nav-brand-icon">
|
||||
شاپ شیپ
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#tools">بخشهای اپ</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>
|
||||
|
||||
<main>
|
||||
<section class="hero container">
|
||||
<span class="kicker">اپ مدیریت کسبوکار کوچک</span>
|
||||
<h1>هرچی برای ادارهی کسبوکارت لازمه، توی یک اپ</h1>
|
||||
<p>
|
||||
کاسبیار فاکتور رسمی صادر میکند، پیام گروهی با شماره میفرستد
|
||||
(پیامک، واتساپ، تلگرام، بله، ایتا)، حساب مشتریها و دخلوخرجت را نگه
|
||||
میدارد، انبارت را زیر نظر میگیرد و نوبتدهیات را مدیریت میکند —
|
||||
بدون اینکه کاسبی که تازهکار با موبایل است گیج شود.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary" href="#cta">یک هفته رایگان امتحان کن</a>
|
||||
<a class="btn btn-secondary" href="#tools">ببین چی داره</a>
|
||||
<section class="hero container" id="top">
|
||||
<div class="hero-blobs" aria-hidden="true">
|
||||
<svg viewBox="0 0 400 500" width="100%" height="100%">
|
||||
<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>
|
||||
<div class="hero-copy">
|
||||
<span class="kicker">اپ مدیریت کسبوکار کوچک</span>
|
||||
<h1>هرچی برای ادارهی کسبوکارت لازمه، توی یک اپ</h1>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<hr class="hairline">
|
||||
|
||||
<section id="tools" class="container">
|
||||
<div class="section-head">
|
||||
<h2>بخشهای کاسبیار</h2>
|
||||
<p>هرکدام جدا فعال میشود، پس فقط برای چیزی که لازم داری هزینه میکنی.</p>
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
<div class="card">
|
||||
<div class="icon">🧾</div>
|
||||
<h3>فاکتور</h3>
|
||||
<p>فاکتور رسمی با لوگو، چند نوع مالیات، تخفیف روی هر قلم کالا، و پیگیری پرداختشده/نشده — کاملا محلی روی گوشی.</p>
|
||||
<section id="tools">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="kicker">بخشهای شاپ شیپ</span>
|
||||
<h2>هرکدام جدا فعال میشود</h2>
|
||||
<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 class="card">
|
||||
<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 class="card">
|
||||
<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 class="card">
|
||||
<div class="icon">🔒</div>
|
||||
<h3>امن و ساده</h3>
|
||||
<p>قفل اثرانگشت برای اطلاعات حساس کسبوکارت، تاریخ شمسی همهجا، و ظاهری که کار کردن باهاش راحت است.</p>
|
||||
<div class="grid grid-3">
|
||||
<div class="card">
|
||||
<div class="icon-tile accent-1">🧾</div>
|
||||
<h3>فاکتور</h3>
|
||||
<p>فاکتور رسمی با لوگو، چند نوع مالیات، تخفیف روی هر قلم کالا، اتصال به انبار، و پیگیری پرداختشده/نشده.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon-tile accent-3">📨</div>
|
||||
<h3>پیام گروهی</h3>
|
||||
<p>یک قالب بنویس، به مخاطبینت پیامک/واتساپ/تلگرام/بله/ایتا بفرست — از مسیر رسمی هر پلتفرم، بدون ترفندهای دور زدن قانون.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon-tile accent-0">👥</div>
|
||||
<h3>مشتریان</h3>
|
||||
<p>برای هر مشتری تاریخچهی خریدش و ماندهی بدهیاش را ببین — خودش از فاکتورها و دفترت ساخته میشود.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon-tile accent-4">💰</div>
|
||||
<h3>دفتر درآمد و هزینه</h3>
|
||||
<p>ثبتِ ساده برای خودت، حسابداریِ درست پشتِ صحنه — فروش قسطی را جدا از نقدی میشناسد، اقساط را پیگیری میکند و خروجی CSV میدهد.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon-tile accent-5">📦</div>
|
||||
<h3>انبارداری</h3>
|
||||
<p>موجودی کالاهایت را دستهبندی کن، با یک ضربه کموزیاد کن، ارزش کل انبارت را ببین و وقتی چیزی کم شد هشدار بگیر.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon-tile accent-2">🚚</div>
|
||||
<h3>پیگیری سفارش</h3>
|
||||
<p>مراحل کارت را تعریف کن (آمادهسازی، بستهبندی، ارسال، تحویل) و با هر «مرحلهی بعد» یک پیامک خودکار از روی قالب دلخواهت برای مشتری میرود.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -123,84 +172,192 @@
|
||||
<section id="who" class="alt">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<h2>کاسبیار برای کیه؟</h2>
|
||||
<span class="kicker">برای کی مناسبه</span>
|
||||
<h2>شاپ شیپ برای کیه؟</h2>
|
||||
<p>برای هر کسی که یک کسبوکار کوچک دارد و نمیخواهد وقتش را صرف نرمافزارهای پیچیده کند.</p>
|
||||
</div>
|
||||
<div class="grid grid-5">
|
||||
<div class="card">
|
||||
<div class="icon">🛍️</div>
|
||||
<div class="icon-tile accent-0">🛍️</div>
|
||||
<h3>فروشگاهدارها</h3>
|
||||
<p>فاکتور بزن، انبارت را زیر نظر بگیر، به مشتریهایت خبر بده.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon">💇</div>
|
||||
<h3>آرایشگاه و سالنها</h3>
|
||||
<p>نوبتدهی منظم و یادآوری نوبت با پیامک.</p>
|
||||
<div class="icon-tile accent-1">🛒</div>
|
||||
<h3>آنلاینشاپهای کوچک</h3>
|
||||
<p>برای هر سفارش، شمارهی مشتری را وارد کن و با «پیگیری سفارش» در هر مرحله خودکار برایش پیامک بفرست.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon">🥬</div>
|
||||
<div class="icon-tile accent-2">🥬</div>
|
||||
<h3>میوه/سبزی و توزیعکنندهها</h3>
|
||||
<p>هر روز با «گزارش سریع» موجودی را به مشتریهای ثابت اعلام کن.</p>
|
||||
<p>گزارش روزانهی موجودی را با یک قالبِ آماده به مشتریهای ثابتت بفرست.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon">🧰</div>
|
||||
<div class="icon-tile accent-3">🧰</div>
|
||||
<h3>کسبوکارهای خدماتی</h3>
|
||||
<p>از تعمیرکار تا مشاور — حساب مشتری، دخلوخرج و پیام گروهی یکجا.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon">🛒</div>
|
||||
<h3>آنلاینشاپهای کوچک</h3>
|
||||
<p>برای هر سفارش، شمارهی مشتری را وارد کن و با «پیگیری سفارش» در هر مرحله (آمادهسازی، بستهبندی، ارسال، تحویل + کد پیگیری پستی) خودکار برایش پیامک بفرست.</p>
|
||||
<div class="icon-tile accent-4">💼</div>
|
||||
<h3>فروش قسطی/نسیه</h3>
|
||||
<p>وقتی جنسی را قسطی میدهی، دفترت خودش میفهمد این یک نوع بدهکاری است، نه فقط یک عدد در لیست درآمد.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how" class="container">
|
||||
<div class="section-head">
|
||||
<h2>در سه قدم شروع کن</h2>
|
||||
<section class="stats-band">
|
||||
<div class="container">
|
||||
<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 class="steps">
|
||||
<div class="step">
|
||||
<div>
|
||||
<h3>با شمارهات وارد شو</h3>
|
||||
<p>فقط شماره موبایل و یک کد تایید — همان لحظه یک هفته رایگان برایت فعال میشود.</p>
|
||||
</section>
|
||||
|
||||
<section id="how">
|
||||
<div class="container">
|
||||
<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 class="step">
|
||||
<div>
|
||||
<h3>بخشی که لازم داری را فعال کن</h3>
|
||||
<p>هر بخش قیمت جدا دارد؛ فقط برای همانی که کسبوکارت لازم دارد هزینه کن.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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 class="step">
|
||||
<div>
|
||||
<h3>کارت را شروع کن</h3>
|
||||
<p>فاکتور بزن، پیام بفرست، انبار و دخلوخرجت را ثبت کن — همه از یک اپ.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pricing">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section id="cta" class="cta-band">
|
||||
<div class="container">
|
||||
<h2>یک هفته کاملا رایگان امتحانش کن</h2>
|
||||
<p>کاسبیار را نصب کن، با شمارهات وارد شو، و همین امروز اولین فاکتورت را بزن.</p>
|
||||
<div class="hero-actions">
|
||||
<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>
|
||||
<h2>یک هفته کاملا رایگان امتحانش کن</h2>
|
||||
<p>شاپ شیپ را نصب کن، با شمارهات وارد شو، و همین امروز اولین فاکتورت را بزن.</p>
|
||||
<div class="hero-actions" style="justify-content:center;">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<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="https://niqow.ir" target="_blank" rel="noopener">niqow.ir</a></span>
|
||||
<span>© شاپ شیپ — اپ مدیریت کسبوکارهای کوچک</span>
|
||||
</div>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://mobiniqow.ir/sitemap.xml
|
||||
Sitemap: https://shopship.ir/sitemap.xml
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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://mobiniqow.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://mobiniqow.ir/articles/field-sefareshi-dar-template/</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://mobiniqow.ir/articles/merge-mokhatebin-ganoshi/</loc><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/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>https://shopship.ir/articles/</loc><changefreq>weekly</changefreq><priority>0.8</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://shopship.ir/articles/field-sefareshi-dar-template/</loc><lastmod>2025-08-23</lastmod><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://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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
Django>=5.1,<5.2
|
||||
djangorestframework>=3.15,<4.0
|
||||
djangorestframework-simplejwt>=5.3,<6.0
|
||||
PyJWT>=2.8,<3.0
|
||||
psycopg2-binary>=2.9,<3.0
|
||||
python-decouple>=3.8,<4.0
|
||||
django-cors-headers>=4.4,<5.0
|
||||
|
||||
@@ -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;
|
||||
}
|
||||