Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3397f212b5
|
||
|
|
1ea2f9f4c2
|
||
|
|
1f5e60dbb4
|
||
|
|
00d25698c6
|
||
|
|
6cdf042f49
|
||
|
|
2db0369d7c
|
||
|
|
c414116ed5
|
||
|
|
4bf75fdfe4
|
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -1,26 +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/;
|
||||
}
|
||||
location /media/ {
|
||||
alias /var/www/html/media/;
|
||||
}
|
||||
|
||||
|
||||
# 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
@@ -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 gateway — OTP + notifications
|
||||
# =============================================================================
|
||||
SMS_API_URL=""
|
||||
SMS_API_KEY=""
|
||||
SMS_API_URL=""
|
||||
|
||||
# =============================================================================
|
||||
# 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=""
|
||||
|
||||
@@ -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
|
||||
+26
@@ -12,6 +12,8 @@ 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(
|
||||
@@ -57,10 +59,34 @@ else:
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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."))
|
||||
@@ -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
@@ -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,8 +1,4 @@
|
||||
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, TemporaryUser
|
||||
from django.contrib.auth.models import Permission
|
||||
@@ -44,44 +40,3 @@ def verify_user_with_person_api(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
print(f"Temporary User Instance: {instance}")
|
||||
verify_user_with_person_api_task(instance.t_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()
|
||||
|
||||
+65
-15
@@ -9,13 +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, telegram_loop, escape_markdown_v2
|
||||
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__)
|
||||
@@ -163,10 +170,58 @@ def verify_user_with_person_api_task(user_id: int):
|
||||
response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{t_user.t_id_card}")
|
||||
|
||||
|
||||
verification_failed_message = f"""*The following user verification failed*:\n\n*ID Card:* {t_user.t_id_card}\n*Name:* {t_user.t_first_name} {t_user.t_last_name}\n*House Name:* {t_user.t_address}\n*Date of Birth:* {t_user.t_dob}\n*Island:* {(t_user.t_atoll.name if t_user.t_atoll else "N/A")} {(t_user.t_island.name if t_user.t_island else "N/A")}\n*Mobile:* {t_user.t_mobile}\nVisit [SAR Link Portal](https://portal.sarlink.net/users/{user_id}/details) to manually verify this user.
|
||||
"""
|
||||
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')}"
|
||||
)
|
||||
|
||||
logger.info(verification_failed_message)
|
||||
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:
|
||||
@@ -177,6 +232,7 @@ 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 t_user.t_mobile or t_user.t_dob is None:
|
||||
logger.error("User mobile or date of birth is not set.")
|
||||
@@ -230,22 +286,16 @@ def verify_user_with_person_api_task(user_id: int):
|
||||
):
|
||||
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:
|
||||
t_user.t_verified = False
|
||||
t_user.save()
|
||||
|
||||
# send_clean_telegram_markdown(message=verification_failed_message)
|
||||
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
send_telegram_alert(
|
||||
markdown_message=escape_markdown_v2(verification_failed_message)
|
||||
),
|
||||
telegram_loop,
|
||||
).result()
|
||||
except Exception as e:
|
||||
logger.warning("[Registration] TELEGRAM ALERT ERROR", e)
|
||||
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>
|
||||
@@ -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(
|
||||
|
||||
@@ -29,7 +29,6 @@ from knox.views import LoginView as KnoxLoginView
|
||||
from knox.models import AuthToken
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
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
|
||||
@@ -550,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()
|
||||
|
||||
+3
-18
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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")),
|
||||
|
||||
+55
-32
@@ -327,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"],
|
||||
@@ -471,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"],
|
||||
|
||||
+35
@@ -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:
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user