Commit 0b833660 by chrisndodge

Merge pull request #896 from MITx/feature/ichuang/cms-input-filter-latex2edx

Add "high level source" editing capability for problems & html; provides latex2edx I/F
parents 146ad77c 557b7912
......@@ -37,6 +37,7 @@ from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from github_sync import export_to_github
from static_replace import replace_urls
from external_auth.views import ssl_login_shortcut
from mitxmako.shortcuts import render_to_response, render_to_string
from xmodule.modulestore.django import modulestore
......@@ -88,7 +89,7 @@ def signup(request):
csrf_token = csrf(request)['csrf_token']
return render_to_response('signup.html', {'csrf': csrf_token})
@ssl_login_shortcut
@ensure_csrf_cookie
def login_page(request):
"""
......
......@@ -32,7 +32,8 @@ from xmodule.static_content import write_descriptor_styles, write_descriptor_js,
MITX_FEATURES = {
'USE_DJANGO_PIPELINE': True,
'GITHUB_PUSH': False,
'ENABLE_DISCUSSION_SERVICE': False
'ENABLE_DISCUSSION_SERVICE': False,
'AUTH_USE_MIT_CERTIFICATES' : False,
}
# needed to use lms student app
......
# dev environment for ichuang/mit
# FORCE_SCRIPT_NAME = '/cms'
from .common import *
from logsettings import get_logger_config
from .dev import *
import socket
MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
MITX_FEATURES['USE_DJANGO_PIPELINE']=False # don't recompile scss
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') # django 1.4 for nginx ssl proxy
% if metadata:
<%
import hashlib
hlskey = hashlib.md5(module.location.url()).hexdigest()
%>
<section class="metadata_edit">
<h3>Metadata</h3>
<ul>
% for keyname in editable_metadata_fields:
<li><label>${keyname}:</label> <input type='text' data-metadata-name='${keyname}' value='${metadata[keyname]}' size='60' /></li>
<li>
% if keyname=='source_code':
<a href="#hls-modal-${hlskey}" style="color:yellow;" id="hls-trig-${hlskey}" >Edit High Level Source</a>
% else:
<label>${keyname}:</label>
<input type='text' data-metadata-name='${keyname}' value='${metadata[keyname]}' size='60' />
% endif
</li>
% endfor
</ul>
% if 'source_code' in editable_metadata_fields:
<%include file="source-edit.html" />
% endif
</section>
% endif
<%
import hashlib
hlskey = hashlib.md5(module.location.url()).hexdigest()
%>
<section id="hls-modal-${hlskey}" class="upload-modal modal" style="width:90%!important; left:5%!important; margin-left:0px!important; height:90%; overflow:auto; background:#ECF7D3;" >
<a href="#" class="close-button"><span class="close-icon"></span></a>
<div id="hls-div">
<header>
<h2>High Level Source Editing</h2>
</header>
<form id="hls-form">
<section class="source-edit">
<textarea name="" data-metadata-name="source_code" class="source-edit-box hls-data" rows="8" cols="40">${metadata['source_code']|h}</textarea>
</section>
<div class="submit">
<button type="reset" class="hls-compile">Save &amp; Compile to edX XML</button>
<button type="reset" class="hls-save">Save</button>
<button type="reset" class="hls-refresh">Refresh</button>
</div>
</form>
</div>
</section>
<script type="text/javascript" src="/static/js/vendor/CodeMirror/stex.js"></script>
<script type="text/javascript">
$('#hls-trig-${hlskey}').leanModal({ top:40, overlay:0.8, closeButton: ".close-button"});
$('#hls-modal-${hlskey}').data('editor',CodeMirror.fromTextArea($('#hls-modal-${hlskey}').find('.hls-data')[0], {lineNumbers: true, mode: 'stex'}));
$('#hls-trig-${hlskey}').click(function(){slow_refresh_hls($('#hls-modal-${hlskey}'))})
// refresh button
$('#hls-modal-${hlskey}').find('.hls-refresh').click(function(){refresh_hls($('#hls-modal-${hlskey}'))});
function refresh_hls(el){
el.data('editor').refresh();
}
function slow_refresh_hls(el){
el.delay(200).queue(function(){
refresh_hls(el);
$(this).dequeue();
});
// resize the codemirror box
h = el.height();
el.find('.CodeMirror-scroll').height(h-100);
}
// compile & save button
$('#hls-modal-${hlskey}').find('.hls-compile').click(compile_hls_${hlskey});
function compile_hls_${hlskey}(){
editor = $('#hls-modal-${hlskey}').data('editor')
var myquery = { latexin: editor.getValue() };
$.ajax({
url: '${metadata.get('source_processor_url','https://qisx.mit.edu:5443/latex2edx')}',
type: 'GET',
contentType: 'application/json',
data: escape(JSON.stringify(myquery)),
crossDomain: true,
dataType: 'jsonp',
jsonpCallback: 'process_return_${hlskey}',
beforeSend: function (xhr) { xhr.setRequestHeader ("Authorization", "Basic eHFhOmFnYXJ3YWw="); },
timeout : 7000,
success: function(result) {
console.log(result);
},
error: function() {
alert('Error: cannot connect to latex2edx server');
console.log('error!');
}
});
// $('#hls-modal-${hlskey}').hide();
}
function process_return_${hlskey}(datadict){
// datadict is json of array with "xml" and "message"
// if "xml" value is '' then the conversion failed
xml = datadict.xml;
console.log('xml:');
console.log(xml);
if (xml.length==0){
alert('Conversion failed! error:'+ datadict.message);
}else{
set_raw_edit_box(xml,'${hlskey}');
save_hls($('#hls-modal-${hlskey}'));
}
}
function set_raw_edit_box(data,key){
// get the codemirror editor for the raw-edit-box
// it's a CodeMirror-wrap class element
$('#hls-modal-'+key).closest('.component').find('.CodeMirror-wrap')[0].CodeMirror.setValue(data);
}
// save button
$('#hls-modal-${hlskey}').find('.hls-save').click(function(){save_hls($('#hls-modal-${hlskey}'))});
function save_hls(el){
el.find('.hls-data').val(el.data('editor').getValue());
el.closest('.component').find('.save-button').click();
}
</script>
......@@ -215,6 +215,52 @@ def ssl_dn_extract_info(dn):
else:
return None
return (user, email, fullname)
def ssl_get_cert_from_request(request):
"""
Extract user information from certificate, if it exists, returning (user, email, fullname).
Else return None.
"""
certkey = "SSL_CLIENT_S_DN" # specify the request.META field to use
cert = request.META.get(certkey, '')
if not cert:
cert = request.META.get('HTTP_' + certkey, '')
if not cert:
try:
# try the direct apache2 SSL key
cert = request._req.subprocess_env.get(certkey, '')
except Exception:
return ''
return cert
(user, email, fullname) = ssl_dn_extract_info(cert)
return (user, email, fullname)
def ssl_login_shortcut(fn):
"""
Python function decorator for login procedures, to allow direct login
based on existing ExternalAuth record and MIT ssl certificate.
"""
def wrapped(*args, **kwargs):
if not settings.MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES']:
return fn(*args, **kwargs)
request = args[0]
cert = ssl_get_cert_from_request(request)
if not cert: # no certificate information - show normal login window
return fn(*args, **kwargs)
(user, email, fullname) = ssl_dn_extract_info(cert)
return external_login_or_signup(request,
external_id=email,
external_domain="ssl:MIT",
credentials=cert,
email=email,
fullname=fullname)
return wrapped
@csrf_exempt
......@@ -234,17 +280,7 @@ def ssl_login(request):
Else continues on with student.views.index, and no authentication.
"""
certkey = "SSL_CLIENT_S_DN" # specify the request.META field to use
cert = request.META.get(certkey, '')
if not cert:
cert = request.META.get('HTTP_' + certkey, '')
if not cert:
try:
# try the direct apache2 SSL key
cert = request._req.subprocess_env.get(certkey, '')
except Exception:
cert = None
cert = ssl_get_cert_from_request(request)
if not cert:
# no certificate information - go onward to main index
......
......@@ -63,7 +63,7 @@ class StudentInputError(Exception):
class LoncapaResponse(object):
'''
"""
Base class for CAPA responsetypes. Each response type (ie a capa question,
which is part of a capa problem) is represented as a subclass,
which should provide the following methods:
......@@ -89,7 +89,7 @@ class LoncapaResponse(object):
- required_attributes : list of required attributes (each a string) on the main response XML stanza
- hint_tag : xhtml tag identifying hint associated with this response inside hintgroup
'''
"""
__metaclass__ = abc.ABCMeta # abc = Abstract Base Class
response_tag = None
......@@ -164,6 +164,8 @@ class LoncapaResponse(object):
- renderer : procedure which produces HTML given an ElementTree
'''
tree = etree.Element('span') # render ourself as a <span> + our content
if self.xml.get('inline',''): # problem author can make this span display:inline
tree.set('class','inline')
for item in self.xml:
item_xhtml = renderer(item) # call provided procedure to do the rendering
if item_xhtml is not None: tree.append(item_xhtml)
......
......@@ -618,12 +618,14 @@ class CapaModule(XModule):
if self.closed():
event_info['failure'] = 'closed'
self.system.track_function('reset_problem_fail', event_info)
return "Problem is closed"
return {'success': False,
'error': "Problem is closed"}
if not self.lcp.done:
event_info['failure'] = 'not_done'
self.system.track_function('reset_problem_fail', event_info)
return "Refresh the page and make an attempt before resetting."
return {'success': False,
'error': "Refresh the page and make an attempt before resetting."}
self.lcp.do_reset()
if self.rerandomize in ["always", "onreset"]:
......
class @HTMLModule
constructor: (@element) ->
@el = $(@element)
@setCollapsibles()
@el = $(@element)
@setCollapsibles()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, @el[0]]
$: (selector) ->
$(selector, @el)
......
---
metadata:
display_name: E-text Written in LaTeX
source_processor_url: https://qisx.mit.edu:5443/latex2edx
source_code: |
\subsection{Example of E-text in LaTeX}
It is very convenient to write complex equations in LaTeX.
\begin{equation}
x = \frac{-b\pm\sqrt{b^2-4*a*c}}{2a}
\end{equation}
Seize the moment.
data: |
<html>
<h2>Example: E-text page</h2>
<p>
It is very convenient to write complex equations in LaTeX.
</p>
</html>
children: []
---
metadata:
display_name: Problem Written in LaTeX
source_processor_url: https://qisx.mit.edu:5443/latex2edx
source_code: |
% Nearly any kind of edX problem can be authored using Latex as
% the source language. Write latex as usual, including equations. The
% key new feature is the \edXabox{} macro, which specifies an "Answer
% Box" that queries students for a response, and specifies what the
% epxected (correct) answer is.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\subsection{Example "option" problem}
Where is the earth?
\edXabox{options='up','down' expect='down'}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\subsection{Example "symbolic" problem}
What is Einstein's equation for the energy equivalent of a mass $m$?
\edXabox{type='symbolic' size='90' expect='m*c^2' }
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\subsection{Example "numerical" problem}
Estimate the energy savings (in J/y) if all the people
($3\times 10^8$) in the U.~S. switched from U.~S. code to low flow
shower heads.
\edXinline{Energy saved = }\edXabox{expect="0.52" type="numerical" tolerance='0.02' inline='1' } %
\edXinline{~EJ/year}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\subsection{Example "multiple choice" problem}
What color is a bannana?
\edXabox{ type="multichoice" expect="Yellow" options="Red","Green","Yellow","Blue" }
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\subsection{Example "string response" problem}
In what U.S. state is Detroit located?
\edXabox{ type="string" expect="Michigan" options="ci" }
An explanation of the answer can be provided by using the edXsolution
macro. Click on "Show Answer" to see the solution.
\begin{edXsolution}
Detroit is near Canada, but it is actually in the United States.
\end{edXsolution}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\subsection{Example "custom response" problem}
This problem demonstrates the use of a custom python script used for
checking the answer.
\begin{edXscript}
def sumtest(expect,ans):
(a1,a2) = map(float,eval(ans))
return (a1+a2)==10
\end{edXscript}
Enter a python list of two numbers which sum to 10, eg [9,1]:
\edXabox{expect="[1,9]" type="custom" cfn="sumtest"}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\subsection{Example image}
Include image by using the edXxml macro:
\edXxml{<img src="http://autoid.mit.edu/images/mit_dome.jpg"/>}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\subsection{Example show/hide explanation}
Extra explanations can be tucked away behind a "showhide" toggle flag:
\edXshowhide{sh1}{More explanation}{This is a hidden explanation. It
can contain equations: $\alpha = \frac{2}{\sqrt{1+\gamma}}$ }
This is some text after the showhide example.
data: |
<?xml version="1.0"?>
<problem>
<text>
<p>
<h4>Example "option" problem</h4>
</p>
<p>
Where is the earth? </p>
<p>
<optionresponse>
<optioninput options="('up','down')" correct="down"/>
</optionresponse>
</p>
<p>
<h4>Example "symbolic" problem</h4>
</p>
<p>
What is Einstein's equation for the energy equivalent of a mass [mathjaxinline]m[/mathjaxinline]? </p>
<p>
<symbolicresponse expect="m*c^2">
<textline size="90" correct_answer="m*c^2" math="1"/>
</symbolicresponse>
</p>
<p>
<h4>Example "numerical" problem</h4>
</p>
<p>
Estimate the energy savings (in J/y) if all the people ([mathjaxinline]3\times 10^8[/mathjaxinline]) in the U.&#xA0;S. switched from U.&#xA0;S. code to low flow shower heads. </p>
<p>
<p style="display:inline">Energy saved = </p>
<numericalresponse inline="1" answer="0.52">
<textline inline="1">
<responseparam description="Numerical Tolerance" type="tolerance" default="0.02" name="tol"/>
</textline>
</numericalresponse>
<p style="display:inline">&#xA0;EJ/year</p>
</p>
<p>
<h4>Example "multiple choice" problem</h4>
</p>
<p>
What color is a bannana? </p>
<p>
<choiceresponse>
<checkboxgroup>
<choice correct="false" name="1">
<text>Red</text>
</choice>
<choice correct="false" name="2">
<text>Green</text>
</choice>
<choice correct="true" name="3">
<text>Yellow</text>
</choice>
<choice correct="false" name="4">
<text>Blue</text>
</choice>
</checkboxgroup>
</choiceresponse>
</p>
<p>
<h4>Example "string response" problem</h4>
</p>
<p>
In what U.S. state is Detroit located? </p>
<p>
<stringresponse answer="Michigan">
<textline/>
</stringresponse>
</p>
<p>
An explanation of the answer can be provided by using the edXsolution macro: </p>
<p>
<solution>
<font color="blue">Answer: </font>
<font color="blue">Detroit is near Canada, but it is actually in the United States. </font>
</solution>
</p>
<p>
<h4>Example "custom response" problem</h4>
</p>
<p>
This problem demonstrates the use of a custom python script used for checking the answer. </p>
<script type="text/python" system_path="python_lib">
def sumtest(expect,ans):
(a1,a2) = map(float,eval(ans))
return (a1+a2)==10
</script>
<p>
Enter a python list of two numbers which sum to 10, eg [9,1]: </p>
<p>
<customresponse cfn="sumtest" expect="[1,9]">
<textline correct_answer="[1,9]"/>
</customresponse>
</p>
<p>
<h4>Example image</h4>
</p>
<p>
Include image by using the edXxml macro: </p>
<p>
<img src="http://autoid.mit.edu/images/mit_dome.jpg"/>
</p>
<p>
<h4>Example show/hide explanation</h4>
</p>
<p>
Extra explanations can be tucked away behind a "showhide" toggle flag: </p>
<p>
<table class="wikitable collapsible collapsed">
<tbody>
<tr>
<th> More explanation [<a href="javascript:$('#sh1').toggle()" id="sh1l">show</a>]</th>
</tr>
<tr id="sh1" style="display:none">
<td>
<p>
This is a hidden explanation. It can contain equations: [mathjaxinline]\alpha = \frac{2}{\sqrt {1+\gamma }}[/mathjaxinline] </p>
<p>
This is some text after the showhide example. </p>
</td>
</tr>
</tbody>
</table>
</p>
</text>
</problem>
children: []
---
metadata:
display_name: Problem with Adaptive Hint
source_processor_url: https://qisx.mit.edu:5443/latex2edx
source_code: |
\subsection{Problem With Adaptive Hint}
% Adaptive hints are messages provided to students which depend on
% student input. These hints are produced using a script embedded
% within the problem (written in Python).
%
% Here is an example. This example uses LaTeX as a high-level
% soure language for the problem. The problem can also be coded
% directly in XML.
This problem demonstrates a question with hints, based on using the
{\tt hintfn} method.
\begin{edXscript}
def test_str(expect, ans):
print expect, ans
ans = ans.strip("'")
ans = ans.strip('"')
return expect == ans.lower()
def hint_fn(answer_ids, student_answers, new_cmap, old_cmap):
aid = answer_ids[0]
ans = str(student_answers[aid]).lower()
print 'hint_fn called, ans=', ans
hint = ''
if 'java' in ans:
hint = 'that is only good for drinking'
elif 'perl' in ans:
hint = 'not that rich'
elif 'pascal' in ans:
hint = 'that is a beatnick language'
elif 'fortran' in ans:
hint = 'those were the good days'
elif 'clu' in ans:
hint = 'you must be invariant'
if hint:
hint = "<font color='blue'>Hint: {0}</font>".format(hint)
new_cmap.set_hint_and_mode(aid,hint,'always')
\end{edXscript}
What is the best programming language that exists today? You may
enter your answer in upper or lower case, with or without quotes.
\edXabox{type="custom" cfn='test_str' expect='python' hintfn='hint_fn'}
data: |
<?xml version="1.0"?>
<problem>
<text>
<p>
<h4>Problem With Adaptive Hint</h4>
</p>
<p>
This problem demonstrates a question with hints, based on using the <tt class="tt">hintfn</tt> method. </p>
<script type="text/python" system_path="python_lib">
def test_str(expect, ans):
print expect, ans
ans = ans.strip("'")
ans = ans.strip('"')
return expect == ans.lower()
def hint_fn(answer_ids, student_answers, new_cmap, old_cmap):
aid = answer_ids[0]
ans = str(student_answers[aid]).lower()
print 'hint_fn called, ans=', ans
hint = ''
if 'java' in ans:
hint = 'that is only good for drinking'
elif 'perl' in ans:
hint = 'not that rich'
elif 'pascal' in ans:
hint = 'that is a beatnick language'
elif 'fortran' in ans:
hint = 'those were the good days'
elif 'clu' in ans:
hint = 'you must be invariant'
if hint:
hint = "&lt;font color='blue'&gt;Hint: {0}&lt;/font&gt;".format(hint)
new_cmap.set_hint_and_mode(aid,hint,'always')
</script>
<p>
What is the best programming language that exists today? You may enter your answer in upper or lower case, with or without quotes. </p>
<p>
<customresponse cfn="test_str" expect="python">
<textline correct_answer="python"/>
<hintgroup hintfn="hint_fn"/>
</customresponse>
</p>
</text>
</problem>
children: []
......@@ -369,6 +369,9 @@ class ResourceTemplates(object):
return []
for template_file in resource_listdir(__name__, dirname):
if not template_file.endswith('.yaml'):
log.warning("Skipping unknown template file %s" % template_file)
continue
template_content = resource_string(__name__, os.path.join(dirname, template_file))
template = yaml.load(template_content)
templates.append(Template(**template))
......
/*
* Author: Constantin Jucovschi (c.jucovschi@jacobs-university.de)
* Licence: MIT
*/
CodeMirror.defineMode("stex", function(cmCfg, modeCfg)
{
function pushCommand(state, command) {
state.cmdState.push(command);
}
function peekCommand(state) {
if (state.cmdState.length>0)
return state.cmdState[state.cmdState.length-1];
else
return null;
}
function popCommand(state) {
if (state.cmdState.length>0) {
var plug = state.cmdState.pop();
plug.closeBracket();
}
}
function applyMostPowerful(state) {
var context = state.cmdState;
for (var i = context.length - 1; i >= 0; i--) {
var plug = context[i];
if (plug.name=="DEFAULT")
continue;
return plug.styleIdentifier();
}
return null;
}
function addPluginPattern(pluginName, cmdStyle, brackets, styles) {
return function () {
this.name=pluginName;
this.bracketNo = 0;
this.style=cmdStyle;
this.styles = styles;
this.brackets = brackets;
this.styleIdentifier = function(content) {
if (this.bracketNo<=this.styles.length)
return this.styles[this.bracketNo-1];
else
return null;
};
this.openBracket = function(content) {
this.bracketNo++;
return "bracket";
};
this.closeBracket = function(content) {
};
};
}
var plugins = new Array();
plugins["importmodule"] = addPluginPattern("importmodule", "tag", "{[", ["string", "builtin"]);
plugins["documentclass"] = addPluginPattern("documentclass", "tag", "{[", ["", "atom"]);
plugins["usepackage"] = addPluginPattern("documentclass", "tag", "[", ["atom"]);
plugins["begin"] = addPluginPattern("documentclass", "tag", "[", ["atom"]);
plugins["end"] = addPluginPattern("documentclass", "tag", "[", ["atom"]);
plugins["DEFAULT"] = function () {
this.name="DEFAULT";
this.style="tag";
this.styleIdentifier = function(content) {
};
this.openBracket = function(content) {
};
this.closeBracket = function(content) {
};
};
function setState(state, f) {
state.f = f;
}
function normal(source, state) {
if (source.match(/^\\[a-zA-Z@]+/)) {
var cmdName = source.current();
cmdName = cmdName.substr(1, cmdName.length-1);
var plug;
if (plugins.hasOwnProperty(cmdName)) {
plug = plugins[cmdName];
} else {
plug = plugins["DEFAULT"];
}
plug = new plug();
pushCommand(state, plug);
setState(state, beginParams);
return plug.style;
}
// escape characters
if (source.match(/^\\[$&%#{}_]/)) {
return "tag";
}
// white space control characters
if (source.match(/^\\[,;!\/]/)) {
return "tag";
}
var ch = source.next();
if (ch == "%") {
// special case: % at end of its own line; stay in same state
if (!source.eol()) {
setState(state, inCComment);
}
return "comment";
}
else if (ch=='}' || ch==']') {
plug = peekCommand(state);
if (plug) {
plug.closeBracket(ch);
setState(state, beginParams);
} else
return "error";
return "bracket";
} else if (ch=='{' || ch=='[') {
plug = plugins["DEFAULT"];
plug = new plug();
pushCommand(state, plug);
return "bracket";
}
else if (/\d/.test(ch)) {
source.eatWhile(/[\w.%]/);
return "atom";
}
else {
source.eatWhile(/[\w-_]/);
return applyMostPowerful(state);
}
}
function inCComment(source, state) {
source.skipToEnd();
setState(state, normal);
return "comment";
}
function beginParams(source, state) {
var ch = source.peek();
if (ch == '{' || ch == '[') {
var lastPlug = peekCommand(state);
var style = lastPlug.openBracket(ch);
source.eat(ch);
setState(state, normal);
return "bracket";
}
if (/[ \t\r]/.test(ch)) {
source.eat(ch);
return null;
}
setState(state, normal);
lastPlug = peekCommand(state);
if (lastPlug) {
popCommand(state);
}
return normal(source, state);
}
return {
startState: function() { return { f:normal, cmdState:[] }; },
copyState: function(s) { return { f: s.f, cmdState: s.cmdState.slice(0, s.cmdState.length) }; },
token: function(stream, state) {
var t = state.f(stream, state);
var w = stream.current();
return t;
}
};
});
CodeMirror.defineMIME("text/x-stex", "stex");
CodeMirror.defineMIME("text/x-latex", "stex");
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment