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