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
|
||||
|
||||
Reference in new issue
Block a user