1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
9 from base64 import urlsafe_b64encode
10 from collections import defaultdict
11 from errno import EEXIST, ENOENT
12 from fcntl import flock, LOCK_EX
13 from os import mkdir, path, unlink
14 from zipfile import ZipFile
16 from django.conf import settings
17 from django.core.files.storage import DefaultStorage
18 from django.core.files.uploadedfile import UploadedFile
19 from django.http import HttpResponse
20 from django.utils.encoding import force_text
22 from reporting.utils import read_chunks
24 # Use the system (hardware-based) random number generator if it exists.
25 if hasattr(random, 'SystemRandom'):
26 randrange = random.SystemRandom().randrange
28 randrange = random.randrange
29 MAX_SESSION_KEY = 18446744073709551616 # 2 << 63
32 def get_random_hash(seed):
33 sha_digest = hashlib.sha1((
35 randrange(0, MAX_SESSION_KEY),
37 str(seed).encode('utf-8', 'replace'),
40 ).encode('utf-8')).digest()
41 return urlsafe_b64encode(sha_digest).decode('latin1').replace('=', '').replace('_', '-').lower()
44 def split_tags(*tag_lists):
45 if len(tag_lists) == 1:
46 result = defaultdict(list)
47 for tag in tag_lists[0]:
48 result[tag.category].append(tag)
50 result = defaultdict(dict)
51 for tag_list in tag_lists:
54 result[tag.category][tag.pk].count += tag.count
56 result[tag.category][tag.pk] = tag
57 for k, v in result.items():
58 result[k] = sorted(v.values(), key=lambda tag: tag.sort_key)
62 class ExistingFile(UploadedFile):
64 def __init__(self, path, *args, **kwargs):
66 super(ExistingFile, self).__init__(*args, **kwargs)
68 def temporary_file_path(self):
75 class LockFile(object):
77 A file lock monitor class; createas an ${objname}.lock
78 file in directory dir, and locks it exclusively.
79 To be used in 'with' construct.
81 def __init__(self, dir, objname):
82 self.lockname = path.join(dir, objname + ".lock")
85 self.lock = open(self.lockname, 'w')
86 flock(self.lock, LOCK_EX)
88 def __exit__(self, *err):
92 if oe.errno != ENOENT:
98 def create_zip(paths, zip_slug):
100 Creates a zip in MEDIA_ROOT/zip directory containing files from path.
101 Resulting archive filename is ${zip_slug}.zip
102 Returns it's path relative to MEDIA_ROOT (no initial slash)
104 # directory to store zip files
105 zip_path = path.join(settings.MEDIA_ROOT, 'zip')
109 except OSError as oe:
110 if oe.errno != EEXIST:
112 zip_filename = zip_slug + ".zip"
114 with LockFile(zip_path, zip_slug):
115 if not path.exists(path.join(zip_path, zip_filename)):
116 zipf = ZipFile(path.join(zip_path, zip_filename), 'w')
118 for arcname, p in paths:
120 arcname = path.basename(p)
121 zipf.write(p, arcname)
125 return 'zip/' + zip_filename
128 def remove_zip(zip_slug):
130 removes the ${zip_slug}.zip file from zip store.
132 zip_file = path.join(settings.MEDIA_ROOT, 'zip', zip_slug + '.zip')
135 except OSError as oe:
136 if oe.errno != ENOENT:
140 class AttachmentHttpResponse(HttpResponse):
141 """Response serving a file to be downloaded.
143 def __init__(self, file_path, file_name, mimetype):
144 super(AttachmentHttpResponse, self).__init__(mimetype=mimetype)
145 self['Content-Disposition'] = 'attachment; filename=%s' % file_name
146 self.file_path = file_path
147 self.file_name = file_name
149 with open(DefaultStorage().path(self.file_path)) as f:
150 for chunk in read_chunks(f):
154 class MultiQuerySet(object):
155 def __init__(self, *args, **kwargs):
156 self.querysets = args
161 self._count = sum(len(qs) for qs in self.querysets)
167 def __getitem__(self, item):
169 (offset, stop, step) = item.indices(self.count())
170 except AttributeError:
171 # it's not a slice - make it one
172 return self[item:item + 1][0]
174 total_len = stop - offset
175 for qs in self.querysets:
179 items += list(qs[offset:stop])
180 if len(items) >= total_len:
184 stop = total_len - len(items)
188 class SortedMultiQuerySet(MultiQuerySet):
189 def __init__(self, *args, **kwargs):
190 self.order_by = kwargs.pop('order_by', None)
191 self.sortfn = kwargs.pop('sortfn', None)
192 if self.order_by is not None:
193 self.sortfn = lambda a, b: cmp((getattr(a, f) for f in self.order_by),
194 (getattr(b, f) for f in self.order_by))
195 super(SortedMultiQuerySet, self).__init__(*args, **kwargs)
197 def __getitem__(self, item):
198 sort_heads = [0] * len(self.querysets)
200 (offset, stop, step) = item.indices(self.count())
201 except AttributeError:
202 # it's not a slice - make it one
203 return self[item:item + 1][0]
205 total_len = stop - offset
207 i_s = range(len(sort_heads))
209 while len(items) < total_len:
214 return self.querysets[i][sort_heads[i]]
216 if candidate is None:
217 candidate = get_next()
220 competitor = get_next()
221 if self.sortfn(candidate, competitor) > 0:
222 candidate = competitor
225 continue # continue next sort_head
226 # we have no more elements:
227 if candidate is None:
229 sort_heads[candidate_i] += 1
232 continue # continue next item
233 items.append(candidate)
238 def truncate_html_words(s, num, end_text='...'):
239 """Truncates HTML to a certain number of words (not counting tags and
240 comments). Closes opened tags if they were correctly closed in the given
241 html. Takes an optional argument of what should be used to notify that the
242 string has been truncated, defaulting to ellipsis (...).
244 Newlines in the HTML are preserved.
246 This is just a version of django.utils.text.truncate_html_words with no space before the end_text.
252 html4_singlets = ('br', 'col', 'link', 'base', 'img', 'param', 'area', 'hr', 'input')
253 # Set up regular expressions
254 re_words = re.compile(r'&.*?;|<.*?>|(\w[\w-]*)', re.U)
255 re_tag = re.compile(r'<(/)?([^ ]+?)(?: (/)| .*?)?>')
256 # Count non-HTML words and keep note of open tags
261 while words <= length:
262 m = re_words.search(s, pos)
264 # Checked through whole string
268 # It's an actual non-HTML word
274 tag = re_tag.match(m.group(0))
275 if not tag or end_text_pos:
276 # Don't worry about non tags or tags after our truncate point
278 closing_tag, tagname, self_closing = tag.groups()
279 tagname = tagname.lower() # Element names are always case-insensitive
280 if self_closing or tagname in html4_singlets:
283 # Check for match in open tags list
285 i = open_tags.index(tagname)
289 # SGML: An end tag closes, back to the matching start tag,
290 # all unclosed intervening start tags with omitted end tags
291 open_tags = open_tags[i+1:]
293 # Add it to the start of the open tags list
294 open_tags.insert(0, tagname)
296 # Don't try to close tags if we don't need to truncate
298 out = s[:end_text_pos]
301 # Close any tags still open
302 for tag in open_tags:
308 def customizations_hash(customizations):
309 customizations.sort()
310 return hash(tuple(customizations))
313 def get_customized_pdf_path(book, customizations):
315 Returns a MEDIA_ROOT relative path for a customized pdf. The name will contain a hash of customization options.
317 h = customizations_hash(customizations)
318 return 'book/%s/%s-custom-%s.pdf' % (book.slug, book.slug, h)
321 def clear_custom_pdf(book):
323 Returns a list of paths to generated customized pdf of a book
325 from waiter.utils import clear_cache
326 clear_cache('book/%s' % book.slug)
329 class AppSettings(object):
330 """Allows specyfying custom settings for an app, with default values.
332 Just subclass, set some properties and instantiate with a prefix.
333 Getting a SETTING from an instance will check for prefix_SETTING
334 in project settings if set, else take the default. The value will be
335 then filtered through _more_SETTING method, if there is one.
338 def __init__(self, prefix):
339 self._prefix = prefix
341 def __getattribute__(self, name):
342 if name.startswith('_'):
343 return object.__getattribute__(self, name)
344 value = getattr(settings, "%s_%s" % (self._prefix, name), object.__getattribute__(self, name))
345 more = "_more_%s" % name
346 if hasattr(self, more):
347 value = getattr(self, more)(value)
351 def delete_from_cache_by_language(cache, key_template):
352 cache.delete_many([key_template % lc for lc, ln in settings.LANGUAGES])
355 def gallery_path(slug):
356 return os.path.join(settings.MEDIA_ROOT, settings.IMAGE_DIR, slug)
359 def gallery_url(slug):
360 return '%s%s%s/' % (settings.MEDIA_URL, settings.IMAGE_DIR, slug)
363 def get_mp3_length(path):
364 from mutagen.mp3 import MP3
365 return int(MP3(path).info.length)