From c1f661cdb69d0ea00509941084b9e08e6592e74d Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Tue, 4 Aug 2026 01:11:20 +0500 Subject: [PATCH] improve registration flows --- .env.example | 2 + .gitignore | 4 +- ...id_card_photo_user_status_iduploadtoken.py | 66 +++++ .../0020_alter_user_id_card_photo.py | 24 ++ .../0021_alter_user_id_card_photo.py | 24 ++ api/models.py | 65 +++++ api/serializers.py | 2 + api/storages.py | 26 ++ api/tasks.py | 5 +- api/urls.py | 25 ++ api/views.py | 249 ++++++++++++++++-- apibase/settings.py | 8 + apibase/urls.py | 3 + nano.2775299.save | 3 + 14 files changed, 485 insertions(+), 21 deletions(-) create mode 100644 api/migrations/0019_user_id_card_photo_user_status_iduploadtoken.py create mode 100644 api/migrations/0020_alter_user_id_card_photo.py create mode 100644 api/migrations/0021_alter_user_id_card_photo.py create mode 100644 api/storages.py create mode 100644 nano.2775299.save diff --git a/.env.example b/.env.example index 218b65f..942d7b2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 50ff047..51c35f5 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,6 @@ cython_debug/ #staticfiles staticfiles/ postgres_data/ -media/ \ No newline at end of file +media/ +# Uploaded ID/passport photos (bind-mounted volume in production) +storage/ \ No newline at end of file diff --git a/api/migrations/0019_user_id_card_photo_user_status_iduploadtoken.py b/api/migrations/0019_user_id_card_photo_user_status_iduploadtoken.py new file mode 100644 index 0000000..3282115 --- /dev/null +++ b/api/migrations/0019_user_id_card_photo_user_status_iduploadtoken.py @@ -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, + ), + ), + ], + ), + ] diff --git a/api/migrations/0020_alter_user_id_card_photo.py b/api/migrations/0020_alter_user_id_card_photo.py new file mode 100644 index 0000000..373b805 --- /dev/null +++ b/api/migrations/0020_alter_user_id_card_photo.py @@ -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="", + ), + ), + ] diff --git a/api/migrations/0021_alter_user_id_card_photo.py b/api/migrations/0021_alter_user_id_card_photo.py new file mode 100644 index 0000000..cf7627e --- /dev/null +++ b/api/migrations/0021_alter_user_id_card_photo.py @@ -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="", + ), + ), + ] diff --git a/api/models.py b/api/models.py index 1823e6b..09b958f 100644 --- a/api/models.py +++ b/api/models.py @@ -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) diff --git a/api/serializers.py b/api/serializers.py index d299ee2..8307905 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -121,6 +121,8 @@ class CustomReadOnlyUserSerializer(serializers.ModelSerializer): "id_card", "agreement", "wallet_balance", + "status", + "id_card_photo", ) depth = 1 diff --git a/api/storages.py b/api/storages.py new file mode 100644 index 0000000..86a3e31 --- /dev/null +++ b/api/storages.py @@ -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() diff --git a/api/tasks.py b/api/tasks.py index 6b3d8d8..751b0cc 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -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: diff --git a/api/urls.py b/api/urls.py index f4918a9..9fe33a3 100644 --- a/api/urls.py +++ b/api/urls.py @@ -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//reject/", UserRejectAPIView.as_view(), name="user-reject"), + path( + "users//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//", PersonVerifyAPIView.as_view(), name="person-verify"), path("healthcheck/", healthcheck, name="healthcheck"), path("atolls/", ListAtollView.as_view(), name="atolls"), diff --git a/api/views.py b/api/views.py index 7bce8fa..5fad265 100644 --- a/api/views.py +++ b/api/views.py @@ -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 ,"), 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} ) diff --git a/apibase/settings.py b/apibase/settings.py index cdbb360..7593671 100644 --- a/apibase/settings.py +++ b/apibase/settings.py @@ -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 diff --git a/apibase/urls.py b/apibase/urls.py index 3bdc0c8..2429495 100644 --- a/apibase/urls.py +++ b/apibase/urls.py @@ -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"),) diff --git a/nano.2775299.save b/nano.2775299.save new file mode 100644 index 0000000..05fb169 --- /dev/null +++ b/nano.2775299.save @@ -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" +