Author SHA1 Message Date
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 Aidhaan 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 Aidhaan f8c0725558 Merge pull request #17 from i701/feat/user-verification-flow
Feat/user verification flow
2025-07-23 22:34:37 +05:00
i701 7c5ed1e89d feat(payment): enhance list view to support admin access to all payments 2025-07-23 22:10:49 +05:00
i701 976a119fcc refactor(models): add db_index to mobile and id_card fields in User and TemporaryUser models 🔨 2025-07-16 01:49:17 +05:00
Abdulla Aidhaan 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
i701 d64a2675e4 refactor(verification): enhance error handling and response structure in user verification process 🔨 2025-07-15 23:04:11 +05:00
Abdulla Aidhaan 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
i701 eee314af46 feat(user): add user rejection endpoint and improve verification response messages 2025-07-13 19:38:01 +05:00
i701 ff065fa4a9 feat(user): add user update endpoint with authorization checks and serializer support 2025-07-11 19:55:25 +05:00
i701 72c2ea1ecc refactor(views): update user queryset logic to filter out superusers for non-admins 🔨 2025-07-11 15:10:01 +05:00
i701 596ce510c7 feat(filters): add id_card and mobile filters to UserFilter class 2025-07-11 15:09:51 +05:00
i701 436a8b7d7a feat(profile): add user profile update serializer and update profile URL to use UserprofileAPIView 2025-07-11 11:44:40 +05:00
i701 82ae1e6cea feat(user): enhance user profile management with dynamic serializer selection and authorization check 2025-07-11 11:44:21 +05:00
i701 56ab79bd8c refactor(signals): exclude delete permissions for payment and topup in user permission assignment 🔨 2025-07-11 09:57:58 +05:00
Abdulla Aidhaan 64c2189209 Merge pull request #14 from i701/feat/task-queues
refactor(tasks): update job scheduling frequency and reduce max hours for old job removal 🔨
2025-07-11 09:54:00 +05:00
Abdulla Aidhaan dacf821bad Merge pull request #13 from i701/feat/task-queues
feat(devices): add pending_payment_id field to DeviceSerializer for tracking unpaid payments 
2025-07-09 22:07:46 +05:00
Abdulla Aidhaan 3f0a5f0f03 Merge pull request #12 from i701/feat/task-queues
refactor(devices): update device payment status in background task and remove unused serializer field 🔨
2025-07-09 21:56:48 +05:00
Abdulla Aidhaan db53874ff4 Merge pull request #11 from i701/feat/task-queues
refactor(tasks): replace Enum with string literals for notification types in SMS tasks 🔨
2025-07-09 20:33:03 +05:00
Abdulla Aidhaan d557bb879f Merge pull request #10 from i701/feat/task-queues
feature / task queues cleanup 
2025-07-09 20:16:48 +05:00
30 changed files with 1373 additions and 360 deletions
+4
View File
@@ -9,6 +9,10 @@ server {
location /static/ { location /static/ {
alias /var/www/html/staticfiles/; alias /var/www/html/staticfiles/;
} }
location /media/ {
alias /var/www/html/media/;
}
# Forward requests to Gunicorn # Forward requests to Gunicorn
location / { location / {
+1
View File
@@ -163,3 +163,4 @@ cython_debug/
#staticfiles #staticfiles
staticfiles/ staticfiles/
postgres_data/ postgres_data/
media/
+1
View File
@@ -47,6 +47,7 @@ class UserAdmin(BaseUserAdmin):
"island", "island",
"terms_accepted", "terms_accepted",
"policy_accepted", "policy_accepted",
"agreement",
) )
}, },
), ),
+69
View File
@@ -0,0 +1,69 @@
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)
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...")
await bot.send_message(
chat_id=str(CHAT_ID),
text=markdown_message,
parse_mode=ParseMode.MARKDOWN_V2,
)
def escape_markdown_v2(text: str) -> str:
escape_chars = r"_~`>#+-=|{}.!\\"
return re.sub(f"([{re.escape(escape_chars)}])", r"\\\1", text)
+2
View File
@@ -6,6 +6,8 @@ class UserFilter(django_filters.FilterSet):
last_name = django_filters.CharFilter(lookup_expr="icontains") last_name = django_filters.CharFilter(lookup_expr="icontains")
first_name = django_filters.CharFilter(lookup_expr="icontains") first_name = django_filters.CharFilter(lookup_expr="icontains")
email = django_filters.CharFilter(lookup_expr="icontains") email = django_filters.CharFilter(lookup_expr="icontains")
id_card = django_filters.CharFilter(lookup_expr="icontains")
mobile = django_filters.CharFilter(lookup_expr="icontains")
class Meta: class Meta:
model = User model = User
+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))
@@ -0,0 +1,40 @@
# Generated by Django 5.2 on 2025-07-15 20:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0016_user_is_admin"),
]
operations = [
migrations.AlterField(
model_name="temporaryuser",
name="t_id_card",
field=models.CharField(
blank=True, db_index=True, max_length=255, null=True, unique=True
),
),
migrations.AlterField(
model_name="temporaryuser",
name="t_mobile",
field=models.CharField(
blank=True, db_index=True, max_length=255, null=True, unique=True
),
),
migrations.AlterField(
model_name="user",
name="id_card",
field=models.CharField(
blank=True, db_index=True, max_length=255, null=True, unique=True
),
),
migrations.AlterField(
model_name="user",
name="mobile",
field=models.CharField(
blank=True, db_index=True, max_length=255, null=True, unique=True
),
),
]
+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/",
),
),
]
+46 -5
View File
@@ -8,15 +8,20 @@ from django.db import models
from .managers import CustomUserManager from .managers import CustomUserManager
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):
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(max_length=255, blank=True, unique=True, null=True) mobile = models.CharField(
max_length=255, blank=True, unique=True, null=True, db_index=True
)
designation = models.CharField(max_length=255, blank=True) designation = models.CharField(max_length=255, blank=True)
acc_no = models.CharField(max_length=255, blank=True) acc_no = models.CharField(max_length=255, blank=True)
id_card = models.CharField(max_length=255, blank=True, unique=True, null=True) id_card = models.CharField(
max_length=255, blank=True, unique=True, null=True, db_index=True
)
verified = models.BooleanField(default=False) verified = models.BooleanField(default=False)
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)
@@ -30,12 +35,44 @@ 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()
@@ -46,10 +83,14 @@ class TemporaryUser(models.Model):
t_last_name = models.CharField(max_length=255, blank=True) t_last_name = models.CharField(max_length=255, blank=True)
t_address = models.CharField(max_length=255, blank=True) t_address = models.CharField(max_length=255, blank=True)
t_email = models.EmailField(blank=True, null=True, unique=True) t_email = models.EmailField(blank=True, null=True, unique=True)
t_mobile = models.CharField(max_length=255, blank=True, unique=True, null=True) t_mobile = models.CharField(
max_length=255, blank=True, unique=True, null=True, db_index=True
)
t_designation = models.CharField(max_length=255, blank=True) t_designation = models.CharField(max_length=255, blank=True)
t_acc_no = models.CharField(max_length=255, blank=True) t_acc_no = models.CharField(max_length=255, blank=True)
t_id_card = models.CharField(max_length=255, blank=True, unique=True, null=True) t_id_card = models.CharField(
max_length=255, blank=True, unique=True, null=True, db_index=True
)
t_verified = models.BooleanField(default=False) t_verified = models.BooleanField(default=False)
t_dob = models.DateField(blank=True, null=True) t_dob = models.DateField(blank=True, null=True)
t_terms_accepted = models.BooleanField(default=False) t_terms_accepted = models.BooleanField(default=False)
@@ -95,7 +136,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):
+41 -2
View File
@@ -1,9 +1,12 @@
from knox.models import AuthToken from knox.models import AuthToken
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from api.models import User, Atoll, Island, TemporaryUser from api.models import Atoll, Island, TemporaryUser
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from rest_framework import serializers from rest_framework import serializers
from django.contrib.auth import get_user_model
User = get_user_model()
class IslandSerializer(serializers.ModelSerializer): class IslandSerializer(serializers.ModelSerializer):
@@ -21,6 +24,39 @@ class AtollSerializer(serializers.ModelSerializer):
depth = 2 depth = 2
class UserProfileUpdateSerializer(serializers.ModelSerializer):
class Meta: # type: ignore
model = User
fields = (
"email",
"mobile",
) # Only allow these fields
class UserUpdateSerializer(serializers.ModelSerializer):
class Meta: # type: ignore
model = User
fields = (
"id_card",
"mobile",
"first_name",
"last_name",
"address",
"dob",
"atoll",
"island",
)
class UserAgreementSerializer(serializers.ModelSerializer):
"""serializer for the user agreement object"""
class Meta: # type: ignore
model = User
fields = ("agreement",)
extra_kwargs = {"agreement": {"required": True, "allow_null": False}}
class CustomUserSerializer(serializers.ModelSerializer): class CustomUserSerializer(serializers.ModelSerializer):
"""serializer for the user object""" """serializer for the user object"""
@@ -80,7 +116,10 @@ class CustomReadOnlyUserSerializer(serializers.ModelSerializer):
"username", "username",
"mobile", "mobile",
"address", "address",
"acc_no",
"id_card", "id_card",
"agreement",
"wallet_balance",
) )
depth = 1 depth = 1
@@ -120,7 +159,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):
+19 -7
View File
@@ -4,7 +4,7 @@ from django.template.loader import render_to_string
from decouple import config from decouple import config
from django_rest_passwordreset.signals import reset_password_token_created 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
@@ -12,13 +12,22 @@ from api.tasks import verify_user_with_person_api_task
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def assign_device_permissions(sender, instance, created, **kwargs): def assign_device_permissions(sender, instance, created, **kwargs):
if created: if created:
# Assign all permissions for devices and read permission for atoll and island
device_permissions = Permission.objects.filter(content_type__model="device") device_permissions = Permission.objects.filter(content_type__model="device")
atoll_read_permission = Permission.objects.get(codename="view_atoll") atoll_read_permission = Permission.objects.get(codename="view_atoll")
island_read_permission = Permission.objects.get(codename="view_island") island_read_permission = Permission.objects.get(codename="view_island")
payment_permissions = Permission.objects.filter(content_type__model="payment") payment_permissions = Permission.objects.filter(
topup_permissions = Permission.objects.filter(content_type__model="topup") content_type__model="payment"
).exclude(codename__startswith="delete_")
topup_permissions = Permission.objects.filter(
content_type__model="topup"
).exclude(codename__startswith="delete_")
wallet_transaction_permissions = Permission.objects.filter(
content_type__model="wallettransaction"
).exclude(codename__startswith="delete_")
user_read_only_permission = Permission.objects.get(
codename="view_user", content_type__model="user"
)
instance.user_permissions.add(user_read_only_permission)
for permission in topup_permissions: 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:
@@ -26,12 +35,15 @@ 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) @receiver(reset_password_token_created)
+122 -62
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,13 @@ from django.utils import timezone
# from api.notifications import send_clean_telegram_markdown # from api.notifications import send_clean_telegram_markdown
from api.omada import Omada from api.omada import Omada
from api.bot import send_telegram_alert, telegram_loop, escape_markdown_v2
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 requests
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,22 +41,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 +106,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 +144,31 @@ 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}")
verification_failed_message = f"""*The following user verification failed*:\n\n*ID Card:* {t_user.t_id_card}\n*Name:* {t_user.t_first_name} {t_user.t_last_name}\n*House Name:* {t_user.t_address}\n*Date of Birth:* {t_user.t_dob}\n*Island:* {(t_user.t_atoll.name if t_user.t_atoll else "N/A")} {(t_user.t_island.name if t_user.t_island else "N/A")}\n*Mobile:* {t_user.t_mobile}\nVisit [SAR Link Portal](https://portal.sarlink.net/users/{user_id}/details) to manually verify this user.
"""
logger.info(verification_failed_message)
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")
@@ -125,10 +178,10 @@ def verify_user_with_person_api_task(user_id: int):
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")
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 +192,60 @@ 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,
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",
)
return True return True
else: else:
user.verified = False t_user.t_verified = False
user.save() t_user.save()
send_sms(
user.mobile,
f"Dear {user.first_name} {user.last_name}, \n\nYour account registration is being processed. \n\nWe will notify you once verification is complete. \n\n - SAR Link",
)
# send_clean_telegram_markdown(message=verification_failed_message) # send_clean_telegram_markdown(message=verification_failed_message)
try:
asyncio.run_coroutine_threadsafe(
send_telegram_alert(
markdown_message=escape_markdown_v2(verification_failed_message)
),
telegram_loop,
).result()
except Exception as e:
logger.warning("[Registration] TELEGRAM ALERT ERROR", e)
return False return False
else: else:
# Handle the error case # Handle the error case
+14 -7
View File
@@ -5,7 +5,7 @@ from knox import views as knox_views
from .views import ( from .views import (
LoginView, LoginView,
CreateTemporaryUserView, CreateTemporaryUserView,
ManageUserView, UserprofileAPIView,
KnoxTokenListApiView, KnoxTokenListApiView,
ListUserView, ListUserView,
UserDetailAPIView, UserDetailAPIView,
@@ -18,29 +18,36 @@ from .views import (
RetrieveUpdateDestroyIslandView, RetrieveUpdateDestroyIslandView,
filter_user, filter_user,
filter_temporary_user, filter_temporary_user,
UpdateUserWalletView,
VerifyOTPView, VerifyOTPView,
UserVerifyAPIView, UserVerifyAPIView,
UserUpdateAPIView,
UserRejectAPIView,
AgreementUpdateAPIView,
) )
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("profile/", ManageUserView.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"),
path("logoutall/", knox_views.LogoutAllView.as_view(), name="knox_logoutall"), path("logoutall/", knox_views.LogoutAllView.as_view(), name="knox_logoutall"),
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>/verify/", UserVerifyAPIView.as_view(), name="user-verify"), path("users/<int:pk>/update/", UserUpdateAPIView.as_view(), name="user-update"),
path("users/filter/", filter_user, name="filter-users"), path("users/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
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("healthcheck/", healthcheck, name="healthcheck"), path("healthcheck/", healthcheck, name="healthcheck"),
path("test/", test_email, name="testemail"), path("test/", test_email, name="testemail"),
path("atolls/", ListAtollView.as_view(), name="atolls"), path("atolls/", ListAtollView.as_view(), name="atolls"),
+27 -18
View File
@@ -1,5 +1,5 @@
import logging import logging
from typing import List, TypedDict from typing import List, Optional, TypedDict
import requests import requests
from decouple import config from decouple import config
from api.models import User from api.models import User
@@ -40,7 +40,9 @@ def reverse_dhivehi_string(input_str):
class MismatchResult(TypedDict): class MismatchResult(TypedDict):
ok: bool ok: bool
mismatch_fields: List[str] mismatch_fields: Optional[List[str]]
error: Optional[str]
detail: Optional[str]
def check_person_api_verification( def check_person_api_verification(
@@ -63,20 +65,22 @@ def check_person_api_verification(
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."
) )
print(id_card)
response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{id_card}") response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{id_card}")
api_reponse = response.json()
if response.status_code != 200: if response.status_code != 200:
logger.error( logger.error(
f"Failed to fetch data from Person API for ID Card '{id_card}'. " f"Failed to fetch data from Person API for ID Card '{id_card}'. "
f"Status Code: {response.status_code}, Response: {response.text}" f"Status Code: {response.status_code}, Response: {response.text}"
) )
return {"ok": False, "mismatch_fields": ["api_error"]} return {
api_data = response.json() "ok": False,
if not api_data: "mismatch_fields": None,
logger.error( "error": response.json()["error"] if "error" in response.json() else None,
f"No data found in Person API for ID Card '{id_card}'. Response: {response.text}" "detail": response.json()["detail"]
) if "detail" in response.json()
return {"ok": False, "mismatch_fields": ["no_data"]} else None,
}
# Initialize a list to hold fields that do not match # Initialize a list to hold fields that do not match
mismatch_fields = [] mismatch_fields = []
@@ -86,12 +90,12 @@ def check_person_api_verification(
user_dob_iso = user_data.dob.isoformat() if user_data.dob else None user_dob_iso = user_data.dob.isoformat() if user_data.dob else None
# Prepare API data for comparison # Prepare API data for comparison
api_nic = api_data.get("nic") api_nic = api_reponse.get("nic")
api_name = api_data.get("name_en") api_name = api_reponse.get("name_en")
api_house_name = api_data.get("house_name_en") api_house_name = api_reponse.get("house_name_en")
api_dob = api_data.get("dob") api_dob = api_reponse.get("dob")
api_atoll = api_data.get("atoll_en") api_atoll = api_reponse.get("atoll_en")
api_island_name = api_data.get("island_name_en") api_island_name = api_reponse.get("island_name_en")
# Perform comparisons and identify mismatches # Perform comparisons and identify mismatches
if user_data.id_card != api_nic: if user_data.id_card != api_nic:
@@ -134,6 +138,11 @@ def check_person_api_verification(
) )
if mismatch_fields: if mismatch_fields:
return {"ok": False, "mismatch_fields": mismatch_fields} return {
"ok": False,
"mismatch_fields": mismatch_fields,
"error": None,
"detail": None,
}
else: else:
return {"ok": True, "mismatch_fields": []} return {"ok": True, "mismatch_fields": [], "error": None, "detail": None}
+268 -185
View File
@@ -2,11 +2,13 @@
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.models import User, Atoll, Island, TemporaryUser
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
@@ -14,24 +16,27 @@ from rest_framework.decorators import api_view, permission_classes
from api.serializers import ( from api.serializers import (
AtollSerializer, AtollSerializer,
IslandSerializer, IslandSerializer,
CustomUserByWalletBalanceSerializer,
OTPVerificationSerializer, OTPVerificationSerializer,
TemporaryUserSerializer, TemporaryUserSerializer,
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.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 (
@@ -39,22 +44,9 @@ from .serializers import (
AuthSerializer, AuthSerializer,
CustomUserSerializer, CustomUserSerializer,
CustomReadOnlyUserSerializer, CustomReadOnlyUserSerializer,
CustomReadOnlyUserByIDCardSerializer, UserProfileUpdateSerializer,
) )
ID_CARD_PATTERN = r"^[A-Z]{1,2}[0-9]{6,7}$"
MOBILE_PATTERN = r"^[7|9][0-9]{6}$"
ACCOUNT_NUMBER_PATTERN = r"^(7\d{12}|9\d{16})$"
class ErrorMessages:
USERNAME_EXISTS = "Username already exists."
MOBILE_EXISTS = "Mobile number already exists."
INVALID_ID_CARD = "Please enter a valid ID card number."
ID_CARD_EXISTS = "ID card already exists."
INVALID_MOBILE = "Please enter a valid mobile number."
INVALID_ACCOUNT = "Please enter a valid account number."
UNDERAGE_ERROR = "You must be 18 and above to signup."
@api_view(["GET"]) @api_view(["GET"])
@@ -62,170 +54,94 @@ 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):
# Create user API view
serializer_class = CustomUserByWalletBalanceSerializer
permission_classes = (permissions.IsAuthenticated,)
queryset = User.objects.all()
lookup_field = "pk"
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
@@ -267,17 +183,34 @@ 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,
) )
if temp_user.t_verified:
send_sms(
t_user.t_mobile,
f"Dear {temp_user.t_first_name} {temp_user.t_last_name}, \n\nYour account has been successfully verified. \n\nYou can now manage your devices and make payments through our portal at https://portal.sarlink.net. \n\n - SAR Link",
)
else:
send_sms(
t_user.t_mobile,
f"Dear {t_user.t_first_name} {t_user.t_last_name}, \n\nYour account registration is being processed. \n\nWe will notify you once verification is complete. \n\n - SAR Link",
)
# You can now trigger registry verification as a signal or task # 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."}) return Response(
{
"message": "User created successfully.",
"verified": temp_user.t_verified
}
)
class LoginView(KnoxLoginView): class LoginView(KnoxLoginView):
@@ -305,17 +238,109 @@ class LoginView(KnoxLoginView):
return Response({"message": message}, status=status.HTTP_400_BAD_REQUEST) return Response({"message": message}, status=status.HTTP_400_BAD_REQUEST)
class ManageUserView(generics.RetrieveUpdateAPIView): class UserprofileAPIView(generics.RetrieveUpdateAPIView):
"""Manage the authenticated user""" """Retrieve user api view"""
serializer_class = CustomUserSerializer queryset = User.objects.all()
permission_classes = (permissions.IsAuthenticated,) permission_classes = (permissions.IsAuthenticated,)
def get_serializer_class(self):
"""Return the serializer class based on the request method"""
if self.request.method == "GET":
return CustomReadOnlyUserSerializer
elif self.request.method == "PUT" or self.request.method == "PATCH":
return UserProfileUpdateSerializer
return super().get_serializer_class()
def get_object(self): def get_object(self):
"""Retrieve and return authenticated user""" """Retrieve and return authenticated user"""
return self.request.user return self.request.user
class UserUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
serializer_class = UserUpdateSerializer
queryset = User.objects.all()
lookup_field = "pk"
def update(self, request, *args, **kwargs):
user_id = kwargs.get("pk")
user = get_object_or_404(User, pk=user_id)
if user.is_superuser:
return Response(
{"message": "You cannot update a superuser."},
status=status.HTTP_403_FORBIDDEN,
)
if request.user != user and (
not request.user.is_authenticated
or not getattr(request.user, "is_admin", False)
):
return Response(
{"message": "You are not authorized to update this user."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = self.get_serializer(
user,
data=request.data,
partial=True,
)
serializer.is_valid(raise_exception=True)
user.save()
return super().update(request, *args, **kwargs)
class AgreementUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
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 request.user.is_authenticated
or not getattr(request.user, "is_admin", False)
):
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,
@@ -336,16 +361,16 @@ 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()
def get_queryset(self): def get_queryset(self):
user = self.request.user user = self.request.user
if user.is_authenticated and user.is_staff: if user.is_authenticated and getattr(user, "is_admin"):
return User.objects.all() return User.objects.filter(is_superuser=False)
return User.objects.filter(is_staff=False) return User.objects.none()
class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView): class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
@@ -364,30 +389,80 @@ class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
{"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,
) )
serializer = self.get_serializer(user, data=request.data, partial=True) if user.verified:
serializer.is_valid(raise_exception=True)
verified_person = check_person_api_verification(
user_data=user, id_card=user.id_card
)
if not verified_person["ok"]:
return Response( return Response(
{ {"message": "User is already verified."},
"message": "User verification failed. Please check sarlink user details.",
"mismatch_fields": verified_person["mismatch_fields"],
},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
if verified_person["mismatch_fields"]: serializer = self.get_serializer(user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
result = check_person_api_verification(user_data=user, id_card=user.id_card)
# The verification system might not have the records of every user hence can be skipped if not found and verify directly.
if result.get("error") == "Not Found":
user.verified = True
user.save()
return Response( return Response(
{ {
"message": "User verification failed due to mismatched fields.", "message": "User not found in the verification system. User marked as verified."
"mismatch_fields": verified_person["mismatch_fields"],
}, },
status=status.HTTP_404_NOT_FOUND,
)
if not result["ok"]:
return Response(
result,
status=status.HTTP_404_NOT_FOUND,
)
if result["mismatch_fields"]:
return Response(
result,
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
user.verified = True user.verified = True
user.save() user.save()
return Response({"message": "User verification status updated."}) return Response({"message": "User successfully verified."})
class UserRejectAPIView(StaffEditorPermissionMixin, generics.DestroyAPIView):
serializer_class = CustomUserSerializer
queryset = User.objects.all()
lookup_field = "pk"
def destroy(self, request, *args, **kwargs):
rejection_details = request.data.get("rejection_details", "")
if not rejection_details:
return Response(
{"message": "Rejection details are required."},
status=status.HTTP_400_BAD_REQUEST,
)
user_id = kwargs.get("pk")
user = get_object_or_404(User, pk=user_id)
mobile_number = user.mobile
if not mobile_number:
return Response(
{"message": "User does not have a mobile number."},
status=status.HTTP_400_BAD_REQUEST,
)
if user.is_superuser:
return Response(
{"message": "You cannot remove a superuser."},
status=status.HTTP_403_FORBIDDEN,
)
if request.user != user and (
not request.user.is_authenticated
or not getattr(request.user, "is_admin", False)
):
return Response(
{"message": "You are not authorized to reject this user."},
status=status.HTTP_403_FORBIDDEN,
)
user.delete()
t_user = get_object_or_404(TemporaryUser, t_mobile=user.mobile)
t_user.delete()
send_sms(message=rejection_details, mobile=mobile_number)
return Response(
{"message": "User successfully rejected."},
status=status.HTTP_204_NO_CONTENT,
)
@api_view(["GET"]) @api_view(["GET"])
@@ -399,12 +474,14 @@ 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").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}")
@@ -425,33 +502,29 @@ 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):
# Create user API view
permission_classes = (permissions.AllowAny,)
serializer_class = CustomReadOnlyUserByIDCardSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = "__all__"
filterset_class = UserFilter
queryset = User.objects.all()
class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView): class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = CustomReadOnlyUserSerializer serializer_class = CustomReadOnlyUserSerializer
@@ -459,6 +532,16 @@ 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
if (
user != instance
and not getattr(user, "is_admin", False)
and not user.is_superuser #type: ignore
):
return Response(
{"message": "You are not authorized to view this user's details."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = self.get_serializer(instance) serializer = self.get_serializer(instance)
data = serializer.data data = serializer.data
+6 -3
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
@@ -235,8 +235,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",
} }
+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)
+271 -34
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,
@@ -105,6 +128,30 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
serializer = PaymentSerializer(payment) serializer = PaymentSerializer(payment)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
all_payments = request.query_params.get("all_payments", "false").lower() in [
"true",
"1",
"yes",
]
if (
request.user.is_authenticated
and getattr(request.user, "is_admin")
and bool(all_payments)
):
pass
else:
queryset = queryset.filter(user=request.user)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class PaymentDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView): class PaymentDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
queryset = Payment.objects.select_related("user").all() queryset = Payment.objects.select_related("user").all()
@@ -113,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"
@@ -133,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,
@@ -160,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(
@@ -169,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 = {
@@ -187,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()
@@ -197,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)
@@ -226,16 +300,28 @@ 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
@@ -270,6 +356,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"],
@@ -283,7 +370,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"
@@ -295,7 +382,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,
@@ -313,9 +400,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
@@ -336,10 +423,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()
@@ -348,7 +459,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)
@@ -388,6 +499,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"],
@@ -408,7 +520,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,
@@ -425,7 +537,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()
@@ -471,7 +587,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."},
@@ -485,3 +601,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
# )
+4 -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):
@@ -38,7 +40,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):
+19 -3
View File
@@ -36,9 +36,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 +58,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)
+27 -7
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 billing.models import Payment
from .models import Device from .models import Device
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,7 +67,17 @@ 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]") 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):
@@ -90,7 +109,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 +135,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 +155,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 +168,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"
+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"
]
} }