diff --git a/api/notifications.py b/api/notifications.py index 8329426..1e30bca 100644 --- a/api/notifications.py +++ b/api/notifications.py @@ -59,9 +59,13 @@ def send_sms(mobile: str, message: str): return False -# def escape_markdown_v2(text: str) -> str: -# special_chars = r"\_*[]()~`>#+-=|{}.!" -# return "".join(["\\" + c if c in special_chars else c for c in text]) +def escape_markdown_v2(text: str) -> str: + special_chars = r"\~`>#+-=|{}!" + return "".join(["\\" + c if c in special_chars else c for c in text]) + + +def normalize_whitespace(text: str) -> str: + return "\n".join(line.strip() for line in text.strip().splitlines()) def send_telegram_markdown(message: str): @@ -83,3 +87,9 @@ def send_telegram_markdown(message: str): except requests.RequestException as e: logger.error(f"Error sending Telegram message: {e}") return {"error": str(e)} + + +def send_clean_telegram_markdown(message: str): + text = normalize_whitespace(message) + escaped = escape_markdown_v2(text) + return send_telegram_markdown(escaped) diff --git a/api/tasks.py b/api/tasks.py index 6743b6e..559ea4f 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -8,7 +8,7 @@ import os import logging from celery import shared_task from django.utils import timezone -from api.notifications import send_telegram_markdown +from api.notifications import send_clean_telegram_markdown logger = logging.getLogger(__name__) @@ -161,15 +161,15 @@ def verify_user_with_person_api_task(user_id: int): verification_failed_message = f""" _The following user verification failed_: - *ID Card:* {user.id_card} \n - *Name:* {user.first_name} {user.last_name} \n - *House Name:* {user.address} \n - *Date of Birth:* {user.dob} \n - *Island:* {(user.atoll.name if user.atoll else "N/A")} {(user.island.name if user.island else "N/A")} \n - *Mobile:* {user.mobile} \n - + *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) if not PERSON_VERIFY_BASE_URL: raise ValueError( @@ -245,7 +245,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_telegram_markdown(message=verification_failed_message) + send_clean_telegram_markdown(message=verification_failed_message) return False else: # Handle the error case diff --git a/billing/admin.py b/billing/admin.py index 75cd10f..db6311c 100644 --- a/billing/admin.py +++ b/billing/admin.py @@ -9,6 +9,7 @@ class PaymentAdmin(admin.ModelAdmin): "id", "user", "amount", + "number_of_months", "paid", "paid_at", "method", diff --git a/devices/admin.py b/devices/admin.py index 79ca54d..b45fe7a 100644 --- a/devices/admin.py +++ b/devices/admin.py @@ -3,4 +3,20 @@ from django.contrib import admin # Register your models here. from .models import Device -admin.site.register(Device) + +class DeviceAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "mac", + "vendor", + "blocked_by", + "name", + "created_at", + "updated_at", + ) + search_fields = ("mac", "name") + list_filter = ("user",) + + +admin.site.register(Device, DeviceAdmin) diff --git a/devices/migrations/0007_device_vendor.py b/devices/migrations/0007_device_vendor.py new file mode 100644 index 0000000..971fd65 --- /dev/null +++ b/devices/migrations/0007_device_vendor.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2 on 2025-06-01 13:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("devices", "0006_alter_device_mac"), + ] + + operations = [ + migrations.AddField( + model_name="device", + name="vendor", + field=models.CharField(blank=True, default="", max_length=255, null=True), + ), + ] diff --git a/devices/migrations/0008_alter_device_blocked_by.py b/devices/migrations/0008_alter_device_blocked_by.py new file mode 100644 index 0000000..a901d05 --- /dev/null +++ b/devices/migrations/0008_alter_device_blocked_by.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2 on 2025-06-01 14:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("devices", "0007_device_vendor"), + ] + + operations = [ + migrations.AlterField( + model_name="device", + name="blocked_by", + field=models.CharField( + blank=True, + choices=[("ADMIN", "Admin"), ("PARENT", "Parent")], + default=None, + max_length=255, + null=True, + ), + ), + ] diff --git a/devices/models.py b/devices/models.py index ce12563..343361e 100644 --- a/devices/models.py +++ b/devices/models.py @@ -21,6 +21,7 @@ class Device(models.Model): validate_mac_address, ], ) + vendor = models.CharField(max_length=255, null=True, blank=True, default="") has_a_pending_payment = models.BooleanField(default=False) reason_for_blocking = models.CharField(max_length=255, null=True, blank=True) is_active = models.BooleanField(default=False) @@ -29,7 +30,9 @@ class Device(models.Model): blocked_by = models.CharField( max_length=255, choices=[("ADMIN", "Admin"), ("PARENT", "Parent")], - default="PARENT", + default=None, + blank=True, + null=True, ) expiry_date = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(default=timezone.now) diff --git a/devices/views.py b/devices/views.py index 675006f..7bc4701 100644 --- a/devices/views.py +++ b/devices/views.py @@ -1,3 +1,4 @@ +from attr import dataclass from rest_framework import generics, status from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend @@ -11,6 +12,8 @@ from .serializers import ( from api.mixins import StaffEditorPermissionMixin from .filters import DeviceFilter import re +import requests +from decouple import config class DeviceListCreateAPIView( @@ -26,7 +29,6 @@ class DeviceListCreateAPIView( def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) - # Filter devices by the logged-in user unless the user is a superuser if not request.user.is_superuser: queryset = queryset.filter(user=request.user) @@ -43,24 +45,31 @@ class DeviceListCreateAPIView( return CreateDeviceSerializer return DeviceSerializer - # @method_decorator(cache_page(10)) def create(self, request, *args, **kwargs): mac = request.data.get("mac", None) - if not re.match(r"^([0-9A-Fa-f]{2}([.:-]?)){5}[0-9A-Fa-f]{2}$", mac): + MAC_REGEX = re.compile(r"^([0-9A-Fa-f]{2}([.:-]?)){5}[0-9A-Fa-f]{2}$") + NORMALIZE_MAC_REGEX = re.compile(r"[^0-9A-Fa-f]") + if not isinstance(mac, str) or not MAC_REGEX.match(mac): return Response({"message": "Invalid mac address."}, status=400) if Device.objects.filter(mac=mac).exists(): return Response( {"message": "Device with this mac address already exists."}, status=400 ) + mac_details = get_mac_address_details(mac) + if mac_details.vendor == "Unknown": + return Response({"message": "MAC address vendor not found."}, status=400) - # Normalize MAC address to use "-" as separators - mac = re.sub(r"[^0-9A-Fa-f]", "-", mac).upper() + mac = re.sub(NORMALIZE_MAC_REGEX, "-", mac).upper() request.data["mac"] = mac return super().create(request, *args, **kwargs) def perform_create(self, serializer): - serializer.save(user=self.request.user) + mac_details = get_mac_address_details(serializer.validated_data.get("mac")) + serializer.save( + user=self.request.user, + vendor=mac_details.vendor, + ) class DeviceDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView): @@ -138,3 +147,26 @@ class DeviceDestroyAPIView(StaffEditorPermissionMixin, generics.DestroyAPIView): {"message": f"Device '{device_name}' deleted."}, status=status.HTTP_200_OK, ) + + +@dataclass +class MacResponse: + mac_address: str + vendor: str + detail: str | None = None + + +def get_mac_address_details(mac: str) -> MacResponse: + API_URL = config("MACVENDOR_API_URL") + if not API_URL: + raise ValueError("MACVENDOR API URL Not set. Please set it.") + response = requests.get(f"{API_URL}/lookup/{mac}") + json_data = response.json() + if response.status_code == 200: + return MacResponse( + mac_address=json_data.get("mac_address", mac), + vendor=json_data.get("vendor", ""), + detail=json_data.get("detail"), + ) + else: + return MacResponse(mac_address=mac, vendor="Unknown", detail=None)