Show More
@@ -507,6 +507,12 b' class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):' | |||
|
507 | 507 | # under Python 2.x for some reason |
|
508 | 508 | msg = msg.encode('utf8', 'replace') |
|
509 | 509 | try: |
|
510 | identity, msg = msg.split(':', 1) | |
|
511 | self.session.session = identity.decode('ascii') | |
|
512 | except Exception: | |
|
513 | logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg) | |
|
514 | ||
|
515 | try: | |
|
510 | 516 | self.request._cookies = Cookie.SimpleCookie(msg) |
|
511 | 517 | except: |
|
512 | 518 | self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True) |
@@ -519,23 +525,28 b' class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):' | |||
|
519 | 525 | self.on_message = self.save_on_message |
|
520 | 526 | |
|
521 | 527 | |
|
522 |
class |
|
|
523 | ||
|
528 | class ZMQChannelHandler(AuthenticatedZMQStreamHandler): | |
|
529 | ||
|
530 | @property | |
|
531 | def max_msg_size(self): | |
|
532 | return self.settings.get('max_msg_size', 65535) | |
|
533 | ||
|
534 | def create_stream(self): | |
|
535 | km = self.kernel_manager | |
|
536 | meth = getattr(km, 'connect_%s' % self.channel) | |
|
537 | self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession) | |
|
538 | ||
|
524 | 539 | def initialize(self, *args, **kwargs): |
|
525 |
self. |
|
|
526 | ||
|
540 | self.zmq_stream = None | |
|
541 | ||
|
527 | 542 | def on_first_message(self, msg): |
|
528 | 543 | try: |
|
529 |
super( |
|
|
544 | super(ZMQChannelHandler, self).on_first_message(msg) | |
|
530 | 545 | except web.HTTPError: |
|
531 | 546 | self.close() |
|
532 | 547 | return |
|
533 | km = self.kernel_manager | |
|
534 | kernel_id = self.kernel_id | |
|
535 | km.add_restart_callback(kernel_id, self.on_kernel_restarted) | |
|
536 | km.add_restart_callback(kernel_id, self.on_restart_failed, 'dead') | |
|
537 | 548 | try: |
|
538 | self.iopub_stream = km.connect_iopub(kernel_id) | |
|
549 | self.create_stream() | |
|
539 | 550 | except web.HTTPError: |
|
540 | 551 | # WebSockets don't response to traditional error codes so we |
|
541 | 552 | # close the connection. |
@@ -543,29 +554,32 b' class IOPubHandler(AuthenticatedZMQStreamHandler):' | |||
|
543 | 554 | self.stream.close() |
|
544 | 555 | self.close() |
|
545 | 556 | else: |
|
546 |
self. |
|
|
557 | self.zmq_stream.on_recv(self._on_zmq_reply) | |
|
547 | 558 | |
|
548 | 559 | def on_message(self, msg): |
|
549 | pass | |
|
550 | ||
|
551 | def _send_status_message(self, status): | |
|
552 | msg = self.session.msg("status", | |
|
553 | {'execution_state': status} | |
|
554 | ) | |
|
555 | self.write_message(jsonapi.dumps(msg, default=date_default)) | |
|
556 | ||
|
557 | def on_kernel_restarted(self): | |
|
558 | self.log.warn("kernel %s restarted", self.kernel_id) | |
|
559 | self._send_status_message('restarting') | |
|
560 | ||
|
561 | def on_restart_failed(self): | |
|
562 | self.log.error("kernel %s restarted failed!", self.kernel_id) | |
|
563 | self._send_status_message('dead') | |
|
560 | if len(msg) < self.max_msg_size: | |
|
561 | msg = jsonapi.loads(msg) | |
|
562 | self.session.send(self.zmq_stream, msg) | |
|
564 | 563 | |
|
565 | 564 | def on_close(self): |
|
566 | 565 | # This method can be called twice, once by self.kernel_died and once |
|
567 | 566 | # from the WebSocket close event. If the WebSocket connection is |
|
568 | 567 | # closed before the ZMQ streams are setup, they could be None. |
|
568 | if self.zmq_stream is not None and not self.zmq_stream.closed(): | |
|
569 | self.zmq_stream.on_recv(None) | |
|
570 | self.zmq_stream.close() | |
|
571 | ||
|
572 | ||
|
573 | class IOPubHandler(ZMQChannelHandler): | |
|
574 | channel = 'iopub' | |
|
575 | ||
|
576 | def create_stream(self): | |
|
577 | super(IOPubHandler, self).create_stream() | |
|
578 | km = self.kernel_manager | |
|
579 | km.add_restart_callback(self.kernel_id, self.on_kernel_restarted) | |
|
580 | km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead') | |
|
581 | ||
|
582 | def on_close(self): | |
|
569 | 583 | km = self.kernel_manager |
|
570 | 584 | if self.kernel_id in km: |
|
571 | 585 | km.remove_restart_callback( |
@@ -574,48 +588,31 b' class IOPubHandler(AuthenticatedZMQStreamHandler):' | |||
|
574 | 588 | km.remove_restart_callback( |
|
575 | 589 | self.kernel_id, self.on_restart_failed, 'dead', |
|
576 | 590 | ) |
|
577 | if self.iopub_stream is not None and not self.iopub_stream.closed(): | |
|
578 | self.iopub_stream.on_recv(None) | |
|
579 | self.iopub_stream.close() | |
|
580 | ||
|
581 | ||
|
582 | class ShellHandler(AuthenticatedZMQStreamHandler): | |
|
591 | super(IOPubHandler, self).on_close() | |
|
583 | 592 | |
|
584 | @property | |
|
585 | def max_msg_size(self): | |
|
586 | return self.settings.get('max_msg_size', 65535) | |
|
587 | ||
|
588 | def initialize(self, *args, **kwargs): | |
|
589 | self.shell_stream = None | |
|
593 | def _send_status_message(self, status): | |
|
594 | msg = self.session.msg("status", | |
|
595 | {'execution_state': status} | |
|
596 | ) | |
|
597 | self.write_message(jsonapi.dumps(msg, default=date_default)) | |
|
590 | 598 | |
|
591 |
def on_ |
|
|
592 | try: | |
|
593 | super(ShellHandler, self).on_first_message(msg) | |
|
594 | except web.HTTPError: | |
|
595 | self.close() | |
|
596 | return | |
|
597 | km = self.kernel_manager | |
|
598 | kernel_id = self.kernel_id | |
|
599 | try: | |
|
600 | self.shell_stream = km.connect_shell(kernel_id) | |
|
601 | except web.HTTPError: | |
|
602 | # WebSockets don't response to traditional error codes so we | |
|
603 | # close the connection. | |
|
604 | if not self.stream.closed(): | |
|
605 | self.stream.close() | |
|
606 | self.close() | |
|
607 | else: | |
|
608 | self.shell_stream.on_recv(self._on_zmq_reply) | |
|
599 | def on_kernel_restarted(self): | |
|
600 | logging.warn("kernel %s restarted", self.kernel_id) | |
|
601 | self._send_status_message('restarting') | |
|
609 | 602 | |
|
603 | def on_restart_failed(self): | |
|
604 | logging.error("kernel %s restarted failed!", self.kernel_id) | |
|
605 | self._send_status_message('dead') | |
|
606 | ||
|
610 | 607 | def on_message(self, msg): |
|
611 | if len(msg) < self.max_msg_size: | |
|
612 | msg = jsonapi.loads(msg) | |
|
613 | self.session.send(self.shell_stream, msg) | |
|
608 | """IOPub messages make no sense""" | |
|
609 | pass | |
|
614 | 610 | |
|
615 | def on_close(self): | |
|
616 | # Make sure the stream exists and is not already closed. | |
|
617 | if self.shell_stream is not None and not self.shell_stream.closed(): | |
|
618 | self.shell_stream.close() | |
|
611 | class ShellHandler(ZMQChannelHandler): | |
|
612 | channel = 'shell' | |
|
613 | ||
|
614 | class StdinHandler(ZMQChannelHandler): | |
|
615 | channel = 'stdin' | |
|
619 | 616 | |
|
620 | 617 | |
|
621 | 618 | #----------------------------------------------------------------------------- |
@@ -66,7 +66,7 b' from IPython.frontend.html.notebook import DEFAULT_STATIC_FILES_PATH' | |||
|
66 | 66 | from .kernelmanager import MappingKernelManager |
|
67 | 67 | from .handlers import (LoginHandler, LogoutHandler, |
|
68 | 68 | ProjectDashboardHandler, NewHandler, NamedNotebookHandler, |
|
69 | MainKernelHandler, KernelHandler, KernelActionHandler, IOPubHandler, | |
|
69 | MainKernelHandler, KernelHandler, KernelActionHandler, IOPubHandler, StdinHandler, | |
|
70 | 70 | ShellHandler, NotebookRootHandler, NotebookHandler, NotebookCopyHandler, |
|
71 | 71 | RSTHandler, AuthenticatedFileHandler, PrintNotebookHandler, |
|
72 | 72 | MainClusterHandler, ClusterProfileHandler, ClusterActionHandler, |
@@ -160,6 +160,7 b' class NotebookWebApplication(web.Application):' | |||
|
160 | 160 | (r"/kernels/%s/%s" % (_kernel_id_regex, _kernel_action_regex), KernelActionHandler), |
|
161 | 161 | (r"/kernels/%s/iopub" % _kernel_id_regex, IOPubHandler), |
|
162 | 162 | (r"/kernels/%s/shell" % _kernel_id_regex, ShellHandler), |
|
163 | (r"/kernels/%s/stdin" % _kernel_id_regex, StdinHandler), | |
|
163 | 164 | (r"/notebooks", NotebookRootHandler), |
|
164 | 165 | (r"/notebooks/%s" % _notebook_id_regex, NotebookHandler), |
|
165 | 166 | (r"/rstservice/render", RSTHandler), |
@@ -936,6 +936,9 b' pre,code,kbd,samp{white-space:pre-wrap;}' | |||
|
936 | 936 | a{text-decoration:underline;} |
|
937 | 937 | p{margin-bottom:0;} |
|
938 | 938 | a.heading-anchor:link,a.heading-anchor:visited{text-decoration:none;color:inherit;} |
|
939 | div.raw_input{padding-top:0px;padding-bottom:0px;height:1em;line-height:1em;font-family:monospace;} | |
|
940 | span.input_prompt{font-family:inherit;} | |
|
941 | input.raw_input{font-family:inherit;font-size:inherit;color:inherit;width:auto;margin:-2px 0px 0px 1px;padding-left:1px;padding-top:2px;height:1em;} | |
|
939 | 942 | @media print{body{overflow:visible !important;} div#notebook{overflow:visible !important;} .ui-widget-content{border:0px;} #save_widget{margin:0px !important;} #header,#pager,#pager_splitter,#menubar,#toolbar{display:none !important;} .cell{border:none !important;} .toolbar{display:none;}}.rendered_html{color:black;}.rendered_html em{font-style:italic;} |
|
940 | 943 | .rendered_html strong{font-weight:bold;} |
|
941 | 944 | .rendered_html u{text-decoration:underline;} |
@@ -245,7 +245,8 b' var IPython = (function (IPython) {' | |||
|
245 | 245 | 'execute_reply': $.proxy(this._handle_execute_reply, this), |
|
246 | 246 | 'output': $.proxy(this.output_area.handle_output, this.output_area), |
|
247 | 247 | 'clear_output': $.proxy(this.output_area.handle_clear_output, this.output_area), |
|
248 | 'set_next_input': $.proxy(this._handle_set_next_input, this) | |
|
248 | 'set_next_input': $.proxy(this._handle_set_next_input, this), | |
|
249 | 'input_request': $.proxy(this._handle_input_request, this) | |
|
249 | 250 | }; |
|
250 | 251 | var msg_id = this.kernel.execute(this.get_text(), callbacks, {silent: false}); |
|
251 | 252 | }; |
@@ -260,10 +261,23 b' var IPython = (function (IPython) {' | |||
|
260 | 261 | $([IPython.events]).trigger('set_dirty.Notebook', {'value': true}); |
|
261 | 262 | } |
|
262 | 263 | |
|
264 | /** | |
|
265 | * @method _handle_set_next_input | |
|
266 | * @private | |
|
267 | */ | |
|
263 | 268 | CodeCell.prototype._handle_set_next_input = function (text) { |
|
264 | 269 | var data = {'cell': this, 'text': text} |
|
265 | 270 | $([IPython.events]).trigger('set_next_input.Notebook', data); |
|
266 | 271 | } |
|
272 | ||
|
273 | /** | |
|
274 | * @method _handle_input_request | |
|
275 | * @private | |
|
276 | */ | |
|
277 | CodeCell.prototype._handle_input_request = function (content) { | |
|
278 | this.output_area.append_raw_input(content); | |
|
279 | } | |
|
280 | ||
|
267 | 281 | |
|
268 | 282 | // Basic cell manipulation. |
|
269 | 283 |
@@ -28,6 +28,7 b' var IPython = (function (IPython) {' | |||
|
28 | 28 | this.kernel_id = null; |
|
29 | 29 | this.shell_channel = null; |
|
30 | 30 | this.iopub_channel = null; |
|
31 | this.stdin_channel = null; | |
|
31 | 32 | this.base_url = base_url; |
|
32 | 33 | this.running = false; |
|
33 | 34 | this.username = "username"; |
@@ -127,9 +128,12 b' var IPython = (function (IPython) {' | |||
|
127 | 128 | var ws_url = this.ws_url + this.kernel_url; |
|
128 | 129 | console.log("Starting WebSockets:", ws_url); |
|
129 | 130 | this.shell_channel = new this.WebSocket(ws_url + "/shell"); |
|
131 | this.stdin_channel = new this.WebSocket(ws_url + "/stdin"); | |
|
130 | 132 | this.iopub_channel = new this.WebSocket(ws_url + "/iopub"); |
|
131 | 133 | send_cookie = function(){ |
|
132 | this.send(document.cookie); | |
|
134 | // send the session id so the Session object Python-side | |
|
135 | // has the same identity | |
|
136 | this.send(that.session_id + ':' + document.cookie); | |
|
133 | 137 | }; |
|
134 | 138 | var already_called_onclose = false; // only alert once |
|
135 | 139 | var ws_closed_early = function(evt){ |
@@ -150,21 +154,26 b' var IPython = (function (IPython) {' | |||
|
150 | 154 | that._websocket_closed(ws_url, false); |
|
151 | 155 | } |
|
152 | 156 | }; |
|
153 | this.shell_channel.onopen = send_cookie; | |
|
154 | this.shell_channel.onclose = ws_closed_early; | |
|
155 |
|
|
|
156 |
|
|
|
157 | var channels = [this.shell_channel, this.iopub_channel, this.stdin_channel]; | |
|
158 | for (var i=0; i < channels.length; i++) { | |
|
159 | channels[i].onopen = send_cookie; | |
|
160 | channels[i].onclose = ws_closed_early; | |
|
161 | } | |
|
157 | 162 | // switch from early-close to late-close message after 1s |
|
158 | 163 | setTimeout(function() { |
|
159 | if (that.shell_channel !== null) { | |
|
160 | that.shell_channel.onclose = ws_closed_late; | |
|
161 | } | |
|
162 | if (that.iopub_channel !== null) { | |
|
163 | that.iopub_channel.onclose = ws_closed_late; | |
|
164 | for (var i=0; i < channels.length; i++) { | |
|
165 | if (channels[i] !== null) { | |
|
166 | channels[i].onclose = ws_closed_late; | |
|
167 | } | |
|
164 | 168 | } |
|
165 | 169 | }, 1000); |
|
166 | this.shell_channel.onmessage = $.proxy(this._handle_shell_reply,this); | |
|
167 | this.iopub_channel.onmessage = $.proxy(this._handle_iopub_reply,this); | |
|
170 | this.shell_channel.onmessage = $.proxy(this._handle_shell_reply, this); | |
|
171 | this.iopub_channel.onmessage = $.proxy(this._handle_iopub_reply, this); | |
|
172 | this.stdin_channel.onmessage = $.proxy(this._handle_input_request, this); | |
|
173 | ||
|
174 | $([IPython.events]).on('send_input_reply.Kernel', function(evt, data) { | |
|
175 | that.send_input_reply(data); | |
|
176 | }); | |
|
168 | 177 | }; |
|
169 | 178 | |
|
170 | 179 | /** |
@@ -172,16 +181,14 b' var IPython = (function (IPython) {' | |||
|
172 | 181 | * @method stop_channels |
|
173 | 182 | */ |
|
174 | 183 | Kernel.prototype.stop_channels = function () { |
|
175 | if (this.shell_channel !== null) { | |
|
176 | this.shell_channel.onclose = function (evt) {}; | |
|
177 | this.shell_channel.close(); | |
|
178 | this.shell_channel = null; | |
|
179 | }; | |
|
180 | if (this.iopub_channel !== null) { | |
|
181 | this.iopub_channel.onclose = function (evt) {}; | |
|
182 | this.iopub_channel.close(); | |
|
183 | this.iopub_channel = null; | |
|
184 | var channels = [this.shell_channel, this.iopub_channel, this.stdin_channel]; | |
|
185 | for (var i=0; i < channels.length; i++) { | |
|
186 | if ( channels[i] !== null ) { | |
|
187 | channels[i].onclose = function (evt) {}; | |
|
188 | channels[i].close(); | |
|
189 | } | |
|
184 | 190 | }; |
|
191 | this.shell_channel = this.iopub_channel = this.stdin_channel = null; | |
|
185 | 192 | }; |
|
186 | 193 | |
|
187 | 194 | // Main public methods. |
@@ -284,6 +291,9 b' var IPython = (function (IPython) {' | |||
|
284 | 291 | user_expressions : {}, |
|
285 | 292 | allow_stdin : false |
|
286 | 293 | }; |
|
294 | if (callbacks.input_request !== undefined) { | |
|
295 | content.allow_stdin = true; | |
|
296 | } | |
|
287 | 297 | $.extend(true, content, options) |
|
288 | 298 | $([IPython.events]).trigger('execution_request.Kernel', {kernel: this, content:content}); |
|
289 | 299 | var msg = this._get_msg("execute_request", content); |
@@ -343,8 +353,18 b' var IPython = (function (IPython) {' | |||
|
343 | 353 | }; |
|
344 | 354 | }; |
|
345 | 355 | |
|
356 | Kernel.prototype.send_input_reply = function (input) { | |
|
357 | var content = { | |
|
358 | value : input, | |
|
359 | }; | |
|
360 | $([IPython.events]).trigger('input_reply.Kernel', {kernel: this, content:content}); | |
|
361 | var msg = this._get_msg("input_reply", content); | |
|
362 | this.stdin_channel.send(JSON.stringify(msg)); | |
|
363 | return msg.header.msg_id; | |
|
364 | }; | |
|
365 | ||
|
346 | 366 | |
|
347 |
// Reply handlers |
|
|
367 | // Reply handlers | |
|
348 | 368 | |
|
349 | 369 | Kernel.prototype.get_callbacks_for_msg = function (msg_id) { |
|
350 | 370 | var callbacks = this._msg_callbacks[msg_id]; |
@@ -433,6 +453,26 b' var IPython = (function (IPython) {' | |||
|
433 | 453 | }; |
|
434 | 454 | |
|
435 | 455 | |
|
456 | Kernel.prototype._handle_input_request = function (e) { | |
|
457 | var request = $.parseJSON(e.data); | |
|
458 | var header = request.header; | |
|
459 | var content = request.content; | |
|
460 | var metadata = request.metadata; | |
|
461 | var msg_type = header.msg_type; | |
|
462 | if (msg_type !== 'input_request') { | |
|
463 | console.log("Invalid input request!", request); | |
|
464 | return; | |
|
465 | } | |
|
466 | var callbacks = this.get_callbacks_for_msg(request.parent_header.msg_id); | |
|
467 | if (callbacks !== undefined) { | |
|
468 | var cb = callbacks[msg_type]; | |
|
469 | if (cb !== undefined) { | |
|
470 | cb(content, metadata); | |
|
471 | } | |
|
472 | }; | |
|
473 | }; | |
|
474 | ||
|
475 | ||
|
436 | 476 | IPython.Kernel = Kernel; |
|
437 | 477 | |
|
438 | 478 | return IPython; |
@@ -448,6 +448,55 b' var IPython = (function (IPython) {' | |||
|
448 | 448 | toinsert.append(latex); |
|
449 | 449 | element.append(toinsert); |
|
450 | 450 | }; |
|
451 | ||
|
452 | OutputArea.prototype.append_raw_input = function (content) { | |
|
453 | var that = this; | |
|
454 | this.expand(); | |
|
455 | this.flush_clear_timeout(); | |
|
456 | var area = this.create_output_area(); | |
|
457 | ||
|
458 | area.append( | |
|
459 | $("<div/>") | |
|
460 | .addClass("box-flex1 output_subarea raw_input") | |
|
461 | .append( | |
|
462 | $("<span/>") | |
|
463 | .addClass("input_prompt") | |
|
464 | .text(content.prompt) | |
|
465 | ) | |
|
466 | .append( | |
|
467 | $("<input/>") | |
|
468 | .addClass("raw_input") | |
|
469 | .attr('type', 'text') | |
|
470 | .attr("size", 80) | |
|
471 | .keydown(function (event, ui) { | |
|
472 | // make sure we submit on enter, | |
|
473 | // and don't re-execute the *cell* on shift-enter | |
|
474 | if (event.which === utils.keycodes.ENTER) { | |
|
475 | that._submit_raw_input(); | |
|
476 | return false; | |
|
477 | } | |
|
478 | }) | |
|
479 | ) | |
|
480 | ); | |
|
481 | this.element.append(area); | |
|
482 | area.find("input.raw_input").focus(); | |
|
483 | } | |
|
484 | OutputArea.prototype._submit_raw_input = function (evt) { | |
|
485 | var container = this.element.find("div.raw_input"); | |
|
486 | var theprompt = container.find("span.input_prompt"); | |
|
487 | var theinput = container.find("input.raw_input"); | |
|
488 | var value = theinput.attr("value"); | |
|
489 | var content = { | |
|
490 | output_type : 'stream', | |
|
491 | name : 'stdout', | |
|
492 | text : theprompt.text() + value + '\n' | |
|
493 | } | |
|
494 | // remove form container | |
|
495 | container.parent().remove(); | |
|
496 | // replace with plaintext version in stdout | |
|
497 | this.append_output(content, false); | |
|
498 | $([IPython.events]).trigger('send_input_reply.Kernel', value); | |
|
499 | } | |
|
451 | 500 | |
|
452 | 501 | |
|
453 | 502 | OutputArea.prototype.handle_clear_output = function (content) { |
@@ -477,3 +477,26 b' a.heading-anchor:link, a.heading-anchor:visited {' | |||
|
477 | 477 | text-decoration: none; |
|
478 | 478 | color: inherit; |
|
479 | 479 | } |
|
480 | ||
|
481 | /* raw_input styles */ | |
|
482 | ||
|
483 | div.raw_input { | |
|
484 | padding-top: 0px; | |
|
485 | padding-bottom: 0px; | |
|
486 | height: 1em; | |
|
487 | line-height: 1em; | |
|
488 | font-family: monospace; | |
|
489 | } | |
|
490 | span.input_prompt { | |
|
491 | font-family: inherit; | |
|
492 | } | |
|
493 | input.raw_input { | |
|
494 | font-family: inherit; | |
|
495 | font-size: inherit; | |
|
496 | color: inherit; | |
|
497 | width: auto; | |
|
498 | margin: -2px 0px 0px 1px; | |
|
499 | padding-left: 1px; | |
|
500 | padding-top: 2px; | |
|
501 | height: 1em; | |
|
502 | } |
@@ -746,7 +746,16 b' class Kernel(Configurable):' | |||
|
746 | 746 | # Flush output before making the request. |
|
747 | 747 | sys.stderr.flush() |
|
748 | 748 | sys.stdout.flush() |
|
749 | ||
|
749 | # flush the stdin socket, to purge stale replies | |
|
750 | while True: | |
|
751 | try: | |
|
752 | self.stdin_socket.recv_multipart(zmq.NOBLOCK) | |
|
753 | except zmq.ZMQError as e: | |
|
754 | if e.errno == zmq.EAGAIN: | |
|
755 | break | |
|
756 | else: | |
|
757 | raise | |
|
758 | ||
|
750 | 759 | # Send the input request. |
|
751 | 760 | content = json_clean(dict(prompt=prompt)) |
|
752 | 761 | self.session.send(self.stdin_socket, u'input_request', content, parent, |
General Comments 0
You need to be logged in to leave comments.
Login now