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['odt'] = True
657 formats['txt'] = True
659 formats['mp3'] = True
661 formats['ogg'] = True
663 formats['daisy'] = True
665 return HttpResponse(LazyEncoder().encode(formats))
671 def new_set(request):
672 new_set_form = forms.NewSetForm(request.POST)
673 if new_set_form.is_valid():
674 new_set = new_set_form.save(request.user)
676 if request.is_ajax():
677 return JSONResponse('{"id":"%d", "name":"%s", "msg":"<p>Shelf <strong>%s</strong> was successfully created</p>"}' % (new_set.id, new_set.name, new_set))
679 return HttpResponseRedirect('/')
681 return HttpResponseRedirect('/')
687 def delete_shelf(request, slug):
688 user_set = get_object_or_404(models.Tag, slug=slug, category='set', user=request.user)
691 if request.is_ajax():
692 return HttpResponse(_('<p>Shelf <strong>%s</strong> was successfully removed</p>') % user_set.name)
694 return HttpResponseRedirect('/')
703 form = AuthenticationForm(data=request.POST, prefix='login')
705 auth.login(request, form.get_user())
706 response_data = {'success': True, 'errors': {}}
708 response_data = {'success': False, 'errors': form.errors}
709 return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
714 def register(request):
715 registration_form = UserCreationForm(request.POST, prefix='registration')
716 if registration_form.is_valid():
717 user = registration_form.save()
718 user = auth.authenticate(
719 username=registration_form.cleaned_data['username'],
720 password=registration_form.cleaned_data['password1']
722 auth.login(request, user)
723 response_data = {'success': True, 'errors': {}}
725 response_data = {'success': False, 'errors': registration_form.errors}
726 return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
730 def logout_then_redirect(request):
732 return HttpResponseRedirect(urlquote_plus(request.GET.get('next', '/'), safe='/?='))
741 def import_book(request):
742 """docstring for import_book"""
743 book_import_form = forms.BookImportForm(request.POST, request.FILES)
744 if book_import_form.is_valid():
746 book_import_form.save()
748 info = sys.exc_info()
749 exception = pprint.pformat(info[1])
750 tb = '\n'.join(traceback.format_tb(info[2]))
751 return HttpResponse(_("An error occurred: %(exception)s\n\n%(tb)s") % {'exception':exception, 'tb':tb}, mimetype='text/plain')
752 return HttpResponse(_("Book imported successfully"))
754 return HttpResponse(_("Error importing file: %r") % book_import_form.errors)
759 """ Provides server time for jquery.countdown,
760 in a format suitable for Date.parse()
762 return HttpResponse(datetime.now().strftime('%Y/%m/%d %H:%M:%S'))
768 Create a zip archive with all XML files.
770 temp = tempfile.TemporaryFile()
771 archive = zipfile.ZipFile(temp, 'w')
773 for book in models.Book.objects.all():
774 archive.write(book.xml_file.path, str('%s.xml' % book.slug))
777 response = HttpResponse(content_type='application/zip', mimetype='application/x-zip-compressed')
778 response['Content-Disposition'] = 'attachment; filename=xmls.zip'
779 response['Content-Length'] = temp.tell()
782 response.write(temp.read())
789 Create a tar archive with all EPUB files, segregated to directories.
792 temp = tempfile.TemporaryFile()
793 archive = tarfile.TarFile(fileobj=temp, mode='w')
795 for book in models.Book.objects.exclude(epub_file=''):
796 archive.add(book.epub_file.path, (u'%s/%s.epub' % (book.get_extra_info_value()['author'], book.slug)).encode('utf-8'))
799 response = HttpResponse(content_type='application/tar', mimetype='application/x-tar')
800 response['Content-Disposition'] = 'attachment; filename=epubs.tar'
801 response['Content-Length'] = temp.tell()
804 response.write(temp.read())