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
shihaam a46f2635ad serve media 2025-07-25 16:07:22 +05:00
16 changed files with 646 additions and 257 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 / {
+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)
+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))
+1 -2
View File
@@ -45,7 +45,6 @@ class UserUpdateSerializer(serializers.ModelSerializer):
"dob", "dob",
"atoll", "atoll",
"island", "island",
"agreement",
) )
@@ -160,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):
+13 -4
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
@@ -21,7 +21,13 @@ def assign_device_permissions(sender, instance, created, **kwargs):
topup_permissions = Permission.objects.filter( topup_permissions = Permission.objects.filter(
content_type__model="topup" content_type__model="topup"
).exclude(codename__startswith="delete_") ).exclude(codename__startswith="delete_")
wallet_transaction_permissions = Permission.objects.filter(
content_type__model="wallettransaction"
).exclude(codename__startswith="delete_")
user_read_only_permission = Permission.objects.get(
codename="view_user", content_type__model="user"
)
instance.user_permissions.add(user_read_only_permission)
for permission in topup_permissions: for permission in topup_permissions:
instance.user_permissions.add(permission) instance.user_permissions.add(permission)
for permission in device_permissions: for permission in device_permissions:
@@ -29,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)
+118 -58
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)
try:
send_sms( send_sms(
mobile, 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", 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) sms_count += 1
print(f"Total {count} expired devices.") 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
+93 -146
View File
@@ -2,6 +2,7 @@
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
@@ -22,19 +23,20 @@ from api.serializers import (
) )
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 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 (
@@ -45,19 +47,6 @@ from .serializers import (
UserProfileUpdateSerializer, UserProfileUpdateSerializer,
) )
ID_CARD_PATTERN = r"^[A-Z]{1,2}[0-9]{6,7}$"
MOBILE_PATTERN = r"^[7|9][0-9]{6}$"
ACCOUNT_NUMBER_PATTERN = r"^(7\d{12}|9\d{16})$"
class ErrorMessages:
USERNAME_EXISTS = "Username already exists."
MOBILE_EXISTS = "Mobile number already exists."
INVALID_ID_CARD = "Please enter a valid ID card number."
ID_CARD_EXISTS = "ID card already exists."
INVALID_MOBILE = "Please enter a valid mobile number."
INVALID_ACCOUNT = "Please enter a valid account number."
UNDERAGE_ERROR = "You must be 18 and above to signup."
@api_view(["GET"]) @api_view(["GET"])
@@ -65,141 +54,94 @@ def healthcheck(request):
return Response({"status": "Good"}, status=status.HTTP_200_OK) return Response({"status": "Good"}, status=status.HTTP_200_OK)
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
@@ -241,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):
@@ -324,26 +283,6 @@ class UserUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
data=request.data, data=request.data,
partial=True, partial=True,
) )
agreement_file = request.data.get("agreement_file")
if not agreement_file:
return Response(
{"message": "Agreement file is required."},
status=status.HTTP_400_BAD_REQUEST,
)
if agreement_file.size > 10 * 1024 * 1024: # 5 MB limit
return Response(
{"message": "File size exceeds 10 MB limit."},
status=status.HTTP_400_BAD_REQUEST,
)
if agreement_file.content_type not in [
"application/pdf",
]:
return Response(
{"message": "Invalid file type. Only PDF files are allowed."},
status=status.HTTP_400_BAD_REQUEST,
)
if agreement_file:
user.agreement = agreement_file
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
user.save() user.save()
return super().update(request, *args, **kwargs) return super().update(request, *args, **kwargs)
@@ -422,7 +361,7 @@ class KnoxTokenListApiView(
class ListUserView(StaffEditorPermissionMixin, generics.ListAPIView): class ListUserView(StaffEditorPermissionMixin, generics.ListAPIView):
serializer_class = CustomReadOnlyUserSerializer serializer_class = CustomReadOnlyUserSerializer
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend] #type: ignore
filterset_fields = "__all__" filterset_fields = "__all__"
filterset_class = UserFilter filterset_class = UserFilter
queryset = User.objects.all() queryset = User.objects.all()
@@ -535,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}")
@@ -561,20 +502,26 @@ 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}
) )
@@ -589,7 +536,7 @@ class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
if ( if (
user != instance user != instance
and not getattr(user, "is_admin", False) and not getattr(user, "is_admin", False)
and not user.is_superuser and not user.is_superuser #type: ignore
): ):
return Response( return Response(
{"message": "You are not authorized to view this user's details."}, {"message": "You are not authorized to view this user's details."},
+1 -1
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
@@ -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),
),
]
+10
View File
@@ -17,6 +17,7 @@ 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)
@@ -66,6 +67,14 @@ 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)
+5
View File
@@ -11,6 +11,8 @@ from .views import (
TopupDetailAPIView, TopupDetailAPIView,
CancelTopupView, CancelTopupView,
ListWalletTransactionView, ListWalletTransactionView,
AdminTopupCreateView,
# AlertTestView,
) )
urlpatterns = [ urlpatterns = [
@@ -37,6 +39,7 @@ 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(),
@@ -48,4 +51,6 @@ urlpatterns = [
ListWalletTransactionView.as_view(), ListWalletTransactionView.as_view(),
name="list-wallet-transactions", 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)
+145 -24
View File
@@ -15,6 +15,7 @@ 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 from django.db.models import Prefetch
import logging import logging
from .utils import calculate_total_new_price
from .models import Device, Payment, Topup, WalletTransaction from .models import Device, Payment, Topup, WalletTransaction
from .serializers import ( from .serializers import (
@@ -27,6 +28,10 @@ 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.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"))
@@ -56,7 +61,7 @@ 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
@@ -69,7 +74,7 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
Prefetch("devices", queryset=device_qs) Prefetch("devices", queryset=device_qs)
) )
if not self.request.user.is_superuser: if not self.request.user.is_superuser: #type: ignore
queryset = queryset.filter(user=self.request.user) queryset = queryset.filter(user=self.request.user)
return queryset return queryset
@@ -77,10 +82,10 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
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:
@@ -91,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:
@@ -101,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,
@@ -152,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"
@@ -172,21 +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.select_related("user").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):
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,
@@ -198,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(
@@ -209,7 +217,25 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
self.process_wallet_payment( self.process_wallet_payment(
user, # type: ignore 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( return Response(
{ {
"status": True, "status": True,
@@ -232,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()
@@ -242,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)
@@ -271,14 +300,23 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
def process_wallet_payment(self, user: User, payment: 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" 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.deduct_wallet_funds( user.deduct_wallet_funds(
@@ -318,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"],
@@ -343,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,
@@ -363,7 +402,7 @@ class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView): class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
queryset = Topup.objects.all().prefetch_related("user") 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
@@ -384,7 +423,7 @@ 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)
@@ -420,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)
@@ -460,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"],
@@ -480,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,
@@ -547,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."},
@@ -563,16 +603,65 @@ class CancelTopupView(StaffEditorPermissionMixin, generics.UpdateAPIView):
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): class ListWalletTransactionView(StaffEditorPermissionMixin, generics.ListAPIView):
serializer_class = WalletTransactionSerializer serializer_class = WalletTransactionSerializer
queryset = WalletTransaction.objects.all().select_related("user") queryset = WalletTransaction.objects.all().select_related("user")
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend] #type: ignore
filterset_fields = "__all__" filterset_fields = "__all__"
filterset_class = WalletTransactionFilter filterset_class = WalletTransactionFilter
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)
@@ -601,3 +690,35 @@ class ListWalletTransactionView(StaffEditorPermissionMixin, generics.ListAPIView
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) 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
# )
+15 -4
View File
@@ -67,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):
@@ -145,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)
@@ -157,7 +168,7 @@ 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,
) )
+1 -6
View File
@@ -7,10 +7,5 @@
"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"
]
} }