Compare commits
11
Commits
c56140011f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f661cdb6
|
||
|
|
9d8b5cd26f
|
||
|
|
192f987125
|
||
|
|
3397f212b5
|
||
|
|
1ea2f9f4c2
|
||
|
|
1f5e60dbb4
|
||
|
|
00d25698c6
|
||
|
|
6cdf042f49
|
||
|
|
2db0369d7c
|
||
|
|
c414116ed5
|
||
|
|
4bf75fdfe4
|
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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 "$@"
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -164,3 +164,5 @@ cython_debug/
|
|||||||
staticfiles/
|
staticfiles/
|
||||||
postgres_data/
|
postgres_data/
|
||||||
media/
|
media/
|
||||||
|
# Uploaded ID/passport photos (bind-mounted volume in production)
|
||||||
|
storage/
|
||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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="",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,28 +235,72 @@ 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(
|
return Response(
|
||||||
{
|
{
|
||||||
"message": "User created successfully.",
|
"message": "User created successfully.",
|
||||||
"verified": temp_user.t_verified
|
"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": 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
|
||||||
@@ -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
@@ -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
@@ -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"),)
|
||||||
|
|||||||
+44
-21
@@ -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."
|
return PaymentVerificationResponse(
|
||||||
|
message="Payment gateway is not configured. Please contact support.",
|
||||||
|
success=False,
|
||||||
|
transaction=None,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{PAYMENT_BASE_URL}/verify-payment",
|
f"{PAYMENT_BASE_URL}/verify-payment",
|
||||||
json=data,
|
json=data,
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
logger.info("MIB Verification Response -> ", response)
|
|
||||||
try:
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.exceptions.HTTPError as e:
|
|
||||||
logger.error(f"HTTPError: {e}")
|
|
||||||
return PaymentVerificationResponse(
|
|
||||||
message="Payment verification failed.", success=False, transaction=None
|
|
||||||
)
|
|
||||||
mib_resp = response.json()
|
mib_resp = response.json()
|
||||||
logger.info("MIB Verification Response ->", mib_resp)
|
except requests.exceptions.RequestException as e:
|
||||||
if not response.json().get("success"):
|
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)
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{PAYMENT_BASE_URL}/verify-payment",
|
f"{PAYMENT_BASE_URL}/verify-payment",
|
||||||
json=data,
|
json=data,
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.exceptions.HTTPError as e:
|
|
||||||
logger.error(f"HTTPError: {e}")
|
|
||||||
return PaymentVerificationResponse(
|
|
||||||
message="Payment verification failed.", success=False, transaction=None
|
|
||||||
)
|
|
||||||
mib_resp = response.json()
|
mib_resp = response.json()
|
||||||
print(mib_resp)
|
except requests.exceptions.RequestException as e:
|
||||||
if not response.json().get("success"):
|
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
@@ -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],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user