improve registration flows
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 5s

This commit is contained in:
2026-08-04 01:11:20 +05:00
parent 9d8b5cd26f
commit c1f661cdb6
14 changed files with 485 additions and 21 deletions
+2
View File
@@ -64,3 +64,5 @@ TG_CHAT_ID=""
# Optional forum topic id. If empty/unset, message_thread_id is omitted and
# messages post to the group's General topic.
TG_TOPIC_ID=""
# Public frontend base URL used in SMS/Telegram links
FRONTEND_URL=https://portal.sarlink.net
+3 -1
View File
@@ -163,4 +163,6 @@ cython_debug/
#staticfiles
staticfiles/
postgres_data/
media/
media/
# Uploaded ID/passport photos (bind-mounted volume in production)
storage/
@@ -0,0 +1,66 @@
# Generated by Django 5.2 on 2026-08-03 17:40
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0018_user_agreement"),
]
operations = [
migrations.AddField(
model_name="user",
name="id_card_photo",
field=models.ImageField(
blank=True,
help_text="ID card / passport photo uploaded by the user for manual review.",
null=True,
upload_to="id_cards/",
),
),
migrations.AddField(
model_name="user",
name="status",
field=models.CharField(
choices=[
("pending", "Pending"),
("verified", "Verified"),
("id_required", "ID required"),
("id_submitted", "ID submitted"),
],
db_index=True,
default="pending",
max_length=20,
),
),
migrations.CreateModel(
name="IdUploadToken",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("key", models.CharField(db_index=True, max_length=64, unique=True)),
("created_at", models.DateTimeField(default=django.utils.timezone.now)),
("expires_at", models.DateTimeField()),
("used_at", models.DateTimeField(blank=True, null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="id_upload_tokens",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2 on 2026-08-03 18:00
import api.storages
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0019_user_id_card_photo_user_status_iduploadtoken"),
]
operations = [
migrations.AlterField(
model_name="user",
name="id_card_photo",
field=models.ImageField(
blank=True,
help_text="ID card / passport photo uploaded by the user for manual review.",
null=True,
storage=api.storages.idcard_storage,
upload_to="",
),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2 on 2026-08-03 18:24
import api.storages
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0020_alter_user_id_card_photo"),
]
operations = [
migrations.AlterField(
model_name="user",
name="id_card_photo",
field=models.FileField(
blank=True,
help_text="ID card / passport (JPG, PNG or PDF) uploaded by the user for manual review.",
null=True,
storage=api.storages.idcard_storage,
upload_to="",
),
),
]
+65
View File
@@ -2,16 +2,31 @@
This is the models module for api.
"""
import secrets
from datetime import timedelta
from django.contrib.auth.models import AbstractUser
from django.db import models
from .managers import CustomUserManager
from .storages import idcard_storage
from django.utils import timezone
import pyotp
from billing.models import WalletTransaction
class User(AbstractUser):
# Registration workflow states. `verified` (below) stays the login gate and
# is kept in sync: verified is True iff status == STATUS_VERIFIED.
STATUS_PENDING = "pending" # created, awaiting/undergoing auto-verify
STATUS_VERIFIED = "verified" # approved, may log in
STATUS_ID_REQUIRED = "id_required" # must (re)upload an ID/passport photo
STATUS_ID_SUBMITTED = "id_submitted" # photo uploaded, awaiting admin review
STATUS_CHOICES = [
(STATUS_PENDING, "Pending"),
(STATUS_VERIFIED, "Verified"),
(STATUS_ID_REQUIRED, "ID required"),
(STATUS_ID_SUBMITTED, "ID submitted"),
]
address = models.CharField(max_length=255, blank=True)
email = models.EmailField(blank=True, null=True, unique=True)
mobile = models.CharField(
@@ -23,6 +38,16 @@ class User(AbstractUser):
max_length=255, blank=True, unique=True, null=True, db_index=True
)
verified = models.BooleanField(default=False)
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING, db_index=True
)
id_card_photo = models.FileField(
upload_to="",
storage=idcard_storage,
blank=True,
null=True,
help_text="ID card / passport (JPG, PNG or PDF) uploaded by the user for manual review.",
)
is_admin = models.BooleanField(default=False)
dob = models.DateField(blank=True, null=True)
terms_accepted = models.BooleanField(default=False)
@@ -79,6 +104,46 @@ class User(AbstractUser):
# by createsuperuser.
REQUIRED_FIELDS = []
def set_status(self, status, *, save=True):
"""Set workflow status and keep the `verified` login gate in sync."""
self.status = status
self.verified = status == self.STATUS_VERIFIED
if save:
self.save(update_fields=["status", "verified", "updated_at"])
class IdUploadToken(models.Model):
"""
One-time-ish magic-link token letting an unverified user reach the public
ID-upload page without logging in. Issuing a fresh token supersedes any
outstanding ones for the same user.
"""
key = models.CharField(max_length=64, unique=True, db_index=True)
user = models.ForeignKey(
"User", on_delete=models.CASCADE, related_name="id_upload_tokens"
)
created_at = models.DateTimeField(default=timezone.now)
expires_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
@classmethod
def issue(cls, user, ttl_hours=48):
cls.objects.filter(user=user, used_at__isnull=True).update(
used_at=timezone.now()
)
return cls.objects.create(
key=secrets.token_urlsafe(32),
user=user,
expires_at=timezone.now() + timedelta(hours=ttl_hours),
)
def is_valid(self):
return self.used_at is None and self.expires_at > timezone.now()
def __str__(self) -> str:
return f"IdUploadToken(user={self.user_id}, valid={self.is_valid()})"
class TemporaryUser(models.Model):
t_id = models.AutoField(primary_key=True)
+2
View File
@@ -121,6 +121,8 @@ class CustomReadOnlyUserSerializer(serializers.ModelSerializer):
"id_card",
"agreement",
"wallet_balance",
"status",
"id_card_photo",
)
depth = 1
+26
View File
@@ -0,0 +1,26 @@
"""Storage backends for the api app."""
from django.conf import settings
from django.core.files.storage import FileSystemStorage
class IdCardStorage(FileSystemStorage):
"""Filesystem storage for uploaded ID/passport photos.
Location/URL come from settings (IDCARD_STORAGE_ROOT / IDCARD_STORAGE_URL)
so the directory can be bind-mounted as a persistent volume in production.
"""
def __init__(self, **kwargs):
kwargs.setdefault("location", settings.IDCARD_STORAGE_ROOT)
kwargs.setdefault("base_url", settings.IDCARD_STORAGE_URL)
super().__init__(**kwargs)
def idcard_storage():
"""Callable referenced by the model field.
Using a named callable keeps the resolved location out of migrations, so
changing the mount path never requires a new migration.
"""
return IdCardStorage()
+3 -2
View File
@@ -180,15 +180,16 @@ def verify_user_with_person_api_task(user_id: int):
f"{(t_user.t_island.name if t_user.t_island else 'N/A')}"
)
frontend_url = env.str("FRONTEND_URL", default="https://portal.sarlink.net")
verification_failed_message = (
f"⚠️ *User verification failed*\n\n{user_details}\n"
f"Visit [SAR Link Portal](https://portal.sarlink.net/users/{user_id}/details) "
f"Visit [SAR Link Portal]({frontend_url}/users/{user_id}/details) "
f"to manually verify this user."
)
verification_success_message = (
f"✅ *New user registered and verified*\n\n{user_details}\n"
f"View on [SAR Link Portal](https://portal.sarlink.net/users/{user_id}/details)."
f"View on [SAR Link Portal]({frontend_url}/users/{user_id}/details)."
)
def _run(coro) -> None:
+25
View File
@@ -18,9 +18,14 @@ from .views import (
filter_user,
filter_temporary_user,
VerifyOTPView,
ResendRegistrationOTPView,
UserVerifyAPIView,
UserUpdateAPIView,
UserRejectAPIView,
RequestIdUploadAPIView,
IdUploadTokenInfoAPIView,
IdUploadStartAPIView,
IdUploadSubmitAPIView,
AgreementUpdateAPIView,
PersonVerifyAPIView,
)
@@ -29,6 +34,11 @@ from .views import (
urlpatterns = [
path("register/", CreateTemporaryUserView.as_view(), name="register"),
path("register/verify/", VerifyOTPView.as_view(), name="verify-otp"),
path(
"register/resend-otp/",
ResendRegistrationOTPView.as_view(),
name="resend-registration-otp",
),
path("profile/", UserprofileAPIView.as_view(), name="profile"),
path("login/", LoginView.as_view(), name="knox_login"),
path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"),
@@ -48,6 +58,21 @@ urlpatterns = [
name="user-agreement-update",
),
path("users/<int:pk>/reject/", UserRejectAPIView.as_view(), name="user-reject"),
path(
"users/<int:pk>/request-id-card/",
RequestIdUploadAPIView.as_view(),
name="user-request-id-card",
),
# Public, unauthenticated self-service ID-upload flow (single-use magic link)
path(
"id-upload/token/", IdUploadTokenInfoAPIView.as_view(), name="id-upload-token"
),
path(
"id-upload/start/", IdUploadStartAPIView.as_view(), name="id-upload-start"
),
path(
"id-upload/submit/", IdUploadSubmitAPIView.as_view(), name="id-upload-submit"
),
path("person/<str:id_card>/", PersonVerifyAPIView.as_view(), name="person-verify"),
path("healthcheck/", healthcheck, name="healthcheck"),
path("atolls/", ListAtollView.as_view(), name="atolls"),
+231 -18
View File
@@ -8,12 +8,15 @@ from rest_framework.authtoken.serializers import AuthTokenSerializer
from api.filters import UserFilter
from api.mixins import StaffEditorPermissionMixin
from api.permissions import IsAdminOrStaffPermission, user_is_admin
from api.models import User, Atoll, Island, TemporaryUser
from api.models import User, Atoll, Island, TemporaryUser, IdUploadToken
from api.notifications import send_sms
from rest_framework.response import Response
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.decorators import api_view, permission_classes
from decouple import config
import os
from api.serializers import (
AtollSerializer,
IslandSerializer,
@@ -54,6 +57,48 @@ def healthcheck(request):
return Response({"status": "Good"}, status=status.HTTP_200_OK)
FRONTEND_URL = config("FRONTEND_URL", default="https://portal.sarlink.net")
# File types accepted for the ID card / passport upload.
ID_PHOTO_CONTENT_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"application/pdf",
]
# Default editable body of the "please upload your ID" SMS. The greeting
# ("Dear <name>,"), the secure upload link, and the "- SAR Link" signature are
# always added by request_id_upload() and are NOT part of the editable body.
DEFAULT_ID_UPLOAD_BODY = (
"We're sorry, but your SAR Link account registration could not be "
"approved automatically.\n\n"
"Please upload a clear photo of your ID card / passport at the "
"link below."
)
def request_id_upload(user, message_body=""):
"""
Mark a user as needing an ID/passport photo, mint a fresh magic-link token,
and SMS them the upload link. Used both by the automatic auto-verify-fail
path and by the admin "Request ID Upload" action.
`message_body` is the admin-editable middle of the message; when blank the
default body is used. The greeting, the one-time upload link, and the
signature are always appended here so the link is never exposed to the admin.
"""
token = IdUploadToken.issue(user)
user.set_status(User.STATUS_ID_REQUIRED)
full_name = f"{user.first_name} {user.last_name}".strip() or "Customer"
link = f"{FRONTEND_URL}/upload-id?token={token.key}"
body = (message_body or "").strip() or DEFAULT_ID_UPLOAD_BODY
message = f"Dear {full_name},\n\n{body}\n\n{link}\n\n- SAR Link"
if user.mobile:
send_sms(mobile=user.mobile, message=message)
return token
class CreateTemporaryUserView(generics.CreateAPIView):
serializer_class = TemporaryUserSerializer
@@ -173,7 +218,7 @@ class VerifyOTPView(generics.GenericAPIView):
return Response({"message": "Invalid OTP."}, status=400)
# Create real user
User.objects.create_user(
user = User.objects.create_user(
first_name=temp_user.t_first_name,
last_name=temp_user.t_last_name,
username=str(temp_user.t_username),
@@ -190,29 +235,73 @@ class VerifyOTPView(generics.GenericAPIView):
policy_accepted=temp_user.t_policy_accepted,
)
if temp_user.t_verified:
send_sms(
t_user.t_mobile,
f"Dear {temp_user.t_first_name} {temp_user.t_last_name}, \n\nYour account has been successfully verified. \n\nYou can now manage your devices and make payments through our portal at https://portal.sarlink.net. \n\n - SAR Link",
)
else:
send_sms(
t_user.t_mobile,
f"Dear {t_user.t_first_name} {t_user.t_last_name}, \n\nYour account registration is being processed. \n\nWe will notify you once verification is complete. \n\n - SAR Link",
)
# You can now trigger registry verification as a signal or task
temp_user.otp_verified = True
temp_user.save()
if temp_user.t_verified:
user.set_status(User.STATUS_VERIFIED)
send_sms(
t_user.t_mobile,
f"Dear {temp_user.t_first_name} {temp_user.t_last_name}, \n\nYour account has been successfully verified. \n\nYou can now manage your devices and make payments through our portal at {FRONTEND_URL}. \n\n - SAR Link",
)
return Response(
{
"message": "User created successfully.",
"verified": True,
"status": user.status,
}
)
# Auto-verification failed -> ask the user to upload their ID/passport.
token = request_id_upload(user)
return Response(
{
"message": "User created successfully.",
"verified": temp_user.t_verified
"verified": False,
"status": user.status,
"upload_token": token.key,
}
)
class ResendRegistrationOTPView(generics.GenericAPIView):
"""Resend the registration OTP for a pending (not-yet-verified) signup.
Handles the "registered but closed the browser before entering the OTP"
case: logging in again finds the TemporaryUser and resends a fresh code
instead of dead-ending at the signup form (which rejects the existing
mobile as already taken).
"""
permission_classes = (permissions.AllowAny,)
throttle_classes = []
def post(self, request, *args, **kwargs):
mobile = request.data.get("mobile", "")
t_user = TemporaryUser.objects.filter(t_mobile=mobile).first()
if (
not t_user
or t_user.otp_verified
or User.objects.filter(mobile=mobile).exists()
):
return Response(
{"message": "No pending registration for this number."},
status=status.HTTP_400_BAD_REQUEST,
)
# Reset the OTP validity window (is_expired() is based on created_at)
# and resend a fresh code.
t_user.created_at = timezone.now()
t_user.save(update_fields=["created_at"])
otp = t_user.generate_otp()
otp_expiry = timezone.now() + timezone.timedelta(minutes=3)
formatted_time = otp_expiry.strftime("%d/%m/%Y %H:%M:%S")
send_otp(
str(t_user.t_mobile),
f"Your Registration SARLink OTP: {otp}. \nExpires at {formatted_time}. \n\n- SAR Link",
)
return Response({"message": "OTP resent.", "t_username": t_user.t_username})
class LoginView(KnoxLoginView):
# login view extending KnoxLoginView
serializer_class = AuthSerializer
@@ -447,16 +536,140 @@ class UserRejectAPIView(generics.DestroyAPIView):
{"message": "You are not authorized to reject this user."},
status=status.HTTP_403_FORBIDDEN,
)
full_name = f"{user.first_name} {user.last_name}".strip() or "Customer"
rejection_message = (
f"Dear {full_name}, \n\n"
"We're sorry, but your SAR Link account registration could not be "
"approved at this time. \n\n"
f"Reason: {rejection_details} \n\n"
"Register again at "
f"{FRONTEND_URL}, or contact us for assistance. \n\n"
" - SAR Link"
)
user.delete()
t_user = get_object_or_404(TemporaryUser, t_mobile=user.mobile)
t_user.delete()
send_sms(message=rejection_details, mobile=mobile_number)
send_sms(message=rejection_message, mobile=mobile_number)
return Response(
{"message": "User successfully rejected."},
status=status.HTTP_204_NO_CONTENT,
)
class RequestIdUploadAPIView(generics.GenericAPIView):
"""Admin action: ask a user to (re)upload their ID/passport photo."""
permission_classes = [IsAdminOrStaffPermission]
queryset = User.objects.all()
lookup_field = "pk"
def post(self, request, *args, **kwargs):
user = get_object_or_404(User, pk=kwargs.get("pk"))
if user.is_superuser:
return Response(
{"message": "You cannot modify a superuser."},
status=status.HTTP_403_FORBIDDEN,
)
if not user.mobile:
return Response(
{"message": "User does not have a mobile number."},
status=status.HTTP_400_BAD_REQUEST,
)
message_body = request.data.get("message", "")
request_id_upload(user, message_body=message_body)
return Response({"message": "ID upload request sent to the user."})
class IdUploadTokenInfoAPIView(generics.GenericAPIView):
"""Public: validate a magic-link token and return whose upload it is."""
permission_classes = [permissions.AllowAny]
def get(self, request, *args, **kwargs):
key = request.query_params.get("token", "")
token = IdUploadToken.objects.filter(key=key).select_related("user").first()
if not token or not token.is_valid():
return Response(
{"message": "This upload link is invalid or has expired."},
status=status.HTTP_400_BAD_REQUEST,
)
user = token.user
return Response(
{
"first_name": user.first_name,
"last_name": user.last_name,
"status": user.status,
}
)
class IdUploadStartAPIView(generics.GenericAPIView):
"""Public: mint a fresh upload token for a mobile whose account needs an ID.
Lets the login flow send an `id_required` user straight to the upload page
instead of a "pending verification" dead-end. Only works for accounts
actually in the id_required state.
"""
permission_classes = [permissions.AllowAny]
def post(self, request, *args, **kwargs):
mobile = request.data.get("mobile", "")
user = User.objects.filter(mobile=mobile).first()
if not user or user.status != User.STATUS_ID_REQUIRED:
return Response(
{"message": "No ID upload is pending for this number."},
status=status.HTTP_400_BAD_REQUEST,
)
token = IdUploadToken.issue(user)
return Response({"token": token.key})
class IdUploadSubmitAPIView(generics.GenericAPIView):
"""Public: accept the ID/passport photo for a valid token."""
permission_classes = [permissions.AllowAny]
parser_classes = [MultiPartParser, FormParser]
def post(self, request, *args, **kwargs):
key = request.data.get("token", "")
token = IdUploadToken.objects.filter(key=key).select_related("user").first()
if not token or not token.is_valid():
return Response(
{"message": "This upload link is invalid or has expired."},
status=status.HTTP_400_BAD_REQUEST,
)
photo = request.data.get("id_card_photo")
if not photo:
return Response(
{"message": "An ID card / passport photo is required."},
status=status.HTTP_400_BAD_REQUEST,
)
if photo.size > 10 * 1024 * 1024:
return Response(
{"message": "File size exceeds the 10 MB limit."},
status=status.HTTP_400_BAD_REQUEST,
)
if getattr(photo, "content_type", "") not in ID_PHOTO_CONTENT_TYPES:
return Response(
{
"message": "Invalid file type. Please upload a JPG, PNG or PDF file."
},
status=status.HTTP_400_BAD_REQUEST,
)
user = token.user
ext = os.path.splitext(photo.name)[1].lower() or ".jpg"
photo.name = f"{uuid.uuid4()}_{user.id}_id_card{ext}"
user.id_card_photo = photo
user.set_status(User.STATUS_ID_SUBMITTED, save=False)
user.save()
# Single-use: burn the token so the link can't be reused for another
# upload. A fresh link is minted if an admin requests another upload.
token.used_at = timezone.now()
token.save(update_fields=["used_at"])
return Response({"message": "Your ID has been submitted for review."})
@api_view(["GET"])
def filter_user(request):
id_card = request.GET.get("id_card", "").strip() or None
@@ -473,13 +686,13 @@ def filter_user(request):
elif mobile:
filters = Q(mobile=mobile)
user = User.objects.only("id", "verified").filter(filters).first()
user = User.objects.only("id", "verified", "status").filter(filters).first()
print(f"Querying with filters: {filters}")
print(f"Found user: {user}")
return Response(
{"ok": True, "verified": user.verified}
{"ok": True, "verified": user.verified, "status": user.status}
if user
else {"ok": False, "verified": False}
)
+8
View File
@@ -201,6 +201,14 @@ STATICFILES_DIRS = [
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
# Uploaded ID/passport photos live in a dedicated directory so it can be
# bind-mounted as a persistent volume in production. Override with the
# IDCARD_STORAGE_ROOT / IDCARD_STORAGE_URL env vars to point at the mount.
IDCARD_STORAGE_ROOT = os.environ.get(
"IDCARD_STORAGE_ROOT", os.path.join(BASE_DIR, "storage", "idcards")
)
IDCARD_STORAGE_URL = os.environ.get("IDCARD_STORAGE_URL", "/storage/idcards/")
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
+3
View File
@@ -37,6 +37,9 @@ urlpatterns = [
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(
settings.IDCARD_STORAGE_URL, document_root=settings.IDCARD_STORAGE_ROOT
)
urlpatterns += (path("api-auth/", include("rest_framework.urls")),)
urlpatterns += (path("__debug__/", include("debug_toolbar.urls")),)
urlpatterns += (path("api/schema/", SpectacularAPIView.as_view(), name="schema"),)
+3
View File
@@ -0,0 +1,3 @@
"We're sorry, but your SAR Link account registration could not be approved automatically. \n
f{note} or if empty "Please upload a clear photo of your ID card / passport here: \n"