##// END OF EJS Templates
checkpoint info is a dict...
MinRK -
Show More
@@ -1,323 +1,343 b''
1 """A notebook manager that uses the local file system for storage.
1 """A notebook manager that uses the local file system for storage.
2
2
3 Authors:
3 Authors:
4
4
5 * Brian Granger
5 * Brian Granger
6 """
6 """
7
7
8 #-----------------------------------------------------------------------------
8 #-----------------------------------------------------------------------------
9 # Copyright (C) 2011 The IPython Development Team
9 # Copyright (C) 2011 The IPython Development Team
10 #
10 #
11 # Distributed under the terms of the BSD License. The full license is in
11 # Distributed under the terms of the BSD License. The full license is in
12 # the file COPYING, distributed as part of this software.
12 # the file COPYING, distributed as part of this software.
13 #-----------------------------------------------------------------------------
13 #-----------------------------------------------------------------------------
14
14
15 #-----------------------------------------------------------------------------
15 #-----------------------------------------------------------------------------
16 # Imports
16 # Imports
17 #-----------------------------------------------------------------------------
17 #-----------------------------------------------------------------------------
18
18
19 import datetime
19 import datetime
20 import io
20 import io
21 import os
21 import os
22 import glob
22 import glob
23 import shutil
23 import shutil
24
24
25 from tornado import web
25 from tornado import web
26
26
27 from .nbmanager import NotebookManager
27 from .nbmanager import NotebookManager
28 from IPython.nbformat import current
28 from IPython.nbformat import current
29 from IPython.utils.traitlets import Unicode, Dict, Bool, TraitError
29 from IPython.utils.traitlets import Unicode, Dict, Bool, TraitError
30
30
31 #-----------------------------------------------------------------------------
31 #-----------------------------------------------------------------------------
32 # Classes
32 # Classes
33 #-----------------------------------------------------------------------------
33 #-----------------------------------------------------------------------------
34
34
35 class FileNotebookManager(NotebookManager):
35 class FileNotebookManager(NotebookManager):
36
36
37 save_script = Bool(False, config=True,
37 save_script = Bool(False, config=True,
38 help="""Automatically create a Python script when saving the notebook.
38 help="""Automatically create a Python script when saving the notebook.
39
39
40 For easier use of import, %run and %load across notebooks, a
40 For easier use of import, %run and %load across notebooks, a
41 <notebook-name>.py script will be created next to any
41 <notebook-name>.py script will be created next to any
42 <notebook-name>.ipynb on each save. This can also be set with the
42 <notebook-name>.ipynb on each save. This can also be set with the
43 short `--script` flag.
43 short `--script` flag.
44 """
44 """
45 )
45 )
46
46
47 checkpoint_dir = Unicode(config=True,
47 checkpoint_dir = Unicode(config=True,
48 help="""The location in which to keep notebook checkpoints
48 help="""The location in which to keep notebook checkpoints
49
49
50 By default, it is notebook-dir/.ipynb_checkpoints
50 By default, it is notebook-dir/.ipynb_checkpoints
51 """
51 """
52 )
52 )
53 def _checkpoint_dir_default(self):
53 def _checkpoint_dir_default(self):
54 return os.path.join(self.notebook_dir, '.ipynb_checkpoints')
54 return os.path.join(self.notebook_dir, '.ipynb_checkpoints')
55
55
56 def _checkpoint_dir_changed(self, name, old, new):
56 def _checkpoint_dir_changed(self, name, old, new):
57 """do a bit of validation of the checkpoint dir"""
57 """do a bit of validation of the checkpoint dir"""
58 if not os.path.isabs(new):
58 if not os.path.isabs(new):
59 # If we receive a non-absolute path, make it absolute.
59 # If we receive a non-absolute path, make it absolute.
60 abs_new = os.path.abspath(new)
60 abs_new = os.path.abspath(new)
61 self.checkpoint_dir = abs_new
61 self.checkpoint_dir = abs_new
62 return
62 return
63 if os.path.exists(new) and not os.path.isdir(new):
63 if os.path.exists(new) and not os.path.isdir(new):
64 raise TraitError("checkpoint dir %r is not a directory" % new)
64 raise TraitError("checkpoint dir %r is not a directory" % new)
65 if not os.path.exists(new):
65 if not os.path.exists(new):
66 self.log.info("Creating checkpoint dir %s", new)
66 self.log.info("Creating checkpoint dir %s", new)
67 try:
67 try:
68 os.mkdir(new)
68 os.mkdir(new)
69 except:
69 except:
70 raise TraitError("Couldn't create checkpoint dir %r" % new)
70 raise TraitError("Couldn't create checkpoint dir %r" % new)
71
71
72 filename_ext = Unicode(u'.ipynb')
72 filename_ext = Unicode(u'.ipynb')
73
73
74 # Map notebook names to notebook_ids
74 # Map notebook names to notebook_ids
75 rev_mapping = Dict()
75 rev_mapping = Dict()
76
76
77 def get_notebook_names(self):
77 def get_notebook_names(self):
78 """List all notebook names in the notebook dir."""
78 """List all notebook names in the notebook dir."""
79 names = glob.glob(os.path.join(self.notebook_dir,
79 names = glob.glob(os.path.join(self.notebook_dir,
80 '*' + self.filename_ext))
80 '*' + self.filename_ext))
81 names = [os.path.splitext(os.path.basename(name))[0]
81 names = [os.path.splitext(os.path.basename(name))[0]
82 for name in names]
82 for name in names]
83 return names
83 return names
84
84
85 def list_notebooks(self):
85 def list_notebooks(self):
86 """List all notebooks in the notebook dir."""
86 """List all notebooks in the notebook dir."""
87 names = self.get_notebook_names()
87 names = self.get_notebook_names()
88
88
89 data = []
89 data = []
90 for name in names:
90 for name in names:
91 if name not in self.rev_mapping:
91 if name not in self.rev_mapping:
92 notebook_id = self.new_notebook_id(name)
92 notebook_id = self.new_notebook_id(name)
93 else:
93 else:
94 notebook_id = self.rev_mapping[name]
94 notebook_id = self.rev_mapping[name]
95 data.append(dict(notebook_id=notebook_id,name=name))
95 data.append(dict(notebook_id=notebook_id,name=name))
96 data = sorted(data, key=lambda item: item['name'])
96 data = sorted(data, key=lambda item: item['name'])
97 return data
97 return data
98
98
99 def new_notebook_id(self, name):
99 def new_notebook_id(self, name):
100 """Generate a new notebook_id for a name and store its mappings."""
100 """Generate a new notebook_id for a name and store its mappings."""
101 notebook_id = super(FileNotebookManager, self).new_notebook_id(name)
101 notebook_id = super(FileNotebookManager, self).new_notebook_id(name)
102 self.rev_mapping[name] = notebook_id
102 self.rev_mapping[name] = notebook_id
103 return notebook_id
103 return notebook_id
104
104
105 def delete_notebook_id(self, notebook_id):
105 def delete_notebook_id(self, notebook_id):
106 """Delete a notebook's id in the mapping."""
106 """Delete a notebook's id in the mapping."""
107 name = self.mapping[notebook_id]
107 name = self.mapping[notebook_id]
108 super(FileNotebookManager, self).delete_notebook_id(notebook_id)
108 super(FileNotebookManager, self).delete_notebook_id(notebook_id)
109 del self.rev_mapping[name]
109 del self.rev_mapping[name]
110
110
111 def notebook_exists(self, notebook_id):
111 def notebook_exists(self, notebook_id):
112 """Does a notebook exist?"""
112 """Does a notebook exist?"""
113 exists = super(FileNotebookManager, self).notebook_exists(notebook_id)
113 exists = super(FileNotebookManager, self).notebook_exists(notebook_id)
114 if not exists:
114 if not exists:
115 return False
115 return False
116 path = self.get_path_by_name(self.mapping[notebook_id])
116 path = self.get_path_by_name(self.mapping[notebook_id])
117 return os.path.isfile(path)
117 return os.path.isfile(path)
118
118
119 def get_name(self, notebook_id):
119 def get_name(self, notebook_id):
120 """get a notebook name, raising 404 if not found"""
120 """get a notebook name, raising 404 if not found"""
121 try:
121 try:
122 name = self.mapping[notebook_id]
122 name = self.mapping[notebook_id]
123 except KeyError:
123 except KeyError:
124 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
124 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
125 return name
125 return name
126
126
127 def get_path(self, notebook_id):
127 def get_path(self, notebook_id):
128 """Return a full path to a notebook given its notebook_id."""
128 """Return a full path to a notebook given its notebook_id."""
129 name = self.get_name(notebook_id)
129 name = self.get_name(notebook_id)
130 return self.get_path_by_name(name)
130 return self.get_path_by_name(name)
131
131
132 def get_path_by_name(self, name):
132 def get_path_by_name(self, name):
133 """Return a full path to a notebook given its name."""
133 """Return a full path to a notebook given its name."""
134 filename = name + self.filename_ext
134 filename = name + self.filename_ext
135 path = os.path.join(self.notebook_dir, filename)
135 path = os.path.join(self.notebook_dir, filename)
136 return path
136 return path
137
137
138 def read_notebook_object_from_path(self, path):
138 def read_notebook_object_from_path(self, path):
139 """read a notebook object from a path"""
139 """read a notebook object from a path"""
140 info = os.stat(path)
140 info = os.stat(path)
141 last_modified = datetime.datetime.utcfromtimestamp(info.st_mtime)
141 last_modified = datetime.datetime.utcfromtimestamp(info.st_mtime)
142 with open(path,'r') as f:
142 with open(path,'r') as f:
143 s = f.read()
143 s = f.read()
144 try:
144 try:
145 # v1 and v2 and json in the .ipynb files.
145 # v1 and v2 and json in the .ipynb files.
146 nb = current.reads(s, u'json')
146 nb = current.reads(s, u'json')
147 except Exception as e:
147 except Exception as e:
148 raise web.HTTPError(500, u'Unreadable JSON notebook: %s' % e)
148 raise web.HTTPError(500, u'Unreadable JSON notebook: %s' % e)
149 return last_modified, nb
149 return last_modified, nb
150
150
151 def read_notebook_object(self, notebook_id):
151 def read_notebook_object(self, notebook_id):
152 """Get the Notebook representation of a notebook by notebook_id."""
152 """Get the Notebook representation of a notebook by notebook_id."""
153 path = self.get_path(notebook_id)
153 path = self.get_path(notebook_id)
154 if not os.path.isfile(path):
154 if not os.path.isfile(path):
155 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
155 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
156 last_modified, nb = self.read_notebook_object_from_path(path)
156 last_modified, nb = self.read_notebook_object_from_path(path)
157 # Always use the filename as the notebook name.
157 # Always use the filename as the notebook name.
158 nb.metadata.name = os.path.splitext(os.path.basename(path))[0]
158 nb.metadata.name = os.path.splitext(os.path.basename(path))[0]
159 return last_modified, nb
159 return last_modified, nb
160
160
161 def write_notebook_object(self, nb, notebook_id=None):
161 def write_notebook_object(self, nb, notebook_id=None):
162 """Save an existing notebook object by notebook_id."""
162 """Save an existing notebook object by notebook_id."""
163 try:
163 try:
164 new_name = nb.metadata.name
164 new_name = nb.metadata.name
165 except AttributeError:
165 except AttributeError:
166 raise web.HTTPError(400, u'Missing notebook name')
166 raise web.HTTPError(400, u'Missing notebook name')
167
167
168 if notebook_id is None:
168 if notebook_id is None:
169 notebook_id = self.new_notebook_id(new_name)
169 notebook_id = self.new_notebook_id(new_name)
170
170
171 if notebook_id not in self.mapping:
171 if notebook_id not in self.mapping:
172 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
172 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
173
173
174 old_name = self.mapping[notebook_id]
174 old_name = self.mapping[notebook_id]
175 old_checkpoints = self.list_checkpoints(notebook_id)
175 old_checkpoints = self.list_checkpoints(notebook_id)
176
176
177 path = self.get_path_by_name(new_name)
177 path = self.get_path_by_name(new_name)
178 try:
178 try:
179 self.log.debug("Writing notebook %s", path)
179 self.log.debug("Writing notebook %s", path)
180 with open(path,'w') as f:
180 with open(path,'w') as f:
181 current.write(nb, f, u'json')
181 current.write(nb, f, u'json')
182 except Exception as e:
182 except Exception as e:
183 raise web.HTTPError(400, u'Unexpected error while saving notebook: %s' % e)
183 raise web.HTTPError(400, u'Unexpected error while saving notebook: %s' % e)
184
184
185 # save .py script as well
185 # save .py script as well
186 if self.save_script:
186 if self.save_script:
187 pypath = os.path.splitext(path)[0] + '.py'
187 pypath = os.path.splitext(path)[0] + '.py'
188 self.log.debug("Writing script %s", pypath)
188 self.log.debug("Writing script %s", pypath)
189 try:
189 try:
190 with io.open(pypath,'w', encoding='utf-8') as f:
190 with io.open(pypath,'w', encoding='utf-8') as f:
191 current.write(nb, f, u'py')
191 current.write(nb, f, u'py')
192 except Exception as e:
192 except Exception as e:
193 raise web.HTTPError(400, u'Unexpected error while saving notebook as script: %s' % e)
193 raise web.HTTPError(400, u'Unexpected error while saving notebook as script: %s' % e)
194
194
195 # remove old files if the name changed
195 # remove old files if the name changed
196 if old_name != new_name:
196 if old_name != new_name:
197 # update mapping
197 # update mapping
198 self.mapping[notebook_id] = new_name
198 self.mapping[notebook_id] = new_name
199 self.rev_mapping[new_name] = notebook_id
199 self.rev_mapping[new_name] = notebook_id
200 del self.rev_mapping[old_name]
200 del self.rev_mapping[old_name]
201
201
202 # remove renamed original, if it exists
202 # remove renamed original, if it exists
203 old_path = self.get_path_by_name(old_name)
203 old_path = self.get_path_by_name(old_name)
204 if os.path.isfile(old_path):
204 if os.path.isfile(old_path):
205 self.log.debug("unlinking %s", old_path)
205 self.log.debug("unlinking %s", old_path)
206 os.unlink(old_path)
206 os.unlink(old_path)
207
207
208 # cleanup old script, if it exists
208 # cleanup old script, if it exists
209 if self.save_script:
209 if self.save_script:
210 old_pypath = os.path.splitext(old_path)[0] + '.py'
210 old_pypath = os.path.splitext(old_path)[0] + '.py'
211 if os.path.isfile(old_pypath):
211 if os.path.isfile(old_pypath):
212 self.log.debug("unlinking %s", old_pypath)
212 self.log.debug("unlinking %s", old_pypath)
213 os.unlink(old_pypath)
213 os.unlink(old_pypath)
214
214
215 # rename checkpoints to follow file
215 # rename checkpoints to follow file
216 self.log.debug("%s", old_checkpoints)
216 self.log.debug("%s", old_checkpoints)
217 for cp in old_checkpoints:
217 for cp in old_checkpoints:
218 old_cp_path = self.get_checkpoint_path_by_name(old_name, cp)
218 old_cp_path = self.get_checkpoint_path_by_name(old_name, cp)
219 new_cp_path = self.get_checkpoint_path_by_name(new_name, cp)
219 new_cp_path = self.get_checkpoint_path_by_name(new_name, cp)
220 if os.path.isfile(old_cp_path):
220 if os.path.isfile(old_cp_path):
221 self.log.debug("renaming %s -> %s", old_cp_path, new_cp_path)
221 self.log.debug("renaming %s -> %s", old_cp_path, new_cp_path)
222 os.rename(old_cp_path, new_cp_path)
222 os.rename(old_cp_path, new_cp_path)
223
223
224 return notebook_id
224 return notebook_id
225
225
226 def delete_notebook(self, notebook_id):
226 def delete_notebook(self, notebook_id):
227 """Delete notebook by notebook_id."""
227 """Delete notebook by notebook_id."""
228 path = self.get_path(notebook_id)
228 path = self.get_path(notebook_id)
229 if not os.path.isfile(path):
229 if not os.path.isfile(path):
230 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
230 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
231 self.log.debug("unlinking %s", path)
231 self.log.debug("unlinking %s", path)
232 os.unlink(path)
232 os.unlink(path)
233
233
234 # clear checkpoints
234 # clear checkpoints
235 for checkpoint_id in self.list_checkpoints(notebook_id):
235 for checkpoint_id in self.list_checkpoints(notebook_id):
236 path = self.get_checkpoint_path(notebook_id, checkpoint_id)
236 path = self.get_checkpoint_path(notebook_id, checkpoint_id)
237 if os.path.isfile(path):
237 if os.path.isfile(path):
238 self.log.debug("unlinking %s", path)
238 self.log.debug("unlinking %s", path)
239 os.unlink(path)
239 os.unlink(path)
240 self.delete_notebook_id(notebook_id)
240 self.delete_notebook_id(notebook_id)
241
241
242 def increment_filename(self, basename):
242 def increment_filename(self, basename):
243 """Return a non-used filename of the form basename<int>.
243 """Return a non-used filename of the form basename<int>.
244
244
245 This searches through the filenames (basename0, basename1, ...)
245 This searches through the filenames (basename0, basename1, ...)
246 until is find one that is not already being used. It is used to
246 until is find one that is not already being used. It is used to
247 create Untitled and Copy names that are unique.
247 create Untitled and Copy names that are unique.
248 """
248 """
249 i = 0
249 i = 0
250 while True:
250 while True:
251 name = u'%s%i' % (basename,i)
251 name = u'%s%i' % (basename,i)
252 path = self.get_path_by_name(name)
252 path = self.get_path_by_name(name)
253 if not os.path.isfile(path):
253 if not os.path.isfile(path):
254 break
254 break
255 else:
255 else:
256 i = i+1
256 i = i+1
257 return name
257 return name
258
258
259 # Checkpoint-related utilities
259 # Checkpoint-related utilities
260
260
261 def get_checkpoint_path_by_name(self, name, checkpoint_id):
261 def get_checkpoint_path_by_name(self, name, checkpoint_id):
262 """Return a full path to a notebook checkpoint, given its name and checkpoint id."""
262 """Return a full path to a notebook checkpoint, given its name and checkpoint id."""
263 filename = "{name}-{checkpoint_id}{ext}".format(
263 filename = "{name}-{checkpoint_id}{ext}".format(
264 name=name,
264 name=name,
265 checkpoint_id=checkpoint_id,
265 checkpoint_id=checkpoint_id,
266 ext=self.filename_ext,
266 ext=self.filename_ext,
267 )
267 )
268 path = os.path.join(self.checkpoint_dir, filename)
268 path = os.path.join(self.checkpoint_dir, filename)
269 return path
269 return path
270
270
271 def get_checkpoint_path(self, notebook_id, checkpoint_id):
271 def get_checkpoint_path(self, notebook_id, checkpoint_id):
272 """find the path to a checkpoint"""
272 """find the path to a checkpoint"""
273 name = self.get_name(notebook_id)
273 name = self.get_name(notebook_id)
274 return self.get_checkpoint_path_by_name(name, checkpoint_id)
274 return self.get_checkpoint_path_by_name(name, checkpoint_id)
275
275
276 def get_checkpoint_info(self, notebook_id, checkpoint_id):
277 """construct the info dict for a given checkpoint"""
278 path = self.get_checkpoint_path(notebook_id, checkpoint_id)
279 stats = os.stat(path)
280 last_modified = datetime.datetime.utcfromtimestamp(stats.st_mtime)
281 info = dict(
282 checkpoint_id = checkpoint_id,
283 last_modified = last_modified,
284 )
285
286 return info
287
276 # public checkpoint API
288 # public checkpoint API
277
289
278 def create_checkpoint(self, notebook_id):
290 def create_checkpoint(self, notebook_id):
279 """Create a checkpoint from the current state of a notebook"""
291 """Create a checkpoint from the current state of a notebook"""
280 nb_path = self.get_path(notebook_id)
292 nb_path = self.get_path(notebook_id)
281 cp_path = self.get_checkpoint_path(notebook_id, "checkpoint")
293 # only the one checkpoint ID:
294 checkpoint_id = "checkpoint"
295 cp_path = self.get_checkpoint_path(notebook_id, checkpoint_id)
282 self.log.debug("creating checkpoint for notebook %s", notebook_id)
296 self.log.debug("creating checkpoint for notebook %s", notebook_id)
283 if not os.path.exists(self.checkpoint_dir):
297 if not os.path.exists(self.checkpoint_dir):
284 os.mkdir(self.checkpoint_dir)
298 os.mkdir(self.checkpoint_dir)
285 shutil.copy2(nb_path, cp_path)
299 shutil.copy2(nb_path, cp_path)
300
301 # return the checkpoint info
302 return self.get_checkpoint_info(notebook_id, checkpoint_id)
286
303
287 def list_checkpoints(self, notebook_id):
304 def list_checkpoints(self, notebook_id):
288 """list the checkpoints for a given notebook
305 """list the checkpoints for a given notebook
289
306
290 This notebook manager currently only supports one checkpoint per notebook.
307 This notebook manager currently only supports one checkpoint per notebook.
291 """
308 """
292 path = self.get_checkpoint_path(notebook_id, "checkpoint")
309 checkpoint_id = "checkpoint"
293 if os.path.exists(path):
310 path = self.get_checkpoint_path(notebook_id, checkpoint_id)
294 return ["checkpoint"]
311 if not os.path.exists(path):
295 else:
296 return []
312 return []
313 else:
314 return [self.get_checkpoint_info(notebook_id, checkpoint_id)]
315
297
316
298 def restore_checkpoint(self, notebook_id, checkpoint_id):
317 def restore_checkpoint(self, notebook_id, checkpoint_id):
299 """restore a notebook to a checkpointed state"""
318 """restore a notebook to a checkpointed state"""
300 self.log.info("restoring Notebook %s from checkpoint %s", notebook_id, checkpoint_id)
319 self.log.info("restoring Notebook %s from checkpoint %s", notebook_id, checkpoint_id)
301 nb_path = self.get_path(notebook_id)
320 nb_path = self.get_path(notebook_id)
302 cp_path = self.get_checkpoint_path(notebook_id, checkpoint_id)
321 cp_path = self.get_checkpoint_path(notebook_id, checkpoint_id)
303 if not os.path.isfile(cp_path):
322 if not os.path.isfile(cp_path):
323 self.log.debug("checkpoint file does not exist: %s", cp_path)
304 raise web.HTTPError(404,
324 raise web.HTTPError(404,
305 u'Notebook checkpoint does not exist: %s-%s' % (notebook_id, checkpoint_id)
325 u'Notebook checkpoint does not exist: %s-%s' % (notebook_id, checkpoint_id)
306 )
326 )
307 # ensure notebook is readable (never restore from an unreadable notebook)
327 # ensure notebook is readable (never restore from an unreadable notebook)
308 last_modified, nb = self.read_notebook_object_from_path(cp_path)
328 last_modified, nb = self.read_notebook_object_from_path(cp_path)
309 shutil.copy2(cp_path, nb_path)
329 shutil.copy2(cp_path, nb_path)
310 self.log.debug("copying %s -> %s", cp_path, nb_path)
330 self.log.debug("copying %s -> %s", cp_path, nb_path)
311
331
312 def delete_checkpoint(self, notebook_id, checkpoint_id):
332 def delete_checkpoint(self, notebook_id, checkpoint_id):
313 """delete a notebook's checkpoint"""
333 """delete a notebook's checkpoint"""
314 path = self.get_checkpoint_path(notebook_id, checkpoint_id)
334 path = self.get_checkpoint_path(notebook_id, checkpoint_id)
315 if not os.path.isfile(path):
335 if not os.path.isfile(path):
316 raise web.HTTPError(404,
336 raise web.HTTPError(404,
317 u'Notebook checkpoint does not exist: %s-%s' % (notebook_id, checkpoint_id)
337 u'Notebook checkpoint does not exist: %s-%s' % (notebook_id, checkpoint_id)
318 )
338 )
319 self.log.debug("unlinking %s", path)
339 self.log.debug("unlinking %s", path)
320 os.unlink(path)
340 os.unlink(path)
321
341
322 def info_string(self):
342 def info_string(self):
323 return "Serving notebooks from local directory: %s" % self.notebook_dir
343 return "Serving notebooks from local directory: %s" % self.notebook_dir
General Comments 0
You need to be logged in to leave comments. Login now