Commit 0a84b65d by Ibrahim Ahmed

Merge branch 'master' into ibrahimahmed443/MAYN-280-explore-new-courses-button-fix

parents 8c9ad0d3 920fba50
......@@ -94,7 +94,8 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
response = self.client.get(url)
# Check whether we were correctly redirected
start_flow_url = reverse('verify_student_start_flow', args=[unicode(self.course.id)])
purchase_workflow = "?purchase_workflow=single"
start_flow_url = reverse('verify_student_start_flow', args=[unicode(self.course.id)]) + purchase_workflow
self.assertRedirects(response, start_flow_url)
def test_no_id_redirect_otto(self):
......@@ -194,7 +195,8 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
# Since the only available track is professional ed, expect that
# we're redirected immediately to the start of the payment flow.
start_flow_url = reverse('verify_student_start_flow', args=[unicode(self.course.id)])
purchase_workflow = "?purchase_workflow=single"
start_flow_url = reverse('verify_student_start_flow', args=[unicode(self.course.id)]) + purchase_workflow
self.assertRedirects(response, start_flow_url)
# Now enroll in the course
......
......@@ -88,12 +88,15 @@ class ChooseModeView(View):
# If there are both modes, default to non-id-professional.
has_enrolled_professional = (CourseMode.is_professional_slug(enrollment_mode) and is_active)
if CourseMode.has_professional_mode(modes) and not has_enrolled_professional:
redirect_url = reverse('verify_student_start_flow', kwargs={'course_id': unicode(course_key)})
purchase_workflow = request.GET.get("purchase_workflow", "single")
verify_url = reverse('verify_student_start_flow', kwargs={'course_id': unicode(course_key)})
redirect_url = "{url}?purchase_workflow={workflow}".format(url=verify_url, workflow=purchase_workflow)
if ecommerce_service.is_enabled(request.user):
professional_mode = modes.get(CourseMode.NO_ID_PROFESSIONAL_MODE) or modes.get(CourseMode.PROFESSIONAL)
if professional_mode.sku:
if purchase_workflow == "single" and professional_mode.sku:
redirect_url = ecommerce_service.checkout_page_url(professional_mode.sku)
if purchase_workflow == "bulk" and professional_mode.bulk_sku:
redirect_url = ecommerce_service.checkout_page_url(professional_mode.bulk_sku)
return redirect(redirect_url)
# If there isn't a verified mode available, then there's nothing
......
......@@ -200,7 +200,7 @@ def auth_pipeline_urls(auth_entry, redirect_url=None):
# Query string parameters that can be passed to the "finish_auth" view to manage
# things like auto-enrollment.
POST_AUTH_PARAMS = ('course_id', 'enrollment_action', 'course_mode', 'email_opt_in')
POST_AUTH_PARAMS = ('course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow')
def get_next_url_for_login_page(request):
......
......@@ -44,13 +44,17 @@ class StaticContent(object):
return self.location.category == 'thumbnail'
@staticmethod
def generate_thumbnail_name(original_name, dimensions=None):
def generate_thumbnail_name(original_name, dimensions=None, extension=None):
"""
- original_name: Name of the asset (typically its location.name)
- dimensions: `None` or a tuple of (width, height) in pixels
- extension: `None` or desired filename extension of the thumbnail
"""
if extension is None:
extension = XASSET_THUMBNAIL_TAIL_NAME
name_root, ext = os.path.splitext(original_name)
if not ext == XASSET_THUMBNAIL_TAIL_NAME:
if not ext == extension:
name_root = name_root + ext.replace(u'.', u'-')
if dimensions:
......@@ -59,7 +63,7 @@ class StaticContent(object):
return u"{name_root}{extension}".format(
name_root=name_root,
extension=XASSET_THUMBNAIL_TAIL_NAME,
extension=extension,
)
@staticmethod
......@@ -330,9 +334,10 @@ class ContentStore(object):
pixels. It defaults to None.
"""
thumbnail_content = None
is_svg = content.content_type == 'image/svg+xml'
# use a naming convention to associate originals with the thumbnail
thumbnail_name = StaticContent.generate_thumbnail_name(
content.location.name, dimensions=dimensions
content.location.name, dimensions=dimensions, extension='.svg' if is_svg else None
)
thumbnail_file_location = StaticContent.compute_location(
content.location.course_key, thumbnail_name, is_thumbnail=True
......@@ -340,28 +345,42 @@ class ContentStore(object):
# if we're uploading an image, then let's generate a thumbnail so that we can
# serve it up when needed without having to rescale on the fly
if content.content_type is not None and content.content_type.split('/')[0] == 'image':
try:
try:
if is_svg:
# for svg simply store the provided svg file, since vector graphics should be good enough
# for downscaling client-side
if tempfile_path is None:
thumbnail_file = StringIO.StringIO(content.data)
else:
with open(tempfile_path) as f:
thumbnail_file = StringIO.StringIO(f.read())
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
'image/svg+xml', thumbnail_file)
self.save(thumbnail_content)
elif content.content_type is not None and content.content_type.split('/')[0] == 'image':
# use PIL to do the thumbnail generation (http://www.pythonware.com/products/pil/)
# My understanding is that PIL will maintain aspect ratios while restricting
# the max-height/width to be whatever you pass in as 'size'
# @todo: move the thumbnail size to a configuration setting?!?
if tempfile_path is None:
im = Image.open(StringIO.StringIO(content.data))
source = StringIO.StringIO(content.data)
else:
im = Image.open(tempfile_path)
source = tempfile_path
# I've seen some exceptions from the PIL library when trying to save palletted
# PNG files to JPEG. Per the google-universe, they suggest converting to RGB first.
im = im.convert('RGB')
# We use the context manager here to avoid leaking the inner file descriptor
# of the Image object -- this way it gets closed after we're done with using it.
thumbnail_file = StringIO.StringIO()
with Image.open(source) as image:
# I've seen some exceptions from the PIL library when trying to save palletted
# PNG files to JPEG. Per the google-universe, they suggest converting to RGB first.
thumbnail_image = image.convert('RGB')
if not dimensions:
dimensions = (128, 128)
if not dimensions:
dimensions = (128, 128)
im.thumbnail(dimensions, Image.ANTIALIAS)
thumbnail_file = StringIO.StringIO()
im.save(thumbnail_file, 'JPEG')
thumbnail_file.seek(0)
thumbnail_image.thumbnail(dimensions, Image.ANTIALIAS)
thumbnail_image.save(thumbnail_file, 'JPEG')
thumbnail_file.seek(0)
# store this thumbnail as any other piece of content
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
......@@ -369,9 +388,11 @@ class ContentStore(object):
self.save(thumbnail_content)
except Exception, e:
# log and continue as thumbnails are generally considered as optional
logging.exception(u"Failed to generate thumbnail for {0}. Exception: {1}".format(content.location, str(e)))
except Exception, exc: # pylint: disable=broad-except
# log and continue as thumbnails are generally considered as optional
logging.exception(
u"Failed to generate thumbnail for {0}. Exception: {1}".format(content.location, str(exc))
)
return thumbnail_content, thumbnail_file_location
......
......@@ -3,6 +3,7 @@
import os
import unittest
import ddt
from mock import Mock, patch
from path import Path as path
from xmodule.contentstore.content import StaticContent, StaticContentStream
......@@ -58,6 +59,7 @@ class Content(object):
def __init__(self, location, content_type):
self.location = location
self.content_type = content_type
self.data = None
class FakeGridFsItem(object):
......@@ -84,6 +86,17 @@ class FakeGridFsItem(object):
return chunk
class MockImage(Mock):
"""
This class pretends to be PIL.Image for purposes of thumbnails testing.
"""
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
@ddt.ddt
class ContentTest(unittest.TestCase):
def test_thumbnail_none(self):
......@@ -103,11 +116,43 @@ class ContentTest(unittest.TestCase):
)
@ddt.unpack
def test_generate_thumbnail_image(self, original_filename, thumbnail_filename):
contentStore = ContentStore()
content_store = ContentStore()
content = Content(AssetLocation(u'mitX', u'800', u'ignore_run', u'asset', original_filename), None)
(thumbnail_content, thumbnail_file_location) = contentStore.generate_thumbnail(content)
(thumbnail_content, thumbnail_file_location) = content_store.generate_thumbnail(content)
self.assertIsNone(thumbnail_content)
self.assertEqual(AssetLocation(u'mitX', u'800', u'ignore_run', u'thumbnail', thumbnail_filename), thumbnail_file_location)
self.assertEqual(
AssetLocation(u'mitX', u'800', u'ignore_run', u'thumbnail', thumbnail_filename),
thumbnail_file_location
)
@patch('xmodule.contentstore.content.Image')
def test_image_is_closed_when_generating_thumbnail(self, image_class_mock):
# We used to keep the Image's file descriptor open when generating a thumbnail.
# It should be closed after being used.
mock_image = MockImage()
image_class_mock.open.return_value = mock_image
content_store = ContentStore()
content = Content(AssetLocation(u'mitX', u'800', u'ignore_run', u'asset', "monsters.jpg"), "image/jpeg")
content.data = 'mock data'
content_store.generate_thumbnail(content)
self.assertTrue(image_class_mock.open.called, "Image.open not called")
self.assertTrue(mock_image.close.called, "mock_image.close not called")
def test_store_svg_as_thumbnail(self):
# We had a bug that caused generate_thumbnail to attempt to pass SVG to PIL to generate a thumbnail.
# SVG files should be stored in original form for thumbnail purposes.
content_store = ContentStore()
content_store.save = Mock()
thumbnail_filename = u'test.svg'
content = Content(AssetLocation(u'mitX', u'800', u'ignore_run', u'asset', u'test.svg'), 'image/svg+xml')
content.data = 'mock svg file'
(thumbnail_content, thumbnail_file_location) = content_store.generate_thumbnail(content)
self.assertEqual(thumbnail_content.data.read(), b'mock svg file')
self.assertEqual(
AssetLocation(u'mitX', u'800', u'ignore_run', u'thumbnail', thumbnail_filename),
thumbnail_file_location
)
def test_compute_location(self):
# We had a bug that __ got converted into a single _. Make sure that substitution of INVALID_CHARS (like space)
......@@ -115,7 +160,10 @@ class ContentTest(unittest.TestCase):
asset_location = StaticContent.compute_location(
SlashSeparatedCourseKey('mitX', '400', 'ignore'), 'subs__1eo_jXvZnE .srt.sjson'
)
self.assertEqual(AssetLocation(u'mitX', u'400', u'ignore', u'asset', u'subs__1eo_jXvZnE_.srt.sjson', None), asset_location)
self.assertEqual(
AssetLocation(u'mitX', u'400', u'ignore', u'asset', u'subs__1eo_jXvZnE_.srt.sjson', None),
asset_location
)
def test_get_location_from_path(self):
asset_location = StaticContent.get_location_from_path(u'/c4x/a/b/asset/images_course_image.jpg')
......
......@@ -758,6 +758,14 @@ class VideoExportTestCase(VideoDescriptorTestBase):
with self.assertRaises(ValueError):
self.descriptor.definition_to_xml(None)
def test_export_to_xml_unicode_characters(self):
"""
Test XML export handles the unicode characters.
"""
self.descriptor.display_name = '这是文'
xml = self.descriptor.definition_to_xml(None)
self.assertEqual(xml.get('display_name'), u'\u8fd9\u662f\u6587')
class VideoDescriptorIndexingTestCase(unittest.TestCase):
"""
......
......@@ -38,7 +38,7 @@ from xmodule.exceptions import NotFoundError
from xmodule.contentstore.content import StaticContent
from .transcripts_utils import VideoTranscriptsMixin, Transcript, get_html5_ids
from .video_utils import create_youtube_string, get_poster, rewrite_video_url
from .video_utils import create_youtube_string, get_poster, rewrite_video_url, format_xml_exception_message
from .bumper_utils import bumperize
from .video_xfields import VideoFields
from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers
......@@ -563,14 +563,16 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
if key in self.fields and self.fields[key].is_set_on(self):
try:
xml.set(key, unicode(value))
except ValueError as exception:
exception_message = "{message}, Block-location:{location}, Key:{key}, Value:{value}".format(
message=exception.message,
location=unicode(self.location),
key=key,
value=unicode(value)
)
raise ValueError(exception_message)
except UnicodeDecodeError:
exception_message = format_xml_exception_message(self.location, key, value)
log.exception(exception_message)
# If exception is UnicodeDecodeError set value using unicode 'utf-8' scheme.
log.info("Setting xml value using 'utf-8' scheme.")
xml.set(key, unicode(value, 'utf-8'))
except ValueError:
exception_message = format_xml_exception_message(self.location, key, value)
log.exception(exception_message)
raise
for source in self.html5_sources:
ele = etree.Element('source')
......
......@@ -98,6 +98,19 @@ def get_poster(video):
return poster
def format_xml_exception_message(location, key, value):
"""
Generate exception message for VideoDescriptor class which will use for ValueError and UnicodeDecodeError
when setting xml attributes.
"""
exception_message = "Block-location:{location}, Key:{key}, Value:{value}".format(
location=unicode(location),
key=key,
value=value
)
return exception_message
def set_query_parameter(url, param_name, param_value):
"""
Given a URL, set or replace a query parameter and return the
......
......@@ -4,8 +4,9 @@ End-to-end tests for Student's Profile Page.
"""
from contextlib import contextmanager
from datetime import datetime
from bok_choy.web_app_test import WebAppTest
from datetime import datetime
from flaky import flaky
from nose.plugins.attrib import attr
from ...pages.common.logout import LogoutPage
......@@ -300,6 +301,7 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
self.verify_profile_page_is_private(profile_page)
self.verify_profile_page_view_event(username, user_id, visibility=self.PRIVACY_PRIVATE)
@flaky # TODO fix this, see TNL-4683
def test_fields_on_my_public_profile(self):
"""
Scenario: Verify that desired fields are shown when looking at her own public profile.
......
"""Acceptance tests for LMS-hosted Programs pages"""
from unittest import skip
from nose.plugins.attrib import attr
from ...fixtures.programs import ProgramsFixture, ProgramsConfigMixin
......@@ -139,7 +137,6 @@ class ProgramListingPageA11yTest(ProgramPageBase):
@attr('a11y')
@skip('The tested page is currently disabled. This test will be re-enabled once a11y failures are resolved.')
class ProgramDetailsPageA11yTest(ProgramPageBase):
"""Test program details page accessibility."""
def setUp(self):
......
......@@ -1240,8 +1240,15 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red
available_features = instructor_analytics.basic.AVAILABLE_FEATURES
# Allow for microsites to be able to define additional columns (e.g. )
query_features = microsite.get_value('student_profile_download_fields')
# Allow for microsites to be able to define additional columns.
# Note that adding additional columns has the potential to break
# the student profile report due to a character limit on the
# asynchronous job input which in this case is a JSON string
# containing the list of columns to include in the report.
# TODO: Refactor the student profile report code to remove the list of columns
# that should be included in the report from the asynchronous job input.
# We need to clone the list because we modify it below
query_features = list(microsite.get_value('student_profile_download_fields', []))
if not query_features:
query_features = [
......
......@@ -337,7 +337,7 @@ def submit_calculate_students_features_csv(request, course_key, features):
"""
task_type = 'profile_info_csv'
task_class = calculate_students_features_csv
task_input = {'features': features}
task_input = features
task_key = ""
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
......
......@@ -996,7 +996,7 @@ def upload_students_csv(_xmodule_instance_args, _entry_id, course_id, task_input
task_progress.update_task_state(extra_meta=current_step)
# compute the student features table and format it
query_features = task_input.get('features')
query_features = task_input
student_data = enrolled_students_features(course_id, query_features)
header, rows = format_dictlist(student_data, query_features)
......
......@@ -261,7 +261,7 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
course_codes=[self.course_code]
)
def _mock_programs_api(self):
def _mock_programs_api(self, data, status=200):
"""Helper for mocking out Programs API URLs."""
self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.')
......@@ -269,9 +269,16 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
api_root=ProgramsApiConfig.current().internal_api_url.strip('/'),
id=self.program_id
)
body = json.dumps(self.data)
httpretty.register_uri(httpretty.GET, url, body=body, content_type='application/json')
body = json.dumps(data)
httpretty.register_uri(
httpretty.GET,
url,
body=body,
status=status,
content_type='application/json',
)
def _assert_program_data_present(self, response):
"""Verify that program data is present."""
......@@ -283,7 +290,7 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
Verify that login is required to access the page.
"""
self.create_programs_config()
self._mock_programs_api()
self._mock_programs_api(self.data)
self.client.logout()
......@@ -310,7 +317,7 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
def test_page_routing(self):
"""Verify that the page can be hit with or without a program name in the URL."""
self.create_programs_config()
self._mock_programs_api()
self._mock_programs_api(self.data)
response = self.client.get(self.details_page)
self._assert_program_data_present(response)
......@@ -320,3 +327,17 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
response = self.client.get(self.details_page + 'program_name/invalid/')
self.assertEquals(response.status_code, 404)
def test_404_if_no_data(self):
"""Verify that the page 404s if no program data is found."""
self.create_programs_config()
self._mock_programs_api(self.data, status=404)
response = self.client.get(self.details_page)
self.assertEquals(response.status_code, 404)
httpretty.reset()
self._mock_programs_api({})
response = self.client.get(self.details_page)
self.assertEquals(response.status_code, 404)
......@@ -57,6 +57,10 @@ def program_details(request, program_id):
raise Http404
program_data = utils.get_programs(request.user, program_id=program_id)
if not program_data:
raise Http404
program_data = utils.supplement_program_data(program_data, request.user)
context = {
......
......@@ -190,6 +190,7 @@ def show_cart(request):
'form_html': form_html,
'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1],
'currency': settings.PAID_COURSE_REGISTRATION_CURRENCY[0],
'enable_bulk_purchase': microsite.get_value('ENABLE_SHOPPING_CART_BULK_PURCHASE', True)
}
return render_to_response("shoppingcart/shopping_cart.html", context)
......
......@@ -32,7 +32,7 @@ class TestProfEdVerification(ModuleStoreTestCase):
min_price=self.MIN_PRICE,
suggested_prices=''
)
purchase_workflow = "?purchase_workflow=single"
self.urls = {
'course_modes_choose': reverse(
'course_modes_choose',
......@@ -42,7 +42,7 @@ class TestProfEdVerification(ModuleStoreTestCase):
'verify_student_start_flow': reverse(
'verify_student_start_flow',
args=[unicode(self.course_key)]
),
) + purchase_workflow,
}
def test_start_flow(self):
......
......@@ -334,6 +334,10 @@ class PayAndVerifyView(View):
# Redirect the user to a more appropriate page if the
# messaging won't make sense based on the user's
# enrollment / payment / verification status.
sku_to_use = relevant_course_mode.sku
purchase_workflow = request.GET.get('purchase_workflow', 'single')
if purchase_workflow == 'bulk' and relevant_course_mode.bulk_sku:
sku_to_use = relevant_course_mode.bulk_sku
redirect_response = self._redirect_if_necessary(
message,
already_verified,
......@@ -342,7 +346,7 @@ class PayAndVerifyView(View):
course_key,
user_is_trying_to_pay,
request.user,
relevant_course_mode.sku
sku_to_use
)
if redirect_response is not None:
return redirect_response
......
......@@ -61,7 +61,7 @@ define([
expect(view.$('.header-img').attr('src')).toEqual(context.run_modes[0].course_image_url);
expect(view.$('.course-details .course-title-link').text().trim()).toEqual(context.display_name);
expect(view.$('.course-details .course-title-link').attr('href')).toEqual(
context.run_modes[0].marketing_url);
context.run_modes[0].course_url);
expect(view.$('.course-details .course-text .course-key').html()).toEqual(context.key);
expect(view.$('.course-details .course-text .run-period').html())
.toEqual(context.run_modes[0].start_date + ' - ' + context.run_modes[0].end_date);
......@@ -71,7 +71,7 @@ define([
expect(view.$('.header-img').attr('src')).toEqual(context.run_modes[0].course_image_url);
expect(view.$('.course-details .course-title-link').text().trim()).toEqual(context.display_name);
expect(view.$('.course-details .course-title-link').attr('href')).toEqual(
context.run_modes[0].marketing_url);
context.run_modes[0].course_url);
expect(view.$('.course-details .course-text .course-key').html()).toEqual(context.key);
expect(view.$('.course-details .course-text .run-period').html()).not.toBeDefined();
});
......
......@@ -51,7 +51,8 @@
enrollmentAction: $.url( '?enrollment_action' ),
courseId: $.url( '?course_id' ),
courseMode: $.url( '?course_mode' ),
emailOptIn: $.url( '?email_opt_in' )
emailOptIn: $.url( '?email_opt_in' ),
purchaseWorkflow: $.url( '?purchase_workflow' )
};
for (var key in queryParams) {
if (queryParams[key]) {
......@@ -63,6 +64,7 @@
this.courseMode = queryParams.courseMode;
this.emailOptIn = queryParams.emailOptIn;
this.nextUrl = this.urls.defaultNextUrl;
this.purchaseWorkflow = queryParams.purchaseWorkflow;
if (queryParams.next) {
// Ensure that the next URL is internal for security reasons
if ( ! window.isExternal( queryParams.next ) ) {
......
<div class="grid-container grid-manual">
<div class="course-meta-container col-12 md-col-8 sm-col-12">
<a href="<%- marketing_url %>" class="course-image-link">
<a href="<%- course_url %>" class="course-image-link">
<img
class="header-img"
src="<%- course_image_url %>"
......@@ -8,7 +8,7 @@
</a>
<div class="course-details">
<h3 class="course-title">
<a href="<%- marketing_url %>" class="course-title-link">
<a href="<%- course_url %>" class="course-title-link">
<%- display_name %>
</a>
</h3>
......
......@@ -5,7 +5,7 @@
</picture>
<h2 class="hd-2 title"><%- name %></h2>
<p class="subtitle"><%- subtitle %></p>
<a href="" class="breadcrumb"><%- gettext('Programs') %></a>
<a href="/dashboard/programs" class="breadcrumb"><%- gettext('Programs') %></a>
<span><%- StringUtils.interpolate(
gettext('{category}\'s program'),
{category: category}
......
......@@ -101,6 +101,7 @@ from openedx.core.lib.courses import course_image_url
% endif
</div>
<div class="col-2">
% if enable_bulk_purchase:
<div class="numbers-row" aria-live="polite">
<label for="field_${item.id}">${_('Students:')}</label>
<div class="counter">
......@@ -116,8 +117,9 @@ from openedx.core.lib.courses import course_image_url
</button>
<!--<a name="updateBtn" class="updateBtn hidden" id="updateBtn-${item.id}" href="#">update</a>-->
<span class="error-text hidden" id="students-${item.id}"></span>
</div>
</div>
% endif
</div>
<div class="col-3">
<button href="#" class="btn-remove" data-item-id="${item.id}">
......
......@@ -16,7 +16,7 @@
"underscore.string": "~3.3.4"
},
"devDependencies": {
"edx-custom-a11y-rules": "edx/edx-custom-a11y-rules",
"edx-custom-a11y-rules": "git+https://github.com/edx/edx-custom-a11y-rules.git#12d2cae4ffdbb45c5643819211e06c17d6200210",
"pa11y": "3.6.0",
"pa11y-reporter-1.0-json": "1.0.2",
"jasmine-core": "^2.4.1",
......
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