first
This commit is contained in:
commit
fa9d154db9
260 files changed
+17643
No files matched your search
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,72 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
|
||||
from apps.staff.models import StaffProfile
|
||||
from apps.staff.forms import StaffProfileAdminForm
|
||||
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
|
||||
|
||||
|
||||
class MagicLinkAccessLogInline(admin.TabularInline):
|
||||
model = MagicLinkAccessLog
|
||||
extra = 0
|
||||
can_delete = False
|
||||
fields = ("opened_at", "ip_address", "user_agent")
|
||||
readonly_fields = fields
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(TicketMagicLink)
|
||||
class TicketMagicLinkAdmin(admin.ModelAdmin):
|
||||
list_display = ("ticket", "recipient_name", "recipient_mobile", "created_unit", "created_by", "created_at", "expires_at", "view_count", "revoked_at")
|
||||
list_filter = ("created_unit", "created_reason", "revoked_at", "expires_at")
|
||||
search_fields = ("ticket__ticket_number", "recipient_name", "recipient_mobile", "created_by__username")
|
||||
readonly_fields = ("ticket", "customer", "token_hash", "created_by", "created_unit", "recipient_name", "recipient_mobile", "created_at", "expires_at", "used_at", "revoked_at", "created_reason", "last_used_ip", "last_user_agent", "view_count")
|
||||
inlines = (MagicLinkAccessLogInline,)
|
||||
|
||||
def get_queryset(self, request):
|
||||
queryset = super().get_queryset(request).select_related("ticket", "created_unit", "created_by")
|
||||
if request.user.is_superuser or request.user.has_perm("accounts.view_all_magic_links"):
|
||||
return queryset
|
||||
unit_ids = request.user.unit_memberships.filter(is_active=True).values_list("unit_id", flat=True)
|
||||
return queryset.filter(created_unit_id__in=unit_ids)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(MagicLinkAccessLog)
|
||||
class MagicLinkAccessLogAdmin(admin.ModelAdmin):
|
||||
list_display = ("magic_link", "opened_at", "ip_address", "user_agent")
|
||||
list_filter = ("opened_at",)
|
||||
search_fields = ("magic_link__ticket__ticket_number", "ip_address", "user_agent")
|
||||
readonly_fields = ("magic_link", "opened_at", "ip_address", "user_agent")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
class StaffProfileInline(admin.StackedInline):
|
||||
model = StaffProfile
|
||||
form = StaffProfileAdminForm
|
||||
extra = 0
|
||||
max_num = 1
|
||||
fields = ("primary_unit", "role", "first_name", "last_name", "display_name", "job_title", "avatar", "mobile", "bale_chat_id", "bale_notifications_enabled", "is_customer_selectable", "sort_order", "is_active")
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
fieldsets = UserAdmin.fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),)
|
||||
add_fieldsets = UserAdmin.add_fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),)
|
||||
list_display = ("username", "mobile", "account_type", "is_active", "is_staff")
|
||||
list_filter = UserAdmin.list_filter + ("account_type",)
|
||||
inlines = (StaffProfileInline,)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if obj.account_type == User.AccountType.STAFF:
|
||||
obj.is_staff = True
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
admin.site.register(OTPChallenge)
|
||||
@@ -0,0 +1,4 @@
|
||||
from django.apps import AppConfig
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "apps.accounts"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from django import forms
|
||||
from django.contrib.auth import authenticate
|
||||
from .models import normalize_mobile
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.core.iran_locations import validate_location
|
||||
|
||||
class MobileForm(forms.Form):
|
||||
mobile = forms.CharField(label="شماره موبایل")
|
||||
def clean_mobile(self):
|
||||
try: return normalize_mobile(self.cleaned_data["mobile"])
|
||||
except ValueError as exc: raise forms.ValidationError(str(exc))
|
||||
class OTPForm(forms.Form): code = forms.CharField(label="کد تایید", min_length=4, max_length=8, widget=forms.TextInput(attrs={"autocomplete":"one-time-code","inputmode":"numeric","pattern":"[0-9۰-۹]*","data-webotp":""}))
|
||||
class CustomerPasswordLoginForm(forms.Form):
|
||||
mobile=forms.CharField(label="شماره موبایل")
|
||||
password=forms.CharField(label="رمز عبور",widget=forms.PasswordInput)
|
||||
def clean_mobile(self):
|
||||
try:return normalize_mobile(self.cleaned_data["mobile"])
|
||||
except ValueError as exc:raise forms.ValidationError(str(exc))
|
||||
def authenticate(self,request):
|
||||
from .models import User
|
||||
user=User.objects.filter(mobile=self.cleaned_data["mobile"],account_type=User.AccountType.CUSTOMER).first()
|
||||
return authenticate(request,username=user.username,password=self.cleaned_data["password"]) if user else None
|
||||
class CustomerProfileForm(forms.ModelForm):
|
||||
password=forms.CharField(label="رمز عبور جدید",required=False,min_length=8,widget=forms.PasswordInput,help_text="برای حفظ رمز فعلی خالی بگذارید.")
|
||||
class Meta:
|
||||
model=CustomerProfile; fields=["first_name","last_name","customer_type","province","city"]; labels={"first_name":"نام","last_name":"نام خانوادگی","customer_type":"نوع مشتری","province":"استان","city":"شهر"}
|
||||
widgets={"province":forms.TextInput(attrs={"list":"profile-province-options","autocomplete":"off","data-province-input":""}),"city":forms.TextInput(attrs={"list":"profile-city-options","autocomplete":"off","data-city-input":""})}
|
||||
def clean(self):
|
||||
data=super().clean(); province,city,error=validate_location(data.get("province"),data.get("city")); data["province"],data["city"]=province,city
|
||||
if error: self.add_error("city" if city else "province",error)
|
||||
return data
|
||||
class StaffLoginForm(forms.Form):
|
||||
username = forms.CharField(label="نام کاربری")
|
||||
password = forms.CharField(label="رمز عبور", widget=forms.PasswordInput)
|
||||
def authenticate(self, request): return authenticate(request, username=self.cleaned_data["username"], password=self.cleaned_data["password"])
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import TicketMagicLink
|
||||
|
||||
|
||||
MAGIC_LINK_SESSION_KEY = "ticket_magic_link_id"
|
||||
|
||||
|
||||
def grant_ticket_magic_access(request, link):
|
||||
"""Grant an anonymous session access to exactly one ticket until link expiry."""
|
||||
request.session.cycle_key()
|
||||
request.session[MAGIC_LINK_SESSION_KEY] = link.pk
|
||||
remaining = max(1, int((link.expires_at - timezone.now()).total_seconds()))
|
||||
request.session.set_expiry(remaining)
|
||||
|
||||
|
||||
def get_ticket_magic_access(request, ticket=None):
|
||||
link_id = request.session.get(MAGIC_LINK_SESSION_KEY)
|
||||
if not link_id:
|
||||
return None
|
||||
filters = {
|
||||
"pk": link_id,
|
||||
"revoked_at__isnull": True,
|
||||
"expires_at__gt": timezone.now(),
|
||||
"customer__is_active": True,
|
||||
}
|
||||
if ticket is not None:
|
||||
filters["ticket_id"] = ticket.pk
|
||||
link = TicketMagicLink.objects.select_related("customer", "ticket").filter(**filters).first()
|
||||
if link is None and ticket is None:
|
||||
request.session.pop(MAGIC_LINK_SESSION_KEY, None)
|
||||
return link
|
||||
@@ -0,0 +1,78 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-05 07:03
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
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='OTPChallenge',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('mobile', models.CharField(db_index=True, max_length=16)),
|
||||
('purpose', models.CharField(choices=[('LOGIN', 'ورود')], default='LOGIN', max_length=20)),
|
||||
('code_hash', models.CharField(max_length=64)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('attempt_count', models.PositiveSmallIntegerField(default=0)),
|
||||
('max_attempts', models.PositiveSmallIntegerField(default=5)),
|
||||
('verified_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('request_ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.CharField(blank=True, max_length=300)),
|
||||
],
|
||||
),
|
||||
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')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('mobile', models.CharField(blank=True, max_length=16, null=True, unique=True)),
|
||||
('account_type', models.CharField(choices=[('CUSTOMER', 'مشتری'), ('STAFF', 'کارشناس')], default='CUSTOMER', max_length=10)),
|
||||
('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={
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketMagicLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token_hash', models.CharField(max_length=64, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_reason', models.CharField(blank=True, max_length=100)),
|
||||
('last_used_ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-05 07:03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
('tickets', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ticketmagiclink',
|
||||
name='ticket',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='magic_links', to='tickets.ticket'),
|
||||
),
|
||||
]
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-08 09:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MagicLinkAccessLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('opened_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.CharField(blank=True, max_length=500)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'بازدید لینک جادویی',
|
||||
'verbose_name_plural': 'بازدیدهای لینک جادویی',
|
||||
'ordering': ['-opened_at'],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='ticketmagiclink',
|
||||
options={'permissions': [('view_all_magic_links', 'Can audit magic links across all units')]},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketmagiclink',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='created_magic_links', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-08 09:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more'),
|
||||
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ticketmagiclink',
|
||||
name='created_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='magic_links', to='staff.organizationalunit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketmagiclink',
|
||||
name='last_user_agent',
|
||||
field=models.CharField(blank=True, max_length=500),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketmagiclink',
|
||||
name='recipient_mobile',
|
||||
field=models.CharField(blank=True, max_length=16),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketmagiclink',
|
||||
name='recipient_name',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketmagiclink',
|
||||
name='view_count',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='magiclinkaccesslog',
|
||||
name='magic_link',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_logs', to='accounts.ticketmagiclink'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-08 15:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0004_ticketmagiclink_created_unit_and_more'),
|
||||
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
|
||||
('tickets', '0006_assign_legacy_contacts_to_sales'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='magiclinkaccesslog',
|
||||
index=models.Index(fields=['magic_link', 'opened_at'], name='magic_opened_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ticketmagiclink',
|
||||
index=models.Index(fields=['created_unit', 'created_at'], name='magic_unit_created_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ticketmagiclink',
|
||||
index=models.Index(fields=['expires_at', 'revoked_at'], name='magic_validity_idx'),
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,157 @@
|
||||
import hashlib, secrets, string
|
||||
from datetime import timedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.utils import timezone
|
||||
from apps.core.models import PublicIdModel, TimeStampedModel
|
||||
|
||||
def normalize_mobile(value: str) -> str:
|
||||
value = "".join(c for c in (value or "").strip() if c.isdigit() or c == "+")
|
||||
if value.startswith("0098"): value = "+98" + value[4:]
|
||||
elif value.startswith("98") and not value.startswith("+98"): value = "+" + value
|
||||
elif value.startswith("0"): value = "+98" + value[1:]
|
||||
if not (value.startswith("+989") and len(value) == 13 and value[1:].isdigit()):
|
||||
raise ValueError("شماره موبایل معتبر نیست.")
|
||||
return value
|
||||
|
||||
class User(PublicIdModel, AbstractUser):
|
||||
class AccountType(models.TextChoices):
|
||||
CUSTOMER = "CUSTOMER", "مشتری"
|
||||
STAFF = "STAFF", "کارشناس"
|
||||
mobile = models.CharField(max_length=16, unique=True, null=True, blank=True)
|
||||
account_type = models.CharField(max_length=10, choices=AccountType.choices, default=AccountType.CUSTOMER)
|
||||
def save(self, *args, **kwargs):
|
||||
if self.mobile: self.mobile = normalize_mobile(self.mobile)
|
||||
super().save(*args, **kwargs)
|
||||
@property
|
||||
def local_mobile(self):
|
||||
return f"0{self.mobile[3:]}" if self.mobile and self.mobile.startswith("+98") else (self.mobile or "")
|
||||
|
||||
class OTPChallenge(models.Model):
|
||||
class Purpose(models.TextChoices): LOGIN = "LOGIN", "ورود"
|
||||
mobile = models.CharField(max_length=16, db_index=True)
|
||||
purpose = models.CharField(max_length=20, choices=Purpose.choices, default=Purpose.LOGIN)
|
||||
code_hash = models.CharField(max_length=64)
|
||||
expires_at = models.DateTimeField()
|
||||
attempt_count = models.PositiveSmallIntegerField(default=0)
|
||||
max_attempts = models.PositiveSmallIntegerField(default=5)
|
||||
verified_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
request_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.CharField(max_length=300, blank=True)
|
||||
@staticmethod
|
||||
def hash_code(code): return hashlib.sha256(f"{settings.SECRET_KEY}:{code}".encode()).hexdigest()
|
||||
@classmethod
|
||||
def issue(cls, mobile, request=None):
|
||||
mobile = normalize_mobile(mobile)
|
||||
request_ip = request.META.get("REMOTE_ADDR") if request else None
|
||||
window = timezone.now() - timedelta(seconds=settings.OTP_RATE_LIMIT_WINDOW_SECONDS)
|
||||
if cls.objects.filter(mobile=mobile, created_at__gte=window).count() >= settings.OTP_RATE_LIMIT_PER_MOBILE:
|
||||
raise ValueError("تعداد درخواست کد برای این شماره بیش از حد مجاز است.")
|
||||
if request_ip and cls.objects.filter(request_ip=request_ip, created_at__gte=window).count() >= settings.OTP_RATE_LIMIT_PER_IP:
|
||||
raise ValueError("تعداد درخواست کد از این اتصال بیش از حد مجاز است.")
|
||||
cutoff = timezone.now() - timedelta(seconds=settings.OTP_RESEND_SECONDS)
|
||||
if cls.objects.filter(mobile=mobile, created_at__gt=cutoff).exists(): raise ValueError("برای ارسال مجدد کمی صبر کنید.")
|
||||
is_dev_login = settings.DEBUG and mobile == settings.DEV_OTP_MOBILE and bool(settings.DEV_OTP_CODE)
|
||||
code = settings.DEV_OTP_CODE if is_dev_login else "".join(secrets.choice("0123456789") for _ in range(settings.OTP_LENGTH))
|
||||
obj = cls.objects.create(mobile=mobile, code_hash=cls.hash_code(code), expires_at=timezone.now()+timedelta(seconds=settings.OTP_EXPIRE_SECONDS), max_attempts=settings.OTP_MAX_ATTEMPTS, request_ip=request_ip, user_agent=(request.META.get("HTTP_USER_AGENT", "")[:300] if request else ""))
|
||||
return obj, code
|
||||
def verify(self, code):
|
||||
if self.verified_at or timezone.now() >= self.expires_at or self.attempt_count >= self.max_attempts: return False
|
||||
self.attempt_count += 1
|
||||
ok = secrets.compare_digest(self.code_hash, self.hash_code(code))
|
||||
if ok: self.verified_at = timezone.now()
|
||||
self.save(update_fields=["attempt_count", "verified_at"])
|
||||
return ok
|
||||
|
||||
class TicketMagicLink(models.Model):
|
||||
ticket = models.ForeignKey("tickets.Ticket", on_delete=models.CASCADE, related_name="magic_links")
|
||||
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
token_hash = models.CharField(max_length=64, unique=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="created_magic_links",
|
||||
)
|
||||
created_unit = models.ForeignKey(
|
||||
"staff.OrganizationalUnit",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="magic_links",
|
||||
)
|
||||
recipient_name = models.CharField(max_length=200, blank=True)
|
||||
recipient_mobile = models.CharField(max_length=16, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
used_at = models.DateTimeField(null=True, blank=True)
|
||||
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||
created_reason = models.CharField(max_length=100, blank=True)
|
||||
last_used_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
last_user_agent = models.CharField(max_length=500, blank=True)
|
||||
view_count = models.PositiveIntegerField(default=0)
|
||||
class Meta:
|
||||
permissions = [("view_all_magic_links", "Can audit magic links across all units")]
|
||||
indexes = [models.Index(fields=["created_unit", "created_at"], name="magic_unit_created_idx"), models.Index(fields=["expires_at", "revoked_at"], name="magic_validity_idx")]
|
||||
@staticmethod
|
||||
def digest(token): return hashlib.sha256(token.encode()).hexdigest()
|
||||
@staticmethod
|
||||
def generate_secure_token(length=10):
|
||||
alphabet=string.ascii_lowercase+string.digits
|
||||
return "".join(secrets.SystemRandom().choices(alphabet,k=length))
|
||||
@classmethod
|
||||
def issue_with_record(cls,ticket,reason="staff_reply",max_retries=5,created_by=None):
|
||||
# Never add a preflight SELECT to token issuance; callers may select_related
|
||||
# the profile when they need a populated recipient-name snapshot.
|
||||
profile = ticket.customer._state.fields_cache.get("customer_profile")
|
||||
recipient_name = " ".join(
|
||||
value for value in [getattr(profile, "first_name", ""), getattr(profile, "last_name", "")] if value
|
||||
).strip()
|
||||
for attempt in range(max_retries):
|
||||
token=cls.generate_secure_token(10)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
record=cls.objects.create(
|
||||
ticket=ticket,
|
||||
customer=ticket.customer,
|
||||
token_hash=cls.digest(token),
|
||||
expires_at=timezone.now()+timedelta(days=settings.MAGIC_LINK_TTL_DAYS),
|
||||
created_reason=reason,
|
||||
created_by=created_by or ticket.created_by,
|
||||
created_unit=ticket.owning_unit,
|
||||
recipient_name=recipient_name,
|
||||
recipient_mobile=ticket.customer.mobile or "",
|
||||
)
|
||||
return token,record
|
||||
except IntegrityError:
|
||||
if attempt==max_retries-1: raise RuntimeError("خطای نادر: عدم موفقیت در تولید توکن یکتا.")
|
||||
raise RuntimeError("خطای نادر: عدم موفقیت در تولید توکن یکتا.")
|
||||
@classmethod
|
||||
def issue(cls,ticket,reason="staff_reply"):
|
||||
token,_=cls.issue_with_record(ticket,reason)
|
||||
return token
|
||||
|
||||
|
||||
class MagicLinkAccessLog(models.Model):
|
||||
"""Immutable audit row for every valid magic-link opening."""
|
||||
|
||||
magic_link = models.ForeignKey(
|
||||
TicketMagicLink,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="access_logs",
|
||||
)
|
||||
opened_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.CharField(max_length=500, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-opened_at"]
|
||||
indexes = [models.Index(fields=["magic_link", "opened_at"], name="magic_opened_idx")]
|
||||
verbose_name = "بازدید لینک جادویی"
|
||||
verbose_name_plural = "بازدیدهای لینک جادویی"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.magic_link_id} @ {self.opened_at}"
|
||||
@@ -0,0 +1,4 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
app_name = "accounts"
|
||||
urlpatterns = [path("login/", views.customer_login, name="login"),path("login/password/",views.customer_password_login,name="password_login"), path("login/otp/", views.verify_otp, name="otp"),path("profile/",views.customer_profile,name="profile"), path("logout/", views.logout_view, name="logout"), path("staff/login/", views.staff_login, name="staff_login"), path("staff/logout/", views.logout_view, name="staff_logout"), path("t/<str:token>/", views.consume_magic_link, name="magic")]
|
||||
@@ -0,0 +1,131 @@
|
||||
from django.contrib import messages
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import login, logout
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.decorators.http import require_POST
|
||||
from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm
|
||||
from apps.customers.models import CustomerProfile
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
|
||||
from .magic_access import grant_ticket_magic_access
|
||||
from apps.integrations.sms.providers import SMSResult, get_sms_provider
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _authenticated_home(request):
|
||||
if not request.user.is_authenticated: return None
|
||||
return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "tickets:list")
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def customer_login(request):
|
||||
if response:=_authenticated_home(request): return response
|
||||
form = MobileForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
challenge = None
|
||||
try:
|
||||
challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request)
|
||||
is_fixed_dev_otp = settings.DEBUG and challenge.mobile == settings.DEV_OTP_MOBILE and code == settings.DEV_OTP_CODE
|
||||
result = SMSResult(True, "fixed-dev-otp") if is_fixed_dev_otp else get_sms_provider().send_otp(challenge.mobile, code)
|
||||
if not result.success:
|
||||
raise RuntimeError(result.error or "سرویس پیامک درخواست را نپذیرفت.")
|
||||
request.session["otp_challenge_id"] = challenge.pk
|
||||
return redirect("accounts:otp")
|
||||
except ValueError as exc: form.add_error(None, str(exc))
|
||||
except Exception:
|
||||
if challenge is not None:
|
||||
challenge.delete()
|
||||
logger.exception("OTP SMS delivery failed")
|
||||
form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.")
|
||||
return render(request, "accounts/customer_login.html", {"form": form})
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def verify_otp(request):
|
||||
if response:=_authenticated_home(request): return response
|
||||
challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id"))
|
||||
form = OTPForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
if challenge.verify(form.cleaned_data["code"]):
|
||||
user, created = User.objects.get_or_create(mobile=challenge.mobile, defaults={"username": f"customer_{challenge.mobile[3:]}", "account_type": User.AccountType.CUSTOMER})
|
||||
if created: user.set_unusable_password(); user.save(update_fields=["password"])
|
||||
CustomerProfile.objects.get_or_create(user=user)
|
||||
if not user.is_active: form.add_error(None, "حساب شما غیرفعال است.")
|
||||
else:
|
||||
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
return redirect(request.session.pop("next", "tickets:list"))
|
||||
else: form.add_error("code", "کد نامعتبر یا منقضی است.")
|
||||
return render(request, "accounts/otp.html", {"form": form, "mobile": challenge.mobile})
|
||||
|
||||
@require_http_methods(["GET","POST"])
|
||||
def customer_password_login(request):
|
||||
if response:=_authenticated_home(request): return response
|
||||
form=CustomerPasswordLoginForm(request.POST or None)
|
||||
if request.method=="POST" and form.is_valid():
|
||||
user=form.authenticate(request)
|
||||
if user and user.is_active:
|
||||
login(request,user); return redirect(request.session.pop("next","tickets:list"))
|
||||
form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.")
|
||||
return render(request,"accounts/customer_password_login.html",{"form":form})
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET","POST"])
|
||||
def customer_profile(request):
|
||||
if request.user.account_type!=User.AccountType.CUSTOMER: return redirect("staff_tickets:list")
|
||||
profile,_=CustomerProfile.objects.get_or_create(user=request.user)
|
||||
form=CustomerProfileForm(request.POST or None,instance=profile)
|
||||
if request.method=="POST" and form.is_valid():
|
||||
form.save(); password=form.cleaned_data.get("password")
|
||||
if password: request.user.set_password(password); request.user.save(update_fields=["password"]); login(request,request.user,backend="django.contrib.auth.backends.ModelBackend")
|
||||
messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile")
|
||||
return render(request,"accounts/profile.html",{"form":form})
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def staff_login(request):
|
||||
if response:=_authenticated_home(request): return response
|
||||
form = StaffLoginForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
user = form.authenticate(request)
|
||||
if user and user.is_active and user.account_type == User.AccountType.STAFF:
|
||||
login(request, user); return redirect("staff_tickets:list")
|
||||
form.add_error(None, "اطلاعات ورود صحیح نیست.")
|
||||
return render(request, "accounts/staff_login.html", {"form": form})
|
||||
|
||||
@require_POST
|
||||
def logout_view(request):
|
||||
logout(request)
|
||||
request.session.flush()
|
||||
response=redirect("accounts:login")
|
||||
response["Cache-Control"]="no-store"
|
||||
return response
|
||||
|
||||
def consume_magic_link(request, token):
|
||||
link = TicketMagicLink.objects.select_related("customer", "ticket").filter(token_hash=TicketMagicLink.digest(token)).first()
|
||||
if not link or link.revoked_at or link.expires_at <= timezone.now():
|
||||
request.session["next"] = f"/tickets/{link.ticket.public_id}/" if link else "/tickets/"
|
||||
messages.error(request, "این لینک منقضی شده یا اشتباه است. لطفاً با شماره موبایل خود وارد شوید تا بتوانید پیامها را ببینید.")
|
||||
return redirect("accounts:login")
|
||||
if request.user.is_authenticated:
|
||||
if request.user.pk != link.customer_id:
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied
|
||||
else:
|
||||
grant_ticket_magic_access(request, link)
|
||||
ip_address = request.META.get("REMOTE_ADDR")
|
||||
user_agent = request.META.get("HTTP_USER_AGENT", "")[:500]
|
||||
with transaction.atomic():
|
||||
MagicLinkAccessLog.objects.create(
|
||||
magic_link=link,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
TicketMagicLink.objects.filter(pk=link.pk).update(
|
||||
used_at=timezone.now(),
|
||||
last_used_ip=ip_address,
|
||||
last_user_agent=user_agent,
|
||||
view_count=F("view_count") + 1,
|
||||
)
|
||||
response = redirect("tickets:detail", public_id=link.ticket.public_id); response["Referrer-Policy"] = "no-referrer"; return response
|
||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
from .models import Attachment,AttachmentPolicy
|
||||
admin.site.register([Attachment,AttachmentPolicy])
|
||||
@@ -0,0 +1,2 @@
|
||||
from django.apps import AppConfig
|
||||
class AttachmentsConfig(AppConfig): name="apps.attachments"
|
||||
@@ -0,0 +1,21 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
|
||||
def signed_media_redirect(relative_path, *, download=False, lifetime=300):
|
||||
"""Create an Nginx secure_link URL after Django has authorized the request."""
|
||||
safe_path = quote(str(relative_path).replace("\\", "/").lstrip("/"), safe="/")
|
||||
uri = f"/protected-media/{safe_path}"
|
||||
expires = int(time.time()) + lifetime
|
||||
source = f"{expires}{uri} {settings.MEDIA_SIGNING_SECRET}".encode()
|
||||
digest = hashlib.md5(source, usedforsecurity=False).digest()
|
||||
signature = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
||||
query = {"md5": signature, "expires": expires}
|
||||
if download:
|
||||
query["download"] = "1"
|
||||
return HttpResponseRedirect(f"{uri}?{urlencode(query)}")
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-05 07:03
|
||||
|
||||
import apps.attachments.models
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Attachment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('file', models.FileField(upload_to=apps.attachments.models.attachment_path)),
|
||||
('original_name', models.CharField(max_length=255)),
|
||||
('stored_name', models.CharField(max_length=255)),
|
||||
('relative_path', models.CharField(max_length=500)),
|
||||
('attachment_type', models.CharField(choices=[('IMAGE', 'تصویر'), ('VIDEO', 'ویدئو'), ('VOICE', 'صدا'), ('PDF', 'PDF'), ('WORD', 'Word'), ('EXCEL', 'Excel'), ('ZIP', 'ZIP'), ('RAR', 'RAR'), ('OTHER', 'سایر')], max_length=20)),
|
||||
('mime_type', models.CharField(max_length=150)),
|
||||
('extension', models.CharField(max_length=20)),
|
||||
('file_size', models.PositiveBigIntegerField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'permissions': [('download_attachment', 'Can download attachment'), ('manage_attachment_policy', 'Can manage attachment policy')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AttachmentPolicy',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment_type', models.CharField(max_length=20, unique=True)),
|
||||
('is_enabled', models.BooleanField(default=True)),
|
||||
('max_file_size', models.PositiveBigIntegerField()),
|
||||
('max_files_per_message', models.PositiveSmallIntegerField(default=10)),
|
||||
('allowed_extensions', models.JSONField(default=list)),
|
||||
('allowed_mime_types', models.JSONField(default=list)),
|
||||
('sort_order', models.PositiveIntegerField(default=0)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-05 07:03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('attachments', '0001_initial'),
|
||||
('messaging', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='attachment',
|
||||
name='message',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attachments', to='messaging.message'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='attachment',
|
||||
name='uploaded_by',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-05 08:31
|
||||
|
||||
import apps.attachments.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('attachments', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='attachment',
|
||||
name='file',
|
||||
field=models.FileField(max_length=500, upload_to=apps.attachments.models.attachment_path),
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,14 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from apps.core.models import PublicIdModel
|
||||
def attachment_path(instance,filename):
|
||||
ext=Path(filename).suffix.lower(); t=instance.message.ticket; m=instance.message
|
||||
return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
|
||||
class AttachmentPolicy(models.Model):
|
||||
attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True)
|
||||
class Attachment(PublicIdModel,models.Model):
|
||||
class Type(models.TextChoices): IMAGE="IMAGE","تصویر"; VIDEO="VIDEO","ویدئو"; VOICE="VOICE","صدا"; PDF="PDF","PDF"; WORD="WORD","Word"; EXCEL="EXCEL","Excel"; ZIP="ZIP","ZIP"; RAR="RAR","RAR"; OTHER="OTHER","سایر"
|
||||
message=models.ForeignKey("messaging.Message",on_delete=models.PROTECT,related_name="attachments"); file=models.FileField(upload_to=attachment_path,max_length=500); original_name=models.CharField(max_length=255); stored_name=models.CharField(max_length=255); relative_path=models.CharField(max_length=500); attachment_type=models.CharField(max_length=20,choices=Type.choices); mime_type=models.CharField(max_length=150); extension=models.CharField(max_length=20); file_size=models.PositiveBigIntegerField(); uploaded_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT); created_at=models.DateTimeField(auto_now_add=True)
|
||||
class Meta: permissions=[("download_attachment","Can download attachment"),("manage_attachment_policy","Can manage attachment policy")]
|
||||
@@ -0,0 +1,32 @@
|
||||
import mimetypes
|
||||
import magic
|
||||
from pathlib import Path
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import Attachment,AttachmentPolicy
|
||||
TYPE_EXT={"IMAGE":{".jpg",".jpeg",".png",".gif",".webp"},"VIDEO":{".mp4",".webm",".mov"},"VOICE":{".webm",".ogg",".mp3",".m4a",".wav"},"PDF":{".pdf"},"WORD":{".doc",".docx"},"EXCEL":{".xls",".xlsx"},"ZIP":{".zip"},"RAR":{".rar"}}
|
||||
def classify(ext,mime):
|
||||
if ext==".webm" and str(mime).startswith("audio/"): return "VOICE"
|
||||
for kind,extensions in TYPE_EXT.items():
|
||||
if ext in extensions:return kind
|
||||
return "OTHER"
|
||||
def save_attachments(message,files,user):
|
||||
files=list(files); result=[]
|
||||
counts={}
|
||||
for upload in files:
|
||||
ext=Path(Path(upload.name).name).suffix.lower(); kind=classify(ext,getattr(upload,"content_type", "")); counts[kind]=counts.get(kind,0)+1
|
||||
for kind,count in counts.items():
|
||||
policy=AttachmentPolicy.objects.filter(attachment_type=kind,is_enabled=True).first()
|
||||
if not policy: raise ValidationError(f"نوع فایل {kind} مجاز نیست.")
|
||||
if count>policy.max_files_per_message: raise ValidationError(f"تعداد فایلهای {kind} بیش از حد مجاز است.")
|
||||
for upload in files:
|
||||
name=Path(upload.name).name[:255]; ext=Path(name).suffix.lower(); declared=getattr(upload,"content_type","") or mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
head=upload.read(8192); upload.seek(0); detected=magic.from_buffer(head,mime=True) or "application/octet-stream"; kind=classify(ext,declared)
|
||||
policy=AttachmentPolicy.objects.filter(attachment_type=kind,is_enabled=True).first()
|
||||
if not policy: raise ValidationError(f"نوع فایل {ext or name} مجاز نیست.")
|
||||
if upload.size>policy.max_file_size: raise ValidationError(f"حجم فایل {name} بیش از حد مجاز است.")
|
||||
if policy.allowed_extensions and ext not in policy.allowed_extensions: raise ValidationError("پسوند فایل مجاز نیست.")
|
||||
if policy.allowed_mime_types and detected not in policy.allowed_mime_types: raise ValidationError("نوع محتوای واقعی فایل مجاز نیست.")
|
||||
compatible={"IMAGE":detected.startswith("image/"),"VIDEO":detected.startswith("video/"),"VOICE":detected.startswith("audio/") or detected in {"video/webm","application/ogg"},"PDF":detected=="application/pdf","ZIP":detected in {"application/zip","application/x-zip-compressed"}}.get(kind,True)
|
||||
if not compatible: raise ValidationError(f"محتوای واقعی فایل {name} با پسوند آن سازگار نیست.")
|
||||
obj=Attachment(message=message,file=upload,original_name=name,stored_name="pending",relative_path="pending",attachment_type=kind,mime_type=detected,extension=ext,file_size=upload.size,uploaded_by=user); obj.save(); obj.stored_name=Path(obj.file.name).name; obj.relative_path=obj.file.name; obj.save(update_fields=["stored_name","relative_path"]); result.append(obj)
|
||||
return result
|
||||
@@ -0,0 +1,4 @@
|
||||
from django.urls import path
|
||||
from .views import download, view
|
||||
app_name="attachments"
|
||||
urlpatterns=[path("<uuid:public_id>/view/",view,name="view"),path("<uuid:public_id>/download/",download,name="download")]
|
||||
@@ -0,0 +1,30 @@
|
||||
import mimetypes
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import FileResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from apps.accounts.magic_access import get_ticket_magic_access
|
||||
from .delivery import signed_media_redirect
|
||||
from .models import Attachment
|
||||
def _authorized_attachment(request,public_id):
|
||||
attachment=get_object_or_404(Attachment.objects.select_related("message__ticket"),public_id=public_id); ticket=attachment.message.ticket
|
||||
magic_link = None if request.user.is_authenticated else get_ticket_magic_access(request,ticket)
|
||||
if not TicketPolicy.can_view(request.user,ticket) and not magic_link: raise PermissionDenied
|
||||
if attachment.message.is_internal and (magic_link or getattr(request.user,"account_type",None)=="CUSTOMER"): raise PermissionDenied
|
||||
if attachment.message.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied
|
||||
return attachment
|
||||
def view(request,public_id):
|
||||
attachment=_authorized_attachment(request,public_id)
|
||||
if settings.USE_NGINX_MEDIA:
|
||||
return signed_media_redirect(attachment.relative_path)
|
||||
content_type=attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
|
||||
response=FileResponse(attachment.file.open("rb"),content_type=content_type)
|
||||
response["Content-Disposition"]=f'inline; filename="{attachment.original_name}"'
|
||||
response["X-Content-Type-Options"]="nosniff"
|
||||
return response
|
||||
def download(request,public_id):
|
||||
attachment=_authorized_attachment(request,public_id)
|
||||
if settings.USE_NGINX_MEDIA:
|
||||
return signed_media_redirect(attachment.relative_path, download=True)
|
||||
return FileResponse(attachment.file.open("rb"),as_attachment=True,filename=attachment.original_name)
|
||||
Whitespace-only changes.
@@ -0,0 +1,37 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import PageTitle, SiteBranding
|
||||
|
||||
|
||||
@admin.register(SiteBranding)
|
||||
class SiteBrandingAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
("عمومی", {"fields": ("site_title", "header_title", "header_subtitle")}),
|
||||
("لوگو و آیکونها", {"fields": ("company_logo", "favicon", "pwa_icon"), "description": "در صورت خالی بودن هر فیلد، فایل پیشفرض فعلی استفاده میشود."}),
|
||||
("PWA", {"fields": ("pwa_name", "pwa_title", "pwa_description", "pwa_install_label", "pwa_install_help")}),
|
||||
("پنل کارشناسان", {"fields": ("staff_brand_title", "staff_brand_subtitle", "staff_panel_label")}),
|
||||
("صفحه ورود مشتری", {"fields": ("login_intro_enabled", "login_intro_icon", "login_intro_eyebrow", "login_intro_heading", "login_intro_body")}),
|
||||
("صفحه ورود کارشناسان", {"fields": ("staff_login_intro_enabled", "staff_login_intro_icon", "staff_login_intro_eyebrow", "staff_login_intro_heading", "staff_login_intro_body")}),
|
||||
("صفحه تیکتهای مشتری", {"fields": ("customer_tickets_hero_enabled", "customer_tickets_hero_eyebrow", "customer_tickets_hero_heading", "customer_tickets_hero_body")}),
|
||||
("امضای توسعهدهنده", {"fields": ("developer_credit_text", "developer_credit_html", "use_developer_credit_html"), "description": "HTML خام تنها برای محتوای کاملاً مورد اعتماد فعال شود."}),
|
||||
("امضای مستقل sidebar کارکنان", {"fields": ("staff_sidebar_credit_text", "staff_sidebar_credit_html", "use_staff_sidebar_credit_html"), "description": "این مقدار فقط در پایین sidebar پنل staff نمایش داده میشود."}),
|
||||
)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if request.user.is_superuser:
|
||||
return ()
|
||||
return ("developer_credit_html", "use_developer_credit_html", "staff_sidebar_credit_html", "use_staff_sidebar_credit_html")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return not SiteBranding.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(PageTitle)
|
||||
class PageTitleAdmin(admin.ModelAdmin):
|
||||
list_display = ("route_name", "title", "is_active", "updated_at")
|
||||
list_editable = ("title", "is_active")
|
||||
search_fields = ("route_name", "title")
|
||||
list_filter = ("is_active",)
|
||||
@@ -0,0 +1,4 @@
|
||||
from django.apps import AppConfig
|
||||
class CoreConfig(AppConfig):
|
||||
name = "apps.core"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from django.conf import settings
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.templatetags.static import static
|
||||
|
||||
from .models import PageTitle, SiteBranding
|
||||
|
||||
|
||||
def site_branding(request):
|
||||
"""Expose editable branding and the title configured for the current route."""
|
||||
try:
|
||||
branding = SiteBranding.load()
|
||||
route_name = getattr(getattr(request, "resolver_match", None), "view_name", "") or ""
|
||||
configured = PageTitle.objects.filter(route_name=route_name, is_active=True).values_list("title", flat=True).first()
|
||||
except (OperationalError, ProgrammingError, RuntimeError):
|
||||
branding = SiteBranding()
|
||||
configured = None
|
||||
developer_credit = (
|
||||
mark_safe(branding.developer_credit_html)
|
||||
if branding.use_developer_credit_html and branding.developer_credit_html.strip()
|
||||
else branding.developer_credit_text
|
||||
)
|
||||
staff_sidebar_credit = (
|
||||
mark_safe(branding.staff_sidebar_credit_html)
|
||||
if branding.use_staff_sidebar_credit_html and branding.staff_sidebar_credit_html.strip()
|
||||
else branding.staff_sidebar_credit_text
|
||||
)
|
||||
def asset_url(field, fallback):
|
||||
try:
|
||||
return field.url if field else static(fallback)
|
||||
except (ValueError, OSError):
|
||||
return static(fallback)
|
||||
branding_version = str(int(branding.updated_at.timestamp())) if branding.pk and branding.updated_at else "default"
|
||||
return {
|
||||
"site_branding": branding,
|
||||
"page_title": configured or branding.site_title,
|
||||
"developer_credit": developer_credit,
|
||||
"staff_sidebar_credit": staff_sidebar_credit,
|
||||
"branding_version": branding_version,
|
||||
"company_logo_url": asset_url(branding.company_logo, "images/company-logo.png"),
|
||||
"favicon_url": asset_url(branding.favicon, "images/favicon.ico"),
|
||||
"pwa_icon_url": asset_url(branding.pwa_icon, "images/pwa-icon.png"),
|
||||
}
|
||||
|
||||
|
||||
def static_asset_version(request):
|
||||
"""Change frontend and worker versions whenever static files or templates change."""
|
||||
roots = (settings.BASE_DIR / "static", settings.BASE_DIR / "templates")
|
||||
mtimes = (path.stat().st_mtime_ns for root in roots if root.exists() for path in root.rglob("*") if path.is_file())
|
||||
return {"static_asset_version": str(max(mtimes, default=0))}
|
||||
|
||||
|
||||
def staff_operations_access(request):
|
||||
"""Expose unit-scoped navigation capabilities without hardcoded roles."""
|
||||
user = request.user
|
||||
if not getattr(user, "is_authenticated", False) or getattr(user, "account_type", "") != "STAFF":
|
||||
return {}
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
|
||||
def allowed(permission):
|
||||
return user.is_superuser or user.has_perm(permission) or any(TicketPolicy.has_unit_permission(user, item.unit, permission) for item in memberships)
|
||||
return {"staff_operations_access": {
|
||||
"create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"),
|
||||
"contacts": allowed("customers.view_customerprofile"),
|
||||
"dispatches": allowed("tickets.create_bulk_dispatch") or allowed("tickets.create_inquiry") or allowed("tickets.dispatch_group_tickets"),
|
||||
"templates": allowed("notifications.view_smstemplate") or allowed("notifications.add_smstemplate"),
|
||||
"audit": user.has_perm("accounts.view_all_magic_links") or allowed("accounts.view_ticketmagiclink"),
|
||||
}}
|
||||
@@ -0,0 +1,36 @@
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def location_data():
|
||||
path = Path(settings.BASE_DIR) / "static" / "data" / "iran-locations.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def provinces():
|
||||
return tuple(location_data())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def cities_by_province():
|
||||
return {item["province"]: tuple(item["cities"]) for item in location_data()}
|
||||
|
||||
|
||||
def validate_location(province, city):
|
||||
province = (province or "").strip()
|
||||
city = (city or "").strip()
|
||||
if not province and not city:
|
||||
return province, city, None
|
||||
if city and not province:
|
||||
return province, city, "برای انتخاب شهر، ابتدا استان را انتخاب کنید."
|
||||
cities = cities_by_province()
|
||||
if province not in cities:
|
||||
return province, city, "استان انتخابشده معتبر نیست."
|
||||
if city and city not in cities[province]:
|
||||
return province, city, "شهر انتخابشده متعلق به این استان نیست."
|
||||
return province, city, None
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,147 @@
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.attachments.models import AttachmentPolicy
|
||||
from apps.tickets.models import SLAConfig, TicketCategory
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import ContactGroup
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile, UnitMembership
|
||||
from apps.tickets.category_catalog import CATEGORY_CATALOG, UNIT_CONTENT
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create initial categories, policies and permission groups"
|
||||
|
||||
def merge_legacy_unit(self, legacy_slug, canonical_slug, canonical_name, canonical_description):
|
||||
legacy = OrganizationalUnit.objects.filter(slug=legacy_slug).first()
|
||||
canonical = OrganizationalUnit.objects.filter(slug=canonical_slug).first()
|
||||
if legacy is None:
|
||||
return
|
||||
if canonical is None:
|
||||
legacy.slug = canonical_slug
|
||||
legacy.name = canonical_name
|
||||
legacy.description = canonical_description
|
||||
legacy.save()
|
||||
return
|
||||
|
||||
from apps.accounts.models import TicketMagicLink
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from apps.tickets.models import GroupDispatch, Ticket
|
||||
|
||||
canonical.permissions.add(*legacy.permissions.all())
|
||||
TicketCategory.objects.filter(unit=legacy).update(unit=canonical)
|
||||
Ticket.objects.filter(owning_unit=legacy).update(owning_unit=canonical)
|
||||
Ticket.objects.filter(assigned_unit=legacy).update(assigned_unit=canonical)
|
||||
GroupDispatch.objects.filter(unit=legacy).update(unit=canonical)
|
||||
TicketMagicLink.objects.filter(created_unit=legacy).update(created_unit=canonical)
|
||||
StaffProfile.objects.filter(primary_unit=legacy).update(primary_unit=canonical)
|
||||
for membership in UnitMembership.objects.filter(unit=legacy):
|
||||
existing = UnitMembership.objects.filter(user=membership.user, unit=canonical).first()
|
||||
if existing is None:
|
||||
membership.unit = canonical
|
||||
membership.save(update_fields=["unit", "updated_at"])
|
||||
continue
|
||||
existing.permissions.add(*membership.permissions.all())
|
||||
existing.is_active = existing.is_active or membership.is_active
|
||||
existing.is_primary = existing.is_primary or membership.is_primary
|
||||
if membership.ticket_scope == UnitMembership.TicketScope.UNIT:
|
||||
existing.ticket_scope = UnitMembership.TicketScope.UNIT
|
||||
existing.save(update_fields=["is_active", "is_primary", "ticket_scope", "updated_at"])
|
||||
membership.delete()
|
||||
for group in ContactGroup.objects.filter(unit=legacy):
|
||||
existing = ContactGroup.objects.filter(unit=canonical, name=group.name).first()
|
||||
if existing is None:
|
||||
group.unit = canonical
|
||||
group.save(update_fields=["unit", "updated_at"])
|
||||
continue
|
||||
existing.contacts.add(*group.contacts.all())
|
||||
GroupDispatch.objects.filter(contact_group=group).update(contact_group=existing)
|
||||
group.delete()
|
||||
for template in SMSTemplate.objects.filter(unit=legacy):
|
||||
if SMSTemplate.objects.filter(unit=canonical, event_type=template.event_type).exists():
|
||||
template.delete()
|
||||
else:
|
||||
template.unit = canonical
|
||||
template.save(update_fields=["unit", "updated_at"])
|
||||
legacy.delete()
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
self.merge_legacy_unit("accounting", "finance", "مالی / حسابداری", "واحد مالی و حسابداری (یک مفهوم سازمانی)")
|
||||
self.merge_legacy_unit("logistics", "warehouse-loading", "بارگیری انبار", "بارگیری، انبار و ارسال")
|
||||
self.merge_legacy_unit("warehouse", "warehouse-loading", "بارگیری انبار", "بارگیری، انبار و ارسال")
|
||||
categories = [("استعلام قیمت","price"),("درصد تخفیف","discount"),("شرایط فروش","sales-terms"),("موجودی","inventory"),("شرایط ارسال","delivery"),("درخواست نمایندگی","agency"),("شکایت","complaint"),("مشاوره","consulting"),("سایر","other")]
|
||||
for i, (title, slug) in enumerate(categories):
|
||||
TicketCategory.objects.get_or_create(slug=slug, defaults={"title":title,"sort_order":i})
|
||||
sizes = {"IMAGE":10,"VOICE":25,"VIDEO":100,"PDF":30,"WORD":30,"EXCEL":30,"ZIP":50,"RAR":50}
|
||||
exts = {"IMAGE":[".jpg",".jpeg",".png",".gif",".webp"],"VOICE":[".webm",".ogg",".mp3",".m4a",".wav"],"VIDEO":[".mp4",".webm",".mov"],"PDF":[".pdf"],"WORD":[".doc",".docx"],"EXCEL":[".xls",".xlsx"],"ZIP":[".zip"],"RAR":[".rar"]}
|
||||
for i, (kind, mb) in enumerate(sizes.items()):
|
||||
AttachmentPolicy.objects.get_or_create(attachment_type=kind, defaults={"max_file_size":mb*1024*1024,"allowed_extensions":exts[kind],"sort_order":i})
|
||||
for priority, first, resolution in [("NORMAL",240,2880),("IMPORTANT",120,1440),("URGENT",30,480)]:
|
||||
SLAConfig.objects.get_or_create(priority=priority, defaults={"first_response_minutes":first,"resolution_minutes":resolution})
|
||||
expert_codes = ["view_ticket","add_ticket","claim_ticket","transfer_ticket","share_ticket","change_ticket","add_internal_note","edit_own_message","delete_own_message","download_attachment"]
|
||||
supervisor_codes = expert_codes + ["view_message_revisions","change_ticket_priority","view_customer_history"]
|
||||
manager_codes = supervisor_codes + ["view_deleted_message_content","delete_ticket"]
|
||||
role_permissions = {
|
||||
"Sales Expert": expert_codes,
|
||||
"Sales Supervisor": supervisor_codes,
|
||||
"Sales Manager": manager_codes,
|
||||
"Warehouse Expert": expert_codes,
|
||||
"Finance Expert": expert_codes,
|
||||
"CEO": manager_codes + ["view_all_tickets", "create_bulk_dispatch", "view_all_bulk_dispatches", "create_inquiry", "view_all_inquiries"],
|
||||
"General Admin": manager_codes + ["view_all_tickets"],
|
||||
}
|
||||
for group_name, codes in role_permissions.items():
|
||||
group, _ = Group.objects.get_or_create(name=group_name)
|
||||
group.permissions.set(Permission.objects.filter(codename__in=codes))
|
||||
units = {}
|
||||
for order, (slug, name, description, external, customer_visible) in enumerate([
|
||||
("sales", "فروش", UNIT_CONTENT["sales"]["description"], True, True),
|
||||
("warehouse-loading", "بارگیری انبار", "عملیات داخلی بارگیری و انبار", True, False),
|
||||
("finance", "مالی / حسابداری", UNIT_CONTENT["finance"]["description"], True, True),
|
||||
("executive", "مدیریت ارشد", "مدیریت عامل", False, False),
|
||||
], 10):
|
||||
unit, _ = OrganizationalUnit.objects.update_or_create(slug=slug, defaults={"name": name, "description": description, "search_keywords": UNIT_CONTENT.get(slug, {}).get("search_keywords", ""), "is_active": True, "accepts_external_tickets": external, "show_in_customer_form": customer_visible, "sort_order": order})
|
||||
units[slug] = unit
|
||||
for unit_slug, rows in CATEGORY_CATALOG.items():
|
||||
for sort_order, slug, title, description, search_keywords in rows:
|
||||
TicketCategory.objects.update_or_create(slug=slug, defaults={"unit": units[unit_slug], "title": title, "description": description, "search_keywords": search_keywords, "sort_order": sort_order, "is_active": True, "show_in_customer_form": True})
|
||||
ContactGroup.objects.get_or_create(unit=units[unit_slug], name=f"مخاطبان {units[unit_slug].name}")
|
||||
TicketCategory.objects.update_or_create(
|
||||
slug="warehouse-loading-general",
|
||||
defaults={"unit": units["warehouse-loading"], "title": "عملیات بارگیری انبار", "description": "هماهنگی داخلی عملیات انبار و بارگیری.", "search_keywords": "انبار بارگیری عملیات داخلی", "sort_order": 10, "is_active": True, "show_in_customer_form": False},
|
||||
)
|
||||
TicketCategory.objects.filter(slug__in=("other", "e2e-sms")).update(show_in_customer_form=False)
|
||||
unit_permission_pairs = [
|
||||
("tickets", "view_ticket"), ("tickets", "add_ticket"), ("tickets", "claim_ticket"),
|
||||
("messaging", "add_message"), ("customers", "view_customerprofile"),
|
||||
("customers", "add_customerprofile"), ("customers", "change_customerprofile"),
|
||||
("customers", "view_contactgroup"), ("customers", "add_contactgroup"), ("customers", "change_contactgroup"),
|
||||
]
|
||||
for unit in (units["sales"], units["warehouse-loading"], units["finance"]):
|
||||
unit.permissions.add(*[Permission.objects.get(content_type__app_label=app, codename=code) for app, code in unit_permission_pairs])
|
||||
people = [
|
||||
("kimiya_ghaffari", "کیمیا", "غفاری", "کیمیا غفاری", StaffProfile.Role.SALES_EXPERT, "sales", "کارشناس فروش", UnitMembership.TicketScope.SELF),
|
||||
("zahra_jamali", "زهرا", "جمالی", "زهرا جمالی", StaffProfile.Role.SALES_EXPERT, "sales", "کارشناس فروش", UnitMembership.TicketScope.SELF),
|
||||
("maryam_fotovat", "مریم", "فتوت", "مریم فتوت", StaffProfile.Role.SALES_SUPERVISOR, "sales", "سرپرست فروش", UnitMembership.TicketScope.UNIT),
|
||||
("morteza_sokhaeifard", "مرتضی", "سخائی فرد", "مرتضی سخائی فرد", StaffProfile.Role.SALES_MANAGER, "sales", "مدیر فروش", UnitMembership.TicketScope.UNIT),
|
||||
("mostafa_sokhaeifard", "مصطفی", "سخائی فرد", "مصطفی سخائی فرد", StaffProfile.Role.CEO, "executive", "مدیرعامل", UnitMembership.TicketScope.UNIT),
|
||||
("ms_sabzali", "", "سبزعلی", "خانم سبزعلی", StaffProfile.Role.WAREHOUSE_EXPERT, "warehouse-loading", "کارشناس بارگیری انبار", UnitMembership.TicketScope.UNIT),
|
||||
("mehdi_azami", "مهدی", "عظمی", "آقای مهدی عظمی", StaffProfile.Role.WAREHOUSE_EXPERT, "warehouse-loading", "کارشناس بارگیری انبار", UnitMembership.TicketScope.UNIT),
|
||||
("fereshteh_yavari", "فرشته", "یاوری", "خانم فرشته یاوری", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
|
||||
("fatemeh_sokhaei", "فاطمه", "سخایی", "خانم فاطمه سخایی", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
|
||||
("rana_nobakht", "رعنا", "نوبخت", "خانم رعنا نوبخت", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
|
||||
("mehdi_liaqat", "مهدی", "لیاقت", "آقای مهدی لیاقت", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
|
||||
("aliasghar_sokhaeifard", "علی اصغر", "سخائی فرد", "علی اصغر سخائی فرد", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
|
||||
]
|
||||
for username, first_name, last_name, display_name, role, unit_slug, title, scope in people:
|
||||
user, created = User.objects.get_or_create(username=username, defaults={"account_type": User.AccountType.STAFF})
|
||||
user.account_type = User.AccountType.STAFF; user.first_name = first_name; user.last_name = last_name; user.is_active = True
|
||||
if created: user.set_unusable_password()
|
||||
user.save()
|
||||
profile, _ = StaffProfile.objects.update_or_create(user=user, defaults={"primary_unit": units[unit_slug], "role": role, "display_name": display_name, "first_name": first_name, "last_name": last_name, "job_title": title, "is_active": True})
|
||||
membership = UnitMembership.objects.get(user=user, unit=units[unit_slug])
|
||||
membership.ticket_scope = scope; membership.is_primary = True; membership.is_active = True; membership.save()
|
||||
user.unit_memberships.exclude(pk=membership.pk).delete()
|
||||
sabzali = User.objects.get(username="ms_sabzali")
|
||||
sabzali.unit_memberships.get(unit=units["warehouse-loading"]).permissions.add(*Permission.objects.filter(content_type__app_label="tickets", codename__in=["create_bulk_dispatch", "create_inquiry"]))
|
||||
ceo = User.objects.get(username="mostafa_sokhaeifard")
|
||||
ceo.user_permissions.add(*Permission.objects.filter(content_type__app_label="tickets", codename__in=["view_all_tickets", "add_ticket", "create_bulk_dispatch", "view_all_bulk_dispatches", "create_inquiry", "view_all_inquiries"]))
|
||||
self.stdout.write(self.style.SUCCESS("Initial data created."))
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.messaging.models import Message, MessageRevision
|
||||
from apps.staff.models import StaffProfile
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Normalize existing human-authored Persian fields using the model policy."
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
specs = (
|
||||
(CustomerProfile, CustomerProfile.persian_text_fields),
|
||||
(StaffProfile, StaffProfile.persian_text_fields),
|
||||
(TicketCategory, TicketCategory.persian_text_fields),
|
||||
(Ticket, Ticket.persian_text_fields),
|
||||
(Message, Message.persian_text_fields),
|
||||
(MessageRevision, MessageRevision.persian_text_fields),
|
||||
)
|
||||
changed = 0
|
||||
for model, fields in specs:
|
||||
for instance in model.objects.all().iterator():
|
||||
before = tuple(getattr(instance, name) for name in fields)
|
||||
instance.save(update_fields=[*fields])
|
||||
after = tuple(getattr(instance, name) for name in fields)
|
||||
changed += before != after
|
||||
self.stdout.write(self.style.SUCCESS(f"Normalized {changed} existing records."))
|
||||
@@ -0,0 +1,13 @@
|
||||
class NoStoreHTMLMiddleware:
|
||||
"""Prevent browsers and installed PWAs from retaining authenticated HTML views."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
if response.get("Content-Type", "").lower().startswith("text/html"):
|
||||
response["Cache-Control"] = "private, no-cache, no-store, must-revalidate"
|
||||
response["Pragma"] = "no-cache"
|
||||
response["Expires"] = "0"
|
||||
return response
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-09 03:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PageTitle',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('route_name', models.CharField(help_text='مانند accounts:login', max_length=160, unique=True)),
|
||||
('title', models.CharField(max_length=240)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'عنوان صفحه',
|
||||
'verbose_name_plural': 'عنوان صفحات',
|
||||
'ordering': ('route_name',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SiteBranding',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('site_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)),
|
||||
('header_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)),
|
||||
('header_subtitle', models.CharField(default='درخواست روشن، پیگیری مطمئن', max_length=200)),
|
||||
('staff_brand_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)),
|
||||
('staff_brand_subtitle', models.CharField(default='مرکز ارتباط با مشتری', max_length=200)),
|
||||
('staff_panel_label', models.CharField(default='پنل کارشناسان', max_length=120)),
|
||||
('pwa_title', models.CharField(default='واحد فروش', max_length=80)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'تنظیمات عنوان و برند',
|
||||
'verbose_name_plural': 'تنظیمات عنوان و برند',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
PAGE_TITLES = {
|
||||
"accounts:login": "ورود مشتری | گفتوگو با واحد فروش",
|
||||
"accounts:password_login": "ورود با رمز عبور | گفتوگو با واحد فروش",
|
||||
"accounts:otp": "تأیید شماره موبایل | گفتوگو با واحد فروش",
|
||||
"accounts:profile": "پروفایل من | گفتوگو با واحد فروش",
|
||||
"accounts:staff_login": "ورود کارشناسان | گفتوگو با واحد فروش",
|
||||
"accounts:magic": "مشاهده امن تیکت | گفتوگو با واحد فروش",
|
||||
"tickets:list": "درخواستهای من | گفتوگو با واحد فروش",
|
||||
"tickets:new": "درخواست جدید | گفتوگو با واحد فروش",
|
||||
"tickets:detail": "جزئیات درخواست | گفتوگو با واحد فروش",
|
||||
"staff_tickets:list": "میز کار | پنل کارشناسان",
|
||||
"staff_tickets:create": "ثبت تیکت برای مشتری | پنل کارشناسان",
|
||||
"staff_tickets:detail": "جزئیات تیکت | پنل کارشناسان",
|
||||
"staff_tickets:contacts": "مخاطبان | پنل کارشناسان",
|
||||
"staff_tickets:customers": "مخاطبان | پنل کارشناسان",
|
||||
"staff_tickets:contact_create": "مخاطب جدید | پنل کارشناسان",
|
||||
"staff_tickets:contact_edit": "ویرایش مخاطب | پنل کارشناسان",
|
||||
"staff_tickets:groups": "گروههای مخاطبان | پنل کارشناسان",
|
||||
"staff_tickets:sms_templates": "الگوهای پیامک | پنل کارشناسان",
|
||||
"staff_tickets:sms_template_edit": "ویرایش الگوی پیامک | پنل کارشناسان",
|
||||
"staff_tickets:dispatch_create": "ارسال گروهی و ردیابی | پنل کارشناسان",
|
||||
"staff_tickets:dispatch_detail": "گزارش ارسال گروهی | پنل کارشناسان",
|
||||
"staff_tickets:magic_audit": "ممیزی لینکهای جادویی | پنل کارشناسان",
|
||||
"messages:revisions": "تاریخچه ویرایش پیام | گفتوگو با واحد فروش",
|
||||
}
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
apps.get_model("core", "SiteBranding").objects.get_or_create(pk=1)
|
||||
model = apps.get_model("core", "PageTitle")
|
||||
for route_name, title in PAGE_TITLES.items():
|
||||
model.objects.get_or_create(route_name=route_name, defaults={"title": title})
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0001_initial")]
|
||||
operations = [migrations.RunPython(seed, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-09 04:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_seed_branding_and_page_titles'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='developer_credit_html',
|
||||
field=models.TextField(blank=True, help_text='فقط برای HTML مورد اعتماد؛ لینک، آیکون و style خطی مجاز است.', verbose_name='HTML سفارشی امضای توسعه\u200cدهنده'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='developer_credit_text',
|
||||
field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت | 09202202129', max_length=500, verbose_name='متن امضای توسعه\u200cدهنده'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='login_intro_body',
|
||||
field=models.TextField(default='درخواست خود را ثبت کنید، فایل و پیام صوتی بفرستید و پاسخ کارشناس را در یک فضای امن دنبال کنید.', verbose_name='متن معرفی ورود'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='login_intro_enabled',
|
||||
field=models.BooleanField(default=True, verbose_name='نمایش بخش معرفی ورود'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='login_intro_eyebrow',
|
||||
field=models.CharField(default='ارتباط مستقیم با تیم فروش', max_length=200, verbose_name='بالانویس معرفی ورود'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='login_intro_heading',
|
||||
field=models.TextField(default='پاسخ روشن،\nپیگیری ساده.', verbose_name='عنوان معرفی ورود'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='login_intro_icon',
|
||||
field=models.CharField(blank=True, default='✦', max_length=32, verbose_name='آیکون معرفی ورود'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='use_developer_credit_html',
|
||||
field=models.BooleanField(default=False, help_text='با فعال\u200cسازی، HTML بدون escape نمایش داده می\u200cشود؛ فقط مدیر ارشد باید آن را ویرایش کند.', verbose_name='استفاده از HTML سفارشی'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-09 05:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_sitebranding_developer_credit_html_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_sidebar_credit_html',
|
||||
field=models.TextField(blank=True, help_text='مستقل از امضای عمومی؛ فقط برای HTML مورد اعتماد.', verbose_name='HTML سفارشی امضای sidebar کارکنان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_sidebar_credit_text',
|
||||
field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت', max_length=500, verbose_name='متن امضای پایین sidebar کارکنان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='use_staff_sidebar_credit_html',
|
||||
field=models.BooleanField(default=False, verbose_name='استفاده از HTML سفارشی در sidebar'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-09 05:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_sitebranding_staff_sidebar_credit_html_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='company_logo',
|
||||
field=models.ImageField(blank=True, upload_to='branding/logo/', verbose_name='لوگوی اصلی'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='customer_tickets_hero_body',
|
||||
field=models.TextField(default='موضوع جدیدی دارید؟ آن را جدا ثبت کنید تا تیم فروش دقیق\u200cتر و سریع\u200cتر پیگیری کند.', verbose_name='متن صفحه تیکت\u200cهای مشتری'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='customer_tickets_hero_enabled',
|
||||
field=models.BooleanField(default=True, verbose_name='نمایش معرفی صفحه تیکت\u200cهای مشتری'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='customer_tickets_hero_eyebrow',
|
||||
field=models.CharField(default='مرکز ارتباط با فروش', max_length=200, verbose_name='بالانویس صفحه تیکت\u200cهای مشتری'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='customer_tickets_hero_heading',
|
||||
field=models.TextField(default='هر درخواست، یک مسیر روشن\nتا رسیدن به پاسخ.', verbose_name='عنوان صفحه تیکت\u200cهای مشتری'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='favicon',
|
||||
field=models.ImageField(blank=True, upload_to='branding/favicon/', verbose_name='Favicon'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='pwa_description',
|
||||
field=models.CharField(default='ثبت و پیگیری درخواست\u200cهای فروش لوله گستر گلپایگان', max_length=300, verbose_name='توضیح PWA'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='pwa_icon',
|
||||
field=models.ImageField(blank=True, upload_to='branding/pwa/', verbose_name='آیکون PWA'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='pwa_install_help',
|
||||
field=models.CharField(default='برای دسترسی سریع، برنامه را به صفحه اصلی گوشی اضافه کنید.', max_length=300, verbose_name='راهنمای نصب PWA'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='pwa_install_label',
|
||||
field=models.CharField(default='نصب گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='عنوان پیشنهاد نصب PWA'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='pwa_name',
|
||||
field=models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='نام کامل PWA'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_login_intro_body',
|
||||
field=models.TextField(default='صف درخواست\u200cها، SLA، اولویت\u200cها و سوابق مشتری را از یک میز کار متمرکز مدیریت کنید.', verbose_name='متن ورود کارشناسان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_login_intro_enabled',
|
||||
field=models.BooleanField(default=True, verbose_name='نمایش معرفی ورود کارشناسان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_login_intro_eyebrow',
|
||||
field=models.CharField(default='پنل کارشناسان فروش', max_length=200, verbose_name='بالانویس ورود کارشناسان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_login_intro_heading',
|
||||
field=models.TextField(default='پاسخ\u200cهای بهتر،\nفروش هوشمندتر.', verbose_name='عنوان ورود کارشناسان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_login_intro_icon',
|
||||
field=models.CharField(blank=True, default='◈', max_length=32, verbose_name='آیکون معرفی ورود کارشناسان'),
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,127 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
from .persian_text import normalize_persian_text
|
||||
|
||||
class PersianTextNormalizationMixin:
|
||||
"""Normalize only explicitly listed human-authored model fields on save."""
|
||||
persian_text_fields = {}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get("update_fields")
|
||||
for field_name, multiline in self.persian_text_fields.items():
|
||||
if update_fields is None or field_name in update_fields:
|
||||
value = getattr(self, field_name, None)
|
||||
if value is not None:
|
||||
setattr(
|
||||
self,
|
||||
field_name,
|
||||
normalize_persian_text(value, multiline=multiline),
|
||||
)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
class TimeStampedModel(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
class PublicIdModel(models.Model):
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class SiteBranding(models.Model):
|
||||
"""Database-backed copy shared by customer, authentication and staff pages."""
|
||||
site_title = models.CharField(max_length=160, default="گفتوگو با واحد فروش")
|
||||
header_title = models.CharField(max_length=160, default="گفتوگو با واحد فروش")
|
||||
header_subtitle = models.CharField(max_length=200, default="درخواست روشن، پیگیری مطمئن")
|
||||
staff_brand_title = models.CharField(max_length=160, default="گفتوگو با واحد فروش")
|
||||
staff_brand_subtitle = models.CharField(max_length=200, default="مرکز ارتباط با مشتری")
|
||||
staff_panel_label = models.CharField(max_length=120, default="پنل کارشناسان")
|
||||
pwa_title = models.CharField(max_length=80, default="واحد فروش")
|
||||
company_logo = models.ImageField(upload_to="branding/logo/", blank=True, verbose_name="لوگوی اصلی")
|
||||
favicon = models.ImageField(upload_to="branding/favicon/", blank=True, verbose_name="Favicon")
|
||||
pwa_icon = models.ImageField(upload_to="branding/pwa/", blank=True, verbose_name="آیکون PWA")
|
||||
pwa_name = models.CharField(max_length=160, default="گفتوگو با واحد فروش", verbose_name="نام کامل PWA")
|
||||
pwa_description = models.CharField(max_length=300, default="ثبت و پیگیری درخواستهای فروش لوله گستر گلپایگان", verbose_name="توضیح PWA")
|
||||
pwa_install_label = models.CharField(max_length=160, default="نصب گفتوگو با واحد فروش", verbose_name="عنوان پیشنهاد نصب PWA")
|
||||
pwa_install_help = models.CharField(max_length=300, default="برای دسترسی سریع، برنامه را به صفحه اصلی گوشی اضافه کنید.", verbose_name="راهنمای نصب PWA")
|
||||
login_intro_enabled = models.BooleanField(default=True, verbose_name="نمایش بخش معرفی ورود")
|
||||
login_intro_icon = models.CharField(max_length=32, default="✦", blank=True, verbose_name="آیکون معرفی ورود")
|
||||
login_intro_eyebrow = models.CharField(max_length=200, default="ارتباط مستقیم با تیم فروش", verbose_name="بالانویس معرفی ورود")
|
||||
login_intro_heading = models.TextField(default="پاسخ روشن،\nپیگیری ساده.", verbose_name="عنوان معرفی ورود")
|
||||
login_intro_body = models.TextField(
|
||||
default="درخواست خود را ثبت کنید، فایل و پیام صوتی بفرستید و پاسخ کارشناس را در یک فضای امن دنبال کنید.",
|
||||
verbose_name="متن معرفی ورود",
|
||||
)
|
||||
staff_login_intro_enabled = models.BooleanField(default=True, verbose_name="نمایش معرفی ورود کارشناسان")
|
||||
staff_login_intro_icon = models.CharField(max_length=32, default="◈", blank=True, verbose_name="آیکون معرفی ورود کارشناسان")
|
||||
staff_login_intro_eyebrow = models.CharField(max_length=200, default="پنل کارشناسان فروش", verbose_name="بالانویس ورود کارشناسان")
|
||||
staff_login_intro_heading = models.TextField(default="پاسخهای بهتر،\nفروش هوشمندتر.", verbose_name="عنوان ورود کارشناسان")
|
||||
staff_login_intro_body = models.TextField(default="صف درخواستها، SLA، اولویتها و سوابق مشتری را از یک میز کار متمرکز مدیریت کنید.", verbose_name="متن ورود کارشناسان")
|
||||
customer_tickets_hero_enabled = models.BooleanField(default=True, verbose_name="نمایش معرفی صفحه تیکتهای مشتری")
|
||||
customer_tickets_hero_eyebrow = models.CharField(max_length=200, default="مرکز ارتباط با فروش", verbose_name="بالانویس صفحه تیکتهای مشتری")
|
||||
customer_tickets_hero_heading = models.TextField(default="هر درخواست، یک مسیر روشن\nتا رسیدن به پاسخ.", verbose_name="عنوان صفحه تیکتهای مشتری")
|
||||
customer_tickets_hero_body = models.TextField(default="موضوع جدیدی دارید؟ آن را جدا ثبت کنید تا تیم فروش دقیقتر و سریعتر پیگیری کند.", verbose_name="متن صفحه تیکتهای مشتری")
|
||||
developer_credit_text = models.CharField(
|
||||
max_length=500,
|
||||
default="برنامه نویسی و توسعه: محمد جواد فصاحت | 09202202129",
|
||||
verbose_name="متن امضای توسعهدهنده",
|
||||
)
|
||||
developer_credit_html = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="HTML سفارشی امضای توسعهدهنده",
|
||||
help_text="فقط برای HTML مورد اعتماد؛ لینک، آیکون و style خطی مجاز است.",
|
||||
)
|
||||
use_developer_credit_html = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="استفاده از HTML سفارشی",
|
||||
help_text="با فعالسازی، HTML بدون escape نمایش داده میشود؛ فقط مدیر ارشد باید آن را ویرایش کند.",
|
||||
)
|
||||
staff_sidebar_credit_text = models.CharField(
|
||||
max_length=500,
|
||||
default="برنامه نویسی و توسعه: محمد جواد فصاحت",
|
||||
verbose_name="متن امضای پایین sidebar کارکنان",
|
||||
)
|
||||
staff_sidebar_credit_html = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="HTML سفارشی امضای sidebar کارکنان",
|
||||
help_text="مستقل از امضای عمومی؛ فقط برای HTML مورد اعتماد.",
|
||||
)
|
||||
use_staff_sidebar_credit_html = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="استفاده از HTML سفارشی در sidebar",
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "تنظیمات عنوان و برند"
|
||||
verbose_name_plural = "تنظیمات عنوان و برند"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
return cls.objects.filter(pk=1).first() or cls()
|
||||
|
||||
def __str__(self):
|
||||
return self.site_title
|
||||
|
||||
|
||||
class PageTitle(models.Model):
|
||||
"""Editable browser title for a named Django route."""
|
||||
route_name = models.CharField(max_length=160, unique=True, help_text="مانند accounts:login")
|
||||
title = models.CharField(max_length=240)
|
||||
is_active = models.BooleanField(default=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("route_name",)
|
||||
verbose_name = "عنوان صفحه"
|
||||
verbose_name_plural = "عنوان صفحات"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.route_name}: {self.title}"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Safe, conservative Persian text normalization for human-authored fields."""
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from hazm import Normalizer
|
||||
|
||||
|
||||
# Hazm's spacing rules are intended for prose and can add spaces inside URLs and
|
||||
# technical identifiers. Temporarily replace those spans before normalization.
|
||||
_PROTECTED_SPAN = re.compile(
|
||||
r"https?://\S+|www\.\S+|[\w.+-]+@[\w.-]+\.\w+|"
|
||||
r"(?<![\w\u0600-\u06ff])[A-Za-z0-9][A-Za-z0-9._:/?#%+\\=@-]*"
|
||||
)
|
||||
_TOKEN_PREFIX = "HAZMPROTECTEDTOKEN"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _normalizer():
|
||||
return Normalizer(
|
||||
remove_diacritics=False,
|
||||
remove_specials_chars=False,
|
||||
decrease_repeated_chars=False,
|
||||
persian_numbers=False,
|
||||
)
|
||||
|
||||
|
||||
def _alpha_index(index):
|
||||
"""Return an ASCII-letter-only stable suffix (A, B, ..., AA)."""
|
||||
result = ""
|
||||
while True:
|
||||
index, remainder = divmod(index, 26)
|
||||
result = chr(65 + remainder) + result
|
||||
if index == 0:
|
||||
return result
|
||||
index -= 1
|
||||
|
||||
|
||||
def normalize_persian_text(value, *, multiline=False):
|
||||
"""Normalize Persian orthography without rewriting meaning or technical data.
|
||||
|
||||
Multiline values retain paragraph boundaries. Empty/None values become an
|
||||
empty string, matching the project's non-null textual model fields.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
|
||||
protected = {}
|
||||
|
||||
def protect(match):
|
||||
token = f"{_TOKEN_PREFIX}{_alpha_index(len(protected))}"
|
||||
protected[token] = match.group(0)
|
||||
return token
|
||||
|
||||
text = _PROTECTED_SPAN.sub(protect, text)
|
||||
if multiline:
|
||||
normalized = "\n".join(
|
||||
_normalizer().normalize(line).strip() if line.strip() else ""
|
||||
for line in text.split("\n")
|
||||
).strip()
|
||||
else:
|
||||
normalized = _normalizer().normalize(" ".join(text.splitlines())).strip()
|
||||
|
||||
for token, original in protected.items():
|
||||
normalized = normalized.replace(token, original)
|
||||
return normalized
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Presentation-only Jalali filters; persisted datetimes stay Gregorian/UTC."""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
import jdatetime
|
||||
from django import template
|
||||
from django.utils import timezone
|
||||
|
||||
register = template.Library()
|
||||
_PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||
|
||||
|
||||
def _persian_digits(value):
|
||||
return str(value).translate(_PERSIAN_DIGITS)
|
||||
|
||||
|
||||
def _local_gregorian(value):
|
||||
if isinstance(value, datetime) and timezone.is_aware(value):
|
||||
return timezone.localtime(value)
|
||||
return value
|
||||
|
||||
|
||||
def _jalali(value):
|
||||
value = _local_gregorian(value)
|
||||
if isinstance(value, datetime):
|
||||
return jdatetime.datetime.fromgregorian(datetime=value, locale=jdatetime.FA_LOCALE)
|
||||
if isinstance(value, date):
|
||||
return jdatetime.date.fromgregorian(date=value, locale=jdatetime.FA_LOCALE)
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
def jalali_datetime(value):
|
||||
"""Example: ۱۵ شهریور ۱۴۰۵، ساعت ۰۹:۳۰."""
|
||||
converted = _jalali(value)
|
||||
if converted is None:
|
||||
return ""
|
||||
pattern = "%d %B %Y، ساعت %H:%M" if isinstance(converted, jdatetime.datetime) else "%d %B %Y"
|
||||
return _persian_digits(converted.strftime(pattern))
|
||||
|
||||
|
||||
@register.filter
|
||||
def jalali_date(value):
|
||||
"""Example: ۱۵ شهریور ۱۴۰۵."""
|
||||
converted = _jalali(value)
|
||||
return _persian_digits(converted.strftime("%d %B %Y")) if converted else ""
|
||||
|
||||
|
||||
@register.filter
|
||||
def persian_time(value):
|
||||
"""Display local Iran time using Persian digits."""
|
||||
value = _local_gregorian(value)
|
||||
return _persian_digits(value.strftime("%H:%M")) if isinstance(value, datetime) else ""
|
||||
Whitespace-only changes.
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
from .models import ContactGroup, CustomerProfile
|
||||
|
||||
|
||||
@admin.register(ContactGroup)
|
||||
class ContactGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "unit", "is_active", "updated_at")
|
||||
list_filter = ("unit", "is_active")
|
||||
search_fields = ("name", "description")
|
||||
|
||||
|
||||
@admin.register(CustomerProfile)
|
||||
class CustomerProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name")
|
||||
list_filter = ("stakeholder_type", "groups")
|
||||
search_fields = ("user__mobile", "organization_name", "first_name", "last_name")
|
||||
filter_horizontal = ("groups",)
|
||||
@@ -0,0 +1,2 @@
|
||||
from django.apps import AppConfig
|
||||
class CustomersConfig(AppConfig): name = "apps.customers"
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-05 07:03
|
||||
|
||||
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='CustomerProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('first_name', models.CharField(blank=True, max_length=100)),
|
||||
('last_name', models.CharField(blank=True, max_length=100)),
|
||||
('customer_type', models.CharField(blank=True, choices=[('PERSON', 'شخص'), ('CONSUMER', 'مصرف\u200cکننده نهایی'), ('SHOP', 'فروشگاه'), ('REPRESENTATIVE', 'نمایندگی'), ('COMPANY', 'شرکت'), ('OTHER', 'سایر')], max_length=20)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='customer_profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("customers", "0001_initial")]
|
||||
operations = [
|
||||
migrations.AddField(model_name="customerprofile", name="city", field=models.CharField(blank=True, max_length=100)),
|
||||
migrations.AddField(model_name="customerprofile", name="province", field=models.CharField(blank=True, max_length=100)),
|
||||
]
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-08 09:57
|
||||
|
||||
import apps.core.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0002_customerprofile_city_customerprofile_province'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ContactGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=150)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'گروه مخاطبان',
|
||||
'verbose_name_plural': 'گروه\u200cهای مخاطبان',
|
||||
'ordering': ['unit__name', 'name'],
|
||||
},
|
||||
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customerprofile',
|
||||
name='organization_name',
|
||||
field=models.CharField(blank=True, max_length=180),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customerprofile',
|
||||
name='stakeholder_type',
|
||||
field=models.CharField(choices=[('CUSTOMER', 'مشتری'), ('SUPPLIER', 'تامین\u200cکننده'), ('CARRIER', 'باربری'), ('OTHER', 'سایر')], db_index=True, default='CUSTOMER', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-08 09:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0003_contactgroup_customerprofile_organization_name_and_more'),
|
||||
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contactgroup',
|
||||
name='unit',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_groups', to='staff.organizationalunit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customerprofile',
|
||||
name='groups',
|
||||
field=models.ManyToManyField(blank=True, related_name='contacts', to='customers.contactgroup'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contactgroup',
|
||||
constraint=models.UniqueConstraint(fields=('unit', 'name'), name='unique_contact_group_name_per_unit'),
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,54 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
|
||||
|
||||
|
||||
class ContactGroup(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
"""A unit-owned address-book group; contacts may belong to many groups."""
|
||||
|
||||
persian_text_fields = {"name": False, "description": True}
|
||||
name = models.CharField(max_length=150)
|
||||
unit = models.ForeignKey(
|
||||
"staff.OrganizationalUnit",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="contact_groups",
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["unit", "name"], name="unique_contact_group_name_per_unit")
|
||||
]
|
||||
ordering = ["unit__name", "name"]
|
||||
verbose_name = "گروه مخاطبان"
|
||||
verbose_name_plural = "گروههای مخاطبان"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.unit}: {self.name}"
|
||||
|
||||
|
||||
class CustomerProfile(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
persian_text_fields = {"first_name": False, "last_name": False, "organization_name": False, "province": False, "city": False}
|
||||
class Type(models.TextChoices):
|
||||
PERSON="PERSON","شخص"; CONSUMER="CONSUMER","مصرفکننده نهایی"; SHOP="SHOP","فروشگاه"; REPRESENTATIVE="REPRESENTATIVE","نمایندگی"; COMPANY="COMPANY","شرکت"; OTHER="OTHER","سایر"
|
||||
class StakeholderType(models.TextChoices):
|
||||
CUSTOMER = "CUSTOMER", "مشتری"
|
||||
SUPPLIER = "SUPPLIER", "تامینکننده"
|
||||
CARRIER = "CARRIER", "باربری"
|
||||
OTHER = "OTHER", "سایر"
|
||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="customer_profile")
|
||||
first_name = models.CharField(max_length=100, blank=True)
|
||||
last_name = models.CharField(max_length=100, blank=True)
|
||||
customer_type = models.CharField(max_length=20, choices=Type.choices, blank=True)
|
||||
stakeholder_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=StakeholderType.choices,
|
||||
default=StakeholderType.CUSTOMER,
|
||||
db_index=True,
|
||||
)
|
||||
organization_name = models.CharField(max_length=180, blank=True)
|
||||
groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts")
|
||||
province = models.CharField(max_length=100, blank=True)
|
||||
city = models.CharField(max_length=100, blank=True)
|
||||
def __str__(self): return (f"{self.first_name} {self.last_name}".strip() or self.user.mobile or self.user.username)
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,30 @@
|
||||
from dataclasses import dataclass
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
@dataclass
|
||||
class NotificationResult:
|
||||
success: bool
|
||||
provider_id: str = ""
|
||||
error: str = ""
|
||||
|
||||
class ConsoleBaleProvider:
|
||||
def send_message(self, chat_id, text):
|
||||
if settings.DEBUG: print(f"[DEV BALE] {chat_id or 'dashboard'}: {text}")
|
||||
return NotificationResult(True, "console")
|
||||
|
||||
class BaleBotProvider:
|
||||
def send_message(self, chat_id, text):
|
||||
if not chat_id: return NotificationResult(False, error="Bale chat id is not configured")
|
||||
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
|
||||
response.raise_for_status(); data = response.json()
|
||||
if not data.get("ok", True): return NotificationResult(False, error=str(data.get("description", "Bale rejected message")))
|
||||
return NotificationResult(True, str((data.get("result") or {}).get("message_id", "bale")))
|
||||
|
||||
def get_bale_provider():
|
||||
if settings.BALE_BOT_TOKEN:
|
||||
return BaleBotProvider()
|
||||
if settings.DEBUG:
|
||||
return ConsoleBaleProvider()
|
||||
raise ImproperlyConfigured("BALE_BOT_TOKEN is required to deliver Bale notifications in production.")
|
||||
Whitespace-only changes.
@@ -0,0 +1,18 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
from django.conf import settings
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SMSResult:
|
||||
success: bool
|
||||
provider_id: str = ""
|
||||
error: str = ""
|
||||
def webotp_message(code):
|
||||
host=settings.WEBOTP_HOST or urlparse(settings.PUBLIC_BASE_URL).hostname
|
||||
if not host: raise ValueError("WEBOTP_HOST یا PUBLIC_BASE_URL معتبر تنظیم نشده است.")
|
||||
return f"کد شما: {code}\n@{host} #{code}"
|
||||
class SMSProvider(ABC):
|
||||
"""Stable application-facing contract for every SMS vendor."""
|
||||
def send_otp(self,mobile,code): return self.send_message(mobile,webotp_message(code))
|
||||
@abstractmethod
|
||||
def send_message(self,mobile,message): raise NotImplementedError
|
||||
@@ -0,0 +1,69 @@
|
||||
import logging
|
||||
import zeep
|
||||
from django.conf import settings
|
||||
from zeep.helpers import serialize_object
|
||||
from .base import SMSProvider, SMSResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BehinSMSProvider(SMSProvider):
|
||||
"""SOAP adapter for panel.behinsms.com, isolated from application code."""
|
||||
|
||||
def __init__(self):
|
||||
self.username = settings.BEHIN_SMS_USERNAME
|
||||
self.password = settings.BEHIN_SMS_PASSWORD
|
||||
self.special_number = settings.BEHIN_SMS_SPECIAL_NUMBER
|
||||
self.wsdl_url = settings.BEHIN_SMS_WSDL_URL
|
||||
self._client = None
|
||||
|
||||
def _configuration_error(self):
|
||||
missing = [name for name, value in (
|
||||
("BEHIN_SMS_USERNAME", self.username),
|
||||
("BEHIN_SMS_PASSWORD", self.password),
|
||||
("BEHIN_SMS_SPECIAL_NUMBER", self.special_number)
|
||||
) if not value]
|
||||
return f"تنظیمات پیامک بهین ناقص است: {', '.join(missing)}" if missing else ""
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
if self._client is None:
|
||||
# استفاده از تنظیمات پیشفرض Zeep (مشابه نمونهٔ کاری)
|
||||
self._client = zeep.Client(wsdl=self.wsdl_url)
|
||||
return self._client
|
||||
|
||||
def get_credit(self):
|
||||
error = self._configuration_error()
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
return self.client.service.GetCredit(UserName=self.username, Password=self.password)
|
||||
|
||||
def send_message(self, mobile, message):
|
||||
return self.send_many([mobile], message)
|
||||
|
||||
def send_many(self, mobile_numbers, message, is_flash=False):
|
||||
error = self._configuration_error()
|
||||
if error:
|
||||
return SMSResult(False, error=error)
|
||||
|
||||
recipients = list(dict.fromkeys(str(number).strip() for number in mobile_numbers if str(number).strip()))
|
||||
if not recipients:
|
||||
return SMSResult(False, error="شماره گیرنده پیامک خالی است.")
|
||||
|
||||
try:
|
||||
response = self.client.service.SendArray(
|
||||
UserName=self.username,
|
||||
Password=self.password,
|
||||
RecipientNumber={"string": recipients},
|
||||
MessageBody=message,
|
||||
SpecialNumber=self.special_number,
|
||||
IsFlashMessage=is_flash,
|
||||
CheckingMessageID={"long": [0] * len(recipients)}
|
||||
)
|
||||
serialized = serialize_object(response)
|
||||
if serialized is False or serialized is None:
|
||||
return SMSResult(False, error="وبسرویس بهین ارسال پیامک را تأیید نکرد.")
|
||||
return SMSResult(True, provider_id=str(serialized)[:500])
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Behin SMS delivery failed")
|
||||
return SMSResult(False, error=str(exc)[:1000])
|
||||
@@ -0,0 +1,10 @@
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from .base import SMSProvider,SMSResult,webotp_message
|
||||
logger=logging.getLogger(__name__)
|
||||
class ConsoleSMSProvider(SMSProvider):
|
||||
def send_otp(self,mobile,code):
|
||||
if settings.DEBUG: print(f"[DEV OTP] {mobile}: {webotp_message(code)}")
|
||||
return SMSResult(True,"console")
|
||||
def send_message(self,mobile,message):
|
||||
logger.info("[DEV SMS] destination=***%s message=%s",mobile[-4:],message); return SMSResult(True,"console")
|
||||
@@ -0,0 +1,10 @@
|
||||
from functools import lru_cache
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
from .base import SMSProvider
|
||||
@lru_cache(maxsize=1)
|
||||
def get_sms_provider():
|
||||
provider_class=import_string(settings.SMS_PROVIDER_CLASS)
|
||||
if not issubclass(provider_class,SMSProvider): raise TypeError("SMS_PROVIDER_CLASS باید از SMSProvider ارثبری کند.")
|
||||
return provider_class()
|
||||
def reset_sms_provider(): get_sms_provider.cache_clear()
|
||||
@@ -0,0 +1,13 @@
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from .base import SMSProvider,SMSResult
|
||||
class HTTPSMSProvider(SMSProvider):
|
||||
def send_message(self,mobile,message):
|
||||
if not settings.SMS_API_URL: return SMSResult(False,error="SMS_API_URL تنظیم نشده است.")
|
||||
headers={"Accept":"application/json","Content-Type":"application/json"}
|
||||
if settings.SMS_API_TOKEN: headers["Authorization"]=f"Bearer {settings.SMS_API_TOKEN}"
|
||||
try:
|
||||
response=requests.post(settings.SMS_API_URL,json={"to":mobile,"message":message,"sender":settings.SMS_SENDER},headers=headers,timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS); response.raise_for_status(); data=response.json() if response.content else {}
|
||||
if data.get("success") is False: return SMSResult(False,error=str(data.get("error","Provider rejected SMS")))
|
||||
return SMSResult(True,str(data.get("id") or data.get("message_id") or "http"))
|
||||
except requests.RequestException as exc: return SMSResult(False,error=str(exc))
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Backward-compatible public facade for the SMS integration."""
|
||||
from .base import SMSProvider,SMSResult,webotp_message
|
||||
from .behin import BehinSMSProvider
|
||||
from .console import ConsoleSMSProvider
|
||||
from .factory import get_sms_provider,reset_sms_provider
|
||||
from .http import HTTPSMSProvider
|
||||
__all__=["SMSProvider","SMSResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"]
|
||||
@@ -0,0 +1,36 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from apps.accounts.models import normalize_mobile
|
||||
from .base import SMSProvider, SMSResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZeroZeroNinetyEightSMSProvider(SMSProvider):
|
||||
"""Adapter for the 0098SMS HTTP gateway."""
|
||||
|
||||
def __init__(self):
|
||||
self.username = settings.SMS0098_USERNAME
|
||||
self.password = settings.SMS0098_PASSWORD
|
||||
self.sender = settings.SMS0098_FROM
|
||||
self.api_url = settings.SMS0098_API_URL
|
||||
|
||||
def send_message(self, mobile, message):
|
||||
missing = [name for name, value in (("SMS0098_USERNAME", self.username), ("SMS0098_PASSWORD", self.password), ("SMS0098_FROM", self.sender)) if not value]
|
||||
if missing:
|
||||
return SMSResult(False, error=f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}")
|
||||
try:
|
||||
recipient = int(normalize_mobile(str(mobile)).removeprefix("+98"))
|
||||
response = requests.get(self.api_url, params={"USERNAME": self.username, "PASSWORD": self.password, "FROM": self.sender, "TO": recipient, "TEXT": str(message)}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
|
||||
response.raise_for_status()
|
||||
# This legacy endpoint may append an HTML page containing the
|
||||
# original query string. Persist only its first scalar result so
|
||||
# credentials can never leak into notification logs.
|
||||
provider_id = response.text.strip().splitlines()[0][:100]
|
||||
return SMSResult(True, provider_id=provider_id)
|
||||
except (ValueError, requests.RequestException) as exc:
|
||||
logger.exception("0098SMS delivery failed")
|
||||
return SMSResult(False, error=str(exc)[:1000])
|
||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
from .models import Message,MessageMention,MessageRevision
|
||||
admin.site.register([Message,MessageMention,MessageRevision])
|
||||
@@ -0,0 +1,2 @@
|
||||
from django.apps import AppConfig
|
||||
class MessagingConfig(AppConfig): name="apps.messaging"
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-05 07:03
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
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='MessageMention',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MessageRevision',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('body', models.TextField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Message',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('body', models.TextField(blank=True)),
|
||||
('message_type', models.CharField(choices=[('PUBLIC', 'عمومی'), ('INTERNAL_NOTE', 'یادداشت داخلی')], default='PUBLIC', max_length=20)),
|
||||
('is_internal', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('edited_at', models.DateTimeField(blank=True, null=True)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='authored_messages', to=settings.AUTH_USER_MODEL)),
|
||||
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='deleted_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at', 'id'],
|
||||
'permissions': [('add_internal_note', 'Can add internal note'), ('edit_own_message', 'Can edit own message'), ('delete_own_message', 'Can delete own message'), ('view_deleted_message_content', 'Can view deleted content'), ('view_message_revisions', 'Can view revisions')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-05 07:03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('messaging', '0001_initial'),
|
||||
('tickets', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='ticket',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ticket_messages', to='tickets.ticket'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='messagemention',
|
||||
name='mentioned_user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='messagemention',
|
||||
name='message',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mentions', to='messaging.message'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='messagerevision',
|
||||
name='edited_by',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='messagerevision',
|
||||
name='message',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='messaging.message'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='messagemention',
|
||||
constraint=models.UniqueConstraint(fields=('message', 'mentioned_user'), name='unique_message_mention'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-06 15:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('messaging', '0002_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MessageReadReceipt',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('read_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_receipts', to='messaging.message')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='message_read_receipts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['read_at', 'id'],
|
||||
'constraints': [models.UniqueConstraint(fields=('message', 'user'), name='unique_message_read_receipt')],
|
||||
},
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,31 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from apps.core.models import PersianTextNormalizationMixin, PublicIdModel
|
||||
class Message(PersianTextNormalizationMixin, PublicIdModel, models.Model):
|
||||
persian_text_fields = {"body": True}
|
||||
class Type(models.TextChoices): PUBLIC="PUBLIC","عمومی"; INTERNAL_NOTE="INTERNAL_NOTE","یادداشت داخلی"
|
||||
ticket=models.ForeignKey("tickets.Ticket",on_delete=models.PROTECT,related_name="ticket_messages"); author=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,related_name="authored_messages"); body=models.TextField(blank=True); message_type=models.CharField(max_length=20,choices=Type.choices,default=Type.PUBLIC); is_internal=models.BooleanField(default=False); created_at=models.DateTimeField(auto_now_add=True,db_index=True); updated_at=models.DateTimeField(auto_now=True); edited_at=models.DateTimeField(null=True,blank=True); deleted_at=models.DateTimeField(null=True,blank=True); deleted_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,null=True,blank=True,related_name="deleted_messages")
|
||||
class Meta:
|
||||
ordering=["created_at","id"]
|
||||
permissions=[("add_internal_note","Can add internal note"),("edit_own_message","Can edit own message"),("delete_own_message","Can delete own message"),("view_deleted_message_content","Can view deleted content"),("view_message_revisions","Can view revisions")]
|
||||
@property
|
||||
def author_name(self):
|
||||
profile=getattr(self.author,"staff_profile",None) or getattr(self.author,"customer_profile",None)
|
||||
return str(profile) if profile else self.author.username
|
||||
class MessageRevision(PersianTextNormalizationMixin, models.Model):
|
||||
persian_text_fields = {"body": True}
|
||||
message=models.ForeignKey(Message,on_delete=models.CASCADE,related_name="revisions"); body=models.TextField(); edited_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT); created_at=models.DateTimeField(auto_now_add=True)
|
||||
class MessageMention(models.Model):
|
||||
message=models.ForeignKey(Message,on_delete=models.CASCADE,related_name="mentions"); mentioned_user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); created_at=models.DateTimeField(auto_now_add=True)
|
||||
class Meta: constraints=[models.UniqueConstraint(fields=["message","mentioned_user"],name="unique_message_mention")]
|
||||
class MessageReadReceipt(models.Model):
|
||||
message=models.ForeignKey(Message,on_delete=models.CASCADE,related_name="read_receipts")
|
||||
user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,related_name="message_read_receipts")
|
||||
read_at=models.DateTimeField(auto_now_add=True,db_index=True)
|
||||
class Meta:
|
||||
ordering=["read_at","id"]
|
||||
constraints=[models.UniqueConstraint(fields=["message","user"],name="unique_message_read_receipt")]
|
||||
@property
|
||||
def reader_name(self):
|
||||
profile=getattr(self.user,"staff_profile",None) or getattr(self.user,"customer_profile",None)
|
||||
return str(profile) if profile else self.user.username
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from apps.core.persian_text import normalize_persian_text
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from .models import Message,MessageRevision
|
||||
@transaction.atomic
|
||||
def edit_message(message_id,user,body):
|
||||
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
|
||||
if msg.author_id!=user.id or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
|
||||
if not (user.account_type=="CUSTOMER" or user.has_perm("messaging.edit_own_message")): raise PermissionDenied
|
||||
if msg.deleted_at: raise PermissionDenied
|
||||
body=normalize_persian_text(body,multiline=True)
|
||||
if not body: raise ValueError("متن پیام نمیتواند خالی باشد.")
|
||||
MessageRevision.objects.create(message=msg,body=msg.body,edited_by=user); msg.body=body; msg.edited_at=timezone.now(); msg.save(update_fields=["body","edited_at","updated_at"]); return msg
|
||||
@transaction.atomic
|
||||
def soft_delete_message(message_id,user):
|
||||
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
|
||||
may_delete=user.account_type=="CUSTOMER" or user.has_perm("messaging.delete_own_message")
|
||||
if msg.author_id!=user.id or not may_delete or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
|
||||
msg.deleted_at=timezone.now(); msg.deleted_by=user; msg.save(update_fields=["deleted_at","deleted_by","updated_at"]); return msg
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
app_name="messages"
|
||||
urlpatterns=[path("<uuid:public_id>/edit/",views.edit,name="edit"),path("<uuid:public_id>/delete/",views.delete,name="delete"),path("<uuid:public_id>/revisions/",views.revisions,name="revisions")]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404,redirect,render
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.decorators.http import require_POST
|
||||
from .models import Message
|
||||
from .services import edit_message,soft_delete_message
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
def _safe_next(request, fallback):
|
||||
value=request.POST.get("next")
|
||||
return value if value and url_has_allowed_host_and_scheme(value,allowed_hosts={request.get_host()},require_https=request.is_secure()) else fallback
|
||||
@login_required
|
||||
@require_POST
|
||||
def edit(request,public_id):
|
||||
msg=get_object_or_404(Message,public_id=public_id)
|
||||
try: edit_message(msg.pk,request.user,request.POST.get("body",""))
|
||||
except (PermissionDenied,ValueError) as exc: raise PermissionDenied(str(exc))
|
||||
return redirect(_safe_next(request,"/tickets/"))
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete(request,public_id):
|
||||
msg=get_object_or_404(Message,public_id=public_id); soft_delete_message(msg.pk,request.user); return redirect(_safe_next(request,"/staff/"))
|
||||
@login_required
|
||||
def revisions(request,public_id):
|
||||
msg=get_object_or_404(Message,public_id=public_id)
|
||||
if not request.user.has_perm("messaging.view_message_revisions") or not TicketPolicy.can_view(request.user,msg.ticket): raise PermissionDenied
|
||||
return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")})
|
||||
Whitespace-only changes.
@@ -0,0 +1,13 @@
|
||||
from django.contrib import admin
|
||||
from .models import NotificationJob,NotificationPolicy,SMSTemplate
|
||||
@admin.register(NotificationJob)
|
||||
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_message_id","created_at"); list_filter=("channel","status"); readonly_fields=("provider_message_id","dedupe_key","created_at","sent_at")
|
||||
@admin.register(SMSTemplate)
|
||||
class SMSTemplateAdmin(admin.ModelAdmin):
|
||||
list_display=("event_type","unit","title","is_active","updated_at")
|
||||
list_filter=("unit","is_active","event_type")
|
||||
readonly_fields=("allowed_tags",)
|
||||
@admin.register(NotificationPolicy)
|
||||
class NotificationPolicyAdmin(admin.ModelAdmin):
|
||||
list_display=("key","quiet_period_seconds","is_active","updated_at")
|
||||
def has_add_permission(self,request): return NotificationPolicy.objects.count()<len(NotificationPolicy.Key.values)
|
||||
@@ -0,0 +1,2 @@
|
||||
from django.apps import AppConfig
|
||||
class NotificationsConfig(AppConfig): name="apps.notifications"
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,164 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
import jdatetime
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import TicketMagicLink
|
||||
from apps.integrations.bale.providers import get_bale_provider
|
||||
from apps.integrations.sms.providers import get_sms_provider
|
||||
from apps.messaging.models import MessageReadReceipt
|
||||
from apps.notifications.models import NotificationJob,NotificationPolicy,SMSTemplate
|
||||
from apps.notifications.services import customer_reply_bale_policy,staff_reply_policy
|
||||
from apps.tickets.models import Ticket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _magic_link(job, reason):
|
||||
token,link = TicketMagicLink.issue_with_record(job.ticket, reason=reason, created_by=job.staff_user or job.ticket.owner or job.ticket.created_by)
|
||||
return link, f"{settings.PUBLIC_BASE_URL}/t/{token}/"
|
||||
|
||||
def _sms_context(job,magic_link):
|
||||
profile=getattr(job.customer,"customer_profile",None)
|
||||
unit=job.ticket.owning_unit
|
||||
return {"magic_link":magic_link,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","recipient_name":" ".join(filter(None,[getattr(profile,"first_name","") or "",getattr(profile,"last_name","") or ""])),"unit_name":unit.name if unit else "","company_name":getattr(settings,"COMPANY_NAME","لوله گستر گلپایگان"),"ticket_number":job.ticket.ticket_number,"subject":job.ticket.subject,"province":job.ticket.province or getattr(profile,"province","") or "","city":job.ticket.city or getattr(profile,"city","") or "","expert_name":job.ticket.assigned_expert_name}
|
||||
|
||||
def _render_sms(event_type,context,unit=None):
|
||||
from string import Formatter
|
||||
template=(SMSTemplate.objects.filter(event_type=event_type,is_active=True,unit=unit).first() if unit else None) or SMSTemplate.objects.get(event_type=event_type,is_active=True,unit__isnull=True)
|
||||
used={field for _,field,_,_ in Formatter().parse(template.body) if field}
|
||||
invalid=used-set(template.allowed_tags)
|
||||
if invalid: raise ValueError(f"SMS template contains unsupported tags: {', '.join(sorted(invalid))}")
|
||||
missing=used-set(context)
|
||||
if missing: raise ValueError(f"SMS template context is missing: {', '.join(sorted(missing))}")
|
||||
return template.body.format_map(context)
|
||||
|
||||
def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1, reopened=False):
|
||||
profile = getattr(ticket.customer, "customer_profile", None)
|
||||
name = " ".join(x for x in [getattr(profile, "first_name", ""), getattr(profile, "last_name", "")] if x).strip() or "ثبت نشده"
|
||||
location = "، ".join(x for x in [getattr(profile, "city", "") or ticket.city, getattr(profile, "province", "") or ticket.province] if x)
|
||||
message = message or ticket.ticket_messages.filter(is_internal=False).order_by("created_at", "id").first()
|
||||
raw_excerpt = " ".join((message.body if message else "").split())
|
||||
excerpt = f"{raw_excerpt[:277].rstrip()}…" if len(raw_excerpt)>280 else raw_excerpt or "بدون متن"
|
||||
local_created = timezone.localtime(message.created_at if message else ticket.created_at)
|
||||
jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M")
|
||||
lines=[
|
||||
"♻️ بازگشایی تیکت توسط مشتری" if reopened else (f"💬 {message_count} پیام جدید مشتری" if is_reply and message_count>1 else ("💬 پاسخ جدید مشتری" if is_reply else "🆕 تیکت جدید مشتری")),
|
||||
"────────────",
|
||||
f"🎫 {ticket.ticket_number}",
|
||||
f"📌 {ticket.subject}",
|
||||
f"👤 {name}",
|
||||
f"📞 {ticket.customer.local_mobile or 'ثبت نشده'}",
|
||||
]
|
||||
if location: lines.append(f"📍 {location}")
|
||||
lines.extend([f"🕒 {jalali}", "", f"📝 {excerpt}", "", "🔗 مشاهده و پاسخ", f"{settings.PUBLIC_BASE_URL}/staff/tickets/{ticket.public_id}/"])
|
||||
return "\n".join(lines)
|
||||
|
||||
def _send_sms(job):
|
||||
issued = None
|
||||
if job.event_type == "CUSTOMER_TICKET_CREATED":
|
||||
issued, url = _magic_link(job, "customer_created_ticket")
|
||||
message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
|
||||
elif job.event_type == "STAFF_TICKET_CREATED":
|
||||
issued, url = _magic_link(job, "staff_created_ticket"); message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
|
||||
elif job.event_type == "STAFF_REPLY":
|
||||
issued, url = _magic_link(job, "staff_reply"); message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
|
||||
else:
|
||||
raise ValueError(f"Unsupported SMS event: {job.event_type}")
|
||||
return get_sms_provider().send_message(job.customer.mobile, message), issued
|
||||
|
||||
def _send_bale(job):
|
||||
if job.event_type == "NEW_CUSTOMER_TICKET":
|
||||
return get_bale_provider().send_message(settings.BALE_GROUP_CHAT_ID, _bale_ticket_text(job.ticket))
|
||||
if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]:
|
||||
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first()
|
||||
if not message: raise ValueError("Customer reply message no longer exists")
|
||||
return get_bale_provider().send_message(settings.BALE_GROUP_CHAT_ID, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED"))
|
||||
if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured")
|
||||
profile = job.staff_user.staff_profile
|
||||
if not profile.bale_notifications_enabled: return None
|
||||
return get_bale_provider().send_message(profile.bale_chat_id, f"رویداد جدید برای تیکت {job.ticket.ticket_number}\n{settings.PUBLIC_BASE_URL}/staff/tickets/{job.ticket.public_id}/")
|
||||
|
||||
def _prepare_staff_reply_sms(job):
|
||||
"""Final transactional guard immediately before the irreversible provider call."""
|
||||
with transaction.atomic():
|
||||
ticket=Ticket.objects.select_for_update().get(pk=job.ticket_id)
|
||||
locked=NotificationJob.objects.select_for_update().get(pk=job.pk)
|
||||
policy=staff_reply_policy()
|
||||
message_id=locked.payload.get("latest_message_id")
|
||||
message=ticket.ticket_messages.filter(pk=message_id,is_internal=False,author__account_type="STAFF").first()
|
||||
reason=""
|
||||
if not policy.is_active: reason="policy_disabled"
|
||||
elif not message: reason="staff_message_missing"
|
||||
elif MessageReadReceipt.objects.filter(message=message,user_id=locked.customer_id).exists(): reason="customer_read"
|
||||
elif ticket.ticket_messages.filter(is_internal=False,author_id=locked.customer_id,created_at__gt=message.created_at).exists(): reason="customer_replied"
|
||||
if reason:
|
||||
locked.status=NotificationJob.Status.CANCELLED; locked.last_error=reason
|
||||
locked.save(update_fields=["status","last_error"])
|
||||
return False
|
||||
due=ticket.last_activity_at+timedelta(seconds=policy.quiet_period_seconds)
|
||||
if due>timezone.now():
|
||||
locked.status=NotificationJob.Status.PENDING; locked.next_attempt_at=due; locked.last_error="conversation_active"
|
||||
locked.save(update_fields=["status","next_attempt_at","last_error"])
|
||||
return False
|
||||
return True
|
||||
|
||||
def _prepare_customer_reply_bale(job):
|
||||
with transaction.atomic():
|
||||
ticket=Ticket.objects.select_for_update().get(pk=job.ticket_id)
|
||||
locked=NotificationJob.objects.select_for_update().get(pk=job.pk)
|
||||
policy=customer_reply_bale_policy()
|
||||
if not policy.is_active:
|
||||
locked.status=NotificationJob.Status.CANCELLED; locked.last_error="policy_disabled"; locked.save(update_fields=["status","last_error"]); return False
|
||||
due=ticket.last_customer_message_at+timedelta(seconds=policy.quiet_period_seconds)
|
||||
if due>timezone.now():
|
||||
locked.status=NotificationJob.Status.PENDING; locked.next_attempt_at=due; locked.last_error="customer_chat_active"
|
||||
locked.save(update_fields=["status","next_attempt_at","last_error"]); return False
|
||||
return True
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Process notification outbox"
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--once", action="store_true")
|
||||
parser.add_argument("--job-id", type=int, help="Process only one notification job")
|
||||
parser.add_argument("--min-id", type=int, help="Process this job id and newer jobs")
|
||||
parser.add_argument("--channel", choices=NotificationJob.Channel.values, help="Process only one notification channel")
|
||||
def handle(self, *args, **options):
|
||||
while True:
|
||||
stale = timezone.now() - timedelta(minutes=10)
|
||||
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale).update(status="FAILED", next_attempt_at=timezone.now(), last_error="Recovered stale processing job")
|
||||
with transaction.atomic():
|
||||
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(status__in=["PENDING", "FAILED"], attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, next_attempt_at__lte=timezone.now())
|
||||
if options.get("channel"):
|
||||
queryset = queryset.filter(channel=options["channel"])
|
||||
if options.get("job_id"):
|
||||
queryset = queryset.filter(pk=options["job_id"])
|
||||
elif options.get("min_id"):
|
||||
queryset = queryset.filter(pk__gte=options["min_id"])
|
||||
jobs = list(queryset.order_by("created_at")[:20])
|
||||
NotificationJob.objects.filter(pk__in=[x.pk for x in jobs]).update(status="PROCESSING", next_attempt_at=timezone.now())
|
||||
for job in jobs:
|
||||
issued_link = None
|
||||
try:
|
||||
if job.channel == NotificationJob.Channel.SMS and job.event_type == "STAFF_REPLY" and not _prepare_staff_reply_sms(job):
|
||||
continue
|
||||
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
|
||||
continue
|
||||
if job.channel == NotificationJob.Channel.SMS: result, issued_link = _send_sms(job)
|
||||
else: result = _send_bale(job)
|
||||
if result is not None and not result.success: raise RuntimeError(result.error or "Provider rejected notification")
|
||||
job.attempts += 1; job.status = "SENT"; job.sent_at = timezone.now(); job.last_error = ""; job.provider_message_id = getattr(result,"provider_id","") if result is not None else ""
|
||||
except Exception as exc:
|
||||
if issued_link:
|
||||
issued_link.revoked_at = timezone.now(); issued_link.save(update_fields=["revoked_at"])
|
||||
job.attempts += 1; job.last_error = str(exc)[:1000]; job.provider_message_id = ""; job.status = "FAILED"; job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10)); logger.exception("notification failed id=%s", job.pk)
|
||||
job.save()
|
||||
if job.ticket_id and job.ticket.group_dispatch_id:
|
||||
from apps.tickets.services import refresh_group_dispatch_status
|
||||
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
|
||||
if options["once"] or options.get("job_id"): break
|
||||
time.sleep(5)
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-05 07:03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NotificationJob',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('channel', models.CharField(choices=[('SMS', 'پیامک'), ('BALE', 'بله')], max_length=10)),
|
||||
('event_type', models.CharField(max_length=50)),
|
||||
('payload', models.JSONField(default=dict)),
|
||||
('status', models.CharField(choices=[('PENDING', 'در صف'), ('PROCESSING', 'در حال ارسال'), ('SENT', 'ارسال\u200cشده'), ('FAILED', 'ناموفق')], db_index=True, default='PENDING', max_length=15)),
|
||||
('attempts', models.PositiveSmallIntegerField(default=0)),
|
||||
('next_attempt_at', models.DateTimeField(db_index=True)),
|
||||
('last_error', models.TextField(blank=True)),
|
||||
('dedupe_key', models.CharField(max_length=200, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customer_notifications', to=settings.AUTH_USER_MODEL)),
|
||||
('staff_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='staff_notifications', to=settings.AUTH_USER_MODEL)),
|
||||
('ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tickets.ticket')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-07 04:56
|
||||
|
||||
import apps.core.models
|
||||
from django.db import migrations, models
|
||||
|
||||
TAGS=["magic_link","first_name","last_name","ticket_number","subject","province","city","expert_name"]
|
||||
DEFAULTS={
|
||||
"CUSTOMER_TICKET_CREATED":("ثبت تیکت توسط مشتری","{first_name} عزیز، از ثبت درخواست شما متشکریم. تیکت {ticket_number} با موفقیت ثبت شد.\nمشاهده و پیگیری: {magic_link}\nپس از بررسی و پاسخ کارشناس، از طریق پیامک به شما اطلاع خواهیم داد.\nلوله گستر گلپایگان"),
|
||||
"STAFF_TICKET_CREATED":("ثبت تیکت توسط کارشناس","{first_name} عزیز، تیکت {ticket_number} با موضوع «{subject}» توسط واحد فروش برای شما ثبت شد.\nمشاهده و پاسخ: {magic_link}\nلوله گستر گلپایگان"),
|
||||
"STAFF_REPLY":("پاسخ کارشناس","{first_name} عزیز، پاسخ جدیدی برای تیکت {ticket_number} ثبت شد.\nمشاهده پاسخ: {magic_link}\nلوله گستر گلپایگان"),
|
||||
}
|
||||
def seed_sms_templates(apps,schema_editor):
|
||||
model=apps.get_model("notifications","SMSTemplate")
|
||||
for event,(title,body) in DEFAULTS.items(): model.objects.get_or_create(event_type=event,defaults={"title":title,"body":body,"allowed_tags":TAGS})
|
||||
def remove_sms_templates(apps,schema_editor):
|
||||
apps.get_model("notifications","SMSTemplate").objects.filter(event_type__in=DEFAULTS).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SMSTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('event_type', models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس')], max_length=50, unique=True)),
|
||||
('title', models.CharField(max_length=120)),
|
||||
('body', models.TextField(help_text='تگ\u200cها را مانند {magic_link} در متن قرار دهید.')),
|
||||
('allowed_tags', models.JSONField(blank=True, default=list)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
|
||||
),
|
||||
migrations.RunPython(seed_sms_templates,remove_sms_templates),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def seed_policy(apps, schema_editor):
|
||||
apps.get_model("notifications", "NotificationPolicy").objects.get_or_create(
|
||||
key="STAFF_REPLY_SMS",
|
||||
defaults={"quiet_period_seconds": 300, "is_active": True},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("notifications", "0002_smstemplate")]
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="NotificationPolicy",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("key", models.CharField(choices=[("STAFF_REPLY_SMS", "پیامک پاسخ کارشناس")], max_length=50, unique=True)),
|
||||
("quiet_period_seconds", models.PositiveIntegerField(default=300, help_text="پس از آخرین فعالیت تیکت، چند ثانیه برای ادامه گفتوگو صبر شود.")),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={"verbose_name": "سیاست اعلان", "verbose_name_plural": "سیاستهای اعلان"},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="notificationjob",
|
||||
name="status",
|
||||
field=models.CharField(choices=[("PENDING", "در صف"), ("PROCESSING", "در حال ارسال"), ("SENT", "ارسالشده"), ("FAILED", "ناموفق"), ("CANCELLED", "لغوشده")], db_index=True, default="PENDING", max_length=15),
|
||||
),
|
||||
migrations.RunPython(seed_policy, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def seed_policy(apps, schema_editor):
|
||||
apps.get_model("notifications", "NotificationPolicy").objects.get_or_create(
|
||||
key="CUSTOMER_REPLY_BALE",
|
||||
defaults={"quiet_period_seconds": 600, "is_active": True},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("notifications", "0003_notificationpolicy_notificationjob_status")]
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="notificationpolicy",
|
||||
name="key",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("STAFF_REPLY_SMS", "پیامک پاسخ کارشناس"),
|
||||
("CUSTOMER_REPLY_BALE", "اعلان پاسخ مشتری در بله"),
|
||||
],
|
||||
max_length=50,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(seed_policy, migrations.RunPython.noop),
|
||||
]
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-08 09:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0004_customer_reply_bale_policy'),
|
||||
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='smstemplate',
|
||||
name='unit',
|
||||
field=models.ForeignKey(blank=True, help_text='خالی یعنی الگوی عمومی و fallback.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sms_templates', to='staff.organizationalunit'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='smstemplate',
|
||||
name='event_type',
|
||||
field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس')], max_length=50),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='smstemplate',
|
||||
constraint=models.UniqueConstraint(fields=('unit', 'event_type'), name='unique_sms_template_per_unit'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='smstemplate',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('unit__isnull', True)), fields=('event_type',), name='unique_global_sms_template_event'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-08 10:05
|
||||
|
||||
import apps.notifications.models
|
||||
from django.db import migrations, models
|
||||
|
||||
TAGS = ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name"]
|
||||
|
||||
|
||||
def add_supported_tags(apps, schema_editor):
|
||||
template_model = apps.get_model("notifications", "SMSTemplate")
|
||||
for template in template_model.objects.all().iterator():
|
||||
template.allowed_tags = list(dict.fromkeys([*template.allowed_tags, *TAGS]))
|
||||
template.save(update_fields=["allowed_tags"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0005_smstemplate_unit_alter_smstemplate_event_type_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='smstemplate',
|
||||
name='allowed_tags',
|
||||
field=models.JSONField(blank=True, default=apps.notifications.models.sms_template_tags),
|
||||
),
|
||||
migrations.RunPython(add_supported_tags, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-08 15:32
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0006_alter_smstemplate_allowed_tags'),
|
||||
('tickets', '0006_assign_legacy_contacts_to_sales'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='notificationjob',
|
||||
index=models.Index(fields=['status', 'next_attempt_at'], name='notif_due_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notificationjob',
|
||||
index=models.Index(fields=['ticket', 'channel', 'status'], name='notif_ticket_chan_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-08 15:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0007_notificationjob_notif_due_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notificationjob',
|
||||
name='provider_message_id',
|
||||
field=models.CharField(blank=True, max_length=500),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
DEFAULTS = {
|
||||
"CUSTOMER_TICKET_CREATED": ("ثبت تیکت توسط مشتری", "{first_name} عزیز، تیکت {ticket_number} ثبت شد.\nمشاهده و پیگیری: {magic_link}\nلوله گستر گلپایگان"),
|
||||
"STAFF_TICKET_CREATED": ("ثبت تیکت توسط کارشناس", "{first_name} عزیز، تیکت {ticket_number} با موضوع «{subject}» برای شما ثبت شد.\nمشاهده و پاسخ: {magic_link}\nلوله گستر گلپایگان"),
|
||||
"STAFF_REPLY": ("پاسخ کارشناس", "{first_name} عزیز، پاسخ جدیدی برای تیکت {ticket_number} ثبت شد.\nمشاهده پاسخ: {magic_link}\nلوله گستر گلپایگان"),
|
||||
}
|
||||
TAGS = ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name"]
|
||||
|
||||
|
||||
def ensure_defaults(apps, schema_editor):
|
||||
model = apps.get_model("notifications", "SMSTemplate")
|
||||
for event_type, (title, body) in DEFAULTS.items():
|
||||
model.objects.get_or_create(unit=None, event_type=event_type, defaults={"title": title, "body": body, "allowed_tags": TAGS, "is_active": True})
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("notifications", "0008_notificationjob_provider_message_id")]
|
||||
operations = [migrations.RunPython(ensure_defaults, migrations.RunPython.noop)]
|
||||
Whitespace-only changes.
@@ -0,0 +1,50 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from apps.core.models import PersianTextNormalizationMixin
|
||||
|
||||
|
||||
def sms_template_tags():
|
||||
return ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name"]
|
||||
|
||||
class SMSTemplate(PersianTextNormalizationMixin, models.Model):
|
||||
persian_text_fields={"title":False,"body":True}
|
||||
class Event(models.TextChoices):
|
||||
CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری"
|
||||
STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس"
|
||||
STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس"
|
||||
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.")
|
||||
event_type=models.CharField(max_length=50,choices=Event.choices)
|
||||
title=models.CharField(max_length=120)
|
||||
body=models.TextField(help_text="تگها را مانند {magic_link} در متن قرار دهید.")
|
||||
allowed_tags=models.JSONField(default=sms_template_tags,blank=True)
|
||||
is_active=models.BooleanField(default=True)
|
||||
updated_at=models.DateTimeField(auto_now=True)
|
||||
class Meta:
|
||||
constraints=[models.UniqueConstraint(fields=["unit","event_type"],name="unique_sms_template_per_unit"),models.UniqueConstraint(fields=["event_type"],condition=models.Q(unit__isnull=True),name="unique_global_sms_template_event")]
|
||||
def __str__(self): return self.get_event_type_display()
|
||||
def clean(self):
|
||||
from string import Formatter
|
||||
tags={field for _,field,_,_ in Formatter().parse(self.body) if field}
|
||||
invalid=tags-set(self.allowed_tags)
|
||||
if invalid: raise ValidationError({"body":f"تگهای غیرمجاز: {', '.join(sorted(invalid))}"})
|
||||
|
||||
class NotificationPolicy(models.Model):
|
||||
class Key(models.TextChoices):
|
||||
STAFF_REPLY_SMS="STAFF_REPLY_SMS","پیامک پاسخ کارشناس"
|
||||
CUSTOMER_REPLY_BALE="CUSTOMER_REPLY_BALE","اعلان پاسخ مشتری در بله"
|
||||
key=models.CharField(max_length=50,choices=Key.choices,unique=True)
|
||||
quiet_period_seconds=models.PositiveIntegerField(default=300,help_text="پس از آخرین فعالیت تیکت، چند ثانیه برای ادامه گفتوگو صبر شود.")
|
||||
is_active=models.BooleanField(default=True)
|
||||
updated_at=models.DateTimeField(auto_now=True)
|
||||
class Meta:
|
||||
verbose_name="سیاست اعلان"
|
||||
verbose_name_plural="سیاستهای اعلان"
|
||||
def __str__(self): return self.get_key_display()
|
||||
|
||||
class NotificationJob(models.Model):
|
||||
class Channel(models.TextChoices): SMS="SMS","پیامک"; BALE="BALE","بله"
|
||||
class Status(models.TextChoices): PENDING="PENDING","در صف"; PROCESSING="PROCESSING","در حال ارسال"; SENT="SENT","ارسالشده"; FAILED="FAILED","ناموفق"; CANCELLED="CANCELLED","لغوشده"
|
||||
channel=models.CharField(max_length=10,choices=Channel.choices); event_type=models.CharField(max_length=50); customer=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="customer_notifications"); staff_user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="staff_notifications"); ticket=models.ForeignKey("tickets.Ticket",on_delete=models.CASCADE,null=True,blank=True); payload=models.JSONField(default=dict); status=models.CharField(max_length=15,choices=Status.choices,default=Status.PENDING,db_index=True); attempts=models.PositiveSmallIntegerField(default=0); next_attempt_at=models.DateTimeField(db_index=True); last_error=models.TextField(blank=True); provider_message_id=models.CharField(max_length=500,blank=True); dedupe_key=models.CharField(max_length=200,unique=True); created_at=models.DateTimeField(auto_now_add=True); sent_at=models.DateTimeField(null=True,blank=True)
|
||||
class Meta:
|
||||
indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")]
|
||||
@@ -0,0 +1,53 @@
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
from django.utils import timezone
|
||||
from .models import NotificationJob,NotificationPolicy
|
||||
def enqueue(channel,event_type,ticket,dedupe_key,payload=None,customer=None,staff_user=None):
|
||||
return NotificationJob.objects.get_or_create(dedupe_key=dedupe_key,defaults={"channel":channel,"event_type":event_type,"ticket":ticket,"payload":payload or {},"customer":customer,"staff_user":staff_user,"next_attempt_at":timezone.now()})[0]
|
||||
|
||||
def staff_reply_policy():
|
||||
return NotificationPolicy.objects.get_or_create(key=NotificationPolicy.Key.STAFF_REPLY_SMS,defaults={"quiet_period_seconds":300})[0]
|
||||
|
||||
def customer_reply_bale_policy():
|
||||
return NotificationPolicy.objects.get_or_create(key=NotificationPolicy.Key.CUSTOMER_REPLY_BALE,defaults={"quiet_period_seconds":600})[0]
|
||||
|
||||
def cancel_staff_reply_sms(ticket,reason):
|
||||
return NotificationJob.objects.filter(ticket=ticket,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED]).update(status=NotificationJob.Status.CANCELLED,last_error=reason)
|
||||
|
||||
def schedule_staff_reply_sms(ticket,message):
|
||||
"""Debounce a chat burst into one SMS; caller already locks the ticket."""
|
||||
policy=staff_reply_policy()
|
||||
if not policy.is_active:
|
||||
cancel_staff_reply_sms(ticket,"policy_disabled")
|
||||
return None,False
|
||||
due=timezone.now()+timedelta(seconds=policy.quiet_period_seconds)
|
||||
job=NotificationJob.objects.filter(ticket=ticket,customer=ticket.customer,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED]).order_by("-created_at").first()
|
||||
payload={"latest_message_id":message.pk}
|
||||
if job:
|
||||
job.payload=payload; job.next_attempt_at=due; job.status=NotificationJob.Status.PENDING; job.attempts=0; job.last_error=""
|
||||
job.save(update_fields=["payload","next_attempt_at","status","attempts","last_error"])
|
||||
return job,False
|
||||
return NotificationJob.objects.create(channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",ticket=ticket,customer=ticket.customer,payload=payload,next_attempt_at=due,dedupe_key=f"ticket:{ticket.pk}:staff-reply:{uuid4().hex}"),True
|
||||
|
||||
def cancel_staff_reply_sms_if_read(ticket,customer):
|
||||
jobs=NotificationJob.objects.filter(ticket=ticket,customer=customer,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED])
|
||||
cancelled=0
|
||||
for job in jobs:
|
||||
message_id=job.payload.get("latest_message_id")
|
||||
if message_id and customer.message_read_receipts.filter(message_id=message_id).exists():
|
||||
job.status=NotificationJob.Status.CANCELLED; job.last_error="customer_read"; job.save(update_fields=["status","last_error"]); cancelled+=1
|
||||
return cancelled
|
||||
|
||||
def schedule_customer_reply_bale(ticket,message):
|
||||
"""Debounce consecutive customer chat messages into one Bale group alert."""
|
||||
policy=customer_reply_bale_policy()
|
||||
if not policy.is_active: return None,False
|
||||
due=timezone.now()+timedelta(seconds=policy.quiet_period_seconds)
|
||||
job=NotificationJob.objects.filter(ticket=ticket,channel=NotificationJob.Channel.BALE,event_type="CUSTOMER_TICKET_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED]).order_by("-created_at").first()
|
||||
count=int((job.payload if job else {}).get("message_count",0))+1
|
||||
payload={"message_id":message.pk,"message_count":count}
|
||||
if job:
|
||||
job.payload=payload; job.next_attempt_at=due; job.status=NotificationJob.Status.PENDING; job.attempts=0; job.last_error=""
|
||||
job.save(update_fields=["payload","next_attempt_at","status","attempts","last_error"])
|
||||
return job,False
|
||||
return NotificationJob.objects.create(channel=NotificationJob.Channel.BALE,event_type="CUSTOMER_TICKET_REPLY",ticket=ticket,customer=ticket.customer,payload=payload,next_attempt_at=due,dedupe_key=f"ticket:{ticket.pk}:customer-reply-bale:{uuid4().hex}"),True
|
||||
Whitespace-only changes.
@@ -0,0 +1,51 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from .forms import StaffProfileAdminForm
|
||||
from .models import OrganizationalUnit, StaffProfile, UnitMembership
|
||||
|
||||
|
||||
class UnitMembershipInline(admin.TabularInline):
|
||||
model = UnitMembership
|
||||
extra = 0
|
||||
filter_horizontal = ("permissions",)
|
||||
|
||||
|
||||
@admin.register(OrganizationalUnit)
|
||||
class OrganizationalUnitAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "slug", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at")
|
||||
list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active")
|
||||
search_fields = ("name", "slug")
|
||||
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
|
||||
filter_horizontal = ("permissions",)
|
||||
inlines = (UnitMembershipInline,)
|
||||
|
||||
|
||||
@admin.register(UnitMembership)
|
||||
class UnitMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "unit", "ticket_scope", "is_primary", "is_active")
|
||||
list_filter = ("unit", "ticket_scope", "is_primary", "is_active")
|
||||
search_fields = ("user__username", "user__mobile", "unit__name")
|
||||
filter_horizontal = ("permissions",)
|
||||
|
||||
@admin.register(StaffProfile)
|
||||
class StaffProfileAdmin(admin.ModelAdmin):
|
||||
form = StaffProfileAdminForm
|
||||
list_display = ("display_name", "primary_unit", "role", "sort_order", "job_title", "mobile", "is_active", "is_customer_selectable")
|
||||
list_editable = ("sort_order",)
|
||||
list_filter = ("primary_unit", "role", "is_active", "is_customer_selectable", "bale_notifications_enabled")
|
||||
search_fields = ("first_name", "last_name", "display_name", "mobile", "user__username")
|
||||
fieldsets = (
|
||||
("حساب کاربری", {"fields": ("user",)}),
|
||||
("مشخصات کارشناس", {"fields": ("primary_unit", "role", "first_name", "last_name", "display_name", "job_title", "avatar_preview", "avatar", "mobile")}),
|
||||
("اطلاعرسانی", {"fields": ("bale_chat_id", "bale_notifications_enabled")}),
|
||||
("نمایش و دسترسی", {"fields": ("is_customer_selectable", "sort_order", "is_active")}),
|
||||
)
|
||||
readonly_fields = ("avatar_preview",)
|
||||
|
||||
@admin.display(description="تصویر فعلی")
|
||||
def avatar_preview(self, obj):
|
||||
if not obj or not obj.pk or not obj.avatar:
|
||||
return "تصویری ثبت نشده است."
|
||||
url = reverse("staff_tickets:expert_avatar", args=[obj.user.public_id])
|
||||
return format_html('<img src="{}?v={}" alt="تصویر کارشناس" style="width:96px;height:96px;border-radius:16px;object-fit:cover;border:1px solid #ccd8e5">', url, obj.updated_at.timestamp())
|
||||
@@ -0,0 +1,2 @@
|
||||
from django.apps import AppConfig
|
||||
class StaffConfig(AppConfig): name = "apps.staff"
|
||||
@@ -0,0 +1,31 @@
|
||||
from django import forms
|
||||
|
||||
from .models import StaffProfile
|
||||
|
||||
|
||||
class StaffProfileAdminForm(forms.ModelForm):
|
||||
"""Upload/replace avatars without exposing a direct public media URL."""
|
||||
role = forms.ChoiceField(
|
||||
choices=StaffProfile.Role.choices,
|
||||
required=False,
|
||||
initial=StaffProfile.Role.SALES_EXPERT,
|
||||
label="نقش سازمانی",
|
||||
)
|
||||
avatar = forms.ImageField(
|
||||
required=False,
|
||||
widget=forms.FileInput(attrs={"accept": "image/jpeg,image/png,image/webp"}),
|
||||
help_text="تصویر JPG، PNG یا WebP انتخاب کنید. تصویر جدید جایگزین تصویر فعلی میشود.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = StaffProfile
|
||||
fields = "__all__"
|
||||
|
||||
def clean_avatar(self):
|
||||
avatar = self.cleaned_data.get("avatar")
|
||||
if avatar and avatar.size > 5 * 1024 * 1024:
|
||||
raise forms.ValidationError("حجم تصویر پروفایل نباید بیشتر از ۵ مگابایت باشد.")
|
||||
return avatar
|
||||
|
||||
def clean_role(self):
|
||||
return self.cleaned_data.get("role") or StaffProfile.Role.SALES_EXPERT
|
||||
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user