Skip to content

Commit

Permalink
Merge pull request #71 from edx/mushtaq/video-thumbnail
Browse files Browse the repository at this point in the history
Add course video image upload support
  • Loading branch information
muhammad-ammar committed May 19, 2017
2 parents 82d5a13 + 3e1133f commit 5ea408a
Show file tree
Hide file tree
Showing 17 changed files with 629 additions and 100 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,5 @@ logs/*/*.log*
.vagrant

venv/

video-images/
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
Christopher Lee <clee@edx.org>
Mushtaq Ali <mushtaak@gmail.com>
Muhammad Ammar <mammar@gmail.com>
12 changes: 11 additions & 1 deletion edxval/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,26 @@
"""

from django.contrib import admin
from .models import Video, Profile, EncodedVideo, Subtitle, CourseVideo
from .models import Video, Profile, EncodedVideo, Subtitle, CourseVideo, VideoImage


class ProfileAdmin(admin.ModelAdmin): # pylint: disable=C0111
list_display = ('id', 'profile_name')
list_display_links = ('id', 'profile_name')
admin_order_field = 'profile_name'


class EncodedVideoInline(admin.TabularInline): # pylint: disable=C0111
model = EncodedVideo


class CourseVideoInline(admin.TabularInline): # pylint: disable=C0111
model = CourseVideo
extra = 0
verbose_name = "Course"
verbose_name_plural = "Courses"


class VideoAdmin(admin.ModelAdmin): # pylint: disable=C0111
list_display = (
'id', 'edx_video_id', 'client_video_id', 'duration', 'created', 'status'
Expand All @@ -30,6 +33,13 @@ class VideoAdmin(admin.ModelAdmin): # pylint: disable=C0111
admin_order_field = 'edx_video_id'
inlines = [CourseVideoInline, EncodedVideoInline]


class VideoImageAdmin(admin.ModelAdmin):
model = VideoImage
verbose_name = 'Video Image'
verbose_name_plural = 'Video Images'

admin.site.register(Profile, ProfileAdmin)
admin.site.register(Video, VideoAdmin)
admin.site.register(Subtitle)
admin.site.register(VideoImage, VideoImageAdmin)
158 changes: 89 additions & 69 deletions edxval/api.py
Original file line number Diff line number Diff line change
@@ -1,69 +1,29 @@
# pylint: disable=E1101
# -*- coding: utf-8 -*-
"""
The internal API for VAL. This is not yet stable
The internal API for VAL.
"""
import logging

from lxml.etree import Element, SubElement
from enum import Enum

from django.core.exceptions import ValidationError
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.core.files.base import ContentFile

from edxval.models import Video, EncodedVideo, CourseVideo, Profile
from edxval.models import Video, EncodedVideo, CourseVideo, Profile, VideoImage
from edxval.serializers import VideoSerializer
from edxval.exceptions import ( # pylint: disable=unused-import
ValError,
ValInternalError,
ValVideoNotFoundError,
ValCannotCreateError,
ValCannotUpdateError
)

logger = logging.getLogger(__name__) # pylint: disable=C0103


class ValError(Exception):
"""
An error that occurs during VAL actions.
This error is raised when the VAL API cannot perform a requested
action.
"""
pass


class ValInternalError(ValError):
"""
An error internal to the VAL API has occurred.
This error is raised when an error occurs that is not caused by incorrect
use of the API, but rather internal implementation of the underlying
services.
"""
pass


class ValVideoNotFoundError(ValError):
"""
This error is raised when a video is not found
If a state is specified in a call to the API that results in no matching
entry in database, this error may be raised.
"""
pass


class ValCannotCreateError(ValError):
"""
This error is raised when an object cannot be created
"""
pass


class ValCannotUpdateError(ValError):
"""
This error is raised when an object cannot be updated
"""
pass


class VideoSortField(Enum):
"""An enum representing sortable fields in the Video model"""
created = "created"
Expand Down Expand Up @@ -101,6 +61,7 @@ def create_video(video_data):
file_size: size of the video in bytes
profile: ID of the profile
courses: Courses associated with this video
image: poster image file name for a particular course
}
Raises:
Expand Down Expand Up @@ -182,6 +143,51 @@ def update_video_status(edx_video_id, status):
video.save()


def get_course_video_image_url(course_id, edx_video_id):
"""
Returns course video image url or None if no image found
"""
try:
video_image = CourseVideo.objects.select_related('video_image').get(
course_id=course_id, video__edx_video_id=edx_video_id
).video_image
return video_image.image_url()
except ObjectDoesNotExist:
return None


def update_video_image(edx_video_id, course_id, image_data, file_name):
"""
Update video image for an existing video.
NOTE: If `image_data` is None then `file_name` value will be used as it is, otherwise
a new file name is constructed based on uuid and extension from `file_name` value.
`image_data` will be None in case of course re-run and export.
Arguments:
image_data (InMemoryUploadedFile): Image data to be saved for a course video.
Returns:
course video image url
Raises:
Raises ValVideoNotFoundError if the CourseVideo cannot be retrieved.
"""
try:
course_video = CourseVideo.objects.select_related('video').get(
course_id=course_id, video__edx_video_id=edx_video_id
)
except ObjectDoesNotExist:
error_message = u'VAL: CourseVideo not found for edx_video_id: {0} and course_id: {1}'.format(
edx_video_id,
course_id
)
raise ValVideoNotFoundError(error_message)

video_image, _ = VideoImage.create_or_update(course_video, file_name, image_data)
return video_image.image_url()


def create_profile(profile_name):
"""
Used to create Profile objects in the database
Expand Down Expand Up @@ -314,11 +320,7 @@ def get_url_for_profile(edx_video_id, profile):
return get_urls_for_profiles(edx_video_id, [profile])[profile]


def _get_videos_for_filter(
video_filter,
sort_field=None,
sort_dir=SortDirection.asc
):
def _get_videos_for_filter(video_filter, sort_field=None, sort_dir=SortDirection.asc):
"""
Returns a generator expression that contains the videos found, sorted by
the given field and direction, with ties broken by edx_video_id to ensure a
Expand All @@ -333,11 +335,7 @@ def _get_videos_for_filter(
return (VideoSerializer(video).data for video in videos)


def get_videos_for_course(
course_id,
sort_field=None,
sort_dir=SortDirection.asc,
):
def get_videos_for_course(course_id, sort_field=None, sort_dir=SortDirection.asc):
"""
Returns an iterator of videos for the given course id.
Expand All @@ -352,7 +350,7 @@ def get_videos_for_course(
total order.
"""
return _get_videos_for_filter(
{"courses__course_id": unicode(course_id), "courses__is_hidden": False},
{'courses__course_id': unicode(course_id), 'courses__is_hidden': False},
sort_field,
sort_dir,
)
Expand Down Expand Up @@ -490,34 +488,51 @@ def copy_course_videos(source_course_id, destination_course_id):
if source_course_id == destination_course_id:
return

videos = Video.objects.filter(courses__course_id=unicode(source_course_id))
course_videos = CourseVideo.objects.select_related('video', 'video_image').filter(
course_id=unicode(source_course_id)
)

for video in videos:
CourseVideo.objects.get_or_create(
video=video,
for course_video in course_videos:
destination_course_video, __ = CourseVideo.objects.get_or_create(
video=course_video.video,
course_id=destination_course_id
)
if hasattr(course_video, 'video_image'):
VideoImage.create_or_update(
course_video=destination_course_video,
file_name=course_video.video_image.image.name
)


def export_to_xml(edx_video_id):
def export_to_xml(edx_video_id, course_id=None):
"""
Exports data about the given edx_video_id into the given xml object.
Args:
edx_video_id (str): The ID of the video to export
course_id (str): The ID of the course with which this video is associated
Returns:
An lxml video_asset element containing export data
Raises:
ValVideoNotFoundError: if the video does not exist
"""
video_image_name = ''
video = _get_video(edx_video_id)

try:
course_video = CourseVideo.objects.select_related('video_image').get(course_id=course_id, video=video)
video_image_name = course_video.video_image.image.name
except ObjectDoesNotExist:
pass

video_el = Element(
'video_asset',
attrib={
'client_video_id': video.client_video_id,
'duration': unicode(video.duration),
'image': video_image_name
}
)
for encoded_video in video.encoded_videos.all():
Expand Down Expand Up @@ -562,7 +577,12 @@ def import_from_xml(xml, edx_video_id, course_id=None):
course_id,
)
if course_id:
CourseVideo.get_or_create_with_validation(video=video, course_id=course_id)
course_video, __ = CourseVideo.get_or_create_with_validation(video=video, course_id=course_id)

image_file_name = xml.get('image', '').strip()
if image_file_name:
VideoImage.create_or_update(course_video, image_file_name)

return
except ValidationError as err:
logger.exception(err.message)
Expand All @@ -577,7 +597,7 @@ def import_from_xml(xml, edx_video_id, course_id=None):
'duration': xml.get('duration'),
'status': 'imported',
'encoded_videos': [],
'courses': [course_id] if course_id else [],
'courses': [{course_id: xml.get('image')}] if course_id else [],
}
for encoded_video_el in xml.iterfind('encoded_video'):
profile_name = encoded_video_el.get('profile')
Expand Down
50 changes: 50 additions & 0 deletions edxval/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
VAL Exceptions.
"""

class ValError(Exception):
"""
An error that occurs during VAL actions.
This error is raised when the VAL API cannot perform a requested
action.
"""
pass


class ValInternalError(ValError):
"""
An error internal to the VAL API has occurred.
This error is raised when an error occurs that is not caused by incorrect
use of the API, but rather internal implementation of the underlying
services.
"""
pass


class ValVideoNotFoundError(ValError):
"""
This error is raised when a video is not found
If a state is specified in a call to the API that results in no matching
entry in database, this error may be raised.
"""
pass


class ValCannotCreateError(ValError):
"""
This error is raised when an object cannot be created
"""
pass


class ValCannotUpdateError(ValError):
"""
This error is raised when an object cannot be updated
"""
pass
31 changes: 31 additions & 0 deletions edxval/migrations/0005_videoimage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.db import migrations, models
import django.utils.timezone
import model_utils.fields
import edxval.models


class Migration(migrations.Migration):

dependencies = [
('edxval', '0004_data__add_hls_profile'),
]

operations = [
migrations.CreateModel(
name='VideoImage',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)),
('image', edxval.models.CustomizableImageField(null=True, blank=True)),
('generated_images', edxval.models.ListField()),
('course_video', models.OneToOneField(related_name='video_image', to='edxval.CourseVideo')),
],
options={
'abstract': False,
},
),
]
Loading

0 comments on commit 5ea408a

Please sign in to comment.