This commit is contained in:
javad committed 2026-09-10 11:34:19 +03:30
commit fa9d154db9
260 files changed
+17643

No files matched your search

View File
Whitespace-only changes.
+72
View File
@@ -0,0 +1,72 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from apps.staff.models import StaffProfile
from apps.staff.forms import StaffProfileAdminForm
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
class MagicLinkAccessLogInline(admin.TabularInline):
model = MagicLinkAccessLog
extra = 0
can_delete = False
fields = ("opened_at", "ip_address", "user_agent")
readonly_fields = fields
def has_add_permission(self, request, obj=None):
return False
@admin.register(TicketMagicLink)
class TicketMagicLinkAdmin(admin.ModelAdmin):
list_display = ("ticket", "recipient_name", "recipient_mobile", "created_unit", "created_by", "created_at", "expires_at", "view_count", "revoked_at")
list_filter = ("created_unit", "created_reason", "revoked_at", "expires_at")
search_fields = ("ticket__ticket_number", "recipient_name", "recipient_mobile", "created_by__username")
readonly_fields = ("ticket", "customer", "token_hash", "created_by", "created_unit", "recipient_name", "recipient_mobile", "created_at", "expires_at", "used_at", "revoked_at", "created_reason", "last_used_ip", "last_user_agent", "view_count")
inlines = (MagicLinkAccessLogInline,)
def get_queryset(self, request):
queryset = super().get_queryset(request).select_related("ticket", "created_unit", "created_by")
if request.user.is_superuser or request.user.has_perm("accounts.view_all_magic_links"):
return queryset
unit_ids = request.user.unit_memberships.filter(is_active=True).values_list("unit_id", flat=True)
return queryset.filter(created_unit_id__in=unit_ids)
def has_add_permission(self, request):
return False
@admin.register(MagicLinkAccessLog)
class MagicLinkAccessLogAdmin(admin.ModelAdmin):
list_display = ("magic_link", "opened_at", "ip_address", "user_agent")
list_filter = ("opened_at",)
search_fields = ("magic_link__ticket__ticket_number", "ip_address", "user_agent")
readonly_fields = ("magic_link", "opened_at", "ip_address", "user_agent")
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
class StaffProfileInline(admin.StackedInline):
model = StaffProfile
form = StaffProfileAdminForm
extra = 0
max_num = 1
fields = ("primary_unit", "role", "first_name", "last_name", "display_name", "job_title", "avatar", "mobile", "bale_chat_id", "bale_notifications_enabled", "is_customer_selectable", "sort_order", "is_active")
@admin.register(User)
class CustomUserAdmin(UserAdmin):
fieldsets = UserAdmin.fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),)
add_fieldsets = UserAdmin.add_fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),)
list_display = ("username", "mobile", "account_type", "is_active", "is_staff")
list_filter = UserAdmin.list_filter + ("account_type",)
inlines = (StaffProfileInline,)
def save_model(self, request, obj, form, change):
if obj.account_type == User.AccountType.STAFF:
obj.is_staff = True
super().save_model(request, obj, form, change)
admin.site.register(OTPChallenge)
+4
View File
@@ -0,0 +1,4 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "apps.accounts"
+35
View File
@@ -0,0 +1,35 @@
from django import forms
from django.contrib.auth import authenticate
from .models import normalize_mobile
from apps.customers.models import CustomerProfile
from apps.core.iran_locations import validate_location
class MobileForm(forms.Form):
mobile = forms.CharField(label="شماره موبایل")
def clean_mobile(self):
try: return normalize_mobile(self.cleaned_data["mobile"])
except ValueError as exc: raise forms.ValidationError(str(exc))
class OTPForm(forms.Form): code = forms.CharField(label="کد تایید", min_length=4, max_length=8, widget=forms.TextInput(attrs={"autocomplete":"one-time-code","inputmode":"numeric","pattern":"[0-9۰-۹]*","data-webotp":""}))
class CustomerPasswordLoginForm(forms.Form):
mobile=forms.CharField(label="شماره موبایل")
password=forms.CharField(label="رمز عبور",widget=forms.PasswordInput)
def clean_mobile(self):
try:return normalize_mobile(self.cleaned_data["mobile"])
except ValueError as exc:raise forms.ValidationError(str(exc))
def authenticate(self,request):
from .models import User
user=User.objects.filter(mobile=self.cleaned_data["mobile"],account_type=User.AccountType.CUSTOMER).first()
return authenticate(request,username=user.username,password=self.cleaned_data["password"]) if user else None
class CustomerProfileForm(forms.ModelForm):
password=forms.CharField(label="رمز عبور جدید",required=False,min_length=8,widget=forms.PasswordInput,help_text="برای حفظ رمز فعلی خالی بگذارید.")
class Meta:
model=CustomerProfile; fields=["first_name","last_name","customer_type","province","city"]; labels={"first_name":"نام","last_name":"نام خانوادگی","customer_type":"نوع مشتری","province":"استان","city":"شهر"}
widgets={"province":forms.TextInput(attrs={"list":"profile-province-options","autocomplete":"off","data-province-input":""}),"city":forms.TextInput(attrs={"list":"profile-city-options","autocomplete":"off","data-city-input":""})}
def clean(self):
data=super().clean(); province,city,error=validate_location(data.get("province"),data.get("city")); data["province"],data["city"]=province,city
if error: self.add_error("city" if city else "province",error)
return data
class StaffLoginForm(forms.Form):
username = forms.CharField(label="نام کاربری")
password = forms.CharField(label="رمز عبور", widget=forms.PasswordInput)
def authenticate(self, request): return authenticate(request, username=self.cleaned_data["username"], password=self.cleaned_data["password"])
+32
View File
@@ -0,0 +1,32 @@
from django.utils import timezone
from .models import TicketMagicLink
MAGIC_LINK_SESSION_KEY = "ticket_magic_link_id"
def grant_ticket_magic_access(request, link):
"""Grant an anonymous session access to exactly one ticket until link expiry."""
request.session.cycle_key()
request.session[MAGIC_LINK_SESSION_KEY] = link.pk
remaining = max(1, int((link.expires_at - timezone.now()).total_seconds()))
request.session.set_expiry(remaining)
def get_ticket_magic_access(request, ticket=None):
link_id = request.session.get(MAGIC_LINK_SESSION_KEY)
if not link_id:
return None
filters = {
"pk": link_id,
"revoked_at__isnull": True,
"expires_at__gt": timezone.now(),
"customer__is_active": True,
}
if ticket is not None:
filters["ticket_id"] = ticket.pk
link = TicketMagicLink.objects.select_related("customer", "ticket").filter(**filters).first()
if link is None and ticket is None:
request.session.pop(MAGIC_LINK_SESSION_KEY, None)
return link
+78
View File
@@ -0,0 +1,78 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='OTPChallenge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mobile', models.CharField(db_index=True, max_length=16)),
('purpose', models.CharField(choices=[('LOGIN', 'ورود')], default='LOGIN', max_length=20)),
('code_hash', models.CharField(max_length=64)),
('expires_at', models.DateTimeField()),
('attempt_count', models.PositiveSmallIntegerField(default=0)),
('max_attempts', models.PositiveSmallIntegerField(default=5)),
('verified_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('request_ip', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, max_length=300)),
],
),
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('mobile', models.CharField(blank=True, max_length=16, null=True, unique=True)),
('account_type', models.CharField(choices=[('CUSTOMER', 'مشتری'), ('STAFF', 'کارشناس')], default='CUSTOMER', max_length=10)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='TicketMagicLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_hash', models.CharField(max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('used_at', models.DateTimeField(blank=True, null=True)),
('revoked_at', models.DateTimeField(blank=True, null=True)),
('created_reason', models.CharField(blank=True, max_length=100)),
('last_used_ip', models.GenericIPAddressField(blank=True, null=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
+22
View File
@@ -0,0 +1,22 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts', '0001_initial'),
('tickets', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='ticketmagiclink',
name='ticket',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='magic_links', to='tickets.ticket'),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 5.1.1 on 2026-09-08 09:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_initial'),
]
operations = [
migrations.CreateModel(
name='MagicLinkAccessLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('opened_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, max_length=500)),
],
options={
'verbose_name': 'بازدید لینک جادویی',
'verbose_name_plural': 'بازدیدهای لینک جادویی',
'ordering': ['-opened_at'],
},
),
migrations.AlterModelOptions(
name='ticketmagiclink',
options={'permissions': [('view_all_magic_links', 'Can audit magic links across all units')]},
),
migrations.AddField(
model_name='ticketmagiclink',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='created_magic_links', to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,45 @@
# Generated by Django 5.1.1 on 2026-09-08 09:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more'),
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
]
operations = [
migrations.AddField(
model_name='ticketmagiclink',
name='created_unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='magic_links', to='staff.organizationalunit'),
),
migrations.AddField(
model_name='ticketmagiclink',
name='last_user_agent',
field=models.CharField(blank=True, max_length=500),
),
migrations.AddField(
model_name='ticketmagiclink',
name='recipient_mobile',
field=models.CharField(blank=True, max_length=16),
),
migrations.AddField(
model_name='ticketmagiclink',
name='recipient_name',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='ticketmagiclink',
name='view_count',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='magiclinkaccesslog',
name='magic_link',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_logs', to='accounts.ticketmagiclink'),
),
]
@@ -0,0 +1,27 @@
# Generated by Django 5.1.1 on 2026-09-08 15:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_ticketmagiclink_created_unit_and_more'),
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
('tickets', '0006_assign_legacy_contacts_to_sales'),
]
operations = [
migrations.AddIndex(
model_name='magiclinkaccesslog',
index=models.Index(fields=['magic_link', 'opened_at'], name='magic_opened_idx'),
),
migrations.AddIndex(
model_name='ticketmagiclink',
index=models.Index(fields=['created_unit', 'created_at'], name='magic_unit_created_idx'),
),
migrations.AddIndex(
model_name='ticketmagiclink',
index=models.Index(fields=['expires_at', 'revoked_at'], name='magic_validity_idx'),
),
]
Whitespace-only changes.
+157
View File
@@ -0,0 +1,157 @@
import hashlib, secrets, string
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.db import IntegrityError, models, transaction
from django.utils import timezone
from apps.core.models import PublicIdModel, TimeStampedModel
def normalize_mobile(value: str) -> str:
value = "".join(c for c in (value or "").strip() if c.isdigit() or c == "+")
if value.startswith("0098"): value = "+98" + value[4:]
elif value.startswith("98") and not value.startswith("+98"): value = "+" + value
elif value.startswith("0"): value = "+98" + value[1:]
if not (value.startswith("+989") and len(value) == 13 and value[1:].isdigit()):
raise ValueError("شماره موبایل معتبر نیست.")
return value
class User(PublicIdModel, AbstractUser):
class AccountType(models.TextChoices):
CUSTOMER = "CUSTOMER", "مشتری"
STAFF = "STAFF", "کارشناس"
mobile = models.CharField(max_length=16, unique=True, null=True, blank=True)
account_type = models.CharField(max_length=10, choices=AccountType.choices, default=AccountType.CUSTOMER)
def save(self, *args, **kwargs):
if self.mobile: self.mobile = normalize_mobile(self.mobile)
super().save(*args, **kwargs)
@property
def local_mobile(self):
return f"0{self.mobile[3:]}" if self.mobile and self.mobile.startswith("+98") else (self.mobile or "")
class OTPChallenge(models.Model):
class Purpose(models.TextChoices): LOGIN = "LOGIN", "ورود"
mobile = models.CharField(max_length=16, db_index=True)
purpose = models.CharField(max_length=20, choices=Purpose.choices, default=Purpose.LOGIN)
code_hash = models.CharField(max_length=64)
expires_at = models.DateTimeField()
attempt_count = models.PositiveSmallIntegerField(default=0)
max_attempts = models.PositiveSmallIntegerField(default=5)
verified_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
request_ip = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.CharField(max_length=300, blank=True)
@staticmethod
def hash_code(code): return hashlib.sha256(f"{settings.SECRET_KEY}:{code}".encode()).hexdigest()
@classmethod
def issue(cls, mobile, request=None):
mobile = normalize_mobile(mobile)
request_ip = request.META.get("REMOTE_ADDR") if request else None
window = timezone.now() - timedelta(seconds=settings.OTP_RATE_LIMIT_WINDOW_SECONDS)
if cls.objects.filter(mobile=mobile, created_at__gte=window).count() >= settings.OTP_RATE_LIMIT_PER_MOBILE:
raise ValueError("تعداد درخواست کد برای این شماره بیش از حد مجاز است.")
if request_ip and cls.objects.filter(request_ip=request_ip, created_at__gte=window).count() >= settings.OTP_RATE_LIMIT_PER_IP:
raise ValueError("تعداد درخواست کد از این اتصال بیش از حد مجاز است.")
cutoff = timezone.now() - timedelta(seconds=settings.OTP_RESEND_SECONDS)
if cls.objects.filter(mobile=mobile, created_at__gt=cutoff).exists(): raise ValueError("برای ارسال مجدد کمی صبر کنید.")
is_dev_login = settings.DEBUG and mobile == settings.DEV_OTP_MOBILE and bool(settings.DEV_OTP_CODE)
code = settings.DEV_OTP_CODE if is_dev_login else "".join(secrets.choice("0123456789") for _ in range(settings.OTP_LENGTH))
obj = cls.objects.create(mobile=mobile, code_hash=cls.hash_code(code), expires_at=timezone.now()+timedelta(seconds=settings.OTP_EXPIRE_SECONDS), max_attempts=settings.OTP_MAX_ATTEMPTS, request_ip=request_ip, user_agent=(request.META.get("HTTP_USER_AGENT", "")[:300] if request else ""))
return obj, code
def verify(self, code):
if self.verified_at or timezone.now() >= self.expires_at or self.attempt_count >= self.max_attempts: return False
self.attempt_count += 1
ok = secrets.compare_digest(self.code_hash, self.hash_code(code))
if ok: self.verified_at = timezone.now()
self.save(update_fields=["attempt_count", "verified_at"])
return ok
class TicketMagicLink(models.Model):
ticket = models.ForeignKey("tickets.Ticket", on_delete=models.CASCADE, related_name="magic_links")
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
token_hash = models.CharField(max_length=64, unique=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="created_magic_links",
)
created_unit = models.ForeignKey(
"staff.OrganizationalUnit",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="magic_links",
)
recipient_name = models.CharField(max_length=200, blank=True)
recipient_mobile = models.CharField(max_length=16, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
revoked_at = models.DateTimeField(null=True, blank=True)
created_reason = models.CharField(max_length=100, blank=True)
last_used_ip = models.GenericIPAddressField(null=True, blank=True)
last_user_agent = models.CharField(max_length=500, blank=True)
view_count = models.PositiveIntegerField(default=0)
class Meta:
permissions = [("view_all_magic_links", "Can audit magic links across all units")]
indexes = [models.Index(fields=["created_unit", "created_at"], name="magic_unit_created_idx"), models.Index(fields=["expires_at", "revoked_at"], name="magic_validity_idx")]
@staticmethod
def digest(token): return hashlib.sha256(token.encode()).hexdigest()
@staticmethod
def generate_secure_token(length=10):
alphabet=string.ascii_lowercase+string.digits
return "".join(secrets.SystemRandom().choices(alphabet,k=length))
@classmethod
def issue_with_record(cls,ticket,reason="staff_reply",max_retries=5,created_by=None):
# Never add a preflight SELECT to token issuance; callers may select_related
# the profile when they need a populated recipient-name snapshot.
profile = ticket.customer._state.fields_cache.get("customer_profile")
recipient_name = " ".join(
value for value in [getattr(profile, "first_name", ""), getattr(profile, "last_name", "")] if value
).strip()
for attempt in range(max_retries):
token=cls.generate_secure_token(10)
try:
with transaction.atomic():
record=cls.objects.create(
ticket=ticket,
customer=ticket.customer,
token_hash=cls.digest(token),
expires_at=timezone.now()+timedelta(days=settings.MAGIC_LINK_TTL_DAYS),
created_reason=reason,
created_by=created_by or ticket.created_by,
created_unit=ticket.owning_unit,
recipient_name=recipient_name,
recipient_mobile=ticket.customer.mobile or "",
)
return token,record
except IntegrityError:
if attempt==max_retries-1: raise RuntimeError("خطای نادر: عدم موفقیت در تولید توکن یکتا.")
raise RuntimeError("خطای نادر: عدم موفقیت در تولید توکن یکتا.")
@classmethod
def issue(cls,ticket,reason="staff_reply"):
token,_=cls.issue_with_record(ticket,reason)
return token
class MagicLinkAccessLog(models.Model):
"""Immutable audit row for every valid magic-link opening."""
magic_link = models.ForeignKey(
TicketMagicLink,
on_delete=models.CASCADE,
related_name="access_logs",
)
opened_at = models.DateTimeField(auto_now_add=True, db_index=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.CharField(max_length=500, blank=True)
class Meta:
ordering = ["-opened_at"]
indexes = [models.Index(fields=["magic_link", "opened_at"], name="magic_opened_idx")]
verbose_name = "بازدید لینک جادویی"
verbose_name_plural = "بازدیدهای لینک جادویی"
def __str__(self):
return f"{self.magic_link_id} @ {self.opened_at}"
+4
View File
@@ -0,0 +1,4 @@
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [path("login/", views.customer_login, name="login"),path("login/password/",views.customer_password_login,name="password_login"), path("login/otp/", views.verify_otp, name="otp"),path("profile/",views.customer_profile,name="profile"), path("logout/", views.logout_view, name="logout"), path("staff/login/", views.staff_login, name="staff_login"), path("staff/logout/", views.logout_view, name="staff_logout"), path("t/<str:token>/", views.consume_magic_link, name="magic")]
+131
View File
@@ -0,0 +1,131 @@
from django.contrib import messages
from django.conf import settings
from django.contrib.auth import login, logout
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from django.views.decorators.http import require_POST
from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm
from apps.customers.models import CustomerProfile
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db.models import F
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
from .magic_access import grant_ticket_magic_access
from apps.integrations.sms.providers import SMSResult, get_sms_provider
import logging
logger = logging.getLogger(__name__)
def _authenticated_home(request):
if not request.user.is_authenticated: return None
return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "tickets:list")
@require_http_methods(["GET", "POST"])
def customer_login(request):
if response:=_authenticated_home(request): return response
form = MobileForm(request.POST or None)
if request.method == "POST" and form.is_valid():
challenge = None
try:
challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request)
is_fixed_dev_otp = settings.DEBUG and challenge.mobile == settings.DEV_OTP_MOBILE and code == settings.DEV_OTP_CODE
result = SMSResult(True, "fixed-dev-otp") if is_fixed_dev_otp else get_sms_provider().send_otp(challenge.mobile, code)
if not result.success:
raise RuntimeError(result.error or "سرویس پیامک درخواست را نپذیرفت.")
request.session["otp_challenge_id"] = challenge.pk
return redirect("accounts:otp")
except ValueError as exc: form.add_error(None, str(exc))
except Exception:
if challenge is not None:
challenge.delete()
logger.exception("OTP SMS delivery failed")
form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.")
return render(request, "accounts/customer_login.html", {"form": form})
@require_http_methods(["GET", "POST"])
def verify_otp(request):
if response:=_authenticated_home(request): return response
challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id"))
form = OTPForm(request.POST or None)
if request.method == "POST" and form.is_valid():
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})
if created: user.set_unusable_password(); user.save(update_fields=["password"])
CustomerProfile.objects.get_or_create(user=user)
if not user.is_active: form.add_error(None, "حساب شما غیرفعال است.")
else:
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
return redirect(request.session.pop("next", "tickets:list"))
else: form.add_error("code", "کد نامعتبر یا منقضی است.")
return render(request, "accounts/otp.html", {"form": form, "mobile": challenge.mobile})
@require_http_methods(["GET","POST"])
def customer_password_login(request):
if response:=_authenticated_home(request): return response
form=CustomerPasswordLoginForm(request.POST or None)
if request.method=="POST" and form.is_valid():
user=form.authenticate(request)
if user and user.is_active:
login(request,user); return redirect(request.session.pop("next","tickets:list"))
form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.")
return render(request,"accounts/customer_password_login.html",{"form":form})
@login_required
@require_http_methods(["GET","POST"])
def customer_profile(request):
if request.user.account_type!=User.AccountType.CUSTOMER: return redirect("staff_tickets:list")
profile,_=CustomerProfile.objects.get_or_create(user=request.user)
form=CustomerProfileForm(request.POST or None,instance=profile)
if request.method=="POST" and form.is_valid():
form.save(); password=form.cleaned_data.get("password")
if password: request.user.set_password(password); request.user.save(update_fields=["password"]); login(request,request.user,backend="django.contrib.auth.backends.ModelBackend")
messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile")
return render(request,"accounts/profile.html",{"form":form})
@require_http_methods(["GET", "POST"])
def staff_login(request):
if response:=_authenticated_home(request): return response
form = StaffLoginForm(request.POST or None)
if request.method == "POST" and form.is_valid():
user = form.authenticate(request)
if user and user.is_active and user.account_type == User.AccountType.STAFF:
login(request, user); return redirect("staff_tickets:list")
form.add_error(None, "اطلاعات ورود صحیح نیست.")
return render(request, "accounts/staff_login.html", {"form": form})
@require_POST
def logout_view(request):
logout(request)
request.session.flush()
response=redirect("accounts:login")
response["Cache-Control"]="no-store"
return response
def consume_magic_link(request, token):
link = TicketMagicLink.objects.select_related("customer", "ticket").filter(token_hash=TicketMagicLink.digest(token)).first()
if not link or link.revoked_at or link.expires_at <= timezone.now():
request.session["next"] = f"/tickets/{link.ticket.public_id}/" if link else "/tickets/"
messages.error(request, "این لینک منقضی شده یا اشتباه است. لطفاً با شماره موبایل خود وارد شوید تا بتوانید پیام‌ها را ببینید.")
return redirect("accounts:login")
if request.user.is_authenticated:
if request.user.pk != link.customer_id:
from django.core.exceptions import PermissionDenied
raise PermissionDenied
else:
grant_ticket_magic_access(request, link)
ip_address = request.META.get("REMOTE_ADDR")
user_agent = request.META.get("HTTP_USER_AGENT", "")[:500]
with transaction.atomic():
MagicLinkAccessLog.objects.create(
magic_link=link,
ip_address=ip_address,
user_agent=user_agent,
)
TicketMagicLink.objects.filter(pk=link.pk).update(
used_at=timezone.now(),
last_used_ip=ip_address,
last_user_agent=user_agent,
view_count=F("view_count") + 1,
)
response = redirect("tickets:detail", public_id=link.ticket.public_id); response["Referrer-Policy"] = "no-referrer"; return response