7 Commits

  1. 97
      apps/account/views/user.py
  2. 362
      apps/dobodbi_calendar/management/commands/translate_occasions.py
  3. 35
      apps/hadis/docs.py
  4. 7
      apps/hadis/models/category.py
  5. 10
      apps/hadis/views/hadis.py
  6. 11
      apps/hadis/views_admin.py
  7. 6
      apps/video/serializers_dovodi.py
  8. 1
      requirements.txt
  9. 119
      utils/__init__.py
  10. 121
      utils/excel_exporter.py

97
apps/account/views/user.py

@ -638,10 +638,12 @@ class AdminLoginView(CreateAPIView):
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework.decorators import action
from rest_framework.filters import SearchFilter, OrderingFilter from rest_framework.filters import SearchFilter, OrderingFilter
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.permissions import IsAdminUser from rest_framework.permissions import IsAdminUser
from utils.pagination import StandardResultsSetPagination from utils.pagination import StandardResultsSetPagination
from utils.excel_exporter import export_to_excel_response
class AdminUserViewSet(ModelViewSet): class AdminUserViewSet(ModelViewSet):
""" """
@ -654,6 +656,47 @@ class AdminUserViewSet(ModelViewSet):
pagination_class = StandardResultsSetPagination pagination_class = StandardResultsSetPagination
filter_backends = [] # Disable default backend filtering to use custom manual filtering filter_backends = [] # Disable default backend filtering to use custom manual filtering
@action(detail=False, methods=['post', 'get'])
def export_excel(self, request):
"""
Export users to an Excel (.xlsx) file.
Accepts user_ids in POST body for selective bulk export,
or uses current queryset filters if no specific IDs are provided.
"""
queryset = self.get_queryset()
user_ids = request.data.get('user_ids', None) if request.method == 'POST' else None
if user_ids and isinstance(user_ids, list):
queryset = queryset.filter(id__in=user_ids)
headers = [
"ID",
"Full Name",
"Email",
"Phone Number",
"Date Joined",
"Last Login"
]
rows = []
for user in queryset:
rows.append([
user.id,
user.fullname or "Unnamed",
user.email,
user.phone_number or "-",
user.date_joined,
user.last_login or "-"
])
timestamp = timezone.now().strftime("%Y%m%d_%H%M")
filename = f"users_export_{timestamp}.xlsx"
return export_to_excel_response(filename=filename, headers=headers, rows=rows, sheet_title="Users")
def perform_destroy(self, instance):
instance.is_active = False
instance.save(update_fields=['is_active'])
def get_queryset(self): def get_queryset(self):
queryset = User.objects.filter(is_active=True, email__isnull=False).exclude(email='') queryset = User.objects.filter(is_active=True, email__isnull=False).exclude(email='')
@ -728,6 +771,60 @@ class AdminUserDirectoryViewSet(ModelViewSet):
pagination_class = StandardResultsSetPagination pagination_class = StandardResultsSetPagination
filter_backends = [] filter_backends = []
@action(detail=False, methods=['post', 'get'])
def export_excel(self, request):
"""
Export users to an Excel (.xlsx) file.
Accepts user_ids in POST body for selective bulk export,
or uses current queryset filters if no specific IDs are provided.
"""
queryset = self.get_queryset()
user_ids = request.data.get('user_ids', None) if request.method == 'POST' else None
if user_ids and isinstance(user_ids, list):
queryset = queryset.filter(id__in=user_ids)
headers = [
"ID",
"Full Name",
"Email",
"Phone Number",
"Role",
"Date Joined",
"Last Login"
]
role_map = {
'professor': 'Professor',
'client': 'Student',
'student': 'Student',
'admin': 'Admin',
'super_admin': 'Super Admin',
'consultant': 'Consultant',
}
rows = []
for user in queryset:
role_label = role_map.get(user.user_type, user.user_type or "Student")
rows.append([
user.id,
user.fullname or "Unnamed",
user.email,
user.phone_number or "-",
role_label,
user.date_joined,
user.last_login or "-"
])
timestamp = timezone.now().strftime("%Y%m%d_%H%M")
filename = f"users_export_{timestamp}.xlsx"
return export_to_excel_response(filename=filename, headers=headers, rows=rows, sheet_title="Users")
def perform_destroy(self, instance):
instance.is_active = False
instance.save(update_fields=['is_active'])
def get_queryset(self): def get_queryset(self):
# Filter users who have an email and are not soft-deleted # Filter users who have an email and are not soft-deleted
queryset = User.objects.filter(email__isnull=False, deleted_at__isnull=True).exclude(email='') queryset = User.objects.filter(email__isnull=False, deleted_at__isnull=True).exclude(email='')

362
apps/dobodbi_calendar/management/commands/translate_occasions.py

@ -0,0 +1,362 @@
import json
import re
import sys
import time
import requests
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from django.core.management.base import BaseCommand
from apps.dobodbi_calendar.models import CalendarOccasions
# Target languages from زبانها.md
TARGET_LANGUAGES = {
"ar": "Arabic (عربی)",
"tr": "Turkish (ترکی)",
"fa": "Persian (فارسی)",
"ur": "Urdu (اردو)",
"bn": "Bengali (بنگالی)",
"id": "Indonesian (اندونزیایی)",
"fr": "French (فرانسوی)",
"ru": "Russian (روسی)",
"uz": "Uzbek (ازبکی)",
"ky": "Kyrgyz (قرقیزی)",
"tg": "Tajik (تاجیکی)",
"az": "Azerbaijani (آذربایجانی)",
"en": "English (انگلیسی)",
"de": "German (آلمانی)",
"zh": "Mandarin Chinese (چینی ماندارین)",
"ha": "Hausa (هوسا)",
"sw": "Swahili (سواحیلی)",
"es": "Spanish (اسپانیایی)",
}
DEFAULT_API_URL = "http://ai.newhorizonco.uk/v1/chat/completions"
DEFAULT_API_KEY = "hZwAGW4H8v87Ol8tXvqEEY"
DEFAULT_MODEL = "gemini-3.6-flash-high"
class ThreadSafeRateLimiter:
"""
Thread-safe sliding window rate limiter.
Guarantees max `max_calls` executions per `period` seconds across all threads.
"""
def __init__(self, max_calls: int = 5, period: float = 1.0):
self.max_calls = max_calls
self.period = period
self.timestamps = []
self.lock = threading.Lock()
def acquire(self):
with self.lock:
while True:
now = time.time()
# Remove timestamps outside the sliding window
self.timestamps = [t for t in self.timestamps if now - t < self.period]
if len(self.timestamps) < self.max_calls:
self.timestamps.append(now)
break
# Wait until the oldest timestamp drops out of the 1-second window
sleep_time = self.period - (now - self.timestamps[0]) + 0.005
if sleep_time > 0:
time.sleep(sleep_time)
class Command(BaseCommand):
help = "Translates CalendarOccasions titles into target languages concurrently (5 req/sec)."
def add_arguments(self, parser):
parser.add_argument(
"--rate-limit",
type=int,
default=5,
help="Maximum API requests per second (default: 5)",
)
parser.add_argument(
"--workers",
type=int,
default=5,
help="Number of concurrent worker threads (default: 5)",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Limit number of occasions to translate (0 = all)",
)
parser.add_argument(
"--force",
action="store_true",
help="Force re-translation of all target languages even if present",
)
parser.add_argument(
"--api-url",
type=str,
default=DEFAULT_API_URL,
help="API endpoint URL",
)
parser.add_argument(
"--api-key",
type=str,
default=DEFAULT_API_KEY,
help="API bearer token",
)
parser.add_argument(
"--model",
type=str,
default=DEFAULT_MODEL,
help="AI model name",
)
def safe_write(self, msg: str, style_func=None):
"""Helper to write to stdout safely, catching Windows charmap encoding errors."""
if style_func:
msg = style_func(msg)
try:
self.stdout.write(msg)
except Exception:
enc = getattr(sys.stdout, "encoding", "utf-8") or "utf-8"
safe_msg = msg.encode(enc, errors="replace").decode(enc)
try:
self.stdout.write(safe_msg)
except Exception:
pass
def handle(self, *args, **options):
rate_limit = options["rate_limit"]
max_workers = options["workers"]
limit = options["limit"]
force = options["force"]
api_url = options["api_url"]
api_key = options["api_key"]
model = options["model"]
self.safe_write(
f"Starting Calendar Occasions translation script...\n"
f" Rate Limit: {rate_limit} req/sec\n"
f" Workers: {max_workers}\n"
f" Target Languages: {len(TARGET_LANGUAGES)} languages\n"
f" Force Re-translate: {force}",
self.style.WARNING,
)
rate_limiter = ThreadSafeRateLimiter(max_calls=rate_limit, period=1.0)
queryset = CalendarOccasions.objects.all().order_by("id")
if limit > 0:
queryset = queryset[:limit]
occasions = list(queryset)
total_count = len(occasions)
if total_count == 0:
self.safe_write("No calendar occasions found in database.", self.style.WARNING)
return
self.safe_write(f"Found {total_count} occasion objects to process.", self.style.SUCCESS)
success_count = 0
skipped_count = 0
failed_count = 0
lock = threading.Lock()
def process_occasion(occasion):
nonlocal success_count, skipped_count, failed_count
# Extract current title field structure
current_titles = occasion.title
if isinstance(current_titles, str):
current_titles = [{"text": current_titles, "title": current_titles, "language_code": "fa"}]
elif not isinstance(current_titles, list):
current_titles = []
# Map existing language_codes
existing_by_lang = {}
for item in current_titles:
if isinstance(item, dict) and "language_code" in item:
existing_by_lang[item["language_code"]] = item
# Determine source Persian text (or any available text)
source_text = None
if "fa" in existing_by_lang:
source_text = existing_by_lang["fa"].get("title") or existing_by_lang["fa"].get("text")
if not source_text and current_titles:
first_item = current_titles[0]
if isinstance(first_item, dict):
source_text = first_item.get("title") or first_item.get("text")
elif isinstance(first_item, str):
source_text = first_item
if not source_text:
with lock:
skipped_count += 1
self.safe_write(f"[ID {occasion.id}] Skipped - No source text available", self.style.NOTICE)
return
# Find missing target languages
if force:
missing_langs = list(TARGET_LANGUAGES.keys())
else:
missing_langs = [
lang for lang in TARGET_LANGUAGES.keys()
if lang not in existing_by_lang or not (existing_by_lang[lang].get("title") or existing_by_lang[lang].get("text"))
]
if not missing_langs:
with lock:
skipped_count += 1
self.safe_write(
f"[ID {occasion.id}] Skipped - All {len(TARGET_LANGUAGES)} languages already present ({source_text})",
self.style.SUCCESS,
)
return
# Call AI API to translate missing languages
translations = self.call_translation_api(
source_text=source_text,
missing_langs=missing_langs,
rate_limiter=rate_limiter,
api_url=api_url,
api_key=api_key,
model=model,
)
if not translations:
with lock:
failed_count += 1
self.safe_write(
f"[ID {occasion.id}] Failed - API returned no translations for '{source_text}'",
self.style.ERROR,
)
return
# Merge translations into occasion.title
for lang_code, translated_val in translations.items():
if translated_val and isinstance(translated_val, str):
clean_val = translated_val.strip()
existing_by_lang[lang_code] = {
"text": clean_val,
"title": clean_val,
"language_code": lang_code,
}
# Reconstruct title array keeping TARGET_LANGUAGES order first
new_title_list = []
for code in TARGET_LANGUAGES.keys():
if code in existing_by_lang:
new_title_list.append(existing_by_lang.pop(code))
# Append any remaining unexpected language objects
for item in existing_by_lang.values():
new_title_list.append(item)
occasion.title = new_title_list
occasion.save(update_fields=["title", "updated_at"])
with lock:
success_count += 1
self.safe_write(
f"[ID {occasion.id}] Translated successfully into {len(translations)} languages ('{source_text}')",
self.style.SUCCESS,
)
# Run concurrent workers
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [executor.submit(process_occasion, occ) for occ in occasions]
for future in as_completed(futures):
try:
future.result()
except Exception as exc:
self.safe_write(f"Unhandled error in thread: {exc}", self.style.ERROR)
self.safe_write(
f"\nTranslation completed!\n"
f" Total Processed: {total_count}\n"
f" Successfully Updated: {success_count}\n"
f" Skipped (Up to date): {skipped_count}\n"
f" Failed: {failed_count}",
self.style.SUCCESS,
)
def call_translation_api(
self,
source_text: str,
missing_langs: list,
rate_limiter: ThreadSafeRateLimiter,
api_url: str,
api_key: str,
model: str,
max_retries: int = 3,
) -> dict:
"""
Calls the AI completions API to translate source_text into missing_langs.
Enforces rate limiting prior to each request attempt.
"""
targets_desc = json.dumps({code: TARGET_LANGUAGES[code] for code in missing_langs}, ensure_ascii=False)
prompt = (
f"You are a professional translator for calendar events and occasions.\n"
f"Translate the following calendar occasion title from Persian into the specified target languages.\n\n"
f"Original Title: \"{source_text}\"\n\n"
f"Target Languages (JSON map of code -> Language Name):\n{targets_desc}\n\n"
f"INSTRUCTIONS:\n"
f"1. Translate accurately into each requested language code.\n"
f"2. Output strictly a JSON object mapping language_code to translation string.\n"
f"3. Do NOT include markdown code fences (```json or ```), explanations, or outside text.\n\n"
f"Example Output:\n"
f"{{\"en\": \"New Year\", \"ar\": \"السنة الجديدة\"}}"
)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"stream": False,
"messages": [
{
"role": "user",
"content": prompt,
}
],
}
for attempt in range(1, max_retries + 1):
rate_limiter.acquire()
try:
response = requests.post(api_url, headers=headers, json=payload, timeout=20)
if response.status_code == 200:
res_data = response.json()
raw_content = res_data["choices"][0]["message"]["content"].strip()
# Clean any markdown code blocks if present
if raw_content.startswith("```"):
lines = raw_content.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].startswith("```"):
lines = lines[:-1]
raw_content = "\n".join(lines).strip()
try:
parsed = json.loads(raw_content)
if isinstance(parsed, dict):
# Filter only requested target language keys
return {k: v for k, v in parsed.items() if k in missing_langs and isinstance(v, str)}
except json.JSONDecodeError:
# Regex extract JSON substring if extra text was included
match = re.search(r"\{.*\}", raw_content, re.DOTALL)
if match:
try:
parsed = json.loads(match.group(0))
if isinstance(parsed, dict):
return {k: v for k, v in parsed.items() if k in missing_langs and isinstance(v, str)}
except json.JSONDecodeError:
pass
except Exception as e:
if attempt == max_retries:
self.safe_write(f"API Call exception on attempt {attempt}: {e}", self.style.ERROR)
time.sleep(1)
return {}

35
apps/hadis/docs.py

@ -506,6 +506,41 @@ hadis_list_swagger = swagger_auto_schema(
required=True, required=True,
example='-330' example='-330'
), ),
openapi.Parameter(
'status',
openapi.IN_QUERY,
description="Filter by HadisStatus slug(s), comma-separated (e.g. 'sahih,hasan')",
type=openapi.TYPE_STRING,
required=False
),
openapi.Parameter(
'source',
openapi.IN_QUERY,
description="Filter by BookReference slug(s), comma-separated (e.g. 'al-kafi,sahih-al-bukhari')",
type=openapi.TYPE_STRING,
required=False
),
openapi.Parameter(
'author',
openapi.IN_QUERY,
description="Filter by BookAuthor slug(s) or ID(s), comma-separated",
type=openapi.TYPE_STRING,
required=False
),
openapi.Parameter(
'transmitter',
openapi.IN_QUERY,
description="Filter by Transmitter slug(s), comma-separated",
type=openapi.TYPE_STRING,
required=False
),
openapi.Parameter(
'search',
openapi.IN_QUERY,
description="Search query across text, title, narrator, and translations",
type=openapi.TYPE_STRING,
required=False
),
openapi.Parameter( openapi.Parameter(
'is_bookmark', 'is_bookmark',
openapi.IN_QUERY, openapi.IN_QUERY,

7
apps/hadis/models/category.py

@ -92,6 +92,13 @@ class HadisCategory(LowercaseSlugMixin, MPTTModel):
f'Parent sect: {self.parent.sect.sect_type}, ' f'Parent sect: {self.parent.sect.sect_type}, '
f'Your sect: {self.sect.sect_type}') f'Your sect: {self.sect.sect_type}')
) )
if self.parent and self.parent.parent is not None:
if self.source_type != self.parent.source_type:
raise ValidationError(
_('Child category must have the same source_type as its parent when parent is not a root category. '
f'Parent source_type: {self.parent.source_type}, '
f'Your source_type: {self.source_type}')
)
slug_source_field = 'title' slug_source_field = 'title'

10
apps/hadis/views/hadis.py

@ -290,6 +290,16 @@ class HadisListView(ListAPIView):
source_slugs = [s.strip() for s in source_filter.split(',')] source_slugs = [s.strip() for s in source_filter.split(',')]
queryset = queryset.filter(references__book_reference__slug__in=source_slugs) queryset = queryset.filter(references__book_reference__slug__in=source_slugs)
# 👇 5. Apply Source Author Filter (Supports author or source_author, multiple comma-separated slugs/IDs)
author_filter = self.request.query_params.get('author', None) or self.request.query_params.get('source_author', None)
if author_filter:
author_slugs = [a.strip() for a in author_filter.split(',') if a.strip()]
author_q = Q(references__book_reference__author__slug__in=author_slugs)
numeric_ids = [int(a) for a in author_slugs if a.isdigit()]
if numeric_ids:
author_q |= Q(references__book_reference__author_id__in=numeric_ids)
queryset = queryset.filter(author_q)
# Filter by bookmarks if provided # Filter by bookmarks if provided
is_bookmark = self.request.query_params.get('is_bookmark', '').lower() is_bookmark = self.request.query_params.get('is_bookmark', '').lower()
if is_bookmark == 'true' and self.request.user.is_authenticated: if is_bookmark == 'true' and self.request.user.is_authenticated:

11
apps/hadis/views_admin.py

@ -506,6 +506,17 @@ class AdminBookReferenceImageViewSet(ModelViewSet):
queryset = queryset.filter(book_reference_id=book_reference_id) queryset = queryset.filter(book_reference_id=book_reference_id)
return queryset.order_by("order", "id") return queryset.order_by("order", "id")
@action(detail=False, methods=["post"])
def reorder(self, request):
image_ids = request.data.get("image_ids", []) or request.data.get("ids", [])
if not image_ids:
return Response({"error": "image_ids list is required"}, status=400)
for index, img_id in enumerate(image_ids, start=1):
BookReferenceImage.objects.filter(id=img_id).update(order=index)
return Response({"status": "success"})
class AdminBookReferenceDocumentViewSet(ModelViewSet): class AdminBookReferenceDocumentViewSet(ModelViewSet):
serializer_class = AdminBookReferenceDocumentSerializer serializer_class = AdminBookReferenceDocumentSerializer

6
apps/video/serializers_dovodi.py

@ -79,11 +79,7 @@ class DovodiVideoItemSerializer(serializers.ModelSerializer):
def get_stream_url(self, obj): def get_stream_url(self, obj):
if obj.video_type == Video.VedioTypeChoices.YOUTUBE_LINK: if obj.video_type == Video.VedioTypeChoices.YOUTUBE_LINK:
if obj.video_url:
from utils.youtube import get_youtube_stream_url
extracted = get_youtube_stream_url(obj.video_url)
return extracted or obj.video_url
return None
return obj.video_url
elif obj.video_type == Video.VedioTypeChoices.VIDEO_FILE: elif obj.video_type == Video.VedioTypeChoices.VIDEO_FILE:
if obj.video_file: if obj.video_file:
request = self.context.get('request') request = self.context.get('request')

1
requirements.txt

@ -135,6 +135,7 @@ pyjwt
cryptography>=41.0.0 cryptography>=41.0.0
django-celery-beat==2.5.0 django-celery-beat==2.5.0
yt-dlp>=2024.3.10 yt-dlp>=2024.3.10
openpyxl==3.1.5
https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-limitless-dashboard.git/archive/master.zip https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-limitless-dashboard.git/archive/master.zip

119
utils/__init__.py

@ -95,8 +95,8 @@ def send_email(recipient, code):
import requests import requests
from django.conf import settings from django.conf import settings
if not settings.RESEND_API_KEY:
print("RESEND_API_KEY is not set in settings.")
if not getattr(settings, "RESEND_API_KEY", None):
logger.warning("RESEND_API_KEY is not set in settings.")
return False return False
url = "https://api.resend.com/emails" url = "https://api.resend.com/emails"
@ -105,48 +105,97 @@ def send_email(recipient, code):
"Content-Type": "application/json", "Content-Type": "application/json",
} }
subject = 'Verification Code'
site_domain = getattr(settings, "SITE_DOMAIN", "https://dovodi.newhorizonco.uk/")
subject = "Код подтверждения | Verification Code"
html_content = f""" html_content = f"""
<div
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; max-width: 600px; margin: 0 auto; background-color: #FAF6E9; padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.05); border: 1px solid #25D076; overflow: hidden;">
<!-- Logo / Header Section with Vertical Gradient -->
<!-- The negative margin makes it stretch flush to the edges of the padded container -->
<div
style="text-align: center; padding: 40px 20px 40px 20px; margin: -20px -20px 25px -20px; background: linear-gradient(to bottom, #052B18 30%, #0A522E 80%);">
<!-- UPDATE THE SRC BELOW WITH YOUR ACTUAL LOGO URL -->
<img src="{settings.SITE_DOMAIN}/static/images/logo1.svg" alt="Imam Javad Online School Logo" style="width: 150px; height: auto;">
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Код подтверждения | Verification Code</title>
</head>
<body style="margin: 0; padding: 0; background-color: #F4F6F9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; color: #15171C; -webkit-font-smoothing: antialiased;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed; background-color: #F4F6F9; padding: 40px 16px;">
<tr>
<td align="center" valign="top">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 540px; background-color: #FFFFFF; border-radius: 16px; border: 1px solid #E2E8F0; box-shadow: 0 4px 20px -2px rgba(21, 23, 28, 0.06); overflow: hidden;">
<!-- Top Gradient Accent Line -->
<tr>
<td style="height: 4px; background: linear-gradient(90deg, #5172E1 0%, #1C458C 100%); line-height: 4px; font-size: 4px;">&nbsp;</td>
</tr>
<!-- Header with Logo -->
<tr>
<td style="padding: 32px 32px 20px 32px; text-align: center; background-color: #FAFAFC; border-bottom: 1px solid #F1F3F7;">
<img src="{site_domain}/static/images/dovoodi_logo.svg" alt="Dovodi Logo" style="height: 38px; width: auto; max-width: 160px; display: inline-block;" />
</td>
</tr>
<!-- Main Body Section -->
<tr>
<td style="padding: 36px 36px 28px 36px;">
<!-- Security Badge -->
<div style="text-align: center; margin-bottom: 16px;">
<span style="display: inline-block; background-color: #EEF2FF; color: #3B66DE; border: 1px solid #D5DEFF; padding: 4px 12px; border-radius: 9999px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;">
Безопасность / Security
</span>
</div> </div>
<!-- Main Heading -->
<h2 style="color: #0A522E; text-align: center; font-weight: 800; margin-top: 0;">Verification Code</h2>
<!-- Main Title -->
<h1 style="margin: 0 0 12px 0; font-size: 22px; font-weight: 800; color: #15171C; text-align: center; line-height: 1.3;">
Код подтверждения
</h1>
<!-- Greeting and Intro -->
<p style="font-size: 17px; color: #333333; line-height: 1.6; margin-bottom: 15px;">Hello,</p>
<p style="font-size: 17px; color: #333333; line-height: 1.6;">Your verification code for <strong
style="color: #0A522E;">Imam Javad Online School</strong> is:</p>
<!-- Intro Message -->
<p style="margin: 0 0 24px 0; font-size: 14px; color: #646A75; text-align: center; line-height: 1.6;">
Используйте следующий одноразовый код для входа или подтверждения учетной записи в <strong>Dovodi</strong>:
</p>
<!-- Stylized Verification Code Box -->
<div style="text-align: center; margin: 40px 0;">
<span
style="font-size: 40px; font-weight: 900; color: #0A522E; letter-spacing: 6px; background-color: rgba(37, 208, 118, 0.2); padding: 15px 30px; border-radius: 8px; border: 2px dashed #0A522E; display: inline-block;">{code}</span>
<!-- OTP Box -->
<div style="text-align: center; margin: 28px 0; padding: 22px; background-color: #F8FAFD; border: 1.5px dashed #5172E1; border-radius: 14px;">
<span style="font-family: 'SF Pro Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 38px; font-weight: 800; color: #15171C; letter-spacing: 10px; display: inline-block;">
{code}
</span>
</div> </div>
<!-- Disclaimer -->
<p style="font-size: 14px; color: #777777; text-align: center; margin-top: 10px;">This code will expire shortly. If
you did not request this code, please ignore this email.</p>
<!-- Expiry & Security Notice -->
<div style="background-color: #FFFBEB; border: 1px solid #FDE68A; border-radius: 10px; padding: 12px 16px; margin-bottom: 24px;">
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="font-size: 12px; color: #92400E; line-height: 1.5;">
<strong>Внимание:</strong> Срок действия кода истекает через 5 минут. Если вы не запрашивали этот код, просто проигнорируйте это письмо.
</td>
</tr>
</table>
</div>
<p style="margin: 0; font-size: 13px; color: #8C93A0; text-align: center; line-height: 1.5;">
Никому не передавайте этот код в целях безопасности вашего аккаунта.
</p>
</td>
</tr>
<!-- Footer Section --> <!-- Footer Section -->
<hr style="border: 0; border-top: 1px solid #d4d0c3; margin: 30px 0;">
<p style="font-size: 13px; color: #999999; text-align: center; line-height: 1.5; margin-bottom: 0;">
<strong style="color: #0A522E;">Imam Javad Online School</strong><br>
имам джавад | امام جواد<br>
Learn more: <a href="https://imamjavad.nwhco.ir/"
style="color: #0A522E; text-decoration: none;">imamjavad.nwhco.ir</a><br>
<span style="font-style: italic;">Contact us: <a href="mailto:support@yourwebsite.com"
style="color: #0A522E; text-decoration: none;">support@yourwebsite.com</a></span>
<tr>
<td style="padding: 24px 36px; background-color: #F8F9FB; border-top: 1px solid #EEF0F4; text-align: center;">
<p style="margin: 0 0 6px 0; font-size: 13px; font-weight: 700; color: #15171C;">
Dovodi
</p>
<p style="margin: 0 0 12px 0; font-size: 12px; color: #646A75;">
<a href="https://dovodi.newhorizonco.uk/" style="color: #5172E1; text-decoration: none; font-weight: 600;">dovodi.newhorizonco.uk</a>
&nbsp;&nbsp;
<a href="mailto:[EMAIL_ADDRESS]" style="color: #5172E1; text-decoration: none; font-weight: 600;">[EMAIL_ADDRESS]</a>
</p>
<p style="margin: 0; font-size: 11px; color: #9AA0AC; line-height: 1.4;">
© 2026 Dovodi. Все права защищены.
</p> </p>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
""" """
payload = { payload = {
@ -161,7 +210,7 @@ def send_email(recipient, code):
response.raise_for_status() response.raise_for_status()
return True return True
except Exception as e: except Exception as e:
print(f"Failed to send email via Resend: {str(e)}")
logger.error(f"Failed to send email via Resend: {str(e)}")
return False return False

121
utils/excel_exporter.py

@ -0,0 +1,121 @@
import io
from datetime import datetime, date
from typing import List, Any
from django.http import HttpResponse
from django.utils import timezone
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
def export_to_excel_response(
filename: str,
headers: List[str],
rows: List[List[Any]],
sheet_title: str = "Export"
) -> HttpResponse:
"""
Generates a beautifully formatted Excel (.xlsx) file and returns it as a Django HttpResponse.
:param filename: Base name of the file (without extension or with .xlsx)
:param headers: List of column header names
:param rows: List of data rows (each row is a list of cell values)
:param sheet_title: Title for the worksheet
:return: HttpResponse with .xlsx content type and attachment disposition
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = sheet_title[:31] # Excel sheet title max 31 chars
# Enable gridlines
ws.views.sheetView[0].showGridLines = True
# 1. Styles Definition
# Modern dark indigo / slate header style
header_fill = PatternFill(start_color="1E293B", end_color="1E293B", fill_type="solid")
header_font = Font(name="Calibri", size=11, bold=True, color="FFFFFF")
header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
data_font = Font(name="Calibri", size=10, color="0F172A")
data_alignment_left = Alignment(horizontal="left", vertical="center")
data_alignment_center = Alignment(horizontal="center", vertical="center")
thin_border_side = Side(border_style="thin", color="E2E8F0")
border_style = Border(
left=thin_border_side,
right=thin_border_side,
top=thin_border_side,
bottom=thin_border_side
)
zebra_fill = PatternFill(start_color="F8FAFC", end_color="F8FAFC", fill_type="solid")
# 2. Write Headers
ws.append(headers)
header_row = ws[1]
ws.row_dimensions[1].height = 28
for col_idx, cell in enumerate(header_row, 1):
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border_style
# 3. Write Data Rows
for row_idx, row_data in enumerate(rows, start=2):
formatted_row = []
for val in row_data:
if val is None:
formatted_row.append("")
elif isinstance(val, (datetime, date)):
if isinstance(val, datetime) and timezone.is_aware(val):
val = timezone.localtime(val)
formatted_row.append(val.strftime("%Y-%m-%d %H:%M"))
elif isinstance(val, bool):
formatted_row.append("Yes" if val else "No")
else:
formatted_row.append(str(val))
ws.append(formatted_row)
current_row = ws[row_idx]
ws.row_dimensions[row_idx].height = 22
is_even = (row_idx % 2 == 0)
for col_idx, cell in enumerate(current_row, 1):
cell.font = data_font
cell.border = border_style
if is_even:
cell.fill = zebra_fill
# Align IDs or numbers center, text left
val = row_data[col_idx - 1] if col_idx - 1 < len(row_data) else None
if isinstance(val, (int, float, date, datetime)) or (isinstance(val, str) and val.isdigit()):
cell.alignment = data_alignment_center
else:
cell.alignment = data_alignment_left
# 4. Auto-fit column widths (with min 12 and max 50)
for col in ws.columns:
max_len = 0
col_letter = get_column_letter(col[0].column)
for cell in col:
val_str = str(cell.value or "")
if len(val_str) > max_len:
max_len = len(val_str)
adjusted_width = min(max(max_len + 4, 12), 50)
ws.column_dimensions[col_letter].width = adjusted_width
# 5. Output to buffer
output = io.BytesIO()
wb.save(output)
output.seek(0)
# Ensure .xlsx extension in filename
clean_filename = filename if filename.endswith(".xlsx") else f"{filename}.xlsx"
response = HttpResponse(
output.getvalue(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
response["Content-Disposition"] = f'attachment; filename="{clean_filename}"'
response["Access-Control-Expose-Headers"] = "Content-Disposition"
return response
Loading…
Cancel
Save