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 %}
-

مرکز یکپارچه ارتباطات

مخاطبان و مشتریان

پرونده ارتباطی مشتریان، تأمین‌کنندگان و باربری‌ها همراه با گروه‌ها و وضعیت تیکت‌ها در یک نما.

مدیریت گروه‌ها{% if can_add_contact %}+ مخاطب جدید{% endif %}
+

مرکز یکپارچه ارتباطات

مخاطبان و مشتریان

پرونده ارتباطی مشتریان، تأمین‌کنندگان و باربری‌ها همراه با گروه‌ها و وضعیت تیکت‌های قابل مشاهده در یک نما.

{% if staff_operations_access.contact_groups %}مدیریت گروه‌ها{% endif %}{% if can_add_contact %}+ مخاطب جدید{% endif %}
◎
{{ metrics.total }}نتیجه قابل مشاهده
♙
{{ metrics.customers }}مشتری
▣
{{ metrics.suppliers }}تأمین‌کننده
⇄
{{ metrics.carriers }}باربری
!
{{ metrics.needs_action }}تیکت نیازمند اقدام
{% if request.GET %}پاک‌کردن{% endif %}
-
{{ contacts|length }} مخاطبمرتب‌شده بر اساس تیکت‌های فعال و آخرین ثبت
{{ groups|length }} گروه قابل دسترس ←
{% for contact in contacts %}{% with profile=contact.customer_profile %}
{{ profile.first_name|default:contact.local_mobile|slice:':1' }}

{{ profile|default:contact.local_mobile }}

{{ profile.organization_name|default:profile.get_stakeholder_type_display }}

{{ profile.get_stakeholder_type_display }}
☎ {{ contact.local_mobile }}⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}
{% for group in profile.visible_groups %}{{ group.name }}{% empty %}بدون گروه قابل مشاهده{% endfor %}
{{ contact.ticket_count }}کل تیکت{{ contact.active_count }}فعال{{ contact.waiting_staff_count }}نیازمند اقدام{{ contact.resolved_count }}مختومه
{% endwith %}{% empty %}
⌕

مخاطبی پیدا نشد

فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.

{% if can_add_contact %}ثبت مخاطب جدید{% endif %}
{% endfor %}
{% endblock %} +
{{ contacts|length }} مخاطبمرتب‌شده بر اساس تیکت‌های قابل مشاهده و آخرین ثبت
{% if staff_operations_access.contact_groups %}{{ groups|length }} گروه قابل دسترس ←{% endif %}
{% for contact in contacts %}{% with profile=contact.customer_profile %}
{{ profile.first_name|default:contact.local_mobile|slice:':1' }}

{{ profile|default:contact.local_mobile }}

{% if profile %}{{ profile.organization_name|default:profile.get_stakeholder_type_display }}{% else %}مشتری{% endif %}

{{ profile.get_stakeholder_type_display }}
☎ {{ contact.local_mobile }}⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}
{% for group in profile.visible_groups %}{{ group.name }}{% empty %}بدون گروه قابل مشاهده{% endfor %}
{{ contact.ticket_count }}تیکت قابل مشاهده{{ contact.active_count }}فعال{{ contact.waiting_staff_count }}نیازمند اقدام{{ contact.resolved_count }}مختومه
{% endwith %}{% empty %}
⌕

مخاطبی پیدا نشد

فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.

{% if can_add_contact %}ثبت مخاطب جدید{% endif %}
{% endfor %}
{% endblock %} {% block extra_scripts %}{% endblock %} diff --git a/templates/tickets/staff_entity_form.html b/templates/tickets/staff_entity_form.html index b126d5f..7a41eb2 100644 --- a/templates/tickets/staff_entity_form.html +++ b/templates/tickets/staff_entity_form.html @@ -6,7 +6,8 @@ {% if form.non_field_errors %}{% endif %}
۱

هویت و راه ارتباطی

اطلاعات پایه مخاطب برای شناسایی و ارسال پیامک

۲

موقعیت جغرافیایی

استان و شهر را از پایگاه مکان‌های ایران انتخاب کنید

برای نمایش شهرها، ابتدا استان را انتخاب کنید.

-
۳

گروه‌بندی و دسترسی

با یک کلیک عضویت مخاطب را در گروه‌های قابل مدیریت اضافه یا حذف کنید

{{ form.groups.label }} *
۰ گروه انتخاب شده
{{ form.groups }}
{{ form.groups.errors }}{{ form.groups.help_text }}
+
۳

گروه‌بندی

عضویت مخاطب در گروه‌های قابل مدیریت را اضافه یا حذف کنید

{{ form.groups.label }}
۰ گروه انتخاب شده
{{ form.groups }}
{{ form.groups.errors }}{{ form.groups.help_text }}
-{% endblock %} + +{% if call_customer %}

سابقه ویرایش

{% for entry in contact_edit_logs %}

{{ entry.actor_name }} · {{ entry.created_at|date:"Y/m/d H:i" }}

{% empty %}

هنوز ویرایشی در این بخش ثبت نشده است.

{% endfor %}
{% endif %}{% endblock %} {% block extra_scripts %}{% endblock %} diff --git a/tests/test_organizational_ticketing.py b/tests/test_organizational_ticketing.py index c24a216..a1f7376 100644 --- a/tests/test_organizational_ticketing.py +++ b/tests/test_organizational_ticketing.py @@ -1,15 +1,19 @@ import base64 from datetime import timedelta +from importlib import import_module +from types import SimpleNamespace import pytest +from django.apps import apps as django_apps from django.contrib.auth.models import Permission from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import connection from django.urls import reverse from django.utils import timezone from apps.accounts.models import MagicLinkAccessLog, TicketMagicLink, User from apps.attachments.models import AttachmentPolicy -from apps.customers.models import ContactGroup, CustomerProfile +from apps.customers.models import ContactEditLog, ContactGroup, CustomerProfile from apps.notifications.models import NotificationJob from apps.staff.models import OrganizationalUnit, StaffProfile from apps.tickets.models import GroupDispatch, Ticket, TicketCategory, TicketEvent, TicketTag @@ -314,7 +318,7 @@ def test_dispatch_pages_are_available_only_to_the_dispatch_unit(client): @pytest.mark.django_db -def test_staff_operations_pages_respect_unit_scoped_permissions(client): +def test_staff_contacts_are_shared_while_groups_remain_unit_scoped(client): unit = OrganizationalUnit.objects.create(name="عملیات", slug="operations") other = OrganizationalUnit.objects.create(name="واحد محرمانه", slug="private-unit") codenames = [ @@ -336,7 +340,9 @@ def test_staff_operations_pages_respect_unit_scoped_permissions(client): response = client.get(reverse("staff_tickets:contacts")) assert response.status_code == 200 assert "visible-contact" in response.content.decode() - assert "hidden-contact" not in response.content.decode() + assert "hidden-contact" in response.content.decode() + assert client.get(reverse("staff_tickets:contact_edit", args=[hidden.public_id])).status_code == 200 + assert hidden_group.name not in response.content.decode() assert client.get(reverse("staff_tickets:groups")).status_code == 200 assert client.get(reverse("staff_tickets:magic_audit")).status_code == 200 assert client.get(reverse("staff_tickets:sms_templates")).status_code == 200 @@ -348,7 +354,7 @@ def test_superuser_without_staff_profile_can_open_all_staff_contacts(client): contact = make_contact("admin-visible-contact", "09120000503") group = ContactGroup.objects.create(name="گروه قابل مشاهده مدیر", unit=unit) contact.customer_profile.groups.add(group) - User.objects.create_user( + incomplete = User.objects.create_user( username="customer-without-profile", mobile="09120000504", account_type=User.AccountType.CUSTOMER, @@ -364,7 +370,12 @@ def test_superuser_without_staff_profile_can_open_all_staff_contacts(client): assert response.status_code == 200 assert "admin-visible-contact" in response.content.decode() - assert "customer-without-profile" not in response.content.decode() + assert "09120000504" in response.content.decode() + searched = client.get(reverse("staff_tickets:contacts"), {"q": "09120000504"}) + assert searched.status_code == 200 + assert str(incomplete.public_id) in searched.content.decode() + assert client.get(reverse("staff_tickets:contact_edit", args=[incomplete.public_id])).status_code == 200 + assert CustomerProfile.objects.filter(user=incomplete).exists() @pytest.mark.django_db @@ -434,8 +445,8 @@ def test_staff_can_classify_and_edit_ticket_customer_without_an_existing_group(c "province": "", "city": "", }) - assert missing_group.status_code == 200 - assert missing_group.context["form"]["groups"].errors + assert missing_group.status_code == 302 + assert not contact.customer_profile.groups.exists() saved = client.post(url, { "mobile": contact.local_mobile, @@ -452,21 +463,141 @@ def test_staff_can_classify_and_edit_ticket_customer_without_an_existing_group(c @pytest.mark.django_db -def test_staff_cannot_edit_ungrouped_customer_from_an_unrelated_ticket_unit(client): - ticket_unit = OrganizationalUnit.objects.create(name="واحد مالک مشتری", slug="ungrouped-owner-unit") - unrelated_unit = OrganizationalUnit.objects.create(name="واحد نامرتبط مشتری", slug="ungrouped-unrelated-unit") - unrelated_unit.permissions.add(*Permission.objects.filter( - content_type__app_label="customers", - codename__in=("view_customerprofile", "change_customerprofile"), - )) - owner = make_staff("ungrouped-owner-agent", ticket_unit) - outsider = make_staff("ungrouped-outsider-agent", unrelated_unit) - contact = make_contact("private-ungrouped-customer", "09120000516") - category = TicketCategory.objects.create(unit=ticket_unit, title="درخواست محرمانه", slug="private-ungrouped-ticket") - create_ticket(customer=contact, created_by=owner, unit=ticket_unit, category=category, subject="محرمانه", body="شرح") - client.force_login(outsider) +def test_legacy_ungrouped_contact_is_searchable_editable_and_audited(client): + unit = OrganizationalUnit.objects.create(name="واحد جست‌وجوی مخاطب", slug="shared-contact-search") + staff = make_staff("shared-contact-editor", unit) + contact = make_contact("legacy-ungrouped-contact", "09120000561") + incomplete = User.objects.create_user(username="legacy-without-profile", mobile="09120000564", first_name="نام قدیمی ناقص", account_type=User.AccountType.CUSTOMER) + edit_url = reverse("staff_tickets:contact_edit", args=[contact.public_id]) + client.force_login(staff) - assert client.get(reverse("staff_tickets:contact_edit", args=[contact.public_id])).status_code == 404 + for query in ("legacy-ungrouped-contact", "09120000561"): + result = client.get(reverse("staff_tickets:contacts"), {"q": query}) + assert result.status_code == 200 + assert str(contact.public_id) in result.content.decode() + assert str(incomplete.public_id) in client.get(reverse("staff_tickets:contacts"), {"q": "09120000564"}).content.decode() + assert str(incomplete.public_id) in client.get(reverse("staff_tickets:contacts"), {"q": "نام قدیمی ناقص"}).content.decode() + assert client.get(reverse("staff_tickets:contact_edit", args=[incomplete.public_id])).status_code == 200 + assert CustomerProfile.objects.filter(user=incomplete).exists() + assert incomplete.customer_profile.first_name == "نام قدیمی ناقص" + assert client.get(edit_url).status_code == 200 + + saved = client.post(edit_url, { + "mobile": "09120000562", + "first_name": "مخاطب اصلاح‌شده", + "last_name": "", + "organization_name": "", + "stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER, + "groups": [], + "province": "", + "city": "", + }) + assert saved.status_code == 302 + contact.refresh_from_db() + contact.customer_profile.refresh_from_db() + assert contact.mobile == "+989120000562" + assert contact.customer_profile.first_name == "مخاطب اصلاح‌شده" + assert not contact.customer_profile.groups.exists() + assert User.objects.filter(pk=contact.pk).exists() + assert CustomerProfile.objects.filter(user=contact).exists() + log = ContactEditLog.objects.get(contact=contact.customer_profile) + assert log.actor == staff + assert log.actor_name == staff.username + assert log.contact_public_id == contact.public_id + assert {change["field"] for change in log.changes} == {"شماره همراه", "نام"} + assert staff.username in client.get(edit_url).content.decode() + assert ContactEditLog.objects.count() == 1 + + repeated = client.post(edit_url, { + "mobile": "09120000562", + "first_name": "مخاطب اصلاح‌شده", + "last_name": "", + "organization_name": "", + "stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER, + "groups": [], + "province": "", + "city": "", + }) + assert repeated.status_code == 302 + assert ContactEditLog.objects.count() == 1 + + +@pytest.mark.django_db +def test_customer_profile_backfill_is_repeatable_and_keeps_existing_contacts(): + existing = make_contact("existing-profile-backfill", "09120000565") + incomplete = User.objects.create_user(username="missing-profile-backfill", mobile="09120000566", first_name="نام قدیمی", last_name="مشتری", account_type=User.AccountType.CUSTOMER) + migration = import_module("apps.customers.migrations.0006_backfill_customer_profiles") + editor = SimpleNamespace(connection=connection) + + migration.backfill_customer_profiles(django_apps, editor) + migration.backfill_customer_profiles(django_apps, editor) + + assert CustomerProfile.objects.filter(user=existing, first_name="existing-profile-backfill").count() == 1 + assert CustomerProfile.objects.filter(user=incomplete).count() == 1 + assert incomplete.customer_profile.first_name == "نام قدیمی" + assert not incomplete.customer_profile.groups.exists() + + +@pytest.mark.django_db +def test_shared_contact_edit_preserves_other_unit_groups_and_ticket_counts(client): + sales = OrganizationalUnit.objects.create(name="فروش مشترک", slug="shared-sales") + finance = OrganizationalUnit.objects.create(name="حسابداری مشترک", slug="shared-finance") + view_ticket = Permission.objects.get(content_type__app_label="tickets", codename="view_ticket") + sales.permissions.add(view_ticket) + finance.permissions.add(view_ticket) + sales_staff = make_staff("shared-sales-agent", sales) + finance_staff = make_staff("shared-finance-agent", finance) + contact = make_contact("shared-contact", "09120000563") + finance_group = ContactGroup.objects.create(name="مخاطبان حسابداری مشترک", unit=finance) + sales_category = TicketCategory.objects.create(unit=sales, title="فروش مشترک", slug="shared-sales-ticket") + finance_category = TicketCategory.objects.create(unit=finance, title="مالی مشترک", slug="shared-finance-ticket") + sales_ticket = create_ticket(customer=contact, created_by=sales_staff, unit=sales, category=sales_category, subject="درخواست فروش", body="شرح") + finance_ticket = create_ticket(customer=contact, created_by=finance_staff, unit=finance, category=finance_category, subject="درخواست مالی", body="شرح") + client.force_login(sales_staff) + assert client.get(reverse("staff_tickets:contact_edit", args=[contact.public_id])).status_code == 200 + assert client.get(reverse("staff_tickets:detail", args=[finance_ticket.public_id])).status_code == 403 + contact.customer_profile.groups.add(finance_group) + + directory = client.get(reverse("staff_tickets:contacts"), {"q": "shared-contact"}) + assert directory.status_code == 200 + assert directory.context["contacts"][0].ticket_count == 1 + assert finance_group.name not in directory.content.decode() + assert client.get(reverse("staff_tickets:detail", args=[sales_ticket.public_id])).status_code == 200 + assert client.get(reverse("staff_tickets:detail", args=[finance_ticket.public_id])).status_code == 403 + + forged = client.post(reverse("staff_tickets:contact_edit", args=[contact.public_id]), { + "mobile": contact.local_mobile, + "first_name": "نام جعلی", + "last_name": "", + "organization_name": "", + "stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER, + "groups": [finance_group.pk], + "province": "", + "city": "", + }) + assert forged.status_code == 200 + assert forged.context["form"]["groups"].errors + contact.customer_profile.refresh_from_db() + assert contact.customer_profile.first_name == "shared-contact" + assert ContactEditLog.objects.count() == 0 + + edited = client.post(reverse("staff_tickets:contact_edit", args=[contact.public_id]), { + "mobile": contact.local_mobile, + "first_name": "نام تازه", + "last_name": "", + "organization_name": "", + "stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER, + "groups": [], + "province": "", + "city": "", + }) + assert edited.status_code == 302 + assert contact.customer_profile.groups.filter(pk=finance_group.pk).exists() + assert ContactEditLog.objects.filter(contact=contact.customer_profile, actor=sales_staff).exists() + + client.force_login(contact) + assert client.get(reverse("staff_tickets:contacts")).status_code == 403 + assert client.get(reverse("staff_tickets:contact_edit", args=[contact.public_id])).status_code == 403 @pytest.mark.django_db diff --git a/tests/test_views.py b/tests/test_views.py index 75bc2aa..bfdad04 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -10,7 +10,7 @@ from apps.tickets.models import Ticket, TicketCategory from apps.tickets.services import create_ticket, claim_ticket, reply_ticket from apps.messaging.models import Message,MessageReadReceipt from apps.notifications.models import NotificationJob -from apps.customers.models import CustomerProfile +from apps.customers.models import ContactEditLog, CustomerProfile from apps.core.persian_text import normalize_persian_text @pytest.mark.django_db @@ -664,7 +664,8 @@ def test_staff_customer_directory_search_and_ticket_stats(client): response=client.get(legacy.url) content=response.content.decode() assert response.status_code==200 and "زهرا محمدی" in content and "09121234567" in content - assert "کل تیکت" in content and "نیازمند اقدام" in content + assert "تیکت قابل مشاهده" in content and "نیازمند اقدام" in content + assert response.context["contacts"][0].ticket_count == 0 @pytest.mark.django_db def test_staff_customer_live_search_returns_profile_data(client): @@ -687,9 +688,11 @@ def test_staff_create_uses_existing_customer_selected_by_public_id(client): StaffProfile.objects.create(user=expert,display_name="کارشناس انتخاب") expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="add_ticket")) client.force_login(expert) - response=client.post(reverse("staff_tickets:create"),{"customer_id":customer.public_id,"customer_mobile":"09125550000","category":category.pk,"subject":"پیگیری مشتری موجود","province":"گیلان","city":"رشت","body":"شرح کامل پیگیری"}) + response=client.post(reverse("staff_tickets:create"),{"customer_id":customer.public_id,"customer_mobile":"09125550000","first_name":"مریم تازه","category":category.pk,"subject":"پیگیری مشتری موجود","province":"گیلان","city":"رشت","body":"شرح کامل پیگیری"}) assert response.status_code==302 assert customer.customer_tickets.filter(subject="پیگیری مشتری موجود").exists() + log = ContactEditLog.objects.get(contact__user=customer, actor=expert) + assert {"field": "نام", "before": "مریم", "after": "مریم تازه"} in log.changes @pytest.mark.django_db def test_staff_create_requires_name_for_unknown_mobile(client):