improve registration flows
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 5s
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 5s
This commit is contained in:
+231
-18
@@ -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}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user