block user in django admin

This commit is contained in:
javad committed 2026-10-14 12:27:55 +03:30
1 parent 089db0afa4
commit fe7a10cb61
19 files changed
+574 -11

No files matched your search

+16 -4
View File
@@ -1,10 +1,10 @@
Project Root: customerTicket Project Root: customerTicket
Generated At: 2026-10-13T12:06:20+00:00 Generated At: 2026-10-14T08:18:05+00:00
Python Files: 327 Python Files: 333
Django Files: 49 Django Files: 49
JavaScript/TypeScript Files: 41 JavaScript/TypeScript Files: 41
HTML Files: 77 HTML Files: 77
Total Files: 1582 Total Files: 1594
Total Directories: 144 Total Directories: 144
customerTicket customerTicket
@@ -214,6 +214,7 @@ customerTicket
│ │ │ ├── 0010_organizationalunit_notification_destinations.py │ │ │ ├── 0010_organizationalunit_notification_destinations.py
│ │ │ ├── 0011_organizationalunit_supervisor_mobile_4.py │ │ │ ├── 0011_organizationalunit_supervisor_mobile_4.py
│ │ │ ├── 0012_organizationalunit_supervisor_mobile_5.py │ │ │ ├── 0012_organizationalunit_supervisor_mobile_5.py
│ │ │ ├── 0013_organizationalunit_supervisor_sms_ticket_link_enabled.py
│ │ │ └── __init__.py │ │ │ └── __init__.py
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── admin.py │ │ ├── admin.py
@@ -240,6 +241,7 @@ customerTicket
│ │ │ ├── 0016_scope_legacy_public_tickets_to_units.py │ │ │ ├── 0016_scope_legacy_public_tickets_to_units.py
│ │ │ ├── 0017_ticketaccessgrant_first_viewed_at_and_more.py │ │ │ ├── 0017_ticketaccessgrant_first_viewed_at_and_more.py
│ │ │ ├── 0018_dispatch_response_opening.py │ │ │ ├── 0018_dispatch_response_opening.py
│ │ │ ├── 0019_staffticketshortlink.py
│ │ │ └── __init__.py │ │ │ └── __init__.py
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── admin.py │ │ ├── admin.py
@@ -257,6 +259,7 @@ customerTicket
│ │ ├── services.py │ │ ├── services.py
│ │ ├── sharing.py │ │ ├── sharing.py
│ │ ├── sharing_views.py │ │ ├── sharing_views.py
│ │ ├── short_links.py
│ │ ├── staff_conversation.py │ │ ├── staff_conversation.py
│ │ ├── staff_urls.py │ │ ├── staff_urls.py
│ │ └── views.py │ │ └── views.py
@@ -331,6 +334,7 @@ customerTicket
│ ├── PRODUCTION_SEED.md │ ├── PRODUCTION_SEED.md
│ ├── QA_PERMISSION_MATRIX.md │ ├── QA_PERMISSION_MATRIX.md
│ ├── STAFF_SHARING.md │ ├── STAFF_SHARING.md
│ ├── STAFF_TICKET_SHORT_LINKS.md
│ └── THEMING.md │ └── THEMING.md
├── graphify-out ├── graphify-out
│ ├── 2026-09-05 │ ├── 2026-09-05
@@ -479,6 +483,7 @@ customerTicket
│ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json │ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json
│ │ │ ├── 1436cce90bf2f00f050c9eb67cddf5672ed6a3db185ef663c257ee7936b175ae.json │ │ │ ├── 1436cce90bf2f00f050c9eb67cddf5672ed6a3db185ef663c257ee7936b175ae.json
│ │ │ ├── 143ae363276b4a1db64805529e618db71c204aa56bc8b54761693a9a2060336c.json │ │ │ ├── 143ae363276b4a1db64805529e618db71c204aa56bc8b54761693a9a2060336c.json
│ │ │ ├── 1468b7e152af37dfc56ad4b7c02e23deeb6880efba63a89dcb81934bb30054bd.json
│ │ │ ├── 14ffc999200b172d3919e3d9899656c1f8a212769b1e86fe388b82fa302ed1be.json │ │ │ ├── 14ffc999200b172d3919e3d9899656c1f8a212769b1e86fe388b82fa302ed1be.json
│ │ │ ├── 15539048155c760644fc70dd1fbff3855ceedf11bc6ef7b270d1c7280cb4df48.json │ │ │ ├── 15539048155c760644fc70dd1fbff3855ceedf11bc6ef7b270d1c7280cb4df48.json
│ │ │ ├── 175f840f1587093869a030b52c3b019e020c2fb7cf1223579b251b4857752130.json │ │ │ ├── 175f840f1587093869a030b52c3b019e020c2fb7cf1223579b251b4857752130.json
@@ -687,6 +692,7 @@ customerTicket
│ │ │ ├── 7bc90a6d89ccfaea18fd0aa05f2ab506d0639360f33413bfa9385ddf49e6d2fe.json │ │ │ ├── 7bc90a6d89ccfaea18fd0aa05f2ab506d0639360f33413bfa9385ddf49e6d2fe.json
│ │ │ ├── 7cb69c820b549230484188086c76dd8d20f68f682fa6345c879cb80d54e09849.json │ │ │ ├── 7cb69c820b549230484188086c76dd8d20f68f682fa6345c879cb80d54e09849.json
│ │ │ ├── 7cd278b7ea82af8777e4da2f84fd1773dae3dd371744f66bbe12c0b925fd6486.json │ │ │ ├── 7cd278b7ea82af8777e4da2f84fd1773dae3dd371744f66bbe12c0b925fd6486.json
│ │ │ ├── 7d1800caddb416b3c12e4c593d0240bd52111e04f5da48a44cf879ee42aa3603.json
│ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json │ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json
│ │ │ ├── 7de85cc2c6e58ead611e3ac76417ecd5316b7fd3f82ae70937c829eb809ea840.json │ │ │ ├── 7de85cc2c6e58ead611e3ac76417ecd5316b7fd3f82ae70937c829eb809ea840.json
│ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json │ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json
@@ -882,6 +888,7 @@ customerTicket
│ │ │ ├── de1655ae1d42e56f0cdb8788c0f6ba1c7c6e6725e6ef5e80969b92ec3e68ac78.json │ │ │ ├── de1655ae1d42e56f0cdb8788c0f6ba1c7c6e6725e6ef5e80969b92ec3e68ac78.json
│ │ │ ├── dea57fa4a89ef99a815bd9b1bb71b8475727a3d7d88161a9a696ebf636f29ed0.json │ │ │ ├── dea57fa4a89ef99a815bd9b1bb71b8475727a3d7d88161a9a696ebf636f29ed0.json
│ │ │ ├── df2d803fc69084e91df819bda27a7e9f7303a40ff7a5d30d93fbc16daf5e2d45.json │ │ │ ├── df2d803fc69084e91df819bda27a7e9f7303a40ff7a5d30d93fbc16daf5e2d45.json
│ │ │ ├── dfa1b188974aa98a6cf45345da9e0a8071b7de5b8a6392494482d6733b19c56e.json
│ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json │ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json
│ │ │ ├── e00e2e63606d8a49d1669a78789e6be1dc4d2aa78365ce58439e1d7c7975e2bf.json │ │ │ ├── e00e2e63606d8a49d1669a78789e6be1dc4d2aa78365ce58439e1d7c7975e2bf.json
│ │ │ ├── e247b821b3662d49d7381b743644858c0d14fd5b7bed224964c292f7ce9daad3.json │ │ │ ├── e247b821b3662d49d7381b743644858c0d14fd5b7bed224964c292f7ce9daad3.json
@@ -1126,7 +1133,8 @@ customerTicket
│ │ ├── query_20260917_063554_1824eb56_پس_از_باز_شدن_کیبورد_موبایل__کادر_نوشتن_پاسخ_تیکت.md │ │ ├── query_20260917_063554_1824eb56_پس_از_باز_شدن_کیبورد_موبایل__کادر_نوشتن_پاسخ_تیکت.md
│ │ ├── query_20260923_131543_e5730a0b_how_do_staff_ticket_sms_status__failed_delivery__a.md │ │ ├── query_20260923_131543_e5730a0b_how_do_staff_ticket_sms_status__failed_delivery__a.md
│ │ ├── query_20261006_111656_eab3ac96_how_do_payment_approval_policy_and_receipt_approva.md │ │ ├── query_20261006_111656_eab3ac96_how_do_payment_approval_policy_and_receipt_approva.md
│ │ └── query_20261007_080904_8f4d683c_payment_notice__bank_account_snapshots__short_paym.md │ │ ├── query_20261007_080904_8f4d683c_payment_notice__bank_account_snapshots__short_paym.md
│ │ └── query_20261013_122853_ce932abc_how_do_organizational_ticketing_rules_relate_to_su.md
│ ├── reflections │ ├── reflections
│ │ └── LESSONS.md │ │ └── LESSONS.md
│ ├── .graphify_analysis.json │ ├── .graphify_analysis.json
@@ -1173,6 +1181,7 @@ customerTicket
│ ├── db-before-payment-links-20261006.sqlite3 │ ├── db-before-payment-links-20261006.sqlite3
│ ├── db-before-receipt-customer-details-20261011.sqlite3 │ ├── db-before-receipt-customer-details-20261011.sqlite3
│ ├── db-before-staff-sharing.sqlite3 │ ├── db-before-staff-sharing.sqlite3
│ ├── db-before-staff-ticket-short-links-20261013.sqlite3
│ ├── db-pre-chaharsu.sqlite3 │ ├── db-pre-chaharsu.sqlite3
│ ├── deposit-sharing-live-1440.png │ ├── deposit-sharing-live-1440.png
│ ├── deposit-sharing-live-320.png │ ├── deposit-sharing-live-320.png
@@ -1648,6 +1657,7 @@ customerTicket
│ │ │ ├── receipt_upload.py │ │ │ ├── receipt_upload.py
│ │ │ ├── sharing.py │ │ │ ├── sharing.py
│ │ │ ├── staff_payments.py │ │ │ ├── staff_payments.py
│ │ │ ├── staff_short_link.py
│ │ │ ├── ticket_notices.py │ │ │ ├── ticket_notices.py
│ │ │ └── tickets.py │ │ │ └── tickets.py
│ │ ├── config.py │ │ ├── config.py
@@ -1676,6 +1686,7 @@ customerTicket
│ │ ├── test_staff_operations_ui.py │ │ ├── test_staff_operations_ui.py
│ │ ├── test_staff_payment_sms_e2e.py │ │ ├── test_staff_payment_sms_e2e.py
│ │ ├── test_staff_sharing_e2e.py │ │ ├── test_staff_sharing_e2e.py
│ │ ├── test_staff_ticket_short_links_e2e.py
│ │ └── test_ticket_notices_e2e.py │ │ └── test_ticket_notices_e2e.py
│ ├── js │ ├── js
│ │ └── chat-coach.test.mjs │ │ └── chat-coach.test.mjs
@@ -1709,6 +1720,7 @@ customerTicket
│ ├── test_staff_payment_sms.py │ ├── test_staff_payment_sms.py
│ ├── test_staff_shared_queue.py │ ├── test_staff_shared_queue.py
│ ├── test_staff_sharing.py │ ├── test_staff_sharing.py
│ ├── test_staff_ticket_short_links.py
│ ├── test_views.py │ ├── test_views.py
│ └── test_voip_calls.py │ └── test_voip_calls.py
├── .dockerignore ├── .dockerignore
+3
View File
@@ -2,3 +2,6 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig): class AccountsConfig(AppConfig):
name = "apps.accounts" name = "apps.accounts"
def ready(self):
from . import customer_access # Register the login-session receiver.
+81
View File
@@ -0,0 +1,81 @@
"""Restrictions for full customer logins; staff and scoped links stay independent."""
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
from django.http import JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET
from django.utils.cache import add_never_cache_headers
from apps.customers.models import CustomerProfile
from .models import User
ACCESS_VERSION_KEY = "customer_login_access_version"
BLOCK_REASON_KEY = "customer_login_block_reason"
DEFAULT_BLOCK_REASON = "ورود شما مسدود شده است. برای پیگیری با پشتیبانی تماس بگیرید."
def is_customer_login(user):
return user.account_type == User.AccountType.CUSTOMER and not (user.is_staff or user.is_superuser)
def customer_login_profile(user):
if not is_customer_login(user):
return None
return CustomerProfile.objects.filter(user_id=user.pk).first()
def blocked_login_reason(mobile):
user = User.objects.filter(mobile=mobile).first()
profile = customer_login_profile(user) if user else None
if profile and profile.login_blocked:
return profile.login_block_reason.strip() or DEFAULT_BLOCK_REASON
return ""
@receiver(user_logged_in, dispatch_uid="customer_login_access_version")
def remember_customer_access(sender, request, user, **kwargs):
if not is_customer_login(user):
return
profile = customer_login_profile(user)
request.session[ACCESS_VERSION_KEY] = profile.login_access_version if profile else 0
request.session.pop(BLOCK_REASON_KEY, None)
class CustomerLoginAccessMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
profile = customer_login_profile(request.user)
if profile and (
profile.login_blocked
or request.session.get(ACCESS_VERSION_KEY, 0) != profile.login_access_version
):
logout(request)
if profile.login_blocked:
request.session[BLOCK_REASON_KEY] = profile.login_block_reason.strip() or DEFAULT_BLOCK_REASON
else:
messages.error(request, "نشست قبلی شما باطل شده است. لطفاً دوباره وارد شوید.")
if request.path == reverse("accounts:login_access"):
response = JsonResponse({"authenticated": False, "login_url": reverse("accounts:login")})
else:
response = redirect("accounts:login")
add_never_cache_headers(response)
return response
return self.get_response(request)
@never_cache
@require_GET
def customer_login_access(request):
return JsonResponse({
"authenticated": request.user.is_authenticated,
"login_url": reverse("accounts:login"),
})
+2
View File
@@ -1,9 +1,11 @@
from django.urls import path from django.urls import path
from . import views from . import views
from .customer_access import customer_login_access
app_name = "accounts" app_name = "accounts"
urlpatterns = [ urlpatterns = [
path("login/access/", customer_login_access, name="login_access"),
path("", views.home, name="home"), path("", views.home, name="home"),
path("login/", views.customer_login, name="login"), path("login/", views.customer_login, name="login"),
path("login/password/", views.customer_password_login, name="password_login"), path("login/password/", views.customer_password_login, name="password_login"),
+16 -2
View File
@@ -14,6 +14,7 @@ from django.db import transaction
from django.db.models import F from django.db.models import F
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
from .magic_access import grant_ticket_magic_access from .magic_access import grant_ticket_magic_access
from .customer_access import BLOCK_REASON_KEY, blocked_login_reason
from apps.integrations.sms.providers import SMSResult, get_sms_provider from apps.integrations.sms.providers import SMSResult, get_sms_provider
import logging import logging
@@ -46,7 +47,11 @@ def customer_login(request):
_remember_destination(request) _remember_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form = MobileForm(request.POST or None) form = MobileForm(request.POST or None)
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
block_reason = blocked_login_reason(form.cleaned_data["mobile"])
if block_reason:
return render(request, "accounts/customer_login.html", {"form": form, "block_reason": block_reason})
challenge = None challenge = None
try: try:
challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request) challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request)
@@ -62,13 +67,18 @@ def customer_login(request):
challenge.delete() challenge.delete()
logger.exception("OTP SMS delivery failed") logger.exception("OTP SMS delivery failed")
form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.") form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.")
return render(request, "accounts/customer_login.html", {"form": form}) return render(request, "accounts/customer_login.html", {"form": form, "block_reason": block_reason})
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def verify_otp(request): def verify_otp(request):
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id")) challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id"))
form = OTPForm(request.POST or None) form = OTPForm(request.POST or None)
block_reason = blocked_login_reason(challenge.mobile)
if block_reason:
request.session.pop("otp_challenge_id", None)
request.session[BLOCK_REASON_KEY] = block_reason
return redirect("accounts:login")
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
if challenge.verify(form.cleaned_data["code"]): if challenge.verify(form.cleaned_data["code"]):
user, created = User.objects.get_or_create(mobile=challenge.mobile, defaults={"username": f"customer_{challenge.mobile[3:]}", "account_type": User.AccountType.CUSTOMER}) user, created = User.objects.get_or_create(mobile=challenge.mobile, defaults={"username": f"customer_{challenge.mobile[3:]}", "account_type": User.AccountType.CUSTOMER})
@@ -86,12 +96,16 @@ def customer_password_login(request):
_remember_destination(request) _remember_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form=CustomerPasswordLoginForm(request.POST or None) form=CustomerPasswordLoginForm(request.POST or None)
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
if request.method=="POST" and form.is_valid(): if request.method=="POST" and form.is_valid():
block_reason = blocked_login_reason(form.cleaned_data["mobile"])
if block_reason:
return render(request, "accounts/customer_password_login.html", {"form": form, "block_reason": block_reason})
user=form.authenticate(request) user=form.authenticate(request)
if user and user.is_active: if user and user.is_active:
login(request,user); return redirect(_login_destination(request)) login(request,user); return redirect(_login_destination(request))
form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.") form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.")
return render(request,"accounts/customer_password_login.html",{"form":form}) return render(request,"accounts/customer_password_login.html",{"form":form,"block_reason":block_reason})
@login_required @login_required
@require_http_methods(["GET","POST"]) @require_http_methods(["GET","POST"])
+2 -2
View File
@@ -11,8 +11,8 @@ class ContactGroupAdmin(admin.ModelAdmin):
@admin.register(CustomerProfile) @admin.register(CustomerProfile)
class CustomerProfileAdmin(admin.ModelAdmin): class CustomerProfileAdmin(admin.ModelAdmin):
list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name") list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name", "login_blocked")
list_filter = ("stakeholder_type", "groups") list_filter = ("stakeholder_type", "groups", "login_blocked")
search_fields = ("user__mobile", "organization_name", "first_name", "last_name") search_fields = ("user__mobile", "organization_name", "first_name", "last_name")
filter_horizontal = ("groups",) filter_horizontal = ("groups",)
@@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2026-10-14 08:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customers', '0006_backfill_customer_profiles'),
]
operations = [
migrations.AddField(
model_name='customerprofile',
name='login_access_version',
field=models.PositiveIntegerField(default=0, editable=False),
),
migrations.AddField(
model_name='customerprofile',
name='login_block_reason',
field=models.TextField(blank=True, help_text='این متن به مشتری یا مخاطب نمایش داده می\u200cشود. ورود کارکنان و کارشناسان تحت تأثیر نیست.', verbose_name='دلیل مسدودی ورود'),
),
migrations.AddField(
model_name='customerprofile',
name='login_blocked',
field=models.BooleanField(default=False, verbose_name='مسدود کردن ورود مشتری / مخاطب'),
),
]
+30
View File
@@ -1,5 +1,7 @@
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db import transaction
from django.core.exceptions import ValidationError
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
@@ -51,6 +53,34 @@ class CustomerProfile(PersianTextNormalizationMixin, TimeStampedModel):
groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts") groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts")
province = models.CharField(max_length=100, blank=True) province = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True) city = models.CharField(max_length=100, blank=True)
login_blocked = models.BooleanField("مسدود کردن ورود مشتری / مخاطب", default=False)
login_block_reason = models.TextField(
"دلیل مسدودی ورود", blank=True,
help_text="این متن به مشتری یا مخاطب نمایش داده می‌شود. ورود کارکنان و کارشناسان تحت تأثیر نیست.",
)
login_access_version = models.PositiveIntegerField(default=0, editable=False)
def clean(self):
super().clean()
if self.login_blocked and not self.login_block_reason.strip():
raise ValidationError({"login_block_reason": "برای مسدود کردن ورود، دلیل مسدودی را بنویسید."})
def save(self, *args, **kwargs):
# Keep old sessions revoked even if the administrator later unblocks access.
with transaction.atomic():
previous = type(self).objects.select_for_update().filter(pk=self.pk).first()
if previous is None and self.login_blocked:
self.login_access_version = 1
if previous is not None:
self.login_access_version = previous.login_access_version
fields = kwargs.get("update_fields")
changes_access = fields is None or "login_blocked" in fields
if changes_access and self.login_blocked and not previous.login_blocked:
self.login_access_version += 1
if fields is not None:
kwargs["update_fields"] = set(fields) | {"login_access_version"}
super().save(*args, **kwargs)
def __str__(self): return (f"{self.first_name} {self.last_name}".strip() or self.user.mobile or self.user.username) def __str__(self): return (f"{self.first_name} {self.last_name}".strip() or self.user.mobile or self.user.username)
+2 -1
View File
@@ -29,7 +29,8 @@ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "apps.payments.middleware.FinancialGrantAuditMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "apps.accounts.customer_access.CustomerLoginAccessMiddleware",
"apps.payments.middleware.FinancialGrantAuditMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
+57
View File
@@ -0,0 +1,57 @@
# مسدودی ورود مشتریان و مخاطبان
در Django Admin، پروفایل مشتری / مخاطب را با شماره موبایل جست‌وجو کنید.
گزینهٔ «مسدود کردن ورود مشتری / مخاطب» را فعال کنید، «دلیل مسدودی ورود» را
بنویسید و ذخیره کنید. ثبت مسدودی بدون دلیل در ادمین پذیرفته نمی‌شود.
برای رفع مسدودی، همان گزینه را غیرفعال کنید و ذخیره کنید.
این تنظیم ورود با موبایل و OTP در `/login/` و ورود با رمز عبور در
`/login/password/` را کنترل می‌کند. دلیل با متن قرمز، فونت برنامه و کادر
دارای `role="alert"` نمایش داده می‌شود. متن دلیل HTML اجرا نمی‌کند.
تنظیمات مسدودی در فرم مشتری و فرم مدیریت مخاطبان کارشناسان قابل تغییر نیست.
هر بار مسدود کردن، نسخهٔ دسترسی پروفایل را افزایش می‌دهد. نشست‌های ورود
قدیمی در نخستین درخواست بعدی خارج می‌شوند و حتی پس از رفع مسدودی قابل
استفاده نیستند؛ ورود مجدد لازم است. نشست‌های قبل از استقرار این قابلیت
نسخهٔ صفر دارند و تا زمانی که حساب مسدود نشود معتبر می‌مانند.
صفحات مشتری هر ۱۰ ثانیه وضعیت نشست را از `/login/access/` بررسی می‌کنند.
در مرورگر باز و متصل، مسدودی بدون کلیک کاربر نیز باعث انتقال به صفحهٔ
ورود و نمایش دلیل می‌شود. هنگام بازگشت به تب، باز شدن مجدد صفحه یا اتصال
دوبارهٔ شبکه هم بررسی انجام می‌شود. کنترل سمت سرور به جاوااسکریپت وابسته نیست.
حساب‌های `STAFF`، کاربران دارای `is_staff` و ابرکاربران از این محدودیت
مستثنا هستند. لینک‌های امن با دسترسی محدود به تیکت یا پرداخت، نشست ورود
کامل مشتری ایجاد نمی‌کنند و قواعد مستقل قبلی خود را حفظ می‌کنند.
این قابلیت مقدار `is_active` حساب را تغییر نمی‌دهد.
Migration مورد نیاز: `customers.0007_customerprofile_login_access_version_and_more`.
برای استقرار با پایتون محیط پروژه، `manage.py migrate` را اجرا کنید و سرویس
Django را دوباره راه‌اندازی کنید.
تست‌ها در `tests/test_customer_login_access.py` و
`tests/e2e/test_customer_login_access_e2e.py` قرار دارند. تست مرورگر از
`live_server` و Chromium مدیریت‌شدهٔ پروژه استفاده می‌کند.
## نتیجهٔ بررسی
- مجموعهٔ گستردهٔ ورود، نقش‌ها، مخاطبان، پرداخت‌ها، لینک‌های امن و انتقال
داده: ۲۶۰ تست موفق.
- آخرین اجرای پس از اصلاح ترتیب middlewareها: ۸۵ تست موفق، شامل
`test_customer_login_access.py`، `test_views.py`، `test_role_workflow_regression.py`،
تست‌های مرورگر مسدودی و `test_pwa_versioning.py`.
- کنترل‌های Django، نبود migration جاافتاده، بررسی نحو Python و JavaScript
و `git diff --check` برای فایل‌های این قابلیت موفق بودند.
- صفحهٔ ورود، CSS قرمز نسخه‌دار و endpoint بدون کش نشست از سرور توسعه
روی `0.0.0.0:8000` با پاسخ HTTP 200 بررسی شدند.
Graphify برای ارتباط مفهومی پروفایل و دسترسی‌ها و Ripwire برای بررسی
نمادها، اثر تغییر، محل استفاده و گیت‌های پس از تغییر اجرا شدند.
`test-gate` فهرست تست‌های لازم را ارائه می‌کند و خود تست‌ها را اجرا نمی‌کند؛
تست‌های معرفی‌شده جداگانه اجرا و موفق شدند. `quality-delta` سبز نشد:
هشدارهایی دربارهٔ callbackهای Django، تست‌های کشف‌شده توسط pytest و شباهت
ساختاری توابع بازیابی رکورد باقی مانده است. برای نمونه، بازیابی
`CustomerProfile` با بازیابی `DispatchResponseControl` شبیه تشخیص داده شد؛
این دو به مدل‌ها و قواعد مستقل مربوط‌اند و ادغام آن‌ها مناسب نیست.
این هشدارهای تحلیل ایستا به معنی شکست تست‌های اجرایی نیستند و به‌عنوان
نتیجهٔ سبز گیت کیفیت گزارش نشده‌اند.
+17
View File
@@ -0,0 +1,17 @@
.customer-login-block {
margin-block: 1.25rem;
padding: 1rem 1.25rem;
border: 1px solid #fca5a5;
border-inline-start: 4px solid #b91c1c;
border-radius: 12px;
background: #fff1f2;
color: #991b1b;
font-family: inherit;
font-size: 1rem;
line-height: 1.9;
text-align: start;
overflow-wrap: anywhere;
}
.customer-login-block strong { display: block; font-size: 1.125rem; font-weight: 800; }
.customer-login-block .customer-login-block-label { margin: .5rem 0 0; color: #991b1b; }
.customer-login-block .customer-login-block-reason { margin: .25rem 0 0; color: #991b1b; font-weight: 700; white-space: pre-wrap; }
+25
View File
@@ -0,0 +1,25 @@
(() => {
const script = document.querySelector('script[data-customer-login-access]');
if (!script) return;
let pending = false;
async function checkAccess() {
if (pending || document.hidden) return;
pending = true;
try {
const response = await fetch(script.dataset.customerLoginAccess, {
credentials: 'same-origin', cache: 'no-store', headers: {Accept: 'application/json'}
});
if (!response.ok) return;
const result = await response.json();
if (!result.authenticated) window.location.replace(result.login_url);
} catch (_) {
// Retry on the next heartbeat or when the browser returns online.
} finally {
pending = false;
}
}
window.setInterval(checkAccess, 10000);
document.addEventListener('visibilitychange', checkAccess);
window.addEventListener('pageshow', checkAccess);
window.addEventListener('online', checkAccess);
})();
+1 -1
View File
@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="auth-shell"> <div class="auth-shell">
{% if site_branding.login_intro_enabled %}<section class="auth-intro"><div class="auth-intro-icon">{{ site_branding.login_intro_icon }}</div><p class="eyebrow">{{ site_branding.login_intro_eyebrow }}</p><h2>{{ site_branding.login_intro_heading|linebreaksbr }}</h2><p>{{ site_branding.login_intro_body|linebreaksbr }}</p></section>{% endif %} {% if site_branding.login_intro_enabled %}<section class="auth-intro"><div class="auth-intro-icon">{{ site_branding.login_intro_icon }}</div><p class="eyebrow">{{ site_branding.login_intro_eyebrow }}</p><h2>{{ site_branding.login_intro_heading|linebreaksbr }}</h2><p>{{ site_branding.login_intro_body|linebreaksbr }}</p></section>{% endif %}
<div class="auth-panel"><section class="auth card">{% include 'includes/auth_brand.html' with hide_copy=True %}<p class="eyebrow">ورود مشتریان</p><h1>خوش آمدید</h1><p class="muted">شماره موبایل خود را وارد کنید؛ نیازی به ثبت‌نام یا رمز عبور نیست.</p><form method="post" data-disable-on-submit>{% csrf_token %}{{ form.as_p }}<button type="submit">دریافت کد یک‌بارمصرف <span aria-hidden="true">←</span></button></form><div class="auth-links"><a href="{% url 'accounts:password_login' %}">ورود با رمز عبور</a><a href="{% url 'accounts:staff_login' %}">ورود کارشناسان</a></div></section>{% include 'includes/auth_footer.html' %}</div> <div class="auth-panel"><section class="auth card">{% include 'includes/auth_brand.html' with hide_copy=True %}<p class="eyebrow">ورود مشتریان</p><h1>خوش آمدید</h1><p class="muted">شماره موبایل خود را وارد کنید؛ نیازی به ثبت‌نام یا رمز عبور نیست.</p>{% include 'includes/customer_login_block.html' %}<form method="post" data-disable-on-submit>{% csrf_token %}{{ form.as_p }}<button type="submit">دریافت کد یک‌بارمصرف <span aria-hidden="true">←</span></button></form><div class="auth-links"><a href="{% url 'accounts:password_login' %}">ورود با رمز عبور</a><a href="{% url 'accounts:staff_login' %}">ورود کارشناسان</a></div></section>{% include 'includes/auth_footer.html' %}</div>
</div> </div>
{% endblock %} {% endblock %}
{% block footer %}{% endblock %} {% block footer %}{% endblock %}
@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="auth-shell"> <div class="auth-shell">
<section class="auth-intro"><div class="auth-intro-icon">●</div><p class="eyebrow">ورود سریع</p><h2>دوباره خوش آمدید.</h2><p>اگر قبلاً در پروفایل خود رمز تعیین کرده‌اید، بدون انتظار برای پیامک وارد شوید.</p></section> <section class="auth-intro"><div class="auth-intro-icon">●</div><p class="eyebrow">ورود سریع</p><h2>دوباره خوش آمدید.</h2><p>اگر قبلاً در پروفایل خود رمز تعیین کرده‌اید، بدون انتظار برای پیامک وارد شوید.</p></section>
<div class="auth-panel"><section class="auth card">{% include 'includes/auth_brand.html' %}<p class="eyebrow">حساب مشتری</p><h1>ورود با رمز عبور</h1><p class="muted">شماره موبایل و رمز انتخابی خود را وارد کنید.</p><form method="post" data-disable-on-submit>{% csrf_token %}{{ form.as_p }}<button type="submit">ورود به حساب</button></form><div class="auth-links"><a href="{% url 'accounts:login' %}">ورود با کد یک‌بارمصرف</a></div></section>{% include 'includes/auth_footer.html' %}</div> <div class="auth-panel"><section class="auth card">{% include 'includes/auth_brand.html' %}<p class="eyebrow">حساب مشتری</p><h1>ورود با رمز عبور</h1><p class="muted">شماره موبایل و رمز انتخابی خود را وارد کنید.</p>{% include 'includes/customer_login_block.html' %}<form method="post" data-disable-on-submit>{% csrf_token %}{{ form.as_p }}<button type="submit">ورود به حساب</button></form><div class="auth-links"><a href="{% url 'accounts:login' %}">ورود با کد یک‌بارمصرف</a></div></section>{% include 'includes/auth_footer.html' %}</div>
</div> </div>
{% endblock %} {% endblock %}
{% block footer %}{% endblock %} {% block footer %}{% endblock %}
+2
View File
@@ -12,6 +12,7 @@
<link rel="stylesheet" href="{% static 'css/tailwind.css' %}?v={{ static_asset_version }}"> <link rel="stylesheet" href="{% static 'css/tailwind.css' %}?v={{ static_asset_version }}">
<link rel="stylesheet" href="{% static 'css/operations.css' %}?v={{ static_asset_version }}"> <link rel="stylesheet" href="{% static 'css/operations.css' %}?v={{ static_asset_version }}">
<link rel="stylesheet" href="{% static 'css/dispatches.css' %}?v={{ static_asset_version }}"> <link rel="stylesheet" href="{% static 'css/dispatches.css' %}?v={{ static_asset_version }}">
{% if request.resolver_match.namespace == 'accounts' %}<link rel="stylesheet" href="{% static 'css/customer-login-access.css' %}?v={{ static_asset_version }}">{% endif %}
{% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' or financial_page or request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name == 'list' or request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name == 'new' %}<link rel="stylesheet" href="{% static 'css/customer-dock.css' %}?v={{ static_asset_version }}">{% endif %} {% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' or financial_page or request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name == 'list' or request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name == 'new' %}<link rel="stylesheet" href="{% static 'css/customer-dock.css' %}?v={{ static_asset_version }}">{% endif %}
<script defer src="{% static 'vendor/htmx-2.0.3.min.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'vendor/alpine-3.14.8.min.js' %}?v={{ static_asset_version }}"></script> <script defer src="{% static 'vendor/htmx-2.0.3.min.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'vendor/alpine-3.14.8.min.js' %}?v={{ static_asset_version }}"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
@@ -73,6 +74,7 @@
{% if user.is_authenticated %}<script defer src="{% static 'js/pwa.js' %}?v={{ static_asset_version }}"></script>{% endif %} {% if user.is_authenticated %}<script defer src="{% static 'js/pwa.js' %}?v={{ static_asset_version }}"></script>{% endif %}
{% if user.is_authenticated and user.account_type == 'STAFF' %}<link rel="stylesheet" href="{% static 'css/staff-sharing.css' %}?v={{ static_asset_version }}"><script defer src="{% static 'js/staff-sharing.js' %}?v={{ static_asset_version }}"></script>{% endif %} {% if user.is_authenticated and user.account_type == 'STAFF' %}<link rel="stylesheet" href="{% static 'css/staff-sharing.css' %}?v={{ static_asset_version }}"><script defer src="{% static 'js/staff-sharing.js' %}?v={{ static_asset_version }}"></script>{% endif %}
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
{% if user.is_authenticated and user.account_type == 'CUSTOMER' and not user.is_staff and not user.is_superuser %}<script defer src="{% static 'js/customer-login-access.js' %}?v={{ static_asset_version }}" data-customer-login-access="{% url 'accounts:login_access' %}"></script>{% endif %}
{% block otp_script %}{% endblock %} {% block otp_script %}{% endblock %}
{% endblock %} {% endblock %}
</body></html> </body></html>
@@ -0,0 +1,7 @@
{% if block_reason %}
<div class="customer-login-block" role="alert" aria-labelledby="login-block-title" tabindex="-1">
<strong id="login-block-title">ورود به حساب شما مسدود است</strong>
<p class="customer-login-block-label">دلیل مسدودی:</p>
<p class="customer-login-block-reason">{{ block_reason }}</p>
</div>
{% endif %}
+25
View File
@@ -0,0 +1,25 @@
from django.urls import reverse
from playwright.sync_api import expect
from tests.e2e.pages.auth import AuthenticationPage
class CustomerLoginAccessPage(AuthenticationPage):
def request_otp(self, mobile):
self.goto(reverse("accounts:login"))
self.page.get_by_label("شماره موبایل").fill(mobile)
self.page.get_by_role("button", name="دریافت کد یک‌بارمصرف").click()
def assert_block_reason(self, reason):
alert = self.page.get_by_role("alert")
expect(alert).to_contain_text("ورود به حساب شما مسدود است")
expect(alert).to_contain_text(reason)
expect(alert).to_be_visible()
assert alert.locator(".customer-login-block-reason").evaluate(
"node => getComputedStyle(node).color"
) == "rgb(153, 27, 27)"
assert self.page.evaluate("document.documentElement.scrollWidth <= innerWidth + 1")
def assert_logged_out(self, reason):
expect(self.page).to_have_url(self.url(reverse("accounts:login")), timeout=20000)
self.assert_block_reason(reason)
@@ -0,0 +1,44 @@
import pytest
from concurrent.futures import ThreadPoolExecutor
from django.db import connections
from apps.accounts.models import User
from apps.customers.models import CustomerProfile
from tests.e2e.pages.customer_login_access import CustomerLoginAccessPage
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
REASON = "به علت بدهی معوق، ورود شما تا بررسی واحد مالی مسدود شده است."
PASSWORD = "Customer-access-e2e-2026!"
def block_customer(profile_id):
try:
profile = CustomerProfile.objects.get(pk=profile_id)
profile.login_blocked = True
profile.login_block_reason = REASON
profile.save(update_fields=["login_blocked", "login_block_reason"])
finally:
connections.close_all()
@pytest.mark.parametrize("width", [320, 1280])
def test_block_reason_is_clear_and_red_on_mobile_and_desktop(live_server, browser_page, width):
user = User.objects.create_user(username="blocked-ui", mobile="09121234566", password=PASSWORD)
CustomerProfile.objects.create(user=user, login_blocked=True, login_block_reason=REASON)
with browser_page() as page:
page.set_viewport_size({"width": width, "height": 850})
access = CustomerLoginAccessPage(page, live_server.url)
access.request_otp(user.local_mobile)
access.assert_block_reason(REASON)
def test_open_customer_browser_logs_out_without_interaction(live_server, browser_page):
user = User.objects.create_user(username="idle-ui", mobile="09121234566", password=PASSWORD)
profile = CustomerProfile.objects.create(user=user)
with browser_page() as page:
access = CustomerLoginAccessPage(page, live_server.url)
access.login_customer(user, PASSWORD)
with ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(block_customer, profile.pk).result()
access.assert_logged_out(REASON)
+215
View File
@@ -0,0 +1,215 @@
from unittest.mock import patch
import pytest
from django.contrib.auth import SESSION_KEY
from django.test import Client
from django.urls import reverse
from apps.accounts.models import OTPChallenge, User
from apps.customers.models import CustomerProfile
from apps.staff.models import StaffProfile
pytestmark = pytest.mark.django_db
PASSWORD = "Customer-access-2026!"
REASON = "به علت بدهی معوق، ورود شما تا بررسی واحد مالی مسدود شده است."
@pytest.fixture
def customer():
user = User.objects.create_user(username="blocked-customer", mobile="09121234566", password=PASSWORD)
CustomerProfile.objects.create(user=user)
return user
def block(user, reason=REASON):
profile = CustomerProfile.objects.get(user=user)
profile.login_blocked = True
profile.login_block_reason = reason
profile.save(update_fields=["login_blocked", "login_block_reason"])
return profile
@pytest.mark.parametrize("stakeholder", CustomerProfile.StakeholderType.values)
def test_blocked_customers_and_contacts_cannot_request_sms(client, customer, stakeholder):
profile = block(customer)
profile.stakeholder_type = stakeholder
profile.save(update_fields=["stakeholder_type"])
with patch("apps.accounts.views.get_sms_provider") as provider:
response = client.post(reverse("accounts:login"), {"mobile": customer.local_mobile})
assert response.status_code == 200
assert REASON in response.content.decode()
assert 'role="alert"' in response.content.decode()
assert SESSION_KEY not in client.session
assert not OTPChallenge.objects.exists()
provider.assert_not_called()
def test_password_login_is_blocked_and_reason_is_escaped(client, customer):
block(customer, "علت مسدودی\n<script>alert('unsafe')</script>")
response = client.post(reverse("accounts:password_login"), {"mobile": customer.local_mobile, "password": PASSWORD})
content = response.content.decode()
assert response.status_code == 200
assert "علت مسدودی" in content
assert "&lt;script&gt;" in content
assert "<script>alert('unsafe')</script>" not in content
assert SESSION_KEY not in client.session
@pytest.mark.parametrize("method", ["get", "post"])
def test_blocking_after_otp_issue_prevents_verification(client, customer, method):
challenge, code = OTPChallenge.issue(customer.mobile)
session = client.session
session["otp_challenge_id"] = challenge.pk
session.save()
block(customer)
response = getattr(client, method)(reverse("accounts:otp"), {"code": code}, follow=True)
assert response.status_code == 200
assert REASON in response.content.decode()
assert SESSION_KEY not in client.session
assert "otp_challenge_id" not in client.session
@pytest.mark.parametrize("endpoint", ["accounts:profile", "tickets:list", "payments:home", "accounts:login", "accounts:login_access"])
def test_blocking_logs_out_all_existing_sessions(client, customer, endpoint):
other_browser = Client()
client.force_login(customer)
other_browser.force_login(customer)
block(customer)
for browser in (client, other_browser):
response = browser.get(reverse(endpoint))
if endpoint == "accounts:login_access":
assert response.json()["authenticated"] is False
assert "no-store" in response["Cache-Control"]
else:
assert response.status_code == 302
assert response.url == reverse("accounts:login")
assert SESSION_KEY not in browser.session
assert REASON in browser.get(reverse("accounts:login")).content.decode()
def test_blocked_session_cannot_write_customer_profile(client, customer):
client.force_login(customer)
block(customer)
response = client.post(reverse("accounts:profile"), {"first_name": "تغییر غیرمجاز"})
assert response.status_code == 302
customer.customer_profile.refresh_from_db()
assert customer.customer_profile.first_name == ""
def test_unblocking_does_not_restore_an_old_session_but_allows_new_login(client, customer):
client.force_login(customer)
profile = block(customer)
profile.login_blocked = False
profile.save(update_fields=["login_blocked"])
response = client.get(reverse("accounts:profile"))
assert response.url == reverse("accounts:login")
assert SESSION_KEY not in client.session
login_page = client.get(response.url).content.decode()
assert "نشست قبلی شما باطل شده است." in login_page
assert "ورود به حساب شما مسدود است" not in login_page
response = client.post(reverse("accounts:password_login"), {"mobile": customer.local_mobile, "password": PASSWORD})
assert response.url == reverse("payments:home")
assert SESSION_KEY in client.session
assert client.get(reverse("accounts:profile")).status_code == 200
def test_unblocked_customer_can_still_login_with_otp(client, customer):
challenge, code = OTPChallenge.issue(customer.mobile)
session = client.session
session["otp_challenge_id"] = challenge.pk
session.save()
response = client.post(reverse("accounts:otp"), {"code": code})
assert response.url == reverse("payments:home")
assert SESSION_KEY in client.session
@pytest.mark.parametrize("role", ["staff", "django_staff", "superuser"])
def test_staff_and_admin_sessions_are_exempt_even_with_blocked_customer_profile(client, role):
user = User.objects.create_user(
username=role, password=PASSWORD, mobile="09129998877",
account_type="STAFF" if role == "staff" else "CUSTOMER",
is_staff=role == "django_staff", is_superuser=role == "superuser",
)
CustomerProfile.objects.create(user=user, login_blocked=True, login_block_reason=REASON)
if role == "staff":
StaffProfile.objects.create(user=user, display_name="کارشناس")
response = client.post(reverse("accounts:staff_login"), {"username": user.username, "password": PASSWORD})
assert response.url == reverse("staff_tickets:list")
workspace = client.get(reverse("staff_tickets:list"))
assert workspace.status_code == 200
assert "js/customer-login-access.js" not in workspace.content.decode()
else:
client.force_login(user)
assert client.get(reverse("accounts:login_access")).json()["authenticated"] is True
assert SESSION_KEY in client.session
response = client.get(reverse("accounts:login"))
assert response.status_code == 302
assert SESSION_KEY in client.session
def test_only_admin_can_change_login_block_settings(client, customer):
client.force_login(customer)
client.post(reverse("accounts:profile"), {
"first_name": "مشتری", "last_name": "آزمایشی", "customer_type": "PERSON",
"login_blocked": "on", "login_block_reason": REASON,
})
profile = CustomerProfile.objects.get(user=customer)
assert not profile.login_blocked
assert profile.login_block_reason == ""
def test_admin_requires_a_reason_and_can_block_and_unblock(client, customer):
admin = User.objects.create_superuser(username="access-admin", password=PASSWORD)
client.force_login(admin)
profile = customer.customer_profile
url = reverse("admin:customers_customerprofile_change", args=[profile.pk])
data = {"user": customer.pk, "stakeholder_type": "CUSTOMER", "login_blocked": "on", "login_block_reason": " "}
response = client.post(url, data)
assert response.status_code == 200
assert "برای مسدود کردن ورود، دلیل مسدودی را بنویسید." in response.content.decode()
profile.refresh_from_db()
assert not profile.login_blocked
data["login_block_reason"] = REASON
assert client.post(url, data).status_code == 302
profile.refresh_from_db()
assert profile.login_blocked and profile.login_block_reason == REASON
assert profile.login_access_version == 1
data.pop("login_blocked")
assert client.post(url, data).status_code == 302
profile.refresh_from_db()
assert not profile.login_blocked
assert profile.login_access_version == 1
def test_legacy_session_without_access_version_is_revoked(client, customer):
client.force_login(customer)
session = client.session
session.pop("customer_login_access_version")
session.save()
block(customer)
assert client.get(reverse("accounts:profile")).url == reverse("accounts:login")
assert SESSION_KEY not in client.session
def test_missing_profile_does_not_prevent_customer_login(client):
user = User.objects.create_user(username="no-profile", mobile="09120000111", password=PASSWORD)
response = client.post(reverse("accounts:password_login"), {"mobile": user.local_mobile, "password": PASSWORD})
assert response.url == reverse("payments:home")
assert client.get(reverse("accounts:login_access")).json()["authenticated"] is True
def test_login_block_does_not_change_scoped_magic_link_access(client, customer):
from apps.accounts.models import TicketMagicLink
from apps.tickets.models import TicketCategory
from apps.tickets.services import create_ticket
category = TicketCategory.objects.create(title="فروش", slug="login-access-sale")
ticket = create_ticket(customer=customer, category=category, subject="دسترسی لینک", body="متن")
token = TicketMagicLink.issue(ticket)
block(customer)
response = client.get(reverse("accounts:magic", args=[token]), follow=True)
assert response.status_code == 200
assert "فقط به همین تیکت دسترسی دارید" in response.content.decode()
assert SESSION_KEY not in client.session
assert client.get(reverse("tickets:list")).status_code == 302