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,
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'))
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)
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'))
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()
class Meta:
model = BookAuthor
fields = ['id', 'name', 'slug']
fields = ['id', 'name', 'slug', 'image']
class BookResearcherV2Serializer(serializers.ModelSerializer):
name = LocalizedField()
@ -152,7 +152,7 @@ class BookReferenceV2DetailSerializer(serializers.ModelSerializer):
paginator.page_size = 20
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:
serializer = HadisShortSerializer(paginated_hadis, many=True, context=self.context)
@ -171,8 +171,8 @@ class BookReferenceV2DetailSerializer(serializers.ModelSerializer):
}
def get_volumes(self, obj):
editions = obj.editions.all()
if editions.exists():
if obj.has_editions:
editions = obj.editions.all()
editions_data = []
for ed in editions:
editions_data.append({
@ -189,7 +189,7 @@ class BookReferenceV2DetailSerializer(serializers.ModelSerializer):
return {
"has_editions": False,
"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):
@ -205,7 +205,7 @@ class BookReferenceV2SyncSerializer(serializers.ModelSerializer):
class Meta:
model = BookReference
fields = [
'id', 'title', 'slug', 'share_link', 'rate', 'authors',
'id', 'title', 'slug', 'share_link', 'rate', 'has_editions', 'authors',
'attributes', 'images', 'information', 'hadises', 'volumes'
]
@ -231,8 +231,8 @@ class BookReferenceV2SyncSerializer(serializers.ModelSerializer):
return HadisShortSerializer(hadis_list, many=True, context=self.context).data
def get_volumes(self, obj):
editions = obj.editions.all()
if editions.exists():
if obj.has_editions:
editions = obj.editions.all()
editions_data = []
for ed in editions:
editions_data.append({
@ -249,7 +249,7 @@ class BookReferenceV2SyncSerializer(serializers.ModelSerializer):
return {
"has_editions": False,
"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
@ -261,7 +261,7 @@ class BookAuthorListSerializer(serializers.ModelSerializer):
class Meta:
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):
@ -277,7 +277,7 @@ class BookAuthorDetailSerializer(serializers.ModelSerializer):
class Meta:
model = BookAuthor
fields = [
'id', 'slug', 'name', 'known_as', 'scholarity',
'id', 'slug', 'name', 'image', 'known_as', 'scholarity',
'tabaqa', 'rutba', 'features',
'birth_year_hijri', 'death_year_hijri',
'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):
image = AbsoluteImageField(required=False, allow_null=True)
remove_image = serializers.BooleanField(write_only=True, required=False, default=False)
class Meta:
model = BookAuthor
fields = [
"id",
"name",
"slug",
"image",
"remove_image",
"birth_year_hijri",
"death_year_hijri",
"birth_year_miladi",
@ -843,6 +848,33 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
]
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 Meta:
@ -885,6 +917,7 @@ class AdminBookReferenceListSerializer(serializers.ModelSerializer):
"id",
"title",
"slug",
"has_editions",
"volume",
"publisher",
"year_of_publication",
@ -981,6 +1014,7 @@ class AdminBookReferenceDetailSerializer(serializers.ModelSerializer):
"title",
"description",
"language",
"has_editions",
"isbn",
"volume",
"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 = "[email protected]"
RESEND_API_KEY = env('RESEND_API_KEY', default="re_JFFAfESy_JHmJTJLu5ToGTPhwrZx7trKd")
RESEND_FROM_EMAIL = env('RESEND_FROM_EMAIL', default="Dovodi <[email protected]>")
PHONENUMBER_DEFAULT_REGION = "IR"

53
utils/__init__.py

@ -90,22 +90,38 @@ def environment_callback(request):
return [_("Production"), "primary"]
def send_email(recipient, code):
import requests
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.")
return False
url = "https://api.resend.com/emails"
headers = {
"Authorization": f"Bearer {settings.RESEND_API_KEY}",
"Authorization": f"Bearer {api_key}",
"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 <[email protected]>")
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"
html_content = f"""
@ -128,7 +144,7 @@ def send_email(recipient, code):
<!-- 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;" />
<img src="{site_domain}/static/images/dovoodi_logo.svg" alt="Dovodi" style="height: 38px; width: auto; max-width: 160px; display: inline-block;" />
</td>
</tr>
<!-- Main Body Section -->
@ -148,7 +164,7 @@ def send_email(recipient, code):
<!-- Intro Message -->
<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>
<!-- OTP Box -->
@ -183,7 +199,7 @@ def send_email(recipient, code):
<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>
<a href="mailto:[email protected]" style="color: #5172E1; text-decoration: none; font-weight: 600;">info@imamjavad.online</a>
</p>
<p style="margin: 0; font-size: 11px; color: #9AA0AC; line-height: 1.4;">
© 2026 Dovodi. Все права защищены.
@ -197,20 +213,33 @@ def send_email(recipient, code):
</body>
</html>
"""
text_content = (
f"Код подтверждения Dovodi: {code}\n\n"
f"Your Dovodi verification code is: {code}\n\n"
f"Срок действия кода истекает через 5 минут. Если вы не запрашивали этот код, проигнорируйте это письмо.\n"
f"Никому не передавайте этот код в целях безопасности вашего аккаунта."
)
payload = {
"from": settings.RESEND_FROM_EMAIL,
"to": recipient,
"from": from_email,
"to": to_emails,
"subject": subject,
"html": html_content,
"text": text_content,
}
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
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

Loading…
Cancel
Save