diff --git a/YOUTUBE_OAUTH_SETUP.md b/YOUTUBE_OAUTH_SETUP.md
new file mode 100644
index 000000000..d60a9d6a6
--- /dev/null
+++ b/YOUTUBE_OAUTH_SETUP.md
@@ -0,0 +1,128 @@
+# YouTube OAuth Setup Guide
+*Spoken Tutorial – YouTube Upload Integration*
+
+This document explains how to create and configure Google OAuth credentials required for uploading videos to YouTube from the Spoken Tutorial website.
+
+The YouTube Data API does **not** allow uploads using a simple API key. Uploading requires OAuth 2.0 credentials because it modifies a user's YouTube channel.
+
+---
+
+## 1. Create a Google Cloud Project
+
+1. Visit: https://console.cloud.google.com/
+2. Click **New Project**
+3. Name it something like:
+
+```
+spoken-youtube-upload
+```
+
+4. Create the project.
+
+
+---
+
+## 2. Enable YouTube Data API v3
+
+1. Inside the project, go to **APIs & Services → Library**
+2. Search for **YouTube Data API v3**
+3. Click **Enable**
+
+
+
+---
+
+## 3. Configure OAuth Consent Screen
+
+1. Go to **APIs & Services → OAuth consent screen → Get Started**
+2. Fill the basic details:
+ - App name: `Spoken YouTube Uploader`
+ - User support email: your email
+3. Choose **External**
+4. Save and continue with defaults.
+
+
+
+
+If the app is in **Testing** mode, go to **Audience** and add your email under **Test Users**.
+
+
+
+
+---
+
+## 4. Create OAuth Client ID
+
+1. Go to **APIs & Services → Credentials**
+2. Click **Create Credentials → OAuth client ID**
+3. Application type: **Web application**
+4. Name: `Django YouTube Uploader`
+5. Add the following **Authorized redirect URIs**:
+
+```
+http://127.0.0.1:8000/youtube/oauth2callback/
+http://localhost:8000/youtube/oauth2callback/
+https://spoken-tutorial.org/youtube/oauth2callback/
+```
+
+6. Save.
+
+
+
+---
+
+## 5. Download Client Secret
+
+1. In the Credentials list, click the download icon for the OAuth client.
+2. Rename the file to:
+
+```
+client_secret.json
+```
+
+3. Place it in:
+
+```
+spoken-website/youtube/client_secret.json
+```
+
+This file is required for authentication and **must not be committed to version control**.
+
+
+
+---
+
+## 6. First-Time Authorization
+
+1. Open the upload page:
+
+```
+/software-training/add-youtube-video/
+```
+
+2. If not authorized, the page will show:
+
+> "YouTube credentials not found. Click here to authorize."
+
+3. Click the link and sign in with the Google account that owns the YouTube channel.
+4. Grant access.
+5. You will be redirected back to the site.
+
+This is a one-time step. After authorization, uploads work normally.
+
+---
+
+## Notes
+
+- Each environment (local, staging, production) must have its redirect URI registered.
+- Only users added as **Test Users** can authorize while the app is unverified.
+- Upload permissions inside the site are controlled by:
+
+```python
+# spoken/config.py
+YOUTUBE_UPLOAD_ROLES = ["YouTube Admin"]
+```
+
+Only users in these roles can access the upload feature.
+
+---
diff --git a/events/views.py b/events/views.py
index c509dbd04..ad8171d94 100644
--- a/events/views.py
+++ b/events/views.py
@@ -2,6 +2,7 @@
from django.http import HttpResponseForbidden, HttpResponseBadRequest
from .models import StudentBatch
from django.urls import reverse
+from django import forms
def get_batches(request):
school_id = request.GET.get('school_id')
@@ -89,6 +90,7 @@ def get_batches(request):
from mdldjango.get_or_create_participant import encript_password
from .helpers import send_bulk_student_reset_mail, get_fossmdlcourse
from .certificates import *
+from youtube.utils import user_can_upload_to_youtube
def can_clone_training(training):
if training.tdate > datetime.datetime.strptime('01-02-2015', "%d-%m-%Y").date() and training.organiser.academic.institution_type.name != 'School':
@@ -622,6 +624,7 @@ def events_dashboard(request):
'rp_workshop_notification': rp_workshop_notification,
'rp_training_notification': rp_training_notification,
'invigilator_test_notification': invigilator_test_notification,
+ 'can_upload_youtube': user_can_upload_to_youtube(user),
}
return render(request, 'events/templates/events_dashboard.html',
context)
@@ -3348,4 +3351,52 @@ def get_schools(request):
return JsonResponse([])
-
+class YouTubeUploadForm(forms.Form):
+ """Form for uploading YouTube videos"""
+ video = forms.FileField(
+ label='Video File',
+ widget=forms.FileInput(attrs={'class': 'form-control'})
+ )
+ title = forms.CharField(
+ label='Title',
+ max_length=200,
+ widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Enter video title'})
+ )
+ description = forms.CharField(
+ label='Description',
+ widget=forms.Textarea(attrs={'class': 'form-control', 'placeholder': 'Enter video description'})
+ )
+ privacy_status = forms.ChoiceField(
+ label='Privacy Status',
+ choices=[
+ ('public', 'Public'),
+ ('unlisted', 'Unlisted'),
+ ('private', 'Private'),
+ ],
+ widget=forms.Select(attrs={'class': 'form-control'})
+ )
+
+
+def add_youtube_video(request):
+ """
+ View for uploading YouTube videos with title, description, and privacy settings.
+ """
+ context = {}
+ template = 'youtube/templates/add_youtube_video.html'
+
+ if request.method == 'GET':
+ # Display empty form
+ form = YouTubeUploadForm()
+ context['form'] = form
+
+ elif request.method == 'POST':
+ # Handle form submission
+ form = YouTubeUploadForm(request.POST, request.FILES)
+ context['form'] = form
+
+ if form.is_valid():
+ # Backend processing would happen here (currently placeholder)
+ messages.success(request, 'Video upload initiated successfully!')
+ return HttpResponseRedirect('/software-training/')
+
+ return render(request, template, context)
diff --git a/static/events/templates/events_dashboard.html b/static/events/templates/events_dashboard.html
index abb9f986d..d499179e2 100755
--- a/static/events/templates/events_dashboard.html
+++ b/static/events/templates/events_dashboard.html
@@ -110,6 +110,16 @@
diff --git a/youtube/ajax.py b/youtube/ajax.py
index f372ec3dd..eee7127ce 100644
--- a/youtube/ajax.py
+++ b/youtube/ajax.py
@@ -8,7 +8,7 @@
from django.views.decorators.csrf import csrf_exempt
# Spoken Tutorial Stuff
-from creation.models import Language, TutorialDetail, TutorialResource
+from creation.models import Language, TutorialDetail, TutorialResource, FossCategory
@csrf_exempt
@@ -46,3 +46,56 @@ def ajax_foss_based_language_tutorial(request):
data = '
' + data
return HttpResponse(json.dumps(data), content_type='application/json')
+
+
+@csrf_exempt
+def get_uploadable_tutorials(request):
+ """
+ Get tutorials available for YouTube upload.
+ Implements FK traversal: FOSSCategory → TutorialDetail → TutorialResource
+ Filters by is_on_youtube=False
+
+ GET params:
+ foss_id: FOSSCategory ID
+ language_id: Language ID
+
+ Returns JSON with list of tutorials:
+ {
+ "tutorials": [
+ {
+ "id": tutorial_resource_id,
+ "name": "Tutorial Name",
+ "outline": "Tutorial outline text"
+ },
+ ...
+ ]
+ }
+ """
+ tutorials = []
+
+ if request.method == 'GET':
+ foss_id = request.GET.get('foss_id', '')
+ language_id = request.GET.get('language_id', '')
+
+ if foss_id and language_id:
+ try:
+ foss_id = int(foss_id)
+ language_id = int(language_id)
+
+ # Filter resources by FOSS, Language, and YouTube status
+ tutorial_resources = TutorialResource.objects.filter(
+ tutorial_detail__foss_id=foss_id,
+ language_id=language_id,
+ is_on_youtube=False
+ ).select_related('tutorial_detail').order_by('tutorial_detail__order')
+
+ for tr in tutorial_resources:
+ tutorials.append({
+ 'id': tr.id,
+ 'name': tr.tutorial_detail.tutorial,
+ 'outline': tr.outline
+ })
+ except (ValueError, Language.DoesNotExist, FossCategory.DoesNotExist):
+ pass
+
+ return HttpResponse(json.dumps({'tutorials': tutorials}), content_type='application/json')
diff --git a/youtube/core.py b/youtube/core.py
index 75f3f08e8..520770fd6 100644
--- a/youtube/core.py
+++ b/youtube/core.py
@@ -16,7 +16,7 @@
from youtube.models import *
youtube_scope_urls = ["https://www.googleapis.com/auth/youtube.upload", "https://www.googleapis.com/auth/youtube"]
-client_secrets_file = os.path.join(settings.BASE_DIR, 'youtube', '.client_secrets.json')
+client_secrets_file = os.path.join(settings.BASE_DIR, 'youtube', 'client_secret.json')
credentials_file = os.path.join(settings.BASE_DIR, 'youtube', '.youtube-upload-credentials.json')
@@ -26,9 +26,9 @@ def to_utf8(s):
return (s.decode(current).encode("UTF-8") if s and current != "UTF-8" else s)
-def get_flow():
+def get_flow(redirect_uri=None):
flow = oauth2client.client.flow_from_clientsecrets(client_secrets_file, scope=youtube_scope_urls)
- flow.redirect_uri = settings.YOUTUBE_REDIRECT_URL
+ flow.redirect_uri = redirect_uri or settings.YOUTUBE_REDIRECT_URL
flow.params['state'] = xsrfutil.generate_token(settings.SECRET_KEY, 0)
return flow
@@ -45,39 +45,32 @@ def get_youtube_credential():
try:
http = credential.authorize(httplib2.Http())
return googleapiclient.discovery.build("youtube", "v3", http=http)
- except Exception as e:
- print(e)
+ except Exception:
return None
return None
-def get_auth_url():
- flow = get_flow()
+def get_auth_url(redirect_uri=None):
+ flow = get_flow(redirect_uri)
return flow.step1_get_authorize_url()
-def store_youtube_credential(code):
- flow = get_flow()
+def store_youtube_credential(code, redirect_uri=None):
+ flow = get_flow(redirect_uri)
storage = get_storage()
credential = flow.step2_exchange(code, http=None)
storage.put(credential)
- # credential.set_store(storage)
def resumable_upload(insert_request):
response = None
- error = None
- retry = 0
-
while response is None:
try:
- print("Sending video file...")
status, response = insert_request.next_chunk()
if response is not None:
return response
except Exception as e:
- print(e)
return {'error': str(e)}
return {'error': 'something went wrong'}
@@ -93,7 +86,7 @@ def upload_video(service, options):
categoryId=27
),
status=dict(
- privacyStatus='public'
+ privacyStatus=options.get('privacyStatus', 'public')
)
)
insert_request = service.videos().insert(
@@ -106,14 +99,10 @@ def upload_video(service, options):
)
)
response = resumable_upload(insert_request)
-
- if 'id' in response:
- return response['id']
+ return response
except Exception as e:
- print(e)
-
- return None
+ return {'error': str(e)}
def create_playlist(service, title, description):
@@ -134,8 +123,8 @@ def create_playlist(service, title, description):
if playlist and 'id' in playlist:
return playlist['id']
- except Exception as e:
- print(e)
+ except Exception:
+ pass
return None
diff --git a/youtube/forms.py b/youtube/forms.py
index bea5a1cbd..9f103de0f 100644
--- a/youtube/forms.py
+++ b/youtube/forms.py
@@ -86,3 +86,78 @@ def __init__(self, *args, **kwargs):
else:
self.fields['tutorial_name'].choices = [('', 'Select Tutorial'), ]
self.fields['tutorial_name'].widget.attrs = {'disabled': 'disabled'}
+
+
+class YouTubeUploadForm(forms.Form):
+ """Form for uploading YouTube videos with cascading dropdowns"""
+
+ foss_category = forms.ModelChoiceField(
+ queryset=FossCategory.objects.all().order_by('foss'),
+ empty_label='-- Select FOSS Category --',
+ required=True,
+ error_messages={'required': 'FOSS category field is required.'},
+ widget=forms.Select(attrs={
+ 'class': 'form-control'
+ })
+ )
+
+ language = forms.ModelChoiceField(
+ queryset=Language.objects.all().order_by('name'),
+ empty_label='-- Select Language --',
+ required=True,
+ error_messages={'required': 'Language field is required.'},
+ widget=forms.Select(attrs={
+ 'class': 'form-control'
+ })
+ )
+
+ tutorial = forms.ModelChoiceField(
+ queryset=TutorialResource.objects.none(),
+ empty_label='-- Select Tutorial --',
+ required=True,
+ error_messages={'required': 'Tutorial field is required.'},
+ widget=forms.Select(attrs={
+ 'class': 'form-control',
+ 'disabled': 'disabled'
+ })
+ )
+
+ title = forms.CharField(
+ max_length=200,
+ required=True,
+ error_messages={'required': 'Title field is required.'},
+ widget=forms.TextInput(attrs={
+ 'class': 'form-control',
+ 'placeholder': 'Auto-generated title'
+ })
+ )
+
+ description = forms.CharField(
+ required=True,
+ error_messages={'required': 'Description field is required.'},
+ widget=forms.Textarea(attrs={
+ 'class': 'form-control',
+ 'placeholder': 'Auto-filled from outline',
+ 'rows': 5
+ })
+ )
+
+ privacy_status = forms.ChoiceField(
+ choices=[
+ ('public', 'Public'),
+ ('unlisted', 'Unlisted'),
+ ('private', 'Private'),
+ ],
+ required=True,
+ error_messages={'required': 'Privacy status field is required.'},
+ widget=forms.Select(attrs={
+ 'class': 'form-control'
+ })
+ )
+
+ def __init__(self, *args, **kwargs):
+ super(YouTubeUploadForm, self).__init__(*args, **kwargs)
+ if self.data.get('tutorial'):
+ self.fields['tutorial'].queryset = TutorialResource.objects.all()
+ self.fields['tutorial'].widget.attrs.pop('disabled', None)
+
diff --git a/youtube/tasks.py b/youtube/tasks.py
index be7b5cbce..b37f99c87 100644
--- a/youtube/tasks.py
+++ b/youtube/tasks.py
@@ -50,7 +50,8 @@ def upload_videos():
# try:
print(('uploading video', tresource.id, '-', tresource.tutorial_detail.tutorial, '-', tresource.tutorial_detail.foss.foss, '-', tresource.language.name))
- video_id = upload_video(service, options)
+ response = upload_video(service, options)
+ video_id = response.get('id') if response else None
# except:
# video_id = None
print(('video id -', video_id))
diff --git a/youtube/urls.py b/youtube/urls.py
index fe98cf676..9caed48ff 100644
--- a/youtube/urls.py
+++ b/youtube/urls.py
@@ -1,13 +1,16 @@
# Third Party Stuff
from django.conf.urls import url
from youtube.views import *
+from youtube.ajax import *
app_name = 'youtube'
urlpatterns = [ # noqa
url(r'^$', home, name="home"),
+ url(r'^add-video/$', add_youtube_video, name="add_youtube_video"),
url(r'^delete-videos/$', delete_all_videos, name="delete_all_videos"),
url(r'^remove-youtube-video/$', remove_youtube_video, name="remove_youtube_video"),
url(r'^remove-video-entry/(\d+)/(\d+)/$', remove_video_entry, name="remove_video_entry"),
url(r'^ajax-foss-based-language-tutorial/$', ajax_foss_based_language_tutorial,
name="ajax_foss_based_language_tutorial"),
+ url(r'^ajax/get-uploadable-tutorials/$', get_uploadable_tutorials, name="get_uploadable_tutorials"),
url(r'^oauth2callback/$', auth_return, name="auth_return"),
]
diff --git a/youtube/utils.py b/youtube/utils.py
index 29a1a7c7a..3e42cdeb8 100644
--- a/youtube/utils.py
+++ b/youtube/utils.py
@@ -3,6 +3,16 @@
import os
import subprocess
+# Third Party Stuff
+from django.conf import settings
+from spoken import config
+
+
+def user_can_upload_to_youtube(user):
+ if not user.is_authenticated:
+ return False
+ return user.groups.filter(name__in=config.YOUTUBE_UPLOAD_ROLES).exists()
+
def convert_tmp_video(src_path, dst_path):
stdout = None
diff --git a/youtube/views.py b/youtube/views.py
index 74746cf01..1fe7cc406 100644
--- a/youtube/views.py
+++ b/youtube/views.py
@@ -5,12 +5,14 @@
# Third Party Stuff
from django.conf import settings
+from django.urls import reverse
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import render
+from django.utils.safestring import mark_safe
#from oauth2client import xsrfutil
# Spoken Tutorial Stuff
@@ -19,29 +21,121 @@
from youtube.ajax import *
from youtube.core import *
from youtube.forms import *
+from youtube.utils import user_can_upload_to_youtube
YOUTUBE_UPLOAD_SCOPE = ["https://www.googleapis.com/auth/youtube.upload", "https://www.googleapis.com/auth/youtube"]
@login_required
+def add_youtube_video(request):
+ """View to upload videos to YouTube via cascading dropdowns."""
+ if not user_can_upload_to_youtube(request.user):
+ return HttpResponseForbidden("You are not authorized to upload videos.")
+
+ context = {}
+ template = 'youtube/templates/add_youtube_video.html'
+
+ if request.method == 'GET':
+ context['form'] = YouTubeUploadForm()
+
+ elif request.method == 'POST':
+ form = YouTubeUploadForm(request.POST)
+ context['form'] = form
+
+ if form.is_valid():
+ try:
+ tutorial_resource_id = form.cleaned_data['tutorial'].id
+ title = form.cleaned_data['title']
+ description = form.cleaned_data['description']
+ privacy = form.cleaned_data['privacy_status']
+
+ # Get Resource and File Path
+ resource = TutorialResource.objects.select_related('tutorial_detail__foss').get(id=tutorial_resource_id)
+ foss_id = resource.tutorial_detail.foss.id
+ detail_id = resource.tutorial_detail.id
+ video_dir = os.path.join(settings.MEDIA_ROOT, 'videos', str(foss_id), str(detail_id))
+
+ if not os.path.isdir(video_dir):
+ raise Exception("Video directory not found: {}".format(video_dir))
+
+ # Find valid video file
+ video_file = None
+ for f in os.listdir(video_dir):
+ if f.lower().endswith(('.mp4', '.ogv', '.mov', '.avi')):
+ video_file = os.path.join(video_dir, f)
+ break
+
+ if not video_file:
+ raise Exception("No video file found in {}".format(video_dir))
+
+ # Authenticate
+ service = get_youtube_credential()
+ if not service:
+ redirect_uri = request.build_absolute_uri(reverse('youtube:auth_return'))
+ auth_url = get_auth_url(redirect_uri)
+ messages.error(request, mark_safe(f'YouTube credentials not found.
Click here to authorize.'))
+ return render(request, template, context)
+
+ # Upload
+ options = {
+ 'file': video_file,
+ 'title': title,
+ 'description': description,
+ 'privacyStatus': privacy,
+ 'tags': []
+ }
+
+ result = upload_video(service, options)
+
+ if not result:
+ raise Exception("Upload failed: No response from upload service.")
+
+ if 'error' in result:
+ raise Exception(f"YouTube API Error: {result['error']}")
+
+ if 'id' not in result:
+ if isinstance(result, str):
+ resource.video_id = result
+ else:
+ raise Exception(f"Unexpected API response: {result}")
+ else:
+ resource.video_id = result['id']
+
+ # Success
+ resource.is_on_youtube = True
+ resource.save()
+
+ messages.success(request, f"Successfully uploaded '{title}' to YouTube! (Video ID: {resource.video_id})")
+ return HttpResponseRedirect('/software-training/')
+
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ messages.error(request, f"Upload Failed: {str(e)}")
+ else:
+ messages.error(request, "Please correct the errors below.")
+
+ return render(request, template, context)
+
def home(request):
return HttpResponse('YouTube API V3 Implementation')
def auth_return(request):
- # print request.REQUEST['state']
- # if not xsrfutil.validate_token(settings.SECRET_KEY, request.REQUEST['state'], 0):
- # return HttpResponseForbidden('Access Denied!')
-
+ """Callback for Google OAuth2."""
code = request.GET.get('code', '')
error = request.GET.get('error', 'Something went wrong!')
if code:
try:
- store_youtube_credential(code)
- return HttpResponse('Youtube auth token updated successfully!')
+ redirect_uri = request.build_absolute_uri(reverse('youtube:auth_return'))
+ store_youtube_credential(code, redirect_uri)
+ messages.success(request, 'YouTube credentials saved successfully!')
+ return HttpResponseRedirect(reverse('youtube:add_youtube_video'))
except Exception as e:
error = str(e)
+ messages.error(request, 'Failed to save credentials: ' + error)
+ return HttpResponseRedirect(reverse('youtube:add_youtube_video'))
return HttpResponse(error)
@@ -127,3 +221,4 @@ def remove_video_entry(request, tdid, lgid):
except:
messages.error(request, 'Invalid tutorial id ...')
return HttpResponseRedirect('/youtube/remove-youtube-video/')
+
diff --git a/youtube_auth_setup_screenshots/client-secret.png b/youtube_auth_setup_screenshots/client-secret.png
new file mode 100644
index 000000000..eaa48d125
Binary files /dev/null and b/youtube_auth_setup_screenshots/client-secret.png differ
diff --git a/youtube_auth_setup_screenshots/create project.png b/youtube_auth_setup_screenshots/create project.png
new file mode 100644
index 000000000..0f7918c98
Binary files /dev/null and b/youtube_auth_setup_screenshots/create project.png differ
diff --git a/youtube_auth_setup_screenshots/enable-youtube-api.png b/youtube_auth_setup_screenshots/enable-youtube-api.png
new file mode 100644
index 000000000..1aacbebb2
Binary files /dev/null and b/youtube_auth_setup_screenshots/enable-youtube-api.png differ
diff --git a/youtube_auth_setup_screenshots/oauth-client.png b/youtube_auth_setup_screenshots/oauth-client.png
new file mode 100644
index 000000000..084dce84b
Binary files /dev/null and b/youtube_auth_setup_screenshots/oauth-client.png differ
diff --git a/youtube_auth_setup_screenshots/oauth-consent.png b/youtube_auth_setup_screenshots/oauth-consent.png
new file mode 100644
index 000000000..193380c44
Binary files /dev/null and b/youtube_auth_setup_screenshots/oauth-consent.png differ
diff --git a/youtube_auth_setup_screenshots/test-users.png b/youtube_auth_setup_screenshots/test-users.png
new file mode 100644
index 000000000..62abae13c
Binary files /dev/null and b/youtube_auth_setup_screenshots/test-users.png differ