3 Commits

  1. 18
      apps/hadis/migrations/0034_bookauthor_image.py
  2. 18
      apps/hadis/migrations/0035_bookreference_has_editions.py
  3. 13
      apps/hadis/models/reference.py
  4. 22
      apps/hadis/serializers/reference_v2.py
  5. 34
      apps/hadis/serializers/serializers_admin.py
  6. 6
      config/settings/base.py
  7. 53
      utils/__init__.py

18
apps/hadis/migrations/0034_bookauthor_image.py

@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-08-23 16:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hadis', '0033_bookeditor'),
]
operations = [
migrations.AddField(
model_name='bookauthor',
name='image',
field=models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='hadis/author_images/', verbose_name='Image'),
),
]

18
apps/hadis/migrations/0035_bookreference_has_editions.py

@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-08-24 10:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hadis', '0034_bookauthor_image'),
]
operations = [
migrations.AddField(
model_name='bookreference',
name='has_editions',
field=models.BooleanField(default=False, help_text='True if this reference has one or more editions, False for direct volumes without edition structure', verbose_name='Has Editions Structure'),
),
]

13
apps/hadis/models/reference.py

@ -125,6 +125,11 @@ class BookReference(LowercaseSlugMixin, models.Model):
null=True, null=True,
help_text=_('Rating from 0 to 5') help_text=_('Rating from 0 to 5')
) )
has_editions = models.BooleanField(
default=False,
verbose_name=_('Has Editions Structure'),
help_text=_('True if this reference has one or more editions, False for direct volumes without edition structure')
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
@ -352,7 +357,13 @@ class BookAuthor(LowercaseSlugMixin, models.Model):
birth_year_miladi = models.IntegerField(verbose_name=_("Birth Year (Miladi)"), null=True, blank=True) birth_year_miladi = models.IntegerField(verbose_name=_("Birth Year (Miladi)"), null=True, blank=True)
death_year_miladi = models.IntegerField(verbose_name=_("Death Year (Miladi)"), null=True, blank=True) death_year_miladi = models.IntegerField(verbose_name=_("Death Year (Miladi)"), null=True, blank=True)
image = models.ImageField(
upload_to='hadis/author_images/',
null=True,
blank=True,
help_text=_('image allowed'),
verbose_name=_('Image')
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))

22
apps/hadis/serializers/reference_v2.py

@ -17,7 +17,7 @@ class BookAuthorV2Serializer(serializers.ModelSerializer):
name = LocalizedField() name = LocalizedField()
class Meta: class Meta:
model = BookAuthor model = BookAuthor
fields = ['id', 'name', 'slug']
fields = ['id', 'name', 'slug', 'image']
class BookResearcherV2Serializer(serializers.ModelSerializer): class BookResearcherV2Serializer(serializers.ModelSerializer):
name = LocalizedField() name = LocalizedField()
@ -152,7 +152,7 @@ class BookReferenceV2DetailSerializer(serializers.ModelSerializer):
paginator.page_size = 20 paginator.page_size = 20
request = self.context.get("request") request = self.context.get("request")
paginated_hadis = paginator.paginate_queryset(hadis_qs, request)
paginated_hadis = paginator.paginate_queryset(hadis_qs, request) if request else None
if paginated_hadis is not None: if paginated_hadis is not None:
serializer = HadisShortSerializer(paginated_hadis, many=True, context=self.context) serializer = HadisShortSerializer(paginated_hadis, many=True, context=self.context)
@ -171,8 +171,8 @@ class BookReferenceV2DetailSerializer(serializers.ModelSerializer):
} }
def get_volumes(self, obj): def get_volumes(self, obj):
editions = obj.editions.all()
if editions.exists():
if obj.has_editions:
editions = obj.editions.all()
editions_data = [] editions_data = []
for ed in editions: for ed in editions:
editions_data.append({ editions_data.append({
@ -189,7 +189,7 @@ class BookReferenceV2DetailSerializer(serializers.ModelSerializer):
return { return {
"has_editions": False, "has_editions": False,
"editions_volumes": [], "editions_volumes": [],
"all_volumes": BookVolumeV2Serializer(obj.volumes.all(), many=True, context=self.context).data
"all_volumes": BookVolumeV2Serializer(obj.volumes.filter(edition__isnull=True), many=True, context=self.context).data
} }
class BookReferenceV2SyncSerializer(serializers.ModelSerializer): class BookReferenceV2SyncSerializer(serializers.ModelSerializer):
@ -205,7 +205,7 @@ class BookReferenceV2SyncSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = BookReference model = BookReference
fields = [ fields = [
'id', 'title', 'slug', 'share_link', 'rate', 'authors',
'id', 'title', 'slug', 'share_link', 'rate', 'has_editions', 'authors',
'attributes', 'images', 'information', 'hadises', 'volumes' 'attributes', 'images', 'information', 'hadises', 'volumes'
] ]
@ -231,8 +231,8 @@ class BookReferenceV2SyncSerializer(serializers.ModelSerializer):
return HadisShortSerializer(hadis_list, many=True, context=self.context).data return HadisShortSerializer(hadis_list, many=True, context=self.context).data
def get_volumes(self, obj): def get_volumes(self, obj):
editions = obj.editions.all()
if editions.exists():
if obj.has_editions:
editions = obj.editions.all()
editions_data = [] editions_data = []
for ed in editions: for ed in editions:
editions_data.append({ editions_data.append({
@ -249,7 +249,7 @@ class BookReferenceV2SyncSerializer(serializers.ModelSerializer):
return { return {
"has_editions": False, "has_editions": False,
"editions_volumes": [], "editions_volumes": [],
"all_volumes": BookVolumeV2Serializer(obj.volumes.all(), many=True, context=self.context).data
"all_volumes": BookVolumeV2Serializer(obj.volumes.filter(edition__isnull=True), many=True, context=self.context).data
} }
from apps.hadis.models.reference import BookAuthor, BookReference from apps.hadis.models.reference import BookAuthor, BookReference
@ -261,7 +261,7 @@ class BookAuthorListSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = BookAuthor model = BookAuthor
fields = ['id', 'slug', 'name', 'birth_year_hijri', 'birth_year_miladi']
fields = ['id', 'slug', 'name', 'image', 'birth_year_hijri', 'birth_year_miladi']
class BookAuthorDetailSerializer(serializers.ModelSerializer): class BookAuthorDetailSerializer(serializers.ModelSerializer):
@ -277,7 +277,7 @@ class BookAuthorDetailSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = BookAuthor model = BookAuthor
fields = [ fields = [
'id', 'slug', 'name', 'known_as', 'scholarity',
'id', 'slug', 'name', 'image', 'known_as', 'scholarity',
'tabaqa', 'rutba', 'features', 'tabaqa', 'rutba', 'features',
'birth_year_hijri', 'death_year_hijri', 'birth_year_hijri', 'death_year_hijri',
'birth_year_miladi', 'death_year_miladi', 'birth_year_miladi', 'death_year_miladi',

34
apps/hadis/serializers/serializers_admin.py

@ -823,12 +823,17 @@ class AdminBookTypeSerializer(serializers.ModelSerializer):
class AdminBookAuthorSerializer(serializers.ModelSerializer): class AdminBookAuthorSerializer(serializers.ModelSerializer):
image = AbsoluteImageField(required=False, allow_null=True)
remove_image = serializers.BooleanField(write_only=True, required=False, default=False)
class Meta: class Meta:
model = BookAuthor model = BookAuthor
fields = [ fields = [
"id", "id",
"name", "name",
"slug", "slug",
"image",
"remove_image",
"birth_year_hijri", "birth_year_hijri",
"death_year_hijri", "death_year_hijri",
"birth_year_miladi", "birth_year_miladi",
@ -843,6 +848,33 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
] ]
read_only_fields = ["id", "slug"] read_only_fields = ["id", "slug"]
def to_internal_value(self, data):
import json
mutable_data = safe_copy_data(data)
for json_field in ["name", "known_as", "scholarity", "tabaqa", "rutba", "features", "book_references"]:
val = mutable_data.get(json_field)
if isinstance(val, str):
try:
mutable_data[json_field] = json.loads(val)
except ValueError:
pass
for int_field in ["birth_year_hijri", "death_year_hijri", "birth_year_miladi", "death_year_miladi", "related_narrator"]:
val = mutable_data.get(int_field)
if val == "" or val == "null" or val == "undefined":
mutable_data[int_field] = None
return super().to_internal_value(mutable_data)
def create(self, validated_data):
validated_data.pop("remove_image", False)
return super().create(validated_data)
def update(self, instance, validated_data):
remove_image = validated_data.pop("remove_image", False)
if remove_image and instance.image:
instance.image.delete(save=False)
instance.image = None
return super().update(instance, validated_data)
class AdminBookAttributeSerializer(serializers.ModelSerializer): class AdminBookAttributeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@ -885,6 +917,7 @@ class AdminBookReferenceListSerializer(serializers.ModelSerializer):
"id", "id",
"title", "title",
"slug", "slug",
"has_editions",
"volume", "volume",
"publisher", "publisher",
"year_of_publication", "year_of_publication",
@ -981,6 +1014,7 @@ class AdminBookReferenceDetailSerializer(serializers.ModelSerializer):
"title", "title",
"description", "description",
"language", "language",
"has_editions",
"isbn", "isbn",
"volume", "volume",
"number_of_volumes", "number_of_volumes",

6
config/settings/base.py

@ -105,10 +105,8 @@ REDIS_URL = env('REDIS_URL')
OTP_SERIVCE_KEY = "33213d78f1234e99b81f94eefda77e45"
RESEND_API_KEY = "re_JFFAfESy_JHmJTJLu5ToGTPhwrZx7trKd"
RESEND_FROM_EMAIL = "info@imamjavad.online"
RESEND_API_KEY = env('RESEND_API_KEY', default="re_JFFAfESy_JHmJTJLu5ToGTPhwrZx7trKd")
RESEND_FROM_EMAIL = env('RESEND_FROM_EMAIL', default="Dovodi <info@imamjavad.online>")
PHONENUMBER_DEFAULT_REGION = "IR" PHONENUMBER_DEFAULT_REGION = "IR"

53
utils/__init__.py

@ -90,22 +90,38 @@ def environment_callback(request):
return [_("Production"), "primary"] return [_("Production"), "primary"]
def send_email(recipient, code): def send_email(recipient, code):
import requests import requests
from django.conf import settings from django.conf import settings
if not getattr(settings, "RESEND_API_KEY", None):
api_key = getattr(settings, "RESEND_API_KEY", None)
if not api_key:
logger.warning("RESEND_API_KEY is not set in settings.") 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"
headers = { headers = {
"Authorization": f"Bearer {settings.RESEND_API_KEY}",
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
site_domain = getattr(settings, "SITE_DOMAIN", "https://dovodi.newhorizonco.uk/")
# Normalize recipient into a clean list of email strings
if isinstance(recipient, str):
to_emails = [recipient.strip()]
elif isinstance(recipient, (list, tuple, set)):
to_emails = [str(r).strip() for r in recipient if r and str(r).strip()]
else:
to_emails = [str(recipient).strip()]
if not to_emails:
logger.error(f"Cannot send email: recipient list is empty ({recipient})")
return False
from_email = getattr(settings, "RESEND_FROM_EMAIL", "Dovodi <info@imamjavad.online>")
if "<" not in from_email and "@" in from_email:
from_email = f"Dovodi <{from_email}>"
site_domain = getattr(settings, "SITE_DOMAIN", "https://dovodi.newhorizonco.uk/").rstrip('/')
subject = "Код подтверждения | Verification Code" subject = "Код подтверждения | Verification Code"
html_content = f""" html_content = f"""
@ -128,7 +144,7 @@ def send_email(recipient, code):
<!-- Header with Logo --> <!-- Header with Logo -->
<tr> <tr>
<td style="padding: 32px 32px 20px 32px; text-align: center; background-color: #FAFAFC; border-bottom: 1px solid #F1F3F7;"> <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;" />
<img src="{site_domain}/static/images/dovoodi_logo.svg" alt="Dovodi" style="height: 38px; width: auto; max-width: 160px; display: inline-block;" />
</td> </td>
</tr> </tr>
<!-- Main Body Section --> <!-- Main Body Section -->
@ -148,7 +164,7 @@ def send_email(recipient, code):
<!-- Intro Message --> <!-- Intro Message -->
<p style="margin: 0 0 24px 0; font-size: 14px; color: #646A75; text-align: center; line-height: 1.6;"> <p style="margin: 0 0 24px 0; font-size: 14px; color: #646A75; text-align: center; line-height: 1.6;">
Используйте следующий одноразовый код для входа или подтверждения учетной записи в <strong>Dovodi</strong>:
Используйте следующий одноразовый код для входа یا подтверждения учетной записи в <strong>Dovodi</strong>:
</p> </p>
<!-- OTP Box --> <!-- OTP Box -->
@ -183,7 +199,7 @@ def send_email(recipient, code):
<p style="margin: 0 0 12px 0; font-size: 12px; color: #646A75;"> <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> <a href="https://dovodi.newhorizonco.uk/" style="color: #5172E1; text-decoration: none; font-weight: 600;">dovodi.newhorizonco.uk</a>
&nbsp;&nbsp; &nbsp;&nbsp;
<a href="mailto:[EMAIL_ADDRESS]" style="color: #5172E1; text-decoration: none; font-weight: 600;">[EMAIL_ADDRESS]</a>
<a href="mailto:info@imamjavad.online" style="color: #5172E1; text-decoration: none; font-weight: 600;">info@imamjavad.online</a>
</p> </p>
<p style="margin: 0; font-size: 11px; color: #9AA0AC; line-height: 1.4;"> <p style="margin: 0; font-size: 11px; color: #9AA0AC; line-height: 1.4;">
© 2026 Dovodi. Все права защищены. © 2026 Dovodi. Все права защищены.
@ -198,19 +214,32 @@ def send_email(recipient, code):
</html> </html>
""" """
text_content = (
f"Код подтверждения Dovodi: {code}\n\n"
f"Your Dovodi verification code is: {code}\n\n"
f"Срок действия кода истекает через 5 минут. Если вы не запрашивали этот код, проигнорируйте это письмо.\n"
f"Никому не передавайте этот код в целях безопасности вашего аккаунта."
)
payload = { payload = {
"from": settings.RESEND_FROM_EMAIL,
"to": recipient,
"from": from_email,
"to": to_emails,
"subject": subject, "subject": subject,
"html": html_content, "html": html_content,
"text": text_content,
} }
try: try:
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
response = requests.post(url, headers=headers, json=payload, timeout=15)
if response.status_code >= 400:
logger.error(f"Failed to send email via Resend: HTTP {response.status_code} - {response.text}")
print(f"Failed to send email via Resend: HTTP {response.status_code} - {response.text}")
return False
logger.info(f"Email OTP sent successfully via Resend to {to_emails}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to send email via Resend: {str(e)}")
logger.error(f"Failed to send email via Resend: {str(e)}", exc_info=True)
print(f"Failed to send email via Resend: {str(e)}")
return False return False

Loading…
Cancel
Save