Author SHA1 Message Date
shihaam c1f661cdb6 improve registration flows
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 5s
2026-08-04 01:11:20 +05:00
shihaam 9d8b5cd26f fix admin user persm
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 5s
2026-08-03 21:44:55 +05:00
shihaam 192f987125 handle service error
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 12s
2026-08-02 19:58:00 +05:00
shihaam 3397f212b5 handle payment erros better
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 6s
2026-08-02 15:11:46 +05:00
shihaam 1ea2f9f4c2 update sign up notifications
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 9s
2026-08-02 14:54:00 +05:00
shihaam 1f5e60dbb4 remove more email related code
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 8s
2026-08-02 14:32:10 +05:00
shihaam 00d25698c6 remove email related code
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 9s
2026-08-02 14:25:41 +05:00
shihaam 6cdf042f49 add support for group topics tg notifications
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 8s
2026-08-02 06:40:11 +05:00
shihaam 2db0369d7c update sms api for smspipe
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 9s
2026-08-02 06:04:20 +05:00
shihaam c414116ed5 update env example
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 12s
2026-08-02 05:59:19 +05:00
shihaam 4bf75fdfe4 update compose for monorepo
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 28s
2026-08-02 05:10:30 +05:00
i701 c56140011f refactor: add src_bank attribute to Payment and Topup model 🔨 2025-09-20 21:03:43 +05:00
i701 4714b6ec15 move registration sms to view from signals and fix include User verified
attribute 🐛
2025-09-15 21:33:59 +05:00
i701 6ae56774d1 remove unnessary return in user verification function 🔨 2025-09-14 22:35:23 +05:00
i701 bfc3fd1b89 fix temporary user id passing to user verification 🐛 2025-09-14 21:51:31 +05:00
i701 9721585f8a refactor(view): extract validation logic from CreateTemporaryUserView to
reduce cyclomatic complexity 🔨
2025-09-14 21:51:05 +05:00
i701 b0936cd489 fix(signals): change post_save receiver to TemporaryUser for user verification 2025-08-02 14:17:06 +05:00
i701 fc1aba3239 fix(user): update verification status field to t_verified in verify_user_with_person_api_task 2025-08-02 13:56:27 +05:00
i701 85485ae351 feat(user): update user verification process to utilize TemporaryUser model and include verification status in response 2025-08-02 13:38:47 +05:00
i701 64bba25fb9 feat(user): enhance user filtering logic to support combined ID card and mobile queries 2025-07-31 19:23:12 +05:00
i701 fbc8a17e6a fix(user): remove agreement file validation from UserUpdateAPIView 🐛 2025-07-28 09:39:02 +05:00
i701 36160c2665 feat(user): remove agreement field from UserUpdateSerializer 2025-07-27 22:57:44 +05:00
i701 f6afb3b658 feat(permissions): add user read-only permission assignment on user creation 2025-07-27 22:28:12 +05:00
i701 9c082aedf2 feat(telegram): implement asynchronous Telegram alert system and enhance user verification messaging 2025-07-27 21:56:59 +05:00
i701 2bc594da9c feat(permissions): add wallet transaction permissions assignment on user creation 2025-07-27 20:13:40 +05:00
i701 19321da0be feat(admin): enhance AdminTopupCreateView to support custom top-up descriptions 2025-07-27 19:55:22 +05:00
i701 e3c2d4450f feat(billing): add calculate_total_new_price utility for dynamic payment calculation 2025-07-27 15:17:47 +05:00
i701 ee54386fd5 feat(devices): enhance device naming to include user details and enforce name length limit 2025-07-27 14:59:08 +05:00
i701 b52cd9285a fix(devices): update DeviceBlockAPIView to handle omada_client response correctly 🐛 2025-07-27 14:32:16 +05:00
shihaam d0c809489c fix nginx path 2025-07-27 13:23:14 +05:00
i701 80fc27fd74 feat(topup): add payment_type field and update AdminTopupCreateView to handle payment type 2025-07-27 12:17:12 +05:00
i701 cdef5ed27c feat(admin): add AdminTopupCreateView for admin top-up functionality 2025-07-27 10:50:40 +05:00
i701 3e7a74950e fix(tasks): add device activation/deactivation & blocking/unblocking for background task 🐛 2025-07-26 20:33:26 +05:00
i701 72e0cd1fba fix(payment): update wallet payment processing to activate devices and set expiry date and add further optimizations 🐛 2025-07-25 23:46:59 +05:00
Abdulla AidhaanandGitHub a4b6f44348 Merge pull request #18 from i701/chore/optimize-queries
fix(views): optimize database queries to solve N+1 problems 🔨🐛
2025-07-25 23:24:00 +05:00
i701 4aae0064ca fix(views): optimize database queries to solve N+1 problems 🔨🐛 2025-07-25 23:22:35 +05:00
shihaam a46f2635ad serve media 2025-07-25 16:07:22 +05:00
i701 118ad52c71 feat(wallet): add transaction_type field to WalletTransactionFilter for enhanced filtering options 2025-07-25 16:02:01 +05:00
i701 8d9a2ed2e0 fix(payment): update payment verification response status to 200 OK and set payment status to PAID 2025-07-25 15:32:28 +05:00
i701 3200d8e41c feat(user): add wallet_balance field to CustomReadOnlyUserSerializer 2025-07-25 15:19:05 +05:00
i701 1554829b9a feat(wallet): implement wallet transaction model, views, and serializers for fund management 2025-07-25 14:38:34 +05:00
i701 f8c91e8f14 feat(admin): add agreement field to UserAdmin for improved user management 2025-07-25 10:44:58 +05:00
i701 f84f03fd5b feat(agreement): implement user agreement update functionality with validation checks 2025-07-25 10:12:04 +05:00
i701 fd603daaaf refactor: remove UpdateAgreementView and its URL path from user management 🔨 2025-07-25 00:05:35 +05:00
i701 9e4449d0d6 feat(agreement): add agreement field to user model and implement agreement upload functionality 2025-07-25 00:01:56 +05:00
i701 087782e351 feat(verification): handle user verification not found scenario and mark user as verified 2025-07-24 23:35:53 +05:00
i701 446ca6653e feat(filters, serializers): add user filtering and enhance user serialization in Payment and Device models 2025-07-24 20:34:50 +05:00
Abdulla AidhaanandGitHub f8c0725558 Merge pull request #17 from i701/feat/user-verification-flow
Feat/user verification flow
2025-07-23 22:34:37 +05:00
Abdulla AidhaanandGitHub ea57598e8d Merge pull request #16 from i701/feat/user-verification-flow
refactor(verification): enhance error handling and response structure in user verification process 🔨
2025-07-16 00:39:17 +05:00
Abdulla AidhaanandGitHub 4cc6e91a66 Merge pull request #15 from i701/feat/user-verification-flow
feat(user): add user rejection endpoint and improve verification response messages 
2025-07-13 19:42:59 +05:00
51 changed files with 2018 additions and 718 deletions
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Source is bind-mounted at runtime; (re)install deps on start so
# requirements.txt changes are picked up without a rebuild, then run the
# Django autoreloading dev server.
CMD pip install --no-cache-dir -r requirements.txt \
&& python manage.py migrate \
&& python manage.py runserver 0.0.0.0:5000
-13
View File
@@ -1,13 +0,0 @@
FROM python:3.11.4-slim-buster
WORKDIR /var/www/html/
COPY . .
RUN pip install -r requirements.txt
RUN python3 manage.py collectstatic
VOLUME /var/www/html/staticfiles/
CMD gunicorn apibase.wsgi:application --bind 0.0.0.0:5000 --workers=4
-13
View File
@@ -1,13 +0,0 @@
services:
api:
build:
context: ../../
dockerfile: .build/prod/api.Dockerfile
hostname: sarlink-portal-api
image: git.shihaam.dev/sarlink/sarlink-portal-api/api
nginx:
build:
context: .
dockerfile: ./nginx.Dockerfile
hostname: sarlink-portal-api-nginx
image: git.shihaam.dev/sarlink/sarlink-portal-api/nginx
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
if [ "$DATABASE" = "postgres" ]
then
echo "Waiting for postgres..."
while ! nc -z $POSTGRES_HOST $POSTGRES_PORT; do
sleep 0.1
done
echo "PostgreSQL started"
fi
exec "$@"
-11
View File
@@ -1,11 +0,0 @@
FROM nginx
# Install basic tools
RUN apt update \
&& apt install curl nano iputils-ping zip unzip -y --no-install-recommends \
&& apt auto-remove -y \
&& apt clean -y
COPY nginx.conf /etc/nginx/conf.d/default.conf
WORKDIR /etc/nginx
-22
View File
@@ -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
View File
@@ -1,26 +1,68 @@
CSRF_ALLOWED_HOST="" # =============================================================================
DJANGO_DEBUG= # Django
# =============================================================================
SECRET_KEY="" SECRET_KEY=""
POSTGRES_DATABASE= # DEBUG defaults to True if unset. Set False in production.
POSTGRES_USER= DJANGO_DEBUG=True
POSTGRES_PASSWORD= # Comma-separated. Include the backend service name for the compose network.
POSTGRES_HOST= ALLOWED_HOSTS="localhost,127.0.0.1,backend"
POSTGRES_PORT=
# --- Only read when DJANGO_DEBUG=False (production) ---
DJANGO_SECURE_SSL_REDIRECT= DJANGO_SECURE_SSL_REDIRECT=
ALLOWED_HOSTS="" SECURE_HSTS_SECONDS=
# Comma-separated absolute origins, e.g. https://portal.sarlink.net
CSRF_TRUSTED_ORIGINS="" CSRF_TRUSTED_ORIGINS=""
CSRF_COOKIE_DOMAIN=""
EMAIL_HOSTNAME="" # =============================================================================
EMAIL_PORT= # Database (PostgreSQL) — matches the `database` service in compose.yml
EMAIL_USERNAME= # =============================================================================
EMAIL_PASSWORD= POSTGRES_DATABASE=sarlink
POSTGRES_USER=sarlink
POSTGRES_PASSWORD=changeme
POSTGRES_HOST=database
POSTGRES_PORT=5432
# =============================================================================
# Redis — currently optional (CACHES is disabled in settings.py)
# =============================================================================
REDIS_HOST=redis
OMADA_PROXY_URL="" # =============================================================================
PAYMENT_BASE_URL="" # SMS gateway — OTP + notifications
PERSON_VERIFY_BASE_URL="" # =============================================================================
FRONTEND_URL=""
SMS_API_KEY=""
SMS_API_URL="" SMS_API_URL=""
SMS_API_KEY=""
# =============================================================================
# ID / person verification — GET {PERSON_VERIFY_BASE_URL}/api/person/{id_card}
# =============================================================================
PERSON_VERIFY_BASE_URL=""
# =============================================================================
# MIB payments — POST {PAYMENT_BASE_URL}/verify-payment
# =============================================================================
PAYMENT_BASE_URL=""
# =============================================================================
# Omada (TP-Link) proxy — device access control (block/unblock)
# =============================================================================
OMADA_PROXY_URL=""
OMADA_PROXY_API_KEY=""
OMADA_SITE_ID=""
OMADA_GROUP_ID=""
# =============================================================================
# MAC vendor lookup — REQUIRED, no default: the "add device" flow errors if unset
# =============================================================================
MACVENDOR_API_URL=""
# =============================================================================
# Telegram bot — admin alerts (optional; falls back to no-op if unset)
# =============================================================================
TG_BOT_TOKEN=""
TG_CHAT_ID=""
# Optional forum topic id. If empty/unset, message_thread_id is omitted and
# messages post to the group's General topic.
TG_TOPIC_ID=""
# Public frontend base URL used in SMS/Telegram links
FRONTEND_URL=https://portal.sarlink.net
+3
View File
@@ -163,3 +163,6 @@ cython_debug/
#staticfiles #staticfiles
staticfiles/ staticfiles/
postgres_data/ postgres_data/
media/
# Uploaded ID/passport photos (bind-mounted volume in production)
storage/
+1
View File
@@ -47,6 +47,7 @@ class UserAdmin(BaseUserAdmin):
"island", "island",
"terms_accepted", "terms_accepted",
"policy_accepted", "policy_accepted",
"agreement",
) )
}, },
), ),
-17
View File
@@ -1,17 +0,0 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
class EmailBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
UserModel = get_user_model()
if not password:
return None
try:
user = UserModel.objects.get(email=username)
except UserModel.DoesNotExist:
return None
else:
if user.check_password(password):
return user
return None
+95
View File
@@ -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)
+83
View File
@@ -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))
View File
View File
+29
View File
@@ -0,0 +1,29 @@
# api/management/commands/seed.py
from django.core.management.base import BaseCommand
from api.models import Atoll, Island
class Command(BaseCommand):
help = "Seeds baseline reference data (atolls and islands) required for sign up."
def handle(self, *args, **options):
# Atoll name must match the person-verify API's `atoll_en` (the atoll
# code letter, e.g. "F" for Faafu) or user verification fails.
atoll, atoll_created = Atoll.objects.get_or_create(name="F")
self.stdout.write(
self.style.SUCCESS(f"Created atoll: {atoll.name}")
if atoll_created
else self.style.NOTICE(f"Atoll already exists: {atoll.name}")
)
island, island_created = Island.objects.get_or_create(
name="Dharanboodhoo", defaults={"atoll": atoll}
)
self.stdout.write(
self.style.SUCCESS(f"Created island: {island.name} ({atoll.name})")
if island_created
else self.style.NOTICE(f"Island already exists: {island.name}")
)
self.stdout.write(self.style.SUCCESS("Seeding complete."))
+22
View File
@@ -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="",
),
),
]
+103 -1
View File
@@ -2,15 +2,31 @@
This is the models module for api. This is the models module for api.
""" """
import secrets
from datetime import timedelta from datetime import timedelta
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from .managers import CustomUserManager from .managers import CustomUserManager
from .storages import idcard_storage
from django.utils import timezone from django.utils import timezone
import pyotp import pyotp
from billing.models import WalletTransaction
class User(AbstractUser): class User(AbstractUser):
# Registration workflow states. `verified` (below) stays the login gate and
# is kept in sync: verified is True iff status == STATUS_VERIFIED.
STATUS_PENDING = "pending" # created, awaiting/undergoing auto-verify
STATUS_VERIFIED = "verified" # approved, may log in
STATUS_ID_REQUIRED = "id_required" # must (re)upload an ID/passport photo
STATUS_ID_SUBMITTED = "id_submitted" # photo uploaded, awaiting admin review
STATUS_CHOICES = [
(STATUS_PENDING, "Pending"),
(STATUS_VERIFIED, "Verified"),
(STATUS_ID_REQUIRED, "ID required"),
(STATUS_ID_SUBMITTED, "ID submitted"),
]
address = models.CharField(max_length=255, blank=True) address = models.CharField(max_length=255, blank=True)
email = models.EmailField(blank=True, null=True, unique=True) email = models.EmailField(blank=True, null=True, unique=True)
mobile = models.CharField( mobile = models.CharField(
@@ -22,6 +38,16 @@ class User(AbstractUser):
max_length=255, blank=True, unique=True, null=True, db_index=True max_length=255, blank=True, unique=True, null=True, db_index=True
) )
verified = models.BooleanField(default=False) verified = models.BooleanField(default=False)
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING, db_index=True
)
id_card_photo = models.FileField(
upload_to="",
storage=idcard_storage,
blank=True,
null=True,
help_text="ID card / passport (JPG, PNG or PDF) uploaded by the user for manual review.",
)
is_admin = models.BooleanField(default=False) is_admin = models.BooleanField(default=False)
dob = models.DateField(blank=True, null=True) dob = models.DateField(blank=True, null=True)
terms_accepted = models.BooleanField(default=False) terms_accepted = models.BooleanField(default=False)
@@ -34,14 +60,90 @@ class User(AbstractUser):
island = models.ForeignKey( island = models.ForeignKey(
"Island", on_delete=models.SET_NULL, null=True, blank=True, related_name="users" "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) created_at = models.DateTimeField(default=timezone.now)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def get_all_fields(self, instance): def get_all_fields(self, instance):
return [field.name for field in instance.get_fields()] 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() objects = CustomUserManager()
# Log in with username + password only; email is optional and not prompted
# by createsuperuser.
REQUIRED_FIELDS = []
def set_status(self, status, *, save=True):
"""Set workflow status and keep the `verified` login gate in sync."""
self.status = status
self.verified = status == self.STATUS_VERIFIED
if save:
self.save(update_fields=["status", "verified", "updated_at"])
class IdUploadToken(models.Model):
"""
One-time-ish magic-link token letting an unverified user reach the public
ID-upload page without logging in. Issuing a fresh token supersedes any
outstanding ones for the same user.
"""
key = models.CharField(max_length=64, unique=True, db_index=True)
user = models.ForeignKey(
"User", on_delete=models.CASCADE, related_name="id_upload_tokens"
)
created_at = models.DateTimeField(default=timezone.now)
expires_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
@classmethod
def issue(cls, user, ttl_hours=48):
cls.objects.filter(user=user, used_at__isnull=True).update(
used_at=timezone.now()
)
return cls.objects.create(
key=secrets.token_urlsafe(32),
user=user,
expires_at=timezone.now() + timedelta(hours=ttl_hours),
)
def is_valid(self):
return self.used_at is None and self.expires_at > timezone.now()
def __str__(self) -> str:
return f"IdUploadToken(user={self.user_id}, valid={self.is_valid()})"
class TemporaryUser(models.Model): class TemporaryUser(models.Model):
t_id = models.AutoField(primary_key=True) t_id = models.AutoField(primary_key=True)
@@ -103,7 +205,7 @@ class TemporaryUser(models.Model):
verbose_name_plural = "Temporary Users" verbose_name_plural = "Temporary Users"
def __str__(self) -> str: def __str__(self) -> str:
return str(self.t_username) return f"{self.t_username}"
class Atoll(models.Model): class Atoll(models.Model):
+20 -8
View File
@@ -8,6 +8,18 @@ api_url = str(config("SMS_API_URL", cast=str, default=""))
api_key = str(config("SMS_API_KEY", cast=str, default="")) api_key = str(config("SMS_API_KEY", cast=str, default=""))
bot_token = str(config("TG_BOT_TOKEN", cast=str, default="")) bot_token = str(config("TG_BOT_TOKEN", cast=str, default=""))
chat_id = str(config("TG_CHAT_ID", cast=str, default="")) chat_id = str(config("TG_CHAT_ID", cast=str, default=""))
# Optional forum topic to post into. If empty, messages go to the group's General topic.
topic_id = str(config("TG_TOPIC_ID", cast=str, default=""))
def format_mobile(mobile: str) -> str:
"""Normalize a Maldives mobile number to E.164 (+960XXXXXXX)."""
number = str(mobile).strip().replace(" ", "")
if number.startswith("+"):
return number
if number.startswith("960"):
return "+" + number
return "+960" + number
def send_otp(mobile: str, message: str): def send_otp(mobile: str, message: str):
@@ -17,12 +29,11 @@ def send_otp(mobile: str, message: str):
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {api_key}", "X-API-Key": api_key,
} }
data = { data = {
"number": mobile, "to": format_mobile(mobile),
"message": message, "text": message,
"check_delivery": False,
} }
response = requests.post(api_url, headers=headers, data=json.dumps(data)) response = requests.post(api_url, headers=headers, data=json.dumps(data))
if response.status_code == 200: if response.status_code == 200:
@@ -41,12 +52,11 @@ def send_sms(mobile: str, message: str):
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {api_key}", "X-API-Key": api_key,
} }
data = { data = {
"number": mobile, "to": format_mobile(mobile),
"message": message, "text": message,
"check_delivery": False,
} }
response = requests.post(api_url, headers=headers, data=json.dumps(data)) response = requests.post(api_url, headers=headers, data=json.dumps(data))
if response.status_code == 200: if response.status_code == 200:
@@ -80,6 +90,8 @@ def send_telegram_markdown(message: str):
try: try:
url = f"https://api.telegram.org/bot{bot_token}/sendMessage" url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {"chat_id": chat_id, "text": message, "parse_mode": "MarkdownV2"} payload = {"chat_id": chat_id, "text": message, "parse_mode": "MarkdownV2"}
if topic_id:
payload["message_thread_id"] = topic_id
response = requests.post(url, data=payload) response = requests.post(url, data=payload)
response.raise_for_status() response.raise_for_status()
+38
View File
@@ -1,6 +1,44 @@
from rest_framework import permissions from rest_framework import permissions
def user_is_admin(user) -> bool:
"""Single source of truth for "is this user an administrator?".
Any of the three flags grants admin access: the app-specific ``is_admin``
flag, Django's ``is_staff``, or ``is_superuser``. Both the permission
classes and the per-view authorization checks use this so the three flags
behave identically everywhere.
"""
return bool(
user
and user.is_authenticated
and (
getattr(user, "is_admin", False)
or getattr(user, "is_staff", False)
or getattr(user, "is_superuser", False)
)
)
class IsAdminOrStaffPermission(permissions.BasePermission):
"""Admin gate for admin-only endpoints and for views that have no
model/queryset of their own (e.g. proxy endpoints).
``IsStaffEditorPermission`` can't be used on model-less views because it
derives the required permission from ``view.queryset.model`` (``None``
there); it also requires granular Django model permissions that admin
accounts are not necessarily granted. This gate keys off admin status
instead.
"""
message = {
"message": "You do not have permission to perform this action.",
}
def has_permission(self, request, view):
return user_is_admin(request.user)
class IsStaffEditorPermission(permissions.DjangoModelPermissions): class IsStaffEditorPermission(permissions.DjangoModelPermissions):
perms_map = { perms_map = {
"GET": ["%(app_label)s.view_%(model_name)s"], "GET": ["%(app_label)s.view_%(model_name)s"],
+15 -1
View File
@@ -48,6 +48,15 @@ class UserUpdateSerializer(serializers.ModelSerializer):
) )
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): class CustomUserSerializer(serializers.ModelSerializer):
"""serializer for the user object""" """serializer for the user object"""
@@ -85,6 +94,7 @@ class CustomUserSerializer(serializers.ModelSerializer):
"email", "email",
"last_login", "last_login",
"date_joined", "date_joined",
"is_staff",
"is_superuser", "is_superuser",
) )
@@ -109,6 +119,10 @@ class CustomReadOnlyUserSerializer(serializers.ModelSerializer):
"address", "address",
"acc_no", "acc_no",
"id_card", "id_card",
"agreement",
"wallet_balance",
"status",
"id_card_photo",
) )
depth = 1 depth = 1
@@ -148,7 +162,7 @@ class UserSerializer(serializers.ModelSerializer):
extra_kwargs = {"password": {"write_only": True, "min_length": 5}} extra_kwargs = {"password": {"write_only": True, "min_length": 5}}
def create(self, validated_data): 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): class AuthSerializer(serializers.Serializer):
+13 -49
View File
@@ -1,10 +1,6 @@
from django.core.mail import EmailMultiAlternatives
from django.dispatch import receiver from django.dispatch import receiver
from django.template.loader import render_to_string
from decouple import config
from django_rest_passwordreset.signals import reset_password_token_created
from django.db.models.signals import post_save from django.db.models.signals import post_save
from api.models import User from api.models import User, TemporaryUser
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from api.tasks import verify_user_with_person_api_task from api.tasks import verify_user_with_person_api_task
@@ -21,7 +17,13 @@ def assign_device_permissions(sender, instance, created, **kwargs):
topup_permissions = Permission.objects.filter( topup_permissions = Permission.objects.filter(
content_type__model="topup" content_type__model="topup"
).exclude(codename__startswith="delete_") ).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: for permission in topup_permissions:
instance.user_permissions.add(permission) instance.user_permissions.add(permission)
for permission in device_permissions: for permission in device_permissions:
@@ -29,50 +31,12 @@ def assign_device_permissions(sender, instance, created, **kwargs):
instance.user_permissions.add(atoll_read_permission, island_read_permission) instance.user_permissions.add(atoll_read_permission, island_read_permission)
for permission in payment_permissions: for permission in payment_permissions:
instance.user_permissions.add(permission) 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): def verify_user_with_person_api(sender, instance, created, **kwargs):
if created: if created:
verify_user_with_person_api_task(instance.id) print(f"Temporary User Instance: {instance}")
verify_user_with_person_api_task(instance.t_id)
@receiver(reset_password_token_created)
def password_reset_token_created(
sender, instance, reset_password_token, *args, **kwargs
):
"""
Handles password reset tokens
When a token is created, an e-mail needs to be sent to the user
:param sender: View Class that sent the signal
:param instance: View Instance that sent the signal
:param reset_password_token: Token Model Object
:param args:
:param kwargs:
:return:
"""
context = {
"current_user": reset_password_token.user,
"username": reset_password_token.user.username,
"email": reset_password_token.user.email,
"reset_password_url": f"{config('FRONTEND_URL')}/auth/reset-password-confirm/?token={reset_password_token.key}",
}
# render email text
email_html_message = render_to_string("email/password_reset_email.html", context)
email_plaintext_message = (
f"Here is your password reset link: {context['reset_password_url']}"
)
msg = EmailMultiAlternatives(
# title:
"Password Reset for {title}".format(title="Sarlink Portal"),
# message:
email_plaintext_message, # This is the plaintext version
# from:
"noreply@sarlink.net",
# to:
[reset_password_token.user.email],
)
msg.attach_alternative(email_html_message, "text/html")
msg.send()
+26
View File
@@ -0,0 +1,26 @@
"""Storage backends for the api app."""
from django.conf import settings
from django.core.files.storage import FileSystemStorage
class IdCardStorage(FileSystemStorage):
"""Filesystem storage for uploaded ID/passport photos.
Location/URL come from settings (IDCARD_STORAGE_ROOT / IDCARD_STORAGE_URL)
so the directory can be bind-mounted as a persistent volume in production.
"""
def __init__(self, **kwargs):
kwargs.setdefault("location", settings.IDCARD_STORAGE_ROOT)
kwargs.setdefault("base_url", settings.IDCARD_STORAGE_URL)
super().__init__(**kwargs)
def idcard_storage():
"""Callable referenced by the model field.
Using a named callable keeps the resolved location out of migrations, so
changing the mount path never requires a new migration.
"""
return IdCardStorage()
+174 -63
View File
@@ -1,5 +1,6 @@
# pyright: reportGeneralTypeIssues=false
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from api.models import User from api.models import TemporaryUser
from devices.models import Device from devices.models import Device
from api.notifications import send_sms from api.notifications import send_sms
import os import os
@@ -8,9 +9,20 @@ from django.utils import timezone
# from api.notifications import send_clean_telegram_markdown # from api.notifications import send_clean_telegram_markdown
from api.omada import Omada from api.omada import Omada
from api.bot import (
send_telegram_alert,
send_telegram_photo,
telegram_loop,
escape_markdown_v2,
)
import asyncio
from apibase.env import env, BASE_DIR from apibase.env import env, BASE_DIR
from procrastinate.contrib.django import app from procrastinate.contrib.django import app
from procrastinate import builtin_tasks from procrastinate import builtin_tasks
import time
import io
import requests
from PIL import Image
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,22 +48,64 @@ async def remove_old_jobs(context, timestamp):
@app.periodic( @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 ) # type: ignore
@app.task @app.task
def deactivate_expired_devices(): def deactivate_expired_devices_and_block_in_omada():
expired_devices = Device.objects.filter( expired_devices = Device.objects.filter(
expiry_date__lte=timezone.localtime(timezone.now()), is_active=True expiry_date__lte=timezone.localtime(timezone.now()), is_active=True
).select_related("user") ).select_related("user")
print("Expired Devices: ", expired_devices) print("Expired Devices: ", expired_devices)
count = expired_devices.count() count = expired_devices.count()
if count == 0:
return {"total_expired_devices": 0}
user_devices_map = {} 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: for device in expired_devices:
# Collect devices for SMS notifications
if device.user and device.user.mobile: if device.user and device.user.mobile:
if device.user.mobile not in user_devices_map: if device.user.mobile not in user_devices_map:
user_devices_map[device.user.mobile] = [] user_devices_map[device.user.mobile] = []
user_devices_map[device.user.mobile].append(device.name) 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(): for mobile, device_names in user_devices_map.items():
if not mobile: if not mobile:
continue continue
@@ -59,14 +113,25 @@ def deactivate_expired_devices():
[f"{i + 1}. {name}" for i, name in enumerate(device_names)] [f"{i + 1}. {name}" for i, name in enumerate(device_names)]
) )
print("device list: ", device_list) print("device list: ", device_list)
send_sms( try:
mobile, send_sms(
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", 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.") 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 { return {
"total_expired_devices": count, "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. Verify the user with the Person API.
:param user_id: The ID of the user to verify. :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 PERSON_VERIFY_BASE_URL = env.str("PERSON_VERIFY_BASE_URL", default="") # type: ignore
if not PERSON_VERIFY_BASE_URL: if not PERSON_VERIFY_BASE_URL:
raise ValueError( raise ValueError(
"PERSON_VERIFY_BASE_URL is not set in the environment variables." "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: if response.status_code == 200:
data = response.json() data = response.json()
api_nic = data.get("nic") 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_dob = data.get("dob")
api_atoll = data.get("atoll_en") api_atoll = data.get("atoll_en")
api_island_name = data.get("island_name_en") api_island_name = data.get("island_name_en")
api_image_url = data.get("image_url")
if not 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.") logger.error("User mobile or date of birth is not set.")
return None 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.") logger.error("User island or atoll is not set.")
return None 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 atoll: {api_atoll}")
logger.info(f"API island name: {api_island_name}") logger.info(f"API island name: {api_island_name}")
user_nic = user.id_card user_nic = t_user.t_id_card
user_name = f"{user.first_name} {user.last_name}" user_name = f"{t_user.t_first_name} {t_user.t_last_name}"
user_house_name = user.address user_house_name = t_user.t_address
user_dob = user.dob.isoformat() user_dob = t_user.t_dob.isoformat()
logger.info(f"User nic: {user_nic}") logger.info(f"User nic: {user_nic}")
logger.info(f"User name: {user_name}") logger.info(f"User name: {user_name}")
logger.info(f"User house name: {user_house_name}") logger.info(f"User house name: {user_house_name}")
logger.info(f"User dob: {user_dob}") logger.info(f"User dob: {user_dob}")
logger.info(f"User atoll: {user.atoll}") logger.info(f"User atoll: {t_user.t_atoll.name if t_user.t_atoll else 'N/A'}")
logger.info(f"User island name: {user.island}") logger.info(
f"User island name: {t_user.t_island.name if t_user.t_island else 'N/A'}"
)
logger.info( 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 ) # Defensive check for api_atoll
logger.info(f"api atoll type: {type(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( 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 ) # Defensive check for api_island_name
logger.info(f"api island name type: {type(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 ( if (
data.get("nic") == user.id_card data.get("nic") == t_user.t_id_card
and data.get("name_en") == f"{user.first_name} {user.last_name}" and data.get("name_en") == f"{t_user.t_first_name} {t_user.t_last_name}"
and data.get("house_name_en") == user.address and data.get("house_name_en") == t_user.t_address
and data.get("dob").split("T")[0] == user.dob.isoformat() and data.get("dob").split("T")[0] == t_user.t_dob.isoformat()
and data.get("atoll_en").strip() == user.atoll.name and data.get("atoll_en").strip() == t_user.t_atoll.name
and data.get("island_name_en").strip() == user.island.name and data.get("island_name_en").strip() == t_user.t_island.name
): ):
user.verified = True t_user.t_verified = True
user.save() t_user.save()
send_sms(
user.mobile, logger.info(verification_success_message)
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", send_telegram(verification_success_message, photo_url=api_image_url)
)
return True return True
else: else:
user.verified = False t_user.t_verified = False
user.save() t_user.save()
send_sms( logger.info(verification_failed_message)
user.mobile, send_telegram(verification_failed_message, photo_url=api_image_url)
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)
return False return False
else: else:
# Handle the error case # Handle the error case
@@ -1,102 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Instructions to reset your password." />
<meta name="keywords" content="password, reset, email, instructions" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Password Reset Email</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f5f5f5;
margin: 0;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header {
margin-bottom: 30px;
}
.logo {
color: #2c3e50;
font-size: 24px;
font-weight: bold;
}
.message {
color: #6c757d;
font-size: 16px;
line-height: 1.5;
margin-top: 20px;
}
.footer {
margin-top: 30px;
color: #6c757d;
font-size: 14px;
}
.button {
display: inline-block;
padding: 10px 20px;
background-color: #007bff;
color: #ffffff !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}
.button:hover {
background-color: #0056b3;
}
a {
color: #007bff;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">Password Reset Instructions</div>
</div>
<p class="message">
Hello {{ username }},
</p>
<p class="message">
We received a request to reset your password. Click the button below to create a new password:
</p>
<a href="{{ reset_password_url }}" class="button">Reset Password</a>
<p class="message">
If the button doesn't work, you can copy and paste this link into your browser:
<br>
<a href="{{ reset_password_url }}">{{ reset_password_url }}</a>
</p>
<p class="message">
If you did not request this password reset, you can safely ignore
this email.
</p>
<p class="footer">Best regards,<br>SARLink</p>
</div>
</body>
</html>
+33 -6
View File
@@ -10,7 +10,6 @@ from .views import (
ListUserView, ListUserView,
UserDetailAPIView, UserDetailAPIView,
healthcheck, healthcheck,
test_email,
ListAtollView, ListAtollView,
CreateAtollView, CreateAtollView,
RetrieveUpdateDestroyAtollView, RetrieveUpdateDestroyAtollView,
@@ -18,17 +17,28 @@ from .views import (
RetrieveUpdateDestroyIslandView, RetrieveUpdateDestroyIslandView,
filter_user, filter_user,
filter_temporary_user, filter_temporary_user,
UpdateUserWalletView,
VerifyOTPView, VerifyOTPView,
ResendRegistrationOTPView,
UserVerifyAPIView, UserVerifyAPIView,
UserUpdateAPIView, UserUpdateAPIView,
UserRejectAPIView, UserRejectAPIView,
RequestIdUploadAPIView,
IdUploadTokenInfoAPIView,
IdUploadStartAPIView,
IdUploadSubmitAPIView,
AgreementUpdateAPIView,
PersonVerifyAPIView,
) )
urlpatterns = [ urlpatterns = [
path("register/", CreateTemporaryUserView.as_view(), name="register"), path("register/", CreateTemporaryUserView.as_view(), name="register"),
path("register/verify/", VerifyOTPView.as_view(), name="verify-otp"), path("register/verify/", VerifyOTPView.as_view(), name="verify-otp"),
path(
"register/resend-otp/",
ResendRegistrationOTPView.as_view(),
name="resend-registration-otp",
),
path("profile/", UserprofileAPIView.as_view(), name="profile"), path("profile/", UserprofileAPIView.as_view(), name="profile"),
path("login/", LoginView.as_view(), name="knox_login"), path("login/", LoginView.as_view(), name="knox_login"),
path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"), path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"),
@@ -36,18 +46,35 @@ urlpatterns = [
path("tokens/", KnoxTokenListApiView.as_view(), name="knox_tokens"), path("tokens/", KnoxTokenListApiView.as_view(), name="knox_tokens"),
# path("auth/", CustomAuthToken.as_view()), # path("auth/", CustomAuthToken.as_view()),
path("users/", ListUserView.as_view(), name="users"), 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>/", UserDetailAPIView.as_view(), name="user-detail"),
path("users/<int:pk>/update/", UserUpdateAPIView.as_view(), name="user-update"), path("users/<int:pk>/update/", UserUpdateAPIView.as_view(), name="user-update"),
path("users/filter/", filter_user, name="filter-users"), path("users/filter/", filter_user, name="filter-users"),
path("users/temp/filter/", filter_temporary_user, name="filter-temporary-users"), path("users/temp/filter/", filter_temporary_user, name="filter-temporary-users"),
# User verification flow # User verification flow
path("users/<int:pk>/verify/", UserVerifyAPIView.as_view(), name="user-verify"), 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>/reject/", UserRejectAPIView.as_view(), name="user-reject"),
path(
"users/<int:pk>/request-id-card/",
RequestIdUploadAPIView.as_view(),
name="user-request-id-card",
),
# Public, unauthenticated self-service ID-upload flow (single-use magic link)
path(
"id-upload/token/", IdUploadTokenInfoAPIView.as_view(), name="id-upload-token"
),
path(
"id-upload/start/", IdUploadStartAPIView.as_view(), name="id-upload-start"
),
path(
"id-upload/submit/", IdUploadSubmitAPIView.as_view(), name="id-upload-submit"
),
path("person/<str:id_card>/", PersonVerifyAPIView.as_view(), name="person-verify"),
path("healthcheck/", healthcheck, name="healthcheck"), path("healthcheck/", healthcheck, name="healthcheck"),
path("test/", test_email, name="testemail"),
path("atolls/", ListAtollView.as_view(), name="atolls"), path("atolls/", ListAtollView.as_view(), name="atolls"),
path("atolls/new/", CreateAtollView.as_view(), name="atoll-new"), path("atolls/new/", CreateAtollView.as_view(), name="atoll-new"),
path( path(
+429 -203
View File
@@ -2,38 +2,44 @@
from django.contrib.auth import login from django.contrib.auth import login
# rest_framework imports # rest_framework imports
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import generics, permissions from rest_framework import generics, permissions
from rest_framework.authtoken.serializers import AuthTokenSerializer from rest_framework.authtoken.serializers import AuthTokenSerializer
from api.filters import UserFilter from api.filters import UserFilter
from api.mixins import StaffEditorPermissionMixin from api.mixins import StaffEditorPermissionMixin
from api.models import User, Atoll, Island, TemporaryUser from api.permissions import IsAdminOrStaffPermission, user_is_admin
from api.models import User, Atoll, Island, TemporaryUser, IdUploadToken
from api.notifications import send_sms from api.notifications import send_sms
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from decouple import config
import os
from api.serializers import ( from api.serializers import (
AtollSerializer, AtollSerializer,
IslandSerializer, IslandSerializer,
CustomUserByWalletBalanceSerializer,
OTPVerificationSerializer, OTPVerificationSerializer,
TemporaryUserSerializer, TemporaryUserSerializer,
UserUpdateSerializer, UserUpdateSerializer,
UserAgreementSerializer,
) )
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from datetime import timedelta
# knox imports # knox imports
from knox.views import LoginView as KnoxLoginView from knox.views import LoginView as KnoxLoginView
from knox.models import AuthToken from knox.models import AuthToken
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
import re
from typing import cast, Dict, Any from typing import cast, Dict, Any
from django.core.mail import send_mail
from django.db.models import Q from django.db.models import Q
from api.notifications import send_otp from api.notifications import send_otp
from .utils import check_person_api_verification from .utils import check_person_api_verification
import uuid
from .helpers import ErrorMessages, validate_required_fields, validate_unique_fields, validate_patterns, calculate_age
# local apps import # local apps import
from .serializers import ( from .serializers import (
@@ -41,23 +47,9 @@ from .serializers import (
AuthSerializer, AuthSerializer,
CustomUserSerializer, CustomUserSerializer,
CustomReadOnlyUserSerializer, CustomReadOnlyUserSerializer,
CustomReadOnlyUserByIDCardSerializer,
UserProfileUpdateSerializer, 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"]) @api_view(["GET"])
@@ -65,170 +57,136 @@ def healthcheck(request):
return Response({"status": "Good"}, status=status.HTTP_200_OK) return Response({"status": "Good"}, status=status.HTTP_200_OK)
class UpdateUserWalletView(generics.UpdateAPIView): FRONTEND_URL = config("FRONTEND_URL", default="https://portal.sarlink.net")
# Create user API view
serializer_class = CustomUserByWalletBalanceSerializer # File types accepted for the ID card / passport upload.
permission_classes = (permissions.IsAuthenticated,) ID_PHOTO_CONTENT_TYPES = [
queryset = User.objects.all() "image/jpeg",
lookup_field = "pk" "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): class CreateTemporaryUserView(generics.CreateAPIView):
# Create user API view
serializer_class = TemporaryUserSerializer serializer_class = TemporaryUserSerializer
permission_classes = (permissions.AllowAny,) permission_classes = (permissions.AllowAny,)
queryset = TemporaryUser.objects.all() queryset = TemporaryUser.objects.all()
throttle_classes = [] throttle_classes = []
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
# Extract required fields from request data # Extract data once
username = request.data.get("username") data = request.data
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")
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: 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: except ValueError:
return Response( return Response({"message": "Invalid date format for DOB. Use YYYY-MM-DD."}, status=400)
{"message": "Invalid date format for DOB. Use YYYY-MM-DD."}, status=400
)
age_from_dob = ( # Check age
current_date.year age = calculate_age(dob)
- dob.year if age < 18:
- ((current_date.month, current_date.day) < (dob.month, dob.day))
)
if age_from_dob < 18:
return Response({"message": ErrorMessages.UNDERAGE_ERROR}, status=400) return Response({"message": ErrorMessages.UNDERAGE_ERROR}, status=400)
if ( # Validate uniqueness
TemporaryUser.objects.filter(t_mobile=mobile).exists() uniqueness_error = validate_unique_fields(
or User.objects.filter(mobile=mobile).exists() username=data.get("username"),
): mobile=data.get("mobile"),
return Response({"message": ErrorMessages.MOBILE_EXISTS}, status=400) id_card=data.get("id_card"),
if ( )
TemporaryUser.objects.filter(t_username=username).exists() if uniqueness_error:
or User.objects.filter(username=username).exists() return uniqueness_error
):
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 required fields first # Validate patterns
validation_error = self.validate_required_fields(request.data) pattern_error = validate_patterns(
if validation_error: id_card=data.get("id_card"),
return validation_error 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: try:
atoll = Atoll.objects.get(id=atoll_id) atoll = Atoll.objects.get(id=atoll_id)
island = Island.objects.get(id=island_id) island = Island.objects.get(id=island_id)
except Atoll.DoesNotExist: except ObjectDoesNotExist as e:
return Response({"message": "Atoll not found."}, status=404) model_name = "Atoll" if isinstance(e, Atoll.DoesNotExist) else "Island"
except Island.DoesNotExist: return Response({"message": f"{model_name} not found."}, status=404)
return Response({"message": "Island not found."}, status=404)
# Create user # Create user
temp_user = TemporaryUser.objects.create( temp_user = TemporaryUser.objects.create(
t_first_name=firstname, t_first_name=data.get("firstname"),
t_last_name=lastname, t_last_name=data.get("lastname"),
t_username=str(username), t_username=str(data.get("username")),
t_email=None, t_email=None,
t_address=address, t_address=data.get("address"),
t_mobile=mobile, t_mobile=data.get("mobile"),
t_acc_no=acc_no, t_acc_no=data.get("acc_no"),
t_id_card=id_card, t_id_card=data.get("id_card"),
t_dob=dob, t_dob=dob,
t_atoll=atoll, t_atoll=atoll,
t_island=island, t_island=island,
t_terms_accepted=terms_accepted, t_terms_accepted=data.get("terms_accepted"),
t_policy_accepted=policy_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") formatted_time = otp_expiry.strftime("%d/%m/%Y %H:%M:%S")
otp = temp_user.generate_otp() otp = temp_user.generate_otp()
send_otp( send_otp(
str(temp_user.t_mobile), str(temp_user.t_mobile),
f"Your Registration SARLink OTP: {otp}. \nExpires at {formatted_time}. \n\n- SAR Link", f"Your Registration SARLink OTP: {otp}. \nExpires at {formatted_time}. \n\n- SAR Link",
) )
# Return success
serializer = self.get_serializer(temp_user) serializer = self.get_serializer(temp_user)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
return Response( return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers 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): class VerifyOTPView(generics.GenericAPIView):
permission_classes = (permissions.AllowAny,) permission_classes = (permissions.AllowAny,)
serializer_class = OTPVerificationSerializer serializer_class = OTPVerificationSerializer
@@ -260,7 +218,7 @@ class VerifyOTPView(generics.GenericAPIView):
return Response({"message": "Invalid OTP."}, status=400) return Response({"message": "Invalid OTP."}, status=400)
# Create real user # Create real user
User.objects.create_user( user = User.objects.create_user(
first_name=temp_user.t_first_name, first_name=temp_user.t_first_name,
last_name=temp_user.t_last_name, last_name=temp_user.t_last_name,
username=str(temp_user.t_username), username=str(temp_user.t_username),
@@ -270,17 +228,78 @@ class VerifyOTPView(generics.GenericAPIView):
acc_no=temp_user.t_acc_no, acc_no=temp_user.t_acc_no,
id_card=temp_user.t_id_card, id_card=temp_user.t_id_card,
dob=temp_user.t_dob, dob=temp_user.t_dob,
verified=temp_user.t_verified,
atoll=temp_user.t_atoll, atoll=temp_user.t_atoll,
island=temp_user.t_island, island=temp_user.t_island,
terms_accepted=temp_user.t_terms_accepted, terms_accepted=temp_user.t_terms_accepted,
policy_accepted=temp_user.t_policy_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.otp_verified = True
temp_user.save() 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): class LoginView(KnoxLoginView):
@@ -327,7 +346,8 @@ class UserprofileAPIView(generics.RetrieveUpdateAPIView):
return self.request.user return self.request.user
class UserUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView): class UserUpdateAPIView(generics.UpdateAPIView):
permission_classes = [IsAdminOrStaffPermission]
serializer_class = UserUpdateSerializer serializer_class = UserUpdateSerializer
queryset = User.objects.all() queryset = User.objects.all()
lookup_field = "pk" lookup_field = "pk"
@@ -340,10 +360,7 @@ class UserUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
{"message": "You cannot update a superuser."}, {"message": "You cannot update a superuser."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
if request.user != user and ( if request.user != user and not user_is_admin(request.user):
not request.user.is_authenticated
or not getattr(request.user, "is_admin", False)
):
return Response( return Response(
{"message": "You are not authorized to update this user."}, {"message": "You are not authorized to update this user."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
@@ -358,6 +375,57 @@ class UserUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
return super().update(request, *args, **kwargs) 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( class KnoxTokenListApiView(
StaffEditorPermissionMixin, StaffEditorPermissionMixin,
generics.ListAPIView, generics.ListAPIView,
@@ -378,7 +446,7 @@ class KnoxTokenListApiView(
class ListUserView(StaffEditorPermissionMixin, generics.ListAPIView): class ListUserView(StaffEditorPermissionMixin, generics.ListAPIView):
serializer_class = CustomReadOnlyUserSerializer serializer_class = CustomReadOnlyUserSerializer
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend] #type: ignore
filterset_fields = "__all__" filterset_fields = "__all__"
filterset_class = UserFilter filterset_class = UserFilter
queryset = User.objects.all() queryset = User.objects.all()
@@ -390,7 +458,8 @@ class ListUserView(StaffEditorPermissionMixin, generics.ListAPIView):
return User.objects.none() return User.objects.none()
class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView): class UserVerifyAPIView(generics.UpdateAPIView):
permission_classes = [IsAdminOrStaffPermission]
serializer_class = CustomUserSerializer serializer_class = CustomUserSerializer
queryset = User.objects.all() queryset = User.objects.all()
lookup_field = "pk" lookup_field = "pk"
@@ -398,10 +467,7 @@ class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
user_id = kwargs.get("pk") user_id = kwargs.get("pk")
user = get_object_or_404(User, pk=user_id) user = get_object_or_404(User, pk=user_id)
if request.user != user and ( if request.user != user and not user_is_admin(request.user):
not request.user.is_authenticated
or not getattr(request.user, "is_admin", False)
):
return Response( return Response(
{"message": "You are not authorized to update this user."}, {"message": "You are not authorized to update this user."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
@@ -414,6 +480,16 @@ class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
serializer = self.get_serializer(user, data=request.data, partial=True) serializer = self.get_serializer(user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
result = check_person_api_verification(user_data=user, id_card=user.id_card) 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 not found in the verification system. User marked as verified."
},
status=status.HTTP_404_NOT_FOUND,
)
if not result["ok"]: if not result["ok"]:
return Response( return Response(
result, result,
@@ -429,7 +505,8 @@ class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
return Response({"message": "User successfully verified."}) return Response({"message": "User successfully verified."})
class UserRejectAPIView(StaffEditorPermissionMixin, generics.DestroyAPIView): class UserRejectAPIView(generics.DestroyAPIView):
permission_classes = [IsAdminOrStaffPermission]
serializer_class = CustomUserSerializer serializer_class = CustomUserSerializer
queryset = User.objects.all() queryset = User.objects.all()
lookup_field = "pk" lookup_field = "pk"
@@ -454,24 +531,145 @@ class UserRejectAPIView(StaffEditorPermissionMixin, generics.DestroyAPIView):
{"message": "You cannot remove a superuser."}, {"message": "You cannot remove a superuser."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
if request.user != user and ( if request.user != user and not user_is_admin(request.user):
not request.user.is_authenticated
or not getattr(request.user, "is_admin", False)
):
return Response( return Response(
{"message": "You are not authorized to reject this user."}, {"message": "You are not authorized to reject this user."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
full_name = f"{user.first_name} {user.last_name}".strip() or "Customer"
rejection_message = (
f"Dear {full_name}, \n\n"
"We're sorry, but your SAR Link account registration could not be "
"approved at this time. \n\n"
f"Reason: {rejection_details} \n\n"
"Register again at "
f"{FRONTEND_URL}, or contact us for assistance. \n\n"
" - SAR Link"
)
user.delete() user.delete()
t_user = get_object_or_404(TemporaryUser, t_mobile=user.mobile) t_user = get_object_or_404(TemporaryUser, t_mobile=user.mobile)
t_user.delete() t_user.delete()
send_sms(message=rejection_details, mobile=mobile_number) send_sms(message=rejection_message, mobile=mobile_number)
return Response( return Response(
{"message": "User successfully rejected."}, {"message": "User successfully rejected."},
status=status.HTTP_204_NO_CONTENT, status=status.HTTP_204_NO_CONTENT,
) )
class RequestIdUploadAPIView(generics.GenericAPIView):
"""Admin action: ask a user to (re)upload their ID/passport photo."""
permission_classes = [IsAdminOrStaffPermission]
queryset = User.objects.all()
lookup_field = "pk"
def post(self, request, *args, **kwargs):
user = get_object_or_404(User, pk=kwargs.get("pk"))
if user.is_superuser:
return Response(
{"message": "You cannot modify a superuser."},
status=status.HTTP_403_FORBIDDEN,
)
if not user.mobile:
return Response(
{"message": "User does not have a mobile number."},
status=status.HTTP_400_BAD_REQUEST,
)
message_body = request.data.get("message", "")
request_id_upload(user, message_body=message_body)
return Response({"message": "ID upload request sent to the user."})
class IdUploadTokenInfoAPIView(generics.GenericAPIView):
"""Public: validate a magic-link token and return whose upload it is."""
permission_classes = [permissions.AllowAny]
def get(self, request, *args, **kwargs):
key = request.query_params.get("token", "")
token = IdUploadToken.objects.filter(key=key).select_related("user").first()
if not token or not token.is_valid():
return Response(
{"message": "This upload link is invalid or has expired."},
status=status.HTTP_400_BAD_REQUEST,
)
user = token.user
return Response(
{
"first_name": user.first_name,
"last_name": user.last_name,
"status": user.status,
}
)
class IdUploadStartAPIView(generics.GenericAPIView):
"""Public: mint a fresh upload token for a mobile whose account needs an ID.
Lets the login flow send an `id_required` user straight to the upload page
instead of a "pending verification" dead-end. Only works for accounts
actually in the id_required state.
"""
permission_classes = [permissions.AllowAny]
def post(self, request, *args, **kwargs):
mobile = request.data.get("mobile", "")
user = User.objects.filter(mobile=mobile).first()
if not user or user.status != User.STATUS_ID_REQUIRED:
return Response(
{"message": "No ID upload is pending for this number."},
status=status.HTTP_400_BAD_REQUEST,
)
token = IdUploadToken.issue(user)
return Response({"token": token.key})
class IdUploadSubmitAPIView(generics.GenericAPIView):
"""Public: accept the ID/passport photo for a valid token."""
permission_classes = [permissions.AllowAny]
parser_classes = [MultiPartParser, FormParser]
def post(self, request, *args, **kwargs):
key = request.data.get("token", "")
token = IdUploadToken.objects.filter(key=key).select_related("user").first()
if not token or not token.is_valid():
return Response(
{"message": "This upload link is invalid or has expired."},
status=status.HTTP_400_BAD_REQUEST,
)
photo = request.data.get("id_card_photo")
if not photo:
return Response(
{"message": "An ID card / passport photo is required."},
status=status.HTTP_400_BAD_REQUEST,
)
if photo.size > 10 * 1024 * 1024:
return Response(
{"message": "File size exceeds the 10 MB limit."},
status=status.HTTP_400_BAD_REQUEST,
)
if getattr(photo, "content_type", "") not in ID_PHOTO_CONTENT_TYPES:
return Response(
{
"message": "Invalid file type. Please upload a JPG, PNG or PDF file."
},
status=status.HTTP_400_BAD_REQUEST,
)
user = token.user
ext = os.path.splitext(photo.name)[1].lower() or ".jpg"
photo.name = f"{uuid.uuid4()}_{user.id}_id_card{ext}"
user.id_card_photo = photo
user.set_status(User.STATUS_ID_SUBMITTED, save=False)
user.save()
# Single-use: burn the token so the link can't be reused for another
# upload. A fresh link is minted if an admin requests another upload.
token.used_at = timezone.now()
token.save(update_fields=["used_at"])
return Response({"message": "Your ID has been submitted for review."})
@api_view(["GET"]) @api_view(["GET"])
def filter_user(request): def filter_user(request):
id_card = request.GET.get("id_card", "").strip() or None id_card = request.GET.get("id_card", "").strip() or None
@@ -481,18 +679,20 @@ def filter_user(request):
return Response({"ok": False}) return Response({"ok": False})
filters = Q() filters = Q()
if id_card is not None: if id_card and mobile:
filters |= Q(id_card=id_card) filters = Q(id_card=id_card) & Q(mobile=mobile)
if mobile is not None: elif id_card:
filters |= Q(mobile=mobile) 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"Querying with filters: {filters}")
print(f"Found user: {user}") print(f"Found user: {user}")
return Response( return Response(
{"ok": True, "verified": user.verified} {"ok": True, "verified": user.verified, "status": user.status}
if user if user
else {"ok": False, "verified": False} else {"ok": False, "verified": False}
) )
@@ -507,34 +707,31 @@ def filter_temporary_user(request):
return Response({"ok": False}) return Response({"ok": False})
filters = Q() 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) filters |= Q(t_id_card=id_card)
if mobile is not None: elif mobile:
filters |= Q(t_mobile=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"Querying with filters: {filters}")
print(f"Found temporary user: {user}") print(f"Found temporary user: {user}")
return Response( return Response(
{"ok": True, "otp_verified": user.otp_verified} {"ok": True, "otp_verified": user.otp_verified, "t_verified": user.t_verified}
if user if user
else {"ok": False, "otp_verified": False} else {"ok": False, "otp_verified": False, "t_verified": False}
) )
class ListUserByIDCardView(generics.ListAPIView): class UserDetailAPIView(generics.RetrieveAPIView):
# Create user API view permission_classes = [IsAdminOrStaffPermission]
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):
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = CustomReadOnlyUserSerializer serializer_class = CustomReadOnlyUserSerializer
lookup_field = "pk" lookup_field = "pk"
@@ -542,11 +739,7 @@ class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
instance = self.get_object() instance = self.get_object()
user = request.user user = request.user
if ( if user != instance and not user_is_admin(user):
user != instance
and not getattr(user, "is_admin", False)
and not user.is_superuser
):
return Response( return Response(
{"message": "You are not authorized to view this user's details."}, {"message": "You are not authorized to view this user's details."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
@@ -559,19 +752,6 @@ class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
return Response(data) return Response(data)
@api_view(["POST"])
@permission_classes((permissions.AllowAny,))
def test_email(request):
send_mail(
"Subject here",
"Here is the message.",
"noreply@sarlink.net",
["shihaam@shihaam.me"],
fail_silently=False,
)
return Response({"status": "ok"}, status=status.HTTP_200_OK)
class CreateAtollView(StaffEditorPermissionMixin, generics.CreateAPIView): class CreateAtollView(StaffEditorPermissionMixin, generics.CreateAPIView):
serializer_class = AtollSerializer serializer_class = AtollSerializer
queryset = Atoll.objects.all() queryset = Atoll.objects.all()
@@ -638,3 +818,49 @@ class RetrieveUpdateDestroyIslandView(
if name and Island.objects.filter(name=name).exclude(pk=instance.pk).exists(): if name and Island.objects.filter(name=name).exclude(pk=instance.pk).exists():
return Response({"message": "Island name already exists."}, status=400) return Response({"message": "Island name already exists."}, status=400)
return super().update(request, *args, **kwargs) return super().update(request, *args, **kwargs)
class PersonVerifyAPIView(generics.GenericAPIView):
"""
Admin-gated proxy to the external Person verification API.
The SPA frontend can no longer call the external person-verify service
directly (it would leak an internal infra host to the browser), so the
backend owns the integration. Returns the upstream JSON as-is.
This view has no model of its own, so it uses ``IsAdminOrStaffPermission``
rather than the model-based ``StaffEditorPermissionMixin`` (which would
crash dereferencing ``view.queryset.model``).
GET /api/auth/person/<id_card>/
"""
permission_classes = [IsAdminOrStaffPermission]
def get(self, request, id_card: str, *args, **kwargs):
import requests
from decouple import config
PERSON_VERIFY_BASE_URL = config("PERSON_VERIFY_BASE_URL", default="") # type: ignore
if not PERSON_VERIFY_BASE_URL:
return Response(
{"detail": "PERSON_VERIFY_BASE_URL is not set."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
try:
upstream = requests.get(
f"{PERSON_VERIFY_BASE_URL}/api/person/{id_card}", timeout=10
)
except requests.RequestException as exc:
return Response(
{"detail": f"Failed to reach person verification service: {exc}"},
status=status.HTTP_502_BAD_GATEWAY,
)
try:
data = upstream.json()
except ValueError:
return Response(
{"detail": "Invalid response from person verification service."},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(data, status=upstream.status_code)
+17 -21
View File
@@ -26,7 +26,7 @@ env.read_env(os.path.join(BASE_DIR, ".env"))
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # 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 DEBUG = env.bool("DJANGO_DEBUG", default=True) # type: ignore
@@ -52,10 +52,8 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"rest_framework", "rest_framework",
"django_rest_passwordreset",
"djangopasswordlessknox", "djangopasswordlessknox",
"django_extensions", "django_extensions",
"django_seed",
"storages", "storages",
"whitenoise.runserver_nostatic", "whitenoise.runserver_nostatic",
"django.contrib.staticfiles", "django.contrib.staticfiles",
@@ -159,9 +157,9 @@ DATABASES = {
# Password validation # Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
# Uses email as username for login # Log in with username + password (default Django backend)
AUTH_USER_MODEL = "api.User" AUTH_USER_MODEL = "api.User"
AUTHENTICATION_BACKENDS = ["api.backends.EmailBackend"] AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
@@ -203,6 +201,14 @@ STATICFILES_DIRS = [
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media") MEDIA_ROOT = os.path.join(BASE_DIR, "media")
# Uploaded ID/passport photos live in a dedicated directory so it can be
# bind-mounted as a persistent volume in production. Override with the
# IDCARD_STORAGE_ROOT / IDCARD_STORAGE_URL env vars to point at the mount.
IDCARD_STORAGE_ROOT = os.environ.get(
"IDCARD_STORAGE_ROOT", os.path.join(BASE_DIR, "storage", "idcards")
)
IDCARD_STORAGE_URL = os.environ.get("IDCARD_STORAGE_URL", "/storage/idcards/")
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
@@ -235,8 +241,11 @@ REST_FRAMEWORK = {
"login": "1000/min", "login": "1000/min",
}, },
"EXCEPTION_HANDLER": "api.exceptions.custom_exception_handler", "EXCEPTION_HANDLER": "api.exceptions.custom_exception_handler",
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), "DEFAULT_RENDERER_CLASSES": (
# "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema" "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_AUTH = {
# 'PASSWORDLESS_EMAIL_TOKEN_HTML_TEMPLATE_NAME': "password_reset_email.html", "PASSWORDLESS_AUTH_TYPES": ["MOBILE"],
"PASSWORDLESS_AUTH_TYPES": ["EMAIL", "MOBILE"],
"PASSWORDLESS_USER_MOBILE_FIELD_NAME": "mobile", "PASSWORDLESS_USER_MOBILE_FIELD_NAME": "mobile",
"PASSWORDLESS_TEST_SUPPRESSION": False, "PASSWORDLESS_TEST_SUPPRESSION": False,
"PASSWORDLESS_REGISTER_NEW_USERS": True, "PASSWORDLESS_REGISTER_NEW_USERS": True,
"PASSWORDLESS_EMAIL_NOREPLY_ADDRESS": "noreply@sarlink.net",
} }
+3 -4
View File
@@ -27,10 +27,6 @@ from drf_spectacular.views import (
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path(
"api/password_reset/",
include("django_rest_passwordreset.urls", namespace="password_reset"),
),
path("", include("djangopasswordlessknox.urls")), path("", include("djangopasswordlessknox.urls")),
# Authentication # Authentication
path("api/auth/", include("api.urls")), path("api/auth/", include("api.urls")),
@@ -41,6 +37,9 @@ urlpatterns = [
] ]
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(
settings.IDCARD_STORAGE_URL, document_root=settings.IDCARD_STORAGE_ROOT
)
urlpatterns += (path("api-auth/", include("rest_framework.urls")),) urlpatterns += (path("api-auth/", include("rest_framework.urls")),)
urlpatterns += (path("__debug__/", include("debug_toolbar.urls")),) urlpatterns += (path("__debug__/", include("debug_toolbar.urls")),)
urlpatterns += (path("api/schema/", SpectacularAPIView.as_view(), name="schema"),) urlpatterns += (path("api/schema/", SpectacularAPIView.as_view(), name="schema"),)
+21 -1
View File
@@ -1,9 +1,28 @@
from django.contrib import admin from django.contrib import admin
from .models import Payment, BillFormula, Topup from .models import Payment, BillFormula, Topup, WalletTransaction
# Register your models here. # 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): class PaymentAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"id", "id",
@@ -53,3 +72,4 @@ class TopupAdmin(admin.ModelAdmin):
admin.site.register(Payment, PaymentAdmin) admin.site.register(Payment, PaymentAdmin)
admin.site.register(BillFormula) admin.site.register(BillFormula)
admin.site.register(Topup, TopupAdmin) admin.site.register(Topup, TopupAdmin)
admin.site.register(WalletTransaction, WalletTransactionAdmin)
+39 -2
View File
@@ -1,5 +1,5 @@
import django_filters import django_filters
from .models import Payment, Topup from .models import Payment, Topup, WalletTransaction
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
@@ -8,6 +8,7 @@ class PaymentFilter(django_filters.FilterSet):
amount = django_filters.RangeFilter(field_name="amount") amount = django_filters.RangeFilter(field_name="amount")
number_of_months = django_filters.RangeFilter(field_name="number_of_months") number_of_months = django_filters.RangeFilter(field_name="number_of_months")
paid = django_filters.BooleanFilter(field_name="paid") paid = django_filters.BooleanFilter(field_name="paid")
user = django_filters.CharFilter(method="filter_user_search")
method = django_filters.ChoiceFilter( method = django_filters.ChoiceFilter(
choices=Payment.PAYMENT_TYPES, lookup_expr="iexact" choices=Payment.PAYMENT_TYPES, lookup_expr="iexact"
) )
@@ -16,6 +17,14 @@ class PaymentFilter(django_filters.FilterSet):
created_at = django_filters.DateFromToRangeFilter() created_at = django_filters.DateFromToRangeFilter()
is_expired = django_filters.BooleanFilter(method="filter_is_expired") 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): def filter_is_expired(self, queryset, name, value):
""" """
Filter payments based on whether they are expired or not Filter payments based on whether they are expired or not
@@ -29,7 +38,14 @@ class PaymentFilter(django_filters.FilterSet):
class Meta: class Meta:
model = Payment model = Payment
fields = "__all__" fields = [
"amount",
"paid",
"method",
"user",
"created_at",
"is_expired",
]
class TopupFilter(django_filters.FilterSet): class TopupFilter(django_filters.FilterSet):
@@ -71,3 +87,24 @@ class TopupFilter(django_filters.FilterSet):
"created_at", "created_at",
"is_expired", "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
View File
@@ -1,11 +1,11 @@
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from api.models import User
import uuid import uuid
from django.conf import settings
from devices.models import Device
# Create your models here. # Create your models here.
user = settings.AUTH_USER_MODEL
from devices.models import Device
# Create your models here. # Create your models here.
@@ -17,10 +17,11 @@ class Payment(models.Model):
] ]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
mib_reference = models.CharField(default="", null=True, blank=True) mib_reference = models.CharField(default="", null=True, blank=True)
source_bank = models.CharField(default="", null=True, blank=True)
number_of_months = models.IntegerField() number_of_months = models.IntegerField()
amount = models.FloatField() amount = models.FloatField()
paid = models.BooleanField(default=False) 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) paid_at = models.DateTimeField(null=True, blank=True)
method = models.CharField(max_length=255, choices=PAYMENT_TYPES, default="TRANSFER") method = models.CharField(max_length=255, choices=PAYMENT_TYPES, default="TRANSFER")
expiry_notification_sent = models.BooleanField(default=False) expiry_notification_sent = models.BooleanField(default=False)
@@ -65,7 +66,15 @@ class BillFormula(models.Model):
class Topup(models.Model): class Topup(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
amount = models.FloatField() 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 = models.BooleanField(default=False)
paid_at = models.DateTimeField(null=True, blank=True) paid_at = models.DateTimeField(null=True, blank=True)
status = models.CharField( status = models.CharField(
@@ -78,6 +87,7 @@ class Topup(models.Model):
default="PENDING", default="PENDING",
) )
mib_reference = models.CharField(default="", null=True, blank=True) 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) expires_at = models.DateTimeField(null=True, blank=True)
expiry_notification_sent = models.BooleanField(default=False) expiry_notification_sent = models.BooleanField(default=False)
created_at = models.DateTimeField(default=timezone.now) created_at = models.DateTimeField(default=timezone.now)
@@ -94,3 +104,28 @@ class Topup(models.Model):
class Meta: class Meta:
ordering = ["-created_at"] 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
View File
@@ -1,11 +1,23 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Payment, Topup from .models import Payment, Topup, WalletTransaction
from devices.serializers import DeviceSerializer from devices.serializers import AdminDeviceSerializer
class PaymentSerializer(serializers.ModelSerializer): class PaymentSerializer(serializers.ModelSerializer):
devices = DeviceSerializer(many=True, read_only=True) devices = AdminDeviceSerializer(many=True, read_only=True)
is_expired = serializers.SerializerMethodField() 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): def get_is_expired(self, obj):
return obj.is_expired return obj.is_expired
@@ -57,3 +69,23 @@ class TopupSerializer(serializers.ModelSerializer):
"updated_at", "updated_at",
] ]
read_only_fields = ["id", "created_at", "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"]
+12
View File
@@ -10,6 +10,9 @@ from .views import (
VerifyTopupPaymentAPIView, VerifyTopupPaymentAPIView,
TopupDetailAPIView, TopupDetailAPIView,
CancelTopupView, CancelTopupView,
ListWalletTransactionView,
AdminTopupCreateView,
# AlertTestView,
) )
urlpatterns = [ urlpatterns = [
@@ -36,9 +39,18 @@ urlpatterns = [
VerifyTopupPaymentAPIView.as_view(), VerifyTopupPaymentAPIView.as_view(),
name="verify-topup-payment", name="verify-topup-payment",
), ),
path("admin-topup/", AdminTopupCreateView.as_view(), name="admin-topup"),
path( path(
"topup/<str:pk>/cancel/", "topup/<str:pk>/cancel/",
CancelTopupView.as_view(), CancelTopupView.as_view(),
name="cancel-topup", 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"),
] ]
+33
View File
@@ -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)
+302 -66
View File
@@ -13,13 +13,25 @@ from rest_framework.response import Response
from api.mixins import StaffEditorPermissionMixin from api.mixins import StaffEditorPermissionMixin
from api.tasks import add_new_devices_to_omada from api.tasks import add_new_devices_to_omada
from apibase.env import BASE_DIR, env from apibase.env import BASE_DIR, env
from django.db.models import Prefetch
import logging import logging
from .utils import calculate_total_new_price
from .models import Device, Payment, Topup from .models import Device, Payment, Topup, WalletTransaction
from .serializers import PaymentSerializer, UpdatePaymentSerializer, TopupSerializer from .serializers import (
from .filters import PaymentFilter, TopupFilter PaymentSerializer,
UpdatePaymentSerializer,
TopupSerializer,
WalletTransactionSerializer,
)
from .filters import PaymentFilter, TopupFilter, WalletTransactionFilter
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import Optional 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")) env.read_env(os.path.join(BASE_DIR, ".env"))
@@ -49,23 +61,31 @@ class InsufficientFundsError(Exception):
class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIView): class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
serializer_class = PaymentSerializer serializer_class = PaymentSerializer
queryset = Payment.objects.all().select_related("user") queryset = Payment.objects.all().select_related("user")
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend] #type: ignore
filterset_fields = "__all__" filterset_fields = "__all__"
filterset_class = PaymentFilter filterset_class = PaymentFilter
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() unpaid_qs = Payment.objects.filter(paid=False).order_by("-created_at")
if self.request.user.is_superuser: device_qs = Device.objects.prefetch_related(
return queryset Prefetch("payments", queryset=unpaid_qs, to_attr="unpaid_payments")
return queryset.filter(user=self.request.user) )
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): def create(self, request):
data = request.data data = request.data
user = request.user user = request.user
amount = data.get("amount")
number_of_months = data.get("number_of_months") number_of_months = data.get("number_of_months")
number_of_devices = 0
device_ids = data.get("device_ids", []) device_ids = data.get("device_ids", [])
print(amount, number_of_months, device_ids) print(number_of_months, device_ids)
current_time = timezone.now() current_time = timezone.now()
expires_at = current_time + timedelta(minutes=10) expires_at = current_time + timedelta(minutes=10)
for device_id in device_ids: for device_id in device_ids:
@@ -76,9 +96,10 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
{"message": f"Device with id {device_id} not found."}, {"message": f"Device with id {device_id} not found."},
status=status.HTTP_400_BAD_REQUEST, 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( return Response(
{"message": "amount and number_of_months are required."}, {"message": "number_of_months is required."},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
if not device_ids: if not device_ids:
@@ -86,7 +107,9 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
{"message": "device_ids are required."}, {"message": "device_ids are required."},
status=status.HTTP_400_BAD_REQUEST, 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( payment = Payment.objects.create(
amount=amount, amount=amount,
number_of_months=number_of_months, number_of_months=number_of_months,
@@ -137,7 +160,7 @@ class PaymentDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView)
class UpdatePaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView): 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 serializer_class = UpdatePaymentSerializer
lookup_field = "pk" lookup_field = "pk"
@@ -157,22 +180,22 @@ class UpdatePaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView): class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
serializer_class = PaymentSerializer serializer_class = PaymentSerializer
queryset = Payment.objects.all() queryset = Payment.objects.select_related("user").prefetch_related("devices").all()
lookup_field = "pk" lookup_field = "pk"
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
# TODO: Fix check for success payment
payment = self.get_object() payment = self.get_object()
devices = payment.devices.all()
data = request.data data = request.data
user = request.user user = request.user
print("logged in user", 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
print("Payment user", payment.user) omada_client = Omada()
if payment.paid: if payment.paid:
return Response( return Response(
{"message": "Payment has already been verified."}, {"message": "Payment has already been verified."},
status=status.HTTP_400_BAD_REQUEST, 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( return Response(
{"message": "You are not authorized to verify this payment."}, {"message": "You are not authorized to verify this payment."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
@@ -184,7 +207,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
devices = payment.devices.all()
if method == "WALLET": if method == "WALLET":
if user.wallet_balance < payment.amount: # type: ignore if user.wallet_balance < payment.amount: # type: ignore
return Response( return Response(
@@ -193,8 +215,33 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
) )
else: else:
self.process_wallet_payment( self.process_wallet_payment(
user, user, # type: ignore
payment, 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": if method == "TRANSFER":
data = { data = {
@@ -211,7 +258,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
is_active=True, is_active=True,
expiry_date=expiry_date, expiry_date=expiry_date,
has_a_pending_payment=False, has_a_pending_payment=False,
registered=True,
) )
payment.status = "PAID" payment.status = "PAID"
payment.save() payment.save()
@@ -221,9 +267,13 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
device_list.append( device_list.append(
{ {
"mac": device.mac, "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: if not device.registered:
# Add to omada # Add to omada
add_new_devices_to_omada.defer(new_devices=device_list) add_new_devices_to_omada.defer(new_devices=device_list)
@@ -250,40 +300,63 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
status=status.HTTP_400_BAD_REQUEST, 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("processing wallet payment...")
print(user, payment.amount) 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 = True
payment.paid_at = timezone.now() payment.paid_at = timezone.now()
payment.method = "WALLET" 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() payment.save()
user.wallet_balance -= payment.amount user.deduct_wallet_funds(
payment.amount, "Wallet payment for devices", payment.id
)
user.save() user.save()
return True return True
def verify_transfer_payment(self, data, payment) -> PaymentVerificationResponse: def verify_transfer_payment(self, data, payment) -> PaymentVerificationResponse:
if not PAYMENT_BASE_URL: if not PAYMENT_BASE_URL:
raise ValueError( logger.error("PAYMENT_BASE_URL is not set.")
"PAYMENT_BASE_URL is not set. Please set it in your environment variables."
)
response = requests.post(
f"{PAYMENT_BASE_URL}/verify-payment",
json=data,
headers={"Content-Type": "application/json"},
)
logger.info("MIB Verification Response -> ", response)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.error(f"HTTPError: {e}")
return PaymentVerificationResponse( return PaymentVerificationResponse(
message="Payment verification failed.", success=False, transaction=None message="Payment gateway is not configured. Please contact support.",
success=False,
transaction=None,
) )
mib_resp = response.json() try:
logger.info("MIB Verification Response ->", mib_resp) response = requests.post(
if not response.json().get("success"): f"{PAYMENT_BASE_URL}/verify-payment",
json=data,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
mib_resp = response.json()
except requests.exceptions.RequestException as e:
logger.error(f"MIB request failed: {e}")
return PaymentVerificationResponse(
message="Unable to reach the payment gateway. Please try again or contact support.",
success=False,
transaction=None,
)
except ValueError as e:
logger.error(f"MIB returned an invalid response: {e}")
return PaymentVerificationResponse(
message="Received an invalid response from the payment gateway. Please contact support.",
success=False,
transaction=None,
)
logger.info("MIB Verification Response -> %s", mib_resp)
if not mib_resp.get("success"):
return PaymentVerificationResponse( return PaymentVerificationResponse(
message=mib_resp["message"], message=mib_resp["message"],
success=mib_resp["success"], success=mib_resp["success"],
@@ -294,6 +367,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
payment.paid_at = timezone.now() payment.paid_at = timezone.now()
payment.method = "TRANSFER" payment.method = "TRANSFER"
payment.mib_reference = mib_resp["transaction"]["ref"] or "" payment.mib_reference = mib_resp["transaction"]["ref"] or ""
payment.source_bank = mib_resp["transaction"]["sourceBank"] or ""
payment.save() payment.save()
return PaymentVerificationResponse( return PaymentVerificationResponse(
message=mib_resp["message"], message=mib_resp["message"],
@@ -307,7 +381,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView): class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
queryset = Payment.objects.all() queryset = Payment.objects.select_related("user").all()
serializer_class = PaymentSerializer serializer_class = PaymentSerializer
lookup_field = "pk" lookup_field = "pk"
@@ -319,7 +393,7 @@ class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
{"message": "Payment has already been cancelled."}, {"message": "Payment has already been cancelled."},
status=status.HTTP_400_BAD_REQUEST, 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( return Response(
{"message": "You are not authorized to cancel this payment."}, {"message": "You are not authorized to cancel this payment."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
@@ -337,9 +411,9 @@ class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView): class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
queryset = Topup.objects.all() queryset = Topup.objects.all().prefetch_related("user")
serializer_class = TopupSerializer serializer_class = TopupSerializer
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend] #type: ignore
filterset_fields = "__all__" filterset_fields = "__all__"
filterset_class = TopupFilter filterset_class = TopupFilter
@@ -360,10 +434,34 @@ class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() 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
return queryset.filter(user=self.request.user) 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): class TopupDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
queryset = Topup.objects.all() queryset = Topup.objects.all()
@@ -372,7 +470,7 @@ class TopupDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() 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
return queryset.filter(user=self.request.user) return queryset.filter(user=self.request.user)
@@ -384,25 +482,37 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
def verify_transfer_topup(self, data, topup) -> PaymentVerificationResponse: def verify_transfer_topup(self, data, topup) -> PaymentVerificationResponse:
if not PAYMENT_BASE_URL: if not PAYMENT_BASE_URL:
raise ValueError( logger.error("PAYMENT_BASE_URL is not set.")
"PAYMENT_BASE_URL is not set. Please set it in your environment variables." return PaymentVerificationResponse(
message="Payment gateway is not configured. Please contact support.",
success=False,
transaction=None,
) )
logger.info(data) logger.info(data)
response = requests.post(
f"{PAYMENT_BASE_URL}/verify-payment",
json=data,
headers={"Content-Type": "application/json"},
)
try: try:
response.raise_for_status() response = requests.post(
except requests.exceptions.HTTPError as e: f"{PAYMENT_BASE_URL}/verify-payment",
logger.error(f"HTTPError: {e}") json=data,
return PaymentVerificationResponse( headers={"Content-Type": "application/json"},
message="Payment verification failed.", success=False, transaction=None
) )
mib_resp = response.json() response.raise_for_status()
print(mib_resp) mib_resp = response.json()
if not response.json().get("success"): except requests.exceptions.RequestException as e:
logger.error(f"MIB request failed: {e}")
return PaymentVerificationResponse(
message="Unable to reach the payment gateway. Please try again or contact support.",
success=False,
transaction=None,
)
except ValueError as e:
logger.error(f"MIB returned an invalid response: {e}")
return PaymentVerificationResponse(
message="Received an invalid response from the payment gateway. Please contact support.",
success=False,
transaction=None,
)
logger.info("MIB Verification Response -> %s", mib_resp)
if not mib_resp.get("success"):
return PaymentVerificationResponse( return PaymentVerificationResponse(
message=mib_resp["message"], message=mib_resp["message"],
success=mib_resp["success"], success=mib_resp["success"],
@@ -412,6 +522,7 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
topup.paid = True topup.paid = True
topup.mib_reference = mib_resp["transaction"]["ref"] or "" topup.mib_reference = mib_resp["transaction"]["ref"] or ""
topup.paid_at = mib_resp["transaction"]["trxDate"] topup.paid_at = mib_resp["transaction"]["trxDate"]
topup.source_bank = mib_resp["transaction"]["sourceBank"] or ""
topup.save() topup.save()
return PaymentVerificationResponse( return PaymentVerificationResponse(
message=mib_resp["message"], message=mib_resp["message"],
@@ -432,7 +543,7 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
{"message": "Payment has already been verified."}, {"message": "Payment has already been verified."},
status=status.HTTP_400_BAD_REQUEST, 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( return Response(
{"message": "You are not allowed to pay for this topup."}, {"message": "You are not allowed to pay for this topup."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
@@ -449,7 +560,11 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
topup_verification_response = self.verify_transfer_topup(data, topup_instance) topup_verification_response = self.verify_transfer_topup(data, topup_instance)
print("Topup verification response:", topup_verification_response) print("Topup verification response:", topup_verification_response)
if topup_verification_response.success: 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() user.save()
topup_instance.status = "PAID" topup_instance.status = "PAID"
topup_instance.save() topup_instance.save()
@@ -495,7 +610,7 @@ class CancelTopupView(StaffEditorPermissionMixin, generics.UpdateAPIView):
if ( if (
instance.user != user instance.user != user
and getattr(user, "is_admin") and getattr(user, "is_admin")
and not user.is_superuser and not user.is_superuser #type: ignore
): ):
return Response( return Response(
{"message": "You are not authorized to delete this topup."}, {"message": "You are not authorized to delete this topup."},
@@ -509,3 +624,124 @@ class CancelTopupView(StaffEditorPermissionMixin, generics.UpdateAPIView):
instance.status = "CANCELLED" instance.status = "CANCELLED"
instance.save() instance.save()
return super().update(request, *args, **kwargs) 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
View File
@@ -0,0 +1,35 @@
services:
backend:
build:
context: .build/dev
hostname: backend
volumes:
- ./:/app
ports:
# host 8000 -> container 5000 (host :5000 is taken by the local macvendor-api)
- 8000:5000
env_file:
- .env
depends_on:
database:
condition: service_healthy
database:
image: postgres:16
hostname: database
environment:
POSTGRES_DB: ${POSTGRES_DATABASE:-sarlink}
POSTGRES_USER: ${POSTGRES_USER:-sarlink}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-sarlink}"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
@@ -0,0 +1,22 @@
# Generated by Django 5.2 on 2026-08-03 15:59
import devices.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("devices", "0008_alter_device_blocked_by"),
]
operations = [
migrations.AlterField(
model_name="device",
name="mac",
field=models.CharField(
max_length=255,
unique=True,
validators=[devices.models.validate_mac_address],
),
),
]
+13 -2
View File
@@ -1,8 +1,10 @@
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from api.models import User
import re import re
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.conf import settings
user = settings.AUTH_USER_MODEL
def validate_mac_address(value): def validate_mac_address(value):
@@ -13,10 +15,19 @@ def validate_mac_address(value):
return value return value
def normalize_mac(value):
"""Canonicalize any accepted MAC format to upper-case, dash-separated
form (e.g. "aa:bb:cc:dd:ee:ff" and "aabbccddeeff" -> "AA-BB-CC-DD-EE-FF").
Used so uniqueness is enforced on one canonical representation."""
hex_only = re.sub(r"[^0-9A-Fa-f]", "", value or "").upper()
return "-".join(hex_only[i : i + 2] for i in range(0, len(hex_only), 2))
class Device(models.Model): class Device(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
mac = models.CharField( mac = models.CharField(
max_length=255, max_length=255,
unique=True,
validators=[ validators=[
validate_mac_address, validate_mac_address,
], ],
@@ -38,7 +49,7 @@ class Device(models.Model):
created_at = models.DateTimeField(default=timezone.now) created_at = models.DateTimeField(default=timezone.now)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
user = models.ForeignKey( 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): def __str__(self):
+23 -4
View File
@@ -1,5 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Device from .models import Device, normalize_mac
from api.serializers import CustomReadOnlyUserSerializer from api.serializers import CustomReadOnlyUserSerializer
from billing.models import Payment # Import the Payment model from billing.models import Payment # Import the Payment model
@@ -8,6 +8,9 @@ class CreateDeviceSerializer(serializers.ModelSerializer):
name = serializers.CharField(required=True) name = serializers.CharField(required=True)
mac = serializers.CharField(required=True) mac = serializers.CharField(required=True)
def validate_mac(self, value):
return normalize_mac(value)
class Meta: # type: ignore class Meta: # type: ignore
model = Device model = Device
fields = [ fields = [
@@ -36,9 +39,9 @@ class DeviceSerializer(serializers.ModelSerializer):
def get_pending_payment_id(self, obj): def get_pending_payment_id(self, obj):
unpaid_payment = ( unpaid_payment = (
Payment.objects.filter(devices=obj, paid=False) obj.unpaid_payments[0]
.order_by("-created_at") if hasattr(obj, "unpaid_payments") and obj.unpaid_payments
.first() else None
) )
return unpaid_payment.id if unpaid_payment else None return unpaid_payment.id if unpaid_payment else None
@@ -58,6 +61,22 @@ class DeviceSerializer(serializers.ModelSerializer):
fields = "__all__" 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): class ReadOnlyDeviceSerializer(serializers.ModelSerializer):
user = CustomReadOnlyUserSerializer(read_only=True) user = CustomReadOnlyUserSerializer(read_only=True)
+36 -11
View File
@@ -3,7 +3,9 @@ from xmlrpc.client import Boolean
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework.response import Response from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from .models import Device from billing.models import Payment
from .models import Device, normalize_mac
from django.db.models import Prefetch
from .serializers import ( from .serializers import (
CreateDeviceSerializer, CreateDeviceSerializer,
DeviceSerializer, DeviceSerializer,
@@ -28,6 +30,13 @@ class DeviceListCreateAPIView(
filterset_fields = "__all__" filterset_fields = "__all__"
filterset_class = DeviceFilter 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): def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
all_devices = request.query_params.get("all_devices", "false").lower() in [ all_devices = request.query_params.get("all_devices", "false").lower() in [
@@ -58,11 +67,25 @@ class DeviceListCreateAPIView(
return DeviceSerializer return DeviceSerializer
def create(self, request, *args, **kwargs): 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}$") MAC_REGEX = re.compile(r"^([0-9A-Fa-f]{2}([.:-]?)){5}[0-9A-Fa-f]{2}$")
NORMALIZE_MAC_REGEX = re.compile(r"[^0-9A-Fa-f]")
if not isinstance(mac, str) or not MAC_REGEX.match(mac): if not isinstance(mac, str) or not MAC_REGEX.match(mac):
return Response({"message": "Invalid mac address."}, status=400) return Response({"message": "Invalid mac address."}, status=400)
# Canonicalize BEFORE checking uniqueness and before saving, so that
# the same physical MAC in different formats/cases is treated as one.
mac = normalize_mac(mac)
if Device.objects.filter(mac=mac).exists(): if Device.objects.filter(mac=mac).exists():
return Response( return Response(
{"message": "Device with this mac address already exists."}, status=400 {"message": "Device with this mac address already exists."}, status=400
@@ -71,8 +94,9 @@ class DeviceListCreateAPIView(
if not mac_details.ok: if not mac_details.ok:
return Response({"message": "MAC address vendor not found."}, status=400) return Response({"message": "MAC address vendor not found."}, status=400)
mac = re.sub(NORMALIZE_MAC_REGEX, "-", mac).upper() # The serializer canonicalizes the MAC again on save (see
# CreateDeviceSerializer.validate_mac), so the stored value matches
# what we checked above regardless of the raw request format.
return super().create(request, *args, **kwargs) return super().create(request, *args, **kwargs)
def perform_create(self, serializer): def perform_create(self, serializer):
@@ -90,7 +114,7 @@ class DeviceDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
class DeviceUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView): class DeviceUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
queryset = Device.objects.all() queryset = Device.objects.select_related("user").all()
serializer_class = CreateDeviceSerializer serializer_class = CreateDeviceSerializer
lookup_field = "pk" lookup_field = "pk"
@@ -116,7 +140,7 @@ class DeviceUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
class DeviceBlockAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView): class DeviceBlockAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
queryset = Device.objects.all() queryset = Device.objects.select_related("user").all()
serializer_class = BlockDeviceSerializer serializer_class = BlockDeviceSerializer
lookup_field = "pk" lookup_field = "pk"
@@ -136,10 +160,11 @@ class DeviceBlockAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
if not isinstance(blocked, bool): if not isinstance(blocked, bool):
return Response({"message": "Blocked field must be a boolean."}, status=400) return Response({"message": "Blocked field must be a boolean."}, status=400)
omada_client = Omada() omada_client = Omada()
blocked = omada_client.block_device( omada_response = omada_client.block_device(
instance.mac, operation="block" if blocked else "unblock" 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.blocked = blocked
instance.save() instance.save()
serializer = self.get_serializer(instance, data=request.data, partial=False) serializer = self.get_serializer(instance, data=request.data, partial=False)
@@ -148,13 +173,13 @@ class DeviceBlockAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
return Response(serializer.data) return Response(serializer.data)
else: else:
return Response( return Response(
{"message": blocked.msg}, {"message": omada_response.msg},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
class DeviceDestroyAPIView(StaffEditorPermissionMixin, generics.DestroyAPIView): class DeviceDestroyAPIView(StaffEditorPermissionMixin, generics.DestroyAPIView):
queryset = Device.objects.all() queryset = Device.objects.select_related("user").all()
serializer_class = DeviceSerializer serializer_class = DeviceSerializer
lookup_field = "pk" lookup_field = "pk"
+5 -4
View File
@@ -232,14 +232,15 @@ def send_sms_with_callback_token(user, mobile_token, **kwargs):
logger.debug("Failed to send SMS. Missing SMS_API_URL or SMS_API_KEY.") logger.debug("Failed to send SMS. Missing SMS_API_URL or SMS_API_KEY.")
return False return False
from api.notifications import format_mobile
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {api_key}", "X-API-Key": api_key,
} }
data = { data = {
"number": to_number, "to": format_mobile(to_number),
"message": base_string % mobile_token.key, "text": base_string % mobile_token.key,
"check_delivery": False,
} }
print(mobile_token.key) print(mobile_token.key)
response = requests.post(api_url, headers=headers, data=json.dumps(data)) response = requests.post(api_url, headers=headers, data=json.dumps(data))
-37
View File
@@ -1,37 +0,0 @@
services:
api:
build:
context: .
restart: always
command: gunicorn apibase.wsgi:application --bind 0.0.0.0:5000 --workers=2
volumes:
- /home/<username>/docker/council-api/staticfiles:/home/app/api/staticfiles
ports:
- 5000:5000
env_file:
- ./.env
depends_on:
- db
- redis
db:
image: postgres:15
restart: always
volumes:
- ./postgres_data:/var/lib/postgresql/data/
env_file:
- ./.env
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DATABASE}
ports:
- 5232:5432
redis:
image: "redis:alpine"
restart: always
expose:
- "6379"
+3
View File
@@ -0,0 +1,3 @@
"We're sorry, but your SAR Link account registration could not be approved automatically. \n
f{note} or if empty "Please upload a clear photo of your ID card / passport here: \n"
+9 -14
View File
@@ -1,16 +1,11 @@
{ {
"venvPath": ".", "venvPath": ".",
"venv": ".venv", "venv": ".venv",
"reportMissingImports": "error", "reportMissingImports": "error",
"include": ["src"], "include": ["src"],
"typeCheckingMode": "standard", "typeCheckingMode": "standard",
"reportArgumentType": "warning", "reportArgumentType": "warning",
"reportUnusedVariable": "warning", "reportUnusedVariable": "warning",
"reportFunctionMemberAccess": "none", "reportFunctionMemberAccess": "none",
"exclude": [ "exclude": ["council-api/**/migrations", "**/__pycache__"]
"council-api/**/migrations",
"**/__pycache__",
"src/experimental",
"src/typestubs"
]
} }
-2
View File
@@ -36,8 +36,6 @@ django-extensions==3.2.3
django-filter==23.5 django-filter==23.5
django-redis==5.4.0 django-redis==5.4.0
django-rest-knox==4.2.0 django-rest-knox==4.2.0
django-rest-passwordreset==1.5.0
django-seed==0.3.1
django-storages==1.14.4 django-storages==1.14.4
django-stubs==5.1.1 django-stubs==5.1.1
django-stubs-ext==5.1.1 django-stubs-ext==5.1.1