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
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
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
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
Abdulla AidhaanandGitHub d557bb879f Merge pull request #10 from i701/feat/task-queues
feature / task queues cleanup 
2025-07-09 20:16:48 +05:00
9 changed files with 262 additions and 51 deletions
+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
+6 -3
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)
+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 -22
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
@@ -40,6 +42,7 @@ from .serializers import (
CustomUserSerializer,
CustomReadOnlyUserSerializer,
CustomReadOnlyUserByIDCardSerializer,
UserProfileUpdateSerializer,
)
ID_CARD_PATTERN = r"^[A-Z]{1,2}[0-9]{6,7}$"
@@ -305,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,
@@ -343,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):
@@ -364,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"])
@@ -459,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
+24
View File
@@ -105,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()