Author SHA1 Message Date
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
i701 d64a2675e4 refactor(verification): enhance error handling and response structure in user verification process 🔨 2025-07-15 23:04:11 +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 AidhaanandGitHub 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
i701 c3fc48fddc refactor(tasks): update job scheduling frequency and reduce max hours for old job removal 🔨 2025-07-11 09:53:03 +05:00
Abdulla AidhaanandGitHub 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
i701 bb2d0348c2 feat(devices): add pending_payment_id field to DeviceSerializer for tracking unpaid payments 2025-07-09 22:06:28 +05:00
Abdulla AidhaanandGitHub 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
i701 e2ede37f4f refactor(devices): update device payment status in background task and remove unused serializer field 🔨 2025-07-09 21:55:33 +05:00
Abdulla AidhaanandGitHub 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
i701 6418b1469d refactor(tasks): replace Enum with string literals for notification types in SMS tasks 🔨 2025-07-09 20:32:04 +05:00
Abdulla AidhaanandGitHub d557bb879f Merge pull request #10 from i701/feat/task-queues
feature / task queues cleanup 
2025-07-09 20:16:48 +05:00
i701 911d01b8e3 feat(billing): implement notification for expired payments and refactor SMS handling 2025-07-09 20:13:42 +05:00
i701 1644bd47b9 feat(payment): add expiry_notification_sent field to track notification status 2025-07-09 20:13:30 +05:00
i701 ff897ee2ab feat(tasks): add periodic task to remove old jobs with enhanced logging and context handling 2025-07-09 20:13:20 +05:00
i701 35384ef049 refactor(api): change async functions to synchronous for user verification task 🔨 2025-07-08 22:57:43 +05:00
i701 8657435fbf fix(payment): refine unpaid payment query to include status and expiration checks 🐛 2025-07-08 20:53:05 +05:00
i701 39da7607f6 refactor(billing): enhance SMS message formatting for expired topups with improved date handling 2025-07-08 20:52:42 +05:00
i701 d0a8408121 feat(payment): update periodic task to run every minute and improve SMS notification handling for expired topups 2025-07-08 16:00:30 +05:00
i701 c17e34a592 feat(payment): update payment status to "PAID" upon verification 2025-07-06 22:53:02 +05:00
i701 cdfa2d9192 feat(payment): add expiration time for payments to enhance payment validity tracking 2025-07-06 22:04:37 +05:00
i701 342e963861 chore(payment): replace DeletePaymentView with CancelPaymentView in payment URLs 🔧 2025-07-06 21:24:39 +05:00
i701 4f794571e9 feat(payment): add is_expired filter and serializer method for payment expiration tracking 2025-07-06 21:23:56 +05:00
i701 6bc2d71a0e test(payment): add PaymentTests for canceling payments functionality 2025-07-06 21:23:35 +05:00
i701 ceb30025ee feat(admin): add is_expired field to PaymentAdmin for better payment visibility
feat(admin): include expiry_date in DeviceAdmin for enhanced device management 
2025-07-06 21:23:12 +05:00
i701 950f42ae3f feat(payment): add status field and is_expired property to Payment model for enhanced payment tracking 2025-07-06 21:22:56 +05:00
i701 f10fa74fbb refactor(payment): enhance payment verification with detailed response structure and transaction data 🔨 2025-07-06 21:22:36 +05:00
shihaam 60e394fffa clean up dangling images after restart 2025-07-05 20:23:00 +05:00
i701 193ce850b4 fix(filters): refine filter logic in TopupFilter to ensure only unpaid topups are considered for expiration check 🐛 2025-07-05 20:14:58 +05:00
i701 27f89b6d3d feat(filters): add is_expired filter to TopupFilter for improved topup management 2025-07-05 19:54:04 +05:00
i701 63b1a6b9ef fix(tasks): include paid filter in expired topups query to prevent notifications for paid topups 🐛 2025-07-05 18:05:39 +05:00
i701 3dafc7d4c8 feat(serializers): add paid_at field to TopupSerializer for improved data tracking 2025-07-05 17:55:26 +05:00
Abdulla AidhaanandGitHub 8e564f766b Merge pull request #9 from i701/feat/topups
feat/topups
2025-07-05 17:42:57 +05:00
22 changed files with 621 additions and 137 deletions
+4
View File
@@ -39,3 +39,7 @@ jobs:
docker compose --progress plain down portal-api portal-api-nginx && \ docker compose --progress plain down portal-api portal-api-nginx && \
docker compose --progress plain up -d portal-api portal-api-nginx && \ docker compose --progress plain up -d portal-api portal-api-nginx && \
docker compose exec portal-api python manage.py migrate" docker compose exec portal-api python manage.py migrate"
- name: Clean up dangling images
if: github.event_name != 'pull_request'
run: ssh root@10.0.1.5 -t "docker image prune -f"
+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
@@ -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
),
),
]
+12 -4
View File
@@ -13,10 +13,14 @@ import pyotp
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)
@@ -46,10 +50,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)
+29 -1
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,30 @@ 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 CustomUserSerializer(serializers.ModelSerializer): class CustomUserSerializer(serializers.ModelSerializer):
"""serializer for the user object""" """serializer for the user object"""
@@ -80,6 +107,7 @@ class CustomReadOnlyUserSerializer(serializers.ModelSerializer):
"username", "username",
"mobile", "mobile",
"address", "address",
"acc_no",
"id_card", "id_card",
) )
depth = 1 depth = 1
+8 -5
View File
@@ -12,12 +12,15 @@ 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_")
for permission in topup_permissions: for permission in topup_permissions:
instance.user_permissions.add(permission) instance.user_permissions.add(permission)
@@ -29,9 +32,9 @@ def assign_device_permissions(sender, instance, created, **kwargs):
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
async def verify_user_with_person_api(sender, instance, created, **kwargs): def verify_user_with_person_api(sender, instance, created, **kwargs):
if created: if created:
await verify_user_with_person_api_task.defer_async(instance.id) verify_user_with_person_api_task(instance.id)
@receiver(reset_password_token_created) @receiver(reset_password_token_created)
+19 -7
View File
@@ -5,10 +5,12 @@ from api.notifications import send_sms
import os import os
import logging import logging
from django.utils import timezone 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 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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -16,10 +18,21 @@ logger = logging.getLogger(__name__)
env.read_env(os.path.join(BASE_DIR, ".env")) env.read_env(os.path.join(BASE_DIR, ".env"))
@app.task @app.periodic(cron="0 * * * *") # every 1 hour
def add(x, y): @app.task(
logger.info(f"Executing test background task with {x} and {y}") queueing_lock="remove_old_jobs",
return x + y pass_context=True,
)
async def remove_old_jobs(context, timestamp):
logger.info("Running remove_old_jobs task...")
return await builtin_tasks.remove_old_jobs(
context,
queue="heavy_tasks",
max_hours=1,
remove_failed=True,
remove_cancelled=True,
remove_aborted=True,
)
@app.periodic( @app.periodic(
@@ -68,8 +81,7 @@ def add_new_devices_to_omada(new_devices: list[dict]):
omada_client.add_new_devices_to_omada(new_devices) omada_client.add_new_devices_to_omada(new_devices)
@app.task def verify_user_with_person_api_task(user_id: int):
async 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.
+8 -3
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,
@@ -21,13 +21,15 @@ from .views import (
UpdateUserWalletView, UpdateUserWalletView,
VerifyOTPView, VerifyOTPView,
UserVerifyAPIView, UserVerifyAPIView,
UserUpdateAPIView,
UserRejectAPIView,
) )
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"),
@@ -38,9 +40,12 @@ urlpatterns = [
"update-wallet/<int:pk>/", UpdateUserWalletView.as_view(), name="update-wallet" "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>/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}
+114 -24
View File
@@ -7,6 +7,7 @@ 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
@@ -17,6 +18,7 @@ from api.serializers import (
CustomUserByWalletBalanceSerializer, CustomUserByWalletBalanceSerializer,
OTPVerificationSerializer, OTPVerificationSerializer,
TemporaryUserSerializer, TemporaryUserSerializer,
UserUpdateSerializer,
) )
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
@@ -31,7 +33,6 @@ 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 .tasks import add
from .utils import check_person_api_verification from .utils import check_person_api_verification
# local apps import # local apps import
@@ -41,6 +42,7 @@ from .serializers import (
CustomUserSerializer, CustomUserSerializer,
CustomReadOnlyUserSerializer, CustomReadOnlyUserSerializer,
CustomReadOnlyUserByIDCardSerializer, CustomReadOnlyUserByIDCardSerializer,
UserProfileUpdateSerializer,
) )
ID_CARD_PATTERN = r"^[A-Z]{1,2}[0-9]{6,7}$" ID_CARD_PATTERN = r"^[A-Z]{1,2}[0-9]{6,7}$"
@@ -60,7 +62,6 @@ class ErrorMessages:
@api_view(["GET"]) @api_view(["GET"])
def healthcheck(request): def healthcheck(request):
add.defer(1, 2)
return Response({"status": "Good"}, status=status.HTTP_200_OK) return Response({"status": "Good"}, status=status.HTTP_200_OK)
@@ -307,17 +308,56 @@ 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 KnoxTokenListApiView( class KnoxTokenListApiView(
StaffEditorPermissionMixin, StaffEditorPermissionMixin,
generics.ListAPIView, generics.ListAPIView,
@@ -345,9 +385,9 @@ class ListUserView(StaffEditorPermissionMixin, generics.ListAPIView):
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):
@@ -366,30 +406,70 @@ 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)
if not result["ok"]:
return Response( return Response(
{ result,
"message": "User verification failed due to mismatched fields.", status=status.HTTP_404_NOT_FOUND,
"mismatch_fields": verified_person["mismatch_fields"], )
}, 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"])
@@ -461,6 +541,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
):
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
View File
@@ -14,9 +14,15 @@ class PaymentAdmin(admin.ModelAdmin):
"paid_at", "paid_at",
"method", "method",
"created_at", "created_at",
"expires_at",
"is_expired",
"updated_at", "updated_at",
) )
@admin.display(boolean=True, description="Expired")
def is_expired(self, obj):
return obj.is_expired
class TopupAdmin(admin.ModelAdmin): class TopupAdmin(admin.ModelAdmin):
list_display = ( list_display = (
+28 -1
View File
@@ -1,6 +1,7 @@
import django_filters import django_filters
from .models import Payment, Topup from .models import Payment, Topup
from django.db.models import Q from django.db.models import Q
from django.utils import timezone
class PaymentFilter(django_filters.FilterSet): class PaymentFilter(django_filters.FilterSet):
@@ -13,6 +14,18 @@ class PaymentFilter(django_filters.FilterSet):
mib_reference = django_filters.CharFilter(lookup_expr="icontains") mib_reference = django_filters.CharFilter(lookup_expr="icontains")
paid_at = django_filters.DateFromToRangeFilter() paid_at = django_filters.DateFromToRangeFilter()
created_at = django_filters.DateFromToRangeFilter() created_at = django_filters.DateFromToRangeFilter()
is_expired = django_filters.BooleanFilter(method="filter_is_expired")
def filter_is_expired(self, queryset, name, value):
"""
Filter payments based on whether they are expired or not
"""
now = timezone.now()
queryset = queryset.filter(paid=False)
if value:
return queryset.filter(expires_at__isnull=False, expires_at__lt=now)
else:
return queryset.filter(Q(expires_at__isnull=True) | Q(expires_at__gte=now))
class Meta: class Meta:
model = Payment model = Payment
@@ -24,6 +37,7 @@ class TopupFilter(django_filters.FilterSet):
paid = django_filters.BooleanFilter(field_name="paid") paid = django_filters.BooleanFilter(field_name="paid")
user = django_filters.CharFilter(method="filter_user_search") user = django_filters.CharFilter(method="filter_user_search")
created_at = django_filters.DateFromToRangeFilter(field_name="created_at") created_at = django_filters.DateFromToRangeFilter(field_name="created_at")
is_expired = django_filters.BooleanFilter(method="filter_is_expired")
def filter_user_search(self, queryset, name, value): def filter_user_search(self, queryset, name, value):
""" """
@@ -36,11 +50,24 @@ class TopupFilter(django_filters.FilterSet):
| Q(user__mobile__icontains=value) | Q(user__mobile__icontains=value)
) )
def filter_is_expired(self, queryset, name, value):
"""
Filter topups based on whether they are expired or not
"""
now = timezone.now()
queryset = queryset.filter(paid=False)
if value:
return queryset.filter(expires_at__isnull=False, expires_at__lt=now)
else:
return queryset.filter(Q(expires_at__isnull=True) | Q(expires_at__gte=now))
class Meta: class Meta:
model = Topup # Assuming Topup is a subclass of Payment model = Topup
fields = [ fields = [
"amount", "amount",
"paid", "paid",
"status",
"user", "user",
"created_at", "created_at",
"is_expired",
] ]
+25
View File
@@ -0,0 +1,25 @@
# Generated by Django 5.2 on 2025-07-06 15:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0011_topup_status"),
]
operations = [
migrations.AddField(
model_name="payment",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("PAID", "Paid"),
("CANCELLED", "Cancelled"),
],
default="PENDING",
max_length=20,
),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.2 on 2025-07-09 14:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0012_payment_status"),
]
operations = [
migrations.AddField(
model_name="payment",
name="expiry_notification_sent",
field=models.BooleanField(default=False),
),
]
+16
View File
@@ -23,10 +23,26 @@ class Payment(models.Model):
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)
expires_at = models.DateTimeField(null=True, blank=True) expires_at = models.DateTimeField(null=True, blank=True)
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)
devices = models.ManyToManyField(Device, related_name="payments") devices = models.ManyToManyField(Device, related_name="payments")
status = models.CharField(
max_length=20,
choices=[
("PENDING", "Pending"),
("PAID", "Paid"),
("CANCELLED", "Cancelled"),
],
default="PENDING",
)
@property
def is_expired(self):
if self.expires_at is None:
return False
return timezone.now() > self.expires_at
def __str__(self): def __str__(self):
return f"Payment by {self.user}" return f"Payment by {self.user}"
+5
View File
@@ -5,6 +5,10 @@ from devices.serializers import DeviceSerializer
class PaymentSerializer(serializers.ModelSerializer): class PaymentSerializer(serializers.ModelSerializer):
devices = DeviceSerializer(many=True, read_only=True) devices = DeviceSerializer(many=True, read_only=True)
is_expired = serializers.SerializerMethodField()
def get_is_expired(self, obj):
return obj.is_expired
class Meta: # type: ignore class Meta: # type: ignore
model = Payment model = Payment
@@ -44,6 +48,7 @@ class TopupSerializer(serializers.ModelSerializer):
"amount", "amount",
"user", "user",
"paid", "paid",
"paid_at",
"status", "status",
"mib_reference", "mib_reference",
"is_expired", "is_expired",
+103 -15
View File
@@ -4,20 +4,26 @@ from django.db import transaction
from django.utils import timezone from django.utils import timezone
from procrastinate.contrib.django import app from procrastinate.contrib.django import app
from api.notifications import send_sms from api.notifications import send_sms
from billing.models import Topup from billing.models import Topup, Payment
from django.utils.timezone import localtime from django.utils.timezone import localtime
from datetime import datetime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@app.periodic( @app.periodic(
cron="*/30 * * * * *", periodic_id="notify_expired_topups", queue="heavy_tasks" cron="*/1 * * * * *", periodic_id="notify_expired_topups", queue="heavy_tasks"
) # every 30 seconds )
@app.task @app.task
def update_expired_topups(timestamp: int): def update_expired_topups(timestamp: int):
expired_topups_qs = Topup.objects.filter( expired_topups_qs = Topup.objects.filter(
expires_at__lte=timezone.now(), expiry_notification_sent=False expires_at__lte=timezone.now(),
expiry_notification_sent=False,
paid=False,
).select_related("user") ).select_related("user")
if not expired_topups_qs.exists():
logger.info("No expired topups found.")
return {"total_expired_topups": 0}
with transaction.atomic(): with transaction.atomic():
count = expired_topups_qs.count() count = expired_topups_qs.count()
@@ -27,35 +33,117 @@ def update_expired_topups(timestamp: int):
if topup.user and topup.user.mobile and not topup.expiry_notification_sent: if topup.user and topup.user.mobile and not topup.expiry_notification_sent:
send_sms_task.defer( send_sms_task.defer(
mobile=topup.user.mobile, mobile=topup.user.mobile,
type="TOPUP",
amount=topup.amount, amount=topup.amount,
topup_id=str(topup.id), model_id=str(topup.id),
created_at=localtime(topup.created_at).isoformat(), created_at=localtime(topup.created_at).isoformat(),
user=f"{topup.user.first_name + ' ' + topup.user.last_name}"
if topup.user.last_name and topup.user.first_name
else "User",
) )
else:
# Mark as notified even if we can't send SMS (no mobile number)
topup.expiry_notification_sent = True topup.expiry_notification_sent = True
topup.save() topup.save()
else:
topup.expiry_notification_sent = True
topup.save()
return
return { return {
"total_expired_topups": count, "total_expired_topups": count,
} }
# Assuming you have a separate task for sending SMS if you go that route @app.periodic(
cron="*/1 * * * * *", periodic_id="notify_expired_payments", queue="heavy_tasks"
)
@app.task @app.task
def send_sms_task(mobile: str, amount: float, topup_id: str, created_at: str): def update_expired_payments(timestamp: int):
expired_payments_qs = Payment.objects.filter(
expires_at__lte=timezone.now(),
expiry_notification_sent=False,
paid=False,
).select_related("user")
if not expired_payments_qs.exists():
logger.info("No expired payments found.")
return {"total_expired_payments": 0}
with transaction.atomic():
count = expired_payments_qs.count()
logger.info(f"Found {count} payments to expire.")
for payment in expired_payments_qs:
for device in payment.devices.all():
device.has_a_pending_payment = False
device.save()
if (
payment.user
and payment.user.mobile
and not payment.expiry_notification_sent
):
send_sms_task.defer(
mobile=payment.user.mobile,
type="PAYMENT",
amount=payment.amount,
model_id=str(payment.id),
created_at=localtime(payment.created_at).isoformat(),
user=f"{payment.user.first_name + ' ' + payment.user.last_name}"
if payment.user.last_name and payment.user.first_name
else "User",
)
payment.expiry_notification_sent = True
payment.save()
else:
payment.expiry_notification_sent = True
payment.save()
return
return {
"total_expired_payments": count,
}
@app.task
def send_sms_task(
user: str,
mobile: str,
amount: float,
model_id: str,
created_at: str,
type: str = "TOPUP", # Default to TOPUP if not provided,
):
try:
dt = datetime.fromisoformat(created_at)
formatted_date = dt.strftime("%d %b %Y, %I:%M %p")
except Exception:
formatted_date = created_at
message: str = ""
if type == "TOPUP":
message = ( message = (
f"Dear {mobile}, \n\nYour topup of {amount} MVR [created at {created_at}] has expired. " f"Dear {user}, \n\nYour topup of {amount} MVR [created at {formatted_date}] has expired. "
"Please make a new topup to update your wallet. \n\n- SAR Link" "Please make a new topup to update your wallet. \n\n- SAR Link"
) )
elif type == "PAYMENT":
message = f"Dear {user}, \n\nYour payment of {amount} MVR [created at {formatted_date}] has expired. \n\n- SAR Link"
send_sms(mobile, message) send_sms(mobile, message)
logger.info(f"SMS sent to {mobile} for expired topup of {amount} MVR.") logger.info(f"SMS sent to {mobile} for expired {type} of {amount} MVR.")
# Mark the topup as notified after successful SMS sending if type == "TOPUP":
try: try:
topup = Topup.objects.get(id=topup_id) topup = Topup.objects.get(id=model_id)
topup.expiry_notification_sent = True topup.expiry_notification_sent = True
topup.save() topup.save()
logger.info(f"Marked topup {topup_id} as notified.") logger.info(f"Marked topup {model_id} as notified.")
except Topup.DoesNotExist: except Topup.DoesNotExist:
logger.error(f"Topup {topup_id} not found when trying to mark as notified.") logger.error(
f"Topup id: {model_id} not found when trying to mark as notified."
)
else:
try:
topup = Payment.objects.get(id=model_id)
topup.expiry_notification_sent = True
topup.save()
logger.info(f"Marked payment {model_id} as notified.")
except Payment.DoesNotExist:
logger.error(
f"Payment id: {model_id} not found when trying to mark as notified."
)
+45 -1
View File
@@ -3,7 +3,7 @@ from django.urls import reverse
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .models import Topup from .models import Topup, Payment
from .serializers import TopupSerializer from .serializers import TopupSerializer
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
@@ -205,3 +205,47 @@ class TopupTests(TestCase):
response = self.client.delete(url, format="json") response = self.client.delete(url, format="json")
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(Topup.objects.count(), 0) self.assertEqual(Topup.objects.count(), 0)
class PaymentTests(TestCase):
def setUp(self):
self.client = APIClient()
self.real_user = User.objects.create_user(
username="testuser",
password="testpassword",
first_name=REAL_USER_FIRST_NAME,
last_name=REAL_USER_LAST_NAME,
acc_no="7770000010629",
is_admin=True,
)
self.user = User.objects.create_user(
username="plskillme",
password="modewasgayithink",
first_name="mode",
last_name="hussain",
acc_no="1122334455",
)
self.admin_user = User.objects.create_superuser(
username="adminuser",
password="adminpassword",
email="admin@example.com",
first_name="Admin",
last_name="User",
acc_no="987654321",
)
self.client.force_authenticate(user=self.real_user)
def test_cancel_payment(self):
payment = Payment.objects.create(
amount=100.00,
user=self.real_user,
status="PENDING",
number_of_months=1,
paid=False,
)
url = reverse("cancel-payment", kwargs={"pk": payment.pk})
response = self.client.patch(url, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
payment.refresh_from_db()
self.assertEqual(payment.status, "CANCELLED")
self.assertFalse(payment.paid)
+4 -4
View File
@@ -5,7 +5,7 @@ from .views import (
VerifyPaymentView, VerifyPaymentView,
PaymentDetailAPIView, PaymentDetailAPIView,
UpdatePaymentAPIView, UpdatePaymentAPIView,
DeletePaymentView, CancelPaymentView,
ListCreateTopupView, ListCreateTopupView,
VerifyTopupPaymentAPIView, VerifyTopupPaymentAPIView,
TopupDetailAPIView, TopupDetailAPIView,
@@ -21,9 +21,9 @@ urlpatterns = [
name="update-payment", name="update-payment",
), ),
path( path(
"payment/<str:pk>/delete/", "payment/<str:pk>/cancel/",
DeletePaymentView.as_view(), CancelPaymentView.as_view(),
name="delete-payment", name="cancel-payment",
), ),
path( path(
"payment/<str:pk>/verify/", VerifyPaymentView.as_view(), name="verify-payment" "payment/<str:pk>/verify/", VerifyPaymentView.as_view(), name="verify-payment"
+92 -37
View File
@@ -28,6 +28,20 @@ PAYMENT_BASE_URL = env("PAYMENT_BASE_URL", default="") # type: ignore
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass
class Transaction:
ref: str
sourceBank: str
trxDate: str
@dataclass
class PaymentVerificationResponse:
message: str
success: bool
transaction: Optional[Transaction] = None
class InsufficientFundsError(Exception): class InsufficientFundsError(Exception):
pass pass
@@ -52,7 +66,8 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
number_of_months = data.get("number_of_months") number_of_months = data.get("number_of_months")
device_ids = data.get("device_ids", []) device_ids = data.get("device_ids", [])
print(amount, number_of_months, device_ids) print(amount, number_of_months, device_ids)
current_time = timezone.now()
expires_at = current_time + timedelta(minutes=10)
for device_id in device_ids: for device_id in device_ids:
device = Device.objects.filter(id=device_id, user=user).first() device = Device.objects.filter(id=device_id, user=user).first()
print("DEVICE", device) print("DEVICE", device)
@@ -77,6 +92,7 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
number_of_months=number_of_months, number_of_months=number_of_months,
paid=data.get("paid", False), paid=data.get("paid", False),
user=user, user=user,
expires_at=expires_at,
) )
# Connect devices to payment # Connect devices to payment
@@ -89,6 +105,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()
@@ -110,7 +150,7 @@ class UpdatePaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
self.perform_update(serializer) self.perform_update(serializer)
devices.update( devices.update(
is_active=True, expiry_date=device_expire_date, has_a_pending_payment=False is_active=False, expiry_date=device_expire_date, has_a_pending_payment=False
) )
return Response(serializer.data) return Response(serializer.data)
@@ -145,7 +185,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
) )
devices = payment.devices.all() devices = payment.devices.all()
payment_status = False
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(
@@ -153,7 +192,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
else: else:
payment_status = self.process_wallet_payment( self.process_wallet_payment(
user, user,
payment, payment,
) )
@@ -161,15 +200,12 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
data = { data = {
"benefName": f"{user.first_name} {user.last_name}", # type: ignore "benefName": f"{user.first_name} {user.last_name}", # type: ignore
"accountNo": user.acc_no, # type: ignore "accountNo": user.acc_no, # type: ignore
"absAmount": payment.amount, "absAmount": "{:.2f}".format(payment.amount),
"time": localtime(timezone.now() + timedelta(minutes=5)).strftime( "time": localtime(payment.created_at).strftime("%Y-%m-%d %H:%M"),
"%Y-%m-%d %H:%M"
),
} }
payment_status = self.verify_transfer_payment(data, payment) payment_verification_response = self.verify_transfer_payment(data, payment)
if payment_status: if payment_verification_response.success:
# Update devices
expiry_date = timezone.now() + timedelta(days=30 * payment.number_of_months) expiry_date = timezone.now() + timedelta(days=30 * payment.number_of_months)
devices.update( devices.update(
is_active=True, is_active=True,
@@ -177,7 +213,9 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
has_a_pending_payment=False, has_a_pending_payment=False,
registered=True, registered=True,
) )
# Need to add to omada if its a new device and not an existing device payment.status = "PAID"
payment.save()
# add to omada if its a new device and not an existing device
device_list = [] device_list = []
for device in devices: for device in devices:
device_list.append( device_list.append(
@@ -193,12 +231,22 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
device.save() device.save()
return Response( return Response(
{"message": f"Payment verified successfully using [{method}]."}, {
"status": payment_verification_response.success,
"message": payment_verification_response.message,
"transaction": asdict(payment_verification_response.transaction)
if payment_verification_response.transaction
else None,
},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
else: else:
return Response( return Response(
{"message": f"Payment verification FAILED using [{method}]."}, {
"status": payment_verification_response.success,
"message": payment_verification_response.message
or "Topup payment verification failed.",
},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
@@ -215,7 +263,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
user.save() user.save()
return True return True
def verify_transfer_payment(self, data, payment): def verify_transfer_payment(self, data, payment) -> PaymentVerificationResponse:
if not PAYMENT_BASE_URL: if not PAYMENT_BASE_URL:
raise ValueError( raise ValueError(
"PAYMENT_BASE_URL is not set. Please set it in your environment variables." "PAYMENT_BASE_URL is not set. Please set it in your environment variables."
@@ -230,41 +278,62 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
logger.error(f"HTTPError: {e}") logger.error(f"HTTPError: {e}")
return False return PaymentVerificationResponse(
message="Payment verification failed.", success=False, transaction=None
)
mib_resp = response.json() mib_resp = response.json()
logger.info("MIB Verification Response ->", mib_resp) logger.info("MIB Verification Response ->", mib_resp)
if not response.json().get("success"): if not response.json().get("success"):
return mib_resp["success"] return PaymentVerificationResponse(
message=mib_resp["message"],
success=mib_resp["success"],
transaction=None,
)
else: else:
payment.paid = True payment.paid = True
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.save() payment.save()
return True return PaymentVerificationResponse(
message=mib_resp["message"],
success=mib_resp["success"],
transaction=Transaction(
ref=payment.mib_reference,
sourceBank=mib_resp["transaction"]["sourceBank"],
trxDate=mib_resp["transaction"]["trxDate"],
),
)
class DeletePaymentView(StaffEditorPermissionMixin, generics.DestroyAPIView): class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
queryset = Payment.objects.all() queryset = Payment.objects.all()
serializer_class = PaymentSerializer serializer_class = PaymentSerializer
lookup_field = "pk" lookup_field = "pk"
def delete(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
instance = self.get_object() instance = self.get_object()
user = request.user user = request.user
if instance.status == "CANCELLED":
return Response(
{"message": "Payment has already been cancelled."},
status=status.HTTP_400_BAD_REQUEST,
)
if instance.user != user and not user.is_superuser: if instance.user != user and not user.is_superuser:
return Response( return Response(
{"message": "You are not authorized to delete this payment."}, {"message": "You are not authorized to cancel this payment."},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
if instance.paid: if instance.paid:
return Response( return Response(
{"message": "Paid payments cannot be deleted."}, {"message": "Paid payments cannot be cancelled."},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
devices = instance.devices.all() devices = instance.devices.all()
instance.status = "CANCELLED"
instance.save()
devices.update(is_active=False, expiry_date=None, has_a_pending_payment=False) devices.update(is_active=False, expiry_date=None, has_a_pending_payment=False)
return super().delete(request, *args, **kwargs) return super().update(request, *args, **kwargs)
class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView): class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
@@ -308,20 +377,6 @@ class TopupDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
return queryset.filter(user=self.request.user) return queryset.filter(user=self.request.user)
@dataclass
class Transaction:
ref: str
sourceBank: str
trxDate: str
@dataclass
class PaymentVerificationResponse:
message: str
success: bool
transaction: Optional[Transaction] = None
class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView): class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
queryset = Topup.objects.all() queryset = Topup.objects.all()
serializer_class = TopupSerializer serializer_class = TopupSerializer
+1
View File
@@ -10,6 +10,7 @@ class DeviceAdmin(admin.ModelAdmin):
"user", "user",
"mac", "mac",
"vendor", "vendor",
"expiry_date",
"blocked_by", "blocked_by",
"name", "name",
"created_at", "created_at",
+9 -10
View File
@@ -31,8 +31,16 @@ class BlockDeviceSerializer(serializers.ModelSerializer):
class DeviceSerializer(serializers.ModelSerializer): class DeviceSerializer(serializers.ModelSerializer):
pending_payment_id = serializers.SerializerMethodField()
user = serializers.SerializerMethodField() user = serializers.SerializerMethodField()
pending_payment_id = serializers.SerializerMethodField()
def get_pending_payment_id(self, obj):
unpaid_payment = (
Payment.objects.filter(devices=obj, paid=False)
.order_by("-created_at")
.first()
)
return unpaid_payment.id if unpaid_payment else None
def get_user(self, obj): def get_user(self, obj):
user = obj.user user = obj.user
@@ -45,15 +53,6 @@ class DeviceSerializer(serializers.ModelSerializer):
} }
return None return None
def get_pending_payment_id(self, obj):
# Query the last unpaid payment for the device
unpaid_payment = (
Payment.objects.filter(devices=obj, paid=False)
.order_by("-created_at")
.first()
)
return unpaid_payment.id if unpaid_payment else None
class Meta: # type: ignore class Meta: # type: ignore
model = Device model = Device
fields = "__all__" fields = "__all__"