diff --git a/apps/account/migrations/0001_initial.py b/apps/account/migrations/0001_initial.py deleted file mode 100644 index dc606e7..0000000 --- a/apps/account/migrations/0001_initial.py +++ /dev/null @@ -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': 'کاربران', - }, - ), - ] diff --git a/apps/account/migrations/0002_user_gem_balance_user_referral_bonus_granted_and_more.py b/apps/account/migrations/0002_user_gem_balance_user_referral_bonus_granted_and_more.py deleted file mode 100644 index 67cd862..0000000 --- a/apps/account/migrations/0002_user_gem_balance_user_referral_bonus_granted_and_more.py +++ /dev/null @@ -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ی رایگان'), - ), - ] diff --git a/apps/account/migrations/0003_termsandconditions.py b/apps/account/migrations/0003_termsandconditions.py deleted file mode 100644 index 4200ec3..0000000 --- a/apps/account/migrations/0003_termsandconditions.py +++ /dev/null @@ -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': 'قوانین و مقررات', - }, - ), - ] diff --git a/apps/account/send_sms.py b/apps/account/send_sms.py index 50427f1..4bf08fa 100644 --- a/apps/account/send_sms.py +++ b/apps/account/send_sms.py @@ -1,15 +1,6 @@ # apps/account/send_sms.py """ -ارسال پیامک OTP. - -فقط دو مقدار از .env خوانده میشود (تنظیمات در config/settings.py): - - SMS_PATTERN_NAME: نام/کد قالبی که نزد سرویسدهندهی پیامک ثبت شده - - SMS_TOKEN_URL: آدرسی که با یک درخواست POST، قالب+شماره+کد را برایش - میفرستیم (سرویسدهندهی تو باید چنین آدرسی در اختیارت گذاشته باشد) - -اگر ساختار درخواست سرویسدهندهی واقعیات با payload زیر فرق دارد، فقط -تابع `_call_token_url` را با مستندات سرویسدهندهات هماهنگ کن؛ بقیهی -پروژه با این ماژول کاری ندارد و فقط send_otp_message را صدا میزند. +ارسال پیامک OTP با کاوهنگار """ import logging @@ -24,36 +15,65 @@ REQUEST_TIMEOUT_SECONDS = 10 def _call_token_url(phone: str, code: str) -> bool: - token_url = settings.SMS_TOKEN_URL - pattern_name = settings.SMS_PATTERN_NAME + """ + ارسال OTP با استفاده از API کاوهنگار + """ + api_key = settings.KAVENEGAR_API_KEY + template = settings.SMS_PATTERN_NAME - if not token_url or not pattern_name: - logger.error("SMS_TOKEN_URL یا SMS_PATTERN_NAME در .env تنظیم نشده است") + # ✅ لاگ برای دیباگ + logger.info(f"📤 Sending OTP to {phone} with template: {template}") + + if not api_key or not template: + logger.error("❌ KAVENEGAR_API_KEY یا SMS_PATTERN_NAME در .env تنظیم نشده است") + logger.error(f" KAVENEGAR_API_KEY: {'✅' if api_key else '❌'}") + logger.error(f" SMS_PATTERN_NAME: {'✅' if template else '❌'}") return False + # ساخت آدرس کامل + url = f"https://api.kavenegar.com/v1/{api_key}/verify/lookup.json" + + # پیلود مورد نیاز کاوهنگار payload = { - "pattern": pattern_name, "receptor": phone, "token": code, + "template": template, } try: - response = requests.post(token_url, json=payload, timeout=REQUEST_TIMEOUT_SECONDS) - response.raise_for_status() - logger.info(f"✅ OTP sent to {phone} via {token_url}") - return True + logger.info(f"📤 POST {url}") + response = requests.post(url, data=payload, timeout=REQUEST_TIMEOUT_SECONDS) + response_data = response.json() + + # بررسی پاسخ + if response.status_code == 200: + return_status = response_data.get("return", {}).get("status") + if return_status == 200: + logger.info(f"✅ OTP sent to {phone} via Kavenegar") + return True + else: + error_msg = response_data.get("return", {}).get("message", "Unknown error") + logger.error(f"❌ Kavenegar error: {error_msg}") + return False + else: + logger.error(f"❌ Kavenegar HTTP error: {response.status_code} - {response.text}") + return False + + except requests.Timeout: + logger.error(f"❌ Kavenegar timeout for {phone}") + return False except requests.RequestException as exc: logger.error(f"❌ خطا در ارسال پیامک به {phone}: {exc}") return False def send_otp_message(phone: str, code: str) -> None: - """ارسال غیربلاکینگ کد تایید (شکست ارسال، درخواست HTTP کاربر را کند نمیکند)""" + """ارسال غیربلاکینگ کد تایید""" def _send(): try: _call_token_url(phone, code) - except Exception as exc: # noqa: BLE001 - لاگ و رد شدن، نباید کل ترد را بترکاند + except Exception as exc: logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}") - Thread(target=_send, daemon=True).start() + Thread(target=_send, daemon=True).start() \ No newline at end of file diff --git a/apps/banker/migrations/0001_initial.py b/apps/banker/migrations/0001_initial.py deleted file mode 100644 index 9b7d506..0000000 --- a/apps/banker/migrations/0001_initial.py +++ /dev/null @@ -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'), - ), - ] diff --git a/apps/banker/migrations/0002_structuredtemplate_structuredtemplatefield.py b/apps/banker/migrations/0002_structuredtemplate_structuredtemplatefield.py deleted file mode 100644 index 0110d6c..0000000 --- a/apps/banker/migrations/0002_structuredtemplate_structuredtemplatefield.py +++ /dev/null @@ -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'], - }, - ), - ] diff --git a/apps/billing/admin.py b/apps/billing/admin.py index 4bf15cf..96928cd 100644 --- a/apps/billing/admin.py +++ b/apps/billing/admin.py @@ -1,14 +1,39 @@ -from django.contrib import admin +from django.contrib import admin, messages from django.shortcuts import redirect +from django.urls import path, reverse +from django.utils.html import format_html from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction +from .providers.bazaar import BazaarAuthError, build_authorize_url, exchange_code_for_tokens @admin.register(BazaarSettings) class BazaarSettingsAdmin(admin.ModelAdmin): - """تکردیفی — لیست را رد میکنیم و مستقیم فرم ویرایش همان یک ردیف را نشان میدهیم.""" + """تکردیفی — لیست را رد میکنیم و مستقیم فرم ویرایش همان یک ردیف را نشان میدهیم. + + برای اتصال با روش OAuth (تمدید خودکار access_token): این سه فیلد را پر + کن — Client ID، Client Secret، application id اپ — ذخیره کن، بعد از + «وضعیت اتصال کافهبازار» پایین فرم روی «شروع اتصال» بزن. اگر Redirect URI + که موقع ساخت Client در پیشخان ثبت کردهای با آدرسِ نشاندادهشده اینجا فرق + دارد، بعد از تایید در کافهبازار، مقدار code= را از آدرسِ برگشتی کپی و + خودت به همان آدرس callback اضافه کن (?code=...) — نیازی به فیلد جداگانه نیست. + + یا سادهتر: اگر همین حالا یک توکن ثابتِ «روش جدید» پیشخان داری، مستقیم در + Access Token بچسبان و Access Token Expires At را خالی بگذار — کارش تمام + است، فقط اگر روزی باطل شد باید دستی توکن تازه بگیری.""" list_display = ("package_name", "updated_at") + readonly_fields = ("oauth_status",) + fields = ( + "package_name", + "access_token", + "access_token_expires_at", + "refresh_token", + "client_id", + "client_secret", + "dynamic_discount_secret", + "oauth_status", + ) def has_add_permission(self, request): return not BazaarSettings.objects.exists() @@ -17,6 +42,71 @@ class BazaarSettingsAdmin(admin.ModelAdmin): obj = BazaarSettings.get_solo() return redirect("admin:billing_bazaarsettings_change", obj.pk) + def get_urls(self): + custom = [ + path( + "oauth/connect/", + self.admin_site.admin_view(self.oauth_connect_view), + name="billing_bazaarsettings_oauth_connect", + ), + path( + "oauth/callback/", + self.admin_site.admin_view(self.oauth_callback_view), + name="billing_bazaarsettings_oauth_callback", + ), + ] + return custom + super().get_urls() + + def _callback_url(self, request) -> str: + return request.build_absolute_uri(reverse("admin:billing_bazaarsettings_oauth_callback")) + + def oauth_connect_view(self, request): + obj = BazaarSettings.get_solo() + try: + authorize_url = build_authorize_url(self._callback_url(request)) + except BazaarAuthError as exc: + messages.error(request, str(exc)) + return redirect("admin:billing_bazaarsettings_change", obj.pk) + return redirect(authorize_url) + + def oauth_callback_view(self, request): + obj = BazaarSettings.get_solo() + code = request.GET.get("code") + if not code: + messages.error(request, "پارامتر code در آدرس بازگشتی پیدا نشد.") + return redirect("admin:billing_bazaarsettings_change", obj.pk) + try: + exchange_code_for_tokens(code, self._callback_url(request)) + except BazaarAuthError as exc: + messages.error(request, f"اتصال به کافهبازار ناموفق بود: {exc}") + else: + messages.success(request, "اتصال به کافهبازار موفق بود — access_token از این پس خودکار تمدید میشود.") + return redirect("admin:billing_bazaarsettings_change", obj.pk) + + @admin.display(description="وضعیت اتصال کافهبازار") + def oauth_status(self, obj): + if not obj or not obj.pk: + return "ابتدا ذخیره کن." + callback_hint = reverse("admin:billing_bazaarsettings_oauth_callback") + connect_url = reverse("admin:billing_bazaarsettings_oauth_connect") + if obj.access_token_expires_at: + status_line = f"متصل (OAuth) — انقضای فعلی: {obj.access_token_expires_at:%Y-%m-%d %H:%M}" + elif obj.access_token: + status_line = "متصل با توکن ثابتِ روش جدید (بدون تمدید خودکار)." + else: + status_line = "هنوز متصل نیست." + return format_html( + "
{}
" + "Redirect URI برای ثبت در پیشخان (اگر میخواهی OAuth را وصل کنی) — این مسیر را با " + "آدرس دامنهی سرورت جلو ببر، مثلا https://api.example.com{} :
" + "{}
شروع اتصال به کافهبازار (OAuth)
', + status_line, + callback_hint, + callback_hint, + connect_url, + ) + class FeaturePlanTierInline(admin.TabularInline): model = FeaturePlanTier diff --git a/apps/billing/management/__init__.py b/apps/billing/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/billing/management/commands/__init__.py b/apps/billing/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/billing/management/commands/setup_bazaar_settings.py b/apps/billing/management/commands/setup_bazaar_settings.py new file mode 100644 index 0000000..a398f02 --- /dev/null +++ b/apps/billing/management/commands/setup_bazaar_settings.py @@ -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( + "توکن ثابت ذخیره شد — از همین الان خرید باید کار کند. برای فعالکردنِ تمدیدِ " + "خودکار (اختیاری)، از پنل ادمین دکمهی «اتصال به کافهبازار» را بزن." + ) + ) diff --git a/apps/billing/migrations/0001_initial.py b/apps/billing/migrations/0001_initial.py deleted file mode 100644 index e40e5aa..0000000 --- a/apps/billing/migrations/0001_initial.py +++ /dev/null @@ -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')], - }, - ), - ] diff --git a/apps/billing/migrations/0002_seed_feature_plans.py b/apps/billing/migrations/0002_seed_feature_plans.py deleted file mode 100644 index a77ad50..0000000 --- a/apps/billing/migrations/0002_seed_feature_plans.py +++ /dev/null @@ -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)] diff --git a/apps/billing/migrations/0003_remove_brokerage_feature.py b/apps/billing/migrations/0003_remove_brokerage_feature.py deleted file mode 100644 index 1e54ae0..0000000 --- a/apps/billing/migrations/0003_remove_brokerage_feature.py +++ /dev/null @@ -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)] diff --git a/apps/billing/migrations/0004_rename_sms_bank_feature.py b/apps/billing/migrations/0004_rename_sms_bank_feature.py deleted file mode 100644 index 0472c69..0000000 --- a/apps/billing/migrations/0004_rename_sms_bank_feature.py +++ /dev/null @@ -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)] diff --git a/apps/billing/migrations/0005_seed_orders_feature.py b/apps/billing/migrations/0005_seed_orders_feature.py deleted file mode 100644 index 4658ccd..0000000 --- a/apps/billing/migrations/0005_seed_orders_feature.py +++ /dev/null @@ -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)] diff --git a/apps/billing/models.py b/apps/billing/models.py index 2918d1c..47c6b61 100644 --- a/apps/billing/models.py +++ b/apps/billing/models.py @@ -10,13 +10,60 @@ from django.utils.translation import gettext_lazy as _ class BazaarSettings(models.Model): """تنظیمات کافهبازار — عمداً از پنل ادمین ویرایش میشود (نه .env)، چون - باید بدون ریدیپلوی قابلتغییر باشد. تکردیفی (singleton).""" + باید بدون ریدیپلوی قابلتغییر باشد. تکردیفی (singleton). - access_token = models.CharField(max_length=255, blank=True, verbose_name=_("Access Token")) - refresh_token = models.CharField(max_length=255, blank=True, verbose_name=_("Refresh Token")) + دو روش احراز هویت با devapi کافهبازار پشتیبانی میشود: + ۱) روش جدید (ساده): توکن ثابت از پیشخان → «API پیشخان بازار» → «دریافت + توکن جدید»، مستقیم در access_token جایگذاری میشود؛ چون access_token_ + expires_at خالی میماند، هیچوقت سعی در refresh نمیشود (این توکن API + رفرش ندارد؛ اگر باطل شد باید دستی از پیشخان توکن تازه گرفت و همینجا + جایگزین کرد). + ۲) روش قدیمی (OAuth با رفرش خودکار): client_id/client_secret را اینجا + بگذار، از دکمهی «اتصال به کافهبازار» در همین صفحه استفاده کن؛ از آن + به بعد access_token هر بار که نزدیک انقضا باشد، خودش با refresh_token + تمدید میشود — بدون نیاز به مراجعهی دستی دوباره.""" + + access_token = models.CharField( + max_length=255, + blank=True, + verbose_name=_("Access Token"), + help_text=_( + "یا توکن ثابت «روش جدید» پیشخان را مستقیم اینجا بچسبان، یا با دکمهی اتصال زیر " + "بگذار خودش (روش قدیمی/OAuth) پر و بعدا خودکار تمدید شود." + ), + ) + refresh_token = models.CharField( + max_length=255, blank=True, verbose_name=_("Refresh Token"), + help_text=_("فقط برای روش OAuth — با دکمهی اتصال خودش پر میشود، دستی لازم نیست."), + ) + access_token_expires_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("انقضای Access Token (فقط OAuth)"), + help_text=_( + "اگر خالی باشد یعنی توکن ثابتِ روش جدید است و هیچوقت خودکار تمدید نمیشود. " + "اگر پر باشد یعنی از OAuth آمده و پیش از انقضا خودش تمدید خواهد شد." + ), + ) + client_id = models.CharField( + max_length=255, blank=True, verbose_name=_("Client ID (روش قدیمی/OAuth)") + ) + client_secret = models.CharField( + max_length=255, blank=True, verbose_name=_("Client Secret (روش قدیمی/OAuth)") + ) package_name = models.CharField( max_length=150, blank=True, verbose_name=_("application id اپ (مثلا ir.example.app)") ) + dynamic_discount_secret = models.CharField( + max_length=255, + blank=True, + verbose_name=_("کلید امضای تخفیف پویا"), + help_text=_( + "از پیشخان → برنامهات → پرداخت درونبرنامهای → تخفیف پویا. فقط سمت سرور استفاده " + "میشود (apps.billing.providers.dynamic_discount) — هیچوقت نباید در اپ فلاتر ذخیره شود، " + "وگرنه کلید افشا میشود و کافهبازار مسئولیتی نمیپذیرد." + ), + ) updated_at = models.DateTimeField(auto_now=True) class Meta: diff --git a/apps/billing/providers/bazaar.py b/apps/billing/providers/bazaar.py index c7315c5..6c2bf0d 100644 --- a/apps/billing/providers/bazaar.py +++ b/apps/billing/providers/bazaar.py @@ -1,33 +1,142 @@ # apps/billing/providers/bazaar.py """ -تایید خرید کافهبازار — سمت سرور، طبق مستندات عمومی devapi کافهبازار -(https://developers.cafebazaar.ir/fa/docs/iab). این تنها API ای است که -اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»، تا این -تابع True برنگرداند هیچ جمی اضافه نمیشود. - -فرض مستنداتِ مسیر/پاسخ (اگر ساختار حساب توسعهدهندهی خودت فرق دارد، فقط -همین یک تابع را با مستندات واقعی هماهنگ کن — بقیهی پروژه فقط -`verify_purchase(product_id, purchase_token)` را صدا میزند): +تایید خرید کافهبازار — سمت سرور، طبق مستندات رسمی کافهبازار +(developers.cafebazaar.ir/fa/guidelines/in-app-billing). این تنها API ای +است که اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»، +تا این تابع True برنگرداند هیچ جمی اضافه نمیشود. GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name} - /inapp/{product_id}/purchases/{purchase_token}/?access_token=... + /inapp/{product_id}/purchases/{purchase_token}/ پاسخ موفق شامل purchaseState=0 (خریداریشده، نه refundشده) است. -access_token از BazaarSettings (پنل ادمین) خوانده میشود، نه .env — چون باید -بدون ریدیپلوی قابلتعویض باشد (مثلا وقتی منقضی شد). +دو روش احراز هویت (هر دو طبق مستندات رسمی): + + «روش جدید» — توکن ثابتِ پیشخان (.../api/setting-up-new)، در هدر: + CAFEBAZAAR-PISHKHAN-API-SECRET:،
- «پیام گروهی با شماره» یعنی ارسال یک پیام (پیامک، واتساپ، تلگرام و…) به - لیستی از شمارهها، معمولاً با متنی که برای هر گیرنده کمی شخصیسازی - شده — مثلاً با نام یا یک اطلاعات اختصاصی دیگر. سوالی که همیشه پشت این - نیاز میآید این است: چطور این کار را انجام بدهیم بدون اینکه شماره یا - اکانت فرستنده مسدود شود؟ -
- -- واتساپ، تلگرام، بله و ایتا همه یک سیستم ضدهرزنامه دارند که رفتار - غیرطبیعی (ارسال حجم بالا از یک شمارهی معمولی، چرخاندن چند دستگاه/سشن، - شبیهسازی رفتار انسانی برای فرار از تشخیص) را شناسایی و مسدود میکند. - این محدودیت تصادفی نیست — دقیقاً همان مکانیزمی است که جلوی اسپم واقعی - را میگیرد. -
- -- هر پلتفرم یک راه رسمی برای ارسال حجمی به کسبوکارها دارد: -
-- پیامک از طریق درگاههای بالک اپراتور، واتساپ - از طریق WhatsApp Business Platform (نیاز به تایید Template پیام دارد)، - و تلگرام/بله/ایتا از طریق Bot API — با یک محدودیت مهم: - بات فقط میتواند به کسانی پیام بدهد که خودشان قبلاً چت را با بات شروع - کردهاند (opt-in). -
- -- ارسال خودکار به هر شمارهی دلخواه در تلگرام/بله/ایتا بدون اینکه آن - شخص قبلاً با بات تعامل داشته باشد، از مسیر رسمی امکانپذیر نیست — و - هیچ روش «دور زدن» پایداری هم برایش وجود ندارد. کاسبیار برای این - موارد بهجای اتوماسیون مخفی، یک صف ارسال دستیِ یکلمسی نشان میدهد: - اپلیکیشن مقصد را با متن آماده باز میکند، خودت دستی میزنی ارسال. -
- -- پیام گروهی پایدار یعنی حجم را از مسیر رسمی هر پلتفرم رد کنی، نه اینکه - رفتار انسانی را شبیهسازی کنی. کندتر شروع میشود (چون باید Template یا - Bot تاییدشده داشته باشی) ولی هرگز اکانتت را به خطر نمیاندازد. -
-""", - }, - { - "slug": "field-sefareshi-dar-template", - "title": "فیلد سفارشی در قالب پیام یعنی چه؟", - "meta_description": "چطور برای هر گروه مخاطب فیلدهای سفارشی مثل شماره گذرنامه یا کد ملی تعریف کنی و در قالب پیام همان گروه استفاده کنی.", - "excerpt": "چطور برای هر گروه مخاطب، فیلدهای مخصوص خودش را تعریف کنی و در متن پیام استفاده کنی.", - "read_minutes": 3, - "content_html": """ -- قالب پیام معمولی یک متن ثابت با چند جایخالی است، مثلاً «سلام {نام} - عزیز». اما خیلی از کسبوکارها به بیشتر از نام و شماره نیاز دارند — یک - آژانس مسافرتی به «شماره گذرنامه» نیاز دارد، یک کلینیک به «تاریخ نوبت»، - یک باشگاه به «کد عضویت». اینجاست که فیلد سفارشی وارد میشود. -
- -- در کاسبیار، مخاطبین را در گروههایی مثل «تور»، «مشتریان VIP» یا - «دانشجویان» دستهبندی میکنی. هر گروه فیلدهای سفارشی مخصوص خودش را - دارد — یعنی گروه «تور» میتواند «شماره گذرنامه» داشته باشد بدون اینکه - این فیلد بیربط در گروه «دانشجویان» هم ظاهر شود. -
- -- اگر نیازی به فیلد سفارشی نداری، همان گروه «عمومی» پیشفرض کافیست — - فقط نام و شماره. هیچ فیلدی مجبور نیستی اضافه کنی. -
- -
- بعد از تعریف فیلد در یک گروه، هنگام نوشتن قالب پیام برای همان گروه،
- اسم فیلد بهعنوان یک تگ قابلدرج (مثل {شماره گذرنامه})
- کنار فیلدهای پایه (نام، شماره) نمایش داده میشود. برای هر گیرنده، اپ
- خودکار مقدار واقعی همان مخاطب را جایگزین میکند.
-
- اگر لیست مخاطبینت همین حالا در دفترچهتلفن گوشیات است، نیازی به - تایپ دوبارهی تکتک شمارهها نیست. کاسبیار میتواند مستقیم به - مخاطبین گوشیات دسترسی بگیرد (با اجازهی خودت) و هرکدام را که خواستی - یکجا وارد اپ کند. -
- -- از صفحهی «مخاطبین» گزینهی «مرج از مخاطبین گوشی» را بزن. اپ لیست - مخاطبین گوشیات را نشان میدهد (فقط آنهایی که هنوز در کاسبیار - نیستند)، هرکدام را انتخاب میکنی، یک گروه مقصد مشخص میکنی، و همه - یکجا اضافه میشوند. -
- -- مخاطبینی که شمارهشان از قبل در اپ ثبت شده، دوباره در لیست مرج نشان - داده نمیشوند — تطبیق بر اساس شمارهی نرمالشده انجام میشود، نه اسم. -
- -- دسترسی به مخاطبین فقط برای همین عملیات استفاده میشود؛ چیزی خودکار یا - در پسزمینه آپلود نمیشود — فقط همان مخاطبینی که خودت تیک میزنی. -
-""", - }, - { - "slug": "sms-ya-whatsapp-business", - "title": "پیامک یا واتساپبیزینس؟ کدام برای ارسال گروهی بهتر است", - "meta_description": "مقایسهی پیامک و واتساپبیزینس برای ارسال پیام گروهی: هزینه، نرخ باز شدن، محدودیت Template، و کدام برای چه کسبوکاری مناسبتر است.", - "excerpt": "مقایسهی هزینه، نرخ باز شدن، و محدودیتهای هرکدام برای پیام گروهی کسبوکارها.", - "read_minutes": 5, - "content_html": """ -- هر دو کانال جای خودشان را دارند و در کاسبیار میتوانی همزمان از - هردو (بهعلاوهی تلگرام، بله و ایتا) استفاده کنی. اما اگر مجبوری یکی - را اول انتخاب کنی، این تفاوتها کمک میکند: -
- -- به هر شمارهای میرسد، حتی بدون اینترنت یا اپ نصبشده — قابلاطمینانترین - کانال از نظر تحویل. هزینهی هر پیام مشخص و قابلپیشبینی است. محدودیت: - طول پیام (پیامکهای بلند چند-قسمتی حساب میشوند) و نداشتن تصویر/دکمه. -
- -- نرخ باز شدن پیام معمولاً بالاتر از پیامک است و امکاناتی مثل دکمه و - رسانه دارد. اما برای ارسال گروهی (business-initiated) باید از یک - Template از پیش تاییدشده در Meta Business Manager استفاده کنی — - یعنی نمیتوانی هر متن دلخواهی را بیمحدودیت به هرکسی بفرستی؛ باید - ساختار پیام از قبل تایید شده باشد. -
- -- اینها فقط به کسانی میرسند که قبلاً با بات کسبوکارت چت را شروع - کردهاند. برای مخاطبینی که این کار را نکردهاند، کاسبیار یک صف - ارسال دستیِ یکلمسی نشان میدهد. -
- -- اگر تحویل تضمینی برایت مهمتر است، پیامک را انتخاب کن. اگر مخاطبینت - بیشتر واتساپی هستند و Template تاییدشده داری، واتساپبیزینس گزینهی - بهتری است. بهترین نتیجه معمولاً ترکیب چند کانال است — دقیقاً همان - چیزی که کاسبیار از یک صفحه انجام میدهد. -
-""", - }, -] - - -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), - ] diff --git a/apps/landing/templates/landing/article_detail.html b/apps/landing/templates/landing/article_detail.html index 9de2096..7fba294 100644 --- a/apps/landing/templates/landing/article_detail.html +++ b/apps/landing/templates/landing/article_detail.html @@ -3,14 +3,32 @@ - -راهنمای ارسال پیام گروهی با شماره — پیامک، واتساپ، فیلد سفارشی و مرج مخاطبین.
+راهنمای مدیریت کسبوکار کوچک — فاکتور، دفتر درآمد و هزینه، انبارداری، پیگیری سفارش و پیام گروهی با شماره.
{{ article.excerpt }}
+ {% empty %} +هنوز مقالهای منتشر نشده — بهزودی برمیگردیم.
{% endfor %}متن تست
", + "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, "عنوان تست") diff --git a/apps/landing/views.py b/apps/landing/views.py index ceb237c..876ec54 100644 --- a/apps/landing/views.py +++ b/apps/landing/views.py @@ -1,13 +1,23 @@ from django.shortcuts import get_object_or_404, render +from django.utils import timezone from .models import Article +def _visible_articles(): + # published_at یعنی «از این تاریخ به بعد دیده شود» — نه فقط یک برچسبِ + # نمایشی. همین باعث میشود بشود چند مقاله را از قبل با is_published=True + # و published_at آینده ثبت کرد و هرکدام سرِ تاریخِ خودش خودکار ظاهر شود + # (مثلا برای انتشار پلکانیِ هر چند روز یکبار، بدون نیاز به کرون/تسک جدا). + return Article.objects.filter(is_published=True, published_at__lte=timezone.localdate()) + + def article_list(request): - articles = Article.objects.filter(is_published=True) + articles = _visible_articles() return render(request, "landing/article_list.html", {"articles": articles}) def article_detail(request, slug): - article = get_object_or_404(Article, slug=slug, is_published=True) - return render(request, "landing/article_detail.html", {"article": article}) + article = get_object_or_404(_visible_articles(), slug=slug) + related = _visible_articles().exclude(pk=article.pk)[:3] + return render(request, "landing/article_detail.html", {"article": article, "related": related}) diff --git a/apps/messaging/migrations/0001_initial.py b/apps/messaging/migrations/0001_initial.py deleted file mode 100644 index 1969859..0000000 --- a/apps/messaging/migrations/0001_initial.py +++ /dev/null @@ -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')], - }, - ), - ] diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py deleted file mode 100644 index 9f5790e..0000000 --- a/apps/notifications/migrations/0001_initial.py +++ /dev/null @@ -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')], - }, - ), - ] diff --git a/apps/orders/migrations/0001_initial.py b/apps/orders/migrations/0001_initial.py deleted file mode 100644 index db51a4a..0000000 --- a/apps/orders/migrations/0001_initial.py +++ /dev/null @@ -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'], - }, - ), - ] diff --git a/apps/orders/migrations/0002_orderstage_extra_input_label_and_more.py b/apps/orders/migrations/0002_orderstage_extra_input_label_and_more.py deleted file mode 100644 index e8af829..0000000 --- a/apps/orders/migrations/0002_orderstage_extra_input_label_and_more.py +++ /dev/null @@ -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ی این مرحله'), - ), - ] diff --git a/apps/telemetry/__init__.py b/apps/telemetry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/telemetry/admin.py b/apps/telemetry/admin.py new file mode 100644 index 0000000..b0ad2fb --- /dev/null +++ b/apps/telemetry/admin.py @@ -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") diff --git a/apps/telemetry/api/__init__.py b/apps/telemetry/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/telemetry/api/serializers.py b/apps/telemetry/api/serializers.py new file mode 100644 index 0000000..c115412 --- /dev/null +++ b/apps/telemetry/api/serializers.py @@ -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 diff --git a/apps/telemetry/api/services.py b/apps/telemetry/api/services.py new file mode 100644 index 0000000..5644f89 --- /dev/null +++ b/apps/telemetry/api/services.py @@ -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"], + ) diff --git a/apps/telemetry/api/views.py b/apps/telemetry/api/views.py new file mode 100644 index 0000000..6721095 --- /dev/null +++ b/apps/telemetry/api/views.py @@ -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) diff --git a/apps/telemetry/apps.py b/apps/telemetry/apps.py new file mode 100644 index 0000000..6d081ae --- /dev/null +++ b/apps/telemetry/apps.py @@ -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 = _("مانیتورینگ و آمار اپ") diff --git a/apps/telemetry/middleware.py b/apps/telemetry/middleware.py new file mode 100644 index 0000000..b561589 --- /dev/null +++ b/apps/telemetry/middleware.py @@ -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 diff --git a/apps/telemetry/migrations/__init__.py b/apps/telemetry/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/telemetry/models.py b/apps/telemetry/models.py new file mode 100644 index 0000000..eb6e09e --- /dev/null +++ b/apps/telemetry/models.py @@ -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"] diff --git a/apps/telemetry/templates/admin/telemetry/dashboard.html b/apps/telemetry/templates/admin/telemetry/dashboard.html new file mode 100644 index 0000000..b2817e7 --- /dev/null +++ b/apps/telemetry/templates/admin/telemetry/dashboard.html @@ -0,0 +1,143 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block content %} ++ آمار {{ window_days }} روزِ اخیر — برای دیدنِ لیستِ خامِ جلسهها + اینجا را بزن. +
+{{ label }}
+{{ value }}
+هنوز دادهای ثبت نشده.
+ {% endif %} +{{ e.message }}
+خطایی ثبت نشده — خوب است.
+ {% endif %} +| مسیر | +متد | +تعداد فراخوانی | +میانگین زمان (ms) | +بیشترین زمان (ms) | +تعداد خطا | +
|---|---|---|---|---|---|
| {{ e.path }} | +{{ e.method }} | +{{ e.count }} | +{{ e.avg_ms }} | +{{ e.max_ms|floatformat:1 }} | +{{ e.error_count }} | +
| هنوز دادهای ثبت نشده. | |||||
| کاربر | +زمان در اپ (دقیقه) | +تعداد جلسه | +صفحهبازدید | +فراخوانی API | +خطا | +صفحهی محبوب | +تعداد دعوت | +آیپیها | +آخرین حضور | +
|---|---|---|---|---|---|---|---|---|---|
| {{ row.user.phone }} | +{{ row.total_minutes }} | +{{ row.session_count }} | +{{ row.page_view_count }} | +{{ row.api_call_count }} | +{{ row.error_count }} | +{{ row.top_screen }} | +{{ row.referral_count }} | +{{ row.ips|join:", "|default:"—" }} | +{{ row.last_seen|date:"Y-m-d H:i"|default:"—" }} | +
| هنوز کاربری در این بازه فعال نبوده. | |||||||||
- کاسبیار فاکتور رسمی صادر میکند، پیام گروهی با شماره میفرستد - (پیامک، واتساپ، تلگرام، بله، ایتا)، حساب مشتریها و دخلوخرجت را نگه - میدارد، انبارت را زیر نظر میگیرد و نوبتدهیات را مدیریت میکند — - بدون اینکه کاسبی که تازهکار با موبایل است گیج شود. -
-+ شاپ شیپ فاکتور رسمی صادر میکند، پیام گروهی با شماره میفرستد + (پیامک، واتساپ، تلگرام، بله، ایتا)، حساب مشتریها و دخلوخرجت را نگه + میدارد، انبارت را زیر نظر میگیرد و سفارشهایت را پیگیری میکند — + بدون اینکه کاسبی که تازهکار با موبایل است گیج شود. +
+ +هرکدام جدا فعال میشود، پس فقط برای چیزی که لازم داری هزینه میکنی.
-فاکتور رسمی با لوگو، چند نوع مالیات، تخفیف روی هر قلم کالا، و پیگیری پرداختشده/نشده — کاملا محلی روی گوشی.
+فقط برای چیزی که کسبوکارت لازم دارد هزینه میکنی — نه یک بستهی سنگین که نصفش را استفاده نمیکنی.
یک قالب بنویس، به مخاطبینت پیامک/واتساپ/تلگرام/بله/ایتا بفرست — از مسیر رسمی هر پلتفرم، بدون ترفندهای دور زدن قانون.
-برای هر مشتری تاریخچهی خریدش را ببین — خودش از فاکتورهایی که برایش صادر کردی ساخته میشود.
-هرچه وارد و خارج میشود را ثبت کن، نمودار هفتگیاش را ببین، بر اساس درآمد یا هزینه فیلتر کن.
-موجودی کالاهایت را دستهبندی کن، با یک ضربه کموزیاد کن، و وقتی چیزی کم شد هشدار بگیر.
-برای آرایشگاه، مطب، کارگاه یا هر جای دیگری که نوبت میگیرد — لیست نوبتهای امروز و فردا همیشه دم دست.
-قالب کلید-مقدار بساز (مثلا لیست کالاهای روزانه)؛ اپ یکییکی مقدارش را میپرسد و برایت آماده میفرستد.
-برای آنلاینشاپهای کوچک: مراحل کارت را تعریف کن (آمادهسازی، بستهبندی، ارسال، تحویل) و با هر «مرحلهی بعد» یک پیامک خودکار از روی قالب دلخواهت برای مشتری میرود.
-کد معرف خودت را با دوستانت به اشتراک بگذار — هر دوتون جم جایزه میگیرید و میتوانید اشتراک را با آن تمدید کنید.
-قفل اثرانگشت برای اطلاعات حساس کسبوکارت، تاریخ شمسی همهجا، و ظاهری که کار کردن باهاش راحت است.
+فاکتور رسمی با لوگو، چند نوع مالیات، تخفیف روی هر قلم کالا، اتصال به انبار، و پیگیری پرداختشده/نشده.
+یک قالب بنویس، به مخاطبینت پیامک/واتساپ/تلگرام/بله/ایتا بفرست — از مسیر رسمی هر پلتفرم، بدون ترفندهای دور زدن قانون.
+برای هر مشتری تاریخچهی خریدش و ماندهی بدهیاش را ببین — خودش از فاکتورها و دفترت ساخته میشود.
+ثبتِ ساده برای خودت، حسابداریِ درست پشتِ صحنه — فروش قسطی را جدا از نقدی میشناسد، اقساط را پیگیری میکند و خروجی CSV میدهد.
+موجودی کالاهایت را دستهبندی کن، با یک ضربه کموزیاد کن، ارزش کل انبارت را ببین و وقتی چیزی کم شد هشدار بگیر.
+مراحل کارت را تعریف کن (آمادهسازی، بستهبندی، ارسال، تحویل) و با هر «مرحلهی بعد» یک پیامک خودکار از روی قالب دلخواهت برای مشتری میرود.
+برای هر کسی که یک کسبوکار کوچک دارد و نمیخواهد وقتش را صرف نرمافزارهای پیچیده کند.
فاکتور بزن، انبارت را زیر نظر بگیر، به مشتریهایت خبر بده.
نوبتدهی منظم و یادآوری نوبت با پیامک.
+برای هر سفارش، شمارهی مشتری را وارد کن و با «پیگیری سفارش» در هر مرحله خودکار برایش پیامک بفرست.
هر روز با «گزارش سریع» موجودی را به مشتریهای ثابت اعلام کن.
+گزارش روزانهی موجودی را با یک قالبِ آماده به مشتریهای ثابتت بفرست.
از تعمیرکار تا مشاور — حساب مشتری، دخلوخرج و پیام گروهی یکجا.
برای هر سفارش، شمارهی مشتری را وارد کن و با «پیگیری سفارش» در هر مرحله (آمادهسازی، بستهبندی، ارسال، تحویل + کد پیگیری پستی) خودکار برایش پیامک بفرست.
+وقتی جنسی را قسطی میدهی، دفترت خودش میفهمد این یک نوع بدهکاری است، نه فقط یک عدد در لیست درآمد.
فقط شماره موبایل و یک کد تایید — همان لحظه یک هفته رایگان برایت فعال میشود.
+ + +فقط شماره موبایل و یک کد تایید — همان لحظه یک هفته رایگان برایت فعال میشود.
+هر بخش قیمت جدا دارد؛ فقط برای همانی که کسبوکارت لازم دارد هزینه کن.
+فاکتور بزن، پیام بفرست، انبار و دخلوخرجت را ثبت کن — همه از یک اپ.
+هر بخش قیمت جدا دارد؛ فقط برای همانی که کسبوکارت لازم دارد هزینه کن.
+قفل اثرانگشت برای اطلاعات حساس کسبوکارت، و دیتای تو فقط مالِ خودت است.
+هر ابزار را ماهانه یا چندماهه فعال میکنی — هر وقت خواستی تمدید نکن.
+فاکتور، دفتر، پیگیری سفارش — همهجا با تقویم شمسیِ خودمان.
فاکتور بزن، پیام بفرست، انبار و دخلوخرجت را ثبت کن — همه از یک اپ.
-+ شاپ شیپ اشتراکِ همهیاهیچ نمیفروشد. هر بخش (فاکتور، پیام گروهی، + دفتر، انبار، مشتریان، پیگیری سفارش) را جدا و فقط وقتی لازمش داری + با جم فعال میکنی — قیمتِ هر بخش و مدتش داخل خودِ اپ، توی فروشگاه، + شفاف نوشته شده. +
+ شروع رایگانکاسبیار را نصب کن، با شمارهات وارد شو، و همین امروز اولین فاکتورت را بزن.
- +شاپ شیپ را نصب کن، با شمارهات وارد شو، و همین امروز اولین فاکتورت را بزن.
+ + diff --git a/landing/robots.txt b/landing/robots.txt index d09693a..45b79bb 100644 --- a/landing/robots.txt +++ b/landing/robots.txt @@ -1,4 +1,4 @@ User-agent: * Allow: / -Sitemap: https://mobiniqow.ir/sitemap.xml +Sitemap: https://shopship.ir/sitemap.xml diff --git a/landing/sitemap.xml b/landing/sitemap.xml index 568544e..cac3152 100644 --- a/landing/sitemap.xml +++ b/landing/sitemap.xml @@ -1,9 +1,13 @@