Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aae0064ca | ||
|
|
118ad52c71 | ||
|
|
8d9a2ed2e0 | ||
|
|
3200d8e41c | ||
|
|
1554829b9a | ||
|
|
f8c91e8f14 | ||
|
|
f84f03fd5b | ||
|
|
fd603daaaf | ||
|
|
9e4449d0d6 | ||
|
|
087782e351 | ||
|
|
446ca6653e | ||
|
|
f8c0725558 | ||
|
|
7c5ed1e89d | ||
|
|
976a119fcc | ||
|
|
ea57598e8d | ||
|
|
d64a2675e4 | ||
|
|
4cc6e91a66 | ||
|
|
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 | ||
|
|
cd7555e35f | ||
|
|
3cc29cade6 | ||
|
|
7003e4bcba | ||
|
|
2da9dcf141 | ||
|
|
081366f87f | ||
|
|
6431d61d39 | ||
|
|
8871de8ce1 | ||
|
|
54056e9b7e | ||
|
|
09768ef2d3 | ||
|
|
48bde9d52b | ||
|
|
e85d605454 | ||
|
|
683cfe76f1 | ||
|
|
3b3e963568 | ||
|
|
c31acead70 | ||
|
|
6ec31023c7 | ||
|
|
6fb70e82a3 | ||
|
|
cf25863afe | ||
|
|
cd17ced67d | ||
|
|
f3e86d5873 | ||
|
|
b6517e79ab | ||
|
|
e88f4268cf | ||
|
|
e8e6a09b24 | ||
|
|
70f8efb19a | ||
|
|
ddb65ca985 | ||
|
|
740d16189b | ||
|
|
212ea2541f | ||
|
|
5db71edc2c | ||
|
|
6568504f5b | ||
|
|
d4b26074e6 | ||
|
|
638c32cb80 | ||
|
|
61e008d4fb | ||
|
|
25bad98900 | ||
|
|
cec2045e5f | ||
|
|
e4a01597aa | ||
|
|
4a944c176b | ||
|
|
f67a3762ad | ||
|
|
2122e8dfee | ||
|
|
978a4a27d0 | ||
|
|
367ccf0f88 | ||
|
|
c07d3c93d2 | ||
|
|
bae0882879 | ||
|
|
708d7c2bec | ||
|
|
dc8fe44004 | ||
|
|
c4f8989734 | ||
|
|
eb43b3108d
|
||
|
|
828da25046
|
@@ -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"
|
||||
|
||||
@@ -163,3 +163,4 @@ cython_debug/
|
||||
#staticfiles
|
||||
staticfiles/
|
||||
postgres_data/
|
||||
media/
|
||||
@@ -47,6 +47,7 @@ class UserAdmin(BaseUserAdmin):
|
||||
"island",
|
||||
"terms_accepted",
|
||||
"policy_accepted",
|
||||
"agreement",
|
||||
)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2 on 2025-07-24 18:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0017_alter_temporaryuser_t_id_card_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="agreement",
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
help_text="Upload the agreement file signed by the user.",
|
||||
null=True,
|
||||
upload_to="agreements/",
|
||||
),
|
||||
),
|
||||
]
|
||||
+46
-5
@@ -8,15 +8,20 @@ from django.db import models
|
||||
from .managers import CustomUserManager
|
||||
from django.utils import timezone
|
||||
import pyotp
|
||||
from billing.models import WalletTransaction
|
||||
|
||||
|
||||
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)
|
||||
@@ -30,12 +35,44 @@ class User(AbstractUser):
|
||||
island = models.ForeignKey(
|
||||
"Island", on_delete=models.SET_NULL, null=True, blank=True, related_name="users"
|
||||
)
|
||||
|
||||
agreement = models.FileField(
|
||||
upload_to="agreements/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Upload the agreement file signed by the user.",
|
||||
)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def get_all_fields(self, instance):
|
||||
return [field.name for field in instance.get_fields()]
|
||||
|
||||
def add_wallet_funds(self, amount, description="", reference_id=None):
|
||||
self.wallet_balance += amount
|
||||
self.save(update_fields=["wallet_balance"])
|
||||
WalletTransaction.objects.create(
|
||||
user=self,
|
||||
amount=amount,
|
||||
transaction_type="TOPUP",
|
||||
description=description,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
|
||||
def deduct_wallet_funds(self, amount, description="", reference_id=None):
|
||||
if self.wallet_balance >= amount:
|
||||
self.wallet_balance -= amount
|
||||
self.save(update_fields=["wallet_balance"])
|
||||
WalletTransaction.objects.create(
|
||||
user=self,
|
||||
amount=amount,
|
||||
transaction_type="DEBIT",
|
||||
description=description,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
objects = CustomUserManager()
|
||||
|
||||
|
||||
@@ -46,10 +83,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)
|
||||
@@ -95,7 +136,7 @@ class TemporaryUser(models.Model):
|
||||
verbose_name_plural = "Temporary Users"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.t_username)
|
||||
return f"{self.t_username}"
|
||||
|
||||
|
||||
class Atoll(models.Model):
|
||||
|
||||
+41
-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,40 @@ 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",
|
||||
"agreement",
|
||||
)
|
||||
|
||||
|
||||
class UserAgreementSerializer(serializers.ModelSerializer):
|
||||
"""serializer for the user agreement object"""
|
||||
|
||||
class Meta: # type: ignore
|
||||
model = User
|
||||
fields = ("agreement",)
|
||||
extra_kwargs = {"agreement": {"required": True, "allow_null": False}}
|
||||
|
||||
|
||||
class CustomUserSerializer(serializers.ModelSerializer):
|
||||
"""serializer for the user object"""
|
||||
|
||||
@@ -80,7 +117,10 @@ class CustomReadOnlyUserSerializer(serializers.ModelSerializer):
|
||||
"username",
|
||||
"mobile",
|
||||
"address",
|
||||
"acc_no",
|
||||
"id_card",
|
||||
"agreement",
|
||||
"wallet_balance",
|
||||
)
|
||||
depth = 1
|
||||
|
||||
|
||||
+8
-4
@@ -7,18 +7,23 @@ from django.db.models.signals import post_save
|
||||
from api.models import User
|
||||
from django.contrib.auth.models import Permission
|
||||
from api.tasks import verify_user_with_person_api_task
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
|
||||
@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")
|
||||
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)
|
||||
for permission in device_permissions:
|
||||
instance.user_permissions.add(permission)
|
||||
instance.user_permissions.add(atoll_read_permission, island_read_permission)
|
||||
@@ -27,7 +32,6 @@ def assign_device_permissions(sender, instance, created, **kwargs):
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
@sync_to_async
|
||||
def verify_user_with_person_api(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
verify_user_with_person_api_task(instance.id)
|
||||
|
||||
+34
-20
@@ -5,26 +5,39 @@ 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__)
|
||||
|
||||
env.read_env(os.path.join(BASE_DIR, ".env"))
|
||||
|
||||
omada_client = Omada()
|
||||
|
||||
@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.task
|
||||
def add(x, y):
|
||||
print(f"Adding {x} and {y}")
|
||||
return x + y
|
||||
|
||||
|
||||
@app.periodic(cron="0 0 */28 * *") # type: ignore
|
||||
@app.periodic(
|
||||
cron="0 0 */28 * *", queue="heavy_tasks", periodic_id="deactivate_expired_devices"
|
||||
) # type: ignore
|
||||
@app.task
|
||||
def deactivate_expired_devices():
|
||||
expired_devices = Device.objects.filter(
|
||||
@@ -64,6 +77,7 @@ def add_new_devices_to_omada(new_devices: list[dict]):
|
||||
:param new_devices: List of new device names to add.
|
||||
"""
|
||||
logger.info("Running add new devices to Omada task...")
|
||||
omada_client = Omada()
|
||||
omada_client.add_new_devices_to_omada(new_devices)
|
||||
|
||||
|
||||
@@ -81,16 +95,16 @@ def verify_user_with_person_api_task(user_id: int):
|
||||
return None
|
||||
# Call the Person API to verify the user
|
||||
|
||||
verification_failed_message = f"""
|
||||
_The following user verification failed_:
|
||||
*ID Card:* {user.id_card}
|
||||
*Name:* {user.first_name} {user.last_name}
|
||||
*House Name:* {user.address}
|
||||
*Date of Birth:* {user.dob}
|
||||
*Island:* {(user.atoll.name if user.atoll else "N/A")} {(user.island.name if user.island else "N/A")}
|
||||
*Mobile:* {user.mobile}
|
||||
Visit [SAR Link Portal](https://portal.sarlink.net) to manually verify this user.
|
||||
"""
|
||||
# verification_failed_message = f"""
|
||||
# _The following user verification failed_:
|
||||
# *ID Card:* {user.id_card}
|
||||
# *Name:* {user.first_name} {user.last_name}
|
||||
# *House Name:* {user.address}
|
||||
# *Date of Birth:* {user.dob}
|
||||
# *Island:* {(user.atoll.name if user.atoll else "N/A")} {(user.island.name if user.island else "N/A")}
|
||||
# *Mobile:* {user.mobile}
|
||||
# Visit [SAR Link Portal](https://portal.sarlink.net) to manually verify this user.
|
||||
# """
|
||||
|
||||
# logger.info(verification_failed_message)
|
||||
PERSON_VERIFY_BASE_URL = env.str("PERSON_VERIFY_BASE_URL", default="") # type: ignore
|
||||
@@ -171,7 +185,7 @@ def verify_user_with_person_api_task(user_id: int):
|
||||
user.mobile,
|
||||
f"Dear {user.first_name} {user.last_name}, \n\nYour account registration is being processed. \n\nWe will notify you once verification is complete. \n\n - SAR Link",
|
||||
)
|
||||
send_clean_telegram_markdown(message=verification_failed_message)
|
||||
# send_clean_telegram_markdown(message=verification_failed_message)
|
||||
return False
|
||||
else:
|
||||
# Handle the error case
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# projects/tasks_app.py
|
||||
from procrastinate import App
|
||||
from procrastinate.contrib.django import django_connector
|
||||
|
||||
app = App(
|
||||
connector=django_connector.DjangoConnector(),
|
||||
periodic_defaults={"max_delay": 86400}, # accept up to 24h delay
|
||||
)
|
||||
|
||||
|
||||
def on_app_ready(app):
|
||||
app.periodic_defaults = {"max_delay": 86400}
|
||||
app.import_paths.append("api.tasks")
|
||||
app.import_paths.append("billing.tasks")
|
||||
+14
-7
@@ -5,7 +5,7 @@ from knox import views as knox_views
|
||||
from .views import (
|
||||
LoginView,
|
||||
CreateTemporaryUserView,
|
||||
ManageUserView,
|
||||
UserprofileAPIView,
|
||||
KnoxTokenListApiView,
|
||||
ListUserView,
|
||||
UserDetailAPIView,
|
||||
@@ -18,29 +18,36 @@ from .views import (
|
||||
RetrieveUpdateDestroyIslandView,
|
||||
filter_user,
|
||||
filter_temporary_user,
|
||||
UpdateUserWalletView,
|
||||
VerifyOTPView,
|
||||
UserVerifyAPIView,
|
||||
UserUpdateAPIView,
|
||||
UserRejectAPIView,
|
||||
AgreementUpdateAPIView,
|
||||
)
|
||||
|
||||
|
||||
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"),
|
||||
path("tokens/", KnoxTokenListApiView.as_view(), name="knox_tokens"),
|
||||
# path("auth/", CustomAuthToken.as_view()),
|
||||
path("users/", ListUserView.as_view(), name="users"),
|
||||
path(
|
||||
"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>/agreement/",
|
||||
AgreementUpdateAPIView.as_view(),
|
||||
name="user-agreement-update",
|
||||
),
|
||||
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}
|
||||
|
||||
+197
-63
@@ -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
|
||||
@@ -14,9 +15,10 @@ from rest_framework.decorators import api_view, permission_classes
|
||||
from api.serializers import (
|
||||
AtollSerializer,
|
||||
IslandSerializer,
|
||||
CustomUserByWalletBalanceSerializer,
|
||||
OTPVerificationSerializer,
|
||||
TemporaryUserSerializer,
|
||||
UserUpdateSerializer,
|
||||
UserAgreementSerializer,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
@@ -31,8 +33,8 @@ 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
|
||||
import uuid
|
||||
|
||||
# local apps import
|
||||
from .serializers import (
|
||||
@@ -40,7 +42,7 @@ from .serializers import (
|
||||
AuthSerializer,
|
||||
CustomUserSerializer,
|
||||
CustomReadOnlyUserSerializer,
|
||||
CustomReadOnlyUserByIDCardSerializer,
|
||||
UserProfileUpdateSerializer,
|
||||
)
|
||||
|
||||
ID_CARD_PATTERN = r"^[A-Z]{1,2}[0-9]{6,7}$"
|
||||
@@ -60,39 +62,9 @@ class ErrorMessages:
|
||||
|
||||
@api_view(["GET"])
|
||||
def healthcheck(request):
|
||||
add.defer(1, 2)
|
||||
return Response({"status": "Good"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UpdateUserWalletView(generics.UpdateAPIView):
|
||||
# Create user API view
|
||||
serializer_class = CustomUserByWalletBalanceSerializer
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
queryset = User.objects.all()
|
||||
lookup_field = "pk"
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
id_to_update = kwargs.get("pk")
|
||||
user_id = request.user.id
|
||||
print(f"User ID: {user_id}")
|
||||
print(f"ID to update: {id_to_update}")
|
||||
if user_id != id_to_update:
|
||||
return Response(
|
||||
{"message": "You are not authorized to update this user."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
wallet_balance = request.data.get("wallet_balance")
|
||||
if not wallet_balance:
|
||||
return Response(
|
||||
{"message": "wallet_balance is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
user = self.get_object()
|
||||
user.wallet_balance = wallet_balance
|
||||
user.save()
|
||||
return Response({"message": "Wallet balance updated successfully."})
|
||||
|
||||
|
||||
class CreateTemporaryUserView(generics.CreateAPIView):
|
||||
# Create user API view
|
||||
serializer_class = TemporaryUserSerializer
|
||||
@@ -307,17 +279,129 @@ 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,
|
||||
)
|
||||
agreement_file = request.data.get("agreement_file")
|
||||
if not agreement_file:
|
||||
return Response(
|
||||
{"message": "Agreement file is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if agreement_file.size > 10 * 1024 * 1024: # 5 MB limit
|
||||
return Response(
|
||||
{"message": "File size exceeds 10 MB limit."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if agreement_file.content_type not in [
|
||||
"application/pdf",
|
||||
]:
|
||||
return Response(
|
||||
{"message": "Invalid file type. Only PDF files are allowed."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if agreement_file:
|
||||
user.agreement = agreement_file
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user.save()
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class AgreementUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
serializer_class = UserAgreementSerializer
|
||||
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,
|
||||
)
|
||||
agreement = request.data.get("agreement")
|
||||
if not agreement:
|
||||
return Response(
|
||||
{"message": "Agreement file is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if agreement.size > 10 * 1024 * 1024: # 5 MB limit
|
||||
return Response(
|
||||
{"message": "File size exceeds 10 MB limit."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if agreement.content_type not in [
|
||||
"application/pdf",
|
||||
]:
|
||||
return Response(
|
||||
{"message": "Invalid file type. Only PDF files are allowed."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# rename the file name to a random UUID followed by user_id
|
||||
agreement.name = f"{uuid.uuid4()}_{user_id}_agreement.pdf"
|
||||
if agreement:
|
||||
user.agreement = agreement
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user.save()
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class KnoxTokenListApiView(
|
||||
StaffEditorPermissionMixin,
|
||||
generics.ListAPIView,
|
||||
@@ -345,9 +429,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 +450,80 @@ 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)
|
||||
# The verification system might not have the records of every user hence can be skipped if not found and verify directly.
|
||||
if result.get("error") == "Not Found":
|
||||
user.verified = True
|
||||
user.save()
|
||||
return Response(
|
||||
{
|
||||
"message": "User verification failed due to mismatched fields.",
|
||||
"mismatch_fields": verified_person["mismatch_fields"],
|
||||
"message": "User not found in the verification system. User marked as verified."
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if not result["ok"]:
|
||||
return Response(
|
||||
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"])
|
||||
@@ -444,16 +578,6 @@ def filter_temporary_user(request):
|
||||
)
|
||||
|
||||
|
||||
class ListUserByIDCardView(generics.ListAPIView):
|
||||
# Create user API view
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
serializer_class = CustomReadOnlyUserByIDCardSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = "__all__"
|
||||
filterset_class = UserFilter
|
||||
queryset = User.objects.all()
|
||||
|
||||
|
||||
class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
|
||||
queryset = User.objects.all()
|
||||
serializer_class = CustomReadOnlyUserSerializer
|
||||
@@ -461,6 +585,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
|
||||
|
||||
|
||||
+19
-33
@@ -137,37 +137,23 @@ if not DEBUG:
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": env("POSTGRES_DATABASE"),
|
||||
"USER": env("POSTGRES_USER"),
|
||||
"PASSWORD": env("POSTGRES_PASSWORD"),
|
||||
"HOST": env("POSTGRES_HOST"),
|
||||
"PORT": env("POSTGRES_PORT"),
|
||||
"NAME": env("POSTGRES_DATABASE", default="mydb"), # type: ignore
|
||||
"USER": env("POSTGRES_USER", default="postgres"), # type: ignore
|
||||
"PASSWORD": env("POSTGRES_PASSWORD", default="testpass123"), # type: ignore
|
||||
"HOST": env("POSTGRES_HOST", default="localhost"), # type: ignore
|
||||
"PORT": env("POSTGRES_PORT", default="6500"), # type: ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# More robust caching configuration
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": (
|
||||
"django_redis.cache.RedisCache"
|
||||
if not DEBUG
|
||||
else "django.core.cache.backends.locmem.LocMemCache"
|
||||
),
|
||||
"LOCATION": (
|
||||
env("REDIS_URL", default="redis://redis:6379/") if not DEBUG else "" # type: ignore
|
||||
),
|
||||
"OPTIONS": (
|
||||
{
|
||||
"CLIENT_CLASS": (
|
||||
"django_redis.client.DefaultClient" if not DEBUG else None
|
||||
),
|
||||
}
|
||||
if not DEBUG
|
||||
else {}
|
||||
),
|
||||
}
|
||||
}
|
||||
# More robust caching configuration
|
||||
# CACHES = {
|
||||
# "default": {
|
||||
# "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
# "LOCATION": "unix:/tmp/memcached.sock",
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
# Password validation
|
||||
@@ -249,8 +235,11 @@ REST_FRAMEWORK = {
|
||||
"login": "1000/min",
|
||||
},
|
||||
"EXCEPTION_HANDLER": "api.exceptions.custom_exception_handler",
|
||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||
# "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema"
|
||||
"DEFAULT_RENDERER_CLASSES": (
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
# "rest_framework.renderers.BrowsableAPIRenderer",
|
||||
),
|
||||
# "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
}
|
||||
|
||||
|
||||
@@ -371,8 +360,5 @@ PASSWORDLESS_AUTH = {
|
||||
}
|
||||
|
||||
|
||||
# CELERY CONFIGURATION
|
||||
CELERY_BROKER_URL = f"redis://{REDIS_HOST}:6379/0"
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_BACKEND = f"redis://{REDIS_HOST}:6379/0"
|
||||
PROCRASTINATE_ON_APP_READY = "api.tasks_app.on_app_ready"
|
||||
PROCRASTINATE_APP = "api.tasks_app.app"
|
||||
|
||||
+54
-2
@@ -1,9 +1,28 @@
|
||||
from django.contrib import admin
|
||||
from .models import Payment, BillFormula, Topup
|
||||
from .models import Payment, BillFormula, Topup, WalletTransaction
|
||||
|
||||
# Register your models here.
|
||||
|
||||
|
||||
class WalletTransactionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"user",
|
||||
"amount",
|
||||
"transaction_type",
|
||||
"description",
|
||||
"reference_id",
|
||||
"created_at",
|
||||
)
|
||||
search_fields = (
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
"user__mobile",
|
||||
"user__id_card",
|
||||
)
|
||||
list_filter = ("transaction_type",)
|
||||
|
||||
|
||||
class PaymentAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
@@ -14,10 +33,43 @@ 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 = (
|
||||
"id",
|
||||
"user",
|
||||
"amount",
|
||||
"paid",
|
||||
"paid_at",
|
||||
"status",
|
||||
"created_at",
|
||||
"is_expired",
|
||||
"expires_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
search_fields = (
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
"user__id_card",
|
||||
"user__mobile",
|
||||
)
|
||||
|
||||
@admin.display(boolean=True, description="Expired")
|
||||
def is_expired(self, obj):
|
||||
return obj.is_expired
|
||||
|
||||
|
||||
admin.site.register(Payment, PaymentAdmin)
|
||||
admin.site.register(BillFormula)
|
||||
admin.site.register(Topup)
|
||||
admin.site.register(Topup, TopupAdmin)
|
||||
admin.site.register(WalletTransaction, WalletTransactionAdmin)
|
||||
|
||||
+94
-2
@@ -1,18 +1,110 @@
|
||||
import django_filters
|
||||
from .models import Payment
|
||||
from .models import Payment, Topup, WalletTransaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class PaymentFilter(django_filters.FilterSet):
|
||||
amount = django_filters.RangeFilter(field_name="amount")
|
||||
number_of_months = django_filters.RangeFilter(field_name="number_of_months")
|
||||
paid = django_filters.BooleanFilter(field_name="paid")
|
||||
user = django_filters.CharFilter(method="filter_user_search")
|
||||
method = django_filters.ChoiceFilter(
|
||||
choices=Payment.PAYMENT_TYPES, lookup_expr="iexact"
|
||||
)
|
||||
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_user_search(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(user__first_name__icontains=value)
|
||||
| Q(user__last_name__icontains=value)
|
||||
| Q(user__id_card__icontains=value)
|
||||
| Q(user__mobile__icontains=value)
|
||||
)
|
||||
|
||||
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
|
||||
fields = "__all__"
|
||||
fields = [
|
||||
"amount",
|
||||
"paid",
|
||||
"method",
|
||||
"user",
|
||||
"created_at",
|
||||
"is_expired",
|
||||
]
|
||||
|
||||
|
||||
class TopupFilter(django_filters.FilterSet):
|
||||
amount = django_filters.RangeFilter(field_name="amount")
|
||||
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):
|
||||
"""
|
||||
Search across multiple user fields: first_name, last_name, id_card, mobile
|
||||
"""
|
||||
return queryset.filter(
|
||||
Q(user__first_name__icontains=value)
|
||||
| Q(user__last_name__icontains=value)
|
||||
| Q(user__id_card__icontains=value)
|
||||
| Q(user__mobile__icontains=value)
|
||||
)
|
||||
|
||||
def filter_is_expired(self, queryset, name, value):
|
||||
"""
|
||||
Filter topups based on whether they are expired or not
|
||||
"""
|
||||
now = timezone.now()
|
||||
queryset = queryset.filter(paid=False)
|
||||
if value:
|
||||
return queryset.filter(expires_at__isnull=False, expires_at__lt=now)
|
||||
else:
|
||||
return queryset.filter(Q(expires_at__isnull=True) | Q(expires_at__gte=now))
|
||||
|
||||
class Meta:
|
||||
model = Topup
|
||||
fields = [
|
||||
"amount",
|
||||
"paid",
|
||||
"status",
|
||||
"user",
|
||||
"created_at",
|
||||
"is_expired",
|
||||
]
|
||||
|
||||
|
||||
class WalletTransactionFilter(django_filters.FilterSet):
|
||||
user = django_filters.CharFilter(method="filter_user_search")
|
||||
amount = django_filters.RangeFilter(field_name="amount")
|
||||
created_at = django_filters.DateFromToRangeFilter(field_name="created_at")
|
||||
|
||||
def filter_user_search(self, queryset, name, value):
|
||||
"""
|
||||
Search across multiple user fields: first_name, last_name, id_card, mobile
|
||||
"""
|
||||
return queryset.filter(
|
||||
Q(user__first_name__icontains=value)
|
||||
| Q(user__last_name__icontains=value)
|
||||
| Q(user__id_card__icontains=value)
|
||||
| Q(user__mobile__icontains=value)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = WalletTransaction
|
||||
fields = ["user", "amount", "created_at", "transaction_type"]
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# billing/management/commands/seed_billing.py
|
||||
|
||||
import random
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from faker import Faker
|
||||
from billing.models import Topup
|
||||
from api.models import User
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seeds topup models with dummy data."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--number",
|
||||
type=int,
|
||||
default=10,
|
||||
help="The number of topups to create.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
number = options["number"]
|
||||
fake = Faker()
|
||||
|
||||
users = User.objects.all()
|
||||
if not users.exists():
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
"No users found. Please seed users first (e.g., python manage.py seed_users)."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self.stdout.write(self.style.NOTICE(f"Seeding {number} topups..."))
|
||||
|
||||
for _ in range(number):
|
||||
random_user = random.choice(users)
|
||||
|
||||
expires_at_date = timezone.now() + timezone.timedelta(
|
||||
minutes=10,
|
||||
)
|
||||
print(
|
||||
f"Creating topup for user {getattr(random_user, 'id', None)} expires at: {expires_at_date}"
|
||||
)
|
||||
Topup.objects.create(
|
||||
amount=fake.pydecimal(
|
||||
left_digits=4,
|
||||
right_digits=2,
|
||||
positive=True,
|
||||
min_value=100.00,
|
||||
max_value=5000.00,
|
||||
),
|
||||
status=random.choice(["PENDING", "PAID", "CANCELLED"]),
|
||||
user=random_user,
|
||||
updated_at=timezone.now(),
|
||||
expires_at=expires_at_date,
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Successfully seeded {number} topups."))
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2 on 2025-07-03 11:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("billing", "0005_alter_payment_options_payment_mib_reference"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="topup",
|
||||
name="mib_reference",
|
||||
field=models.CharField(blank=True, default="", null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2 on 2025-07-03 12:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("billing", "0006_topup_mib_reference"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="topup",
|
||||
name="paid_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2 on 2025-07-04 06:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("billing", "0007_topup_paid_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="topup",
|
||||
options={"ordering": ["-created_at"]},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="topup",
|
||||
name="expired",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="topup",
|
||||
name="expires_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 5.2 on 2025-07-04 11:13
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("billing", "0008_alter_topup_options_topup_expired_topup_expires_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="topup",
|
||||
name="expired",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2 on 2025-07-05 09:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("billing", "0009_remove_topup_expired"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="topup",
|
||||
name="expiry_notification_sent",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2 on 2025-07-05 12:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("billing", "0010_add_expiry_notification_sent_to_topup"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="topup",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("PAID", "Paid"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 5.2 on 2025-07-25 08:34
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("billing", "0013_payment_expiry_notification_sent"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WalletTransaction",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("amount", models.FloatField()),
|
||||
(
|
||||
"transaction_type",
|
||||
models.CharField(
|
||||
choices=[("TOPUP", "Topup"), ("DEBIT", "Debit")], max_length=10
|
||||
),
|
||||
),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"reference_id",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(default=django.utils.timezone.now)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="wallet_transactions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
+68
-5
@@ -1,11 +1,11 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from api.models import User
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from devices.models import Device
|
||||
|
||||
# Create your models here.
|
||||
|
||||
from devices.models import Device
|
||||
user = settings.AUTH_USER_MODEL
|
||||
|
||||
# Create your models here.
|
||||
|
||||
@@ -20,13 +20,29 @@ class Payment(models.Model):
|
||||
number_of_months = models.IntegerField()
|
||||
amount = models.FloatField()
|
||||
paid = models.BooleanField(default=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payments")
|
||||
user = models.ForeignKey(user, on_delete=models.CASCADE, related_name="payments")
|
||||
paid_at = models.DateTimeField(null=True, blank=True)
|
||||
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}"
|
||||
@@ -49,10 +65,57 @@ class BillFormula(models.Model):
|
||||
class Topup(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
amount = models.FloatField()
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="topups")
|
||||
user = models.ForeignKey(user, on_delete=models.CASCADE, related_name="topups")
|
||||
paid = models.BooleanField(default=False)
|
||||
paid_at = models.DateTimeField(null=True, blank=True)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("PAID", "Paid"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
],
|
||||
default="PENDING",
|
||||
)
|
||||
mib_reference = models.CharField(default="", null=True, blank=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
expiry_notification_sent = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
def __str__(self):
|
||||
return f"Topup for {self.user}"
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
class WalletTransaction(models.Model):
|
||||
TRANSACTION_TYPES = [
|
||||
("TOPUP", "Topup"),
|
||||
("DEBIT", "Debit"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="wallet_transactions",
|
||||
)
|
||||
amount = models.FloatField()
|
||||
transaction_type = models.CharField(max_length=10, choices=TRANSACTION_TYPES)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
reference_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.transaction_type} {self.amount} ({self.user.username})"
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
+77
-5
@@ -1,19 +1,91 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Payment
|
||||
from devices.serializers import DeviceSerializer
|
||||
from .models import Payment, Topup, WalletTransaction
|
||||
from devices.serializers import AdminDeviceSerializer
|
||||
|
||||
|
||||
class PaymentSerializer(serializers.ModelSerializer):
|
||||
devices = DeviceSerializer(many=True, read_only=True)
|
||||
devices = AdminDeviceSerializer(many=True, read_only=True)
|
||||
is_expired = serializers.SerializerMethodField()
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
def get_user(self, obj):
|
||||
user = obj.user
|
||||
if user:
|
||||
return {
|
||||
"id": user.id,
|
||||
"name": user.first_name + " " + user.last_name,
|
||||
"id_card": user.id_card,
|
||||
"mobile": user.mobile,
|
||||
}
|
||||
return None
|
||||
|
||||
def get_is_expired(self, obj):
|
||||
return obj.is_expired
|
||||
|
||||
class Meta: # type: ignore
|
||||
model = Payment
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class UpdatePaymentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
class Meta: # type: ignore
|
||||
model = Payment
|
||||
fields = [
|
||||
"number_of_months",
|
||||
]
|
||||
|
||||
|
||||
class TopupSerializer(serializers.ModelSerializer):
|
||||
user = serializers.SerializerMethodField()
|
||||
is_expired = serializers.SerializerMethodField()
|
||||
|
||||
def get_user(self, obj):
|
||||
user = obj.user
|
||||
if user:
|
||||
return {
|
||||
"id": user.id,
|
||||
"name": user.first_name + " " + user.last_name,
|
||||
"id_card": user.id_card,
|
||||
"mobile": user.mobile,
|
||||
}
|
||||
return None
|
||||
|
||||
def get_is_expired(self, obj):
|
||||
return obj.is_expired
|
||||
|
||||
class Meta: # type: ignore
|
||||
model = Topup
|
||||
fields = [
|
||||
"id",
|
||||
"amount",
|
||||
"user",
|
||||
"paid",
|
||||
"paid_at",
|
||||
"status",
|
||||
"mib_reference",
|
||||
"is_expired",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class WalletTransactionSerializer(serializers.ModelSerializer):
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
def get_user(self, obj):
|
||||
user = obj.user
|
||||
if user:
|
||||
return {
|
||||
"id": user.id,
|
||||
"name": user.first_name + " " + user.last_name,
|
||||
"id_card": user.id_card,
|
||||
"mobile": user.mobile,
|
||||
}
|
||||
return None
|
||||
|
||||
class Meta: # type: ignore
|
||||
model = WalletTransaction
|
||||
fields = "__all__"
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import logging
|
||||
|
||||
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, Payment
|
||||
from django.utils.timezone import localtime
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.periodic(
|
||||
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,
|
||||
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()
|
||||
logger.info(f"Found {count} topups to expire.")
|
||||
|
||||
for topup in expired_topups_qs:
|
||||
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,
|
||||
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",
|
||||
)
|
||||
topup.expiry_notification_sent = True
|
||||
topup.save()
|
||||
else:
|
||||
topup.expiry_notification_sent = True
|
||||
topup.save()
|
||||
return
|
||||
|
||||
return {
|
||||
"total_expired_topups": count,
|
||||
}
|
||||
|
||||
|
||||
@app.periodic(
|
||||
cron="*/1 * * * * *", periodic_id="notify_expired_payments", queue="heavy_tasks"
|
||||
)
|
||||
@app.task
|
||||
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 {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."
|
||||
)
|
||||
+249
-1
@@ -1,3 +1,251 @@
|
||||
from django.test import TestCase
|
||||
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, Payment
|
||||
from .serializers import TopupSerializer
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from decouple import config
|
||||
from unittest import mock
|
||||
|
||||
# Create your tests here.
|
||||
User = get_user_model()
|
||||
|
||||
REAL_USER_FIRST_NAME = config("REAL_USER_FIRST_NAME", default="josh")
|
||||
REAL_USER_LAST_NAME = config("REAL_USER_LAST_NAME", default="mosh")
|
||||
|
||||
|
||||
class TopupTests(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_create_topup(self):
|
||||
url = reverse("create-list-topups")
|
||||
data = {"amount": 100.00}
|
||||
response = self.client.post(url, data, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Topup.objects.count(), 1)
|
||||
topup = Topup.objects.first()
|
||||
self.assertEqual(getattr(topup, "amount"), 100.00)
|
||||
self.assertEqual(getattr(topup, "user"), self.real_user)
|
||||
|
||||
def test_create_topup_no_amount(self):
|
||||
url = reverse("create-list-topups")
|
||||
data = {}
|
||||
response = self.client.post(url, data, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_list_topups(self):
|
||||
Topup.objects.create(amount=50.00, user=self.real_user)
|
||||
Topup.objects.create(amount=75.00, user=self.real_user)
|
||||
url = reverse("create-list-topups")
|
||||
response = self.client.get(url, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.json()["data"]), 2)
|
||||
|
||||
def test_list_topups_admin(self):
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.admin_user)
|
||||
Topup.objects.create(amount=50.00, user=self.real_user)
|
||||
admin_user = User.objects.create_user(
|
||||
username="anotheruser",
|
||||
password="testpassword",
|
||||
first_name="Another",
|
||||
last_name="User",
|
||||
acc_no="1122334455",
|
||||
is_admin=True,
|
||||
)
|
||||
Topup.objects.create(amount=75.00, user=admin_user)
|
||||
url = reverse("create-list-topups")
|
||||
response = self.client.get(url, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.json()["data"]), 2) # Admin sees all
|
||||
|
||||
def test_list_topups_filtered_by_user(self):
|
||||
other_user = User.objects.create_user(
|
||||
username="otheruser",
|
||||
password="testpassword",
|
||||
first_name="Other",
|
||||
last_name="User",
|
||||
acc_no="5544332211",
|
||||
)
|
||||
Topup.objects.create(amount=50.00, user=self.user)
|
||||
Topup.objects.create(amount=75.00, user=other_user)
|
||||
url = reverse("create-list-topups")
|
||||
response = self.client.get(url, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.json()["data"]), 2)
|
||||
|
||||
@mock.patch("billing.views.localtime")
|
||||
def test_verify_topup_payment(self, mock_localtime):
|
||||
fixed_time = timezone.datetime(
|
||||
2025, 7, 3, 19, 36, tzinfo=timezone.get_current_timezone()
|
||||
)
|
||||
mock_localtime.return_value = fixed_time
|
||||
|
||||
topup = Topup.objects.create(amount=1.5, user=self.real_user)
|
||||
url = reverse("verify-topup-payment", kwargs={"pk": topup.pk})
|
||||
self.client = APIClient()
|
||||
|
||||
self.client.force_authenticate(user=self.real_user)
|
||||
data = {
|
||||
"benefName": f"{REAL_USER_FIRST_NAME} {REAL_USER_LAST_NAME}",
|
||||
"accountNo": "7770000010629",
|
||||
"absAmount": 1.5,
|
||||
"time": fixed_time.strftime("%Y-%m-%d %H:%M"), # Use the same fixed time
|
||||
}
|
||||
response = self.client.patch(url, data, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
topup.refresh_from_db()
|
||||
self.assertEqual(topup.paid, True)
|
||||
|
||||
def test_verify_topup_payment_already_verified(self):
|
||||
topup = Topup.objects.create(amount=100.00, user=self.real_user, paid=True)
|
||||
url = reverse("verify-topup-payment", kwargs={"pk": topup.pk})
|
||||
data = {}
|
||||
response = self.client.patch(url, data, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_verify_topup_payment_unauthorized(self):
|
||||
other_user = User.objects.create_user(
|
||||
username="otheruser",
|
||||
password="testpassword",
|
||||
first_name="Other",
|
||||
last_name="User",
|
||||
acc_no="5544332211",
|
||||
)
|
||||
topup = Topup.objects.create(amount=100.00, user=other_user)
|
||||
url = reverse("verify-topup-payment", kwargs={"pk": topup.pk})
|
||||
data = {}
|
||||
response = self.client.patch(url, data, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_topup_serializer(self):
|
||||
topup = Topup.objects.create(amount=120.00, user=self.real_user)
|
||||
serializer = TopupSerializer(topup)
|
||||
self.assertEqual(serializer.data["amount"], 120.00)
|
||||
self.assertEqual(serializer.data["user"]["id"], getattr(self.real_user, "id"))
|
||||
|
||||
def test_topup_filter_amount(self):
|
||||
Topup.objects.create(amount=50.00, user=self.real_user)
|
||||
Topup.objects.create(amount=100.00, user=self.real_user)
|
||||
url = reverse("create-list-topups") + "?amount_min=75"
|
||||
response = self.client.get(url, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.json()["data"]), 1)
|
||||
self.assertEqual(response.json()["data"][0]["amount"], 100.00)
|
||||
|
||||
def test_topup_filter_user_search(self):
|
||||
Topup.objects.create(amount=50.00, user=self.real_user)
|
||||
other_user = User.objects.create_user(
|
||||
username="otheruser",
|
||||
password="testpassword",
|
||||
first_name="Other",
|
||||
last_name="User",
|
||||
id_card="12345",
|
||||
mobile="1234567890",
|
||||
acc_no="5544332211",
|
||||
)
|
||||
Topup.objects.create(amount=75.00, user=other_user)
|
||||
url = reverse("create-list-topups") + "?user=Other"
|
||||
response = self.client.get(url, format="json")
|
||||
print(response.json())
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.json()["data"]), 1)
|
||||
self.assertEqual(response.json()["data"][0]["amount"], 75.00)
|
||||
|
||||
def test_topup_filter_created_at(self):
|
||||
now = timezone.now()
|
||||
Topup.objects.create(
|
||||
amount=50.00, user=self.real_user, created_at=now - timedelta(days=2)
|
||||
)
|
||||
Topup.objects.create(amount=100.00, user=self.real_user, created_at=now)
|
||||
url = reverse("create-list-topups") + f"?created_at_after={now.date()}"
|
||||
response = self.client.get(url, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.json()["data"]), 1)
|
||||
self.assertEqual(response.json()["data"][0]["amount"], 100.00)
|
||||
|
||||
def test_retrieve_single_topup(self):
|
||||
topup = Topup.objects.create(amount=50.00, user=self.real_user)
|
||||
url = reverse("retrieve-topup", kwargs={"pk": topup.pk})
|
||||
response = self.client.get(url, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.json()["amount"], 50.00)
|
||||
self.assertEqual(response.json()["user"]["id"], getattr(self.real_user, "id"))
|
||||
|
||||
def test_delete_topup(self):
|
||||
topup = Topup.objects.create(amount=50.00, user=self.real_user)
|
||||
url = reverse("delete-topup", kwargs={"pk": topup.pk})
|
||||
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)
|
||||
|
||||
+28
-4
@@ -5,7 +5,12 @@ from .views import (
|
||||
VerifyPaymentView,
|
||||
PaymentDetailAPIView,
|
||||
UpdatePaymentAPIView,
|
||||
DeletePaymentView,
|
||||
CancelPaymentView,
|
||||
ListCreateTopupView,
|
||||
VerifyTopupPaymentAPIView,
|
||||
TopupDetailAPIView,
|
||||
CancelTopupView,
|
||||
ListWalletTransactionView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -17,11 +22,30 @@ 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"
|
||||
),
|
||||
# Topups
|
||||
path("topup/", ListCreateTopupView.as_view(), name="create-list-topups"),
|
||||
path("topup/<str:pk>/", TopupDetailAPIView.as_view(), name="retrieve-topup"),
|
||||
path(
|
||||
"topup/<str:pk>/verify/",
|
||||
VerifyTopupPaymentAPIView.as_view(),
|
||||
name="verify-topup-payment",
|
||||
),
|
||||
path(
|
||||
"topup/<str:pk>/cancel/",
|
||||
CancelTopupView.as_view(),
|
||||
name="cancel-topup",
|
||||
),
|
||||
# Wallet transactions
|
||||
path(
|
||||
"wallet-transactions/",
|
||||
ListWalletTransactionView.as_view(),
|
||||
name="list-wallet-transactions",
|
||||
),
|
||||
]
|
||||
|
||||
+387
-45
@@ -13,11 +13,20 @@ from rest_framework.response import Response
|
||||
from api.mixins import StaffEditorPermissionMixin
|
||||
from api.tasks import add_new_devices_to_omada
|
||||
from apibase.env import BASE_DIR, env
|
||||
from django.db.models import Prefetch
|
||||
import logging
|
||||
|
||||
from .models import Device, Payment
|
||||
from .serializers import PaymentSerializer, UpdatePaymentSerializer
|
||||
from .filters import PaymentFilter
|
||||
from .models import Device, Payment, Topup, WalletTransaction
|
||||
from .serializers import (
|
||||
PaymentSerializer,
|
||||
UpdatePaymentSerializer,
|
||||
TopupSerializer,
|
||||
WalletTransactionSerializer,
|
||||
)
|
||||
from .filters import PaymentFilter, TopupFilter, WalletTransactionFilter
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Optional
|
||||
from api.models import User
|
||||
|
||||
env.read_env(os.path.join(BASE_DIR, ".env"))
|
||||
|
||||
@@ -26,6 +35,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
|
||||
|
||||
@@ -38,10 +61,18 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
|
||||
filterset_class = PaymentFilter
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.request.user.is_superuser:
|
||||
return queryset
|
||||
return queryset.filter(user=self.request.user)
|
||||
unpaid_qs = Payment.objects.filter(paid=False).order_by("-created_at")
|
||||
device_qs = Device.objects.prefetch_related(
|
||||
Prefetch("payments", queryset=unpaid_qs, to_attr="unpaid_payments")
|
||||
)
|
||||
queryset = Payment.objects.select_related("user").prefetch_related(
|
||||
Prefetch("devices", queryset=device_qs)
|
||||
)
|
||||
|
||||
if not self.request.user.is_superuser:
|
||||
queryset = queryset.filter(user=self.request.user)
|
||||
|
||||
return queryset
|
||||
|
||||
def create(self, request):
|
||||
data = request.data
|
||||
@@ -50,7 +81,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)
|
||||
@@ -75,6 +107,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
|
||||
@@ -87,6 +120,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()
|
||||
@@ -108,18 +165,17 @@ 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)
|
||||
|
||||
|
||||
class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
serializer_class = PaymentSerializer
|
||||
queryset = Payment.objects.all()
|
||||
queryset = Payment.objects.select_related("user").all()
|
||||
lookup_field = "pk"
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
# TODO: Fix check for success payment
|
||||
payment = self.get_object()
|
||||
data = request.data
|
||||
user = request.user
|
||||
@@ -143,7 +199,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(
|
||||
@@ -151,23 +206,27 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
payment_status = self.process_wallet_payment(
|
||||
user,
|
||||
self.process_wallet_payment(
|
||||
user, # type: ignore
|
||||
payment,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"status": True,
|
||||
"message": "Payment verified successfully using wallet.",
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
if method == "TRANSFER":
|
||||
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,
|
||||
@@ -175,7 +234,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(
|
||||
@@ -191,29 +252,185 @@ 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,
|
||||
)
|
||||
|
||||
def process_wallet_payment(self, user, payment):
|
||||
def process_wallet_payment(self, user: User, payment: Payment):
|
||||
print("processing wallet payment...")
|
||||
print(user, payment.amount)
|
||||
|
||||
payment.paid = True
|
||||
payment.paid_at = timezone.now()
|
||||
payment.method = "WALLET"
|
||||
payment.status = "PAID"
|
||||
payment.save()
|
||||
|
||||
user.wallet_balance -= payment.amount
|
||||
user.deduct_wallet_funds(
|
||||
payment.amount, "Wallet payment for devices", payment.id
|
||||
)
|
||||
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."
|
||||
)
|
||||
response = requests.post(
|
||||
f"{PAYMENT_BASE_URL}/verify-payment",
|
||||
json=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
logger.info("MIB Verification Response -> ", response)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"HTTPError: {e}")
|
||||
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 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 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 CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
queryset = Payment.objects.select_related("user").all()
|
||||
serializer_class = PaymentSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
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 cancel this payment."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if instance.paid:
|
||||
return Response(
|
||||
{"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().update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
|
||||
queryset = Topup.objects.all().prefetch_related("user")
|
||||
serializer_class = TopupSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = "__all__"
|
||||
filterset_class = TopupFilter
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
data = request.data
|
||||
user = request.user
|
||||
current_time = timezone.now()
|
||||
expires_at = current_time + timedelta(minutes=10) # Topup expires in 10 minutes
|
||||
amount = data.get("amount")
|
||||
if not amount:
|
||||
return Response(
|
||||
{"message": "amount is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
topup = Topup.objects.create(amount=amount, user=user, expires_at=expires_at)
|
||||
serializer = TopupSerializer(topup)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser:
|
||||
return queryset
|
||||
return queryset.filter(user=self.request.user)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
all_topups = request.query_params.get("all_topups", "false").lower() in [
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
]
|
||||
if (
|
||||
request.user.is_authenticated
|
||||
and getattr(request.user, "is_admin")
|
||||
and bool(all_topups)
|
||||
):
|
||||
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 TopupDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
|
||||
queryset = Topup.objects.all()
|
||||
serializer_class = TopupSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser:
|
||||
return queryset
|
||||
return queryset.filter(user=self.request.user)
|
||||
|
||||
|
||||
class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
queryset = Topup.objects.all()
|
||||
serializer_class = TopupSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
def verify_transfer_topup(self, data, topup) -> PaymentVerificationResponse:
|
||||
if not PAYMENT_BASE_URL:
|
||||
raise ValueError(
|
||||
"PAYMENT_BASE_URL is not set. Please set it in your environment variables."
|
||||
@@ -224,38 +441,163 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
json=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"HTTPError: {e}")
|
||||
return PaymentVerificationResponse(
|
||||
message="Payment verification failed.", success=False, transaction=None
|
||||
)
|
||||
mib_resp = response.json()
|
||||
print(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
|
||||
topup.paid = True
|
||||
topup.mib_reference = mib_resp["transaction"]["ref"] or ""
|
||||
topup.paid_at = mib_resp["transaction"]["trxDate"]
|
||||
topup.save()
|
||||
return PaymentVerificationResponse(
|
||||
message=mib_resp["message"],
|
||||
success=mib_resp["success"],
|
||||
transaction=Transaction(
|
||||
ref=topup.mib_reference,
|
||||
sourceBank=mib_resp["transaction"]["sourceBank"],
|
||||
trxDate=mib_resp["transaction"]["trxDate"],
|
||||
),
|
||||
)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
topup_instance = self.get_object()
|
||||
user = request.user
|
||||
|
||||
if topup_instance.paid:
|
||||
return Response(
|
||||
{"message": "Payment has already been verified."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if topup_instance.user != user and not user.is_superuser:
|
||||
return Response(
|
||||
{"message": "You are not allowed to pay for this topup."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
data = {
|
||||
"benefName": f"{user.first_name} {user.last_name}", # type: ignore
|
||||
"accountNo": user.acc_no, # type: ignore
|
||||
"absAmount": "{:.2f}".format(topup_instance.amount),
|
||||
"time": localtime(topup_instance.created_at).strftime("%Y-%m-%d %H:%M"),
|
||||
}
|
||||
logger.info(
|
||||
f"Verifying topup payment created at {localtime(topup_instance.created_at)} with data: {data}"
|
||||
)
|
||||
topup_verification_response = self.verify_transfer_topup(data, topup_instance)
|
||||
print("Topup verification response:", topup_verification_response)
|
||||
if topup_verification_response.success:
|
||||
user.add_wallet_funds( # type: ignore
|
||||
topup_instance.amount,
|
||||
f"Topup of {topup_instance.amount} MVR",
|
||||
topup_instance.id,
|
||||
)
|
||||
user.save()
|
||||
topup_instance.status = "PAID"
|
||||
topup_instance.save()
|
||||
return Response(
|
||||
{
|
||||
"status": topup_verification_response.success,
|
||||
"message": topup_verification_response.message,
|
||||
"transaction": asdict(topup_verification_response.transaction)
|
||||
if topup_verification_response.transaction
|
||||
else None,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"status": topup_verification_response.success,
|
||||
"message": topup_verification_response.message
|
||||
or "Topup payment verification failed.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class DeletePaymentView(StaffEditorPermissionMixin, generics.DestroyAPIView):
|
||||
queryset = Payment.objects.all()
|
||||
serializer_class = PaymentSerializer
|
||||
class CancelTopupView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
queryset = Topup.objects.all().select_related("user")
|
||||
serializer_class = TopupSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
def update(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
user = request.user
|
||||
if instance.user != user and not user.is_superuser:
|
||||
if instance.status == "CANCELLED":
|
||||
return Response(
|
||||
{"message": "You are not authorized to delete this payment."},
|
||||
{"message": "Topup has already been cancelled."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if instance.is_expired:
|
||||
return Response(
|
||||
{"message": "Expired topups cannot be cancelled."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if (
|
||||
instance.user != user
|
||||
and getattr(user, "is_admin")
|
||||
and not user.is_superuser
|
||||
):
|
||||
return Response(
|
||||
{"message": "You are not authorized to delete this topup."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if instance.paid:
|
||||
return Response(
|
||||
{"message": "Paid payments cannot be deleted."},
|
||||
{"message": "Paid topups cannot be deleted."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
devices = instance.devices.all()
|
||||
devices.update(is_active=False, expiry_date=None, has_a_pending_payment=False)
|
||||
return super().delete(request, *args, **kwargs)
|
||||
instance.status = "CANCELLED"
|
||||
instance.save()
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class ListWalletTransactionView(StaffEditorPermissionMixin, generics.ListAPIView):
|
||||
serializer_class = WalletTransactionSerializer
|
||||
queryset = WalletTransaction.objects.all().select_related("user")
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = "__all__"
|
||||
filterset_class = WalletTransactionFilter
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser:
|
||||
return queryset
|
||||
return queryset.filter(user=self.request.user)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
all_transations = request.query_params.get(
|
||||
"all_transations", "false"
|
||||
).lower() in [
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
]
|
||||
if (
|
||||
request.user.is_authenticated
|
||||
and getattr(request.user, "is_admin")
|
||||
and bool(all_transations)
|
||||
):
|
||||
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)
|
||||
|
||||
@@ -10,6 +10,7 @@ class DeviceAdmin(admin.ModelAdmin):
|
||||
"user",
|
||||
"mac",
|
||||
"vendor",
|
||||
"expiry_date",
|
||||
"blocked_by",
|
||||
"name",
|
||||
"created_at",
|
||||
|
||||
+13
-3
@@ -1,14 +1,24 @@
|
||||
import django_filters
|
||||
from .models import Device
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class DeviceFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(lookup_expr="icontains")
|
||||
mac = django_filters.CharFilter(lookup_expr="icontains")
|
||||
vendor = django_filters.CharFilter(lookup_expr="icontains")
|
||||
user = django_filters.CharFilter(
|
||||
field_name="user__last_name", lookup_expr="icontains"
|
||||
)
|
||||
user = django_filters.CharFilter(method="filter_user_search")
|
||||
|
||||
def filter_user_search(self, queryset, name, value):
|
||||
"""
|
||||
Search across multiple user fields: first_name, last_name, id_card, mobile
|
||||
"""
|
||||
return queryset.filter(
|
||||
Q(user__first_name__icontains=value)
|
||||
| Q(user__last_name__icontains=value)
|
||||
| Q(user__id_card__icontains=value)
|
||||
| Q(user__mobile__icontains=value)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
|
||||
+4
-2
@@ -1,8 +1,10 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from api.models import User
|
||||
import re
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
user = settings.AUTH_USER_MODEL
|
||||
|
||||
|
||||
def validate_mac_address(value):
|
||||
@@ -38,7 +40,7 @@ class Device(models.Model):
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True, related_name="devices"
|
||||
user, on_delete=models.SET_NULL, null=True, blank=True, related_name="devices"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
+20
-5
@@ -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 = (
|
||||
obj.unpaid_payments[0]
|
||||
if hasattr(obj, "unpaid_payments") and obj.unpaid_payments
|
||||
else None
|
||||
)
|
||||
return unpaid_payment.id if unpaid_payment else None
|
||||
|
||||
def get_user(self, obj):
|
||||
user = obj.user
|
||||
@@ -45,12 +53,19 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
}
|
||||
return None
|
||||
|
||||
class Meta: # type: ignore
|
||||
model = Device
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class AdminDeviceSerializer(serializers.ModelSerializer):
|
||||
pending_payment_id = serializers.SerializerMethodField()
|
||||
|
||||
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()
|
||||
obj.unpaid_payments[0]
|
||||
if hasattr(obj, "unpaid_payments") and obj.unpaid_payments
|
||||
else None
|
||||
)
|
||||
return unpaid_payment.id if unpaid_payment else None
|
||||
|
||||
|
||||
+19
-6
@@ -3,7 +3,9 @@ from xmlrpc.client import Boolean
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.response import Response
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from billing.models import Payment
|
||||
from .models import Device
|
||||
from django.db.models import Prefetch
|
||||
from .serializers import (
|
||||
CreateDeviceSerializer,
|
||||
DeviceSerializer,
|
||||
@@ -28,13 +30,24 @@ class DeviceListCreateAPIView(
|
||||
filterset_fields = "__all__"
|
||||
filterset_class = DeviceFilter
|
||||
|
||||
def get_queryset(self):
|
||||
unpaid_qs = Payment.objects.filter(paid=False).order_by("-created_at")
|
||||
base_qs = Device.objects.select_related("user").prefetch_related(
|
||||
Prefetch("payments", queryset=unpaid_qs, to_attr="unpaid_payments")
|
||||
)
|
||||
return base_qs.all()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
all_devices = request.query_params.get("all_devices", None)
|
||||
all_devices = request.query_params.get("all_devices", "false").lower() in [
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
]
|
||||
if (
|
||||
request.user.is_authenticated
|
||||
and getattr(request.user, "is_admin", False)
|
||||
and all_devices
|
||||
and getattr(request.user, "is_admin")
|
||||
and bool(all_devices)
|
||||
):
|
||||
pass
|
||||
else:
|
||||
@@ -86,7 +99,7 @@ class DeviceDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
|
||||
|
||||
|
||||
class DeviceUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.select_related("user").all()
|
||||
serializer_class = CreateDeviceSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
@@ -112,7 +125,7 @@ class DeviceUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
|
||||
|
||||
class DeviceBlockAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.select_related("user").all()
|
||||
serializer_class = BlockDeviceSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
@@ -150,7 +163,7 @@ class DeviceBlockAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||
|
||||
|
||||
class DeviceDestroyAPIView(StaffEditorPermissionMixin, generics.DestroyAPIView):
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.select_related("user").all()
|
||||
serializer_class = DeviceSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
|
||||
@@ -4,3 +4,17 @@ default:
|
||||
dev:
|
||||
python manage.py runserver
|
||||
python manage.py procrastinate worker
|
||||
|
||||
migrate:
|
||||
python manage.py migrate
|
||||
make-migrations:
|
||||
python manage.py makemigrations
|
||||
|
||||
seed-topups:
|
||||
python manage.py seed_topups --number=50
|
||||
seed-payments:
|
||||
python manage.py seed_payments --number=50
|
||||
|
||||
# TESTS
|
||||
test-billing:
|
||||
python manage.py test billing
|
||||
Reference in New Issue
Block a user