42 changed files with 3156 additions and 139 deletions
-
0apps/article/__init__.py
-
273apps/article/admin.py
-
6apps/article/apps.py
-
161apps/article/migrations/0001_initial.py
-
0apps/article/migrations/__init__.py
-
211apps/article/models.py
-
131apps/article/serializers.py
-
4apps/article/templates/article/change_form_before_template.html
-
3apps/article/tests.py
-
17apps/article/urls.py
-
177apps/article/views.py
-
13apps/course/admin/course.py
-
0apps/podcast/__init__.py
-
287apps/podcast/admin.py
-
0apps/podcast/apps.py
-
170apps/podcast/migrations/0001_initial.py
-
39apps/podcast/migrations/0002_podcast_collections_alter_podcast_categories_and_more.py
-
0apps/podcast/migrations/__init__.py
-
207apps/podcast/models.py
-
205apps/podcast/serializers.py
-
0apps/podcast/tests.py
-
17apps/podcast/urls.py
-
287apps/podcast/views.py
-
0apps/video/__init__.py
-
255apps/video/admin.py
-
0apps/video/apps.py
-
0apps/video/migrations/0001_initial.py
-
0apps/video/migrations/0002_alter_video_thumbnail.py
-
87apps/video/migrations/0003_remove_videocollection_videos_middlevideocollection_and_more.py
-
18apps/video/migrations/0004_videocollection_order.py
-
48apps/video/migrations/0005_videoplaylist_alter_video_options_playlistitem.py
-
18apps/video/migrations/0006_alter_video_video_type.py
-
43apps/video/migrations/0007_videoincollection_alter_video_collections.py
-
18apps/video/migrations/0008_videocollection_videos.py
-
0apps/video/migrations/__init__.py
-
155apps/video/models.py
-
173apps/video/serializers.py
-
0apps/video/tests.py
-
4apps/video/urls.py
-
146apps/video/views.py
-
110config/settings/base.py
-
2config/urls.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) |
|||
@ -0,0 +1,6 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class ArticleConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.article' |
|||
@ -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,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}" |
|||
|
|||
|
|||
|
|||
@ -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 |
|||
) |
|||
@ -0,0 +1,4 @@ |
|||
{% load i18n %} |
|||
{% load unfold %} |
|||
{% load course_tags %} |
|||
|
|||
@ -0,0 +1,3 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -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'), |
|||
] |
|||
@ -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) |
|||
@ -1,23 +1,290 @@ |
|||
from django.contrib import admin |
|||
from ajaxdatatable.admin import AjaxDatatable |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from django.urls import reverse |
|||
from django.utils.html import format_html |
|||
from django.db import models |
|||
from unfold.admin import ModelAdmin, StackedInline, TabularInline |
|||
from django.contrib.admin import SimpleListFilter |
|||
from unfold.widgets import UnfoldAdminSelectWidget |
|||
|
|||
from apps.podcast.models import * |
|||
from unfold.decorators import display, action |
|||
from django import forms |
|||
|
|||
from utils.admin import project_admin_site |
|||
from unfold.sections import TableSection |
|||
|
|||
from apps.podcast.models import * |
|||
|
|||
|
|||
class PodcastInCollectionInline(admin.TabularInline): |
|||
class PodcastInCollectionInline(TabularInline): |
|||
model = PodcastInCollection |
|||
extra = 1 |
|||
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] |
|||
|
|||
|
|||
@admin.register(Podcast) |
|||
class PodcastAdmin(AjaxDatatable): |
|||
list_display = ('title', 'view_count', 'download_count', 'status') |
|||
search_fields = ('title',) |
|||
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 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,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')}, |
|||
}, |
|||
), |
|||
] |
|||
@ -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,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,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'), |
|||
] |
|||
@ -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,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', |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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')}, |
|||
}, |
|||
), |
|||
] |
|||
@ -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), |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -1,10 +1,12 @@ |
|||
from django.urls import path |
|||
from .views import VideoCategoryListAPIView, VideoListAPIView, VideoDetailAPIView |
|||
from .views import * |
|||
|
|||
app_name = 'video' |
|||
|
|||
urlpatterns = [ |
|||
path('categories/', VideoCategoryListAPIView.as_view(), name='category-list'), |
|||
path('pinned-collections/', PinnedVideoCollectionListView.as_view(), name='pinned-collection-list'), |
|||
path('collections/', MiddleVideoCollectionListView.as_view(), name='collection-list'), |
|||
|
|||
path('list/', VideoListAPIView.as_view(), name='video-list'), |
|||
|
|||
|
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue