1 # -*- coding: utf-8 -*-
2 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
3 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
13 from operator import itemgetter
14 from datetime import datetime
16 from django.conf import settings
17 from django.template import RequestContext
18 from django.shortcuts import render_to_response, get_object_or_404
19 from django.http import HttpResponse, HttpResponseRedirect, Http404
20 from django.core.urlresolvers import reverse
21 from django.db.models import Q
22 from django.contrib.auth.decorators import login_required, user_passes_test
23 from django.utils.datastructures import SortedDict
24 from django.views.decorators.http import require_POST
25 from django.contrib import auth
26 from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
27 from django.utils import simplejson
28 from django.utils.functional import Promise
29 from django.utils.encoding import force_unicode
30 from django.utils.http import urlquote_plus
31 from django.views.decorators import cache
32 from django.utils.translation import ugettext as _
33 from django.views.generic.list_detail import object_list
34 from django.template.defaultfilters import slugify
35 from catalogue import models
36 from catalogue import forms
37 from catalogue.utils import split_tags
38 from newtagging import views as newtagging_views
39 from pdcounter import models as pdcounter_models
40 from pdcounter import views as pdcounter_views
41 from slughifi import slughifi
44 staff_required = user_passes_test(lambda user: user.is_staff)
47 class LazyEncoder(simplejson.JSONEncoder):
48 def default(self, obj):
49 if isinstance(obj, Promise):
50 return force_unicode(obj)
53 # shortcut for JSON reponses
54 class JSONResponse(HttpResponse):
55 def __init__(self, data={}, callback=None, **kwargs):
57 kwargs.pop('mimetype', None)
58 data = simplejson.dumps(data)
60 data = callback + "(" + data + ");"
61 super(JSONResponse, self).__init__(data, mimetype="application/json", **kwargs)
64 def main_page(request):
65 if request.user.is_authenticated():
66 shelves = models.Tag.objects.filter(category='set', user=request.user)
67 new_set_form = forms.NewSetForm()
69 tags = models.Tag.objects.exclude(category__in=('set', 'book'))
71 tag.count = tag.get_count()
72 categories = split_tags(tags)
73 fragment_tags = categories.get('theme', [])
75 form = forms.SearchForm()
76 return render_to_response('catalogue/main_page.html', locals(),
77 context_instance=RequestContext(request))
80 def book_list(request, filter=None, template_name='catalogue/book_list.html'):
81 """ generates a listing of all books, optionally filtered with a test function """
83 form = forms.SearchForm()
86 books = models.Book.objects.all().order_by('parent_number', 'title').only('title', 'parent', 'slug')
88 books = books.filter(filter).distinct()
89 book_ids = set((book.pk for book in books))
91 parent = book.parent_id
92 if parent not in book_ids:
94 books_by_parent.setdefault(parent, []).append(book)
97 books_by_parent.setdefault(book.parent_id, []).append(book)
100 books_by_author = SortedDict()
101 books_nav = SortedDict()
102 for tag in models.Tag.objects.filter(category='author'):
103 books_by_author[tag] = []
105 for book in books_by_parent.get(None,()):
106 authors = list(book.tags.filter(category='author'))
108 for author in authors:
109 books_by_author[author].append(book)
113 for tag in books_by_author:
114 if books_by_author[tag]:
115 books_nav.setdefault(tag.sort_key[0], []).append(tag)
117 return render_to_response(template_name, locals(),
118 context_instance=RequestContext(request))
121 def audiobook_list(request):
122 return book_list(request, Q(medias__type='mp3') | Q(medias__type='ogg'),
123 template_name='catalogue/audiobook_list.html')
126 def daisy_list(request):
127 return book_list(request, Q(medias__type='daisy'),
128 template_name='catalogue/daisy_list.html')
131 def differentiate_tags(request, tags, ambiguous_slugs):
132 beginning = '/'.join(tag.url_chunk for tag in tags)
133 unparsed = '/'.join(ambiguous_slugs[1:])
135 for tag in models.Tag.objects.exclude(category='book').filter(slug=ambiguous_slugs[0]):
137 'url_args': '/'.join((beginning, tag.url_chunk, unparsed)).strip('/'),
140 return render_to_response('catalogue/differentiate_tags.html',
141 {'tags': tags, 'options': options, 'unparsed': ambiguous_slugs[1:]},
142 context_instance=RequestContext(request))
145 def tagged_object_list(request, tags=''):
147 tags = models.Tag.get_tag_list(tags)
148 except models.Tag.DoesNotExist:
149 chunks = tags.split('/')
150 if len(chunks) == 2 and chunks[0] == 'autor':
151 return pdcounter_views.author_detail(request, chunks[1])
154 except models.Tag.MultipleObjectsReturned, e:
155 return differentiate_tags(request, e.tags, e.ambiguous_slugs)
158 if len(tags) > settings.MAX_TAG_LIST:
160 except AttributeError:
163 if len([tag for tag in tags if tag.category == 'book']):
166 theme_is_set = [tag for tag in tags if tag.category == 'theme']
167 shelf_is_set = [tag for tag in tags if tag.category == 'set']
168 only_shelf = shelf_is_set and len(tags) == 1
169 only_my_shelf = only_shelf and request.user.is_authenticated() and request.user == tags[0].user
171 objects = only_author = None
175 shelf_tags = [tag for tag in tags if tag.category == 'set']
176 fragment_tags = [tag for tag in tags if tag.category != 'set']
177 fragments = models.Fragment.tagged.with_all(fragment_tags)
180 books = models.Book.tagged.with_all(shelf_tags).order_by()
181 l_tags = models.Tag.objects.filter(category='book', slug__in=[book.book_tag_slug() for book in books])
182 fragments = models.Fragment.tagged.with_any(l_tags, fragments)
184 # newtagging goes crazy if we just try:
185 #related_tags = models.Tag.objects.usage_for_queryset(fragments, counts=True,
186 # extra={'where': ["catalogue_tag.category != 'book'"]})
187 fragment_keys = [fragment.pk for fragment in fragments]
189 related_tags = models.Fragment.tags.usage(counts=True,
190 filters={'pk__in': fragment_keys},
191 extra={'where': ["catalogue_tag.category != 'book'"]})
192 related_tags = (tag for tag in related_tags if tag not in fragment_tags)
193 categories = split_tags(related_tags)
197 # get relevant books and their tags
198 objects = models.Book.tagged.with_all(tags)
200 # eliminate descendants
201 l_tags = models.Tag.objects.filter(category='book', slug__in=[book.book_tag_slug() for book in objects])
202 descendants_keys = [book.pk for book in models.Book.tagged.with_any(l_tags)]
204 objects = objects.exclude(pk__in=descendants_keys)
206 # get related tags from `tag_counter` and `theme_counter`
208 tags_pks = [tag.pk for tag in tags]
210 for tag_pk, value in itertools.chain(book.tag_counter.iteritems(), book.theme_counter.iteritems()):
211 if tag_pk in tags_pks:
213 related_counts[tag_pk] = related_counts.get(tag_pk, 0) + value
214 related_tags = models.Tag.objects.filter(pk__in=related_counts.keys())
215 related_tags = [tag for tag in related_tags if tag not in tags]
216 for tag in related_tags:
217 tag.count = related_counts[tag.pk]
219 categories = split_tags(related_tags)
223 only_author = len(tags) == 1 and tags[0].category == 'author'
224 objects = models.Book.objects.none()
229 template_name='catalogue/tagged_object_list.html',
231 'categories': categories,
232 'only_shelf': only_shelf,
233 'only_author': only_author,
234 'only_my_shelf': only_my_shelf,
235 'formats_form': forms.DownloadFormatsForm(),
242 def book_fragments(request, book_slug, theme_slug):
243 book = get_object_or_404(models.Book, slug=book_slug)
244 book_tag = get_object_or_404(models.Tag, slug='l-' + book_slug, category='book')
245 theme = get_object_or_404(models.Tag, slug=theme_slug, category='theme')
246 fragments = models.Fragment.tagged.with_all([book_tag, theme])
248 form = forms.SearchForm()
249 return render_to_response('catalogue/book_fragments.html', locals(),
250 context_instance=RequestContext(request))
253 def book_detail(request, slug):
255 book = models.Book.objects.get(slug=slug)
256 except models.Book.DoesNotExist:
257 return pdcounter_views.book_stub_detail(request, slug)
259 book_tag = book.book_tag()
260 tags = list(book.tags.filter(~Q(category='set')))
261 categories = split_tags(tags)
262 book_children = book.children.all().order_by('parent_number', 'title')
267 parents.append(_book.parent)
269 parents = reversed(parents)
271 theme_counter = book.theme_counter
272 book_themes = models.Tag.objects.filter(pk__in=theme_counter.keys())
273 for tag in book_themes:
274 tag.count = theme_counter[tag.pk]
276 extra_info = book.get_extra_info_value()
278 form = forms.SearchForm()
279 return render_to_response('catalogue/book_detail.html', locals(),
280 context_instance=RequestContext(request))
283 def book_text(request, slug):
284 book = get_object_or_404(models.Book, slug=slug)
285 if not book.has_html_file():
288 for fragment in book.fragments.all():
289 for theme in fragment.tags.filter(category='theme'):
290 book_themes.setdefault(theme, []).append(fragment)
292 book_themes = book_themes.items()
293 book_themes.sort(key=lambda s: s[0].sort_key)
294 return render_to_response('catalogue/book_text.html', locals(),
295 context_instance=RequestContext(request))
302 def _no_diacritics_regexp(query):
303 """ returns a regexp for searching for a query without diacritics
305 should be locale-aware """
307 u'a':u'aąĄ', u'c':u'cćĆ', u'e':u'eęĘ', u'l': u'lłŁ', u'n':u'nńŃ', u'o':u'oóÓ', u's':u'sśŚ', u'z':u'zźżŹŻ',
308 u'ą':u'ąĄ', u'ć':u'ćĆ', u'ę':u'ęĘ', u'ł': u'łŁ', u'ń':u'ńŃ', u'ó':u'óÓ', u'ś':u'śŚ', u'ź':u'źŹ', u'ż':u'żŻ'
312 return u"(%s)" % '|'.join(names[l])
313 return re.sub(u'[%s]' % (u''.join(names.keys())), repl, query)
315 def unicode_re_escape(query):
316 """ Unicode-friendly version of re.escape """
317 return re.sub('(?u)(\W)', r'\\\1', query)
319 def _word_starts_with(name, prefix):
320 """returns a Q object getting models having `name` contain a word
321 starting with `prefix`
323 We define word characters as alphanumeric and underscore, like in JS.
325 Works for MySQL, PostgreSQL, Oracle.
326 For SQLite, _sqlite* version is substituted for this.
330 prefix = _no_diacritics_regexp(unicode_re_escape(prefix))
331 # can't use [[:<:]] (word start),
332 # but we want both `xy` and `(xy` to catch `(xyz)`
333 kwargs['%s__iregex' % name] = u"(^|[^[:alnum:]_])%s" % prefix
338 def _word_starts_with_regexp(prefix):
339 prefix = _no_diacritics_regexp(unicode_re_escape(prefix))
340 return ur"(^|(?<=[^\wąćęłńóśźżĄĆĘŁŃÓŚŹŻ]))%s" % prefix
343 def _sqlite_word_starts_with(name, prefix):
344 """ version of _word_starts_with for SQLite
346 SQLite in Django uses Python re module
349 kwargs['%s__iregex' % name] = _word_starts_with_regexp(prefix)
353 if hasattr(settings, 'DATABASES'):
354 if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.sqlite3':
355 _word_starts_with = _sqlite_word_starts_with
356 elif settings.DATABASE_ENGINE == 'sqlite3':
357 _word_starts_with = _sqlite_word_starts_with
361 def __init__(self, name, view):
364 self.lower = name.lower()
365 self.category = 'application'
367 return reverse(*self._view)
370 App(u'Leśmianator', (u'lesmianator', )),
374 def _tags_starting_with(prefix, user=None):
375 prefix = prefix.lower()
377 book_stubs = pdcounter_models.BookStub.objects.filter(_word_starts_with('title', prefix))
378 authors = pdcounter_models.Author.objects.filter(_word_starts_with('name', prefix))
380 books = models.Book.objects.filter(_word_starts_with('title', prefix))
381 tags = models.Tag.objects.filter(_word_starts_with('name', prefix))
382 if user and user.is_authenticated():
383 tags = tags.filter(~Q(category='book') & (~Q(category='set') | Q(user=user)))
385 tags = tags.filter(~Q(category='book') & ~Q(category='set'))
387 prefix_regexp = re.compile(_word_starts_with_regexp(prefix))
388 return list(books) + list(tags) + [app for app in _apps if prefix_regexp.search(app.lower)] + list(book_stubs) + list(authors)
391 def _get_result_link(match, tag_list):
392 if isinstance(match, models.Tag):
393 return reverse('catalogue.views.tagged_object_list',
394 kwargs={'tags': '/'.join(tag.url_chunk for tag in tag_list + [match])}
396 elif isinstance(match, App):
399 return match.get_absolute_url()
402 def _get_result_type(match):
403 if isinstance(match, models.Book) or isinstance(match, pdcounter_models.BookStub):
406 type = match.category
410 def books_starting_with(prefix):
411 prefix = prefix.lower()
412 return models.Book.objects.filter(_word_starts_with('title', prefix))
415 def find_best_matches(query, user=None):
416 """ Finds a Book, Tag, BookStub or Author best matching a query.
419 - zero elements when nothing is found,
420 - one element when a best result is found,
421 - more then one element on multiple exact matches
423 Raises a ValueError on too short a query.
426 query = query.lower()
428 raise ValueError("query must have at least two characters")
430 result = tuple(_tags_starting_with(query, user))
431 # remove pdcounter stuff
432 book_titles = set(match.pretty_title().lower() for match in result
433 if isinstance(match, models.Book))
434 authors = set(match.name.lower() for match in result
435 if isinstance(match, models.Tag) and match.category=='author')
436 result = tuple(res for res in result if not (
437 (isinstance(res, pdcounter_models.BookStub) and res.pretty_title().lower() in book_titles)
438 or (isinstance(res, pdcounter_models.Author) and res.name.lower() in authors)
441 exact_matches = tuple(res for res in result if res.name.lower() == query)
445 return tuple(result)[:1]
449 tags = request.GET.get('tags', '')
450 prefix = request.GET.get('q', '')
453 tag_list = models.Tag.get_tag_list(tags)
458 result = find_best_matches(prefix, request.user)
460 return render_to_response('catalogue/search_too_short.html', {'tags':tag_list, 'prefix':prefix},
461 context_instance=RequestContext(request))
464 return HttpResponseRedirect(_get_result_link(result[0], tag_list))
465 elif len(result) > 1:
466 return render_to_response('catalogue/search_multiple_hits.html',
467 {'tags':tag_list, 'prefix':prefix, 'results':((x, _get_result_link(x, tag_list), _get_result_type(x)) for x in result)},
468 context_instance=RequestContext(request))
470 return render_to_response('catalogue/search_no_hits.html', {'tags':tag_list, 'prefix':prefix},
471 context_instance=RequestContext(request))
474 def tags_starting_with(request):
475 prefix = request.GET.get('q', '')
476 # Prefix must have at least 2 characters
478 return HttpResponse('')
481 for tag in _tags_starting_with(prefix, request.user):
482 if not tag.name in tags_list:
483 result += "\n" + tag.name
484 tags_list.append(tag.name)
485 return HttpResponse(result)
487 def json_tags_starting_with(request, callback=None):
489 prefix = request.GET.get('q', '')
490 callback = request.GET.get('callback', '')
491 # Prefix must have at least 2 characters
493 return HttpResponse('')
496 for tag in _tags_starting_with(prefix, request.user):
497 if not tag.name in tags_list:
498 result += "\n" + tag.name
499 tags_list.append(tag.name)
500 dict_result = {"matches": tags_list}
501 return JSONResponse(dict_result, callback)
503 # ====================
504 # = Shelf management =
505 # ====================
508 def user_shelves(request):
509 shelves = models.Tag.objects.filter(category='set', user=request.user)
510 new_set_form = forms.NewSetForm()
511 return render_to_response('catalogue/user_shelves.html', locals(),
512 context_instance=RequestContext(request))
515 def book_sets(request, slug):
516 if not request.user.is_authenticated():
517 return HttpResponse(_('<p>To maintain your shelves you need to be logged in.</p>'))
519 book = get_object_or_404(models.Book, slug=slug)
520 user_sets = models.Tag.objects.filter(category='set', user=request.user)
521 book_sets = book.tags.filter(category='set', user=request.user)
523 if request.method == 'POST':
524 form = forms.ObjectSetsForm(book, request.user, request.POST)
526 old_shelves = list(book.tags.filter(category='set'))
527 new_shelves = [models.Tag.objects.get(pk=id) for id in form.cleaned_data['set_ids']]
529 for shelf in [shelf for shelf in old_shelves if shelf not in new_shelves]:
530 shelf.book_count = None
533 for shelf in [shelf for shelf in new_shelves if shelf not in old_shelves]:
534 shelf.book_count = None
537 book.tags = new_shelves + list(book.tags.filter(~Q(category='set') | ~Q(user=request.user)))
538 if request.is_ajax():
539 return JSONResponse('{"msg":"'+_("<p>Shelves were sucessfully saved.</p>")+'", "after":"close"}')
541 return HttpResponseRedirect('/')
543 form = forms.ObjectSetsForm(book, request.user)
544 new_set_form = forms.NewSetForm()
546 return render_to_response('catalogue/book_sets.html', locals(),
547 context_instance=RequestContext(request))
553 def remove_from_shelf(request, shelf, book):
554 book = get_object_or_404(models.Book, slug=book)
555 shelf = get_object_or_404(models.Tag, slug=shelf, category='set', user=request.user)
557 if shelf in book.tags:
558 models.Tag.objects.remove_tag(book, shelf)
560 shelf.book_count = None
563 return HttpResponse(_('Book was successfully removed from the shelf'))
565 return HttpResponse(_('This book is not on the shelf'))
568 def collect_books(books):
570 Returns all real books in collection.
574 if len(book.children.all()) == 0:
577 result += collect_books(book.children.all())
582 def download_shelf(request, slug):
584 Create a ZIP archive on disk and transmit it in chunks of 8KB,
585 without loading the whole file into memory. A similar approach can
586 be used for large dynamic PDF files.
588 shelf = get_object_or_404(models.Tag, slug=slug, category='set')
591 form = forms.DownloadFormatsForm(request.GET)
593 formats = form.cleaned_data['formats']
594 if len(formats) == 0:
595 formats = ['pdf', 'epub', 'odt', 'txt', 'mp3', 'ogg', 'daisy']
597 # Create a ZIP archive
598 temp = tempfile.TemporaryFile()
599 archive = zipfile.ZipFile(temp, 'w')
602 for book in collect_books(models.Book.tagged.with_all(shelf)):
603 if 'pdf' in formats and book.pdf_file:
604 filename = book.pdf_file.path
605 archive.write(filename, str('%s.pdf' % book.slug))
606 if book.root_ancestor not in already and 'epub' in formats and book.root_ancestor.epub_file:
607 filename = book.root_ancestor.epub_file.path
608 archive.write(filename, str('%s.epub' % book.root_ancestor.slug))
609 already.add(book.root_ancestor)
610 if 'odt' in formats and book.has_media("odt"):
611 for file in book.get_media("odt"):
612 filename = file.file.path
613 archive.write(filename, str('%s.odt' % slugify(file.name)))
614 if 'txt' in formats and book.txt_file:
615 filename = book.txt_file.path
616 archive.write(filename, str('%s.txt' % book.slug))
617 if 'mp3' in formats and book.has_media("mp3"):
618 for file in book.get_media("mp3"):
619 filename = file.file.path
620 archive.write(filename, str('%s.mp3' % slugify(file.name)))
621 if 'ogg' in formats and book.has_media("ogg"):
622 for file in book.get_media("ogg"):
623 filename = file.file.path
624 archive.write(filename, str('%s.ogg' % slugify(file.name)))
625 if 'daisy' in formats and book.has_media("daisy"):
626 for file in book.get_media("daisy"):
627 filename = file.file.path
628 archive.write(filename, str('%s.daisy' % slugify(file.name)))
631 response = HttpResponse(content_type='application/zip', mimetype='application/x-zip-compressed')
632 response['Content-Disposition'] = 'attachment; filename=%s.zip' % slughifi(shelf.name)
633 response['Content-Length'] = temp.tell()
636 response.write(temp.read())
641 def shelf_book_formats(request, shelf):
643 Returns a list of formats of books in shelf.
645 shelf = get_object_or_404(models.Tag, slug=shelf, category='set')
647 formats = {'pdf': False, 'epub': False, 'odt': False, 'txt': False, 'mp3': False, 'ogg': False, 'daisy': False}
649 for book in collect_books(models.Book.tagged.with_all(shelf)):
651 formats['pdf'] = True
652 if book.root_ancestor.epub_file:
653 formats['epub'] = True
655 formats['txt'] = True
656 for format in ('odt', 'mp3', 'ogg'):
657 if not formats[format] and book.has_media(format):
658 formats[format] = True
660 return HttpResponse(LazyEncoder().encode(formats))
666 def new_set(request):
667 new_set_form = forms.NewSetForm(request.POST)
668 if new_set_form.is_valid():
669 new_set = new_set_form.save(request.user)
671 if request.is_ajax():
672 return JSONResponse('{"id":"%d", "name":"%s", "msg":"<p>Shelf <strong>%s</strong> was successfully created</p>"}' % (new_set.id, new_set.name, new_set))
674 return HttpResponseRedirect('/')
676 return HttpResponseRedirect('/')
682 def delete_shelf(request, slug):
683 user_set = get_object_or_404(models.Tag, slug=slug, category='set', user=request.user)
686 if request.is_ajax():
687 return HttpResponse(_('<p>Shelf <strong>%s</strong> was successfully removed</p>') % user_set.name)
689 return HttpResponseRedirect('/')
698 form = AuthenticationForm(data=request.POST, prefix='login')
700 auth.login(request, form.get_user())
701 response_data = {'success': True, 'errors': {}}
703 response_data = {'success': False, 'errors': form.errors}
704 return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
709 def register(request):
710 registration_form = UserCreationForm(request.POST, prefix='registration')
711 if registration_form.is_valid():
712 user = registration_form.save()
713 user = auth.authenticate(
714 username=registration_form.cleaned_data['username'],
715 password=registration_form.cleaned_data['password1']
717 auth.login(request, user)
718 response_data = {'success': True, 'errors': {}}
720 response_data = {'success': False, 'errors': registration_form.errors}
721 return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
725 def logout_then_redirect(request):
727 return HttpResponseRedirect(urlquote_plus(request.GET.get('next', '/'), safe='/?='))
736 def import_book(request):
737 """docstring for import_book"""
738 book_import_form = forms.BookImportForm(request.POST, request.FILES)
739 if book_import_form.is_valid():
741 book_import_form.save()
743 info = sys.exc_info()
744 exception = pprint.pformat(info[1])
745 tb = '\n'.join(traceback.format_tb(info[2]))
746 return HttpResponse(_("An error occurred: %(exception)s\n\n%(tb)s") % {'exception':exception, 'tb':tb}, mimetype='text/plain')
747 return HttpResponse(_("Book imported successfully"))
749 return HttpResponse(_("Error importing file: %r") % book_import_form.errors)
754 """ Provides server time for jquery.countdown,
755 in a format suitable for Date.parse()
757 return HttpResponse(datetime.now().strftime('%Y/%m/%d %H:%M:%S'))
763 Create a zip archive with all XML files.
765 temp = tempfile.TemporaryFile()
766 archive = zipfile.ZipFile(temp, 'w')
768 for book in models.Book.objects.all():
769 archive.write(book.xml_file.path, str('%s.xml' % book.slug))
772 response = HttpResponse(content_type='application/zip', mimetype='application/x-zip-compressed')
773 response['Content-Disposition'] = 'attachment; filename=xmls.zip'
774 response['Content-Length'] = temp.tell()
777 response.write(temp.read())
784 Create a tar archive with all EPUB files, segregated to directories.
787 temp = tempfile.TemporaryFile()
788 archive = tarfile.TarFile(fileobj=temp, mode='w')
790 for book in models.Book.objects.exclude(epub_file=''):
791 archive.add(book.epub_file.path, (u'%s/%s.epub' % (book.get_extra_info_value()['author'], book.slug)).encode('utf-8'))
794 response = HttpResponse(content_type='application/tar', mimetype='application/x-tar')
795 response['Content-Disposition'] = 'attachment; filename=epubs.tar'
796 response['Content-Length'] = temp.tell()
799 response.write(temp.read())