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
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)
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(mp3_file='') | ~Q(ogg_file=''),
121 template_name='catalogue/audiobook_list.html')
124 def daisy_list(request):
125 return book_list(request, ~Q(daisy_file=''),
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.odt_file:
578 filename = book.odt_file.path
579 archive.write(filename, str('%s.odt' % book.slug))
580 if 'txt' in formats and book.txt_file:
581 filename = book.txt_file.path
582 archive.write(filename, str('%s.txt' % book.slug))
583 if 'mp3' in formats and book.mp3_file:
584 filename = book.mp3_file.path
585 archive.write(filename, str('%s.mp3' % book.slug))
586 if 'ogg' in formats and book.ogg_file:
587 filename = book.ogg_file.path
588 archive.write(filename, str('%s.ogg' % book.slug))
589 if 'daisy' in formats and book.daisy_file:
590 filename = book.daisy_file.path
591 archive.write(filename, str('%s.daisy.zip' % book.slug))
594 response = HttpResponse(content_type='application/zip', mimetype='application/x-zip-compressed')
595 response['Content-Disposition'] = 'attachment; filename=%s.zip' % slughifi(shelf.name)
596 response['Content-Length'] = temp.tell()
599 response.write(temp.read())
604 def shelf_book_formats(request, shelf):
606 Returns a list of formats of books in shelf.
608 shelf = get_object_or_404(models.Tag, slug=shelf, category='set')
610 formats = {'pdf': False, 'epub': False, 'odt': False, 'txt': False, 'mp3': False, 'ogg': False, 'daisy': False}
612 for book in collect_books(models.Book.tagged.with_all(shelf)):
614 formats['pdf'] = True
615 if book.root_ancestor.epub_file:
616 formats['epub'] = True
618 formats['odt'] = True
620 formats['txt'] = True
622 formats['mp3'] = True
624 formats['ogg'] = True
626 formats['daisy'] = True
628 return HttpResponse(LazyEncoder().encode(formats))
634 def new_set(request):
635 new_set_form = forms.NewSetForm(request.POST)
636 if new_set_form.is_valid():
637 new_set = new_set_form.save(request.user)
639 if request.is_ajax():
640 return JSONResponse('{"id":"%d", "name":"%s", "msg":"<p>Shelf <strong>%s</strong> was successfully created</p>"}' % (new_set.id, new_set.name, new_set))
642 return HttpResponseRedirect('/')
644 return HttpResponseRedirect('/')
650 def delete_shelf(request, slug):
651 user_set = get_object_or_404(models.Tag, slug=slug, category='set', user=request.user)
654 if request.is_ajax():
655 return HttpResponse(_('<p>Shelf <strong>%s</strong> was successfully removed</p>') % user_set.name)
657 return HttpResponseRedirect('/')
666 form = AuthenticationForm(data=request.POST, prefix='login')
668 auth.login(request, form.get_user())
669 response_data = {'success': True, 'errors': {}}
671 response_data = {'success': False, 'errors': form.errors}
672 return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
677 def register(request):
678 registration_form = UserCreationForm(request.POST, prefix='registration')
679 if registration_form.is_valid():
680 user = registration_form.save()
681 user = auth.authenticate(
682 username=registration_form.cleaned_data['username'],
683 password=registration_form.cleaned_data['password1']
685 auth.login(request, user)
686 response_data = {'success': True, 'errors': {}}
688 response_data = {'success': False, 'errors': registration_form.errors}
689 return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
693 def logout_then_redirect(request):
695 return HttpResponseRedirect(urlquote_plus(request.GET.get('next', '/'), safe='/?='))
704 def import_book(request):
705 """docstring for import_book"""
706 book_import_form = forms.BookImportForm(request.POST, request.FILES)
707 if book_import_form.is_valid():
709 book_import_form.save()
711 info = sys.exc_info()
712 exception = pprint.pformat(info[1])
713 tb = '\n'.join(traceback.format_tb(info[2]))
714 return HttpResponse(_("An error occurred: %(exception)s\n\n%(tb)s") % {'exception':exception, 'tb':tb}, mimetype='text/plain')
715 return HttpResponse(_("Book imported successfully"))
717 return HttpResponse(_("Error importing file: %r") % book_import_form.errors)
722 """ Provides server time for jquery.countdown,
723 in a format suitable for Date.parse()
725 return HttpResponse(datetime.now().strftime('%Y/%m/%d %H:%M:%S'))
731 Create a zip archive with all XML files.
733 temp = tempfile.TemporaryFile()
734 archive = zipfile.ZipFile(temp, 'w')
736 for book in models.Book.objects.all():
737 archive.write(book.xml_file.path, str('%s.xml' % book.slug))
740 response = HttpResponse(content_type='application/zip', mimetype='application/x-zip-compressed')
741 response['Content-Disposition'] = 'attachment; filename=xmls.zip'
742 response['Content-Length'] = temp.tell()
745 response.write(temp.read())
752 Create a tar archive with all EPUB files, segregated to directories.
755 temp = tempfile.TemporaryFile()
756 archive = tarfile.TarFile(fileobj=temp, mode='w')
758 for book in models.Book.objects.exclude(epub_file=''):
759 archive.add(book.epub_file.path, (u'%s/%s.epub' % (book.get_extra_info_value()['author'], book.slug)).encode('utf-8'))
762 response = HttpResponse(content_type='application/tar', mimetype='application/x-tar')
763 response['Content-Disposition'] = 'attachment; filename=epubs.tar'
764 response['Content-Length'] = temp.tell()
767 response.write(temp.read())