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 slughifi import slughifi
42 staff_required = user_passes_test(lambda user: user.is_staff)
45 class LazyEncoder(simplejson.JSONEncoder):
46 def default(self, obj):
47 if isinstance(obj, Promise):
48 return force_unicode(obj)
51 # shortcut for JSON reponses
52 class JSONResponse(HttpResponse):
53 def __init__(self, data={}, callback=None, **kwargs):
55 kwargs.pop('mimetype', None)
56 data = simplejson.dumps(data)
58 data = callback + "(" + data + ");"
59 super(JSONResponse, self).__init__(data, mimetype="application/json", **kwargs)
62 def main_page(request):
63 if request.user.is_authenticated():
64 shelves = models.Tag.objects.filter(category='set', user=request.user)
65 new_set_form = forms.NewSetForm()
67 tags = models.Tag.objects.exclude(category__in=('set', 'book'))
69 tag.count = tag.get_count()
70 categories = split_tags(tags)
71 fragment_tags = categories.get('theme', [])
73 form = forms.SearchForm()
74 return render_to_response('catalogue/main_page.html', locals(),
75 context_instance=RequestContext(request))
78 def book_list(request, filter=None, template_name='catalogue/book_list.html'):
79 """ generates a listing of all books, optionally filtered with a test function """
81 form = forms.SearchForm()
84 books = models.Book.objects.all().order_by('parent_number', 'title').only('title', 'parent', 'slug')
86 books = books.filter(filter).distinct()
87 book_ids = set((book.pk for book in books))
89 parent = book.parent_id
90 if parent not in book_ids:
92 books_by_parent.setdefault(parent, []).append(book)
95 books_by_parent.setdefault(book.parent_id, []).append(book)
98 books_by_author = SortedDict()
99 books_nav = SortedDict()
100 for tag in models.Tag.objects.filter(category='author'):
101 books_by_author[tag] = []
103 for book in books_by_parent.get(None,()):
104 authors = list(book.tags.filter(category='author'))
106 for author in authors:
107 books_by_author[author].append(book)
111 for tag in books_by_author:
112 if books_by_author[tag]:
113 books_nav.setdefault(tag.sort_key[0], []).append(tag)
115 return render_to_response(template_name, locals(),
116 context_instance=RequestContext(request))
119 def audiobook_list(request):
120 return book_list(request, Q(medias__type='mp3') | Q(medias__type='ogg'),
121 template_name='catalogue/audiobook_list.html')
124 def daisy_list(request):
125 return book_list(request, Q(medias__type='daisy'),
126 template_name='catalogue/daisy_list.html')
129 def differentiate_tags(request, tags, ambiguous_slugs):
130 beginning = '/'.join(tag.url_chunk for tag in tags)
131 unparsed = '/'.join(ambiguous_slugs[1:])
133 for tag in models.Tag.objects.exclude(category='book').filter(slug=ambiguous_slugs[0]):
135 'url_args': '/'.join((beginning, tag.url_chunk, unparsed)).strip('/'),
138 return render_to_response('catalogue/differentiate_tags.html',
139 {'tags': tags, 'options': options, 'unparsed': ambiguous_slugs[1:]},
140 context_instance=RequestContext(request))
143 def tagged_object_list(request, tags=''):
145 tags = models.Tag.get_tag_list(tags)
146 except models.Tag.DoesNotExist:
148 except models.Tag.MultipleObjectsReturned, e:
149 return differentiate_tags(request, e.tags, e.ambiguous_slugs)
152 if len(tags) > settings.MAX_TAG_LIST:
154 except AttributeError:
157 if len([tag for tag in tags if tag.category == 'book']):
160 theme_is_set = [tag for tag in tags if tag.category == 'theme']
161 shelf_is_set = [tag for tag in tags if tag.category == 'set']
162 only_shelf = shelf_is_set and len(tags) == 1
163 only_my_shelf = only_shelf and request.user.is_authenticated() and request.user == tags[0].user
165 objects = only_author = pd_counter = None
169 shelf_tags = [tag for tag in tags if tag.category == 'set']
170 fragment_tags = [tag for tag in tags if tag.category != 'set']
171 fragments = models.Fragment.tagged.with_all(fragment_tags)
174 books = models.Book.tagged.with_all(shelf_tags).order_by()
175 l_tags = models.Tag.objects.filter(category='book', slug__in=[book.book_tag_slug() for book in books])
176 fragments = models.Fragment.tagged.with_any(l_tags, fragments)
178 # newtagging goes crazy if we just try:
179 #related_tags = models.Tag.objects.usage_for_queryset(fragments, counts=True,
180 # extra={'where': ["catalogue_tag.category != 'book'"]})
181 fragment_keys = [fragment.pk for fragment in fragments]
183 related_tags = models.Fragment.tags.usage(counts=True,
184 filters={'pk__in': fragment_keys},
185 extra={'where': ["catalogue_tag.category != 'book'"]})
186 related_tags = (tag for tag in related_tags if tag not in fragment_tags)
187 categories = split_tags(related_tags)
191 # get relevant books and their tags
192 objects = models.Book.tagged.with_all(tags)
194 # eliminate descendants
195 l_tags = models.Tag.objects.filter(category='book', slug__in=[book.book_tag_slug() for book in objects])
196 descendants_keys = [book.pk for book in models.Book.tagged.with_any(l_tags)]
198 objects = objects.exclude(pk__in=descendants_keys)
200 # get related tags from `tag_counter` and `theme_counter`
202 tags_pks = [tag.pk for tag in tags]
204 for tag_pk, value in itertools.chain(book.tag_counter.iteritems(), book.theme_counter.iteritems()):
205 if tag_pk in tags_pks:
207 related_counts[tag_pk] = related_counts.get(tag_pk, 0) + value
208 related_tags = models.Tag.objects.filter(pk__in=related_counts.keys())
209 related_tags = [tag for tag in related_tags if tag not in tags]
210 for tag in related_tags:
211 tag.count = related_counts[tag.pk]
213 categories = split_tags(related_tags)
217 only_author = len(tags) == 1 and tags[0].category == 'author'
218 pd_counter = only_author and tags[0].goes_to_pd()
219 objects = models.Book.objects.none()
224 template_name='catalogue/tagged_object_list.html',
226 'categories': categories,
227 'only_shelf': only_shelf,
228 'only_author': only_author,
229 'pd_counter': pd_counter,
230 'only_my_shelf': only_my_shelf,
231 'formats_form': forms.DownloadFormatsForm(),
238 def book_fragments(request, book_slug, theme_slug):
239 book = get_object_or_404(models.Book, slug=book_slug)
240 book_tag = get_object_or_404(models.Tag, slug='l-' + book_slug, category='book')
241 theme = get_object_or_404(models.Tag, slug=theme_slug, category='theme')
242 fragments = models.Fragment.tagged.with_all([book_tag, theme])
244 form = forms.SearchForm()
245 return render_to_response('catalogue/book_fragments.html', locals(),
246 context_instance=RequestContext(request))
249 def book_detail(request, slug):
251 book = models.Book.objects.get(slug=slug)
252 except models.Book.DoesNotExist:
253 return book_stub_detail(request, slug)
255 book_tag = book.book_tag()
256 tags = list(book.tags.filter(~Q(category='set')))
257 categories = split_tags(tags)
258 book_children = book.children.all().order_by('parent_number')
263 parents.append(_book.parent)
265 parents = reversed(parents)
267 theme_counter = book.theme_counter
268 book_themes = models.Tag.objects.filter(pk__in=theme_counter.keys())
269 for tag in book_themes:
270 tag.count = theme_counter[tag.pk]
272 extra_info = book.get_extra_info_value()
274 form = forms.SearchForm()
275 return render_to_response('catalogue/book_detail.html', locals(),
276 context_instance=RequestContext(request))
279 def book_stub_detail(request, slug):
280 book = get_object_or_404(models.BookStub, slug=slug)
282 form = forms.SearchForm()
284 return render_to_response('catalogue/book_stub_detail.html', locals(),
285 context_instance=RequestContext(request))
288 def book_text(request, slug):
289 book = get_object_or_404(models.Book, slug=slug)
290 if not book.has_html_file():
293 for fragment in book.fragments.all():
294 for theme in fragment.tags.filter(category='theme'):
295 book_themes.setdefault(theme, []).append(fragment)
297 book_themes = book_themes.items()
298 book_themes.sort(key=lambda s: s[0].sort_key)
299 return render_to_response('catalogue/book_text.html', locals(),
300 context_instance=RequestContext(request))
307 def _no_diacritics_regexp(query):
308 """ returns a regexp for searching for a query without diacritics
310 should be locale-aware """
312 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źżŹŻ',
313 u'ą':u'ąĄ', u'ć':u'ćĆ', u'ę':u'ęĘ', u'ł': u'łŁ', u'ń':u'ńŃ', u'ó':u'óÓ', u'ś':u'śŚ', u'ź':u'źŹ', u'ż':u'żŻ'
317 return u"(%s)" % '|'.join(names[l])
318 return re.sub(u'[%s]' % (u''.join(names.keys())), repl, query)
320 def unicode_re_escape(query):
321 """ Unicode-friendly version of re.escape """
322 return re.sub('(?u)(\W)', r'\\\1', query)
324 def _word_starts_with(name, prefix):
325 """returns a Q object getting models having `name` contain a word
326 starting with `prefix`
328 We define word characters as alphanumeric and underscore, like in JS.
330 Works for MySQL, PostgreSQL, Oracle.
331 For SQLite, _sqlite* version is substituted for this.
335 prefix = _no_diacritics_regexp(unicode_re_escape(prefix))
336 # can't use [[:<:]] (word start),
337 # but we want both `xy` and `(xy` to catch `(xyz)`
338 kwargs['%s__iregex' % name] = u"(^|[^[:alnum:]_])%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 prefix = _no_diacritics_regexp(unicode_re_escape(prefix))
350 kwargs['%s__iregex' % name] = ur"(^|(?<=[^\wąćęłńóśźżĄĆĘŁŃÓŚŹŻ]))%s" % prefix
354 if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.sqlite3':
355 _word_starts_with = _sqlite_word_starts_with
358 def _tags_starting_with(prefix, user=None):
359 prefix = prefix.lower()
360 book_stubs = models.BookStub.objects.filter(_word_starts_with('title', prefix))
361 books = models.Book.objects.filter(_word_starts_with('title', prefix))
362 book_stubs = filter(lambda x: x not in books, book_stubs)
363 tags = models.Tag.objects.filter(_word_starts_with('name', prefix))
364 if user and user.is_authenticated():
365 tags = tags.filter(~Q(category='book') & (~Q(category='set') | Q(user=user)))
367 tags = tags.filter(~Q(category='book') & ~Q(category='set'))
368 return list(books) + list(tags) + list(book_stubs)
371 def _get_result_link(match, tag_list):
372 if isinstance(match, models.Book) or isinstance(match, models.BookStub):
373 return match.get_absolute_url()
375 return reverse('catalogue.views.tagged_object_list',
376 kwargs={'tags': '/'.join(tag.url_chunk for tag in tag_list + [match])}
379 def _get_result_type(match):
380 if isinstance(match, models.Book) or isinstance(match, models.BookStub):
383 type = match.category
387 def books_starting_with(prefix):
388 prefix = prefix.lower()
389 return models.Book.objects.filter(_word_starts_with('title', prefix))
392 def find_best_matches(query, user=None):
393 """ Finds a Book, Tag or Bookstub best matching a query.
396 - zero elements when nothing is found,
397 - one element when a best result is found,
398 - more then one element on multiple exact matches
400 Raises a ValueError on too short a query.
403 query = query.lower()
405 raise ValueError("query must have at least two characters")
407 result = tuple(_tags_starting_with(query, user))
408 exact_matches = tuple(res for res in result if res.name.lower() == query)
416 tags = request.GET.get('tags', '')
417 prefix = request.GET.get('q', '')
420 tag_list = models.Tag.get_tag_list(tags)
425 result = find_best_matches(prefix, request.user)
427 return render_to_response('catalogue/search_too_short.html', {'tags':tag_list, 'prefix':prefix},
428 context_instance=RequestContext(request))
431 return HttpResponseRedirect(_get_result_link(result[0], tag_list))
432 elif len(result) > 1:
433 return render_to_response('catalogue/search_multiple_hits.html',
434 {'tags':tag_list, 'prefix':prefix, 'results':((x, _get_result_link(x, tag_list), _get_result_type(x)) for x in result)},
435 context_instance=RequestContext(request))
437 return render_to_response('catalogue/search_no_hits.html', {'tags':tag_list, 'prefix':prefix},
438 context_instance=RequestContext(request))
441 def tags_starting_with(request):
442 prefix = request.GET.get('q', '')
443 # Prefix must have at least 2 characters
445 return HttpResponse('')
448 for tag in _tags_starting_with(prefix, request.user):
449 if not tag.name in tags_list:
450 result += "\n" + tag.name
451 tags_list.append(tag.name)
452 return HttpResponse(result)
454 def json_tags_starting_with(request, callback=None):
456 prefix = request.GET.get('q', '')
457 callback = request.GET.get('callback', '')
458 # Prefix must have at least 2 characters
460 return HttpResponse('')
463 for tag in _tags_starting_with(prefix, request.user):
464 if not tag.name in tags_list:
465 result += "\n" + tag.name
466 tags_list.append(tag.name)
467 dict_result = {"matches": tags_list}
468 return JSONResponse(dict_result, callback)
470 # ====================
471 # = Shelf management =
472 # ====================
475 def user_shelves(request):
476 shelves = models.Tag.objects.filter(category='set', user=request.user)
477 new_set_form = forms.NewSetForm()
478 return render_to_response('catalogue/user_shelves.html', locals(),
479 context_instance=RequestContext(request))
482 def book_sets(request, slug):
483 if not request.user.is_authenticated():
484 return HttpResponse(_('<p>To maintain your shelves you need to be logged in.</p>'))
486 book = get_object_or_404(models.Book, slug=slug)
487 user_sets = models.Tag.objects.filter(category='set', user=request.user)
488 book_sets = book.tags.filter(category='set', user=request.user)
490 if request.method == 'POST':
491 form = forms.ObjectSetsForm(book, request.user, request.POST)
493 old_shelves = list(book.tags.filter(category='set'))
494 new_shelves = [models.Tag.objects.get(pk=id) for id in form.cleaned_data['set_ids']]
496 for shelf in [shelf for shelf in old_shelves if shelf not in new_shelves]:
497 shelf.book_count = None
500 for shelf in [shelf for shelf in new_shelves if shelf not in old_shelves]:
501 shelf.book_count = None
504 book.tags = new_shelves + list(book.tags.filter(~Q(category='set') | ~Q(user=request.user)))
505 if request.is_ajax():
506 return JSONResponse('{"msg":"'+_("<p>Shelves were sucessfully saved.</p>")+'", "after":"close"}')
508 return HttpResponseRedirect('/')
510 form = forms.ObjectSetsForm(book, request.user)
511 new_set_form = forms.NewSetForm()
513 return render_to_response('catalogue/book_sets.html', locals(),
514 context_instance=RequestContext(request))
520 def remove_from_shelf(request, shelf, book):
521 book = get_object_or_404(models.Book, slug=book)
522 shelf = get_object_or_404(models.Tag, slug=shelf, category='set', user=request.user)
524 if shelf in book.tags:
525 models.Tag.objects.remove_tag(book, shelf)
527 shelf.book_count = None
530 return HttpResponse(_('Book was successfully removed from the shelf'))
532 return HttpResponse(_('This book is not on the shelf'))
535 def collect_books(books):
537 Returns all real books in collection.
541 if len(book.children.all()) == 0:
544 result += collect_books(book.children.all())
549 def download_shelf(request, slug):
551 Create a ZIP archive on disk and transmit it in chunks of 8KB,
552 without loading the whole file into memory. A similar approach can
553 be used for large dynamic PDF files.
555 shelf = get_object_or_404(models.Tag, slug=slug, category='set')
558 form = forms.DownloadFormatsForm(request.GET)
560 formats = form.cleaned_data['formats']
561 if len(formats) == 0:
562 formats = ['pdf', 'epub', 'odt', 'txt', 'mp3', 'ogg', 'daisy']
564 # Create a ZIP archive
565 temp = tempfile.TemporaryFile()
566 archive = zipfile.ZipFile(temp, 'w')
569 for book in collect_books(models.Book.tagged.with_all(shelf)):
570 if 'pdf' in formats and book.pdf_file:
571 filename = book.pdf_file.path
572 archive.write(filename, str('%s.pdf' % book.slug))
573 if book.root_ancestor not in already and 'epub' in formats and book.root_ancestor.epub_file:
574 filename = book.root_ancestor.epub_file.path
575 archive.write(filename, str('%s.epub' % book.root_ancestor.slug))
576 already.add(book.root_ancestor)
577 if 'odt' in formats and book.has_media("odt"):
578 for file in book.get_media("odt"):
579 filename = file.file.path
580 archive.write(filename, str('%s.odt' % slugify(file.name)))
581 if 'txt' in formats and book.txt_file:
582 filename = book.txt_file.path
583 archive.write(filename, str('%s.txt' % book.slug))
584 if 'mp3' in formats and book.has_media("mp3"):
585 for file in book.get_media("mp3"):
586 filename = file.file.path
587 archive.write(filename, str('%s.mp3' % slugify(file.name)))
588 if 'ogg' in formats and book.has_media("ogg"):
589 for file in book.get_media("ogg"):
590 filename = file.file.path
591 archive.write(filename, str('%s.ogg' % slugify(file.name)))
592 if 'daisy' in formats and book.has_media("daisy"):
593 for file in book.get_media("daisy"):
594 filename = file.file.path
595 archive.write(filename, str('%s.daisy' % slugify(file.name)))
598 response = HttpResponse(content_type='application/zip', mimetype='application/x-zip-compressed')
599 response['Content-Disposition'] = 'attachment; filename=%s.zip' % slughifi(shelf.name)
600 response['Content-Length'] = temp.tell()
603 response.write(temp.read())
608 def shelf_book_formats(request, shelf):
610 Returns a list of formats of books in shelf.
612 shelf = get_object_or_404(models.Tag, slug=shelf, category='set')
614 formats = {'pdf': False, 'epub': False, 'odt': False, 'txt': False, 'mp3': False, 'ogg': False, 'daisy': False}
616 for book in collect_books(models.Book.tagged.with_all(shelf)):
618 formats['pdf'] = True
619 if book.root_ancestor.epub_file:
620 formats['epub'] = True
622 formats['odt'] = True
624 formats['txt'] = True
626 formats['mp3'] = True
628 formats['ogg'] = True
630 formats['daisy'] = True
632 return HttpResponse(LazyEncoder().encode(formats))
638 def new_set(request):
639 new_set_form = forms.NewSetForm(request.POST)
640 if new_set_form.is_valid():
641 new_set = new_set_form.save(request.user)
643 if request.is_ajax():
644 return JSONResponse('{"id":"%d", "name":"%s", "msg":"<p>Shelf <strong>%s</strong> was successfully created</p>"}' % (new_set.id, new_set.name, new_set))
646 return HttpResponseRedirect('/')
648 return HttpResponseRedirect('/')
654 def delete_shelf(request, slug):
655 user_set = get_object_or_404(models.Tag, slug=slug, category='set', user=request.user)
658 if request.is_ajax():
659 return HttpResponse(_('<p>Shelf <strong>%s</strong> was successfully removed</p>') % user_set.name)
661 return HttpResponseRedirect('/')
670 form = AuthenticationForm(data=request.POST, prefix='login')
672 auth.login(request, form.get_user())
673 response_data = {'success': True, 'errors': {}}
675 response_data = {'success': False, 'errors': form.errors}
676 return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
681 def register(request):
682 registration_form = UserCreationForm(request.POST, prefix='registration')
683 if registration_form.is_valid():
684 user = registration_form.save()
685 user = auth.authenticate(
686 username=registration_form.cleaned_data['username'],
687 password=registration_form.cleaned_data['password1']
689 auth.login(request, user)
690 response_data = {'success': True, 'errors': {}}
692 response_data = {'success': False, 'errors': registration_form.errors}
693 return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
697 def logout_then_redirect(request):
699 return HttpResponseRedirect(urlquote_plus(request.GET.get('next', '/'), safe='/?='))
708 def import_book(request):
709 """docstring for import_book"""
710 book_import_form = forms.BookImportForm(request.POST, request.FILES)
711 if book_import_form.is_valid():
713 book_import_form.save()
715 info = sys.exc_info()
716 exception = pprint.pformat(info[1])
717 tb = '\n'.join(traceback.format_tb(info[2]))
718 return HttpResponse(_("An error occurred: %(exception)s\n\n%(tb)s") % {'exception':exception, 'tb':tb}, mimetype='text/plain')
719 return HttpResponse(_("Book imported successfully"))
721 return HttpResponse(_("Error importing file: %r") % book_import_form.errors)
726 """ Provides server time for jquery.countdown,
727 in a format suitable for Date.parse()
729 return HttpResponse(datetime.now().strftime('%Y/%m/%d %H:%M:%S'))
735 Create a zip archive with all XML files.
737 temp = tempfile.TemporaryFile()
738 archive = zipfile.ZipFile(temp, 'w')
740 for book in models.Book.objects.all():
741 archive.write(book.xml_file.path, str('%s.xml' % book.slug))
744 response = HttpResponse(content_type='application/zip', mimetype='application/x-zip-compressed')
745 response['Content-Disposition'] = 'attachment; filename=xmls.zip'
746 response['Content-Length'] = temp.tell()
749 response.write(temp.read())
756 Create a tar archive with all EPUB files, segregated to directories.
759 temp = tempfile.TemporaryFile()
760 archive = tarfile.TarFile(fileobj=temp, mode='w')
762 for book in models.Book.objects.exclude(epub_file=''):
763 archive.add(book.epub_file.path, (u'%s/%s.epub' % (book.get_extra_info_value()['author'], book.slug)).encode('utf-8'))
766 response = HttpResponse(content_type='application/tar', mimetype='application/x-tar')
767 response['Content-Disposition'] = 'attachment; filename=epubs.tar'
768 response['Content-Length'] = temp.tell()
771 response.write(temp.read())