Compare commits
65
Commits
feat/task-queues
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f661cdb6
|
||
|
|
9d8b5cd26f
|
||
|
|
192f987125
|
||
|
|
3397f212b5
|
||
|
|
1ea2f9f4c2
|
||
|
|
1f5e60dbb4
|
||
|
|
00d25698c6
|
||
|
|
6cdf042f49
|
||
|
|
2db0369d7c
|
||
|
|
c414116ed5
|
||
|
|
4bf75fdfe4
|
||
|
|
c56140011f | ||
|
|
4714b6ec15 | ||
|
|
6ae56774d1 | ||
|
|
bfc3fd1b89 | ||
|
|
9721585f8a | ||
|
|
b0936cd489 | ||
|
|
fc1aba3239 | ||
|
|
85485ae351 | ||
|
|
64bba25fb9 | ||
|
|
fbc8a17e6a | ||
|
|
36160c2665 | ||
|
|
f6afb3b658 | ||
|
|
9c082aedf2 | ||
|
|
2bc594da9c | ||
|
|
19321da0be | ||
|
|
e3c2d4450f | ||
|
|
ee54386fd5 | ||
|
|
b52cd9285a | ||
|
|
d0c809489c
|
||
|
|
80fc27fd74 | ||
|
|
cdef5ed27c | ||
|
|
3e7a74950e | ||
|
|
72e0cd1fba | ||
|
|
a4b6f44348 | ||
|
|
4aae0064ca | ||
|
|
a46f2635ad
|
||
|
|
118ad52c71 | ||
|
|
8d9a2ed2e0 | ||
|
|
3200d8e41c | ||
|
|
1554829b9a | ||
|
|
f8c91e8f14 | ||
|
|
f84f03fd5b | ||
|
|
fd603daaaf | ||
|
|
9e4449d0d6 | ||
|
|
087782e351 | ||
|
|
446ca6653e | ||
|
|
f8c0725558 | ||
|
|
7c5ed1e89d | ||
|
|
976a119fcc | ||
|
|
ea57598e8d | ||
|
|
d64a2675e4 | ||
|
|
4cc6e91a66 | ||
|
|
eee314af46 | ||
|
|
ff065fa4a9 | ||
|
|
72c2ea1ecc | ||
|
|
596ce510c7 | ||
|
|
436a8b7d7a | ||
|
|
82ae1e6cea | ||
|
|
56ab79bd8c | ||
|
|
64c2189209 | ||
|
|
dacf821bad | ||
|
|
3f0a5f0f03 | ||
|
|
db53874ff4 | ||
|
|
d557bb879f |
@@ -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,22 +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/;
|
||||
}
|
||||
|
||||
# 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=""
|
||||
POSTGRES_DATABASE=
|
||||
POSTGRES_USER=
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_HOST=
|
||||
POSTGRES_PORT=
|
||||
# DEBUG defaults to True if unset. Set False in production.
|
||||
DJANGO_DEBUG=True
|
||||
# Comma-separated. Include the backend service name for the compose network.
|
||||
ALLOWED_HOSTS="localhost,127.0.0.1,backend"
|
||||
|
||||
# --- Only read when DJANGO_DEBUG=False (production) ---
|
||||
DJANGO_SECURE_SSL_REDIRECT=
|
||||
ALLOWED_HOSTS=""
|
||||
SECURE_HSTS_SECONDS=
|
||||
# Comma-separated absolute origins, e.g. https://portal.sarlink.net
|
||||
CSRF_TRUSTED_ORIGINS=""
|
||||
CSRF_COOKIE_DOMAIN=""
|
||||
|
||||
EMAIL_HOSTNAME=""
|
||||
EMAIL_PORT=
|
||||
EMAIL_USERNAME=
|
||||
EMAIL_PASSWORD=
|
||||
# =============================================================================
|
||||
# Database (PostgreSQL) — matches the `database` service in compose.yml
|
||||
# =============================================================================
|
||||
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=""
|
||||
PERSON_VERIFY_BASE_URL=""
|
||||
FRONTEND_URL=""
|
||||
|
||||
# =============================================================================
|
||||
# SMS gateway — OTP + notifications
|
||||
# =============================================================================
|
||||
SMS_API_URL=""
|
||||
SMS_API_KEY=""
|
||||
SMS_API_URL=""
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
|
||||
@@ -163,3 +163,6 @@ cython_debug/
|
||||
#staticfiles
|
||||
staticfiles/
|
||||
postgres_data/
|
||||
media/
|
||||
# Uploaded ID/passport photos (bind-mounted volume in production)
|
||||
storage/
|
||||
@@ -47,6 +47,7 @@ class UserAdmin(BaseUserAdmin):
|
||||
"island",
|
||||
"terms_accepted",
|
||||
"policy_accepted",
|
||||
"agreement",
|
||||
)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
from telegram import Bot
|
||||
from telegram.constants import ParseMode
|
||||
from decouple import config
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
telegram_loop = None
|
||||
|
||||
BOT_TOKEN = config("TG_BOT_TOKEN", default="killme", 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):
|
||||
raise ValueError(
|
||||
"TG_BOT_TOKEN environment variable must be set and must be a string."
|
||||
)
|
||||
if not CHAT_ID:
|
||||
raise ValueError(
|
||||
"TG_CHAT_ID environment variable must be set and must be a string."
|
||||
)
|
||||
|
||||
bot = Bot(token=BOT_TOKEN)
|
||||
|
||||
|
||||
def telegram_worker():
|
||||
"""
|
||||
Run the event loop for Telegram in a separate daemon thread.
|
||||
"""
|
||||
global telegram_loop
|
||||
telegram_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(telegram_loop)
|
||||
try:
|
||||
logger.info("Telegram loop started.")
|
||||
telegram_loop.run_forever()
|
||||
except Exception as e:
|
||||
logger.exception(f"Telegram worker crashed! {e}", exc_info=True)
|
||||
finally:
|
||||
telegram_loop.close()
|
||||
|
||||
|
||||
# Start the Telegram worker thread when the module is loaded
|
||||
telegram_thread = threading.Thread(target=telegram_worker, daemon=True)
|
||||
telegram_thread.start()
|
||||
|
||||
# Wait until telegram_loop is ready
|
||||
timeout = 5
|
||||
for _ in range(timeout * 10): # up to 5 seconds
|
||||
if telegram_loop is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
logger.error("Telegram loop failed to initialize in time.")
|
||||
|
||||
|
||||
async def send_telegram_alert(markdown_message: str):
|
||||
logger.info("[TELEGRAM] Preparing to send alert...")
|
||||
kwargs = {}
|
||||
if TOPIC_ID:
|
||||
kwargs["message_thread_id"] = int(TOPIC_ID)
|
||||
await bot.send_message(
|
||||
chat_id=str(CHAT_ID),
|
||||
text=markdown_message,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def escape_markdown_v2(text: str) -> str:
|
||||
escape_chars = r"_~`>#+-=|{}.!\\"
|
||||
return re.sub(f"([{re.escape(escape_chars)}])", r"\\\1", text)
|
||||
@@ -6,6 +6,8 @@ class UserFilter(django_filters.FilterSet):
|
||||
last_name = django_filters.CharFilter(lookup_expr="icontains")
|
||||
first_name = django_filters.CharFilter(lookup_expr="icontains")
|
||||
email = django_filters.CharFilter(lookup_expr="icontains")
|
||||
id_card = django_filters.CharFilter(lookup_expr="icontains")
|
||||
mobile = django_filters.CharFilter(lookup_expr="icontains")
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from rest_framework.response import Response
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
from django.utils import timezone
|
||||
import re
|
||||
|
||||
|
||||
ID_CARD_PATTERN = r"^[A-Z]{1,2}[0-9]{6,7}$"
|
||||
MOBILE_PATTERN = r"^[7|9][0-9]{6}$"
|
||||
ACCOUNT_NUMBER_PATTERN = r"^(7\d{12}|9\d{16})$"
|
||||
|
||||
|
||||
class ErrorMessages:
|
||||
USERNAME_EXISTS = "Username already exists."
|
||||
MOBILE_EXISTS = "Mobile number already exists."
|
||||
INVALID_ID_CARD = "Please enter a valid ID card number."
|
||||
ID_CARD_EXISTS = "ID card already exists."
|
||||
INVALID_MOBILE = "Please enter a valid mobile number."
|
||||
INVALID_ACCOUNT = "Please enter a valid account number."
|
||||
UNDERAGE_ERROR = "You must be 18 and above to signup."
|
||||
|
||||
|
||||
def validate_required_fields(data) -> Optional[Response]:
|
||||
required_fields = {
|
||||
"firstname": "First name",
|
||||
"lastname": "Last name",
|
||||
"username": "Username",
|
||||
"address": "Address",
|
||||
"mobile": "Mobile number",
|
||||
"acc_no": "Account number",
|
||||
"id_card": "ID card",
|
||||
"dob": "Date of birth",
|
||||
"atoll": "Atoll",
|
||||
"island": "Island",
|
||||
}
|
||||
|
||||
for field, label in required_fields.items():
|
||||
if not data.get(field):
|
||||
return Response({"message": f"{label} is required."}, status=400)
|
||||
|
||||
if data.get("terms_accepted") is None:
|
||||
return Response({"message": "Terms acceptance is required."}, status=400)
|
||||
if data.get("policy_accepted") is None:
|
||||
return Response({"message": "Policy acceptance is required."}, status=400)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
from .models import TemporaryUser, User
|
||||
|
||||
def validate_unique_fields(username, mobile, id_card) -> Optional[Response]:
|
||||
if mobile and (TemporaryUser.objects.filter(t_mobile=mobile).exists() or User.objects.filter(mobile=mobile).exists()):
|
||||
return Response({"message": ErrorMessages.MOBILE_EXISTS}, status=400)
|
||||
|
||||
if username and (TemporaryUser.objects.filter(t_username=username).exists() or User.objects.filter(username=username).exists()):
|
||||
return Response({"message": ErrorMessages.USERNAME_EXISTS}, status=400)
|
||||
|
||||
if id_card and (TemporaryUser.objects.filter(t_id_card=id_card).exists() or User.objects.filter(id_card=id_card).exists()):
|
||||
return Response({"message": ErrorMessages.ID_CARD_EXISTS}, status=400)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
def validate_patterns(id_card, mobile, acc_no) -> Optional[Response]:
|
||||
if id_card and not re.match(ID_CARD_PATTERN, id_card):
|
||||
return Response({"message": ErrorMessages.INVALID_ID_CARD}, status=400)
|
||||
|
||||
if mobile is None or not re.match(MOBILE_PATTERN, mobile):
|
||||
return Response({"message": ErrorMessages.INVALID_MOBILE}, status=400)
|
||||
|
||||
if acc_no is None or not re.match(ACCOUNT_NUMBER_PATTERN, acc_no):
|
||||
return Response({"message": ErrorMessages.INVALID_ACCOUNT}, status=400)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
def calculate_age(dob: date) -> int:
|
||||
today = timezone.now().date()
|
||||
return today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
|
||||
@@ -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,40 @@
|
||||
# Generated by Django 5.2 on 2025-07-15 20:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0016_user_is_admin"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="temporaryuser",
|
||||
name="t_id_card",
|
||||
field=models.CharField(
|
||||
blank=True, db_index=True, max_length=255, null=True, unique=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="temporaryuser",
|
||||
name="t_mobile",
|
||||
field=models.CharField(
|
||||
blank=True, db_index=True, max_length=255, null=True, unique=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="id_card",
|
||||
field=models.CharField(
|
||||
blank=True, db_index=True, max_length=255, null=True, unique=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="mobile",
|
||||
field=models.CharField(
|
||||
blank=True, db_index=True, max_length=255, null=True, unique=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2 on 2025-07-24 18:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0017_alter_temporaryuser_t_id_card_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="agreement",
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
help_text="Upload the agreement file signed by the user.",
|
||||
null=True,
|
||||
upload_to="agreements/",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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="",
|
||||
),
|
||||
),
|
||||
]
|
||||
+115
-5
@@ -2,22 +2,52 @@
|
||||
This is the models module for api.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from .managers import CustomUserManager
|
||||
from .storages import idcard_storage
|
||||
from django.utils import timezone
|
||||
import pyotp
|
||||
from billing.models import WalletTransaction
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
# Registration workflow states. `verified` (below) stays the login gate and
|
||||
# is kept in sync: verified is True iff status == STATUS_VERIFIED.
|
||||
STATUS_PENDING = "pending" # created, awaiting/undergoing auto-verify
|
||||
STATUS_VERIFIED = "verified" # approved, may log in
|
||||
STATUS_ID_REQUIRED = "id_required" # must (re)upload an ID/passport photo
|
||||
STATUS_ID_SUBMITTED = "id_submitted" # photo uploaded, awaiting admin review
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_PENDING, "Pending"),
|
||||
(STATUS_VERIFIED, "Verified"),
|
||||
(STATUS_ID_REQUIRED, "ID required"),
|
||||
(STATUS_ID_SUBMITTED, "ID submitted"),
|
||||
]
|
||||
|
||||
address = models.CharField(max_length=255, blank=True)
|
||||
email = models.EmailField(blank=True, null=True, unique=True)
|
||||
mobile = models.CharField(max_length=255, blank=True, unique=True, null=True)
|
||||
mobile = models.CharField(
|
||||
max_length=255, blank=True, unique=True, null=True, db_index=True
|
||||
)
|
||||
designation = models.CharField(max_length=255, blank=True)
|
||||
acc_no = models.CharField(max_length=255, blank=True)
|
||||
id_card = models.CharField(max_length=255, blank=True, unique=True, null=True)
|
||||
id_card = models.CharField(
|
||||
max_length=255, blank=True, unique=True, null=True, db_index=True
|
||||
)
|
||||
verified = models.BooleanField(default=False)
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING, db_index=True
|
||||
)
|
||||
id_card_photo = models.FileField(
|
||||
upload_to="",
|
||||
storage=idcard_storage,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="ID card / passport (JPG, PNG or PDF) uploaded by the user for manual review.",
|
||||
)
|
||||
is_admin = models.BooleanField(default=False)
|
||||
dob = models.DateField(blank=True, null=True)
|
||||
terms_accepted = models.BooleanField(default=False)
|
||||
@@ -30,14 +60,90 @@ class User(AbstractUser):
|
||||
island = models.ForeignKey(
|
||||
"Island", on_delete=models.SET_NULL, null=True, blank=True, related_name="users"
|
||||
)
|
||||
|
||||
agreement = models.FileField(
|
||||
upload_to="agreements/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Upload the agreement file signed by the user.",
|
||||
)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def get_all_fields(self, instance):
|
||||
return [field.name for field in instance.get_fields()]
|
||||
|
||||
def add_wallet_funds(self, amount, description="", reference_id=None):
|
||||
self.wallet_balance += amount
|
||||
self.save(update_fields=["wallet_balance"])
|
||||
WalletTransaction.objects.create(
|
||||
user=self,
|
||||
amount=amount,
|
||||
transaction_type="TOPUP",
|
||||
description=description,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
|
||||
def deduct_wallet_funds(self, amount, description="", reference_id=None):
|
||||
if self.wallet_balance >= amount:
|
||||
self.wallet_balance -= amount
|
||||
self.save(update_fields=["wallet_balance"])
|
||||
WalletTransaction.objects.create(
|
||||
user=self,
|
||||
amount=amount,
|
||||
transaction_type="DEBIT",
|
||||
description=description,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
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):
|
||||
t_id = models.AutoField(primary_key=True)
|
||||
@@ -46,10 +152,14 @@ class TemporaryUser(models.Model):
|
||||
t_last_name = models.CharField(max_length=255, blank=True)
|
||||
t_address = models.CharField(max_length=255, blank=True)
|
||||
t_email = models.EmailField(blank=True, null=True, unique=True)
|
||||
t_mobile = models.CharField(max_length=255, blank=True, unique=True, null=True)
|
||||
t_mobile = models.CharField(
|
||||
max_length=255, blank=True, unique=True, null=True, db_index=True
|
||||
)
|
||||
t_designation = models.CharField(max_length=255, blank=True)
|
||||
t_acc_no = models.CharField(max_length=255, blank=True)
|
||||
t_id_card = models.CharField(max_length=255, blank=True, unique=True, null=True)
|
||||
t_id_card = models.CharField(
|
||||
max_length=255, blank=True, unique=True, null=True, db_index=True
|
||||
)
|
||||
t_verified = models.BooleanField(default=False)
|
||||
t_dob = models.DateField(blank=True, null=True)
|
||||
t_terms_accepted = models.BooleanField(default=False)
|
||||
@@ -95,7 +205,7 @@ class TemporaryUser(models.Model):
|
||||
verbose_name_plural = "Temporary Users"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.t_username)
|
||||
return f"{self.t_username}"
|
||||
|
||||
|
||||
class Atoll(models.Model):
|
||||
|
||||
+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=""))
|
||||
bot_token = str(config("TG_BOT_TOKEN", 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):
|
||||
@@ -17,12 +29,11 @@ def send_otp(mobile: str, message: str):
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"X-API-Key": api_key,
|
||||
}
|
||||
data = {
|
||||
"number": mobile,
|
||||
"message": message,
|
||||
"check_delivery": False,
|
||||
"to": format_mobile(mobile),
|
||||
"text": message,
|
||||
}
|
||||
response = requests.post(api_url, headers=headers, data=json.dumps(data))
|
||||
if response.status_code == 200:
|
||||
@@ -41,12 +52,11 @@ def send_sms(mobile: str, message: str):
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"X-API-Key": api_key,
|
||||
}
|
||||
data = {
|
||||
"number": mobile,
|
||||
"message": message,
|
||||
"check_delivery": False,
|
||||
"to": format_mobile(mobile),
|
||||
"text": message,
|
||||
}
|
||||
response = requests.post(api_url, headers=headers, data=json.dumps(data))
|
||||
if response.status_code == 200:
|
||||
@@ -80,6 +90,8 @@ def send_telegram_markdown(message: str):
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
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.raise_for_status()
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
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):
|
||||
perms_map = {
|
||||
"GET": ["%(app_label)s.view_%(model_name)s"],
|
||||
|
||||
+44
-2
@@ -1,9 +1,12 @@
|
||||
from knox.models import AuthToken
|
||||
from django.contrib.auth import authenticate
|
||||
from api.models import User, Atoll, Island, TemporaryUser
|
||||
from api.models import Atoll, Island, TemporaryUser
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class IslandSerializer(serializers.ModelSerializer):
|
||||
@@ -21,6 +24,39 @@ class AtollSerializer(serializers.ModelSerializer):
|
||||
depth = 2
|
||||
|
||||
|
||||
class UserProfileUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta: # type: ignore
|
||||
model = User
|
||||
fields = (
|
||||
"email",
|
||||
"mobile",
|
||||
) # Only allow these fields
|
||||
|
||||
|
||||
class UserUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta: # type: ignore
|
||||
model = User
|
||||
fields = (
|
||||
"id_card",
|
||||
"mobile",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"address",
|
||||
"dob",
|
||||
"atoll",
|
||||
"island",
|
||||
)
|
||||
|
||||
|
||||
class UserAgreementSerializer(serializers.ModelSerializer):
|
||||
"""serializer for the user agreement object"""
|
||||
|
||||
class Meta: # type: ignore
|
||||
model = User
|
||||
fields = ("agreement",)
|
||||
extra_kwargs = {"agreement": {"required": True, "allow_null": False}}
|
||||
|
||||
|
||||
class CustomUserSerializer(serializers.ModelSerializer):
|
||||
"""serializer for the user object"""
|
||||
|
||||
@@ -58,6 +94,7 @@ class CustomUserSerializer(serializers.ModelSerializer):
|
||||
"email",
|
||||
"last_login",
|
||||
"date_joined",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
)
|
||||
|
||||
@@ -80,7 +117,12 @@ class CustomReadOnlyUserSerializer(serializers.ModelSerializer):
|
||||
"username",
|
||||
"mobile",
|
||||
"address",
|
||||
"acc_no",
|
||||
"id_card",
|
||||
"agreement",
|
||||
"wallet_balance",
|
||||
"status",
|
||||
"id_card_photo",
|
||||
)
|
||||
depth = 1
|
||||
|
||||
@@ -120,7 +162,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
extra_kwargs = {"password": {"write_only": True, "min_length": 5}}
|
||||
|
||||
def create(self, validated_data):
|
||||
return User.objects.create_user(**validated_data)
|
||||
return User.objects.create_user(**validated_data) #type: ignore
|
||||
|
||||
|
||||
class AuthSerializer(serializers.Serializer):
|
||||
|
||||
+19
-52
@@ -1,10 +1,6 @@
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
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 api.models import User
|
||||
from api.models import User, TemporaryUser
|
||||
from django.contrib.auth.models import Permission
|
||||
from api.tasks import verify_user_with_person_api_task
|
||||
|
||||
@@ -12,13 +8,22 @@ from api.tasks import verify_user_with_person_api_task
|
||||
@receiver(post_save, sender=User)
|
||||
def assign_device_permissions(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
# Assign all permissions for devices and read permission for atoll and island
|
||||
device_permissions = Permission.objects.filter(content_type__model="device")
|
||||
atoll_read_permission = Permission.objects.get(codename="view_atoll")
|
||||
island_read_permission = Permission.objects.get(codename="view_island")
|
||||
payment_permissions = Permission.objects.filter(content_type__model="payment")
|
||||
topup_permissions = Permission.objects.filter(content_type__model="topup")
|
||||
|
||||
payment_permissions = Permission.objects.filter(
|
||||
content_type__model="payment"
|
||||
).exclude(codename__startswith="delete_")
|
||||
topup_permissions = Permission.objects.filter(
|
||||
content_type__model="topup"
|
||||
).exclude(codename__startswith="delete_")
|
||||
wallet_transaction_permissions = Permission.objects.filter(
|
||||
content_type__model="wallettransaction"
|
||||
).exclude(codename__startswith="delete_")
|
||||
user_read_only_permission = Permission.objects.get(
|
||||
codename="view_user", content_type__model="user"
|
||||
)
|
||||
instance.user_permissions.add(user_read_only_permission)
|
||||
for permission in topup_permissions:
|
||||
instance.user_permissions.add(permission)
|
||||
for permission in device_permissions:
|
||||
@@ -26,50 +31,12 @@ def assign_device_permissions(sender, instance, created, **kwargs):
|
||||
instance.user_permissions.add(atoll_read_permission, island_read_permission)
|
||||
for permission in payment_permissions:
|
||||
instance.user_permissions.add(permission)
|
||||
for permission in wallet_transaction_permissions:
|
||||
instance.user_permissions.add(permission)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
@receiver(post_save, sender=TemporaryUser)
|
||||
def verify_user_with_person_api(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
verify_user_with_person_api_task(instance.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()
|
||||
print(f"Temporary User Instance: {instance}")
|
||||
verify_user_with_person_api_task(instance.t_id)
|
||||
|
||||
@@ -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()
|
||||
+174
-63
@@ -1,5 +1,6 @@
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
from django.shortcuts import get_object_or_404
|
||||
from api.models import User
|
||||
from api.models import TemporaryUser
|
||||
from devices.models import Device
|
||||
from api.notifications import send_sms
|
||||
import os
|
||||
@@ -8,9 +9,20 @@ from django.utils import timezone
|
||||
|
||||
# from api.notifications import send_clean_telegram_markdown
|
||||
from api.omada import Omada
|
||||
from api.bot import (
|
||||
send_telegram_alert,
|
||||
send_telegram_photo,
|
||||
telegram_loop,
|
||||
escape_markdown_v2,
|
||||
)
|
||||
import asyncio
|
||||
from apibase.env import env, BASE_DIR
|
||||
from procrastinate.contrib.django import app
|
||||
from procrastinate import builtin_tasks
|
||||
import time
|
||||
import io
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -36,22 +48,64 @@ async def remove_old_jobs(context, timestamp):
|
||||
|
||||
|
||||
@app.periodic(
|
||||
cron="0 0 */28 * *", queue="heavy_tasks", periodic_id="deactivate_expired_devices"
|
||||
cron="0 22 * * *",
|
||||
queue="heavy_tasks",
|
||||
periodic_id="deactivate_expired_devices_and_block_in_omada",
|
||||
) # type: ignore
|
||||
@app.task
|
||||
def deactivate_expired_devices():
|
||||
def deactivate_expired_devices_and_block_in_omada():
|
||||
expired_devices = Device.objects.filter(
|
||||
expiry_date__lte=timezone.localtime(timezone.now()), is_active=True
|
||||
).select_related("user")
|
||||
|
||||
print("Expired Devices: ", expired_devices)
|
||||
count = expired_devices.count()
|
||||
|
||||
if count == 0:
|
||||
return {"total_expired_devices": 0}
|
||||
|
||||
user_devices_map = {}
|
||||
devices_successfully_blocked = []
|
||||
devices_failed_to_block = []
|
||||
omada_client = Omada()
|
||||
|
||||
# Single loop to collect data and block devices
|
||||
for device in expired_devices:
|
||||
# Collect devices for SMS notifications
|
||||
if device.user and device.user.mobile:
|
||||
if device.user.mobile not in user_devices_map:
|
||||
user_devices_map[device.user.mobile] = []
|
||||
user_devices_map[device.user.mobile].append(device.name)
|
||||
|
||||
# Try to block device in Omada
|
||||
try:
|
||||
omada_client.block_device(mac_address=device.mac, operation="block")
|
||||
# Only prepare for update if Omada blocking succeeded
|
||||
device.blocked = True
|
||||
device.is_active = False
|
||||
devices_successfully_blocked.append(device)
|
||||
logger.info(f"Successfully blocked device {device.mac} in Omada")
|
||||
time.sleep(20) # Sleep to avoid rate limiting
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to block device [omada] {device.mac}: {e}")
|
||||
devices_failed_to_block.append(device)
|
||||
# Continue to next device without updating this one
|
||||
|
||||
# Bulk update only successfully blocked devices
|
||||
if devices_successfully_blocked:
|
||||
try:
|
||||
Device.objects.bulk_update(
|
||||
devices_successfully_blocked, ["is_active", "blocked"]
|
||||
)
|
||||
logger.info(
|
||||
f"Successfully updated {len(devices_successfully_blocked)} devices in database"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to bulk update devices in database: {e}")
|
||||
# You might want to handle this case - devices are blocked in Omada but not updated in DB
|
||||
|
||||
# Send SMS notifications
|
||||
sms_count = 0
|
||||
for mobile, device_names in user_devices_map.items():
|
||||
if not mobile:
|
||||
continue
|
||||
@@ -59,14 +113,25 @@ def deactivate_expired_devices():
|
||||
[f"{i + 1}. {name}" for i, name in enumerate(device_names)]
|
||||
)
|
||||
print("device list: ", device_list)
|
||||
send_sms(
|
||||
mobile,
|
||||
f"Dear {mobile}, \n\nThe following devices have expired: \n{device_list}. \n\nPlease make a payment to keep your devices active. \n\n- SAR Link",
|
||||
)
|
||||
# expired_devices.update(is_active=False)
|
||||
print(f"Total {count} expired devices.")
|
||||
try:
|
||||
send_sms(
|
||||
mobile,
|
||||
f"Dear {mobile}, \n\nThe following devices have expired: \n{device_list}. \n\nPlease make a payment to keep your devices active. \n\n- SAR Link",
|
||||
)
|
||||
sms_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send SMS to {mobile}: {e}")
|
||||
|
||||
print(f"Total {count} expired devices processed.")
|
||||
print(f"Successfully blocked: {len(devices_successfully_blocked)}")
|
||||
print(f"Failed to block: {len(devices_failed_to_block)}")
|
||||
print(f"SMS notifications sent: {sms_count}")
|
||||
|
||||
return {
|
||||
"total_expired_devices": count,
|
||||
"successfully_blocked": len(devices_successfully_blocked),
|
||||
"failed_to_block": len(devices_failed_to_block),
|
||||
"sms_sent": sms_count,
|
||||
}
|
||||
|
||||
|
||||
@@ -86,36 +151,80 @@ def verify_user_with_person_api_task(user_id: int):
|
||||
Verify the user with the Person API.
|
||||
:param user_id: The ID of the user to verify.
|
||||
"""
|
||||
if not user_id:
|
||||
logger.error("User ID is not provided.")
|
||||
return None
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
if not user:
|
||||
logger.error(f"User with ID {user_id} not found.")
|
||||
return None
|
||||
# Call the Person API to verify the user
|
||||
|
||||
# verification_failed_message = f"""
|
||||
# _The following user verification failed_:
|
||||
# *ID Card:* {user.id_card}
|
||||
# *Name:* {user.first_name} {user.last_name}
|
||||
# *House Name:* {user.address}
|
||||
# *Date of Birth:* {user.dob}
|
||||
# *Island:* {(user.atoll.name if user.atoll else "N/A")} {(user.island.name if user.island else "N/A")}
|
||||
# *Mobile:* {user.mobile}
|
||||
# Visit [SAR Link Portal](https://portal.sarlink.net) to manually verify this user.
|
||||
# """
|
||||
|
||||
# logger.info(verification_failed_message)
|
||||
PERSON_VERIFY_BASE_URL = env.str("PERSON_VERIFY_BASE_URL", default="") # type: ignore
|
||||
|
||||
if not PERSON_VERIFY_BASE_URL:
|
||||
raise ValueError(
|
||||
"PERSON_VERIFY_BASE_URL is not set in the environment variables."
|
||||
)
|
||||
import requests
|
||||
|
||||
response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{user.id_card}")
|
||||
print(f"Verifying user with ID: {user_id}")
|
||||
if not user_id:
|
||||
logger.error("User ID is not provided.")
|
||||
return None
|
||||
t_user = get_object_or_404(TemporaryUser, t_id=user_id)
|
||||
if not t_user:
|
||||
logger.error(f"User with ID {user_id} not found.")
|
||||
return None
|
||||
print("t_user:", t_user)
|
||||
response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{t_user.t_id_card}")
|
||||
|
||||
|
||||
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')}"
|
||||
)
|
||||
|
||||
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:
|
||||
data = response.json()
|
||||
api_nic = data.get("nic")
|
||||
@@ -124,11 +233,12 @@ def verify_user_with_person_api_task(user_id: int):
|
||||
api_dob = data.get("dob")
|
||||
api_atoll = data.get("atoll_en")
|
||||
api_island_name = data.get("island_name_en")
|
||||
api_image_url = data.get("image_url")
|
||||
|
||||
if not user.mobile or user.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.")
|
||||
return None
|
||||
if not user.island or user.atoll is None:
|
||||
if not t_user.t_island or t_user.t_atoll is None:
|
||||
logger.error("User island or atoll is not set.")
|
||||
return None
|
||||
|
||||
@@ -139,53 +249,54 @@ def verify_user_with_person_api_task(user_id: int):
|
||||
logger.info(f"API atoll: {api_atoll}")
|
||||
logger.info(f"API island name: {api_island_name}")
|
||||
|
||||
user_nic = user.id_card
|
||||
user_name = f"{user.first_name} {user.last_name}"
|
||||
user_house_name = user.address
|
||||
user_dob = user.dob.isoformat()
|
||||
user_nic = t_user.t_id_card
|
||||
user_name = f"{t_user.t_first_name} {t_user.t_last_name}"
|
||||
user_house_name = t_user.t_address
|
||||
user_dob = t_user.t_dob.isoformat()
|
||||
|
||||
logger.info(f"User nic: {user_nic}")
|
||||
logger.info(f"User name: {user_name}")
|
||||
logger.info(f"User house name: {user_house_name}")
|
||||
logger.info(f"User dob: {user_dob}")
|
||||
logger.info(f"User atoll: {user.atoll}")
|
||||
logger.info(f"User island name: {user.island}")
|
||||
logger.info(f"User atoll: {t_user.t_atoll.name if t_user.t_atoll else 'N/A'}")
|
||||
logger.info(
|
||||
f"User island name: {t_user.t_island.name if t_user.t_island else 'N/A'}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"case User atoll: {user.atoll.name == api_atoll.strip() if api_atoll else False}"
|
||||
f"case User atoll: {t_user.t_atoll.name == api_atoll.strip() if api_atoll else False}"
|
||||
) # Defensive check for api_atoll
|
||||
logger.info(f"api atoll type: {type(api_atoll)}")
|
||||
logger.info(f"user atoll type: {type(user.atoll.name)}")
|
||||
logger.info(f"user atoll type: {type(t_user.t_atoll.name)}")
|
||||
logger.info(
|
||||
f"case User island name: {user.island.name == api_island_name.strip() if api_island_name else False}"
|
||||
f"case User island name: {t_user.t_island.name == api_island_name.strip() if api_island_name else False}"
|
||||
) # Defensive check for api_island_name
|
||||
logger.info(f"api island name type: {type(api_island_name)}")
|
||||
logger.info(f"user island name type: {type(user.island.name)}")
|
||||
logger.info(f"user island name type: {type(t_user.t_island.name)}")
|
||||
|
||||
|
||||
print("CHECKING USER FIELDS AGAINST API DATA")
|
||||
|
||||
if (
|
||||
data.get("nic") == user.id_card
|
||||
and data.get("name_en") == f"{user.first_name} {user.last_name}"
|
||||
and data.get("house_name_en") == user.address
|
||||
and data.get("dob").split("T")[0] == user.dob.isoformat()
|
||||
and data.get("atoll_en").strip() == user.atoll.name
|
||||
and data.get("island_name_en").strip() == user.island.name
|
||||
data.get("nic") == t_user.t_id_card
|
||||
and data.get("name_en") == f"{t_user.t_first_name} {t_user.t_last_name}"
|
||||
and data.get("house_name_en") == t_user.t_address
|
||||
and data.get("dob").split("T")[0] == t_user.t_dob.isoformat()
|
||||
and data.get("atoll_en").strip() == t_user.t_atoll.name
|
||||
and data.get("island_name_en").strip() == t_user.t_island.name
|
||||
):
|
||||
user.verified = True
|
||||
user.save()
|
||||
send_sms(
|
||||
user.mobile,
|
||||
f"Dear {user.first_name} {user.last_name}, \n\nYour account has been successfully and verified. \n\nYou can now manage your devices and make payments through our portal at https://portal.sarlink.net. \n\n - SAR Link",
|
||||
)
|
||||
t_user.t_verified = True
|
||||
t_user.save()
|
||||
|
||||
logger.info(verification_success_message)
|
||||
send_telegram(verification_success_message, photo_url=api_image_url)
|
||||
return True
|
||||
else:
|
||||
user.verified = False
|
||||
user.save()
|
||||
t_user.t_verified = False
|
||||
t_user.save()
|
||||
|
||||
send_sms(
|
||||
user.mobile,
|
||||
f"Dear {user.first_name} {user.last_name}, \n\nYour account registration is being processed. \n\nWe will notify you once verification is complete. \n\n - SAR Link",
|
||||
)
|
||||
# send_clean_telegram_markdown(message=verification_failed_message)
|
||||
logger.info(verification_failed_message)
|
||||
send_telegram(verification_failed_message, photo_url=api_image_url)
|
||||
return False
|
||||
else:
|
||||
# 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>
|
||||
+41
-9
@@ -5,12 +5,11 @@ from knox import views as knox_views
|
||||
from .views import (
|
||||
LoginView,
|
||||
CreateTemporaryUserView,
|
||||
ManageUserView,
|
||||
UserprofileAPIView,
|
||||
KnoxTokenListApiView,
|
||||
ListUserView,
|
||||
UserDetailAPIView,
|
||||
healthcheck,
|
||||
test_email,
|
||||
ListAtollView,
|
||||
CreateAtollView,
|
||||
RetrieveUpdateDestroyAtollView,
|
||||
@@ -18,31 +17,64 @@ from .views import (
|
||||
RetrieveUpdateDestroyIslandView,
|
||||
filter_user,
|
||||
filter_temporary_user,
|
||||
UpdateUserWalletView,
|
||||
VerifyOTPView,
|
||||
ResendRegistrationOTPView,
|
||||
UserVerifyAPIView,
|
||||
UserUpdateAPIView,
|
||||
UserRejectAPIView,
|
||||
RequestIdUploadAPIView,
|
||||
IdUploadTokenInfoAPIView,
|
||||
IdUploadStartAPIView,
|
||||
IdUploadSubmitAPIView,
|
||||
AgreementUpdateAPIView,
|
||||
PersonVerifyAPIView,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("register/", CreateTemporaryUserView.as_view(), name="register"),
|
||||
path("register/verify/", VerifyOTPView.as_view(), name="verify-otp"),
|
||||
path("profile/", ManageUserView.as_view(), name="profile"),
|
||||
path(
|
||||
"register/resend-otp/",
|
||||
ResendRegistrationOTPView.as_view(),
|
||||
name="resend-registration-otp",
|
||||
),
|
||||
path("profile/", UserprofileAPIView.as_view(), name="profile"),
|
||||
path("login/", LoginView.as_view(), name="knox_login"),
|
||||
path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"),
|
||||
path("logoutall/", knox_views.LogoutAllView.as_view(), name="knox_logoutall"),
|
||||
path("tokens/", KnoxTokenListApiView.as_view(), name="knox_tokens"),
|
||||
# path("auth/", CustomAuthToken.as_view()),
|
||||
path("users/", ListUserView.as_view(), name="users"),
|
||||
path(
|
||||
"update-wallet/<int:pk>/", UpdateUserWalletView.as_view(), name="update-wallet"
|
||||
),
|
||||
path("users/<int:pk>/", UserDetailAPIView.as_view(), name="user-detail"),
|
||||
path("users/<int:pk>/verify/", UserVerifyAPIView.as_view(), name="user-verify"),
|
||||
path("users/<int:pk>/update/", UserUpdateAPIView.as_view(), name="user-update"),
|
||||
path("users/filter/", filter_user, name="filter-users"),
|
||||
path("users/temp/filter/", filter_temporary_user, name="filter-temporary-users"),
|
||||
# User verification flow
|
||||
path("users/<int:pk>/verify/", UserVerifyAPIView.as_view(), name="user-verify"),
|
||||
path(
|
||||
"users/<int:pk>/agreement/",
|
||||
AgreementUpdateAPIView.as_view(),
|
||||
name="user-agreement-update",
|
||||
),
|
||||
path("users/<int:pk>/reject/", UserRejectAPIView.as_view(), name="user-reject"),
|
||||
path(
|
||||
"users/<int:pk>/request-id-card/",
|
||||
RequestIdUploadAPIView.as_view(),
|
||||
name="user-request-id-card",
|
||||
),
|
||||
# Public, unauthenticated self-service ID-upload flow (single-use magic link)
|
||||
path(
|
||||
"id-upload/token/", IdUploadTokenInfoAPIView.as_view(), name="id-upload-token"
|
||||
),
|
||||
path(
|
||||
"id-upload/start/", IdUploadStartAPIView.as_view(), name="id-upload-start"
|
||||
),
|
||||
path(
|
||||
"id-upload/submit/", IdUploadSubmitAPIView.as_view(), name="id-upload-submit"
|
||||
),
|
||||
path("person/<str:id_card>/", PersonVerifyAPIView.as_view(), name="person-verify"),
|
||||
path("healthcheck/", healthcheck, name="healthcheck"),
|
||||
path("test/", test_email, name="testemail"),
|
||||
path("atolls/", ListAtollView.as_view(), name="atolls"),
|
||||
path("atolls/new/", CreateAtollView.as_view(), name="atoll-new"),
|
||||
path(
|
||||
|
||||
+27
-18
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import List, TypedDict
|
||||
from typing import List, Optional, TypedDict
|
||||
import requests
|
||||
from decouple import config
|
||||
from api.models import User
|
||||
@@ -40,7 +40,9 @@ def reverse_dhivehi_string(input_str):
|
||||
|
||||
class MismatchResult(TypedDict):
|
||||
ok: bool
|
||||
mismatch_fields: List[str]
|
||||
mismatch_fields: Optional[List[str]]
|
||||
error: Optional[str]
|
||||
detail: Optional[str]
|
||||
|
||||
|
||||
def check_person_api_verification(
|
||||
@@ -63,20 +65,22 @@ def check_person_api_verification(
|
||||
raise ValueError(
|
||||
"PERSON_VERIFY_BASE_URL is not set in the environment variables."
|
||||
)
|
||||
print(id_card)
|
||||
response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{id_card}")
|
||||
api_reponse = response.json()
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"Failed to fetch data from Person API for ID Card '{id_card}'. "
|
||||
f"Status Code: {response.status_code}, Response: {response.text}"
|
||||
)
|
||||
return {"ok": False, "mismatch_fields": ["api_error"]}
|
||||
api_data = response.json()
|
||||
if not api_data:
|
||||
logger.error(
|
||||
f"No data found in Person API for ID Card '{id_card}'. Response: {response.text}"
|
||||
)
|
||||
return {"ok": False, "mismatch_fields": ["no_data"]}
|
||||
return {
|
||||
"ok": False,
|
||||
"mismatch_fields": None,
|
||||
"error": response.json()["error"] if "error" in response.json() else None,
|
||||
"detail": response.json()["detail"]
|
||||
if "detail" in response.json()
|
||||
else None,
|
||||
}
|
||||
|
||||
# Initialize a list to hold fields that do not match
|
||||
mismatch_fields = []
|
||||
@@ -86,12 +90,12 @@ def check_person_api_verification(
|
||||
user_dob_iso = user_data.dob.isoformat() if user_data.dob else None
|
||||
|
||||
# Prepare API data for comparison
|
||||
api_nic = api_data.get("nic")
|
||||
api_name = api_data.get("name_en")
|
||||
api_house_name = api_data.get("house_name_en")
|
||||
api_dob = api_data.get("dob")
|
||||
api_atoll = api_data.get("atoll_en")
|
||||
api_island_name = api_data.get("island_name_en")
|
||||
api_nic = api_reponse.get("nic")
|
||||
api_name = api_reponse.get("name_en")
|
||||
api_house_name = api_reponse.get("house_name_en")
|
||||
api_dob = api_reponse.get("dob")
|
||||
api_atoll = api_reponse.get("atoll_en")
|
||||
api_island_name = api_reponse.get("island_name_en")
|
||||
|
||||
# Perform comparisons and identify mismatches
|
||||
if user_data.id_card != api_nic:
|
||||
@@ -134,6 +138,11 @@ def check_person_api_verification(
|
||||
)
|
||||
|
||||
if mismatch_fields:
|
||||
return {"ok": False, "mismatch_fields": mismatch_fields}
|
||||
return {
|
||||
"ok": False,
|
||||
"mismatch_fields": mismatch_fields,
|
||||
"error": None,
|
||||
"detail": None,
|
||||
}
|
||||
else:
|
||||
return {"ok": True, "mismatch_fields": []}
|
||||
return {"ok": True, "mismatch_fields": [], "error": None, "detail": None}
|
||||
|
||||
+525
-207
@@ -2,36 +2,44 @@
|
||||
from django.contrib.auth import login
|
||||
|
||||
# rest_framework imports
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from rest_framework import generics, permissions
|
||||
from rest_framework.authtoken.serializers import AuthTokenSerializer
|
||||
from api.filters import UserFilter
|
||||
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 rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from decouple import config
|
||||
import os
|
||||
from api.serializers import (
|
||||
AtollSerializer,
|
||||
IslandSerializer,
|
||||
CustomUserByWalletBalanceSerializer,
|
||||
OTPVerificationSerializer,
|
||||
TemporaryUserSerializer,
|
||||
UserUpdateSerializer,
|
||||
UserAgreementSerializer,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
# knox imports
|
||||
from knox.views import LoginView as KnoxLoginView
|
||||
from knox.models import AuthToken
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
import re
|
||||
from typing import cast, Dict, Any
|
||||
from django.core.mail import send_mail
|
||||
from django.db.models import Q
|
||||
from api.notifications import send_otp
|
||||
from .utils import check_person_api_verification
|
||||
import uuid
|
||||
from .helpers import ErrorMessages, validate_required_fields, validate_unique_fields, validate_patterns, calculate_age
|
||||
|
||||
|
||||
|
||||
# local apps import
|
||||
from .serializers import (
|
||||
@@ -39,22 +47,9 @@ from .serializers import (
|
||||
AuthSerializer,
|
||||
CustomUserSerializer,
|
||||
CustomReadOnlyUserSerializer,
|
||||
CustomReadOnlyUserByIDCardSerializer,
|
||||
UserProfileUpdateSerializer,
|
||||
)
|
||||
|
||||
ID_CARD_PATTERN = r"^[A-Z]{1,2}[0-9]{6,7}$"
|
||||
MOBILE_PATTERN = r"^[7|9][0-9]{6}$"
|
||||
ACCOUNT_NUMBER_PATTERN = r"^(7\d{12}|9\d{16})$"
|
||||
|
||||
|
||||
class ErrorMessages:
|
||||
USERNAME_EXISTS = "Username already exists."
|
||||
MOBILE_EXISTS = "Mobile number already exists."
|
||||
INVALID_ID_CARD = "Please enter a valid ID card number."
|
||||
ID_CARD_EXISTS = "ID card already exists."
|
||||
INVALID_MOBILE = "Please enter a valid mobile number."
|
||||
INVALID_ACCOUNT = "Please enter a valid account number."
|
||||
UNDERAGE_ERROR = "You must be 18 and above to signup."
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@@ -62,170 +57,136 @@ def healthcheck(request):
|
||||
return Response({"status": "Good"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UpdateUserWalletView(generics.UpdateAPIView):
|
||||
# Create user API view
|
||||
serializer_class = CustomUserByWalletBalanceSerializer
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
queryset = User.objects.all()
|
||||
lookup_field = "pk"
|
||||
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
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
id_to_update = kwargs.get("pk")
|
||||
user_id = request.user.id
|
||||
print(f"User ID: {user_id}")
|
||||
print(f"ID to update: {id_to_update}")
|
||||
if user_id != id_to_update:
|
||||
return Response(
|
||||
{"message": "You are not authorized to update this user."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
wallet_balance = request.data.get("wallet_balance")
|
||||
if not wallet_balance:
|
||||
return Response(
|
||||
{"message": "wallet_balance is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
user = self.get_object()
|
||||
user.wallet_balance = wallet_balance
|
||||
user.save()
|
||||
return Response({"message": "Wallet balance updated successfully."})
|
||||
|
||||
|
||||
class CreateTemporaryUserView(generics.CreateAPIView):
|
||||
# Create user API view
|
||||
serializer_class = TemporaryUserSerializer
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
queryset = TemporaryUser.objects.all()
|
||||
throttle_classes = []
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
# Extract required fields from request data
|
||||
username = request.data.get("username")
|
||||
address = request.data.get("address")
|
||||
mobile = request.data.get("mobile")
|
||||
acc_no = request.data.get("acc_no")
|
||||
id_card = request.data.get("id_card")
|
||||
dob = request.data.get("dob")
|
||||
atoll_id = request.data.get("atoll")
|
||||
island_id = request.data.get("island")
|
||||
terms_accepted = request.data.get("terms_accepted")
|
||||
policy_accepted = request.data.get("policy_accepted")
|
||||
firstname = request.data.get("firstname")
|
||||
lastname = request.data.get("lastname")
|
||||
# Extract data once
|
||||
data = request.data
|
||||
|
||||
current_date = timezone.now()
|
||||
# Validate required fields
|
||||
required_error = validate_required_fields(data)
|
||||
if required_error:
|
||||
return required_error
|
||||
|
||||
# Parse DOB
|
||||
dob_str = data.get("dob")
|
||||
try:
|
||||
dob = timezone.datetime.strptime(str(dob), "%Y-%m-%d").date()
|
||||
dob = timezone.datetime.strptime(str(dob_str), "%Y-%m-%d").date() # pyright: ignore[reportAttributeAccessIssue]
|
||||
except ValueError:
|
||||
return Response(
|
||||
{"message": "Invalid date format for DOB. Use YYYY-MM-DD."}, status=400
|
||||
)
|
||||
return Response({"message": "Invalid date format for DOB. Use YYYY-MM-DD."}, status=400)
|
||||
|
||||
age_from_dob = (
|
||||
current_date.year
|
||||
- dob.year
|
||||
- ((current_date.month, current_date.day) < (dob.month, dob.day))
|
||||
)
|
||||
|
||||
if age_from_dob < 18:
|
||||
# Check age
|
||||
age = calculate_age(dob)
|
||||
if age < 18:
|
||||
return Response({"message": ErrorMessages.UNDERAGE_ERROR}, status=400)
|
||||
|
||||
if (
|
||||
TemporaryUser.objects.filter(t_mobile=mobile).exists()
|
||||
or User.objects.filter(mobile=mobile).exists()
|
||||
):
|
||||
return Response({"message": ErrorMessages.MOBILE_EXISTS}, status=400)
|
||||
if (
|
||||
TemporaryUser.objects.filter(t_username=username).exists()
|
||||
or User.objects.filter(username=username).exists()
|
||||
):
|
||||
return Response({"message": ErrorMessages.USERNAME_EXISTS}, status=400)
|
||||
if (
|
||||
TemporaryUser.objects.filter(t_id_card=id_card).exists()
|
||||
or User.objects.filter(id_card=id_card).exists()
|
||||
):
|
||||
return Response({"message": "ID card already exists."}, status=400)
|
||||
if (
|
||||
TemporaryUser.objects.filter(t_id_card=id_card).exists()
|
||||
or User.objects.filter(id_card=id_card).exists()
|
||||
):
|
||||
return Response({"message": ErrorMessages.ID_CARD_EXISTS}, status=400)
|
||||
if id_card and not re.match(ID_CARD_PATTERN, id_card):
|
||||
return Response({"message": ErrorMessages.INVALID_ID_CARD}, status=400)
|
||||
if mobile is None or not re.match(MOBILE_PATTERN, mobile):
|
||||
return Response({"message": ErrorMessages.INVALID_MOBILE}, status=400)
|
||||
if acc_no is None or not re.match(ACCOUNT_NUMBER_PATTERN, acc_no):
|
||||
return Response({"message": ErrorMessages.INVALID_ACCOUNT}, status=400)
|
||||
# Validate uniqueness
|
||||
uniqueness_error = validate_unique_fields(
|
||||
username=data.get("username"),
|
||||
mobile=data.get("mobile"),
|
||||
id_card=data.get("id_card"),
|
||||
)
|
||||
if uniqueness_error:
|
||||
return uniqueness_error
|
||||
|
||||
# Validate required fields first
|
||||
validation_error = self.validate_required_fields(request.data)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
# Validate patterns
|
||||
pattern_error = validate_patterns(
|
||||
id_card=data.get("id_card"),
|
||||
mobile=data.get("mobile"),
|
||||
acc_no=data.get("acc_no"),
|
||||
)
|
||||
if pattern_error:
|
||||
return pattern_error
|
||||
|
||||
# Fetch Atoll and Island instances
|
||||
# Fetch related objects
|
||||
atoll_id = data.get("atoll")
|
||||
island_id = data.get("island")
|
||||
try:
|
||||
atoll = Atoll.objects.get(id=atoll_id)
|
||||
island = Island.objects.get(id=island_id)
|
||||
except Atoll.DoesNotExist:
|
||||
return Response({"message": "Atoll not found."}, status=404)
|
||||
except Island.DoesNotExist:
|
||||
return Response({"message": "Island not found."}, status=404)
|
||||
except ObjectDoesNotExist as e:
|
||||
model_name = "Atoll" if isinstance(e, Atoll.DoesNotExist) else "Island"
|
||||
return Response({"message": f"{model_name} not found."}, status=404)
|
||||
|
||||
# Create user
|
||||
temp_user = TemporaryUser.objects.create(
|
||||
t_first_name=firstname,
|
||||
t_last_name=lastname,
|
||||
t_username=str(username),
|
||||
t_first_name=data.get("firstname"),
|
||||
t_last_name=data.get("lastname"),
|
||||
t_username=str(data.get("username")),
|
||||
t_email=None,
|
||||
t_address=address,
|
||||
t_mobile=mobile,
|
||||
t_acc_no=acc_no,
|
||||
t_id_card=id_card,
|
||||
t_address=data.get("address"),
|
||||
t_mobile=data.get("mobile"),
|
||||
t_acc_no=data.get("acc_no"),
|
||||
t_id_card=data.get("id_card"),
|
||||
t_dob=dob,
|
||||
t_atoll=atoll,
|
||||
t_island=island,
|
||||
t_terms_accepted=terms_accepted,
|
||||
t_policy_accepted=policy_accepted,
|
||||
t_terms_accepted=data.get("terms_accepted"),
|
||||
t_policy_accepted=data.get("policy_accepted"),
|
||||
)
|
||||
otp_expiry = timezone.now() + timedelta(minutes=3)
|
||||
|
||||
# Generate and send OTP
|
||||
otp_expiry = timezone.now() + timezone.timedelta(minutes=3) #type: ignore
|
||||
formatted_time = otp_expiry.strftime("%d/%m/%Y %H:%M:%S")
|
||||
otp = temp_user.generate_otp()
|
||||
send_otp(
|
||||
str(temp_user.t_mobile),
|
||||
f"Your Registration SARLink OTP: {otp}. \nExpires at {formatted_time}. \n\n- SAR Link",
|
||||
)
|
||||
|
||||
# Return success
|
||||
serializer = self.get_serializer(temp_user)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
def validate_required_fields(self, data):
|
||||
required_fields = {
|
||||
"firstname": "First name",
|
||||
"lastname": "Last name",
|
||||
"username": "Username",
|
||||
"address": "Address",
|
||||
"mobile": "Mobile number",
|
||||
"acc_no": "Account number",
|
||||
"id_card": "ID card",
|
||||
"dob": "Date of birth",
|
||||
"atoll": "Atoll",
|
||||
"island": "Island",
|
||||
}
|
||||
|
||||
for field, label in required_fields.items():
|
||||
if not data.get(field):
|
||||
return Response({"message": f"{label} is required."}, status=400)
|
||||
|
||||
if data.get("terms_accepted") is None:
|
||||
return Response({"message": "Terms acceptance is required."}, status=400)
|
||||
if data.get("policy_accepted") is None:
|
||||
return Response({"message": "Policy acceptance is required."}, status=400)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class VerifyOTPView(generics.GenericAPIView):
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
serializer_class = OTPVerificationSerializer
|
||||
@@ -257,7 +218,7 @@ class VerifyOTPView(generics.GenericAPIView):
|
||||
return Response({"message": "Invalid OTP."}, status=400)
|
||||
|
||||
# Create real user
|
||||
User.objects.create_user(
|
||||
user = User.objects.create_user(
|
||||
first_name=temp_user.t_first_name,
|
||||
last_name=temp_user.t_last_name,
|
||||
username=str(temp_user.t_username),
|
||||
@@ -267,17 +228,78 @@ class VerifyOTPView(generics.GenericAPIView):
|
||||
acc_no=temp_user.t_acc_no,
|
||||
id_card=temp_user.t_id_card,
|
||||
dob=temp_user.t_dob,
|
||||
verified=temp_user.t_verified,
|
||||
atoll=temp_user.t_atoll,
|
||||
island=temp_user.t_island,
|
||||
terms_accepted=temp_user.t_terms_accepted,
|
||||
policy_accepted=temp_user.t_policy_accepted,
|
||||
)
|
||||
|
||||
# You can now trigger registry verification as a signal or task
|
||||
temp_user.otp_verified = True
|
||||
temp_user.save()
|
||||
|
||||
return Response({"message": "User created successfully."})
|
||||
if temp_user.t_verified:
|
||||
user.set_status(User.STATUS_VERIFIED)
|
||||
send_sms(
|
||||
t_user.t_mobile,
|
||||
f"Dear {temp_user.t_first_name} {temp_user.t_last_name}, \n\nYour account has been successfully verified. \n\nYou can now manage your devices and make payments through our portal at {FRONTEND_URL}. \n\n - SAR Link",
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"message": "User created successfully.",
|
||||
"verified": True,
|
||||
"status": user.status,
|
||||
}
|
||||
)
|
||||
|
||||
# Auto-verification failed -> ask the user to upload their ID/passport.
|
||||
token = request_id_upload(user)
|
||||
return Response(
|
||||
{
|
||||
"message": "User created successfully.",
|
||||
"verified": 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):
|
||||
@@ -305,17 +327,105 @@ class LoginView(KnoxLoginView):
|
||||
return Response({"message": message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ManageUserView(generics.RetrieveUpdateAPIView):
|
||||
"""Manage the authenticated user"""
|
||||
class UserprofileAPIView(generics.RetrieveUpdateAPIView):
|
||||
"""Retrieve user api view"""
|
||||
|
||||
serializer_class = CustomUserSerializer
|
||||
queryset = User.objects.all()
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return the serializer class based on the request method"""
|
||||
if self.request.method == "GET":
|
||||
return CustomReadOnlyUserSerializer
|
||||
elif self.request.method == "PUT" or self.request.method == "PATCH":
|
||||
return UserProfileUpdateSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
def get_object(self):
|
||||
"""Retrieve and return authenticated user"""
|
||||
return self.request.user
|
||||
|
||||
|
||||
class UserUpdateAPIView(generics.UpdateAPIView):
|
||||
permission_classes = [IsAdminOrStaffPermission]
|
||||
serializer_class = UserUpdateSerializer
|
||||
queryset = User.objects.all()
|
||||
lookup_field = "pk"
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
user_id = kwargs.get("pk")
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
if user.is_superuser:
|
||||
return Response(
|
||||
{"message": "You cannot update a superuser."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if request.user != user and not user_is_admin(request.user):
|
||||
return Response(
|
||||
{"message": "You are not authorized to update this user."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
serializer = self.get_serializer(
|
||||
user,
|
||||
data=request.data,
|
||||
partial=True,
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user.save()
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class AgreementUpdateAPIView(generics.UpdateAPIView):
|
||||
permission_classes = [IsAdminOrStaffPermission]
|
||||
serializer_class = UserAgreementSerializer
|
||||
queryset = User.objects.all()
|
||||
lookup_field = "pk"
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
user_id = kwargs.get("pk")
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
if user.is_superuser:
|
||||
return Response(
|
||||
{"message": "You cannot update a superuser."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if request.user != user and not user_is_admin(request.user):
|
||||
return Response(
|
||||
{"message": "You are not authorized to update this user."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
serializer = self.get_serializer(
|
||||
user,
|
||||
data=request.data,
|
||||
partial=True,
|
||||
)
|
||||
agreement = request.data.get("agreement")
|
||||
if not agreement:
|
||||
return Response(
|
||||
{"message": "Agreement file is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if agreement.size > 10 * 1024 * 1024: # 5 MB limit
|
||||
return Response(
|
||||
{"message": "File size exceeds 10 MB limit."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if agreement.content_type not in [
|
||||
"application/pdf",
|
||||
]:
|
||||
return Response(
|
||||
{"message": "Invalid file type. Only PDF files are allowed."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# rename the file name to a random UUID followed by user_id
|
||||
agreement.name = f"{uuid.uuid4()}_{user_id}_agreement.pdf"
|
||||
if agreement:
|
||||
user.agreement = agreement
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user.save()
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class KnoxTokenListApiView(
|
||||
StaffEditorPermissionMixin,
|
||||
generics.ListAPIView,
|
||||
@@ -336,19 +446,20 @@ class KnoxTokenListApiView(
|
||||
|
||||
class ListUserView(StaffEditorPermissionMixin, generics.ListAPIView):
|
||||
serializer_class = CustomReadOnlyUserSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filter_backends = [DjangoFilterBackend] #type: ignore
|
||||
filterset_fields = "__all__"
|
||||
filterset_class = UserFilter
|
||||
queryset = User.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if user.is_authenticated and user.is_staff:
|
||||
return User.objects.all()
|
||||
return User.objects.filter(is_staff=False)
|
||||
if user.is_authenticated and getattr(user, "is_admin"):
|
||||
return User.objects.filter(is_superuser=False)
|
||||
return User.objects.none()
|
||||
|
||||
|
||||
class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
class UserVerifyAPIView(generics.UpdateAPIView):
|
||||
permission_classes = [IsAdminOrStaffPermission]
|
||||
serializer_class = CustomUserSerializer
|
||||
queryset = User.objects.all()
|
||||
lookup_field = "pk"
|
||||
@@ -356,38 +467,207 @@ class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
def update(self, request, *args, **kwargs):
|
||||
user_id = kwargs.get("pk")
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
if request.user != user and (
|
||||
not request.user.is_authenticated
|
||||
or not getattr(request.user, "is_admin", False)
|
||||
):
|
||||
if request.user != user and not user_is_admin(request.user):
|
||||
return Response(
|
||||
{"message": "You are not authorized to update this user."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
serializer = self.get_serializer(user, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
verified_person = check_person_api_verification(
|
||||
user_data=user, id_card=user.id_card
|
||||
)
|
||||
if not verified_person["ok"]:
|
||||
if user.verified:
|
||||
return Response(
|
||||
{
|
||||
"message": "User verification failed. Please check sarlink user details.",
|
||||
"mismatch_fields": verified_person["mismatch_fields"],
|
||||
},
|
||||
{"message": "User is already verified."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if verified_person["mismatch_fields"]:
|
||||
serializer = self.get_serializer(user, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = check_person_api_verification(user_data=user, id_card=user.id_card)
|
||||
# The verification system might not have the records of every user hence can be skipped if not found and verify directly.
|
||||
if result.get("error") == "Not Found":
|
||||
user.verified = True
|
||||
user.save()
|
||||
return Response(
|
||||
{
|
||||
"message": "User verification failed due to mismatched fields.",
|
||||
"mismatch_fields": verified_person["mismatch_fields"],
|
||||
"message": "User not found in the verification system. User marked as verified."
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if not result["ok"]:
|
||||
return Response(
|
||||
result,
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if result["mismatch_fields"]:
|
||||
return Response(
|
||||
result,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
user.verified = True
|
||||
user.save()
|
||||
return Response({"message": "User verification status updated."})
|
||||
return Response({"message": "User successfully verified."})
|
||||
|
||||
|
||||
class UserRejectAPIView(generics.DestroyAPIView):
|
||||
permission_classes = [IsAdminOrStaffPermission]
|
||||
serializer_class = CustomUserSerializer
|
||||
queryset = User.objects.all()
|
||||
lookup_field = "pk"
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
rejection_details = request.data.get("rejection_details", "")
|
||||
if not rejection_details:
|
||||
return Response(
|
||||
{"message": "Rejection details are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
user_id = kwargs.get("pk")
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
mobile_number = user.mobile
|
||||
if not mobile_number:
|
||||
return Response(
|
||||
{"message": "User does not have a mobile number."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if user.is_superuser:
|
||||
return Response(
|
||||
{"message": "You cannot remove a superuser."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if request.user != user and not user_is_admin(request.user):
|
||||
return Response(
|
||||
{"message": "You are not authorized to reject this user."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
full_name = f"{user.first_name} {user.last_name}".strip() or "Customer"
|
||||
rejection_message = (
|
||||
f"Dear {full_name}, \n\n"
|
||||
"We're sorry, but your SAR Link account registration could not be "
|
||||
"approved at this time. \n\n"
|
||||
f"Reason: {rejection_details} \n\n"
|
||||
"Register again at "
|
||||
f"{FRONTEND_URL}, or contact us for assistance. \n\n"
|
||||
" - SAR Link"
|
||||
)
|
||||
user.delete()
|
||||
t_user = get_object_or_404(TemporaryUser, t_mobile=user.mobile)
|
||||
t_user.delete()
|
||||
send_sms(message=rejection_message, mobile=mobile_number)
|
||||
return Response(
|
||||
{"message": "User successfully rejected."},
|
||||
status=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
|
||||
|
||||
class RequestIdUploadAPIView(generics.GenericAPIView):
|
||||
"""Admin action: ask a user to (re)upload their ID/passport photo."""
|
||||
|
||||
permission_classes = [IsAdminOrStaffPermission]
|
||||
queryset = User.objects.all()
|
||||
lookup_field = "pk"
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
user = get_object_or_404(User, pk=kwargs.get("pk"))
|
||||
if user.is_superuser:
|
||||
return Response(
|
||||
{"message": "You cannot modify a superuser."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if not user.mobile:
|
||||
return Response(
|
||||
{"message": "User does not have a mobile number."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
message_body = request.data.get("message", "")
|
||||
request_id_upload(user, message_body=message_body)
|
||||
return Response({"message": "ID upload request sent to the user."})
|
||||
|
||||
|
||||
class IdUploadTokenInfoAPIView(generics.GenericAPIView):
|
||||
"""Public: validate a magic-link token and return whose upload it is."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
key = request.query_params.get("token", "")
|
||||
token = IdUploadToken.objects.filter(key=key).select_related("user").first()
|
||||
if not token or not token.is_valid():
|
||||
return Response(
|
||||
{"message": "This upload link is invalid or has expired."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
user = token.user
|
||||
return Response(
|
||||
{
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"status": user.status,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class IdUploadStartAPIView(generics.GenericAPIView):
|
||||
"""Public: mint a fresh upload token for a mobile whose account needs an ID.
|
||||
|
||||
Lets the login flow send an `id_required` user straight to the upload page
|
||||
instead of a "pending verification" dead-end. Only works for accounts
|
||||
actually in the id_required state.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
mobile = request.data.get("mobile", "")
|
||||
user = User.objects.filter(mobile=mobile).first()
|
||||
if not user or user.status != User.STATUS_ID_REQUIRED:
|
||||
return Response(
|
||||
{"message": "No ID upload is pending for this number."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
token = IdUploadToken.issue(user)
|
||||
return Response({"token": token.key})
|
||||
|
||||
|
||||
class IdUploadSubmitAPIView(generics.GenericAPIView):
|
||||
"""Public: accept the ID/passport photo for a valid token."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
key = request.data.get("token", "")
|
||||
token = IdUploadToken.objects.filter(key=key).select_related("user").first()
|
||||
if not token or not token.is_valid():
|
||||
return Response(
|
||||
{"message": "This upload link is invalid or has expired."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
photo = request.data.get("id_card_photo")
|
||||
if not photo:
|
||||
return Response(
|
||||
{"message": "An ID card / passport photo is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if photo.size > 10 * 1024 * 1024:
|
||||
return Response(
|
||||
{"message": "File size exceeds the 10 MB limit."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if getattr(photo, "content_type", "") not in ID_PHOTO_CONTENT_TYPES:
|
||||
return Response(
|
||||
{
|
||||
"message": "Invalid file type. Please upload a JPG, PNG or PDF file."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
user = token.user
|
||||
ext = os.path.splitext(photo.name)[1].lower() or ".jpg"
|
||||
photo.name = f"{uuid.uuid4()}_{user.id}_id_card{ext}"
|
||||
user.id_card_photo = photo
|
||||
user.set_status(User.STATUS_ID_SUBMITTED, save=False)
|
||||
user.save()
|
||||
# Single-use: burn the token so the link can't be reused for another
|
||||
# upload. A fresh link is minted if an admin requests another upload.
|
||||
token.used_at = timezone.now()
|
||||
token.save(update_fields=["used_at"])
|
||||
return Response({"message": "Your ID has been submitted for review."})
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@@ -399,18 +679,20 @@ def filter_user(request):
|
||||
return Response({"ok": False})
|
||||
|
||||
filters = Q()
|
||||
if id_card is not None:
|
||||
filters |= Q(id_card=id_card)
|
||||
if mobile is not None:
|
||||
filters |= Q(mobile=mobile)
|
||||
if id_card and mobile:
|
||||
filters = Q(id_card=id_card) & Q(mobile=mobile)
|
||||
elif id_card:
|
||||
filters = Q(id_card=id_card)
|
||||
elif mobile:
|
||||
filters = Q(mobile=mobile)
|
||||
|
||||
user = User.objects.filter(filters).first()
|
||||
user = User.objects.only("id", "verified", "status").filter(filters).first()
|
||||
|
||||
print(f"Querying with filters: {filters}")
|
||||
print(f"Found user: {user}")
|
||||
|
||||
return Response(
|
||||
{"ok": True, "verified": user.verified}
|
||||
{"ok": True, "verified": user.verified, "status": user.status}
|
||||
if user
|
||||
else {"ok": False, "verified": False}
|
||||
)
|
||||
@@ -425,40 +707,43 @@ def filter_temporary_user(request):
|
||||
return Response({"ok": False})
|
||||
|
||||
filters = Q()
|
||||
if id_card is not None:
|
||||
if id_card and mobile:
|
||||
filters |= Q(t_id_card=id_card) & Q(t_mobile=mobile)
|
||||
elif id_card:
|
||||
filters |= Q(t_id_card=id_card)
|
||||
if mobile is not None:
|
||||
elif mobile:
|
||||
filters |= Q(t_mobile=mobile)
|
||||
|
||||
user = TemporaryUser.objects.filter(filters).first()
|
||||
user = (
|
||||
TemporaryUser.objects.only("t_id", "otp_verified", "t_verified")
|
||||
.filter(filters)
|
||||
.first()
|
||||
)
|
||||
|
||||
print(f"Querying with filters: {filters}")
|
||||
print(f"Found temporary user: {user}")
|
||||
|
||||
return Response(
|
||||
{"ok": True, "otp_verified": user.otp_verified}
|
||||
{"ok": True, "otp_verified": user.otp_verified, "t_verified": user.t_verified}
|
||||
if user
|
||||
else {"ok": False, "otp_verified": False}
|
||||
else {"ok": False, "otp_verified": False, "t_verified": False}
|
||||
)
|
||||
|
||||
|
||||
class ListUserByIDCardView(generics.ListAPIView):
|
||||
# Create user API view
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
serializer_class = CustomReadOnlyUserByIDCardSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = "__all__"
|
||||
filterset_class = UserFilter
|
||||
queryset = User.objects.all()
|
||||
|
||||
|
||||
class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
|
||||
class UserDetailAPIView(generics.RetrieveAPIView):
|
||||
permission_classes = [IsAdminOrStaffPermission]
|
||||
queryset = User.objects.all()
|
||||
serializer_class = CustomReadOnlyUserSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
user = request.user
|
||||
if user != instance and not user_is_admin(user):
|
||||
return Response(
|
||||
{"message": "You are not authorized to view this user's details."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
serializer = self.get_serializer(instance)
|
||||
data = serializer.data
|
||||
|
||||
@@ -467,19 +752,6 @@ class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
|
||||
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):
|
||||
serializer_class = AtollSerializer
|
||||
queryset = Atoll.objects.all()
|
||||
@@ -546,3 +818,49 @@ class RetrieveUpdateDestroyIslandView(
|
||||
if name and Island.objects.filter(name=name).exclude(pk=instance.pk).exists():
|
||||
return Response({"message": "Island name already exists."}, status=400)
|
||||
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)
|
||||
|
||||
+17
-21
@@ -26,7 +26,7 @@ env.read_env(os.path.join(BASE_DIR, ".env"))
|
||||
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = env("SECRET_KEY", default=get_random_secret_key())
|
||||
SECRET_KEY = env("SECRET_KEY", default=get_random_secret_key()) #type: ignore
|
||||
|
||||
DEBUG = env.bool("DJANGO_DEBUG", default=True) # type: ignore
|
||||
|
||||
@@ -52,10 +52,8 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"rest_framework",
|
||||
"django_rest_passwordreset",
|
||||
"djangopasswordlessknox",
|
||||
"django_extensions",
|
||||
"django_seed",
|
||||
"storages",
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.staticfiles",
|
||||
@@ -159,9 +157,9 @@ DATABASES = {
|
||||
# Password validation
|
||||
# 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"
|
||||
AUTHENTICATION_BACKENDS = ["api.backends.EmailBackend"]
|
||||
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
@@ -203,6 +201,14 @@ STATICFILES_DIRS = [
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||
|
||||
# Uploaded ID/passport photos live in a dedicated directory so it can be
|
||||
# bind-mounted as a persistent volume in production. Override with the
|
||||
# IDCARD_STORAGE_ROOT / IDCARD_STORAGE_URL env vars to point at the mount.
|
||||
IDCARD_STORAGE_ROOT = os.environ.get(
|
||||
"IDCARD_STORAGE_ROOT", os.path.join(BASE_DIR, "storage", "idcards")
|
||||
)
|
||||
IDCARD_STORAGE_URL = os.environ.get("IDCARD_STORAGE_URL", "/storage/idcards/")
|
||||
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
|
||||
@@ -235,8 +241,11 @@ REST_FRAMEWORK = {
|
||||
"login": "1000/min",
|
||||
},
|
||||
"EXCEPTION_HANDLER": "api.exceptions.custom_exception_handler",
|
||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||
# "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema"
|
||||
"DEFAULT_RENDERER_CLASSES": (
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
# "rest_framework.renderers.BrowsableAPIRenderer",
|
||||
),
|
||||
# "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
}
|
||||
|
||||
|
||||
@@ -336,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_EMAIL_TOKEN_HTML_TEMPLATE_NAME': "password_reset_email.html",
|
||||
"PASSWORDLESS_AUTH_TYPES": ["EMAIL", "MOBILE"],
|
||||
"PASSWORDLESS_AUTH_TYPES": ["MOBILE"],
|
||||
"PASSWORDLESS_USER_MOBILE_FIELD_NAME": "mobile",
|
||||
"PASSWORDLESS_TEST_SUPPRESSION": False,
|
||||
"PASSWORDLESS_REGISTER_NEW_USERS": True,
|
||||
"PASSWORDLESS_EMAIL_NOREPLY_ADDRESS": "noreply@sarlink.net",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+3
-4
@@ -27,10 +27,6 @@ from drf_spectacular.views import (
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path(
|
||||
"api/password_reset/",
|
||||
include("django_rest_passwordreset.urls", namespace="password_reset"),
|
||||
),
|
||||
path("", include("djangopasswordlessknox.urls")),
|
||||
# Authentication
|
||||
path("api/auth/", include("api.urls")),
|
||||
@@ -41,6 +37,9 @@ urlpatterns = [
|
||||
]
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(
|
||||
settings.IDCARD_STORAGE_URL, document_root=settings.IDCARD_STORAGE_ROOT
|
||||
)
|
||||
urlpatterns += (path("api-auth/", include("rest_framework.urls")),)
|
||||
urlpatterns += (path("__debug__/", include("debug_toolbar.urls")),)
|
||||
urlpatterns += (path("api/schema/", SpectacularAPIView.as_view(), name="schema"),)
|
||||
|
||||
+21
-1
@@ -1,9 +1,28 @@
|
||||
from django.contrib import admin
|
||||
from .models import Payment, BillFormula, Topup
|
||||
from .models import Payment, BillFormula, Topup, WalletTransaction
|
||||
|
||||
# Register your models here.
|
||||
|
||||
|
||||
class WalletTransactionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"user",
|
||||
"amount",
|
||||
"transaction_type",
|
||||
"description",
|
||||
"reference_id",
|
||||
"created_at",
|
||||
)
|
||||
search_fields = (
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
"user__mobile",
|
||||
"user__id_card",
|
||||
)
|
||||
list_filter = ("transaction_type",)
|
||||
|
||||
|
||||
class PaymentAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
@@ -53,3 +72,4 @@ class TopupAdmin(admin.ModelAdmin):
|
||||
admin.site.register(Payment, PaymentAdmin)
|
||||
admin.site.register(BillFormula)
|
||||
admin.site.register(Topup, TopupAdmin)
|
||||
admin.site.register(WalletTransaction, WalletTransactionAdmin)
|
||||
|
||||
+39
-2
@@ -1,5 +1,5 @@
|
||||
import django_filters
|
||||
from .models import Payment, Topup
|
||||
from .models import Payment, Topup, WalletTransaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -8,6 +8,7 @@ class PaymentFilter(django_filters.FilterSet):
|
||||
amount = django_filters.RangeFilter(field_name="amount")
|
||||
number_of_months = django_filters.RangeFilter(field_name="number_of_months")
|
||||
paid = django_filters.BooleanFilter(field_name="paid")
|
||||
user = django_filters.CharFilter(method="filter_user_search")
|
||||
method = django_filters.ChoiceFilter(
|
||||
choices=Payment.PAYMENT_TYPES, lookup_expr="iexact"
|
||||
)
|
||||
@@ -16,6 +17,14 @@ class PaymentFilter(django_filters.FilterSet):
|
||||
created_at = django_filters.DateFromToRangeFilter()
|
||||
is_expired = django_filters.BooleanFilter(method="filter_is_expired")
|
||||
|
||||
def filter_user_search(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(user__first_name__icontains=value)
|
||||
| Q(user__last_name__icontains=value)
|
||||
| Q(user__id_card__icontains=value)
|
||||
| Q(user__mobile__icontains=value)
|
||||
)
|
||||
|
||||
def filter_is_expired(self, queryset, name, value):
|
||||
"""
|
||||
Filter payments based on whether they are expired or not
|
||||
@@ -29,7 +38,14 @@ class PaymentFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Payment
|
||||
fields = "__all__"
|
||||
fields = [
|
||||
"amount",
|
||||
"paid",
|
||||
"method",
|
||||
"user",
|
||||
"created_at",
|
||||
"is_expired",
|
||||
]
|
||||
|
||||
|
||||
class TopupFilter(django_filters.FilterSet):
|
||||
@@ -71,3 +87,24 @@ class TopupFilter(django_filters.FilterSet):
|
||||
"created_at",
|
||||
"is_expired",
|
||||
]
|
||||
|
||||
|
||||
class WalletTransactionFilter(django_filters.FilterSet):
|
||||
user = django_filters.CharFilter(method="filter_user_search")
|
||||
amount = django_filters.RangeFilter(field_name="amount")
|
||||
created_at = django_filters.DateFromToRangeFilter(field_name="created_at")
|
||||
|
||||
def filter_user_search(self, queryset, name, value):
|
||||
"""
|
||||
Search across multiple user fields: first_name, last_name, id_card, mobile
|
||||
"""
|
||||
return queryset.filter(
|
||||
Q(user__first_name__icontains=value)
|
||||
| Q(user__last_name__icontains=value)
|
||||
| Q(user__id_card__icontains=value)
|
||||
| Q(user__mobile__icontains=value)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = WalletTransaction
|
||||
fields = ["user", "amount", "created_at", "transaction_type"]
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 5.2 on 2025-07-25 08:34
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("billing", "0013_payment_expiry_notification_sent"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WalletTransaction",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("amount", models.FloatField()),
|
||||
(
|
||||
"transaction_type",
|
||||
models.CharField(
|
||||
choices=[("TOPUP", "Topup"), ("DEBIT", "Debit")], max_length=10
|
||||
),
|
||||
),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"reference_id",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(default=django.utils.timezone.now)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="wallet_transactions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2 on 2025-07-27 07:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("billing", "0014_wallettransaction"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="topup",
|
||||
name="payment_type",
|
||||
field=models.CharField(
|
||||
choices=[("CASH", "Cash"), ("TRANSFER", "Transfer")],
|
||||
default="TRANSFER",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2 on 2025-09-20 16:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("billing", "0015_topup_payment_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="payment",
|
||||
name="source_bank",
|
||||
field=models.CharField(blank=True, default="", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="topup",
|
||||
name="source_bank",
|
||||
field=models.CharField(blank=True, default="", null=True),
|
||||
),
|
||||
]
|
||||
+40
-5
@@ -1,11 +1,11 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from api.models import User
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from devices.models import Device
|
||||
|
||||
# Create your models here.
|
||||
|
||||
from devices.models import Device
|
||||
user = settings.AUTH_USER_MODEL
|
||||
|
||||
# Create your models here.
|
||||
|
||||
@@ -17,10 +17,11 @@ class Payment(models.Model):
|
||||
]
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
mib_reference = models.CharField(default="", null=True, blank=True)
|
||||
source_bank = models.CharField(default="", null=True, blank=True)
|
||||
number_of_months = models.IntegerField()
|
||||
amount = models.FloatField()
|
||||
paid = models.BooleanField(default=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payments")
|
||||
user = models.ForeignKey(user, on_delete=models.CASCADE, related_name="payments")
|
||||
paid_at = models.DateTimeField(null=True, blank=True)
|
||||
method = models.CharField(max_length=255, choices=PAYMENT_TYPES, default="TRANSFER")
|
||||
expiry_notification_sent = models.BooleanField(default=False)
|
||||
@@ -65,7 +66,15 @@ class BillFormula(models.Model):
|
||||
class Topup(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
amount = models.FloatField()
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="topups")
|
||||
user = models.ForeignKey(user, on_delete=models.CASCADE, related_name="topups")
|
||||
payment_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
("CASH", "Cash"),
|
||||
("TRANSFER", "Transfer"),
|
||||
],
|
||||
default="TRANSFER",
|
||||
)
|
||||
paid = models.BooleanField(default=False)
|
||||
paid_at = models.DateTimeField(null=True, blank=True)
|
||||
status = models.CharField(
|
||||
@@ -78,6 +87,7 @@ class Topup(models.Model):
|
||||
default="PENDING",
|
||||
)
|
||||
mib_reference = models.CharField(default="", null=True, blank=True)
|
||||
source_bank = models.CharField(default="", null=True, blank=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
expiry_notification_sent = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
@@ -94,3 +104,28 @@ class Topup(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
class WalletTransaction(models.Model):
|
||||
TRANSACTION_TYPES = [
|
||||
("TOPUP", "Topup"),
|
||||
("DEBIT", "Debit"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="wallet_transactions",
|
||||
)
|
||||
amount = models.FloatField()
|
||||
transaction_type = models.CharField(max_length=10, choices=TRANSACTION_TYPES)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
reference_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.transaction_type} {self.amount} ({self.user.username})"
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
+35
-3
@@ -1,11 +1,23 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Payment, Topup
|
||||
from devices.serializers import DeviceSerializer
|
||||
from .models import Payment, Topup, WalletTransaction
|
||||
from devices.serializers import AdminDeviceSerializer
|
||||
|
||||
|
||||
class PaymentSerializer(serializers.ModelSerializer):
|
||||
devices = DeviceSerializer(many=True, read_only=True)
|
||||
devices = AdminDeviceSerializer(many=True, read_only=True)
|
||||
is_expired = serializers.SerializerMethodField()
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
def get_user(self, obj):
|
||||
user = obj.user
|
||||
if user:
|
||||
return {
|
||||
"id": user.id,
|
||||
"name": user.first_name + " " + user.last_name,
|
||||
"id_card": user.id_card,
|
||||
"mobile": user.mobile,
|
||||
}
|
||||
return None
|
||||
|
||||
def get_is_expired(self, obj):
|
||||
return obj.is_expired
|
||||
@@ -57,3 +69,23 @@ class TopupSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class WalletTransactionSerializer(serializers.ModelSerializer):
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
def get_user(self, obj):
|
||||
user = obj.user
|
||||
if user:
|
||||
return {
|
||||
"id": user.id,
|
||||
"name": user.first_name + " " + user.last_name,
|
||||
"id_card": user.id_card,
|
||||
"mobile": user.mobile,
|
||||
}
|
||||
return None
|
||||
|
||||
class Meta: # type: ignore
|
||||
model = WalletTransaction
|
||||
fields = "__all__"
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
@@ -10,6 +10,9 @@ from .views import (
|
||||
VerifyTopupPaymentAPIView,
|
||||
TopupDetailAPIView,
|
||||
CancelTopupView,
|
||||
ListWalletTransactionView,
|
||||
AdminTopupCreateView,
|
||||
# AlertTestView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -36,9 +39,18 @@ urlpatterns = [
|
||||
VerifyTopupPaymentAPIView.as_view(),
|
||||
name="verify-topup-payment",
|
||||
),
|
||||
path("admin-topup/", AdminTopupCreateView.as_view(), name="admin-topup"),
|
||||
path(
|
||||
"topup/<str:pk>/cancel/",
|
||||
CancelTopupView.as_view(),
|
||||
name="cancel-topup",
|
||||
),
|
||||
# Wallet transactions
|
||||
path(
|
||||
"wallet-transactions/",
|
||||
ListWalletTransactionView.as_view(),
|
||||
name="list-wallet-transactions",
|
||||
),
|
||||
# Test tg notification
|
||||
# path("test-alert/", AlertTestView.as_view(), name="test-alert"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
def calculate_total_new_price(number_of_devices, number_of_months):
|
||||
monthly_price_map = {
|
||||
1: 100,
|
||||
2: 175,
|
||||
3: 250,
|
||||
4: 325,
|
||||
5: 400,
|
||||
6: 475,
|
||||
7: 550,
|
||||
8: 625,
|
||||
9: 700,
|
||||
10: 775,
|
||||
11: 850,
|
||||
12: 925,
|
||||
13: 1000,
|
||||
14: 1075,
|
||||
15: 1150,
|
||||
16: 1225,
|
||||
17: 1300,
|
||||
}
|
||||
|
||||
if number_of_devices < 1 or number_of_devices > 17:
|
||||
raise ValueError("Number of devices must be between 1 and 17.")
|
||||
|
||||
monthly_price = monthly_price_map[number_of_devices]
|
||||
total_price = monthly_price * number_of_months
|
||||
print(f"Monthly price for {number_of_devices} devices: {monthly_price}")
|
||||
|
||||
print(f"Total price for {number_of_months} months: {total_price}")
|
||||
return total_price
|
||||
|
||||
|
||||
calculate_total_new_price(number_of_devices=2, number_of_months=3)
|
||||
+326
-66
@@ -13,13 +13,25 @@ from rest_framework.response import Response
|
||||
from api.mixins import StaffEditorPermissionMixin
|
||||
from api.tasks import add_new_devices_to_omada
|
||||
from apibase.env import BASE_DIR, env
|
||||
from django.db.models import Prefetch
|
||||
import logging
|
||||
from .utils import calculate_total_new_price
|
||||
|
||||
from .models import Device, Payment, Topup
|
||||
from .serializers import PaymentSerializer, UpdatePaymentSerializer, TopupSerializer
|
||||
from .filters import PaymentFilter, TopupFilter
|
||||
from .models import Device, Payment, Topup, WalletTransaction
|
||||
from .serializers import (
|
||||
PaymentSerializer,
|
||||
UpdatePaymentSerializer,
|
||||
TopupSerializer,
|
||||
WalletTransactionSerializer,
|
||||
)
|
||||
from .filters import PaymentFilter, TopupFilter, WalletTransactionFilter
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Optional
|
||||
from api.models import User
|
||||
from api.omada import Omada
|
||||
|
||||
# from api.bot import send_telegram_alert, telegram_loop, escape_markdown_v2
|
||||
# import asyncio
|
||||
|
||||
env.read_env(os.path.join(BASE_DIR, ".env"))
|
||||
|
||||
@@ -49,23 +61,31 @@ class InsufficientFundsError(Exception):
|
||||
class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
|
||||
serializer_class = PaymentSerializer
|
||||
queryset = Payment.objects.all().select_related("user")
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filter_backends = [DjangoFilterBackend] #type: ignore
|
||||
filterset_fields = "__all__"
|
||||
filterset_class = PaymentFilter
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.request.user.is_superuser:
|
||||
return queryset
|
||||
return queryset.filter(user=self.request.user)
|
||||
unpaid_qs = Payment.objects.filter(paid=False).order_by("-created_at")
|
||||
device_qs = Device.objects.prefetch_related(
|
||||
Prefetch("payments", queryset=unpaid_qs, to_attr="unpaid_payments")
|
||||
)
|
||||
queryset = Payment.objects.select_related("user").prefetch_related(
|
||||
Prefetch("devices", queryset=device_qs)
|
||||
)
|
||||
|
||||
if not self.request.user.is_superuser: #type: ignore
|
||||
queryset = queryset.filter(user=self.request.user)
|
||||
|
||||
return queryset
|
||||
|
||||
def create(self, request):
|
||||
data = request.data
|
||||
user = request.user
|
||||
amount = data.get("amount")
|
||||
number_of_months = data.get("number_of_months")
|
||||
number_of_devices = 0
|
||||
device_ids = data.get("device_ids", [])
|
||||
print(amount, number_of_months, device_ids)
|
||||
print(number_of_months, device_ids)
|
||||
current_time = timezone.now()
|
||||
expires_at = current_time + timedelta(minutes=10)
|
||||
for device_id in device_ids:
|
||||
@@ -76,9 +96,10 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
|
||||
{"message": f"Device with id {device_id} not found."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not amount or not number_of_months:
|
||||
number_of_devices += 1
|
||||
if not number_of_months:
|
||||
return Response(
|
||||
{"message": "amount and number_of_months are required."},
|
||||
{"message": "number_of_months is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not device_ids:
|
||||
@@ -86,7 +107,9 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
|
||||
{"message": "device_ids are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Create payment
|
||||
amount = calculate_total_new_price(
|
||||
number_of_devices=number_of_devices, number_of_months=number_of_months
|
||||
)
|
||||
payment = Payment.objects.create(
|
||||
amount=amount,
|
||||
number_of_months=number_of_months,
|
||||
@@ -105,6 +128,30 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
|
||||
serializer = PaymentSerializer(payment)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
all_payments = request.query_params.get("all_payments", "false").lower() in [
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
]
|
||||
if (
|
||||
request.user.is_authenticated
|
||||
and getattr(request.user, "is_admin")
|
||||
and bool(all_payments)
|
||||
):
|
||||
pass
|
||||
else:
|
||||
queryset = queryset.filter(user=request.user)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class PaymentDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
|
||||
queryset = Payment.objects.select_related("user").all()
|
||||
@@ -113,7 +160,7 @@ class PaymentDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView)
|
||||
|
||||
|
||||
class UpdatePaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
queryset = Payment.objects.select_related("user").all()
|
||||
queryset = Payment.objects.select_related("user").prefetch_related("devices").all()
|
||||
serializer_class = UpdatePaymentSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
@@ -133,22 +180,22 @@ class UpdatePaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
|
||||
class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
serializer_class = PaymentSerializer
|
||||
queryset = Payment.objects.all()
|
||||
queryset = Payment.objects.select_related("user").prefetch_related("devices").all()
|
||||
lookup_field = "pk"
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
# TODO: Fix check for success payment
|
||||
payment = self.get_object()
|
||||
devices = payment.devices.all()
|
||||
data = request.data
|
||||
user = request.user
|
||||
print("logged in user", user)
|
||||
print("Payment user", payment.user)
|
||||
user_details = f"{user.first_name.capitalize() if user.first_name else ''} {user.last_name.capitalize() if user.last_name else ''} {user.mobile}" # type: ignore
|
||||
omada_client = Omada()
|
||||
if payment.paid:
|
||||
return Response(
|
||||
{"message": "Payment has already been verified."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if payment.user != user and not user.is_superuser:
|
||||
if payment.user != user and not user.is_superuser: #type: ignore
|
||||
return Response(
|
||||
{"message": "You are not authorized to verify this payment."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
@@ -160,7 +207,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
devices = payment.devices.all()
|
||||
if method == "WALLET":
|
||||
if user.wallet_balance < payment.amount: # type: ignore
|
||||
return Response(
|
||||
@@ -169,8 +215,33 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
)
|
||||
else:
|
||||
self.process_wallet_payment(
|
||||
user,
|
||||
user, # type: ignore
|
||||
payment,
|
||||
devices,
|
||||
)
|
||||
device_list = []
|
||||
for device in devices:
|
||||
device_list.append(
|
||||
{
|
||||
"mac": device.mac,
|
||||
"name": f"{user_details} - {device.name}",
|
||||
}
|
||||
)
|
||||
if device.registered:
|
||||
omada_client.block_device(
|
||||
mac_address=device.mac, operation="unblock"
|
||||
)
|
||||
if not device.registered:
|
||||
# Add to omada
|
||||
add_new_devices_to_omada.defer(new_devices=device_list)
|
||||
device.registered = True
|
||||
device.save()
|
||||
return Response(
|
||||
{
|
||||
"status": True,
|
||||
"message": "Payment verified successfully using wallet.",
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
if method == "TRANSFER":
|
||||
data = {
|
||||
@@ -187,7 +258,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
is_active=True,
|
||||
expiry_date=expiry_date,
|
||||
has_a_pending_payment=False,
|
||||
registered=True,
|
||||
)
|
||||
payment.status = "PAID"
|
||||
payment.save()
|
||||
@@ -197,9 +267,13 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
device_list.append(
|
||||
{
|
||||
"mac": device.mac,
|
||||
"name": device.name,
|
||||
"name": f"{user_details} - {device.name}",
|
||||
}
|
||||
)
|
||||
if device.registered:
|
||||
omada_client.block_device(
|
||||
mac_address=device.mac, operation="unblock"
|
||||
)
|
||||
if not device.registered:
|
||||
# Add to omada
|
||||
add_new_devices_to_omada.defer(new_devices=device_list)
|
||||
@@ -226,40 +300,63 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def process_wallet_payment(self, user, payment):
|
||||
def process_wallet_payment(self, user: User, payment: Payment, devices=None):
|
||||
print("processing wallet payment...")
|
||||
print(user, payment.amount)
|
||||
# Use passed devices or fetch if not provided
|
||||
if devices is None:
|
||||
devices = payment.devices.all()
|
||||
|
||||
payment.paid = True
|
||||
payment.paid_at = timezone.now()
|
||||
payment.method = "WALLET"
|
||||
payment.status = "PAID"
|
||||
expiry_date = timezone.now() + timedelta(days=30 * payment.number_of_months)
|
||||
devices.update(
|
||||
is_active=True,
|
||||
expiry_date=expiry_date,
|
||||
has_a_pending_payment=False,
|
||||
)
|
||||
payment.save()
|
||||
|
||||
user.wallet_balance -= payment.amount
|
||||
user.deduct_wallet_funds(
|
||||
payment.amount, "Wallet payment for devices", payment.id
|
||||
)
|
||||
user.save()
|
||||
return True
|
||||
|
||||
def verify_transfer_payment(self, data, payment) -> PaymentVerificationResponse:
|
||||
if not PAYMENT_BASE_URL:
|
||||
raise ValueError(
|
||||
"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}")
|
||||
logger.error("PAYMENT_BASE_URL is not set.")
|
||||
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()
|
||||
logger.info("MIB Verification Response ->", mib_resp)
|
||||
if not response.json().get("success"):
|
||||
try:
|
||||
response = requests.post(
|
||||
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(
|
||||
message=mib_resp["message"],
|
||||
success=mib_resp["success"],
|
||||
@@ -270,6 +367,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
payment.paid_at = timezone.now()
|
||||
payment.method = "TRANSFER"
|
||||
payment.mib_reference = mib_resp["transaction"]["ref"] or ""
|
||||
payment.source_bank = mib_resp["transaction"]["sourceBank"] or ""
|
||||
payment.save()
|
||||
return PaymentVerificationResponse(
|
||||
message=mib_resp["message"],
|
||||
@@ -283,7 +381,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
|
||||
|
||||
class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
queryset = Payment.objects.all()
|
||||
queryset = Payment.objects.select_related("user").all()
|
||||
serializer_class = PaymentSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
@@ -295,7 +393,7 @@ class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
{"message": "Payment has already been cancelled."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if instance.user != user and not user.is_superuser:
|
||||
if instance.user != user and not user.is_superuser: #type: ignore
|
||||
return Response(
|
||||
{"message": "You are not authorized to cancel this payment."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
@@ -313,9 +411,9 @@ class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
|
||||
|
||||
class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
|
||||
queryset = Topup.objects.all()
|
||||
queryset = Topup.objects.all().prefetch_related("user")
|
||||
serializer_class = TopupSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filter_backends = [DjangoFilterBackend] #type: ignore
|
||||
filterset_fields = "__all__"
|
||||
filterset_class = TopupFilter
|
||||
|
||||
@@ -336,10 +434,34 @@ class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser:
|
||||
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser: #type: ignore
|
||||
return queryset
|
||||
return queryset.filter(user=self.request.user)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
all_topups = request.query_params.get("all_topups", "false").lower() in [
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
]
|
||||
if (
|
||||
request.user.is_authenticated
|
||||
and getattr(request.user, "is_admin")
|
||||
and bool(all_topups)
|
||||
):
|
||||
pass
|
||||
else:
|
||||
queryset = queryset.filter(user=request.user)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class TopupDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
|
||||
queryset = Topup.objects.all()
|
||||
@@ -348,7 +470,7 @@ class TopupDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser:
|
||||
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser: #type: ignore
|
||||
return queryset
|
||||
return queryset.filter(user=self.request.user)
|
||||
|
||||
@@ -360,25 +482,37 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
|
||||
|
||||
def verify_transfer_topup(self, data, topup) -> PaymentVerificationResponse:
|
||||
if not PAYMENT_BASE_URL:
|
||||
raise ValueError(
|
||||
"PAYMENT_BASE_URL is not set. Please set it in your environment variables."
|
||||
logger.error("PAYMENT_BASE_URL is not set.")
|
||||
return PaymentVerificationResponse(
|
||||
message="Payment gateway is not configured. Please contact support.",
|
||||
success=False,
|
||||
transaction=None,
|
||||
)
|
||||
logger.info(data)
|
||||
response = requests.post(
|
||||
f"{PAYMENT_BASE_URL}/verify-payment",
|
||||
json=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
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
|
||||
response = requests.post(
|
||||
f"{PAYMENT_BASE_URL}/verify-payment",
|
||||
json=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
mib_resp = response.json()
|
||||
print(mib_resp)
|
||||
if not response.json().get("success"):
|
||||
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(
|
||||
message=mib_resp["message"],
|
||||
success=mib_resp["success"],
|
||||
@@ -388,6 +522,7 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
|
||||
topup.paid = True
|
||||
topup.mib_reference = mib_resp["transaction"]["ref"] or ""
|
||||
topup.paid_at = mib_resp["transaction"]["trxDate"]
|
||||
topup.source_bank = mib_resp["transaction"]["sourceBank"] or ""
|
||||
topup.save()
|
||||
return PaymentVerificationResponse(
|
||||
message=mib_resp["message"],
|
||||
@@ -408,7 +543,7 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
|
||||
{"message": "Payment has already been verified."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if topup_instance.user != user and not user.is_superuser:
|
||||
if topup_instance.user != user and not user.is_superuser: #type: ignore
|
||||
return Response(
|
||||
{"message": "You are not allowed to pay for this topup."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
@@ -425,7 +560,11 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
|
||||
topup_verification_response = self.verify_transfer_topup(data, topup_instance)
|
||||
print("Topup verification response:", topup_verification_response)
|
||||
if topup_verification_response.success:
|
||||
user.wallet_balance += topup_instance.amount # type: ignore
|
||||
user.add_wallet_funds( # type: ignore
|
||||
topup_instance.amount,
|
||||
f"Topup of {topup_instance.amount} MVR",
|
||||
topup_instance.id,
|
||||
)
|
||||
user.save()
|
||||
topup_instance.status = "PAID"
|
||||
topup_instance.save()
|
||||
@@ -471,7 +610,7 @@ class CancelTopupView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
if (
|
||||
instance.user != user
|
||||
and getattr(user, "is_admin")
|
||||
and not user.is_superuser
|
||||
and not user.is_superuser #type: ignore
|
||||
):
|
||||
return Response(
|
||||
{"message": "You are not authorized to delete this topup."},
|
||||
@@ -485,3 +624,124 @@ class CancelTopupView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
instance.status = "CANCELLED"
|
||||
instance.save()
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class AdminTopupCreateView(StaffEditorPermissionMixin, generics.CreateAPIView):
|
||||
queryset = Topup.objects.all().select_related("user")
|
||||
serializer_class = TopupSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
data = request.data
|
||||
user_id = data.get("user_id")
|
||||
amount = data.get("amount")
|
||||
topup_description = ""
|
||||
admin_description = data.get("description", "")
|
||||
if not getattr(request.user, "is_admin", False):
|
||||
return Response(
|
||||
{"message": "You are not authorized to perform this action."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if not user_id:
|
||||
return Response(
|
||||
{"message": "user_id is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not amount:
|
||||
return Response(
|
||||
{"message": "amount is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
if not user:
|
||||
return Response(
|
||||
{"message": "User not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
topup = Topup.objects.create(
|
||||
amount=amount,
|
||||
user=user,
|
||||
paid=True,
|
||||
paid_at=timezone.now(),
|
||||
payment_type="CASH",
|
||||
status="PAID",
|
||||
)
|
||||
default_description = f"Topup of {amount} MVR (Cash)"
|
||||
if admin_description and admin_description.strip() != "":
|
||||
topup_description = admin_description.strip()
|
||||
else:
|
||||
topup_description = default_description
|
||||
user.add_wallet_funds(amount, topup_description, topup.id)
|
||||
serializer = TopupSerializer(topup)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class ListWalletTransactionView(StaffEditorPermissionMixin, generics.ListAPIView):
|
||||
serializer_class = WalletTransactionSerializer
|
||||
queryset = WalletTransaction.objects.all().select_related("user")
|
||||
filter_backends = [DjangoFilterBackend] #type: ignore
|
||||
filterset_fields = "__all__"
|
||||
filterset_class = WalletTransactionFilter
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser: #type: ignore
|
||||
return queryset
|
||||
return queryset.filter(user=self.request.user)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
all_transations = request.query_params.get(
|
||||
"all_transations", "false"
|
||||
).lower() in [
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
]
|
||||
if (
|
||||
request.user.is_authenticated
|
||||
and getattr(request.user, "is_admin")
|
||||
and bool(all_transations)
|
||||
):
|
||||
pass
|
||||
else:
|
||||
queryset = queryset.filter(user=request.user)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# class AlertTestView(generics.GenericAPIView):
|
||||
# def get(self, request, *args, **kwargs):
|
||||
# msg = """*ID Card:* A265117\n*Name:* Abdulla Aidhaan\n*House Name:* Nooree Villa\n*Date of Birth:* 1997-08-24\n*Island:* Sh Funadhoo\n*Mobile:* 9697404\nVisit [SAR Link Portal](https://portal.sarlink.net) to manually verify this user."""
|
||||
# print(msg)
|
||||
# print("escaped:", escape_markdown_v2(msg))
|
||||
# user = request.user
|
||||
# print(user)
|
||||
|
||||
# global telegram_loop # Access the global loop
|
||||
|
||||
# if telegram_loop is None:
|
||||
# return Response(
|
||||
# {"message": "Telegram worker not initialized."},
|
||||
# status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
# )
|
||||
|
||||
# try:
|
||||
# asyncio.run_coroutine_threadsafe(
|
||||
# send_telegram_alert(markdown_message=escape_markdown_v2(msg)),
|
||||
# telegram_loop,
|
||||
# ).result()
|
||||
|
||||
# return Response(
|
||||
# {"message": "Alert sent successfully."}, status=status.HTTP_200_OK
|
||||
# )
|
||||
# except Exception as e:
|
||||
# logger.warning("[alert test] TELEGRAM ALERT ERROR", e)
|
||||
# return Response(
|
||||
# {"message": "Alert failed to send."}, status=status.HTTP_400_BAD_REQUEST
|
||||
# )
|
||||
|
||||
+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],
|
||||
),
|
||||
),
|
||||
]
|
||||
+13
-2
@@ -1,8 +1,10 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from api.models import User
|
||||
import re
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
user = settings.AUTH_USER_MODEL
|
||||
|
||||
|
||||
def validate_mac_address(value):
|
||||
@@ -13,10 +15,19 @@ def validate_mac_address(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):
|
||||
name = models.CharField(max_length=255)
|
||||
mac = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
validators=[
|
||||
validate_mac_address,
|
||||
],
|
||||
@@ -38,7 +49,7 @@ class Device(models.Model):
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True, related_name="devices"
|
||||
user, on_delete=models.SET_NULL, null=True, blank=True, related_name="devices"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
+23
-4
@@ -1,5 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Device
|
||||
from .models import Device, normalize_mac
|
||||
from api.serializers import CustomReadOnlyUserSerializer
|
||||
from billing.models import Payment # Import the Payment model
|
||||
|
||||
@@ -8,6 +8,9 @@ class CreateDeviceSerializer(serializers.ModelSerializer):
|
||||
name = serializers.CharField(required=True)
|
||||
mac = serializers.CharField(required=True)
|
||||
|
||||
def validate_mac(self, value):
|
||||
return normalize_mac(value)
|
||||
|
||||
class Meta: # type: ignore
|
||||
model = Device
|
||||
fields = [
|
||||
@@ -36,9 +39,9 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_pending_payment_id(self, obj):
|
||||
unpaid_payment = (
|
||||
Payment.objects.filter(devices=obj, paid=False)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
obj.unpaid_payments[0]
|
||||
if hasattr(obj, "unpaid_payments") and obj.unpaid_payments
|
||||
else None
|
||||
)
|
||||
return unpaid_payment.id if unpaid_payment else None
|
||||
|
||||
@@ -58,6 +61,22 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class AdminDeviceSerializer(serializers.ModelSerializer):
|
||||
pending_payment_id = serializers.SerializerMethodField()
|
||||
|
||||
def get_pending_payment_id(self, obj):
|
||||
unpaid_payment = (
|
||||
obj.unpaid_payments[0]
|
||||
if hasattr(obj, "unpaid_payments") and obj.unpaid_payments
|
||||
else None
|
||||
)
|
||||
return unpaid_payment.id if unpaid_payment else None
|
||||
|
||||
class Meta: # type: ignore
|
||||
model = Device
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ReadOnlyDeviceSerializer(serializers.ModelSerializer):
|
||||
user = CustomReadOnlyUserSerializer(read_only=True)
|
||||
|
||||
|
||||
+36
-11
@@ -3,7 +3,9 @@ from xmlrpc.client import Boolean
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.response import Response
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from .models import Device
|
||||
from billing.models import Payment
|
||||
from .models import Device, normalize_mac
|
||||
from django.db.models import Prefetch
|
||||
from .serializers import (
|
||||
CreateDeviceSerializer,
|
||||
DeviceSerializer,
|
||||
@@ -28,6 +30,13 @@ class DeviceListCreateAPIView(
|
||||
filterset_fields = "__all__"
|
||||
filterset_class = DeviceFilter
|
||||
|
||||
def get_queryset(self):
|
||||
unpaid_qs = Payment.objects.filter(paid=False).order_by("-created_at")
|
||||
base_qs = Device.objects.select_related("user").prefetch_related(
|
||||
Prefetch("payments", queryset=unpaid_qs, to_attr="unpaid_payments")
|
||||
)
|
||||
return base_qs.all()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
all_devices = request.query_params.get("all_devices", "false").lower() in [
|
||||
@@ -58,11 +67,25 @@ class DeviceListCreateAPIView(
|
||||
return DeviceSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
mac = request.data.get("mac", None)
|
||||
user = request.user
|
||||
name = request.data.get("name", None)
|
||||
user_details = f"{user.first_name.capitalize() if user.first_name else ''} {user.last_name.capitalize() if user.last_name else ''} {user.mobile}" # type: ignore
|
||||
omada_device_name = f"{user_details} - {name}" if name else user_details
|
||||
if len(omada_device_name) > 64:
|
||||
return Response(
|
||||
{"message": "Device name is too long."},
|
||||
status=400,
|
||||
)
|
||||
raw_mac = request.data.get("mac", 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}$")
|
||||
NORMALIZE_MAC_REGEX = re.compile(r"[^0-9A-Fa-f]")
|
||||
if not isinstance(mac, str) or not MAC_REGEX.match(mac):
|
||||
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():
|
||||
return Response(
|
||||
{"message": "Device with this mac address already exists."}, status=400
|
||||
@@ -71,8 +94,9 @@ class DeviceListCreateAPIView(
|
||||
if not mac_details.ok:
|
||||
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)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -90,7 +114,7 @@ class DeviceDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
|
||||
|
||||
|
||||
class DeviceUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.select_related("user").all()
|
||||
serializer_class = CreateDeviceSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
@@ -116,7 +140,7 @@ class DeviceUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
|
||||
|
||||
class DeviceBlockAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.select_related("user").all()
|
||||
serializer_class = BlockDeviceSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
@@ -136,10 +160,11 @@ class DeviceBlockAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
if not isinstance(blocked, bool):
|
||||
return Response({"message": "Blocked field must be a boolean."}, status=400)
|
||||
omada_client = Omada()
|
||||
blocked = omada_client.block_device(
|
||||
omada_response = omada_client.block_device(
|
||||
instance.mac, operation="block" if blocked else "unblock"
|
||||
)
|
||||
if blocked.errorCode == 0:
|
||||
print(f"Blocked: {blocked}")
|
||||
if omada_response.errorCode == 0:
|
||||
instance.blocked = blocked
|
||||
instance.save()
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=False)
|
||||
@@ -148,13 +173,13 @@ class DeviceBlockAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
return Response(serializer.data)
|
||||
else:
|
||||
return Response(
|
||||
{"message": blocked.msg},
|
||||
{"message": omada_response.msg},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class DeviceDestroyAPIView(StaffEditorPermissionMixin, generics.DestroyAPIView):
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.select_related("user").all()
|
||||
serializer_class = DeviceSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
|
||||
@@ -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.")
|
||||
return False
|
||||
|
||||
from api.notifications import format_mobile
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"X-API-Key": api_key,
|
||||
}
|
||||
data = {
|
||||
"number": to_number,
|
||||
"message": base_string % mobile_token.key,
|
||||
"check_delivery": False,
|
||||
"to": format_mobile(to_number),
|
||||
"text": base_string % mobile_token.key,
|
||||
}
|
||||
print(mobile_token.key)
|
||||
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"
|
||||
|
||||
+9
-14
@@ -1,16 +1,11 @@
|
||||
{
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"reportMissingImports": "error",
|
||||
"include": ["src"],
|
||||
"typeCheckingMode": "standard",
|
||||
"reportArgumentType": "warning",
|
||||
"reportUnusedVariable": "warning",
|
||||
"reportFunctionMemberAccess": "none",
|
||||
"exclude": [
|
||||
"council-api/**/migrations",
|
||||
"**/__pycache__",
|
||||
"src/experimental",
|
||||
"src/typestubs"
|
||||
]
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"reportMissingImports": "error",
|
||||
"include": ["src"],
|
||||
"typeCheckingMode": "standard",
|
||||
"reportArgumentType": "warning",
|
||||
"reportUnusedVariable": "warning",
|
||||
"reportFunctionMemberAccess": "none",
|
||||
"exclude": ["council-api/**/migrations", "**/__pycache__"]
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ django-extensions==3.2.3
|
||||
django-filter==23.5
|
||||
django-redis==5.4.0
|
||||
django-rest-knox==4.2.0
|
||||
django-rest-passwordreset==1.5.0
|
||||
django-seed==0.3.1
|
||||
django-storages==1.14.4
|
||||
django-stubs==5.1.1
|
||||
django-stubs-ext==5.1.1
|
||||
|
||||
Reference in New Issue
Block a user