This commit is contained in:
javad committed 2026-09-10 11:34:19 +03:30
commit fa9d154db9
260 files changed
+17643

No files matched your search

View File
Whitespace-only changes.
View File
Whitespace-only changes.
+72
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "apps.accounts"
+35
View File
@@ -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"])
+32
View File
@@ -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
+78
View File
@@ -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)),
],
),
]
+22
View File
@@ -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'),
),
]
@@ -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.
+157
View File
@@ -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}"
+4
View File
@@ -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")]
+131
View File
@@ -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
View File
Whitespace-only changes.
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
from .models import Attachment,AttachmentPolicy
admin.site.register([Attachment,AttachmentPolicy])
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class AttachmentsConfig(AppConfig): name="apps.attachments"
+21
View File
@@ -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.
+14
View File
@@ -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")]
+32
View File
@@ -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
+4
View File
@@ -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")]
+30
View File
@@ -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)
View File
Whitespace-only changes.
+37
View File
@@ -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",)
+4
View File
@@ -0,0 +1,4 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "apps.core"
+68
View File
@@ -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"),
}}
+36
View File
@@ -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
View File
Whitespace-only changes.
Whitespace-only changes.
+147
View File
@@ -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."))
+13
View File
@@ -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
+47
View File
@@ -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='آیکون معرفی ورود کارشناسان'),
),
]
View File
Whitespace-only changes.
+127
View File
@@ -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}"
+66
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+53
View File
@@ -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 ""
View File
Whitespace-only changes.
+17
View File
@@ -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",)
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class CustomersConfig(AppConfig): name = "apps.customers"
+32
View File
@@ -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)),
]
@@ -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.
+54
View File
@@ -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)
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+30
View File
@@ -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.")
View File
Whitespace-only changes.
+18
View File
@@ -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
+69
View File
@@ -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])
+10
View File
@@ -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")
+10
View File
@@ -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()
+13
View File
@@ -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))
+7
View File
@@ -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"]
+36
View File
@@ -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])
View File
Whitespace-only changes.
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
from .models import Message,MessageMention,MessageRevision
admin.site.register([Message,MessageMention,MessageRevision])
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class MessagingConfig(AppConfig): name="apps.messaging"
+53
View File
@@ -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')],
},
),
]
+48
View File
@@ -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.
+31
View File
@@ -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
+21
View File
@@ -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
+5
View File
@@ -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")]
+28
View File
@@ -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")})
View File
Whitespace-only changes.
+13
View File
@@ -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)
+2
View File
@@ -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),
]
@@ -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.
+50
View File
@@ -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")]
+53
View File
@@ -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
View File
Whitespace-only changes.
+51
View File
@@ -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())
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class StaffConfig(AppConfig): name = "apps.staff"
+31
View File
@@ -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