ptrad
[redakcja.git] / src / wiki / views.py
1 # This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
3 #
4 from datetime import datetime
5 import json
6 import os
7 import logging
8 from time import mktime
9 from urllib.parse import quote
10
11 from django.apps import apps
12 from django.conf import settings
13 from django.urls import reverse
14 from django import http
15 from django.http import Http404, HttpResponse, HttpResponseForbidden, HttpResponseBadRequest
16 from django.middleware.gzip import GZipMiddleware
17 from django.utils.decorators import decorator_from_middleware
18 from django.utils.formats import localize
19 from django.utils.translation import gettext as _
20 from django.views.decorators.http import require_POST, require_GET
21 from django.shortcuts import get_object_or_404, render
22 from django_gravatar.helpers import get_gravatar_url
23 from sorl.thumbnail import get_thumbnail
24
25 from documents.models import Book, Chunk
26 import sources.models
27 from . import nice_diff
28 from team.models import Presence
29 from wiki import forms
30 from wiki.helpers import (JSONResponse, JSONFormInvalid, JSONServerError,
31                 ajax_require_permission)
32 from wiki.models import Theme
33
34 #
35 # Quick hack around caching problems, TODO: use ETags
36 #
37 from django.views.decorators.cache import never_cache
38
39 logger = logging.getLogger("fnp.wiki")
40
41 MAX_LAST_DOCS = 10
42
43
44 class HttpResponseLengthRequired(HttpResponse):
45     status_code = 411
46
47
48 @never_cache
49 def editor(request, slug, chunk=None, template_name='wiki/document_details.html'):
50     try:
51         chunk = Chunk.get(slug, chunk)
52     except Chunk.MultipleObjectsReturned:
53         # TODO: choice page
54         raise Http404
55     except Chunk.DoesNotExist:
56         if chunk is None:
57             try:
58                 book = Book.objects.get(slug=slug)
59             except Book.DoesNotExist:
60                 return http.HttpResponseRedirect(reverse("documents_create_missing", args=[slug]))
61         else:
62             raise Http404
63     if not chunk.book.accessible(request):
64         return HttpResponseForbidden("Not authorized.")
65
66     access_time = datetime.now()
67     last_books = request.session.get("wiki_last_books", {})
68     last_books[reverse(editor, args=[chunk.book.slug, chunk.slug])] = {
69         'time': mktime(access_time.timetuple()),
70         'title': chunk.pretty_name(),
71         }
72
73     if len(last_books) > MAX_LAST_DOCS:
74         oldest_key = min(last_books, key=lambda x: last_books[x]['time'])
75         del last_books[oldest_key]
76     request.session['wiki_last_books'] = last_books
77
78     return render(request, template_name, {
79         'chunk': chunk,
80         'forms': {
81             "text_save": forms.DocumentTextSaveForm(user=request.user, prefix="textsave"),
82             "text_revert": forms.DocumentTextRevertForm(prefix="textrevert"),
83             "pubmark": forms.DocumentPubmarkForm(prefix="pubmark"),
84         },
85         'can_pubmark': request.user.has_perm('documents.can_pubmark'),
86         'REDMINE_URL': settings.REDMINE_URL,
87     })
88
89
90 def editor_user_area(request):
91     return render(request, 'wiki/editor-user-area.html', {
92         'forms': {
93             "text_save": forms.DocumentTextSaveForm(user=request.user, prefix="textsave"),
94             "text_revert": forms.DocumentTextRevertForm(prefix="textrevert"),
95             "pubmark": forms.DocumentPubmarkForm(prefix="pubmark"),
96         },
97         'can_pubmark': request.user.has_perm('documents.can_pubmark'),
98     })
99
100
101 @require_GET
102 def editor_readonly(request, slug, chunk=None, template_name='wiki/document_details_readonly.html'):
103     try:
104         chunk = Chunk.get(slug, chunk)
105         revision = request.GET['revision']
106     except (Chunk.MultipleObjectsReturned, Chunk.DoesNotExist, KeyError):
107         raise Http404
108     if not chunk.book.accessible(request):
109         return HttpResponseForbidden("Not authorized.")
110
111     access_time = datetime.now()
112     last_books = request.session.get("wiki_last_books", {})
113     last_books[slug, chunk.slug] = {
114         'time': mktime(access_time.timetuple()),
115         'title': chunk.book.title,
116         }
117
118     if len(last_books) > MAX_LAST_DOCS:
119         oldest_key = min(last_books, key=lambda x: last_books[x]['time'])
120         del last_books[oldest_key]
121     request.session['wiki_last_books'] = last_books
122
123     return render(request, template_name, {
124         'chunk': chunk,
125         'revision': revision,
126         'readonly': True,
127         'REDMINE_URL': settings.REDMINE_URL,
128     })
129
130
131 @never_cache
132 @decorator_from_middleware(GZipMiddleware)
133 def text(request, chunk_id):
134     doc = get_object_or_404(Chunk, pk=chunk_id)
135     if not doc.book.accessible(request):
136         return HttpResponseForbidden("Not authorized.")
137
138     if request.method == 'POST':
139         # Check length to reject broken request.
140         try:
141             expected_cl = int(request.META['CONTENT_LENGTH'])
142         except:
143             return HttpResponseLengthRequired(json.dumps(
144                 {"__message": _("Content length required.")}
145             ))
146         # 411 if missing
147         cl = len(request.body)
148         if cl != expected_cl:
149             return HttpResponseBadRequest(json.dumps(
150                 {"__message": _("Wrong content length, request probably interrupted.")}
151             ))
152
153         form = forms.DocumentTextSaveForm(request.POST, user=request.user, prefix="textsave")
154         if form.is_valid():
155             if request.user.is_authenticated:
156                 author = request.user
157             else:
158                 author = None
159             text = form.cleaned_data['text']
160             parent_revision = form.cleaned_data['parent_revision']
161             if parent_revision is not None:
162                 parent = doc.at_revision(parent_revision)
163             else:
164                 parent = None
165             stage = form.cleaned_data['stage_completed']
166             tags = [stage] if stage else []
167             publishable = (form.cleaned_data['publishable'] and
168                     request.user.has_perm('documents.can_pubmark'))
169             doc.commit(author=author,
170                        text=text,
171                        parent=parent,
172                        description=form.cleaned_data['comment'],
173                        tags=tags,
174                        author_name=form.cleaned_data['author_name'],
175                        author_email=form.cleaned_data['author_email'],
176                        publishable=publishable,
177                        )
178             revision = doc.revision()
179             return JSONResponse({
180                 'text': doc.materialize() if parent_revision != revision else None,
181                 'meta': {},
182                 'revision': revision,
183             })
184         else:
185             return JSONFormInvalid(form)
186     else:
187         revision = request.GET.get("revision", None)
188         
189         try:
190             revision = int(revision)
191         except (ValueError, TypeError):
192             revision = doc.revision()
193
194         if revision is not None:
195             text = doc.at_revision(revision).materialize()
196         else:
197             text = ''
198
199         return JSONResponse({
200             'text': text,
201             'meta': {},
202             'revision': revision,
203         })
204
205
206 @never_cache
207 @require_POST
208 def revert(request, chunk_id):
209     form = forms.DocumentTextRevertForm(request.POST, prefix="textrevert")
210     if form.is_valid():
211         doc = get_object_or_404(Chunk, pk=chunk_id)
212         if not doc.book.accessible(request):
213             return HttpResponseForbidden("Not authorized.")
214
215         revision = form.cleaned_data['revision']
216
217         comment = form.cleaned_data['comment']
218         comment += "\n#revert to %s" % revision
219
220         if request.user.is_authenticated:
221             author = request.user
222         else:
223             author = None
224
225         before = doc.revision()
226         logger.info("Reverting %s to %s", chunk_id, revision)
227         doc.at_revision(revision).revert(author=author, description=comment)
228
229         return JSONResponse({
230             'text': doc.materialize() if before != doc.revision() else None,
231             'meta': {},
232             'revision': doc.revision(),
233         })
234     else:
235         return JSONFormInvalid(form)
236
237
238 @never_cache
239 def gallery(request, directory):
240     try:
241         base_url = ''.join((
242                         settings.MEDIA_URL,
243                         settings.IMAGE_DIR,
244                         directory))
245
246         base_dir = os.path.join(
247                     settings.MEDIA_ROOT,
248                     settings.IMAGE_DIR,
249                     directory)
250
251         def map_to_url(filename):
252             return quote(("%s/%s" % (base_url, filename)))
253
254         def is_image(filename):
255             return os.path.splitext(filename)[1].lower() in (u'.jpg', u'.jpeg', u'.png')
256
257         books = Book.objects.filter(gallery=directory)
258
259         if not all(book.public for book in books) and not request.user.is_authenticated:
260             return HttpResponseForbidden("Not authorized.")
261
262         images = [
263             {
264                 "url": map_to_url(f),
265                 "thumb": get_thumbnail(os.path.join(base_dir, f), '120x120').url
266             } for f in sorted(os.listdir(base_dir)) if is_image(f)
267         ]
268
269         return JSONResponse(images)
270     except (IndexError, OSError):
271         logger.exception("Unable to fetch gallery")
272         raise http.Http404
273
274
275 @never_cache
276 def scans_list(request, pks):
277     pks = pks.split(',')
278     bss = [
279         get_object_or_404(sources.models.BookSource, pk=pk)
280         for pk in pks
281     ]
282     def map_to_url(filename):
283         return quote(("%s/%s" % (settings.MEDIA_URL, filename)))
284     images = []
285     for bs in bss:
286         images.extend([
287             {
288                 "url": map_to_url(f),
289             } for f in bs.get_view_files()
290         ])
291     return JSONResponse(images)
292
293
294 @never_cache
295 def diff(request, chunk_id):
296     revA = int(request.GET.get('from', 0))
297     revB = int(request.GET.get('to', 0))
298
299     if revA > revB:
300         revA, revB = revB, revA
301
302     if revB == 0:
303         revB = None
304
305     doc = get_object_or_404(Chunk, pk=chunk_id)
306     if not doc.book.accessible(request):
307         return HttpResponseForbidden("Not authorized.")
308
309     # allow diff from the beginning
310     if revA:
311         docA = doc.at_revision(revA).materialize()
312     else:
313         docA = ""
314     docB = doc.at_revision(revB).materialize()
315
316     return http.HttpResponse(nice_diff.html_diff_table(docA.splitlines(),
317                                          docB.splitlines(), context=3))
318
319
320 @never_cache
321 def revision(request, chunk_id):
322     if not request.session.session_key:
323         return HttpResponseForbidden("Not authorized.")
324     doc = get_object_or_404(Chunk, pk=chunk_id)
325     if not doc.book.accessible(request):
326         return HttpResponseForbidden("Not authorized.")
327
328     Presence.report(
329         request.user, request.session.session_key,
330         doc,
331         request.GET.get('a') == 'true'
332     )
333
334     # Temporary compat for unreloaded clients.
335     if not request.GET.get('new'):
336         return http.HttpResponse(str(doc.revision()))
337
338     return JSONResponse({
339         'rev': doc.revision(),
340         'people': list([
341             {
342                 'name': (p.user.first_name + ' ' + p.user.last_name) if p.user is not None else '?',
343                 'gravatar': get_gravatar_url(p.user.email if p.user is not None else '-', size=26),
344                 'since': p.since.strftime('%H:%M'),
345                 'active': p.active,
346             }
347             for p in Presence.get_current(request.session.session_key, doc)
348         ]),
349     })
350
351
352 @never_cache
353 def history(request, chunk_id):
354     # TODO: pagination
355     doc = get_object_or_404(Chunk, pk=chunk_id)
356     if not doc.book.accessible(request):
357         return HttpResponseForbidden("Not authorized.")
358
359     history = doc.history()
360     try:
361         before = int(request.GET.get('before'))
362     except:
363         pass
364     else:
365         history = history.filter(revision__lt=before)
366     changes = []
367     for change in history.reverse()[:20]:
368         changes.append({
369                 "version": change.revision,
370                 "description": change.description,
371                 "author": change.author_str(),
372                 "date": localize(change.created_at),
373                 "publishable": change.publishable,
374                 "tag": ',\n'.join(str(tag) for tag in change.tags.all()),
375                 "published": _("Published") + ": " + \
376                     localize(change.publish_log.order_by('-book_record__timestamp')[0].book_record.timestamp) \
377                     if change.publish_log.exists() else "",
378             })
379     return JSONResponse(changes)
380
381
382 @require_POST
383 @ajax_require_permission('documents.can_pubmark')
384 def pubmark(request, chunk_id):
385     form = forms.DocumentPubmarkForm(request.POST, prefix="pubmark")
386     if form.is_valid():
387         doc = get_object_or_404(Chunk, pk=chunk_id)
388         if not doc.book.accessible(request):
389             return HttpResponseForbidden("Not authorized.")
390
391         revision = form.cleaned_data['revision']
392         publishable = form.cleaned_data['publishable']
393         change = doc.at_revision(revision)
394         if publishable != change.publishable:
395             change.set_publishable(publishable)
396             return JSONResponse({"message": _("Revision marked")})
397         else:
398             return JSONResponse({"message": _("Nothing changed")})
399     else:
400         return JSONFormInvalid(form)
401
402
403 @require_POST
404 @ajax_require_permission('documents.book_edit')
405 def set_gallery(request, chunk_id):
406     doc = get_object_or_404(Chunk, pk=chunk_id)
407     book = doc.book
408     book.gallery = request.POST['gallery']
409     book.save(update_fields=['gallery'])
410     return JSONResponse({})
411
412 @require_POST
413 @ajax_require_permission('documents.chunk_edit')
414 def set_gallery_start(request, chunk_id):
415     doc = get_object_or_404(Chunk, pk=chunk_id)
416     doc.gallery_start = request.POST['start']
417     doc.save(update_fields=['gallery_start'])
418     return JSONResponse({})
419
420 @ajax_require_permission('documents.chunk_edit')
421 def galleries(request):
422     return JSONResponse(
423         sorted(
424             os.listdir(
425                 os.path.join(
426                     settings.MEDIA_ROOT,
427                     settings.IMAGE_DIR,
428                 )
429             )
430         )
431     )
432
433 def themes(request):
434     prefix = request.GET.get('q', '')
435     return http.HttpResponse('\n'.join([str(t) for t in Theme.objects.filter(name__istartswith=prefix)]))
436
437
438 def back(request):
439     return render(request, 'wiki/back.html')