fix queue
This commit is contained in:
1 parent
049cc41afc
commit
9a1c99cbd3
14 files changed
+237
-32
No files matched your search
+13
-1
@@ -1,4 +1,16 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "accounts"
|
||||
urlpatterns = [path("login/", views.customer_login, name="login"),path("login/password/",views.customer_password_login,name="password_login"), path("login/otp/", views.verify_otp, name="otp"),path("profile/",views.customer_profile,name="profile"), path("logout/", views.logout_view, name="logout"), path("staff/login/", views.staff_login, name="staff_login"), path("staff/logout/", views.logout_view, name="staff_logout"), path("t/<str:token>/", views.consume_magic_link, name="magic")]
|
||||
urlpatterns = [
|
||||
path("", views.home, name="home"),
|
||||
path("login/", views.customer_login, name="login"),
|
||||
path("login/password/", views.customer_password_login, name="password_login"),
|
||||
path("login/otp/", views.verify_otp, name="otp"),
|
||||
path("profile/", views.customer_profile, name="profile"),
|
||||
path("logout/", views.logout_view, name="logout"),
|
||||
path("staff/login/", views.staff_login, name="staff_login"),
|
||||
path("staff/logout/", views.logout_view, name="staff_logout"),
|
||||
path("t/<str:token>/", views.consume_magic_link, name="magic"),
|
||||
]
|
||||
@@ -3,8 +3,7 @@ from django.conf import settings
|
||||
from django.contrib.auth import login, logout
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||
from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm
|
||||
from apps.customers.models import CustomerProfile
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@@ -21,6 +20,11 @@ def _authenticated_home(request):
|
||||
if not request.user.is_authenticated: return None
|
||||
return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "tickets:list")
|
||||
|
||||
@require_GET
|
||||
def home(request):
|
||||
"""Send every visitor to the correct login or authenticated workspace."""
|
||||
return _authenticated_home(request) or redirect("accounts:login")
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def customer_login(request):
|
||||
if response:=_authenticated_home(request): return response
|
||||
|
||||
@@ -8,10 +8,12 @@ from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import connection, transaction
|
||||
from django.db.models import F
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.core.models import ProductionSeedState
|
||||
from apps.core.production_seed import sha256_file
|
||||
from apps.tickets.models import Ticket
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -82,6 +84,27 @@ class Command(BaseCommand):
|
||||
# the authoritative SQLite snapshot; schema/migration history remains.
|
||||
call_command("flush", interactive=False, verbosity=0)
|
||||
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
|
||||
# Old SQLite snapshots may predate unit-scoped queues. A
|
||||
# category already belongs to exactly one unit, so it is a
|
||||
# deterministic and safe source for repairing those rows.
|
||||
repaired = []
|
||||
for ticket in Ticket.objects.filter(
|
||||
owning_unit__isnull=True,
|
||||
category__unit__isnull=False,
|
||||
).select_related("category"):
|
||||
ticket.owning_unit_id = ticket.category.unit_id
|
||||
repaired.append(ticket)
|
||||
if repaired:
|
||||
Ticket.objects.bulk_update(repaired, ["owning_unit"])
|
||||
Ticket.objects.filter(
|
||||
assigned_unit__isnull=True,
|
||||
owning_unit__isnull=False,
|
||||
).update(assigned_unit_id=F("owning_unit_id"))
|
||||
if Ticket.objects.filter(owning_unit__isnull=True).exists():
|
||||
raise CommandError(
|
||||
"Seed contains tickets without an organizational unit; "
|
||||
"assign their categories to units before exporting again."
|
||||
)
|
||||
media_root.mkdir(parents=True, exist_ok=True)
|
||||
for source in sorted(staged_media.rglob("*")):
|
||||
if not source.is_file():
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.db import migrations
|
||||
from django.db.models import F
|
||||
|
||||
|
||||
def scope_legacy_tickets(apps, schema_editor):
|
||||
Ticket = apps.get_model("tickets", "Ticket")
|
||||
|
||||
repaired = []
|
||||
for ticket in Ticket.objects.filter(
|
||||
owning_unit__isnull=True,
|
||||
category__unit__isnull=False,
|
||||
).select_related("category"):
|
||||
ticket.owning_unit_id = ticket.category.unit_id
|
||||
repaired.append(ticket)
|
||||
if repaired:
|
||||
Ticket.objects.bulk_update(repaired, ["owning_unit"])
|
||||
|
||||
Ticket.objects.filter(
|
||||
assigned_unit__isnull=True,
|
||||
owning_unit__isnull=False,
|
||||
).update(assigned_unit_id=F("owning_unit_id"))
|
||||
|
||||
if Ticket.objects.filter(owning_unit__isnull=True).exists():
|
||||
raise RuntimeError(
|
||||
"Cannot create unit-scoped queues: some tickets and their categories "
|
||||
"have no organizational unit."
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("tickets", "0015_seed_customer_category_catalog")]
|
||||
operations = [migrations.RunPython(scope_legacy_tickets, migrations.RunPython.noop)]
|
||||
@@ -75,7 +75,7 @@ class TicketPolicy:
|
||||
scope = cls.unit_scope(user, unit)
|
||||
if scope == "UNIT": return True
|
||||
return bool(ticket.origin_visibility == "PUBLIC" and ticket.owner_id is None and ticket.selected_expert_id is None)
|
||||
return bool(not ticket.owning_unit_id and ticket.origin_visibility == "PUBLIC" and ticket.owner_id is None and ticket.selected_expert_id is None and user.has_perm("tickets.view_ticket"))
|
||||
return False
|
||||
@classmethod
|
||||
def viewable_q(cls, user):
|
||||
"""Database equivalent of can_view; use it for every staff ticket collection."""
|
||||
@@ -88,14 +88,22 @@ class TicketPolicy:
|
||||
if cls.has_unit_permission(user, item.unit, "tickets.view_ticket")
|
||||
}
|
||||
unit_wide_ids = set(memberships.filter(ticket_scope="UNIT", unit_id__in=permitted_ids).values_list("unit_id", flat=True))
|
||||
unit_wide = Q(owning_unit_id__in=unit_wide_ids) | Q(assigned_unit_id__in=unit_wide_ids)
|
||||
unit_wide = Q(assigned_unit_id__in=unit_wide_ids) | Q(
|
||||
assigned_unit__isnull=True,
|
||||
owning_unit_id__in=unit_wide_ids,
|
||||
)
|
||||
limited_ids = permitted_ids - unit_wide_ids
|
||||
public_unassigned = (
|
||||
(Q(owning_unit_id__in=limited_ids) | Q(assigned_unit_id__in=limited_ids))
|
||||
& Q(origin_visibility="PUBLIC", selected_expert__isnull=True)
|
||||
(
|
||||
Q(assigned_unit_id__in=limited_ids)
|
||||
| Q(assigned_unit__isnull=True, owning_unit_id__in=limited_ids)
|
||||
)
|
||||
& Q(origin_visibility="PUBLIC", owner__isnull=True, selected_expert__isnull=True)
|
||||
)
|
||||
legacy = Q(owning_unit__isnull=True, origin_visibility="PUBLIC", selected_expert__isnull=True) if user.has_perm("tickets.view_ticket") else Q(pk__in=[])
|
||||
return direct | unit_wide | public_unassigned | legacy
|
||||
# There is deliberately no global/unitless public queue. Legacy tickets
|
||||
# must be assigned to a unit by the data migration before staff can see
|
||||
# them through a unit queue.
|
||||
return direct | unit_wide | public_unassigned
|
||||
@classmethod
|
||||
def can_reply(cls,user,ticket):
|
||||
if not cls.can_view(user,ticket): return False
|
||||
@@ -103,7 +111,6 @@ class TicketPolicy:
|
||||
if cls.has_unit_permission(user,ticket.assigned_unit or ticket.owning_unit,"messaging.add_message"): return True
|
||||
if user.has_perm("tickets.view_all_tickets") or ticket.owner_id==user.id or ticket.selected_expert_id==user.id or ticket.created_by_id==user.id: return True
|
||||
if ticket.owner_id is None and ticket.selected_expert_id is None and user.has_perm("messaging.add_message"): return True
|
||||
if not ticket.owning_unit_id and ticket.owner_id is None and ticket.selected_expert_id is None and user.has_perm("tickets.view_ticket"): return True
|
||||
return ticket.access_grants.filter(user=user,revoked_at__isnull=True,permission_level__in=[TicketAccessGrant.Level.REPLY,TicketAccessGrant.Level.FULL]).exists()
|
||||
@classmethod
|
||||
def can_claim(cls,user,ticket):
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ urlpatterns = [
|
||||
path("media/branding/<path:path>", public_branding, name="public_branding"),
|
||||
path("service-worker.js", service_worker, name="service_worker"),
|
||||
path("manifest.webmanifest", web_manifest, name="web_manifest"),
|
||||
path("admin/", admin.site.urls),
|
||||
path("x5admin/", admin.site.urls),
|
||||
path("", include("apps.accounts.urls")),
|
||||
path("tickets/", include("apps.tickets.customer_urls")),
|
||||
path("staff/", include("apps.tickets.staff_urls")),
|
||||
|
||||
@@ -137,6 +137,15 @@ SECURE_HSTS_PRELOAD=false
|
||||
6. log سرویس `notification-worker` را بررسی کنید؛ container باید Running بماند.
|
||||
7. با superuser منتقلشده از SQLite وارد شوید و با گیرنده آزمایشی مجاز یک SMS و یک پیام Bale واقعی بفرستید.
|
||||
|
||||
Migration شماره `tickets.0016_scope_legacy_public_tickets_to_units` فقط یکبار اجرا میشود و تیکتهای قدیمی بدون واحد را از روی واحد قطعی دستهبندی به `owning_unit` و `assigned_unit` صحیح متصل میکند. این migration همراه deploy عادی و دستور `migrate` خودکار اجرا میشود و اجرای دستی SQL لازم نیست. برای کنترل نتیجه در Terminal سرویس `web`:
|
||||
|
||||
```bash
|
||||
python manage.py showmigrations tickets
|
||||
python manage.py shell -c "from apps.tickets.models import Ticket; print(Ticket.objects.filter(owning_unit__isnull=True).count(), Ticket.objects.filter(assigned_unit__isnull=True).count())"
|
||||
```
|
||||
|
||||
هر دو عدد باید صفر باشند.
|
||||
|
||||
## 8. کنترل سلامت و عیبیابی
|
||||
|
||||
- `GET /healthz/` هم web server و هم اتصال دیتابیس را بررسی میکند.
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
<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.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.templates %}<a class="{% if active_staff_section == 'templates' %}active{% endif %}" href="{% url 'staff_tickets:sms_templates' %}" {% if active_staff_section == 'templates' %}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>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@ def test_e2e_03_warehouse_staff_cannot_see_sales_tickets(live_server, browser_pa
|
||||
users, units = org_data
|
||||
buyer = make_customer("e2e-scope-buyer", "09120008101")
|
||||
create_ticket(customer=buyer, unit=units["sales"], category=TicketCategory.objects.get(slug="sales-general"), subject="تیکت محرمانه فروش E2E", body="شرح")
|
||||
create_ticket(customer=buyer, unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت مجاز بارگیری E2E", body="شرح")
|
||||
create_ticket(customer=buyer, created_by=users["ms_sabzali"], unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت مجاز بارگیری E2E", body="شرح")
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["ms_sabzali"])
|
||||
content = page.locator("body")
|
||||
@@ -144,7 +144,7 @@ def test_e2e_04_accounting_sees_only_finance_scope(live_server, browser_page, or
|
||||
users, units = org_data
|
||||
buyer = make_customer("e2e-finance-buyer", "09120008102")
|
||||
create_ticket(customer=buyer, unit=units["finance"], category=TicketCategory.objects.get(slug="finance-general"), subject="تیکت مالی مجاز E2E", body="شرح")
|
||||
create_ticket(customer=buyer, unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت انبار نامجاز E2E", body="شرح")
|
||||
create_ticket(customer=buyer, created_by=users["ms_sabzali"], unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت انبار نامجاز E2E", body="شرح")
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["fereshteh_yavari"])
|
||||
expect(page.locator("body")).to_contain_text("تیکت مالی مجاز E2E")
|
||||
@@ -238,8 +238,8 @@ def test_unit_change_renders_experts_when_a_legacy_cached_payload_has_no_queue(l
|
||||
page.route("**/tickets/new/options/**", remove_queue_from_response)
|
||||
page.goto(f"{live_server.url}{reverse('tickets:new')}", wait_until="domcontentloaded")
|
||||
ticket_page = PublicTicketCreatePage(page, live_server.url)
|
||||
payload = ticket_page.select_unit(units["warehouse-loading"].pk)
|
||||
payload = ticket_page.select_unit(units["finance"].pk)
|
||||
assert payload.get("queue") is None
|
||||
expect(ticket_page.expert_picker).to_contain_text("خانم سبزعلی")
|
||||
expect(ticket_page.expert_picker).to_contain_text("آقای مهدی عظمی")
|
||||
expect(ticket_page.expert_picker).to_contain_text("خانم فرشته یاوری")
|
||||
expect(ticket_page.expert_picker).not_to_contain_text("کیمیا غفاری")
|
||||
expect(page.get_by_role("alert")).to_have_count(0)
|
||||
+14
-4
@@ -9,14 +9,19 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from apps.attachments.models import Attachment, AttachmentPolicy
|
||||
from apps.accounts.models import User, normalize_mobile
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.staff.models import StaffProfile
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from apps.tickets.services import claim_ticket, create_ticket, delete_ticket, reply_ticket
|
||||
|
||||
@pytest.fixture
|
||||
def category(db):
|
||||
return TicketCategory.objects.create(title="قیمت", slug="price")
|
||||
unit = OrganizationalUnit.objects.create(name="واحد تست دامنه", slug="domain-test-unit")
|
||||
unit.permissions.add(
|
||||
Permission.objects.get(codename="view_ticket", content_type__app_label="tickets"),
|
||||
Permission.objects.get(codename="add_message", content_type__app_label="messaging"),
|
||||
)
|
||||
return TicketCategory.objects.create(unit=unit, title="قیمت", slug="domain-test-price")
|
||||
|
||||
@pytest.fixture
|
||||
def customer(db):
|
||||
@@ -25,6 +30,8 @@ def customer(db):
|
||||
def make_staff(username):
|
||||
mobile = "09120000001" if username == "a" else "09120000002"
|
||||
user = User.objects.create_user(username=username, mobile=mobile, account_type="STAFF")
|
||||
unit = OrganizationalUnit.objects.filter(slug="domain-test-unit").first()
|
||||
StaffProfile.objects.create(user=user, primary_unit=unit, display_name=username)
|
||||
for code in ["view_ticket", "claim_ticket", "change_ticket"]:
|
||||
user.user_permissions.add(Permission.objects.get(codename=code, content_type__app_label="tickets"))
|
||||
return user
|
||||
@@ -122,14 +129,17 @@ def test_bale_ticket_excerpt_is_compact_and_whitespace_normalized(category,custo
|
||||
def test_ticket_full_delete_requires_manager_permission_and_denies_sales_expert(category, customer):
|
||||
ticket = create_ticket(customer=customer, category=category, subject="حذف پرونده", body="متن اولیه")
|
||||
expert = User.objects.create_user(username="delete-expert", mobile="09120000003", account_type="STAFF")
|
||||
StaffProfile.objects.create(user=expert, display_name="کارشناس", role=StaffProfile.Role.SALES_EXPERT)
|
||||
StaffProfile.objects.create(user=expert, primary_unit=category.unit, display_name="کارشناس", role=StaffProfile.Role.SALES_EXPERT)
|
||||
expert.user_permissions.add(Permission.objects.get(codename="delete_ticket", content_type__app_label="tickets"))
|
||||
with pytest.raises(PermissionDenied):
|
||||
delete_ticket(ticket_id=ticket.pk, user=expert)
|
||||
manager = User.objects.create_user(username="delete-manager", mobile="09120000004", account_type="STAFF")
|
||||
StaffProfile.objects.create(user=manager, display_name="مدیر", role=StaffProfile.Role.SALES_MANAGER)
|
||||
StaffProfile.objects.create(user=manager, primary_unit=category.unit, display_name="مدیر", role=StaffProfile.Role.SALES_MANAGER)
|
||||
for codename in ("delete_ticket", "view_all_tickets"):
|
||||
manager.user_permissions.add(Permission.objects.get(codename=codename, content_type__app_label="tickets"))
|
||||
manager.unit_memberships.get(unit=category.unit).permissions.add(
|
||||
Permission.objects.get(codename="delete_ticket", content_type__app_label="tickets")
|
||||
)
|
||||
delete_ticket(ticket_id=ticket.pk, user=manager)
|
||||
assert not Ticket.objects.filter(pk=ticket.pk).exists()
|
||||
assert not NotificationJob.objects.filter(ticket_id=ticket.pk).exists()
|
||||
|
||||
@@ -28,7 +28,7 @@ def make_contact(username, mobile):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unit_permission_isolates_ticket_queries_and_object_access():
|
||||
sales = OrganizationalUnit.objects.create(name="فروش", slug="sales")
|
||||
sales = OrganizationalUnit.objects.create(name="فروش ایزوله", slug="isolated-sales")
|
||||
logistics = OrganizationalUnit.objects.create(name="بارگیری ایزوله", slug="isolated-logistics")
|
||||
view_permission = Permission.objects.get(codename="view_ticket", content_type__app_label="tickets")
|
||||
sales.permissions.add(view_permission)
|
||||
@@ -114,7 +114,7 @@ def test_magic_link_records_every_open_with_ip_and_user_agent(client):
|
||||
@pytest.mark.django_db
|
||||
def test_dispatch_pages_are_available_only_to_the_dispatch_unit(client):
|
||||
unit = OrganizationalUnit.objects.create(name="انبار دسترسی", slug="access-warehouse")
|
||||
other_unit = OrganizationalUnit.objects.create(name="فروش", slug="sales-2")
|
||||
other_unit = OrganizationalUnit.objects.create(name="فروش دسترسی تست", slug="sales-2")
|
||||
for codename in ("view_ticket", "dispatch_group_tickets"):
|
||||
permission = Permission.objects.get(codename=codename, content_type__app_label="tickets")
|
||||
unit.permissions.add(permission)
|
||||
@@ -123,7 +123,7 @@ def test_dispatch_pages_are_available_only_to_the_dispatch_unit(client):
|
||||
customer = make_contact("warehouse-contact", "09120000131")
|
||||
group = ContactGroup.objects.create(name="تامینکنندگان", unit=unit)
|
||||
customer.customer_profile.groups.add(group)
|
||||
category = TicketCategory.objects.create(title="موجودی", slug="inventory")
|
||||
category = TicketCategory.objects.create(unit=unit, title="موجودی", slug="test-inventory-access")
|
||||
|
||||
client.force_login(staff)
|
||||
assert client.get(reverse("staff_tickets:dispatch_create")).status_code == 200
|
||||
|
||||
@@ -135,6 +135,59 @@ def test_ticket_visibility_obeys_self_unit_and_global_scopes(organization):
|
||||
assert set(staff_ticket_rows(ceo)) == {public_sales, selected_sales, kimia_owned, warehouse_ticket, finance_ticket}
|
||||
|
||||
|
||||
def test_each_public_queue_and_staff_message_endpoint_is_strictly_unit_scoped(client, organization):
|
||||
sales, finance = organization["sales"], organization["finance"]
|
||||
sales_staff = User.objects.get(username="kimiya_ghaffari")
|
||||
other_sales_staff = User.objects.get(username="zahra_jamali")
|
||||
finance_staff = User.objects.get(username="fereshteh_yavari")
|
||||
buyer = customer("isolated-queue-buyer", "09120007111")
|
||||
sales_ticket = create_ticket(
|
||||
customer=buyer,
|
||||
unit=sales,
|
||||
category=TicketCategory.objects.get(slug="sales-general"),
|
||||
subject="صف عمومی فقط فروش",
|
||||
body="پیام فروش",
|
||||
)
|
||||
finance_ticket = create_ticket(
|
||||
customer=buyer,
|
||||
unit=finance,
|
||||
category=TicketCategory.objects.get(slug="finance-general"),
|
||||
subject="صف عمومی فقط مالی",
|
||||
body="پیام مالی",
|
||||
)
|
||||
|
||||
assert set(staff_ticket_rows(sales_staff)) == {sales_ticket}
|
||||
assert finance_ticket not in staff_ticket_rows(sales_staff)
|
||||
assert set(staff_ticket_rows(finance_staff)) == {finance_ticket}
|
||||
assert sales_ticket not in staff_ticket_rows(finance_staff)
|
||||
|
||||
client.force_login(sales_staff)
|
||||
assert client.get(reverse("staff_tickets:detail", args=[finance_ticket.public_id])).status_code == 403
|
||||
assert client.get(reverse("staff_tickets:messages", args=[finance_ticket.public_id])).status_code == 403
|
||||
assert client.get(reverse("staff_tickets:poll", args=[finance_ticket.public_id])).status_code == 403
|
||||
|
||||
routed_ticket = create_ticket(
|
||||
customer=buyer,
|
||||
unit=sales,
|
||||
category=TicketCategory.objects.get(slug="sales-general"),
|
||||
subject="صف منتقلشده از فروش به مالی",
|
||||
body="پیام منتقلشده",
|
||||
)
|
||||
routed_ticket.assigned_unit = finance
|
||||
routed_ticket.save(update_fields=["assigned_unit", "updated_at"])
|
||||
assert routed_ticket not in staff_ticket_rows(sales_staff)
|
||||
assert not TicketPolicy.can_view(sales_staff, routed_ticket)
|
||||
assert routed_ticket in staff_ticket_rows(finance_staff)
|
||||
assert TicketPolicy.can_view(finance_staff, routed_ticket)
|
||||
|
||||
# Once a SELF-scope queue item is claimed, it is no longer visible to a
|
||||
# different expert merely because both experts belong to the same unit.
|
||||
sales_ticket.owner = other_sales_staff
|
||||
sales_ticket.save(update_fields=["owner", "updated_at"])
|
||||
assert sales_ticket not in staff_ticket_rows(sales_staff)
|
||||
assert not TicketPolicy.can_view(sales_staff, sales_ticket)
|
||||
|
||||
|
||||
def test_staff_ticket_form_locks_primary_unit_and_rejects_tampered_unit_category_and_group(client, organization):
|
||||
kimia = User.objects.get(username="kimiya_ghaffari")
|
||||
sales, finance = organization["sales"], organization["finance"]
|
||||
|
||||
@@ -5,11 +5,14 @@ import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.management import call_command
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.core.models import ProductionSeedState
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@@ -26,6 +29,19 @@ def test_seed_bundle_round_trip_is_complete_idempotent_and_excludes_outbox(tmp_p
|
||||
account_type=User.AccountType.STAFF,
|
||||
)
|
||||
StaffProfile.objects.create(user=user, primary_unit=unit, display_name="کارشناس Seed")
|
||||
customer = User.objects.create_user(
|
||||
username="seed-customer", mobile="09120009991", account_type=User.AccountType.CUSTOMER
|
||||
)
|
||||
CustomerProfile.objects.create(user=customer, first_name="مشتری Seed")
|
||||
category = TicketCategory.objects.create(
|
||||
unit=unit, title="دسته Seed", slug="seed-category"
|
||||
)
|
||||
legacy_ticket = Ticket.objects.create(
|
||||
customer=customer,
|
||||
category=category,
|
||||
subject="تیکت قدیمی بدون واحد",
|
||||
last_activity_at=timezone.now(),
|
||||
)
|
||||
permission = Permission.objects.order_by("pk").first()
|
||||
permission_key = (permission.content_type.app_label, permission.codename)
|
||||
unit.permissions.add(permission)
|
||||
@@ -69,4 +85,7 @@ def test_seed_bundle_round_trip_is_complete_idempotent_and_excludes_outbox(tmp_p
|
||||
assert restored_membership.unit.permissions.filter(**permission_filter).exists()
|
||||
assert ProductionSeedState.objects.count() == 1
|
||||
assert NotificationJob.objects.count() == 0
|
||||
restored_ticket = Ticket.objects.get(pk=legacy_ticket.pk)
|
||||
assert restored_ticket.owning_unit.slug == "seed-unit"
|
||||
assert restored_ticket.assigned_unit.slug == "seed-unit"
|
||||
assert (target_media / "branding" / "logo" / "current.png").read_bytes() == b"seed-media"
|
||||
+45
-8
@@ -23,12 +23,16 @@ def test_customer_detail_is_forbidden_to_other_customer(client):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_locked_public_ticket_only_leaks_metadata(client):
|
||||
category = TicketCategory.objects.create(title="قیمت", slug="test-locked-price")
|
||||
unit = OrganizationalUnit.objects.create(name="فروش قفلشده", slug="locked-sales")
|
||||
unit.permissions.add(
|
||||
Permission.objects.get(content_type__app_label="tickets", codename="view_ticket")
|
||||
)
|
||||
category = TicketCategory.objects.create(unit=unit, title="قیمت", slug="test-locked-price")
|
||||
customer = User.objects.create_user(username="customer", mobile="09123333333", account_type="CUSTOMER")
|
||||
expert = User.objects.create_user(username="expert", mobile="09124444444", account_type="STAFF")
|
||||
observer = User.objects.create_user(username="observer", mobile="09125555555", account_type="STAFF")
|
||||
StaffProfile.objects.create(user=expert, display_name="کارشناس اول")
|
||||
StaffProfile.objects.create(user=observer, display_name="کارشناس دوم")
|
||||
StaffProfile.objects.create(user=expert, primary_unit=unit, display_name="کارشناس اول")
|
||||
StaffProfile.objects.create(user=observer, primary_unit=unit, display_name="کارشناس دوم")
|
||||
for user in (expert, observer):
|
||||
for code in ("view_ticket", "claim_ticket"):
|
||||
user.user_permissions.add(Permission.objects.get(content_type__app_label="tickets", codename=code))
|
||||
@@ -36,7 +40,7 @@ def test_locked_public_ticket_only_leaks_metadata(client):
|
||||
claim_ticket(ticket.pk, expert)
|
||||
client.force_login(observer)
|
||||
listing = client.get(reverse("staff_tickets:list"))
|
||||
assert normalize_persian_text("موضوع قابل مشاهده") in listing.content.decode()
|
||||
assert normalize_persian_text("موضوع قابل مشاهده") not in listing.content.decode()
|
||||
assert "محتوای محرمانه" not in listing.content.decode()
|
||||
assert client.get(reverse("staff_tickets:detail", args=[ticket.public_id])).status_code == 403
|
||||
|
||||
@@ -70,11 +74,13 @@ def test_staff_filters_keep_selected_values_and_active_tab(client):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_staff_desk_prioritizes_fresh_actionable_activity_and_renders_distinct_statuses(client):
|
||||
category=TicketCategory.objects.create(title="پیگیری فروش",slug="staff-priority-order")
|
||||
unit=OrganizationalUnit.objects.create(name="فروش اولویت",slug="priority-sales")
|
||||
unit.permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
|
||||
category=TicketCategory.objects.create(unit=unit,title="پیگیری فروش",slug="staff-priority-order")
|
||||
customer=User.objects.create_user(username="priority-customer",mobile="09130000010",account_type="CUSTOMER")
|
||||
CustomerProfile.objects.create(user=customer,first_name="مشتری",last_name="نمونه")
|
||||
expert=User.objects.create_user(username="priority-expert",mobile="09130000011",account_type="STAFF")
|
||||
StaffProfile.objects.create(user=expert,display_name="کارشناس اولویت")
|
||||
StaffProfile.objects.create(user=expert,primary_unit=unit,display_name="کارشناس اولویت")
|
||||
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
|
||||
waiting_customer=create_ticket(customer=customer,category=category,subject="منتظر پاسخ مشتری",body="شرح اول")
|
||||
fresh_customer_reply=create_ticket(customer=customer,category=category,subject="پاسخ تازه مشتری",body="شرح دوم")
|
||||
@@ -224,6 +230,32 @@ def test_staff_sidebar_hides_admin_link_and_shows_developer_credit(client):
|
||||
assert "برنامه نویسی و توسعه: محمد جواد فصاحت" in content
|
||||
assert "09202202129" in content
|
||||
assert "واحد فناوری اطلاعات" not in content
|
||||
assert "الگوهای پیامک" not in content
|
||||
assert reverse("staff_tickets:sms_templates") not in content
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_root_redirects_to_login_or_authenticated_workspace(client):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse("accounts:login")
|
||||
|
||||
customer = User.objects.create_user(
|
||||
username="root-customer", mobile="09120000991", account_type="CUSTOMER"
|
||||
)
|
||||
client.force_login(customer)
|
||||
assert client.get("/").url == reverse("tickets:list")
|
||||
|
||||
staff = User.objects.create_user(
|
||||
username="root-staff", mobile="09120000992", account_type="STAFF"
|
||||
)
|
||||
StaffProfile.objects.create(user=staff, display_name="کارشناس ریشه")
|
||||
client.force_login(staff)
|
||||
assert client.get("/").url == reverse("staff_tickets:list")
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_admin_is_only_mounted_at_private_path(client):
|
||||
assert reverse("admin:index") == "/x5admin/"
|
||||
assert client.get("/admin/").status_code == 404
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_can_set_password_and_login(client):
|
||||
@@ -266,10 +298,15 @@ def test_magic_link_is_reusable_for_seven_days_and_only_grants_one_ticket(client
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ticket_polling_is_versioned_incremental_and_permission_safe(client):
|
||||
category=TicketCategory.objects.create(title="Polling",slug="polling")
|
||||
unit=OrganizationalUnit.objects.create(name="فروش Polling",slug="polling-sales")
|
||||
unit.permissions.add(
|
||||
Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"),
|
||||
Permission.objects.get(content_type__app_label="messaging",codename="add_message"),
|
||||
)
|
||||
category=TicketCategory.objects.create(unit=unit,title="Polling",slug="polling")
|
||||
customer=User.objects.create_user(username="poll-customer",mobile="09129990001",account_type="CUSTOMER")
|
||||
expert=User.objects.create_user(username="poll-expert",mobile="09129990002",account_type="STAFF")
|
||||
StaffProfile.objects.create(user=expert,display_name="کارشناس پولینگ")
|
||||
StaffProfile.objects.create(user=expert,primary_unit=unit,display_name="کارشناس پولینگ")
|
||||
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
|
||||
ticket=create_ticket(customer=customer,category=category,subject="بررسی پاسخ تازه",body="پیام نخست")
|
||||
client.force_login(customer)
|
||||
|
||||
Reference in new issue
Block a user