View unused covers.
[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, pks):
275     pks = pks.split(',')
276     bss = [
277         get_object_or_404(sources.models.BookSource, pk=pk)
278         for pk in pks
279     ]
280     def map_to_url(filename):
281         return quote(("%s/%s" % (settings.MEDIA_URL, filename)))
282     images = []
283     for bs in bss:
284         images.extend([
285             {
286                 "url": map_to_url(f),
287             } for f in bs.get_view_files()
288         ])
289     return JSONResponse(images)
290
291
292 @never_cache
293 def diff(request, chunk_id):
294     revA = int(request.GET.get('from', 0))
295     revB = int(request.GET.get('to', 0))
296
297     if revA > revB:
298         revA, revB = revB, revA
299
300     if revB == 0:
301         revB = None
302
303     doc = get_object_or_404(Chunk, pk=chunk_id)
304     if not doc.book.accessible(request):
305         return HttpResponseForbidden("Not authorized.")
306
307     # allow diff from the beginning
308     if revA:
309         docA = doc.at_revision(revA).materialize()
310     else:
311         docA = ""
312     docB = doc.at_revision(revB).materialize()
313
314     return http.HttpResponse(nice_diff.html_diff_table(docA.splitlines(),
315                                          docB.splitlines(), context=3))
316
317
318 @never_cache
319 def revision(request, chunk_id):
320     doc = get_object_or_404(Chunk, pk=chunk_id)
321     if not doc.book.accessible(request):
322         return HttpResponseForbidden("Not authorized.")
323     Presence = apps.get_model('team', 'Presence')
324     Presence.report(request.user, doc, request.GET.get('a') == 'true')
325     return http.HttpResponse(str(doc.revision()))
326
327
328 @never_cache
329 def history(request, chunk_id):
330     # TODO: pagination
331     doc = get_object_or_404(Chunk, pk=chunk_id)
332     if not doc.book.accessible(request):
333         return HttpResponseForbidden("Not authorized.")
334
335     history = doc.history()
336     try:
337         before = int(request.GET.get('before'))
338     except:
339         pass
340     else:
341         history = history.filter(revision__lt=before)
342     changes = []
343     for change in history.reverse()[:20]:
344         changes.append({
345                 "version": change.revision,
346                 "description": change.description,
347                 "author": change.author_str(),
348                 "date": localize(change.created_at),
349                 "publishable": change.publishable,
350                 "tag": ',\n'.join(str(tag) for tag in change.tags.all()),
351                 "published": _("Published") + ": " + \
352                     localize(change.publish_log.order_by('-book_record__timestamp')[0].book_record.timestamp) \
353                     if change.publish_log.exists() else "",
354             })
355     return JSONResponse(changes)
356
357
358 @require_POST
359 @ajax_require_permission('documents.can_pubmark')
360 def pubmark(request, chunk_id):
361     form = forms.DocumentPubmarkForm(request.POST, prefix="pubmark")
362     if form.is_valid():
363         doc = get_object_or_404(Chunk, pk=chunk_id)
364         if not doc.book.accessible(request):
365             return HttpResponseForbidden("Not authorized.")
366
367         revision = form.cleaned_data['revision']
368         publishable = form.cleaned_data['publishable']
369         change = doc.at_revision(revision)
370         if publishable != change.publishable:
371             change.set_publishable(publishable)
372             return JSONResponse({"message": _("Revision marked")})
373         else:
374             return JSONResponse({"message": _("Nothing changed")})
375     else:
376         return JSONFormInvalid(form)
377
378
379 @require_POST
380 @ajax_require_permission('documents.book_edit')
381 def set_gallery(request, chunk_id):
382     doc = get_object_or_404(Chunk, pk=chunk_id)
383     book = doc.book
384     book.gallery = request.POST['gallery']
385     book.save(update_fields=['gallery'])
386     return JSONResponse({})
387
388 @require_POST
389 @ajax_require_permission('documents.chunk_edit')
390 def set_gallery_start(request, chunk_id):
391     doc = get_object_or_404(Chunk, pk=chunk_id)
392     doc.gallery_start = request.POST['start']
393     doc.save(update_fields=['gallery_start'])
394     return JSONResponse({})
395
396 @ajax_require_permission('documents.chunk_edit')
397 def galleries(request):
398     return JSONResponse(
399         sorted(
400             os.listdir(
401                 os.path.join(
402                     settings.MEDIA_ROOT,
403                     settings.IMAGE_DIR,
404                 )
405             )
406         )
407     )
408
409 def themes(request):
410     prefix = request.GET.get('q', '')
411     return http.HttpResponse('\n'.join([str(t) for t in Theme.objects.filter(name__istartswith=prefix)]))
412
413
414 def back(request):
415     return render(request, 'wiki/back.html')