fix customer view and edit
This commit is contained in:
1 parent
81335b1439
commit
d5c22a39dc
13 files changed
+329
-81
No files matched your search
@@ -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
@@ -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)]
|
||||
@@ -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 = "سوابق ویرایش مخاطبان"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user