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
128 changes: 128 additions & 0 deletions YOUTUBE_OAUTH_SETUP.md
Original file line number Diff line number Diff line change
@@ -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.

---
53 changes: 52 additions & 1 deletion events/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
10 changes: 10 additions & 0 deletions static/events/templates/events_dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ <h5>Online Assessment Test</h5>
</div>
{% endif %}

{% if can_upload_youtube %}
<div class="panel panel-primary">
<div class="panel-heading panel-heading-notif">YouTube</div>
<div class="panel-body">
<ul>
<li><a href="/software-training/add-youtube-video/">Upload Video</a></li>
</ul>
</div>
</div>
{% endif %}

{% if user|is_invigilator %}
<div class="panel panel-primary">
Expand Down
55 changes: 54 additions & 1 deletion youtube/ajax.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,3 +46,56 @@ def ajax_foss_based_language_tutorial(request):
data = '<option value="">Select Language</option>' + 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')
37 changes: 13 additions & 24 deletions youtube/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')


Expand All @@ -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

Expand All @@ -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'}
Expand All @@ -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(
Expand All @@ -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):
Expand All @@ -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

Expand Down
Loading