diff --git a/.env.example b/.env.example deleted file mode 100644 index 9cccd1ee..00000000 --- a/.env.example +++ /dev/null @@ -1,19 +0,0 @@ -# ============================= -# Email config - -# For development or quick testing use console as the email backend -EMAIL_BACKEND="django.core.mail.backends.console.EmailBackend" -# For production use smtp, uncomment the below variable -# EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST="smtp.gmail.com" -EMAIL_PORT=587 -EMAIL_USE_TLS=True -EMAIL_FROM_ADDRESS="SkyLearn " -EMAIL_HOST_USER="" -EMAIL_HOST_PASSWORD="" - -# ============================= -# Other - -DEBUG=True -SECRET_KEY="" \ No newline at end of file diff --git a/course/forms.py b/course/forms.py index 18b1d9ea..b694253b 100644 --- a/course/forms.py +++ b/course/forms.py @@ -99,9 +99,48 @@ class Meta: fields = ( "title", "video", + "video_url", ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["title"].widget.attrs.update({"class": "form-control"}) self.fields["video"].widget.attrs.update({"class": "form-control"}) + self.fields["video_url"].widget.attrs.update({"class": "form-control", "placeholder": "https://example.com/video"}) + + def clean(self): + cleaned_data = super().clean() + video = cleaned_data.get("video") + video_url = cleaned_data.get("video_url") + + if not video and not video_url: + raise forms.ValidationError("Please upload a video file or provide a video URL.") + + if video and video_url: + raise forms.ValidationError("Please provide either a video file or a video URL, not both.") + + return cleaned_data + +class VideoBulkUploadForm(forms.Form): + playlist_url = forms.URLField( + required=False, + label="YouTube Playlist URL" + ) + + videos = forms.FileField( + widget=forms.ClearableFileInput(attrs={'multiple': True}), + required=False, + label="Upload Video Files" + ) + + def clean(self): + cleaned_data = super().clean() + playlist_url = cleaned_data.get("playlist_url") + videos = self.files.getlist("videos") # Use self.files for uploaded files + + if not playlist_url and not videos: + raise forms.ValidationError( + "Please provide either a YouTube playlist URL or upload at least one video file." + ) + + return cleaned_data \ No newline at end of file diff --git a/course/migrations/0005_uploadvideo_video_url_alter_uploadvideo_video.py b/course/migrations/0005_uploadvideo_video_url_alter_uploadvideo_video.py new file mode 100644 index 00000000..1dc38f84 --- /dev/null +++ b/course/migrations/0005_uploadvideo_video_url_alter_uploadvideo_video.py @@ -0,0 +1,24 @@ +# Generated by Django 4.0.8 on 2025-07-02 11:49 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0004_alter_course_code_alter_course_credit_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='uploadvideo', + name='video_url', + field=models.URLField(blank=True, help_text='Paste external video URL (e.g., YouTube, Vimeo, etc.)', null=True), + ), + migrations.AlterField( + model_name='uploadvideo', + name='video', + field=models.FileField(blank=True, help_text='Valid video formats: mp4, mkv, wmv, 3gp, f4v, avi, mp3', null=True, upload_to='course_videos/', validators=[django.core.validators.FileExtensionValidator(['mp4', 'mkv', 'wmv', '3gp', 'f4v', 'avi', 'mp3'])]), + ), + ] diff --git a/course/models.py b/course/models.py index 37377458..0f94d1c3 100644 --- a/course/models.py +++ b/course/models.py @@ -192,17 +192,32 @@ def log_upload_delete(sender, instance, **kwargs): ) +from django.core.validators import FileExtensionValidator +from django.utils.translation import gettext_lazy as _ +from django.db import models +from django.urls import reverse + class UploadVideo(models.Model): title = models.CharField(max_length=100) slug = models.SlugField(unique=True, blank=True) - course = models.ForeignKey(Course, on_delete=models.CASCADE) + course = models.ForeignKey('Course', on_delete=models.CASCADE) + video = models.FileField( upload_to="course_videos/", help_text=_("Valid video formats: mp4, mkv, wmv, 3gp, f4v, avi, mp3"), validators=[ FileExtensionValidator(["mp4", "mkv", "wmv", "3gp", "f4v", "avi", "mp3"]) ], + blank=True, # Make file optional if using URL + null=True, + ) + + video_url = models.URLField( + blank=True, + null=True, + help_text=_("Paste external video URL (e.g., YouTube, Vimeo, etc.)") ) + summary = models.TextField(blank=True) timestamp = models.DateTimeField(auto_now_add=True) @@ -215,10 +230,12 @@ def get_absolute_url(self): ) def delete(self, *args, **kwargs): - self.video.delete(save=False) + if self.video: + self.video.delete(save=False) super().delete(*args, **kwargs) + @receiver(pre_save, sender=UploadVideo) def video_pre_save_receiver(sender, instance, **kwargs): if not instance.slug: diff --git a/course/urls.py b/course/urls.py index ef29b76a..c40643be 100644 --- a/course/urls.py +++ b/course/urls.py @@ -74,4 +74,6 @@ path("course/registration/", views.course_registration, name="course_registration"), path("course/drop/", views.course_drop, name="course_drop"), path("my_courses/", views.user_course_list, name="user_course_list"), + # Playlist upload + path("course//upload_playlist/", views.handle_playlist_upload, name="upload_playlist"), ] diff --git a/course/views.py b/course/views.py index 73466e00..38e98b6a 100644 --- a/course/views.py +++ b/course/views.py @@ -1,3 +1,4 @@ +import yt_dlp from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required @@ -7,6 +8,8 @@ from django.utils.decorators import method_decorator from django.views.generic import CreateView from django_filters.views import FilterView +from .forms import VideoBulkUploadForm +from django.utils.text import slugify from accounts.decorators import lecturer_required, student_required from accounts.models import Student @@ -322,17 +325,43 @@ def handle_file_delete(request, slug, file_id): @lecturer_required def handle_video_upload(request, slug): course = get_object_or_404(Course, slug=slug) + if request.method == "POST": form = UploadFormVideo(request.POST, request.FILES) + if form.is_valid(): - video = form.save(commit=False) - video.course = course - video.save() - messages.success(request, f"{video.title} has been uploaded.") + video_instance = form.save(commit=False) + video_instance.course = course + + # Generate slug from title + slugified_title = slugify(video_instance.title) + + # Check for duplicate slug (title) + if UploadVideo.objects.filter(slug=slugified_title, course=course).exists(): + messages.warning(request, "A video with this title already exists.") + return redirect("course_detail", slug=slug) + + # Check for duplicate video file (if uploaded) + if video_instance.video and UploadVideo.objects.filter(video=video_instance.video.name, course=course).exists(): + messages.warning(request, "This video file is already uploaded.") + return redirect("course_detail", slug=slug) + + # Check for duplicate video URL (if provided) + if video_instance.video_url and UploadVideo.objects.filter(video_url=video_instance.video_url, course=course).exists(): + messages.warning(request, "This video URL already exists.") + return redirect("course_detail", slug=slug) + + # Save if all checks pass + video_instance.slug = slugified_title + video_instance.save() + messages.success(request, f"{video_instance.title} has been uploaded.") return redirect("course_detail", slug=slug) + messages.error(request, "Correct the error(s) below.") + else: form = UploadFormVideo() + return render( request, "upload/upload_video_form.html", @@ -502,3 +531,81 @@ def user_course_list(request): # For other users return render(request, "course/user_course_list.html") +# ================================================== +# view for playlist upload +# ================================================== +def handle_playlist_upload(request, slug): + course = get_object_or_404(Course, slug=slug) + + if request.method == "POST": + form = VideoBulkUploadForm(request.POST, request.FILES) + if form.is_valid(): + playlist_url = form.cleaned_data.get('playlist_url') + videos = request.FILES.getlist('videos') + imported_count = 0 + + # Handle Playlist URL + if playlist_url: + ydl_opts = { + 'quiet': True, + 'extract_flat': 'in_playlist', + 'skip_download': True, + } + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info_dict = ydl.extract_info(playlist_url, download=False) + print("INFO_DICT:", info_dict) # <-- DEBUG + entries = info_dict.get('entries', []) + + for idx, entry in enumerate(entries, start=1): + video_id = entry.get('id') + if not video_id: + continue + + embed_url = f'https://www.youtube.com/embed/{video_id}' + title = f"Lecture {idx}" + slugified = slugify(title) + + if UploadVideo.objects.filter(slug=slugified, course=course).exists(): + continue + + UploadVideo.objects.create( + title=title, + slug=slugified, + course=course, + video_url=embed_url, + summary=f"Imported from playlist: {playlist_url}" + ) + imported_count += 1 + + except Exception as e: + messages.error(request, f"Error while importing playlist: {str(e)}") + + # Handle Uploaded Files + for file in videos: + title = file.name.rsplit('.', 1)[0] + slugified = slugify(title) + + if UploadVideo.objects.filter(slug=slugified, course=course).exists(): + continue + + UploadVideo.objects.create( + title=title, + slug=slugified, + course=course, + video=file, + summary="Uploaded by user" + ) + imported_count += 1 + + messages.success(request, f"{imported_count} videos uploaded/imported successfully.") + return redirect("course_detail", slug=slug) + + else: + form = VideoBulkUploadForm() + + return render(request, "upload/upload_playlist_form.html", { + "title": "Upload Playlist or Videos", + "form": form, + "course": course, + }) diff --git a/requirements/base.txt b/requirements/base.txt index 7be538b3..9fdd6685 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,7 +1,7 @@ pytz==2022.7 # https://github.com/stub42/pytz Pillow==9.3.0 # https://github.com/python-pillow/Pillow whitenoise==6.2.0 # https://github.com/evansd/whitenoise - +yt_dlp==2025.06.30 # https://github.com/yt-dlp/yt-dlp # Django # ------------------------------------------------------------------------------ django==4.0.8 # pyup: < 4.1 # https://www.djangoproject.com/ diff --git a/templates/course/course_single.html b/templates/course/course_single.html index d2612509..4fa9ac95 100644 --- a/templates/course/course_single.html +++ b/templates/course/course_single.html @@ -33,6 +33,9 @@ {% trans 'Upload new video' %} + + {% trans 'Upload Playlist' %} + {% endif %}
diff --git a/templates/upload/upload_playlist_form.html b/templates/upload/upload_playlist_form.html new file mode 100644 index 00000000..81135bca --- /dev/null +++ b/templates/upload/upload_playlist_form.html @@ -0,0 +1,71 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %} + {{ title }} | {% trans 'Learning management system' %} +{% endblock title %} + +{% block content %} + + +

{% trans 'Upload Playlist or Videos for' %} {{ course }}

+ +{% include 'snippets/messages.html' %} + +
+
+
+
+ Upload Bulk +
+
+
+ {% csrf_token %} + {{ form|crispy }} + +
+ + + {% trans 'Cancel' %} + +
+
+
+
+ +
+
+ + +{% endblock %} diff --git a/templates/upload/upload_video_form.html b/templates/upload/upload_video_form.html index cded974b..e9955808 100644 --- a/templates/upload/upload_video_form.html +++ b/templates/upload/upload_video_form.html @@ -25,7 +25,7 @@

{% trans 'Video Upload Form' %}

-
{% csrf_token %} + {% csrf_token %} {{ form|crispy }}
@@ -37,4 +37,31 @@
+ + {% endblock content %} diff --git a/templates/upload/video_single.html b/templates/upload/video_single.html index 8721d84d..bac69920 100644 --- a/templates/upload/video_single.html +++ b/templates/upload/video_single.html @@ -19,12 +19,33 @@

-
+ + {% if video.video %} +
+ +
+ {% elif video.video_url %} +
+ +
+ {% else %} +

{% trans "No video available." %}

+ {% endif %} +

{{ video.timestamp|timesince }} {% trans 'ago' %}

+ {% if video.summary %} -

{{ video.summary }}

+

{{ video.summary }}

{% else %} - {% trans 'No video description set.' %} +

{% trans 'No video description set.' %}

{% endif %}