Author SHA1 Message Date
shihaam 3397f212b5 handle payment erros better
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 6s
2026-08-02 15:11:46 +05:00
shihaam 1ea2f9f4c2 update sign up notifications
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 9s
2026-08-02 14:54:00 +05:00
shihaam 1f5e60dbb4 remove more email related code
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 8s
2026-08-02 14:32:10 +05:00
shihaam 00d25698c6 remove email related code
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 9s
2026-08-02 14:25:41 +05:00
shihaam 6cdf042f49 add support for group topics tg notifications
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 8s
2026-08-02 06:40:11 +05:00
shihaam 2db0369d7c update sms api for smspipe
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 9s
2026-08-02 06:04:20 +05:00
shihaam c414116ed5 update env example
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 12s
2026-08-02 05:59:19 +05:00
shihaam 4bf75fdfe4 update compose for monorepo
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 28s
2026-08-02 05:10:30 +05:00
i701 c56140011f refactor: add src_bank attribute to Payment and Topup model 🔨 2025-09-20 21:03:43 +05:00
i701 4714b6ec15 move registration sms to view from signals and fix include User verified
attribute 🐛
2025-09-15 21:33:59 +05:00
i701 6ae56774d1 remove unnessary return in user verification function 🔨 2025-09-14 22:35:23 +05:00
i701 bfc3fd1b89 fix temporary user id passing to user verification 🐛 2025-09-14 21:51:31 +05:00
i701 9721585f8a refactor(view): extract validation logic from CreateTemporaryUserView to
reduce cyclomatic complexity 🔨
2025-09-14 21:51:05 +05:00
i701 b0936cd489 fix(signals): change post_save receiver to TemporaryUser for user verification 2025-08-02 14:17:06 +05:00
i701 fc1aba3239 fix(user): update verification status field to t_verified in verify_user_with_person_api_task 2025-08-02 13:56:27 +05:00
i701 85485ae351 feat(user): update user verification process to utilize TemporaryUser model and include verification status in response 2025-08-02 13:38:47 +05:00
i701 64bba25fb9 feat(user): enhance user filtering logic to support combined ID card and mobile queries 2025-07-31 19:23:12 +05:00
i701 fbc8a17e6a fix(user): remove agreement file validation from UserUpdateAPIView 🐛 2025-07-28 09:39:02 +05:00
i701 36160c2665 feat(user): remove agreement field from UserUpdateSerializer 2025-07-27 22:57:44 +05:00
i701 f6afb3b658 feat(permissions): add user read-only permission assignment on user creation 2025-07-27 22:28:12 +05:00
i701 9c082aedf2 feat(telegram): implement asynchronous Telegram alert system and enhance user verification messaging 2025-07-27 21:56:59 +05:00
i701 2bc594da9c feat(permissions): add wallet transaction permissions assignment on user creation 2025-07-27 20:13:40 +05:00
i701 19321da0be feat(admin): enhance AdminTopupCreateView to support custom top-up descriptions 2025-07-27 19:55:22 +05:00
i701 e3c2d4450f feat(billing): add calculate_total_new_price utility for dynamic payment calculation 2025-07-27 15:17:47 +05:00
i701 ee54386fd5 feat(devices): enhance device naming to include user details and enforce name length limit 2025-07-27 14:59:08 +05:00
i701 b52cd9285a fix(devices): update DeviceBlockAPIView to handle omada_client response correctly 🐛 2025-07-27 14:32:16 +05:00
shihaam d0c809489c fix nginx path 2025-07-27 13:23:14 +05:00
i701 80fc27fd74 feat(topup): add payment_type field and update AdminTopupCreateView to handle payment type 2025-07-27 12:17:12 +05:00
i701 cdef5ed27c feat(admin): add AdminTopupCreateView for admin top-up functionality 2025-07-27 10:50:40 +05:00
i701 3e7a74950e fix(tasks): add device activation/deactivation & blocking/unblocking for background task 🐛 2025-07-26 20:33:26 +05:00
i701 72e0cd1fba fix(payment): update wallet payment processing to activate devices and set expiry date and add further optimizations 🐛 2025-07-25 23:46:59 +05:00
Abdulla Aidhaan a4b6f44348 Merge pull request #18 from i701/chore/optimize-queries
fix(views): optimize database queries to solve N+1 problems 🔨🐛
2025-07-25 23:24:00 +05:00
shihaam a46f2635ad serve media 2025-07-25 16:07:22 +05:00
35 changed files with 942 additions and 635 deletions
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Source is bind-mounted at runtime; (re)install deps on start so
# requirements.txt changes are picked up without a rebuild, then run the
# Django autoreloading dev server.
CMD pip install --no-cache-dir -r requirements.txt \
&& python manage.py migrate \
&& python manage.py runserver 0.0.0.0:5000
-13
View File
@@ -1,13 +0,0 @@
FROM python:3.11.4-slim-buster
WORKDIR /var/www/html/
COPY . .
RUN pip install -r requirements.txt
RUN python3 manage.py collectstatic
VOLUME /var/www/html/staticfiles/
CMD gunicorn apibase.wsgi:application --bind 0.0.0.0:5000 --workers=4
-13
View File
@@ -1,13 +0,0 @@
services:
api:
build:
context: ../../
dockerfile: .build/prod/api.Dockerfile
hostname: sarlink-portal-api
image: git.shihaam.dev/sarlink/sarlink-portal-api/api
nginx:
build:
context: .
dockerfile: ./nginx.Dockerfile
hostname: sarlink-portal-api-nginx
image: git.shihaam.dev/sarlink/sarlink-portal-api/nginx
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
if [ "$DATABASE" = "postgres" ]
then
echo "Waiting for postgres..."
while ! nc -z $POSTGRES_HOST $POSTGRES_PORT; do
sleep 0.1
done
echo "PostgreSQL started"
fi
exec "$@"
-11
View File
@@ -1,11 +0,0 @@
FROM nginx
# Install basic tools
RUN apt update \
&& apt install curl nano iputils-ping zip unzip -y --no-install-recommends \
&& apt auto-remove -y \
&& apt clean -y
COPY nginx.conf /etc/nginx/conf.d/default.conf
WORKDIR /etc/nginx
-22
View File
@@ -1,22 +0,0 @@
server {
listen 80;
server_name _;
access_log /dev/stdout;
error_log /dev/stdout info;
# Serve static files
location /static/ {
alias /var/www/html/staticfiles/;
}
# Forward requests to Gunicorn
location / {
proxy_pass http://portal-api:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 6M;
}
}
+59 -19
View File
@@ -1,26 +1,66 @@
CSRF_ALLOWED_HOST=""
DJANGO_DEBUG=
# =============================================================================
# Django
# =============================================================================
SECRET_KEY=""
POSTGRES_DATABASE=
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_HOST=
POSTGRES_PORT=
# DEBUG defaults to True if unset. Set False in production.
DJANGO_DEBUG=True
# Comma-separated. Include the backend service name for the compose network.
ALLOWED_HOSTS="localhost,127.0.0.1,backend"
# --- Only read when DJANGO_DEBUG=False (production) ---
DJANGO_SECURE_SSL_REDIRECT=
ALLOWED_HOSTS=""
SECURE_HSTS_SECONDS=
# Comma-separated absolute origins, e.g. https://portal.sarlink.net
CSRF_TRUSTED_ORIGINS=""
CSRF_COOKIE_DOMAIN=""
EMAIL_HOSTNAME=""
EMAIL_PORT=
EMAIL_USERNAME=
EMAIL_PASSWORD=
# =============================================================================
# Database (PostgreSQL) — matches the `database` service in compose.yml
# =============================================================================
POSTGRES_DATABASE=sarlink
POSTGRES_USER=sarlink
POSTGRES_PASSWORD=changeme
POSTGRES_HOST=database
POSTGRES_PORT=5432
# =============================================================================
# Redis — currently optional (CACHES is disabled in settings.py)
# =============================================================================
REDIS_HOST=redis
OMADA_PROXY_URL=""
PAYMENT_BASE_URL=""
PERSON_VERIFY_BASE_URL=""
FRONTEND_URL=""
SMS_API_KEY=""
# =============================================================================
# SMS gateway — OTP + notifications
# =============================================================================
SMS_API_URL=""
SMS_API_KEY=""
# =============================================================================
# ID / person verification — GET {PERSON_VERIFY_BASE_URL}/api/person/{id_card}
# =============================================================================
PERSON_VERIFY_BASE_URL=""
# =============================================================================
# MIB payments — POST {PAYMENT_BASE_URL}/verify-payment
# =============================================================================
PAYMENT_BASE_URL=""
# =============================================================================
# Omada (TP-Link) proxy — device access control (block/unblock)
# =============================================================================
OMADA_PROXY_URL=""
OMADA_PROXY_API_KEY=""
OMADA_SITE_ID=""
OMADA_GROUP_ID=""
# =============================================================================
# MAC vendor lookup — REQUIRED, no default: the "add device" flow errors if unset
# =============================================================================
MACVENDOR_API_URL=""
# =============================================================================
# Telegram bot — admin alerts (optional; falls back to no-op if unset)
# =============================================================================
TG_BOT_TOKEN=""
TG_CHAT_ID=""
# Optional forum topic id. If empty/unset, message_thread_id is omitted and
# messages post to the group's General topic.
TG_TOPIC_ID=""
-17
View File
@@ -1,17 +0,0 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
class EmailBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
UserModel = get_user_model()
if not password:
return None
try:
user = UserModel.objects.get(email=username)
except UserModel.DoesNotExist:
return None
else:
if user.check_password(password):
return user
return None
+95
View File
@@ -0,0 +1,95 @@
import asyncio
import threading
import logging
import time
from telegram import Bot
from telegram.constants import ParseMode
from decouple import config
import re
logger = logging.getLogger(__name__)
telegram_loop = None
BOT_TOKEN = config("TG_BOT_TOKEN", default="killme", cast=str)
CHAT_ID = config("TG_CHAT_ID", default="drake", cast=str)
# Optional forum topic to post into. If empty, messages go to the group's General topic.
TOPIC_ID = config("TG_TOPIC_ID", default="", cast=str)
if not BOT_TOKEN or not isinstance(BOT_TOKEN, str):
raise ValueError(
"TG_BOT_TOKEN environment variable must be set and must be a string."
)
if not CHAT_ID:
raise ValueError(
"TG_CHAT_ID environment variable must be set and must be a string."
)
bot = Bot(token=BOT_TOKEN)
def telegram_worker():
"""
Run the event loop for Telegram in a separate daemon thread.
"""
global telegram_loop
telegram_loop = asyncio.new_event_loop()
asyncio.set_event_loop(telegram_loop)
try:
logger.info("Telegram loop started.")
telegram_loop.run_forever()
except Exception as e:
logger.exception(f"Telegram worker crashed! {e}", exc_info=True)
finally:
telegram_loop.close()
# Start the Telegram worker thread when the module is loaded
telegram_thread = threading.Thread(target=telegram_worker, daemon=True)
telegram_thread.start()
# Wait until telegram_loop is ready
timeout = 5
for _ in range(timeout * 10): # up to 5 seconds
if telegram_loop is not None:
break
time.sleep(0.1)
else:
logger.error("Telegram loop failed to initialize in time.")
async def send_telegram_alert(markdown_message: str):
logger.info("[TELEGRAM] Preparing to send alert...")
kwargs = {}
if TOPIC_ID:
kwargs["message_thread_id"] = int(TOPIC_ID)
await bot.send_message(
chat_id=str(CHAT_ID),
text=markdown_message,
parse_mode=ParseMode.MARKDOWN_V2,
**kwargs,
)
async def send_telegram_photo(photo, markdown_caption: str):
"""Send a photo. `photo` may be raw bytes / a file-like object (uploaded
directly to Telegram) or a public URL string."""
logger.info("[TELEGRAM] Preparing to send photo...")
kwargs = {}
if TOPIC_ID:
kwargs["message_thread_id"] = int(TOPIC_ID)
await bot.send_photo(
chat_id=str(CHAT_ID),
photo=photo,
caption=markdown_caption,
parse_mode=ParseMode.MARKDOWN_V2,
# Uploading media is slower than a text send; the defaults (5s) time out.
connect_timeout=15,
read_timeout=30,
write_timeout=60,
**kwargs,
)
def escape_markdown_v2(text: str) -> str:
escape_chars = r"_~`>#+-=|{}.!\\"
return re.sub(f"([{re.escape(escape_chars)}])", r"\\\1", text)
+83
View File
@@ -0,0 +1,83 @@
from rest_framework.response import Response
from typing import Optional
from datetime import date
from django.utils import timezone
import re
ID_CARD_PATTERN = r"^[A-Z]{1,2}[0-9]{6,7}$"
MOBILE_PATTERN = r"^[7|9][0-9]{6}$"
ACCOUNT_NUMBER_PATTERN = r"^(7\d{12}|9\d{16})$"
class ErrorMessages:
USERNAME_EXISTS = "Username already exists."
MOBILE_EXISTS = "Mobile number already exists."
INVALID_ID_CARD = "Please enter a valid ID card number."
ID_CARD_EXISTS = "ID card already exists."
INVALID_MOBILE = "Please enter a valid mobile number."
INVALID_ACCOUNT = "Please enter a valid account number."
UNDERAGE_ERROR = "You must be 18 and above to signup."
def validate_required_fields(data) -> Optional[Response]:
required_fields = {
"firstname": "First name",
"lastname": "Last name",
"username": "Username",
"address": "Address",
"mobile": "Mobile number",
"acc_no": "Account number",
"id_card": "ID card",
"dob": "Date of birth",
"atoll": "Atoll",
"island": "Island",
}
for field, label in required_fields.items():
if not data.get(field):
return Response({"message": f"{label} is required."}, status=400)
if data.get("terms_accepted") is None:
return Response({"message": "Terms acceptance is required."}, status=400)
if data.get("policy_accepted") is None:
return Response({"message": "Policy acceptance is required."}, status=400)
return None
from .models import TemporaryUser, User
def validate_unique_fields(username, mobile, id_card) -> Optional[Response]:
if mobile and (TemporaryUser.objects.filter(t_mobile=mobile).exists() or User.objects.filter(mobile=mobile).exists()):
return Response({"message": ErrorMessages.MOBILE_EXISTS}, status=400)
if username and (TemporaryUser.objects.filter(t_username=username).exists() or User.objects.filter(username=username).exists()):
return Response({"message": ErrorMessages.USERNAME_EXISTS}, status=400)
if id_card and (TemporaryUser.objects.filter(t_id_card=id_card).exists() or User.objects.filter(id_card=id_card).exists()):
return Response({"message": ErrorMessages.ID_CARD_EXISTS}, status=400)
return None
def validate_patterns(id_card, mobile, acc_no) -> Optional[Response]:
if id_card and not re.match(ID_CARD_PATTERN, id_card):
return Response({"message": ErrorMessages.INVALID_ID_CARD}, status=400)
if mobile is None or not re.match(MOBILE_PATTERN, mobile):
return Response({"message": ErrorMessages.INVALID_MOBILE}, status=400)
if acc_no is None or not re.match(ACCOUNT_NUMBER_PATTERN, acc_no):
return Response({"message": ErrorMessages.INVALID_ACCOUNT}, status=400)
return None
def calculate_age(dob: date) -> int:
today = timezone.now().date()
return today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
View File
View File
+29
View File
@@ -0,0 +1,29 @@
# api/management/commands/seed.py
from django.core.management.base import BaseCommand
from api.models import Atoll, Island
class Command(BaseCommand):
help = "Seeds baseline reference data (atolls and islands) required for sign up."
def handle(self, *args, **options):
# Atoll name must match the person-verify API's `atoll_en` (the atoll
# code letter, e.g. "F" for Faafu) or user verification fails.
atoll, atoll_created = Atoll.objects.get_or_create(name="F")
self.stdout.write(
self.style.SUCCESS(f"Created atoll: {atoll.name}")
if atoll_created
else self.style.NOTICE(f"Atoll already exists: {atoll.name}")
)
island, island_created = Island.objects.get_or_create(
name="Dharanboodhoo", defaults={"atoll": atoll}
)
self.stdout.write(
self.style.SUCCESS(f"Created island: {island.name} ({atoll.name})")
if island_created
else self.style.NOTICE(f"Island already exists: {island.name}")
)
self.stdout.write(self.style.SUCCESS("Seeding complete."))
+4
View File
@@ -75,6 +75,10 @@ class User(AbstractUser):
objects = CustomUserManager()
# Log in with username + password only; email is optional and not prompted
# by createsuperuser.
REQUIRED_FIELDS = []
class TemporaryUser(models.Model):
t_id = models.AutoField(primary_key=True)
+20 -8
View File
@@ -8,6 +8,18 @@ api_url = str(config("SMS_API_URL", cast=str, default=""))
api_key = str(config("SMS_API_KEY", cast=str, default=""))
bot_token = str(config("TG_BOT_TOKEN", cast=str, default=""))
chat_id = str(config("TG_CHAT_ID", cast=str, default=""))
# Optional forum topic to post into. If empty, messages go to the group's General topic.
topic_id = str(config("TG_TOPIC_ID", cast=str, default=""))
def format_mobile(mobile: str) -> str:
"""Normalize a Maldives mobile number to E.164 (+960XXXXXXX)."""
number = str(mobile).strip().replace(" ", "")
if number.startswith("+"):
return number
if number.startswith("960"):
return "+" + number
return "+960" + number
def send_otp(mobile: str, message: str):
@@ -17,12 +29,11 @@ def send_otp(mobile: str, message: str):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"X-API-Key": api_key,
}
data = {
"number": mobile,
"message": message,
"check_delivery": False,
"to": format_mobile(mobile),
"text": message,
}
response = requests.post(api_url, headers=headers, data=json.dumps(data))
if response.status_code == 200:
@@ -41,12 +52,11 @@ def send_sms(mobile: str, message: str):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"X-API-Key": api_key,
}
data = {
"number": mobile,
"message": message,
"check_delivery": False,
"to": format_mobile(mobile),
"text": message,
}
response = requests.post(api_url, headers=headers, data=json.dumps(data))
if response.status_code == 200:
@@ -80,6 +90,8 @@ def send_telegram_markdown(message: str):
try:
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {"chat_id": chat_id, "text": message, "parse_mode": "MarkdownV2"}
if topic_id:
payload["message_thread_id"] = topic_id
response = requests.post(url, data=payload)
response.raise_for_status()
+1 -2
View File
@@ -45,7 +45,6 @@ class UserUpdateSerializer(serializers.ModelSerializer):
"dob",
"atoll",
"island",
"agreement",
)
@@ -160,7 +159,7 @@ class UserSerializer(serializers.ModelSerializer):
extra_kwargs = {"password": {"write_only": True, "min_length": 5}}
def create(self, validated_data):
return User.objects.create_user(**validated_data)
return User.objects.create_user(**validated_data) #type: ignore
class AuthSerializer(serializers.Serializer):
+13 -49
View File
@@ -1,10 +1,6 @@
from django.core.mail import EmailMultiAlternatives
from django.dispatch import receiver
from django.template.loader import render_to_string
from decouple import config
from django_rest_passwordreset.signals import reset_password_token_created
from django.db.models.signals import post_save
from api.models import User
from api.models import User, TemporaryUser
from django.contrib.auth.models import Permission
from api.tasks import verify_user_with_person_api_task
@@ -21,7 +17,13 @@ def assign_device_permissions(sender, instance, created, **kwargs):
topup_permissions = Permission.objects.filter(
content_type__model="topup"
).exclude(codename__startswith="delete_")
wallet_transaction_permissions = Permission.objects.filter(
content_type__model="wallettransaction"
).exclude(codename__startswith="delete_")
user_read_only_permission = Permission.objects.get(
codename="view_user", content_type__model="user"
)
instance.user_permissions.add(user_read_only_permission)
for permission in topup_permissions:
instance.user_permissions.add(permission)
for permission in device_permissions:
@@ -29,50 +31,12 @@ def assign_device_permissions(sender, instance, created, **kwargs):
instance.user_permissions.add(atoll_read_permission, island_read_permission)
for permission in payment_permissions:
instance.user_permissions.add(permission)
for permission in wallet_transaction_permissions:
instance.user_permissions.add(permission)
@receiver(post_save, sender=User)
@receiver(post_save, sender=TemporaryUser)
def verify_user_with_person_api(sender, instance, created, **kwargs):
if created:
verify_user_with_person_api_task(instance.id)
@receiver(reset_password_token_created)
def password_reset_token_created(
sender, instance, reset_password_token, *args, **kwargs
):
"""
Handles password reset tokens
When a token is created, an e-mail needs to be sent to the user
:param sender: View Class that sent the signal
:param instance: View Instance that sent the signal
:param reset_password_token: Token Model Object
:param args:
:param kwargs:
:return:
"""
context = {
"current_user": reset_password_token.user,
"username": reset_password_token.user.username,
"email": reset_password_token.user.email,
"reset_password_url": f"{config('FRONTEND_URL')}/auth/reset-password-confirm/?token={reset_password_token.key}",
}
# render email text
email_html_message = render_to_string("email/password_reset_email.html", context)
email_plaintext_message = (
f"Here is your password reset link: {context['reset_password_url']}"
)
msg = EmailMultiAlternatives(
# title:
"Password Reset for {title}".format(title="Sarlink Portal"),
# message:
email_plaintext_message, # This is the plaintext version
# from:
"noreply@sarlink.net",
# to:
[reset_password_token.user.email],
)
msg.attach_alternative(email_html_message, "text/html")
msg.send()
print(f"Temporary User Instance: {instance}")
verify_user_with_person_api_task(instance.t_id)
+173 -63
View File
@@ -1,5 +1,6 @@
# pyright: reportGeneralTypeIssues=false
from django.shortcuts import get_object_or_404
from api.models import User
from api.models import TemporaryUser
from devices.models import Device
from api.notifications import send_sms
import os
@@ -8,9 +9,20 @@ from django.utils import timezone
# from api.notifications import send_clean_telegram_markdown
from api.omada import Omada
from api.bot import (
send_telegram_alert,
send_telegram_photo,
telegram_loop,
escape_markdown_v2,
)
import asyncio
from apibase.env import env, BASE_DIR
from procrastinate.contrib.django import app
from procrastinate import builtin_tasks
import time
import io
import requests
from PIL import Image
logger = logging.getLogger(__name__)
@@ -36,22 +48,64 @@ async def remove_old_jobs(context, timestamp):
@app.periodic(
cron="0 0 */28 * *", queue="heavy_tasks", periodic_id="deactivate_expired_devices"
cron="0 22 * * *",
queue="heavy_tasks",
periodic_id="deactivate_expired_devices_and_block_in_omada",
) # type: ignore
@app.task
def deactivate_expired_devices():
def deactivate_expired_devices_and_block_in_omada():
expired_devices = Device.objects.filter(
expiry_date__lte=timezone.localtime(timezone.now()), is_active=True
).select_related("user")
print("Expired Devices: ", expired_devices)
count = expired_devices.count()
if count == 0:
return {"total_expired_devices": 0}
user_devices_map = {}
devices_successfully_blocked = []
devices_failed_to_block = []
omada_client = Omada()
# Single loop to collect data and block devices
for device in expired_devices:
# Collect devices for SMS notifications
if device.user and device.user.mobile:
if device.user.mobile not in user_devices_map:
user_devices_map[device.user.mobile] = []
user_devices_map[device.user.mobile].append(device.name)
# Try to block device in Omada
try:
omada_client.block_device(mac_address=device.mac, operation="block")
# Only prepare for update if Omada blocking succeeded
device.blocked = True
device.is_active = False
devices_successfully_blocked.append(device)
logger.info(f"Successfully blocked device {device.mac} in Omada")
time.sleep(20) # Sleep to avoid rate limiting
except Exception as e:
logger.error(f"Failed to block device [omada] {device.mac}: {e}")
devices_failed_to_block.append(device)
# Continue to next device without updating this one
# Bulk update only successfully blocked devices
if devices_successfully_blocked:
try:
Device.objects.bulk_update(
devices_successfully_blocked, ["is_active", "blocked"]
)
logger.info(
f"Successfully updated {len(devices_successfully_blocked)} devices in database"
)
except Exception as e:
logger.error(f"Failed to bulk update devices in database: {e}")
# You might want to handle this case - devices are blocked in Omada but not updated in DB
# Send SMS notifications
sms_count = 0
for mobile, device_names in user_devices_map.items():
if not mobile:
continue
@@ -59,14 +113,25 @@ def deactivate_expired_devices():
[f"{i + 1}. {name}" for i, name in enumerate(device_names)]
)
print("device list: ", device_list)
send_sms(
mobile,
f"Dear {mobile}, \n\nThe following devices have expired: \n{device_list}. \n\nPlease make a payment to keep your devices active. \n\n- SAR Link",
)
# expired_devices.update(is_active=False)
print(f"Total {count} expired devices.")
try:
send_sms(
mobile,
f"Dear {mobile}, \n\nThe following devices have expired: \n{device_list}. \n\nPlease make a payment to keep your devices active. \n\n- SAR Link",
)
sms_count += 1
except Exception as e:
logger.error(f"Failed to send SMS to {mobile}: {e}")
print(f"Total {count} expired devices processed.")
print(f"Successfully blocked: {len(devices_successfully_blocked)}")
print(f"Failed to block: {len(devices_failed_to_block)}")
print(f"SMS notifications sent: {sms_count}")
return {
"total_expired_devices": count,
"successfully_blocked": len(devices_successfully_blocked),
"failed_to_block": len(devices_failed_to_block),
"sms_sent": sms_count,
}
@@ -86,36 +151,79 @@ def verify_user_with_person_api_task(user_id: int):
Verify the user with the Person API.
:param user_id: The ID of the user to verify.
"""
if not user_id:
logger.error("User ID is not provided.")
return None
user = get_object_or_404(User, id=user_id)
if not user:
logger.error(f"User with ID {user_id} not found.")
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.
# """
# logger.info(verification_failed_message)
PERSON_VERIFY_BASE_URL = env.str("PERSON_VERIFY_BASE_URL", default="") # type: ignore
if not PERSON_VERIFY_BASE_URL:
raise ValueError(
"PERSON_VERIFY_BASE_URL is not set in the environment variables."
)
import requests
response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{user.id_card}")
print(f"Verifying user with ID: {user_id}")
if not user_id:
logger.error("User ID is not provided.")
return None
t_user = get_object_or_404(TemporaryUser, t_id=user_id)
if not t_user:
logger.error(f"User with ID {user_id} not found.")
return None
print("t_user:", t_user)
response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{t_user.t_id_card}")
user_details = (
f"*NID:* {t_user.t_id_card}\n"
f"*Name:* {t_user.t_first_name} {t_user.t_last_name}\n"
f"*Phone:* {t_user.t_mobile}\n"
f"*Date of Birth:* {t_user.t_dob}\n"
f"*Address:* {t_user.t_address}\n"
f"*Island:* {(t_user.t_atoll.name if t_user.t_atoll else 'N/A')}. "
f"{(t_user.t_island.name if t_user.t_island else 'N/A')}"
)
verification_failed_message = (
f"⚠️ *User verification failed*\n\n{user_details}\n"
f"Visit [SAR Link Portal](https://portal.sarlink.net/users/{user_id}/details) "
f"to manually verify this user."
)
verification_success_message = (
f"✅ *New user registered and verified*\n\n{user_details}\n"
f"View on [SAR Link Portal](https://portal.sarlink.net/users/{user_id}/details)."
)
def _run(coro) -> None:
asyncio.run_coroutine_threadsafe(coro, telegram_loop).result()
def _fetch_photo(url: str) -> io.BytesIO:
"""Download the ID photo and convert it to a Telegram-safe JPEG.
The image lives on a private-network URL that Telegram's servers can't
reach, so we fetch the bytes here and upload them directly. Conversion
to JPEG avoids Telegram rejecting formats like webp.
"""
resp = requests.get(url, timeout=10)
resp.raise_for_status()
buf = io.BytesIO()
Image.open(io.BytesIO(resp.content)).convert("RGB").save(buf, format="JPEG")
buf.seek(0)
buf.name = "photo.jpg"
return buf
def send_telegram(message: str, photo_url: str | None = None) -> None:
caption = escape_markdown_v2(message)
if photo_url:
try:
_run(send_telegram_photo(_fetch_photo(photo_url), caption))
return
except Exception as e:
logger.warning(f"[Registration] TELEGRAM PHOTO ERROR: {e}")
# Fall through to a plain text alert below.
try:
_run(send_telegram_alert(markdown_message=caption))
except Exception as e:
logger.warning(f"[Registration] TELEGRAM ALERT ERROR: {e}")
if response.status_code == 200:
data = response.json()
api_nic = data.get("nic")
@@ -124,11 +232,12 @@ def verify_user_with_person_api_task(user_id: int):
api_dob = data.get("dob")
api_atoll = data.get("atoll_en")
api_island_name = data.get("island_name_en")
api_image_url = data.get("image_url")
if not user.mobile or user.dob is None:
if not t_user.t_mobile or t_user.t_dob is None:
logger.error("User mobile or date of birth is not set.")
return None
if not user.island or user.atoll is None:
if not t_user.t_island or t_user.t_atoll is None:
logger.error("User island or atoll is not set.")
return None
@@ -139,53 +248,54 @@ def verify_user_with_person_api_task(user_id: int):
logger.info(f"API atoll: {api_atoll}")
logger.info(f"API island name: {api_island_name}")
user_nic = user.id_card
user_name = f"{user.first_name} {user.last_name}"
user_house_name = user.address
user_dob = user.dob.isoformat()
user_nic = t_user.t_id_card
user_name = f"{t_user.t_first_name} {t_user.t_last_name}"
user_house_name = t_user.t_address
user_dob = t_user.t_dob.isoformat()
logger.info(f"User nic: {user_nic}")
logger.info(f"User name: {user_name}")
logger.info(f"User house name: {user_house_name}")
logger.info(f"User dob: {user_dob}")
logger.info(f"User atoll: {user.atoll}")
logger.info(f"User island name: {user.island}")
logger.info(f"User atoll: {t_user.t_atoll.name if t_user.t_atoll else 'N/A'}")
logger.info(
f"User island name: {t_user.t_island.name if t_user.t_island else 'N/A'}"
)
logger.info(
f"case User atoll: {user.atoll.name == api_atoll.strip() if api_atoll else False}"
f"case User atoll: {t_user.t_atoll.name == api_atoll.strip() if api_atoll else False}"
) # Defensive check for api_atoll
logger.info(f"api atoll type: {type(api_atoll)}")
logger.info(f"user atoll type: {type(user.atoll.name)}")
logger.info(f"user atoll type: {type(t_user.t_atoll.name)}")
logger.info(
f"case User island name: {user.island.name == api_island_name.strip() if api_island_name else False}"
f"case User island name: {t_user.t_island.name == api_island_name.strip() if api_island_name else False}"
) # Defensive check for api_island_name
logger.info(f"api island name type: {type(api_island_name)}")
logger.info(f"user island name type: {type(user.island.name)}")
logger.info(f"user island name type: {type(t_user.t_island.name)}")
print("CHECKING USER FIELDS AGAINST API DATA")
if (
data.get("nic") == user.id_card
and data.get("name_en") == f"{user.first_name} {user.last_name}"
and data.get("house_name_en") == user.address
and data.get("dob").split("T")[0] == user.dob.isoformat()
and data.get("atoll_en").strip() == user.atoll.name
and data.get("island_name_en").strip() == user.island.name
data.get("nic") == t_user.t_id_card
and data.get("name_en") == f"{t_user.t_first_name} {t_user.t_last_name}"
and data.get("house_name_en") == t_user.t_address
and data.get("dob").split("T")[0] == t_user.t_dob.isoformat()
and data.get("atoll_en").strip() == t_user.t_atoll.name
and data.get("island_name_en").strip() == t_user.t_island.name
):
user.verified = True
user.save()
send_sms(
user.mobile,
f"Dear {user.first_name} {user.last_name}, \n\nYour account has been successfully and verified. \n\nYou can now manage your devices and make payments through our portal at https://portal.sarlink.net. \n\n - SAR Link",
)
t_user.t_verified = True
t_user.save()
logger.info(verification_success_message)
send_telegram(verification_success_message, photo_url=api_image_url)
return True
else:
user.verified = False
user.save()
t_user.t_verified = False
t_user.save()
send_sms(
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)
logger.info(verification_failed_message)
send_telegram(verification_failed_message, photo_url=api_image_url)
return False
else:
# Handle the error case
@@ -1,102 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Instructions to reset your password." />
<meta name="keywords" content="password, reset, email, instructions" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Password Reset Email</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f5f5f5;
margin: 0;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header {
margin-bottom: 30px;
}
.logo {
color: #2c3e50;
font-size: 24px;
font-weight: bold;
}
.message {
color: #6c757d;
font-size: 16px;
line-height: 1.5;
margin-top: 20px;
}
.footer {
margin-top: 30px;
color: #6c757d;
font-size: 14px;
}
.button {
display: inline-block;
padding: 10px 20px;
background-color: #007bff;
color: #ffffff !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}
.button:hover {
background-color: #0056b3;
}
a {
color: #007bff;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">Password Reset Instructions</div>
</div>
<p class="message">
Hello {{ username }},
</p>
<p class="message">
We received a request to reset your password. Click the button below to create a new password:
</p>
<a href="{{ reset_password_url }}" class="button">Reset Password</a>
<p class="message">
If the button doesn't work, you can copy and paste this link into your browser:
<br>
<a href="{{ reset_password_url }}">{{ reset_password_url }}</a>
</p>
<p class="message">
If you did not request this password reset, you can safely ignore
this email.
</p>
<p class="footer">Best regards,<br>SARLink</p>
</div>
</body>
</html>
-2
View File
@@ -10,7 +10,6 @@ from .views import (
ListUserView,
UserDetailAPIView,
healthcheck,
test_email,
ListAtollView,
CreateAtollView,
RetrieveUpdateDestroyAtollView,
@@ -49,7 +48,6 @@ urlpatterns = [
),
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"),
path("atolls/new/", CreateAtollView.as_view(), name="atoll-new"),
path(
+93 -160
View File
@@ -2,6 +2,7 @@
from django.contrib.auth import login
# rest_framework imports
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import generics, permissions
from rest_framework.authtoken.serializers import AuthTokenSerializer
from api.filters import UserFilter
@@ -22,19 +23,19 @@ from api.serializers import (
)
from django.shortcuts import get_object_or_404
from django.utils import timezone
from datetime import timedelta
# knox imports
from knox.views import LoginView as KnoxLoginView
from knox.models import AuthToken
from django_filters.rest_framework import DjangoFilterBackend
import re
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 .utils import check_person_api_verification
import uuid
from .helpers import ErrorMessages, validate_required_fields, validate_unique_fields, validate_patterns, calculate_age
# local apps import
from .serializers import (
@@ -45,19 +46,6 @@ from .serializers import (
UserProfileUpdateSerializer,
)
ID_CARD_PATTERN = r"^[A-Z]{1,2}[0-9]{6,7}$"
MOBILE_PATTERN = r"^[7|9][0-9]{6}$"
ACCOUNT_NUMBER_PATTERN = r"^(7\d{12}|9\d{16})$"
class ErrorMessages:
USERNAME_EXISTS = "Username already exists."
MOBILE_EXISTS = "Mobile number already exists."
INVALID_ID_CARD = "Please enter a valid ID card number."
ID_CARD_EXISTS = "ID card already exists."
INVALID_MOBILE = "Please enter a valid mobile number."
INVALID_ACCOUNT = "Please enter a valid account number."
UNDERAGE_ERROR = "You must be 18 and above to signup."
@api_view(["GET"])
@@ -65,141 +53,94 @@ def healthcheck(request):
return Response({"status": "Good"}, status=status.HTTP_200_OK)
class CreateTemporaryUserView(generics.CreateAPIView):
# Create user API view
serializer_class = TemporaryUserSerializer
permission_classes = (permissions.AllowAny,)
queryset = TemporaryUser.objects.all()
throttle_classes = []
def post(self, request, *args, **kwargs):
# Extract required fields from request data
username = request.data.get("username")
address = request.data.get("address")
mobile = request.data.get("mobile")
acc_no = request.data.get("acc_no")
id_card = request.data.get("id_card")
dob = request.data.get("dob")
atoll_id = request.data.get("atoll")
island_id = request.data.get("island")
terms_accepted = request.data.get("terms_accepted")
policy_accepted = request.data.get("policy_accepted")
firstname = request.data.get("firstname")
lastname = request.data.get("lastname")
# Extract data once
data = request.data
current_date = timezone.now()
# Validate required fields
required_error = validate_required_fields(data)
if required_error:
return required_error
# Parse DOB
dob_str = data.get("dob")
try:
dob = timezone.datetime.strptime(str(dob), "%Y-%m-%d").date()
dob = timezone.datetime.strptime(str(dob_str), "%Y-%m-%d").date() # pyright: ignore[reportAttributeAccessIssue]
except ValueError:
return Response(
{"message": "Invalid date format for DOB. Use YYYY-MM-DD."}, status=400
)
return Response({"message": "Invalid date format for DOB. Use YYYY-MM-DD."}, status=400)
age_from_dob = (
current_date.year
- dob.year
- ((current_date.month, current_date.day) < (dob.month, dob.day))
)
if age_from_dob < 18:
# Check age
age = calculate_age(dob)
if age < 18:
return Response({"message": ErrorMessages.UNDERAGE_ERROR}, status=400)
if (
TemporaryUser.objects.filter(t_mobile=mobile).exists()
or User.objects.filter(mobile=mobile).exists()
):
return Response({"message": ErrorMessages.MOBILE_EXISTS}, status=400)
if (
TemporaryUser.objects.filter(t_username=username).exists()
or User.objects.filter(username=username).exists()
):
return Response({"message": ErrorMessages.USERNAME_EXISTS}, status=400)
if (
TemporaryUser.objects.filter(t_id_card=id_card).exists()
or User.objects.filter(id_card=id_card).exists()
):
return Response({"message": "ID card already exists."}, status=400)
if (
TemporaryUser.objects.filter(t_id_card=id_card).exists()
or User.objects.filter(id_card=id_card).exists()
):
return Response({"message": ErrorMessages.ID_CARD_EXISTS}, status=400)
if id_card and not re.match(ID_CARD_PATTERN, id_card):
return Response({"message": ErrorMessages.INVALID_ID_CARD}, status=400)
if mobile is None or not re.match(MOBILE_PATTERN, mobile):
return Response({"message": ErrorMessages.INVALID_MOBILE}, status=400)
if acc_no is None or not re.match(ACCOUNT_NUMBER_PATTERN, acc_no):
return Response({"message": ErrorMessages.INVALID_ACCOUNT}, status=400)
# Validate uniqueness
uniqueness_error = validate_unique_fields(
username=data.get("username"),
mobile=data.get("mobile"),
id_card=data.get("id_card"),
)
if uniqueness_error:
return uniqueness_error
# Validate required fields first
validation_error = self.validate_required_fields(request.data)
if validation_error:
return validation_error
# Validate patterns
pattern_error = validate_patterns(
id_card=data.get("id_card"),
mobile=data.get("mobile"),
acc_no=data.get("acc_no"),
)
if pattern_error:
return pattern_error
# Fetch Atoll and Island instances
# Fetch related objects
atoll_id = data.get("atoll")
island_id = data.get("island")
try:
atoll = Atoll.objects.get(id=atoll_id)
island = Island.objects.get(id=island_id)
except Atoll.DoesNotExist:
return Response({"message": "Atoll not found."}, status=404)
except Island.DoesNotExist:
return Response({"message": "Island not found."}, status=404)
except ObjectDoesNotExist as e:
model_name = "Atoll" if isinstance(e, Atoll.DoesNotExist) else "Island"
return Response({"message": f"{model_name} not found."}, status=404)
# Create user
temp_user = TemporaryUser.objects.create(
t_first_name=firstname,
t_last_name=lastname,
t_username=str(username),
t_first_name=data.get("firstname"),
t_last_name=data.get("lastname"),
t_username=str(data.get("username")),
t_email=None,
t_address=address,
t_mobile=mobile,
t_acc_no=acc_no,
t_id_card=id_card,
t_address=data.get("address"),
t_mobile=data.get("mobile"),
t_acc_no=data.get("acc_no"),
t_id_card=data.get("id_card"),
t_dob=dob,
t_atoll=atoll,
t_island=island,
t_terms_accepted=terms_accepted,
t_policy_accepted=policy_accepted,
t_terms_accepted=data.get("terms_accepted"),
t_policy_accepted=data.get("policy_accepted"),
)
otp_expiry = timezone.now() + timedelta(minutes=3)
# Generate and send OTP
otp_expiry = timezone.now() + timezone.timedelta(minutes=3) #type: ignore
formatted_time = otp_expiry.strftime("%d/%m/%Y %H:%M:%S")
otp = temp_user.generate_otp()
send_otp(
str(temp_user.t_mobile),
f"Your Registration SARLink OTP: {otp}. \nExpires at {formatted_time}. \n\n- SAR Link",
)
# Return success
serializer = self.get_serializer(temp_user)
headers = self.get_success_headers(serializer.data)
return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
def validate_required_fields(self, data):
required_fields = {
"firstname": "First name",
"lastname": "Last name",
"username": "Username",
"address": "Address",
"mobile": "Mobile number",
"acc_no": "Account number",
"id_card": "ID card",
"dob": "Date of birth",
"atoll": "Atoll",
"island": "Island",
}
for field, label in required_fields.items():
if not data.get(field):
return Response({"message": f"{label} is required."}, status=400)
if data.get("terms_accepted") is None:
return Response({"message": "Terms acceptance is required."}, status=400)
if data.get("policy_accepted") is None:
return Response({"message": "Policy acceptance is required."}, status=400)
return None
class VerifyOTPView(generics.GenericAPIView):
permission_classes = (permissions.AllowAny,)
serializer_class = OTPVerificationSerializer
@@ -241,17 +182,34 @@ class VerifyOTPView(generics.GenericAPIView):
acc_no=temp_user.t_acc_no,
id_card=temp_user.t_id_card,
dob=temp_user.t_dob,
verified=temp_user.t_verified,
atoll=temp_user.t_atoll,
island=temp_user.t_island,
terms_accepted=temp_user.t_terms_accepted,
policy_accepted=temp_user.t_policy_accepted,
)
if temp_user.t_verified:
send_sms(
t_user.t_mobile,
f"Dear {temp_user.t_first_name} {temp_user.t_last_name}, \n\nYour account has been successfully verified. \n\nYou can now manage your devices and make payments through our portal at https://portal.sarlink.net. \n\n - SAR Link",
)
else:
send_sms(
t_user.t_mobile,
f"Dear {t_user.t_first_name} {t_user.t_last_name}, \n\nYour account registration is being processed. \n\nWe will notify you once verification is complete. \n\n - SAR Link",
)
# You can now trigger registry verification as a signal or task
temp_user.otp_verified = True
temp_user.save()
return Response({"message": "User created successfully."})
return Response(
{
"message": "User created successfully.",
"verified": temp_user.t_verified
}
)
class LoginView(KnoxLoginView):
@@ -324,26 +282,6 @@ class UserUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
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)
@@ -422,7 +360,7 @@ class KnoxTokenListApiView(
class ListUserView(StaffEditorPermissionMixin, generics.ListAPIView):
serializer_class = CustomReadOnlyUserSerializer
filter_backends = [DjangoFilterBackend]
filter_backends = [DjangoFilterBackend] #type: ignore
filterset_fields = "__all__"
filterset_class = UserFilter
queryset = User.objects.all()
@@ -535,12 +473,14 @@ def filter_user(request):
return Response({"ok": False})
filters = Q()
if id_card is not None:
filters |= Q(id_card=id_card)
if mobile is not None:
filters |= Q(mobile=mobile)
if id_card and mobile:
filters = Q(id_card=id_card) & Q(mobile=mobile)
elif id_card:
filters = Q(id_card=id_card)
elif mobile:
filters = Q(mobile=mobile)
user = User.objects.filter(filters).first()
user = User.objects.only("id", "verified").filter(filters).first()
print(f"Querying with filters: {filters}")
print(f"Found user: {user}")
@@ -561,20 +501,26 @@ def filter_temporary_user(request):
return Response({"ok": False})
filters = Q()
if id_card is not None:
if id_card and mobile:
filters |= Q(t_id_card=id_card) & Q(t_mobile=mobile)
elif id_card:
filters |= Q(t_id_card=id_card)
if mobile is not None:
elif mobile:
filters |= Q(t_mobile=mobile)
user = TemporaryUser.objects.filter(filters).first()
user = (
TemporaryUser.objects.only("t_id", "otp_verified", "t_verified")
.filter(filters)
.first()
)
print(f"Querying with filters: {filters}")
print(f"Found temporary user: {user}")
return Response(
{"ok": True, "otp_verified": user.otp_verified}
{"ok": True, "otp_verified": user.otp_verified, "t_verified": user.t_verified}
if user
else {"ok": False, "otp_verified": False}
else {"ok": False, "otp_verified": False, "t_verified": False}
)
@@ -589,7 +535,7 @@ class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
if (
user != instance
and not getattr(user, "is_admin", False)
and not user.is_superuser
and not user.is_superuser #type: ignore
):
return Response(
{"message": "You are not authorized to view this user's details."},
@@ -603,19 +549,6 @@ class UserDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
return Response(data)
@api_view(["POST"])
@permission_classes((permissions.AllowAny,))
def test_email(request):
send_mail(
"Subject here",
"Here is the message.",
"noreply@sarlink.net",
["shihaam@shihaam.me"],
fail_silently=False,
)
return Response({"status": "ok"}, status=status.HTTP_200_OK)
class CreateAtollView(StaffEditorPermissionMixin, generics.CreateAPIView):
serializer_class = AtollSerializer
queryset = Atoll.objects.all()
+4 -19
View File
@@ -26,7 +26,7 @@ env.read_env(os.path.join(BASE_DIR, ".env"))
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env("SECRET_KEY", default=get_random_secret_key())
SECRET_KEY = env("SECRET_KEY", default=get_random_secret_key()) #type: ignore
DEBUG = env.bool("DJANGO_DEBUG", default=True) # type: ignore
@@ -52,10 +52,8 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"rest_framework",
"django_rest_passwordreset",
"djangopasswordlessknox",
"django_extensions",
"django_seed",
"storages",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
@@ -159,9 +157,9 @@ DATABASES = {
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
# Uses email as username for login
# Log in with username + password (default Django backend)
AUTH_USER_MODEL = "api.User"
AUTHENTICATION_BACKENDS = ["api.backends.EmailBackend"]
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
AUTH_PASSWORD_VALIDATORS = [
{
@@ -339,24 +337,11 @@ logging.config.dictConfig(
)
EMAIL_BACKEND = (
"django.core.mail.backends.smtp.EmailBackend" # Replace with your preferred backend
)
EMAIL_HOST = env("EMAIL_HOSTNAME", default="") # type: ignore
EMAIL_PORT = env("EMAIL_PORT", cast=int, default=25) # type: ignore
EMAIL_HOST_USER = env("EMAIL_USERNAME", default="") # type: ignore
EMAIL_HOST_PASSWORD = env("EMAIL_PASSWORD", default="") # type: ignore
# DEFAULT_FROM_EMAIL = "noreply@sarlink.net"
EMAIL_USE_TLS = True
PASSWORDLESS_AUTH = {
# 'PASSWORDLESS_EMAIL_TOKEN_HTML_TEMPLATE_NAME': "password_reset_email.html",
"PASSWORDLESS_AUTH_TYPES": ["EMAIL", "MOBILE"],
"PASSWORDLESS_AUTH_TYPES": ["MOBILE"],
"PASSWORDLESS_USER_MOBILE_FIELD_NAME": "mobile",
"PASSWORDLESS_TEST_SUPPRESSION": False,
"PASSWORDLESS_REGISTER_NEW_USERS": True,
"PASSWORDLESS_EMAIL_NOREPLY_ADDRESS": "noreply@sarlink.net",
}
-4
View File
@@ -27,10 +27,6 @@ from drf_spectacular.views import (
urlpatterns = [
path("admin/", admin.site.urls),
path(
"api/password_reset/",
include("django_rest_passwordreset.urls", namespace="password_reset"),
),
path("", include("djangopasswordlessknox.urls")),
# Authentication
path("api/auth/", include("api.urls")),
@@ -0,0 +1,21 @@
# Generated by Django 5.2 on 2025-07-27 07:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0014_wallettransaction"),
]
operations = [
migrations.AddField(
model_name="topup",
name="payment_type",
field=models.CharField(
choices=[("CASH", "Cash"), ("TRANSFER", "Transfer")],
default="TRANSFER",
max_length=20,
),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2 on 2025-09-20 16:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0015_topup_payment_type"),
]
operations = [
migrations.AddField(
model_name="payment",
name="source_bank",
field=models.CharField(blank=True, default="", null=True),
),
migrations.AddField(
model_name="topup",
name="source_bank",
field=models.CharField(blank=True, default="", null=True),
),
]
+10
View File
@@ -17,6 +17,7 @@ class Payment(models.Model):
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
mib_reference = models.CharField(default="", null=True, blank=True)
source_bank = models.CharField(default="", null=True, blank=True)
number_of_months = models.IntegerField()
amount = models.FloatField()
paid = models.BooleanField(default=False)
@@ -66,6 +67,14 @@ 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")
payment_type = models.CharField(
max_length=20,
choices=[
("CASH", "Cash"),
("TRANSFER", "Transfer"),
],
default="TRANSFER",
)
paid = models.BooleanField(default=False)
paid_at = models.DateTimeField(null=True, blank=True)
status = models.CharField(
@@ -78,6 +87,7 @@ class Topup(models.Model):
default="PENDING",
)
mib_reference = models.CharField(default="", null=True, blank=True)
source_bank = 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)
+5
View File
@@ -11,6 +11,8 @@ from .views import (
TopupDetailAPIView,
CancelTopupView,
ListWalletTransactionView,
AdminTopupCreateView,
# AlertTestView,
)
urlpatterns = [
@@ -37,6 +39,7 @@ urlpatterns = [
VerifyTopupPaymentAPIView.as_view(),
name="verify-topup-payment",
),
path("admin-topup/", AdminTopupCreateView.as_view(), name="admin-topup"),
path(
"topup/<str:pk>/cancel/",
CancelTopupView.as_view(),
@@ -48,4 +51,6 @@ urlpatterns = [
ListWalletTransactionView.as_view(),
name="list-wallet-transactions",
),
# Test tg notification
# path("test-alert/", AlertTestView.as_view(), name="test-alert"),
]
+33
View File
@@ -0,0 +1,33 @@
def calculate_total_new_price(number_of_devices, number_of_months):
monthly_price_map = {
1: 100,
2: 175,
3: 250,
4: 325,
5: 400,
6: 475,
7: 550,
8: 625,
9: 700,
10: 775,
11: 850,
12: 925,
13: 1000,
14: 1075,
15: 1150,
16: 1225,
17: 1300,
}
if number_of_devices < 1 or number_of_devices > 17:
raise ValueError("Number of devices must be between 1 and 17.")
monthly_price = monthly_price_map[number_of_devices]
total_price = monthly_price * number_of_months
print(f"Monthly price for {number_of_devices} devices: {monthly_price}")
print(f"Total price for {number_of_months} months: {total_price}")
return total_price
calculate_total_new_price(number_of_devices=2, number_of_months=3)
+200 -56
View File
@@ -15,6 +15,7 @@ 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 .utils import calculate_total_new_price
from .models import Device, Payment, Topup, WalletTransaction
from .serializers import (
@@ -27,6 +28,10 @@ from .filters import PaymentFilter, TopupFilter, WalletTransactionFilter
from dataclasses import dataclass, asdict
from typing import Optional
from api.models import User
from api.omada import Omada
# from api.bot import send_telegram_alert, telegram_loop, escape_markdown_v2
# import asyncio
env.read_env(os.path.join(BASE_DIR, ".env"))
@@ -56,7 +61,7 @@ class InsufficientFundsError(Exception):
class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
serializer_class = PaymentSerializer
queryset = Payment.objects.all().select_related("user")
filter_backends = [DjangoFilterBackend]
filter_backends = [DjangoFilterBackend] #type: ignore
filterset_fields = "__all__"
filterset_class = PaymentFilter
@@ -69,7 +74,7 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
Prefetch("devices", queryset=device_qs)
)
if not self.request.user.is_superuser:
if not self.request.user.is_superuser: #type: ignore
queryset = queryset.filter(user=self.request.user)
return queryset
@@ -77,10 +82,10 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
def create(self, request):
data = request.data
user = request.user
amount = data.get("amount")
number_of_months = data.get("number_of_months")
number_of_devices = 0
device_ids = data.get("device_ids", [])
print(amount, number_of_months, device_ids)
print(number_of_months, device_ids)
current_time = timezone.now()
expires_at = current_time + timedelta(minutes=10)
for device_id in device_ids:
@@ -91,9 +96,10 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
{"message": f"Device with id {device_id} not found."},
status=status.HTTP_400_BAD_REQUEST,
)
if not amount or not number_of_months:
number_of_devices += 1
if not number_of_months:
return Response(
{"message": "amount and number_of_months are required."},
{"message": "number_of_months is required."},
status=status.HTTP_400_BAD_REQUEST,
)
if not device_ids:
@@ -101,7 +107,9 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
{"message": "device_ids are required."},
status=status.HTTP_400_BAD_REQUEST,
)
# Create payment
amount = calculate_total_new_price(
number_of_devices=number_of_devices, number_of_months=number_of_months
)
payment = Payment.objects.create(
amount=amount,
number_of_months=number_of_months,
@@ -152,7 +160,7 @@ class PaymentDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView)
class UpdatePaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
queryset = Payment.objects.select_related("user").all()
queryset = Payment.objects.select_related("user").prefetch_related("devices").all()
serializer_class = UpdatePaymentSerializer
lookup_field = "pk"
@@ -172,21 +180,22 @@ class UpdatePaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
serializer_class = PaymentSerializer
queryset = Payment.objects.select_related("user").all()
queryset = Payment.objects.select_related("user").prefetch_related("devices").all()
lookup_field = "pk"
def update(self, request, *args, **kwargs):
payment = self.get_object()
devices = payment.devices.all()
data = request.data
user = request.user
print("logged in user", user)
print("Payment user", payment.user)
user_details = f"{user.first_name.capitalize() if user.first_name else ''} {user.last_name.capitalize() if user.last_name else ''} {user.mobile}" # type: ignore
omada_client = Omada()
if payment.paid:
return Response(
{"message": "Payment has already been verified."},
status=status.HTTP_400_BAD_REQUEST,
)
if payment.user != user and not user.is_superuser:
if payment.user != user and not user.is_superuser: #type: ignore
return Response(
{"message": "You are not authorized to verify this payment."},
status=status.HTTP_403_FORBIDDEN,
@@ -198,7 +207,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
devices = payment.devices.all()
if method == "WALLET":
if user.wallet_balance < payment.amount: # type: ignore
return Response(
@@ -209,7 +217,25 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
self.process_wallet_payment(
user, # type: ignore
payment,
devices,
)
device_list = []
for device in devices:
device_list.append(
{
"mac": device.mac,
"name": f"{user_details} - {device.name}",
}
)
if device.registered:
omada_client.block_device(
mac_address=device.mac, operation="unblock"
)
if not device.registered:
# Add to omada
add_new_devices_to_omada.defer(new_devices=device_list)
device.registered = True
device.save()
return Response(
{
"status": True,
@@ -232,7 +258,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
is_active=True,
expiry_date=expiry_date,
has_a_pending_payment=False,
registered=True,
)
payment.status = "PAID"
payment.save()
@@ -242,9 +267,13 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
device_list.append(
{
"mac": device.mac,
"name": device.name,
"name": f"{user_details} - {device.name}",
}
)
if device.registered:
omada_client.block_device(
mac_address=device.mac, operation="unblock"
)
if not device.registered:
# Add to omada
add_new_devices_to_omada.defer(new_devices=device_list)
@@ -271,14 +300,23 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
def process_wallet_payment(self, user: User, payment: Payment):
def process_wallet_payment(self, user: User, payment: Payment, devices=None):
print("processing wallet payment...")
print(user, payment.amount)
# Use passed devices or fetch if not provided
if devices is None:
devices = payment.devices.all()
payment.paid = True
payment.paid_at = timezone.now()
payment.method = "WALLET"
payment.status = "PAID"
expiry_date = timezone.now() + timedelta(days=30 * payment.number_of_months)
devices.update(
is_active=True,
expiry_date=expiry_date,
has_a_pending_payment=False,
)
payment.save()
user.deduct_wallet_funds(
@@ -289,25 +327,36 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
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}")
logger.error("PAYMENT_BASE_URL is not set.")
return PaymentVerificationResponse(
message="Payment verification failed.", success=False, transaction=None
message="Payment gateway is not configured. Please contact support.",
success=False,
transaction=None,
)
mib_resp = response.json()
logger.info("MIB Verification Response ->", mib_resp)
if not response.json().get("success"):
try:
response = requests.post(
f"{PAYMENT_BASE_URL}/verify-payment",
json=data,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
mib_resp = response.json()
except requests.exceptions.RequestException as e:
logger.error(f"MIB request failed: {e}")
return PaymentVerificationResponse(
message="Unable to reach the payment gateway. Please try again or contact support.",
success=False,
transaction=None,
)
except ValueError as e:
logger.error(f"MIB returned an invalid response: {e}")
return PaymentVerificationResponse(
message="Received an invalid response from the payment gateway. Please contact support.",
success=False,
transaction=None,
)
logger.info("MIB Verification Response -> %s", mib_resp)
if not mib_resp.get("success"):
return PaymentVerificationResponse(
message=mib_resp["message"],
success=mib_resp["success"],
@@ -318,6 +367,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
payment.paid_at = timezone.now()
payment.method = "TRANSFER"
payment.mib_reference = mib_resp["transaction"]["ref"] or ""
payment.source_bank = mib_resp["transaction"]["sourceBank"] or ""
payment.save()
return PaymentVerificationResponse(
message=mib_resp["message"],
@@ -343,7 +393,7 @@ class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
{"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: #type: ignore
return Response(
{"message": "You are not authorized to cancel this payment."},
status=status.HTTP_403_FORBIDDEN,
@@ -363,7 +413,7 @@ class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
queryset = Topup.objects.all().prefetch_related("user")
serializer_class = TopupSerializer
filter_backends = [DjangoFilterBackend]
filter_backends = [DjangoFilterBackend] #type: ignore
filterset_fields = "__all__"
filterset_class = TopupFilter
@@ -384,7 +434,7 @@ class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView
def get_queryset(self):
queryset = super().get_queryset()
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser:
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser: #type: ignore
return queryset
return queryset.filter(user=self.request.user)
@@ -420,7 +470,7 @@ class TopupDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
def get_queryset(self):
queryset = super().get_queryset()
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser:
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser: #type: ignore
return queryset
return queryset.filter(user=self.request.user)
@@ -432,25 +482,37 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
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."
logger.error("PAYMENT_BASE_URL is not set.")
return PaymentVerificationResponse(
message="Payment gateway is not configured. Please contact support.",
success=False,
transaction=None,
)
logger.info(data)
response = requests.post(
f"{PAYMENT_BASE_URL}/verify-payment",
json=data,
headers={"Content-Type": "application/json"},
)
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
response = requests.post(
f"{PAYMENT_BASE_URL}/verify-payment",
json=data,
headers={"Content-Type": "application/json"},
)
mib_resp = response.json()
print(mib_resp)
if not response.json().get("success"):
response.raise_for_status()
mib_resp = response.json()
except requests.exceptions.RequestException as e:
logger.error(f"MIB request failed: {e}")
return PaymentVerificationResponse(
message="Unable to reach the payment gateway. Please try again or contact support.",
success=False,
transaction=None,
)
except ValueError as e:
logger.error(f"MIB returned an invalid response: {e}")
return PaymentVerificationResponse(
message="Received an invalid response from the payment gateway. Please contact support.",
success=False,
transaction=None,
)
logger.info("MIB Verification Response -> %s", mib_resp)
if not mib_resp.get("success"):
return PaymentVerificationResponse(
message=mib_resp["message"],
success=mib_resp["success"],
@@ -460,6 +522,7 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
topup.paid = True
topup.mib_reference = mib_resp["transaction"]["ref"] or ""
topup.paid_at = mib_resp["transaction"]["trxDate"]
topup.source_bank = mib_resp["transaction"]["sourceBank"] or ""
topup.save()
return PaymentVerificationResponse(
message=mib_resp["message"],
@@ -480,7 +543,7 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
{"message": "Payment has already been verified."},
status=status.HTTP_400_BAD_REQUEST,
)
if topup_instance.user != user and not user.is_superuser:
if topup_instance.user != user and not user.is_superuser: #type: ignore
return Response(
{"message": "You are not allowed to pay for this topup."},
status=status.HTTP_403_FORBIDDEN,
@@ -547,7 +610,7 @@ class CancelTopupView(StaffEditorPermissionMixin, generics.UpdateAPIView):
if (
instance.user != user
and getattr(user, "is_admin")
and not user.is_superuser
and not user.is_superuser #type: ignore
):
return Response(
{"message": "You are not authorized to delete this topup."},
@@ -563,16 +626,65 @@ class CancelTopupView(StaffEditorPermissionMixin, generics.UpdateAPIView):
return super().update(request, *args, **kwargs)
class AdminTopupCreateView(StaffEditorPermissionMixin, generics.CreateAPIView):
queryset = Topup.objects.all().select_related("user")
serializer_class = TopupSerializer
def create(self, request, *args, **kwargs):
data = request.data
user_id = data.get("user_id")
amount = data.get("amount")
topup_description = ""
admin_description = data.get("description", "")
if not getattr(request.user, "is_admin", False):
return Response(
{"message": "You are not authorized to perform this action."},
status=status.HTTP_403_FORBIDDEN,
)
if not user_id:
return Response(
{"message": "user_id is required."},
status=status.HTTP_400_BAD_REQUEST,
)
if not amount:
return Response(
{"message": "amount is required."},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.filter(id=user_id).first()
if not user:
return Response(
{"message": "User not found."},
status=status.HTTP_404_NOT_FOUND,
)
topup = Topup.objects.create(
amount=amount,
user=user,
paid=True,
paid_at=timezone.now(),
payment_type="CASH",
status="PAID",
)
default_description = f"Topup of {amount} MVR (Cash)"
if admin_description and admin_description.strip() != "":
topup_description = admin_description.strip()
else:
topup_description = default_description
user.add_wallet_funds(amount, topup_description, topup.id)
serializer = TopupSerializer(topup)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class ListWalletTransactionView(StaffEditorPermissionMixin, generics.ListAPIView):
serializer_class = WalletTransactionSerializer
queryset = WalletTransaction.objects.all().select_related("user")
filter_backends = [DjangoFilterBackend]
filter_backends = [DjangoFilterBackend] #type: ignore
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:
if getattr(self.request.user, "is_admin") or self.request.user.is_superuser: #type: ignore
return queryset
return queryset.filter(user=self.request.user)
@@ -601,3 +713,35 @@ class ListWalletTransactionView(StaffEditorPermissionMixin, generics.ListAPIView
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
# class AlertTestView(generics.GenericAPIView):
# def get(self, request, *args, **kwargs):
# msg = """*ID Card:* A265117\n*Name:* Abdulla Aidhaan\n*House Name:* Nooree Villa\n*Date of Birth:* 1997-08-24\n*Island:* Sh Funadhoo\n*Mobile:* 9697404\nVisit [SAR Link Portal](https://portal.sarlink.net) to manually verify this user."""
# print(msg)
# print("escaped:", escape_markdown_v2(msg))
# user = request.user
# print(user)
# global telegram_loop # Access the global loop
# if telegram_loop is None:
# return Response(
# {"message": "Telegram worker not initialized."},
# status=status.HTTP_503_SERVICE_UNAVAILABLE,
# )
# try:
# asyncio.run_coroutine_threadsafe(
# send_telegram_alert(markdown_message=escape_markdown_v2(msg)),
# telegram_loop,
# ).result()
# return Response(
# {"message": "Alert sent successfully."}, status=status.HTTP_200_OK
# )
# except Exception as e:
# logger.warning("[alert test] TELEGRAM ALERT ERROR", e)
# return Response(
# {"message": "Alert failed to send."}, status=status.HTTP_400_BAD_REQUEST
# )
+35
View File
@@ -0,0 +1,35 @@
services:
backend:
build:
context: .build/dev
hostname: backend
volumes:
- ./:/app
ports:
# host 8000 -> container 5000 (host :5000 is taken by the local macvendor-api)
- 8000:5000
env_file:
- .env
depends_on:
database:
condition: service_healthy
database:
image: postgres:16
hostname: database
environment:
POSTGRES_DB: ${POSTGRES_DATABASE:-sarlink}
POSTGRES_USER: ${POSTGRES_USER:-sarlink}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-sarlink}"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
+15 -4
View File
@@ -67,7 +67,17 @@ class DeviceListCreateAPIView(
return DeviceSerializer
def create(self, request, *args, **kwargs):
mac = request.data.get("mac", None)
user = request.user
name = request.data.get("name", None)
user_details = f"{user.first_name.capitalize() if user.first_name else ''} {user.last_name.capitalize() if user.last_name else ''} {user.mobile}" # type: ignore
omada_device_name = f"{user_details} - {name}" if name else user_details
if len(omada_device_name) > 64:
return Response(
{"message": "Device name is too long."},
status=400,
)
raw_mac = request.data.get("mac", None)
mac = raw_mac.strip() if raw_mac else None
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):
@@ -145,10 +155,11 @@ class DeviceBlockAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
if not isinstance(blocked, bool):
return Response({"message": "Blocked field must be a boolean."}, status=400)
omada_client = Omada()
blocked = omada_client.block_device(
omada_response = omada_client.block_device(
instance.mac, operation="block" if blocked else "unblock"
)
if blocked.errorCode == 0:
print(f"Blocked: {blocked}")
if omada_response.errorCode == 0:
instance.blocked = blocked
instance.save()
serializer = self.get_serializer(instance, data=request.data, partial=False)
@@ -157,7 +168,7 @@ class DeviceBlockAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
return Response(serializer.data)
else:
return Response(
{"message": blocked.msg},
{"message": omada_response.msg},
status=status.HTTP_400_BAD_REQUEST,
)
+5 -4
View File
@@ -232,14 +232,15 @@ def send_sms_with_callback_token(user, mobile_token, **kwargs):
logger.debug("Failed to send SMS. Missing SMS_API_URL or SMS_API_KEY.")
return False
from api.notifications import format_mobile
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"X-API-Key": api_key,
}
data = {
"number": to_number,
"message": base_string % mobile_token.key,
"check_delivery": False,
"to": format_mobile(to_number),
"text": base_string % mobile_token.key,
}
print(mobile_token.key)
response = requests.post(api_url, headers=headers, data=json.dumps(data))
-37
View File
@@ -1,37 +0,0 @@
services:
api:
build:
context: .
restart: always
command: gunicorn apibase.wsgi:application --bind 0.0.0.0:5000 --workers=2
volumes:
- /home/<username>/docker/council-api/staticfiles:/home/app/api/staticfiles
ports:
- 5000:5000
env_file:
- ./.env
depends_on:
- db
- redis
db:
image: postgres:15
restart: always
volumes:
- ./postgres_data:/var/lib/postgresql/data/
env_file:
- ./.env
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DATABASE}
ports:
- 5232:5432
redis:
image: "redis:alpine"
restart: always
expose:
- "6379"
+9 -14
View File
@@ -1,16 +1,11 @@
{
"venvPath": ".",
"venv": ".venv",
"reportMissingImports": "error",
"include": ["src"],
"typeCheckingMode": "standard",
"reportArgumentType": "warning",
"reportUnusedVariable": "warning",
"reportFunctionMemberAccess": "none",
"exclude": [
"council-api/**/migrations",
"**/__pycache__",
"src/experimental",
"src/typestubs"
]
"venvPath": ".",
"venv": ".venv",
"reportMissingImports": "error",
"include": ["src"],
"typeCheckingMode": "standard",
"reportArgumentType": "warning",
"reportUnusedVariable": "warning",
"reportFunctionMemberAccess": "none",
"exclude": ["council-api/**/migrations", "**/__pycache__"]
}
-2
View File
@@ -36,8 +36,6 @@ django-extensions==3.2.3
django-filter==23.5
django-redis==5.4.0
django-rest-knox==4.2.0
django-rest-passwordreset==1.5.0
django-seed==0.3.1
django-storages==1.14.4
django-stubs==5.1.1
django-stubs-ext==5.1.1