11 Commits
Author SHA1 Message Date
shihaam c1f661cdb6 improve registration flows
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 5s
2026-08-04 01:11:20 +05:00
shihaam 9d8b5cd26f fix admin user persm
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 5s
2026-08-03 21:44:55 +05:00
shihaam 192f987125 handle service error
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 12s
2026-08-02 19:58:00 +05:00
shihaam 3397f212b5 handle payment erros better
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 6s
2026-08-02 15:11:46 +05:00
shihaam 1ea2f9f4c2 update sign up notifications
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 9s
2026-08-02 14:54:00 +05:00
shihaam 1f5e60dbb4 remove more email related code
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 8s
2026-08-02 14:32:10 +05:00
shihaam 00d25698c6 remove email related code
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 9s
2026-08-02 14:25:41 +05:00
shihaam 6cdf042f49 add support for group topics tg notifications
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 8s
2026-08-02 06:40:11 +05:00
shihaam 2db0369d7c update sms api for smspipe
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 9s
2026-08-02 06:04:20 +05:00
shihaam c414116ed5 update env example
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 12s
2026-08-02 05:59:19 +05:00
shihaam 4bf75fdfe4 update compose for monorepo
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 28s
2026-08-02 05:10:30 +05:00
38 changed files with 944 additions and 446 deletions
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Source is bind-mounted at runtime; (re)install deps on start so
# requirements.txt changes are picked up without a rebuild, then run the
# Django autoreloading dev server.
CMD pip install --no-cache-dir -r requirements.txt \
&& python manage.py migrate \
&& python manage.py runserver 0.0.0.0:5000
-13
View File
@@ -1,13 +0,0 @@
FROM python:3.11.4-slim-buster
WORKDIR /var/www/html/
COPY . .
RUN pip install -r requirements.txt
RUN python3 manage.py collectstatic
VOLUME /var/www/html/staticfiles/
CMD gunicorn apibase.wsgi:application --bind 0.0.0.0:5000 --workers=4
-13
View File
@@ -1,13 +0,0 @@
services:
api:
build:
context: ../../
dockerfile: .build/prod/api.Dockerfile
hostname: sarlink-portal-api
image: git.shihaam.dev/sarlink/sarlink-portal-api/api
nginx:
build:
context: .
dockerfile: ./nginx.Dockerfile
hostname: sarlink-portal-api-nginx
image: git.shihaam.dev/sarlink/sarlink-portal-api/nginx
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
if [ "$DATABASE" = "postgres" ]
then
echo "Waiting for postgres..."
while ! nc -z $POSTGRES_HOST $POSTGRES_PORT; do
sleep 0.1
done
echo "PostgreSQL started"
fi
exec "$@"
-11
View File
@@ -1,11 +0,0 @@
FROM nginx
# Install basic tools
RUN apt update \
&& apt install curl nano iputils-ping zip unzip -y --no-install-recommends \
&& apt auto-remove -y \
&& apt clean -y
COPY nginx.conf /etc/nginx/conf.d/default.conf
WORKDIR /etc/nginx
-26
View File
@@ -1,26 +0,0 @@
server {
listen 80;
server_name _;
access_log /dev/stdout;
error_log /dev/stdout info;
# Serve static files
location /static/ {
alias /var/www/html/staticfiles/;
}
location /media/ {
alias /var/www/html/media/;
}
# Forward requests to Gunicorn
location / {
proxy_pass http://portal-api:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 6M;
}
}
+61 -19
View File
@@ -1,26 +1,68 @@
CSRF_ALLOWED_HOST="" # =============================================================================
DJANGO_DEBUG= # Django
# =============================================================================
SECRET_KEY="" SECRET_KEY=""
POSTGRES_DATABASE= # DEBUG defaults to True if unset. Set False in production.
POSTGRES_USER= DJANGO_DEBUG=True
POSTGRES_PASSWORD= # Comma-separated. Include the backend service name for the compose network.
POSTGRES_HOST= ALLOWED_HOSTS="localhost,127.0.0.1,backend"
POSTGRES_PORT=
# --- Only read when DJANGO_DEBUG=False (production) ---
DJANGO_SECURE_SSL_REDIRECT= DJANGO_SECURE_SSL_REDIRECT=
ALLOWED_HOSTS="" SECURE_HSTS_SECONDS=
# Comma-separated absolute origins, e.g. https://portal.sarlink.net
CSRF_TRUSTED_ORIGINS="" CSRF_TRUSTED_ORIGINS=""
CSRF_COOKIE_DOMAIN=""
EMAIL_HOSTNAME="" # =============================================================================
EMAIL_PORT= # Database (PostgreSQL) — matches the `database` service in compose.yml
EMAIL_USERNAME= # =============================================================================
EMAIL_PASSWORD= POSTGRES_DATABASE=sarlink
POSTGRES_USER=sarlink
POSTGRES_PASSWORD=changeme
POSTGRES_HOST=database
POSTGRES_PORT=5432
# =============================================================================
# Redis — currently optional (CACHES is disabled in settings.py)
# =============================================================================
REDIS_HOST=redis
OMADA_PROXY_URL="" # =============================================================================
PAYMENT_BASE_URL="" # SMS gateway — OTP + notifications
PERSON_VERIFY_BASE_URL="" # =============================================================================
FRONTEND_URL=""
SMS_API_KEY=""
SMS_API_URL="" SMS_API_URL=""
SMS_API_KEY=""
# =============================================================================
# ID / person verification — GET {PERSON_VERIFY_BASE_URL}/api/person/{id_card}
# =============================================================================
PERSON_VERIFY_BASE_URL=""
# =============================================================================
# MIB payments — POST {PAYMENT_BASE_URL}/verify-payment
# =============================================================================
PAYMENT_BASE_URL=""
# =============================================================================
# Omada (TP-Link) proxy — device access control (block/unblock)
# =============================================================================
OMADA_PROXY_URL=""
OMADA_PROXY_API_KEY=""
OMADA_SITE_ID=""
OMADA_GROUP_ID=""
# =============================================================================
# MAC vendor lookup — REQUIRED, no default: the "add device" flow errors if unset
# =============================================================================
MACVENDOR_API_URL=""
# =============================================================================
# Telegram bot — admin alerts (optional; falls back to no-op if unset)
# =============================================================================
TG_BOT_TOKEN=""
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
+2
View File
@@ -164,3 +164,5 @@ cython_debug/
staticfiles/ staticfiles/
postgres_data/ postgres_data/
media/ media/
# Uploaded ID/passport photos (bind-mounted volume in production)
storage/
-17
View File
@@ -1,17 +0,0 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
class EmailBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
UserModel = get_user_model()
if not password:
return None
try:
user = UserModel.objects.get(email=username)
except UserModel.DoesNotExist:
return None
else:
if user.check_password(password):
return user
return None
+26
View File
@@ -12,6 +12,8 @@ telegram_loop = None
BOT_TOKEN = config("TG_BOT_TOKEN", default="killme", cast=str) BOT_TOKEN = config("TG_BOT_TOKEN", default="killme", cast=str)
CHAT_ID = config("TG_CHAT_ID", default="drake", cast=str) CHAT_ID = config("TG_CHAT_ID", default="drake", cast=str)
# Optional forum topic to post into. If empty, messages go to the group's General topic.
TOPIC_ID = config("TG_TOPIC_ID", default="", cast=str)
if not BOT_TOKEN or not isinstance(BOT_TOKEN, str): if not BOT_TOKEN or not isinstance(BOT_TOKEN, str):
raise ValueError( raise ValueError(
@@ -57,10 +59,34 @@ else:
async def send_telegram_alert(markdown_message: str): async def send_telegram_alert(markdown_message: str):
logger.info("[TELEGRAM] Preparing to send alert...") logger.info("[TELEGRAM] Preparing to send alert...")
kwargs = {}
if TOPIC_ID:
kwargs["message_thread_id"] = int(TOPIC_ID)
await bot.send_message( await bot.send_message(
chat_id=str(CHAT_ID), chat_id=str(CHAT_ID),
text=markdown_message, text=markdown_message,
parse_mode=ParseMode.MARKDOWN_V2, parse_mode=ParseMode.MARKDOWN_V2,
**kwargs,
)
async def send_telegram_photo(photo, markdown_caption: str):
"""Send a photo. `photo` may be raw bytes / a file-like object (uploaded
directly to Telegram) or a public URL string."""
logger.info("[TELEGRAM] Preparing to send photo...")
kwargs = {}
if TOPIC_ID:
kwargs["message_thread_id"] = int(TOPIC_ID)
await bot.send_photo(
chat_id=str(CHAT_ID),
photo=photo,
caption=markdown_caption,
parse_mode=ParseMode.MARKDOWN_V2,
# Uploading media is slower than a text send; the defaults (5s) time out.
connect_timeout=15,
read_timeout=30,
write_timeout=60,
**kwargs,
) )
View File
View File
+29
View File
@@ -0,0 +1,29 @@
# api/management/commands/seed.py
from django.core.management.base import BaseCommand
from api.models import Atoll, Island
class Command(BaseCommand):
help = "Seeds baseline reference data (atolls and islands) required for sign up."
def handle(self, *args, **options):
# Atoll name must match the person-verify API's `atoll_en` (the atoll
# code letter, e.g. "F" for Faafu) or user verification fails.
atoll, atoll_created = Atoll.objects.get_or_create(name="F")
self.stdout.write(
self.style.SUCCESS(f"Created atoll: {atoll.name}")
if atoll_created
else self.style.NOTICE(f"Atoll already exists: {atoll.name}")
)
island, island_created = Island.objects.get_or_create(
name="Dharanboodhoo", defaults={"atoll": atoll}
)
self.stdout.write(
self.style.SUCCESS(f"Created island: {island.name} ({atoll.name})")
if island_created
else self.style.NOTICE(f"Island already exists: {island.name}")
)
self.stdout.write(self.style.SUCCESS("Seeding complete."))
@@ -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="",
),
),
]
+69
View File
@@ -2,16 +2,31 @@
This is the models module for api. This is the models module for api.
""" """
import secrets
from datetime import timedelta from datetime import timedelta
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from .managers import CustomUserManager from .managers import CustomUserManager
from .storages import idcard_storage
from django.utils import timezone from django.utils import timezone
import pyotp import pyotp
from billing.models import WalletTransaction from billing.models import WalletTransaction
class User(AbstractUser): 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) address = models.CharField(max_length=255, blank=True)
email = models.EmailField(blank=True, null=True, unique=True) email = models.EmailField(blank=True, null=True, unique=True)
mobile = models.CharField( mobile = models.CharField(
@@ -23,6 +38,16 @@ class User(AbstractUser):
max_length=255, blank=True, unique=True, null=True, db_index=True max_length=255, blank=True, unique=True, null=True, db_index=True
) )
verified = models.BooleanField(default=False) 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) is_admin = models.BooleanField(default=False)
dob = models.DateField(blank=True, null=True) dob = models.DateField(blank=True, null=True)
terms_accepted = models.BooleanField(default=False) terms_accepted = models.BooleanField(default=False)
@@ -75,6 +100,50 @@ class User(AbstractUser):
objects = CustomUserManager() objects = CustomUserManager()
# Log in with username + password only; email is optional and not prompted
# 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): class TemporaryUser(models.Model):
t_id = models.AutoField(primary_key=True) t_id = models.AutoField(primary_key=True)
+20 -8
View File
@@ -8,6 +8,18 @@ api_url = str(config("SMS_API_URL", cast=str, default=""))
api_key = str(config("SMS_API_KEY", cast=str, default="")) api_key = str(config("SMS_API_KEY", cast=str, default=""))
bot_token = str(config("TG_BOT_TOKEN", cast=str, default="")) bot_token = str(config("TG_BOT_TOKEN", cast=str, default=""))
chat_id = str(config("TG_CHAT_ID", cast=str, default="")) chat_id = str(config("TG_CHAT_ID", cast=str, default=""))
# Optional forum topic to post into. If empty, messages go to the group's General topic.
topic_id = str(config("TG_TOPIC_ID", cast=str, default=""))
def format_mobile(mobile: str) -> str:
"""Normalize a Maldives mobile number to E.164 (+960XXXXXXX)."""
number = str(mobile).strip().replace(" ", "")
if number.startswith("+"):
return number
if number.startswith("960"):
return "+" + number
return "+960" + number
def send_otp(mobile: str, message: str): def send_otp(mobile: str, message: str):
@@ -17,12 +29,11 @@ def send_otp(mobile: str, message: str):
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {api_key}", "X-API-Key": api_key,
} }
data = { data = {
"number": mobile, "to": format_mobile(mobile),
"message": message, "text": message,
"check_delivery": False,
} }
response = requests.post(api_url, headers=headers, data=json.dumps(data)) response = requests.post(api_url, headers=headers, data=json.dumps(data))
if response.status_code == 200: if response.status_code == 200:
@@ -41,12 +52,11 @@ def send_sms(mobile: str, message: str):
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {api_key}", "X-API-Key": api_key,
} }
data = { data = {
"number": mobile, "to": format_mobile(mobile),
"message": message, "text": message,
"check_delivery": False,
} }
response = requests.post(api_url, headers=headers, data=json.dumps(data)) response = requests.post(api_url, headers=headers, data=json.dumps(data))
if response.status_code == 200: if response.status_code == 200:
@@ -80,6 +90,8 @@ def send_telegram_markdown(message: str):
try: try:
url = f"https://api.telegram.org/bot{bot_token}/sendMessage" url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {"chat_id": chat_id, "text": message, "parse_mode": "MarkdownV2"} payload = {"chat_id": chat_id, "text": message, "parse_mode": "MarkdownV2"}
if topic_id:
payload["message_thread_id"] = topic_id
response = requests.post(url, data=payload) response = requests.post(url, data=payload)
response.raise_for_status() response.raise_for_status()
+38
View File
@@ -1,6 +1,44 @@
from rest_framework import permissions from rest_framework import permissions
def user_is_admin(user) -> bool:
"""Single source of truth for "is this user an administrator?".
Any of the three flags grants admin access: the app-specific ``is_admin``
flag, Django's ``is_staff``, or ``is_superuser``. Both the permission
classes and the per-view authorization checks use this so the three flags
behave identically everywhere.
"""
return bool(
user
and user.is_authenticated
and (
getattr(user, "is_admin", False)
or getattr(user, "is_staff", False)
or getattr(user, "is_superuser", False)
)
)
class IsAdminOrStaffPermission(permissions.BasePermission):
"""Admin gate for admin-only endpoints and for views that have no
model/queryset of their own (e.g. proxy endpoints).
``IsStaffEditorPermission`` can't be used on model-less views because it
derives the required permission from ``view.queryset.model`` (``None``
there); it also requires granular Django model permissions that admin
accounts are not necessarily granted. This gate keys off admin status
instead.
"""
message = {
"message": "You do not have permission to perform this action.",
}
def has_permission(self, request, view):
return user_is_admin(request.user)
class IsStaffEditorPermission(permissions.DjangoModelPermissions): class IsStaffEditorPermission(permissions.DjangoModelPermissions):
perms_map = { perms_map = {
"GET": ["%(app_label)s.view_%(model_name)s"], "GET": ["%(app_label)s.view_%(model_name)s"],
+3
View File
@@ -94,6 +94,7 @@ class CustomUserSerializer(serializers.ModelSerializer):
"email", "email",
"last_login", "last_login",
"date_joined", "date_joined",
"is_staff",
"is_superuser", "is_superuser",
) )
@@ -120,6 +121,8 @@ class CustomReadOnlyUserSerializer(serializers.ModelSerializer):
"id_card", "id_card",
"agreement", "agreement",
"wallet_balance", "wallet_balance",
"status",
"id_card_photo",
) )
depth = 1 depth = 1
-45
View File
@@ -1,8 +1,4 @@
from django.core.mail import EmailMultiAlternatives
from django.dispatch import receiver from django.dispatch import receiver
from django.template.loader import render_to_string
from decouple import config
from django_rest_passwordreset.signals import reset_password_token_created
from django.db.models.signals import post_save from django.db.models.signals import post_save
from api.models import User, TemporaryUser from api.models import User, TemporaryUser
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@@ -44,44 +40,3 @@ def verify_user_with_person_api(sender, instance, created, **kwargs):
if created: if created:
print(f"Temporary User Instance: {instance}") print(f"Temporary User Instance: {instance}")
verify_user_with_person_api_task(instance.t_id) verify_user_with_person_api_task(instance.t_id)
@receiver(reset_password_token_created)
def password_reset_token_created(
sender, instance, reset_password_token, *args, **kwargs
):
"""
Handles password reset tokens
When a token is created, an e-mail needs to be sent to the user
:param sender: View Class that sent the signal
:param instance: View Instance that sent the signal
:param reset_password_token: Token Model Object
:param args:
:param kwargs:
:return:
"""
context = {
"current_user": reset_password_token.user,
"username": reset_password_token.user.username,
"email": reset_password_token.user.email,
"reset_password_url": f"{config('FRONTEND_URL')}/auth/reset-password-confirm/?token={reset_password_token.key}",
}
# render email text
email_html_message = render_to_string("email/password_reset_email.html", context)
email_plaintext_message = (
f"Here is your password reset link: {context['reset_password_url']}"
)
msg = EmailMultiAlternatives(
# title:
"Password Reset for {title}".format(title="Sarlink Portal"),
# message:
email_plaintext_message, # This is the plaintext version
# from:
"noreply@sarlink.net",
# to:
[reset_password_token.user.email],
)
msg.attach_alternative(email_html_message, "text/html")
msg.send()
+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()
+66 -15
View File
@@ -9,13 +9,20 @@ from django.utils import timezone
# from api.notifications import send_clean_telegram_markdown # from api.notifications import send_clean_telegram_markdown
from api.omada import Omada from api.omada import Omada
from api.bot import send_telegram_alert, telegram_loop, escape_markdown_v2 from api.bot import (
send_telegram_alert,
send_telegram_photo,
telegram_loop,
escape_markdown_v2,
)
import asyncio import asyncio
from apibase.env import env, BASE_DIR from apibase.env import env, BASE_DIR
from procrastinate.contrib.django import app from procrastinate.contrib.django import app
from procrastinate import builtin_tasks from procrastinate import builtin_tasks
import time import time
import io
import requests import requests
from PIL import Image
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -163,10 +170,59 @@ def verify_user_with_person_api_task(user_id: int):
response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{t_user.t_id_card}") response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{t_user.t_id_card}")
verification_failed_message = f"""*The following user verification failed*:\n\n*ID Card:* {t_user.t_id_card}\n*Name:* {t_user.t_first_name} {t_user.t_last_name}\n*House Name:* {t_user.t_address}\n*Date of Birth:* {t_user.t_dob}\n*Island:* {(t_user.t_atoll.name if t_user.t_atoll else "N/A")} {(t_user.t_island.name if t_user.t_island else "N/A")}\n*Mobile:* {t_user.t_mobile}\nVisit [SAR Link Portal](https://portal.sarlink.net/users/{user_id}/details) to manually verify this user. user_details = (
""" f"*NID:* {t_user.t_id_card}\n"
f"*Name:* {t_user.t_first_name} {t_user.t_last_name}\n"
f"*Phone:* {t_user.t_mobile}\n"
f"*Date of Birth:* {t_user.t_dob}\n"
f"*Address:* {t_user.t_address}\n"
f"*Island:* {(t_user.t_atoll.name if t_user.t_atoll else 'N/A')}. "
f"{(t_user.t_island.name if t_user.t_island else 'N/A')}"
)
logger.info(verification_failed_message) 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]({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]({frontend_url}/users/{user_id}/details)."
)
def _run(coro) -> None:
asyncio.run_coroutine_threadsafe(coro, telegram_loop).result()
def _fetch_photo(url: str) -> io.BytesIO:
"""Download the ID photo and convert it to a Telegram-safe JPEG.
The image lives on a private-network URL that Telegram's servers can't
reach, so we fetch the bytes here and upload them directly. Conversion
to JPEG avoids Telegram rejecting formats like webp.
"""
resp = requests.get(url, timeout=10)
resp.raise_for_status()
buf = io.BytesIO()
Image.open(io.BytesIO(resp.content)).convert("RGB").save(buf, format="JPEG")
buf.seek(0)
buf.name = "photo.jpg"
return buf
def send_telegram(message: str, photo_url: str | None = None) -> None:
caption = escape_markdown_v2(message)
if photo_url:
try:
_run(send_telegram_photo(_fetch_photo(photo_url), caption))
return
except Exception as e:
logger.warning(f"[Registration] TELEGRAM PHOTO ERROR: {e}")
# Fall through to a plain text alert below.
try:
_run(send_telegram_alert(markdown_message=caption))
except Exception as e:
logger.warning(f"[Registration] TELEGRAM ALERT ERROR: {e}")
if response.status_code == 200: if response.status_code == 200:
@@ -177,6 +233,7 @@ def verify_user_with_person_api_task(user_id: int):
api_dob = data.get("dob") api_dob = data.get("dob")
api_atoll = data.get("atoll_en") api_atoll = data.get("atoll_en")
api_island_name = data.get("island_name_en") api_island_name = data.get("island_name_en")
api_image_url = data.get("image_url")
if not t_user.t_mobile or t_user.t_dob is None: if not t_user.t_mobile or t_user.t_dob is None:
logger.error("User mobile or date of birth is not set.") logger.error("User mobile or date of birth is not set.")
@@ -230,22 +287,16 @@ def verify_user_with_person_api_task(user_id: int):
): ):
t_user.t_verified = True t_user.t_verified = True
t_user.save() t_user.save()
logger.info(verification_success_message)
send_telegram(verification_success_message, photo_url=api_image_url)
return True return True
else: else:
t_user.t_verified = False t_user.t_verified = False
t_user.save() t_user.save()
# send_clean_telegram_markdown(message=verification_failed_message) logger.info(verification_failed_message)
send_telegram(verification_failed_message, photo_url=api_image_url)
try:
asyncio.run_coroutine_threadsafe(
send_telegram_alert(
markdown_message=escape_markdown_v2(verification_failed_message)
),
telegram_loop,
).result()
except Exception as e:
logger.warning("[Registration] TELEGRAM ALERT ERROR", e)
return False return False
else: else:
# Handle the error case # Handle the error case
@@ -1,102 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Instructions to reset your password." />
<meta name="keywords" content="password, reset, email, instructions" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Password Reset Email</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f5f5f5;
margin: 0;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header {
margin-bottom: 30px;
}
.logo {
color: #2c3e50;
font-size: 24px;
font-weight: bold;
}
.message {
color: #6c757d;
font-size: 16px;
line-height: 1.5;
margin-top: 20px;
}
.footer {
margin-top: 30px;
color: #6c757d;
font-size: 14px;
}
.button {
display: inline-block;
padding: 10px 20px;
background-color: #007bff;
color: #ffffff !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}
.button:hover {
background-color: #0056b3;
}
a {
color: #007bff;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">Password Reset Instructions</div>
</div>
<p class="message">
Hello {{ username }},
</p>
<p class="message">
We received a request to reset your password. Click the button below to create a new password:
</p>
<a href="{{ reset_password_url }}" class="button">Reset Password</a>
<p class="message">
If the button doesn't work, you can copy and paste this link into your browser:
<br>
<a href="{{ reset_password_url }}">{{ reset_password_url }}</a>
</p>
<p class="message">
If you did not request this password reset, you can safely ignore
this email.
</p>
<p class="footer">Best regards,<br>SARLink</p>
</div>
</body>
</html>
+27 -2
View File
@@ -10,7 +10,6 @@ from .views import (
ListUserView, ListUserView,
UserDetailAPIView, UserDetailAPIView,
healthcheck, healthcheck,
test_email,
ListAtollView, ListAtollView,
CreateAtollView, CreateAtollView,
RetrieveUpdateDestroyAtollView, RetrieveUpdateDestroyAtollView,
@@ -19,16 +18,27 @@ from .views import (
filter_user, filter_user,
filter_temporary_user, filter_temporary_user,
VerifyOTPView, VerifyOTPView,
ResendRegistrationOTPView,
UserVerifyAPIView, UserVerifyAPIView,
UserUpdateAPIView, UserUpdateAPIView,
UserRejectAPIView, UserRejectAPIView,
RequestIdUploadAPIView,
IdUploadTokenInfoAPIView,
IdUploadStartAPIView,
IdUploadSubmitAPIView,
AgreementUpdateAPIView, AgreementUpdateAPIView,
PersonVerifyAPIView,
) )
urlpatterns = [ urlpatterns = [
path("register/", CreateTemporaryUserView.as_view(), name="register"), path("register/", CreateTemporaryUserView.as_view(), name="register"),
path("register/verify/", VerifyOTPView.as_view(), name="verify-otp"), 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("profile/", UserprofileAPIView.as_view(), name="profile"),
path("login/", LoginView.as_view(), name="knox_login"), path("login/", LoginView.as_view(), name="knox_login"),
path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"), path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"),
@@ -48,8 +58,23 @@ urlpatterns = [
name="user-agreement-update", name="user-agreement-update",
), ),
path("users/<int:pk>/reject/", UserRejectAPIView.as_view(), name="user-reject"), 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("healthcheck/", healthcheck, name="healthcheck"),
path("test/", test_email, name="testemail"),
path("atolls/", ListAtollView.as_view(), name="atolls"), path("atolls/", ListAtollView.as_view(), name="atolls"),
path("atolls/new/", CreateAtollView.as_view(), name="atoll-new"), path("atolls/new/", CreateAtollView.as_view(), name="atoll-new"),
path( path(
+293 -58
View File
@@ -7,12 +7,16 @@ from rest_framework import generics, permissions
from rest_framework.authtoken.serializers import AuthTokenSerializer from rest_framework.authtoken.serializers import AuthTokenSerializer
from api.filters import UserFilter from api.filters import UserFilter
from api.mixins import StaffEditorPermissionMixin from api.mixins import StaffEditorPermissionMixin
from api.models import User, Atoll, Island, TemporaryUser from api.permissions import IsAdminOrStaffPermission, user_is_admin
from api.models import User, Atoll, Island, TemporaryUser, IdUploadToken
from api.notifications import send_sms from api.notifications import send_sms
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from decouple import config
import os
from api.serializers import ( from api.serializers import (
AtollSerializer, AtollSerializer,
IslandSerializer, IslandSerializer,
@@ -29,7 +33,6 @@ from knox.views import LoginView as KnoxLoginView
from knox.models import AuthToken from knox.models import AuthToken
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from typing import cast, Dict, Any from typing import cast, Dict, Any
from django.core.mail import send_mail
from django.db.models import Q from django.db.models import Q
from api.notifications import send_otp from api.notifications import send_otp
from .utils import check_person_api_verification from .utils import check_person_api_verification
@@ -54,6 +57,48 @@ def healthcheck(request):
return Response({"status": "Good"}, status=status.HTTP_200_OK) 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): class CreateTemporaryUserView(generics.CreateAPIView):
serializer_class = TemporaryUserSerializer serializer_class = TemporaryUserSerializer
@@ -173,7 +218,7 @@ class VerifyOTPView(generics.GenericAPIView):
return Response({"message": "Invalid OTP."}, status=400) return Response({"message": "Invalid OTP."}, status=400)
# Create real user # Create real user
User.objects.create_user( user = User.objects.create_user(
first_name=temp_user.t_first_name, first_name=temp_user.t_first_name,
last_name=temp_user.t_last_name, last_name=temp_user.t_last_name,
username=str(temp_user.t_username), username=str(temp_user.t_username),
@@ -190,29 +235,73 @@ class VerifyOTPView(generics.GenericAPIView):
policy_accepted=temp_user.t_policy_accepted, 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.otp_verified = True
temp_user.save() 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( return Response(
{ {
"message": "User created successfully.", "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): class LoginView(KnoxLoginView):
# login view extending KnoxLoginView # login view extending KnoxLoginView
serializer_class = AuthSerializer serializer_class = AuthSerializer
@@ -257,7 +346,8 @@ class UserprofileAPIView(generics.RetrieveUpdateAPIView):
return self.request.user return self.request.user
class UserUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView): class UserUpdateAPIView(generics.UpdateAPIView):
permission_classes = [IsAdminOrStaffPermission]
serializer_class = UserUpdateSerializer serializer_class = UserUpdateSerializer
queryset = User.objects.all() queryset = User.objects.all()
lookup_field = "pk" lookup_field = "pk"
@@ -270,10 +360,7 @@ class UserUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
{"message": "You cannot update a superuser."}, {"message": "You cannot update a superuser."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
if request.user != user and ( if request.user != user and not user_is_admin(request.user):
not request.user.is_authenticated
or not getattr(request.user, "is_admin", False)
):
return Response( return Response(
{"message": "You are not authorized to update this user."}, {"message": "You are not authorized to update this user."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
@@ -288,7 +375,8 @@ class UserUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
return super().update(request, *args, **kwargs) return super().update(request, *args, **kwargs)
class AgreementUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView): class AgreementUpdateAPIView(generics.UpdateAPIView):
permission_classes = [IsAdminOrStaffPermission]
serializer_class = UserAgreementSerializer serializer_class = UserAgreementSerializer
queryset = User.objects.all() queryset = User.objects.all()
lookup_field = "pk" lookup_field = "pk"
@@ -301,10 +389,7 @@ class AgreementUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView)
{"message": "You cannot update a superuser."}, {"message": "You cannot update a superuser."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
if request.user != user and ( if request.user != user and not user_is_admin(request.user):
not request.user.is_authenticated
or not getattr(request.user, "is_admin", False)
):
return Response( return Response(
{"message": "You are not authorized to update this user."}, {"message": "You are not authorized to update this user."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
@@ -373,7 +458,8 @@ class ListUserView(StaffEditorPermissionMixin, generics.ListAPIView):
return User.objects.none() return User.objects.none()
class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView): class UserVerifyAPIView(generics.UpdateAPIView):
permission_classes = [IsAdminOrStaffPermission]
serializer_class = CustomUserSerializer serializer_class = CustomUserSerializer
queryset = User.objects.all() queryset = User.objects.all()
lookup_field = "pk" lookup_field = "pk"
@@ -381,10 +467,7 @@ class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
user_id = kwargs.get("pk") user_id = kwargs.get("pk")
user = get_object_or_404(User, pk=user_id) user = get_object_or_404(User, pk=user_id)
if request.user != user and ( if request.user != user and not user_is_admin(request.user):
not request.user.is_authenticated
or not getattr(request.user, "is_admin", False)
):
return Response( return Response(
{"message": "You are not authorized to update this user."}, {"message": "You are not authorized to update this user."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
@@ -422,7 +505,8 @@ class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
return Response({"message": "User successfully verified."}) return Response({"message": "User successfully verified."})
class UserRejectAPIView(StaffEditorPermissionMixin, generics.DestroyAPIView): class UserRejectAPIView(generics.DestroyAPIView):
permission_classes = [IsAdminOrStaffPermission]
serializer_class = CustomUserSerializer serializer_class = CustomUserSerializer
queryset = User.objects.all() queryset = User.objects.all()
lookup_field = "pk" lookup_field = "pk"
@@ -447,24 +531,145 @@ class UserRejectAPIView(StaffEditorPermissionMixin, generics.DestroyAPIView):
{"message": "You cannot remove a superuser."}, {"message": "You cannot remove a superuser."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
if request.user != user and ( if request.user != user and not user_is_admin(request.user):
not request.user.is_authenticated
or not getattr(request.user, "is_admin", False)
):
return Response( return Response(
{"message": "You are not authorized to reject this user."}, {"message": "You are not authorized to reject this user."},
status=status.HTTP_403_FORBIDDEN, 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() user.delete()
t_user = get_object_or_404(TemporaryUser, t_mobile=user.mobile) t_user = get_object_or_404(TemporaryUser, t_mobile=user.mobile)
t_user.delete() t_user.delete()
send_sms(message=rejection_details, mobile=mobile_number) send_sms(message=rejection_message, mobile=mobile_number)
return Response( return Response(
{"message": "User successfully rejected."}, {"message": "User successfully rejected."},
status=status.HTTP_204_NO_CONTENT, 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"]) @api_view(["GET"])
def filter_user(request): def filter_user(request):
id_card = request.GET.get("id_card", "").strip() or None id_card = request.GET.get("id_card", "").strip() or None
@@ -481,13 +686,13 @@ def filter_user(request):
elif mobile: elif mobile:
filters = Q(mobile=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"Querying with filters: {filters}")
print(f"Found user: {user}") print(f"Found user: {user}")
return Response( return Response(
{"ok": True, "verified": user.verified} {"ok": True, "verified": user.verified, "status": user.status}
if user if user
else {"ok": False, "verified": False} else {"ok": False, "verified": False}
) )
@@ -525,7 +730,8 @@ def filter_temporary_user(request):
) )
class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView): class UserDetailAPIView(generics.RetrieveAPIView):
permission_classes = [IsAdminOrStaffPermission]
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = CustomReadOnlyUserSerializer serializer_class = CustomReadOnlyUserSerializer
lookup_field = "pk" lookup_field = "pk"
@@ -533,11 +739,7 @@ class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
instance = self.get_object() instance = self.get_object()
user = request.user user = request.user
if ( if user != instance and not user_is_admin(user):
user != instance
and not getattr(user, "is_admin", False)
and not user.is_superuser #type: ignore
):
return Response( return Response(
{"message": "You are not authorized to view this user's details."}, {"message": "You are not authorized to view this user's details."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
@@ -550,19 +752,6 @@ class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
return Response(data) return Response(data)
@api_view(["POST"])
@permission_classes((permissions.AllowAny,))
def test_email(request):
send_mail(
"Subject here",
"Here is the message.",
"noreply@sarlink.net",
["shihaam@shihaam.me"],
fail_silently=False,
)
return Response({"status": "ok"}, status=status.HTTP_200_OK)
class CreateAtollView(StaffEditorPermissionMixin, generics.CreateAPIView): class CreateAtollView(StaffEditorPermissionMixin, generics.CreateAPIView):
serializer_class = AtollSerializer serializer_class = AtollSerializer
queryset = Atoll.objects.all() queryset = Atoll.objects.all()
@@ -629,3 +818,49 @@ class RetrieveUpdateDestroyIslandView(
if name and Island.objects.filter(name=name).exclude(pk=instance.pk).exists(): if name and Island.objects.filter(name=name).exclude(pk=instance.pk).exists():
return Response({"message": "Island name already exists."}, status=400) return Response({"message": "Island name already exists."}, status=400)
return super().update(request, *args, **kwargs) return super().update(request, *args, **kwargs)
class PersonVerifyAPIView(generics.GenericAPIView):
"""
Admin-gated proxy to the external Person verification API.
The SPA frontend can no longer call the external person-verify service
directly (it would leak an internal infra host to the browser), so the
backend owns the integration. Returns the upstream JSON as-is.
This view has no model of its own, so it uses ``IsAdminOrStaffPermission``
rather than the model-based ``StaffEditorPermissionMixin`` (which would
crash dereferencing ``view.queryset.model``).
GET /api/auth/person/<id_card>/
"""
permission_classes = [IsAdminOrStaffPermission]
def get(self, request, id_card: str, *args, **kwargs):
import requests
from decouple import config
PERSON_VERIFY_BASE_URL = config("PERSON_VERIFY_BASE_URL", default="") # type: ignore
if not PERSON_VERIFY_BASE_URL:
return Response(
{"detail": "PERSON_VERIFY_BASE_URL is not set."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
try:
upstream = requests.get(
f"{PERSON_VERIFY_BASE_URL}/api/person/{id_card}", timeout=10
)
except requests.RequestException as exc:
return Response(
{"detail": f"Failed to reach person verification service: {exc}"},
status=status.HTTP_502_BAD_GATEWAY,
)
try:
data = upstream.json()
except ValueError:
return Response(
{"detail": "Invalid response from person verification service."},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(data, status=upstream.status_code)
+11 -18
View File
@@ -52,10 +52,8 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"rest_framework", "rest_framework",
"django_rest_passwordreset",
"djangopasswordlessknox", "djangopasswordlessknox",
"django_extensions", "django_extensions",
"django_seed",
"storages", "storages",
"whitenoise.runserver_nostatic", "whitenoise.runserver_nostatic",
"django.contrib.staticfiles", "django.contrib.staticfiles",
@@ -159,9 +157,9 @@ DATABASES = {
# Password validation # Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
# Uses email as username for login # Log in with username + password (default Django backend)
AUTH_USER_MODEL = "api.User" AUTH_USER_MODEL = "api.User"
AUTHENTICATION_BACKENDS = ["api.backends.EmailBackend"] AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
@@ -203,6 +201,14 @@ STATICFILES_DIRS = [
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "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" STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
@@ -339,24 +345,11 @@ logging.config.dictConfig(
) )
EMAIL_BACKEND = (
"django.core.mail.backends.smtp.EmailBackend" # Replace with your preferred backend
)
EMAIL_HOST = env("EMAIL_HOSTNAME", default="") # type: ignore
EMAIL_PORT = env("EMAIL_PORT", cast=int, default=25) # type: ignore
EMAIL_HOST_USER = env("EMAIL_USERNAME", default="") # type: ignore
EMAIL_HOST_PASSWORD = env("EMAIL_PASSWORD", default="") # type: ignore
# DEFAULT_FROM_EMAIL = "noreply@sarlink.net"
EMAIL_USE_TLS = True
PASSWORDLESS_AUTH = { PASSWORDLESS_AUTH = {
# 'PASSWORDLESS_EMAIL_TOKEN_HTML_TEMPLATE_NAME': "password_reset_email.html", "PASSWORDLESS_AUTH_TYPES": ["MOBILE"],
"PASSWORDLESS_AUTH_TYPES": ["EMAIL", "MOBILE"],
"PASSWORDLESS_USER_MOBILE_FIELD_NAME": "mobile", "PASSWORDLESS_USER_MOBILE_FIELD_NAME": "mobile",
"PASSWORDLESS_TEST_SUPPRESSION": False, "PASSWORDLESS_TEST_SUPPRESSION": False,
"PASSWORDLESS_REGISTER_NEW_USERS": True, "PASSWORDLESS_REGISTER_NEW_USERS": True,
"PASSWORDLESS_EMAIL_NOREPLY_ADDRESS": "noreply@sarlink.net",
} }
+3 -4
View File
@@ -27,10 +27,6 @@ from drf_spectacular.views import (
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path(
"api/password_reset/",
include("django_rest_passwordreset.urls", namespace="password_reset"),
),
path("", include("djangopasswordlessknox.urls")), path("", include("djangopasswordlessknox.urls")),
# Authentication # Authentication
path("api/auth/", include("api.urls")), path("api/auth/", include("api.urls")),
@@ -41,6 +37,9 @@ urlpatterns = [
] ]
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 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("api-auth/", include("rest_framework.urls")),)
urlpatterns += (path("__debug__/", include("debug_toolbar.urls")),) urlpatterns += (path("__debug__/", include("debug_toolbar.urls")),)
urlpatterns += (path("api/schema/", SpectacularAPIView.as_view(), name="schema"),) urlpatterns += (path("api/schema/", SpectacularAPIView.as_view(), name="schema"),)
+55 -32
View File
@@ -327,25 +327,36 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
def verify_transfer_payment(self, data, payment) -> PaymentVerificationResponse: def verify_transfer_payment(self, data, payment) -> PaymentVerificationResponse:
if not PAYMENT_BASE_URL: if not PAYMENT_BASE_URL:
raise ValueError( logger.error("PAYMENT_BASE_URL is not set.")
"PAYMENT_BASE_URL is not set. Please set it in your environment variables."
)
response = requests.post(
f"{PAYMENT_BASE_URL}/verify-payment",
json=data,
headers={"Content-Type": "application/json"},
)
logger.info("MIB Verification Response -> ", response)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.error(f"HTTPError: {e}")
return PaymentVerificationResponse( return PaymentVerificationResponse(
message="Payment verification failed.", success=False, transaction=None message="Payment gateway is not configured. Please contact support.",
success=False,
transaction=None,
) )
mib_resp = response.json() try:
logger.info("MIB Verification Response ->", mib_resp) response = requests.post(
if not response.json().get("success"): f"{PAYMENT_BASE_URL}/verify-payment",
json=data,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
mib_resp = response.json()
except requests.exceptions.RequestException as e:
logger.error(f"MIB request failed: {e}")
return PaymentVerificationResponse(
message="Unable to reach the payment gateway. Please try again or contact support.",
success=False,
transaction=None,
)
except ValueError as e:
logger.error(f"MIB returned an invalid response: {e}")
return PaymentVerificationResponse(
message="Received an invalid response from the payment gateway. Please contact support.",
success=False,
transaction=None,
)
logger.info("MIB Verification Response -> %s", mib_resp)
if not mib_resp.get("success"):
return PaymentVerificationResponse( return PaymentVerificationResponse(
message=mib_resp["message"], message=mib_resp["message"],
success=mib_resp["success"], success=mib_resp["success"],
@@ -471,25 +482,37 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
def verify_transfer_topup(self, data, topup) -> PaymentVerificationResponse: def verify_transfer_topup(self, data, topup) -> PaymentVerificationResponse:
if not PAYMENT_BASE_URL: if not PAYMENT_BASE_URL:
raise ValueError( logger.error("PAYMENT_BASE_URL is not set.")
"PAYMENT_BASE_URL is not set. Please set it in your environment variables." return PaymentVerificationResponse(
message="Payment gateway is not configured. Please contact support.",
success=False,
transaction=None,
) )
logger.info(data) logger.info(data)
response = requests.post(
f"{PAYMENT_BASE_URL}/verify-payment",
json=data,
headers={"Content-Type": "application/json"},
)
try: try:
response.raise_for_status() response = requests.post(
except requests.exceptions.HTTPError as e: f"{PAYMENT_BASE_URL}/verify-payment",
logger.error(f"HTTPError: {e}") json=data,
return PaymentVerificationResponse( headers={"Content-Type": "application/json"},
message="Payment verification failed.", success=False, transaction=None
) )
mib_resp = response.json() response.raise_for_status()
print(mib_resp) mib_resp = response.json()
if not response.json().get("success"): except requests.exceptions.RequestException as e:
logger.error(f"MIB request failed: {e}")
return PaymentVerificationResponse(
message="Unable to reach the payment gateway. Please try again or contact support.",
success=False,
transaction=None,
)
except ValueError as e:
logger.error(f"MIB returned an invalid response: {e}")
return PaymentVerificationResponse(
message="Received an invalid response from the payment gateway. Please contact support.",
success=False,
transaction=None,
)
logger.info("MIB Verification Response -> %s", mib_resp)
if not mib_resp.get("success"):
return PaymentVerificationResponse( return PaymentVerificationResponse(
message=mib_resp["message"], message=mib_resp["message"],
success=mib_resp["success"], success=mib_resp["success"],
+35
View File
@@ -0,0 +1,35 @@
services:
backend:
build:
context: .build/dev
hostname: backend
volumes:
- ./:/app
ports:
# host 8000 -> container 5000 (host :5000 is taken by the local macvendor-api)
- 8000:5000
env_file:
- .env
depends_on:
database:
condition: service_healthy
database:
image: postgres:16
hostname: database
environment:
POSTGRES_DB: ${POSTGRES_DATABASE:-sarlink}
POSTGRES_USER: ${POSTGRES_USER:-sarlink}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-sarlink}"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
@@ -0,0 +1,22 @@
# Generated by Django 5.2 on 2026-08-03 15:59
import devices.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("devices", "0008_alter_device_blocked_by"),
]
operations = [
migrations.AlterField(
model_name="device",
name="mac",
field=models.CharField(
max_length=255,
unique=True,
validators=[devices.models.validate_mac_address],
),
),
]
+9
View File
@@ -15,10 +15,19 @@ def validate_mac_address(value):
return value return value
def normalize_mac(value):
"""Canonicalize any accepted MAC format to upper-case, dash-separated
form (e.g. "aa:bb:cc:dd:ee:ff" and "aabbccddeeff" -> "AA-BB-CC-DD-EE-FF").
Used so uniqueness is enforced on one canonical representation."""
hex_only = re.sub(r"[^0-9A-Fa-f]", "", value or "").upper()
return "-".join(hex_only[i : i + 2] for i in range(0, len(hex_only), 2))
class Device(models.Model): class Device(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
mac = models.CharField( mac = models.CharField(
max_length=255, max_length=255,
unique=True,
validators=[ validators=[
validate_mac_address, validate_mac_address,
], ],
+4 -1
View File
@@ -1,5 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Device from .models import Device, normalize_mac
from api.serializers import CustomReadOnlyUserSerializer from api.serializers import CustomReadOnlyUserSerializer
from billing.models import Payment # Import the Payment model from billing.models import Payment # Import the Payment model
@@ -8,6 +8,9 @@ class CreateDeviceSerializer(serializers.ModelSerializer):
name = serializers.CharField(required=True) name = serializers.CharField(required=True)
mac = serializers.CharField(required=True) mac = serializers.CharField(required=True)
def validate_mac(self, value):
return normalize_mac(value)
class Meta: # type: ignore class Meta: # type: ignore
model = Device model = Device
fields = [ fields = [
+9 -4
View File
@@ -4,7 +4,7 @@ from rest_framework import generics, status
from rest_framework.response import Response from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from billing.models import Payment from billing.models import Payment
from .models import Device from .models import Device, normalize_mac
from django.db.models import Prefetch from django.db.models import Prefetch
from .serializers import ( from .serializers import (
CreateDeviceSerializer, CreateDeviceSerializer,
@@ -79,9 +79,13 @@ class DeviceListCreateAPIView(
raw_mac = request.data.get("mac", None) raw_mac = request.data.get("mac", None)
mac = raw_mac.strip() if raw_mac else None mac = raw_mac.strip() if raw_mac else None
MAC_REGEX = re.compile(r"^([0-9A-Fa-f]{2}([.:-]?)){5}[0-9A-Fa-f]{2}$") MAC_REGEX = re.compile(r"^([0-9A-Fa-f]{2}([.:-]?)){5}[0-9A-Fa-f]{2}$")
NORMALIZE_MAC_REGEX = re.compile(r"[^0-9A-Fa-f]")
if not isinstance(mac, str) or not MAC_REGEX.match(mac): if not isinstance(mac, str) or not MAC_REGEX.match(mac):
return Response({"message": "Invalid mac address."}, status=400) return Response({"message": "Invalid mac address."}, status=400)
# Canonicalize BEFORE checking uniqueness and before saving, so that
# the same physical MAC in different formats/cases is treated as one.
mac = normalize_mac(mac)
if Device.objects.filter(mac=mac).exists(): if Device.objects.filter(mac=mac).exists():
return Response( return Response(
{"message": "Device with this mac address already exists."}, status=400 {"message": "Device with this mac address already exists."}, status=400
@@ -90,8 +94,9 @@ class DeviceListCreateAPIView(
if not mac_details.ok: if not mac_details.ok:
return Response({"message": "MAC address vendor not found."}, status=400) return Response({"message": "MAC address vendor not found."}, status=400)
mac = re.sub(NORMALIZE_MAC_REGEX, "-", mac).upper() # The serializer canonicalizes the MAC again on save (see
# CreateDeviceSerializer.validate_mac), so the stored value matches
# what we checked above regardless of the raw request format.
return super().create(request, *args, **kwargs) return super().create(request, *args, **kwargs)
def perform_create(self, serializer): def perform_create(self, serializer):
+5 -4
View File
@@ -232,14 +232,15 @@ def send_sms_with_callback_token(user, mobile_token, **kwargs):
logger.debug("Failed to send SMS. Missing SMS_API_URL or SMS_API_KEY.") logger.debug("Failed to send SMS. Missing SMS_API_URL or SMS_API_KEY.")
return False return False
from api.notifications import format_mobile
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {api_key}", "X-API-Key": api_key,
} }
data = { data = {
"number": to_number, "to": format_mobile(to_number),
"message": base_string % mobile_token.key, "text": base_string % mobile_token.key,
"check_delivery": False,
} }
print(mobile_token.key) print(mobile_token.key)
response = requests.post(api_url, headers=headers, data=json.dumps(data)) response = requests.post(api_url, headers=headers, data=json.dumps(data))
-37
View File
@@ -1,37 +0,0 @@
services:
api:
build:
context: .
restart: always
command: gunicorn apibase.wsgi:application --bind 0.0.0.0:5000 --workers=2
volumes:
- /home/<username>/docker/council-api/staticfiles:/home/app/api/staticfiles
ports:
- 5000:5000
env_file:
- ./.env
depends_on:
- db
- redis
db:
image: postgres:15
restart: always
volumes:
- ./postgres_data:/var/lib/postgresql/data/
env_file:
- ./.env
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DATABASE}
ports:
- 5232:5432
redis:
image: "redis:alpine"
restart: always
expose:
- "6379"
+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"
-2
View File
@@ -36,8 +36,6 @@ django-extensions==3.2.3
django-filter==23.5 django-filter==23.5
django-redis==5.4.0 django-redis==5.4.0
django-rest-knox==4.2.0 django-rest-knox==4.2.0
django-rest-passwordreset==1.5.0
django-seed==0.3.1
django-storages==1.14.4 django-storages==1.14.4
django-stubs==5.1.1 django-stubs==5.1.1
django-stubs-ext==5.1.1 django-stubs-ext==5.1.1