fd74c94980022c8309d9855ed0054bd1ead4fbec
[wolnelektury.git] / apps / catalogue / utils.py
1 # -*- coding: utf-8 -*-
2 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
3 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
4 #
5 from __future__ import with_statement
6
7 import hashlib
8 import random
9 import re
10 import time
11 from base64 import urlsafe_b64encode
12
13 from django.http import HttpResponse
14 from django.core.files.uploadedfile import UploadedFile
15 from django.core.files.storage import DefaultStorage
16 from django.utils.encoding import force_unicode
17 from django.utils.translation import get_language
18 from django.conf import settings
19 from os import mkdir, path, unlink
20 from errno import EEXIST, ENOENT
21 from fcntl import flock, LOCK_EX
22 from zipfile import ZipFile
23
24 from reporting.utils import read_chunks
25
26 # Use the system (hardware-based) random number generator if it exists.
27 if hasattr(random, 'SystemRandom'):
28     randrange = random.SystemRandom().randrange
29 else:
30     randrange = random.randrange
31 MAX_SESSION_KEY = 18446744073709551616L     # 2 << 63
32
33
34 def get_random_hash(seed):
35     sha_digest = hashlib.sha1('%s%s%s%s' %
36         (randrange(0, MAX_SESSION_KEY), time.time(), unicode(seed).encode('utf-8', 'replace'),
37         settings.SECRET_KEY)).digest()
38     return urlsafe_b64encode(sha_digest).replace('=', '').replace('_', '-').lower()
39
40
41 def split_tags(tags, initial=None):
42     if initial is None:
43         result = {}
44     else:
45         result = initial
46     
47     for tag in tags:
48         result.setdefault(tag.category, []).append(tag)
49     return result
50
51
52 def get_dynamic_path(media, filename, ext=None, maxlen=100):
53     from fnpdjango.utils.text.slughifi import slughifi
54
55     # how to put related book's slug here?
56     if not ext:
57         # BookMedia case
58         ext = media.formats[media.type].ext
59     if media is None or not media.name:
60         name = slughifi(filename.split(".")[0])
61     else:
62         name = slughifi(media.name)
63     return 'book/%s/%s.%s' % (ext, name[:maxlen-len('book/%s/.%s' % (ext, ext))-4], ext)
64
65
66 # TODO: why is this hard-coded ?
67 def book_upload_path(ext=None, maxlen=100):
68     return lambda *args: get_dynamic_path(*args, ext=ext, maxlen=maxlen)
69
70
71 class ExistingFile(UploadedFile):
72
73     def __init__(self, path, *args, **kwargs):
74         self.path = path
75         super(ExistingFile, self).__init__(*args, **kwargs)
76
77     def temporary_file_path(self):
78         return self.path
79
80     def close(self):
81         pass
82
83
84 class LockFile(object):
85     """
86     A file lock monitor class; createas an ${objname}.lock
87     file in directory dir, and locks it exclusively.
88     To be used in 'with' construct.
89     """
90     def __init__(self, dir, objname):
91         self.lockname = path.join(dir, objname + ".lock")
92
93     def __enter__(self):
94         self.lock = open(self.lockname, 'w')
95         flock(self.lock, LOCK_EX)
96
97     def __exit__(self, *err):
98         try:
99             unlink(self.lockname)
100         except OSError as oe:
101             if oe.errno != EEXIST:
102                 raise oe
103         self.lock.close()
104
105
106 #@task
107 def create_zip(paths, zip_slug):
108     """
109     Creates a zip in MEDIA_ROOT/zip directory containing files from path.
110     Resulting archive filename is ${zip_slug}.zip
111     Returns it's path relative to MEDIA_ROOT (no initial slash)
112     """
113     # directory to store zip files
114     zip_path = path.join(settings.MEDIA_ROOT, 'zip')
115
116     try:
117         mkdir(zip_path)
118     except OSError as oe:
119         if oe.errno != EEXIST:
120             raise oe
121     zip_filename = zip_slug + ".zip"
122
123     with LockFile(zip_path, zip_slug):
124         if not path.exists(path.join(zip_path, zip_filename)):
125             zipf = ZipFile(path.join(zip_path, zip_filename), 'w')
126             try:
127                 for arcname, p in paths:
128                     if arcname is None:
129                         arcname = path.basename(p)
130                     zipf.write(p, arcname)
131             finally:
132                 zipf.close()
133
134         return 'zip/' + zip_filename
135
136
137 def remove_zip(zip_slug):
138     """
139     removes the ${zip_slug}.zip file from zip store.
140     """
141     zip_file = path.join(settings.MEDIA_ROOT, 'zip', zip_slug + '.zip')
142     try:
143         unlink(zip_file)
144     except OSError as oe:
145         if oe.errno != ENOENT:
146             raise oe
147
148
149 class AttachmentHttpResponse(HttpResponse):
150     """Response serving a file to be downloaded.
151     """
152     def __init__ (self, file_path, file_name, mimetype):
153         super(AttachmentHttpResponse, self).__init__(mimetype=mimetype)
154         self['Content-Disposition'] = 'attachment; filename=%s' % file_name
155         self.file_path = file_path
156         self.file_name = file_name
157
158         with open(DefaultStorage().path(self.file_path)) as f:
159             for chunk in read_chunks(f):
160                 self.write(chunk)
161
162 class MultiQuerySet(object):
163     def __init__(self, *args, **kwargs):
164         self.querysets = args
165         self._count = None
166     
167     def count(self):
168         if not self._count:
169             self._count = sum(len(qs) for qs in self.querysets)
170         return self._count
171     
172     def __len__(self):
173         return self.count()
174         
175     def __getitem__(self, item):
176         try:
177             indices = (offset, stop, step) = item.indices(self.count())
178         except AttributeError:
179             # it's not a slice - make it one
180             return self[item : item + 1][0]
181         items = []
182         total_len = stop - offset
183         for qs in self.querysets:
184             if len(qs) < offset:
185                 offset -= len(qs)
186             else:
187                 items += list(qs[offset:stop])
188                 if len(items) >= total_len:
189                     return items
190                 else:
191                     offset = 0
192                     stop = total_len - len(items)
193                     continue
194
195 class SortedMultiQuerySet(MultiQuerySet):
196     def __init__(self, *args, **kwargs):
197         self.order_by = kwargs.pop('order_by', None)
198         self.sortfn = kwargs.pop('sortfn', None)
199         if self.order_by is not None:
200             self.sortfn = lambda a, b: cmp(getattr(a, self.order_by), 
201                                            getattr(b, self.order_by))
202         super(SortedMultiQuerySet, self).__init__(*args, **kwargs)
203
204     def __getitem__(self, item):
205         sort_heads = [0] * len(self.querysets)
206         try:
207             indices = (offset, stop, step) = item.indices(self.count())
208         except AttributeError:
209             # it's not a slice - make it one
210             return self[item : item + 1][0]
211         items = []
212         total_len = stop - offset
213         skipped = 0
214         i_s = range(len(sort_heads))
215
216         while len(items) < total_len:
217             candidate = None
218             for i in i_s:
219                 def get_next():
220                     return self.querysets[i][sort_heads[i]]
221                 try:
222                     if candidate is None:
223                         candidate = get_next()
224                     else:
225                         competitor = get_next()
226                         if self.sortfn(candidate, competitor) > 0:
227                             candidate = competitor
228                 except IndexError:
229                     continue # continue next sort_head
230                 sort_heads[i] += 1
231             # we have no more elements:
232             if candidate is None:
233                 break
234             if skipped < offset:
235                 skipped += 1
236                 continue # continue next item
237             items.append(candidate)
238         
239         return items
240
241
242 def truncate_html_words(s, num, end_text='...'):
243     """Truncates HTML to a certain number of words (not counting tags and
244     comments). Closes opened tags if they were correctly closed in the given
245     html. Takes an optional argument of what should be used to notify that the
246     string has been truncated, defaulting to ellipsis (...).
247
248     Newlines in the HTML are preserved.
249
250     This is just a version of django.utils.text.truncate_html_words with no space before the end_text.
251     """
252     s = force_unicode(s)
253     length = int(num)
254     if length <= 0:
255         return u''
256     html4_singlets = ('br', 'col', 'link', 'base', 'img', 'param', 'area', 'hr', 'input')
257     # Set up regular expressions
258     re_words = re.compile(r'&.*?;|<.*?>|(\w[\w-]*)', re.U)
259     re_tag = re.compile(r'<(/)?([^ ]+?)(?: (/)| .*?)?>')
260     # Count non-HTML words and keep note of open tags
261     pos = 0
262     end_text_pos = 0
263     words = 0
264     open_tags = []
265     while words <= length:
266         m = re_words.search(s, pos)
267         if not m:
268             # Checked through whole string
269             break
270         pos = m.end(0)
271         if m.group(1):
272             # It's an actual non-HTML word
273             words += 1
274             if words == length:
275                 end_text_pos = pos
276             continue
277         # Check for tag
278         tag = re_tag.match(m.group(0))
279         if not tag or end_text_pos:
280             # Don't worry about non tags or tags after our truncate point
281             continue
282         closing_tag, tagname, self_closing = tag.groups()
283         tagname = tagname.lower()  # Element names are always case-insensitive
284         if self_closing or tagname in html4_singlets:
285             pass
286         elif closing_tag:
287             # Check for match in open tags list
288             try:
289                 i = open_tags.index(tagname)
290             except ValueError:
291                 pass
292             else:
293                 # SGML: An end tag closes, back to the matching start tag, all unclosed intervening start tags with omitted end tags
294                 open_tags = open_tags[i+1:]
295         else:
296             # Add it to the start of the open tags list
297             open_tags.insert(0, tagname)
298     if words <= length:
299         # Don't try to close tags if we don't need to truncate
300         return s
301     out = s[:end_text_pos]
302     if end_text:
303         out += end_text
304     # Close any tags still open
305     for tag in open_tags:
306         out += '</%s>' % tag
307     # Return string
308     return out
309
310
311 def customizations_hash(customizations):
312     customizations.sort()
313     return hash(tuple(customizations))
314
315
316 def get_customized_pdf_path(book, customizations):
317     """
318     Returns a MEDIA_ROOT relative path for a customized pdf. The name will contain a hash of customization options.
319     """
320     h = customizations_hash(customizations)
321     return 'book/%s/%s-custom-%s.pdf' % (book.slug, book.slug, h)
322
323
324 def clear_custom_pdf(book):
325     """
326     Returns a list of paths to generated customized pdf of a book
327     """
328     from waiter.utils import clear_cache
329     clear_cache('book/%s' % book.slug)
330
331
332 class AppSettings(object):
333     """Allows specyfying custom settings for an app, with default values.
334
335     Just subclass, set some properties and instantiate with a prefix.
336     Getting a SETTING from an instance will check for prefix_SETTING
337     in project settings if set, else take the default. The value will be
338     then filtered through _more_SETTING method, if there is one.
339
340     """
341     def __init__(self, prefix):
342         self._prefix = prefix
343
344     def __getattribute__(self, name):
345         if name.startswith('_'):
346             return object.__getattribute__(self, name)
347         value = getattr(settings,
348                          "%s_%s" % (self._prefix, name),
349                          object.__getattribute__(self, name))
350         more = "_more_%s" % name
351         if hasattr(self, more):
352             value = getattr(self, more)(value)
353         return value
354
355
356 def trim_query_log(trim_to=25):
357     """
358 connection.queries includes all SQL statements -- INSERTs, UPDATES, SELECTs, etc. Each time your app hits the database, the query will be recorded.
359 This can sometimes occupy lots of memory, so trim it here a bit.
360     """
361     if settings.DEBUG:
362         from django.db import connection
363         connection.queries = trim_to > 0 \
364             and connection.queries[-trim_to:] \
365             or []
366
367
368 def related_tag_name(tag_info, language=None):
369     return tag_info.get("name_%s" % (language or get_language()),
370         tag_info.get("name_%s" % settings.LANGUAGE_CODE, ""))
371
372
373 def delete_from_cache_by_language(cache, key_template):
374     cache.delete_many([key_template % lc for lc, ln in settings.LANGUAGES])