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
|
# apps/account/send_sms.py
|
||||||
"""
|
"""
|
||||||
ارسال پیامک OTP.
|
ارسال پیامک OTP با کاوهنگار
|
||||||
|
|
||||||
فقط دو مقدار از .env خوانده میشود (تنظیمات در config/settings.py):
|
|
||||||
- SMS_PATTERN_NAME: نام/کد قالبی که نزد سرویسدهندهی پیامک ثبت شده
|
|
||||||
- SMS_TOKEN_URL: آدرسی که با یک درخواست POST، قالب+شماره+کد را برایش
|
|
||||||
میفرستیم (سرویسدهندهی تو باید چنین آدرسی در اختیارت گذاشته باشد)
|
|
||||||
|
|
||||||
اگر ساختار درخواست سرویسدهندهی واقعیات با payload زیر فرق دارد، فقط
|
|
||||||
تابع `_call_token_url` را با مستندات سرویسدهندهات هماهنگ کن؛ بقیهی
|
|
||||||
پروژه با این ماژول کاری ندارد و فقط send_otp_message را صدا میزند.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -24,36 +15,65 @@ REQUEST_TIMEOUT_SECONDS = 10
|
|||||||
|
|
||||||
|
|
||||||
def _call_token_url(phone: str, code: str) -> bool:
|
def _call_token_url(phone: str, code: str) -> bool:
|
||||||
token_url = settings.SMS_TOKEN_URL
|
"""
|
||||||
pattern_name = settings.SMS_PATTERN_NAME
|
ارسال OTP با استفاده از API کاوهنگار
|
||||||
|
"""
|
||||||
|
api_key = settings.KAVENEGAR_API_KEY
|
||||||
|
template = settings.SMS_PATTERN_NAME
|
||||||
|
|
||||||
if not token_url or not pattern_name:
|
# ✅ لاگ برای دیباگ
|
||||||
logger.error("SMS_TOKEN_URL یا SMS_PATTERN_NAME در .env تنظیم نشده است")
|
logger.info(f"📤 Sending OTP to {phone} with template: {template}")
|
||||||
|
|
||||||
|
if not api_key or not template:
|
||||||
|
logger.error("❌ KAVENEGAR_API_KEY یا SMS_PATTERN_NAME در .env تنظیم نشده است")
|
||||||
|
logger.error(f" KAVENEGAR_API_KEY: {'✅' if api_key else '❌'}")
|
||||||
|
logger.error(f" SMS_PATTERN_NAME: {'✅' if template else '❌'}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ساخت آدرس کامل
|
||||||
|
url = f"https://api.kavenegar.com/v1/{api_key}/verify/lookup.json"
|
||||||
|
|
||||||
|
# پیلود مورد نیاز کاوهنگار
|
||||||
payload = {
|
payload = {
|
||||||
"pattern": pattern_name,
|
|
||||||
"receptor": phone,
|
"receptor": phone,
|
||||||
"token": code,
|
"token": code,
|
||||||
|
"template": template,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(token_url, json=payload, timeout=REQUEST_TIMEOUT_SECONDS)
|
logger.info(f"📤 POST {url}")
|
||||||
response.raise_for_status()
|
response = requests.post(url, data=payload, timeout=REQUEST_TIMEOUT_SECONDS)
|
||||||
logger.info(f"✅ OTP sent to {phone} via {token_url}")
|
response_data = response.json()
|
||||||
return True
|
|
||||||
|
# بررسی پاسخ
|
||||||
|
if response.status_code == 200:
|
||||||
|
return_status = response_data.get("return", {}).get("status")
|
||||||
|
if return_status == 200:
|
||||||
|
logger.info(f"✅ OTP sent to {phone} via Kavenegar")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
error_msg = response_data.get("return", {}).get("message", "Unknown error")
|
||||||
|
logger.error(f"❌ Kavenegar error: {error_msg}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ Kavenegar HTTP error: {response.status_code} - {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except requests.Timeout:
|
||||||
|
logger.error(f"❌ Kavenegar timeout for {phone}")
|
||||||
|
return False
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
logger.error(f"❌ خطا در ارسال پیامک به {phone}: {exc}")
|
logger.error(f"❌ خطا در ارسال پیامک به {phone}: {exc}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def send_otp_message(phone: str, code: str) -> None:
|
def send_otp_message(phone: str, code: str) -> None:
|
||||||
"""ارسال غیربلاکینگ کد تایید (شکست ارسال، درخواست HTTP کاربر را کند نمیکند)"""
|
"""ارسال غیربلاکینگ کد تایید"""
|
||||||
|
|
||||||
def _send():
|
def _send():
|
||||||
try:
|
try:
|
||||||
_call_token_url(phone, code)
|
_call_token_url(phone, code)
|
||||||
except Exception as exc: # noqa: BLE001 - لاگ و رد شدن، نباید کل ترد را بترکاند
|
except Exception as exc:
|
||||||
logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}")
|
logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}")
|
||||||
|
|
||||||
Thread(target=_send, daemon=True).start()
|
Thread(target=_send, daemon=True).start()
|
||||||
@@ -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.shortcuts import redirect
|
||||||
|
from django.urls import path, reverse
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction
|
from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction
|
||||||
|
from .providers.bazaar import BazaarAuthError, build_authorize_url, exchange_code_for_tokens
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BazaarSettings)
|
@admin.register(BazaarSettings)
|
||||||
class BazaarSettingsAdmin(admin.ModelAdmin):
|
class BazaarSettingsAdmin(admin.ModelAdmin):
|
||||||
"""تکردیفی — لیست را رد میکنیم و مستقیم فرم ویرایش همان یک ردیف را نشان میدهیم."""
|
"""تکردیفی — لیست را رد میکنیم و مستقیم فرم ویرایش همان یک ردیف را نشان میدهیم.
|
||||||
|
|
||||||
|
برای اتصال با روش OAuth (تمدید خودکار access_token): این سه فیلد را پر
|
||||||
|
کن — Client ID، Client Secret، application id اپ — ذخیره کن، بعد از
|
||||||
|
«وضعیت اتصال کافهبازار» پایین فرم روی «شروع اتصال» بزن. اگر Redirect URI
|
||||||
|
که موقع ساخت Client در پیشخان ثبت کردهای با آدرسِ نشاندادهشده اینجا فرق
|
||||||
|
دارد، بعد از تایید در کافهبازار، مقدار code= را از آدرسِ برگشتی کپی و
|
||||||
|
خودت به همان آدرس callback اضافه کن (?code=...) — نیازی به فیلد جداگانه نیست.
|
||||||
|
|
||||||
|
یا سادهتر: اگر همین حالا یک توکن ثابتِ «روش جدید» پیشخان داری، مستقیم در
|
||||||
|
Access Token بچسبان و Access Token Expires At را خالی بگذار — کارش تمام
|
||||||
|
است، فقط اگر روزی باطل شد باید دستی توکن تازه بگیری."""
|
||||||
|
|
||||||
list_display = ("package_name", "updated_at")
|
list_display = ("package_name", "updated_at")
|
||||||
|
readonly_fields = ("oauth_status",)
|
||||||
|
fields = (
|
||||||
|
"package_name",
|
||||||
|
"access_token",
|
||||||
|
"access_token_expires_at",
|
||||||
|
"refresh_token",
|
||||||
|
"client_id",
|
||||||
|
"client_secret",
|
||||||
|
"dynamic_discount_secret",
|
||||||
|
"oauth_status",
|
||||||
|
)
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
def has_add_permission(self, request):
|
||||||
return not BazaarSettings.objects.exists()
|
return not BazaarSettings.objects.exists()
|
||||||
@@ -17,6 +42,71 @@ class BazaarSettingsAdmin(admin.ModelAdmin):
|
|||||||
obj = BazaarSettings.get_solo()
|
obj = BazaarSettings.get_solo()
|
||||||
return redirect("admin:billing_bazaarsettings_change", obj.pk)
|
return redirect("admin:billing_bazaarsettings_change", obj.pk)
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
custom = [
|
||||||
|
path(
|
||||||
|
"oauth/connect/",
|
||||||
|
self.admin_site.admin_view(self.oauth_connect_view),
|
||||||
|
name="billing_bazaarsettings_oauth_connect",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"oauth/callback/",
|
||||||
|
self.admin_site.admin_view(self.oauth_callback_view),
|
||||||
|
name="billing_bazaarsettings_oauth_callback",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return custom + super().get_urls()
|
||||||
|
|
||||||
|
def _callback_url(self, request) -> str:
|
||||||
|
return request.build_absolute_uri(reverse("admin:billing_bazaarsettings_oauth_callback"))
|
||||||
|
|
||||||
|
def oauth_connect_view(self, request):
|
||||||
|
obj = BazaarSettings.get_solo()
|
||||||
|
try:
|
||||||
|
authorize_url = build_authorize_url(self._callback_url(request))
|
||||||
|
except BazaarAuthError as exc:
|
||||||
|
messages.error(request, str(exc))
|
||||||
|
return redirect("admin:billing_bazaarsettings_change", obj.pk)
|
||||||
|
return redirect(authorize_url)
|
||||||
|
|
||||||
|
def oauth_callback_view(self, request):
|
||||||
|
obj = BazaarSettings.get_solo()
|
||||||
|
code = request.GET.get("code")
|
||||||
|
if not code:
|
||||||
|
messages.error(request, "پارامتر code در آدرس بازگشتی پیدا نشد.")
|
||||||
|
return redirect("admin:billing_bazaarsettings_change", obj.pk)
|
||||||
|
try:
|
||||||
|
exchange_code_for_tokens(code, self._callback_url(request))
|
||||||
|
except BazaarAuthError as exc:
|
||||||
|
messages.error(request, f"اتصال به کافهبازار ناموفق بود: {exc}")
|
||||||
|
else:
|
||||||
|
messages.success(request, "اتصال به کافهبازار موفق بود — access_token از این پس خودکار تمدید میشود.")
|
||||||
|
return redirect("admin:billing_bazaarsettings_change", obj.pk)
|
||||||
|
|
||||||
|
@admin.display(description="وضعیت اتصال کافهبازار")
|
||||||
|
def oauth_status(self, obj):
|
||||||
|
if not obj or not obj.pk:
|
||||||
|
return "ابتدا ذخیره کن."
|
||||||
|
callback_hint = reverse("admin:billing_bazaarsettings_oauth_callback")
|
||||||
|
connect_url = reverse("admin:billing_bazaarsettings_oauth_connect")
|
||||||
|
if obj.access_token_expires_at:
|
||||||
|
status_line = f"متصل (OAuth) — انقضای فعلی: {obj.access_token_expires_at:%Y-%m-%d %H:%M}"
|
||||||
|
elif obj.access_token:
|
||||||
|
status_line = "متصل با توکن ثابتِ روش جدید (بدون تمدید خودکار)."
|
||||||
|
else:
|
||||||
|
status_line = "هنوز متصل نیست."
|
||||||
|
return format_html(
|
||||||
|
"<p>{}</p>"
|
||||||
|
"<p>Redirect URI برای ثبت در پیشخان (اگر میخواهی OAuth را وصل کنی) — این مسیر را با "
|
||||||
|
"آدرس دامنهی سرورت جلو ببر، مثلا https://api.example.com{} :</p>"
|
||||||
|
"<p><code>{}</code></p>"
|
||||||
|
'<p><a class="button" href="{}">شروع اتصال به کافهبازار (OAuth)</a></p>',
|
||||||
|
status_line,
|
||||||
|
callback_hint,
|
||||||
|
callback_hint,
|
||||||
|
connect_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FeaturePlanTierInline(admin.TabularInline):
|
class FeaturePlanTierInline(admin.TabularInline):
|
||||||
model = FeaturePlanTier
|
model = FeaturePlanTier
|
||||||
|
|||||||
@@ -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):
|
class BazaarSettings(models.Model):
|
||||||
"""تنظیمات کافهبازار — عمداً از پنل ادمین ویرایش میشود (نه .env)، چون
|
"""تنظیمات کافهبازار — عمداً از پنل ادمین ویرایش میشود (نه .env)، چون
|
||||||
باید بدون ریدیپلوی قابلتغییر باشد. تکردیفی (singleton)."""
|
باید بدون ریدیپلوی قابلتغییر باشد. تکردیفی (singleton).
|
||||||
|
|
||||||
access_token = models.CharField(max_length=255, blank=True, verbose_name=_("Access Token"))
|
دو روش احراز هویت با devapi کافهبازار پشتیبانی میشود:
|
||||||
refresh_token = models.CharField(max_length=255, blank=True, verbose_name=_("Refresh Token"))
|
۱) روش جدید (ساده): توکن ثابت از پیشخان → «API پیشخان بازار» → «دریافت
|
||||||
|
توکن جدید»، مستقیم در access_token جایگذاری میشود؛ چون access_token_
|
||||||
|
expires_at خالی میماند، هیچوقت سعی در refresh نمیشود (این توکن API
|
||||||
|
رفرش ندارد؛ اگر باطل شد باید دستی از پیشخان توکن تازه گرفت و همینجا
|
||||||
|
جایگزین کرد).
|
||||||
|
۲) روش قدیمی (OAuth با رفرش خودکار): client_id/client_secret را اینجا
|
||||||
|
بگذار، از دکمهی «اتصال به کافهبازار» در همین صفحه استفاده کن؛ از آن
|
||||||
|
به بعد access_token هر بار که نزدیک انقضا باشد، خودش با refresh_token
|
||||||
|
تمدید میشود — بدون نیاز به مراجعهی دستی دوباره."""
|
||||||
|
|
||||||
|
access_token = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Access Token"),
|
||||||
|
help_text=_(
|
||||||
|
"یا توکن ثابت «روش جدید» پیشخان را مستقیم اینجا بچسبان، یا با دکمهی اتصال زیر "
|
||||||
|
"بگذار خودش (روش قدیمی/OAuth) پر و بعدا خودکار تمدید شود."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
refresh_token = models.CharField(
|
||||||
|
max_length=255, blank=True, verbose_name=_("Refresh Token"),
|
||||||
|
help_text=_("فقط برای روش OAuth — با دکمهی اتصال خودش پر میشود، دستی لازم نیست."),
|
||||||
|
)
|
||||||
|
access_token_expires_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("انقضای Access Token (فقط OAuth)"),
|
||||||
|
help_text=_(
|
||||||
|
"اگر خالی باشد یعنی توکن ثابتِ روش جدید است و هیچوقت خودکار تمدید نمیشود. "
|
||||||
|
"اگر پر باشد یعنی از OAuth آمده و پیش از انقضا خودش تمدید خواهد شد."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
client_id = models.CharField(
|
||||||
|
max_length=255, blank=True, verbose_name=_("Client ID (روش قدیمی/OAuth)")
|
||||||
|
)
|
||||||
|
client_secret = models.CharField(
|
||||||
|
max_length=255, blank=True, verbose_name=_("Client Secret (روش قدیمی/OAuth)")
|
||||||
|
)
|
||||||
package_name = models.CharField(
|
package_name = models.CharField(
|
||||||
max_length=150, blank=True, verbose_name=_("application id اپ (مثلا ir.example.app)")
|
max_length=150, blank=True, verbose_name=_("application id اپ (مثلا ir.example.app)")
|
||||||
)
|
)
|
||||||
|
dynamic_discount_secret = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("کلید امضای تخفیف پویا"),
|
||||||
|
help_text=_(
|
||||||
|
"از پیشخان → برنامهات → پرداخت درونبرنامهای → تخفیف پویا. فقط سمت سرور استفاده "
|
||||||
|
"میشود (apps.billing.providers.dynamic_discount) — هیچوقت نباید در اپ فلاتر ذخیره شود، "
|
||||||
|
"وگرنه کلید افشا میشود و کافهبازار مسئولیتی نمیپذیرد."
|
||||||
|
),
|
||||||
|
)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -1,33 +1,142 @@
|
|||||||
# apps/billing/providers/bazaar.py
|
# apps/billing/providers/bazaar.py
|
||||||
"""
|
"""
|
||||||
تایید خرید کافهبازار — سمت سرور، طبق مستندات عمومی devapi کافهبازار
|
تایید خرید کافهبازار — سمت سرور، طبق مستندات رسمی کافهبازار
|
||||||
(https://developers.cafebazaar.ir/fa/docs/iab). این تنها API ای است که
|
(developers.cafebazaar.ir/fa/guidelines/in-app-billing). این تنها API ای
|
||||||
اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»، تا این
|
است که اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»،
|
||||||
تابع True برنگرداند هیچ جمی اضافه نمیشود.
|
تا این تابع True برنگرداند هیچ جمی اضافه نمیشود.
|
||||||
|
|
||||||
فرض مستنداتِ مسیر/پاسخ (اگر ساختار حساب توسعهدهندهی خودت فرق دارد، فقط
|
|
||||||
همین یک تابع را با مستندات واقعی هماهنگ کن — بقیهی پروژه فقط
|
|
||||||
`verify_purchase(product_id, purchase_token)` را صدا میزند):
|
|
||||||
|
|
||||||
GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}
|
GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}
|
||||||
/inapp/{product_id}/purchases/{purchase_token}/?access_token=...
|
/inapp/{product_id}/purchases/{purchase_token}/
|
||||||
|
|
||||||
پاسخ موفق شامل purchaseState=0 (خریداریشده، نه refundشده) است.
|
پاسخ موفق شامل purchaseState=0 (خریداریشده، نه refundشده) است.
|
||||||
|
|
||||||
access_token از BazaarSettings (پنل ادمین) خوانده میشود، نه .env — چون باید
|
دو روش احراز هویت (هر دو طبق مستندات رسمی):
|
||||||
بدون ریدیپلوی قابلتعویض باشد (مثلا وقتی منقضی شد).
|
|
||||||
|
«روش جدید» — توکن ثابتِ پیشخان (.../api/setting-up-new)، در هدر:
|
||||||
|
CAFEBAZAAR-PISHKHAN-API-SECRET: <token>
|
||||||
|
این توکن API رفرش ندارد (اگر باطل شود باید دستی از پیشخان توکن تازه گرفت).
|
||||||
|
|
||||||
|
«روش قدیمی» — OAuth2 (.../api/setting-up)، در پارامتر URL:
|
||||||
|
?access_token=<token>
|
||||||
|
با client_id/client_secret/refresh_token قابل تمدید خودکار است — دقیقا
|
||||||
|
همین رفرش خودکار اینجا با auto_refresh_access_token_if_needed پیاده شده.
|
||||||
|
|
||||||
|
تشخیص اینکه کدام روش فعال است صرفا از روی BazaarSettings.access_token_
|
||||||
|
expires_at: خالی=روش جدید (هدر)، پرشده=روش قدیمی/OAuth (پارامتر URL، با
|
||||||
|
رفرش خودکار پیش از انقضا).
|
||||||
|
|
||||||
|
همهچیز از BazaarSettings (پنل ادمین) خوانده میشود، نه .env — چون باید بدون
|
||||||
|
ریدیپلوی قابلتعویض باشد.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.billing.models import BazaarSettings
|
from apps.billing.models import BazaarSettings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
REQUEST_TIMEOUT_SECONDS = 15
|
REQUEST_TIMEOUT_SECONDS = 15
|
||||||
BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api"
|
DEVAPI_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api"
|
||||||
|
OAUTH_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth"
|
||||||
|
PISHKHAN_TOKEN_HEADER = "CAFEBAZAAR-PISHKHAN-API-SECRET"
|
||||||
|
|
||||||
|
# پیش از این فاصله تا انقضا، توکن OAuth را تمدید میکنیم تا وسط یک درخواست
|
||||||
|
# واقعی منقضی نشود (access_token های کافهبازار معمولا ۳۶۰۰ ثانیه عمر دارند).
|
||||||
|
REFRESH_SAFETY_MARGIN_SECONDS = 120
|
||||||
|
|
||||||
|
|
||||||
|
class BazaarAuthError(Exception):
|
||||||
|
"""احراز هویت با کافهبازار ممکن نیست (تنظیمات ناقص یا رفرش ناموفق)."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_authorize_url(redirect_uri: str) -> str:
|
||||||
|
"""لینک شروع فلوی OAuth (روش قدیمی) — کاربر باید یکبار در مرورگر باز و
|
||||||
|
تایید کند. redirect_uri باید دقیقا همانی باشد که موقع ساخت Client در
|
||||||
|
پیشخان ثبت شده."""
|
||||||
|
settings_obj = BazaarSettings.get_solo()
|
||||||
|
if not settings_obj.client_id:
|
||||||
|
raise BazaarAuthError("Client ID در تنظیمات کافهبازار خالی است.")
|
||||||
|
return (
|
||||||
|
f"{OAUTH_BASE_URL}/authorize/"
|
||||||
|
f"?response_type=code&access_type=offline"
|
||||||
|
f"&redirect_uri={redirect_uri}&client_id={settings_obj.client_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_code_for_tokens(code: str, redirect_uri: str) -> None:
|
||||||
|
"""گام آخر فلوی OAuth — code گرفتهشده از ریدایرکت (یا دستی کپیشده) را
|
||||||
|
به access_token/refresh_token تبدیل و در BazaarSettings ذخیره میکند."""
|
||||||
|
settings_obj = BazaarSettings.get_solo()
|
||||||
|
if not settings_obj.client_id or not settings_obj.client_secret:
|
||||||
|
raise BazaarAuthError("Client ID یا Client Secret در تنظیمات کافهبازار خالی است.")
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f"{OAUTH_BASE_URL}/token/",
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"client_id": settings_obj.client_id,
|
||||||
|
"client_secret": settings_obj.client_secret,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
},
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if not response.ok:
|
||||||
|
raise BazaarAuthError(f"تبادل کد ناموفق بود: {response.status_code} {response.text}")
|
||||||
|
|
||||||
|
_save_token_response(settings_obj, response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_access_token(settings_obj: BazaarSettings) -> None:
|
||||||
|
if not settings_obj.client_id or not settings_obj.client_secret or not settings_obj.refresh_token:
|
||||||
|
raise BazaarAuthError(
|
||||||
|
"برای تمدید خودکار access_token باید client_id/client_secret/refresh_token هر سه ست باشند."
|
||||||
|
)
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f"{OAUTH_BASE_URL}/token/",
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"client_id": settings_obj.client_id,
|
||||||
|
"client_secret": settings_obj.client_secret,
|
||||||
|
"refresh_token": settings_obj.refresh_token,
|
||||||
|
},
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if not response.ok:
|
||||||
|
raise BazaarAuthError(f"تمدید access_token ناموفق بود: {response.status_code} {response.text}")
|
||||||
|
|
||||||
|
_save_token_response(settings_obj, response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def _save_token_response(settings_obj: BazaarSettings, body: dict) -> None:
|
||||||
|
settings_obj.access_token = body["access_token"]
|
||||||
|
# رفرشگرنت گاهی refresh_token جدید برنمیگرداند — قبلی را نگه میداریم.
|
||||||
|
if body.get("refresh_token"):
|
||||||
|
settings_obj.refresh_token = body["refresh_token"]
|
||||||
|
expires_in = body.get("expires_in", 3600)
|
||||||
|
settings_obj.access_token_expires_at = timezone.now() + timezone.timedelta(seconds=expires_in)
|
||||||
|
settings_obj.save(
|
||||||
|
update_fields=["access_token", "refresh_token", "access_token_expires_at", "updated_at"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_kwargs_for_request(settings_obj: BazaarSettings) -> dict:
|
||||||
|
"""توکنِ سالم برای یک درخواست devapi را برمیگرداند — هدر یا پارامتر URL،
|
||||||
|
بسته به اینکه توکن ثابتِ پیشخان است یا OAuth (و اگر OAuth و نزدیک
|
||||||
|
انقضاست، همینجا اول تمدیدش میکند)."""
|
||||||
|
is_oauth_managed = settings_obj.access_token_expires_at is not None
|
||||||
|
|
||||||
|
if is_oauth_managed:
|
||||||
|
margin = timezone.timedelta(seconds=REFRESH_SAFETY_MARGIN_SECONDS)
|
||||||
|
if timezone.now() >= settings_obj.access_token_expires_at - margin:
|
||||||
|
_refresh_access_token(settings_obj)
|
||||||
|
return {"params": {"access_token": settings_obj.access_token}}
|
||||||
|
|
||||||
|
return {"headers": {PISHKHAN_TOKEN_HEADER: settings_obj.access_token}}
|
||||||
|
|
||||||
|
|
||||||
def verify_purchase(product_id: str, purchase_token: str) -> bool:
|
def verify_purchase(product_id: str, purchase_token: str) -> bool:
|
||||||
@@ -36,14 +145,16 @@ def verify_purchase(product_id: str, purchase_token: str) -> bool:
|
|||||||
logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده")
|
logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
url = f"{BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/"
|
url = f"{DEVAPI_BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
auth_kwargs = _auth_kwargs_for_request(settings_obj)
|
||||||
url,
|
except BazaarAuthError as exc:
|
||||||
params={"access_token": settings_obj.access_token},
|
logger.error(f"❌ احراز هویت کافهبازار ناموفق: {exc}")
|
||||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
return False
|
||||||
)
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS, **auth_kwargs)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
body = response.json()
|
body = response.json()
|
||||||
purchase_state = body.get("purchaseState")
|
purchase_state = body.get("purchaseState")
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# apps/billing/providers/dynamic_discount.py
|
||||||
|
"""
|
||||||
|
تولید توکن «تخفیف پویا» کافهبازار
|
||||||
|
(developers.cafebazaar.ir/fa/guidelines/in-app-billing/dynamic-discount).
|
||||||
|
|
||||||
|
فقط یک تابع خام است، بدون endpoint/کمپین — چون فعلا کمپین تخفیف مشخصی در
|
||||||
|
کار نیست؛ هر وقت لازم شد (مثلا یک ویو که برای یک GemPack با قیمت پایینتر
|
||||||
|
توکن میسازد)، همین تابع را صدا بزن. کلید امضا (BazaarSettings.
|
||||||
|
dynamic_discount_secret) عمدا فقط همینجا (سمت سرور) استفاده میشود و
|
||||||
|
هیچوقت نباید به کلاینت/اپ فلاتر فرستاده شود — طبق مستندات، ذخیرهی این کلید
|
||||||
|
در اپ یعنی افشای آن، و کافهبازار مسئولیتی در قبال سوءاستفاده نمیپذیرد.
|
||||||
|
|
||||||
|
خروجی این تابع مستقیم در فیلد dynamicPriceToken کلاس PurchaseRequest
|
||||||
|
(سمت اپ، هنگام FlutterPoolakey.purchase) استفاده میشود.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from apps.billing.models import BazaarSettings
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicDiscountNotConfigured(Exception):
|
||||||
|
"""کلید امضای تخفیف پویا در BazaarSettings تنظیم نشده."""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_dynamic_discount_token(
|
||||||
|
*,
|
||||||
|
sku: str,
|
||||||
|
price_rial: int,
|
||||||
|
ttl_seconds: int = 300,
|
||||||
|
account_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""یک توکن یکبارمصرف طبق مشخصات مستندات کافهبازار میسازد.
|
||||||
|
|
||||||
|
price_rial باید <= قیمت ثبتشدهی همین sku در پنل پیشخان باشد، وگرنه
|
||||||
|
کافهبازار خودش موقع خرید خطای «قیمت بیش از حد مجاز» (کد ۱۰) برمیگرداند."""
|
||||||
|
settings_obj = BazaarSettings.get_solo()
|
||||||
|
if not settings_obj.dynamic_discount_secret or not settings_obj.package_name:
|
||||||
|
raise DynamicDiscountNotConfigured(
|
||||||
|
"dynamic_discount_secret یا package_name در تنظیمات کافهبازار خالی است."
|
||||||
|
)
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"price": price_rial,
|
||||||
|
"package_name": settings_obj.package_name,
|
||||||
|
"sku": sku,
|
||||||
|
"exp": now + ttl_seconds,
|
||||||
|
"nonce": uuid.uuid4().hex,
|
||||||
|
}
|
||||||
|
if account_id:
|
||||||
|
payload["account_id"] = account_id
|
||||||
|
|
||||||
|
return jwt.encode(payload, settings_obj.dynamic_discount_secret, algorithm="HS256")
|
||||||
@@ -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">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>{{ article.title }} | کاسبیار</title>
|
<title>{{ article.title }} | شاپ شیپ</title>
|
||||||
<meta name="description" content="{{ article.meta_description }}">
|
<meta name="description" content="{{ article.meta_description|default:article.excerpt }}">
|
||||||
<link rel="canonical" href="https://mobiniqow.ir/articles/{{ article.slug }}/">
|
<meta name="robots" content="index, follow">
|
||||||
|
<meta name="author" content="niqow.ir">
|
||||||
|
<link rel="canonical" href="https://shopship.ir/articles/{{ article.slug }}/">
|
||||||
|
<link rel="alternate" hreflang="fa-ir" href="https://shopship.ir/articles/{{ article.slug }}/">
|
||||||
|
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/img/favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/img/favicon-16.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/img/apple-touch-icon.png">
|
||||||
|
|
||||||
<meta property="og:type" content="article">
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:site_name" content="شاپ شیپ">
|
||||||
<meta property="og:title" content="{{ article.title }}">
|
<meta property="og:title" content="{{ article.title }}">
|
||||||
<meta property="og:description" content="{{ article.meta_description }}">
|
<meta property="og:description" content="{{ article.meta_description|default:article.excerpt }}">
|
||||||
<meta name="theme-color" content="#107c41">
|
<meta property="og:url" content="https://shopship.ir/articles/{{ article.slug }}/">
|
||||||
|
<meta property="og:locale" content="fa_IR">
|
||||||
|
<meta property="og:image" content="https://shopship.ir/assets/img/og-image.png">
|
||||||
|
<meta property="article:published_time" content="{{ article.published_at|date:'Y-m-d' }}">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="{{ article.title }}">
|
||||||
|
<meta name="twitter:image" content="https://shopship.ir/assets/img/og-image.png">
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#2d2a55">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
|
||||||
@@ -19,20 +37,50 @@
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
"headline": "{{ article.title }}",
|
"headline": "{{ article.title|escapejs }}",
|
||||||
|
"description": "{{ article.meta_description|default:article.excerpt|escapejs }}",
|
||||||
|
"url": "https://shopship.ir/articles/{{ article.slug }}/",
|
||||||
"datePublished": "{{ article.published_at|date:'Y-m-d' }}",
|
"datePublished": "{{ article.published_at|date:'Y-m-d' }}",
|
||||||
"author": { "@type": "Organization", "name": "کاسبیار" }
|
"dateModified": "{{ article.updated_at|date:'Y-m-d' }}",
|
||||||
|
"inLanguage": "fa-IR",
|
||||||
|
"image": "https://shopship.ir/assets/img/og-image.png",
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "niqow.ir",
|
||||||
|
"url": "https://niqow.ir",
|
||||||
|
"logo": { "@type": "ImageObject", "url": "https://shopship.ir/assets/img/icon-512.png" }
|
||||||
|
},
|
||||||
|
"author": { "@type": "Organization", "name": "شاپ شیپ", "url": "https://shopship.ir/" },
|
||||||
|
"mainEntityOfPage": { "@type": "WebPage", "@id": "https://shopship.ir/articles/{{ article.slug }}/" }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "شاپ شیپ", "item": "https://shopship.ir/" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "مقالات", "item": "https://shopship.ir/articles/" },
|
||||||
|
{ "@type": "ListItem", "position": 3, "name": "{{ article.title|escapejs }}", "item": "https://shopship.ir/articles/{{ article.slug }}/" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header class="nav container">
|
<header class="nav-wrap" id="siteHeader">
|
||||||
<a class="nav-brand" href="/">کاسبیار</a>
|
<div class="nav container">
|
||||||
<nav class="nav-links">
|
<a class="nav-brand" href="/">
|
||||||
<a href="/#features">امکانات</a>
|
<img src="/assets/img/icon-192.png" alt="" width="28" height="28" class="nav-brand-icon">
|
||||||
<a href="/articles/">مقالات</a>
|
شاپ شیپ
|
||||||
</nav>
|
</a>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="/#tools">بخشهای اپ</a>
|
||||||
|
<a href="/#pricing">قیمتگذاری</a>
|
||||||
|
<a href="/articles/">مقالات</a>
|
||||||
|
</nav>
|
||||||
|
<a class="btn btn-primary btn-sm" href="/#cta">دریافت اپ</a>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="container" style="padding: var(--space-12) 0;">
|
<main class="container" style="padding: var(--space-12) 0;">
|
||||||
@@ -46,14 +94,39 @@
|
|||||||
<div class="article-body">
|
<div class="article-body">
|
||||||
{{ article.content_html|safe }}
|
{{ article.content_html|safe }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if related %}
|
||||||
|
<div class="related-articles">
|
||||||
|
<h2>مقالات مرتبط</h2>
|
||||||
|
<ul>
|
||||||
|
{% for item in related %}
|
||||||
|
<li><a href="/articles/{{ item.slug }}/">{{ item.title }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<span>© کاسبیار — ابزار ارسال پیام گروهی</span>
|
<div class="footer-brand">
|
||||||
<span><a href="/">صفحهی اصلی</a></span>
|
<strong>شاپ شیپ</strong>
|
||||||
|
<span>محصولی از <a href="https://niqow.ir" target="_blank" rel="noopener">niqow.ir</a></span>
|
||||||
|
</div>
|
||||||
|
<span><a href="/articles/">همهی مقالات</a></span>
|
||||||
|
<span>© شاپ شیپ</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var header = document.getElementById('siteHeader');
|
||||||
|
if (header) {
|
||||||
|
var onScroll = function () {
|
||||||
|
header.classList.toggle('is-scrolled', window.scrollY > 8);
|
||||||
|
};
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,31 +3,69 @@
|
|||||||
<html lang="fa" dir="rtl">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>مقالات — کاسبیار | راهنمای ارسال پیام گروهی</title>
|
<title>مقالات — شاپ شیپ | راهنمای مدیریت کسبوکار کوچک</title>
|
||||||
<meta name="description" content="راهنما و مقالات کاسبیار دربارهی ارسال پیام گروهی با شماره: پیامک، واتساپبیزینس، فیلد سفارشی در قالب پیام، و مرج مخاطبین گوشی.">
|
<meta name="description" content="راهنما و مقالات شاپ شیپ دربارهی مدیریت کسبوکار کوچک: فاکتور رسمی، دفتر درآمد و هزینه و فروش قسطی، انبارداری، پیگیری سفارش، و ارسال پیام گروهی با شماره.">
|
||||||
<link rel="canonical" href="https://mobiniqow.ir/articles/">
|
<meta name="robots" content="index, follow">
|
||||||
<meta name="theme-color" content="#107c41">
|
<meta name="author" content="niqow.ir">
|
||||||
|
<link rel="canonical" href="https://shopship.ir/articles/">
|
||||||
|
<link rel="alternate" hreflang="fa-ir" href="https://shopship.ir/articles/">
|
||||||
|
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/img/favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/img/favicon-16.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/img/apple-touch-icon.png">
|
||||||
|
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="شاپ شیپ">
|
||||||
|
<meta property="og:title" content="مقالات — شاپ شیپ">
|
||||||
|
<meta property="og:description" content="راهنمای مدیریت کسبوکار کوچک: فاکتور، دفتر درآمد و هزینه، انبارداری، پیگیری سفارش و پیام گروهی.">
|
||||||
|
<meta property="og:url" content="https://shopship.ir/articles/">
|
||||||
|
<meta property="og:locale" content="fa_IR">
|
||||||
|
<meta property="og:image" content="https://shopship.ir/assets/img/og-image.png">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="مقالات — شاپ شیپ">
|
||||||
|
<meta name="twitter:image" content="https://shopship.ir/assets/img/og-image.png">
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#2d2a55">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
|
||||||
<link rel="stylesheet" href="/assets/atlas.css">
|
<link rel="stylesheet" href="/assets/atlas.css">
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "CollectionPage",
|
||||||
|
"name": "مقالات شاپ شیپ",
|
||||||
|
"url": "https://shopship.ir/articles/",
|
||||||
|
"inLanguage": "fa-IR",
|
||||||
|
"isPartOf": { "@type": "WebSite", "name": "شاپ شیپ", "url": "https://shopship.ir/" },
|
||||||
|
"publisher": { "@type": "Organization", "name": "niqow.ir", "url": "https://niqow.ir" }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header class="nav container">
|
<header class="nav-wrap" id="siteHeader">
|
||||||
<a class="nav-brand" href="/">کاسبیار</a>
|
<div class="nav container">
|
||||||
<nav class="nav-links">
|
<a class="nav-brand" href="/">
|
||||||
<a href="/#features">امکانات</a>
|
<img src="/assets/img/icon-192.png" alt="" width="28" height="28" class="nav-brand-icon">
|
||||||
<a href="/#channels">کانالها</a>
|
شاپ شیپ
|
||||||
<a href="/articles/">مقالات</a>
|
</a>
|
||||||
</nav>
|
<nav class="nav-links">
|
||||||
|
<a href="/#tools">بخشهای اپ</a>
|
||||||
|
<a href="/#who">برای کیه</a>
|
||||||
|
<a href="/#pricing">قیمتگذاری</a>
|
||||||
|
<a href="/articles/">مقالات</a>
|
||||||
|
</nav>
|
||||||
|
<a class="btn btn-primary btn-sm" href="/#cta">دریافت اپ</a>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="container" style="padding: var(--space-12) 0;">
|
<main class="container" style="padding: var(--space-12) 0;">
|
||||||
<div class="section-head" style="text-align:right; margin-inline:0;">
|
<div class="section-head" style="text-align:right; margin-inline:0;">
|
||||||
<h1>مقالات</h1>
|
<h1>مقالات</h1>
|
||||||
<p>راهنمای ارسال پیام گروهی با شماره — پیامک، واتساپ، فیلد سفارشی و مرج مخاطبین.</p>
|
<p>راهنمای مدیریت کسبوکار کوچک — فاکتور، دفتر درآمد و هزینه، انبارداری، پیگیری سفارش و پیام گروهی با شماره.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="article-list">
|
<div class="article-list">
|
||||||
@@ -37,16 +75,32 @@
|
|||||||
<h2><a href="/articles/{{ article.slug }}/">{{ article.title }}</a></h2>
|
<h2><a href="/articles/{{ article.slug }}/">{{ article.title }}</a></h2>
|
||||||
<p>{{ article.excerpt }}</p>
|
<p>{{ article.excerpt }}</p>
|
||||||
</article>
|
</article>
|
||||||
|
{% empty %}
|
||||||
|
<p>هنوز مقالهای منتشر نشده — بهزودی برمیگردیم.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<span>© کاسبیار — ابزار ارسال پیام گروهی</span>
|
<div class="footer-brand">
|
||||||
|
<strong>شاپ شیپ</strong>
|
||||||
|
<span>محصولی از <a href="https://niqow.ir" target="_blank" rel="noopener">niqow.ir</a></span>
|
||||||
|
</div>
|
||||||
<span><a href="/">صفحهی اصلی</a></span>
|
<span><a href="/">صفحهی اصلی</a></span>
|
||||||
|
<span>© شاپ شیپ</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var header = document.getElementById('siteHeader');
|
||||||
|
if (header) {
|
||||||
|
var onScroll = function () {
|
||||||
|
header.classList.toggle('is-scrolled', window.scrollY > 8);
|
||||||
|
};
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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.shortcuts import get_object_or_404, render
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from .models import Article
|
from .models import Article
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_articles():
|
||||||
|
# published_at یعنی «از این تاریخ به بعد دیده شود» — نه فقط یک برچسبِ
|
||||||
|
# نمایشی. همین باعث میشود بشود چند مقاله را از قبل با is_published=True
|
||||||
|
# و published_at آینده ثبت کرد و هرکدام سرِ تاریخِ خودش خودکار ظاهر شود
|
||||||
|
# (مثلا برای انتشار پلکانیِ هر چند روز یکبار، بدون نیاز به کرون/تسک جدا).
|
||||||
|
return Article.objects.filter(is_published=True, published_at__lte=timezone.localdate())
|
||||||
|
|
||||||
|
|
||||||
def article_list(request):
|
def article_list(request):
|
||||||
articles = Article.objects.filter(is_published=True)
|
articles = _visible_articles()
|
||||||
return render(request, "landing/article_list.html", {"articles": articles})
|
return render(request, "landing/article_list.html", {"articles": articles})
|
||||||
|
|
||||||
|
|
||||||
def article_detail(request, slug):
|
def article_detail(request, slug):
|
||||||
article = get_object_or_404(Article, slug=slug, is_published=True)
|
article = get_object_or_404(_visible_articles(), slug=slug)
|
||||||
return render(request, "landing/article_detail.html", {"article": article})
|
related = _visible_articles().exclude(pk=article.pk)[:3]
|
||||||
|
return render(request, "landing/article_detail.html", {"article": article, "related": related})
|
||||||
|
|||||||
@@ -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
|
# config/settings.py
|
||||||
"""
|
"""
|
||||||
تنظیمات جنگو برای بکاند «پیامکبان».
|
تنظیمات جنگو برای پروژه «شاپشیپ».
|
||||||
همهی مقادیر حساس/محیطی از .env خوانده میشوند (با python-decouple) تا برای
|
همهی مقادیر حساس/محیطی از .env خوانده میشوند.
|
||||||
بالا آوردن روی هر سروری فقط کافی باشد یک فایل .env ساخته شود؛ نیازی به تغییر
|
|
||||||
کد نیست.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -13,24 +12,26 @@ from decouple import Config, Csv, RepositoryEnv
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# مسیر .env را عمداً صریح میدهیم (نه decouple.config پیشفرض که بر اساس
|
# ============================================
|
||||||
# فریم صدازننده حدس میزند) — آن حدسِ مبتنیبر عمقِ استک، بسته به اینکه
|
# بارگذاری فایل .env
|
||||||
# جنگو از کجا اجرا شود (manage.py check در برابر gunicorn در برابر
|
# ============================================
|
||||||
# manage.py shell)، میتواند مسیر اشتباه را پیدا کند و بیصدا از مقادیر
|
|
||||||
# default() استفاده کند — یعنی .env واقعی هیچوقت خوانده نمیشود.
|
|
||||||
_env_path = BASE_DIR / ".env"
|
_env_path = BASE_DIR / ".env"
|
||||||
if not _env_path.exists():
|
if not _env_path.exists():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"فایل .env پیدا نشد ({_env_path}) — یکبار «cp .env.example .env» بزن و مقادیر واقعی را پر کن."
|
f"❌ فایل .env پیدا نشد ({_env_path}) — لطفاً یک فایل .env ایجاد کنید."
|
||||||
)
|
)
|
||||||
config = Config(RepositoryEnv(str(_env_path)))
|
config = Config(RepositoryEnv(str(_env_path)))
|
||||||
|
|
||||||
# -------------------- امنیت و محیط اجرا --------------------
|
# ============================================
|
||||||
SECRET_KEY = config("SECRET_KEY", default="django-insecure-change-me-in-production")
|
# امنیت و محیط اجرا
|
||||||
DEBUG = config("DEBUG", default=False, cast=bool)
|
# ============================================
|
||||||
|
SECRET_KEY = config("SECRET_KEY")
|
||||||
|
DEBUG = False
|
||||||
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=Csv())
|
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=Csv())
|
||||||
|
|
||||||
# -------------------- اپلیکیشنها --------------------
|
# ============================================
|
||||||
|
# اپلیکیشنها
|
||||||
|
# ============================================
|
||||||
DJANGO_APPS = [
|
DJANGO_APPS = [
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
@@ -60,6 +61,9 @@ LOCAL_APPS = [
|
|||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Middleware
|
||||||
|
# ============================================
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
@@ -75,6 +79,9 @@ MIDDLEWARE = [
|
|||||||
ROOT_URLCONF = "config.urls"
|
ROOT_URLCONF = "config.urls"
|
||||||
WSGI_APPLICATION = "config.wsgi.application"
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Templates
|
||||||
|
# ============================================
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
@@ -91,44 +98,43 @@ TEMPLATES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# -------------------- دیتابیس --------------------
|
# ============================================
|
||||||
# پیشفرض SQLite (بدون نیاز به .env برای اجرای محلی)؛ برای پروداکشن/داکر با
|
# Database
|
||||||
# گذاشتن DB_ENGINE=django.db.backends.postgresql در .env به PostgreSQL سوییچ کن.
|
# ============================================
|
||||||
if config("DB_ENGINE", default="django.db.backends.sqlite3") == "django.db.backends.sqlite3":
|
DATABASES = {
|
||||||
DATABASES = {
|
"default": {
|
||||||
"default": {
|
"ENGINE": config("DB_ENGINE", default="django.db.backends.sqlite3"),
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"NAME": config("DB_NAME", default=BASE_DIR / "db.sqlite3"),
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
"USER": config("DB_USER", default=""),
|
||||||
}
|
"PASSWORD": config("DB_PASSWORD", default=""),
|
||||||
}
|
"HOST": config("DB_HOST", default=""),
|
||||||
else:
|
"PORT": config("DB_PORT", default=""),
|
||||||
DATABASES = {
|
|
||||||
"default": {
|
|
||||||
"ENGINE": config("DB_ENGINE"),
|
|
||||||
"NAME": config("DB_NAME"),
|
|
||||||
"USER": config("DB_USER"),
|
|
||||||
"PASSWORD": config("DB_PASSWORD"),
|
|
||||||
"HOST": config("DB_HOST", default="localhost"),
|
|
||||||
"PORT": config("DB_PORT", default="5432"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Auth
|
||||||
|
# ============================================
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||||
]
|
]
|
||||||
|
|
||||||
AUTH_USER_MODEL = "account.User"
|
AUTH_USER_MODEL = "account.User"
|
||||||
|
|
||||||
# -------------------- بینالملل --------------------
|
# ============================================
|
||||||
|
# بینالملل
|
||||||
|
# ============================================
|
||||||
LANGUAGE_CODE = "fa-ir"
|
LANGUAGE_CODE = "fa-ir"
|
||||||
TIME_ZONE = "Asia/Tehran"
|
TIME_ZONE = "Asia/Tehran"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
# -------------------- فایلهای استاتیک --------------------
|
# ============================================
|
||||||
# استاتیک (CSS/JS پنل ادمین و غیره) را WhiteNoise middleware سرو میکند —
|
# فایلهای استاتیک
|
||||||
# چه DEBUG=True چه False، بدون نیاز به تغییری در urls.py.
|
# ============================================
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
@@ -137,34 +143,30 @@ STORAGES = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# -------------------- لندینگپیج --------------------
|
# ============================================
|
||||||
# پوشهی landing/ (HTML/CSS ساده، بدون build step) دقیقا از ریشهی دامنه
|
# Landing Page
|
||||||
# سرو میشود — یعنی landing/index.html روی «/»، landing/articles/index.html
|
# ============================================
|
||||||
# روی «/articles/» و غیره؛ همان WhiteNoise که استاتیک را سرو میکند، این
|
|
||||||
# را هم سرو میکند، بدون نیاز به هیچ view یا route دستی در urls.py.
|
|
||||||
WHITENOISE_ROOT = BASE_DIR / "landing"
|
WHITENOISE_ROOT = BASE_DIR / "landing"
|
||||||
WHITENOISE_INDEX_FILE = True
|
WHITENOISE_INDEX_FILE = True
|
||||||
|
|
||||||
# -------------------- فایلهای رسانه (آپلودی) --------------------
|
# ============================================
|
||||||
# فعلا هیچ مدلی ImageField/FileField ندارد، ولی مسیرش را از الان آماده
|
# فایلهای رسانه
|
||||||
# میکنیم. برخلاف استاتیک، WhiteNoise رسانه را سرو نمیکند — سروش در
|
# ============================================
|
||||||
# urls.py و فقط برای DEBUG=True است (dev)؛ در production باید Nginx/سرور
|
|
||||||
# وب مستقیم از MEDIA_ROOT سرو کند، نه خودِ جنگو.
|
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "/media/"
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# -------------------- CORS --------------------
|
# ============================================
|
||||||
|
# CORS
|
||||||
|
# ============================================
|
||||||
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default="", cast=Csv())
|
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default="", cast=Csv())
|
||||||
CORS_ALLOW_ALL_ORIGINS = config("CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool)
|
CORS_ALLOW_ALL_ORIGINS = config("CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool)
|
||||||
|
|
||||||
# دامنههای مجاز برای پنل ادمین (فرمهای session-cookie/CSRF جنگو، برخلاف
|
|
||||||
# JWT API که این محدودیت را ندارد) — بدون این، پشت HTTPS/دامنهی واقعی، لاگین
|
|
||||||
# ادمین با خطای «CSRF verification failed» رد میشود.
|
|
||||||
CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS", default="", cast=Csv())
|
CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS", default="", cast=Csv())
|
||||||
|
|
||||||
# -------------------- Django REST Framework --------------------
|
# ============================================
|
||||||
|
# Django REST Framework
|
||||||
|
# ============================================
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||||
@@ -182,6 +184,9 @@ REST_FRAMEWORK = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Simple JWT
|
||||||
|
# ============================================
|
||||||
SIMPLE_JWT = {
|
SIMPLE_JWT = {
|
||||||
"ACCESS_TOKEN_LIFETIME": timedelta(days=config("JWT_ACCESS_LIFETIME_DAYS", default=1, cast=int)),
|
"ACCESS_TOKEN_LIFETIME": timedelta(days=config("JWT_ACCESS_LIFETIME_DAYS", default=1, cast=int)),
|
||||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=config("JWT_REFRESH_LIFETIME_DAYS", default=30, cast=int)),
|
"REFRESH_TOKEN_LIFETIME": timedelta(days=config("JWT_REFRESH_LIFETIME_DAYS", default=30, cast=int)),
|
||||||
@@ -191,35 +196,48 @@ SIMPLE_JWT = {
|
|||||||
"USER_ID_CLAIM": "user_id",
|
"USER_ID_CLAIM": "user_id",
|
||||||
}
|
}
|
||||||
|
|
||||||
# -------------------- پیامک (OTP) --------------------
|
# ============================================
|
||||||
# فقط دو مقدار لازم است: نام قالب/پترن پیام و آدرس سرویس ارسال (توکن).
|
# ✅ تنظیمات پیامک (فقط OTP با کاوهنگار)
|
||||||
# اگر بخواهی خودت payload را با سرویسدهندهات هماهنگ کنی، apps/account/send_sms.py را ببین.
|
# ============================================
|
||||||
SMS_PATTERN_NAME = config("SMS_PATTERN_NAME", default="")
|
KAVENEGAR_API_KEY = config("KAVENEGAR_API_KEY", default="")
|
||||||
|
SMS_PATTERN_NAME = config("SMS_PATTERN_NAME", default="shopshipotp")
|
||||||
|
|
||||||
|
# برای سازگاری با کدهای قدیمی (استفاده نمیشود)
|
||||||
SMS_TOKEN_URL = config("SMS_TOKEN_URL", default="")
|
SMS_TOKEN_URL = config("SMS_TOKEN_URL", default="")
|
||||||
|
|
||||||
# -------------------- ارسال گروهی چندکاناله (apps.messaging) --------------------
|
# ============================================
|
||||||
# هرکدام خالی بماند، همان کانال بهجای ارسال خودکار needs_manual برمیگرداند
|
# تنظیمات کانالهای پیامرسان (در صورت نیاز)
|
||||||
# (صف deep-link سمت اپ) — نه خطا، دقیقا مثل رفتار SMS_TOKEN_URL بالا.
|
# ============================================
|
||||||
|
|
||||||
# پیامک بالک با متن آزاد — جدا از SMS_PATTERN_NAME/SMS_TOKEN_URL که فقط OTP است.
|
|
||||||
KAVENEGAR_API_KEY = config("KAVENEGAR_API_KEY", default="")
|
|
||||||
KAVENEGAR_SENDER_LINE = config("KAVENEGAR_SENDER_LINE", default="")
|
KAVENEGAR_SENDER_LINE = config("KAVENEGAR_SENDER_LINE", default="")
|
||||||
|
|
||||||
# WhatsApp Business Cloud API رسمی (Meta) — نیاز به Template تاییدشده دارد.
|
|
||||||
WHATSAPP_ACCESS_TOKEN = config("WHATSAPP_ACCESS_TOKEN", default="")
|
WHATSAPP_ACCESS_TOKEN = config("WHATSAPP_ACCESS_TOKEN", default="")
|
||||||
WHATSAPP_PHONE_NUMBER_ID = config("WHATSAPP_PHONE_NUMBER_ID", default="")
|
WHATSAPP_PHONE_NUMBER_ID = config("WHATSAPP_PHONE_NUMBER_ID", default="")
|
||||||
WHATSAPP_TEMPLATE_NAME = config("WHATSAPP_TEMPLATE_NAME", default="")
|
WHATSAPP_TEMPLATE_NAME = config("WHATSAPP_TEMPLATE_NAME", default="")
|
||||||
|
|
||||||
# Bot API رسمی هرکدام — فقط برای مخاطبینی که قبلا با بات چت را شروع کردهاند.
|
|
||||||
TELEGRAM_BOT_TOKEN = config("TELEGRAM_BOT_TOKEN", default="")
|
TELEGRAM_BOT_TOKEN = config("TELEGRAM_BOT_TOKEN", default="")
|
||||||
BALE_BOT_TOKEN = config("BALE_BOT_TOKEN", default="")
|
BALE_BOT_TOKEN = config("BALE_BOT_TOKEN", default="")
|
||||||
EITAA_BOT_TOKEN = config("EITAA_BOT_TOKEN", default="")
|
EITAA_BOT_TOKEN = config("EITAA_BOT_TOKEN", default="")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# لاگینگ
|
||||||
|
# ============================================
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
"handlers": {
|
"handlers": {
|
||||||
"console": {"class": "logging.StreamHandler"},
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"apps.account": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": "DEBUG",
|
||||||
|
"propagate": True,
|
||||||
|
},
|
||||||
|
"django": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": True,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
/* Atlas — توکنهای دیزاینسیستم (از claude.ai/design، پروژهی
|
/* Atlas — توکنهای دیزاینسیستمِ فعلیِ اپ شاپ شیپ (سرمهای/صورتی/مرجانی/کرم،
|
||||||
"Flutter Design System"، برند accent: کاسبیار — سبز اکسل) برای لندینگ
|
گردیِ زیاد، سایهی نرم) برای لندینگ بازاستفاده شده تا ظاهر وب دقیقا با اپ
|
||||||
بازاستفاده شده تا ظاهر وب با اپ یکی باشد. بدون build step، بدون فریمورک —
|
یکی باشد. بدون build step، بدون فریمورک. فونت Vazirmatn با <link> در
|
||||||
دقیقا روحیهی خودِ سیستم: "Performance یک تصمیم طراحی است».
|
<head> صفحه بارگذاری میشود (نه @import اینجا) — @import رندر CSS را
|
||||||
فونت Vazirmatn با <link> در <head> صفحه بارگذاری میشود (نه @import اینجا)
|
قفل میکند. */
|
||||||
— @import رندر CSS را قفل میکند تا فونت خارجی هم دانلود شود. */
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--color-bg: #f3f2f2;
|
--color-bg: #fdf8f3;
|
||||||
--color-surface: #eae9e9;
|
--color-bg-secondary: #f6efe4;
|
||||||
--color-surface-elevated: #f8f4f4;
|
--color-surface: #ffffff;
|
||||||
--color-text: #201e1d;
|
--color-surface-interactive: #f6efe4;
|
||||||
--color-text-secondary: #605d5d;
|
--color-text: #201e3d;
|
||||||
--color-text-tertiary: #7d7979;
|
--color-text-secondary: #6b6889;
|
||||||
--color-accent: #107c41;
|
--color-text-tertiary: #9591ac;
|
||||||
--color-accent-600: #0e6837;
|
--color-brand: #2d2a55;
|
||||||
--color-accent-700: #185c37;
|
--color-on-brand: #ffffff;
|
||||||
--color-accent-100: #e2efda;
|
--color-pink: #f2467d;
|
||||||
--color-accent-800: #0b3d21;
|
--color-coral: #e8635b;
|
||||||
--color-divider: rgba(32, 30, 29, 0.4);
|
--color-cream: #fbeedd;
|
||||||
--color-divider-subtle: #d7d3d3;
|
--color-peach: #fadec0;
|
||||||
|
--color-lavender: #e3e1f7;
|
||||||
|
--color-blue: #4c6fe0;
|
||||||
|
--color-teal: #2e9e9e;
|
||||||
|
--color-amber: #e0a93b;
|
||||||
|
--color-divider: #e3d9c7;
|
||||||
|
--color-divider-subtle: #eee6d8;
|
||||||
|
--shadow-color: rgba(32, 30, 61, 0.12);
|
||||||
|
|
||||||
--font-heading: 'Vazirmatn', system-ui, sans-serif;
|
--font-heading: 'Vazirmatn', system-ui, sans-serif;
|
||||||
--font-body: 'Vazirmatn', system-ui, sans-serif;
|
--font-body: 'Vazirmatn', system-ui, sans-serif;
|
||||||
@@ -27,50 +33,69 @@
|
|||||||
--space-2: 8px;
|
--space-2: 8px;
|
||||||
--space-3: 12px;
|
--space-3: 12px;
|
||||||
--space-4: 16px;
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
--space-6: 24px;
|
--space-6: 24px;
|
||||||
--space-8: 32px;
|
--space-8: 32px;
|
||||||
--space-12: 48px;
|
--space-12: 48px;
|
||||||
--space-16: 64px;
|
--space-16: 64px;
|
||||||
|
|
||||||
--radius: 0px; /* امضای بصری سیستم: گوشهی صفر همهجا */
|
--radius-sm: 12px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
--radius-lg: 20px;
|
||||||
|
--radius-xl: 28px;
|
||||||
|
--radius-pill: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
--color-bg: #161514;
|
--color-bg: #14132a;
|
||||||
--color-surface: #201e1d;
|
--color-bg-secondary: #1c1a3b;
|
||||||
--color-surface-elevated: #2d2b2b;
|
--color-surface: #262452;
|
||||||
--color-text: #f5f3f2;
|
--color-surface-interactive: #383468;
|
||||||
--color-text-secondary: #bab6b6;
|
--color-text: #f5f3fc;
|
||||||
--color-text-tertiary: #9b9797;
|
--color-text-secondary: #c3bfe2;
|
||||||
--color-accent: #21a366;
|
--color-text-tertiary: #9793bc;
|
||||||
--color-accent-600: #34b378;
|
--color-brand: #b3aeff;
|
||||||
--color-accent-700: #6bcb9a;
|
--color-on-brand: #1b1938;
|
||||||
--color-accent-100: #112a1c;
|
--color-pink: #ff7cae;
|
||||||
--color-accent-800: #c6efce;
|
--color-coral: #ff9789;
|
||||||
--color-divider: #2d2b2b;
|
--color-cream: #423c74;
|
||||||
--color-divider-subtle: #2d2b2b;
|
--color-peach: #4e4780;
|
||||||
|
--color-lavender: #423c74;
|
||||||
|
--color-blue: #7c8cf5;
|
||||||
|
--color-teal: #4fc7c7;
|
||||||
|
--color-amber: #e0a93b;
|
||||||
|
--color-divider: #3a366e;
|
||||||
|
--color-divider-subtle: #2e2b60;
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme="light"]) {
|
:root:not([data-theme="light"]) {
|
||||||
--color-bg: #161514;
|
--color-bg: #14132a;
|
||||||
--color-surface: #201e1d;
|
--color-bg-secondary: #1c1a3b;
|
||||||
--color-surface-elevated: #2d2b2b;
|
--color-surface: #262452;
|
||||||
--color-text: #f5f3f2;
|
--color-surface-interactive: #383468;
|
||||||
--color-text-secondary: #bab6b6;
|
--color-text: #f5f3fc;
|
||||||
--color-text-tertiary: #9b9797;
|
--color-text-secondary: #c3bfe2;
|
||||||
--color-accent: #21a366;
|
--color-text-tertiary: #9793bc;
|
||||||
--color-accent-600: #34b378;
|
--color-brand: #b3aeff;
|
||||||
--color-accent-700: #6bcb9a;
|
--color-on-brand: #1b1938;
|
||||||
--color-accent-100: #112a1c;
|
--color-pink: #ff7cae;
|
||||||
--color-accent-800: #c6efce;
|
--color-coral: #ff9789;
|
||||||
--color-divider: #2d2b2b;
|
--color-cream: #423c74;
|
||||||
--color-divider-subtle: #2d2b2b;
|
--color-peach: #4e4780;
|
||||||
|
--color-lavender: #423c74;
|
||||||
|
--color-blue: #7c8cf5;
|
||||||
|
--color-teal: #4fc7c7;
|
||||||
|
--color-amber: #e0a93b;
|
||||||
|
--color-divider: #3a366e;
|
||||||
|
--color-divider-subtle: #2e2b60;
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html { direction: rtl; }
|
html { direction: rtl; scroll-behavior: smooth; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
@@ -78,122 +103,303 @@ body {
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
h1, h2, h3, h4 {
|
h1, h2, h3, h4 {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.25;
|
line-height: 1.3;
|
||||||
margin: 0 0 var(--space-3);
|
margin: 0 0 var(--space-3);
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
h1 { font-size: 40px; letter-spacing: -0.02em; }
|
h1 { font-size: clamp(25px, 4.6vw + 10px, 42px); letter-spacing: -0.01em; }
|
||||||
h2 { font-size: 28px; }
|
h2 { font-size: clamp(22px, 1.6vw + 16px, 30px); }
|
||||||
h3 { font-size: 20px; }
|
h3 { font-size: 19px; }
|
||||||
p { margin: 0 0 var(--space-4); color: var(--color-text-secondary); }
|
p { margin: 0 0 var(--space-4); color: var(--color-text-secondary); }
|
||||||
a { color: var(--color-accent); text-decoration: none; }
|
a { color: var(--color-brand); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
img { max-width: 100%; display: block; }
|
img, svg { max-width: 100%; display: block; }
|
||||||
.container { max-width: 1080px; margin: 0 auto; padding: 0 var(--space-6); }
|
.container { max-width: 1100px; margin: 0 auto; padding: 0 var(--space-6); }
|
||||||
|
|
||||||
.hairline { border: none; border-top: 2px solid var(--color-divider); margin: 0; }
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* بخشهایی که ناوبریِ چسبان با اسکرول به آنها میپرد، باید زیرِ هدر پنهان
|
||||||
|
نشوند — همان مقدارِ ارتفاعِ هدر بهعنوان فاصلهی امن اسکرول رزرو میشود. */
|
||||||
|
#tools, #who, #how, #pricing, #cta { scroll-margin-top: 84px; }
|
||||||
|
|
||||||
/* — nav — */
|
/* — nav — */
|
||||||
|
.nav-wrap {
|
||||||
|
position: sticky; top: 0; z-index: 40;
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color 0.25s ease, box-shadow 0.25s ease, backdrop-filter 0.25s ease;
|
||||||
|
}
|
||||||
|
.nav-wrap.is-scrolled {
|
||||||
|
background: color-mix(in srgb, var(--color-bg) 88%, transparent);
|
||||||
|
backdrop-filter: blur(12px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(12px) saturate(1.2);
|
||||||
|
box-shadow: 0 4px 24px var(--shadow-color);
|
||||||
|
}
|
||||||
.nav {
|
.nav {
|
||||||
display: flex; align-items: center; gap: var(--space-6);
|
display: flex; align-items: center; gap: var(--space-6);
|
||||||
padding: var(--space-4) 0; border-bottom: 2px solid var(--color-divider);
|
padding: var(--space-4) 0;
|
||||||
}
|
}
|
||||||
.nav-brand { font-family: var(--font-heading); font-weight: 800; font-size: 19px; color: var(--color-text); }
|
.nav-brand { display: inline-flex; align-items: center; gap: var(--space-2); font-family: var(--font-heading); font-weight: 800; font-size: 20px; color: var(--color-brand); }
|
||||||
.nav-links { display: flex; gap: var(--space-6); margin-inline-start: auto; font-size: 14px; flex-wrap: wrap; }
|
.nav-brand:hover { text-decoration: none; }
|
||||||
.nav-links a { color: var(--color-text); }
|
.nav-brand-icon { border-radius: 8px; }
|
||||||
.nav-links a:hover { color: var(--color-accent); }
|
.nav-links { display: flex; gap: var(--space-6); margin-inline-start: auto; font-size: 14px; font-weight: 500; flex-wrap: wrap; }
|
||||||
|
.nav-links a { color: var(--color-text); position: relative; }
|
||||||
|
.nav-links a:hover { color: var(--color-brand); text-decoration: none; }
|
||||||
|
|
||||||
/* — buttons — */
|
/* — buttons — */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
font-family: var(--font-heading); font-weight: 700; font-size: 15px;
|
font-family: var(--font-heading); font-weight: 700; font-size: 15px;
|
||||||
padding: 12px 22px; border: 2px solid transparent; cursor: pointer;
|
padding: 14px 26px; border: 2px solid transparent; cursor: pointer;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-pill);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||||
}
|
}
|
||||||
.btn-primary { background: var(--color-accent); color: #f3f2f2; }
|
.btn:hover { text-decoration: none; }
|
||||||
.btn-primary:hover { background: var(--color-accent-600); text-decoration: none; }
|
.btn:active { transform: scale(0.97); }
|
||||||
.btn-secondary { border-color: var(--color-divider); color: var(--color-text); }
|
.btn-sm { padding: 10px 20px; font-size: 14px; }
|
||||||
.btn-secondary:hover { background: var(--color-surface); text-decoration: none; }
|
.btn-primary {
|
||||||
|
background: var(--color-brand); color: var(--color-on-brand);
|
||||||
|
box-shadow: 0 8px 24px var(--shadow-color);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { box-shadow: 0 10px 28px var(--shadow-color); transform: translateY(-1px); }
|
||||||
|
.btn-secondary { border-color: var(--color-divider); color: var(--color-text); background: var(--color-surface); }
|
||||||
|
.btn-secondary:hover { background: var(--color-surface-interactive); transform: translateY(-1px); }
|
||||||
|
|
||||||
/* — hero — */
|
/* — hero — */
|
||||||
.hero { padding: var(--space-16) 0 var(--space-12); text-align: center; }
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--space-16) 0 var(--space-12);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
gap: var(--space-12);
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero-copy { position: relative; z-index: 1; text-align: start; }
|
||||||
.hero .kicker {
|
.hero .kicker {
|
||||||
display: inline-block; font-size: 12px; font-weight: 700; letter-spacing: 0.04em;
|
display: inline-block; font-size: 12px; font-weight: 700; letter-spacing: 0.04em;
|
||||||
color: var(--color-accent); background: var(--color-accent-100);
|
color: var(--color-brand); background: var(--color-lavender);
|
||||||
padding: 4px 12px; margin-bottom: var(--space-4);
|
padding: 6px 16px; border-radius: var(--radius-pill); margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
.hero h1 { max-width: 18ch; margin-inline: auto; }
|
.hero h1 { max-width: 16ch; }
|
||||||
.hero p { max-width: 56ch; margin-inline: auto; font-size: 18px; }
|
.hero-copy p { max-width: 48ch; font-size: 18px; }
|
||||||
.hero-actions { display: flex; gap: var(--space-3); justify-content: center; margin-top: var(--space-6); flex-wrap: wrap; }
|
.hero-actions { display: flex; gap: var(--space-3); margin-top: var(--space-6); flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* بلابهای تزئینیِ پشتِ هیرو — همان زبانِ بصریِ AtlasBlobHeader توی اپ */
|
||||||
|
.hero-blobs { position: absolute; inset: -10% -10% auto auto; width: 60%; height: 140%; z-index: 0; pointer-events: none; }
|
||||||
|
.blob { position: absolute; border-radius: 50%; filter: blur(2px); opacity: 0.9; }
|
||||||
|
|
||||||
|
/* موکاپِ گوشی — نمایِ سادهشده و انتزاعیِ صفحهی اول اپ، بدون دیتای واقعیِ
|
||||||
|
هیچ کاربری؛ فقط برای نشاندادنِ زبانِ بصری (کاشیهای رنگی، کارتِ سرمهای). */
|
||||||
|
.phone-mock {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: 260px; margin: 0 auto;
|
||||||
|
background: var(--color-text);
|
||||||
|
border-radius: 40px;
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: 0 30px 60px var(--shadow-color);
|
||||||
|
}
|
||||||
|
.phone-screen {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: var(--space-4);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.phone-hero-card {
|
||||||
|
background: var(--color-brand);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4);
|
||||||
|
color: var(--color-on-brand);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.phone-hero-card .line { height: 10px; background: rgba(255,255,255,0.85); border-radius: var(--radius-pill); width: 60%; margin-bottom: 8px; }
|
||||||
|
.phone-hero-card .line.short { width: 35%; background: rgba(255,255,255,0.5); }
|
||||||
|
.phone-stats {
|
||||||
|
background: var(--color-surface); border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-3); display: flex; justify-content: space-around;
|
||||||
|
margin-bottom: var(--space-3); box-shadow: 0 8px 20px var(--shadow-color);
|
||||||
|
}
|
||||||
|
.phone-stats .dot { width: 28px; height: 28px; border-radius: var(--radius-sm); margin: 0 auto 4px; }
|
||||||
|
.phone-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
|
.phone-tile { aspect-ratio: 1; border-radius: var(--radius-md); }
|
||||||
|
|
||||||
/* — sections — */
|
/* — sections — */
|
||||||
section { padding: var(--space-12) 0; }
|
section { padding: var(--space-12) 0; position: relative; }
|
||||||
section.alt { background: var(--color-surface); }
|
section.alt { background: var(--color-bg-secondary); }
|
||||||
.section-head { text-align: center; max-width: 60ch; margin: 0 auto var(--space-8); }
|
.section-head { text-align: center; max-width: 60ch; margin: 0 auto var(--space-8); }
|
||||||
|
.section-head .kicker { display: inline-block; font-size: 12px; font-weight: 700; color: var(--color-pink); margin-bottom: var(--space-2); }
|
||||||
|
|
||||||
/* — grid / cards — */
|
/* — grid / cards — */
|
||||||
.grid { display: grid; gap: 2px; background: var(--color-divider-subtle); }
|
.grid { display: grid; gap: var(--space-4); }
|
||||||
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
.grid-4 { grid-template-columns: repeat(4, 1fr); }
|
.grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
.grid-5 { grid-template-columns: repeat(5, 1fr); }
|
.grid-5 { grid-template-columns: repeat(5, 1fr); }
|
||||||
.card { background: var(--color-bg); padding: var(--space-6); }
|
.card {
|
||||||
.card .icon { font-size: 28px; margin-bottom: var(--space-3); }
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-divider-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-6);
|
||||||
|
box-shadow: 0 12px 28px var(--shadow-color);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 18px 36px var(--shadow-color);
|
||||||
|
}
|
||||||
|
.card .icon-tile {
|
||||||
|
width: 52px; height: 52px; border-radius: var(--radius-md);
|
||||||
|
display: grid; place-items: center; font-size: 24px;
|
||||||
|
margin-bottom: var(--space-4); color: #fff;
|
||||||
|
}
|
||||||
.card h3 { font-size: 17px; margin-bottom: var(--space-2); }
|
.card h3 { font-size: 17px; margin-bottom: var(--space-2); }
|
||||||
.card p { font-size: 14px; margin: 0; }
|
.card p { font-size: 14px; margin: 0; }
|
||||||
|
|
||||||
/* — channels — */
|
/* چرخهی رنگِ کاشی — دقیقا همراستا با atlasTileAccents توی اپ */
|
||||||
.channels { display: flex; flex-wrap: wrap; gap: var(--space-3); justify-content: center; }
|
.accent-0 { background: var(--color-pink); }
|
||||||
.channel-tag {
|
.accent-1 { background: var(--color-blue); }
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
.accent-2 { background: var(--color-coral); }
|
||||||
padding: 8px 16px; border: 2px solid var(--color-divider); font-size: 14px; font-weight: 500;
|
.accent-3 { background: var(--color-teal); }
|
||||||
}
|
.accent-4 { background: var(--color-brand); }
|
||||||
|
.accent-5 { background: var(--color-amber); }
|
||||||
|
|
||||||
|
/* — stats — */
|
||||||
|
.stats-band { background: var(--color-brand); color: var(--color-on-brand); }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-4); text-align: center; }
|
||||||
|
.stat-num { font-family: var(--font-heading); font-weight: 800; font-size: 32px; }
|
||||||
|
.stat-label { font-size: 13px; opacity: 0.8; }
|
||||||
|
|
||||||
/* — steps — */
|
/* — steps — */
|
||||||
.steps { counter-reset: step; display: flex; flex-direction: column; gap: var(--space-4); max-width: 640px; margin: 0 auto; }
|
.steps { display: flex; flex-direction: column; gap: var(--space-4); max-width: 640px; margin: 0 auto; }
|
||||||
.step { display: flex; gap: var(--space-4); align-items: flex-start; }
|
.step { display: flex; gap: var(--space-4); align-items: flex-start; background: var(--color-surface); border: 1px solid var(--color-divider-subtle); border-radius: var(--radius-lg); padding: var(--space-5); box-shadow: 0 8px 20px var(--shadow-color); transition: transform 0.2s ease, box-shadow 0.2s ease; }
|
||||||
.step::before {
|
.step:hover { transform: translateY(-3px); box-shadow: 0 14px 28px var(--shadow-color); }
|
||||||
counter-increment: step; content: counter(step);
|
.step-num {
|
||||||
flex: none; width: 36px; height: 36px; display: grid; place-items: center;
|
flex: none; width: 40px; height: 40px; display: grid; place-items: center;
|
||||||
background: var(--color-accent); color: #f3f2f2; font-weight: 800; font-family: var(--font-heading);
|
background: var(--color-pink); color: #fff; font-weight: 800; font-family: var(--font-heading);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
}
|
}
|
||||||
.step h3 { margin-bottom: 4px; font-size: 16px; }
|
.step h3 { margin-bottom: 4px; font-size: 16px; }
|
||||||
.step p { margin: 0; font-size: 14px; }
|
.step p { margin: 0; font-size: 14px; }
|
||||||
|
|
||||||
|
/* — trust row (بهجای تستیمونیال ساختگی) — */
|
||||||
|
.trust-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); }
|
||||||
|
.trust-item { text-align: center; padding: var(--space-4); border-radius: var(--radius-lg); transition: transform 0.2s ease, background-color 0.2s ease; }
|
||||||
|
.trust-item:hover { transform: translateY(-3px); background: var(--color-surface); }
|
||||||
|
.trust-item .icon-tile { margin-inline: auto; }
|
||||||
|
|
||||||
|
/* — pricing explainer (بدون رقمِ ساختگی، چون قیمتِ واقعی جممحور و پویاست) — */
|
||||||
|
.pricing-card {
|
||||||
|
background: var(--color-surface); border: 1px solid var(--color-divider-subtle);
|
||||||
|
border-radius: var(--radius-xl); padding: var(--space-8); text-align: center;
|
||||||
|
box-shadow: 0 16px 36px var(--shadow-color);
|
||||||
|
max-width: 560px; margin: 0 auto;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.pricing-card:hover { transform: translateY(-3px); box-shadow: 0 22px 44px var(--shadow-color); }
|
||||||
|
.pricing-card .badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; background: var(--color-cream);
|
||||||
|
color: var(--color-coral); padding: 6px 16px; border-radius: var(--radius-pill);
|
||||||
|
font-weight: 700; font-size: 13px; margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
/* — CTA band — */
|
/* — CTA band — */
|
||||||
.cta-band { background: var(--color-text); color: var(--color-bg); text-align: center; }
|
.cta-band { background: var(--color-brand); color: var(--color-on-brand); text-align: center; border-radius: var(--radius-xl); margin: 0 var(--space-6); padding: var(--space-12) var(--space-6); }
|
||||||
.cta-band h2 { color: var(--color-bg); }
|
.cta-band h2 { color: var(--color-on-brand); }
|
||||||
.cta-band p { color: rgba(243, 242, 242, 0.75); }
|
.cta-band p { color: rgba(255,255,255,0.8); max-width: 50ch; margin-inline: auto; }
|
||||||
|
.cta-band .btn-secondary { border-color: rgba(255,255,255,0.35); color: #fff; background: transparent; }
|
||||||
|
.cta-band .btn-secondary:hover { background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
/* — footer — */
|
/* — footer — */
|
||||||
footer { border-top: 2px solid var(--color-divider); padding: var(--space-8) 0; }
|
footer { padding: var(--space-8) 0; }
|
||||||
footer .container { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-4); font-size: 13px; color: var(--color-text-tertiary); }
|
footer .container { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-4); font-size: 13px; color: var(--color-text-tertiary); }
|
||||||
footer a { color: var(--color-text-secondary); }
|
footer a { color: var(--color-text-secondary); }
|
||||||
|
footer .footer-brand { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
footer .footer-brand strong { color: var(--color-text-secondary); font-weight: 600; }
|
||||||
|
|
||||||
/* — articles — */
|
/* — articles — */
|
||||||
.article-list { display: flex; flex-direction: column; gap: 2px; background: var(--color-divider-subtle); }
|
.article-list { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||||
.article-row { background: var(--color-bg); padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-2); }
|
.article-row { background: var(--color-surface); border: 1px solid var(--color-divider-subtle); border-radius: var(--radius-lg); padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-2); box-shadow: 0 8px 20px var(--shadow-color); transition: transform 0.2s ease, box-shadow 0.2s ease; }
|
||||||
|
.article-row:hover { transform: translateY(-3px); box-shadow: 0 14px 28px var(--shadow-color); }
|
||||||
.article-row .meta { font-size: 12px; color: var(--color-text-tertiary); }
|
.article-row .meta { font-size: 12px; color: var(--color-text-tertiary); }
|
||||||
.article-row h2 { font-size: 20px; margin-bottom: 0; }
|
.article-row h2 { font-size: 20px; margin-bottom: 0; }
|
||||||
.article-row h2 a { color: var(--color-text); }
|
.article-row h2 a { color: var(--color-text); }
|
||||||
|
.article-row h2 a:hover { color: var(--color-brand); text-decoration: none; }
|
||||||
.article-body { max-width: 68ch; margin: 0 auto; font-size: 16px; }
|
.article-body { max-width: 68ch; margin: 0 auto; font-size: 16px; }
|
||||||
.article-body h2 { font-size: 22px; margin-top: var(--space-8); }
|
.article-body h2 { font-size: 22px; margin-top: var(--space-8); }
|
||||||
|
.article-body strong { color: var(--color-text); }
|
||||||
.article-header { max-width: 68ch; margin: 0 auto var(--space-8); }
|
.article-header { max-width: 68ch; margin: 0 auto var(--space-8); }
|
||||||
.article-header .meta { font-size: 13px; color: var(--color-text-tertiary); margin-bottom: var(--space-3); }
|
.article-header .meta { font-size: 13px; color: var(--color-text-tertiary); margin-bottom: var(--space-3); }
|
||||||
.back-link { display: inline-block; margin-bottom: var(--space-6); font-size: 14px; }
|
.back-link { display: inline-block; margin-bottom: var(--space-6); font-size: 14px; }
|
||||||
|
.related-articles { max-width: 68ch; margin: var(--space-12) auto 0; padding-top: var(--space-8); border-top: 1px solid var(--color-divider-subtle); }
|
||||||
|
.related-articles h2 { font-size: 18px; }
|
||||||
|
.related-articles ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
|
||||||
|
.related-articles a { font-weight: 600; }
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
/* — ورودِ تدریجیِ بخشها هنگامِ اسکرول — با جاوااسکریپتِ صفحه کلاسِ reveal
|
||||||
|
اضافه و بعد از دیدهشدن reveal-visible را ست میکند؛ اگر جاوااسکریپت
|
||||||
|
غیرفعال باشد یا کاربر حرکتِ کمتر خواسته باشد، محتوا همیشه دیده میماند. */
|
||||||
|
.reveal { opacity: 0; transform: translateY(16px); transition: opacity 0.5s ease, transform 0.5s ease; }
|
||||||
|
.reveal-visible { opacity: 1; transform: none; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal { opacity: 1; transform: none; transition: none; }
|
||||||
|
.card, .step, .trust-item, .pricing-card, .btn, .article-row { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero { grid-template-columns: 1fr; text-align: center; padding-top: var(--space-12); gap: var(--space-8); }
|
||||||
|
.hero-copy { text-align: center; }
|
||||||
|
.hero h1, .hero-copy p { margin-inline: auto; }
|
||||||
|
.hero-blobs { display: none; }
|
||||||
|
.phone-mock { order: -1; }
|
||||||
|
.grid-3, .grid-4, .grid-5 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: var(--space-6); }
|
||||||
|
.trust-grid { grid-template-columns: 1fr; max-width: 420px; margin-inline: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* بین تبلت و موبایل — فاصلههای درشتِ دسکتاپ روی این سایز زیادی باز بهنظر
|
||||||
|
میرسند؛ همهجا یک پله جمعتر میشوند، نه فقط شکستنِ گرید. */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
section { padding: 40px 0; }
|
||||||
|
.hero { padding: 32px 0 40px; }
|
||||||
|
.section-head { margin-bottom: var(--space-6); }
|
||||||
|
.card { padding: var(--space-5); }
|
||||||
|
.card .icon-tile { width: 46px; height: 46px; font-size: 21px; margin-bottom: var(--space-3); }
|
||||||
|
.phone-mock { width: min(240px, 62vw); }
|
||||||
|
.steps { gap: var(--space-3); }
|
||||||
|
.step { padding: var(--space-4); gap: var(--space-3); }
|
||||||
|
.pricing-card { padding: var(--space-6); }
|
||||||
|
.cta-band { padding: var(--space-8) var(--space-6); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
.grid-3, .grid-4, .grid-5 { grid-template-columns: 1fr; }
|
.grid-3, .grid-4, .grid-5 { grid-template-columns: 1fr; }
|
||||||
.hero h1 { font-size: 30px; }
|
.hero-copy p { font-size: 16px; }
|
||||||
footer .container { flex-direction: column; }
|
.kicker { font-size: 11px; }
|
||||||
|
footer .container { flex-direction: column; text-align: center; }
|
||||||
|
footer .footer-brand { align-items: center; }
|
||||||
.nav { flex-wrap: wrap; row-gap: var(--space-3); }
|
.nav { flex-wrap: wrap; row-gap: var(--space-3); }
|
||||||
.nav-links { order: 3; width: 100%; gap: var(--space-4); }
|
.nav-links { order: 3; width: 100%; gap: var(--space-4); justify-content: center; }
|
||||||
|
.cta-band { margin: 0 var(--space-4); padding: var(--space-8) var(--space-4); }
|
||||||
|
.hero-actions { gap: var(--space-2); }
|
||||||
|
.hero-actions .btn { flex: 1 1 200px; }
|
||||||
|
.stats-grid { gap: var(--space-4); }
|
||||||
|
.stat-num { font-size: 26px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 400px) {
|
||||||
.container { padding: 0 var(--space-4); }
|
.container { padding: 0 var(--space-4); }
|
||||||
.hero h1 { font-size: 26px; }
|
.hero-copy p { font-size: 15px; }
|
||||||
.hero p { font-size: 16px; }
|
.btn { padding: 12px 20px; font-size: 14px; }
|
||||||
|
.hero-actions .btn, .cta-band .hero-actions .btn { flex: 1 1 100%; }
|
||||||
|
.phone-mock { width: min(210px, 58vw); padding: 10px; border-radius: 32px; }
|
||||||
|
.phone-screen { border-radius: 22px; padding: var(--space-3); }
|
||||||
|
.pricing-card { padding: var(--space-5); }
|
||||||
|
.step { flex-direction: column; align-items: flex-start; }
|
||||||
}
|
}
|
||||||
|
|||||||
|
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">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>کاسبیار — اپ مدیریت کسبوکارهای کوچک (فاکتور، پیامک گروهی، مشتریان، انبار، نوبتدهی)</title>
|
<title>شاپ شیپ — اپ مدیریت کسبوکارهای کوچک | فاکتور، پیامک گروهی، انبار، دفتر درآمد و هزینه</title>
|
||||||
<meta name="description" content="کاسبیار یک اپ برای ادارهی کسبوکار کوچک است: فاکتور رسمی، پیام گروهی با شماره (پیامک/واتساپ/تلگرام/بله/ایتا)، مشتریان، دفتر درآمد و هزینه، انبارداری و نوبتدهی — همه در یک اپ ساده. یک هفته کاملا رایگان.">
|
<meta name="description" content="شاپ شیپ اپ مدیریت کسبوکار کوچک است: صدور فاکتور رسمی، ارسال پیامک/واتساپ/تلگرام گروهی، مدیریت مشتریان، دفتر درآمد و هزینهی حرفهای با تفکیک فروش قسطی، انبارداری و پیگیری سفارش آنلاینشاپ — همه در یک اپ ساده با تقویم شمسی. یک هفته کاملا رایگان.">
|
||||||
<meta name="keywords" content="اپ مدیریت کسب و کار, پیام گروهی با شماره, نرم افزار فاکتور, نرم افزار انبارداری, نرم افزار نوبت دهی, دفتر درآمد و هزینه, نرم افزار مدیریت مشتریان">
|
<meta name="keywords" content="شاپ شیپ, shopship, نرم افزار مدیریت کسب و کار, اپ مدیریت کسب و کار, نرم افزار فروشگاهی, نرم افزار حسابداری کوچک, نرم افزار صدور فاکتور, فاکتور رسمی با لوگو, ارسال پیامک انبوه, پیامک تبلیغاتی گروهی, ارسال واتساپ گروهی, نرم افزار مدیریت مشتریان, دفتر درآمد و هزینه, نرم افزار حسابداری فروش قسطی, پیگیری بدهی مشتری, نرم افزار انبارداری, کنترل موجودی انبار, پیگیری سفارش آنلاین شاپ, نرم افزار مغازه, اپ کسب و کار خرد, حسابداری فروشگاه با موبایل">
|
||||||
<link rel="canonical" href="https://mobiniqow.ir/">
|
<meta name="robots" content="index, follow">
|
||||||
|
<meta name="author" content="niqow.ir">
|
||||||
|
<link rel="canonical" href="https://shopship.ir/">
|
||||||
|
<link rel="alternate" hreflang="fa-ir" href="https://shopship.ir/">
|
||||||
|
<link rel="alternate" hreflang="x-default" href="https://shopship.ir/">
|
||||||
|
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/img/favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="assets/img/favicon-16.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="assets/img/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="assets/img/apple-touch-icon.png">
|
||||||
|
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:title" content="کاسبیار — اپ مدیریت کسبوکارهای کوچک">
|
<meta property="og:site_name" content="شاپ شیپ">
|
||||||
<meta property="og:description" content="فاکتور، پیام گروهی، مشتریان، دفتر درآمد و هزینه، انبارداری و نوبتدهی — همه در یک اپ. یک هفته کاملا رایگان.">
|
<meta property="og:title" content="شاپ شیپ — اپ مدیریت کسبوکارهای کوچک">
|
||||||
<meta property="og:url" content="https://mobiniqow.ir/">
|
<meta property="og:description" content="فاکتور، پیام گروهی، مشتریان، دفتر درآمد و هزینهی حرفهای، انبارداری و پیگیری سفارش — همه در یک اپ. یک هفته کاملا رایگان.">
|
||||||
|
<meta property="og:url" content="https://shopship.ir/">
|
||||||
<meta property="og:locale" content="fa_IR">
|
<meta property="og:locale" content="fa_IR">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta property="og:image" content="https://shopship.ir/assets/img/og-image.png">
|
||||||
<meta name="theme-color" content="#107c41">
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="شاپ شیپ — اپ مدیریت کسبوکارهای کوچک">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="شاپ شیپ — اپ مدیریت کسبوکارهای کوچک">
|
||||||
|
<meta name="twitter:description" content="فاکتور، پیام گروهی، مشتریان، دفتر درآمد و هزینه، انبارداری و پیگیری سفارش — همه در یک اپ.">
|
||||||
|
<meta name="twitter:image" content="https://shopship.ir/assets/img/og-image.png">
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#2d2a55">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="شاپ شیپ">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;800&display=swap">
|
||||||
@@ -22,100 +47,124 @@
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "SoftwareApplication",
|
"@type": "SoftwareApplication",
|
||||||
"name": "کاسبیار",
|
"name": "شاپ شیپ",
|
||||||
|
"alternateName": "ShopShip",
|
||||||
"applicationCategory": "BusinessApplication",
|
"applicationCategory": "BusinessApplication",
|
||||||
"operatingSystem": "Android, iOS",
|
"applicationSubCategory": "Invoicing, CRM, Inventory, Messaging",
|
||||||
"description": "اپ مدیریت کسبوکارهای کوچک: فاکتور، پیام گروهی با شماره، مشتریان، دفتر درآمد و هزینه، انبارداری و نوبتدهی.",
|
"operatingSystem": "Android",
|
||||||
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "IRR", "description": "یک هفته اول رایگان" }
|
"inLanguage": "fa-IR",
|
||||||
|
"url": "https://shopship.ir/",
|
||||||
|
"installUrl": "https://cafebazaar.ir/app/ir.niqow.kangelo",
|
||||||
|
"description": "اپ مدیریت کسبوکارهای کوچک: فاکتور، پیام گروهی با شماره، مشتریان، دفتر درآمد و هزینه، انبارداری و پیگیری سفارش.",
|
||||||
|
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "IRR", "description": "یک هفته اول رایگان" },
|
||||||
|
"publisher": { "@type": "Organization", "name": "niqow.ir", "url": "https://niqow.ir" },
|
||||||
|
"author": { "@type": "Organization", "name": "niqow.ir", "url": "https://niqow.ir" }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header class="nav container">
|
<header class="nav-wrap" id="siteHeader">
|
||||||
<span class="nav-brand">کاسبیار</span>
|
<div class="nav container">
|
||||||
<nav class="nav-links">
|
<a class="nav-brand" href="#top">
|
||||||
<a href="#tools">بخشهای اپ</a>
|
<img src="assets/img/icon-192.png" alt="" width="28" height="28" class="nav-brand-icon">
|
||||||
<a href="#who">برای کیه</a>
|
شاپ شیپ
|
||||||
<a href="#how">نحوهی کار</a>
|
</a>
|
||||||
<a href="/articles/">مقالات</a>
|
<nav class="nav-links">
|
||||||
</nav>
|
<a href="#tools">بخشهای اپ</a>
|
||||||
<a class="btn btn-primary" href="#cta">دریافت اپ</a>
|
<a href="#who">برای کیه</a>
|
||||||
|
<a href="#how">نحوهی کار</a>
|
||||||
|
<a href="#pricing">قیمتگذاری</a>
|
||||||
|
<a href="/articles/">مقالات</a>
|
||||||
|
</nav>
|
||||||
|
<a class="btn btn-primary btn-sm" href="#cta">دریافت اپ</a>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section class="hero container">
|
<section class="hero container" id="top">
|
||||||
<span class="kicker">اپ مدیریت کسبوکار کوچک</span>
|
<div class="hero-blobs" aria-hidden="true">
|
||||||
<h1>هرچی برای ادارهی کسبوکارت لازمه، توی یک اپ</h1>
|
<svg viewBox="0 0 400 500" width="100%" height="100%">
|
||||||
<p>
|
<circle cx="330" cy="70" r="120" fill="var(--color-pink)" opacity="0.55"/>
|
||||||
کاسبیار فاکتور رسمی صادر میکند، پیام گروهی با شماره میفرستد
|
<circle cx="260" cy="40" r="60" fill="var(--color-coral)" opacity="0.5"/>
|
||||||
(پیامک، واتساپ، تلگرام، بله، ایتا)، حساب مشتریها و دخلوخرجت را نگه
|
<circle cx="360" cy="260" r="90" fill="var(--color-lavender)" opacity="0.6"/>
|
||||||
میدارد، انبارت را زیر نظر میگیرد و نوبتدهیات را مدیریت میکند —
|
</svg>
|
||||||
بدون اینکه کاسبی که تازهکار با موبایل است گیج شود.
|
</div>
|
||||||
</p>
|
<div class="hero-copy">
|
||||||
<div class="hero-actions">
|
<span class="kicker">اپ مدیریت کسبوکار کوچک</span>
|
||||||
<a class="btn btn-primary" href="#cta">یک هفته رایگان امتحان کن</a>
|
<h1>هرچی برای ادارهی کسبوکارت لازمه، توی یک اپ</h1>
|
||||||
<a class="btn btn-secondary" href="#tools">ببین چی داره</a>
|
<p>
|
||||||
|
شاپ شیپ فاکتور رسمی صادر میکند، پیام گروهی با شماره میفرستد
|
||||||
|
(پیامک، واتساپ، تلگرام، بله، ایتا)، حساب مشتریها و دخلوخرجت را نگه
|
||||||
|
میدارد، انبارت را زیر نظر میگیرد و سفارشهایت را پیگیری میکند —
|
||||||
|
بدون اینکه کاسبی که تازهکار با موبایل است گیج شود.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="btn btn-primary" href="#cta">یک هفته رایگان امتحان کن</a>
|
||||||
|
<a class="btn btn-secondary" href="#tools">ببین چی داره</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="phone-mock" aria-hidden="true">
|
||||||
|
<div class="phone-screen">
|
||||||
|
<div class="phone-hero-card">
|
||||||
|
<div class="line"></div>
|
||||||
|
<div class="line short"></div>
|
||||||
|
</div>
|
||||||
|
<div class="phone-stats">
|
||||||
|
<div><div class="dot accent-0"></div></div>
|
||||||
|
<div><div class="dot accent-1"></div></div>
|
||||||
|
<div><div class="dot accent-2"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="phone-tiles">
|
||||||
|
<div class="phone-tile accent-0"></div>
|
||||||
|
<div class="phone-tile accent-1"></div>
|
||||||
|
<div class="phone-tile accent-2"></div>
|
||||||
|
<div class="phone-tile accent-3"></div>
|
||||||
|
<div class="phone-tile accent-4"></div>
|
||||||
|
<div class="phone-tile accent-5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<hr class="hairline">
|
<section id="tools">
|
||||||
|
<div class="container">
|
||||||
<section id="tools" class="container">
|
<div class="section-head">
|
||||||
<div class="section-head">
|
<span class="kicker">بخشهای شاپ شیپ</span>
|
||||||
<h2>بخشهای کاسبیار</h2>
|
<h2>هرکدام جدا فعال میشود</h2>
|
||||||
<p>هرکدام جدا فعال میشود، پس فقط برای چیزی که لازم داری هزینه میکنی.</p>
|
<p>فقط برای چیزی که کسبوکارت لازم دارد هزینه میکنی — نه یک بستهی سنگین که نصفش را استفاده نمیکنی.</p>
|
||||||
</div>
|
|
||||||
<div class="grid grid-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="icon">🧾</div>
|
|
||||||
<h3>فاکتور</h3>
|
|
||||||
<p>فاکتور رسمی با لوگو، چند نوع مالیات، تخفیف روی هر قلم کالا، و پیگیری پرداختشده/نشده — کاملا محلی روی گوشی.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="grid grid-3">
|
||||||
<div class="icon">📨</div>
|
<div class="card">
|
||||||
<h3>پیام گروهی</h3>
|
<div class="icon-tile accent-1">🧾</div>
|
||||||
<p>یک قالب بنویس، به مخاطبینت پیامک/واتساپ/تلگرام/بله/ایتا بفرست — از مسیر رسمی هر پلتفرم، بدون ترفندهای دور زدن قانون.</p>
|
<h3>فاکتور</h3>
|
||||||
</div>
|
<p>فاکتور رسمی با لوگو، چند نوع مالیات، تخفیف روی هر قلم کالا، اتصال به انبار، و پیگیری پرداختشده/نشده.</p>
|
||||||
<div class="card">
|
</div>
|
||||||
<div class="icon">👥</div>
|
<div class="card">
|
||||||
<h3>مشتریان</h3>
|
<div class="icon-tile accent-3">📨</div>
|
||||||
<p>برای هر مشتری تاریخچهی خریدش را ببین — خودش از فاکتورهایی که برایش صادر کردی ساخته میشود.</p>
|
<h3>پیام گروهی</h3>
|
||||||
</div>
|
<p>یک قالب بنویس، به مخاطبینت پیامک/واتساپ/تلگرام/بله/ایتا بفرست — از مسیر رسمی هر پلتفرم، بدون ترفندهای دور زدن قانون.</p>
|
||||||
<div class="card">
|
</div>
|
||||||
<div class="icon">💰</div>
|
<div class="card">
|
||||||
<h3>دفتر درآمد و هزینه</h3>
|
<div class="icon-tile accent-0">👥</div>
|
||||||
<p>هرچه وارد و خارج میشود را ثبت کن، نمودار هفتگیاش را ببین، بر اساس درآمد یا هزینه فیلتر کن.</p>
|
<h3>مشتریان</h3>
|
||||||
</div>
|
<p>برای هر مشتری تاریخچهی خریدش و ماندهی بدهیاش را ببین — خودش از فاکتورها و دفترت ساخته میشود.</p>
|
||||||
<div class="card">
|
</div>
|
||||||
<div class="icon">📦</div>
|
<div class="card">
|
||||||
<h3>انبارداری</h3>
|
<div class="icon-tile accent-4">💰</div>
|
||||||
<p>موجودی کالاهایت را دستهبندی کن، با یک ضربه کموزیاد کن، و وقتی چیزی کم شد هشدار بگیر.</p>
|
<h3>دفتر درآمد و هزینه</h3>
|
||||||
</div>
|
<p>ثبتِ ساده برای خودت، حسابداریِ درست پشتِ صحنه — فروش قسطی را جدا از نقدی میشناسد، اقساط را پیگیری میکند و خروجی CSV میدهد.</p>
|
||||||
<div class="card">
|
</div>
|
||||||
<div class="icon">📅</div>
|
<div class="card">
|
||||||
<h3>نوبتدهی</h3>
|
<div class="icon-tile accent-5">📦</div>
|
||||||
<p>برای آرایشگاه، مطب، کارگاه یا هر جای دیگری که نوبت میگیرد — لیست نوبتهای امروز و فردا همیشه دم دست.</p>
|
<h3>انبارداری</h3>
|
||||||
</div>
|
<p>موجودی کالاهایت را دستهبندی کن، با یک ضربه کموزیاد کن، ارزش کل انبارت را ببین و وقتی چیزی کم شد هشدار بگیر.</p>
|
||||||
<div class="card">
|
</div>
|
||||||
<div class="icon">✅</div>
|
<div class="card">
|
||||||
<h3>گزارش سریع</h3>
|
<div class="icon-tile accent-2">🚚</div>
|
||||||
<p>قالب کلید-مقدار بساز (مثلا لیست کالاهای روزانه)؛ اپ یکییکی مقدارش را میپرسد و برایت آماده میفرستد.</p>
|
<h3>پیگیری سفارش</h3>
|
||||||
</div>
|
<p>مراحل کارت را تعریف کن (آمادهسازی، بستهبندی، ارسال، تحویل) و با هر «مرحلهی بعد» یک پیامک خودکار از روی قالب دلخواهت برای مشتری میرود.</p>
|
||||||
<div class="card">
|
</div>
|
||||||
<div class="icon">🚚</div>
|
|
||||||
<h3>پیگیری سفارش</h3>
|
|
||||||
<p>برای آنلاینشاپهای کوچک: مراحل کارت را تعریف کن (آمادهسازی، بستهبندی، ارسال، تحویل) و با هر «مرحلهی بعد» یک پیامک خودکار از روی قالب دلخواهت برای مشتری میرود.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="icon">🎁</div>
|
|
||||||
<h3>دعوت دوستان</h3>
|
|
||||||
<p>کد معرف خودت را با دوستانت به اشتراک بگذار — هر دوتون جم جایزه میگیرید و میتوانید اشتراک را با آن تمدید کنید.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="icon">🔒</div>
|
|
||||||
<h3>امن و ساده</h3>
|
|
||||||
<p>قفل اثرانگشت برای اطلاعات حساس کسبوکارت، تاریخ شمسی همهجا، و ظاهری که کار کردن باهاش راحت است.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -123,84 +172,192 @@
|
|||||||
<section id="who" class="alt">
|
<section id="who" class="alt">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>کاسبیار برای کیه؟</h2>
|
<span class="kicker">برای کی مناسبه</span>
|
||||||
|
<h2>شاپ شیپ برای کیه؟</h2>
|
||||||
<p>برای هر کسی که یک کسبوکار کوچک دارد و نمیخواهد وقتش را صرف نرمافزارهای پیچیده کند.</p>
|
<p>برای هر کسی که یک کسبوکار کوچک دارد و نمیخواهد وقتش را صرف نرمافزارهای پیچیده کند.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-5">
|
<div class="grid grid-5">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="icon">🛍️</div>
|
<div class="icon-tile accent-0">🛍️</div>
|
||||||
<h3>فروشگاهدارها</h3>
|
<h3>فروشگاهدارها</h3>
|
||||||
<p>فاکتور بزن، انبارت را زیر نظر بگیر، به مشتریهایت خبر بده.</p>
|
<p>فاکتور بزن، انبارت را زیر نظر بگیر، به مشتریهایت خبر بده.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="icon">💇</div>
|
<div class="icon-tile accent-1">🛒</div>
|
||||||
<h3>آرایشگاه و سالنها</h3>
|
<h3>آنلاینشاپهای کوچک</h3>
|
||||||
<p>نوبتدهی منظم و یادآوری نوبت با پیامک.</p>
|
<p>برای هر سفارش، شمارهی مشتری را وارد کن و با «پیگیری سفارش» در هر مرحله خودکار برایش پیامک بفرست.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="icon">🥬</div>
|
<div class="icon-tile accent-2">🥬</div>
|
||||||
<h3>میوه/سبزی و توزیعکنندهها</h3>
|
<h3>میوه/سبزی و توزیعکنندهها</h3>
|
||||||
<p>هر روز با «گزارش سریع» موجودی را به مشتریهای ثابت اعلام کن.</p>
|
<p>گزارش روزانهی موجودی را با یک قالبِ آماده به مشتریهای ثابتت بفرست.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="icon">🧰</div>
|
<div class="icon-tile accent-3">🧰</div>
|
||||||
<h3>کسبوکارهای خدماتی</h3>
|
<h3>کسبوکارهای خدماتی</h3>
|
||||||
<p>از تعمیرکار تا مشاور — حساب مشتری، دخلوخرج و پیام گروهی یکجا.</p>
|
<p>از تعمیرکار تا مشاور — حساب مشتری، دخلوخرج و پیام گروهی یکجا.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="icon">🛒</div>
|
<div class="icon-tile accent-4">💼</div>
|
||||||
<h3>آنلاینشاپهای کوچک</h3>
|
<h3>فروش قسطی/نسیه</h3>
|
||||||
<p>برای هر سفارش، شمارهی مشتری را وارد کن و با «پیگیری سفارش» در هر مرحله (آمادهسازی، بستهبندی، ارسال، تحویل + کد پیگیری پستی) خودکار برایش پیامک بفرست.</p>
|
<p>وقتی جنسی را قسطی میدهی، دفترت خودش میفهمد این یک نوع بدهکاری است، نه فقط یک عدد در لیست درآمد.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="how" class="container">
|
<section class="stats-band">
|
||||||
<div class="section-head">
|
<div class="container">
|
||||||
<h2>در سه قدم شروع کن</h2>
|
<div class="stats-grid">
|
||||||
|
<div>
|
||||||
|
<div class="stat-num">۶</div>
|
||||||
|
<div class="stat-label">ابزار در یک اپ</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-num">۵</div>
|
||||||
|
<div class="stat-label">کانال پیامرسانی</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-num">۷</div>
|
||||||
|
<div class="stat-label">روز اول رایگان</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-num">۱</div>
|
||||||
|
<div class="stat-label">اپ بهجای چندتا نرمافزار جدا</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="steps">
|
</section>
|
||||||
<div class="step">
|
|
||||||
<div>
|
<section id="how">
|
||||||
<h3>با شمارهات وارد شو</h3>
|
<div class="container">
|
||||||
<p>فقط شماره موبایل و یک کد تایید — همان لحظه یک هفته رایگان برایت فعال میشود.</p>
|
<div class="section-head">
|
||||||
|
<span class="kicker">شروع سریع</span>
|
||||||
|
<h2>در سه قدم شروع کن</h2>
|
||||||
|
</div>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">۱</div>
|
||||||
|
<div>
|
||||||
|
<h3>با شمارهات وارد شو</h3>
|
||||||
|
<p>فقط شماره موبایل و یک کد تایید — همان لحظه یک هفته رایگان برایت فعال میشود.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">۲</div>
|
||||||
|
<div>
|
||||||
|
<h3>بخشی که لازم داری را فعال کن</h3>
|
||||||
|
<p>هر بخش قیمت جدا دارد؛ فقط برای همانی که کسبوکارت لازم دارد هزینه کن.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">۳</div>
|
||||||
|
<div>
|
||||||
|
<h3>کارت را شروع کن</h3>
|
||||||
|
<p>فاکتور بزن، پیام بفرست، انبار و دخلوخرجت را ثبت کن — همه از یک اپ.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
</div>
|
||||||
<div>
|
</section>
|
||||||
<h3>بخشی که لازم داری را فعال کن</h3>
|
|
||||||
<p>هر بخش قیمت جدا دارد؛ فقط برای همانی که کسبوکارت لازم دارد هزینه کن.</p>
|
<section class="alt">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker">چرا شاپ شیپ</span>
|
||||||
|
<h2>ساده برای تو، درست پشتِ صحنه</h2>
|
||||||
|
</div>
|
||||||
|
<div class="trust-grid">
|
||||||
|
<div class="trust-item">
|
||||||
|
<div class="icon-tile accent-4">🔒</div>
|
||||||
|
<h3>امن و شخصی</h3>
|
||||||
|
<p>قفل اثرانگشت برای اطلاعات حساس کسبوکارت، و دیتای تو فقط مالِ خودت است.</p>
|
||||||
|
</div>
|
||||||
|
<div class="trust-item">
|
||||||
|
<div class="icon-tile accent-0">📆</div>
|
||||||
|
<h3>بدون قرارداد بلندمدت</h3>
|
||||||
|
<p>هر ابزار را ماهانه یا چندماهه فعال میکنی — هر وقت خواستی تمدید نکن.</p>
|
||||||
|
</div>
|
||||||
|
<div class="trust-item">
|
||||||
|
<div class="icon-tile accent-3">🗓️</div>
|
||||||
|
<h3>تاریخ شمسی همهجا</h3>
|
||||||
|
<p>فاکتور، دفتر، پیگیری سفارش — همهجا با تقویم شمسیِ خودمان.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
</div>
|
||||||
<div>
|
</section>
|
||||||
<h3>کارت را شروع کن</h3>
|
|
||||||
<p>فاکتور بزن، پیام بفرست، انبار و دخلوخرجت را ثبت کن — همه از یک اپ.</p>
|
<section id="pricing">
|
||||||
</div>
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker">قیمتگذاری</span>
|
||||||
|
<h2>فقط برای همون ابزاری که استفاده میکنی</h2>
|
||||||
|
</div>
|
||||||
|
<div class="pricing-card">
|
||||||
|
<span class="badge">۷ روز اول، همهچیز رایگان</span>
|
||||||
|
<p style="font-size:16px; margin-bottom: var(--space-6);">
|
||||||
|
شاپ شیپ اشتراکِ همهیاهیچ نمیفروشد. هر بخش (فاکتور، پیام گروهی،
|
||||||
|
دفتر، انبار، مشتریان، پیگیری سفارش) را جدا و فقط وقتی لازمش داری
|
||||||
|
با جم فعال میکنی — قیمتِ هر بخش و مدتش داخل خودِ اپ، توی فروشگاه،
|
||||||
|
شفاف نوشته شده.
|
||||||
|
</p>
|
||||||
|
<a class="btn btn-primary" href="#cta">شروع رایگان</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="cta" class="cta-band">
|
<section id="cta" class="cta-band">
|
||||||
<div class="container">
|
<h2>یک هفته کاملا رایگان امتحانش کن</h2>
|
||||||
<h2>یک هفته کاملا رایگان امتحانش کن</h2>
|
<p>شاپ شیپ را نصب کن، با شمارهات وارد شو، و همین امروز اولین فاکتورت را بزن.</p>
|
||||||
<p>کاسبیار را نصب کن، با شمارهات وارد شو، و همین امروز اولین فاکتورت را بزن.</p>
|
<div class="hero-actions" style="justify-content:center;">
|
||||||
<div class="hero-actions">
|
<a class="btn btn-primary" href="https://cafebazaar.ir/app/ir.niqow.kangelo" target="_blank" rel="noopener" style="background:#fff; color:var(--color-brand);">دانلود از کافهبازار</a>
|
||||||
<a class="btn btn-primary" href="#">دانلود برای اندروید</a>
|
|
||||||
<a class="btn btn-secondary" href="#" style="border-color:rgba(243,242,242,0.3); color:#f3f2f2;">دانلود برای iOS</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<span>© کاسبیار — اپ مدیریت کسبوکارهای کوچک</span>
|
<div class="footer-brand">
|
||||||
|
<strong>شاپ شیپ</strong>
|
||||||
|
<span>محصولی از <a href="https://niqow.ir" target="_blank" rel="noopener">niqow.ir</a></span>
|
||||||
|
</div>
|
||||||
<span><a href="/articles/">مقالات</a></span>
|
<span><a href="/articles/">مقالات</a></span>
|
||||||
<span>قدرت گرفته از <a href="https://niqow.ir" target="_blank" rel="noopener">niqow.ir</a></span>
|
<span>© شاپ شیپ — اپ مدیریت کسبوکارهای کوچک</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var header = document.getElementById('siteHeader');
|
||||||
|
if (header) {
|
||||||
|
var onScroll = function () {
|
||||||
|
header.classList.toggle('is-scrolled', window.scrollY > 8);
|
||||||
|
};
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (!prefersReducedMotion && 'IntersectionObserver' in window) {
|
||||||
|
var revealTargets = document.querySelectorAll(
|
||||||
|
'.card, .step, .trust-item, .pricing-card, .section-head, .stats-grid > div'
|
||||||
|
);
|
||||||
|
revealTargets.forEach(function (el, i) {
|
||||||
|
el.classList.add('reveal');
|
||||||
|
el.style.transitionDelay = (i % 6) * 60 + 'ms';
|
||||||
|
});
|
||||||
|
var observer = new IntersectionObserver(
|
||||||
|
function (entries) {
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('reveal-visible');
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.15, rootMargin: '0px 0px -40px 0px' }
|
||||||
|
);
|
||||||
|
revealTargets.forEach(function (el) { observer.observe(el); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
Sitemap: https://mobiniqow.ir/sitemap.xml
|
Sitemap: https://shopship.ir/sitemap.xml
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url><loc>https://mobiniqow.ir/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>
|
<url><loc>https://shopship.ir/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>
|
||||||
<url><loc>https://mobiniqow.ir/articles/</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>
|
<url><loc>https://shopship.ir/articles/</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>
|
||||||
<url><loc>https://mobiniqow.ir/articles/peyam-goruhi-ba-shomare/</loc><priority>0.6</priority></url>
|
<url><loc>https://shopship.ir/articles/peyam-goruhi-ba-shomare/</loc><lastmod>2025-08-23</lastmod><priority>0.6</priority></url>
|
||||||
<url><loc>https://mobiniqow.ir/articles/field-sefareshi-dar-template/</loc><priority>0.6</priority></url>
|
<url><loc>https://shopship.ir/articles/field-sefareshi-dar-template/</loc><lastmod>2025-08-23</lastmod><priority>0.6</priority></url>
|
||||||
<url><loc>https://mobiniqow.ir/articles/merge-mokhatebin-ganoshi/</loc><priority>0.6</priority></url>
|
<url><loc>https://shopship.ir/articles/merge-mokhatebin-ganoshi/</loc><lastmod>2025-08-23</lastmod><priority>0.6</priority></url>
|
||||||
<url><loc>https://mobiniqow.ir/articles/sms-ya-whatsapp-business/</loc><priority>0.6</priority></url>
|
<url><loc>https://shopship.ir/articles/sms-ya-whatsapp-business/</loc><lastmod>2025-08-23</lastmod><priority>0.6</priority></url>
|
||||||
|
<url><loc>https://shopship.ir/articles/forosh-ghesti-hesabdari/</loc><lastmod>2026-08-30</lastmod><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://shopship.ir/articles/fatoore-rasmi-baraye-maghaze/</loc><lastmod>2026-09-02</lastmod><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://shopship.ir/articles/kontorol-mojoodi-anbar-ba-mobile/</loc><lastmod>2026-09-05</lastmod><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://shopship.ir/articles/peygiri-sefaresh-online-shop-etemad/</loc><lastmod>2026-09-08</lastmod><priority>0.7</priority></url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
Django>=5.1,<5.2
|
Django>=5.1,<5.2
|
||||||
djangorestframework>=3.15,<4.0
|
djangorestframework>=3.15,<4.0
|
||||||
djangorestframework-simplejwt>=5.3,<6.0
|
djangorestframework-simplejwt>=5.3,<6.0
|
||||||
|
PyJWT>=2.8,<3.0
|
||||||
psycopg2-binary>=2.9,<3.0
|
psycopg2-binary>=2.9,<3.0
|
||||||
python-decouple>=3.8,<4.0
|
python-decouple>=3.8,<4.0
|
||||||
django-cors-headers>=4.4,<5.0
|
django-cors-headers>=4.4,<5.0
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||