General A/B testing.
[wolnelektury.git] / src / catalogue / utils.py
1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
3 #
4 import hashlib
5 import os.path
6 import random
7 import re
8 import time
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
15
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
21
22 from reporting.utils import read_chunks
23
24 # Use the system (hardware-based) random number generator if it exists.
25 if hasattr(random, 'SystemRandom'):
26     randrange = random.SystemRandom().randrange
27 else:
28     randrange = random.randrange
29 MAX_SESSION_KEY = 18446744073709551616     # 2 << 63
30
31
32 def get_random_hash(seed):
33     sha_digest = hashlib.sha1((
34         '%s%s%s%s' % (
35             randrange(0, MAX_SESSION_KEY),
36             time.time(),
37             str(seed).encode('utf-8', 'replace'),
38             settings.SECRET_KEY
39         )
40     ).encode('utf-8')).digest()
41     return urlsafe_b64encode(sha_digest).decode('latin1').replace('=', '').replace('_', '-').lower()
42
43
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)
49     else:
50         result = defaultdict(dict)
51         for tag_list in tag_lists:
52             for tag in tag_list:
53                 try:
54                     result[tag.category][tag.pk].count += tag.count
55                 except KeyError:
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)
59     return result
60
61
62 class ExistingFile(UploadedFile):
63
64     def __init__(self, path, *args, **kwargs):
65         self.path = path
66         super(ExistingFile, self).__init__(*args, **kwargs)
67
68     def temporary_file_path(self):
69         return self.path
70
71     def close(self):
72         pass
73
74
75 class LockFile(object):
76     """
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.
80     """
81     def __init__(self, dir, objname):
82         self.lockname = path.join(dir, objname + ".lock")
83
84     def __enter__(self):
85         self.lock = open(self.lockname, 'w')
86         flock(self.lock, LOCK_EX)
87
88     def __exit__(self, *err):
89         try:
90             unlink(self.lockname)
91         except OSError as oe:
92             if oe.errno != ENOENT:
93                 raise oe
94         self.lock.close()
95
96
97 # @task
98 def create_zip(paths, zip_slug):
99     """
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)
103     """
104     # directory to store zip files
105     zip_path = path.join(settings.MEDIA_ROOT, 'zip')
106
107     try:
108         mkdir(zip_path)
109     except OSError as oe:
110         if oe.errno != EEXIST:
111             raise oe
112     zip_filename = zip_slug + ".zip"
113
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')
117             try:
118                 for arcname, p in paths:
119                     if arcname is None:
120                         arcname = path.basename(p)
121                     zipf.write(p, arcname)
122             finally:
123                 zipf.close()
124
125         return 'zip/' + zip_filename
126
127
128 def remove_zip(zip_slug):
129     """
130     removes the ${zip_slug}.zip file from zip store.
131     """
132     zip_file = path.join(settings.MEDIA_ROOT, 'zip', zip_slug + '.zip')
133     try:
134         unlink(zip_file)
135     except OSError as oe:
136         if oe.errno != ENOENT:
137             raise oe
138
139
140 class AttachmentHttpResponse(HttpResponse):
141     """Response serving a file to be downloaded.
142     """
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
148
149         with open(DefaultStorage().path(self.file_path)) as f:
150             for chunk in read_chunks(f):
151                 self.write(chunk)
152
153
154 class MultiQuerySet(object):
155     def __init__(self, *args, **kwargs):
156         self.querysets = args
157         self._count = None
158
159     def count(self):
160         if not self._count:
161             self._count = sum(len(qs) for qs in self.querysets)
162         return self._count
163
164     def __len__(self):
165         return self.count()
166
167     def __getitem__(self, item):
168         try:
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]
173         items = []
174         total_len = stop - offset
175         for qs in self.querysets:
176             if len(qs) < offset:
177                 offset -= len(qs)
178             else:
179                 items += list(qs[offset:stop])
180                 if len(items) >= total_len:
181                     return items
182                 else:
183                     offset = 0
184                     stop = total_len - len(items)
185                     continue
186
187
188 def truncate_html_words(s, num, end_text='...'):
189     """Truncates HTML to a certain number of words (not counting tags and
190     comments). Closes opened tags if they were correctly closed in the given
191     html. Takes an optional argument of what should be used to notify that the
192     string has been truncated, defaulting to ellipsis (...).
193
194     Newlines in the HTML are preserved.
195
196     This is just a version of django.utils.text.truncate_html_words with no space before the end_text.
197     """
198     s = force_text(s)
199     length = int(num)
200     if length <= 0:
201         return ''
202     html4_singlets = ('br', 'col', 'link', 'base', 'img', 'param', 'area', 'hr', 'input')
203     # Set up regular expressions
204     re_words = re.compile(r'&.*?;|<.*?>|(\w[\w-]*)', re.U)
205     re_tag = re.compile(r'<(/)?([^ ]+?)(?: (/)| .*?)?>')
206     # Count non-HTML words and keep note of open tags
207     pos = 0
208     end_text_pos = 0
209     words = 0
210     open_tags = []
211     while words <= length:
212         m = re_words.search(s, pos)
213         if not m:
214             # Checked through whole string
215             break
216         pos = m.end(0)
217         if m.group(1):
218             # It's an actual non-HTML word
219             words += 1
220             if words == length:
221                 end_text_pos = pos
222             continue
223         # Check for tag
224         tag = re_tag.match(m.group(0))
225         if not tag or end_text_pos:
226             # Don't worry about non tags or tags after our truncate point
227             continue
228         closing_tag, tagname, self_closing = tag.groups()
229         tagname = tagname.lower()  # Element names are always case-insensitive
230         if self_closing or tagname in html4_singlets:
231             pass
232         elif closing_tag:
233             # Check for match in open tags list
234             try:
235                 i = open_tags.index(tagname)
236             except ValueError:
237                 pass
238             else:
239                 # SGML: An end tag closes, back to the matching start tag,
240                 # all unclosed intervening start tags with omitted end tags
241                 open_tags = open_tags[i+1:]
242         else:
243             # Add it to the start of the open tags list
244             open_tags.insert(0, tagname)
245     if words <= length:
246         # Don't try to close tags if we don't need to truncate
247         return s
248     out = s[:end_text_pos]
249     if end_text:
250         out += end_text
251     # Close any tags still open
252     for tag in open_tags:
253         out += '</%s>' % tag
254     # Return string
255     return out
256
257
258 def customizations_hash(customizations):
259     customizations.sort()
260     return hash(tuple(customizations))
261
262
263 def get_customized_pdf_path(book, customizations):
264     """
265     Returns a MEDIA_ROOT relative path for a customized pdf. The name will contain a hash of customization options.
266     """
267     h = customizations_hash(customizations)
268     return 'book/%s/%s-custom-%s.pdf' % (book.slug, book.slug, h)
269
270
271 def clear_custom_pdf(book):
272     """
273     Returns a list of paths to generated customized pdf of a book
274     """
275     from waiter.utils import clear_cache
276     clear_cache('book/%s' % book.slug)
277
278
279 class AppSettings(object):
280     """Allows specyfying custom settings for an app, with default values.
281
282     Just subclass, set some properties and instantiate with a prefix.
283     Getting a SETTING from an instance will check for prefix_SETTING
284     in project settings if set, else take the default. The value will be
285     then filtered through _more_SETTING method, if there is one.
286
287     """
288     def __init__(self, prefix):
289         self._prefix = prefix
290
291     def __getattribute__(self, name):
292         if name.startswith('_'):
293             return object.__getattribute__(self, name)
294         value = getattr(settings, "%s_%s" % (self._prefix, name), object.__getattribute__(self, name))
295         more = "_more_%s" % name
296         if hasattr(self, more):
297             value = getattr(self, more)(value)
298         return value
299
300
301 def delete_from_cache_by_language(cache, key_template):
302     cache.delete_many([key_template % lc for lc, ln in settings.LANGUAGES])
303
304
305 def gallery_path(slug):
306     return os.path.join(settings.MEDIA_ROOT, settings.IMAGE_DIR, slug)
307
308
309 def gallery_url(slug):
310     return '%s%s%s/' % (settings.MEDIA_URL, settings.IMAGE_DIR, slug)
311
312
313 def get_mp3_length(path):
314     from mutagen.mp3 import MP3
315     return int(MP3(path).info.length)