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