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.
+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 ""