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 up -d portal-api portal-api-nginx && \
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")
first_name = django_filters.CharFilter(lookup_expr="icontains")
email = django_filters.CharFilter(lookup_expr="icontains")
id_card = django_filters.CharFilter(lookup_expr="icontains")
mobile = django_filters.CharFilter(lookup_expr="icontains")
class Meta:
model = User
@@ -0,0 +1,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):
address = models.CharField(max_length=255, blank=True)
email = models.EmailField(blank=True, null=True, unique=True)
mobile = models.CharField(max_length=255, blank=True, unique=True, null=True)
mobile = models.CharField(
max_length=255, blank=True, unique=True, null=True, db_index=True
)
designation = models.CharField(max_length=255, blank=True)
acc_no = models.CharField(max_length=255, blank=True)
id_card = models.CharField(max_length=255, blank=True, unique=True, null=True)
id_card = models.CharField(
max_length=255, blank=True, unique=True, null=True, db_index=True
)
verified = models.BooleanField(default=False)
is_admin = models.BooleanField(default=False)
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_address = models.CharField(max_length=255, blank=True)
t_email = models.EmailField(blank=True, null=True, unique=True)
t_mobile = models.CharField(max_length=255, blank=True, unique=True, null=True)
t_mobile = models.CharField(
max_length=255, blank=True, unique=True, null=True, db_index=True
)
t_designation = models.CharField(max_length=255, blank=True)
t_acc_no = models.CharField(max_length=255, blank=True)
t_id_card = models.CharField(max_length=255, blank=True, unique=True, null=True)
t_id_card = models.CharField(
max_length=255, blank=True, unique=True, null=True, db_index=True
)
t_verified = models.BooleanField(default=False)
t_dob = models.DateField(blank=True, null=True)
t_terms_accepted = models.BooleanField(default=False)
+29 -1
View File
@@ -1,9 +1,12 @@
from knox.models import AuthToken
from django.contrib.auth import authenticate
from api.models import User, Atoll, Island, TemporaryUser
from api.models import Atoll, Island, TemporaryUser
from django.contrib.auth.models import Permission
from rest_framework import serializers
from django.contrib.auth import get_user_model
User = get_user_model()
class IslandSerializer(serializers.ModelSerializer):
@@ -21,6 +24,30 @@ class AtollSerializer(serializers.ModelSerializer):
depth = 2
class UserProfileUpdateSerializer(serializers.ModelSerializer):
class Meta: # type: ignore
model = User
fields = (
"email",
"mobile",
) # Only allow these fields
class UserUpdateSerializer(serializers.ModelSerializer):
class Meta: # type: ignore
model = User
fields = (
"id_card",
"mobile",
"first_name",
"last_name",
"address",
"dob",
"atoll",
"island",
)
class CustomUserSerializer(serializers.ModelSerializer):
"""serializer for the user object"""
@@ -80,6 +107,7 @@ class CustomReadOnlyUserSerializer(serializers.ModelSerializer):
"username",
"mobile",
"address",
"acc_no",
"id_card",
)
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)
def assign_device_permissions(sender, instance, created, **kwargs):
if created:
# Assign all permissions for devices and read permission for atoll and island
device_permissions = Permission.objects.filter(content_type__model="device")
atoll_read_permission = Permission.objects.get(codename="view_atoll")
island_read_permission = Permission.objects.get(codename="view_island")
payment_permissions = Permission.objects.filter(content_type__model="payment")
topup_permissions = Permission.objects.filter(content_type__model="topup")
payment_permissions = Permission.objects.filter(
content_type__model="payment"
).exclude(codename__startswith="delete_")
topup_permissions = Permission.objects.filter(
content_type__model="topup"
).exclude(codename__startswith="delete_")
for permission in topup_permissions:
instance.user_permissions.add(permission)
@@ -29,9 +32,9 @@ def assign_device_permissions(sender, instance, created, **kwargs):
@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:
await verify_user_with_person_api_task.defer_async(instance.id)
verify_user_with_person_api_task(instance.id)
@receiver(reset_password_token_created)
+19 -7
View File
@@ -5,10 +5,12 @@ from api.notifications import send_sms
import os
import logging
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 apibase.env import env, BASE_DIR
from procrastinate.contrib.django import app
from procrastinate import builtin_tasks
logger = logging.getLogger(__name__)
@@ -16,10 +18,21 @@ logger = logging.getLogger(__name__)
env.read_env(os.path.join(BASE_DIR, ".env"))
@app.task
def add(x, y):
logger.info(f"Executing test background task with {x} and {y}")
return x + y
@app.periodic(cron="0 * * * *") # every 1 hour
@app.task(
queueing_lock="remove_old_jobs",
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(
@@ -68,8 +81,7 @@ def add_new_devices_to_omada(new_devices: list[dict]):
omada_client.add_new_devices_to_omada(new_devices)
@app.task
async def verify_user_with_person_api_task(user_id: int):
def verify_user_with_person_api_task(user_id: int):
"""
Verify the user with the Person API.
:param user_id: The ID of the user to verify.
+8 -3
View File
@@ -5,7 +5,7 @@ from knox import views as knox_views
from .views import (
LoginView,
CreateTemporaryUserView,
ManageUserView,
UserprofileAPIView,
KnoxTokenListApiView,
ListUserView,
UserDetailAPIView,
@@ -21,13 +21,15 @@ from .views import (
UpdateUserWalletView,
VerifyOTPView,
UserVerifyAPIView,
UserUpdateAPIView,
UserRejectAPIView,
)
urlpatterns = [
path("register/", CreateTemporaryUserView.as_view(), name="register"),
path("register/verify/", VerifyOTPView.as_view(), name="verify-otp"),
path("profile/", ManageUserView.as_view(), name="profile"),
path("profile/", UserprofileAPIView.as_view(), name="profile"),
path("login/", LoginView.as_view(), name="knox_login"),
path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"),
path("logoutall/", knox_views.LogoutAllView.as_view(), name="knox_logoutall"),
@@ -38,9 +40,12 @@ urlpatterns = [
"update-wallet/<int:pk>/", UpdateUserWalletView.as_view(), name="update-wallet"
),
path("users/<int:pk>/", UserDetailAPIView.as_view(), name="user-detail"),
path("users/<int:pk>/verify/", UserVerifyAPIView.as_view(), name="user-verify"),
path("users/<int:pk>/update/", UserUpdateAPIView.as_view(), name="user-update"),
path("users/filter/", filter_user, name="filter-users"),
path("users/temp/filter/", filter_temporary_user, name="filter-temporary-users"),
# User verification flow
path("users/<int:pk>/verify/", UserVerifyAPIView.as_view(), name="user-verify"),
path("users/<int:pk>/reject/", UserRejectAPIView.as_view(), name="user-reject"),
path("healthcheck/", healthcheck, name="healthcheck"),
path("test/", test_email, name="testemail"),
path("atolls/", ListAtollView.as_view(), name="atolls"),
+27 -18
View File
@@ -1,5 +1,5 @@
import logging
from typing import List, TypedDict
from typing import List, Optional, TypedDict
import requests
from decouple import config
from api.models import User
@@ -40,7 +40,9 @@ def reverse_dhivehi_string(input_str):
class MismatchResult(TypedDict):
ok: bool
mismatch_fields: List[str]
mismatch_fields: Optional[List[str]]
error: Optional[str]
detail: Optional[str]
def check_person_api_verification(
@@ -63,20 +65,22 @@ def check_person_api_verification(
raise ValueError(
"PERSON_VERIFY_BASE_URL is not set in the environment variables."
)
print(id_card)
response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{id_card}")
api_reponse = response.json()
if response.status_code != 200:
logger.error(
f"Failed to fetch data from Person API for ID Card '{id_card}'. "
f"Status Code: {response.status_code}, Response: {response.text}"
)
return {"ok": False, "mismatch_fields": ["api_error"]}
api_data = response.json()
if not api_data:
logger.error(
f"No data found in Person API for ID Card '{id_card}'. Response: {response.text}"
)
return {"ok": False, "mismatch_fields": ["no_data"]}
return {
"ok": False,
"mismatch_fields": None,
"error": response.json()["error"] if "error" in response.json() else None,
"detail": response.json()["detail"]
if "detail" in response.json()
else None,
}
# Initialize a list to hold fields that do not match
mismatch_fields = []
@@ -86,12 +90,12 @@ def check_person_api_verification(
user_dob_iso = user_data.dob.isoformat() if user_data.dob else None
# Prepare API data for comparison
api_nic = api_data.get("nic")
api_name = api_data.get("name_en")
api_house_name = api_data.get("house_name_en")
api_dob = api_data.get("dob")
api_atoll = api_data.get("atoll_en")
api_island_name = api_data.get("island_name_en")
api_nic = api_reponse.get("nic")
api_name = api_reponse.get("name_en")
api_house_name = api_reponse.get("house_name_en")
api_dob = api_reponse.get("dob")
api_atoll = api_reponse.get("atoll_en")
api_island_name = api_reponse.get("island_name_en")
# Perform comparisons and identify mismatches
if user_data.id_card != api_nic:
@@ -134,6 +138,11 @@ def check_person_api_verification(
)
if mismatch_fields:
return {"ok": False, "mismatch_fields": mismatch_fields}
return {
"ok": False,
"mismatch_fields": mismatch_fields,
"error": None,
"detail": None,
}
else:
return {"ok": True, "mismatch_fields": []}
return {"ok": True, "mismatch_fields": [], "error": None, "detail": None}
+114 -24
View File
@@ -7,6 +7,7 @@ from rest_framework.authtoken.serializers import AuthTokenSerializer
from api.filters import UserFilter
from api.mixins import StaffEditorPermissionMixin
from api.models import User, Atoll, Island, TemporaryUser
from api.notifications import send_sms
from rest_framework.response import Response
from rest_framework import status
from rest_framework.exceptions import ValidationError
@@ -17,6 +18,7 @@ from api.serializers import (
CustomUserByWalletBalanceSerializer,
OTPVerificationSerializer,
TemporaryUserSerializer,
UserUpdateSerializer,
)
from django.shortcuts import get_object_or_404
from django.utils import timezone
@@ -31,7 +33,6 @@ from typing import cast, Dict, Any
from django.core.mail import send_mail
from django.db.models import Q
from api.notifications import send_otp
from .tasks import add
from .utils import check_person_api_verification
# local apps import
@@ -41,6 +42,7 @@ from .serializers import (
CustomUserSerializer,
CustomReadOnlyUserSerializer,
CustomReadOnlyUserByIDCardSerializer,
UserProfileUpdateSerializer,
)
ID_CARD_PATTERN = r"^[A-Z]{1,2}[0-9]{6,7}$"
@@ -60,7 +62,6 @@ class ErrorMessages:
@api_view(["GET"])
def healthcheck(request):
add.defer(1, 2)
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)
class ManageUserView(generics.RetrieveUpdateAPIView):
"""Manage the authenticated user"""
class UserprofileAPIView(generics.RetrieveUpdateAPIView):
"""Retrieve user api view"""
serializer_class = CustomUserSerializer
queryset = User.objects.all()
permission_classes = (permissions.IsAuthenticated,)
def get_serializer_class(self):
"""Return the serializer class based on the request method"""
if self.request.method == "GET":
return CustomReadOnlyUserSerializer
elif self.request.method == "PUT" or self.request.method == "PATCH":
return UserProfileUpdateSerializer
return super().get_serializer_class()
def get_object(self):
"""Retrieve and return authenticated user"""
return self.request.user
class UserUpdateAPIView(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(
StaffEditorPermissionMixin,
generics.ListAPIView,
@@ -345,9 +385,9 @@ class ListUserView(StaffEditorPermissionMixin, generics.ListAPIView):
def get_queryset(self):
user = self.request.user
if user.is_authenticated and user.is_staff:
return User.objects.all()
return User.objects.filter(is_staff=False)
if user.is_authenticated and getattr(user, "is_admin"):
return User.objects.filter(is_superuser=False)
return User.objects.none()
class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
@@ -366,30 +406,70 @@ class UserVerifyAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
{"message": "You are not authorized to update this user."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = self.get_serializer(user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
verified_person = check_person_api_verification(
user_data=user, id_card=user.id_card
)
if not verified_person["ok"]:
if user.verified:
return Response(
{
"message": "User verification failed. Please check sarlink user details.",
"mismatch_fields": verified_person["mismatch_fields"],
},
{"message": "User is already verified."},
status=status.HTTP_400_BAD_REQUEST,
)
if verified_person["mismatch_fields"]:
serializer = self.get_serializer(user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
result = check_person_api_verification(user_data=user, id_card=user.id_card)
if not result["ok"]:
return Response(
{
"message": "User verification failed due to mismatched fields.",
"mismatch_fields": verified_person["mismatch_fields"],
},
result,
status=status.HTTP_404_NOT_FOUND,
)
if result["mismatch_fields"]:
return Response(
result,
status=status.HTTP_400_BAD_REQUEST,
)
user.verified = True
user.save()
return Response({"message": "User verification status updated."})
return Response({"message": "User successfully verified."})
class UserRejectAPIView(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"])
@@ -461,6 +541,16 @@ class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
def retrieve(self, request, *args, **kwargs):
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)
data = serializer.data
+6
View File
@@ -14,9 +14,15 @@ class PaymentAdmin(admin.ModelAdmin):
"paid_at",
"method",
"created_at",
"expires_at",
"is_expired",
"updated_at",
)
@admin.display(boolean=True, description="Expired")
def is_expired(self, obj):
return obj.is_expired
class TopupAdmin(admin.ModelAdmin):
list_display = (
+28 -1
View File
@@ -1,6 +1,7 @@
import django_filters
from .models import Payment, Topup
from django.db.models import Q
from django.utils import timezone
class PaymentFilter(django_filters.FilterSet):
@@ -13,6 +14,18 @@ class PaymentFilter(django_filters.FilterSet):
mib_reference = django_filters.CharFilter(lookup_expr="icontains")
paid_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:
model = Payment
@@ -24,6 +37,7 @@ class TopupFilter(django_filters.FilterSet):
paid = django_filters.BooleanFilter(field_name="paid")
user = django_filters.CharFilter(method="filter_user_search")
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):
"""
@@ -36,11 +50,24 @@ class TopupFilter(django_filters.FilterSet):
| 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:
model = Topup # Assuming Topup is a subclass of Payment
model = Topup
fields = [
"amount",
"paid",
"status",
"user",
"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")
paid_at = models.DateTimeField(null=True, blank=True)
method = models.CharField(max_length=255, choices=PAYMENT_TYPES, default="TRANSFER")
expiry_notification_sent = models.BooleanField(default=False)
expires_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
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):
return f"Payment by {self.user}"
+5
View File
@@ -5,6 +5,10 @@ from devices.serializers import DeviceSerializer
class PaymentSerializer(serializers.ModelSerializer):
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
model = Payment
@@ -44,6 +48,7 @@ class TopupSerializer(serializers.ModelSerializer):
"amount",
"user",
"paid",
"paid_at",
"status",
"mib_reference",
"is_expired",
+110 -22
View File
@@ -4,20 +4,26 @@ from django.db import transaction
from django.utils import timezone
from procrastinate.contrib.django import app
from api.notifications import send_sms
from billing.models import Topup
from billing.models import Topup, Payment
from django.utils.timezone import localtime
from datetime import datetime
logger = logging.getLogger(__name__)
@app.periodic(
cron="*/30 * * * * *", periodic_id="notify_expired_topups", queue="heavy_tasks"
) # every 30 seconds
cron="*/1 * * * * *", periodic_id="notify_expired_topups", queue="heavy_tasks"
)
@app.task
def update_expired_topups(timestamp: int):
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")
if not expired_topups_qs.exists():
logger.info("No expired topups found.")
return {"total_expired_topups": 0}
with transaction.atomic():
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:
send_sms_task.defer(
mobile=topup.user.mobile,
type="TOPUP",
amount=topup.amount,
topup_id=str(topup.id),
model_id=str(topup.id),
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.save()
else:
topup.expiry_notification_sent = True
topup.save()
return
return {
"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
def send_sms_task(mobile: str, amount: float, topup_id: str, created_at: str):
message = (
f"Dear {mobile}, \n\nYour topup of {amount} MVR [created at {created_at}] has expired. "
"Please make a new topup to update your wallet. \n\n- SAR Link"
)
send_sms(mobile, message)
logger.info(f"SMS sent to {mobile} for expired topup of {amount} MVR.")
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}
# Mark the topup as notified after successful SMS sending
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:
topup = Topup.objects.get(id=topup_id)
topup.expiry_notification_sent = True
topup.save()
logger.info(f"Marked topup {topup_id} as notified.")
except Topup.DoesNotExist:
logger.error(f"Topup {topup_id} not found when trying to mark as notified.")
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 = (
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"
)
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)
logger.info(f"SMS sent to {mobile} for expired {type} of {amount} MVR.")
if type == "TOPUP":
try:
topup = Topup.objects.get(id=model_id)
topup.expiry_notification_sent = True
topup.save()
logger.info(f"Marked topup {model_id} as notified.")
except Topup.DoesNotExist:
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.test import APIClient
from django.contrib.auth import get_user_model
from .models import Topup
from .models import Topup, Payment
from .serializers import TopupSerializer
from django.utils import timezone
from datetime import timedelta
@@ -205,3 +205,47 @@ class TopupTests(TestCase):
response = self.client.delete(url, format="json")
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
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,
PaymentDetailAPIView,
UpdatePaymentAPIView,
DeletePaymentView,
CancelPaymentView,
ListCreateTopupView,
VerifyTopupPaymentAPIView,
TopupDetailAPIView,
@@ -21,9 +21,9 @@ urlpatterns = [
name="update-payment",
),
path(
"payment/<str:pk>/delete/",
DeletePaymentView.as_view(),
name="delete-payment",
"payment/<str:pk>/cancel/",
CancelPaymentView.as_view(),
name="cancel-payment",
),
path(
"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__)
@dataclass
class Transaction:
ref: str
sourceBank: str
trxDate: str
@dataclass
class PaymentVerificationResponse:
message: str
success: bool
transaction: Optional[Transaction] = None
class InsufficientFundsError(Exception):
pass
@@ -52,7 +66,8 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
number_of_months = data.get("number_of_months")
device_ids = data.get("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:
device = Device.objects.filter(id=device_id, user=user).first()
print("DEVICE", device)
@@ -77,6 +92,7 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
number_of_months=number_of_months,
paid=data.get("paid", False),
user=user,
expires_at=expires_at,
)
# Connect devices to payment
@@ -89,6 +105,30 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
serializer = PaymentSerializer(payment)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
all_payments = request.query_params.get("all_payments", "false").lower() in [
"true",
"1",
"yes",
]
if (
request.user.is_authenticated
and getattr(request.user, "is_admin")
and bool(all_payments)
):
pass
else:
queryset = queryset.filter(user=request.user)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class PaymentDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
queryset = Payment.objects.select_related("user").all()
@@ -110,7 +150,7 @@ class UpdatePaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
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)
@@ -145,7 +185,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
)
devices = payment.devices.all()
payment_status = False
if method == "WALLET":
if user.wallet_balance < payment.amount: # type: ignore
return Response(
@@ -153,7 +192,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
else:
payment_status = self.process_wallet_payment(
self.process_wallet_payment(
user,
payment,
)
@@ -161,15 +200,12 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
data = {
"benefName": f"{user.first_name} {user.last_name}", # type: ignore
"accountNo": user.acc_no, # type: ignore
"absAmount": payment.amount,
"time": localtime(timezone.now() + timedelta(minutes=5)).strftime(
"%Y-%m-%d %H:%M"
),
"absAmount": "{:.2f}".format(payment.amount),
"time": localtime(payment.created_at).strftime("%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:
# Update devices
if payment_verification_response.success:
expiry_date = timezone.now() + timedelta(days=30 * payment.number_of_months)
devices.update(
is_active=True,
@@ -177,7 +213,9 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
has_a_pending_payment=False,
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 = []
for device in devices:
device_list.append(
@@ -193,12 +231,22 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
device.save()
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,
)
else:
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,
)
@@ -215,7 +263,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
user.save()
return True
def verify_transfer_payment(self, data, payment):
def verify_transfer_payment(self, data, payment) -> PaymentVerificationResponse:
if not PAYMENT_BASE_URL:
raise ValueError(
"PAYMENT_BASE_URL is not set. Please set it in your environment variables."
@@ -230,41 +278,62 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
response.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.error(f"HTTPError: {e}")
return False
return PaymentVerificationResponse(
message="Payment verification failed.", success=False, transaction=None
)
mib_resp = response.json()
logger.info("MIB Verification Response ->", mib_resp)
if not response.json().get("success"):
return mib_resp["success"]
return PaymentVerificationResponse(
message=mib_resp["message"],
success=mib_resp["success"],
transaction=None,
)
else:
payment.paid = True
payment.paid_at = timezone.now()
payment.method = "TRANSFER"
payment.mib_reference = mib_resp["transaction"]["ref"] or ""
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()
serializer_class = PaymentSerializer
lookup_field = "pk"
def delete(self, request, *args, **kwargs):
def update(self, request, *args, **kwargs):
instance = self.get_object()
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:
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,
)
if instance.paid:
return Response(
{"message": "Paid payments cannot be deleted."},
{"message": "Paid payments cannot be cancelled."},
status=status.HTTP_400_BAD_REQUEST,
)
devices = instance.devices.all()
instance.status = "CANCELLED"
instance.save()
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):
@@ -308,20 +377,6 @@ class TopupDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
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):
queryset = Topup.objects.all()
serializer_class = TopupSerializer
+1
View File
@@ -10,6 +10,7 @@ class DeviceAdmin(admin.ModelAdmin):
"user",
"mac",
"vendor",
"expiry_date",
"blocked_by",
"name",
"created_at",
+9 -10
View File
@@ -31,8 +31,16 @@ class BlockDeviceSerializer(serializers.ModelSerializer):
class DeviceSerializer(serializers.ModelSerializer):
pending_payment_id = 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):
user = obj.user
@@ -45,15 +53,6 @@ class DeviceSerializer(serializers.ModelSerializer):
}
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
model = Device
fields = "__all__"