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