Browse Source

feat: article, videos. library, podcasts

master
mortezaei 1 year ago
parent
commit
7732e0f15c
  1. 0
      apps/article/__init__.py
  2. 273
      apps/article/admin.py
  3. 6
      apps/article/apps.py
  4. 161
      apps/article/migrations/0001_initial.py
  5. 0
      apps/article/migrations/__init__.py
  6. 211
      apps/article/models.py
  7. 131
      apps/article/serializers.py
  8. 4
      apps/article/templates/article/change_form_before_template.html
  9. 3
      apps/article/tests.py
  10. 17
      apps/article/urls.py
  11. 177
      apps/article/views.py
  12. 13
      apps/course/admin/course.py
  13. 0
      apps/podcast/__init__.py
  14. 289
      apps/podcast/admin.py
  15. 0
      apps/podcast/apps.py
  16. 170
      apps/podcast/migrations/0001_initial.py
  17. 39
      apps/podcast/migrations/0002_podcast_collections_alter_podcast_categories_and_more.py
  18. 0
      apps/podcast/migrations/__init__.py
  19. 211
      apps/podcast/models.py
  20. 205
      apps/podcast/serializers.py
  21. 0
      apps/podcast/tests.py
  22. 17
      apps/podcast/urls.py
  23. 287
      apps/podcast/views.py
  24. 0
      apps/video/__init__.py
  25. 255
      apps/video/admin.py
  26. 0
      apps/video/apps.py
  27. 0
      apps/video/migrations/0001_initial.py
  28. 0
      apps/video/migrations/0002_alter_video_thumbnail.py
  29. 87
      apps/video/migrations/0003_remove_videocollection_videos_middlevideocollection_and_more.py
  30. 18
      apps/video/migrations/0004_videocollection_order.py
  31. 48
      apps/video/migrations/0005_videoplaylist_alter_video_options_playlistitem.py
  32. 18
      apps/video/migrations/0006_alter_video_video_type.py
  33. 43
      apps/video/migrations/0007_videoincollection_alter_video_collections.py
  34. 18
      apps/video/migrations/0008_videocollection_videos.py
  35. 0
      apps/video/migrations/__init__.py
  36. 155
      apps/video/models.py
  37. 173
      apps/video/serializers.py
  38. 0
      apps/video/tests.py
  39. 6
      apps/video/urls.py
  40. 146
      apps/video/views.py
  41. 112
      config/settings/base.py
  42. 2
      config/urls.py

0
apps/article/__init__.py

273
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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', 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)

6
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'

161
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'],
},
),
]

0
apps/article/migrations/__init__.py

211
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}"

131
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
)

4
apps/article/templates/article/change_form_before_template.html

@ -0,0 +1,4 @@
{% load i18n %}
{% load unfold %}
{% load course_tags %}

3
apps/article/tests.py

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

17
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/<slug:slug>/', 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'),
]

177
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)

13
apps/course/admin/course.py

@ -253,11 +253,11 @@ class CourseAdmin(ModelAdmin):
"level": admin.HORIZONTAL, "level": admin.HORIZONTAL,
} }
show_facets = admin.ShowFacets.ALLOW show_facets = admin.ShowFacets.ALLOW
formfield_overrides = {
models.TextField: {
"widget": WysiwygWidget,
},
}
# formfield_overrides = {
# models.TextField: {
# "widget": WysiwygWidget,
# },
# }
conditional_fields = { conditional_fields = {
'price': "is_free == false", 'price': "is_free == false",
'discount_percentage': "is_free == false", 'discount_percentage': "is_free == false",
@ -299,7 +299,8 @@ class CourseAdmin(ModelAdmin):
return [ return [
instance.title, instance.title,
instance.short_description or _("No description"),
# instance.short_description or _("No description"),
None,
None, None,
{ {
"path": thumbnail_path, "path": thumbnail_path,

0
apps/podcast/__init__.py

289
apps/podcast/admin.py

@ -1,23 +1,290 @@
from django.contrib import admin 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 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] 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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', 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('<span>{}</span>', 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)

0
apps/podcast/apps.py

170
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')},
},
),
]

39
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'),
),
]

0
apps/podcast/migrations/__init__.py

211
apps/podcast/models.py

@ -1,5 +1,7 @@
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _
from utils import generate_slug_for_model
class PodcastCategory(models.Model): class PodcastCategory(models.Model):
@ -14,64 +16,94 @@ class PodcastCategory(models.Model):
def __str__(self): def __str__(self):
return self.title 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: class Meta:
verbose_name = _('Video Category')
verbose_name_plural = _('Video Categories')
verbose_name = _('Podcast Category')
verbose_name_plural = _('Podcast Categories')
ordering = ['order'] ordering = ['order']
class PodcastCollection(models.Model): 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") 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')) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
videos = models.ManyToManyField(
Video,
podcasts = models.ManyToManyField(
'Podcast',
through='PodcastInCollection', through='PodcastInCollection',
related_name='collections',
related_name='related_collections_podcast',
verbose_name=_('podcasts'), verbose_name=_('podcasts'),
) )
def __str__(self): def __str__(self):
return f'Collection #{self.id}/{self.title}' 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: class Meta:
verbose_name = _('Podcast Collection') verbose_name = _('Podcast Collection')
verbose_name_plural = _('Podcasts Collections') 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: 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): class Podcast(models.Model):
title = models.CharField(max_length=255, null=True) title = models.CharField(max_length=255, null=True)
slug = models.SlugField(allow_unicode=True, unique=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='book_thumbnails/', null=True, blank=True, help_text=_('image allowed'))
description = models.TextField(null=True) 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_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() audio_time = models.TimeField()
view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) 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')) status = models.BooleanField(default=True, verbose_name=_('status'))
@ -80,8 +112,141 @@ class Podcast(models.Model):
def __str__(self): def __str__(self):
return self.title 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: class Meta:
verbose_name = _('Podcast') verbose_name = _('Podcast')
verbose_name_plural = _('Podcasts') 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()

205
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.")

0
apps/podcast/tests.py

17
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/<slug:slug>/', 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'),
]

287
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
)

0
apps/video/__init__.py

255
apps/video/admin.py

@ -2,51 +2,122 @@ from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.urls import reverse from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from django.db import models
from ajaxdatatable.admin import AjaxDatatable 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 * from apps.video.models import *
class VideoInCollectionInline(admin.TabularInline):
class VideoInCollectionInline(TabularInline):
model = VideoInCollection model = VideoInCollection
extra = 1 extra = 1
autocomplete_fields = ('video',) 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',) search_fields = ('title',)
ordering = ('order',)
list_filter_submit = True
warn_unsaved_form = True
change_form_show_cancel_button = True
inlines = [VideoInCollectionInline] inlines = [VideoInCollectionInline]
fieldsets = ( fieldsets = (
(None, { (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): def count_videos(self, obj):
count = obj.videos.count()
count = obj.related_videos.count()
if count > 0: if count > 0:
url = reverse('admin:video_video_changelist') + f'?collections__id__exact={obj.id}' url = reverse('admin:video_video_changelist') + f'?collections__id__exact={obj.id}'
return format_html('<a href="{}">{}</a>', url, count) return format_html('<a href="{}">{}</a>', url, count)
return 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_display = ('title', 'slug', 'status', 'order', 'count_videos', 'created_at')
list_filter = ('status', 'created_at', 'updated_at') list_filter = ('status', 'created_at', 'updated_at')
search_fields = ('title', 'slug') search_fields = ('title', 'slug')
@ -59,14 +130,30 @@ class VideoCategoryAdmin(AjaxDatatable):
url = reverse('admin:video_video_changelist') + f'?category__id__exact={obj.id}' url = reverse('admin:video_video_changelist') + f'?category__id__exact={obj.id}'
return format_html('<a href="{}">{}</a>', url, count) return format_html('<a href="{}">{}</a>', url, count)
return 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_display = ('title', 'slug', 'video_type', 'status', 'view_count', 'created_at')
list_filter = ('status', 'video_type', 'created_at', 'updated_at') list_filter = ('status', 'video_type', 'created_at', 'updated_at')
search_fields = ('title', 'slug', 'description') search_fields = ('title', 'slug', 'description')
autocomplete_fields = ('categories',) 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 = ( fieldsets = (
(None, { (None, {
@ -82,4 +169,134 @@ class VideoAdmin(AjaxDatatable):
'fields': ('view_count',) '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('<span>{}</span>', 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)

0
apps/video/apps.py

0
apps/video/migrations/0001_initial.py

0
apps/video/migrations/0002_alter_video_thumbnail.py

87
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',
),
]

18
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'),
),
]

48
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')},
},
),
]

18
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),
),
]

43
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'),
),
]

18
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'),
),
]

0
apps/video/migrations/__init__.py

155
apps/video/models.py

@ -1,6 +1,7 @@
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from filer.fields.image import FilerImageField from filer.fields.image import FilerImageField
from utils import generate_slug_for_model
class VideoCategory(models.Model): class VideoCategory(models.Model):
@ -15,6 +16,11 @@ class VideoCategory(models.Model):
def __str__(self): def __str__(self):
return self.title 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: class Meta:
verbose_name = _('Video Category') verbose_name = _('Video Category')
verbose_name_plural = _('Video Categories') verbose_name_plural = _('Video Categories')
@ -22,50 +28,70 @@ class VideoCategory(models.Model):
class VideoCollection(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')) 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')) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
videos = models.ManyToManyField( videos = models.ManyToManyField(
"Video",
'Video',
through='VideoInCollection', through='VideoInCollection',
related_name='collections',
verbose_name=_('videos'),
related_name='related_collections_video',
verbose_name=_('Videos'),
) )
def __str__(self): def __str__(self):
return f'Collection #{self.id}/{self.title}' 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: class Meta:
verbose_name = _('Video Collection') verbose_name = _('Video Collection')
verbose_name_plural = _('Video Collections') 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: 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 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) title = models.CharField(max_length=255, null=True)
slug = models.SlugField(allow_unicode=True, unique=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) description = models.TextField(null=True)
categories = models.ManyToManyField( categories = models.ManyToManyField(
VideoCategory, VideoCategory,
@ -73,7 +99,15 @@ class Video(models.Model):
verbose_name=_('categories'), verbose_name=_('categories'),
blank=True, 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_file = models.FileField(upload_to='video/videos/', null=True, blank=True)
video_url = models.CharField(max_length=655, null=True, blank=True) video_url = models.CharField(max_length=655, null=True, blank=True)
video_time = models.TimeField() video_time = models.TimeField()
@ -94,8 +128,79 @@ class Video(models.Model):
self.save(update_fields=['view_count']) self.save(update_fields=['view_count'])
return self.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: class Meta:
verbose_name = _('Video')
verbose_name_plural = _('Videos')
verbose_name = _('Playlist Item')
verbose_name_plural = _('Playlist Items')
ordering = ['priority']
unique_together = ['playlist', 'video']

173
apps/video/serializers.py

@ -1,5 +1,7 @@
from rest_framework import serializers 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): class VideoCategoryListSerializer(serializers.ModelSerializer):
@ -14,57 +16,148 @@ class VideoCategoryListSerializer(serializers.ModelSerializer):
class VideoListSerializer(serializers.ModelSerializer): class VideoListSerializer(serializers.ModelSerializer):
categories = VideoCategoryListSerializer(many=True, read_only=True)
thumbnail = serializers.SerializerMethodField()
class Meta: class Meta:
model = Video 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): class VideoDetailSerializer(serializers.ModelSerializer):
related_videos = serializers.SerializerMethodField()
categories = VideoCategoryListSerializer(many=True, read_only=True) 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: class Meta:
model = Video model = Video
fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_type', fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_type',
'video_file', 'video_url', 'video_time', 'view_count', '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 []
'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

0
apps/video/tests.py

6
apps/video/urls.py

@ -1,11 +1,13 @@
from django.urls import path from django.urls import path
from .views import VideoCategoryListAPIView, VideoListAPIView, VideoDetailAPIView
from .views import *
app_name = 'video' app_name = 'video'
urlpatterns = [ urlpatterns = [
path('categories/', VideoCategoryListAPIView.as_view(), name='category-list'), 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('list/', VideoListAPIView.as_view(), name='video-list'),
path('detail/<slug:slug>/', VideoDetailAPIView.as_view(), name='video-detail'), path('detail/<slug:slug>/', VideoDetailAPIView.as_view(), name='video-detail'),

146
apps/video/views.py

@ -1,40 +1,168 @@
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework.response import Response 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): 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 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): def get_queryset(self):
return VideoCategory.objects.filter(status=True).order_by('order') 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): 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 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): def get_queryset(self):
queryset = Video.objects.filter(status=True).order_by('-created_at') 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 # Filter by category if provided
category_slug = self.request.query_params.get('category', None) category_slug = self.request.query_params.get('category', None)
if category_slug: 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 return queryset
class VideoDetailAPIView(generics.RetrieveAPIView): class VideoDetailAPIView(generics.RetrieveAPIView):
"""
API view to get video details, including related videos from the same collection
"""
serializer_class = VideoDetailSerializer serializer_class = VideoDetailSerializer
lookup_field = 'slug' lookup_field = 'slug'
@ -43,7 +171,7 @@ class VideoDetailAPIView(generics.RetrieveAPIView):
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
instance = self.get_object() instance = self.get_object()
# Increment view count
instance.increment_view_count() instance.increment_view_count()
serializer = self.get_serializer(instance) serializer = self.get_serializer(instance)
return Response(serializer.data) return Response(serializer.data)

112
config/settings/base.py

@ -51,8 +51,10 @@ LOCAL_APPS = [
'apps.hadis.apps.HadisConfig', 'apps.hadis.apps.HadisConfig',
'apps.library.apps.LibraryConfig', 'apps.library.apps.LibraryConfig',
'apps.video.apps.VideoConfig', 'apps.video.apps.VideoConfig',
'apps.podcast.apps.PodcastConfig',
'apps.bookmark.apps.BookmarkConfig', 'apps.bookmark.apps.BookmarkConfig',
'apps.dobodbi_calendar.apps.DobodbiCalendarConfig',
'apps.article.apps.ArticleConfig',
'apps.dobodbi_calendar.apps.DobodbiCalendarConfig',
'dynamic_preferences', 'dynamic_preferences',
] ]
@ -400,6 +402,24 @@ UNFOLD = {
# lambda request: static("js/chart.min.js"), # lambda request: static("js/chart.min.js"),
], ],
"TABS": [ "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", "page": "library",
"models": ["library.bookcollection", "library.pinnedbookcollection", 'library.middlebookcollection'], "models": ["library.bookcollection", "library.pinnedbookcollection", 'library.middlebookcollection'],
@ -617,7 +637,7 @@ UNFOLD = {
] ]
}, },
{ {
"title": _("Library"),
"title": _("Libraries"),
"collapsible": True, "collapsible": True,
"separator": True, "separator": True,
"items": [ "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": _(""), "title": _(""),
"collapsible": True, "collapsible": True,

2
config/urls.py

@ -71,6 +71,8 @@ api_patterns = [
path('hadis/', include('apps.hadis.urls')), path('hadis/', include('apps.hadis.urls')),
path('library/', include('apps.library.urls')), path('library/', include('apps.library.urls')),
path('videos/', include('apps.video.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('bookmarks/', include('apps.bookmark.urls')),
path('calendar/', include('apps.dobodbi_calendar.urls')), path('calendar/', include('apps.dobodbi_calendar.urls')),

Loading…
Cancel
Save