Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3fc48fddc | ||
|
|
bb2d0348c2 | ||
|
|
e2ede37f4f | ||
|
|
6418b1469d | ||
|
|
911d01b8e3 | ||
|
|
1644bd47b9 | ||
|
|
ff897ee2ab | ||
|
|
35384ef049 | ||
|
|
8657435fbf | ||
|
|
39da7607f6 | ||
|
|
d0a8408121 | ||
|
|
c17e34a592 | ||
|
|
cdfa2d9192 | ||
|
|
342e963861 | ||
|
|
4f794571e9 | ||
|
|
6bc2d71a0e | ||
|
|
ceb30025ee | ||
|
|
950f42ae3f | ||
|
|
f10fa74fbb | ||
|
|
60e394fffa
|
||
|
|
193ce850b4 | ||
|
|
27f89b6d3d | ||
|
|
63b1a6b9ef | ||
|
|
3dafc7d4c8 | ||
|
|
8e564f766b |
@@ -39,3 +39,7 @@ jobs:
|
|||||||
docker compose --progress plain down portal-api portal-api-nginx && \
|
docker compose --progress plain down portal-api portal-api-nginx && \
|
||||||
docker compose --progress plain up -d portal-api portal-api-nginx && \
|
docker compose --progress plain up -d portal-api portal-api-nginx && \
|
||||||
docker compose exec portal-api python manage.py migrate"
|
docker compose exec portal-api python manage.py migrate"
|
||||||
|
|
||||||
|
- name: Clean up dangling images
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: ssh root@10.0.1.5 -t "docker image prune -f"
|
||||||
|
|||||||
+2
-2
@@ -29,9 +29,9 @@ def assign_device_permissions(sender, instance, created, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
async def verify_user_with_person_api(sender, instance, created, **kwargs):
|
def verify_user_with_person_api(sender, instance, created, **kwargs):
|
||||||
if created:
|
if created:
|
||||||
await verify_user_with_person_api_task.defer_async(instance.id)
|
verify_user_with_person_api_task(instance.id)
|
||||||
|
|
||||||
|
|
||||||
@receiver(reset_password_token_created)
|
@receiver(reset_password_token_created)
|
||||||
|
|||||||
+19
-7
@@ -5,10 +5,12 @@ from api.notifications import send_sms
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from api.notifications import send_clean_telegram_markdown
|
|
||||||
|
# from api.notifications import send_clean_telegram_markdown
|
||||||
from api.omada import Omada
|
from api.omada import Omada
|
||||||
from apibase.env import env, BASE_DIR
|
from apibase.env import env, BASE_DIR
|
||||||
from procrastinate.contrib.django import app
|
from procrastinate.contrib.django import app
|
||||||
|
from procrastinate import builtin_tasks
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -16,10 +18,21 @@ logger = logging.getLogger(__name__)
|
|||||||
env.read_env(os.path.join(BASE_DIR, ".env"))
|
env.read_env(os.path.join(BASE_DIR, ".env"))
|
||||||
|
|
||||||
|
|
||||||
@app.task
|
@app.periodic(cron="0 * * * *") # every 1 hour
|
||||||
def add(x, y):
|
@app.task(
|
||||||
logger.info(f"Executing test background task with {x} and {y}")
|
queueing_lock="remove_old_jobs",
|
||||||
return x + y
|
pass_context=True,
|
||||||
|
)
|
||||||
|
async def remove_old_jobs(context, timestamp):
|
||||||
|
logger.info("Running remove_old_jobs task...")
|
||||||
|
return await builtin_tasks.remove_old_jobs(
|
||||||
|
context,
|
||||||
|
queue="heavy_tasks",
|
||||||
|
max_hours=1,
|
||||||
|
remove_failed=True,
|
||||||
|
remove_cancelled=True,
|
||||||
|
remove_aborted=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.periodic(
|
@app.periodic(
|
||||||
@@ -68,8 +81,7 @@ def add_new_devices_to_omada(new_devices: list[dict]):
|
|||||||
omada_client.add_new_devices_to_omada(new_devices)
|
omada_client.add_new_devices_to_omada(new_devices)
|
||||||
|
|
||||||
|
|
||||||
@app.task
|
def verify_user_with_person_api_task(user_id: int):
|
||||||
async def verify_user_with_person_api_task(user_id: int):
|
|
||||||
"""
|
"""
|
||||||
Verify the user with the Person API.
|
Verify the user with the Person API.
|
||||||
:param user_id: The ID of the user to verify.
|
:param user_id: The ID of the user to verify.
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from typing import cast, Dict, Any
|
|||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from api.notifications import send_otp
|
from api.notifications import send_otp
|
||||||
from .tasks import add
|
|
||||||
from .utils import check_person_api_verification
|
from .utils import check_person_api_verification
|
||||||
|
|
||||||
# local apps import
|
# local apps import
|
||||||
@@ -60,7 +59,6 @@ class ErrorMessages:
|
|||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
def healthcheck(request):
|
def healthcheck(request):
|
||||||
add.defer(1, 2)
|
|
||||||
return Response({"status": "Good"}, status=status.HTTP_200_OK)
|
return Response({"status": "Good"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ class PaymentAdmin(admin.ModelAdmin):
|
|||||||
"paid_at",
|
"paid_at",
|
||||||
"method",
|
"method",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
"expires_at",
|
||||||
|
"is_expired",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@admin.display(boolean=True, description="Expired")
|
||||||
|
def is_expired(self, obj):
|
||||||
|
return obj.is_expired
|
||||||
|
|
||||||
|
|
||||||
class TopupAdmin(admin.ModelAdmin):
|
class TopupAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
|
|||||||
+28
-1
@@ -1,6 +1,7 @@
|
|||||||
import django_filters
|
import django_filters
|
||||||
from .models import Payment, Topup
|
from .models import Payment, Topup
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
class PaymentFilter(django_filters.FilterSet):
|
class PaymentFilter(django_filters.FilterSet):
|
||||||
@@ -13,6 +14,18 @@ class PaymentFilter(django_filters.FilterSet):
|
|||||||
mib_reference = django_filters.CharFilter(lookup_expr="icontains")
|
mib_reference = django_filters.CharFilter(lookup_expr="icontains")
|
||||||
paid_at = django_filters.DateFromToRangeFilter()
|
paid_at = django_filters.DateFromToRangeFilter()
|
||||||
created_at = django_filters.DateFromToRangeFilter()
|
created_at = django_filters.DateFromToRangeFilter()
|
||||||
|
is_expired = django_filters.BooleanFilter(method="filter_is_expired")
|
||||||
|
|
||||||
|
def filter_is_expired(self, queryset, name, value):
|
||||||
|
"""
|
||||||
|
Filter payments based on whether they are expired or not
|
||||||
|
"""
|
||||||
|
now = timezone.now()
|
||||||
|
queryset = queryset.filter(paid=False)
|
||||||
|
if value:
|
||||||
|
return queryset.filter(expires_at__isnull=False, expires_at__lt=now)
|
||||||
|
else:
|
||||||
|
return queryset.filter(Q(expires_at__isnull=True) | Q(expires_at__gte=now))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Payment
|
model = Payment
|
||||||
@@ -24,6 +37,7 @@ class TopupFilter(django_filters.FilterSet):
|
|||||||
paid = django_filters.BooleanFilter(field_name="paid")
|
paid = django_filters.BooleanFilter(field_name="paid")
|
||||||
user = django_filters.CharFilter(method="filter_user_search")
|
user = django_filters.CharFilter(method="filter_user_search")
|
||||||
created_at = django_filters.DateFromToRangeFilter(field_name="created_at")
|
created_at = django_filters.DateFromToRangeFilter(field_name="created_at")
|
||||||
|
is_expired = django_filters.BooleanFilter(method="filter_is_expired")
|
||||||
|
|
||||||
def filter_user_search(self, queryset, name, value):
|
def filter_user_search(self, queryset, name, value):
|
||||||
"""
|
"""
|
||||||
@@ -36,11 +50,24 @@ class TopupFilter(django_filters.FilterSet):
|
|||||||
| Q(user__mobile__icontains=value)
|
| Q(user__mobile__icontains=value)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def filter_is_expired(self, queryset, name, value):
|
||||||
|
"""
|
||||||
|
Filter topups based on whether they are expired or not
|
||||||
|
"""
|
||||||
|
now = timezone.now()
|
||||||
|
queryset = queryset.filter(paid=False)
|
||||||
|
if value:
|
||||||
|
return queryset.filter(expires_at__isnull=False, expires_at__lt=now)
|
||||||
|
else:
|
||||||
|
return queryset.filter(Q(expires_at__isnull=True) | Q(expires_at__gte=now))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Topup # Assuming Topup is a subclass of Payment
|
model = Topup
|
||||||
fields = [
|
fields = [
|
||||||
"amount",
|
"amount",
|
||||||
"paid",
|
"paid",
|
||||||
|
"status",
|
||||||
"user",
|
"user",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
"is_expired",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.2 on 2025-07-06 15:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("billing", "0011_topup_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="payment",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("PENDING", "Pending"),
|
||||||
|
("PAID", "Paid"),
|
||||||
|
("CANCELLED", "Cancelled"),
|
||||||
|
],
|
||||||
|
default="PENDING",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2 on 2025-07-09 14:50
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("billing", "0012_payment_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="payment",
|
||||||
|
name="expiry_notification_sent",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -23,10 +23,26 @@ class Payment(models.Model):
|
|||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payments")
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payments")
|
||||||
paid_at = models.DateTimeField(null=True, blank=True)
|
paid_at = models.DateTimeField(null=True, blank=True)
|
||||||
method = models.CharField(max_length=255, choices=PAYMENT_TYPES, default="TRANSFER")
|
method = models.CharField(max_length=255, choices=PAYMENT_TYPES, default="TRANSFER")
|
||||||
|
expiry_notification_sent = models.BooleanField(default=False)
|
||||||
expires_at = models.DateTimeField(null=True, blank=True)
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
created_at = models.DateTimeField(default=timezone.now)
|
created_at = models.DateTimeField(default=timezone.now)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
devices = models.ManyToManyField(Device, related_name="payments")
|
devices = models.ManyToManyField(Device, related_name="payments")
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
("PENDING", "Pending"),
|
||||||
|
("PAID", "Paid"),
|
||||||
|
("CANCELLED", "Cancelled"),
|
||||||
|
],
|
||||||
|
default="PENDING",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
if self.expires_at is None:
|
||||||
|
return False
|
||||||
|
return timezone.now() > self.expires_at
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Payment by {self.user}"
|
return f"Payment by {self.user}"
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ from devices.serializers import DeviceSerializer
|
|||||||
|
|
||||||
class PaymentSerializer(serializers.ModelSerializer):
|
class PaymentSerializer(serializers.ModelSerializer):
|
||||||
devices = DeviceSerializer(many=True, read_only=True)
|
devices = DeviceSerializer(many=True, read_only=True)
|
||||||
|
is_expired = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_is_expired(self, obj):
|
||||||
|
return obj.is_expired
|
||||||
|
|
||||||
class Meta: # type: ignore
|
class Meta: # type: ignore
|
||||||
model = Payment
|
model = Payment
|
||||||
@@ -44,6 +48,7 @@ class TopupSerializer(serializers.ModelSerializer):
|
|||||||
"amount",
|
"amount",
|
||||||
"user",
|
"user",
|
||||||
"paid",
|
"paid",
|
||||||
|
"paid_at",
|
||||||
"status",
|
"status",
|
||||||
"mib_reference",
|
"mib_reference",
|
||||||
"is_expired",
|
"is_expired",
|
||||||
|
|||||||
+110
-22
@@ -4,20 +4,26 @@ from django.db import transaction
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from procrastinate.contrib.django import app
|
from procrastinate.contrib.django import app
|
||||||
from api.notifications import send_sms
|
from api.notifications import send_sms
|
||||||
from billing.models import Topup
|
from billing.models import Topup, Payment
|
||||||
from django.utils.timezone import localtime
|
from django.utils.timezone import localtime
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@app.periodic(
|
@app.periodic(
|
||||||
cron="*/30 * * * * *", periodic_id="notify_expired_topups", queue="heavy_tasks"
|
cron="*/1 * * * * *", periodic_id="notify_expired_topups", queue="heavy_tasks"
|
||||||
) # every 30 seconds
|
)
|
||||||
@app.task
|
@app.task
|
||||||
def update_expired_topups(timestamp: int):
|
def update_expired_topups(timestamp: int):
|
||||||
expired_topups_qs = Topup.objects.filter(
|
expired_topups_qs = Topup.objects.filter(
|
||||||
expires_at__lte=timezone.now(), expiry_notification_sent=False
|
expires_at__lte=timezone.now(),
|
||||||
|
expiry_notification_sent=False,
|
||||||
|
paid=False,
|
||||||
).select_related("user")
|
).select_related("user")
|
||||||
|
if not expired_topups_qs.exists():
|
||||||
|
logger.info("No expired topups found.")
|
||||||
|
return {"total_expired_topups": 0}
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
count = expired_topups_qs.count()
|
count = expired_topups_qs.count()
|
||||||
@@ -27,35 +33,117 @@ def update_expired_topups(timestamp: int):
|
|||||||
if topup.user and topup.user.mobile and not topup.expiry_notification_sent:
|
if topup.user and topup.user.mobile and not topup.expiry_notification_sent:
|
||||||
send_sms_task.defer(
|
send_sms_task.defer(
|
||||||
mobile=topup.user.mobile,
|
mobile=topup.user.mobile,
|
||||||
|
type="TOPUP",
|
||||||
amount=topup.amount,
|
amount=topup.amount,
|
||||||
topup_id=str(topup.id),
|
model_id=str(topup.id),
|
||||||
created_at=localtime(topup.created_at).isoformat(),
|
created_at=localtime(topup.created_at).isoformat(),
|
||||||
|
user=f"{topup.user.first_name + ' ' + topup.user.last_name}"
|
||||||
|
if topup.user.last_name and topup.user.first_name
|
||||||
|
else "User",
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# Mark as notified even if we can't send SMS (no mobile number)
|
|
||||||
topup.expiry_notification_sent = True
|
topup.expiry_notification_sent = True
|
||||||
topup.save()
|
topup.save()
|
||||||
|
else:
|
||||||
|
topup.expiry_notification_sent = True
|
||||||
|
topup.save()
|
||||||
|
return
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_expired_topups": count,
|
"total_expired_topups": count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Assuming you have a separate task for sending SMS if you go that route
|
@app.periodic(
|
||||||
|
cron="*/1 * * * * *", periodic_id="notify_expired_payments", queue="heavy_tasks"
|
||||||
|
)
|
||||||
@app.task
|
@app.task
|
||||||
def send_sms_task(mobile: str, amount: float, topup_id: str, created_at: str):
|
def update_expired_payments(timestamp: int):
|
||||||
message = (
|
expired_payments_qs = Payment.objects.filter(
|
||||||
f"Dear {mobile}, \n\nYour topup of {amount} MVR [created at {created_at}] has expired. "
|
expires_at__lte=timezone.now(),
|
||||||
"Please make a new topup to update your wallet. \n\n- SAR Link"
|
expiry_notification_sent=False,
|
||||||
)
|
paid=False,
|
||||||
send_sms(mobile, message)
|
).select_related("user")
|
||||||
logger.info(f"SMS sent to {mobile} for expired topup of {amount} MVR.")
|
if not expired_payments_qs.exists():
|
||||||
|
logger.info("No expired payments found.")
|
||||||
|
return {"total_expired_payments": 0}
|
||||||
|
|
||||||
# Mark the topup as notified after successful SMS sending
|
with transaction.atomic():
|
||||||
|
count = expired_payments_qs.count()
|
||||||
|
logger.info(f"Found {count} payments to expire.")
|
||||||
|
|
||||||
|
for payment in expired_payments_qs:
|
||||||
|
for device in payment.devices.all():
|
||||||
|
device.has_a_pending_payment = False
|
||||||
|
device.save()
|
||||||
|
if (
|
||||||
|
payment.user
|
||||||
|
and payment.user.mobile
|
||||||
|
and not payment.expiry_notification_sent
|
||||||
|
):
|
||||||
|
send_sms_task.defer(
|
||||||
|
mobile=payment.user.mobile,
|
||||||
|
type="PAYMENT",
|
||||||
|
amount=payment.amount,
|
||||||
|
model_id=str(payment.id),
|
||||||
|
created_at=localtime(payment.created_at).isoformat(),
|
||||||
|
user=f"{payment.user.first_name + ' ' + payment.user.last_name}"
|
||||||
|
if payment.user.last_name and payment.user.first_name
|
||||||
|
else "User",
|
||||||
|
)
|
||||||
|
payment.expiry_notification_sent = True
|
||||||
|
payment.save()
|
||||||
|
else:
|
||||||
|
payment.expiry_notification_sent = True
|
||||||
|
payment.save()
|
||||||
|
return
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_expired_payments": count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.task
|
||||||
|
def send_sms_task(
|
||||||
|
user: str,
|
||||||
|
mobile: str,
|
||||||
|
amount: float,
|
||||||
|
model_id: str,
|
||||||
|
created_at: str,
|
||||||
|
type: str = "TOPUP", # Default to TOPUP if not provided,
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
topup = Topup.objects.get(id=topup_id)
|
dt = datetime.fromisoformat(created_at)
|
||||||
topup.expiry_notification_sent = True
|
formatted_date = dt.strftime("%d %b %Y, %I:%M %p")
|
||||||
topup.save()
|
except Exception:
|
||||||
logger.info(f"Marked topup {topup_id} as notified.")
|
formatted_date = created_at
|
||||||
except Topup.DoesNotExist:
|
message: str = ""
|
||||||
logger.error(f"Topup {topup_id} not found when trying to mark as notified.")
|
if type == "TOPUP":
|
||||||
|
message = (
|
||||||
|
f"Dear {user}, \n\nYour topup of {amount} MVR [created at {formatted_date}] has expired. "
|
||||||
|
"Please make a new topup to update your wallet. \n\n- SAR Link"
|
||||||
|
)
|
||||||
|
elif type == "PAYMENT":
|
||||||
|
message = f"Dear {user}, \n\nYour payment of {amount} MVR [created at {formatted_date}] has expired. \n\n- SAR Link"
|
||||||
|
send_sms(mobile, message)
|
||||||
|
logger.info(f"SMS sent to {mobile} for expired {type} of {amount} MVR.")
|
||||||
|
|
||||||
|
if type == "TOPUP":
|
||||||
|
try:
|
||||||
|
topup = Topup.objects.get(id=model_id)
|
||||||
|
topup.expiry_notification_sent = True
|
||||||
|
topup.save()
|
||||||
|
logger.info(f"Marked topup {model_id} as notified.")
|
||||||
|
except Topup.DoesNotExist:
|
||||||
|
logger.error(
|
||||||
|
f"Topup id: {model_id} not found when trying to mark as notified."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
topup = Payment.objects.get(id=model_id)
|
||||||
|
topup.expiry_notification_sent = True
|
||||||
|
topup.save()
|
||||||
|
logger.info(f"Marked payment {model_id} as notified.")
|
||||||
|
except Payment.DoesNotExist:
|
||||||
|
logger.error(
|
||||||
|
f"Payment id: {model_id} not found when trying to mark as notified."
|
||||||
|
)
|
||||||
|
|||||||
+45
-1
@@ -3,7 +3,7 @@ from django.urls import reverse
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from .models import Topup
|
from .models import Topup, Payment
|
||||||
from .serializers import TopupSerializer
|
from .serializers import TopupSerializer
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -205,3 +205,47 @@ class TopupTests(TestCase):
|
|||||||
response = self.client.delete(url, format="json")
|
response = self.client.delete(url, format="json")
|
||||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||||
self.assertEqual(Topup.objects.count(), 0)
|
self.assertEqual(Topup.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.real_user = User.objects.create_user(
|
||||||
|
username="testuser",
|
||||||
|
password="testpassword",
|
||||||
|
first_name=REAL_USER_FIRST_NAME,
|
||||||
|
last_name=REAL_USER_LAST_NAME,
|
||||||
|
acc_no="7770000010629",
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="plskillme",
|
||||||
|
password="modewasgayithink",
|
||||||
|
first_name="mode",
|
||||||
|
last_name="hussain",
|
||||||
|
acc_no="1122334455",
|
||||||
|
)
|
||||||
|
self.admin_user = User.objects.create_superuser(
|
||||||
|
username="adminuser",
|
||||||
|
password="adminpassword",
|
||||||
|
email="admin@example.com",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="User",
|
||||||
|
acc_no="987654321",
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.real_user)
|
||||||
|
|
||||||
|
def test_cancel_payment(self):
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
amount=100.00,
|
||||||
|
user=self.real_user,
|
||||||
|
status="PENDING",
|
||||||
|
number_of_months=1,
|
||||||
|
paid=False,
|
||||||
|
)
|
||||||
|
url = reverse("cancel-payment", kwargs={"pk": payment.pk})
|
||||||
|
response = self.client.patch(url, format="json")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
payment.refresh_from_db()
|
||||||
|
self.assertEqual(payment.status, "CANCELLED")
|
||||||
|
self.assertFalse(payment.paid)
|
||||||
|
|||||||
+4
-4
@@ -5,7 +5,7 @@ from .views import (
|
|||||||
VerifyPaymentView,
|
VerifyPaymentView,
|
||||||
PaymentDetailAPIView,
|
PaymentDetailAPIView,
|
||||||
UpdatePaymentAPIView,
|
UpdatePaymentAPIView,
|
||||||
DeletePaymentView,
|
CancelPaymentView,
|
||||||
ListCreateTopupView,
|
ListCreateTopupView,
|
||||||
VerifyTopupPaymentAPIView,
|
VerifyTopupPaymentAPIView,
|
||||||
TopupDetailAPIView,
|
TopupDetailAPIView,
|
||||||
@@ -21,9 +21,9 @@ urlpatterns = [
|
|||||||
name="update-payment",
|
name="update-payment",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"payment/<str:pk>/delete/",
|
"payment/<str:pk>/cancel/",
|
||||||
DeletePaymentView.as_view(),
|
CancelPaymentView.as_view(),
|
||||||
name="delete-payment",
|
name="cancel-payment",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"payment/<str:pk>/verify/", VerifyPaymentView.as_view(), name="verify-payment"
|
"payment/<str:pk>/verify/", VerifyPaymentView.as_view(), name="verify-payment"
|
||||||
|
|||||||
+68
-37
@@ -28,6 +28,20 @@ PAYMENT_BASE_URL = env("PAYMENT_BASE_URL", default="") # type: ignore
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Transaction:
|
||||||
|
ref: str
|
||||||
|
sourceBank: str
|
||||||
|
trxDate: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PaymentVerificationResponse:
|
||||||
|
message: str
|
||||||
|
success: bool
|
||||||
|
transaction: Optional[Transaction] = None
|
||||||
|
|
||||||
|
|
||||||
class InsufficientFundsError(Exception):
|
class InsufficientFundsError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -52,7 +66,8 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
|
|||||||
number_of_months = data.get("number_of_months")
|
number_of_months = data.get("number_of_months")
|
||||||
device_ids = data.get("device_ids", [])
|
device_ids = data.get("device_ids", [])
|
||||||
print(amount, number_of_months, device_ids)
|
print(amount, number_of_months, device_ids)
|
||||||
|
current_time = timezone.now()
|
||||||
|
expires_at = current_time + timedelta(minutes=10)
|
||||||
for device_id in device_ids:
|
for device_id in device_ids:
|
||||||
device = Device.objects.filter(id=device_id, user=user).first()
|
device = Device.objects.filter(id=device_id, user=user).first()
|
||||||
print("DEVICE", device)
|
print("DEVICE", device)
|
||||||
@@ -77,6 +92,7 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
|
|||||||
number_of_months=number_of_months,
|
number_of_months=number_of_months,
|
||||||
paid=data.get("paid", False),
|
paid=data.get("paid", False),
|
||||||
user=user,
|
user=user,
|
||||||
|
expires_at=expires_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Connect devices to payment
|
# Connect devices to payment
|
||||||
@@ -110,7 +126,7 @@ class UpdatePaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
self.perform_update(serializer)
|
self.perform_update(serializer)
|
||||||
devices.update(
|
devices.update(
|
||||||
is_active=True, expiry_date=device_expire_date, has_a_pending_payment=False
|
is_active=False, expiry_date=device_expire_date, has_a_pending_payment=False
|
||||||
)
|
)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@@ -145,7 +161,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
devices = payment.devices.all()
|
devices = payment.devices.all()
|
||||||
payment_status = False
|
|
||||||
if method == "WALLET":
|
if method == "WALLET":
|
||||||
if user.wallet_balance < payment.amount: # type: ignore
|
if user.wallet_balance < payment.amount: # type: ignore
|
||||||
return Response(
|
return Response(
|
||||||
@@ -153,7 +168,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
payment_status = self.process_wallet_payment(
|
self.process_wallet_payment(
|
||||||
user,
|
user,
|
||||||
payment,
|
payment,
|
||||||
)
|
)
|
||||||
@@ -161,15 +176,12 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
|||||||
data = {
|
data = {
|
||||||
"benefName": f"{user.first_name} {user.last_name}", # type: ignore
|
"benefName": f"{user.first_name} {user.last_name}", # type: ignore
|
||||||
"accountNo": user.acc_no, # type: ignore
|
"accountNo": user.acc_no, # type: ignore
|
||||||
"absAmount": payment.amount,
|
"absAmount": "{:.2f}".format(payment.amount),
|
||||||
"time": localtime(timezone.now() + timedelta(minutes=5)).strftime(
|
"time": localtime(payment.created_at).strftime("%Y-%m-%d %H:%M"),
|
||||||
"%Y-%m-%d %H:%M"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
payment_status = self.verify_transfer_payment(data, payment)
|
payment_verification_response = self.verify_transfer_payment(data, payment)
|
||||||
|
|
||||||
if payment_status:
|
if payment_verification_response.success:
|
||||||
# Update devices
|
|
||||||
expiry_date = timezone.now() + timedelta(days=30 * payment.number_of_months)
|
expiry_date = timezone.now() + timedelta(days=30 * payment.number_of_months)
|
||||||
devices.update(
|
devices.update(
|
||||||
is_active=True,
|
is_active=True,
|
||||||
@@ -177,7 +189,9 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
|||||||
has_a_pending_payment=False,
|
has_a_pending_payment=False,
|
||||||
registered=True,
|
registered=True,
|
||||||
)
|
)
|
||||||
# Need to add to omada if its a new device and not an existing device
|
payment.status = "PAID"
|
||||||
|
payment.save()
|
||||||
|
# add to omada if its a new device and not an existing device
|
||||||
device_list = []
|
device_list = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
device_list.append(
|
device_list.append(
|
||||||
@@ -193,12 +207,22 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
|||||||
device.save()
|
device.save()
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"message": f"Payment verified successfully using [{method}]."},
|
{
|
||||||
|
"status": payment_verification_response.success,
|
||||||
|
"message": payment_verification_response.message,
|
||||||
|
"transaction": asdict(payment_verification_response.transaction)
|
||||||
|
if payment_verification_response.transaction
|
||||||
|
else None,
|
||||||
|
},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return Response(
|
return Response(
|
||||||
{"message": f"Payment verification FAILED using [{method}]."},
|
{
|
||||||
|
"status": payment_verification_response.success,
|
||||||
|
"message": payment_verification_response.message
|
||||||
|
or "Topup payment verification failed.",
|
||||||
|
},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -215,7 +239,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
|||||||
user.save()
|
user.save()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def verify_transfer_payment(self, data, payment):
|
def verify_transfer_payment(self, data, payment) -> PaymentVerificationResponse:
|
||||||
if not PAYMENT_BASE_URL:
|
if not PAYMENT_BASE_URL:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"PAYMENT_BASE_URL is not set. Please set it in your environment variables."
|
"PAYMENT_BASE_URL is not set. Please set it in your environment variables."
|
||||||
@@ -230,41 +254,62 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
logger.error(f"HTTPError: {e}")
|
logger.error(f"HTTPError: {e}")
|
||||||
return False
|
return PaymentVerificationResponse(
|
||||||
|
message="Payment verification failed.", success=False, transaction=None
|
||||||
|
)
|
||||||
mib_resp = response.json()
|
mib_resp = response.json()
|
||||||
logger.info("MIB Verification Response ->", mib_resp)
|
logger.info("MIB Verification Response ->", mib_resp)
|
||||||
if not response.json().get("success"):
|
if not response.json().get("success"):
|
||||||
return mib_resp["success"]
|
return PaymentVerificationResponse(
|
||||||
|
message=mib_resp["message"],
|
||||||
|
success=mib_resp["success"],
|
||||||
|
transaction=None,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
payment.paid = True
|
payment.paid = True
|
||||||
payment.paid_at = timezone.now()
|
payment.paid_at = timezone.now()
|
||||||
payment.method = "TRANSFER"
|
payment.method = "TRANSFER"
|
||||||
payment.mib_reference = mib_resp["transaction"]["ref"] or ""
|
payment.mib_reference = mib_resp["transaction"]["ref"] or ""
|
||||||
payment.save()
|
payment.save()
|
||||||
return True
|
return PaymentVerificationResponse(
|
||||||
|
message=mib_resp["message"],
|
||||||
|
success=mib_resp["success"],
|
||||||
|
transaction=Transaction(
|
||||||
|
ref=payment.mib_reference,
|
||||||
|
sourceBank=mib_resp["transaction"]["sourceBank"],
|
||||||
|
trxDate=mib_resp["transaction"]["trxDate"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeletePaymentView(StaffEditorPermissionMixin, generics.DestroyAPIView):
|
class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||||
queryset = Payment.objects.all()
|
queryset = Payment.objects.all()
|
||||||
serializer_class = PaymentSerializer
|
serializer_class = PaymentSerializer
|
||||||
lookup_field = "pk"
|
lookup_field = "pk"
|
||||||
|
|
||||||
def delete(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
user = request.user
|
user = request.user
|
||||||
|
if instance.status == "CANCELLED":
|
||||||
|
return Response(
|
||||||
|
{"message": "Payment has already been cancelled."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
if instance.user != user and not user.is_superuser:
|
if instance.user != user and not user.is_superuser:
|
||||||
return Response(
|
return Response(
|
||||||
{"message": "You are not authorized to delete this payment."},
|
{"message": "You are not authorized to cancel this payment."},
|
||||||
status=status.HTTP_403_FORBIDDEN,
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
)
|
)
|
||||||
if instance.paid:
|
if instance.paid:
|
||||||
return Response(
|
return Response(
|
||||||
{"message": "Paid payments cannot be deleted."},
|
{"message": "Paid payments cannot be cancelled."},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
devices = instance.devices.all()
|
devices = instance.devices.all()
|
||||||
|
instance.status = "CANCELLED"
|
||||||
|
instance.save()
|
||||||
devices.update(is_active=False, expiry_date=None, has_a_pending_payment=False)
|
devices.update(is_active=False, expiry_date=None, has_a_pending_payment=False)
|
||||||
return super().delete(request, *args, **kwargs)
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
|
class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
|
||||||
@@ -308,20 +353,6 @@ class TopupDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
|
|||||||
return queryset.filter(user=self.request.user)
|
return queryset.filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Transaction:
|
|
||||||
ref: str
|
|
||||||
sourceBank: str
|
|
||||||
trxDate: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PaymentVerificationResponse:
|
|
||||||
message: str
|
|
||||||
success: bool
|
|
||||||
transaction: Optional[Transaction] = None
|
|
||||||
|
|
||||||
|
|
||||||
class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
|
||||||
queryset = Topup.objects.all()
|
queryset = Topup.objects.all()
|
||||||
serializer_class = TopupSerializer
|
serializer_class = TopupSerializer
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class DeviceAdmin(admin.ModelAdmin):
|
|||||||
"user",
|
"user",
|
||||||
"mac",
|
"mac",
|
||||||
"vendor",
|
"vendor",
|
||||||
|
"expiry_date",
|
||||||
"blocked_by",
|
"blocked_by",
|
||||||
"name",
|
"name",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
|||||||
+9
-10
@@ -31,8 +31,16 @@ class BlockDeviceSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class DeviceSerializer(serializers.ModelSerializer):
|
class DeviceSerializer(serializers.ModelSerializer):
|
||||||
pending_payment_id = serializers.SerializerMethodField()
|
|
||||||
user = serializers.SerializerMethodField()
|
user = serializers.SerializerMethodField()
|
||||||
|
pending_payment_id = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_pending_payment_id(self, obj):
|
||||||
|
unpaid_payment = (
|
||||||
|
Payment.objects.filter(devices=obj, paid=False)
|
||||||
|
.order_by("-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return unpaid_payment.id if unpaid_payment else None
|
||||||
|
|
||||||
def get_user(self, obj):
|
def get_user(self, obj):
|
||||||
user = obj.user
|
user = obj.user
|
||||||
@@ -45,15 +53,6 @@ class DeviceSerializer(serializers.ModelSerializer):
|
|||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_pending_payment_id(self, obj):
|
|
||||||
# Query the last unpaid payment for the device
|
|
||||||
unpaid_payment = (
|
|
||||||
Payment.objects.filter(devices=obj, paid=False)
|
|
||||||
.order_by("-created_at")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return unpaid_payment.id if unpaid_payment else None
|
|
||||||
|
|
||||||
class Meta: # type: ignore
|
class Meta: # type: ignore
|
||||||
model = Device
|
model = Device
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|||||||
Reference in New Issue
Block a user