Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions .env.example

This file was deleted.

39 changes: 39 additions & 0 deletions course/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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'])]),
),
]
21 changes: 19 additions & 2 deletions course/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions course/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug:slug>/upload_playlist/", views.handle_playlist_upload, name="upload_playlist"),
]
115 changes: 111 additions & 4 deletions course/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
})
2 changes: 1 addition & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
3 changes: 3 additions & 0 deletions templates/course/course_single.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
<a class="btn btn-sm btn-primary" href="{% url 'upload_video' course.slug %}"><i class="fas fa-plus"></i>
{% trans 'Upload new video' %}
</a>
<a class="btn btn-sm btn-primary" href="{% url 'upload_playlist' course.slug %}">
<i class="fas fa-list"></i> {% trans 'Upload Playlist' %}
</a>
{% endif %}
</div>
<div class="ms-auto">
Expand Down
71 changes: 71 additions & 0 deletions templates/upload/upload_playlist_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{% extends 'base.html' %}
{% load i18n %}
{% load crispy_forms_tags %}

{% block title %}
{{ title }} | {% trans 'Learning management system' %}
{% endblock title %}

{% block content %}
<nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{% trans 'Home' %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'programs' %}">{% trans 'Programs' %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'program_detail' course.program.id %}">{{ course.program }}</a></li>
<li class="breadcrumb-item"><a href="{% url 'course_detail' course.slug %}">{{ course }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans 'Playlist Upload' %}</li>
</ol>
</nav>

<p class="title-1">{% trans 'Upload Playlist or Videos for' %} {{ course }}</p>

{% include 'snippets/messages.html' %}

<div class="row">
<div class="col-md-8 mx-auto">
<div class="card">
<div class="card-header text-white text-center" style="background: linear-gradient(to right, #3bb3f3, #324da8); font-weight: normal;">
Upload Bulk
</div>
<div class="card-body">
<form id="video-upload-form" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|crispy }}

<div class="form-group mt-3">
<button class="btn btn-primary" type="submit">{% trans 'Upload' %}</button>
<a class="btn btn-danger" href="{% url 'course_detail' course.slug %}" style="float: right;">
{% trans 'Cancel' %}
</a>
</div>
</form>
</div>
</div>

</div>
</div>

<script>
const playlistInput = document.getElementById('id_playlist_url');
const videosInput = document.getElementById('id_videos');

function toggleInputs() {
if (playlistInput.value.trim() !== "") {
videosInput.disabled = true;
} else {
videosInput.disabled = false;
}

if (videosInput.files.length > 0) {
playlistInput.disabled = true;
} else {
playlistInput.disabled = false;
}
}

playlistInput.addEventListener("input", toggleInputs);
videosInput.addEventListener("change", toggleInputs);

window.onload = toggleInputs;
</script>
{% endblock %}
Loading