4204e770198f9d13bcd34a212679f0916de054ed
[redakcja.git] / apps / catalogue / views.py
1 # -*- coding: utf-8 -*-
2 #
3 # This file is part of MIL/PEER, licensed under GNU Affero GPLv3 or later.
4 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
5 #
6 import logging
7 import os
8 import shutil
9 import subprocess
10 from tempfile import NamedTemporaryFile
11 from xml.sax.saxutils import escape as escape_xml
12
13 from django.conf import settings
14 from django.contrib import auth
15 from django.contrib.auth.models import User
16 from django.contrib.auth.decorators import login_required
17 from django.contrib.sites.models import Site
18 from django.core.urlresolvers import reverse
19 from django import http
20 from django.http import Http404, HttpResponse, HttpResponseForbidden
21 from django.shortcuts import get_object_or_404, render, redirect
22 from django.utils.encoding import force_str
23 from django.utils.http import urlquote_plus
24 from django.views.decorators.http import require_POST
25 from unidecode import unidecode
26
27 from catalogue import forms
28 from catalogue.filters import DocumentFilterSet
29 from catalogue.forms import TagMultipleForm, TagSingleForm
30 from catalogue.helpers import active_tab
31 from catalogue.models import Category
32 from librarian import BuildError
33 from librarian.utils import Context
34 from librarian.document import Document as SST
35 from librarian.formats.html import HtmlFormat
36 from librarian.formats.pdf import PdfFormat
37 from librarian.formats.epub import EpubFormat
38 from redakcja.utlis import send_notify_email
39 from .constants import STAGES
40 from .models import Document, Plan
41 from dvcs.models import Revision
42 from organizations.models import Organization
43 from fileupload.views import UploadView
44
45 #
46 # Quick hack around caching problems, TODO: use ETags
47 #
48 from django.views.decorators.cache import never_cache
49 # from fnpdjango.utils.text.slughifi import slughifi
50
51 logger = logging.getLogger("fnp.catalogue")
52
53
54 @never_cache
55 def user(request, username):
56     user = get_object_or_404(User, username=username)
57     return render(request, 'catalogue/user_page.html', {"viewed_user": user})
58
59
60 @login_required
61 @active_tab('my')
62 @never_cache
63 def my(request):
64     return render(request, 'catalogue/my_page.html', {
65         'last_books': sorted(
66             request.session.get("wiki_last_books", {}).items(), key=lambda x: x[1]['time'], reverse=True),
67
68         "logout_to": '/',
69         })
70
71
72 @never_cache
73 def logout_then_redirect(request):
74     auth.logout(request)
75     return http.HttpResponseRedirect(urlquote_plus(request.GET.get('next', '/'), safe='/?='))
76
77
78 @login_required
79 @active_tab('create')
80 def create_missing(request):
81     if request.method == "POST":
82         form = forms.DocumentCreateForm(request.POST, request.FILES)
83         tag_forms = [
84             (TagMultipleForm if category.multiple else TagSingleForm)(
85                 category=category, data=request.POST, prefix=category.dc_tag)
86             for category in Category.objects.all()]
87         if form.is_valid() and all(tag_form.is_valid() for tag_form in tag_forms):
88             
89             if request.user.is_authenticated():
90                 creator = request.user
91             else:
92                 creator = None
93
94             title = form.cleaned_data['title']
95             try:
96                 org = request.user.membership_set.get(
97                     organization=int(form.cleaned_data['owner_organization'])).organization
98                 kwargs = {'owner_organization': org}
99             except:
100                 kwargs = {'owner_user': request.user}
101
102             doc = Document.objects.create(**kwargs)
103
104             for tag_form in tag_forms:
105                 tag_form.save(instance=doc)
106
107             cover = request.FILES.get('cover')
108             if cover:
109                 uppath = 'uploads/%d/' % doc.pk
110                 path = settings.MEDIA_ROOT + uppath
111                 if not os.path.isdir(path):
112                     os.makedirs(path)
113                 cover.name = unidecode(cover.name)
114                 dest_path = path + cover.name
115                 if not os.path.abspath(dest_path).startswith(os.path.abspath(path)):
116                     raise Http404
117                 with open(dest_path, 'w') as destination:
118                     for chunk in cover.chunks():
119                         destination.write(chunk)
120                 cover_url = 'http://milpeer.eu/media/dynamic/' + uppath + cover.name
121             else:
122                 cover_url = ''
123
124             text = '''<section xmlns="http://nowoczesnapolska.org.pl/sst#" xmlns:dc="http://purl.org/dc/elements/1.1/">
125                 <metadata>
126                     <dc:publisher>%s</dc:publisher>
127                     <dc:description>%s</dc:description>
128                     %s
129                     <dc:relation.coverImage.url>%s</dc:relation.coverImage.url>
130                 </metadata>
131                 <header>%s</header>
132                 <div class="p"> </div>
133                 </section>''' % (
134                     escape_xml(form.cleaned_data['publisher']),
135                     escape_xml(form.cleaned_data['description']),
136                     '\n'.join(tag_form.metadata_rows() for tag_form in tag_forms),
137                     escape_xml(cover_url),
138                     escape_xml(title))
139
140             doc.commit(
141                 text=text,
142                 author=creator
143             )
144             doc.assigned_to = request.user
145             doc.save()
146
147             return http.HttpResponseRedirect(reverse("wiki_editor", args=[doc.pk]))
148     else:
149         org_pk = request.GET.get('organization')
150         if org_pk:
151             try:
152                 org = Organization.objects.get(pk=org_pk)
153             except Organization.DoesNotExist:
154                 org = None
155             else:
156                 if not org.is_member(request.user):
157                     org = None
158         else:
159             org = None
160         if org is not None:
161             org = org.pk
162
163         form = forms.DocumentCreateForm(initial={'owner_organization': org})
164
165         tag_forms = [
166             (TagMultipleForm if category.multiple else TagSingleForm)(
167                 category=category, tutorial_no=i, prefix=category.dc_tag)
168             for i, category in enumerate(Category.objects.all(), start=2)]
169
170     return render(request, "catalogue/document_create_missing.html", {
171         "form": form,
172         "tag_forms": tag_forms,
173
174         "logout_to": '/',
175     })
176
177
178 @never_cache
179 def book_html(request, pk, rev_pk=None, preview=False):
180     doc = get_object_or_404(Document, pk=pk, deleted=False)
181
182     try:
183         published_revision = doc.publish_log.all()[0].revision
184     except IndexError:
185         published_revision = None
186
187     if rev_pk is None:
188         if preview:
189             revision = doc.revision
190         else:
191             if published_revision is not None:
192                 revision = published_revision
193             else:
194                 # No published version, fallback to preview mode.
195                 preview = True
196                 revision = doc.revision
197     else:
198         revision = get_object_or_404(Revision, pk=rev_pk)
199
200     was_published = revision == published_revision or doc.publish_log.filter(revision=revision).exists()
201
202     try:
203         sst = SST.from_string(revision.materialize())
204     except ValueError as e:
205         html = e
206     else:
207         html = HtmlFormat(sst).build(
208             files_path='http://%s/media/dynamic/uploads/%s/' % (request.get_host(), pk)).get_string()
209
210     # response = http.HttpResponse(html, content_type='text/html', mimetype='text/html')
211     # return response
212     # book_themes = {}
213     # for fragment in book.fragments.all().iterator():
214     #     for theme in fragment.tags.filter(category='theme').iterator():
215     #         book_themes.setdefault(theme, []).append(fragment)
216
217     # book_themes = book_themes.items()
218     # book_themes.sort(key=lambda s: s[0].sort_key)
219     return render(request, 'catalogue/book_text.html', {
220         'doc': doc,
221         'preview': preview,
222         'revision': revision,
223         'published_revision': published_revision,
224         'specific': rev_pk is not None,
225         'html': html,
226         'can_edit': doc.can_edit(request.user) if doc else None,
227         'was_published': was_published,
228     })
229
230
231 @never_cache
232 def book_pdf(request, pk, rev_pk):
233     doc = get_object_or_404(Document, pk=pk)
234     rev = get_object_or_404(Revision, pk=rev_pk)
235     # Test
236
237     try:
238         sst = SST.from_string(rev.materialize())
239     except ValueError as e:
240         return HttpResponse(content=force_str(e.message), content_type='text/plain', status='400')
241     
242     ctx = Context(
243         files_path='http://%s/media/dynamic/uploads/%s/' % (request.get_host(), pk),
244         source_url='http://%s%s' % (request.get_host(), reverse('catalogue_html', args=[doc.pk])),
245         organization=doc.owner_organization.name if doc.owner_organization else doc.owner_user.get_full_name(),
246     )
247     if doc.owner_organization is not None and doc.owner_organization.logo:
248         ctx.cover_logo = 'http://%s%s' % (request.get_host(), doc.owner_organization.logo.url)
249     try:
250         pdf_file = PdfFormat(sst).build(ctx)
251     except BuildError as e:
252         return HttpResponse(content=force_str(e.message), content_type='text/plain', status='400')
253
254     from catalogue.ebook_utils import serve_file
255     return serve_file(pdf_file.get_filename(), '%d.pdf' % doc.pk, 'application/pdf')
256
257
258 @never_cache
259 def book_epub(request, pk, rev_pk):
260     doc = get_object_or_404(Document, pk=pk)
261     rev = get_object_or_404(Revision, pk=rev_pk)
262     # Test
263
264     try:
265         sst = SST.from_string(rev.materialize())
266     except ValueError as e:
267         return HttpResponse(content=force_str(e.message), content_type='text/plain', status='400')
268
269     ctx = Context(
270         files_path='http://%s/media/dynamic/uploads/%s/' % (request.get_host(), pk),
271         source_url='http://%s%s' % (request.get_host(), reverse('catalogue_html', args=[doc.pk])),
272     )
273     if doc.owner_organization is not None and doc.owner_organization.logo:
274         ctx.cover_logo = 'http://%s%s' % (request.get_host(), doc.owner_organization.logo.url)
275     try:
276         epub_file = EpubFormat(sst).build(ctx)
277     except BuildError as e:
278         return HttpResponse(content=force_str(e.message), content_type='text/plain', status='400')
279
280     from catalogue.ebook_utils import serve_file
281     return serve_file(epub_file.get_filename(), '%d.epub' % doc.pk, 'application/epub+zip')
282
283
284 @never_cache
285 def book_mobi(request, pk, rev_pk):
286     doc = get_object_or_404(Document, pk=pk)
287     rev = get_object_or_404(Revision, pk=rev_pk)
288
289     try:
290         sst = SST.from_string(rev.materialize())
291     except ValueError as e:
292         return HttpResponse(content=force_str(e.message), content_type='text/plain', status='400')
293
294     ctx = Context(
295         files_path='http://%s/media/dynamic/uploads/%s/' % (request.get_host(), pk),
296         source_url='http://%s%s' % (request.get_host(), reverse('catalogue_html', args=[doc.pk])),
297     )
298     if doc.owner_organization is not None and doc.owner_organization.logo:
299         ctx.cover_logo = 'http://%s%s' % (request.get_host(), doc.owner_organization.logo.url)
300     try:
301         epub_file = EpubFormat(sst).build(ctx)
302     except BuildError as e:
303         return HttpResponse(content=force_str(e.message), content_type='text/plain', status='400')
304
305     output_file = NamedTemporaryFile(prefix='librarian', suffix='.mobi', delete=False)
306     output_file.close()
307     subprocess.check_call(
308         ['ebook-convert', epub_file.get_filename(), output_file.name, '--no-inline-toc'])
309
310     from catalogue.ebook_utils import serve_file
311     return serve_file(output_file.name, '%d.mobi' % doc.pk, 'application/epub+zip')
312
313
314 # @never_cache
315 # def revision(request, slug, chunk=None):
316 #     try:
317 #         doc = Chunk.get(slug, chunk)
318 #     except (Chunk.MultipleObjectsReturned, Chunk.DoesNotExist):
319 #         raise Http404
320 #     if not doc.book.accessible(request):
321 #         return HttpResponseForbidden("Not authorized.")
322 #     return http.HttpResponse(str(doc.revision()))
323
324
325 @login_required
326 def book_schedule(request, pk):
327     book = get_object_or_404(Document, pk=pk, deleted=False)
328     if not book.can_edit(request.user):
329         return HttpResponseForbidden("Not authorized.")
330     if request.method == 'POST':
331         Plan.objects.filter(document=book).delete()
332         for i, (s, name) in enumerate(STAGES):
333             user_id = request.POST.get('s%d-user' % i)
334             deadline = request.POST.get('s%d-deadline' % i) or None
335             Plan.objects.create(document=book, stage=s, user_id=user_id, deadline=deadline)
336
337         book.set_stage(request.POST.get('stage', ''))
338         return redirect('catalogue_user')
339
340     current = {}
341     for p in Plan.objects.filter(document=book):
342         current[p.stage] = (getattr(p.user, 'pk', None), (p.deadline.isoformat() if p.deadline else None))
343
344     schedule = [(i, s, current.get(s, ())) for i, (s, name) in enumerate(STAGES)]
345     
346     if book.owner_organization:
347         people = [m.user for m in book.owner_organization.membership_set.exclude(status='pending')]
348     else:
349         people = [book.owner_user]
350     return render(request, 'catalogue/book_schedule.html', {
351         'book': book,
352         'schedule': schedule,
353         'people': people,
354     })
355
356
357 @login_required
358 def book_owner(request, pk):
359     doc = get_object_or_404(Document, pk=pk, deleted=False)
360     if not doc.can_edit(request.user):
361         return HttpResponseForbidden("Not authorized.")
362
363     error = ''
364
365     if request.method == 'POST':
366         # TODO: real form
367         new_org_pk = request.POST.get('owner_organization')
368         if not new_org_pk:
369             doc.owner_organization = None
370             doc.owner_user = request.user
371             doc.save()
372         else:
373             org = Organization.objects.get(pk=new_org_pk)
374             if not org.is_member(request.user):
375                 error = 'Bad organization'
376             else:
377                 doc.owner_organization = org
378                 doc.owner_user = None
379                 doc.save()
380         if not error:
381             return redirect('catalogue_user')
382
383     return render(request, 'catalogue/book_owner.html', {
384         'doc': doc,
385         'error': error,
386     })
387
388
389 @login_required
390 def book_delete(request, pk):
391     doc = get_object_or_404(Document, pk=pk, deleted=False)
392     if not doc.can_edit(request.user):
393         return HttpResponseForbidden("Not authorized.")
394
395     if request.method == 'POST':
396         doc.deleted = True
397         doc.save()
398         return redirect('catalogue_user')
399
400     return render(request, 'catalogue/book_delete.html', {
401         'doc': doc,
402     })
403
404
405 @require_POST
406 @login_required
407 def publish(request, pk):
408     from wiki import forms
409     from .models import PublishRecord
410     from dvcs.models import Revision
411
412     doc = get_object_or_404(Document, pk=pk, deleted=False)
413     if not doc.can_edit(request.user):
414         return HttpResponseForbidden("Not authorized.")
415     form = forms.DocumentTextPublishForm(request.POST, prefix="textpublish")
416     if form.is_valid():
417         rev = Revision.objects.get(pk=form.cleaned_data['revision'])
418         # FIXME: check if in tree
419         # if PublishRecord.objects.filter(revision=rev, document=doc).exists():
420         #     return http.HttpResponse('exists')
421         if not doc.published:
422             site = Site.objects.get_current()
423             send_notify_email(
424                 'New published document in MIL/PEER',
425                 '''New published document in MIL/PEER: %s. View it in browser: https://%s%s.
426
427 --
428 MIL/PEER team.''' % (doc.meta()['title'], site.domain, reverse('catalogue_html', args=[doc.pk])))
429         PublishRecord.objects.create(revision=rev, document=doc, user=request.user)
430         doc.published = True
431         doc.save()
432         if request.is_ajax():
433             return http.HttpResponse('ok')
434         else:
435             return redirect('catalogue_html', doc.pk)
436     else:
437         if request.is_ajax():
438             return http.HttpResponse('error')
439         else:
440             try:
441                 return redirect('catalogue_preview_rev', doc.pk, form.cleaned_data['revision'])
442             except KeyError:
443                 return redirect('catalogue_preview', doc.pk)
444
445
446 @require_POST
447 @login_required
448 def unpublish(request, pk):
449     doc = get_object_or_404(Document, pk=pk, deleted=False)
450     if not doc.can_edit(request.user):
451         return HttpResponseForbidden("Not authorized.")
452
453     doc.publish_log.all().delete()
454     if request.is_ajax():
455         return http.HttpResponse('ok')
456     else:
457         return redirect('catalogue_html', doc.pk)
458
459
460 class GalleryMixin(object):
461     def get_directory(self):
462         # return "%s%s/" % (settings.IMAGE_DIR, 'org%d' % self.org.pk if self.org is not None else self.request.user.pk)
463         return "uploads/%d/" % self.doc.pk
464
465
466 class GalleryView(GalleryMixin, UploadView):
467
468     def breadcrumbs(self):
469         return [
470                 (self.doc.meta()['title'], '/documents/%d/' % self.doc.pk),
471             ]
472
473     def get_object(self, request, pk=None):
474         self.doc = Document.objects.get(pk=pk, deleted=False)
475
476
477 @login_required
478 def fork(request, pk):
479     doc = get_object_or_404(Document, pk=pk, deleted=False)
480     if request.method == "POST":
481         form = forms.DocumentForkForm(request.POST, request.FILES)
482         if form.is_valid():
483             try:
484                 org = request.user.membership_set.get(
485                     organization=int(form.cleaned_data['owner_organization'])).organization
486                 kwargs = {'owner_organization': org}
487             except:
488                 kwargs = {'owner_user': request.user}
489
490             new_doc = Document.objects.create(revision=doc.revision, **kwargs)
491
492             if os.path.isdir(settings.MEDIA_ROOT + "uploads/%d" % doc.pk):
493                 shutil.copytree(
494                     settings.MEDIA_ROOT + "uploads/%d" % doc.pk,
495                     settings.MEDIA_ROOT + "uploads/%d" % new_doc.pk
496                 )
497
498             new_doc.assigned_to = request.user
499             new_doc.save()
500
501             return http.HttpResponseRedirect(reverse("wiki_editor", args=[new_doc.pk]))
502     else:
503         form = forms.DocumentForkForm()
504
505     return render(request, "catalogue/document_fork.html", {
506         "form": form,
507
508         "logout_to": '/',
509     })
510
511
512 def upcoming(request):
513     f = DocumentFilterSet(request.GET, queryset=Document.objects.filter(deleted=False).filter(publish_log=None))
514     return render(request, "catalogue/upcoming.html", {
515         'filter_set': f,
516         'link_url': 'catalogue_preview',
517     })
518
519
520 def finished(request):
521     f = DocumentFilterSet(request.GET, queryset=Document.objects.filter(deleted=False).exclude(publish_log=None))
522     return render(request, "catalogue/finished.html", {
523         'filter_set': f,
524         'link_url': 'catalogue_html',
525     })