33b0830e71c3b42308757874f5f9cc3ce8ebaf1b
[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
196 def truncate_html_words(s, num, end_text='...'):
197     """Truncates HTML to a certain number of words (not counting tags and
198     comments). Closes opened tags if they were correctly closed in the given
199     html. Takes an optional argument of what should be used to notify that the
200     string has been truncated, defaulting to ellipsis (...).
201
202     Newlines in the HTML are preserved.
203
204     This is just a version of django.utils.text.truncate_html_words with no space before the end_text.
205     """
206     s = force_unicode(s)
207     length = int(num)
208     if length <= 0:
209         return u''
210     html4_singlets = ('br', 'col', 'link', 'base', 'img', 'param', 'area', 'hr', 'input')
211     # Set up regular expressions
212     re_words = re.compile(r'&.*?;|<.*?>|(\w[\w-]*)', re.U)
213     re_tag = re.compile(r'<(/)?([^ ]+?)(?: (/)| .*?)?>')
214     # Count non-HTML words and keep note of open tags
215     pos = 0
216     end_text_pos = 0
217     words = 0
218     open_tags = []
219     while words <= length:
220         m = re_words.search(s, pos)
221         if not m:
222             # Checked through whole string
223             break
224         pos = m.end(0)
225         if m.group(1):
226             # It's an actual non-HTML word
227             words += 1
228             if words == length:
229                 end_text_pos = pos
230             continue
231         # Check for tag
232         tag = re_tag.match(m.group(0))
233         if not tag or end_text_pos:
234             # Don't worry about non tags or tags after our truncate point
235             continue
236         closing_tag, tagname, self_closing = tag.groups()
237         tagname = tagname.lower()  # Element names are always case-insensitive
238         if self_closing or tagname in html4_singlets:
239             pass
240         elif closing_tag:
241             # Check for match in open tags list
242             try:
243                 i = open_tags.index(tagname)
244             except ValueError:
245                 pass
246             else:
247                 # SGML: An end tag closes, back to the matching start tag, all unclosed intervening start tags with omitted end tags
248                 open_tags = open_tags[i+1:]
249         else:
250             # Add it to the start of the open tags list
251             open_tags.insert(0, tagname)
252     if words <= length:
253         # Don't try to close tags if we don't need to truncate
254         return s
255     out = s[:end_text_pos]
256     if end_text:
257         out += end_text
258     # Close any tags still open
259     for tag in open_tags:
260         out += '</%s>' % tag
261     # Return string
262     return out
263
264
265 def customizations_hash(customizations):
266     customizations.sort()
267     return hash(tuple(customizations))
268
269
270 def get_customized_pdf_path(book, customizations):
271     """
272     Returns a MEDIA_ROOT relative path for a customized pdf. The name will contain a hash of customization options.
273     """
274     h = customizations_hash(customizations)
275     return 'book/%s/%s-custom-%s.pdf' % (book.slug, book.slug, h)
276
277
278 def clear_custom_pdf(book):
279     """
280     Returns a list of paths to generated customized pdf of a book
281     """
282     from waiter.utils import clear_cache
283     clear_cache('book/%s' % book.slug)
284
285
286 class AppSettings(object):
287     """Allows specyfying custom settings for an app, with default values.
288
289     Just subclass, set some properties and instantiate with a prefix.
290     Getting a SETTING from an instance will check for prefix_SETTING
291     in project settings if set, else take the default. The value will be
292     then filtered through _more_SETTING method, if there is one.
293
294     """
295     def __init__(self, prefix):
296         self._prefix = prefix
297
298     def __getattribute__(self, name):
299         if name.startswith('_'):
300             return object.__getattribute__(self, name)
301         value = getattr(settings,
302                          "%s_%s" % (self._prefix, name),
303                          object.__getattribute__(self, name))
304         more = "_more_%s" % name
305         if hasattr(self, more):
306             value = getattr(self, more)(value)
307         return value
308
309
310 def trim_query_log(trim_to=25):
311     """
312 connection.queries includes all SQL statements -- INSERTs, UPDATES, SELECTs, etc. Each time your app hits the database, the query will be recorded.
313 This can sometimes occupy lots of memory, so trim it here a bit.
314     """
315     if settings.DEBUG:
316         from django.db import connection
317         connection.queries = trim_to > 0 \
318             and connection.queries[-trim_to:] \
319             or []
320
321
322 def related_tag_name(tag_info, language=None):
323     return tag_info.get("name_%s" % (language or get_language()),
324         tag_info.get("name_%s" % settings.LANGUAGE_CODE, ""))
325
326
327 def delete_from_cache_by_language(cache, key_template):
328     cache.delete_many([key_template % lc for lc, ln in settings.LANGUAGES])