first
This commit is contained in:
commit
fa9d154db9
260 files changed
+17643
No files matched your search
Whitespace-only changes.
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
from django.apps import AppConfig
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "apps.accounts"
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
+38
@@ -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.
@@ -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}"
|
||||
@@ -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")]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user