diff --git a/apps/article/__init__.py b/apps/article/__init__.py
new file mode 100755
index 0000000..e69de29
diff --git a/apps/article/admin.py b/apps/article/admin.py
new file mode 100755
index 0000000..f25c0d1
--- /dev/null
+++ b/apps/article/admin.py
@@ -0,0 +1,273 @@
+from django.contrib import admin
+from django.utils.translation import gettext_lazy as _
+from django.urls import reverse
+from django.utils.html import format_html
+from django.db import models
+from ajaxdatatable.admin import AjaxDatatable
+from unfold.admin import ModelAdmin, StackedInline, TabularInline
+from django.contrib.admin import SimpleListFilter
+from unfold.widgets import UnfoldAdminSelectWidget
+from django.shortcuts import get_object_or_404, redirect, render
+
+from unfold.decorators import display, action
+from django import forms
+from django.urls import path, reverse_lazy
+
+from utils.admin import project_admin_site
+from unfold.sections import TableSection
+
+from apps.article.models import (
+ ArticleCategory,
+ ArticleCollection,
+ PinnedArticleCollection,
+ MiddleArticleCollection,
+ Article,
+ ArticleInCollection,
+ ArticleContent,
+ ContentPart
+)
+
+
+class ArticleInCollectionInline(TabularInline):
+ model = ArticleInCollection
+ extra = 1
+ autocomplete_fields = ('article',)
+ fields = ('article', 'order')
+ ordering = ('order',)
+ verbose_name = _('Article')
+ verbose_name_plural = _('Articles')
+ tab = True
+
+
+class ContentPartInline(StackedInline):
+ model = ContentPart
+ extra = 1
+ fields = ('arabic_text', 'translation', 'order')
+ ordering = ('order',)
+ verbose_name = _('Content Part')
+ verbose_name_plural = _('Content Parts')
+
+
+class ArticleContentInline(StackedInline):
+ model = ArticleContent
+ extra = 1
+ fields = ('title', 'content', 'priority', 'status')
+ ordering = ('priority',)
+ verbose_name = _('Article Content')
+ verbose_name_plural = _('Article Contents')
+ tab = True
+
+
+class ArticleCollectionAdminBase(ModelAdmin):
+ list_display = ('get_title', 'status', 'order', 'count_articles')
+ list_filter = ('status', 'order')
+ search_fields = ('title',)
+ ordering = ('order',)
+ list_filter_submit = True
+ warn_unsaved_form = True
+ change_form_show_cancel_button = True
+ inlines = [ArticleInCollectionInline]
+
+
+ fieldsets = (
+ (None, {
+ 'fields': ('title', 'summary', 'thumbnail', 'status', 'pin_top', 'order')
+ }),
+ )
+
+ exclude = ('display_position',)
+
+ @display(description=_('Title'))
+ def get_title(self, obj):
+ return str(obj.title)
+
+ @display(description=_('Number of Articles'))
+ def count_articles(self, obj):
+ count = obj.related_articles.count()
+ if count > 0:
+ url = reverse('admin:article_article_changelist') + f'?collections__id__exact={obj.id}'
+ return format_html('{}', url, count)
+ return count
+
+
+class PinnedArticleCollectionForm(forms.ModelForm):
+ class Meta:
+ model = PinnedArticleCollection
+ exclude = ('slug',)
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields['thumbnail'].required = True
+
+
+class PinnedArticleCollectionAdmin(ArticleCollectionAdminBase):
+ form = PinnedArticleCollectionForm
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).filter(display_position=ArticleCollection.DisplayPosition.PINNED)
+
+ def save_model(self, request, obj, form, change):
+ obj.display_position = ArticleCollection.DisplayPosition.PINNED
+ super().save_model(request, obj, form, change)
+
+
+ @display(description=_('Title'))
+ def get_title(self, obj):
+ from django.templatetags.static import static
+ thumbnail_path = obj.thumbnail.url if obj.thumbnail else None
+ return obj.title
+ # Uncomment below to show thumbnail in the title column
+ # return [
+ # obj.title,
+ # None,
+ # None,
+ # {
+ # "path": thumbnail_path,
+ # "height": 30,
+ # "width": 50,
+ # "borderless": True,
+ # # "squared": True,
+ # },
+ # ]
+
+
+class MiddleArticleCollectionAdmin(ArticleCollectionAdminBase):
+
+ fieldsets = (
+ (None, {
+ 'fields': ('title', 'status', 'pin_top', 'order')
+ }),
+ )
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).filter(display_position=ArticleCollection.DisplayPosition.MIDDLE)
+
+ def save_model(self, request, obj, form, change):
+ obj.display_position = ArticleCollection.DisplayPosition.MIDDLE
+ super().save_model(request, obj, form, change)
+
+
+class ArticleCategoryAdmin(ModelAdmin):
+ list_display = ('display_header', 'slug', 'status', 'order', 'count_articles', 'created_at')
+ list_filter = ('status', 'created_at', 'updated_at')
+ search_fields = ('title', 'slug')
+
+
+ @admin.display(description=_('Number of Articles'))
+ def count_articles(self, obj):
+ count = obj.articles.count()
+ if count > 0:
+ url = reverse('admin:article_article_changelist') + f'?categories__id__exact={obj.id}'
+ return format_html('{}', url, count)
+ return count
+
+ def get_form(self, request, obj=None, change=False, **kwargs):
+ form = super().get_form(request, obj, change, **kwargs)
+ if form.base_fields.get('slug'):
+ form.base_fields['slug'].required = False
+ return form
+
+ @display(description=_("Category"), header=True)
+ def display_header(self, obj):
+ return obj.title
+
+
+class ArticleAdmin(ModelAdmin):
+
+ # change_form_before_template = 'article/change_form_before_template.html'
+ list_display = ('display_header', 'slug', 'status', 'view_count', 'created_at')
+ list_filter = ('status', 'created_at', 'updated_at')
+ search_fields = ('title', 'slug', 'description', 'content')
+ autocomplete_fields = ('categories',)
+ save_as = True
+ search_help_text = _("Search by title, slug, description or content")
+ search_fields_placeholder = _("Search articles")
+ # inlines = [ArticleContentInline]
+ actions_row = [
+ "action_contents",
+ ]
+
+ fieldsets = (
+ (None, {
+ 'fields': ('title', 'slug', 'description', 'thumbnail', 'categories')
+ }),
+ (_('File'), {
+ 'fields': ('article_file',)
+ }),
+ (_('Status'), {
+ 'fields': ('status',)
+ }),
+ (_('Statistics'), {
+ 'fields': ('view_count',)
+ }),
+ )
+
+ def get_form(self, request, obj=None, change=False, **kwargs):
+ form = super().get_form(request, obj, change, **kwargs)
+ if form.base_fields.get('slug'):
+ form.base_fields['slug'].required = False
+ if form.base_fields.get('thumbnail'):
+ form.base_fields['thumbnail'].required = True
+ return form
+
+
+ @action(
+ description=_("Contents"),
+ url_path="actions-row-custom-url",
+ )
+ def action_contents(self, request, object_id):
+ article = get_object_or_404(Article, pk=object_id)
+ url = reverse('admin:article_articlecontent_changelist') + f'?article__id__exact={article.id}'
+ return redirect(url)
+
+ @display(description=_("Article"), header=True)
+ def display_header(self, obj):
+ from django.templatetags.static import static
+
+ # Get thumbnail image path - use article's thumbnail if available, otherwise use default
+ thumbnail_path = obj.thumbnail.url if obj.thumbnail else None
+
+ return [
+ obj.title,
+ None,
+ None,
+ {
+ "path": thumbnail_path,
+ "height": 30,
+ "width": 50,
+ "borderless": True,
+ # "squared": True,
+ },
+ ]
+
+
+class ArticleContentAdmin(ModelAdmin):
+ list_display = ('title', 'article', 'priority', 'status', 'created_at')
+ list_filter = ('status', 'priority', 'created_at')
+ search_fields = ('title', 'content')
+ autocomplete_fields = ('article',)
+ inlines = [ContentPartInline]
+
+ fieldsets = (
+ (None, {
+ 'fields': ('article', 'title', 'content', 'priority', 'status')
+ }),
+ )
+
+ def get_changeform_initial_data(self, request):
+ initial = super().get_changeform_initial_data(request)
+ if 'article__id__exact' in request.GET:
+ initial['article'] = request.GET.get('article__id__exact')
+ return initial
+
+ # @display(description=_("Content Title"), header=True)
+ # def display_header(self, obj):
+ # return str(obj.title)
+
+
+# Register models with admin site
+project_admin_site.register(ArticleCategory, ArticleCategoryAdmin)
+project_admin_site.register(PinnedArticleCollection, PinnedArticleCollectionAdmin)
+project_admin_site.register(MiddleArticleCollection, MiddleArticleCollectionAdmin)
+project_admin_site.register(Article, ArticleAdmin)
+project_admin_site.register(ArticleContent, ArticleContentAdmin)
diff --git a/apps/article/apps.py b/apps/article/apps.py
new file mode 100755
index 0000000..1a2757e
--- /dev/null
+++ b/apps/article/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ArticleConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'apps.article'
diff --git a/apps/article/migrations/0001_initial.py b/apps/article/migrations/0001_initial.py
new file mode 100755
index 0000000..46004ac
--- /dev/null
+++ b/apps/article/migrations/0001_initial.py
@@ -0,0 +1,161 @@
+# Generated by Django 5.1.8 on 2025-05-06 12:35
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ArticleCategory',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=255, verbose_name='title')),
+ ('slug', models.SlugField(allow_unicode=True, unique=True, verbose_name='slug')),
+ ('status', models.BooleanField(default=True, verbose_name='status')),
+ ('order', models.PositiveIntegerField(default=0, verbose_name='order')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ],
+ options={
+ 'verbose_name': 'Article Category',
+ 'verbose_name_plural': 'Article Categories',
+ 'ordering': ['order'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ArticleCollection',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(help_text='This title will not be displayed anywhere', max_length=255)),
+ ('slug', models.SlugField(max_length=255, unique=True)),
+ ('summary', models.CharField(blank=True, help_text='could be null', max_length=512, null=True)),
+ ('pin_top', models.BooleanField(default=True, verbose_name='pin top')),
+ ('thumbnail', models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='article/collection/')),
+ ('order', models.IntegerField(default=0, verbose_name='order')),
+ ('status', models.BooleanField(default=True, verbose_name='status')),
+ ('display_position', models.CharField(choices=[('pinned', 'Pinned'), ('middle', 'Middle Section')], default='pinned', max_length=20, verbose_name='Display Position')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ],
+ options={
+ 'verbose_name': 'Article Collection',
+ 'verbose_name_plural': 'Articles Collections',
+ },
+ ),
+ migrations.CreateModel(
+ name='Article',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=255, null=True)),
+ ('slug', models.SlugField(allow_unicode=True, unique=True)),
+ ('thumbnail', models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='article_thumbnails/')),
+ ('description', models.TextField(null=True)),
+ ('content', models.TextField(null=True)),
+ ('article_file', models.FileField(blank=True, help_text='PDF or other document files', null=True, upload_to='article/files/')),
+ ('view_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')),
+ ('status', models.BooleanField(default=True, verbose_name='status')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ('categories', models.ManyToManyField(blank=True, related_name='articles', to='article.articlecategory', verbose_name='categories')),
+ ],
+ options={
+ 'verbose_name': 'Article',
+ 'verbose_name_plural': 'Articles',
+ },
+ ),
+ migrations.CreateModel(
+ name='MiddleArticleCollection',
+ fields=[
+ ],
+ options={
+ 'verbose_name': 'Middle Section Article Collection',
+ 'verbose_name_plural': 'Middle Section Article Collections',
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('article.articlecollection',),
+ ),
+ migrations.CreateModel(
+ name='PinnedArticleCollection',
+ fields=[
+ ],
+ options={
+ 'verbose_name': 'Pinned Article Collection',
+ 'verbose_name_plural': 'Pinned Article Collections',
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('article.articlecollection',),
+ ),
+ migrations.CreateModel(
+ name='ArticleContent',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=255, verbose_name='title')),
+ ('content', models.TextField(blank=True, verbose_name='content')),
+ ('priority', models.PositiveIntegerField(default=0, verbose_name='priority')),
+ ('status', models.BooleanField(default=True, verbose_name='status')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contents', to='article.article', verbose_name='article')),
+ ],
+ options={
+ 'verbose_name': 'Article Content',
+ 'verbose_name_plural': 'Article Contents',
+ 'ordering': ['priority'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ArticleInCollection',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('order', models.PositiveIntegerField(default=0, verbose_name='order')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_collections', to='article.article', verbose_name='article')),
+ ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_articles', to='article.articlecollection', verbose_name='collection')),
+ ],
+ options={
+ 'verbose_name': 'Article in Collection',
+ 'verbose_name_plural': 'Articles in Collections',
+ 'ordering': ['order'],
+ 'unique_together': {('collection', 'article')},
+ },
+ ),
+ migrations.AddField(
+ model_name='articlecollection',
+ name='articles',
+ field=models.ManyToManyField(related_name='related_collections_article', through='article.ArticleInCollection', to='article.article', verbose_name='articles'),
+ ),
+ migrations.AddField(
+ model_name='article',
+ name='collections',
+ field=models.ManyToManyField(blank=True, related_name='related_articles', through='article.ArticleInCollection', to='article.articlecollection', verbose_name='collections'),
+ ),
+ migrations.CreateModel(
+ name='ContentPart',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('arabic_text', models.TextField(verbose_name='Arabic text')),
+ ('translation', models.TextField(verbose_name='Translation')),
+ ('order', models.PositiveIntegerField(default=0, verbose_name='order')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ('article_content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='article.articlecontent', verbose_name='article content')),
+ ],
+ options={
+ 'verbose_name': 'Content Part',
+ 'verbose_name_plural': 'Content Parts',
+ 'ordering': ['order'],
+ },
+ ),
+ ]
diff --git a/apps/article/migrations/__init__.py b/apps/article/migrations/__init__.py
new file mode 100755
index 0000000..e69de29
diff --git a/apps/article/models.py b/apps/article/models.py
new file mode 100755
index 0000000..91cf186
--- /dev/null
+++ b/apps/article/models.py
@@ -0,0 +1,211 @@
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from utils import generate_slug_for_model
+
+
+class ArticleCategory(models.Model):
+ title = models.CharField(max_length=255, verbose_name=_('title'))
+ slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug'))
+
+ status = models.BooleanField(default=True, verbose_name=_('status'))
+ order = models.PositiveIntegerField(default=0, verbose_name=_('order'))
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ 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 DisplayPosition(models.TextChoices):
+ PINNED = 'pinned', _('Pinned')
+ MIDDLE = 'middle', _('Middle Section')
+
+ title = models.CharField(max_length=255, help_text="This title will not be displayed anywhere")
+ slug = models.SlugField(max_length=255, unique=True)
+ summary = models.CharField(max_length=512, null=True, blank=True, help_text=_('could be null'))
+ pin_top = models.BooleanField(_('pin top'), default=True)
+ thumbnail = models.ImageField(upload_to='article/collection/', null=True, blank=True, help_text=_('image allowed'))
+ order = models.IntegerField(default=0, verbose_name=_('order'))
+ status = models.BooleanField(default=True, verbose_name=_('status'))
+ display_position = models.CharField(
+ max_length=20,
+ choices=DisplayPosition.choices,
+ default=DisplayPosition.PINNED,
+ verbose_name=_('Display Position')
+ )
+
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+ articles = models.ManyToManyField(
+ 'Article',
+ through='ArticleInCollection',
+ related_name='related_collections_article',
+ verbose_name=_('articles'),
+ )
+
+ 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')
+
+
+class PinnedArticleCollection(ArticleCollection):
+ class Meta:
+ proxy = True
+ verbose_name = _('Pinned Article Collection')
+ verbose_name_plural = _('Pinned Article Collections')
+
+
+class MiddleArticleCollection(ArticleCollection):
+ class Meta:
+ proxy = True
+ verbose_name = _('Middle Section Article Collection')
+ verbose_name_plural = _('Middle Section Article Collections')
+
+
+class Article(models.Model):
+
+ title = models.CharField(max_length=255, null=True)
+ slug = models.SlugField(allow_unicode=True, unique=True)
+ thumbnail = models.ImageField(upload_to='article_thumbnails/', null=True, blank=True, help_text=_('image allowed'))
+ description = models.TextField(null=True)
+ content = models.TextField(null=True)
+ article_file = models.FileField(upload_to='article/files/', null=True, blank=True, help_text=_('PDF or other document files'))
+
+ categories = models.ManyToManyField(ArticleCategory, related_name='articles', verbose_name=_('categories'), blank=True)
+ collections = models.ManyToManyField(
+ ArticleCollection,
+ through='ArticleInCollection',
+ related_name='related_articles',
+ verbose_name=_('collections'),
+ blank=True
+ )
+ download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count'))
+
+ view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count'))
+
+ status = models.BooleanField(default=True, verbose_name=_('status'))
+
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ def __str__(self):
+ return self.title
+
+ def increment_view_count(self):
+ self.view_count += 1
+ self.save(update_fields=['view_count'])
+ return self.view_count
+
+ def save(self, *args, **kwargs):
+ if not self.slug:
+ self.slug = generate_slug_for_model(Article, self.title)
+ super().save(*args, **kwargs)
+
+ class Meta:
+ verbose_name = _('Article')
+ verbose_name_plural = _('Articles')
+
+
+
+class ArticleInCollection(models.Model):
+ collection = models.ForeignKey(
+ ArticleCollection,
+ on_delete=models.CASCADE,
+ related_name='collection_articles',
+ verbose_name=_('collection')
+ )
+ article = models.ForeignKey(
+ Article,
+ on_delete=models.CASCADE,
+ related_name='article_collections',
+ verbose_name=_('article')
+ )
+ order = models.PositiveIntegerField(default=0, verbose_name=_('order'))
+
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ def __str__(self):
+ return f"{self.collection.title} - {self.article.title}"
+
+ class Meta:
+ verbose_name = _('Article in Collection')
+ verbose_name_plural = _('Articles in Collections')
+ ordering = ['order']
+ unique_together = ['collection', 'article']
+
+
+class ArticleContent(models.Model):
+ """
+ Model for structured content sections within an article
+ """
+ article = models.ForeignKey(
+ Article,
+ on_delete=models.CASCADE,
+ related_name='contents',
+ verbose_name=_('article')
+ )
+ title = models.CharField(max_length=255, verbose_name=_('title'))
+ content = models.TextField(verbose_name=_('content'), blank=True)
+ priority = models.PositiveIntegerField(default=0, verbose_name=_('priority'))
+
+ status = models.BooleanField(default=True, verbose_name=_('status'))
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ class Meta:
+ verbose_name = _('Article Content')
+ verbose_name_plural = _('Article Contents')
+ ordering = ['priority']
+
+ def __str__(self):
+ return f"{self.article.title} - {self.title}"
+
+
+class ContentPart(models.Model):
+ """
+ Model for bilingual content parts (Arabic text and translation)
+ """
+ article_content = models.ForeignKey(
+ ArticleContent,
+ on_delete=models.CASCADE,
+ related_name='parts',
+ verbose_name=_('article content')
+ )
+ arabic_text = models.TextField(verbose_name=_('Arabic text'))
+ translation = models.TextField(verbose_name=_('Translation'))
+ order = models.PositiveIntegerField(default=0, verbose_name=_('order'))
+
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ class Meta:
+ verbose_name = _('Content Part')
+ verbose_name_plural = _('Content Parts')
+ ordering = ['order']
+
+ def __str__(self):
+ return f"{self.article_content.title} - Part {self.order}"
+
+
+
diff --git a/apps/article/serializers.py b/apps/article/serializers.py
new file mode 100644
index 0000000..28ace47
--- /dev/null
+++ b/apps/article/serializers.py
@@ -0,0 +1,131 @@
+from rest_framework import serializers
+from utils import get_thumbs
+from apps.article.models import *
+from apps.bookmark.serializers import *
+
+
+class ArticleCategoryListSerializer(serializers.ModelSerializer):
+ acticle_count = serializers.SerializerMethodField()
+
+ class Meta:
+ model = ArticleCategory
+ fields = ['id', 'title', 'slug', 'acticle_count']
+
+ def get_acticle_count(self, obj):
+ return obj.articles.filter(status=True).count()
+
+class PinnedArticleCollectionSerializer(serializers.ModelSerializer):
+ thumbnail = serializers.SerializerMethodField()
+
+ class Meta:
+ model = ArticleCollection
+ fields = ['id', 'title', 'slug', 'summary', 'thumbnail', 'order', 'created_at']
+
+ def get_thumbnail(self, obj):
+ return get_thumbs(obj.thumbnail, self.context.get('request'))
+
+
+
+
+class MiddleArticleCollectionSerializer(serializers.ModelSerializer):
+ articles = serializers.SerializerMethodField()
+
+ class Meta:
+ model = ArticleCollection
+ fields = ('id', 'title', 'slug', 'summary', 'status', 'order', 'pin_top', 'articles')
+
+ def get_podcasts(self, obj):
+ articles = obj.articles.filter(status=True).order_by('-created_at')
+ return ArticleListSerializer(articles, many=True, context=self.context).data
+
+
+
+
+class ArticleListSerializer(serializers.ModelSerializer):
+ thumbnail = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Article
+ fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'view_count', 'created_at']
+
+ def get_thumbnail(self, obj):
+ return get_thumbs(obj.thumbnail, self.context.get('request'))
+
+
+class ContentPartSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ContentPart
+ fields = ['id', 'arabic_text', 'translation', 'order', 'created_at', 'updated_at']
+
+class ArticleContentSerializer(serializers.ModelSerializer):
+ parts = ContentPartSerializer(many=True, read_only=True)
+ class Meta:
+ model = ArticleContent
+ fields = ['id', 'title', 'content', 'priority', 'status', 'created_at', 'updated_at', 'parts']
+
+class ArticleDetailSerializer(serializers.ModelSerializer):
+ categories = ArticleCategoryListSerializer(many=True, read_only=True)
+ thumbnail = serializers.SerializerMethodField()
+ bookmark = serializers.SerializerMethodField()
+ user_rate = serializers.SerializerMethodField()
+ average_rate = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Article
+ fields = ['id', 'title', 'slug', 'thumbnail', 'description',
+ 'article_file', 'view_count', 'download_count',
+ 'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark']
+
+ def get_thumbnail(self, obj):
+ return get_thumbs(obj.thumbnail, self.context.get('request'))
+
+ def get_bookmark(self, obj):
+ """
+ Get bookmark information for this article.
+ """
+ # Get the current user from the request context
+ request = self.context.get('request')
+ user = request.user if request else None
+ book_mark = BookmarkStatusSerializer.get_bookmark_info(
+ obj=obj,
+ user=user,
+ service='article',
+ )
+ return book_mark.get('is_bookmarked', False)
+
+ def get_user_rate(self, obj):
+ """
+ Get rate information for this article from the current user.
+ """
+ from apps.bookmark.models.rate import Rate
+
+ # Get the current user from the request context
+ request = self.context.get('request')
+ user = request.user if request and request.user.is_authenticated else None
+
+ if not user:
+ return {
+ 'is_rated': False,
+ 'rate': None
+ }
+
+ # Get rate information using the Rate model's method
+ rate_info = Rate.get_user_rate(
+ user=user,
+ service='article',
+ content_id=obj.id
+ )
+
+ return rate_info
+
+ def get_average_rate(self, obj):
+ """
+ Get the average rate for this article.
+ """
+ from apps.bookmark.models.rate import Rate
+
+ # Get average rate information using the Rate model
+ return Rate.get_average_rate(
+ service='article',
+ content_id=obj.id
+ )
diff --git a/apps/article/templates/article/change_form_before_template.html b/apps/article/templates/article/change_form_before_template.html
new file mode 100755
index 0000000..886c7f1
--- /dev/null
+++ b/apps/article/templates/article/change_form_before_template.html
@@ -0,0 +1,4 @@
+{% load i18n %}
+{% load unfold %}
+{% load course_tags %}
+
diff --git a/apps/article/tests.py b/apps/article/tests.py
new file mode 100755
index 0000000..7ce503c
--- /dev/null
+++ b/apps/article/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/apps/article/urls.py b/apps/article/urls.py
new file mode 100755
index 0000000..afdf826
--- /dev/null
+++ b/apps/article/urls.py
@@ -0,0 +1,17 @@
+from django.urls import path
+from .views import *
+
+app_name = 'article'
+
+urlpatterns = [
+ path('categories/', ArticleCategoryListAPIView.as_view(), name='category-list'),
+ path('pinned-collections/', PinnedArticleCollectionListView.as_view(), name='pinned-collection-list'),
+ path('collections/', MiddleArticleCollectionListView.as_view(), name='collection-list'),
+
+ path('list/', ArticleListAPIView.as_view(), name='podcast-list'),
+ path('detail//', ArticleDetailAPIView.as_view(), name='podcast-detail'),
+
+ # # User playlist endpoints
+ # path('user-playlist/', UserPlaylistCreateAPIView.as_view(), name='user-playlist-create'),
+ # path('user-playlist/list/', UserPlaylistListAPIView.as_view(), name='user-playlist-list'),
+]
\ No newline at end of file
diff --git a/apps/article/views.py b/apps/article/views.py
new file mode 100755
index 0000000..f61fe17
--- /dev/null
+++ b/apps/article/views.py
@@ -0,0 +1,177 @@
+from rest_framework import generics, status
+from rest_framework.response import Response
+from drf_yasg import openapi
+from drf_yasg.utils import swagger_auto_schema
+from apps.library.pagination import NoPagination
+from rest_framework.permissions import IsAuthenticated
+
+
+from apps.article.models import *
+from apps.article.serializers import *
+
+
+class ArticleCategoryListAPIView(generics.ListAPIView):
+ serializer_class = ArticleCategoryListSerializer
+
+ @swagger_auto_schema(
+ operation_description="Get a list of all active article categories",
+ responses={
+ 200: openapi.Response(
+ description="List of article categories",
+ schema=ArticleCategoryListSerializer(many=True)
+ )
+ }
+ )
+ def get(self, request, *args, **kwargs):
+ return super().get(request, *args, **kwargs)
+
+ def get_queryset(self):
+ return ArticleCategory.objects.filter(status=True).order_by('order')
+
+
+class PinnedArticleCollectionListView(generics.ListAPIView):
+ serializer_class = PinnedArticleCollectionSerializer
+ permission_classes = (IsAuthenticated,)
+ pagination_class = NoPagination
+
+
+ def get_queryset(self):
+ return PinnedArticleCollection.objects.filter(
+ status=True,
+ display_position=ArticleCollection.DisplayPosition.PINNED
+ ).order_by('-order', '-id')
+
+ def list(self, request, *args, **kwargs):
+ response = super().list(request, *args, **kwargs)
+ categories_count = ArticleCategory.objects.filter(status=True).count()
+ from apps.bookmark.models import Bookmark
+ bookmarks_count = Bookmark.objects.filter(
+ service=Bookmark.ServiceChoices.ARTICLE,
+ ).count()
+
+ info = {
+ "categories_count": categories_count,
+ "bookmarks_count": bookmarks_count,
+ }
+
+ data = {
+ "count": response.data.get("count"),
+ "next": response.data.get("next"),
+ "previous": response.data.get("previous"),
+ "info": info,
+ "results": response.data.get("results")
+ }
+ return Response(data, status=status.HTTP_200_OK)
+
+class MiddleArticleCollectionListView(generics.ListAPIView):
+ serializer_class = MiddleArticleCollectionSerializer
+ permission_classes = (IsAuthenticated,)
+ pagination_class = NoPagination
+
+ def get_queryset(self):
+ return ArticleCollection.objects.filter(
+ status=True,
+ display_position=ArticleCollection.DisplayPosition.MIDDLE
+ ).order_by('order')
+
+
+
+class ArticleListAPIView(generics.ListAPIView):
+ serializer_class = ArticleListSerializer
+ permission_classes = (IsAuthenticated,)
+
+ @swagger_auto_schema(
+ operation_description="Get a list of article with optional filtering",
+ manual_parameters=[
+ openapi.Parameter(
+ name='category',
+ in_=openapi.IN_QUERY,
+ description='Filter article by category slug',
+ type=openapi.TYPE_STRING,
+ required=False
+ ),
+ openapi.Parameter(
+ name='collection',
+ in_=openapi.IN_QUERY,
+ description='Filter article by collection slug',
+ type=openapi.TYPE_STRING,
+ required=False
+ ),
+ openapi.Parameter(
+ name='is_bookmark',
+ in_=openapi.IN_QUERY,
+ description='Filter article that are bookmarked by the user (true/false)',
+ type=openapi.TYPE_BOOLEAN,
+ required=False
+ ),
+ openapi.Parameter(
+ name='search',
+ in_=openapi.IN_QUERY,
+ description='Search article by title',
+ type=openapi.TYPE_STRING,
+ required=False
+ )
+ ],
+ responses={
+ 200: openapi.Response(
+ description="List of article",
+ schema=ArticleListSerializer(many=True)
+ )
+ }
+ )
+ def get(self, request, *args, **kwargs):
+ return super().get(request, *args, **kwargs)
+
+ def get_queryset(self):
+ queryset = Article.objects.filter(status=True).order_by('-created_at')
+
+ # Search by title if search parameter is provided
+ search_query = self.request.query_params.get('search', None)
+ if search_query:
+ queryset = queryset.filter(title__icontains=search_query)
+
+ # Filter by category if provided
+ category_slug = self.request.query_params.get('category', None)
+ if 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:
+ # Get all podcasts that are in the collection with the given slug
+ queryset = queryset.filter(
+ collections__slug=collection_slug
+ )
+
+
+ # Filter by bookmarks if provided
+ 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 podcast IDs for the current user
+ bookmarked_ids = Bookmark.objects.filter(
+ user=self.request.user,
+ service=Bookmark.ServiceChoices.ARTICLE,
+ status=True
+ ).values_list('content_id', flat=True)
+
+ # Filter podcasts by these IDs
+ queryset = queryset.filter(id__in=bookmarked_ids)
+
+ return queryset
+
+
+class ArticleDetailAPIView(generics.RetrieveAPIView):
+ serializer_class = ArticleDetailSerializer
+ lookup_field = 'slug'
+
+ def get_queryset(self):
+ return Article.objects.filter(status=True)
+
+ def retrieve(self, request, *args, **kwargs):
+ instance = self.get_object()
+ instance.increment_view_count()
+ serializer = self.get_serializer(instance)
+ return Response(serializer.data)
diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py
index 7718d43..90a91e4 100644
--- a/apps/course/admin/course.py
+++ b/apps/course/admin/course.py
@@ -253,11 +253,11 @@ class CourseAdmin(ModelAdmin):
"level": admin.HORIZONTAL,
}
show_facets = admin.ShowFacets.ALLOW
- formfield_overrides = {
- models.TextField: {
- "widget": WysiwygWidget,
- },
- }
+ # formfield_overrides = {
+ # models.TextField: {
+ # "widget": WysiwygWidget,
+ # },
+ # }
conditional_fields = {
'price': "is_free == false",
'discount_percentage': "is_free == false",
@@ -299,7 +299,8 @@ class CourseAdmin(ModelAdmin):
return [
instance.title,
- instance.short_description or _("No description"),
+ # instance.short_description or _("No description"),
+ None,
None,
{
"path": thumbnail_path,
diff --git a/apps/podcast/__init__.py b/apps/podcast/__init__.py
old mode 100644
new mode 100755
diff --git a/apps/podcast/admin.py b/apps/podcast/admin.py
old mode 100644
new mode 100755
index fb046ae..88e41c5
--- a/apps/podcast/admin.py
+++ b/apps/podcast/admin.py
@@ -1,23 +1,290 @@
from django.contrib import admin
-from ajaxdatatable.admin import AjaxDatatable
+from django.utils.translation import gettext_lazy as _
+from django.urls import reverse
+from django.utils.html import format_html
+from django.db import models
+from unfold.admin import ModelAdmin, StackedInline, TabularInline
+from django.contrib.admin import SimpleListFilter
+from unfold.widgets import UnfoldAdminSelectWidget
-from apps.podcast.models import *
+from unfold.decorators import display, action
+from django import forms
+from utils.admin import project_admin_site
+from unfold.sections import TableSection
+from apps.podcast.models import *
-class PodcastInCollectionInline(admin.TabularInline):
+class PodcastInCollectionInline(TabularInline):
model = PodcastInCollection
- extra = 1
+ extra = 1
+ autocomplete_fields = ('podcast',)
+ fields = ('podcast', 'order')
+ ordering = ('order',)
+ verbose_name = _('Podcast')
+ verbose_name_plural = _('Podcasts')
-@admin.register(PodcastCollection)
-class PodcastCollectionAdmin(AjaxDatatable):
- list_display = ('title',)
+class PodcastCollectionAdminBase(ModelAdmin):
+ list_display = ('get_title', 'status', 'order', 'count_podcasts')
+ list_filter = ('status', 'order')
+ search_fields = ('title',)
+ ordering = ('order',)
+ list_filter_submit = True
+ warn_unsaved_form = True
+ change_form_show_cancel_button = True
inlines = [PodcastInCollectionInline]
+
+ fieldsets = (
+ (None, {
+ 'fields': ('title', 'summary', 'thumbnail', 'status', 'pin_top', 'order')
+ }),
+ )
-@admin.register(Podcast)
-class PodcastAdmin(AjaxDatatable):
- list_display = ('title', 'view_count', 'download_count', 'status')
- search_fields = ('title',)
+ exclude = ('display_position',)
+
+ @display(description=_('Title'))
+ def get_title(self, obj):
+ return str(obj.title)
+
+ @display(description=_('Number of Podcasts'))
+ def count_podcasts(self, obj):
+ count = obj.related_podcasts.count()
+ if count > 0:
+ url = reverse('admin:podcast_podcast_changelist') + f'?collections__id__exact={obj.id}'
+ return format_html('{}', url, count)
+ return count
+
+
+class PinnedPodcastCollectionForm(forms.ModelForm):
+ class Meta:
+ model = PinnedPodcastCollection
+ exclude = ('slug',)
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields['thumbnail'].required = True
+
+class PinnedPodcastCollectionAdmin(PodcastCollectionAdminBase):
+ form = PinnedPodcastCollectionForm
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).filter(display_position=PodcastCollection.DisplayPosition.PINNED)
+
+ def save_model(self, request, obj, form, change):
+ obj.display_position = PodcastCollection.DisplayPosition.PINNED
+ super().save_model(request, obj, form, change)
+
+
+ @display(description=_('Title'))
+ def get_title(self, obj):
+ from django.templatetags.static import static
+ thumbnail_path = obj.thumbnail.url if obj.thumbnail else None
+ return obj.title
+
+class MiddlePodcastCollectionAdmin(PodcastCollectionAdminBase):
+
+ fieldsets = (
+ (None, {
+ 'fields': ('title', 'status', 'pin_top', 'order')
+ }),
+ )
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).filter(display_position=PodcastCollection.DisplayPosition.MIDDLE)
+
+ def save_model(self, request, obj, form, change):
+ obj.display_position = PodcastCollection.DisplayPosition.MIDDLE
+ super().save_model(request, obj, form, change)
+
+
+class PodcastCategoryAdmin(ModelAdmin):
+ list_display = ('title', 'slug', 'status', 'order', 'count_podcasts', 'created_at')
+ list_filter = ('status', 'created_at', 'updated_at')
+ search_fields = ('title', 'slug')
+ search_help_text = _("Search by title or slug")
+ search_fields_placeholder = _("Search categories")
+
+
+ @admin.display(description=_('Number of Podcasts'))
+ def count_podcasts(self, obj):
+ count = obj.podcasts.count()
+ if count > 0:
+ url = reverse('admin:podcast_podcast_changelist') + f'?categories__id__exact={obj.id}'
+ return format_html('{}', url, count)
+ return count
+
+ def get_form(self, request, obj=None, change=False, **kwargs):
+ form = super().get_form(request, obj, change, **kwargs)
+ if form.base_fields.get('slug'):
+ form.base_fields['slug'].required = False
+ return form
+
+
+class PodcastAdmin(ModelAdmin):
+ list_display = ('title', 'slug', 'status', 'view_count', 'created_at')
+ list_filter = ('status', 'created_at', 'updated_at')
+ search_fields = ('title', 'slug', 'description')
+ autocomplete_fields = ('categories',)
+ save_as = True
+ search_help_text = _("Search by title, slug, or description")
+ search_fields_placeholder = _("Search podcasts")
+
+ fieldsets = (
+ (None, {
+ 'fields': ('title', 'slug', 'description', 'thumbnail', 'categories')
+ }),
+ (_('Audio Information'), {
+ 'fields': ('audio_file', 'audio_time')
+ }),
+ (_('Status'), {
+ 'fields': ('status',)
+ }),
+ (_('Statistics'), {
+ 'fields': ('view_count', 'download_count')
+ }),
+ )
+
+ def get_form(self, request, obj=None, change=False, **kwargs):
+ form = super().get_form(request, obj, change, **kwargs)
+ if form.base_fields.get('slug'):
+ form.base_fields['slug'].required = False
+ if form.base_fields.get('thumbnail'):
+ form.base_fields['thumbnail'].required = True
+ return form
+
+
+class PodcastPlaylistItemForm(forms.ModelForm):
+ class Meta:
+ model = PlaylistItem
+ fields = ('podcast', 'priority')
+
+ def clean_podcast(self):
+ podcast = self.cleaned_data.get('podcast')
+ if not podcast:
+ return podcast
+
+ # If we're editing, exclude the current instance from the check
+ instance = getattr(self, 'instance', None)
+ if instance and instance.pk and instance.podcast == podcast:
+ return podcast
+
+ # Check if this podcast exists in another playlist
+ existing_item = PlaylistItem.objects.filter(podcast=podcast).first()
+ if existing_item:
+ playlist_name = existing_item.playlist.title
+ raise forms.ValidationError(
+ _('This podcast is already used in playlist "{}". Each podcast can only be in one playlist.').format(playlist_name)
+ )
+ return podcast
+
+
+class PodcastPlaylistItemInline(StackedInline):
+ model = PlaylistItem
+ form = PodcastPlaylistItemForm
+ extra = 1
+ autocomplete_fields = ('podcast',)
+ fields = ('podcast', 'priority')
+ ordering = ('priority',)
+ verbose_name = _('Playlist Item')
+ verbose_name_plural = _('Playlist Items')
+ tab = True
+
+class PodcastPlaylistAdmin(ModelAdmin):
+ list_display = ('title', 'count_podcasts', 'created_at')
+ list_filter = ('created_at',)
+ search_fields = ('title', )
+ list_filter_submit = True
+ warn_unsaved_form = True
+ change_form_show_cancel_button = True
+ inlines = [PodcastPlaylistItemInline]
+
+ fieldsets = (
+ (None, {
+ 'fields': ('title',)
+ }),
+ )
+
+
+ @display(description=_('Number of Podcasts'))
+ def count_podcasts(self, obj):
+ count = obj.playlist_items.count()
+ if count > 0:
+ return format_html('{}', count)
+ return count
+
+ def save_formset(self, request, form, formset, change):
+ """
+ Additional validation to ensure each podcast is used in only one playlist
+ """
+ instances = formset.save(commit=False)
+
+ # Collect all podcasts that are being saved
+ podcasts_to_save = []
+ for instance in instances:
+ if instance.podcast:
+ podcasts_to_save.append(instance.podcast)
+
+ # Check for duplicate podcasts in this formset
+ podcast_counts = {}
+ for podcast in podcasts_to_save:
+ podcast_counts[podcast.id] = podcast_counts.get(podcast.id, 0) + 1
+
+ duplicate_podcasts = [podcast_id for podcast_id, count in podcast_counts.items() if count > 1]
+ if duplicate_podcasts:
+ # If there are duplicate podcasts in this form, show an error
+ formset._non_form_errors = formset.error_class(
+ [_('A podcast cannot be used multiple times in the same playlist.')]
+ )
+ return
+
+ # Check if podcasts are used in other playlists
+ for instance in instances:
+ if instance.podcast: # For both new and edited items
+ playlist_id = form.instance.pk
+ query = PlaylistItem.objects.filter(
+ podcast=instance.podcast
+ ).exclude(
+ playlist_id=playlist_id
+ )
+
+ # If we're editing an existing item, exclude it from the check
+ if instance.pk:
+ query = query.exclude(pk=instance.pk)
+
+ existing_item = query.first()
+
+ if existing_item:
+ playlist_name = existing_item.playlist.title
+ formset._non_form_errors = formset.error_class(
+ [_('Podcast "{}" is already used in playlist "{}". Each podcast can only be in one playlist.').format(
+ instance.podcast.title, playlist_name
+ )]
+ )
+ return
+
+ # If all validations pass, save the formset
+ super().save_formset(request, form, formset, change)
+
+
+class UserPlaylistAdmin(ModelAdmin):
+ list_display = ('user', 'podcast', 'status', 'created_at', 'updated_at')
+ list_filter = ('status', 'created_at', 'updated_at')
+ search_fields = ('user__username', 'podcast__title')
+ autocomplete_fields = ('user', 'podcast')
+
+ fieldsets = (
+ (None, {
+ 'fields': ('user', 'podcast', 'status')
+ }),
+ )
+
+
+project_admin_site.register(PodcastCategory, PodcastCategoryAdmin)
+project_admin_site.register(Podcast, PodcastAdmin)
+project_admin_site.register(PinnedPodcastCollection, PinnedPodcastCollectionAdmin)
+project_admin_site.register(MiddlePodcastCollection, MiddlePodcastCollectionAdmin)
+project_admin_site.register(PodcastPlaylist, PodcastPlaylistAdmin)
+project_admin_site.register(UserPlaylist, UserPlaylistAdmin)
diff --git a/apps/podcast/apps.py b/apps/podcast/apps.py
old mode 100644
new mode 100755
diff --git a/apps/podcast/migrations/0001_initial.py b/apps/podcast/migrations/0001_initial.py
new file mode 100755
index 0000000..ba1b911
--- /dev/null
+++ b/apps/podcast/migrations/0001_initial.py
@@ -0,0 +1,170 @@
+# Generated by Django 5.1.8 on 2025-05-06 11:46
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='PodcastCategory',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=255, verbose_name='title')),
+ ('slug', models.SlugField(allow_unicode=True, unique=True, verbose_name='slug')),
+ ('status', models.BooleanField(default=True, verbose_name='status')),
+ ('order', models.PositiveIntegerField(default=0, verbose_name='order')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ],
+ options={
+ 'verbose_name': 'Podcast Category',
+ 'verbose_name_plural': 'Podcast Categories',
+ 'ordering': ['order'],
+ },
+ ),
+ migrations.CreateModel(
+ name='PodcastCollection',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(help_text='This title will not be displayed anywhere', max_length=255)),
+ ('slug', models.SlugField(max_length=255, unique=True)),
+ ('summary', models.CharField(blank=True, help_text='could be null', max_length=512, null=True)),
+ ('pin_top', models.BooleanField(default=True, verbose_name='pin top')),
+ ('thumbnail', models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='podcast/collection/')),
+ ('order', models.IntegerField(default=0, verbose_name='order')),
+ ('status', models.BooleanField(default=True, verbose_name='status')),
+ ('display_position', models.CharField(choices=[('pinned', 'Pinned'), ('middle', 'Middle Section')], default='pinned', max_length=20, verbose_name='Display Position')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ],
+ options={
+ 'verbose_name': 'Podcast Collection',
+ 'verbose_name_plural': 'Podcasts Collections',
+ },
+ ),
+ migrations.CreateModel(
+ name='PodcastPlaylist',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=255, verbose_name='title')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ],
+ options={
+ 'verbose_name': 'Podcast Playlist',
+ 'verbose_name_plural': 'Podcast Playlists',
+ },
+ ),
+ migrations.CreateModel(
+ name='Podcast',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=255, null=True)),
+ ('slug', models.SlugField(allow_unicode=True, unique=True)),
+ ('thumbnail', models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='book_thumbnails/')),
+ ('description', models.TextField(null=True)),
+ ('audio_file', models.FileField(blank=True, null=True, upload_to='podcast/audio/')),
+ ('audio_time', models.TimeField()),
+ ('view_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')),
+ ('download_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')),
+ ('status', models.BooleanField(default=True, verbose_name='status')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ('categories', models.ManyToManyField(related_name='podcasts', to='podcast.podcastcategory', verbose_name='categories')),
+ ],
+ options={
+ 'verbose_name': 'Podcast',
+ 'verbose_name_plural': 'Podcasts',
+ },
+ ),
+ migrations.CreateModel(
+ name='MiddlePodcastCollection',
+ fields=[
+ ],
+ options={
+ 'verbose_name': 'Middle Section Podcast Collection',
+ 'verbose_name_plural': 'Middle Section Podcast Collections',
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('podcast.podcastcollection',),
+ ),
+ migrations.CreateModel(
+ name='PinnedPodcastCollection',
+ fields=[
+ ],
+ options={
+ 'verbose_name': 'Pinned Podcast Collection',
+ 'verbose_name_plural': 'Pinned Podcast Collections',
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('podcast.podcastcollection',),
+ ),
+ migrations.CreateModel(
+ name='PodcastInCollection',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('order', models.PositiveIntegerField(default=0, verbose_name='order')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_podcasts', to='podcast.podcastcollection', verbose_name='collection')),
+ ('podcast', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_items', to='podcast.podcast', verbose_name='podcast')),
+ ],
+ options={
+ 'verbose_name': 'Podcast in Collection',
+ 'verbose_name_plural': 'Podcasts in Collections',
+ 'ordering': ['order'],
+ 'unique_together': {('collection', 'podcast')},
+ },
+ ),
+ migrations.AddField(
+ model_name='podcastcollection',
+ name='podcasts',
+ field=models.ManyToManyField(related_name='collections', through='podcast.PodcastInCollection', to='podcast.podcast', verbose_name='podcasts'),
+ ),
+ migrations.CreateModel(
+ name='PlaylistItem',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('priority', models.PositiveIntegerField(default=0, verbose_name='priority')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ('podcast', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlist_appearances', to='podcast.podcast', verbose_name='podcast')),
+ ('playlist', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlist_items', to='podcast.podcastplaylist', verbose_name='playlist')),
+ ],
+ options={
+ 'verbose_name': 'Playlist Item',
+ 'verbose_name_plural': 'Playlist Items',
+ 'ordering': ['priority'],
+ 'unique_together': {('playlist', 'podcast')},
+ },
+ ),
+ migrations.CreateModel(
+ name='UserPlaylist',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('status', models.BooleanField(default=True, verbose_name='status')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ('podcast', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_playlists', to='podcast.podcast', verbose_name='podcast')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_playlists', to=settings.AUTH_USER_MODEL, verbose_name='user')),
+ ],
+ options={
+ 'verbose_name': 'User Playlist',
+ 'verbose_name_plural': 'User Playlists',
+ 'unique_together': {('user', 'podcast')},
+ },
+ ),
+ ]
diff --git a/apps/podcast/migrations/0002_podcast_collections_alter_podcast_categories_and_more.py b/apps/podcast/migrations/0002_podcast_collections_alter_podcast_categories_and_more.py
new file mode 100755
index 0000000..cc6b6ac
--- /dev/null
+++ b/apps/podcast/migrations/0002_podcast_collections_alter_podcast_categories_and_more.py
@@ -0,0 +1,39 @@
+# Generated by Django 5.1.8 on 2025-05-06 12:31
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('podcast', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='podcast',
+ name='collections',
+ field=models.ManyToManyField(blank=True, related_name='related_podcasts', through='podcast.PodcastInCollection', to='podcast.podcastcollection', verbose_name='collections'),
+ ),
+ migrations.AlterField(
+ model_name='podcast',
+ name='categories',
+ field=models.ManyToManyField(blank=True, related_name='podcasts', to='podcast.podcastcategory', verbose_name='categories'),
+ ),
+ migrations.AlterField(
+ model_name='podcast',
+ name='download_count',
+ field=models.PositiveBigIntegerField(default=0, verbose_name='download_count view count'),
+ ),
+ migrations.AlterField(
+ model_name='podcastcollection',
+ name='podcasts',
+ field=models.ManyToManyField(related_name='related_collections_podcast', through='podcast.PodcastInCollection', to='podcast.podcast', verbose_name='podcasts'),
+ ),
+ migrations.AlterField(
+ model_name='podcastincollection',
+ name='podcast',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_collections', to='podcast.podcast', verbose_name='podcast'),
+ ),
+ ]
diff --git a/apps/podcast/migrations/__init__.py b/apps/podcast/migrations/__init__.py
old mode 100644
new mode 100755
diff --git a/apps/podcast/models.py b/apps/podcast/models.py
old mode 100644
new mode 100755
index f90c459..be606b0
--- a/apps/podcast/models.py
+++ b/apps/podcast/models.py
@@ -1,5 +1,7 @@
from django.db import models
+from django.utils.translation import gettext_lazy as _
+from utils import generate_slug_for_model
class PodcastCategory(models.Model):
@@ -14,64 +16,94 @@ 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 = _('Video Category')
- verbose_name_plural = _('Video Categories')
+ verbose_name = _('Podcast Category')
+ verbose_name_plural = _('Podcast Categories')
ordering = ['order']
class PodcastCollection(models.Model):
+ class DisplayPosition(models.TextChoices):
+ PINNED = 'pinned', _('Pinned')
+ MIDDLE = 'middle', _('Middle Section')
+
title = models.CharField(max_length=255, help_text="This title will not be displayed anywhere")
-
+ slug = models.SlugField(max_length=255, unique=True)
+ summary = models.CharField(max_length=512, null=True, blank=True, help_text=_('could be null'))
+ pin_top = models.BooleanField(_('pin top'), default=True)
+ thumbnail = models.ImageField(upload_to='podcast/collection/', null=True, blank=True, help_text=_('image allowed'))
+ order = models.IntegerField(default=0, verbose_name=_('order'))
+ status = models.BooleanField(default=True, verbose_name=_('status'))
+ display_position = models.CharField(
+ max_length=20,
+ choices=DisplayPosition.choices,
+ default=DisplayPosition.PINNED,
+ verbose_name=_('Display Position')
+ )
+
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
- videos = models.ManyToManyField(
- Video,
+ podcasts = models.ManyToManyField(
+ 'Podcast',
through='PodcastInCollection',
- related_name='collections',
+ related_name='related_collections_podcast',
verbose_name=_('podcasts'),
)
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')
-class PodcastInCollection(models.Model):
- video_collection = models.ForeignKey(
- VideoCollection, on_delete=models.CASCADE, related_name='podcasts_in_collection', verbose_name=_('podcast collection')
- )
- podcast = models.ForeignKey(
- Podcast, on_delete=models.CASCADE, related_name='collections_podcasts', verbose_name=_('podcasts')
- )
- priority = models.PositiveIntegerField(default=0, verbose_name=_('priority'))
+class PinnedPodcastCollection(PodcastCollection):
+ class Meta:
+ proxy = True
+ verbose_name = _('Pinned Podcast Collection')
+ verbose_name_plural = _('Pinned Podcast Collections')
- def __str__(self):
- return f"{self.podcast_collection.title} - {self.podcast.title} (Priority: {self.priority})"
+class MiddlePodcastCollection(PodcastCollection):
class Meta:
- verbose_name = _('Podcast in Collection')
- verbose_name_plural = _('Podcasts in Collection')
- ordering = ['priority']
+ proxy = True
+ verbose_name = _('Middle Section Podcast Collection')
+ verbose_name_plural = _('Middle Section Podcast Collections')
class Podcast(models.Model):
-
+
title = models.CharField(max_length=255, null=True)
slug = models.SlugField(allow_unicode=True, unique=True)
thumbnail = models.ImageField(upload_to='book_thumbnails/', null=True, blank=True, help_text=_('image allowed'))
description = models.TextField(null=True)
- categories = models.ManyToManyField(PodcastCategory, related_name='podcasts', verbose_name=_('categories'))
+
+ categories = models.ManyToManyField(PodcastCategory, related_name='podcasts', verbose_name=_('categories'), blank=True)
+ collections = models.ManyToManyField(
+ PodcastCollection,
+ through='PodcastInCollection',
+ related_name='related_podcasts',
+ verbose_name=_('collections'),
+ blank=True
+ )
+
audio_file = models.FileField(upload_to='podcast/audio/', null=True, blank=True)
- audio_url = models.CharField(max_length=655, null=True, blank=True)
audio_time = models.TimeField()
view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count'))
- download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count'))
+ download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('download_count view count'))
status = models.BooleanField(default=True, verbose_name=_('status'))
@@ -80,8 +112,141 @@ class Podcast(models.Model):
def __str__(self):
return self.title
+
+ def increment_view_count(self):
+ self.view_count += 1
+ self.save(update_fields=['view_count'])
+ return self.view_count
+
+ def save(self, *args, **kwargs):
+ if not self.slug:
+ self.slug = generate_slug_for_model(Podcast, self.title)
+ super().save(*args, **kwargs)
+
+
class Meta:
verbose_name = _('Podcast')
verbose_name_plural = _('Podcasts')
+
+class PodcastPlaylist(models.Model):
+ title = models.CharField(max_length=255, verbose_name=_('title'))
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ def __str__(self):
+ return self.title
+
+
+ class Meta:
+ verbose_name = _('Podcast Playlist')
+ verbose_name_plural = _('Podcast Playlists')
+
+
+class PodcastInCollection(models.Model):
+ collection = models.ForeignKey(
+ PodcastCollection,
+ on_delete=models.CASCADE,
+ related_name='collection_podcasts',
+ verbose_name=_('collection')
+ )
+ podcast = models.ForeignKey(
+ Podcast,
+ on_delete=models.CASCADE,
+ related_name='podcast_collections',
+ verbose_name=_('podcast')
+ )
+ order = models.PositiveIntegerField(default=0, verbose_name=_('order'))
+
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ def __str__(self):
+ return f"{self.collection.title} - {self.podcast.title}"
+
+ class Meta:
+ verbose_name = _('Podcast in Collection')
+ verbose_name_plural = _('Podcasts in Collections')
+ ordering = ['order']
+ unique_together = ['collection', 'podcast']
+
+
+class PlaylistItem(models.Model):
+ playlist = models.ForeignKey(
+ PodcastPlaylist,
+ on_delete=models.CASCADE,
+ related_name='playlist_items',
+ verbose_name=_('playlist')
+ )
+ podcast = models.ForeignKey(
+ Podcast,
+ on_delete=models.CASCADE,
+ related_name='playlist_appearances',
+ verbose_name=_('podcast')
+ )
+ priority = models.PositiveIntegerField(default=0, verbose_name=_('priority'))
+
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ def __str__(self):
+ return f"{self.playlist.title} - {self.podcast.title} (Priority: {self.priority})"
+
+ class Meta:
+ verbose_name = _('Playlist Item')
+ verbose_name_plural = _('Playlist Items')
+ ordering = ['priority']
+ unique_together = ['playlist', 'podcast']
+
+
+from django.contrib.auth import get_user_model
+
+User = get_user_model()
+
+class UserPlaylist(models.Model):
+ """
+ Model to track which podcasts a user has added to their personal playlist
+ """
+ user = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ related_name='podcast_playlists',
+ verbose_name=_('user')
+ )
+ podcast = models.ForeignKey(
+ Podcast,
+ on_delete=models.CASCADE,
+ related_name='user_playlists',
+ verbose_name=_('podcast')
+ )
+ status = models.BooleanField(default=True, verbose_name=_('status'))
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ class Meta:
+ verbose_name = _('User Playlist')
+ verbose_name_plural = _('User Playlists')
+ unique_together = ['user', 'podcast']
+
+ def __str__(self):
+ return f"{self.user.username} - {self.podcast.title}"
+
+ @classmethod
+ def is_in_user_playlist(cls, user, podcast):
+ """
+ Check if a podcast is in a user's playlist and active
+
+ Args:
+ user: User instance
+ podcast: Podcast instance
+
+ Returns:
+ Boolean indicating if the podcast is in the user's playlist and active
+ """
+ return cls.objects.filter(
+ user=user,
+ podcast=podcast,
+ status=True
+ ).exists()
+
diff --git a/apps/podcast/serializers.py b/apps/podcast/serializers.py
new file mode 100755
index 0000000..2eb4a6b
--- /dev/null
+++ b/apps/podcast/serializers.py
@@ -0,0 +1,205 @@
+from rest_framework import serializers
+from utils import get_thumbs
+from apps.podcast.models import *
+from apps.bookmark.serializers import *
+
+
+class PodcastCategoryListSerializer(serializers.ModelSerializer):
+ podcast_count = serializers.SerializerMethodField()
+
+ class Meta:
+ model = PodcastCategory
+ fields = ['id', 'title', 'slug', 'podcast_count']
+
+ def get_podcast_count(self, obj):
+ return obj.podcasts.filter(status=True).count()
+
+
+class PodcastListSerializer(serializers.ModelSerializer):
+ thumbnail = serializers.SerializerMethodField()
+ in_user_playlist = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Podcast
+ fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'audio_time', 'view_count', 'created_at', 'in_user_playlist']
+
+ def get_thumbnail(self, obj):
+ return get_thumbs(obj.thumbnail, self.context.get('request'))
+
+ def get_in_user_playlist(self, obj):
+ """
+ Check if the podcast is in the user's personal playlist.
+ Returns True if the podcast is in the user's playlist and active, False otherwise.
+ """
+ request = self.context.get('request')
+ user = request.user if request and request.user.is_authenticated else None
+
+ if not user:
+ return False
+
+ return UserPlaylist.is_in_user_playlist(user, obj)
+
+
+class PodcastDetailSerializer(serializers.ModelSerializer):
+ categories = PodcastCategoryListSerializer(many=True, read_only=True)
+ thumbnail = serializers.SerializerMethodField()
+ bookmark = serializers.SerializerMethodField()
+ user_rate = serializers.SerializerMethodField()
+ average_rate = serializers.SerializerMethodField()
+ is_in_playlist = serializers.SerializerMethodField()
+ playlist_podcasts = serializers.SerializerMethodField()
+ in_user_playlist = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Podcast
+ fields = ['id', 'title', 'slug', 'thumbnail', 'description',
+ 'audio_file', 'audio_time', 'view_count', 'download_count',
+ 'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark',
+ 'is_in_playlist', 'playlist_podcasts', 'in_user_playlist']
+
+ def get_thumbnail(self, obj):
+ return get_thumbs(obj.thumbnail, self.context.get('request'))
+
+ def get_bookmark(self, obj):
+ """
+ Get bookmark information for this podcast.
+ """
+ # Get the current user from the request context
+ request = self.context.get('request')
+ user = request.user if request else None
+ book_mark = BookmarkStatusSerializer.get_bookmark_info(
+ obj=obj,
+ user=user,
+ service='podcast'
+ )
+ return book_mark.get('is_bookmarked', False)
+
+ def get_user_rate(self, obj):
+ """
+ Get rate information for this podcast from the current user.
+ """
+ from apps.bookmark.models.rate import Rate
+
+ # Get the current user from the request context
+ request = self.context.get('request')
+ user = request.user if request and request.user.is_authenticated else None
+
+ if not user:
+ return {
+ 'is_rated': False,
+ 'rate': None
+ }
+
+ # Get rate information using the Rate model's method
+ rate_info = Rate.get_user_rate(
+ user=user,
+ service='podcast',
+ content_id=obj.id
+ )
+
+ return rate_info
+
+ def get_average_rate(self, obj):
+ """
+ Get the average rate for this podcast.
+ """
+ from apps.bookmark.models.rate import Rate
+
+ # Get average rate information using the Rate model
+ return Rate.get_average_rate(
+ service='podcast',
+ content_id=obj.id
+ )
+
+ def get_is_in_playlist(self, obj):
+ """
+ Check if the podcast is in any playlist.
+ Returns True if the podcast is in at least one playlist, False otherwise.
+ """
+ return PlaylistItem.objects.filter(podcast=obj).exists()
+
+ def get_playlist_podcasts(self, obj):
+ """
+ If the podcast is in a playlist, return all podcasts from the first playlist it belongs to,
+ excluding the current podcast itself. Podcasts are ordered by their priority in the playlist.
+ Returns null if the podcast is not in any playlist.
+ """
+ # Check if the podcast is in any playlist
+ if not self.get_is_in_playlist(obj):
+ return None
+
+ # Get the first playlist that contains this podcast
+ playlist_item = PlaylistItem.objects.filter(podcast=obj).first()
+ if not playlist_item:
+ return None
+
+ playlist = playlist_item.playlist
+
+ # Get all podcasts in this playlist except the current one, ordered by priority
+ playlist_podcasts = Podcast.objects.filter(
+ playlist_appearances__playlist=playlist
+ ).exclude(
+ id=obj.id
+ ).distinct().order_by('playlist_appearances__priority')
+
+ # Serialize the podcasts
+ return PodcastListSerializer(
+ playlist_podcasts,
+ many=True,
+ context=self.context
+ ).data
+
+ def get_in_user_playlist(self, obj):
+ """
+ Check if the podcast is in the user's personal playlist.
+ Returns True if the podcast is in the user's playlist and active, False otherwise.
+ """
+ request = self.context.get('request')
+ user = request.user if request and request.user.is_authenticated else None
+
+ if not user:
+ return False
+
+ return UserPlaylist.is_in_user_playlist(user, obj)
+
+
+class PinnedPodcastCollectionSerializer(serializers.ModelSerializer):
+ thumbnail = serializers.SerializerMethodField()
+
+ class Meta:
+ model = PodcastCollection
+ fields = ['id', 'title', 'slug', 'summary', 'thumbnail', 'order', 'created_at']
+
+ def get_thumbnail(self, obj):
+ return get_thumbs(obj.thumbnail, self.context.get('request'))
+
+
+class MiddlePodcastCollectionSerializer(serializers.ModelSerializer):
+ podcasts = serializers.SerializerMethodField()
+
+ class Meta:
+ model = PodcastCollection
+ fields = ('id', 'title', 'slug', 'summary', 'status', 'order', 'pin_top', 'podcasts')
+
+ def get_podcasts(self, obj):
+ podcasts = obj.podcasts.filter(status=True).order_by('-created_at')
+ return PodcastListSerializer(podcasts, many=True, context=self.context).data
+
+
+class UserPlaylistSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = UserPlaylist
+ fields = ('id', 'podcast', 'status', 'created_at', 'updated_at')
+ read_only_fields = ('id', 'created_at', 'updated_at')
+
+
+class UserPlaylistCreateSerializer(serializers.Serializer):
+ podcast_id = serializers.IntegerField()
+ status = serializers.BooleanField(default=True)
+
+ def validate_podcast_id(self, value):
+ try:
+ podcast = Podcast.objects.get(id=value, status=True)
+ return value
+ except Podcast.DoesNotExist:
+ raise serializers.ValidationError("Podcast with this ID does not exist or is not active.")
\ No newline at end of file
diff --git a/apps/podcast/tests.py b/apps/podcast/tests.py
old mode 100644
new mode 100755
diff --git a/apps/podcast/urls.py b/apps/podcast/urls.py
new file mode 100755
index 0000000..adc7a93
--- /dev/null
+++ b/apps/podcast/urls.py
@@ -0,0 +1,17 @@
+from django.urls import path
+from .views import *
+
+app_name = 'podcast'
+
+urlpatterns = [
+ path('categories/', PodcastCategoryListAPIView.as_view(), name='category-list'),
+ path('pinned-collections/', PinnedPodcastCollectionListView.as_view(), name='pinned-collection-list'),
+ path('collections/', MiddlePodcastCollectionListView.as_view(), name='collection-list'),
+
+ path('list/', PodcastListAPIView.as_view(), name='podcast-list'),
+ path('detail//', PodcastDetailAPIView.as_view(), name='podcast-detail'),
+
+ # User playlist endpoints
+ path('user-playlist/', UserPlaylistCreateAPIView.as_view(), name='user-playlist-create'),
+ path('user-playlist/list/', UserPlaylistListAPIView.as_view(), name='user-playlist-list'),
+]
\ No newline at end of file
diff --git a/apps/podcast/views.py b/apps/podcast/views.py
old mode 100644
new mode 100755
index 91ea44a..85851ff
--- a/apps/podcast/views.py
+++ b/apps/podcast/views.py
@@ -1,3 +1,286 @@
-from django.shortcuts import render
+from rest_framework import generics, status
+from rest_framework.response import Response
+from drf_yasg import openapi
+from drf_yasg.utils import swagger_auto_schema
+from apps.library.pagination import NoPagination
+from rest_framework.permissions import IsAuthenticated
-# Create your views here.
+
+from apps.podcast.models import *
+from apps.podcast.serializers import *
+
+
+class PodcastCategoryListAPIView(generics.ListAPIView):
+ """
+ API view to list all podcast categories
+ """
+ serializer_class = PodcastCategoryListSerializer
+
+ @swagger_auto_schema(
+ operation_description="Get a list of all active podcast categories",
+ responses={
+ 200: openapi.Response(
+ description="List of podcast categories",
+ schema=PodcastCategoryListSerializer(many=True)
+ )
+ }
+ )
+ def get(self, request, *args, **kwargs):
+ return super().get(request, *args, **kwargs)
+
+ def get_queryset(self):
+ return PodcastCategory.objects.filter(status=True).order_by('order')
+
+
+
+class PinnedPodcastCollectionListView(generics.ListAPIView):
+ serializer_class = PinnedPodcastCollectionSerializer
+ permission_classes = (IsAuthenticated,)
+ pagination_class = NoPagination
+
+
+ def get_queryset(self):
+ return PinnedPodcastCollection.objects.filter(
+ status=True,
+ display_position=PodcastCollection.DisplayPosition.PINNED
+ ).order_by('-order', '-id')
+
+ def list(self, request, *args, **kwargs):
+ response = super().list(request, *args, **kwargs)
+ categories_count = PodcastCategory.objects.filter(status=True).count()
+ # Count podcasts in the user's playlist
+ user_playlist_count = 0
+ if request.user.is_authenticated:
+ user_playlist_count = UserPlaylist.objects.filter(
+ user=request.user,
+ status=True
+ ).count()
+
+ info = {
+ "categories_count": categories_count,
+ "user_playlist_count": user_playlist_count,
+ }
+ data = {
+ "count": response.data.get("count"),
+ "next": response.data.get("next"),
+ "previous": response.data.get("previous"),
+ "info": info,
+ "results": response.data.get("results")
+ }
+ return Response(data, status=status.HTTP_200_OK)
+
+
+class MiddlePodcastCollectionListView(generics.ListAPIView):
+ serializer_class = MiddlePodcastCollectionSerializer
+ permission_classes = (IsAuthenticated,)
+ pagination_class = NoPagination
+
+ def get_queryset(self):
+ return PodcastCollection.objects.filter(
+ status=True,
+ display_position=PodcastCollection.DisplayPosition.MIDDLE
+ ).order_by('order')
+
+
+class PodcastListAPIView(generics.ListAPIView):
+ """
+ API view to list all podcasts, with optional filtering by category, collection, or user playlist
+ """
+ serializer_class = PodcastListSerializer
+ permission_classes = (IsAuthenticated,)
+
+ @swagger_auto_schema(
+ operation_description="Get a list of podcasts with optional filtering",
+ manual_parameters=[
+ openapi.Parameter(
+ name='category',
+ in_=openapi.IN_QUERY,
+ description='Filter podcasts by category slug',
+ type=openapi.TYPE_STRING,
+ required=False
+ ),
+ openapi.Parameter(
+ name='collection',
+ in_=openapi.IN_QUERY,
+ description='Filter podcasts by collection slug',
+ type=openapi.TYPE_STRING,
+ required=False
+ ),
+ openapi.Parameter(
+ name='in_playlist',
+ in_=openapi.IN_QUERY,
+ description='Filter podcasts that are in the user\'s playlist (true/false)',
+ type=openapi.TYPE_BOOLEAN,
+ required=False
+ ),
+ openapi.Parameter(
+ name='is_bookmark',
+ in_=openapi.IN_QUERY,
+ description='Filter podcasts that are bookmarked by the user (true/false)',
+ type=openapi.TYPE_BOOLEAN,
+ required=False
+ ),
+ openapi.Parameter(
+ name='search',
+ in_=openapi.IN_QUERY,
+ description='Search podcasts by title',
+ type=openapi.TYPE_STRING,
+ required=False
+ )
+ ],
+ responses={
+ 200: openapi.Response(
+ description="List of podcasts",
+ schema=PodcastListSerializer(many=True)
+ )
+ }
+ )
+ def get(self, request, *args, **kwargs):
+ return super().get(request, *args, **kwargs)
+
+ def get_queryset(self):
+ queryset = Podcast.objects.filter(status=True).order_by('-created_at')
+
+ # Search by title if search parameter is provided
+ search_query = self.request.query_params.get('search', None)
+ if search_query:
+ queryset = queryset.filter(title__icontains=search_query)
+
+ # Filter by category if provided
+ category_slug = self.request.query_params.get('category', None)
+ if 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:
+ # Get all podcasts that are in the collection with the given slug
+ queryset = queryset.filter(
+ collections__slug=collection_slug
+ )
+
+ # Filter by user playlist if provided
+ in_playlist = self.request.query_params.get('in_playlist', None)
+ if in_playlist and in_playlist.lower() == 'true':
+ # Get podcasts that are in the user's playlist and active
+ user_playlist_podcasts = UserPlaylist.objects.filter(
+ user=self.request.user,
+ status=True
+ ).values_list('podcast_id', flat=True)
+
+ queryset = queryset.filter(id__in=user_playlist_podcasts)
+
+ # Filter by bookmarks if provided
+ 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 podcast IDs for the current user
+ bookmarked_ids = Bookmark.objects.filter(
+ user=self.request.user,
+ service=Bookmark.ServiceChoices.PODCAST,
+ status=True
+ ).values_list('content_id', flat=True)
+
+ # Filter podcasts by these IDs
+ queryset = queryset.filter(id__in=bookmarked_ids)
+
+ return queryset
+
+
+class PodcastDetailAPIView(generics.RetrieveAPIView):
+ serializer_class = PodcastDetailSerializer
+ lookup_field = 'slug'
+
+ def get_queryset(self):
+ return Podcast.objects.filter(status=True)
+
+ def retrieve(self, request, *args, **kwargs):
+ instance = self.get_object()
+ instance.increment_view_count()
+ serializer = self.get_serializer(instance)
+ return Response(serializer.data)
+
+
+class UserPlaylistListAPIView(generics.ListAPIView):
+ """
+ API view to list all podcasts in the user's personal playlist
+ """
+ serializer_class = PodcastListSerializer
+ permission_classes = (IsAuthenticated,)
+
+ @swagger_auto_schema(
+ operation_description="Get a list of podcasts in the user's personal playlist",
+ responses={
+ 200: openapi.Response(
+ description="List of podcasts in the user's playlist",
+ schema=PodcastListSerializer(many=True)
+ )
+ }
+ )
+ def get(self, request, *args, **kwargs):
+ return super().get(request, *args, **kwargs)
+
+ def get_queryset(self):
+ # Get all active podcasts that are in the user's playlist
+ user_playlist_podcasts = UserPlaylist.objects.filter(
+ user=self.request.user,
+ status=True
+ ).values_list('podcast_id', flat=True)
+
+ return Podcast.objects.filter(
+ id__in=user_playlist_podcasts,
+ status=True
+ ).order_by('-created_at')
+
+
+class UserPlaylistCreateAPIView(generics.CreateAPIView):
+ """
+ API view to add or update a podcast in a user's personal playlist
+ """
+ serializer_class = UserPlaylistCreateSerializer
+ permission_classes = (IsAuthenticated,)
+
+ @swagger_auto_schema(
+ operation_description="Add or update a podcast in the user's personal playlist",
+ request_body=UserPlaylistCreateSerializer,
+ responses={
+ 201: openapi.Response(
+ description="Podcast added to playlist successfully",
+ schema=UserPlaylistSerializer()
+ ),
+ 400: "Bad Request"
+ }
+ )
+ def post(self, request, *args, **kwargs):
+ return super().post(request, *args, **kwargs)
+
+ def create(self, request, *args, **kwargs):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ podcast_id = serializer.validated_data['podcast_id']
+ playlist_status = serializer.validated_data.get('status', True)
+
+ try:
+ podcast = Podcast.objects.get(id=podcast_id, status=True)
+ except Podcast.DoesNotExist:
+ return Response(
+ {"detail": "Podcast not found or not active."},
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ # Try to get existing user playlist entry or create a new one
+ user_playlist, created = UserPlaylist.objects.update_or_create(
+ user=request.user,
+ podcast=podcast,
+ defaults={'status': playlist_status}
+ )
+
+ # Return the user playlist entry
+ response_serializer = UserPlaylistSerializer(user_playlist)
+ return Response(
+ response_serializer.data,
+ status=status.HTTP_201_CREATED if created else status.HTTP_200_OK
+ )
diff --git a/apps/video/__init__.py b/apps/video/__init__.py
old mode 100644
new mode 100755
diff --git a/apps/video/admin.py b/apps/video/admin.py
old mode 100644
new mode 100755
index 5c23d54..c722883
--- a/apps/video/admin.py
+++ b/apps/video/admin.py
@@ -2,51 +2,122 @@ from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from django.utils.html import format_html
+from django.db import models
from ajaxdatatable.admin import AjaxDatatable
+from unfold.admin import ModelAdmin, StackedInline, TabularInline
+from django.contrib.admin import SimpleListFilter
+from unfold.widgets import UnfoldAdminSelectWidget
+
+from unfold.decorators import display, action
+from django import forms
+
+from utils.admin import project_admin_site
+from unfold.sections import TableSection
from apps.video.models import *
-class VideoInCollectionInline(admin.TabularInline):
+class VideoInCollectionInline(TabularInline):
model = VideoInCollection
extra = 1
autocomplete_fields = ('video',)
- ordering = ('priority',)
+ fields = ('video', 'order')
+ ordering = ('order',)
+ verbose_name = _('Video')
+ verbose_name_plural = _('Videos')
+ tab = True
-
-class VideoCollectionAdminBase(AjaxDatatable):
- """Base admin class for all video collection types"""
- list_display = ('title', 'status', 'order', 'count_videos', 'created_at')
- list_filter = ('status', 'created_at', 'updated_at')
+class VideoCollectionAdminBase(ModelAdmin):
+ list_display = ('get_title', 'status', 'order', 'count_videos')
+ list_filter = ('status', 'order')
search_fields = ('title',)
+ ordering = ('order',)
+ list_filter_submit = True
+ warn_unsaved_form = True
+ change_form_show_cancel_button = True
inlines = [VideoInCollectionInline]
+
fieldsets = (
(None, {
- 'fields': ('title', 'status', 'order')
+ 'fields': ('title', 'summary', 'thumbnail' , 'status', 'pin_top', 'order')
}),
)
+ exclude = ('display_position',)
- @admin.display(description=_('Number of Videos'))
+ @display(description=_('Title'))
+ def get_title(self, obj):
+ return str(obj.title)
+
+ @display(description=_('Number of Videos'))
def count_videos(self, obj):
- count = obj.videos.count()
+ count = obj.related_videos.count()
if count > 0:
url = reverse('admin:video_video_changelist') + f'?collections__id__exact={obj.id}'
return format_html('{}', url, count)
return count
-# @admin.register(VideoCollection)
-# class VideoCollectionAdmin(VideoCollectionAdminBase):
-# """Admin for all video collections"""
-# list_display = ('title', 'status', 'count_videos', 'created_at')
-# list_filter = ('status', 'created_at', 'updated_at')
+class PinnedVideoCollectionForm(forms.ModelForm):
+ class Meta:
+ model = PinnedVideoCollection
+ # fields = '__all__'
+ exclude = ('slug',)
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields['thumbnail'].required = True
+
+class PinnedVideoCollectionAdmin(VideoCollectionAdminBase):
+ form = PinnedVideoCollectionForm
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).filter(display_position=VideoCollection.DisplayPosition.PINNED)
+
+ def save_model(self, request, obj, form, change):
+ obj.display_position = VideoCollection.DisplayPosition.PINNED
+ super().save_model(request, obj, form, change)
+
+
+ @display(description=_('Title'))
+ def get_title(self, obj):
+ from django.templatetags.static import static
+ thumbnail_path = obj.thumbnail.url if obj.thumbnail else None
+ return obj.title
+ # return [
+ # obj.title,
+ # None,
+ # None,
+ # {
+ # "path": thumbnail_path,
+ # "height": 30,
+ # "width": 50,
+ # "borderless": True,
+ # # "squared": True,
+ # },
+ # ]
+
+class MiddleVideoCollectionAdmin(VideoCollectionAdminBase):
+
+
+ fieldsets = (
+ (None, {
+ 'fields': ('title', 'status', 'pin_top', 'order')
+ }),
+ )
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).filter(display_position=VideoCollection.DisplayPosition.MIDDLE)
+
+ def save_model(self, request, obj, form, change):
+ obj.display_position = VideoCollection.DisplayPosition.MIDDLE
+ super().save_model(request, obj, form, change)
-@admin.register(VideoCategory)
-class VideoCategoryAdmin(AjaxDatatable):
+
+class VideoCategoryAdmin(ModelAdmin):
list_display = ('title', 'slug', 'status', 'order', 'count_videos', 'created_at')
list_filter = ('status', 'created_at', 'updated_at')
search_fields = ('title', 'slug')
@@ -59,14 +130,30 @@ class VideoCategoryAdmin(AjaxDatatable):
url = reverse('admin:video_video_changelist') + f'?category__id__exact={obj.id}'
return format_html('{}', url, count)
return count
+
+ def get_form(self, request, obj=None, change=False, **kwargs):
+ form = super().get_form(request, obj, change, **kwargs)
+ if form.base_fields.get('slug'):
+ form.base_fields['slug'].required = False
+ return form
+
-@admin.register(Video)
-class VideoAdmin(AjaxDatatable):
+class VideoAdmin(ModelAdmin):
list_display = ('title', 'slug', 'video_type', 'status', 'view_count', 'created_at')
list_filter = ('status', 'video_type', 'created_at', 'updated_at')
search_fields = ('title', 'slug', 'description')
autocomplete_fields = ('categories',)
+ conditional_fields = {
+ 'video_file': "video_type == 'video_file'",
+ 'video_url': "video_type == 'youtube_link'",
+ }
+ radio_fields = {
+ "video_type": admin.HORIZONTAL,
+ }
+ save_as = True
+ search_help_text = _("Search by title, slug, or description")
+ search_fields_placeholder = _("Search videos")
fieldsets = (
(None, {
@@ -82,4 +169,134 @@ class VideoAdmin(AjaxDatatable):
'fields': ('view_count',)
}),
)
+
+ def get_form(self, request, obj=None, change=False, **kwargs):
+ form = super().get_form(request, obj, change, **kwargs)
+ if form.base_fields.get('slug'):
+ form.base_fields['slug'].required = False
+ if form.base_fields.get('thumbnail'):
+ form.base_fields['thumbnail'].required = True
+
+ if form.base_fields.get('video_type') and not obj:
+ form.base_fields['video_type'].initial = 'youtube_link'
+ return form
+
+
+class PlaylistItemForm(forms.ModelForm):
+ class Meta:
+ model = PlaylistItem
+ fields = ('video', 'priority')
+
+ def clean_video(self):
+ video = self.cleaned_data.get('video')
+ if not video:
+ return video
+
+ # If we're editing, exclude the current instance from the check
+ instance = getattr(self, 'instance', None)
+ if instance and instance.pk and instance.video == video:
+ return video
+
+ # Check if this video exists in another playlist
+ existing_item = PlaylistItem.objects.filter(video=video).first()
+ if existing_item:
+ playlist_name = existing_item.playlist.title
+ raise forms.ValidationError(
+ _('This video is already used in playlist "{}". Each video can only be in one playlist.').format(playlist_name)
+ )
+ return video
+
+
+class PlaylistItemInline(StackedInline):
+ model = PlaylistItem
+ form = PlaylistItemForm
+ extra = 1
+ autocomplete_fields = ('video',)
+ fields = ('video', 'priority')
+ ordering = ('priority',)
+ verbose_name = _('Playlist Item')
+ verbose_name_plural = _('Playlist Items')
+
+
+class VideoPlaylistAdmin(ModelAdmin):
+ list_display = ('title', 'count_videos', 'created_at')
+ list_filter = ('created_at',)
+ search_fields = ('title', )
+ list_filter_submit = True
+ warn_unsaved_form = True
+ change_form_show_cancel_button = True
+ inlines = [PlaylistItemInline]
+
+ fieldsets = (
+ (None, {
+ 'fields': ('title',)
+ }),
+ )
+
+
+ @display(description=_('Number of Videos'))
+ def count_videos(self, obj):
+ count = obj.playlist_items.count()
+ if count > 0:
+ return format_html('{}', count)
+ return count
+
+ def save_formset(self, request, form, formset, change):
+ """
+ Additional validation to ensure each video is used in only one playlist
+ """
+ instances = formset.save(commit=False)
+
+ # Collect all videos that are being saved
+ videos_to_save = []
+ for instance in instances:
+ if instance.video:
+ videos_to_save.append(instance.video)
+
+ # Check for duplicate videos in this formset
+ video_counts = {}
+ for video in videos_to_save:
+ video_counts[video.id] = video_counts.get(video.id, 0) + 1
+
+ duplicate_videos = [video_id for video_id, count in video_counts.items() if count > 1]
+ if duplicate_videos:
+ # If there are duplicate videos in this form, show an error
+ formset._non_form_errors = formset.error_class(
+ [_('A video cannot be used multiple times in the same playlist.')]
+ )
+ return
+
+ # Check if videos are used in other playlists
+ for instance in instances:
+ if instance.video: # For both new and edited items
+ playlist_id = form.instance.pk
+ query = PlaylistItem.objects.filter(
+ video=instance.video
+ ).exclude(
+ playlist_id=playlist_id
+ )
+
+ # If we're editing an existing item, exclude it from the check
+ if instance.pk:
+ query = query.exclude(pk=instance.pk)
+
+ existing_item = query.first()
+
+ if existing_item:
+ playlist_name = existing_item.playlist.title
+ formset._non_form_errors = formset.error_class(
+ [_('Video "{}" is already used in playlist "{}". Each video can only be in one playlist.').format(
+ instance.video.title, playlist_name
+ )]
+ )
+ return
+
+ # If all validations pass, save the formset
+ super().save_formset(request, form, formset, change)
+
+project_admin_site.register(VideoCategory, VideoCategoryAdmin)
+project_admin_site.register(Video, VideoAdmin)
+project_admin_site.register(PinnedVideoCollection, PinnedVideoCollectionAdmin)
+project_admin_site.register(MiddleVideoCollection, MiddleVideoCollectionAdmin)
+project_admin_site.register(VideoPlaylist, VideoPlaylistAdmin)
diff --git a/apps/video/apps.py b/apps/video/apps.py
old mode 100644
new mode 100755
diff --git a/apps/video/migrations/0001_initial.py b/apps/video/migrations/0001_initial.py
old mode 100644
new mode 100755
diff --git a/apps/video/migrations/0002_alter_video_thumbnail.py b/apps/video/migrations/0002_alter_video_thumbnail.py
old mode 100644
new mode 100755
diff --git a/apps/video/migrations/0003_remove_videocollection_videos_middlevideocollection_and_more.py b/apps/video/migrations/0003_remove_videocollection_videos_middlevideocollection_and_more.py
new file mode 100755
index 0000000..7a92be7
--- /dev/null
+++ b/apps/video/migrations/0003_remove_videocollection_videos_middlevideocollection_and_more.py
@@ -0,0 +1,87 @@
+# Generated by Django 5.1.8 on 2025-05-05 09:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('video', '0002_alter_video_thumbnail'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='videocollection',
+ name='videos',
+ ),
+ migrations.CreateModel(
+ name='MiddleVideoCollection',
+ fields=[
+ ],
+ options={
+ 'verbose_name': 'Middle Section Video Collection',
+ 'verbose_name_plural': 'Middle Section Video Collections',
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('video.videocollection',),
+ ),
+ migrations.CreateModel(
+ name='PinnedVideoCollection',
+ fields=[
+ ],
+ options={
+ 'verbose_name': 'Pinned Video Collection',
+ 'verbose_name_plural': 'Pinned Video Collections',
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('video.videocollection',),
+ ),
+ migrations.AddField(
+ model_name='video',
+ name='collections',
+ field=models.ManyToManyField(blank=True, related_name='related_videos', to='video.videocollection', verbose_name='collections'),
+ ),
+ migrations.AddField(
+ model_name='videocollection',
+ name='display_position',
+ field=models.CharField(choices=[('pinned', 'Pinned'), ('middle', 'Middle Section')], default='pinned', max_length=20, verbose_name='Display Position'),
+ ),
+ migrations.AddField(
+ model_name='videocollection',
+ name='pin_top',
+ field=models.BooleanField(default=True, verbose_name='pin top'),
+ ),
+ migrations.AddField(
+ model_name='videocollection',
+ name='slug',
+ field=models.SlugField(default='v_collection_1', max_length=255, unique=True),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='videocollection',
+ name='summary',
+ field=models.CharField(blank=True, help_text='could be null', max_length=512, null=True),
+ ),
+ migrations.AddField(
+ model_name='videocollection',
+ name='thumbnail',
+ field=models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='video/collection/'),
+ ),
+ migrations.AlterField(
+ model_name='video',
+ name='thumbnail',
+ field=models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='video/thumbnails/'),
+ ),
+ migrations.AlterField(
+ model_name='videocollection',
+ name='title',
+ field=models.CharField(max_length=255),
+ ),
+ migrations.DeleteModel(
+ name='VideoInCollection',
+ ),
+ ]
diff --git a/apps/video/migrations/0004_videocollection_order.py b/apps/video/migrations/0004_videocollection_order.py
new file mode 100755
index 0000000..0d1a217
--- /dev/null
+++ b/apps/video/migrations/0004_videocollection_order.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.8 on 2025-05-05 09:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('video', '0003_remove_videocollection_videos_middlevideocollection_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='videocollection',
+ name='order',
+ field=models.IntegerField(default=0, verbose_name='order'),
+ ),
+ ]
diff --git a/apps/video/migrations/0005_videoplaylist_alter_video_options_playlistitem.py b/apps/video/migrations/0005_videoplaylist_alter_video_options_playlistitem.py
new file mode 100755
index 0000000..46c59e7
--- /dev/null
+++ b/apps/video/migrations/0005_videoplaylist_alter_video_options_playlistitem.py
@@ -0,0 +1,48 @@
+# Generated by Django 5.1.8 on 2025-05-05 13:50
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('video', '0004_videocollection_order'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VideoPlaylist',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=255, verbose_name='title')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ],
+ options={
+ 'verbose_name': 'Video Playlist',
+ 'verbose_name_plural': 'Video Playlists',
+ },
+ ),
+ migrations.AlterModelOptions(
+ name='video',
+ options={},
+ ),
+ migrations.CreateModel(
+ name='PlaylistItem',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('priority', models.PositiveIntegerField(default=0, verbose_name='priority')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ('video', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlist_appearances', to='video.video', verbose_name='video')),
+ ('playlist', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlist_items', to='video.videoplaylist', verbose_name='playlist')),
+ ],
+ options={
+ 'verbose_name': 'Playlist Item',
+ 'verbose_name_plural': 'Playlist Items',
+ 'ordering': ['priority'],
+ 'unique_together': {('playlist', 'video')},
+ },
+ ),
+ ]
diff --git a/apps/video/migrations/0006_alter_video_video_type.py b/apps/video/migrations/0006_alter_video_video_type.py
new file mode 100755
index 0000000..b5bd4ca
--- /dev/null
+++ b/apps/video/migrations/0006_alter_video_video_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.8 on 2025-05-06 00:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('video', '0005_videoplaylist_alter_video_options_playlistitem'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='video',
+ name='video_type',
+ field=models.CharField(choices=[('youtube_link', 'Youtube Link'), ('video_file', 'Video File')], max_length=255),
+ ),
+ ]
diff --git a/apps/video/migrations/0007_videoincollection_alter_video_collections.py b/apps/video/migrations/0007_videoincollection_alter_video_collections.py
new file mode 100755
index 0000000..2d62d6f
--- /dev/null
+++ b/apps/video/migrations/0007_videoincollection_alter_video_collections.py
@@ -0,0 +1,43 @@
+# Generated by Django 5.1.8 on 2025-05-06 11:46
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('video', '0006_alter_video_video_type'),
+ ]
+
+ operations = [
+ # First remove the existing collections field
+ migrations.RemoveField(
+ model_name='video',
+ name='collections',
+ ),
+ # Then create the VideoInCollection model
+ migrations.CreateModel(
+ name='VideoInCollection',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('order', models.PositiveIntegerField(default=0, verbose_name='order')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
+ ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_videos', to='video.videocollection', verbose_name='collection')),
+ ('video', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='video_collections', to='video.video', verbose_name='video')),
+ ],
+ options={
+ 'verbose_name': 'Video in Collection',
+ 'verbose_name_plural': 'Videos in Collections',
+ 'ordering': ['order'],
+ 'unique_together': {('collection', 'video')},
+ },
+ ),
+ # Finally add the collections field back with the through model
+ migrations.AddField(
+ model_name='video',
+ name='collections',
+ field=models.ManyToManyField(blank=True, related_name='related_videos', through='video.VideoInCollection', to='video.videocollection', verbose_name='collections'),
+ ),
+ ]
diff --git a/apps/video/migrations/0008_videocollection_videos.py b/apps/video/migrations/0008_videocollection_videos.py
new file mode 100755
index 0000000..57754a1
--- /dev/null
+++ b/apps/video/migrations/0008_videocollection_videos.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.8 on 2025-05-06 12:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('video', '0007_videoincollection_alter_video_collections'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='videocollection',
+ name='videos',
+ field=models.ManyToManyField(related_name='related_collections_video', through='video.VideoInCollection', to='video.video', verbose_name='Videos'),
+ ),
+ ]
diff --git a/apps/video/migrations/__init__.py b/apps/video/migrations/__init__.py
old mode 100644
new mode 100755
diff --git a/apps/video/models.py b/apps/video/models.py
old mode 100644
new mode 100755
index 4964bb4..4fce0c2
--- a/apps/video/models.py
+++ b/apps/video/models.py
@@ -1,6 +1,7 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from filer.fields.image import FilerImageField
+from utils import generate_slug_for_model
class VideoCategory(models.Model):
@@ -15,6 +16,11 @@ 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')
@@ -22,50 +28,70 @@ class VideoCategory(models.Model):
class VideoCollection(models.Model):
- title = models.CharField(max_length=255, help_text="This title will not be displayed anywhere")
+ class DisplayPosition(models.TextChoices):
+ PINNED = 'pinned', _('Pinned')
+ MIDDLE = 'middle', _('Middle Section')
+
+ title = models.CharField(max_length=255)
+ slug = models.SlugField(max_length=255, unique=True)
+ summary = models.CharField(max_length=512, null=True, blank=True, help_text=_('could be null'))
+ pin_top = models.BooleanField(_('pin top'), default=True)
+ thumbnail = models.ImageField(upload_to='video/collection/', null=True, blank=True, help_text=_('image allowed'))
+ order = models.IntegerField(default=0, verbose_name=_('order'))
+
status = models.BooleanField(default=True, verbose_name=_('status'))
+ display_position = models.CharField(
+ max_length=20,
+ choices=DisplayPosition.choices,
+ default=DisplayPosition.PINNED,
+ verbose_name=_('Display Position')
+ )
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
videos = models.ManyToManyField(
- "Video",
+ 'Video',
through='VideoInCollection',
- related_name='collections',
- verbose_name=_('videos'),
+ related_name='related_collections_video',
+ verbose_name=_('Videos'),
)
+
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')
+class PinnedVideoCollection(VideoCollection):
+ class Meta:
+ proxy = True
+ verbose_name = _('Pinned Video Collection')
+ verbose_name_plural = _('Pinned Video Collections')
-class VideoInCollection(models.Model):
- video_collection = models.ForeignKey(
- "VideoCollection", on_delete=models.CASCADE, related_name='videos_in_collection', verbose_name=_('video collection')
- )
- video = models.ForeignKey(
- "Video", on_delete=models.CASCADE, related_name='collections_videos', verbose_name=_('video')
- )
- priority = models.PositiveIntegerField(default=0, verbose_name=_('priority'))
-
- def __str__(self):
- return f"{self.video_collection.title} - {self.video.title} (Priority: {self.priority})"
+class MiddleVideoCollection(VideoCollection):
class Meta:
- verbose_name = _('Video in Collection')
- verbose_name_plural = _('Videos in Collection')
- ordering = ['priority']
+ proxy = True
+ verbose_name = _('Middle Section Video Collection')
+ verbose_name_plural = _('Middle Section Video Collections')
+
+
class Video(models.Model):
- class vdeo_type(models.TextChoices):
- FILE = 'file'
- YOUTUBE = 'youtube'
+ class VedioTypeChoices(models.TextChoices):
+ YOUTUBE_LINK = 'youtube_link', 'Youtube Link'
+ VIDEO_FILE = 'video_file', 'Video File'
title = models.CharField(max_length=255, null=True)
slug = models.SlugField(allow_unicode=True, unique=True)
- thumbnail = models.ImageField(upload_to='book_thumbnails/', null=True, blank=True, help_text=_('image allowed'))
+ thumbnail = models.ImageField(upload_to='video/thumbnails/', null=True, blank=True, help_text=_('image allowed'))
description = models.TextField(null=True)
categories = models.ManyToManyField(
VideoCategory,
@@ -73,7 +99,15 @@ class Video(models.Model):
verbose_name=_('categories'),
blank=True,
)
- video_type = models.CharField(max_length=255, choices=vdeo_type.choices, default=vdeo_type.FILE)
+ collections = models.ManyToManyField(
+ VideoCollection,
+ through='VideoInCollection',
+ related_name='related_videos',
+ verbose_name=_('collections'),
+ blank=True
+ )
+
+ video_type = models.CharField(max_length=255, choices=VedioTypeChoices.choices)
video_file = models.FileField(upload_to='video/videos/', null=True, blank=True)
video_url = models.CharField(max_length=655, null=True, blank=True)
video_time = models.TimeField()
@@ -94,8 +128,79 @@ class Video(models.Model):
self.save(update_fields=['view_count'])
return self.view_count
+ def save(self, *args, **kwargs):
+ if not self.slug:
+ self.slug = generate_slug_for_model(Video, self.title)
+ super().save(*args, **kwargs)
+
+
+
+class VideoPlaylist(models.Model):
+ title = models.CharField(max_length=255, verbose_name=_('title'))
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ def __str__(self):
+ return self.title
+
+
+ class Meta:
+ verbose_name = _('Video Playlist')
+ verbose_name_plural = _('Video Playlists')
+
+class VideoInCollection(models.Model):
+ collection = models.ForeignKey(
+ VideoCollection,
+ on_delete=models.CASCADE,
+ related_name='collection_videos',
+ verbose_name=_('collection')
+ )
+ video = models.ForeignKey(
+ Video,
+ on_delete=models.CASCADE,
+ related_name='video_collections',
+ verbose_name=_('video')
+ )
+ order = models.PositiveIntegerField(default=0, verbose_name=_('order'))
+
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ def __str__(self):
+ return f"{self.collection.title} - {self.video.title}"
+
+ class Meta:
+ verbose_name = _('Video in Collection')
+ verbose_name_plural = _('Videos in Collections')
+ ordering = ['order']
+ unique_together = ['collection', 'video']
+
+
+class PlaylistItem(models.Model):
+ playlist = models.ForeignKey(
+ VideoPlaylist,
+ on_delete=models.CASCADE,
+ related_name='playlist_items',
+ verbose_name=_('playlist')
+ )
+ video = models.ForeignKey(
+ Video,
+ on_delete=models.CASCADE,
+ related_name='playlist_appearances',
+ verbose_name=_('video')
+ )
+ priority = models.PositiveIntegerField(default=0, verbose_name=_('priority'))
+
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
+
+ def __str__(self):
+ return f"{self.playlist.title} - {self.video.title} (Priority: {self.priority})"
+
class Meta:
- verbose_name = _('Video')
- verbose_name_plural = _('Videos')
+ verbose_name = _('Playlist Item')
+ verbose_name_plural = _('Playlist Items')
+ ordering = ['priority']
+ unique_together = ['playlist', 'video']
diff --git a/apps/video/serializers.py b/apps/video/serializers.py
old mode 100644
new mode 100755
index 85d95a9..ec7369e
--- a/apps/video/serializers.py
+++ b/apps/video/serializers.py
@@ -1,5 +1,7 @@
from rest_framework import serializers
-from .models import VideoCategory, Video, VideoCollection, VideoInCollection
+from utils import get_thumbs
+from .models import VideoCategory, Video, VideoCollection, VideoPlaylist, PlaylistItem, PinnedVideoCollection
+from apps.bookmark.serializers import *
class VideoCategoryListSerializer(serializers.ModelSerializer):
@@ -14,57 +16,148 @@ class VideoCategoryListSerializer(serializers.ModelSerializer):
class VideoListSerializer(serializers.ModelSerializer):
- categories = VideoCategoryListSerializer(many=True, read_only=True)
+ thumbnail = serializers.SerializerMethodField()
class Meta:
model = Video
- fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_time',
- 'view_count', 'categories', 'created_at']
+ fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_time', 'view_count', 'created_at']
+
+ def get_thumbnail(self, obj):
+ return get_thumbs(obj.thumbnail, self.context.get('request'))
+
+
class VideoDetailSerializer(serializers.ModelSerializer):
- related_videos = serializers.SerializerMethodField()
categories = VideoCategoryListSerializer(many=True, read_only=True)
+ thumbnail = serializers.SerializerMethodField()
+ bookmark = serializers.SerializerMethodField()
+ user_rate = serializers.SerializerMethodField()
+ average_rate = serializers.SerializerMethodField()
+ is_in_playlist = serializers.SerializerMethodField()
+ playlist_videos = 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', 'related_videos']
-
-
- def get_related_videos(self, obj):
- # Get all collections that contain this video
- collections = obj.collections.all()
-
- if collections.exists():
- # Get all videos from all collections that contain this video
- related_videos = []
- video_ids = set() # To track unique videos
-
- for collection in collections:
- # Get all videos in this collection ordered by priority
- videos_in_collection = VideoInCollection.objects.filter(
- video_collection=collection
- ).exclude(video=obj).order_by('priority')
-
- # Add videos to our list if not already added
- for vic in videos_in_collection:
- if vic.video.id not in video_ids:
- related_videos.append(vic.video)
- video_ids.add(vic.video.id)
-
- # Return the related videos using VideoListSerializer
- return VideoListSerializer(related_videos, many=True).data
-
- # # If not in a collection, return videos from the same category
- # elif obj.category:
- # related = Video.objects.filter(
- # category=obj.category,
- # status=True
- # ).exclude(id=obj.id)[:5]
- # return VideoListSerializer(related, many=True).data
- return []
\ No newline at end of file
+ 'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark',
+ 'is_in_playlist', 'playlist_videos']
+
+ def get_thumbnail(self, obj):
+ return get_thumbs(obj.thumbnail, self.context.get('request'))
+
+ def get_bookmark(self, obj):
+ """
+ Get bookmark information for this book.
+ """
+ # Get the current user from the request context
+ request = self.context.get('request')
+ user = request.user if request else None
+ book_mark = BookmarkStatusSerializer.get_bookmark_info(
+ obj=obj,
+ user=user,
+ service='video'
+ )
+ return book_mark.get('is_bookmarked', False)
+
+ def get_user_rate(self, obj):
+ """
+ Get rate information for this book from the current user.
+ """
+ from apps.bookmark.models.rate import Rate
+
+ # Get the current user from the request context
+ request = self.context.get('request')
+ user = request.user if request and request.user.is_authenticated else None
+
+ if not user:
+ return {
+ 'is_rated': False,
+ 'rate': None
+ }
+
+ # Get rate information using the Rate model's method
+ rate_info = Rate.get_user_rate(
+ user=user,
+ service='video',
+ content_id=obj.id
+ )
+
+ return rate_info
+
+ def get_average_rate(self, obj):
+ """
+ Get the average rate for this video.
+ """
+ from apps.bookmark.models.rate import Rate
+
+ # Get average rate information using the Rate model
+ return Rate.get_average_rate(
+ service='video',
+ content_id=obj.id
+ )
+
+ def get_is_in_playlist(self, obj):
+ """
+ Check if the video is in any playlist.
+ Returns True if the video is in at least one playlist, False otherwise.
+ """
+ return PlaylistItem.objects.filter(video=obj).exists()
+
+ def get_playlist_videos(self, obj):
+ """
+ If the video is in a playlist, return all videos from the first playlist it belongs to,
+ excluding the current video itself. Videos are ordered by their priority in the playlist.
+ Returns null if the video is not in any playlist.
+ """
+ # Check if the video is in any playlist
+ if not self.get_is_in_playlist(obj):
+ return None
+
+ # Get the first playlist that contains this video
+ playlist_item = PlaylistItem.objects.filter(video=obj).first()
+ if not playlist_item:
+ return None
+
+ playlist = playlist_item.playlist
+
+ # Get all videos in this playlist except the current one, ordered by priority
+ playlist_videos = Video.objects.filter(
+ playlist_appearances__playlist=playlist
+ ).exclude(
+ id=obj.id
+ ).distinct().order_by('playlist_appearances__priority')
+
+ # Serialize the videos
+ return VideoListSerializer(
+ playlist_videos,
+ many=True,
+ context=self.context
+ ).data
+
+
+class PinnedVideoCollectionSerializer(serializers.ModelSerializer):
+ thumbnail = serializers.SerializerMethodField()
+
+ class Meta:
+ model = VideoCollection
+ fields = ['id', 'title', 'slug', 'summary', 'thumbnail', 'order', 'created_at']
+
+ def get_thumbnail(self, obj):
+ return get_thumbs(obj.thumbnail, self.context.get('request'))
+
+
+class MiddleVideoCollectionSerializer(serializers.ModelSerializer):
+ videos = serializers.SerializerMethodField()
+
+ class Meta:
+ model = VideoCollection
+ fields = ('id', 'title', 'slug', 'summary', 'status', 'order', 'pin_top','videos')
+
+ def get_videos(self, obj):
+ videos = obj.related_videos.filter(status=True).order_by('-created_at')
+ return VideoListSerializer(videos, many=True, context=self.context).data
diff --git a/apps/video/tests.py b/apps/video/tests.py
old mode 100644
new mode 100755
diff --git a/apps/video/urls.py b/apps/video/urls.py
old mode 100644
new mode 100755
index e42154f..0c7674b
--- a/apps/video/urls.py
+++ b/apps/video/urls.py
@@ -1,11 +1,13 @@
from django.urls import path
-from .views import VideoCategoryListAPIView, VideoListAPIView, VideoDetailAPIView
+from .views import *
app_name = 'video'
urlpatterns = [
path('categories/', VideoCategoryListAPIView.as_view(), name='category-list'),
-
+ path('pinned-collections/', PinnedVideoCollectionListView.as_view(), name='pinned-collection-list'),
+ path('collections/', MiddleVideoCollectionListView.as_view(), name='collection-list'),
+
path('list/', VideoListAPIView.as_view(), name='video-list'),
path('detail//', VideoDetailAPIView.as_view(), name='video-detail'),
diff --git a/apps/video/views.py b/apps/video/views.py
old mode 100644
new mode 100755
index 3e90c7e..628d0de
--- a/apps/video/views.py
+++ b/apps/video/views.py
@@ -1,40 +1,168 @@
from rest_framework import generics, status
from rest_framework.response import Response
-from .models import VideoCategory, Video
-from .serializers import VideoCategoryListSerializer, VideoListSerializer, VideoDetailSerializer
+from drf_yasg import openapi
+from drf_yasg.utils import swagger_auto_schema
+from apps.library.pagination import NoPagination
+from rest_framework.permissions import IsAuthenticated
+
+
+from apps.video.models import *
+from apps.video.serializers import *
class VideoCategoryListAPIView(generics.ListAPIView):
"""
- API view to list all video categories with their video counts
+ API view to list all video categories
"""
serializer_class = VideoCategoryListSerializer
+ @swagger_auto_schema(
+ operation_description="Get a list of all active video categories",
+ responses={
+ 200: openapi.Response(
+ description="List of video categories",
+ schema=VideoCategoryListSerializer(many=True)
+ )
+ }
+ )
+ def get(self, request, *args, **kwargs):
+ return super().get(request, *args, **kwargs)
+
def get_queryset(self):
return VideoCategory.objects.filter(status=True).order_by('order')
+
+class PinnedVideoCollectionListView(generics.ListAPIView):
+ serializer_class = PinnedVideoCollectionSerializer
+ permission_classes = (IsAuthenticated,)
+ pagination_class = NoPagination
+
+
+ def get_queryset(self):
+ return PinnedVideoCollection.objects.filter(
+ status=True,
+ display_position=VideoCollection.DisplayPosition.PINNED
+ ).order_by('-order', '-id')
+
+ def list(self, request, *args, **kwargs):
+ response = super().list(request, *args, **kwargs)
+ categories_count = VideoCategory.objects.filter(status=True).count()
+ from apps.bookmark.models import Bookmark
+ bookmarks_count = Bookmark.objects.filter(
+ service=Bookmark.ServiceChoices.VIDEO,
+ ).count()
+ info = {
+ "categories_count": categories_count,
+ "bookmarks_count": bookmarks_count,
+ }
+ data = {
+ "count": response.data.get("count"),
+ "next": response.data.get("next"),
+ "previous": response.data.get("previous"),
+ "info": info,
+ "results": response.data.get("results")
+ }
+ return Response(data, status=status.HTTP_200_OK)
+
+
+class MiddleVideoCollectionListView(generics.ListAPIView):
+ serializer_class = MiddleVideoCollectionSerializer
+ permission_classes = (IsAuthenticated,)
+ pagination_class = NoPagination
+
+ def get_queryset(self):
+ return VideoCollection.objects.filter(
+ status=True,
+ display_position=VideoCollection.DisplayPosition.MIDDLE
+ ).order_by('order')
+
+
class VideoListAPIView(generics.ListAPIView):
"""
- API view to list all videos, with optional category filtering
+ API view to list all videos, with optional filtering by category or collection
"""
serializer_class = VideoListSerializer
+ @swagger_auto_schema(
+ operation_description="Get a list of videos with optional filtering",
+ manual_parameters=[
+ openapi.Parameter(
+ name='category',
+ in_=openapi.IN_QUERY,
+ description='Filter videos by category slug',
+ type=openapi.TYPE_STRING,
+ required=False
+ ),
+ openapi.Parameter(
+ name='collection',
+ in_=openapi.IN_QUERY,
+ description='Filter videos by collection slug',
+ type=openapi.TYPE_STRING,
+ required=False
+ ),
+ openapi.Parameter(
+ name='is_bookmark',
+ in_=openapi.IN_QUERY,
+ description='Filter videos that are bookmarked by the user (true/false)',
+ type=openapi.TYPE_BOOLEAN,
+ required=False
+ ),
+ openapi.Parameter(
+ name='search',
+ in_=openapi.IN_QUERY,
+ description='Search videos by title',
+ type=openapi.TYPE_STRING,
+ required=False
+ )
+ ],
+ responses={
+ 200: openapi.Response(
+ description="List of videos",
+ schema=VideoListSerializer(many=True)
+ )
+ }
+ )
+ def get(self, request, *args, **kwargs):
+ return super().get(request, *args, **kwargs)
+
def get_queryset(self):
queryset = Video.objects.filter(status=True).order_by('-created_at')
+ # Search by title if search parameter is provided
+ search_query = self.request.query_params.get('search', None)
+ if search_query:
+ queryset = queryset.filter(title__icontains=search_query)
+
# Filter by category if provided
category_slug = self.request.query_params.get('category', None)
if category_slug:
- queryset = queryset.filter(category__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(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
+ bookmarked_ids = Bookmark.objects.filter(
+ user=self.request.user,
+ service=Bookmark.ServiceChoices.VIDEO,
+ status=True
+ ).values_list('content_id', flat=True)
+
+ # Filter videos by these IDs
+ queryset = queryset.filter(id__in=bookmarked_ids)
return queryset
class VideoDetailAPIView(generics.RetrieveAPIView):
- """
- API view to get video details, including related videos from the same collection
- """
serializer_class = VideoDetailSerializer
lookup_field = 'slug'
@@ -43,7 +171,7 @@ class VideoDetailAPIView(generics.RetrieveAPIView):
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
- # Increment view count
instance.increment_view_count()
serializer = self.get_serializer(instance)
return Response(serializer.data)
+
diff --git a/config/settings/base.py b/config/settings/base.py
old mode 100644
new mode 100755
index 4eda092..194616d
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -51,8 +51,10 @@ LOCAL_APPS = [
'apps.hadis.apps.HadisConfig',
'apps.library.apps.LibraryConfig',
'apps.video.apps.VideoConfig',
+ 'apps.podcast.apps.PodcastConfig',
'apps.bookmark.apps.BookmarkConfig',
- 'apps.dobodbi_calendar.apps.DobodbiCalendarConfig',
+ 'apps.article.apps.ArticleConfig',
+ 'apps.dobodbi_calendar.apps.DobodbiCalendarConfig',
'dynamic_preferences',
]
@@ -400,6 +402,24 @@ UNFOLD = {
# lambda request: static("js/chart.min.js"),
],
"TABS": [
+ {
+ "page": "video",
+ "models": ["video.videocollection", "video.pinnedvideocollection", 'video.middlevideocollection',],
+ "items": [
+ {
+ "title": _("Collections"),
+ "icon": "collections_bookmark",
+ "link": reverse_lazy("admin:video_pinnedvideocollection_changelist"),
+ "active": lambda request: "video/pinnedvideocollection" in request.path and "library/middlevideocollection" not in request.path,
+ },
+ {
+ "title": _("Middle Collections"),
+ "icon": "view_module",
+ "link": reverse_lazy("admin:video_middlevideocollection_changelist"),
+ "active": lambda request: "video/middlevideocollection" in request.path,
+ },
+ ],
+ },
{
"page": "library",
"models": ["library.bookcollection", "library.pinnedbookcollection", 'library.middlebookcollection'],
@@ -617,7 +637,7 @@ UNFOLD = {
]
},
{
- "title": _("Library"),
+ "title": _("Libraries"),
"collapsible": True,
"separator": True,
"items": [
@@ -638,6 +658,94 @@ UNFOLD = {
},
]
},
+ {
+ "title": _("Videos"),
+ "collapsible": True,
+ "separator": True,
+ "items": [
+ {
+ "title": _("Videos"),
+ "icon": "live_tv",
+ "link": reverse_lazy("admin:video_video_changelist"),
+ },
+ {
+ "title": _("Categories"),
+ "icon": "category",
+ "link": reverse_lazy("admin:video_videocategory_changelist"),
+ },
+ {
+ "title": _("Collections"),
+ "icon": "view_module",
+ "link": reverse_lazy("admin:video_pinnedvideocollection_changelist"),
+ },
+ {
+ "title": _("Playlists"),
+ "icon": "playlist_play",
+ "link": reverse_lazy("admin:video_videoplaylist_changelist"),
+ # "active": lambda request: "video/videoplaylist" in request.path,
+ },
+
+ ]
+ },
+ {
+ "title": _("Articles"),
+ "collapsible": True,
+ "separator": True,
+ "items": [
+ {
+ "title": _("Articles"),
+ "icon": "article",
+ "link": reverse_lazy("admin:article_article_changelist"),
+ },
+ {
+ "title": _("Categories"),
+ "icon": "category",
+ "link": reverse_lazy("admin:article_articlecategory_changelist"),
+ },
+ {
+ "title": _("Collections"),
+ "icon": "view_module",
+ "link": reverse_lazy("admin:article_pinnedarticlecollection_changelist"),
+ },
+ {
+ "title": _("Article Contents"),
+ "icon": "text_snippet",
+ "link": reverse_lazy("admin:article_articlecontent_changelist"),
+ },
+ ]
+ },
+ {
+ "title": _("Podcasts"),
+ "collapsible": True,
+ "separator": True,
+ "items": [
+ {
+ "title": _("Podcasts"),
+ "icon": "headset",
+ "link": reverse_lazy("admin:podcast_podcast_changelist"),
+ },
+ {
+ "title": _("Categories"),
+ "icon": "category",
+ "link": reverse_lazy("admin:podcast_podcastcategory_changelist"),
+ },
+ {
+ "title": _("Collections"),
+ "icon": "view_module",
+ "link": reverse_lazy("admin:podcast_pinnedpodcastcollection_changelist"),
+ },
+ {
+ "title": _("Playlists"),
+ "icon": "playlist_play",
+ "link": reverse_lazy("admin:podcast_podcastplaylist_changelist"),
+ },
+ {
+ "title": _("User Playlists"),
+ "icon": "person_add",
+ "link": reverse_lazy("admin:podcast_userplaylist_changelist"),
+ },
+ ]
+ },
{
"title": _(""),
"collapsible": True,
diff --git a/config/urls.py b/config/urls.py
index 2be20aa..c859f21 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -71,6 +71,8 @@ api_patterns = [
path('hadis/', include('apps.hadis.urls')),
path('library/', include('apps.library.urls')),
path('videos/', include('apps.video.urls')),
+ path('article/', include('apps.article.urls')),
+ path('podcast/', include('apps.podcast.urls')),
path('bookmarks/', include('apps.bookmark.urls')),
path('calendar/', include('apps.dobodbi_calendar.urls')),