diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index b426910..9de9861 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -61,7 +61,8 @@ def staff_operations_access(request): 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"), + "contacts": True, + "contact_groups": allowed("customers.view_contactgroup") or allowed("customers.add_contactgroup"), "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"), diff --git a/apps/customers/admin.py b/apps/customers/admin.py index 1f3c26b..a1bc0f2 100644 --- a/apps/customers/admin.py +++ b/apps/customers/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import ContactGroup, CustomerProfile +from .models import ContactEditLog, ContactGroup, CustomerProfile @admin.register(ContactGroup) @@ -15,3 +15,19 @@ class CustomerProfileAdmin(admin.ModelAdmin): list_filter = ("stakeholder_type", "groups") search_fields = ("user__mobile", "organization_name", "first_name", "last_name") filter_horizontal = ("groups",) + + +@admin.register(ContactEditLog) +class ContactEditLogAdmin(admin.ModelAdmin): + list_display = ("contact_public_id", "actor_name", "created_at") + search_fields = ("actor_name", "contact_public_id") + readonly_fields = ("contact", "contact_public_id", "actor", "actor_name", "changes", "created_at") + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/apps/customers/migrations/0005_contacteditlog.py b/apps/customers/migrations/0005_contacteditlog.py new file mode 100644 index 0000000..fe35084 --- /dev/null +++ b/apps/customers/migrations/0005_contacteditlog.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.1 on 2026-09-29 06:14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0004_contactgroup_unit_customerprofile_groups_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ContactEditLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('contact_public_id', models.UUIDField(db_index=True)), + ('actor_name', models.CharField(max_length=150)), + ('changes', models.JSONField()), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contact_edits', to=settings.AUTH_USER_MODEL)), + ('contact', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='edit_logs', to='customers.customerprofile')), + ], + options={ + 'verbose_name': 'سابقه ویرایش مخاطب', + 'verbose_name_plural': 'سوابق ویرایش مخاطبان', + 'ordering': ['-created_at', '-pk'], + }, + ), + ] diff --git a/apps/customers/migrations/0006_backfill_customer_profiles.py b/apps/customers/migrations/0006_backfill_customer_profiles.py new file mode 100644 index 0000000..49f520d --- /dev/null +++ b/apps/customers/migrations/0006_backfill_customer_profiles.py @@ -0,0 +1,25 @@ +from django.db import migrations + + +def backfill_customer_profiles(apps, schema_editor): + User = apps.get_model("accounts", "User") + CustomerProfile = apps.get_model("customers", "CustomerProfile") + database = schema_editor.connection.alias + missing_users = ( + User.objects.using(database) + .filter(account_type="CUSTOMER", customer_profile__isnull=True) + .values("pk", "first_name", "last_name") + ) + batch = [] + for user in missing_users.iterator(chunk_size=500): + batch.append(CustomerProfile(user_id=user["pk"], first_name=user["first_name"][:100], last_name=user["last_name"][:100])) + if len(batch) == 500: + CustomerProfile.objects.using(database).bulk_create(batch, ignore_conflicts=True, batch_size=500) + batch.clear() + if batch: + CustomerProfile.objects.using(database).bulk_create(batch, ignore_conflicts=True, batch_size=500) + + +class Migration(migrations.Migration): + dependencies = [("customers", "0005_contacteditlog")] + operations = [migrations.RunPython(backfill_customer_profiles, migrations.RunPython.noop)] diff --git a/apps/customers/models.py b/apps/customers/models.py index 752bda2..7be85b0 100644 --- a/apps/customers/models.py +++ b/apps/customers/models.py @@ -52,3 +52,19 @@ class CustomerProfile(PersianTextNormalizationMixin, TimeStampedModel): 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) + + +class ContactEditLog(models.Model): + """Persistent record of changes made through the staff contact editor.""" + + contact = models.ForeignKey(CustomerProfile, on_delete=models.SET_NULL, null=True, related_name="edit_logs") + contact_public_id = models.UUIDField(db_index=True) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="contact_edits") + actor_name = models.CharField(max_length=150) + changes = models.JSONField() + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + ordering = ["-created_at", "-pk"] + verbose_name = "سابقه ویرایش مخاطب" + verbose_name_plural = "سوابق ویرایش مخاطبان" diff --git a/apps/tickets/forms.py b/apps/tickets/forms.py index 07c0b1d..7faf3ba 100644 --- a/apps/tickets/forms.py +++ b/apps/tickets/forms.py @@ -74,7 +74,7 @@ class StaffCreateTicketForm(forms.Form): first_name=forms.CharField(label="نام مشتری",required=False,max_length=100); last_name=forms.CharField(label="نام خانوادگی مشتری",required=False,max_length=100) organization_name=forms.CharField(label="نام شرکت / سازمان",required=False,max_length=180) stakeholder_type=forms.ChoiceField(label="نوع مخاطب",choices=CustomerProfile.StakeholderType.choices,initial=CustomerProfile.StakeholderType.CUSTOMER,required=False) - groups=forms.ModelMultipleChoiceField(label="گروههای مخاطب",queryset=ContactGroup.objects.none(),required=False,help_text="برای حفظ مخاطب در دفترچه واحد، حداقل یک گروه مناسب انتخاب کنید.") + groups=forms.ModelMultipleChoiceField(label="گروههای مخاطب",queryset=ContactGroup.objects.none(),required=False,help_text="گروهبندی اختیاری است و بر نمایش مخاطب در دفترچه تأثیری ندارد.") category=forms.ModelChoiceField(label="دستهبندی",queryset=TicketCategory.objects.none()); subject=forms.CharField(label="موضوع",min_length=5,max_length=200) province=forms.CharField(label="استان",required=False,max_length=100,widget=forms.TextInput(attrs={"list":"ticket-province-options","autocomplete":"off","data-province-input":""})); city=forms.CharField(label="شهر",required=False,max_length=100,widget=forms.TextInput(attrs={"list":"ticket-city-options","autocomplete":"off","data-city-input":""})) body=forms.CharField(label="شرح پیام کارشناس",widget=forms.Textarea); files=MultipleFileField(label="پیوستها",required=False,widget=MultipleFileInput) @@ -184,8 +184,8 @@ class StaffContactForm(forms.Form): groups = forms.ModelMultipleChoiceField( label="عضویت در گروهها", queryset=ContactGroup.objects.none(), - required=True, - help_text="گروههای موردنظر را روشن یا خاموش کنید؛ حداقل یک گروه باید باقی بماند.", + required=False, + help_text="عضویت در گروه اختیاری است. فقط گروههای واحدهای قابل مدیریت شما تغییر میکنند.", widget=forms.CheckboxSelectMultiple(attrs={"data-contact-group-option": ""}), ) province = forms.ChoiceField(label="استان", required=False) diff --git a/apps/tickets/views.py b/apps/tickets/views.py index bd44f63..b23f835 100644 --- a/apps/tickets/views.py +++ b/apps/tickets/views.py @@ -21,7 +21,7 @@ import re from difflib import SequenceMatcher from apps.accounts.models import TicketMagicLink, User, normalize_mobile from apps.accounts.magic_access import get_ticket_magic_access -from apps.customers.models import ContactGroup, CustomerProfile +from apps.customers.models import ContactEditLog, ContactGroup, CustomerProfile from apps.messaging.models import Message,MessageReadReceipt from apps.notifications.models import AnnouncementJob, NotificationJob, SMSTemplate from apps.notifications.services import schedule_immediate_announcement @@ -376,32 +376,26 @@ def staff_create(request): if created: customer=User.objects.create(mobile=mobile,username=username,account_type=User.AccountType.CUSTOMER) if created: customer.set_unusable_password(); customer.save(update_fields=["password"]) - profile,_=CustomerProfile.objects.get_or_create(user=customer) + profile,profile_created=CustomerProfile.objects.get_or_create(user=customer) + profile = CustomerProfile.objects.select_related("user").select_for_update().get(pk=profile.pk) + before = _contact_snapshot(profile) if not profile_created else None for field in ("first_name","last_name","organization_name","stakeholder_type","province","city"): value=form.cleaned_data.get(field) if value: setattr(profile,field,value) profile.save() if form.cleaned_data.get("groups"): profile.groups.add(*form.cleaned_data["groups"]) + if before is not None: _log_contact_edit(profile, request.user, before) ticket=create_ticket(customer=customer,created_by=request.user,unit=form.cleaned_data.get("unit"),category=form.cleaned_data["category"],subject=form.cleaned_data["subject"],province=form.cleaned_data["province"],city=form.cleaned_data["city"],body=form.cleaned_data["body"],files=request.FILES.getlist("files")) messages.success(request,"تیکت برای مشتری ثبت و پیامک لینک امن در صف ارسال قرار گرفت.") return redirect("staff_tickets:detail",public_id=ticket.public_id) return render(request,"tickets/staff_create.html",{"form":form,"staff_unit":TicketPolicy.primary_unit(request.user),"active_tab_title":"ثبت تیکت برای مشتری"}) def _customer_rows(search="", user=None): - # A CUSTOMER account can temporarily exist without its one-to-one profile - # (legacy/imported or interrupted registrations). Such an account is not - # an address-book contact and would make the contacts template dereference - # a missing profile, especially for superusers whose scope includes all - # customers. + # Legacy/imported CUSTOMER accounts may lack a profile. Keep them searchable + # so staff can open the editor and complete their contact information. qs=User.objects.filter( account_type=User.AccountType.CUSTOMER, - customer_profile__isnull=False, ).select_related("customer_profile") - has_unit_membership = user is not None and user.unit_memberships.filter(is_active=True, unit__is_active=True).exists() - if user is not None and has_unit_membership and not (user.is_superuser or user.has_perm("tickets.view_all_tickets")): - visible_customer_ids = staff_ticket_rows(user).values_list("customer_id", flat=True) - contact_unit_ids = _unit_ids_with_permission(user, "customers.view_customerprofile") - qs = qs.filter(Q(pk__in=visible_customer_ids) | Q(customer_profile__groups__unit_id__in=contact_unit_ids)).distinct() if search: digits=search.translate(str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩","01234567890123456789")) mobile_terms={digits} @@ -409,12 +403,13 @@ def _customer_rows(search="", user=None): except ValueError: pass mobile_query=Q() for term in mobile_terms: mobile_query|=Q(mobile__icontains=term) - qs=qs.filter(mobile_query|Q(customer_profile__first_name__icontains=search)|Q(customer_profile__last_name__icontains=search)|Q(username__icontains=search)) + qs=qs.filter(mobile_query|Q(customer_profile__first_name__icontains=search)|Q(customer_profile__last_name__icontains=search)|Q(first_name__icontains=search)|Q(last_name__icontains=search)|Q(username__icontains=search)) + visible_tickets = Q(customer_tickets__pk__in=staff_ticket_rows(user).values("pk")) if user is not None else Q() return qs.annotate( - ticket_count=Count("customer_tickets",distinct=True), - active_count=Count("customer_tickets",filter=~Q(customer_tickets__status__in=[Ticket.Status.RESOLVED,Ticket.Status.CLOSED]),distinct=True), - waiting_staff_count=Count("customer_tickets",filter=Q(customer_tickets__status__in=[Ticket.Status.NEW,Ticket.Status.WAITING_STAFF,Ticket.Status.REOPENED]),distinct=True), - resolved_count=Count("customer_tickets",filter=Q(customer_tickets__status__in=[Ticket.Status.RESOLVED,Ticket.Status.CLOSED]),distinct=True), + ticket_count=Count("customer_tickets",filter=visible_tickets,distinct=True), + active_count=Count("customer_tickets",filter=visible_tickets & ~Q(customer_tickets__status__in=[Ticket.Status.RESOLVED,Ticket.Status.CLOSED]),distinct=True), + waiting_staff_count=Count("customer_tickets",filter=visible_tickets & Q(customer_tickets__status__in=[Ticket.Status.NEW,Ticket.Status.WAITING_STAFF,Ticket.Status.REOPENED]),distinct=True), + resolved_count=Count("customer_tickets",filter=visible_tickets & Q(customer_tickets__status__in=[Ticket.Status.RESOLVED,Ticket.Status.CLOSED]),distinct=True), ).order_by("-active_count","-ticket_count","-date_joined") @staff_required @@ -769,20 +764,44 @@ def _visible_dispatches(user): return GroupDispatch.objects.filter(visibility) -def _contact_queryset(user): - """Contacts visible through a permitted group or a viewable ticket. +CONTACT_AUDIT_FIELDS = { + "mobile": "شماره همراه", + "first_name": "نام", + "last_name": "نام خانوادگی", + "organization_name": "نام شرکت / سازمان", + "stakeholder_type": "نوع مخاطب", + "province": "استان", + "city": "شهر", +} - A customer created by login or ticket intake may not have a contact group - yet. Keeping ticket-visible customers in this queryset makes the contact - list and its edit endpoint use the same visibility rule. - """ - visible_groups = ContactGroup.objects.filter(unit_id__in=_unit_ids_with_permission(user, "customers.view_customerprofile")).select_related("unit") - visible_customer_ids = staff_ticket_rows(user).values_list("customer_id", flat=True) - return CustomerProfile.objects.filter( - Q(groups__in=visible_groups) | Q(user_id__in=visible_customer_ids) - ).select_related("user").prefetch_related( - Prefetch("groups", queryset=visible_groups, to_attr="visible_groups") - ).distinct() + +def _contact_snapshot(profile): + values = {key: profile.user.mobile if key == "mobile" else getattr(profile, key) for key in CONTACT_AUDIT_FIELDS} + values["groups"] = set(profile.groups.values_list("pk", flat=True)) + return values + + +def _log_contact_edit(profile, actor, before): + after = _contact_snapshot(profile) + changes = [ + {"field": label, "before": str(before[key]), "after": str(after[key])} + for key, label in CONTACT_AUDIT_FIELDS.items() if before[key] != after[key] + ] + if before["groups"] != after["groups"]: + names = dict(ContactGroup.objects.filter(pk__in=before["groups"] | after["groups"]).values_list("pk", "name")) + changes.append({ + "field": "گروهها", + "before": "، ".join(sorted(names.get(pk, str(pk)) for pk in before["groups"])), + "after": "، ".join(sorted(names.get(pk, str(pk)) for pk in after["groups"])), + }) + if changes: + ContactEditLog.objects.create( + contact=profile, + contact_public_id=profile.user.public_id, + actor=actor, + actor_name=(actor.get_full_name() or actor.username)[:150], + changes=changes, + ) def _voip_call_methods(user): @@ -799,7 +818,7 @@ def _voip_call_methods(user): @staff_required @require_POST def staff_contact_call(request, public_id): - profile = get_object_or_404(_contact_queryset(request.user), user__public_id=public_id) + contact = get_object_or_404(User, public_id=public_id, account_type=User.AccountType.CUSTOMER) if not request.user.voip_call: raise PermissionDenied try: @@ -809,9 +828,9 @@ def staff_contact_call(request, public_id): method = data.get("method") if method not in _voip_call_methods(request.user): return JsonResponse({"success": False, "message": "روش تماس در دسترس نیست."}, status=400) - if not profile.user.mobile: + if not contact.mobile: return JsonResponse({"success": False, "message": "شماره مخاطب ثبت نشده است."}, status=400) - result = place_voip_call(agent=request.user, customer=profile.user, method=method) + result = place_voip_call(agent=request.user, customer=contact, method=method) if not result.success: return JsonResponse({"success": False, "message": "برقراری تماس ناموفق بود."}, status=502) return JsonResponse({"success": True, "message": "درخواست تماس ارسال شد."}) @@ -831,7 +850,6 @@ def staff_contacts(request): group = request.GET.get("group", "") province = request.GET.get("province", "").strip() visible_unit_ids = _unit_ids_with_permission(request.user, "customers.view_customerprofile") - if not visible_unit_ids: raise PermissionDenied visible_groups = ContactGroup.objects.filter(unit_id__in=visible_unit_ids).select_related("unit") qs = _customer_rows(search, request.user).prefetch_related(Prefetch("customer_profile__groups", queryset=visible_groups, to_attr="visible_groups")) if stakeholder in CustomerProfile.StakeholderType.values: @@ -844,7 +862,7 @@ def staff_contacts(request): base_profiles = CustomerProfile.objects.filter(user_id__in=[item.pk for item in contacts]) metrics = { "total": len(contacts), - "customers": base_profiles.filter(stakeholder_type=CustomerProfile.StakeholderType.CUSTOMER).count(), + "customers": base_profiles.filter(stakeholder_type=CustomerProfile.StakeholderType.CUSTOMER).count() + sum(not hasattr(item, "customer_profile") for item in contacts), "suppliers": base_profiles.filter(stakeholder_type=CustomerProfile.StakeholderType.SUPPLIER).count(), "carriers": base_profiles.filter(stakeholder_type=CustomerProfile.StakeholderType.CARRIER).count(), "needs_action": sum(item.waiting_staff_count for item in contacts), @@ -875,19 +893,14 @@ def staff_contact_create(request): @staff_required @require_http_methods(["GET", "POST"]) def staff_contact_edit(request, public_id): - profile = get_object_or_404(_contact_queryset(request.user), user__public_id=public_id) + contact = get_object_or_404(User, public_id=public_id, account_type=User.AccountType.CUSTOMER) + profile, _ = CustomerProfile.objects.get_or_create(user=contact, defaults={"first_name": contact.first_name[:100], "last_name": contact.last_name[:100]}) manageable_unit_ids = _unit_ids_with_permission(request.user, "customers.change_customerprofile") - editable_through_group = profile.groups.filter(unit_id__in=manageable_unit_ids).exists() - editable_through_ticket = staff_ticket_rows(request.user).filter( - Q(assigned_unit_id__in=manageable_unit_ids) - | Q(assigned_unit__isnull=True, owning_unit_id__in=manageable_unit_ids), - customer_id=profile.user_id, - ).exists() - if not (editable_through_group or editable_through_ticket): - raise PermissionDenied form = StaffContactForm(request.POST or None, user=request.user, instance=profile, unit_ids=manageable_unit_ids) if request.method == "POST" and form.is_valid(): with transaction.atomic(): + profile = CustomerProfile.objects.select_related("user").select_for_update().get(pk=profile.pk) + before = _contact_snapshot(profile) profile.user.mobile = form.cleaned_data["mobile"]; profile.user.save(update_fields=["mobile"]) for key in ("first_name", "last_name", "organization_name", "stakeholder_type", "province", "city"): setattr(profile, key, form.cleaned_data[key]) @@ -895,9 +908,10 @@ def staff_contact_edit(request, public_id): accessible = ContactGroup.objects.filter(unit_id__in=manageable_unit_ids) profile.groups.remove(*accessible) profile.groups.add(*form.cleaned_data["groups"]) + _log_contact_edit(profile, request.user, before) messages.success(request, "اطلاعات مخاطب بروزرسانی شد.") return redirect("staff_tickets:contacts") - return render(request, "tickets/staff_entity_form.html", {"form": form, "call_customer": profile.user, "voip_call_methods": _voip_call_methods(request.user), "active_staff_section": "contacts", "active_tab_title": "ویرایش مخاطب", "form_title": str(profile), "form_help": "عضویت همزمان مخاطب در چند گروه قابل مدیریت است."}) + return render(request, "tickets/staff_entity_form.html", {"form": form, "contact_edit_logs": profile.edit_logs.select_related("actor")[:30], "call_customer": profile.user, "voip_call_methods": _voip_call_methods(request.user), "active_staff_section": "contacts", "active_tab_title": "ویرایش مخاطب", "form_title": str(profile), "form_help": "عضویت در گروه اختیاری است و فقط گروههای قابل مدیریت شما تغییر میکنند."}) @staff_required diff --git a/docs/ORGANIZATIONAL_TICKETING.md b/docs/ORGANIZATIONAL_TICKETING.md index a627d52..008cf40 100644 --- a/docs/ORGANIZATIONAL_TICKETING.md +++ b/docs/ORGANIZATIONAL_TICKETING.md @@ -10,7 +10,8 @@ and database-managed Django permissions. 2. Select the Django permissions inherited by every member of each unit. 3. Assign staff through unit memberships and choose one primary unit. 4. Classify external contacts as customer, supplier, carrier, or other. -5. Create unit-owned contact groups and assign each contact to any number of groups. +5. Create unit-owned contact groups when useful. Group membership is optional, + including for customers who registered themselves or existed before this rollout. 6. Create optional unit-specific SMS templates. A template with no unit is the global fallback. Supported variables include `recipient_name`, `unit_name`, `company_name`, `magic_link`, `ticket_number`, and `subject`. @@ -28,6 +29,13 @@ Useful unit permissions include `tickets.view_ticket`, `tickets.claim_ticket`, - Each group recipient gets a separate `Ticket`, notification job, and magic link. - External users can access only tickets whose `customer_id` is their own user ID. - Unit filtering happens in both list querysets and object-level policy checks. +- The staff contact directory and editor include every customer account, + regardless of group membership or ticket ownership. Existing customer accounts + without a profile receive one in a one-time data migration; the editor also + repairs any later account missing its profile. Staff can edit contact details; + each edit records the actor and changed values. Group membership editing remains + limited to groups in units the staff member may manage. Contact access does not + grant access to tickets in another unit. - Every valid magic-link opening appends an immutable access-log row and atomically increments its view counter. - Raw magic-link tokens are never stored; only SHA-256 digests are persisted. diff --git a/templates/base.html b/templates/base.html index e87c40b..26dccaa 100644 --- a/templates/base.html +++ b/templates/base.html @@ -30,7 +30,7 @@ ↺در انتظار پاسخ من{% if counts.waiting %}{{ counts.waiting }}{% endif %} ◎تیکتهای من ◇اشتراکها - {% if staff_operations_access.contacts %}♧مخاطبان و گروهها{% endif %} + {% if staff_operations_access.contacts %}♧مخاطبان{% endif %} {% if staff_operations_access.dispatches %}⇶ارسال گروهی{% endif %} {% if staff_operations_access.audit %}⌁ممیزی لینکها{% endif %} diff --git a/templates/tickets/staff_contacts.html b/templates/tickets/staff_contacts.html index 1bc5810..ee7ca4f 100644 --- a/templates/tickets/staff_contacts.html +++ b/templates/tickets/staff_contacts.html @@ -1,8 +1,8 @@ {% extends 'base.html' %}{% load static %} {% block title %}{{ page_title }}{% endblock %} {% block content %}
مرکز یکپارچه ارتباطات
پرونده ارتباطی مشتریان، تأمینکنندگان و باربریها همراه با گروهها و وضعیت تیکتها در یک نما.
مرکز یکپارچه ارتباطات
پرونده ارتباطی مشتریان، تأمینکنندگان و باربریها همراه با گروهها و وضعیت تیکتهای قابل مشاهده در یک نما.
{{ profile.organization_name|default:profile.get_stakeholder_type_display }}
فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.
{% if can_add_contact %}ثبت مخاطب جدید{% endif %}{% if profile %}{{ profile.organization_name|default:profile.get_stakeholder_type_display }}{% else %}مشتری{% endif %}
فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.
{% if can_add_contact %}ثبت مخاطب جدید{% endif %}اطلاعات پایه مخاطب برای شناسایی و ارسال پیامک
استان و شهر را از پایگاه مکانهای ایران انتخاب کنید
برای نمایش شهرها، ابتدا استان را انتخاب کنید.
با یک کلیک عضویت مخاطب را در گروههای قابل مدیریت اضافه یا حذف کنید
عضویت مخاطب در گروههای قابل مدیریت را اضافه یا حذف کنید
{{ entry.actor_name }} · {{ entry.created_at|date:"Y/m/d H:i" }}
هنوز ویرایشی در این بخش ثبت نشده است.
{% endfor %}