first
This commit is contained in:
commit
fa9d154db9
260 files changed
+17643
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,37 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import PageTitle, SiteBranding
|
||||
|
||||
|
||||
@admin.register(SiteBranding)
|
||||
class SiteBrandingAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
("عمومی", {"fields": ("site_title", "header_title", "header_subtitle")}),
|
||||
("لوگو و آیکونها", {"fields": ("company_logo", "favicon", "pwa_icon"), "description": "در صورت خالی بودن هر فیلد، فایل پیشفرض فعلی استفاده میشود."}),
|
||||
("PWA", {"fields": ("pwa_name", "pwa_title", "pwa_description", "pwa_install_label", "pwa_install_help")}),
|
||||
("پنل کارشناسان", {"fields": ("staff_brand_title", "staff_brand_subtitle", "staff_panel_label")}),
|
||||
("صفحه ورود مشتری", {"fields": ("login_intro_enabled", "login_intro_icon", "login_intro_eyebrow", "login_intro_heading", "login_intro_body")}),
|
||||
("صفحه ورود کارشناسان", {"fields": ("staff_login_intro_enabled", "staff_login_intro_icon", "staff_login_intro_eyebrow", "staff_login_intro_heading", "staff_login_intro_body")}),
|
||||
("صفحه تیکتهای مشتری", {"fields": ("customer_tickets_hero_enabled", "customer_tickets_hero_eyebrow", "customer_tickets_hero_heading", "customer_tickets_hero_body")}),
|
||||
("امضای توسعهدهنده", {"fields": ("developer_credit_text", "developer_credit_html", "use_developer_credit_html"), "description": "HTML خام تنها برای محتوای کاملاً مورد اعتماد فعال شود."}),
|
||||
("امضای مستقل sidebar کارکنان", {"fields": ("staff_sidebar_credit_text", "staff_sidebar_credit_html", "use_staff_sidebar_credit_html"), "description": "این مقدار فقط در پایین sidebar پنل staff نمایش داده میشود."}),
|
||||
)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if request.user.is_superuser:
|
||||
return ()
|
||||
return ("developer_credit_html", "use_developer_credit_html", "staff_sidebar_credit_html", "use_staff_sidebar_credit_html")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return not SiteBranding.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(PageTitle)
|
||||
class PageTitleAdmin(admin.ModelAdmin):
|
||||
list_display = ("route_name", "title", "is_active", "updated_at")
|
||||
list_editable = ("title", "is_active")
|
||||
search_fields = ("route_name", "title")
|
||||
list_filter = ("is_active",)
|
||||
@@ -0,0 +1,4 @@
|
||||
from django.apps import AppConfig
|
||||
class CoreConfig(AppConfig):
|
||||
name = "apps.core"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from django.conf import settings
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.templatetags.static import static
|
||||
|
||||
from .models import PageTitle, SiteBranding
|
||||
|
||||
|
||||
def site_branding(request):
|
||||
"""Expose editable branding and the title configured for the current route."""
|
||||
try:
|
||||
branding = SiteBranding.load()
|
||||
route_name = getattr(getattr(request, "resolver_match", None), "view_name", "") or ""
|
||||
configured = PageTitle.objects.filter(route_name=route_name, is_active=True).values_list("title", flat=True).first()
|
||||
except (OperationalError, ProgrammingError, RuntimeError):
|
||||
branding = SiteBranding()
|
||||
configured = None
|
||||
developer_credit = (
|
||||
mark_safe(branding.developer_credit_html)
|
||||
if branding.use_developer_credit_html and branding.developer_credit_html.strip()
|
||||
else branding.developer_credit_text
|
||||
)
|
||||
staff_sidebar_credit = (
|
||||
mark_safe(branding.staff_sidebar_credit_html)
|
||||
if branding.use_staff_sidebar_credit_html and branding.staff_sidebar_credit_html.strip()
|
||||
else branding.staff_sidebar_credit_text
|
||||
)
|
||||
def asset_url(field, fallback):
|
||||
try:
|
||||
return field.url if field else static(fallback)
|
||||
except (ValueError, OSError):
|
||||
return static(fallback)
|
||||
branding_version = str(int(branding.updated_at.timestamp())) if branding.pk and branding.updated_at else "default"
|
||||
return {
|
||||
"site_branding": branding,
|
||||
"page_title": configured or branding.site_title,
|
||||
"developer_credit": developer_credit,
|
||||
"staff_sidebar_credit": staff_sidebar_credit,
|
||||
"branding_version": branding_version,
|
||||
"company_logo_url": asset_url(branding.company_logo, "images/company-logo.png"),
|
||||
"favicon_url": asset_url(branding.favicon, "images/favicon.ico"),
|
||||
"pwa_icon_url": asset_url(branding.pwa_icon, "images/pwa-icon.png"),
|
||||
}
|
||||
|
||||
|
||||
def static_asset_version(request):
|
||||
"""Change frontend and worker versions whenever static files or templates change."""
|
||||
roots = (settings.BASE_DIR / "static", settings.BASE_DIR / "templates")
|
||||
mtimes = (path.stat().st_mtime_ns for root in roots if root.exists() for path in root.rglob("*") if path.is_file())
|
||||
return {"static_asset_version": str(max(mtimes, default=0))}
|
||||
|
||||
|
||||
def staff_operations_access(request):
|
||||
"""Expose unit-scoped navigation capabilities without hardcoded roles."""
|
||||
user = request.user
|
||||
if not getattr(user, "is_authenticated", False) or getattr(user, "account_type", "") != "STAFF":
|
||||
return {}
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
|
||||
def allowed(permission):
|
||||
return user.is_superuser or user.has_perm(permission) or any(TicketPolicy.has_unit_permission(user, item.unit, permission) for item in memberships)
|
||||
return {"staff_operations_access": {
|
||||
"create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"),
|
||||
"contacts": allowed("customers.view_customerprofile"),
|
||||
"dispatches": allowed("tickets.create_bulk_dispatch") or allowed("tickets.create_inquiry") or allowed("tickets.dispatch_group_tickets"),
|
||||
"templates": allowed("notifications.view_smstemplate") or allowed("notifications.add_smstemplate"),
|
||||
"audit": user.has_perm("accounts.view_all_magic_links") or allowed("accounts.view_ticketmagiclink"),
|
||||
}}
|
||||
@@ -0,0 +1,36 @@
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def location_data():
|
||||
path = Path(settings.BASE_DIR) / "static" / "data" / "iran-locations.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def provinces():
|
||||
return tuple(location_data())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def cities_by_province():
|
||||
return {item["province"]: tuple(item["cities"]) for item in location_data()}
|
||||
|
||||
|
||||
def validate_location(province, city):
|
||||
province = (province or "").strip()
|
||||
city = (city or "").strip()
|
||||
if not province and not city:
|
||||
return province, city, None
|
||||
if city and not province:
|
||||
return province, city, "برای انتخاب شهر، ابتدا استان را انتخاب کنید."
|
||||
cities = cities_by_province()
|
||||
if province not in cities:
|
||||
return province, city, "استان انتخابشده معتبر نیست."
|
||||
if city and city not in cities[province]:
|
||||
return province, city, "شهر انتخابشده متعلق به این استان نیست."
|
||||
return province, city, None
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,147 @@
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.attachments.models import AttachmentPolicy
|
||||
from apps.tickets.models import SLAConfig, TicketCategory
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import ContactGroup
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile, UnitMembership
|
||||
from apps.tickets.category_catalog import CATEGORY_CATALOG, UNIT_CONTENT
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create initial categories, policies and permission groups"
|
||||
|
||||
def merge_legacy_unit(self, legacy_slug, canonical_slug, canonical_name, canonical_description):
|
||||
legacy = OrganizationalUnit.objects.filter(slug=legacy_slug).first()
|
||||
canonical = OrganizationalUnit.objects.filter(slug=canonical_slug).first()
|
||||
if legacy is None:
|
||||
return
|
||||
if canonical is None:
|
||||
legacy.slug = canonical_slug
|
||||
legacy.name = canonical_name
|
||||
legacy.description = canonical_description
|
||||
legacy.save()
|
||||
return
|
||||
|
||||
from apps.accounts.models import TicketMagicLink
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from apps.tickets.models import GroupDispatch, Ticket
|
||||
|
||||
canonical.permissions.add(*legacy.permissions.all())
|
||||
TicketCategory.objects.filter(unit=legacy).update(unit=canonical)
|
||||
Ticket.objects.filter(owning_unit=legacy).update(owning_unit=canonical)
|
||||
Ticket.objects.filter(assigned_unit=legacy).update(assigned_unit=canonical)
|
||||
GroupDispatch.objects.filter(unit=legacy).update(unit=canonical)
|
||||
TicketMagicLink.objects.filter(created_unit=legacy).update(created_unit=canonical)
|
||||
StaffProfile.objects.filter(primary_unit=legacy).update(primary_unit=canonical)
|
||||
for membership in UnitMembership.objects.filter(unit=legacy):
|
||||
existing = UnitMembership.objects.filter(user=membership.user, unit=canonical).first()
|
||||
if existing is None:
|
||||
membership.unit = canonical
|
||||
membership.save(update_fields=["unit", "updated_at"])
|
||||
continue
|
||||
existing.permissions.add(*membership.permissions.all())
|
||||
existing.is_active = existing.is_active or membership.is_active
|
||||
existing.is_primary = existing.is_primary or membership.is_primary
|
||||
if membership.ticket_scope == UnitMembership.TicketScope.UNIT:
|
||||
existing.ticket_scope = UnitMembership.TicketScope.UNIT
|
||||
existing.save(update_fields=["is_active", "is_primary", "ticket_scope", "updated_at"])
|
||||
membership.delete()
|
||||
for group in ContactGroup.objects.filter(unit=legacy):
|
||||
existing = ContactGroup.objects.filter(unit=canonical, name=group.name).first()
|
||||
if existing is None:
|
||||
group.unit = canonical
|
||||
group.save(update_fields=["unit", "updated_at"])
|
||||
continue
|
||||
existing.contacts.add(*group.contacts.all())
|
||||
GroupDispatch.objects.filter(contact_group=group).update(contact_group=existing)
|
||||
group.delete()
|
||||
for template in SMSTemplate.objects.filter(unit=legacy):
|
||||
if SMSTemplate.objects.filter(unit=canonical, event_type=template.event_type).exists():
|
||||
template.delete()
|
||||
else:
|
||||
template.unit = canonical
|
||||
template.save(update_fields=["unit", "updated_at"])
|
||||
legacy.delete()
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
self.merge_legacy_unit("accounting", "finance", "مالی / حسابداری", "واحد مالی و حسابداری (یک مفهوم سازمانی)")
|
||||
self.merge_legacy_unit("logistics", "warehouse-loading", "بارگیری انبار", "بارگیری، انبار و ارسال")
|
||||
self.merge_legacy_unit("warehouse", "warehouse-loading", "بارگیری انبار", "بارگیری، انبار و ارسال")
|
||||
categories = [("استعلام قیمت","price"),("درصد تخفیف","discount"),("شرایط فروش","sales-terms"),("موجودی","inventory"),("شرایط ارسال","delivery"),("درخواست نمایندگی","agency"),("شکایت","complaint"),("مشاوره","consulting"),("سایر","other")]
|
||||
for i, (title, slug) in enumerate(categories):
|
||||
TicketCategory.objects.get_or_create(slug=slug, defaults={"title":title,"sort_order":i})
|
||||
sizes = {"IMAGE":10,"VOICE":25,"VIDEO":100,"PDF":30,"WORD":30,"EXCEL":30,"ZIP":50,"RAR":50}
|
||||
exts = {"IMAGE":[".jpg",".jpeg",".png",".gif",".webp"],"VOICE":[".webm",".ogg",".mp3",".m4a",".wav"],"VIDEO":[".mp4",".webm",".mov"],"PDF":[".pdf"],"WORD":[".doc",".docx"],"EXCEL":[".xls",".xlsx"],"ZIP":[".zip"],"RAR":[".rar"]}
|
||||
for i, (kind, mb) in enumerate(sizes.items()):
|
||||
AttachmentPolicy.objects.get_or_create(attachment_type=kind, defaults={"max_file_size":mb*1024*1024,"allowed_extensions":exts[kind],"sort_order":i})
|
||||
for priority, first, resolution in [("NORMAL",240,2880),("IMPORTANT",120,1440),("URGENT",30,480)]:
|
||||
SLAConfig.objects.get_or_create(priority=priority, defaults={"first_response_minutes":first,"resolution_minutes":resolution})
|
||||
expert_codes = ["view_ticket","add_ticket","claim_ticket","transfer_ticket","share_ticket","change_ticket","add_internal_note","edit_own_message","delete_own_message","download_attachment"]
|
||||
supervisor_codes = expert_codes + ["view_message_revisions","change_ticket_priority","view_customer_history"]
|
||||
manager_codes = supervisor_codes + ["view_deleted_message_content","delete_ticket"]
|
||||
role_permissions = {
|
||||
"Sales Expert": expert_codes,
|
||||
"Sales Supervisor": supervisor_codes,
|
||||
"Sales Manager": manager_codes,
|
||||
"Warehouse Expert": expert_codes,
|
||||
"Finance Expert": expert_codes,
|
||||
"CEO": manager_codes + ["view_all_tickets", "create_bulk_dispatch", "view_all_bulk_dispatches", "create_inquiry", "view_all_inquiries"],
|
||||
"General Admin": manager_codes + ["view_all_tickets"],
|
||||
}
|
||||
for group_name, codes in role_permissions.items():
|
||||
group, _ = Group.objects.get_or_create(name=group_name)
|
||||
group.permissions.set(Permission.objects.filter(codename__in=codes))
|
||||
units = {}
|
||||
for order, (slug, name, description, external, customer_visible) in enumerate([
|
||||
("sales", "فروش", UNIT_CONTENT["sales"]["description"], True, True),
|
||||
("warehouse-loading", "بارگیری انبار", "عملیات داخلی بارگیری و انبار", True, False),
|
||||
("finance", "مالی / حسابداری", UNIT_CONTENT["finance"]["description"], True, True),
|
||||
("executive", "مدیریت ارشد", "مدیریت عامل", False, False),
|
||||
], 10):
|
||||
unit, _ = OrganizationalUnit.objects.update_or_create(slug=slug, defaults={"name": name, "description": description, "search_keywords": UNIT_CONTENT.get(slug, {}).get("search_keywords", ""), "is_active": True, "accepts_external_tickets": external, "show_in_customer_form": customer_visible, "sort_order": order})
|
||||
units[slug] = unit
|
||||
for unit_slug, rows in CATEGORY_CATALOG.items():
|
||||
for sort_order, slug, title, description, search_keywords in rows:
|
||||
TicketCategory.objects.update_or_create(slug=slug, defaults={"unit": units[unit_slug], "title": title, "description": description, "search_keywords": search_keywords, "sort_order": sort_order, "is_active": True, "show_in_customer_form": True})
|
||||
ContactGroup.objects.get_or_create(unit=units[unit_slug], name=f"مخاطبان {units[unit_slug].name}")
|
||||
TicketCategory.objects.update_or_create(
|
||||
slug="warehouse-loading-general",
|
||||
defaults={"unit": units["warehouse-loading"], "title": "عملیات بارگیری انبار", "description": "هماهنگی داخلی عملیات انبار و بارگیری.", "search_keywords": "انبار بارگیری عملیات داخلی", "sort_order": 10, "is_active": True, "show_in_customer_form": False},
|
||||
)
|
||||
TicketCategory.objects.filter(slug__in=("other", "e2e-sms")).update(show_in_customer_form=False)
|
||||
unit_permission_pairs = [
|
||||
("tickets", "view_ticket"), ("tickets", "add_ticket"), ("tickets", "claim_ticket"),
|
||||
("messaging", "add_message"), ("customers", "view_customerprofile"),
|
||||
("customers", "add_customerprofile"), ("customers", "change_customerprofile"),
|
||||
("customers", "view_contactgroup"), ("customers", "add_contactgroup"), ("customers", "change_contactgroup"),
|
||||
]
|
||||
for unit in (units["sales"], units["warehouse-loading"], units["finance"]):
|
||||
unit.permissions.add(*[Permission.objects.get(content_type__app_label=app, codename=code) for app, code in unit_permission_pairs])
|
||||
people = [
|
||||
("kimiya_ghaffari", "کیمیا", "غفاری", "کیمیا غفاری", StaffProfile.Role.SALES_EXPERT, "sales", "کارشناس فروش", UnitMembership.TicketScope.SELF),
|
||||
("zahra_jamali", "زهرا", "جمالی", "زهرا جمالی", StaffProfile.Role.SALES_EXPERT, "sales", "کارشناس فروش", UnitMembership.TicketScope.SELF),
|
||||
("maryam_fotovat", "مریم", "فتوت", "مریم فتوت", StaffProfile.Role.SALES_SUPERVISOR, "sales", "سرپرست فروش", UnitMembership.TicketScope.UNIT),
|
||||
("morteza_sokhaeifard", "مرتضی", "سخائی فرد", "مرتضی سخائی فرد", StaffProfile.Role.SALES_MANAGER, "sales", "مدیر فروش", UnitMembership.TicketScope.UNIT),
|
||||
("mostafa_sokhaeifard", "مصطفی", "سخائی فرد", "مصطفی سخائی فرد", StaffProfile.Role.CEO, "executive", "مدیرعامل", UnitMembership.TicketScope.UNIT),
|
||||
("ms_sabzali", "", "سبزعلی", "خانم سبزعلی", StaffProfile.Role.WAREHOUSE_EXPERT, "warehouse-loading", "کارشناس بارگیری انبار", UnitMembership.TicketScope.UNIT),
|
||||
("mehdi_azami", "مهدی", "عظمی", "آقای مهدی عظمی", StaffProfile.Role.WAREHOUSE_EXPERT, "warehouse-loading", "کارشناس بارگیری انبار", UnitMembership.TicketScope.UNIT),
|
||||
("fereshteh_yavari", "فرشته", "یاوری", "خانم فرشته یاوری", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
|
||||
("fatemeh_sokhaei", "فاطمه", "سخایی", "خانم فاطمه سخایی", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
|
||||
("rana_nobakht", "رعنا", "نوبخت", "خانم رعنا نوبخت", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
|
||||
("mehdi_liaqat", "مهدی", "لیاقت", "آقای مهدی لیاقت", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
|
||||
("aliasghar_sokhaeifard", "علی اصغر", "سخائی فرد", "علی اصغر سخائی فرد", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
|
||||
]
|
||||
for username, first_name, last_name, display_name, role, unit_slug, title, scope in people:
|
||||
user, created = User.objects.get_or_create(username=username, defaults={"account_type": User.AccountType.STAFF})
|
||||
user.account_type = User.AccountType.STAFF; user.first_name = first_name; user.last_name = last_name; user.is_active = True
|
||||
if created: user.set_unusable_password()
|
||||
user.save()
|
||||
profile, _ = StaffProfile.objects.update_or_create(user=user, defaults={"primary_unit": units[unit_slug], "role": role, "display_name": display_name, "first_name": first_name, "last_name": last_name, "job_title": title, "is_active": True})
|
||||
membership = UnitMembership.objects.get(user=user, unit=units[unit_slug])
|
||||
membership.ticket_scope = scope; membership.is_primary = True; membership.is_active = True; membership.save()
|
||||
user.unit_memberships.exclude(pk=membership.pk).delete()
|
||||
sabzali = User.objects.get(username="ms_sabzali")
|
||||
sabzali.unit_memberships.get(unit=units["warehouse-loading"]).permissions.add(*Permission.objects.filter(content_type__app_label="tickets", codename__in=["create_bulk_dispatch", "create_inquiry"]))
|
||||
ceo = User.objects.get(username="mostafa_sokhaeifard")
|
||||
ceo.user_permissions.add(*Permission.objects.filter(content_type__app_label="tickets", codename__in=["view_all_tickets", "add_ticket", "create_bulk_dispatch", "view_all_bulk_dispatches", "create_inquiry", "view_all_inquiries"]))
|
||||
self.stdout.write(self.style.SUCCESS("Initial data created."))
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.messaging.models import Message, MessageRevision
|
||||
from apps.staff.models import StaffProfile
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Normalize existing human-authored Persian fields using the model policy."
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
specs = (
|
||||
(CustomerProfile, CustomerProfile.persian_text_fields),
|
||||
(StaffProfile, StaffProfile.persian_text_fields),
|
||||
(TicketCategory, TicketCategory.persian_text_fields),
|
||||
(Ticket, Ticket.persian_text_fields),
|
||||
(Message, Message.persian_text_fields),
|
||||
(MessageRevision, MessageRevision.persian_text_fields),
|
||||
)
|
||||
changed = 0
|
||||
for model, fields in specs:
|
||||
for instance in model.objects.all().iterator():
|
||||
before = tuple(getattr(instance, name) for name in fields)
|
||||
instance.save(update_fields=[*fields])
|
||||
after = tuple(getattr(instance, name) for name in fields)
|
||||
changed += before != after
|
||||
self.stdout.write(self.style.SUCCESS(f"Normalized {changed} existing records."))
|
||||
@@ -0,0 +1,13 @@
|
||||
class NoStoreHTMLMiddleware:
|
||||
"""Prevent browsers and installed PWAs from retaining authenticated HTML views."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
if response.get("Content-Type", "").lower().startswith("text/html"):
|
||||
response["Cache-Control"] = "private, no-cache, no-store, must-revalidate"
|
||||
response["Pragma"] = "no-cache"
|
||||
response["Expires"] = "0"
|
||||
return response
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-09 03:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PageTitle',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('route_name', models.CharField(help_text='مانند accounts:login', max_length=160, unique=True)),
|
||||
('title', models.CharField(max_length=240)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'عنوان صفحه',
|
||||
'verbose_name_plural': 'عنوان صفحات',
|
||||
'ordering': ('route_name',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SiteBranding',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('site_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)),
|
||||
('header_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)),
|
||||
('header_subtitle', models.CharField(default='درخواست روشن، پیگیری مطمئن', max_length=200)),
|
||||
('staff_brand_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)),
|
||||
('staff_brand_subtitle', models.CharField(default='مرکز ارتباط با مشتری', max_length=200)),
|
||||
('staff_panel_label', models.CharField(default='پنل کارشناسان', max_length=120)),
|
||||
('pwa_title', models.CharField(default='واحد فروش', max_length=80)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'تنظیمات عنوان و برند',
|
||||
'verbose_name_plural': 'تنظیمات عنوان و برند',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
PAGE_TITLES = {
|
||||
"accounts:login": "ورود مشتری | گفتوگو با واحد فروش",
|
||||
"accounts:password_login": "ورود با رمز عبور | گفتوگو با واحد فروش",
|
||||
"accounts:otp": "تأیید شماره موبایل | گفتوگو با واحد فروش",
|
||||
"accounts:profile": "پروفایل من | گفتوگو با واحد فروش",
|
||||
"accounts:staff_login": "ورود کارشناسان | گفتوگو با واحد فروش",
|
||||
"accounts:magic": "مشاهده امن تیکت | گفتوگو با واحد فروش",
|
||||
"tickets:list": "درخواستهای من | گفتوگو با واحد فروش",
|
||||
"tickets:new": "درخواست جدید | گفتوگو با واحد فروش",
|
||||
"tickets:detail": "جزئیات درخواست | گفتوگو با واحد فروش",
|
||||
"staff_tickets:list": "میز کار | پنل کارشناسان",
|
||||
"staff_tickets:create": "ثبت تیکت برای مشتری | پنل کارشناسان",
|
||||
"staff_tickets:detail": "جزئیات تیکت | پنل کارشناسان",
|
||||
"staff_tickets:contacts": "مخاطبان | پنل کارشناسان",
|
||||
"staff_tickets:customers": "مخاطبان | پنل کارشناسان",
|
||||
"staff_tickets:contact_create": "مخاطب جدید | پنل کارشناسان",
|
||||
"staff_tickets:contact_edit": "ویرایش مخاطب | پنل کارشناسان",
|
||||
"staff_tickets:groups": "گروههای مخاطبان | پنل کارشناسان",
|
||||
"staff_tickets:sms_templates": "الگوهای پیامک | پنل کارشناسان",
|
||||
"staff_tickets:sms_template_edit": "ویرایش الگوی پیامک | پنل کارشناسان",
|
||||
"staff_tickets:dispatch_create": "ارسال گروهی و ردیابی | پنل کارشناسان",
|
||||
"staff_tickets:dispatch_detail": "گزارش ارسال گروهی | پنل کارشناسان",
|
||||
"staff_tickets:magic_audit": "ممیزی لینکهای جادویی | پنل کارشناسان",
|
||||
"messages:revisions": "تاریخچه ویرایش پیام | گفتوگو با واحد فروش",
|
||||
}
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
apps.get_model("core", "SiteBranding").objects.get_or_create(pk=1)
|
||||
model = apps.get_model("core", "PageTitle")
|
||||
for route_name, title in PAGE_TITLES.items():
|
||||
model.objects.get_or_create(route_name=route_name, defaults={"title": title})
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0001_initial")]
|
||||
operations = [migrations.RunPython(seed, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-09 04:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_seed_branding_and_page_titles'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='developer_credit_html',
|
||||
field=models.TextField(blank=True, help_text='فقط برای HTML مورد اعتماد؛ لینک، آیکون و style خطی مجاز است.', verbose_name='HTML سفارشی امضای توسعه\u200cدهنده'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='developer_credit_text',
|
||||
field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت | 09202202129', max_length=500, verbose_name='متن امضای توسعه\u200cدهنده'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='login_intro_body',
|
||||
field=models.TextField(default='درخواست خود را ثبت کنید، فایل و پیام صوتی بفرستید و پاسخ کارشناس را در یک فضای امن دنبال کنید.', verbose_name='متن معرفی ورود'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='login_intro_enabled',
|
||||
field=models.BooleanField(default=True, verbose_name='نمایش بخش معرفی ورود'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='login_intro_eyebrow',
|
||||
field=models.CharField(default='ارتباط مستقیم با تیم فروش', max_length=200, verbose_name='بالانویس معرفی ورود'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='login_intro_heading',
|
||||
field=models.TextField(default='پاسخ روشن،\nپیگیری ساده.', verbose_name='عنوان معرفی ورود'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='login_intro_icon',
|
||||
field=models.CharField(blank=True, default='✦', max_length=32, verbose_name='آیکون معرفی ورود'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='use_developer_credit_html',
|
||||
field=models.BooleanField(default=False, help_text='با فعال\u200cسازی، HTML بدون escape نمایش داده می\u200cشود؛ فقط مدیر ارشد باید آن را ویرایش کند.', verbose_name='استفاده از HTML سفارشی'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-09 05:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_sitebranding_developer_credit_html_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_sidebar_credit_html',
|
||||
field=models.TextField(blank=True, help_text='مستقل از امضای عمومی؛ فقط برای HTML مورد اعتماد.', verbose_name='HTML سفارشی امضای sidebar کارکنان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_sidebar_credit_text',
|
||||
field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت', max_length=500, verbose_name='متن امضای پایین sidebar کارکنان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='use_staff_sidebar_credit_html',
|
||||
field=models.BooleanField(default=False, verbose_name='استفاده از HTML سفارشی در sidebar'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-09 05:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_sitebranding_staff_sidebar_credit_html_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='company_logo',
|
||||
field=models.ImageField(blank=True, upload_to='branding/logo/', verbose_name='لوگوی اصلی'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='customer_tickets_hero_body',
|
||||
field=models.TextField(default='موضوع جدیدی دارید؟ آن را جدا ثبت کنید تا تیم فروش دقیق\u200cتر و سریع\u200cتر پیگیری کند.', verbose_name='متن صفحه تیکت\u200cهای مشتری'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='customer_tickets_hero_enabled',
|
||||
field=models.BooleanField(default=True, verbose_name='نمایش معرفی صفحه تیکت\u200cهای مشتری'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='customer_tickets_hero_eyebrow',
|
||||
field=models.CharField(default='مرکز ارتباط با فروش', max_length=200, verbose_name='بالانویس صفحه تیکت\u200cهای مشتری'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='customer_tickets_hero_heading',
|
||||
field=models.TextField(default='هر درخواست، یک مسیر روشن\nتا رسیدن به پاسخ.', verbose_name='عنوان صفحه تیکت\u200cهای مشتری'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='favicon',
|
||||
field=models.ImageField(blank=True, upload_to='branding/favicon/', verbose_name='Favicon'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='pwa_description',
|
||||
field=models.CharField(default='ثبت و پیگیری درخواست\u200cهای فروش لوله گستر گلپایگان', max_length=300, verbose_name='توضیح PWA'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='pwa_icon',
|
||||
field=models.ImageField(blank=True, upload_to='branding/pwa/', verbose_name='آیکون PWA'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='pwa_install_help',
|
||||
field=models.CharField(default='برای دسترسی سریع، برنامه را به صفحه اصلی گوشی اضافه کنید.', max_length=300, verbose_name='راهنمای نصب PWA'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='pwa_install_label',
|
||||
field=models.CharField(default='نصب گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='عنوان پیشنهاد نصب PWA'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='pwa_name',
|
||||
field=models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='نام کامل PWA'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_login_intro_body',
|
||||
field=models.TextField(default='صف درخواست\u200cها، SLA، اولویت\u200cها و سوابق مشتری را از یک میز کار متمرکز مدیریت کنید.', verbose_name='متن ورود کارشناسان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_login_intro_enabled',
|
||||
field=models.BooleanField(default=True, verbose_name='نمایش معرفی ورود کارشناسان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_login_intro_eyebrow',
|
||||
field=models.CharField(default='پنل کارشناسان فروش', max_length=200, verbose_name='بالانویس ورود کارشناسان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_login_intro_heading',
|
||||
field=models.TextField(default='پاسخ\u200cهای بهتر،\nفروش هوشمندتر.', verbose_name='عنوان ورود کارشناسان'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='staff_login_intro_icon',
|
||||
field=models.CharField(blank=True, default='◈', max_length=32, verbose_name='آیکون معرفی ورود کارشناسان'),
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,127 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
from .persian_text import normalize_persian_text
|
||||
|
||||
class PersianTextNormalizationMixin:
|
||||
"""Normalize only explicitly listed human-authored model fields on save."""
|
||||
persian_text_fields = {}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get("update_fields")
|
||||
for field_name, multiline in self.persian_text_fields.items():
|
||||
if update_fields is None or field_name in update_fields:
|
||||
value = getattr(self, field_name, None)
|
||||
if value is not None:
|
||||
setattr(
|
||||
self,
|
||||
field_name,
|
||||
normalize_persian_text(value, multiline=multiline),
|
||||
)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
class TimeStampedModel(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
class PublicIdModel(models.Model):
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class SiteBranding(models.Model):
|
||||
"""Database-backed copy shared by customer, authentication and staff pages."""
|
||||
site_title = models.CharField(max_length=160, default="گفتوگو با واحد فروش")
|
||||
header_title = models.CharField(max_length=160, default="گفتوگو با واحد فروش")
|
||||
header_subtitle = models.CharField(max_length=200, default="درخواست روشن، پیگیری مطمئن")
|
||||
staff_brand_title = models.CharField(max_length=160, default="گفتوگو با واحد فروش")
|
||||
staff_brand_subtitle = models.CharField(max_length=200, default="مرکز ارتباط با مشتری")
|
||||
staff_panel_label = models.CharField(max_length=120, default="پنل کارشناسان")
|
||||
pwa_title = models.CharField(max_length=80, default="واحد فروش")
|
||||
company_logo = models.ImageField(upload_to="branding/logo/", blank=True, verbose_name="لوگوی اصلی")
|
||||
favicon = models.ImageField(upload_to="branding/favicon/", blank=True, verbose_name="Favicon")
|
||||
pwa_icon = models.ImageField(upload_to="branding/pwa/", blank=True, verbose_name="آیکون PWA")
|
||||
pwa_name = models.CharField(max_length=160, default="گفتوگو با واحد فروش", verbose_name="نام کامل PWA")
|
||||
pwa_description = models.CharField(max_length=300, default="ثبت و پیگیری درخواستهای فروش لوله گستر گلپایگان", verbose_name="توضیح PWA")
|
||||
pwa_install_label = models.CharField(max_length=160, default="نصب گفتوگو با واحد فروش", verbose_name="عنوان پیشنهاد نصب PWA")
|
||||
pwa_install_help = models.CharField(max_length=300, default="برای دسترسی سریع، برنامه را به صفحه اصلی گوشی اضافه کنید.", verbose_name="راهنمای نصب PWA")
|
||||
login_intro_enabled = models.BooleanField(default=True, verbose_name="نمایش بخش معرفی ورود")
|
||||
login_intro_icon = models.CharField(max_length=32, default="✦", blank=True, verbose_name="آیکون معرفی ورود")
|
||||
login_intro_eyebrow = models.CharField(max_length=200, default="ارتباط مستقیم با تیم فروش", verbose_name="بالانویس معرفی ورود")
|
||||
login_intro_heading = models.TextField(default="پاسخ روشن،\nپیگیری ساده.", verbose_name="عنوان معرفی ورود")
|
||||
login_intro_body = models.TextField(
|
||||
default="درخواست خود را ثبت کنید، فایل و پیام صوتی بفرستید و پاسخ کارشناس را در یک فضای امن دنبال کنید.",
|
||||
verbose_name="متن معرفی ورود",
|
||||
)
|
||||
staff_login_intro_enabled = models.BooleanField(default=True, verbose_name="نمایش معرفی ورود کارشناسان")
|
||||
staff_login_intro_icon = models.CharField(max_length=32, default="◈", blank=True, verbose_name="آیکون معرفی ورود کارشناسان")
|
||||
staff_login_intro_eyebrow = models.CharField(max_length=200, default="پنل کارشناسان فروش", verbose_name="بالانویس ورود کارشناسان")
|
||||
staff_login_intro_heading = models.TextField(default="پاسخهای بهتر،\nفروش هوشمندتر.", verbose_name="عنوان ورود کارشناسان")
|
||||
staff_login_intro_body = models.TextField(default="صف درخواستها، SLA، اولویتها و سوابق مشتری را از یک میز کار متمرکز مدیریت کنید.", verbose_name="متن ورود کارشناسان")
|
||||
customer_tickets_hero_enabled = models.BooleanField(default=True, verbose_name="نمایش معرفی صفحه تیکتهای مشتری")
|
||||
customer_tickets_hero_eyebrow = models.CharField(max_length=200, default="مرکز ارتباط با فروش", verbose_name="بالانویس صفحه تیکتهای مشتری")
|
||||
customer_tickets_hero_heading = models.TextField(default="هر درخواست، یک مسیر روشن\nتا رسیدن به پاسخ.", verbose_name="عنوان صفحه تیکتهای مشتری")
|
||||
customer_tickets_hero_body = models.TextField(default="موضوع جدیدی دارید؟ آن را جدا ثبت کنید تا تیم فروش دقیقتر و سریعتر پیگیری کند.", verbose_name="متن صفحه تیکتهای مشتری")
|
||||
developer_credit_text = models.CharField(
|
||||
max_length=500,
|
||||
default="برنامه نویسی و توسعه: محمد جواد فصاحت | 09202202129",
|
||||
verbose_name="متن امضای توسعهدهنده",
|
||||
)
|
||||
developer_credit_html = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="HTML سفارشی امضای توسعهدهنده",
|
||||
help_text="فقط برای HTML مورد اعتماد؛ لینک، آیکون و style خطی مجاز است.",
|
||||
)
|
||||
use_developer_credit_html = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="استفاده از HTML سفارشی",
|
||||
help_text="با فعالسازی، HTML بدون escape نمایش داده میشود؛ فقط مدیر ارشد باید آن را ویرایش کند.",
|
||||
)
|
||||
staff_sidebar_credit_text = models.CharField(
|
||||
max_length=500,
|
||||
default="برنامه نویسی و توسعه: محمد جواد فصاحت",
|
||||
verbose_name="متن امضای پایین sidebar کارکنان",
|
||||
)
|
||||
staff_sidebar_credit_html = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="HTML سفارشی امضای sidebar کارکنان",
|
||||
help_text="مستقل از امضای عمومی؛ فقط برای HTML مورد اعتماد.",
|
||||
)
|
||||
use_staff_sidebar_credit_html = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="استفاده از HTML سفارشی در sidebar",
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "تنظیمات عنوان و برند"
|
||||
verbose_name_plural = "تنظیمات عنوان و برند"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
return cls.objects.filter(pk=1).first() or cls()
|
||||
|
||||
def __str__(self):
|
||||
return self.site_title
|
||||
|
||||
|
||||
class PageTitle(models.Model):
|
||||
"""Editable browser title for a named Django route."""
|
||||
route_name = models.CharField(max_length=160, unique=True, help_text="مانند accounts:login")
|
||||
title = models.CharField(max_length=240)
|
||||
is_active = models.BooleanField(default=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("route_name",)
|
||||
verbose_name = "عنوان صفحه"
|
||||
verbose_name_plural = "عنوان صفحات"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.route_name}: {self.title}"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Safe, conservative Persian text normalization for human-authored fields."""
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from hazm import Normalizer
|
||||
|
||||
|
||||
# Hazm's spacing rules are intended for prose and can add spaces inside URLs and
|
||||
# technical identifiers. Temporarily replace those spans before normalization.
|
||||
_PROTECTED_SPAN = re.compile(
|
||||
r"https?://\S+|www\.\S+|[\w.+-]+@[\w.-]+\.\w+|"
|
||||
r"(?<![\w\u0600-\u06ff])[A-Za-z0-9][A-Za-z0-9._:/?#%+\\=@-]*"
|
||||
)
|
||||
_TOKEN_PREFIX = "HAZMPROTECTEDTOKEN"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _normalizer():
|
||||
return Normalizer(
|
||||
remove_diacritics=False,
|
||||
remove_specials_chars=False,
|
||||
decrease_repeated_chars=False,
|
||||
persian_numbers=False,
|
||||
)
|
||||
|
||||
|
||||
def _alpha_index(index):
|
||||
"""Return an ASCII-letter-only stable suffix (A, B, ..., AA)."""
|
||||
result = ""
|
||||
while True:
|
||||
index, remainder = divmod(index, 26)
|
||||
result = chr(65 + remainder) + result
|
||||
if index == 0:
|
||||
return result
|
||||
index -= 1
|
||||
|
||||
|
||||
def normalize_persian_text(value, *, multiline=False):
|
||||
"""Normalize Persian orthography without rewriting meaning or technical data.
|
||||
|
||||
Multiline values retain paragraph boundaries. Empty/None values become an
|
||||
empty string, matching the project's non-null textual model fields.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
|
||||
protected = {}
|
||||
|
||||
def protect(match):
|
||||
token = f"{_TOKEN_PREFIX}{_alpha_index(len(protected))}"
|
||||
protected[token] = match.group(0)
|
||||
return token
|
||||
|
||||
text = _PROTECTED_SPAN.sub(protect, text)
|
||||
if multiline:
|
||||
normalized = "\n".join(
|
||||
_normalizer().normalize(line).strip() if line.strip() else ""
|
||||
for line in text.split("\n")
|
||||
).strip()
|
||||
else:
|
||||
normalized = _normalizer().normalize(" ".join(text.splitlines())).strip()
|
||||
|
||||
for token, original in protected.items():
|
||||
normalized = normalized.replace(token, original)
|
||||
return normalized
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Presentation-only Jalali filters; persisted datetimes stay Gregorian/UTC."""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
import jdatetime
|
||||
from django import template
|
||||
from django.utils import timezone
|
||||
|
||||
register = template.Library()
|
||||
_PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||
|
||||
|
||||
def _persian_digits(value):
|
||||
return str(value).translate(_PERSIAN_DIGITS)
|
||||
|
||||
|
||||
def _local_gregorian(value):
|
||||
if isinstance(value, datetime) and timezone.is_aware(value):
|
||||
return timezone.localtime(value)
|
||||
return value
|
||||
|
||||
|
||||
def _jalali(value):
|
||||
value = _local_gregorian(value)
|
||||
if isinstance(value, datetime):
|
||||
return jdatetime.datetime.fromgregorian(datetime=value, locale=jdatetime.FA_LOCALE)
|
||||
if isinstance(value, date):
|
||||
return jdatetime.date.fromgregorian(date=value, locale=jdatetime.FA_LOCALE)
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
def jalali_datetime(value):
|
||||
"""Example: ۱۵ شهریور ۱۴۰۵، ساعت ۰۹:۳۰."""
|
||||
converted = _jalali(value)
|
||||
if converted is None:
|
||||
return ""
|
||||
pattern = "%d %B %Y، ساعت %H:%M" if isinstance(converted, jdatetime.datetime) else "%d %B %Y"
|
||||
return _persian_digits(converted.strftime(pattern))
|
||||
|
||||
|
||||
@register.filter
|
||||
def jalali_date(value):
|
||||
"""Example: ۱۵ شهریور ۱۴۰۵."""
|
||||
converted = _jalali(value)
|
||||
return _persian_digits(converted.strftime("%d %B %Y")) if converted else ""
|
||||
|
||||
|
||||
@register.filter
|
||||
def persian_time(value):
|
||||
"""Display local Iran time using Persian digits."""
|
||||
value = _local_gregorian(value)
|
||||
return _persian_digits(value.strftime("%H:%M")) if isinstance(value, datetime) else ""
|
||||
Reference in new issue
Block a user