Closes #8 ignore the above, I was talking shite.

This commit is contained in:
Fergal Moran
2013-12-20 19:36:51 +00:00
parent b7b02ace5f
commit f44d8359e1
12 changed files with 227 additions and 188 deletions

View File

@@ -0,0 +1,19 @@
from tastypie import fields
from tastypie.authentication import Authentication
from tastypie.authorization import Authorization
from tastypie.exceptions import ImmediateHttpResponse
from tastypie.http import HttpBadRequest, HttpMethodNotAllowed, HttpUnauthorized, HttpApplicationError, HttpNotImplemented
from spa.api.v1.BackboneCompatibleResource import BackboneCompatibleResource
from spa.models import Mix, UserProfile, Genre
from spa.models.comment import Comment
class GenreResource(BackboneCompatibleResource):
class Meta:
queryset = Genre.objects.all().order_by('text')
resource_name = 'genres'
excludes = ['id', 'resource_uri']
authorization = Authorization()
authentication = Authentication()
always_return_data = True

View File

@@ -21,209 +21,206 @@ from spa.models.mix import Mix
class MixResource(BackboneCompatibleResource): class MixResource(BackboneCompatibleResource):
comments = fields.ToManyField('spa.api.v1.CommentResource.CommentResource', 'comments', null=True, full=True) comments = fields.ToManyField('spa.api.v1.CommentResource.CommentResource', 'comments', null=True, full=True)
favourites = fields.ToManyField('spa.api.v1.UserResource.UserResource', 'favourites', favourites = fields.ToManyField('spa.api.v1.UserResource.UserResource', 'favourites', related_name='favourites', full=False, null=True)
related_name='favourites', full=False, null=True) likes = fields.ToManyField('spa.api.v1.UserResource.UserResource', 'likes', related_name='likes', full=False, null=True)
genres = fields.ToManyField('spa.api.v1.GenreResource.GenreResource', 'genres', related_name='genres', full=True, null=True)
likes = fields.ToManyField('spa.api.v1.UserResource.UserResource', 'likes', class Meta:
related_name='likes', full=False, null=True) queryset = Mix.objects.filter(is_active=True)
user = ToOneField('UserResource', 'user')
always_return_data = True
detail_uri_name = 'slug'
excludes = ['is_active', 'local_file', 'waveform-generated']
post_excludes = ['comments']
filtering = {
'comments': ALL_WITH_RELATIONS,
'genres': ALL_WITH_RELATIONS,
'favourites': ALL_WITH_RELATIONS,
'likes': ALL_WITH_RELATIONS,
'slug': ALL_WITH_RELATIONS,
}
authorization = Authorization()
class Meta: def _parseGenreList(self, genres):
queryset = Mix.objects.filter(is_active=True) #for magic..
user = ToOneField('UserResource', 'user') ret = []
always_return_data = True for genre in genres:
detail_uri_name = 'slug' if genre['id'] == genre['text']:
excludes = ['is_active', 'local_file', 'waveform-generated'] new_item = Genre(description=genre['text'])
post_excludes = ['comments'] new_item.save()
filtering = { ret.append(new_item)
'comments': ALL_WITH_RELATIONS, else:
'favourites': ALL_WITH_RELATIONS, ret.append(Genre.objects.get(pk=genre['id']))
'likes': ALL_WITH_RELATIONS,
'slug': ALL_WITH_RELATIONS,
}
authorization = Authorization()
def _parseGenreList(self, genres): return ret
#for magic..
ret = []
for genre in genres:
if genre['id'] == genre['text']:
new_item = Genre(description=genre['text'])
new_item.save()
ret.append(new_item)
else:
ret.append(Genre.objects.get(pk=genre['id']))
return ret def _unpackGenreList(self, bundle, genres):
genre_list = self._parseGenreList(genres)
bundle.obj.genres = genre_list
bundle.obj.save()
def _unpackGenreList(self, bundle, genres): def prepend_urls(self):
genre_list = self._parseGenreList(genres) return [
bundle.obj.genres = genre_list url(r"^(?P<resource_name>%s)/search%s$" % (self._meta.resource_name, trailing_slash()),
bundle.obj.save() self.wrap_view('get_search'),
name="api_get_search"),
url(r"^(?P<resource_name>%s)/(?P<id>[\d]+)/$" % self._meta.resource_name, self.wrap_view('dispatch_detail'),
name="api_dispatch_detail"),
url(r"^(?P<resource_name>%s)/random/$" % self._meta.resource_name, self.wrap_view('dispatch_random'),
name="api_dispatch_random"),
url(r"^(?P<resource_name>%s)/(?P<slug>[\w\d-]+)/$" % self._meta.resource_name,
self.wrap_view('dispatch_detail'), name="api_dispatch_detail"),
url(r"^(?P<resource_name>%s)/(?P<slug>\w[\w/-]*)/comments%s$" % (
self._meta.resource_name, trailing_slash()), self.wrap_view('get_comments'), name="api_get_comments"),
url(r"^(?P<resource_name>%s)/(?P<slug>\w[\w/-]*)/activity%s$" % (
self._meta.resource_name, trailing_slash()), self.wrap_view('get_activity'), name="api_get_activity"),
]
def prepend_urls(self): def dispatch_random(self, request, **kwargs):
return [ kwargs['pk'] = self._meta.queryset.values_list('pk', flat=True).order_by('?')[0]
url(r"^(?P<resource_name>%s)/search%s$" % (self._meta.resource_name, trailing_slash()), return self.get_detail(request, **kwargs)
self.wrap_view('get_search'),
name="api_get_search"),
url(r"^(?P<resource_name>%s)/(?P<id>[\d]+)/$" % self._meta.resource_name, self.wrap_view('dispatch_detail'),
name="api_dispatch_detail"),
url(r"^(?P<resource_name>%s)/random/$" % self._meta.resource_name, self.wrap_view('dispatch_random'),
name="api_dispatch_random"),
url(r"^(?P<resource_name>%s)/(?P<slug>[\w\d-]+)/$" % self._meta.resource_name,
self.wrap_view('dispatch_detail'), name="api_dispatch_detail"),
url(r"^(?P<resource_name>%s)/(?P<slug>\w[\w/-]*)/comments%s$" % (
self._meta.resource_name, trailing_slash()), self.wrap_view('get_comments'), name="api_get_comments"),
url(r"^(?P<resource_name>%s)/(?P<slug>\w[\w/-]*)/activity%s$" % (
self._meta.resource_name, trailing_slash()), self.wrap_view('get_activity'), name="api_get_activity"),
]
def dispatch_random(self, request, **kwargs): def get_comments(self, request, **kwargs):
kwargs['pk'] = self._meta.queryset.values_list('pk', flat=True).order_by('?')[0] try:
return self.get_detail(request, **kwargs) basic_bundle = self.build_bundle(request=request)
obj = self.cached_obj_get(bundle=basic_bundle, **self.remove_api_resource_names(kwargs))
except ObjectDoesNotExist:
return HttpGone()
def get_comments(self, request, **kwargs): child_resource = CommentResource()
try: return child_resource.get_list(request, mix=obj)
basic_bundle = self.build_bundle(request=request)
obj = self.cached_obj_get(bundle=basic_bundle, **self.remove_api_resource_names(kwargs))
except ObjectDoesNotExist:
return HttpGone()
child_resource = CommentResource() def get_activity(self, request, **kwargs):
return child_resource.get_list(request, mix=obj) try:
basic_bundle = self.build_bundle(request=request)
obj = self.cached_obj_get(bundle=basic_bundle, **self.remove_api_resource_names(kwargs))
except ObjectDoesNotExist:
return HttpGone()
def get_activity(self, request, **kwargs): child_resource = ActivityResource()
try: return child_resource.get_list(request, mix=obj)
basic_bundle = self.build_bundle(request=request)
obj = self.cached_obj_get(bundle=basic_bundle, **self.remove_api_resource_names(kwargs))
except ObjectDoesNotExist:
return HttpGone()
child_resource = ActivityResource() def obj_create(self, bundle, **kwargs):
return child_resource.get_list(request, mix=obj) file_name = "mixes/%s.%s" % (bundle.data['upload-hash'], bundle.data['upload-extension'])
uid = bundle.data['upload-hash']
if 'is_featured' not in bundle.data:
bundle.data['is_featured'] = False
def obj_create(self, bundle, **kwargs): if 'download_allowed' not in bundle.data:
file_name = "mixes/%s.%s" % (bundle.data['upload-hash'], bundle.data['upload-extension']) bundle.data['download_allowed'] = False
uid = bundle.data['upload-hash']
if 'is_featured' not in bundle.data:
bundle.data['is_featured'] = False
if 'download_allowed' not in bundle.data: bundle.data['user'] = bundle.request.user.get_profile()
bundle.data['download_allowed'] = False ret = super(MixResource, self).obj_create(
bundle,
user=bundle.request.user.get_profile(),
local_file=file_name,
uid=uid)
bundle.data['user'] = bundle.request.user.get_profile() return ret
ret = super(MixResource, self).obj_create(
bundle,
user=bundle.request.user.get_profile(),
local_file=file_name,
uid=uid)
self._unpackGenreList(ret, bundle.data['genre-list']) def obj_update(self, bundle, **kwargs):
#if ret is hunky dory #don't sync the mix_image, this has to be handled separately
return ret del bundle.data['mix_image']
ret = super(MixResource, self).obj_update(bundle, bundle.request)
def obj_update(self, bundle, **kwargs): bundle.obj.update_favourite(bundle.request.user, bundle.data['favourited'])
#don't sync the mix_image, this has to be handled separately bundle.obj.update_liked(bundle.request.user, bundle.data['liked'])
del bundle.data['mix_image']
ret = super(MixResource, self).obj_update(bundle, bundle.request)
bundle.obj.update_favourite(bundle.request.user, bundle.data['favourited']) return ret
bundle.obj.update_liked(bundle.request.user, bundle.data['liked'])
self._unpackGenreList(ret, bundle.data['genre-list']) def apply_sorting(self, obj_list, options=None):
return ret orderby = options.get('order_by', '')
if orderby == 'latest':
obj_list = obj_list.order_by('-id')
elif orderby == 'toprated':
obj_list = obj_list.annotate(karma=Count('activity_likes')).order_by('-karma')
elif orderby == 'mostplayed':
obj_list = obj_list.annotate(karma=Count('activity_plays')).order_by('-karma')
elif orderby == 'mostactive':
obj_list = obj_list.annotate(karma=Count('comments')).order_by('-karma')
elif orderby == 'recommended':
obj_list = obj_list.annotate(karma=Count('activity_likes')).order_by('-karma')
def apply_sorting(self, obj_list, options=None): return obj_list
orderby = options.get('order_by', '')
if orderby == 'latest':
obj_list = obj_list.order_by('-id')
elif orderby == 'toprated':
obj_list = obj_list.annotate(karma=Count('activity_likes')).order_by('-karma')
elif orderby == 'mostplayed':
obj_list = obj_list.annotate(karma=Count('activity_plays')).order_by('-karma')
elif orderby == 'mostactive':
obj_list = obj_list.annotate(karma=Count('comments')).order_by('-karma')
elif orderby == 'recommended':
obj_list = obj_list.annotate(karma=Count('activity_likes')).order_by('-karma')
return obj_list def apply_filters(self, request, applicable_filters):
semi_filtered = super(MixResource, self) \
.apply_filters(request, applicable_filters) \
.filter(waveform_generated=True)
def apply_filters(self, request, applicable_filters): f_user = request.GET.get('user', None)
semi_filtered = super(MixResource, self) \
.apply_filters(request, applicable_filters) \
.filter(waveform_generated=True)
f_user = request.GET.get('user', None) if request.GET.get('stream'):
semi_filtered = semi_filtered.filter(user__in=request.user.get_profile().following.all())
if f_user is not None:
semi_filtered = semi_filtered.filter(user__slug=f_user)
else:
semi_filtered = semi_filtered.filter(is_featured=True)
if request.GET.get('stream'): return semi_filtered
semi_filtered = semi_filtered.filter(user__in=request.user.get_profile().following.all())
if f_user is not None:
semi_filtered = semi_filtered.filter(user__slug=f_user)
else:
semi_filtered = semi_filtered.filter(is_featured=True)
return semi_filtered def dehydrate_mix_image(self, bundle):
return bundle.obj.get_image_url(size="160x110")
def dehydrate_mix_image(self, bundle): def dehydrate(self, bundle):
return bundle.obj.get_image_url(size="160x110") bundle.data['waveform_url'] = bundle.obj.get_waveform_url()
bundle.data['user_name'] = bundle.obj.user.get_nice_name()
bundle.data['user_profile_url'] = bundle.obj.user.get_absolute_url()
bundle.data['user_profile_image'] = bundle.obj.user.get_small_profile_image()
bundle.data['item_url'] = '/mix/%s' % bundle.obj.slug
bundle.data['download_allowed'] = bundle.obj.download_allowed and \
bundle.obj.upload_date < datetime.datetime.now() - datetime.timedelta(days=1)
def dehydrate(self, bundle): bundle.data['favourite_count'] = bundle.obj.favourites.count()
bundle.data['waveform_url'] = bundle.obj.get_waveform_url()
bundle.data['user_name'] = bundle.obj.user.get_nice_name()
bundle.data['user_profile_url'] = bundle.obj.user.get_absolute_url()
bundle.data['user_profile_image'] = bundle.obj.user.get_small_profile_image()
bundle.data['item_url'] = '/mix/%s' % bundle.obj.slug
bundle.data['download_allowed'] = bundle.obj.download_allowed and \
bundle.obj.upload_date < datetime.datetime.now() - datetime.timedelta(days=1)
bundle.data['favourite_count'] = bundle.obj.favourites.count() bundle.data['play_count'] = bundle.obj.activity_plays.count()
bundle.data['download_count'] = bundle.obj.activity_downloads.count()
bundle.data['like_count'] = bundle.obj.activity_likes.count()
bundle.data['play_count'] = bundle.obj.activity_plays.count() bundle.data['tooltip'] = render_to_string('inc/player_tooltip.html', {'item': bundle.obj})
bundle.data['download_count'] = bundle.obj.activity_downloads.count() bundle.data['comment_count'] = bundle.obj.comments.count()
bundle.data['like_count'] = bundle.obj.activity_likes.count()
bundle.data['tooltip'] = render_to_string('inc/player_tooltip.html', {'item': bundle.obj}) #bundle.data['genre-list'] = json.to_ajax(bundle.obj.genres.all(), 'description', 'slug')
bundle.data['comment_count'] = bundle.obj.comments.count() bundle.data['liked'] = bundle.obj.is_liked(bundle.request.user)
bundle.data['genre-list'] = json.to_ajax(bundle.obj.genres.all(), 'description', 'slug') if bundle.request.user.is_authenticated():
bundle.data['liked'] = bundle.obj.is_liked(bundle.request.user) bundle.data['can_edit'] = bundle.request.user.is_staff or bundle.obj.user_id == bundle.request.user.id
else:
bundle.data['can_edit'] = False
if bundle.request.user.is_authenticated(): if bundle.request.user.is_authenticated():
bundle.data['can_edit'] = bundle.request.user.is_staff or bundle.obj.user_id == bundle.request.user.id bundle.data['favourited'] = bundle.obj.favourites.filter(user=bundle.request.user).count() != 0
else: else:
bundle.data['can_edit'] = False bundle.data['favourited'] = False
if bundle.request.user.is_authenticated(): return bundle
bundle.data['favourited'] = bundle.obj.favourites.filter(user=bundle.request.user).count() != 0
else:
bundle.data['favourited'] = False
return bundle
def get_search(self, request, **kwargs): def get_search(self, request, **kwargs):
self.method_check(request, allowed=['get']) self.method_check(request, allowed=['get'])
self.is_authenticated(request) self.is_authenticated(request)
self.throttle_check(request) self.throttle_check(request)
# Do the query. # Do the query.
sqs = Mix.objects.filter(title__icontains=request.GET.get('q', '')) sqs = Mix.objects.filter(title__icontains=request.GET.get('q', ''))
paginator = Paginator(sqs, 20) paginator = Paginator(sqs, 20)
try: try:
page = paginator.page(int(request.GET.get('page', 1))) page = paginator.page(int(request.GET.get('page', 1)))
except InvalidPage: except InvalidPage:
raise Http404("Sorry, no results on that page.") raise Http404("Sorry, no results on that page.")
objects = [] objects = []
for result in page.object_list: for result in page.object_list:
bundle = self.build_bundle(obj=result, request=request) bundle = self.build_bundle(obj=result, request=request)
bundle = self.full_dehydrate(bundle) bundle = self.full_dehydrate(bundle)
objects.append(bundle) objects.append(bundle)
object_list = { object_list = {
'objects': objects, 'objects': objects,
} }
self.log_throttled_access(request) self.log_throttled_access(request)
return self.create_response(request, object_list) return self.create_response(request, object_list)

View File

@@ -7,6 +7,6 @@ define ['app.lib/dssView', 'utils', 'text!/tpl/CommentItemView'],
} }
deleteComment: -> deleteComment: ->
utils.messageBox "/dlg/DeleteMixConfirm", => utils.messageBox "/dlg/DeleteCommentConfirm", =>
@model.destroy() @model.destroy()
CommentItemView CommentItemView

View File

@@ -21,7 +21,7 @@
CommentItemView.prototype.deleteComment = function() { CommentItemView.prototype.deleteComment = function() {
var _this = this; var _this = this;
return utils.messageBox("/dlg/DeleteMixConfirm", function() { return utils.messageBox("/dlg/DeleteCommentConfirm", function() {
return _this.model.destroy(); return _this.model.destroy();
}); });
}; };

View File

@@ -69,13 +69,12 @@ define ['app.lib/editableView', 'moment', 'utils', 'backbone.syphon', 'text!/tpl
initSelection: (element, callback) -> initSelection: (element, callback) ->
console.log("MixEditView: genres:initSelection") console.log("MixEditView: genres:initSelection")
result = [] result = []
genres = parent.model.get("genre-list") genres = parent.model.get("genres")
unless genres is `undefined` unless genres is `undefined`
$.each genres, (data) -> $.each genres, (data) ->
result.push result.push
id: @id id: @id
text: @text text: @description
callback result callback result
@@ -94,7 +93,10 @@ define ['app.lib/editableView', 'moment', 'utils', 'backbone.syphon', 'text!/tpl
@model.set data @model.set data
@model.set "upload-hash", @guid @model.set "upload-hash", @guid
@model.set "upload-extension", $("#upload-extension", @el).val() @model.set "upload-extension", $("#upload-extension", @el).val()
@model.set "genre-list", $("#genres", @el).select2("data")
$.each $("#genres", @el).select2("data"), (i, item) =>
@model.get("genres").add({description: item.text});
@model.unset "mix_image" unless @sendImage @model.unset "mix_image" unless @sendImage
@model.unset "comments" @model.unset "comments"

View File

@@ -102,12 +102,12 @@
var genres, result; var genres, result;
console.log("MixEditView: genres:initSelection"); console.log("MixEditView: genres:initSelection");
result = []; result = [];
genres = parent.model.get("genre-list"); genres = parent.model.get("genres");
if (genres !== undefined) { if (genres !== undefined) {
$.each(genres, function(data) { $.each(genres, function(data) {
return result.push({ return result.push({
id: this.id, id: this.id,
text: this.text text: this.description
}); });
}); });
} }
@@ -135,7 +135,11 @@
this.model.set(data); this.model.set(data);
this.model.set("upload-hash", this.guid); this.model.set("upload-hash", this.guid);
this.model.set("upload-extension", $("#upload-extension", this.el).val()); this.model.set("upload-extension", $("#upload-extension", this.el).val());
this.model.set("genre-list", $("#genres", this.el).select2("data")); $.each($("#genres", this.el).select2("data"), function(i, item) {
return _this.model.get("genres").add({
description: item.text
});
});
if (!this.sendImage) { if (!this.sendImage) {
this.model.unset("mix_image"); this.model.unset("mix_image");
} }

View File

@@ -49,8 +49,8 @@ define ['moment', 'app', 'vent', 'marionette', 'utils',
renderGenres: => renderGenres: =>
el = @el el = @el
$.each @model.get("genre-list"), (data) -> $.each @model.get("genres"), (data) ->
$("#genre-list", el).append '<a href="/mixes/' + @slug + '" class="label label-info arrowed-right arrowed-in">' + @text + '</a>' $("#genre-list", el).append '<a href="/mixes/' + @slug + '" class="label label-info arrowed-right arrowed-in">' + @description + '</a>'
true true
true true
@@ -106,7 +106,8 @@ define ['moment', 'app', 'vent', 'marionette', 'utils',
mixDelete: -> mixDelete: ->
console.log("MixItemView: mixDelete") console.log("MixItemView: mixDelete")
vent.trigger("mix:delete", @model) utils.messageBox "/dlg/DeleteMixConfirm", =>
@model.destroy()
mixLike: -> mixLike: ->
console.log("MixItemView: likeMix") console.log("MixItemView: likeMix")

View File

@@ -73,8 +73,8 @@
MixItemView.prototype.renderGenres = function() { MixItemView.prototype.renderGenres = function() {
var el; var el;
el = this.el; el = this.el;
$.each(this.model.get("genre-list"), function(data) { $.each(this.model.get("genres"), function(data) {
$("#genre-list", el).append('<a href="/mixes/' + this.slug + '" class="label label-info arrowed-right arrowed-in">' + this.text + '</a>'); $("#genre-list", el).append('<a href="/mixes/' + this.slug + '" class="label label-info arrowed-right arrowed-in">' + this.description + '</a>');
return true; return true;
}); });
return true; return true;
@@ -125,8 +125,11 @@
}; };
MixItemView.prototype.mixDelete = function() { MixItemView.prototype.mixDelete = function() {
var _this = this;
console.log("MixItemView: mixDelete"); console.log("MixItemView: mixDelete");
return vent.trigger("mix:delete", this.model); return utils.messageBox("/dlg/DeleteMixConfirm", function() {
return _this.model.destroy();
});
}; };
MixItemView.prototype.mixLike = function() { MixItemView.prototype.mixLike = function() {

View File

@@ -7,7 +7,6 @@
*/ */
/*
var socket = new io.Socket({host: 'ext-test.deepsouthsounds.com', resource: 'socket.io', port: '8000', rememberTransport: false}); var socket = new io.Socket({host: 'ext-test.deepsouthsounds.com', resource: 'socket.io', port: '8000', rememberTransport: false});
socket.connect(); socket.connect();
@@ -37,4 +36,3 @@ function message (from, msg) {
console.log(msg); console.log(msg);
$('#lines').append($('<p>').append($('<b>').text(from), msg)); $('#lines').append($('<p>').append($('<b>').text(from), msg));
} }
*/

View File

@@ -79,14 +79,14 @@
</div> </div>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="like-button footer-button"> <div class="like-button footer-button">
<a class="btn btn-pink btn-xs <% if (liked) { %> disabled <% } %>" id="like-<%= id %>" <a class="btn <% if (liked) { %>btn-light<% } else { %>btn-pink<% } %> btn-xs" id="like-<%= id %>"
data-id="<%= id %>"> data-id="<%= id %>">
<i class="icon-heart"></i> Like</a> <i class="icon-heart"></i> Like<% if (liked) { %>d<% } %></a>
</div> </div>
<div class="favourite-button footer-button"> <div class="favourite-button footer-button">
<a class="btn btn-pink btn-xs <% if (favourited) { %> disabled <% } %> " <a class="btn <% if (favourited) { %>btn-light<% } else { %>btn-pink<% } %> btn-xs"
id="favourite-<%= id %>" id="favourite-<%= id %>"
data-id="<%= id %>"><i class="icon-star-empty"></i> Favourite</a> data-id="<%= id %>"><i class="icon-star-empty"></i> Favourite<% if (favourited) { %>d<% } %></a>
</div> </div>
{% endif %} {% endif %}
<div class="footer-button"> <div class="footer-button">

View File

@@ -0,0 +1,15 @@
{% extends 'views/dlg/_DialogBase.html' %}
{% load account %}
{% load static %}
{% load socialaccount %}
{% block header %}
<h3>You sure about this chief??</h3>
{% endblock %}
{% block content %}
Delete this comment?
{% endblock %}
{% block primarybutton %}Cancel{% endblock %}
{% block extrabuttons %}
<button type="button" class="btn btn-success" data-dismiss="modal" id="yes-no-positive">Proceed</button>
{% endblock %}

View File

@@ -7,7 +7,7 @@
<h3>You sure about this chief??</h3> <h3>You sure about this chief??</h3>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
Hit Proceed here and your mix is gone.<br/> Hit Proceed here and your mix is gone forever, no amount of complaining or bitching will bring it back.<br/>
Comments, likes, favourites, all gone, never to return. Comments, likes, favourites, all gone, never to return.
{% endblock %} {% endblock %}
{% block primarybutton %}Cancel{% endblock %} {% block primarybutton %}Cancel{% endblock %}