From 5ebea382200976a6ea56ec257c4445b5fba7fbdc Mon Sep 17 00:00:00 2001 From: Brad Buran Date: Wed, 30 Apr 2014 22:20:54 -0400 Subject: [PATCH] Updated video template tag to accept kwargs Some video backends support optional arguments embedded in the URL query string that adjust how the video is displayed (e.g. Youtube supports rel=0 which means don't show related videos). Updated the template tag to accept optional KEY=VALUE pairs that will be added onto the embedded URL as a query string. Also added support for specifying default query strings on a per-backend basis (e.g. so that all YouTube urls have ?rel=0 added to the end). Added appropriate tests and documentation. --- docs/api/embed_video.settings.rst | 24 ++++ docs/examples.rst | 28 +++++ embed_video/backends.py | 28 ++++- embed_video/templatetags/embed_video_tags.py | 74 +++++++++---- embed_video/tests/tests_tags.py | 110 ++++++++++++++++++- 5 files changed, 240 insertions(+), 24 deletions(-) diff --git a/docs/api/embed_video.settings.rst b/docs/api/embed_video.settings.rst index d071e55..c796744 100644 --- a/docs/api/embed_video.settings.rst +++ b/docs/api/embed_video.settings.rst @@ -25,3 +25,27 @@ EMBED_VIDEO_TIMEOUT Sets timeout for ``GET`` requests to remote servers. Default: ``10`` + + +.. setting:: EMBED_VIDEO_{BACKEND}_QUERY + +EMBED_VIDEO_{BACKEND}_QUERY +--------------------------- + +Set default query dictionary for including in the embedded URL. This is +backend-specific. Substitute the actual name of the backend for {BACKEND} +(i.e. to set the default query for the YouTube backend, the setting name would +be ``EMBED_VIDEO_YOUTUBE_QUERY`` and for SoundCloud the name would be +``EMBED_VIDEO_SOUNDCLOUD_QUERY``). + +As an example, if you set the following:: + + EMBED_VIDEO_YOUTUBE_QUERY = { + 'wmode': 'opaque', + 'rel': '0' + } + +All URLs for YouTube will include the query string ``?wmode=opaque&rel=0``. + +The default value for EMBED_VIDEO_YOUTUBE_QUERY is ``{'wmode': 'opaque'}``. +None of the other backends have default values set. diff --git a/docs/examples.rst b/docs/examples.rst index 48fde56..2431d2d 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -48,6 +48,34 @@ Default sizes are ``tiny`` (420x315), ``small`` (480x360), ``medium`` (640x480), {% video my_video '100% x 50%' %} +Some backends (e.g. YouTube) allow configuration of the embedding via passing +query parameters. To specify the parameters: + +:: + + {% video item.video 'small' rel=0 %} + + {% video item.video 'small' start=2 stop=5 repeat=1 %} + + {% video item.video rel=0 as my_video %} + URL: {{ my_video.url }} + Thumbnail: {{ my_video.thumbnail }} + Backend: {{ my_video.backend }} + {% video my_video 'small' %} + {% endvideo %} + +Parameters may also be template variables: + +:: + + {% video item.video 'small' start=item.start stop=item.stop repeat=item.repeat %} + + +.. tip:: + + You can provide default values for the query string that's included in the + embedded URL by updating your Django settings file. + .. tip:: diff --git a/embed_video/backends.py b/embed_video/backends.py index 499bd37..c47cbb3 100644 --- a/embed_video/backends.py +++ b/embed_video/backends.py @@ -3,13 +3,18 @@ import requests import json try: + # Python <= 2.7 import urlparse + from urllib import urlencode except ImportError: # support for py3 import urllib.parse as urlparse + from urllib.parse import urlencode +from django.conf import settings from django.template.loader import render_to_string from django.utils.functional import cached_property +from django.utils.safestring import mark_safe from .utils import import_by_path from .settings import EMBED_VIDEO_BACKENDS, EMBED_VIDEO_TIMEOUT @@ -106,7 +111,7 @@ class VideoBackend(object): ``{{ width }}``, ``{{ height }}`` """ - def __init__(self, url, is_secure=False): + def __init__(self, url, is_secure=False, query=None): """ First it tries to load data from cache and if it don't succeed, run :py:meth:`init` and then save it to cache. @@ -114,6 +119,12 @@ class VideoBackend(object): self.is_secure = is_secure self.backend = self.__class__.__name__ self._url = url + self.update_query(query) + + def update_query(self, query=None): + self._query = self.get_default_query() + if query is not None: + self._query.update(query) @cached_property def code(self): @@ -155,7 +166,10 @@ class VideoBackend(object): """ Returns URL folded from :py:data:`pattern_url` and parsed code. """ - return self.pattern_url.format(code=self.code, protocol=self.protocol) + url = self.pattern_url.format(code=self.code, protocol=self.protocol) + if self._query: + url += '?' + urlencode(self._query, doseq=True) + return mark_safe(url) def get_thumbnail_url(self): """ @@ -178,6 +192,13 @@ class VideoBackend(object): def get_info(self): raise NotImplementedError + def get_default_query(self): + # Derive backend name from class name + backend_name = self.__class__.__name__[:-7].upper() + default = getattr(self, 'default_query', {}) + settings_key = 'EMBED_VIDEO_{}_QUERY'.format(backend_name) + return getattr(settings, settings_key, default).copy() + class YoutubeBackend(VideoBackend): """ @@ -200,8 +221,9 @@ class YoutubeBackend(VideoBackend): re.I | re.X ) - pattern_url = '{protocol}://www.youtube.com/embed/{code}?wmode=opaque' + pattern_url = '{protocol}://www.youtube.com/embed/{code}' pattern_thumbnail_url = '{protocol}://img.youtube.com/vi/{code}/hqdefault.jpg' + default_query = {'wmode': 'opaque'} def get_code(self): code = super(YoutubeBackend, self).get_code() diff --git a/embed_video/templatetags/embed_video_tags.py b/embed_video/templatetags/embed_video_tags.py index 492c816..3a1c153 100644 --- a/embed_video/templatetags/embed_video_tags.py +++ b/embed_video/templatetags/embed_video_tags.py @@ -1,8 +1,10 @@ -from django.template import Library, Node, TemplateSyntaxError +from django.template import Library, Node, TemplateSyntaxError, Variable from django.utils.safestring import mark_safe +from django.utils.encoding import smart_str import re import logging import requests +from collections import defaultdict from ..backends import detect_backend, VideoBackend, \ VideoDoesntExistException, UnknownBackendException @@ -11,6 +13,9 @@ register = Library() logger = logging.getLogger(__name__) +# Used for parsing keyword arguments passed in as key-value pairs +kw_pat = re.compile(r'^(?P[\w]+)=(?P.+)$') + @register.tag('video') class VideoNode(Node): @@ -47,7 +52,8 @@ class VideoNode(Node): {% endvideo %} """ - error_msg = 'Syntax error. Expected: ``{% video URL ... %}``' + error_msg = 'Syntax error. Expected: ``{% video URL ' \ + '[size] [key1=val1 key2=val2 ...] [as var] %}``' default_size = 'small' re_size = re.compile('(?P\d+%?) *x *(?P\d+%?)') @@ -55,29 +61,57 @@ class VideoNode(Node): def __init__(self, parser, token): self.size = None self.bits = token.split_contents() + self.query = None try: self.url = parser.compile_filter(self.bits[1]) except IndexError: raise TemplateSyntaxError(self.error_msg) + # Determine if the tag is being used as a context variable if self.bits[-2] == 'as': + option_bits = self.bits[2:-2] self.nodelist_file = parser.parse(('endvideo',)) parser.delete_first_token() else: - try: - self.size = parser.compile_filter(self.bits[2]) - except IndexError: - self.size = self.default_size + option_bits = self.bits[2:] + + # Size must be the first argument and is only accepted when this is + # used as a template tag (but not when used as a block tag) + if len(option_bits) != 0 and '=' not in option_bits[0]: + self.size = parser.compile_filter(option_bits[0]) + option_bits = option_bits[1:] + else: + self.size= self.default_size + + # Parse arguments passed in as KEY=VALUE pairs that will be added to + # the URL as a GET query string + if len(option_bits) != 0: + self.query = defaultdict(list) + + for bit in option_bits: + match = kw_pat.match(bit) + key = smart_str(match.group('key')) + value = Variable(smart_str(match.group('value'))) + self.query[key].append(value) def render(self, context): - url = self.url.resolve(context) + # Attempt to resolve any parameters passed in. + if self.query is not None: + resolved_query = defaultdict(list) + for key, values in self.query.items(): + for value in values: + resolved_value = value.resolve(context) + resolved_query[key].append(resolved_value) + else: + resolved_query = None + url = self.url.resolve(context) try: if self.size: - return self.__render_embed(url, context) + return self.__render_embed(url, context, resolved_query) else: - return self.__render_block(url, context) + return self.__render_block(url, context, resolved_query) except requests.Timeout: logger.exception('Timeout reached during rendering embed video (`{0}`)'.format(url)) except UnknownBackendException: @@ -87,44 +121,46 @@ class VideoNode(Node): return '' - def __render_embed(self, url, context): + def __render_embed(self, url, context, query): size = self.size.resolve(context) \ if hasattr(self.size, 'resolve') else self.size - return self.embed(url, size, context=context) + return self.embed(url, size, context=context, query=query) - def __render_block(self, url, context): + def __render_block(self, url, context, query): as_var = self.bits[-1] context.push() - context[as_var] = self.get_backend(url, context=context) + context[as_var] = self.get_backend(url, context=context, query=query) output = self.nodelist_file.render(context) context.pop() return output @staticmethod - def get_backend(backend_or_url, context=None): + def get_backend(backend_or_url, context=None, query=None): """ Returns instance of VideoBackend. If context is passed to the method and request is secure, than the is_secure mark is set to backend. - + A string or VideoBackend instance can be passed to the method. """ - + backend = backend_or_url if isinstance(backend_or_url, VideoBackend) \ else detect_backend(backend_or_url) - + if context and 'request' in context: backend.is_secure = context['request'].is_secure() + backend.update_query(query) + return backend @staticmethod - def embed(url, size, context=None): + def embed(url, size, GET=None, context=None, query=None): """ Direct render of embed video. """ - backend = VideoNode.get_backend(url, context=context) + backend = VideoNode.get_backend(url, context=context, query=query) width, height = VideoNode.get_size(size) return mark_safe(backend.get_embed_code(width=width, height=height)) diff --git a/embed_video/tests/tests_tags.py b/embed_video/tests/tests_tags.py index e97eaac..86e9e87 100644 --- a/embed_video/tests/tests_tags.py +++ b/embed_video/tests/tests_tags.py @@ -1,15 +1,26 @@ from unittest import TestCase from django.test.client import RequestFactory from mock import Mock, patch +import re + +try: + # Python <= 2.7 + import urlparse +except ImportError: + # Python 3 + import urllib.parse as urlparse from django.template import TemplateSyntaxError from django.http import HttpRequest from django.template.base import Template from django.template.context import RequestContext +from django.test.utils import override_settings from testfixtures import LogCapture from embed_video.templatetags.embed_video_tags import VideoNode +URL_PATTERN = re.compile(r'src="?\'?([^"\'>]*)"') + class EmbedVideoNodeTestCase(TestCase): def setUp(self): @@ -17,8 +28,8 @@ class EmbedVideoNodeTestCase(TestCase): self.token = Mock(methods=['split_contents']) @staticmethod - def _grc(): - return RequestContext(HttpRequest()) + def _grc(context=None): + return RequestContext(HttpRequest(), context) def test_embed(self): template = Template(""" @@ -212,3 +223,98 @@ class EmbedVideoNodeTestCase(TestCase): {% video "https://soundcloud.com/xyz/foo" %} """) self.assertEqual(template.render(self._grc()).strip(), '') + + ########################################################################## + # Tests for adding GET query via KEY=VALUE pairs + ########################################################################## + + def _validate_GET_query(self, rendered, expected): + # Use this functioon to test the KEY=VALUE optional arguments to the + # templatetag because there's no guarantee that they will be appended + # to the URL query string in a particular order. By default the + # YouTube backend adds wmode=opaque to the query string, so be sure to + # include that in the expected querystring dict. + url = URL_PATTERN.search(rendered).group(1) + result = urlparse.urlparse(url) + qs = urlparse.parse_qs(result[4]) + self.assertEqual(qs, expected) + + @override_settings(EMBED_VIDEO_YOUTUBE_QUERY={'rel': 0, 'stop': 5}) + def test_embed_with_query_settings_override(self): + # Test KEY=VALUE argument with default values provided by settings + template = Template(""" + {% load embed_video_tags %} + {% video 'http://www.youtube.com/watch?v=jsrRJyHBvzw' %} + """) + expected = { + 'rel': ['0'], + 'stop': ['5'], + } + rendered = template.render(self._grc()) + self._validate_GET_query(rendered, expected) + + def test_embed_with_query_var(self): + # Test KEY=VALUE argument with the value resolving to a context + # variable. + template = Template(""" + {% load embed_video_tags %} + {% video 'http://www.youtube.com/watch?v=jsrRJyHBvzw' rel=show_related loop=5 %} + """) + expected = { + 'rel': ['0'], + 'loop': ['5'], + 'wmode': ['opaque'] + } + context = {'show_related': 0} + rendered = template.render(self._grc(context)) + self._validate_GET_query(rendered, expected) + + def test_embed_with_query_multiple(self): + # Test multiple KEY=VALUE arguments + template = Template(""" + {% load embed_video_tags %} + {% video 'http://www.youtube.com/watch?v=jsrRJyHBvzw' rel=0 loop=1 end=5 %} + """) + expected = { + 'rel': ['0'], + 'loop': ['1'], + 'end': ['5'], + 'wmode': ['opaque'] + } + self._validate_GET_query(template.render(self._grc()), expected) + + def test_embed_with_query_multiple_list(self): + # Test multiple KEY=VALUE arguments where the key is repeated multiple + # times (this is valid in a URL query). + template = Template(""" + {% load embed_video_tags %} + {% video 'http://www.youtube.com/watch?v=jsrRJyHBvzw' rel=0 loop=1 end=5 end=6 %} + """) + expected = { + 'rel': ['0'], + 'loop': ['1'], + 'end': ['5', '6'], + 'wmode': ['opaque'] + } + self._validate_GET_query(template.render(self._grc()), expected) + + def test_tag_youtube_with_query(self): + # Test KEY=VALUE arguments when used as a tag block. + template = Template(""" + {% load embed_video_tags %} + {% video 'http://www.youtube.com/watch?v=jsrRJyHBvzw' rel=0 as ytb %} + {{ ytb.url }} + {% endvideo %} + """) + rendered = 'http://www.youtube.com/embed/jsrRJyHBvzw?wmode=opaque&rel=0' + self.assertEqual(template.render(self._grc()).strip(), rendered) + + def test_embed_with_query_rel(self): + template = Template(""" + {% load embed_video_tags %} + {% video 'http://www.youtube.com/watch?v=jsrRJyHBvzw' rel=0 %} + """) + rendered = u'''''' + self.assertEqual(template.render(self._grc()).strip(), rendered) +