Kolejne zmiany w fabfile i requirements.txt.
[redakcja.git] / apps / api / handlers / library_handlers.py
1 # -*- encoding: utf-8 -*-
2 import os.path
3 import logging
4
5 __author__= "Ɓukasz Rekucki"
6 __date__ = "$2009-09-25 15:49:50$"
7 __doc__ = "Module documentation."
8
9 from piston.handler import BaseHandler, AnonymousBaseHandler
10
11 import re
12 from datetime import date
13
14 from django.core.urlresolvers import reverse
15 from django.utils import simplejson as json
16 from django.db import IntegrityError
17
18 import librarian
19 import librarian.html
20 from librarian import dcparser, parser
21
22 from wlrepo import *
23 from explorer.models import PullRequest, GalleryForDocument
24
25 # internal imports
26 import api.forms as forms
27 import api.response as response
28 from api.utils import validate_form, hglibrary, natural_order
29 from api.models import PartCache
30
31 #
32 import settings
33
34
35 log = logging.getLogger('platforma.api')
36
37
38 #
39 # Document List Handlers
40 #
41 # TODO: security check
42 class BasicLibraryHandler(AnonymousBaseHandler):
43     allowed_methods = ('GET',)
44
45     @hglibrary
46     def read(self, request, lib):
47         """Return the list of documents."""       
48         document_list = [{
49             'url': reverse('document_view', args=[docid]),
50             'name': docid } for docid in lib.documents() ]
51
52         return {'documents' : document_list}
53         
54
55 #
56 # This handler controlls the document collection
57 #
58 class LibraryHandler(BaseHandler):
59     allowed_methods = ('GET', 'POST')
60     anonymous = BasicLibraryHandler
61
62
63     @hglibrary
64     def read(self, request, lib):
65         """Return the list of documents."""
66
67         documents = {}
68         
69         for docid in lib.documents():            
70             documents[docid] = {
71                 'url': reverse('document_view', args=[docid]),
72                 'name': docid,
73                 'parts': []
74             }
75
76         parts = PartCache.objects.defer('part_id')\
77             .values_list('part_id', 'document_id').distinct()
78        
79         document_tree = dict(documents)
80
81         for part, docid in parts:
82             # this way, we won't display broken links
83             if not documents.has_key(part):
84                 log.info("NOT FOUND: %s", part)
85                 continue
86
87             parent = documents[docid]
88             child = documents[part]
89
90             # not top-level anymore
91             document_tree.pop(part)
92             parent['parts'].append(child)
93         
94         for doc in documents.itervalues():
95             doc['parts'].sort(key=natural_order(lambda d: d['name']))
96             
97         return {'documents': sorted(document_tree.itervalues(),
98             key=natural_order(lambda d: d['name']) ) }
99
100     @validate_form(forms.DocumentUploadForm, 'POST')
101     @hglibrary
102     def create(self, request, form, lib):
103         """Create a new document."""       
104
105         if form.cleaned_data['ocr_data']:
106             data = form.cleaned_data['ocr_data']
107         else:            
108             data = request.FILES['ocr_file'].read().decode('utf-8')
109
110         if data is None:
111             return response.BadRequest().django_response('You must pass ocr_data or ocr_file.')
112
113         if form.cleaned_data['generate_dc']:
114             data = librarian.wrap_text(data, unicode(date.today()))
115
116         docid = form.cleaned_data['bookname']
117
118         try:
119             lock = lib.lock()            
120             try:
121                 log.info("DOCID %s", docid)
122                 doc = lib.document_create(docid)
123                 # document created, but no content yet
124
125                 try:
126                     doc = doc.quickwrite('xml', data.encode('utf-8'),
127                         '$AUTO$ XML data uploaded.', user=request.user.username)
128                 except Exception,e:
129                     import traceback
130                     # rollback branch creation
131                     lib._rollback()
132                     raise LibraryException(traceback.format_exc())
133
134                 url = reverse('document_view', args=[doc.id])
135
136                 return response.EntityCreated().django_response(\
137                     body = {
138                         'url': url,
139                         'name': doc.id,
140                         'revision': doc.revision },
141                     url = url )            
142             finally:
143                 lock.release()
144         except LibraryException, e:
145             import traceback
146             return response.InternalError().django_response({
147                 "reason": traceback.format_exc()
148             })
149         except DocumentAlreadyExists:
150             # Document is already there
151             return response.EntityConflict().django_response({
152                 "reason": "already-exists",
153                 "message": "Document already exists." % docid
154             })
155
156 #
157 # Document Handlers
158 #
159 class BasicDocumentHandler(AnonymousBaseHandler):
160     allowed_methods = ('GET',)
161
162     @hglibrary
163     def read(self, request, docid, lib):
164         try:    
165             doc = lib.document(docid)
166         except RevisionNotFound:
167             return rc.NOT_FOUND
168
169         result = {
170             'name': doc.id,
171             'html_url': reverse('dochtml_view', args=[doc.id]),
172             'text_url': reverse('doctext_view', args=[doc.id]),
173             'dc_url': reverse('docdc_view', args=[doc.id]),
174             'public_revision': doc.revision,
175         }
176
177         return result
178
179 #
180 # Document Meta Data
181 #
182 class DocumentHandler(BaseHandler):
183     allowed_methods = ('GET', 'PUT')
184     anonymous = BasicDocumentHandler
185
186     @hglibrary
187     def read(self, request, docid, lib):
188         """Read document's meta data"""       
189         log.info(u"Read %s (%s)" % (docid, type(docid)) )
190         try:
191             doc = lib.document(docid)
192             udoc = doc.take(request.user.username)
193         except RevisionNotFound, e:
194             return response.EntityNotFound().django_response({
195                 'exception': type(e), 'message': e.message,
196                 'docid': docid })
197
198         # is_shared = udoc.ancestorof(doc)
199         # is_uptodate = is_shared or shared.ancestorof(document)
200
201         result = {
202             'name': udoc.id,
203             'html_url': reverse('dochtml_view', args=[udoc.id]),
204             'text_url': reverse('doctext_view', args=[udoc.id]),
205             'dc_url': reverse('docdc_view', args=[udoc.id]),
206             'gallery_url': reverse('docgallery_view', args=[udoc.id]),
207             'merge_url': reverse('docmerge_view', args=[udoc.id]),
208             'user_revision': udoc.revision,
209             'user_timestamp': udoc.revision.timestamp,
210             'public_revision': doc.revision,
211             'public_timestamp': doc.revision.timestamp,
212         }       
213
214         return result
215
216     @hglibrary
217     def update(self, request, docid, lib):
218         """Update information about the document, like display not"""
219         return
220 #
221 #
222 #
223 class DocumentHTMLHandler(BaseHandler):
224     allowed_methods = ('GET')
225
226     @hglibrary
227     def read(self, request, docid, lib, stylesheet='partial'):
228         """Read document as html text"""
229         try:
230             revision = request.GET.get('revision', 'latest')
231
232             if revision == 'latest':
233                 document = lib.document(docid)
234             else:
235                 document = lib.document_for_rev(revision)
236
237             if document.id != docid:
238                 return response.BadRequest().django_response({'reason': 'name-mismatch',
239                     'message': 'Provided revision refers, to document "%s", but provided "%s"' % (document.id, docid) })
240
241             return librarian.html.transform(document.data('xml'), is_file=False, \
242                 parse_dublincore=False, stylesheet=stylesheet,\
243                 options={
244                     "with-paths": 'boolean(1)',                    
245                 })
246                 
247         except (EntryNotFound, RevisionNotFound), e:
248             return response.EntityNotFound().django_response({
249                 'reason': 'not-found', 'message': e.message})
250         except librarian.ParseError, e:
251             return response.InternalError().django_response({
252                 'reason': 'xml-parse-error', 'message': e.message })
253
254 #
255 # Image Gallery
256 #
257
258 class DocumentGalleryHandler(BaseHandler):
259     allowed_methods = ('GET')
260     
261     
262     def read(self, request, docid):
263         """Read meta-data about scans for gallery of this document."""
264         galleries = []
265         from urllib import quote
266
267         for assoc in GalleryForDocument.objects.filter(document=docid):
268             dirpath = os.path.join(settings.MEDIA_ROOT, assoc.subpath)
269
270             if not os.path.isdir(dirpath):
271                 log.warn(u"[WARNING]: missing gallery %s", dirpath)
272                 continue
273
274             gallery = {'name': assoc.name, 'pages': []}
275             
276             for file in os.listdir(dirpath):
277                 if not isinstance(file, unicode):
278                     log.warn(u"File %r is gallery %r is not unicode. Ommiting."\
279                         % (file, dirpath) )
280                     continue
281                                
282                 name, ext = os.path.splitext(os.path.basename(file))
283
284                 if ext.lower() not in [u'.png', u'.jpeg', u'.jpg']:
285                     log.info(u"Ignoring: %s %s", name, ext)
286                     continue
287
288                 url = settings.MEDIA_URL + assoc.subpath + u'/' + file;
289                 gallery['pages'].append( quote(url.encode('utf-8')) )
290
291             gallery['pages'].sort()
292             galleries.append(gallery)
293
294         return galleries                      
295
296 #
297 # Document Text View
298 #
299
300 XINCLUDE_REGEXP = r"""<(?:\w+:)?include\s+[^>]*?href=("|')wlrepo://(?P<link>[^\1]+?)\1\s*[^>]*?>"""
301 #
302 #
303 #
304
305 class DocumentTextHandler(BaseHandler):
306     allowed_methods = ('GET', 'POST')
307
308     @hglibrary
309     def read(self, request, docid, lib):
310         """Read document as raw text"""
311         revision = request.GET.get('revision', 'latest')
312         part = request.GET.get('part', False)
313         
314         try:
315             if revision == 'latest':
316                 document = lib.document(docid)
317             else:
318                 document = lib.document_for_rev(revision)
319
320             if document.id != docid:
321                 return response.BadRequest().django_response({'reason': 'name-mismatch',
322                     'message': 'Provided revision is not valid for this document'})
323             
324             # TODO: some finer-grained access control
325             if part is False:
326                 # we're done :)
327                 return document.data('xml')
328             else:
329                 xdoc = parser.WLDocument.from_string(document.data('xml'),\
330                     parse_dublincore=False)
331                 ptext = xdoc.part_as_text(part)
332
333                 if ptext is None:
334                     return response.EntityNotFound().django_response({
335                       'reason': 'no-part-in-document'                     
336                     })
337
338                 return ptext
339         except librarian.ParseError:
340             return response.EntityNotFound().django_response({
341                 'reason': 'invalid-document-state',
342                 'exception': type(e), 'message': e.message
343             })
344         except (EntryNotFound, RevisionNotFound), e:
345             return response.EntityNotFound().django_response({
346                 'reason': 'not-found',
347                 'exception': type(e), 'message': e.message
348             })   
349
350     @hglibrary
351     def create(self, request, docid, lib):
352         try:
353             revision = request.POST['revision']
354
355             current = lib.document(docid, request.user.username)
356             orig = lib.document_for_rev(revision)
357
358             if current != orig:
359                 return response.EntityConflict().django_response({
360                         "reason": "out-of-date",
361                         "provided_revision": orig.revision,
362                         "latest_revision": current.revision })
363
364             if request.POST.has_key('message'):
365                 msg = u"$USER$ " + request.POST['message']
366             else:
367                 msg = u"$AUTO$ XML content update."
368
369             if request.POST.has_key('contents'):
370                 data = request.POST['contents']
371             else:
372                 if not request.POST.has_key('chunks'):
373                     # bad request
374                     return response.BadRequest().django_response({'reason': 'invalid-arguments',
375                         'message': 'No contents nor chunks specified.'})
376
377                     # TODO: validate
378                 parts = json.loads(request.POST['chunks'])                    
379                 xdoc = parser.WLDocument.from_string(current.data('xml'))
380                    
381                 errors = xdoc.merge_chunks(parts)
382
383                 if len(errors):
384                     return response.EntityConflict().django_response({
385                             "reason": "invalid-chunks",
386                             "message": "Unable to merge following parts into the document: %s " % ",".join(errors)
387                     })
388
389                 data = xdoc.serialize()
390
391             # try to find any Xinclude tags
392             includes = [m.groupdict()['link'] for m in (re.finditer(\
393                 XINCLUDE_REGEXP, data, flags=re.UNICODE) or []) ]
394
395             log.info("INCLUDES: %s", includes)
396
397             # TODO: provide useful routines to make this simpler
398             def xml_update_action(lib, resolve):
399                 try:
400                     f = lib._fileopen(resolve('parts'), 'r')
401                     stored_includes = json.loads(f.read())
402                     f.close()
403                 except:
404                     stored_includes = []
405                 
406                 if stored_includes != includes:
407                     f = lib._fileopen(resolve('parts'), 'w+')
408                     f.write(json.dumps(includes))
409                     f.close()
410
411                     lib._fileadd(resolve('parts'))
412
413                     # update the parts cache
414                     PartCache.update_cache(docid, current.owner,\
415                         stored_includes, includes)
416
417                 # now that the parts are ok, write xml
418                 f = lib._fileopen(resolve('xml'), 'w+')
419                 f.write(data.encode('utf-8'))
420                 f.close()
421
422             ndoc = None
423             ndoc = current.invoke_and_commit(\
424                 xml_update_action, lambda d: (msg, current.owner) )
425
426             try:
427                 # return the new revision number
428                 return response.SuccessAllOk().django_response({
429                     "document": ndoc.id,
430                     "subview": "xml",
431                     "previous_revision": current.revision,
432                     "revision": ndoc.revision,
433                     'timestamp': ndoc.revision.timestamp,
434                     "url": reverse("doctext_view", args=[ndoc.id])
435                 })
436             except Exception, e:
437                 if ndoc: lib._rollback()
438                 raise e        
439         except RevisionNotFound, e:
440             return response.EntityNotFound(mimetype="text/plain").\
441                 django_response(e.message)
442
443
444 #
445 # Dublin Core handlers
446 #
447 # @requires librarian
448 #
449 class DocumentDublinCoreHandler(BaseHandler):
450     allowed_methods = ('GET', 'POST')
451
452     @hglibrary
453     def read(self, request, docid, lib):
454         """Read document as raw text"""        
455         try:
456             revision = request.GET.get('revision', 'latest')
457
458             if revision == 'latest':
459                 doc = lib.document(docid)
460             else:
461                 doc = lib.document_for_rev(revision)
462
463
464             if document.id != docid:
465                 return response.BadRequest().django_response({'reason': 'name-mismatch',
466                     'message': 'Provided revision is not valid for this document'})
467             
468             bookinfo = dcparser.BookInfo.from_string(doc.data('xml'))
469             return bookinfo.serialize()
470         except (EntryNotFound, RevisionNotFound), e:
471             return response.EntityNotFound().django_response({
472                 'exception': type(e), 'message': e.message})
473
474     @hglibrary
475     def create(self, request, docid, lib):
476         try:
477             bi_json = request.POST['contents']
478             revision = request.POST['revision']
479             
480             if request.POST.has_key('message'):
481                 msg = u"$USER$ " + request.PUT['message']
482             else:
483                 msg = u"$AUTO$ Dublin core update."
484
485             current = lib.document(docid, request.user.username)
486             orig = lib.document_for_rev(revision)
487
488             if current != orig:
489                 return response.EntityConflict().django_response({
490                         "reason": "out-of-date",
491                         "provided": orig.revision,
492                         "latest": current.revision })
493
494             xmldoc = parser.WLDocument.from_string(current.data('xml'))
495             document.book_info = dcparser.BookInfo.from_json(bi_json)
496
497             # zapisz
498             ndoc = current.quickwrite('xml', \
499                 document.serialize().encode('utf-8'),\
500                 message=msg, user=request.user.username)
501
502             try:
503                 # return the new revision number
504                 return {
505                     "document": ndoc.id,
506                     "subview": "dc",
507                     "previous_revision": current.revision,
508                     "revision": ndoc.revision,
509                     'timestamp': ndoc.revision.timestamp,
510                     "url": reverse("docdc_view", args=[ndoc.id])
511                 }
512             except Exception, e:
513                 if ndoc: lib._rollback()
514                 raise e
515         except RevisionNotFound:
516             return response.EntityNotFound().django_response()
517
518 class MergeHandler(BaseHandler):
519     allowed_methods = ('POST',)
520
521     @validate_form(forms.MergeRequestForm, 'POST')
522     @hglibrary
523     def create(self, request, form, docid, lib):
524         """Create a new document revision from the information provided by user"""
525
526         target_rev = form.cleaned_data['target_revision']
527
528         doc = lib.document(docid)
529         udoc = doc.take(request.user.username)
530
531         if target_rev == 'latest':
532             target_rev = udoc.revision
533
534         if str(udoc.revision) != target_rev:
535             # user think doesn't know he has an old version
536             # of his own branch.
537             
538             # Updating is teorericly ok, but we need would
539             # have to force a refresh. Sharing may be not safe,
540             # 'cause it doesn't always result in update.
541
542             # In other words, we can't lie about the resource's state
543             # So we should just yield and 'out-of-date' conflict
544             # and let the client ask again with updated info.
545
546             # NOTE: this could result in a race condition, when there
547             # are 2 instances of the same user editing the same document.
548             # Instance "A" trying to update, and instance "B" always changing
549             # the document right before "A". The anwser to this problem is
550             # for the "A" to request a merge from 'latest' and then
551             # check the parent revisions in response, if he actually
552             # merge from where he thinks he should. If not, the client SHOULD
553             # update his internal state.
554             return response.EntityConflict().django_response({
555                     "reason": "out-of-date",
556                     "provided": target_rev,
557                     "latest": udoc.revision })
558
559         if form.cleaned_data['type'] == 'update':
560             # update is always performed from the file branch
561             # to the user branch
562             success, changed = udoc.update(request.user.username)
563
564         if form.cleaned_data['type'] == 'share':        
565             if not request.user.has_perm('explorer.document.can_share'):
566                 # User is not permitted to make a merge, right away
567                 # So we instead create a pull request in the database
568                 try:
569                     prq, created = PullRequest.objects.get_or_create(
570                         source_revision = str(udoc.revision),
571                         defaults = {
572                             'comitter': request.user,
573                             'document': docid,
574                             'status': "N",
575                             'comment': form.cleaned_data['message'] or '$AUTO$ Document shared.',
576                         }
577                     )
578
579                     return response.RequestAccepted().django_response(\
580                         ticket_status=prq.status, \
581                         ticket_uri=reverse("pullrequest_view", args=[prq.id]) )
582                 except IntegrityError:
583                     return response.EntityConflict().django_response({
584                         'reason': 'request-already-exist'
585                     })
586             else:
587                 success, changed = udoc.share(form.cleaned_data['message'])
588
589         if not success:
590             return response.EntityConflict().django_response({
591                 'reason': 'merge-failure',
592             })
593
594         if not changed:
595             return response.SuccessNoContent().django_response()
596
597         nudoc = udoc.latest()
598
599         return response.SuccessAllOk().django_response({
600             "name": nudoc.id,
601             "parent_user_resivion": udoc.revision,
602             "parent_revision": doc.revision,
603             "revision": nudoc.revision,
604             'timestamp': nudoc.revision.timestamp,
605         })