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