More search fixes.
[wolnelektury.git] / apps / catalogue / views.py
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.
4 #
5 import tempfile
6 import zipfile
7 import sys
8 import pprint
9 import traceback
10
11 from django.conf import settings
12 from django.template import RequestContext
13 from django.shortcuts import render_to_response, get_object_or_404
14 from django.http import HttpResponse, HttpResponseRedirect, Http404
15 from django.core.urlresolvers import reverse
16 from django.db.models import Q
17 from django.contrib.auth.decorators import login_required, user_passes_test
18 from django.utils.datastructures import SortedDict
19 from django.views.decorators.http import require_POST
20 from django.contrib import auth
21 from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
22 from django.utils import simplejson
23 from django.utils.functional import Promise
24 from django.utils.encoding import force_unicode
25 from django.utils.http import urlquote_plus
26 from django.views.decorators import cache
27
28 from catalogue import models
29 from catalogue import forms
30 from catalogue.utils import split_tags
31 from newtagging import views as newtagging_views
32
33
34 staff_required = user_passes_test(lambda user: user.is_staff)
35
36
37 class LazyEncoder(simplejson.JSONEncoder):
38     def default(self, obj):
39         if isinstance(obj, Promise):
40             return force_unicode(obj)
41         return obj
42
43
44 def main_page(request):    
45     if request.user.is_authenticated():
46         shelves = models.Tag.objects.filter(category='set', user=request.user)
47         new_set_form = forms.NewSetForm()
48     extra_where = "NOT catalogue_tag.category = 'set'"
49     tags = models.Tag.objects.usage_for_model(models.Book, counts=True, extra={'where': [extra_where]})
50     fragment_tags = models.Tag.objects.usage_for_model(models.Fragment, counts=True,
51         extra={'where': ["catalogue_tag.category = 'theme'"] + [extra_where]})
52     categories = split_tags(tags)
53     
54     form = forms.SearchForm()
55     return render_to_response('catalogue/main_page.html', locals(),
56         context_instance=RequestContext(request))
57
58
59 def book_list(request):
60     books = models.Book.objects.all()
61     form = forms.SearchForm()
62     
63     books_by_first_letter = SortedDict()
64     for book in books:
65         books_by_first_letter.setdefault(book.title[0], []).append(book)
66     
67     return render_to_response('catalogue/book_list.html', locals(),
68         context_instance=RequestContext(request))
69
70
71 def tagged_object_list(request, tags=''):
72     # Prevent DoS attacks on our database
73     if len(tags.split('/')) > 6:
74         raise Http404
75         
76     try:
77         tags = models.Tag.get_tag_list(tags)
78     except models.Tag.DoesNotExist:
79         raise Http404
80     
81     if len([tag for tag in tags if tag.category == 'book']):
82         raise Http404
83     
84     model = models.Book
85     shelf = [tag for tag in tags if tag.category == 'set']
86     shelf_is_set = (len(tags) == 1 and tags[0].category == 'set')
87     theme_is_set = len([tag for tag in tags if tag.category == 'theme']) > 0
88     if theme_is_set:
89         model = models.Fragment
90     only_author = len(tags) == 1 and tags[0].category == 'author'
91     pd_counter = only_author and tags[0].goes_to_pd()
92
93     user_is_owner = (len(shelf) and request.user.is_authenticated() and request.user == shelf[0].user)
94     
95     extra_where = "catalogue_tag.category NOT IN ('set', 'book')"
96     related_tags = models.Tag.objects.related_for_model(tags, model, counts=True, extra={'where': [extra_where]})
97     categories = split_tags(related_tags)
98
99     if not (theme_is_set or shelf_is_set):
100         model=models.Book.objects.filter(parent=None)
101     
102     return newtagging_views.tagged_object_list(
103         request,
104         tag_model=models.Tag,
105         queryset_or_model=model,
106         tags=tags,
107         template_name='catalogue/tagged_object_list.html',
108         extra_context = {
109             'categories': categories,
110             'shelf_is_set': shelf_is_set,
111             'only_author': only_author,
112             'pd_counter': pd_counter,
113             'user_is_owner': user_is_owner,
114             'formats_form': forms.DownloadFormatsForm(),
115         },
116     )
117
118
119 def book_fragments(request, book_slug, theme_slug):
120     book = get_object_or_404(models.Book, slug=book_slug)
121     book_tag = get_object_or_404(models.Tag, slug='l-' + book_slug)
122     theme = get_object_or_404(models.Tag, slug=theme_slug)
123     fragments = models.Fragment.tagged.with_all([book_tag, theme])
124     
125     form = forms.SearchForm()
126     return render_to_response('catalogue/book_fragments.html', locals(),
127         context_instance=RequestContext(request))
128
129
130 def book_detail(request, slug):
131     try:
132         book = models.Book.objects.get(slug=slug)
133     except models.Book.DoesNotExist:
134         return book_stub_detail(request, slug)
135
136     book_tag = get_object_or_404(models.Tag, slug = 'l-' + slug)
137     tags = list(book.tags.filter(~Q(category='set')))
138     categories = split_tags(tags)
139     book_children = book.children.all().order_by('parent_number')
140     extra_where = "catalogue_tag.category = 'theme'"
141     book_themes = models.Tag.objects.related_for_model(book_tag, models.Fragment, counts=True, extra={'where': [extra_where]})
142     extra_info = book.get_extra_info_value()
143     
144     form = forms.SearchForm()
145     return render_to_response('catalogue/book_detail.html', locals(),
146         context_instance=RequestContext(request))
147
148
149 def book_stub_detail(request, slug):
150     book = get_object_or_404(models.BookStub, slug=slug)
151     pd_counter = book.pd
152     form = forms.SearchForm()
153     
154     return render_to_response('catalogue/book_stub_detail.html', locals(),
155         context_instance=RequestContext(request))
156     
157
158 def book_text(request, slug):
159     book = get_object_or_404(models.Book, slug=slug)
160     book_themes = {}
161     for fragment in book.fragments.all():
162         for theme in fragment.tags.filter(category='theme'):
163             book_themes.setdefault(theme, []).append(fragment)
164     
165     book_themes = book_themes.items()
166     book_themes.sort(key=lambda s: s[0].sort_key)
167     return render_to_response('catalogue/book_text.html', locals(),
168         context_instance=RequestContext(request))
169
170
171 # ==========
172 # = Search =
173 # ==========
174 def _word_starts_with(name, prefix):
175     """returns a Q object getting models having `name` contain a word
176     starting with `prefix`
177     """
178     kwargs = {}
179     if settings.DATABASE_ENGINE in ('mysql', 'postgresql_psycopg2', 'postgresql'):
180         # we must escape `prefix` so that it only matches literally
181         for special in r'\^$.*+?|(){}[]':
182             prefix = prefix.replace(special, '\\' + special)
183         
184         # we could use a [[:<:]] (word start), 
185         # but we want both `xy` and `(xy` to catch `(xyz)`
186         kwargs['%s__iregex' % name] = u"(^|[^[:alpha:]])%s" % prefix
187     else:
188         # don't know how to do a generic regex
189         # checking for simple icontain instead
190         kwargs['%s__icontains' % name] = prefix
191     return Q(**kwargs)
192
193
194 def _tags_exact_matches(prefix, user):
195     book_stubs = models.BookStub.objects.filter(title__iexact = prefix)
196     books = models.Book.objects.filter(title__iexact = prefix)
197     book_stubs = filter(lambda x: x not in books, book_stubs)
198     tags = models.Tag.objects.filter(name__iexact = prefix)
199     if user.is_authenticated():
200         tags = tags.filter(~Q(category='book') & (~Q(category='set') | Q(user=user)))
201     else:
202         tags = tags.filter(~Q(category='book') & ~Q(category='set'))
203
204     return list(books) + list(tags) + list(book_stubs)
205
206
207 def _tags_starting_with(prefix, user):
208     book_stubs = models.BookStub.objects.filter(_word_starts_with('title', prefix))
209     books = models.Book.objects.filter(_word_starts_with('title', prefix))
210     book_stubs = filter(lambda x: x not in books, book_stubs)
211     tags = models.Tag.objects.filter(_word_starts_with('name', prefix))
212     if user.is_authenticated():
213         tags = tags.filter(~Q(category='book') & (~Q(category='set') | Q(user=user)))
214     else:
215         tags = tags.filter(~Q(category='book') & ~Q(category='set'))
216
217     return list(books) + list(tags) + list(book_stubs)
218         
219
220
221 def _get_result_link(match, tag_list):
222     if isinstance(match, models.Book) or isinstance(match, models.BookStub):
223         return match.get_absolute_url()
224     else:
225         return reverse('catalogue.views.tagged_object_list', 
226             kwargs={'tags': '/'.join(tag.slug for tag in tag_list + [match])}
227         )
228
229 def _get_result_type(match):
230     if isinstance(match, models.Book) or isinstance(match, models.BookStub):
231         type = 'book'
232     else:
233         type = match.category
234     return dict(models.TAG_CATEGORIES)[type]
235     
236
237
238 def search(request):
239     tags = request.GET.get('tags', '')
240     prefix = request.GET.get('q', '')
241     
242     try:
243         tag_list = models.Tag.get_tag_list(tags)
244     except:
245         tag_list = []
246
247     # Prefix must have at least 2 characters
248     if len(prefix) < 2:
249         return render_to_response('catalogue/search_too_short.html', {'tags':tag_list, 'prefix':prefix},
250             context_instance=RequestContext(request))
251     
252     result = _tags_exact_matches(prefix, request.user)
253     
254     if len(result) > 1:
255         # multiple exact matches
256         return render_to_response('catalogue/search_multiple_hits.html', 
257             {'tags':tag_list, 'prefix':prefix, 'results':((x, _get_result_link(x, tag_list), _get_result_type(x)) for x in result)},
258             context_instance=RequestContext(request))
259     
260     if not result:
261         # no exact matches
262         result = _tags_starting_with(prefix, request.user)
263     
264     if result:
265         return HttpResponseRedirect(_get_result_link(result[0], tag_list))
266     else:
267         return render_to_response('catalogue/search_no_hits.html', {'tags':tag_list, 'prefix':prefix},
268             context_instance=RequestContext(request))
269
270
271 def tags_starting_with(request):
272     prefix = request.GET.get('q', '')
273     # Prefix must have at least 2 characters
274     if len(prefix) < 2:
275         return HttpResponse('')
276     
277     return HttpResponse('\n'.join(tag.name for tag in _tags_starting_with(prefix, request.user)))
278
279
280 # ====================
281 # = Shelf management =
282 # ====================
283 @login_required
284 @cache.never_cache
285 def user_shelves(request):
286     shelves = models.Tag.objects.filter(category='set', user=request.user)
287     new_set_form = forms.NewSetForm()
288     return render_to_response('catalogue/user_shelves.html', locals(),
289             context_instance=RequestContext(request))
290
291 @cache.never_cache
292 def book_sets(request, slug):
293     book = get_object_or_404(models.Book, slug=slug)
294     user_sets = models.Tag.objects.filter(category='set', user=request.user)
295     book_sets = book.tags.filter(category='set', user=request.user)
296     
297     if not request.user.is_authenticated():
298         return HttpResponse('<p>Aby zarządzać swoimi półkami, musisz się zalogować.</p>')
299     
300     if request.method == 'POST':
301         form = forms.ObjectSetsForm(book, request.user, request.POST)
302         if form.is_valid():
303             old_shelves = list(book.tags.filter(category='set'))
304             new_shelves = [models.Tag.objects.get(pk=id) for id in form.cleaned_data['set_ids']]
305             
306             for shelf in [shelf for shelf in old_shelves if shelf not in new_shelves]:
307                 shelf.book_count -= 1
308                 shelf.save()
309                 
310             for shelf in [shelf for shelf in new_shelves if shelf not in old_shelves]:
311                 shelf.book_count += 1
312                 shelf.save()
313             
314             book.tags = new_shelves + list(book.tags.filter(~Q(category='set') | ~Q(user=request.user)))
315             if request.is_ajax():
316                 return HttpResponse('<p>Półki zostały zapisane.</p>')
317             else:
318                 return HttpResponseRedirect('/')
319     else:
320         form = forms.ObjectSetsForm(book, request.user)
321         new_set_form = forms.NewSetForm()
322     
323     return render_to_response('catalogue/book_sets.html', locals(),
324         context_instance=RequestContext(request))
325
326
327 @login_required
328 @require_POST
329 @cache.never_cache
330 def remove_from_shelf(request, shelf, book):
331     book = get_object_or_404(models.Book, slug=book)
332     shelf = get_object_or_404(models.Tag, slug=shelf, category='set', user=request.user)
333     
334     if shelf in book.tags:
335         models.Tag.objects.remove_tag(book, shelf)
336
337         shelf.book_count -= 1
338         shelf.save()
339
340         return HttpResponse('Usunięto')
341     else:
342         return HttpResponse('Książki nie ma na półce')
343
344
345 def collect_books(books):
346     """
347     Returns all real books in collection.
348     """
349     result = []
350     for book in books:
351         if len(book.children.all()) == 0:
352             result.append(book)
353         else:
354             result += collect_books(book.children.all())
355     return result
356
357
358 @cache.never_cache
359 def download_shelf(request, slug):
360     """"
361     Create a ZIP archive on disk and transmit it in chunks of 8KB,
362     without loading the whole file into memory. A similar approach can
363     be used for large dynamic PDF files.                                        
364     """
365     shelf = get_object_or_404(models.Tag, slug=slug, category='set')
366     
367     formats = []
368     form = forms.DownloadFormatsForm(request.GET)
369     if form.is_valid():
370         formats = form.cleaned_data['formats']
371     if len(formats) == 0:
372         formats = ['pdf', 'odt', 'txt', 'mp3', 'ogg']
373     
374     # Create a ZIP archive
375     temp = temp = tempfile.TemporaryFile()
376     archive = zipfile.ZipFile(temp, 'w')
377     
378     for book in collect_books(models.Book.tagged.with_all(shelf)):
379         if 'pdf' in formats and book.pdf_file:
380             filename = book.pdf_file.path
381             archive.write(filename, str('%s.pdf' % book.slug))
382         if 'odt' in formats and book.odt_file:
383             filename = book.odt_file.path
384             archive.write(filename, str('%s.odt' % book.slug))
385         if 'txt' in formats and book.txt_file:
386             filename = book.txt_file.path
387             archive.write(filename, str('%s.txt' % book.slug))
388         if 'mp3' in formats and book.mp3_file:
389             filename = book.mp3_file.path
390             archive.write(filename, str('%s.mp3' % book.slug))
391         if 'ogg' in formats and book.ogg_file:
392             filename = book.ogg_file.path
393             archive.write(filename, str('%s.ogg' % book.slug))
394     archive.close()
395     
396     response = HttpResponse(content_type='application/zip', mimetype='application/x-zip-compressed')
397     response['Content-Disposition'] = 'attachment; filename=%s.zip' % shelf.sort_key
398     response['Content-Length'] = temp.tell()
399     
400     temp.seek(0)
401     response.write(temp.read())
402     return response
403
404
405 @cache.never_cache
406 def shelf_book_formats(request, shelf):
407     """"
408     Returns a list of formats of books in shelf.
409     """
410     shelf = get_object_or_404(models.Tag, slug=shelf, category='set')
411
412     formats = {'pdf': False, 'odt': False, 'txt': False, 'mp3': False, 'ogg': False}
413     
414     for book in collect_books(models.Book.tagged.with_all(shelf)):
415         if book.pdf_file:
416             formats['pdf'] = True
417         if book.odt_file:
418             formats['odt'] = True
419         if book.txt_file:
420             formats['txt'] = True
421         if book.mp3_file:
422             formats['mp3'] = True
423         if book.ogg_file:
424             formats['ogg'] = True
425
426     return HttpResponse(LazyEncoder().encode(formats))
427
428
429 @login_required
430 @require_POST
431 @cache.never_cache
432 def new_set(request):
433     new_set_form = forms.NewSetForm(request.POST)
434     if new_set_form.is_valid():
435         new_set = new_set_form.save(request.user)
436
437         if request.is_ajax():
438             return HttpResponse(u'<p>Półka <strong>%s</strong> została utworzona</p>' % new_set)
439         else:
440             return HttpResponseRedirect('/')
441
442     return HttpResponseRedirect('/')
443
444
445 @login_required
446 @require_POST
447 @cache.never_cache
448 def delete_shelf(request, slug):
449     user_set = get_object_or_404(models.Tag, slug=slug, category='set', user=request.user)
450     user_set.delete()
451
452     if request.is_ajax():
453         return HttpResponse(u'<p>Półka <strong>%s</strong> została usunięta</p>' % user_set.name)
454     else:
455         return HttpResponseRedirect('/')
456
457
458 # ==================
459 # = Authentication =
460 # ==================
461 @require_POST
462 @cache.never_cache
463 def login(request):
464     form = AuthenticationForm(data=request.POST, prefix='login')
465     if form.is_valid():
466         auth.login(request, form.get_user())
467         response_data = {'success': True, 'errors': {}}
468     else:
469         response_data = {'success': False, 'errors': form.errors}
470     return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
471
472
473 @require_POST
474 @cache.never_cache
475 def register(request):
476     registration_form = UserCreationForm(request.POST, prefix='registration')
477     if registration_form.is_valid():
478         user = registration_form.save()
479         user = auth.authenticate(
480             username=registration_form.cleaned_data['username'], 
481             password=registration_form.cleaned_data['password1']
482         )
483         auth.login(request, user)
484         response_data = {'success': True, 'errors': {}}
485     else:
486         response_data = {'success': False, 'errors': registration_form.errors}
487     return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data))
488
489
490 @cache.never_cache
491 def logout_then_redirect(request):
492     auth.logout(request)
493     return HttpResponseRedirect(urlquote_plus(request.GET.get('next', '/'), safe='/?='))
494
495
496
497 # =========
498 # = Admin =
499 # =========
500 @login_required
501 @staff_required
502 def import_book(request):
503     """docstring for import_book"""
504     book_import_form = forms.BookImportForm(request.POST, request.FILES)
505     if book_import_form.is_valid():
506         try:
507             book_import_form.save()
508         except:
509             info = sys.exc_info()
510             exception = pprint.pformat(info[1])
511             tb = '\n'.join(traceback.format_tb(info[2]))
512             return HttpResponse("An error occurred: %s\n\n%s" % (exception, tb), mimetype='text/plain')
513         return HttpResponse("Book imported successfully")
514     else:
515         return HttpResponse("Error importing file: %r" % book_import_form.errors)
516
517
518
519 def clock(request):
520     """ Provides server time for jquery.countdown,
521     in a format suitable for Date.parse()
522     """
523     from datetime import datetime
524     return HttpResponse(datetime.now().strftime('%Y/%m/%d %H:%M:%S'))