##// END OF EJS Templates
Copy as much of the nbextension as we can, even if there are errors for some of the files.
Jason Grout -
Show More
@@ -1,342 +1,344 b''
1 # coding: utf-8
1 # coding: utf-8
2 """Utilities for installing Javascript extensions for the notebook"""
2 """Utilities for installing Javascript extensions for the notebook"""
3
3
4 # Copyright (c) IPython Development Team.
4 # Copyright (c) IPython Development Team.
5 # Distributed under the terms of the Modified BSD License.
5 # Distributed under the terms of the Modified BSD License.
6
6
7 from __future__ import print_function
7 from __future__ import print_function
8
8
9 import os
9 import os
10 import shutil
10 import shutil
11 import sys
11 import sys
12 import tarfile
12 import tarfile
13 import zipfile
13 import zipfile
14 import uuid
14 import uuid
15 from os.path import basename, join as pjoin
15 from os.path import basename, join as pjoin
16
16
17 # Deferred imports
17 # Deferred imports
18 try:
18 try:
19 from urllib.parse import urlparse # Py3
19 from urllib.parse import urlparse # Py3
20 from urllib.request import urlretrieve
20 from urllib.request import urlretrieve
21 except ImportError:
21 except ImportError:
22 from urlparse import urlparse
22 from urlparse import urlparse
23 from urllib import urlretrieve
23 from urllib import urlretrieve
24
24
25 from IPython.utils.path import get_ipython_dir, ensure_dir_exists
25 from IPython.utils.path import get_ipython_dir, ensure_dir_exists
26 from IPython.utils.py3compat import string_types, cast_unicode_py2
26 from IPython.utils.py3compat import string_types, cast_unicode_py2
27 from IPython.utils.tempdir import TemporaryDirectory
27 from IPython.utils.tempdir import TemporaryDirectory
28
28
29 class ArgumentConflict(ValueError):
29 class ArgumentConflict(ValueError):
30 pass
30 pass
31
31
32 # Packagers: modify the next block if you store system-installed nbextensions elsewhere (unlikely)
32 # Packagers: modify the next block if you store system-installed nbextensions elsewhere (unlikely)
33 SYSTEM_NBEXTENSIONS_DIRS = []
33 SYSTEM_NBEXTENSIONS_DIRS = []
34
34
35 if os.name == 'nt':
35 if os.name == 'nt':
36 programdata = os.environ.get('PROGRAMDATA', None)
36 programdata = os.environ.get('PROGRAMDATA', None)
37 if programdata: # PROGRAMDATA is not defined by default on XP.
37 if programdata: # PROGRAMDATA is not defined by default on XP.
38 SYSTEM_NBEXTENSIONS_DIRS = [pjoin(programdata, 'jupyter', 'nbextensions')]
38 SYSTEM_NBEXTENSIONS_DIRS = [pjoin(programdata, 'jupyter', 'nbextensions')]
39 prefixes = []
39 prefixes = []
40 else:
40 else:
41 prefixes = [os.path.sep + pjoin('usr', 'local'), os.path.sep + 'usr']
41 prefixes = [os.path.sep + pjoin('usr', 'local'), os.path.sep + 'usr']
42
42
43 # add sys.prefix at the front
43 # add sys.prefix at the front
44 if sys.prefix not in prefixes:
44 if sys.prefix not in prefixes:
45 prefixes.insert(0, sys.prefix)
45 prefixes.insert(0, sys.prefix)
46
46
47 for prefix in prefixes:
47 for prefix in prefixes:
48 nbext = pjoin(prefix, 'share', 'jupyter', 'nbextensions')
48 nbext = pjoin(prefix, 'share', 'jupyter', 'nbextensions')
49 if nbext not in SYSTEM_NBEXTENSIONS_DIRS:
49 if nbext not in SYSTEM_NBEXTENSIONS_DIRS:
50 SYSTEM_NBEXTENSIONS_DIRS.append(nbext)
50 SYSTEM_NBEXTENSIONS_DIRS.append(nbext)
51
51
52 if os.name == 'nt':
52 if os.name == 'nt':
53 # PROGRAMDATA
53 # PROGRAMDATA
54 SYSTEM_NBEXTENSIONS_INSTALL_DIR = SYSTEM_NBEXTENSIONS_DIRS[-1]
54 SYSTEM_NBEXTENSIONS_INSTALL_DIR = SYSTEM_NBEXTENSIONS_DIRS[-1]
55 else:
55 else:
56 # /usr/local
56 # /usr/local
57 SYSTEM_NBEXTENSIONS_INSTALL_DIR = SYSTEM_NBEXTENSIONS_DIRS[-2]
57 SYSTEM_NBEXTENSIONS_INSTALL_DIR = SYSTEM_NBEXTENSIONS_DIRS[-2]
58
58
59
59
60 def _should_copy(src, dest, verbose=1):
60 def _should_copy(src, dest, verbose=1):
61 """should a file be copied?"""
61 """should a file be copied?"""
62 if not os.path.exists(dest):
62 if not os.path.exists(dest):
63 return True
63 return True
64 if os.stat(dest).st_mtime < os.stat(src).st_mtime:
64 if os.stat(dest).st_mtime < os.stat(src).st_mtime:
65 if verbose >= 2:
65 if verbose >= 2:
66 print("%s is out of date" % dest)
66 print("%s is out of date" % dest)
67 return True
67 return True
68 if verbose >= 2:
68 if verbose >= 2:
69 print("%s is up to date" % dest)
69 print("%s is up to date" % dest)
70 return False
70 return False
71
71
72
72
73 def _maybe_copy(src, dest, verbose=1):
73 def _maybe_copy(src, dest, verbose=1):
74 """copy a file if it needs updating"""
74 """copy a file if it needs updating"""
75 if _should_copy(src, dest, verbose):
75 if _should_copy(src, dest, verbose):
76 if verbose >= 1:
76 if verbose >= 1:
77 print("copying %s -> %s" % (src, dest))
77 print("copying %s -> %s" % (src, dest))
78 shutil.copy2(src, dest)
78 try:
79
79 shutil.copy2(src, dest)
80 except IOError as e:
81 print(str(e), file=sys.stderr)
80
82
81 def _safe_is_tarfile(path):
83 def _safe_is_tarfile(path):
82 """safe version of is_tarfile, return False on IOError"""
84 """safe version of is_tarfile, return False on IOError"""
83 try:
85 try:
84 return tarfile.is_tarfile(path)
86 return tarfile.is_tarfile(path)
85 except IOError:
87 except IOError:
86 return False
88 return False
87
89
88
90
89 def check_nbextension(files, nbextensions_dir=None):
91 def check_nbextension(files, nbextensions_dir=None):
90 """Check whether nbextension files have been installed
92 """Check whether nbextension files have been installed
91
93
92 files should be a list of relative paths within nbextensions.
94 files should be a list of relative paths within nbextensions.
93
95
94 Returns True if all files are found, False if any are missing.
96 Returns True if all files are found, False if any are missing.
95 """
97 """
96 if nbextensions_dir:
98 if nbextensions_dir:
97 nbext = nbextensions_dir
99 nbext = nbextensions_dir
98 else:
100 else:
99 nbext = pjoin(get_ipython_dir(), u'nbextensions')
101 nbext = pjoin(get_ipython_dir(), u'nbextensions')
100 # make sure nbextensions dir exists
102 # make sure nbextensions dir exists
101 if not os.path.exists(nbext):
103 if not os.path.exists(nbext):
102 return False
104 return False
103
105
104 if isinstance(files, string_types):
106 if isinstance(files, string_types):
105 # one file given, turn it into a list
107 # one file given, turn it into a list
106 files = [files]
108 files = [files]
107
109
108 return all(os.path.exists(pjoin(nbext, f)) for f in files)
110 return all(os.path.exists(pjoin(nbext, f)) for f in files)
109
111
110
112
111 def install_nbextension(files, overwrite=False, symlink=False, user=False, prefix=None, nbextensions_dir=None, verbose=1):
113 def install_nbextension(files, overwrite=False, symlink=False, user=False, prefix=None, nbextensions_dir=None, verbose=1):
112 """Install a Javascript extension for the notebook
114 """Install a Javascript extension for the notebook
113
115
114 Stages files and/or directories into the nbextensions directory.
116 Stages files and/or directories into the nbextensions directory.
115 By default, this compares modification time, and only stages files that need updating.
117 By default, this compares modification time, and only stages files that need updating.
116 If `overwrite` is specified, matching files are purged before proceeding.
118 If `overwrite` is specified, matching files are purged before proceeding.
117
119
118 Parameters
120 Parameters
119 ----------
121 ----------
120
122
121 files : list(paths or URLs) or dict(install_name: path or URL)
123 files : list(paths or URLs) or dict(install_name: path or URL)
122 One or more paths or URLs to existing files directories to install.
124 One or more paths or URLs to existing files directories to install.
123 If given as a list, these will be installed with their base name, so '/path/to/foo'
125 If given as a list, these will be installed with their base name, so '/path/to/foo'
124 will install to 'nbextensions/foo'. If given as a dict, such as {'bar': '/path/to/foo'},
126 will install to 'nbextensions/foo'. If given as a dict, such as {'bar': '/path/to/foo'},
125 then '/path/to/foo' will install to 'nbextensions/bar'.
127 then '/path/to/foo' will install to 'nbextensions/bar'.
126 Archives (zip or tarballs) will be extracted into the nbextensions directory.
128 Archives (zip or tarballs) will be extracted into the nbextensions directory.
127 overwrite : bool [default: False]
129 overwrite : bool [default: False]
128 If True, always install the files, regardless of what may already be installed.
130 If True, always install the files, regardless of what may already be installed.
129 symlink : bool [default: False]
131 symlink : bool [default: False]
130 If True, create a symlink in nbextensions, rather than copying files.
132 If True, create a symlink in nbextensions, rather than copying files.
131 Not allowed with URLs or archives. Windows support for symlinks requires
133 Not allowed with URLs or archives. Windows support for symlinks requires
132 Vista or above, Python 3, and a permission bit which only admin users
134 Vista or above, Python 3, and a permission bit which only admin users
133 have by default, so don't rely on it.
135 have by default, so don't rely on it.
134 user : bool [default: False]
136 user : bool [default: False]
135 Whether to install to the user's .ipython/nbextensions directory.
137 Whether to install to the user's .ipython/nbextensions directory.
136 Otherwise do a system-wide install (e.g. /usr/local/share/jupyter/nbextensions).
138 Otherwise do a system-wide install (e.g. /usr/local/share/jupyter/nbextensions).
137 prefix : str [optional]
139 prefix : str [optional]
138 Specify install prefix, if it should differ from default (e.g. /usr/local).
140 Specify install prefix, if it should differ from default (e.g. /usr/local).
139 Will install to prefix/share/jupyter/nbextensions
141 Will install to prefix/share/jupyter/nbextensions
140 nbextensions_dir : str [optional]
142 nbextensions_dir : str [optional]
141 Specify absolute path of nbextensions directory explicitly.
143 Specify absolute path of nbextensions directory explicitly.
142 verbose : int [default: 1]
144 verbose : int [default: 1]
143 Set verbosity level. The default is 1, where file actions are printed.
145 Set verbosity level. The default is 1, where file actions are printed.
144 set verbose=2 for more output, or verbose=0 for silence.
146 set verbose=2 for more output, or verbose=0 for silence.
145 """
147 """
146 if sum(map(bool, [user, prefix, nbextensions_dir])) > 1:
148 if sum(map(bool, [user, prefix, nbextensions_dir])) > 1:
147 raise ArgumentConflict("Cannot specify more than one of user, prefix, or nbextensions_dir.")
149 raise ArgumentConflict("Cannot specify more than one of user, prefix, or nbextensions_dir.")
148 if user:
150 if user:
149 nbext = pjoin(get_ipython_dir(), u'nbextensions')
151 nbext = pjoin(get_ipython_dir(), u'nbextensions')
150 else:
152 else:
151 if prefix:
153 if prefix:
152 nbext = pjoin(prefix, 'share', 'jupyter', 'nbextensions')
154 nbext = pjoin(prefix, 'share', 'jupyter', 'nbextensions')
153 elif nbextensions_dir:
155 elif nbextensions_dir:
154 nbext = nbextensions_dir
156 nbext = nbextensions_dir
155 else:
157 else:
156 nbext = SYSTEM_NBEXTENSIONS_INSTALL_DIR
158 nbext = SYSTEM_NBEXTENSIONS_INSTALL_DIR
157 # make sure nbextensions dir exists
159 # make sure nbextensions dir exists
158 ensure_dir_exists(nbext)
160 ensure_dir_exists(nbext)
159
161
160 if isinstance(files, string_types):
162 if isinstance(files, string_types):
161 # one file given, turn it into a list
163 # one file given, turn it into a list
162 files = [files]
164 files = [files]
163 if isinstance(files, (list,tuple)):
165 if isinstance(files, (list,tuple)):
164 # list given, turn into dict
166 # list given, turn into dict
165 _files = {}
167 _files = {}
166 for path in map(cast_unicode_py2, files):
168 for path in map(cast_unicode_py2, files):
167 if path.startswith(('https://', 'http://')):
169 if path.startswith(('https://', 'http://')):
168 destination = urlparse(path).path.split('/')[-1]
170 destination = urlparse(path).path.split('/')[-1]
169 elif path.endswith('.zip') or _safe_is_tarfile(path):
171 elif path.endswith('.zip') or _safe_is_tarfile(path):
170 destination = str(uuid.uuid4()) # ignored for archives
172 destination = str(uuid.uuid4()) # ignored for archives
171 else:
173 else:
172 destination = basename(path)
174 destination = basename(path)
173 _files[destination] = path
175 _files[destination] = path
174 files = _files
176 files = _files
175
177
176 for dest_basename,path in (map(cast_unicode_py2, item) for item in files.items()):
178 for dest_basename,path in (map(cast_unicode_py2, item) for item in files.items()):
177
179
178 if path.startswith(('https://', 'http://')):
180 if path.startswith(('https://', 'http://')):
179 if symlink:
181 if symlink:
180 raise ValueError("Cannot symlink from URLs")
182 raise ValueError("Cannot symlink from URLs")
181 # Given a URL, download it
183 # Given a URL, download it
182 with TemporaryDirectory() as td:
184 with TemporaryDirectory() as td:
183 filename = urlparse(path).path.split('/')[-1]
185 filename = urlparse(path).path.split('/')[-1]
184 local_path = os.path.join(td, filename)
186 local_path = os.path.join(td, filename)
185 if verbose >= 1:
187 if verbose >= 1:
186 print("downloading %s to %s" % (path, local_path))
188 print("downloading %s to %s" % (path, local_path))
187 urlretrieve(path, local_path)
189 urlretrieve(path, local_path)
188 # now install from the local copy
190 # now install from the local copy
189 install_nbextension({dest_basename: local_path}, overwrite=overwrite, symlink=symlink, nbextensions_dir=nbext, verbose=verbose)
191 install_nbextension({dest_basename: local_path}, overwrite=overwrite, symlink=symlink, nbextensions_dir=nbext, verbose=verbose)
190 continue
192 continue
191
193
192 # handle archives
194 # handle archives
193 archive = None
195 archive = None
194 if path.endswith('.zip'):
196 if path.endswith('.zip'):
195 archive = zipfile.ZipFile(path)
197 archive = zipfile.ZipFile(path)
196 elif _safe_is_tarfile(path):
198 elif _safe_is_tarfile(path):
197 archive = tarfile.open(path)
199 archive = tarfile.open(path)
198
200
199 if archive:
201 if archive:
200 if symlink:
202 if symlink:
201 raise ValueError("Cannot symlink from archives")
203 raise ValueError("Cannot symlink from archives")
202 if verbose >= 1:
204 if verbose >= 1:
203 print("extracting %s to %s" % (path, nbext))
205 print("extracting %s to %s" % (path, nbext))
204 archive.extractall(nbext)
206 archive.extractall(nbext)
205 archive.close()
207 archive.close()
206 continue
208 continue
207
209
208 dest = pjoin(nbext, dest_basename)
210 dest = pjoin(nbext, dest_basename)
209 if overwrite and os.path.exists(dest):
211 if overwrite and os.path.exists(dest):
210 if verbose >= 1:
212 if verbose >= 1:
211 print("removing %s" % dest)
213 print("removing %s" % dest)
212 if os.path.isdir(dest) and not os.path.islink(dest):
214 if os.path.isdir(dest) and not os.path.islink(dest):
213 shutil.rmtree(dest)
215 shutil.rmtree(dest)
214 else:
216 else:
215 os.remove(dest)
217 os.remove(dest)
216
218
217 if symlink:
219 if symlink:
218 path = os.path.abspath(path)
220 path = os.path.abspath(path)
219 if not os.path.exists(dest):
221 if not os.path.exists(dest):
220 if verbose >= 1:
222 if verbose >= 1:
221 print("symlink %s -> %s" % (dest, path))
223 print("symlink %s -> %s" % (dest, path))
222 os.symlink(path, dest)
224 os.symlink(path, dest)
223 continue
225 continue
224
226
225 if os.path.isdir(path):
227 if os.path.isdir(path):
226 path = pjoin(os.path.abspath(path), '') # end in path separator
228 path = pjoin(os.path.abspath(path), '') # end in path separator
227 for parent, dirs, files in os.walk(path):
229 for parent, dirs, files in os.walk(path):
228 dest_dir = pjoin(dest, parent[len(path):])
230 dest_dir = pjoin(dest, parent[len(path):])
229 if not os.path.exists(dest_dir):
231 if not os.path.exists(dest_dir):
230 if verbose >= 2:
232 if verbose >= 2:
231 print("making directory %s" % dest_dir)
233 print("making directory %s" % dest_dir)
232 os.makedirs(dest_dir)
234 os.makedirs(dest_dir)
233 for file in files:
235 for file in files:
234 src = pjoin(parent, file)
236 src = pjoin(parent, file)
235 # print("%r, %r" % (dest_dir, file))
237 # print("%r, %r" % (dest_dir, file))
236 dest_file = pjoin(dest_dir, file)
238 dest_file = pjoin(dest_dir, file)
237 _maybe_copy(src, dest_file, verbose)
239 _maybe_copy(src, dest_file, verbose)
238 else:
240 else:
239 src = path
241 src = path
240 _maybe_copy(src, dest, verbose)
242 _maybe_copy(src, dest, verbose)
241
243
242 #----------------------------------------------------------------------
244 #----------------------------------------------------------------------
243 # install nbextension app
245 # install nbextension app
244 #----------------------------------------------------------------------
246 #----------------------------------------------------------------------
245
247
246 from IPython.utils.traitlets import Bool, Enum, Unicode, TraitError
248 from IPython.utils.traitlets import Bool, Enum, Unicode, TraitError
247 from IPython.core.application import BaseIPythonApplication
249 from IPython.core.application import BaseIPythonApplication
248
250
249 flags = {
251 flags = {
250 "overwrite" : ({
252 "overwrite" : ({
251 "NBExtensionApp" : {
253 "NBExtensionApp" : {
252 "overwrite" : True,
254 "overwrite" : True,
253 }}, "Force overwrite of existing files"
255 }}, "Force overwrite of existing files"
254 ),
256 ),
255 "debug" : ({
257 "debug" : ({
256 "NBExtensionApp" : {
258 "NBExtensionApp" : {
257 "verbose" : 2,
259 "verbose" : 2,
258 }}, "Extra output"
260 }}, "Extra output"
259 ),
261 ),
260 "quiet" : ({
262 "quiet" : ({
261 "NBExtensionApp" : {
263 "NBExtensionApp" : {
262 "verbose" : 0,
264 "verbose" : 0,
263 }}, "Minimal output"
265 }}, "Minimal output"
264 ),
266 ),
265 "symlink" : ({
267 "symlink" : ({
266 "NBExtensionApp" : {
268 "NBExtensionApp" : {
267 "symlink" : True,
269 "symlink" : True,
268 }}, "Create symlinks instead of copying files"
270 }}, "Create symlinks instead of copying files"
269 ),
271 ),
270 "user" : ({
272 "user" : ({
271 "NBExtensionApp" : {
273 "NBExtensionApp" : {
272 "user" : True,
274 "user" : True,
273 }}, "Install to the user's IPython directory"
275 }}, "Install to the user's IPython directory"
274 ),
276 ),
275 }
277 }
276 flags['s'] = flags['symlink']
278 flags['s'] = flags['symlink']
277
279
278 aliases = {
280 aliases = {
279 "ipython-dir" : "NBExtensionApp.ipython_dir",
281 "ipython-dir" : "NBExtensionApp.ipython_dir",
280 "prefix" : "NBExtensionApp.prefix",
282 "prefix" : "NBExtensionApp.prefix",
281 "nbextensions" : "NBExtensionApp.nbextensions_dir",
283 "nbextensions" : "NBExtensionApp.nbextensions_dir",
282 }
284 }
283
285
284 class NBExtensionApp(BaseIPythonApplication):
286 class NBExtensionApp(BaseIPythonApplication):
285 """Entry point for installing notebook extensions"""
287 """Entry point for installing notebook extensions"""
286
288
287 description = """Install IPython notebook extensions
289 description = """Install IPython notebook extensions
288
290
289 Usage
291 Usage
290
292
291 ipython install-nbextension file [more files, folders, archives or urls]
293 ipython install-nbextension file [more files, folders, archives or urls]
292
294
293 This copies files and/or folders into the IPython nbextensions directory.
295 This copies files and/or folders into the IPython nbextensions directory.
294 If a URL is given, it will be downloaded.
296 If a URL is given, it will be downloaded.
295 If an archive is given, it will be extracted into nbextensions.
297 If an archive is given, it will be extracted into nbextensions.
296 If the requested files are already up to date, no action is taken
298 If the requested files are already up to date, no action is taken
297 unless --overwrite is specified.
299 unless --overwrite is specified.
298 """
300 """
299
301
300 examples = """
302 examples = """
301 ipython install-nbextension /path/to/d3.js /path/to/myextension
303 ipython install-nbextension /path/to/d3.js /path/to/myextension
302 """
304 """
303 aliases = aliases
305 aliases = aliases
304 flags = flags
306 flags = flags
305
307
306 overwrite = Bool(False, config=True, help="Force overwrite of existing files")
308 overwrite = Bool(False, config=True, help="Force overwrite of existing files")
307 symlink = Bool(False, config=True, help="Create symlinks instead of copying files")
309 symlink = Bool(False, config=True, help="Create symlinks instead of copying files")
308 user = Bool(False, config=True, help="Whether to do a user install")
310 user = Bool(False, config=True, help="Whether to do a user install")
309 prefix = Unicode('', config=True, help="Installation prefix")
311 prefix = Unicode('', config=True, help="Installation prefix")
310 nbextensions_dir = Unicode('', config=True, help="Full path to nbextensions dir (probably use prefix or user)")
312 nbextensions_dir = Unicode('', config=True, help="Full path to nbextensions dir (probably use prefix or user)")
311 verbose = Enum((0,1,2), default_value=1, config=True,
313 verbose = Enum((0,1,2), default_value=1, config=True,
312 help="Verbosity level"
314 help="Verbosity level"
313 )
315 )
314
316
315 def install_extensions(self):
317 def install_extensions(self):
316 install_nbextension(self.extra_args,
318 install_nbextension(self.extra_args,
317 overwrite=self.overwrite,
319 overwrite=self.overwrite,
318 symlink=self.symlink,
320 symlink=self.symlink,
319 verbose=self.verbose,
321 verbose=self.verbose,
320 user=self.user,
322 user=self.user,
321 prefix=self.prefix,
323 prefix=self.prefix,
322 nbextensions_dir=self.nbextensions_dir,
324 nbextensions_dir=self.nbextensions_dir,
323 )
325 )
324
326
325 def start(self):
327 def start(self):
326 if not self.extra_args:
328 if not self.extra_args:
327 for nbext in [pjoin(self.ipython_dir, u'nbextensions')] + SYSTEM_NBEXTENSIONS_DIRS:
329 for nbext in [pjoin(self.ipython_dir, u'nbextensions')] + SYSTEM_NBEXTENSIONS_DIRS:
328 if os.path.exists(nbext):
330 if os.path.exists(nbext):
329 print("Notebook extensions in %s:" % nbext)
331 print("Notebook extensions in %s:" % nbext)
330 for ext in os.listdir(nbext):
332 for ext in os.listdir(nbext):
331 print(u" %s" % ext)
333 print(u" %s" % ext)
332 else:
334 else:
333 try:
335 try:
334 self.install_extensions()
336 self.install_extensions()
335 except ArgumentConflict as e:
337 except ArgumentConflict as e:
336 print(str(e), file=sys.stderr)
338 print(str(e), file=sys.stderr)
337 self.exit(1)
339 self.exit(1)
338
340
339
341
340 if __name__ == '__main__':
342 if __name__ == '__main__':
341 NBExtensionApp.launch_instance()
343 NBExtensionApp.launch_instance()
342 No newline at end of file
344
General Comments 0
You need to be logged in to leave comments. Login now