From e4302e62d2791d5d5318c6ec761b4fa71b2163b2 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Wed, 14 Aug 2013 16:51:14 -0400 Subject: [PATCH] Allow course image uploads in the settings page. Authors can upload an image (or choose an existing one) from the settings page, using the in-context uploader from PDF textbooks. Includes tests for backwards compatibility with XML courses -- they used a magic filename (images/course_image.jpg) which is mapped to a location in the Mongo contentstore. Still needs some UX work, though the backend plumbing is there. --- .../features/course-settings.feature | 8 +++ .../contentstore/features/course-settings.py | 34 +++++++++++++ .../contentstore/tests/test_contentstore.py | 23 +++++++++ .../tests/test_course_settings.py | 9 ++++ .../contentstore/tests/test_utils.py | 11 +++++ cms/djangoapps/contentstore/utils.py | 8 +++ cms/djangoapps/contentstore/views/course.py | 7 ++- .../models/settings/course_details.py | 10 +++- .../js/models/settings/course_details.js | 4 +- .../js/views/settings/main_settings_view.js | 46 ++++++++++++++++-- cms/static/sass/views/_settings.scss | 14 ++++++ cms/templates/settings.html | 23 ++++++++- common/lib/xmodule/xmodule/course_module.py | 6 +++ common/test/data/uploads/image.jpg | Bin 0 -> 13811 bytes lms/djangoapps/courseware/courses.py | 2 +- 15 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 common/test/data/uploads/image.jpg diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature index 5c79dc7ee3..69183bc3da 100644 --- a/cms/djangoapps/contentstore/features/course-settings.feature +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -57,6 +57,7 @@ Feature: Course Settings | Course Start Time | 11:00 | | Course Introduction Video | 4r7wHMg5Yjg | | Course Effort | 200:00 | + | Course Image URL | image.jpg | # Special case because we have to type in code mirror Scenario: Changes in Course Overview show a confirmation @@ -71,3 +72,10 @@ Feature: Course Settings When I select Schedule and Details And I change the "Course Start Date" field to "" Then the save button is disabled + + Scenario: User can upload course image + Given I have opened a new course in Studio + When I select Schedule and Details + And I upload a new course image + Then I should see the new course image + And the image URL should be present in the field diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index da72d893cf..0847c62a18 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -5,9 +5,13 @@ from lettuce import world, step from terrain.steps import reload_the_page from selenium.webdriver.common.keys import Keys from common import type_in_codemirror +from django.conf import settings +import os from nose.tools import assert_true, assert_false, assert_equal +TEST_ROOT = settings.COMMON_TEST_DATA_ROOT + COURSE_START_DATE_CSS = "#course-start-date" COURSE_END_DATE_CSS = "#course-end-date" ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date" @@ -146,6 +150,36 @@ def test_change_course_overview(_step): type_in_codemirror(0, "

Overview

") +@step('I upload a new course image$') +def upload_new_course_image(_step): + upload_css = '.action-upload-image' + world.css_click(upload_css) + file_css = '.upload-dialog input[type=file]' + upload = world.css_find(file_css) + path = os.path.join(TEST_ROOT, 'image.jpg') + upload._element.send_keys(os.path.abspath(path)) + button_css = '.upload-dialog .action-upload' + world.css_click(button_css) + + +@step('I should see the new course image$') +def i_see_new_course_image(_step): + img_css = '#course-image' + images = world.css_find(img_css) + assert len(images) == 1 + img = images[0] + expected_src = '/c4x/MITx/999/asset/image.jpg' + # Don't worry about the domain in the URL + assert img['src'].endswith(expected_src) + + +@step('the image URL should be present in the field') +def image_url_present(_step): + field_css = '#course-image-url' + field = world.css_find(field_css).first + expected_value = '/c4x/MITx/999/asset/image.jpg' + assert field.value == expected_value + ############### HELPER METHODS #################### def set_date_or_time(css, date_or_time): diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 96b0b84e36..216edc6b88 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1608,6 +1608,29 @@ class ContentStoreTest(ModuleStoreTestCase): # is this test too strict? i.e., it requires the dicts to be == self.assertEqual(course.checklists, fetched_course.checklists) + def test_image_import(self): + """Test backwards compatibilty of course image.""" + module_store = modulestore('direct') + + content_store = contentstore() + + # Use conditional_and_poll, as it's got an image already + import_from_xml( + module_store, + 'common/test/data/', + ['conditional_and_poll'], + static_content_store=content_store + ) + + course = module_store.get_courses()[0] + + # Make sure the course image is set to the right place + self.assertEqual(course.course_image, 'images_course_image.jpg') + + # Ensure that the imported course image is present -- this shouldn't raise an exception + location = course.location._replace(tag='c4x', category='asset', name=course.course_image) + content_store.find(location) + class MetadataSaveTestCase(ModuleStoreTestCase): """Test that metadata is correctly cached and decached.""" diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 2007ba2f69..dbdf8b3f6e 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -30,6 +30,7 @@ class CourseDetailsTestCase(CourseTestCase): def test_virgin_fetch(self): details = CourseDetails.fetch(self.course.location) self.assertEqual(details.course_location, self.course.location, "Location not copied into") + self.assertEqual(details.course_image_name, self.course.course_image) self.assertIsNotNone(details.start_date.tzinfo) self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date)) self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start)) @@ -43,6 +44,7 @@ class CourseDetailsTestCase(CourseTestCase): jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) self.assertTupleEqual(Location(jsondetails['course_location']), self.course.location, "Location !=") + self.assertEqual(jsondetails['course_image_name'], self.course.course_image) self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ") self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ") self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ") @@ -97,6 +99,11 @@ class CourseDetailsTestCase(CourseTestCase): CourseDetails.update_from_json(jsondetails.__dict__).start_date, jsondetails.start_date ) + jsondetails.course_image_name = "an_image.jpg" + self.assertEqual( + CourseDetails.update_from_json(jsondetails.__dict__).course_image_name, + jsondetails.course_image_name + ) @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) def test_marketing_site_fetch(self): @@ -188,6 +195,7 @@ class CourseDetailsViewTest(CourseTestCase): self.alter_field(url, details, 'overview', "Overview") self.alter_field(url, details, 'intro_video', "intro_video") self.alter_field(url, details, 'effort', "effort") + self.alter_field(url, details, 'course_image_name', "course_image_name") def compare_details_with_encoding(self, encoded, details, context): self.compare_date_fields(details, encoded, context, 'start_date') @@ -197,6 +205,7 @@ class CourseDetailsViewTest(CourseTestCase): self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==") self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==") + self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==") def compare_date_fields(self, details, encoded, context, field): if details[field] is not None: diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index c3335aaaa0..3d6d1d0c56 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -5,6 +5,7 @@ import collections import copy from django.test import TestCase from django.test.utils import override_settings +from xmodule.modulestore.tests.factories import CourseFactory class LMSLinksTestCase(TestCase): @@ -150,3 +151,13 @@ class ExtraPanelTabTestCase(TestCase): changed, actual_tabs = utils.remove_extra_panel_tab(tab_type, course) self.assertFalse(changed) self.assertEqual(actual_tabs, expected_tabs) + + +class CourseImageTestCase(TestCase): + """Tests for course image URLs.""" + + def test_get_image_url(self): + """Test image URL formatting.""" + course = CourseFactory.create(org='edX', course='999') + url = utils.course_image_url(course) + self.assertEquals(url, '/c4x/edX/999/asset/{0}'.format(course.course_image)) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index d956a903b6..e5ae6bb66b 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -4,6 +4,7 @@ from django.conf import settings from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.contentstore.content import StaticContent from django.core.urlresolvers import reverse import copy import logging @@ -153,6 +154,13 @@ def get_lms_link_for_about_page(location): return lms_link +def course_image_url(course): + """Returns the image url for the course.""" + loc = course.location._replace(tag='c4x', category='asset', name=course.course_image) + path = StaticContent.get_url_path_from_location(loc) + return path + + class UnitState(object): draft = 'draft' private = 'private' diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 753df66fe0..aad56e4a2e 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -276,7 +276,12 @@ def get_course_settings(request, org, course, name): "section": "details"}), 'about_page_editable': not settings.MITX_FEATURES.get( 'ENABLE_MKTG_SITE', False - ) + ), + 'upload_asset_url': reverse('upload_asset', kwargs={ + 'org': org, + 'course': course, + 'coursename': name, + }) }) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 78c5dcff33..99ce00b891 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -3,7 +3,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.inheritance import own_metadata import json from json.encoder import JSONEncoder -from contentstore.utils import get_modulestore +from contentstore.utils import get_modulestore, course_image_url from models.settings import course_grading from contentstore.utils import update_item from xmodule.fields import Date @@ -23,6 +23,8 @@ class CourseDetails(object): self.overview = "" # html to render as the overview self.intro_video = None # a video pointer self.effort = None # int hours/week + self.course_image_name = "" + self.course_image_asset_path = "" # URL of the course image @classmethod def fetch(cls, course_location): @@ -40,6 +42,8 @@ class CourseDetails(object): course.end_date = descriptor.end course.enrollment_start = descriptor.enrollment_start course.enrollment_end = descriptor.enrollment_end + course.course_image_name = descriptor.course_image + course.course_image_asset_path = course_image_url(descriptor) temploc = course_location.replace(category='about', name='syllabus') try: @@ -121,6 +125,10 @@ class CourseDetails(object): dirty = True descriptor.enrollment_end = converted + if 'course_image_name' in jsondict and jsondict['course_image_name'] != descriptor.course_image: + descriptor.course_image = jsondict['course_image_name'] + dirty = True + if dirty: # Save the data that we've just changed to the underlying # MongoKeyValueStore before we update the mongo datastore. diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index 4d048bab81..b66f2bbba9 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -10,7 +10,9 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ syllabus: null, overview: "", intro_video: null, - effort: null // an int or null + effort: null, // an int or null, + course_image_name: '', // the filename + course_image_asset_path: '' // the full URL (/c4x/org/course/num/asset/filename) }, // When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset) diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 0cbf573ba9..36bee79d80 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -13,8 +13,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ 'mouseover #timezone' : "updateTime", // would love to move to a general superclass, but event hashes don't inherit in backbone :-( 'focus :input' : "inputFocus", - 'blur :input' : "inputUnfocus" - + 'blur :input' : "inputUnfocus", + 'click .action-upload-image': "uploadImage" }, initialize : function() { @@ -51,6 +51,10 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort')); + var imageURL = this.model.get('course_image_asset_path'); + this.$el.find('#course-image-url').val(imageURL) + this.$el.find('#course-image').attr('src', imageURL); + return this; }, fieldToSelectorMap : { @@ -60,7 +64,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ 'enrollment_end' : 'enrollment-end', 'overview' : 'course-overview', 'intro_video' : 'course-introduction-video', - 'effort' : "course-effort" + 'effort' : "course-effort", + 'course_image_asset_path': 'course-image-url' }, updateTime : function(e) { @@ -121,6 +126,17 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ updateModel: function(event) { switch (event.currentTarget.id) { + case 'course-image-url': + this.setField(event); + var url = $(event.currentTarget).val(); + var image_name = _.last(url.split('/')); + this.model.set('course_image_name', image_name); + // Wait to set the image src until the user stops typing + clearTimeout(this.imageTimer); + this.imageTimer = setTimeout(function() { + $('#course-image').attr('src', $(event.currentTarget).val()); + }, 1000); + break; case 'course-effort': this.setField(event); break; @@ -216,6 +232,30 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ this.save_message, _.bind(this.saveView, this), _.bind(this.revertView, this)); + }, + + uploadImage: function(event) { + event.preventDefault(); + var upload = new CMS.Models.FileUpload({ + title: gettext("Upload your course image."), + message: gettext("Files must be in JPG format."), + mimeType: "image/jpeg", + fileType: "JPG" + }); + var self = this; + var modal = new CMS.Views.UploadDialog({ + model: upload, + onSuccess: function(response) { + var options = { + 'course_image_name': response.displayname, + 'course_image_asset_path': response.url + } + self.model.set(options); + self.render(); + $('#course-image').attr('src', self.model.get('course_image_asset_path')) + } + }); + $('.wrapper-view').after(modal.show().el); } }); diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index 1430c41368..ca48244f64 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -432,6 +432,20 @@ body.course.settings { } } + // specific fields - course image + #field-course-image { + .current-course-image { + position: relative; + + .action-upload-image { + @extend .ui-btn-flat-outline; + position: absolute; + bottom: 3px; + right: 0; + } + } + } + // specific fields - requirements &.requirements { diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 1f5d89b2b9..96a8e59d9d 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -2,7 +2,7 @@ <%inherit file="base.html" /> <%block name="title">${_("Schedule & Details Settings")} -<%block name="bodyclass">is-signedin course schedule settings +<%block name="bodyclass">is-signedin course schedule settings file-upload-dialog <%namespace name='static' file='static_content.html'/> <%! @@ -22,6 +22,10 @@ from contentstore import utils + + @@ -208,6 +214,21 @@ from contentstore import utils ${overview_text()} +
  • + +
    + % if context_course.course_image: + ${_('Course Image')} + % endif + +
    + +
    + + ${_("Enter your course image's filename.")} +
    +
  • +
  • diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 57b13c10b3..4555395fef 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -338,6 +338,12 @@ class CourseFields(object): show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True) enrollment_domain = String(help="External login method associated with user accounts allowed to register in course", scope=Scope.settings) + course_image = String( + help="Filename of the course image", + scope=Scope.settings, + # Ensure that courses imported from XML keep their image + default="images_course_image.jpg" + ) # An extra property is used rather than the wiki_slug/number because # there are courses that change the number for different runs. This allows diff --git a/common/test/data/uploads/image.jpg b/common/test/data/uploads/image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..21ece4ef43ef2528c474f52167068ecf37978554 GIT binary patch literal 13811 zcmd_RbyQoy*Df3iMT)xxDcYjN9ZJy@D-t9~OM&9K7hx0fD8cj z*+2RBi~1yJ=xF~06CM3IIu_=O7g(5BSTAt!ab95KVPj$865`_F6A%y*yucwMCL$ny zx+eJ72+F^jsA!l^6$!Ahu%G_@Ka|Hd03jv{8j2Ar3IpI7AqpxX%3~J*_+;cW)PLsw zw_spC*@cGs?CGfv0pOqBC%d0a#Kw3I_^;Av&k4~nh=^Ze@{o`-kdiSSUV2lc5MAu1t23b3ic@PC11_)zXPixmk;?%GdGVY@CR zis78`zPQ!hW3&m(O2Y_lBA^KoHXlLzTZFF7-B!D5cl1!E^>tjiNZ?)RNa20XU+rDG zU$0xTyfWw1y6s|biBk7C>&X$i#lh>gOUt8s4JlTfxcR$l){G3or7=9&9F$1vppv^@ z#7914rb73HR%8QRFI)M}+9M!_Q(fu2&;lD(n^F)E0H^ACCj-yan;5QK9-DK{3 zH;9iHj9~eKnvMKUj{mTXKll)y;HzyLy|%4%PpEc52E{l&c?9H1L6VbX)Z!9fPJdr) z(tb$j(kbGLcynzcZoS@`A7AYUPBpo%AA1DIYTnCU=+#I;)49*(q$c#=aYj1~+-v$K zJ|v8|kNNE{AGBVSew_R=^T6fOiXeFes7w5ZveB>w-d3$;v({X(U8#$s%jMfij1z3D z8a?;GM}SS0exb#y)pMhC2@cM1mM@8{K!KkqFpw~SRpf28bd+p;&jARXnY;AOy_5aC4v$NCgt~rNS&xhp}6kBE$J51TpHn6>Ta>4HoYvs=-zt~+Q_Z) zM@j8{C?RlE_b6o>AL!%&X)PN1{nudEFYEO~mRHFhUb~L zS9kXJ);j;8P3=F%QV!1Gj#EA1?A`tFP!^J(H0G9DH7ju*N25wWoqu)6f{{MP!6!_s+31Lv zE`SDfKXO?ICRE3h3c|w$MFx)|+V}w;!yC`exj$Q+@^A}>hP#tPIy<(B#5u#x zUb*3~u0H~lzgF{bvO+A9cD=uolK6sb&+30gyF2y%wRNOBz<7YV86FT>ksyg_u^Lrc z&5ObuUE;l52F>!Gr_Dd;MSVi56O!#Zy@Kj2q;q>t-oC3um&A}&o+S_xPRz2i!h)GYN;Go^TujbrJF_*pT6YjibeU^J~K*ZerQz;&vpXlB(!MKjF!%mVmUn=r{O; zNwh=E;E_f;=hhf_>t9ZV`BG9HBF!BH^$W}1Q0NTy=2S2H!H}yKkW!~B#*l}hy=~mO zzWryqQ=L%F<=Bczf<6ZW!NR3+_Q>Y=<&TpB;Mf?Hh{o9yqAF`pWA@C9); zXlK=V!yH|WAXTC>r#Xqi0m(J)tY?~!028E?Sjg+>giL^VQ=RS!HW}Osus|X!H^{Y1 z_#_}vR8s-k(sQNK8Sm6DsS(Wzgwq*}87EaOd;teisCalNos}6bWwSfYz)LN?62z^( z?$oWpA@5Mk*w~8UY*2fzII8qW;v0-_?wJus^brHHx4CoD}@%YX<1>QXa|@5=e<57CB#?(Jr{S zzhKe)v2pgkxpYK+H5wRVxC{FdspHK)GnItt_vWhUrc+@B>o3^$E>&RUv=n;(ciFUw zTn)bwYQO8nEbx4=a4;!r68-!n!f7wqbGwFb!#Qde{;GHZsmCJ*L;&ac#c6%quWAMo zmf(r`<7taN6*>s{{s}T*E1g~UP0>AJlOX4C;Mrp}9 z@SLzl@Di_AIHYnl$@CIW7&QiaYxlFHr9rpS?|2Ja?);whQv(iPJW8)NunO6BxnREL zjtI`=n_E}-MomL{6vwCSE-ZOT++I#32(1xixVOM*pj1=u z2SwSB5K+;_NCUDvUyexuFs~p5eNfP;5@Qrq58T~3j2<8c1Eog63~$k91chtTG;)O~CFNv>N|R&oH9-cMIoybJz;NSbfvaH9Aq@w~w* zbeK#KO++OJhqifR$ty*Mdjv>okT` zz$%QtGO&{Mm^AoT|2V6;)e%Tz)V=gc(zS1}SKSMj*S`=5Ty)QklIEi?&yC6w>_b6W zEl0yJ9;Q5NmtrfO|FPnywkWdTR&#VHAl-Z9+J2N0yX+zH!?(}`$>g{>v9S#he*SJ! zzsRU`#9!*}35XIN0jCKjQ(n~IgUy3EXFa_yH3rnwEQHNN-XE!(GI}c#o0~%iL4fME zD>Q7875s~S4H_UBeM}goJaGIG@Qd!{3x0&?yqX!v@%rMb@NVA6-y9% zyO&te7!h0d2#8v;y9BMRC<+TZdo8er%8{8=D?9>T-`=pAY|WgH*sLG+QM_FP@z84d z@x1uU$c;$@@!E9TE`v}*kD;4NU?oE(Tw79QFv$gBjqaDQRb*@wrFF{cOduwGR2O$q zsx+Db_nR1qjjJ4*N8&8^SMAPHm&nZGf`XG>#}0?vxi9djKd7tBU#NTQn19yJF$jh& zlHsbRKP%?g)eDGSc5q`XxQ=`=hhXQ^qMTBDPlwfLZjL)=TOfHyn2epQnD0k4&wM1f z`%Yni16dTFT@HmK=YF zrRQbVG6CY1NFgY2=MnI!#;w@7n!~l$3QIM)R#C-gQHJ2hYP}7P10@q|4Ed@0`<;sO zkJNr8B_$apTvAegt@HDfnpehQ0TGdr6xI;L%+tOLuZ?RSZMBN`L^FjJRm~r7!(+*~ zmTyZv({$kr(=Smd!%@7$%{y`JOmaqtZTtSbxq4r9LM7{wf~H#iblk*QJ9Ss-+gb6SyDKXQIY;*2 z-}-Mfo4kCRc@ zFfPEIDWi>@uKMn!OkOj-x|QRuQ1Ti3>r`}s*XB&3-)fw~|ZHXb}EM2xx-QloxjwK{}A5TbV<*qV4U#y@scHxSaZe-v~+LKSwJ^Le23?ixF|a3ge`R~UQXKdmQecuonQQ_R zp#o0BBgkUB2^2oR2);-PAzwacsDrCf8|dG zPa(`RD=+Bm&+*h7v#wI21NV$=%}J(8LApQ-DeoJjpI*jjuh5X5uR8eAn=}t#mU1X9?R>T8mYXWaclWmud~~5R zQ9*=+2EYFi1wuly@;ZIXYowBXAleh>bBqSwt z5^(Kz5`b(vXk&C7o_f#MSJ_q5sAwhbmz?xkxi443au4ZyBb;y&hV5fJ4fq0v4#`fO zu?*~S#h#YS48L|+ldCH!tuGNMm`-1L>f7SRU#61IdN(vUhPSNZD(j~RMNE>y>?^E? zCtplw4*TZK`l$oExDt2p5m2mf<&U1JHhPqk?;@+=^=&%v?lauVTMit2FHLIu+{RaA zCC1I+41=+b-d`pFjR2G0AIv<_LR&r5=q$0OXX`YfIo;26xwlJ`?29A(1!Rhgk{d9D z50u`3P~i(YHM4K*L3Ab+Ah#Cj>Lm8zB2W}>iqo;n5Kn(43dK|A(wk@7`*m*G4HPD( zTj}z-jkV*oj4AsLHE%LH@?IrXr5yVZdt-Q%RbNowzl% zSspo}>uu6IB`SP!@@zZ$=b-oje0@;Q7sT{$-hBsHkO3^7Z87#j1xOL=b@hirOj{sP zm^z`H_g#}#qe)&)7JRx(K%(c(tCzcB${bvs`QA^A9s_;*qn1i%mfN19DnYkgruyQE^dTX=!0%R#ujR68~6J^QlBHmvfpo48#<- zkZ}3|*RGtZR&Y1D9+yf)$as4pedL$dIL_*>4WF7#JkD^L`>W!D7l*YVmx8X+?v*`9q1`$ikjU&M{v&gUFiQ%POU6wk#i}<9rT8`nx zgZKO~J*NbT0mMoH;vsZ4<`jE`{?3^tWRCT&9=uWPq}qb?O@bbFn~DB6i}r8X=2| zy8@-L1MJt3<0m)FPG1slbrb953$} z1W6oN2!l0=&bYE zZhp1IT1ejG8^5w5Fv=`D74$G>JJ79~Qn@{utpCgXH@PRzN#n-D$RnUBv8ifqS!sHg zMnTiE+%c|In=-T)B19bGCzUJ}PZ|ITJ)t3b>73+8Pj>Z(-q^{)7@^c}28THIsY{S)f}X(jZ~kEA1#aL~ zL%&(pJf{e5O9G-fuwiehRE?FV&YQuc!LdGSJ4*h;k!g$=yhE(D@ix=rLae-Y`c{HQ z>ea#2&XOT1P&TS_FmBP`=oG~fwZa-QwNotI2#fUBpPct~hg~=%ZtK-IM2Y0B_rnuT zHM-u6B6?ma=`OXI<5G&SGQp~?$#NzjMx|G{e13IvY&j7fH#=-O`LPHXma#J?bG@~y zcqK;P@4iy7MoYwrOtUDLcIbu6bdfsarZ4BdfNkusIgk?QHDuA|U~OvAH!N01tq*_T zsJU_~+O-jSl{cQWSC95a11weeTsDe(tuG(&3@Ol@F-5c;T>Hb|PVR%ER^>j7t#LJ^ z>#9XccHXwX?&0q44Wz89@expVor*Rrwb{bb^a#M(_)3hfB-`S@sQ&*02D!azgZ+|P zQL&-QuaRbGCzG1}U1Hx$9Gne9p5b9)pb<0`lv+a-omE1$ydjr~!Un+5Ej)!|wg+U{ z{@CGgypY0pH^}8zjOM~?J$ibnPzG(BB+TVyU}NBPE!TQqA9h-*ZyDE}d(n=oT!y7| zer#4AI;ah-nS{#3#Y7l_vL(Idu)6rO?AxxdoJ_IHMXScVaZz~UCaM*KnHI}y83Red z%zMm1#db?)MHa^nU1+PueG$*i7Zkr&Hb;FIBQi@mzqJ-#1cDCS`5PsU3&HI?VADR~S#&^5<=*w&BHaZuzA9 zcGuqg_kdH(AMKI27Sh<88W7}X^tnk zG5?I6aKPnuQ&s!qQT@}B4X4{9aUd1E#4QZ_=7V5W%9QU zDOpn)jMN^n#-HR23>(v5L;p)!&PfDx-tSr4;uu0w>VKedY*6y{mRwe$m|g8Qb98uEj^a zL;^fx8r@z8Z=&|hKDDHjGflZ&Qo^@V0}>^V0Lu;-)#y|({1b}5q+F`%cUpt98l&n- z%^XTGJigBs5dDK^+&fx&gyaa%#eb}jkkHp+pY34M!GQBZQxmMIIQuh$JP||^DPldh zx>$zx4D%4vkZ)F^|DwE>tl30d%RxtFBCsVT#m+W3ODHk++hSS@HN8otjH$i5o8y*f z&DUquB+A8;4ezryg*0?`%lP9>n|i1^uD26f*Q%Em#nfUQaL;fLoK>xY^v`8=iq-#{ zI?Bp5BbTOi?DEQk5j{oJDpDN_`!7F9U{-j*$=&chf%;nt?v4&7NmJU|9LO(&ZRF08 z{`UuekbJsT@ve_qA?q=LQ0c0XROB!vtgsg+g>)Ua9>Q35{)=YEGq z4E3Ru_WiMO!wwrX(C@^%yk4kY`K?<@^%JJp*Z`N1qB^iPm5;^42xyP zPeZE}O+w91n_iUZ?6R9Mp~F^b=h7vzm67!hlq}Z5=-o)LCfq!P+vi}-KCs&j=Hztc zm;l2OTACB`J9)v{7?twE#zW3(pD~#Tya@_wZfzDvtS@k?F?Sb#nch(W)~pngxIl~( z94oH#>-${CDDF;zsfOdvXUU=#v3}V$73TanRvwW_UoaYjtj2WUVM_Fx9bc?hFN;d?7TgKgQ+t+b7Q<_Zrj#QB#HkM*dE=6xY+998Qev z8&5N7Ju4B}mks)|Wk*(4Zjjre75t|nH4=zlS1Ldfvd8-I5wN<`x~6bXJfYh92DZ#Yh)!@kw2z5;*b#-;d&%qSQ zMmMzU!PTWS{Y?IV!}rx~FaWsDf@!DHOA5631W7-~63JwrASo#J$Hyl~vUFZre}W|E zC=@0ffGSFBqwP=IRLh)K=ed=ttupZmr1C61w?ERHc7fFQk(5kN4fpqG=U3>9DbvM7A&2f^H}juAaq7bkHRFd8C1CsH{+=r0%WJURl#; zHqZ1W3R1ptPak>=SKejxyTeJYDa&`3w`I+X>nYg!x^1%d)^A-7xt`ZWr{WV~ zIntW|)8-h#V6Y)rz}8l|nv|4CR4*@K^C1HicK<`L?^u z-_wZ&-{D-aeQ29Oy$&~GLhf>Ymq{5mn{)J?qG>X5{MoSE@k*%k>($u4n4DzT#Pn;! zLJJdzaI2(1yEnB=WR0l2Jau>&Tn_f)jq~dwTt%ts0pLWNiTXOp3U`cxN5Jn`1LiN$ z0`Jm&BIa$j(%l?pJCnt{r2h~=QA^>f84ENL>Uq_ibdW@Q%V^7Iy3Bx+M4?Y79{ooP z1d8~3|%ysx&(~f3E?xxK*(ikATNC+CVN`yw_k@(TxuWQyhi!N zrFv4_d;f7MLf{Y}HUwn@Yt3nVF^ALj0=B@D+U>cACYorDOFp^;=yoh{x1uz#L;T z{M{kKs=cMns$sjwnGTl2cuGcop5c%8SBFJ!pKShkm@o7@RrFOqt&VK+3yFM zYEyC=KCZaro`GVSMJ5#rbVVjdcMfx=>dm$o01D!-nW>WK@ak4hRL>;KpjsEqvm%`L zZfJgN%=f*%tXxXoq@lQirROVG8m^-Jyl#QvmpG?FKkg9}ZJU{9_0j$o7_BPXG+{i? z8mZ#c^sB*9M{PZNtbF~jf;p^Uw-OPgak2HSX@|PMW+8Pl(4YZU zTH47*A<1(pZ0cm6%;Xt})e(&YKfxuTj&|74qpa2X3GE2A^GTwyN6dZu6I)tdn#Ito zzV@_(W*1q})Yi~{G1Ww|AFOOZ=ON3nyPi!(ri55A-bsa)WeV)L~$fFT8tEYV; zF^B)>6jei2W_e5&d|U2UJxQt@Uj-=%Kr~{kupFJztx5`<++_)rpBl#9cePryeEC8l z$nJn-*0kj0wC#8Cy>4!~x6AEvS7r7y1tPjLtK{iTWiZ2PPA0?N@7QTH%yVgB z!6U@fApHaXFm$T5-%ag`=spW}-m34$k8403W>TwW*LeC}gIB1!@*QU9uz{lc8zqA= zY$eEwbUUfd7ddfGUCjxR1{bbH+BTzEVdA~Yt@p2;eSO6Ce1A2L=*g()xsFx(a0(3l zCifCk`X&oz&c^X@P8mHM`YLUQ&mzbOMp(uE14OR_LGR{=Rnoj}>yo-@wZU8!5Nckc zDkqma89$N3Q_%MmEsMJ*eZ00=-#dHU%Whu4cc4X@R6<;4&b#R;reysVEr|&AEmI5% zd;XnkmhGK$7RN2y%tMY~kuh;@X9z??DK`n4wzlLXa<~qo9(>}+LF_^YZ+Xld5xks9h~Qt2c{++T*8$!;@`+uL|xR}9sJ z7>EC)7!ok#PY;q4OB>sAYUdEKR&+S!q{O4N+;ymV>569_{IZGs@9N+HYVvaO-m|se zQv_dFVe5f_h*`Y)ZV`ihPI0^K{e=iu&uv3AR(b!mwCA z9bQKsB4t9?uK0yswwIK%k6b`Zy=2F8O(J?iJ_|CATvJCTm0AeY+sH8V?`pGiS}>a#6tI}FV2T0sRzX{jgQ&r#W3`BI zJMNT}W#c3ILs^C`(P*=!mUE3nU+n4U;tC7|8t7fU=DS%obr=`ln)dP^gOLId~5biEp%wX)2H zdJ3Cv9gZK1ej6-!^}8WNW#$50Kb0^4!&nJATBg?Q zvo;8~>b1aL2Q|>@J$H@Sg9O)cfnWs%73^FG3wm;bQB~fzBbzVQHFrv>LE^4YXS;(q zqfH=S?5ONCPuqqt!9pOugSMG2vu8qK64K!A5x~RCxdDF!^gYPwG}Ep2?CSTFLv-#k z$^91M%WUE@TWH7cey4XT_KF8euW6lG!sko0_q^q@V4{%s+796?ctdn0Fei{iKT{g4 z*`ksXw*;Z6Wh18cvm#91LqP|);46mbkFpJUD2Atm%JracDF!p4uG8h?8%gLB)-@Yz^OhiB5n$0WQonGHJvc=7@ z7RdXRUo{>g2)tl3L%n_#a2b8}SLQUPG=YXjpan)nC7en^@^n&|yv$Oa4e+brbhhVb zCJU+z7bF`rR1cX_#aM2o^O72(Il0eY*MDDw#x=EY!G_&MYIoDS8uFm;vj69=sOR)R zWfW{3B+;^}gsZ}-Y!~do^2x`kXElUpNFr?v3yrpm)W}__JvemIDAsM1RPxd;dYd)- zQ@=Fdh>%IhvP_c5HxA|qYloRIZBFt9Xz7cmxK4Y|4TNo0#lCXB+_B-!nYy%NSZwty zV*n{Ss}luAm11Xz&jybDLh2go7RD}wyT*`A&K^^#NHePm$C_=g;eEPi!NF9c_j;7& z?hbG-ykZSvh{c@6A{OSchoOk--)V#g&YDk8pIH>w1o{kV-Vs z3`GUiDhl=dqD}{#dEPc>k}TETNDsgU>QjcHXg8<{P9x+}QVl%zIE}3v>CWt{HXMBr z0sASC{|>2jv?NxOgh6XT5S02QPpr34XVipyr9RmPQwV~f9|zk=ISmMktBMf zT=o9JUg{1%2A`g6asNg1ji~)oE*RX@@H=KyqCE~rNV`H6tYUk)m&YA=R=*6U|F9np z+3T~Im0?#N!NKFApwwpzf11eGP9e8$U#rDY+T>C{~9zp$Ax;TMbL=3NZ zydp`UR>v1jsb)y%uSQmee1&N6TH!loM2u(lRI*o zu_7P+=#robUmTe&h)iNr5KOUw3+C_@fZBit)K5taolvStGj)+f6&{u@~rf0JvqTJ<<9Y3Zw$%`8mes{)3!}{bI=)PDD(>)x(QZ#N>UIYwdMbp zq|osCS3ZLQXVu)8Nw@ z+P2}HW~QeF8Aza#FQa08dE&7914A;a7m>H`Dc|KPE@@8^`ArUA|Sigp^<8~nb}e7u)&xKH0t zf6v+j{~;>dMFy(+E|Qbh|3`E_t}@e`>hp^z`uJq1dRUL{Wk3ws^IB1q1_p+;`1o+$ zx%;ECaifrYSCR8ne$TK33a*TVe4xV3NiD`~FFpQe85t7Q@U5nb1ePEx1z;jrs+Z;BDW(kgx zl-sy!qY3AcMJP+xHqwPcR}J|)%|epxlvP#Ec!*)par{(H;? z^1vtXYe6ZUy5cfoy&mN zs2r_Ea2e=13*a)c6OUckL4&R&C}GUprCRmE7E)|#0+krN@TTT4}K^3en6j#ZIRL7 zL70gD9I_g?Hmx`wkmhdj27T3IZPMK%xAtH%m>SJjA$WmpVXi1io_4HPkasCwl@kxo zx*>c1Qjv4RLh(h7Hn(T(nLM(YiDwCI8oS%Z9tN9zzjXl|rRf0~sI|))37@tzZ9@&a zXTrrP_EKAFC+^aV?WH2f+rEu%Gd%HS`tdX-(j~v3V zgV=N4g!&l!)Bh z-<)}4%cS88qb{~wT`2i+LqT?Bx2RJ#Ge^I$YN*VDqP0?qFkpCkQKcXozrn=xtM}K) z;yb3hJVAx++MT)9N)121*O?aPHOt}OrGnZ0OrZBdJ|r38Z~mu*+W%BL+T=UHd5Z6< zt%fhpJiXbVvizJiE8>4fCGIeJmM&vC+v=fT?Emel=6`q8<}Ux5XYvs+A%s7>R6kJ` z+;W0lbybR8fSeloI8{;(pVAobJ@nP|9Jn@^s^yA6c{&!RXEVyi@fG0~;X@fiP#sg* zcSzR;Ayn?ecE{s)Uni=*bKx}_Lru|lY?Ifi*$IUPYCM6#KjBEI)wELLz{{=L!%o2A z@{C1{gZqh^cV%C_K7Nf&-REW= ze5yZzo7-fCo#4Xh@x4h