Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c5ed1e89d | ||
|
|
976a119fcc | ||
|
|
d64a2675e4 | ||
|
|
eee314af46 | ||
|
|
ff065fa4a9 | ||
|
|
72c2ea1ecc | ||
|
|
596ce510c7 | ||
|
|
436a8b7d7a | ||
|
|
82ae1e6cea | ||
|
|
56ab79bd8c | ||
|
|
64c2189209 | ||
|
|
c3fc48fddc | ||
|
|
dacf821bad | ||
|
|
bb2d0348c2 | ||
|
|
3f0a5f0f03 | ||
|
|
e2ede37f4f | ||
|
|
db53874ff4 | ||
|
|
6418b1469d | ||
|
|
d557bb879f | ||
|
|
911d01b8e3 | ||
|
|
1644bd47b9 | ||
|
|
ff897ee2ab | ||
|
|
35384ef049 | ||
|
|
8657435fbf | ||
|
|
39da7607f6 | ||
|
|
d0a8408121 | ||
|
|
c17e34a592 | ||
|
|
cdfa2d9192 | ||
|
|
342e963861 | ||
|
|
4f794571e9 | ||
|
|
6bc2d71a0e | ||
|
|
ceb30025ee | ||
|
|
950f42ae3f | ||
|
|
f10fa74fbb | ||
|
|
60e394fffa
|
||
|
|
193ce850b4 | ||
|
|
27f89b6d3d | ||
|
|
63b1a6b9ef | ||
|
|
3dafc7d4c8 | ||
|
|
8e564f766b |
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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,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",
|
||||
|
||||
+103
-15
@@ -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):
|
||||
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 = (
|
||||
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"
|
||||
)
|
||||
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 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:
|
||||
topup = Topup.objects.get(id=topup_id)
|
||||
topup = Topup.objects.get(id=model_id)
|
||||
topup.expiry_notification_sent = True
|
||||
topup.save()
|
||||
logger.info(f"Marked topup {topup_id} as notified.")
|
||||
logger.info(f"Marked topup {model_id} as notified.")
|
||||
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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ class DeviceAdmin(admin.ModelAdmin):
|
||||
"user",
|
||||
"mac",
|
||||
"vendor",
|
||||
"expiry_date",
|
||||
"blocked_by",
|
||||
"name",
|
||||
"created_at",
|
||||
|
||||
+9
-10
@@ -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__"
|
||||
|
||||
Reference in New Issue
Block a user