5 Commits

  1. 3
      apps/api/views/__init__.py
  2. 227
      apps/api/views/admin_dashboard.py
  3. 14
      apps/article/models.py
  4. 60
      apps/hadis/models/category.py
  5. 146
      apps/hadis/models/hadis.py
  6. 94
      apps/hadis/models/reference.py
  7. 217
      apps/hadis/models/transmitter.py
  8. 22
      apps/library/models.py
  9. 47
      apps/podcast/models.py
  10. 4
      apps/podcast/views_admin.py
  11. 55
      apps/video/models.py
  12. 24
      apps/video/serializers.py
  13. 50
      apps/video/views.py
  14. 4
      apps/video/views_admin.py
  15. 3
      config/urls.py
  16. 2
      manage.py
  17. 35
      utils/admin.py
  18. 38
      utils/media.py
  19. 32
      utils/mixins.py
  20. 61
      utils/slug.py

3
apps/api/views/__init__.py

@ -4,6 +4,8 @@
from .api_views import HomeView, CountryView, CommentListAPIView, CityListView
from .documentation import CustomAPIDocumentationView
from .swagger_views import CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth
from .admin_dashboard import AdminDashboardStatsView
__all__ = [
'HomeView',
'CountryView',
@ -13,4 +15,5 @@ __all__ = [
'CustomSwaggerView',
'SwaggerTokenAuthView',
'clear_swagger_auth',
'AdminDashboardStatsView',
]

227
apps/api/views/admin_dashboard.py

@ -0,0 +1,227 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from django.utils import timezone
from datetime import timedelta
from django.db.models import Count, Sum, Q
from django.db.models.functions import TruncDate
from rest_framework.authentication import TokenAuthentication
from apps.account.models import User, LoginHistory
from apps.hadis.models import Hadis, HadisStatus, HadisCategory, Transmitters, BookReference
from apps.library.models import Book
from apps.article.models import Article
from apps.video.models import Video
from apps.podcast.models import Podcast
from apps.bookmark.models import Bookmark
def extract_text_from_json(value):
if not value:
return ""
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
text = item.get('title') or item.get('value') or item.get('text') or item.get('name')
if text:
return str(text)
else:
if item:
return str(item)
return ""
if isinstance(value, dict):
for lang in ("fa", "en", "ru"):
if lang in value and value[lang]:
v = value[lang]
if isinstance(v, dict):
return str(v.get('title') or v.get('value') or v.get('text') or v.get('name') or "")
return str(v)
for v in value.values():
if isinstance(v, dict):
txt = v.get('title') or v.get('value') or v.get('text') or v.get('name')
if txt:
return str(txt)
elif v:
return str(v)
return ""
if isinstance(value, (str, int, float)):
return str(value)
return ""
class AdminDashboardStatsView(APIView):
authentication_classes = [TokenAuthentication]
permission_classes = [IsAuthenticated, IsAdminUser]
def get(self, request):
now = timezone.now()
thirty_days_ago = now - timedelta(days=30)
# ---------------------------------------------------------
# 1. CARDS & BASIC STATS
# ---------------------------------------------------------
total_arguments = Hadis.objects.count()
library_books = Book.objects.count()
source_references = BookReference.objects.count()
total_transmitters = Transmitters.objects.count()
# Media Items: Video + Podcast count
media_items = Video.objects.count() + Podcast.objects.count()
scientific_articles = Article.objects.count()
registered_users = User.objects.filter(is_active=True).count()
total_bookmarks = Bookmark.objects.filter(status=True).count()
# ---------------------------------------------------------
# 2. CHARTS DATA
# ---------------------------------------------------------
# Argument Types (Donut Chart 1)
argument_types_qs = Hadis.objects.exclude(category__isnull=True).values('category__source_type').annotate(total=Count('id'))
argument_types_chart = [
{"name": item['category__source_type'], "value": item['total']}
for item in argument_types_qs
]
# Hadith Status (Donut Chart 2)
hadith_statuses_qs = Hadis.objects.filter(
category__source_type=HadisCategory.SourceType.HADITH,
hadis_status__isnull=False
).values('hadis_status__slug', 'hadis_status__title').annotate(total=Count('id'))
hadith_status_chart = []
for item in hadith_statuses_qs:
title_text = extract_text_from_json(item['hadis_status__title']) if item['hadis_status__title'] else item['hadis_status__slug']
hadith_status_chart.append({
"name": title_text,
"value": item['total']
})
# User Distribution (Donut Chart)
registered_count = User.objects.filter(is_active=True).exclude(email__isnull=True).exclude(email='').count()
guest_count = User.objects.filter(is_active=True).filter(Q(email__isnull=True) | Q(email='')).count()
user_distribution_chart = [
{"name": "registered", "value": registered_count},
{"name": "guest", "value": guest_count}
]
# New User Registrations Trend (Line Chart)
registrations = User.objects.filter(date_joined__gte=thirty_days_ago).annotate(
date=TruncDate('date_joined')
).values('date').annotate(count=Count('id')).order_by('date')
registration_chart = [{"date": item['date'].strftime('%Y-%m-%d'), "users": item['count']} for item in registrations]
# User Bookmark Activity Trend (Area Chart)
bookmarks_trend = Bookmark.objects.filter(created_at__gte=thirty_days_ago, status=True).annotate(
date=TruncDate('created_at')
).values('date').annotate(count=Count('id')).order_by('date')
chat_chart = [{"date": item['date'].strftime('%Y-%m-%d'), "messages": item['count']} for item in bookmarks_trend]
# ---------------------------------------------------------
# 3. LISTS & LEADERBOARDS
# ---------------------------------------------------------
# Top 5 Media Items (List)
top_videos = Video.objects.order_by('-view_count')[:5]
top_media_list = []
for v in top_videos:
title_text = extract_text_from_json(v.title) if v.title else "Untitled Video"
top_media_list.append({
"id": v.id,
"title": title_text[:40] + "..." if len(title_text) > 40 else title_text,
"professor": "video",
"participants": v.view_count
})
top_podcasts = Podcast.objects.order_by('-view_count')[:5]
for p in top_podcasts:
title_text = extract_text_from_json(p.title) if p.title else "Untitled Podcast"
top_media_list.append({
"id": p.id,
"title": title_text[:40] + "..." if len(title_text) > 40 else title_text,
"professor": "podcast",
"participants": p.view_count
})
top_media_list = sorted(top_media_list, key=lambda x: x['participants'], reverse=True)[:5]
# Top 5 Articles (List)
top_articles = Article.objects.order_by('-view_count')[:5]
top_articles_list = []
for a in top_articles:
title_text = extract_text_from_json(a.title) if a.title else "Untitled Article"
top_articles_list.append({
"id": a.id,
"title": title_text[:40] + "..." if len(title_text) > 40 else title_text,
"views": a.view_count
})
top_articles_list = sorted(top_articles_list, key=lambda x: x['views'], reverse=True)[:5]
# Top 5 Books (List)
top_books = Book.objects.order_by('-view_count')[:5]
top_books_list = []
for b in top_books:
title_text = extract_text_from_json(b.title) if b.title else "Untitled Book"
top_books_list.append({
"id": b.id,
"title": title_text[:40] + "..." if len(title_text) > 40 else title_text,
"views": b.view_count
})
top_books_list = sorted(top_books_list, key=lambda x: x['views'], reverse=True)[:5]
# Top Bookmarked Arguments (Leaderboard)
top_hadis_ids = Bookmark.objects.filter(
service='hadith',
status=True
).values('content_id').annotate(
count=Count('id')
).order_by('-count')[:5]
top_hadis_list = []
id_to_count = {item['content_id']: item['count'] for item in top_hadis_ids}
hadiths = Hadis.objects.filter(id__in=id_to_count.keys())
for h in hadiths:
title_text = extract_text_from_json(h.title) if h.title else f"گزاره {h.number}"
top_hadis_list.append({
"id": h.id,
"name": title_text[:30] + "..." if len(title_text) > 30 else title_text,
"score": id_to_count[h.id]
})
top_hadis_list = sorted(top_hadis_list, key=lambda x: x['score'], reverse=True)
# Fallback if no bookmarks exist
if not top_hadis_list:
hadiths = Hadis.objects.filter(status=True)[:5]
for h in hadiths:
title_text = extract_text_from_json(h.title) if h.title else f"گزاره {h.number}"
top_hadis_list.append({
"id": h.id,
"name": title_text[:30] + "..." if len(title_text) > 30 else title_text,
"score": 0
})
return Response({
"cards": {
"active_students": total_arguments, # total_arguments
"professors": library_books, # library_books
"active_courses": source_references, # source_references
"revenue_30d": total_transmitters, # total_transmitters
"total_blogs": media_items, # media_items
"pending_certificates": scientific_articles, # scientific_articles
"active_live_sessions": registered_users, # registered_users
"group_rooms": total_bookmarks, # total_bookmarks
"private_rooms": 0,
},
"charts": {
"transactions_status": argument_types_chart,
"hadith_statuses": hadith_status_chart,
"user_distribution": user_distribution_chart,
"registrations": registration_chart,
"chat_volume": chat_chart,
"course_pipeline": [],
"course_efficacy": []
},
"lists": {
"top_courses": top_media_list,
"top_blogs": top_articles_list,
"top_books": top_books_list,
"leaderboard": top_hadis_list
}
})

14
apps/article/models.py

@ -6,7 +6,7 @@ from utils import generate_slug_for_model
from utils.mixins import LowercaseSlugMixin
class ArticleCategory(models.Model):
class ArticleCategory(LowercaseSlugMixin, models.Model):
title = models.CharField(max_length=255, verbose_name=_('title'))
slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug'))
@ -18,18 +18,13 @@ class ArticleCategory(models.Model):
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(ArticleCategory, self.title)
super().save(*args, **kwargs)
class Meta:
verbose_name = _('Article Category')
verbose_name_plural = _('Article Categories')
ordering = ['order']
class ArticleCollection(models.Model):
class ArticleCollection(LowercaseSlugMixin, models.Model):
class DisplayPosition(models.TextChoices):
PINNED = 'pinned', _('Pinned')
MIDDLE = 'middle', _('Middle Section')
@ -60,11 +55,6 @@ class ArticleCollection(models.Model):
def __str__(self):
return f'Collection #{self.id}/{self.title}'
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(ArticleCollection, self.title)
super().save(*args, **kwargs)
class Meta:
verbose_name = _('Article Collection')
verbose_name_plural = _('Articles Collections')

60
apps/hadis/models/category.py

@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError
from mptt.models import MPTTModel, TreeForeignKey
from django.utils.text import slugify
from django.conf import settings
from utils.mixins import LowercaseSlugMixin
class HadisSect(models.Model):
@ -63,7 +64,7 @@ class HadisSect(models.Model):
ordering = ('order',)
class HadisCategory(MPTTModel):
class HadisCategory(LowercaseSlugMixin, MPTTModel):
class SourceType(models.TextChoices):
QURAN = 'quran', _('Quran')
HADITH = 'hadith', _('Hadith')
@ -92,63 +93,10 @@ class HadisCategory(MPTTModel):
f'Your sect: {self.sect.sect_type}')
)
slug_source_field = 'title'
def save(self, *args, **kwargs):
self.full_clean()
if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''):
# Try to get text from title field with robust error handling
try:
if self.title and isinstance(self.title, list) and len(self.title) > 0:
first_item = self.title[0]
if isinstance(first_item, dict):
title_text = first_item.get('text', '').strip()
if title_text:
base_slug = slugify(title_text, allow_unicode=True)
slug = base_slug
counter = 1
while HadisCategory.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
else:
# Fallback if text is empty
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"category-{suffix}"
counter = 1
while HadisCategory.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"category-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
# Fallback if structure is invalid
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"category-{suffix}"
counter = 1
while HadisCategory.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"category-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
# Fallback if title is empty or invalid
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"category-{suffix}"
counter = 1
while HadisCategory.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"category-{suffix}-{counter}"
counter += 1
self.slug = base_slug
except (IndexError, KeyError, AttributeError, TypeError):
# Fallback on any error
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"category-{suffix}"
counter = 1
while HadisCategory.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"category-{suffix}-{counter}"
counter += 1
self.slug = base_slug
super().save(*args, **kwargs)
@property

146
apps/hadis/models/hadis.py

@ -9,7 +9,8 @@ from .reference import BookReference
from utils.slug import generate_smart_slug
from utils.mixins import ColorPaletteMixin, LowercaseSlugMixin
class HadisCollection(models.Model):
class HadisCollection(LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
title = models.JSONField(default = list , verbose_name=_('Title'))
slug = models.SlugField(max_length=255, unique=True, verbose_name=_('slug'), blank=True)
summary = models.JSONField(default = list , verbose_name=_('Summary'))
@ -20,64 +21,6 @@ class HadisCollection(models.Model):
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
def save(self, *args, **kwargs):
if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''):
# Try to get text from title field with robust error handling
try:
if self.title and isinstance(self.title, list) and len(self.title) > 0:
first_item = self.title[0]
if isinstance(first_item, dict):
title_text = first_item.get('text', '').strip()
if title_text:
base_slug = slugify(title_text, allow_unicode=True)
slug = base_slug
counter = 1
while HadisCollection.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
else:
# Fallback if text is empty
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"collection-{suffix}"
counter = 1
while HadisCollection.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"collection-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
# Fallback if structure is invalid
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"collection-{suffix}"
counter = 1
while HadisCollection.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"collection-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
# Fallback if title is empty or invalid
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"collection-{suffix}"
counter = 1
while HadisCollection.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"collection-{suffix}-{counter}"
counter += 1
self.slug = base_slug
except (IndexError, KeyError, AttributeError, TypeError):
# Fallback on any error
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"collection-{suffix}"
counter = 1
while HadisCollection.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"collection-{suffix}-{counter}"
counter += 1
self.slug = base_slug
super().save(*args, **kwargs)
def __str__(self):
return self.title[0]['text'] if self.title else "Untitled Collection"
@ -164,7 +107,8 @@ class HadisTag(models.Model):
return None
class HadisStatus(ColorPaletteMixin,models.Model):
class HadisStatus(ColorPaletteMixin, LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
title = models.JSONField(default = list , verbose_name=_('Title'))
slug= models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique = True)
@ -172,65 +116,6 @@ class HadisStatus(ColorPaletteMixin,models.Model):
order = models.IntegerField(default=0, verbose_name=_('order'))
description = models.JSONField(default = list , verbose_name=_('Description'))
def save(self, *args, **kwargs):
if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''):
# Try to get text from title field with robust error handling
try:
if self.title and isinstance(self.title, list) and len(self.title) > 0:
first_item = self.title[0]
if isinstance(first_item, dict):
title_text = first_item.get('text', '').strip()
if title_text:
slug = slugify(title_text)
# Ensure uniqueness
counter = 1
base_slug = slug
while HadisStatus.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
else:
# Fallback if text is empty
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"status-{suffix}"
counter = 1
while HadisStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"status-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
# Fallback if structure is invalid
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"status-{suffix}"
counter = 1
while HadisStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"status-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
# Fallback if title is empty or invalid
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"status-{suffix}"
counter = 1
while HadisStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"status-{suffix}-{counter}"
counter += 1
self.slug = base_slug
except (IndexError, KeyError, AttributeError, TypeError):
# Fallback on any error
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"status-{suffix}"
counter = 1
while HadisStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"status-{suffix}-{counter}"
counter += 1
self.slug = base_slug
super().save(*args, **kwargs)
def __str__(self):
return self.title[0]['text'] if self.title else str(self.id)
@ -517,27 +402,8 @@ class HadisCorrection(models.Model):
def save(self, *args, **kwargs):
# Generate smart slug
if not self.slug and self.title:
title_text = None
if isinstance(self.title, list) and len(self.title) > 0:
first_item = self.title[0]
if isinstance(first_item, dict):
title_text = first_item.get("text")
if title_text:
self.slug = generate_smart_slug(
text=title_text, model_class=HadisCorrection,
max_length=100, keep_words=8, instance=self,
)
else:
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"correction-{self.hadis.slug if self.hadis and self.hadis.slug else 'unknown'}-{suffix}"
counter = 1
while HadisCorrection.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"correction-{self.hadis.slug if self.hadis and self.hadis.slug else 'unknown'}-{suffix}-{counter}"
counter += 1
self.slug = base_slug
from utils.slug import build_slug_for_instance
build_slug_for_instance(self, source_field='title')
if self.slug and self.hadis and self.hadis.slug:
self.share_link = f"{settings.DOVODI_DOMAIN}/arguments/hadith/{self.hadis.slug}/corrections/{self.slug}"

94
apps/hadis/models/reference.py

@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from django.utils.text import slugify
from django.conf import settings
from typing import Optional
from utils.mixins import LowercaseSlugMixin
class BookSubjectArea(models.Model):
title = models.JSONField(default = list , verbose_name=_('Title'))
@ -83,7 +84,8 @@ class BookType(models.Model):
return tr.get('text', '')
return None
class BookReference(models.Model):
class BookReference(LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
"""
Model for hadis book references with detailed information
This is different from library books - these are reference books for hadis
@ -192,63 +194,7 @@ class BookReference(models.Model):
return self._get_json_field("language" , lang)
def save(self, *args, **kwargs):
if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''):
# Try to get text from title field with robust error handling
try:
if self.title and isinstance(self.title, list) and len(self.title) > 0:
first_item = self.title[0]
if isinstance(first_item, dict):
title_text = first_item.get('text', '').strip()
if title_text:
base_slug = slugify(title_text, allow_unicode=True)
slug = base_slug
counter = 1
while BookReference.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
else:
# Fallback if text is empty
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"book-{suffix}"
counter = 1
while BookReference.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"book-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
# Fallback if structure is invalid
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"book-{suffix}"
counter = 1
while BookReference.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"book-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
# Fallback if title is empty or invalid
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"book-{suffix}"
counter = 1
while BookReference.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"book-{suffix}-{counter}"
counter += 1
self.slug = base_slug
except (IndexError, KeyError, AttributeError, TypeError):
# Fallback on any error
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"book-{suffix}"
counter = 1
while BookReference.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"book-{suffix}-{counter}"
counter += 1
self.slug = base_slug
super().save(*args, **kwargs)
class BookEdition(models.Model):
"""
@ -377,7 +323,8 @@ class BookReferenceDocument(models.Model):
return f"{self.book_reference} - {self.title or 'Doc ' + str(self.id)}"
class BookAuthor(models.Model):
class BookAuthor(LowercaseSlugMixin, models.Model):
slug_source_field = 'name'
"""
Model for book reference authors
"""
@ -436,32 +383,6 @@ class BookAuthor(models.Model):
return None
def save(self, *args, **kwargs):
if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''):
try:
if self.name and isinstance(self.name, list) and len(self.name) > 0:
first_item = self.name[0]
if isinstance(first_item, dict):
name_text = first_item.get('text', '').strip()
if name_text:
base_slug = slugify(name_text, allow_unicode=True)
slug = base_slug
counter = 1
while BookAuthor.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
else:
import time
self.slug = f"author-{int(time.time() * 1000) % 1000000}"
else:
import time
self.slug = f"author-{int(time.time() * 1000) % 1000000}"
else:
import time
self.slug = f"author-{int(time.time() * 1000) % 1000000}"
except Exception:
import time
self.slug = f"author-{int(time.time() * 1000) % 1000000}"
super().save(*args, **kwargs)
self.researcher_profiles.all().update(name=self.name, slug=self.slug)
@ -528,7 +449,8 @@ class BookAttribute(models.Model):
return None
class BookResearcher(models.Model):
class BookResearcher(LowercaseSlugMixin, models.Model):
slug_source_field = 'name'
book_edition = models.ForeignKey(
'BookEdition',
on_delete=models.CASCADE,

217
apps/hadis/models/transmitter.py

@ -7,11 +7,12 @@ from django.utils.text import slugify
from typing import Optional
from utils.slug import generate_smart_slug
from django.conf import settings
from utils.mixins import ColorPaletteMixin
from utils.mixins import ColorPaletteMixin, LowercaseSlugMixin
class NarratorLayer(models.Model):
class NarratorLayer(LowercaseSlugMixin, models.Model):
slug_source_field = 'name'
"""
Model for narrator layers/classes (Tabaqat)
Represents the classification level of narrators in hadis chains
@ -122,67 +123,14 @@ class NarratorLayer(models.Model):
self.slug = slug
super().save(*args, **kwargs)
class TransmitterReliability(ColorPaletteMixin, models.Model):
class TransmitterReliability(ColorPaletteMixin, LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
title = models.JSONField(default = list , verbose_name=_('Title'))
slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,null=True)
def save(self, *args, **kwargs):
if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''):
# Try to get text from title field with robust error handling
try:
if self.title and isinstance(self.title, list) and len(self.title) > 0:
first_item = self.title[0]
if isinstance(first_item, dict):
text = first_item.get('text', '').strip()
if text:
slug = slugify(text)
# Ensure uniqueness
counter = 1
base_slug = slug
while TransmitterReliability.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
else:
# Fallback to a timestamp-based slug
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"reliability-{suffix}"
counter = 1
while TransmitterReliability.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"reliability-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"reliability-{suffix}"
counter = 1
while TransmitterReliability.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"reliability-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"reliability-{suffix}"
counter = 1
while TransmitterReliability.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"reliability-{suffix}-{counter}"
counter += 1
self.slug = base_slug
except (IndexError, KeyError, AttributeError, TypeError):
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"reliability-{suffix}"
counter = 1
while TransmitterReliability.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"reliability-{suffix}-{counter}"
counter += 1
self.slug = base_slug
super().save(*args, **kwargs)
def __str__(self):
return self.title[0]['text'] if self.title else str(self.id)
@ -210,7 +158,8 @@ class TransmitterReliability(ColorPaletteMixin, models.Model):
class Transmitters(models.Model):
class Transmitters(LowercaseSlugMixin, models.Model):
slug_source_field = 'full_name'
# class ReliabilityLevel(models.TextChoices):
# VERY_RELIABLE = 'very_reliable', _('Very Reliable')
# RELIABLE = 'reliable', _('Reliable')
@ -313,63 +262,7 @@ class Transmitters(models.Model):
return f"{settings.DOVODI_DOMAIN}/arguments/narrators/{self.slug}"
return None
def save(self, *args, **kwargs):
if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''):
# Try to get text from full_name field with robust error handling
try:
if self.full_name and isinstance(self.full_name, list) and len(self.full_name) > 0:
first_item = self.full_name[0]
if isinstance(first_item, dict):
name_text = first_item.get('text', '').strip()
if name_text:
base_slug = slugify(name_text, allow_unicode=True)
slug = base_slug
counter = 1
while Transmitters.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
else:
# Fallback if text is empty
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"transmitter-{suffix}"
counter = 1
while Transmitters.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"transmitter-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
# Fallback if structure is invalid
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"transmitter-{suffix}"
counter = 1
while Transmitters.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"transmitter-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
# Fallback if full_name is empty or invalid
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"transmitter-{suffix}"
counter = 1
while Transmitters.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"transmitter-{suffix}-{counter}"
counter += 1
self.slug = base_slug
except (IndexError, KeyError, AttributeError, TypeError):
# Fallback on any error
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"transmitter-{suffix}"
counter = 1
while Transmitters.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"transmitter-{suffix}-{counter}"
counter += 1
self.slug = base_slug
super().save(*args, **kwargs)
def _get_json_field(self, field_name: str, lang: Optional[str]=None , fallback: str = "en"):
"""
@ -492,66 +385,13 @@ class HadisTransmitter(models.Model):
return f'{self.hadis.number} - Chain {self.chain_index} - {self.transmitter} (Pos: {self.order})'
class OpinionStatus(ColorPaletteMixin, models.Model):
class OpinionStatus(ColorPaletteMixin, LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
title = models.JSONField(default = list , verbose_name=_('Title'))
slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,null=True)
def save(self, *args, **kwargs):
if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''):
# Try to get text from title field with robust error handling
try:
if self.title and isinstance(self.title, list) and len(self.title) > 0:
first_item = self.title[0]
if isinstance(first_item, dict):
text = first_item.get('text', '').strip()
if text:
slug = slugify(text)
# Ensure uniqueness
counter = 1
base_slug = slug
while OpinionStatus.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
else:
# Fallback to a timestamp-based slug
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"opinion-{suffix}"
counter = 1
while OpinionStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"opinion-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"opinion-{suffix}"
counter = 1
while OpinionStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"opinion-{suffix}-{counter}"
counter += 1
self.slug = base_slug
else:
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"opinion-{suffix}"
counter = 1
while OpinionStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"opinion-{suffix}-{counter}"
counter += 1
self.slug = base_slug
except (IndexError, KeyError, AttributeError, TypeError):
import time
suffix = int(time.time() * 1000) % 1000000
base_slug = f"opinion-{suffix}"
counter = 1
while OpinionStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"opinion-{suffix}-{counter}"
counter += 1
self.slug = base_slug
super().save(*args, **kwargs)
def __str__(self):
return self.title[0]['text'] if self.title else str(self.id)
@ -695,37 +535,8 @@ class TransmitterOriginalText(models.Model):
"""
Override save to automatically generate smart slugs.
"""
# Generate slug if not already set
if not self.slug and self.title:
# Extract title text
title_text = None
if isinstance(self.title, list) and self.title:
first_item = self.title[0]
if isinstance(first_item, dict):
title_text = first_item.get("text")
# Generate smart slug
if title_text:
self.slug = generate_smart_slug(
text=title_text,
model_class=TransmitterOriginalText,
max_length=100, # ← Adjust max length here
keep_words=8, # ← Limit to 8 words (your requirement)
instance=self,
)
else:
# Fallback if title is empty - use timestamp for uniqueness
import time
suffix = int(time.time() * 1000) % 1000000
transmitter_slug = self.transmitter.slug if self.transmitter and self.transmitter.slug else 'unknown'
base_slug = f"original-text-{transmitter_slug}-{suffix}"
# Ensure uniqueness
counter = 1
while TransmitterOriginalText.objects.filter(slug=base_slug).exclude(pk=self.pk).exists():
base_slug = f"original-text-{transmitter_slug}-{suffix}-{counter}"
counter += 1
self.slug = base_slug
from utils.slug import build_slug_for_instance
build_slug_for_instance(self, source_field='title')
# Generate/update share_link before saving
if self.slug and self.transmitter and self.transmitter.slug:

22
apps/library/models.py

@ -11,7 +11,7 @@ from apps.account.models import User
from utils.mixins import LowercaseSlugMixin
class BookCollection(models.Model):
class BookCollection(LowercaseSlugMixin, models.Model):
class DisplayPosition(models.TextChoices):
PINNED = 'pinned', _('Pinned')
MIDDLE = 'middle', _('Middle Section')
@ -38,11 +38,6 @@ class BookCollection(models.Model):
verbose_name = _('Book Collection')
verbose_name_plural = _('Book Collections')
def save(self, *args, **kwargs):
if not self.slug or not self.slug.strip():
self.slug = generate_smart_slug(self.title, BookCollection, instance=self)
super().save(*args, **kwargs)
class PinnedBookCollection(BookCollection):
@ -67,7 +62,7 @@ class MiddleBookCollection(BookCollection):
class Category(models.Model):
class Category(LowercaseSlugMixin, models.Model):
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True, blank=True, null=True)
status = models.BooleanField(default=True, verbose_name=_('status'))
@ -79,11 +74,6 @@ class Category(models.Model):
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug or not self.slug.strip():
self.slug = generate_smart_slug(self.title, Category, instance=self)
super().save(*args, **kwargs)
@property
def books_count(self):
"""Return the number of books in this category"""
@ -94,7 +84,8 @@ class Category(models.Model):
verbose_name_plural = _('Categories')
class Author(models.Model):
class Author(LowercaseSlugMixin, models.Model):
slug_source_field = 'full_name'
full_name = models.CharField(max_length=255, verbose_name=_('Full Name'))
slug = models.SlugField(max_length=255, unique=True, blank=True, null=True)
thumbnail = models.ImageField(
@ -117,11 +108,6 @@ class Author(models.Model):
def __str__(self):
return self.full_name
def save(self, *args, **kwargs):
if not self.slug or not self.slug.strip():
self.slug = generate_smart_slug(self.full_name, Author, instance=self)
super().save(*args, **kwargs)
@property
def books_count(self):
return self.books.filter(status=True).count()

47
apps/podcast/models.py

@ -6,7 +6,7 @@ from utils import generate_slug_for_model
from utils.mixins import LowercaseSlugMixin
class PodcastCategory(models.Model):
class PodcastCategory(LowercaseSlugMixin, models.Model):
title = models.CharField(max_length=255, verbose_name=_('title'))
slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug'))
@ -18,18 +18,13 @@ class PodcastCategory(models.Model):
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(PodcastCategory, self.title)
super().save(*args, **kwargs)
class Meta:
verbose_name = _('Podcast Category')
verbose_name_plural = _('Podcast Categories')
ordering = ['order']
class PodcastCollection(models.Model):
class PodcastCollection(LowercaseSlugMixin, models.Model):
class DisplayPosition(models.TextChoices):
PINNED = 'pinned', _('Pinned')
MIDDLE = 'middle', _('Middle Section')
@ -54,11 +49,6 @@ class PodcastCollection(models.Model):
def __str__(self):
return f'Collection #{self.id}/{self.title}'
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(PodcastCollection, self.title)
super().save(*args, **kwargs)
class Meta:
verbose_name = _('Podcast Collection')
verbose_name_plural = _('Podcasts Collections')
@ -115,8 +105,41 @@ class Podcast(LowercaseSlugMixin, models.Model):
return self.view_count
def save(self, *args, **kwargs):
is_new = self.pk is None
file_changed = False
if not is_new:
try:
old_instance = Podcast.objects.get(pk=self.pk)
if old_instance.audio_file != self.audio_file:
file_changed = True
except Podcast.DoesNotExist:
pass
else:
file_changed = True
if not self.audio_time:
import datetime
self.audio_time = datetime.time(0, 0, 0)
super().save(*args, **kwargs)
if (file_changed or self.audio_time == datetime.time(0, 0, 0)) and self.audio_file:
from utils.media import get_media_duration_time
try:
duration_time = get_media_duration_time(self.audio_file.path)
if duration_time:
self.audio_time = duration_time
Podcast.objects.filter(pk=self.pk).update(audio_time=duration_time)
# Update playlists that contain this podcast
for appearance in self.playlist_appearances.all():
playlist = appearance.playlist
playlist.total_time = playlist.calculate_total_time()
playlist.save(update_fields=['total_time'])
except Exception as e:
pass
class Meta:

4
apps/podcast/views_admin.py

@ -77,6 +77,10 @@ class AdminPodcastViewSet(ModelViewSet):
if collection_id and collection_id != "all":
queryset = queryset.filter(collections__id=collection_id)
unattached = self.request.query_params.get("unattached")
if unattached == "true":
queryset = queryset.filter(playlist_appearances__isnull=True)
return queryset.distinct().order_by("-created_at")

55
apps/video/models.py

@ -8,7 +8,7 @@ from django.core.validators import FileExtensionValidator
from django.core.exceptions import ValidationError
class VideoCategory(models.Model):
class VideoCategory(LowercaseSlugMixin, models.Model):
title = models.CharField(max_length=255, verbose_name=_('title'))
slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug'))
@ -20,18 +20,13 @@ class VideoCategory(models.Model):
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(VideoCategory, self.title)
super().save(*args, **kwargs)
class Meta:
verbose_name = _('Video Category')
verbose_name_plural = _('Video Categories')
ordering = ['order']
class VideoCollection(models.Model):
class VideoCollection(LowercaseSlugMixin, models.Model):
class DisplayPosition(models.TextChoices):
PINNED = 'pinned', _('Pinned')
MIDDLE = 'middle', _('Middle Section')
@ -56,12 +51,6 @@ class VideoCollection(models.Model):
def __str__(self):
return f'Collection #{self.id}/{self.title}'
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(VideoCollection, self.title)
super().save(*args, **kwargs)
class Meta:
verbose_name = _('Video Collection')
verbose_name_plural = _('Video Collections')
@ -136,11 +125,44 @@ class Video(LowercaseSlugMixin, models.Model):
})
def save(self, *args, **kwargs):
is_new = self.pk is None
file_changed = False
if not is_new:
try:
old_instance = Video.objects.get(pk=self.pk)
if old_instance.video_file != self.video_file:
file_changed = True
except Video.DoesNotExist:
pass
else:
file_changed = True
if not self.video_time:
import datetime
self.video_time = datetime.time(0, 0, 0)
super().save(*args, **kwargs)
if (file_changed or self.video_time == datetime.time(0, 0, 0)) and self.video_file and self.video_type == self.VedioTypeChoices.VIDEO_FILE:
from utils.media import get_media_duration_time
try:
duration_time = get_media_duration_time(self.video_file.path)
if duration_time:
self.video_time = duration_time
Video.objects.filter(pk=self.pk).update(video_time=duration_time)
# Update playlists that contain this video
for appearance in self.playlist_appearances.all():
playlist = appearance.playlist
playlist.total_time = playlist.calculate_total_time()
playlist.save(update_fields=['total_time'])
except Exception as e:
pass
class VideoPlaylist(models.Model):
class VideoPlaylist(LowercaseSlugMixin, models.Model):
title = models.CharField(max_length=255, verbose_name=_('title'))
slug = models.SlugField(allow_unicode=True, unique=True, null=True, blank=True, verbose_name=_('slug'))
slogan = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('slogan'))
@ -189,11 +211,6 @@ class VideoPlaylist(models.Model):
return timedelta(seconds=total_seconds)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(VideoPlaylist, self.title)
super().save(*args, **kwargs)
class Meta:
verbose_name = _('Video Playlist')
verbose_name_plural = _('Video Playlists')

24
apps/video/serializers.py

@ -21,15 +21,19 @@ class VideoListSerializer(serializers.ModelSerializer):
video_file = serializers.SerializerMethodField()
share_link = serializers.CharField(read_only=True)
video_time = serializers.SerializerMethodField()
total_time_formatted = serializers.SerializerMethodField()
class Meta:
model = Video
fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_type',
'video_file', 'video_url', 'video_time', 'view_count', 'created_at', 'share_link']
'video_file', 'video_url', 'video_time', 'view_count', 'created_at', 'share_link', 'total_time_formatted']
def get_thumbnail(self, obj):
return get_thumbs(obj.thumbnail, self.context.get('request'))
def get_total_time_formatted(self, obj):
return format_media_time(obj.video_time)
def get_video_file(self, obj):
"""Get full URL for video file if it exists"""
if obj.video_file:
@ -45,15 +49,19 @@ class VideoListSerializer(serializers.ModelSerializer):
class VideoPlaylistListSerializer(serializers.ModelSerializer):
thumbnail = serializers.SerializerMethodField()
video_time = serializers.SerializerMethodField()
total_time_formatted = serializers.SerializerMethodField()
class Meta:
model = VideoPlaylist
fields = ['id', 'title', 'slug', 'thumbnail', 'slogan', 'view_count', 'total_time_formatted', 'order', 'created_at']
fields = ['id', 'title', 'slug', 'thumbnail', 'slogan', 'view_count', 'video_time', 'total_time_formatted', 'order', 'created_at']
def get_thumbnail(self, obj):
return get_thumbs(obj.thumbnail, self.context.get('request'))
def get_video_time(self, obj):
return format_duration(obj.total_time)
def get_total_time_formatted(self, obj):
return format_duration(obj.total_time)
@ -68,17 +76,21 @@ class VideoPlaylistDetailSerializer(serializers.ModelSerializer):
user_rate = serializers.SerializerMethodField()
average_rate = serializers.SerializerMethodField()
videos = serializers.SerializerMethodField()
video_time = serializers.SerializerMethodField()
total_time_formatted = serializers.SerializerMethodField()
class Meta:
model = VideoPlaylist
fields = ['id', 'title', 'slug', 'thumbnail', 'slogan', 'description',
'view_count', 'total_time_formatted', 'order', 'status',
'view_count', 'video_time', 'total_time_formatted', 'order', 'status',
'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark', 'videos']
def get_thumbnail(self, obj):
return get_thumbs(obj.thumbnail, self.context.get('request'))
def get_video_time(self, obj):
return format_duration(obj.total_time)
def get_total_time_formatted(self, obj):
return format_duration(obj.total_time)
@ -147,17 +159,21 @@ class VideoDetailSerializer(serializers.ModelSerializer):
playlist_videos = serializers.SerializerMethodField()
share_link = serializers.CharField(read_only=True)
video_time = serializers.SerializerMethodField()
total_time_formatted = serializers.SerializerMethodField()
class Meta:
model = Video
fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_type',
'video_file', 'video_url', 'video_time', 'view_count',
'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark',
'is_in_playlist', 'playlist_videos', 'share_link', 'video_time']
'is_in_playlist', 'playlist_videos', 'share_link', 'video_time', 'total_time_formatted']
def get_video_time(self, obj):
return format_media_time(obj.video_time)
def get_total_time_formatted(self, obj):
return format_media_time(obj.video_time)
def get_thumbnail(self, obj):
return get_thumbs(obj.thumbnail, self.context.get('request'))

50
apps/video/views.py

@ -286,13 +286,13 @@ class VideoListAPIView(generics.ListAPIView):
"""
API view to list all video playlists, with optional filtering by category or collection
"""
serializer_class = VideoListSerializer
serializer_class = VideoPlaylistListSerializer
permission_classes = (IsAuthenticated,)
authentication_classes = [TokenAuthentication]
pagination_class = StandardResultsSetPagination
@swagger_auto_schema(
operation_description="Get a list of videos with optional filtering",
operation_description="Get a list of video playlists with optional filtering",
tags=["Dobodbi - Video"],
manual_parameters=[
openapi.Parameter(
@ -322,6 +322,13 @@ class VideoListAPIView(generics.ListAPIView):
description='Search playlists by title',
type=openapi.TYPE_STRING,
required=False
),
openapi.Parameter(
name='sort',
in_=openapi.IN_QUERY,
description='Sort playlists by field. Options: created_at, -created_at, view_count, -view_count, title, -title, order, -order',
type=openapi.TYPE_STRING,
required=False
)
],
responses={
@ -335,7 +342,7 @@ class VideoListAPIView(generics.ListAPIView):
return super().get(request, *args, **kwargs)
def get_queryset(self):
queryset = Video.objects.filter(status=True).order_by('-created_at')
queryset = VideoPlaylist.objects.filter(status=True).order_by('order', '-created_at')
# Search by title if search parameter is provided
search_query = self.request.query_params.get('search', None)
@ -345,36 +352,35 @@ class VideoListAPIView(generics.ListAPIView):
# Filter by category if provided
category_slug = self.request.query_params.get('category', None)
if category_slug:
queryset = queryset.filter(playlist_appearances__playlist__categories__slug=category_slug)
queryset = queryset.filter(categories__slug=category_slug)
# Filter by collection if provided
collection_slug = self.request.query_params.get('collection', None)
if collection_slug:
queryset = queryset.filter(playlist_appearances__playlist__collections__slug=collection_slug)
queryset = queryset.filter(collections__slug=collection_slug)
is_bookmark = self.request.query_params.get('is_bookmark', '').lower()
if is_bookmark == 'true':
# Import Bookmark model here to avoid circular imports
from apps.bookmark.models import Bookmark
# Get all bookmarked video IDs for the current user
# Get all bookmarked playlist IDs for the current user
bookmarked_ids = Bookmark.objects.filter(
user=self.request.user,
service=Bookmark.ServiceChoices.VIDEO,
service=Bookmark.ServiceChoices.VIDEO_PLAYLIST,
status=True
).values_list('content_id', flat=True)
# Filter videos by these IDs
# Filter playlists by these IDs
queryset = queryset.filter(id__in=bookmarked_ids)
sort = self.request.query_params.get('sort', '-created_at')
allowed_sorts = [
'created_at', '-created_at', 'view_count', '-view_count',
'title', '-title',
'total_time', '-total_time','-created_at','created_at'
'title', '-title', 'order', '-order',
'total_time', '-total_time'
]
if sort in allowed_sorts:
# Handle multiple sort fields (e.g., '-pin,-created_at')
if ',' in sort:
queryset = queryset.order_by(*sort.split(','))
else:
@ -382,7 +388,7 @@ class VideoListAPIView(generics.ListAPIView):
else:
queryset = queryset.order_by('-created_at')
return queryset.distinct()
return queryset
class VideoPlaylistDetailAPIView(generics.RetrieveAPIView):
@ -415,7 +421,7 @@ class VideoPlaylistDetailAPIView(generics.RetrieveAPIView):
class VideoDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics.RetrieveAPIView):
serializer_class = VideoDetailSerializer
serializer_class = VideoPlaylistDetailSerializer
permission_classes = (IsAuthenticated,)
authentication_classes = [TokenAuthentication]
lookup_field = 'slug'
@ -424,12 +430,12 @@ class VideoDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics.R
active_expected_value = True
@swagger_auto_schema(
operation_description="Get video details by slug",
operation_description="Get video playlist details by slug",
tags=["Dobodbi - Video"],
responses={
200: openapi.Response(
description="Video details",
schema=VideoDetailSerializer()
description="Video playlist details",
schema=VideoPlaylistDetailSerializer()
)
}
)
@ -437,13 +443,13 @@ class VideoDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics.R
return super().get(request, *args, **kwargs)
def get_queryset(self):
return Video.objects.all()
return VideoPlaylist.objects.all()
# def retrieve(self, request, *args, **kwargs):
# instance = self.get_object()
# instance.increment_view_count()
# serializer = self.get_serializer(instance)
# return Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
instance.increment_view_count()
serializer = self.get_serializer(instance)
return Response(serializer.data)
class DovodiVideoCategoryViewSet(ModelViewSet):

4
apps/video/views_admin.py

@ -74,6 +74,10 @@ class AdminVideoViewSet(ModelViewSet):
if video_type and video_type != "all":
queryset = queryset.filter(video_type=video_type)
unattached = self.request.query_params.get("unattached")
if unattached == "true":
queryset = queryset.filter(playlist_appearances__isnull=True)
return queryset.order_by("-created_at")

3
config/urls.py

@ -53,7 +53,7 @@ sitemaps = {
}
# Import custom API views
from apps.api.views import CustomAPIDocumentationView, CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth
from apps.api.views import CustomAPIDocumentationView, CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth, AdminDashboardStatsView
from apps.api.permissions import IsAdminOrSwaggerToken
from apps.api.decorators import swagger_auth_required
@ -95,6 +95,7 @@ api_patterns = [
path('agent/', include('apps.agent.urls')),
path('bookmarks/', include('apps.bookmark.urls')),
path('calendar/', include('apps.dobodbi_calendar.urls')),
path('admin/dashboard-stats/', AdminDashboardStatsView.as_view()),
path('settings/', include('dynamic_preferences.urls')),

2
manage.py

@ -20,3 +20,5 @@ def main():
if __name__ == '__main__':
main()
# Force reload trigger for new dashboard views

35
utils/admin.py

@ -32,41 +32,6 @@ def slugify(value):
return _unicode_slugify(value)
# -------------------------------------------------------
# Unfold Imports
import json
import random
from functools import lru_cache
from django import forms
from django.conf import settings
from django.contrib.humanize.templatetags.humanize import intcomma
from django.urls import reverse, path
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.views.generic import RedirectView
from django.utils.translation import get_language
from django.http import JsonResponse
from django.utils.html import format_html
from django.views.decorators.http import require_POST
# --- AGGRESSIVE UNICODE PATCH FOR UNFOLD 0.64.1 TABS ---
import django.utils.text
from django.template.defaultfilters import register
_original_slugify = django.utils.text.slugify
def _unicode_slugify(value, allow_unicode=True):
# We forcefully pass allow_unicode=True to preserve Russian characters for tab IDs
return _original_slugify(value, allow_unicode=True)
django.utils.text.slugify = _unicode_slugify
# We must also override the template filter explicitly because Unfold calls it directly in 0.64.1
@register.filter(is_safe=True)
def slugify(value):
return _unicode_slugify(value)
# -------------------------------------------------------
# Unfold Imports
from unfold.sites import UnfoldAdminSite

38
utils/media.py

@ -0,0 +1,38 @@
import datetime
import logging
from tinytag import TinyTag
logger = logging.getLogger(__name__)
def get_media_duration_seconds(file_path):
"""
Given a local file path, reads the duration using tinytag.
Returns duration in seconds (float) or None if error occurs.
"""
try:
tag = TinyTag.get(file_path)
return tag.duration
except Exception as e:
logger.error(f"Error reading media duration for {file_path}: {e}")
return None
def get_media_duration_time(file_path):
"""
Given a local file path, reads the duration and returns a datetime.time object.
Caps hours at 23 to comply with Django TimeField limits.
"""
duration_seconds = get_media_duration_seconds(file_path)
if duration_seconds is None:
return None
seconds = int(duration_seconds)
hours = seconds // 3600
if hours > 23:
hours = 23
minutes = 59
secs = 59
else:
minutes = (seconds % 3600) // 60
secs = seconds % 60
return datetime.time(hour=hours, minute=minutes, second=secs)

32
utils/mixins.py

@ -58,36 +58,8 @@ class LowercaseSlugMixin(models.Model):
abstract = True
def save(self, *args, **kwargs):
# 1. Generate slug if empty
if not self.slug:
source_value = getattr(self, self.slug_source_field, '') or ''
base_slug = slugify(source_value, allow_unicode=True) or 'slug'
base_slug = base_slug.lower()
# Ensure uniqueness
slug = base_slug
counter = 1
model_class = self.__class__
# Exclude current instance from conflict check if it exists in database
queryset = model_class.objects.all()
if self.pk:
queryset = queryset.exclude(pk=self.pk)
while queryset.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
else:
# 2. Force existing slug to lowercase (handling both string and JSONField lists)
if isinstance(self.slug, str):
self.slug = self.slug.lower()
elif isinstance(self.slug, list):
for item in self.slug:
if isinstance(item, dict) and "title" in item and item["title"]:
item["title"] = str(item["title"]).lower()
from utils.slug import build_slug_for_instance
build_slug_for_instance(self, source_field=self.slug_source_field)
super().save(*args, **kwargs)

61
utils/slug.py

@ -137,3 +137,64 @@ def generate_unique_slug(
instance=instance,
keep_words=999, # No word limit
)
def build_slug_for_instance(instance, source_field='title', slug_field='slug', max_length=100):
"""
Helper to automatically build, update, and ensure uniqueness/lowercase for a model's slug.
If the record is new: generates slug from source_field if slug is empty.
If the record is being updated: if source_field has changed, regenerates slug.
"""
model_class = instance.__class__
source_val = getattr(instance, source_field, None)
# Helper to extract raw text if source value is a list (e.g. JSONField translation list)
def extract_text(val):
if not val:
return ""
if isinstance(val, str):
return val.strip()
if isinstance(val, list) and len(val) > 0:
first_item = val[0]
if isinstance(first_item, dict):
return (first_item.get('text') or first_item.get('value') or first_item.get('title') or '').strip()
return str(first_item).strip()
return str(val).strip()
text = extract_text(source_val)
current_slug = getattr(instance, slug_field, None)
is_new = instance.pk is None
should_generate = False
if is_new:
if not current_slug or not str(current_slug).strip():
should_generate = True
else:
# Fetch the original source and slug values from DB
orig = model_class.objects.filter(pk=instance.pk).values(source_field, slug_field).first()
if orig:
orig_source_val = orig.get(source_field)
orig_slug = orig.get(slug_field)
orig_text = extract_text(orig_source_val)
# If the text from the source field changed, or the slug is empty
if orig_text != text or not orig_slug or not str(orig_slug).strip():
should_generate = True
if should_generate:
# Generate new slug using generate_smart_slug to handle uniqueness, unicode, and limits safely
new_slug = generate_smart_slug(
text=text,
model_class=model_class,
max_length=max_length,
field_name=slug_field,
instance=instance,
keep_words=999, # Keep all words up to max_length
)
setattr(instance, slug_field, new_slug.lower())
else:
# Enforce lowercase on existing slug
if current_slug and isinstance(current_slug, str):
setattr(instance, slug_field, current_slug.lower())
Loading…
Cancel
Save