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
-
289apps/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
-
211apps/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
-
6apps/video/urls.py
-
146apps/video/views.py
-
112config/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 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,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,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'), |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue