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