fix customer view and edit

This commit is contained in:
javad committed 2026-09-29 10:30:54 +03:30
1 parent 81335b1439
commit d5c22a39dc
13 files changed
+329 -81

No files matched your search

+2 -1
View File
@@ -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"),
+17 -1
View File
@@ -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
@@ -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'],
},
),
]
@@ -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)]
+16
View File
@@ -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 = "سوابق ویرایش مخاطبان"
+3 -3
View File
@@ -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)
+60 -46
View File
@@ -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
+9 -1
View File
@@ -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.
+1 -1
View File
@@ -30,7 +30,7 @@
<a class="staff-action-link {% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=waiting_staff" {% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}aria-current="page"{% endif %}><span>↺</span><b>در انتظار پاسخ من</b>{% if counts.waiting %}<em>{{ counts.waiting }}</em>{% endif %}</a>
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'mine' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=mine" {% if request.resolver_match.url_name == 'list' and active_tab == 'mine' %}aria-current="page"{% endif %}><span>◎</span><b>تیکت‌های من</b></a>
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=shared" {% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}aria-current="page"{% endif %}><span>◇</span><b>اشتراک‌ها</b></a>
{% if staff_operations_access.contacts %}<a class="{% if active_staff_section == 'contacts' %}active{% endif %}" href="{% url 'staff_tickets:contacts' %}" {% if active_staff_section == 'contacts' %}aria-current="page"{% endif %}><span>♧</span><b>مخاطبان و گروه‌ها</b></a>{% endif %}
{% if staff_operations_access.contacts %}<a class="{% if active_staff_section == 'contacts' %}active{% endif %}" href="{% url 'staff_tickets:contacts' %}" {% if active_staff_section == 'contacts' %}aria-current="page"{% endif %}><span>♧</span><b>مخاطبان</b></a>{% endif %}
{% if staff_operations_access.dispatches %}<a class="{% if active_staff_section == 'dispatches' %}active{% endif %}" href="{% url 'staff_tickets:dispatch_create' %}" {% if active_staff_section == 'dispatches' %}aria-current="page"{% endif %}><span>⇶</span><b>ارسال گروهی</b></a>{% endif %}
{% if staff_operations_access.audit %}<a class="{% if active_staff_section == 'audit' %}active{% endif %}" href="{% url 'staff_tickets:magic_audit' %}" {% if active_staff_section == 'audit' %}aria-current="page"{% endif %}><span>⌁</span><b>ممیزی لینک‌ها</b></a>{% endif %}
</nav>
+2 -2
View File
@@ -1,8 +1,8 @@
{% extends 'base.html' %}{% load static %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}<div class="operations-page operations-contacts">
<section class="operations-hero"><div><p class="eyebrow">مرکز یکپارچه ارتباطات</p><h1>مخاطبان و مشتریان</h1><p>پرونده ارتباطی مشتریان، تأمین‌کنندگان و باربری‌ها همراه با گروه‌ها و وضعیت تیکت‌ها در یک نما.</p></div><div class="operations-hero-actions"><a class="button button-secondary" href="{% url 'staff_tickets:groups' %}">مدیریت گروه‌ها</a>{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">+ مخاطب جدید</a>{% endif %}</div></section>
<section class="operations-hero"><div><p class="eyebrow">مرکز یکپارچه ارتباطات</p><h1>مخاطبان و مشتریان</h1><p>پرونده ارتباطی مشتریان، تأمین‌کنندگان و باربری‌ها همراه با گروه‌ها و وضعیت تیکت‌های قابل مشاهده در یک نما.</p></div><div class="operations-hero-actions">{% if staff_operations_access.contact_groups %}<a class="button button-secondary" href="{% url 'staff_tickets:groups' %}">مدیریت گروه‌ها</a>{% endif %}{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">+ مخاطب جدید</a>{% endif %}</div></section>
<section class="operations-kpis" aria-label="آمار مخاطبان"><article><span class="kpi-icon">◎</span><div><b>{{ metrics.total }}</b><small>نتیجه قابل مشاهده</small></div></article><article><span class="kpi-icon is-blue">♙</span><div><b>{{ metrics.customers }}</b><small>مشتری</small></div></article><article><span class="kpi-icon is-amber">▣</span><div><b>{{ metrics.suppliers }}</b><small>تأمین‌کننده</small></div></article><article><span class="kpi-icon is-violet">⇄</span><div><b>{{ metrics.carriers }}</b><small>باربری</small></div></article><article><span class="kpi-icon is-coral">!</span><div><b>{{ metrics.needs_action }}</b><small>تیکت نیازمند اقدام</small></div></article></section>
<section class="operations-panel"><form method="get" class="contact-filter-bar"><label class="contact-search"><span>⌕</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="جست‌وجو با نام، سازمان یا شماره همراه"></label><label><span>نوع مخاطب</span><select name="stakeholder_type"><option value="">همه انواع</option>{% for value,label in stakeholder_types %}<option value="{{ value }}" {% if request.GET.stakeholder_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><label><span>گروه</span><select name="group"><option value="">همه گروه‌ها</option>{% for group in groups %}<option value="{{ group.pk }}" {% if request.GET.group == group.pk|stringformat:'s' %}selected{% endif %}>{{ group.name }} ({{ group.contact_count }})</option>{% endfor %}</select></label><label><span>استان</span><select name="province"><option value="">همه استان‌ها</option>{% for province in provinces %}<option value="{{ province }}" {% if request.GET.province == province %}selected{% endif %}>{{ province }}</option>{% endfor %}</select></label><button type="submit">اعمال فیلتر</button>{% if request.GET %}<a class="filter-reset" href="{% url 'staff_tickets:contacts' %}">پاک‌کردن</a>{% endif %}</form>
<div class="contact-result-meta"><div><b>{{ contacts|length }} مخاطب</b><small>مرتب‌شده بر اساس تیکت‌های فعال و آخرین ثبت</small></div><a href="{% url 'staff_tickets:groups' %}">{{ groups|length }} گروه قابل دسترس ←</a></div><div class="contact-card-grid">{% for contact in contacts %}{% with profile=contact.customer_profile %}<article class="contact-card"><header><span class="contact-avatar">{{ profile.first_name|default:contact.local_mobile|slice:':1' }}</span><div class="contact-identity"><h2>{{ profile|default:contact.local_mobile }}</h2><p>{{ profile.organization_name|default:profile.get_stakeholder_type_display }}</p></div><span class="contact-type type-{{ profile.stakeholder_type|lower }}">{{ profile.get_stakeholder_type_display }}</span></header><div class="contact-meta"><span dir="ltr">☎ {{ contact.local_mobile }}</span><span>⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}</span></div><div class="contact-groups">{% for group in profile.visible_groups %}<span>{{ group.name }}</span>{% empty %}<small>بدون گروه قابل مشاهده</small>{% endfor %}</div><div class="contact-ticket-stats"><span><b>{{ contact.ticket_count }}</b>کل تیکت</span><span><b>{{ contact.active_count }}</b>فعال</span><span class="needs-action"><b>{{ contact.waiting_staff_count }}</b>نیازمند اقدام</span><span><b>{{ contact.resolved_count }}</b>مختومه</span></div><footer><a href="{% url 'staff_tickets:contact_edit' contact.public_id %}">ویرایش پرونده</a>{% include 'tickets/_voip_call_button.html' with customer=contact %}{% if staff_operations_access.create_ticket %}<a class="primary-action" href="{% url 'staff_tickets:create' %}?customer={{ contact.public_id }}">+ ثبت تیکت</a>{% endif %}</footer></article>{% endwith %}{% empty %}<div class="empty-state contact-empty"><span>⌕</span><h2>مخاطبی پیدا نشد</h2><p>فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.</p>{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">ثبت مخاطب جدید</a>{% endif %}</div>{% endfor %}</div></section></div>{% endblock %}
<div class="contact-result-meta"><div><b>{{ contacts|length }} مخاطب</b><small>مرتب‌شده بر اساس تیکت‌های قابل مشاهده و آخرین ثبت</small></div>{% if staff_operations_access.contact_groups %}<a href="{% url 'staff_tickets:groups' %}">{{ groups|length }} گروه قابل دسترس ←</a>{% endif %}</div><div class="contact-card-grid">{% for contact in contacts %}{% with profile=contact.customer_profile %}<article class="contact-card"><header><span class="contact-avatar">{{ profile.first_name|default:contact.local_mobile|slice:':1' }}</span><div class="contact-identity"><h2>{{ profile|default:contact.local_mobile }}</h2><p>{% if profile %}{{ profile.organization_name|default:profile.get_stakeholder_type_display }}{% else %}مشتری{% endif %}</p></div><span class="contact-type type-{{ profile.stakeholder_type|lower }}">{{ profile.get_stakeholder_type_display }}</span></header><div class="contact-meta"><span dir="ltr">☎ {{ contact.local_mobile }}</span><span>⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}</span></div><div class="contact-groups">{% for group in profile.visible_groups %}<span>{{ group.name }}</span>{% empty %}<small>بدون گروه قابل مشاهده</small>{% endfor %}</div><div class="contact-ticket-stats"><span><b>{{ contact.ticket_count }}</b>تیکت قابل مشاهده</span><span><b>{{ contact.active_count }}</b>فعال</span><span class="needs-action"><b>{{ contact.waiting_staff_count }}</b>نیازمند اقدام</span><span><b>{{ contact.resolved_count }}</b>مختومه</span></div><footer><a href="{% url 'staff_tickets:contact_edit' contact.public_id %}">ویرایش پرونده</a>{% include 'tickets/_voip_call_button.html' with customer=contact %}{% if staff_operations_access.create_ticket %}<a class="primary-action" href="{% url 'staff_tickets:create' %}?customer={{ contact.public_id }}">+ ثبت تیکت</a>{% endif %}</footer></article>{% endwith %}{% empty %}<div class="empty-state contact-empty"><span>⌕</span><h2>مخاطبی پیدا نشد</h2><p>فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.</p>{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">ثبت مخاطب جدید</a>{% endif %}</div>{% endfor %}</div></section></div>{% endblock %}
{% block extra_scripts %}<script defer src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
+3 -2
View File
@@ -6,7 +6,8 @@
{% if form.non_field_errors %}<div class="create-form-errors" role="alert">{{ form.non_field_errors }}</div>{% endif %}
<section class="editor-section"><header><span>۱</span><div><h2>هویت و راه ارتباطی</h2><p>اطلاعات پایه مخاطب برای شناسایی و ارسال پیامک</p></div></header><div class="editor-grid"><label><span>{{ form.mobile.label }} <em>*</em></span>{{ form.mobile }}{{ form.mobile.errors }}<small>شماره معتبر به شکل 09xxxxxxxxx</small></label><label><span>{{ form.stakeholder_type.label }} <em>*</em></span>{{ form.stakeholder_type }}{{ form.stakeholder_type.errors }}</label><label><span>{{ form.first_name.label }} <em>*</em></span>{{ form.first_name }}{{ form.first_name.errors }}</label><label><span>{{ form.last_name.label }}</span>{{ form.last_name }}{{ form.last_name.errors }}</label><label class="editor-wide"><span>{{ form.organization_name.label }}</span>{{ form.organization_name }}{{ form.organization_name.errors }}</label></div></section>
<section class="editor-section"><header><span>۲</span><div><h2>موقعیت جغرافیایی</h2><p>استان و شهر را از پایگاه مکان‌های ایران انتخاب کنید</p></div></header><div class="editor-grid"><label><span>{{ form.province.label }}</span>{{ form.province }}{{ form.province.errors }}</label><label><span>{{ form.city.label }}</span>{{ form.city }}{{ form.city.errors }}</label></div><p class="location-status" data-location-hint>برای نمایش شهرها، ابتدا استان را انتخاب کنید.</p></section>
<section class="editor-section"><header><span>۳</span><div><h2>گروه‌بندی و دسترسی</h2><p>با یک کلیک عضویت مخاطب را در گروه‌های قابل مدیریت اضافه یا حذف کنید</p></div></header><fieldset class="group-selector" data-contact-group-selector><legend>{{ form.groups.label }} <em>*</em></legend><div class="group-selector-tools"><label><span class="sr-only">جست‌وجوی گروه</span><input type="search" placeholder="جست‌وجوی نام گروه یا واحد…" autocomplete="off" data-group-search></label><span class="group-selection-count" data-group-selection-count aria-live="polite">۰ گروه انتخاب شده</span><button type="button" data-clear-groups>پاک‌کردن انتخاب‌ها</button></div><div class="group-choice-list">{{ form.groups }}</div>{{ form.groups.errors }}<small>{{ form.groups.help_text }}</small><p class="group-empty" data-group-empty hidden>گروهی با این عبارت پیدا نشد.</p></fieldset></section>
<section class="editor-section"><header><span>۳</span><div><h2>گروه‌بندی</h2><p>عضویت مخاطب در گروه‌های قابل مدیریت را اضافه یا حذف کنید</p></div></header><fieldset class="group-selector" data-contact-group-selector><legend>{{ form.groups.label }}</legend><div class="group-selector-tools"><label><span class="sr-only">جست‌وجوی گروه</span><input type="search" placeholder="جست‌وجوی نام گروه یا واحد…" autocomplete="off" data-group-search></label><span class="group-selection-count" data-group-selection-count aria-live="polite">۰ گروه انتخاب شده</span><button type="button" data-clear-groups>پاک‌کردن انتخاب‌ها</button></div><div class="group-choice-list">{{ form.groups }}</div>{{ form.groups.errors }}<small>{{ form.groups.help_text }}</small><p class="group-empty" data-group-empty hidden>گروهی با این عبارت پیدا نشد.</p></fieldset></section>
<footer class="editor-actions"><button type="submit">ذخیره مخاطب</button><a href="{% url 'staff_tickets:contacts' %}">انصراف</a><span>اطلاعات ورود یا رمز عبور برای مخاطب ساخته نمی‌شود.</span></footer>
</form></div>{% endblock %}
</form>
{% if call_customer %}<section class="operations-panel" aria-label="سابقه ویرایش مخاطب"><h2>سابقه ویرایش</h2>{% for entry in contact_edit_logs %}<article><p><strong>{{ entry.actor_name }}</strong> · {{ entry.created_at|date:"Y/m/d H:i" }}</p><ul>{% for change in entry.changes %}<li>{{ change.field }}: «{{ change.before|default:"خالی" }}» ← «{{ change.after|default:"خالی" }}»</li>{% endfor %}</ul></article>{% empty %}<p>هنوز ویرایشی در این بخش ثبت نشده است.</p>{% endfor %}</section>{% endif %}</div>{% endblock %}
{% block extra_scripts %}<script defer src="{% static 'js/location-picker.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/contact-group-selector.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
+152 -21
View File
@@ -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
+6 -3
View File
@@ -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):