block user in django admin
This commit is contained in:
1 parent
089db0afa4
commit
fe7a10cb61
19 files changed
+574
-11
No files matched your search
+16
-4
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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"),
|
||||||
|
})
|
||||||
@@ -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
@@ -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"])
|
||||||
|
|||||||
@@ -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='مسدود کردن ورود مشتری / مخاطب'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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` شبیه تشخیص داده شد؛
|
||||||
|
این دو به مدلها و قواعد مستقل مربوطاند و ادغام آنها مناسب نیست.
|
||||||
|
این هشدارهای تحلیل ایستا به معنی شکست تستهای اجرایی نیستند و بهعنوان
|
||||||
|
نتیجهٔ سبز گیت کیفیت گزارش نشدهاند.
|
||||||
@@ -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; }
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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)
|
||||||
@@ -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 "<script>" 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
|
||||||
Reference in new issue
Block a user