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. + +![Create Project](youtube_auth_setup_screenshots/create%20project.png) +--- + +## 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** + +![Enable YouTube API](youtube_auth_setup_screenshots/enable-youtube-api.png) + +--- + +## 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. + + +![Consent Screen](youtube_auth_setup_screenshots/oauth-consent.png) + +If the app is in **Testing** mode, go to **Audience** and add your email under **Test Users**. + + +![Test Users](youtube_auth_setup_screenshots/test-users.png) + +--- + +## 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. + +![OAuth Client](youtube_auth_setup_screenshots/oauth-client.png) + +--- + +## 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**. + +![Client Secret](youtube_auth_setup_screenshots/client-secret.png) + +--- + +## 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 @@
Online Assessment Test
{% endif %} + {% if can_upload_youtube %} +
+
YouTube
+
+ +
+
+ {% endif %} {% if user|is_invigilator %}
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