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', 'title')
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 _word_starts_with_regexp(prefix):
344 prefix = _no_diacritics_regexp(unicode_re_escape(prefix))
345 return ur"(^|(?<=[^\wąćęłńóśźżĄĆĘŁŃÓŚŹŻ]))%s" % prefix
348 def _sqlite_word_starts_with(name, prefix):
349 """ version of _word_starts_with for SQLite
351 SQLite in Django uses Python re module
354 kwargs['%s__iregex' % name] = _word_starts_with_regexp(prefix)
358 if hasattr(settings, 'DATABASES'):
359 if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.sqlite3':
360 _word_starts_with = _sqlite_word_starts_with
361 elif settings.DATABASE_ENGINE == 'sqlite3':
362 _word_starts_with = _sqlite_word_starts_with
366 def __init__(self, name, view):
369 self.lower = name.lower()
370 self.category = 'application'
372 return reverse(*self._view)
375 App(u'Leśmianator', (u'lesmianator', )),
379 def _tags_starting_with(prefix, user=None):
380 prefix = prefix.lower()
381 book_stubs = models.BookStub.objects.filter(_word_starts_with('title', prefix))
382 books = models.Book.objects.filter(_word_starts_with('title', prefix))
383 book_stubs = filter(lambda x: x not in books, book_stubs)
384 tags = models.Tag.objects.filter(_word_starts_with('name', prefix))
385 if user and user.is_authenticated():
386 tags = tags.filter(~Q(category='book') & (~Q(category='set') | Q(user=user)))
388 tags = tags.filter(~Q(category='book') & ~Q(category='set'))
390 prefix_regexp = re.compile(_word_starts_with_regexp(prefix))
391 return list(books) + list(tags) + list(book_stubs) + [app for app in _apps if prefix_regexp.search(app.lower)]
394 def _get_result_link(match, tag_list):
395 if isinstance(match, models.Book) or isinstance(match, models.BookStub):
396 return match.get_absolute_url()
397 elif isinstance(match, App):
400 return reverse('catalogue.views.tagged_object_list',
401 kwargs={'tags': '/'.join(tag.url_chunk for tag in tag_list + [match])}
404 def _get_result_type(match):
405 if isinstance(match, models.Book) or isinstance(match, models.BookStub):
408 type = match.category
412 def books_starting_with(prefix):
413 prefix = prefix.lower()
414 return models.Book.objects.filter(_word_starts_with('title', prefix))
417 def find_best_matches(query, user=None):
418 """ Finds a Book, Tag or Bookstub best matching a query.
421 - zero elements when nothing is found,
422 - one element when a best result is found,
423 - more then one element on multiple exact matches
425 Raises a ValueError on too short a query.
428 query = query.lower()
430 raise ValueError("query must have at least two characters")
432 result = tuple(_tags_starting_with(query, user))
433 exact_matches = tuple(res for res in result if res.name.lower() == query)
441 tags = request.GET.get('tags', '')
442 prefix = request.GET.get('q', '')
445 tag_list = models.Tag.get_tag_list(tags)
450 result = find_best_matches(prefix, request.user)
452 return render_to_response('catalogue/search_too_short.html', {'tags':tag_list, 'prefix':prefix},
453 context_instance=RequestContext(request))
456 return HttpResponseRedirect(_get_result_link(result[0], tag_list))
457 elif len(result) > 1:
458 return render_to_response('catalogue/search_multiple_hits.html',
459 {'tags':tag_list, 'prefix':prefix, 'results':((x, _get_result_link(x, tag_list), _get_result_type(x)) for x in result)},
460 context_instance=RequestContext(request))
462 return render_to_response('catalogue/search_no_hits.html', {'tags':tag_list, 'prefix':prefix},
463 context_instance=RequestContext(request))
466 def tags_starting_with(request):
467 prefix = request.GET.get('q', '')
468 # Prefix must have at least 2 characters
470 return HttpResponse('')
473 for tag in _tags_starting_with(prefix, request.user):
474 if not tag.name in tags_list:
475 result += "\n" + tag.name
476 tags_list.append(tag.name)
477 return HttpResponse(result)
479 def json_tags_starting_with(request, callback=None):
481 prefix = request.GET.get('q', '')
482 callback = request.GET.get('callback', '')
483 # Prefix must have at least 2 characters
485 return HttpResponse('')
488 for tag in _tags_starting_with(prefix, request.user):
489 if not tag.name in tags_list:
490 result += "\n" + tag.name
491 tags_list.append(tag.name)
492 dict_result = {"matches": tags_list}
493 return JSONResponse(dict_result, callback)
495 # ====================
496 # = Shelf management =
497 # ====================
500 def user_shelves(request):
501 shelves = models.Tag.objects.filter(category='set', user=request.user)
502 new_set_form = forms.NewSetForm()
503 return render_to_response('catalogue/user_shelves.html', locals(),
504 context_instance=RequestContext(request))
507 def book_sets(request, slug):
508 if not request.user.is_authenticated():
509 return HttpResponse(_('<p>To maintain your shelves you need to be logged in.</p>'))
511 book = get_object_or_404(models.Book, slug=slug)
512 user_sets = models.Tag.objects.filter(category='set', user=request.user)
513 book_sets = book.tags.filter(category='set', user=request.user)
515 if request.method == 'POST':
516 form = forms.ObjectSetsForm(book, request.user, request.POST)
518 old_shelves = list(book.tags.filter(category='set'))
519 new_shelves = [models.Tag.objects.get(pk=id) for id in form.cleaned_data['set_ids']]
521 for shelf in [shelf for shelf in old_shelves if shelf not in new_shelves]:
522 shelf.book_count = None
525 for shelf in [shelf for shelf in new_shelves if shelf not in old_shelves]:
526 shelf.book_count = None
529 book.tags = new_shelves + list(book.tags.filter(~Q(category='set') | ~Q(user=request.user)))
530 if request.is_ajax():
531 return JSONResponse('{"msg":"'+_("<p>Shelves were sucessfully saved.</p>")+'", "after":"close"}')
533 return HttpResponseRedirect('/')
535 form = forms.ObjectSetsForm(book, request.user)
536 new_set_form = forms.NewSetForm()
538 return render_to_response('catalogue/book_sets.html', locals(),
539 context_instance=RequestContext(request))
545 def remove_from_shelf(request, shelf, book):
546 book = get_object_or_404(models.Book, slug=book)
547 shelf = get_object_or_404(models.Tag, slug=shelf, category='set', user=request.user)
549 if shelf in book.tags:
550 models.Tag.objects.remove_tag(book, shelf)
552 shelf.book_count = None
555 return HttpResponse(_('Book was successfully removed from the shelf'))
557 return HttpResponse(_('This book is not on the shelf'))
560 def collect_books(books):
562 Returns all real books in collection.
566 if len(book.children.all()) == 0:
569 result += collect_books(book.children.all())
574 def download_shelf(request, slug):
576 Create a ZIP archive on disk and transmit it in chunks of 8KB,
577 without loading the whole file into memory. A similar approach can
578 be used for large dynamic PDF files.
580 shelf = get_object_or_404(models.Tag, slug=slug, category='set')
583 form = forms.DownloadFormatsForm(request.GET)
585 formats = form.cleaned_data['formats']
586 if len(formats) == 0:
587 formats = ['pdf', 'epub', 'odt', 'txt', 'mp3', 'ogg', 'daisy']
589 # Create a ZIP archive
590 temp = tempfile.TemporaryFile()
591 archive = zipfile.ZipFile(temp, 'w')
594 for book in collect_books(models.Book.tagged.with_all(shelf)):
595 if 'pdf' in formats and book.pdf_file:
596 filename = book.pdf_file.path
597 archive.write(filename, str('%s.pdf' % book.slug))
598 if book.root_ancestor not in already and 'epub' in formats and book.root_ancestor.epub_file:
599 filename = book.root_ancestor.epub_file.path
600 archive.write(filename, str('%s.epub' % book.root_ancestor.slug))
601 already.add(book.root_ancestor)
602 if 'odt' in formats and book.has_media("odt"):
603 for file in book.get_media("odt"):
604 filename = file.file.path
605 archive.write(filename, str('%s.odt' % slugify(file.name)))
606 if 'txt' in formats and book.txt_file:
607 filename = book.txt_file.path
608 archive.write(filename, str('%s.txt' % book.slug))
609 if 'mp3' in formats and book.has_media("mp3"):
610 for file in book.get_media("mp3"):
611 filename = file.file.path
612 archive.write(filename, str('%s.mp3' % slugify(file.name)))
613 if 'ogg' in formats and book.has_media("ogg"):
614 for file in book.get_media("ogg"):
615 filename = file.file.path
616 archive.write(filename, str('%s.ogg' % slugify(file.name)))
617 if 'daisy' in formats and book.has_media("daisy"):
618 for file in book.get_media("daisy"):
619 filename = file.file.path
620 archive.write(filename, str('%s.daisy' % slugify(file.name)))
623 response = HttpResponse(content_type='application/zip', mimetype='application/x-zip-compressed')
624 response['Content-Disposition'] = 'attachment; filename=%s.zip' % slughifi(shelf.name)
625 response['Content-Length'] = temp.tell()
628 response.write(temp.read())
633 def shelf_book_formats(request, shelf):
635 Returns a list of formats of books in shelf.
637 shelf = get_object_or_404(models.Tag, slug=shelf, category='set')
639 formats = {'pdf': False, 'epub': False, 'odt': False, 'txt': False, 'mp3': False, 'ogg': False, 'daisy': False}
641 for book in collect_books(models.Book.tagged.with_all(shelf)):
643 formats['pdf'] = True
644 if book.root_ancestor.epub_file:
645 formats['epub'] = True
647 formats['odt'] = True
649 formats['txt'] = True
651 formats['mp3'] = True
653 formats['ogg'] = True
655 formats['daisy'] = True
657 return HttpResponse(LazyEncoder().encode(formats))
663 def new_set(request):
664 new_set_form = forms.NewSetForm(request.POST)
665 if new_set_form.is_valid():
666 new_set = new_set_form.save(request.user)
668 if request.is_ajax():
669 return JSONResponse('{"id":"%d", "name":"%s", "msg":"<p>Shelf <strong>%s</strong> was successfully created</p>"}' % (new_set.id, new_set.name, new_set))
671 return HttpResponseRedirect('/')
673 return HttpResponseRedirect('/')
679 def delete_shelf(request, slug):
680 user_set = get_object_or_404(models.Tag, slug=slug, category='set', user=request.user)
683 if request.is_ajax():
684 return HttpResponse(_('<p>Shelf <strong>%s</strong> was successfully removed</p>') % user_set.name)
686 return HttpResponseRedirect('/')
695 form = AuthenticationForm(data=request.POST, prefix='login')
697 auth.login(request, form.get_user())
698 response_data = {'success': True, 'errors': {}}
700 response_data = {'success': False, 'errors': form.errors}
701 return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
706 def register(request):
707 registration_form = UserCreationForm(request.POST, prefix='registration')
708 if registration_form.is_valid():
709 user = registration_form.save()
710 user = auth.authenticate(
711 username=registration_form.cleaned_data['username'],
712 password=registration_form.cleaned_data['password1']
714 auth.login(request, user)
715 response_data = {'success': True, 'errors': {}}
717 response_data = {'success': False, 'errors': registration_form.errors}
718 return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
722 def logout_then_redirect(request):
724 return HttpResponseRedirect(urlquote_plus(request.GET.get('next', '/'), safe='/?='))
733 def import_book(request):
734 """docstring for import_book"""
735 book_import_form = forms.BookImportForm(request.POST, request.FILES)
736 if book_import_form.is_valid():
738 book_import_form.save()
740 info = sys.exc_info()
741 exception = pprint.pformat(info[1])
742 tb = '\n'.join(traceback.format_tb(info[2]))
743 return HttpResponse(_("An error occurred: %(exception)s\n\n%(tb)s") % {'exception':exception, 'tb':tb}, mimetype='text/plain')
744 return HttpResponse(_("Book imported successfully"))
746 return HttpResponse(_("Error importing file: %r") % book_import_form.errors)
751 """ Provides server time for jquery.countdown,
752 in a format suitable for Date.parse()
754 return HttpResponse(datetime.now().strftime('%Y/%m/%d %H:%M:%S'))
760 Create a zip archive with all XML files.
762 temp = tempfile.TemporaryFile()
763 archive = zipfile.ZipFile(temp, 'w')
765 for book in models.Book.objects.all():
766 archive.write(book.xml_file.path, str('%s.xml' % book.slug))
769 response = HttpResponse(content_type='application/zip', mimetype='application/x-zip-compressed')
770 response['Content-Disposition'] = 'attachment; filename=xmls.zip'
771 response['Content-Length'] = temp.tell()
774 response.write(temp.read())
781 Create a tar archive with all EPUB files, segregated to directories.
784 temp = tempfile.TemporaryFile()
785 archive = tarfile.TarFile(fileobj=temp, mode='w')
787 for book in models.Book.objects.exclude(epub_file=''):
788 archive.add(book.epub_file.path, (u'%s/%s.epub' % (book.get_extra_info_value()['author'], book.slug)).encode('utf-8'))
791 response = HttpResponse(content_type='application/tar', mimetype='application/x-tar')
792 response['Content-Disposition'] = 'attachment; filename=epubs.tar'
793 response['Content-Length'] = temp.tell()
796 response.write(temp.read())