├── .gitignore ├── README.md ├── __init__.py └── client.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **NOTE:** This client library is for the SCORM Cloud V1 API, which is on a deprecation schedule. The client library for SCORM Cloud's V2 API can be found [here](https://github.com/RusticiSoftware/scormcloud-api-v2-client-python). 2 | 3 | # License 4 | > Software License Agreement (BSD License) 5 | > 6 | > Copyright (c) 2010-2011, Rustici Software, LLC 7 | > All rights reserved. 8 | > 9 | > Redistribution and use in source and binary forms, with or without 10 | > modification, are permitted provided that the following conditions are met: 11 | > 12 | > * Redistributions of source code must retain the above copyright 13 | > notice, this list of conditions and the following disclaimer. 14 | > * Redistributions in binary form must reproduce the above copyright 15 | > notice, this list of conditions and the following disclaimer in the 16 | > documentation and/or other materials provided with the distribution. 17 | > * Neither the name of the nor the 18 | > names of its contributors may be used to endorse or promote products 19 | > derived from this software without specific prior written permission. 20 | > 21 | > THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | > AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | > IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | > DISCLAIMED. IN NO EVENT SHALL Rustici Software, LLC BE LIABLE FOR ANY 25 | > DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | > (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | > LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 28 | > ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | > (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | > SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RusticiSoftware/SCORMCloud_PythonLibrary/34c37bc15ed6091bd97c22efcc164694103f2741/__init__.py -------------------------------------------------------------------------------- /client.py: -------------------------------------------------------------------------------- 1 | import cgi 2 | import datetime 3 | import logging 4 | import os 5 | import re 6 | import sys 7 | from six.moves import urllib 8 | import urllib2 9 | import ssl 10 | import uuid 11 | import requests 12 | from xml.dom import minidom 13 | import lxml.etree as etree 14 | import shutil 15 | import logging 16 | 17 | # Smartly import hashlib and fall back on md5 18 | try: 19 | from hashlib import md5 20 | except ImportError: 21 | from md5 import md5 22 | 23 | class Configuration(object): 24 | """ 25 | Stores the configuration elements required by the API. 26 | """ 27 | 28 | def __init__(self, appid, secret, 29 | serviceurl, origin='rusticisoftware.pythonlibrary.2.0.0'): 30 | self.appid = appid 31 | self.secret = secret 32 | self.serviceurl = serviceurl 33 | self.origin = origin; 34 | 35 | def __repr__(self): 36 | return 'Configuration for AppID %s from origin %s' % ( 37 | self.appid, self.origin) 38 | 39 | class ScormCloudService(object): 40 | """ 41 | Primary cloud service object that provides access to the more specific 42 | service areas, like the RegistrationService. 43 | """ 44 | 45 | def __init__(self, configuration): 46 | self.config = configuration 47 | self.__handler_cache = {} 48 | 49 | @classmethod 50 | def withconfig(cls, config): 51 | """ 52 | Named constructor that creates a ScormCloudService with the specified 53 | Configuration object. 54 | 55 | Arguments: 56 | config -- the Configuration object holding the required configuration 57 | values for the SCORM Cloud API 58 | """ 59 | return cls(config) 60 | 61 | @classmethod 62 | def withargs(cls, appid, secret, serviceurl, origin): 63 | """ 64 | Named constructor that creates a ScormCloudService with the specified 65 | configuration values. 66 | 67 | Arguments: 68 | appid -- the AppID for the application defined in the SCORM Cloud 69 | account 70 | secret -- the secret key for the application 71 | serviceurl -- the service URL for the SCORM Cloud web service. For 72 | example, http://cloud.scorm.com/EngineWebServices 73 | origin -- the origin string for the application software using the 74 | API/Python client library 75 | """ 76 | return cls(Configuration(appid, secret, serviceurl, origin)) 77 | 78 | def get_course_service(self): 79 | """ 80 | Retrieves the CourseService. 81 | """ 82 | return CourseService(self) 83 | 84 | def get_debug_service(self): 85 | """ 86 | Retrieves the DebugService. 87 | """ 88 | return DebugService(self) 89 | 90 | def get_dispatch_service(self): 91 | """ 92 | Retrieves the DebugService. 93 | """ 94 | return DispatchService(self) 95 | 96 | def get_registration_service(self): 97 | """ 98 | Retrieves the RegistrationService. 99 | """ 100 | return RegistrationService(self) 101 | 102 | def get_invitation_service(self): 103 | """ 104 | Retrieves the InvitationService. 105 | """ 106 | return InvitationService(self) 107 | 108 | def get_reporting_service(self): 109 | """ 110 | Retrieves the ReportingService. 111 | """ 112 | return ReportingService(self) 113 | 114 | def get_upload_service(self): 115 | """ 116 | Retrieves the UploadService. 117 | """ 118 | return UploadService(self) 119 | 120 | def request(self): 121 | """ 122 | Convenience method to create a new ServiceRequest. 123 | """ 124 | return ServiceRequest(self) 125 | 126 | def make_call(self, method): 127 | """ 128 | Convenience method to create and call a simple ServiceRequest (no 129 | parameters). 130 | """ 131 | return self.request().call_service(method) 132 | 133 | def get_lrsaccount_service(self): 134 | return LrsAccountService(self) 135 | 136 | def get_application_service(self): 137 | return ApplicationService(self) 138 | 139 | def get_post_back_service(self): 140 | return PostbackService(self) 141 | 142 | class DebugService(object): 143 | """ 144 | Debugging and testing service that allows you to check the status of the 145 | SCORM Cloud and test your configuration settings. 146 | """ 147 | 148 | def __init__(self, service): 149 | self.service = service 150 | 151 | def ping(self): 152 | """ 153 | A simple ping that checks the connection to the SCORM Cloud. 154 | """ 155 | try: 156 | xmldoc = self.service.make_call('rustici.debug.ping') 157 | return xmldoc.documentElement.attributes['stat'].value == 'ok' 158 | except Exception: 159 | return False 160 | 161 | def authping(self): 162 | """ 163 | An authenticated ping that checks the connection to the SCORM Cloud 164 | and verifies the configured credentials. 165 | """ 166 | try: 167 | xmldoc = self.service.make_call('rustici.debug.authPing') 168 | return xmldoc.documentElement.attributes['stat'].value == 'ok' 169 | except Exception: 170 | return False 171 | 172 | class DispatchService(object): 173 | 174 | def __init__(self, service): 175 | self.service = service 176 | 177 | def get_dispatch_info(self, dispatchid): 178 | request = self.service.request() 179 | request.parameters['dispatchid'] = dispatchid 180 | 181 | result = request.call_service('rustici.dispatch.getDispatchInfo') 182 | 183 | return result 184 | 185 | def get_destination_info(self, destinationid): 186 | request = self.service.request() 187 | request.parameters['destinationid'] = dispatchid 188 | 189 | result = request.call_service('rustici.dispatch.getDestinationInfo') 190 | 191 | return result 192 | 193 | def download_dispatches(self, dispatchid=None, tags=None, destinationid=None, courseid=None): 194 | request = self.service.request() 195 | if dispatchid is not None: 196 | request.parameters['dispatchid'] = dispatchid 197 | if destinationid is not None: 198 | request.parameters['destinationid'] = destinationid 199 | if tags is not None: 200 | request.parameters['tags'] = tags 201 | if courseid is not None: 202 | request.parameters['courseid'] = courseid 203 | 204 | url = request.construct_url('rustici.dispatch.downloadDispatches') 205 | r = request.send_post(url, None) 206 | 207 | with open("dispatch.zip", "w") as f: 208 | f.write(r) 209 | 210 | return "success" 211 | 212 | class ApplicationService(object): 213 | """ 214 | Used for testing Nathan's Application web service changes 215 | """ 216 | 217 | def __init__(self, service): 218 | self.service = service 219 | 220 | def create_application(self, name): 221 | request = self.service.request() 222 | request.parameters['appid'] = self.service.config.appid 223 | request.parameters['name'] = name 224 | try: 225 | result = request.call_service('rustici.application.createApplication') 226 | result = ApplicationCallbackData.list_from_result(result) 227 | except urllib.error.HTTPError: 228 | logging.exception('failed to create application') 229 | 230 | return result 231 | 232 | def get_app_list(self): 233 | request = self.service.request() 234 | request.parameters['appid'] = self.service.config.appid 235 | result = "failed" 236 | try: 237 | result = request.call_service('rustici.application.getAppList') 238 | result = ApplicationCallbackData.list_from_list(result) 239 | except urllib.error.HTTPError: 240 | logging.exception('failed to get list') 241 | 242 | return result 243 | 244 | def get_app_info(self, childAppId): 245 | request = self.service.request() 246 | request.parameters['appid'] = self.service.config.appid 247 | request.parameters['childappid'] = childAppId 248 | result = "failed" 249 | try: 250 | result = request.call_service('rustici.application.getAppInfo') 251 | result = ApplicationCallbackData.list_from_result(result) 252 | except urllib.error.HTTPError: 253 | logging.exception('failed to get info') 254 | 255 | return result 256 | 257 | def update_app(self, childAppId, appName): 258 | request = self.service.request() 259 | request.parameters['appid'] = self.service.config.appid 260 | request.parameters['childappid'] = childAppId 261 | if(appName != ''): 262 | request.parameters['name'] = appName 263 | result = "failed" 264 | try: 265 | result = request.call_service('rustici.application.updateApplication') 266 | result = ApplicationCallbackData.list_from_result(result) 267 | except urllib.error.HTTPError: 268 | logging.exception('failed to update') 269 | 270 | return result 271 | 272 | class LrsAccountService(object): 273 | """ 274 | Used for testing LRSAccountService stuff 275 | """ 276 | 277 | def __init__(self, service): 278 | self.service = service 279 | 280 | def get_lrs_callback_url(self): 281 | request = self.service.request() 282 | request.parameters['appid'] = self.service.config.appid 283 | lrs = "Service call failed" 284 | try: 285 | result = request.call_service('rustici.lrsaccount.getAppLrsAuthCallbackUrl') 286 | lrs = LrsCallbackData.list_from_result(result) 287 | except urllib.error.HTTPError: 288 | logging.exception('failed') 289 | 290 | return lrs 291 | 292 | def get_reset_lrs_callback_url(self): 293 | request = self.service.request() 294 | request.parameters['appid'] = self.service.config.appid 295 | success = False 296 | try: 297 | result = request.call_service('rustici.lrsaccount.resetAppLrsAuthCallbackUrl') 298 | success = LrsCallbackData.get_success(result) 299 | except urllib.error.HTTPError: 300 | logging.exception('failed') 301 | 302 | return success 303 | 304 | def set_lrs_callback_url(self, lrsUrl): 305 | request = self.service.request() 306 | request.parameters['appid'] = self.service.config.appid 307 | request.parameters['lrsAuthCallbackUrl'] = lrsUrl 308 | success = False 309 | try: 310 | result = request.call_service('rustici.lrsaccount.setAppLrsAuthCallbackUrl'); 311 | success = LrsCallbackData.get_success(result) 312 | except urllib.error.HTTPError: 313 | logging.exception('failed') 314 | 315 | return success 316 | 317 | def edit_activity_provider(self, activityProviderId, appIds, permissionsLevel): 318 | request = self.service.request() 319 | request.parameters['appid'] = self.service.config.appid 320 | request.parameters['accountkey'] = activityProviderId 321 | if appIds != "": 322 | request.parameters['allowedendpoints'] = appIds 323 | if permissionsLevel != "": 324 | request.parameters['permissionslevel'] = permissionsLevel 325 | success = False 326 | try: 327 | result = request.call_service('rustici.lrsaccount.editActivityProvider') 328 | success = ActivityProviderCallbackData.activity_provider_from_result(result) 329 | except urllib.error.HTTPError: 330 | logging.exception('failed') 331 | 332 | return success 333 | 334 | def list_activity_providers(self): 335 | request = self.service.request() 336 | request.parameters['appid'] = self.service.config.appid 337 | success = False 338 | try: 339 | result = request.call_service('rustici.lrsaccount.listActivityProviders') 340 | success = ActivityProviderCallbackData.activity_providers_from_result(result) 341 | except urllib.error.HTTPError: 342 | logging.exception('failed') 343 | except KeyError: 344 | logging.exception('key error fail') 345 | 346 | return success 347 | 348 | class UploadService(object): 349 | """ 350 | Service that provides functionality to upload files to the SCORM Cloud. 351 | """ 352 | 353 | def __init__(self, service): 354 | self.service = service 355 | 356 | def get_upload_token(self): 357 | """ 358 | Retrieves an upload token which must be used to successfully upload a 359 | file. 360 | """ 361 | xmldoc = self.service.make_call('rustici.upload.getUploadToken') 362 | serverNodes = xmldoc.getElementsByTagName('server') 363 | tokenidNodes = xmldoc.getElementsByTagName('id') 364 | server = None 365 | for s in serverNodes: 366 | server = s.childNodes[0].nodeValue 367 | tokenid = None 368 | for t in tokenidNodes: 369 | tokenid = t.childNodes[0].nodeValue 370 | if server and tokenid: 371 | token = UploadToken(server,tokenid) 372 | return token 373 | else: 374 | return None 375 | 376 | def get_upload_url(self, callbackurl): 377 | """ 378 | Returns a URL that can be used to upload a file via HTTP POST, through 379 | an HTML form element action, for example. 380 | """ 381 | token = self.get_upload_token() 382 | if token: 383 | request = self.service.request() 384 | request.parameters['tokenid'] = token.tokenid 385 | request.parameters['redirecturl'] = callbackurl 386 | return request.construct_url('rustici.upload.uploadFile') 387 | else: 388 | return None 389 | 390 | def delete_file(self, location): 391 | """ 392 | Deletes the specified file. 393 | """ 394 | locParts = location.split("/") 395 | request = self.service.request() 396 | request.parameters['file'] = locParts[len(locParts) - 1] 397 | return request.call_service('rustici.upload.deleteFiles') 398 | 399 | def get_upload_progress(self, token): 400 | request = self.service.request() 401 | request.parameters['token'] = token 402 | return request.call_service('rustici.upload.getUploadProgress') 403 | 404 | 405 | class CourseService(object): 406 | """ 407 | Service that provides methods to manage and interact with courses on the 408 | SCORM Cloud. These methods correspond to the "rustici.course.*" web service 409 | methods. 410 | """ 411 | 412 | def __init__(self, service): 413 | self.service = service 414 | 415 | def exists(self, courseid): 416 | request = self.service.request() 417 | request.parameters['courseid'] = courseid 418 | 419 | result = request.call_service('rustici.course.exists') 420 | 421 | return result 422 | 423 | def import_course(self, courseid, file_handle): 424 | request = self.service.request() 425 | request.parameters['courseid'] = courseid 426 | 427 | files = { 'file': file_handle } 428 | 429 | url = request.construct_url('rustici.course.importCourse') 430 | res = requests.post(url, files=files) 431 | 432 | xmldoc = request.get_xml(res.content) 433 | return ImportResult.list_from_result(xmldoc) 434 | 435 | def import_course_async(self, courseid, file_handle): 436 | request = self.service.request() 437 | request.parameters['courseid'] = courseid 438 | 439 | files = { 'file': file_handle } 440 | 441 | url = request.construct_url('rustici.course.importCourseAsync') 442 | res = requests.post(url, files=files) 443 | 444 | xmldoc = request.get_xml(res.content) 445 | return xmldoc.getElementsByTagName('id')[0].childNodes[0].nodeValue 446 | 447 | def get_async_import_result(self, token): 448 | request = self.service.request() 449 | request.parameters['token'] = token 450 | 451 | url = request.construct_url('rustici.course.getAsyncImportResult') 452 | res = requests.post(url) 453 | 454 | xmldoc = request.get_xml(res.content) 455 | return AsyncImportResult.result_from_xmldoc(xmldoc) 456 | 457 | def import_uploaded_course(self, courseid, path): 458 | """ 459 | Imports a SCORM PIF (zip file) from an existing zip file on the SCORM 460 | Cloud server. 461 | 462 | Arguments: 463 | courseid -- the unique identifier for the course 464 | path -- the relative path to the zip file to import 465 | """ 466 | request = self.service.request() 467 | request.parameters['courseid'] = courseid 468 | request.parameters['path'] = path 469 | result = request.call_service('rustici.course.importCourse') 470 | ir = ImportResult.list_from_result(result) 471 | return ir 472 | 473 | def delete_course(self, courseid): 474 | """ 475 | Deletes the specified course. 476 | 477 | Arguments: 478 | courseid -- the unique identifier for the course 479 | """ 480 | request = self.service.request() 481 | request.parameters['courseid'] = courseid 482 | return request.call_service('rustici.course.deleteCourse') 483 | 484 | def get_assets(self, courseid, pathToSave, path=None): 485 | """ 486 | Downloads a file from a course by path. If no path is provided, all the 487 | course files will be downloaded contained in a zip file. 488 | 489 | Arguments: 490 | courseid -- the unique identifier for the course 491 | pathToSave -- the path where the downloaded content should be saved. 492 | If not provided or is None, the file will be saved at the location of this file. 493 | path -- the path (relative to the course root) of the file to download. 494 | If not provided or is None, all course files will be downloaded. 495 | """ 496 | request = self.service.request() 497 | request.parameters['courseid'] = courseid 498 | if (path is not None): 499 | request.parameters['path'] = path 500 | request.download_file('rustici.course.getAssets', pathToSave) 501 | 502 | def get_course_list(self, courseIdFilterRegex=None): 503 | """ 504 | Retrieves a list of CourseData elements for all courses owned by the 505 | configured AppID that meet the specified filter criteria. 506 | 507 | Arguments: 508 | courseIdFilterRegex -- (optional) Regular expression to filter courses 509 | by ID 510 | """ 511 | request = self.service.request() 512 | if courseIdFilterRegex is not None: 513 | request.parameters['filter'] = courseIdFilterRegex 514 | result = request.call_service('rustici.course.getCourseList') 515 | courses = CourseData.list_from_result(result) 516 | return courses 517 | 518 | def get_preview_url(self, courseid, redirecturl, versionid=None, stylesheeturl=None): 519 | """ 520 | Gets the URL that can be opened to preview the course without the need 521 | for a registration. 522 | 523 | Arguments: 524 | courseid -- the unique identifier for the course 525 | redirecturl -- the URL to which the browser should redirect upon course 526 | exit 527 | stylesheeturl -- the URL for the CSS stylesheet to include 528 | """ 529 | request = self.service.request() 530 | request.parameters['courseid'] = courseid 531 | request.parameters['redirecturl'] = redirecturl 532 | if stylesheeturl is not None: 533 | request.parameters['stylesheet'] = stylesheeturl 534 | if versionid is not None: 535 | request.parameters['versionid'] = str(versionid) 536 | url = request.construct_url('rustici.course.preview') 537 | logging.info('preview link: '+ url) 538 | return url 539 | 540 | def get_metadata(self, courseid): 541 | """ 542 | Gets the course metadata in XML format. 543 | 544 | Arguments: 545 | courseid -- the unique identifier for the course 546 | """ 547 | request = self.service.request() 548 | request.parameters['courseid'] = courseid 549 | return request.call_service('rustici.course.getMetadata') 550 | 551 | def get_property_editor_url(self, courseid, stylesheetUrl=None, 552 | notificationFrameUrl=None): 553 | """ 554 | Gets the URL to view/edit the package properties for the course. 555 | Typically used within an IFRAME element. 556 | 557 | Arguments: 558 | courseid -- the unique identifier for the course 559 | stylesheeturl -- URL to a custom editor stylesheet 560 | notificationFrameUrl -- Tells the property editor to render a sub-iframe 561 | with this URL as the source. This can be used to simulate an 562 | "onload" by using a notificationFrameUrl that is on the same domain 563 | as the host system and calling parent.parent.method() 564 | """ 565 | request = self.service.request() 566 | request.parameters['courseid'] = courseid 567 | if stylesheetUrl is not None: 568 | request.parameters['stylesheet'] = stylesheetUrl 569 | if notificationFrameUrl is not None: 570 | request.parameters['notificationframesrc'] = notificationFrameUrl 571 | 572 | url = request.construct_url('rustici.course.properties') 573 | logging.info('properties link: '+url) 574 | return url 575 | 576 | def get_attributes(self, courseid): 577 | """ 578 | Retrieves the list of associated attributes for the course. 579 | 580 | Arguments: 581 | courseid -- the unique identifier for the course 582 | versionid -- the specific version of the course 583 | """ 584 | request = self.service.request() 585 | request.parameters['courseid'] = courseid 586 | xmldoc = request.call_service('rustici.course.getAttributes') 587 | 588 | attrNodes = xmldoc.getElementsByTagName('attribute') 589 | atts = {} 590 | for an in attrNodes: 591 | atts[an.attributes['name'].value] = an.attributes['value'].value 592 | return atts 593 | 594 | def update_attributes(self, courseid, attributePairs): 595 | """ 596 | Updates the specified attributes for the course. 597 | 598 | Arguments: 599 | courseid -- the unique identifier for the course 600 | attributePairs -- the attribute name/value pairs to update 601 | """ 602 | request = self.service.request() 603 | request.parameters['courseid'] = courseid 604 | for (key, value) in attributePairs.items(): 605 | request.parameters[key] = value 606 | xmldoc = request.call_service('rustici.course.updateAttributes') 607 | 608 | attrNodes = xmldoc.getElementsByTagName('attribute') 609 | atts = {} 610 | for an in attrNodes: 611 | atts[an.attributes['name'].value] = an.attributes['value'].value 612 | return atts 613 | 614 | class PostbackService(object): 615 | def __init__(self, service): 616 | self.service = service 617 | 618 | def get_postback_info(self, appid, regid): 619 | request = self.service.request() 620 | request.parameters['appid'] = appid 621 | request.parameters['regid'] = regid 622 | 623 | xmldoc = request.call_service('rustici.registration.getPostbackInfo') 624 | pbd = PostbackData(xmldoc) 625 | 626 | logging.debug('postback registration id: %s', pbd.registrationId) 627 | 628 | return pbd 629 | 630 | class RegistrationService(object): 631 | """ 632 | Service that provides methods for managing and interacting with 633 | registrations on the SCORM Cloud. These methods correspond to the 634 | "rustici.registration.*" web service methods. 635 | """ 636 | 637 | def __init__(self, service): 638 | self.service = service 639 | 640 | def test_registration_post(self,authtype, postbackurl,urlname, passw): 641 | request = self.service.request() 642 | request.parameters['authtype'] = authtype 643 | request.parameters['postbackurl'] = postbackurl 644 | request.parameters['urlname'] = urlname 645 | request.parameters['urlpass'] = passw 646 | return request.call_service('rustici.registration.testRegistrationPostUrl') 647 | 648 | def update_postback_info(self, regid, url, authtype='', user='', password='', resultsformat='course'): 649 | request = self.service.request() 650 | request.parameters['regid'] = regid 651 | request.parameters['url'] = url 652 | request.parameters['authtype'] = authtype 653 | request.parameters['name'] = user 654 | request.parameters['password'] = password 655 | request.parameters['resultsformat'] = resultsformat 656 | return request.call_service('rustici.registration.updatePostbackInfo') 657 | 658 | def create_registration(self, regid, courseid, userid, fname, lname, postbackUrl=None, 659 | email=None, learnerTags=None, courseTags=None, registrationTags=None): 660 | """ 661 | Creates a new registration (an instance of a user taking a course). 662 | 663 | Arguments: 664 | regid -- the unique identifier for the registration 665 | courseid -- the unique identifier for the course 666 | userid -- the unique identifier for the learner 667 | fname -- the learner's first name 668 | lname -- the learner's last name 669 | email -- the learner's email address 670 | """ 671 | if regid is None: 672 | regid = str(uuid.uuid1()) 673 | request = self.service.request() 674 | request.parameters['appid'] = self.service.config.appid 675 | request.parameters['courseid'] = courseid 676 | request.parameters['regid'] = regid 677 | if fname is not None: 678 | request.parameters['fname'] = fname 679 | if lname is not None: 680 | request.parameters['lname'] = lname 681 | request.parameters['learnerid'] = userid 682 | if email is not None: 683 | request.parameters['email'] = email 684 | if learnerTags is not None: 685 | request.parameters['learnerTags'] = learnerTags 686 | if courseTags is not None: 687 | request.parameters['courseTags'] = courseTags 688 | if registrationTags is not None: 689 | request.parameters['registrationTags'] = registrationTags 690 | if postbackUrl is not None: 691 | request.parameters['postbackurl'] = postbackUrl 692 | xmldoc = request.call_service('rustici.registration.createRegistration') 693 | successNodes = xmldoc.getElementsByTagName('success') 694 | if successNodes.length == 0: 695 | raise ScormCloudError("Create Registration failed. " + 696 | xmldoc.err.attributes['msg']) 697 | return regid 698 | 699 | def get_launch_url(self, regid, redirecturl, cssUrl=None, courseTags=None, 700 | learnerTags=None, registrationTags=None): 701 | """ 702 | Gets the URL to directly launch the course in a web browser. 703 | 704 | Arguments: 705 | regid -- the unique identifier for the registration 706 | redirecturl -- the URL to which the SCORM player will redirect upon 707 | course exit 708 | cssUrl -- the URL to a custom stylesheet 709 | courseTags -- comma-delimited list of tags to associate with the 710 | launched course 711 | learnerTags -- comma-delimited list of tags to associate with the 712 | learner launching the course 713 | registrationTags -- comma-delimited list of tags to associate with the 714 | launched registration 715 | """ 716 | request = self.service.request() 717 | request.parameters['regid'] = regid 718 | request.parameters['redirecturl'] = redirecturl + '?regid=' + regid 719 | if cssUrl is not None: 720 | request.parameters['cssurl'] = cssUrl 721 | if courseTags is not None: 722 | request.parameters['coursetags'] = courseTags 723 | if learnerTags is not None: 724 | request.parameters['learnertags'] = learnerTags 725 | if registrationTags is not None: 726 | request.parameters['registrationTags'] = registrationTags 727 | url = request.construct_url('rustici.registration.launch') 728 | return url 729 | 730 | def get_registration_list(self, regIdFilterRegex=None, 731 | courseIdFilterRegex=None): 732 | """ 733 | Retrieves a list of registration associated with the configured AppID. 734 | Can optionally be filtered by registration or course ID. 735 | 736 | Arguments: 737 | regIdFilterRegex -- (optional) the regular expression used to filter the 738 | list by registration ID 739 | courseIdFilterRegex -- (optional) the regular expression used to filter 740 | the list by course ID 741 | """ 742 | request = self.service.request() 743 | if regIdFilterRegex is not None: 744 | request.parameters['filter'] = regIdFilterRegex 745 | if courseIdFilterRegex is not None: 746 | request.parameters['coursefilter'] = courseIdFilterRegex 747 | 748 | result = request.call_service( 749 | 'rustici.registration.getRegistrationList') 750 | regs = RegistrationData.list_from_result(result) 751 | return regs 752 | 753 | def get_registration_result(self, regid, resultsformat): 754 | """ 755 | Gets information about the specified registration. 756 | 757 | Arguments: 758 | regid -- the unique identifier for the registration 759 | resultsformat -- (optional) can be "course", "activity", or "full" to 760 | determine the level of detail returned. The default is "course" 761 | """ 762 | request = self.service.request() 763 | request.parameters['regid'] = regid 764 | request.parameters['resultsformat'] = resultsformat 765 | return request.call_service( 766 | 'rustici.registration.getRegistrationResult') 767 | 768 | def get_registration_detail(self, regid): 769 | """ 770 | This method will return some detail for the registration specified with 771 | the given appid and registrationid, including information regarding 772 | registration instances. 773 | 774 | Arguments: 775 | regid -- the unique identifier for the registration 776 | """ 777 | request = self.service.request() 778 | request.parameters['regid'] = regid 779 | return request.call_service( 780 | 'rustici.registration.getRegistrationDetail') 781 | 782 | def get_launch_history(self, regid): 783 | """ 784 | Retrieves a list of LaunchInfo objects describing each launch. These 785 | LaunchInfo objects do not contain the full launch history log; use 786 | get_launch_info to retrieve the full launch information. 787 | 788 | Arguments: 789 | regid -- the unique identifier for the registration 790 | """ 791 | request = self.service.request() 792 | request.parameters['regid'] = regid 793 | return request.call_service('rustici.registration.getLaunchHistory') 794 | 795 | def get_launch_info(self, launchid): 796 | request = self.service.request() 797 | request.parameters['launchid'] = launchid 798 | return request.call_service('rustici.registration.getLaunchInfo') 799 | 800 | def reset_registration(self, regid): 801 | """ 802 | Resets all status data for the specified registration, essentially 803 | restarting the course for the associated learner. 804 | 805 | Arguments: 806 | regid -- the unique identifier for the registration 807 | """ 808 | request = self.service.request() 809 | request.parameters['regid'] = regid 810 | return request.call_service('rustici.registration.resetRegistration') 811 | 812 | def exists(self, regid): 813 | """ 814 | Checks if a registration exists 815 | 816 | Arguments: 817 | regid -- the unique identifier for the registration 818 | """ 819 | request = self.service.request() 820 | request.parameters['regid'] = regid 821 | return request.call_service('rustici.registration.exists') 822 | 823 | def reset_global_objectives(self, regid): 824 | """ 825 | Clears global objective data for the specified registration. 826 | 827 | Arguments: 828 | regid -- the unique identifier for the registration 829 | """ 830 | request = self.service.request() 831 | request.parameters['regid'] = regid 832 | return request.call_service( 833 | 'rustici.registration.resetGlobalObjectives') 834 | 835 | def delete_registration(self, regid): 836 | """ 837 | Deletes the specified registration. 838 | 839 | Arguments: 840 | regid -- the unique identifier for the registration 841 | """ 842 | request = self.service.request() 843 | request.parameters['regid'] = regid 844 | return request.call_service('rustici.registration.deleteRegistration') 845 | 846 | 847 | class InvitationService(object): 848 | 849 | def __init__(self, service): 850 | self.service = service 851 | 852 | 853 | def create_invitation(self,courseid,tags=None,publicInvitation='true',send='true',addresses=None,emailSubject=None,emailBody=None,creatingUserEmail=None, 854 | registrationCap=None,postbackUrl=None,authType=None,urlName=None,urlPass=None,resultsFormat=None,async=False): 855 | request = self.service.request() 856 | 857 | request.parameters['courseid'] = courseid 858 | request.parameters['send'] = send 859 | request.parameters['public'] = publicInvitation 860 | 861 | if addresses is not None: 862 | request.parameters['addresses'] = addresses 863 | if emailSubject is not None: 864 | request.parameters['emailSubject'] = emailSubject 865 | if emailBody is not None: 866 | request.parameters['emailBody'] = emailBody 867 | if creatingUserEmail is not None: 868 | request.parameters['creatingUserEmail'] = creatingUserEmail 869 | if registrationCap is not None: 870 | request.parameters['registrationCap'] = registrationCap 871 | if postbackUrl is not None: 872 | request.parameters['postbackUrl'] = postbackUrl 873 | if authType is not None: 874 | request.parameters['authType'] = authType 875 | if urlName is not None: 876 | request.parameters['urlName'] = urlName 877 | if urlPass is not None: 878 | request.parameters['urlPass'] = urlPass 879 | if resultsFormat is not None: 880 | request.parameters['resultsFormat'] = resultsFormat 881 | if tags is not None: 882 | request.parameters['tags'] = tags 883 | 884 | if async: 885 | data = request.call_service('rustici.invitation.createInvitationAsync') 886 | else: 887 | data = request.call_service('rustici.invitation.createInvitation') 888 | 889 | return data 890 | 891 | def get_invitation_list(self, filter=None,coursefilter=None): 892 | request = self.service.request() 893 | if filter is not None: 894 | request.parameters['filter'] = filter 895 | if coursefilter is not None: 896 | request.parameters['coursefilter'] = coursefilter 897 | data = request.call_service('rustici.invitation.getInvitationList') 898 | return data 899 | 900 | def get_invitation_status(self, invitationId): 901 | request = self.service.request() 902 | request.parameters['invitationId'] = invitationId 903 | data = request.call_service('rustici.invitation.getInvitationStatus') 904 | return data 905 | 906 | def get_invitation_info(self, invitationId,detail=None): 907 | request = self.service.request() 908 | request.parameters['invitationId'] = invitationId 909 | if detail is not None: 910 | request.parameters['detail'] = detail 911 | data = request.call_service('rustici.invitation.getInvitationInfo') 912 | return data 913 | 914 | def change_status(self, invitationId,enable,open=None): 915 | request = self.service.request() 916 | request.parameters['invitationId'] = invitationId 917 | request.parameters['enable'] = enable 918 | if open is not None: 919 | request.parameters['open'] = open 920 | data = request.call_service('rustici.invitation.changeStatus') 921 | return data 922 | 923 | 924 | 925 | class ReportingService(object): 926 | """ 927 | Service that provides methods for interacting with the Reportage service. 928 | """ 929 | 930 | def __init__(self, service): 931 | self.service = service 932 | 933 | def get_reportage_date(self): 934 | """ 935 | Gets the date/time, according to Reportage. 936 | """ 937 | reportUrl = (self._get_reportage_service_url() + 938 | 'Reportage/scormreports/api/getReportDate.php?appId=' + 939 | self.service.config.appid) 940 | ctx = ssl.create_default_context() 941 | ctx.check_hostname = False 942 | ctx.verify_mode = ssl.CERT_NONE 943 | cloudsocket = urllib.request.urlopen(reportUrl, None, 2000, context=ctx) 944 | reply = cloudsocket.read() 945 | cloudsocket.close() 946 | d = datetime.datetime 947 | return d.strptime(reply,"%Y-%m-%d %H:%M:%S") 948 | 949 | def get_reportage_auth(self, navperm, allowadmin): 950 | """ 951 | Authenticates against the Reportage application, returning a session 952 | string used to make subsequent calls to launchReport. 953 | 954 | Arguments: 955 | navperm -- the Reportage navigation permissions to assign to the 956 | session. If "NONAV", the session will be prevented from navigating 957 | away from the original report/widget. "DOWNONLY" allows the user to 958 | drill down into additional detail. "FREENAV" allows the user full 959 | navigation privileges and the ability to change any reporting 960 | parameter. 961 | allowadmin -- if True, the Reportage session will have admin privileges 962 | """ 963 | request = self.service.request() 964 | request.parameters['navpermission'] = navperm 965 | request.parameters['admin'] = 'true' if allowadmin else 'false' 966 | xmldoc = request.call_service('rustici.reporting.getReportageAuth') 967 | token = xmldoc.getElementsByTagName('auth') 968 | if token.length > 0: 969 | return token[0].childNodes[0].nodeValue 970 | else: 971 | return None 972 | 973 | def _get_reportage_service_url(self): 974 | """ 975 | Returns the base Reportage URL. 976 | """ 977 | return self.service.config.serviceurl.replace('EngineWebServices','') 978 | 979 | def _get_base_reportage_url(self): 980 | return (self._get_reportage_service_url() + 'Reportage/reportage.php' + 981 | '?appId=' + self.service.config.appid) 982 | 983 | def get_report_url(self, auth, reportUrl): 984 | """ 985 | Returns an authenticated URL that can launch a Reportage session at 986 | the specified Reportage entry point. 987 | 988 | Arguments: 989 | auth -- the Reportage authentication string, as retrieved from 990 | get_reportage_auth 991 | reportUrl -- the URL to the desired Reportage entry point 992 | """ 993 | request = self.service.request() 994 | request.parameters['auth'] = auth 995 | request.parameters['reporturl'] = reportUrl 996 | url = request.construct_url('rustici.reporting.launchReport') 997 | return url 998 | 999 | def get_reportage_url(self, auth): 1000 | """ 1001 | Returns the authenticated URL to the main Reportage entry point. 1002 | 1003 | Arguments: 1004 | auth -- the Reportage authentication string, as retrieved from 1005 | get_reportage_auth 1006 | """ 1007 | reporturl = self._get_base_reportage_url() 1008 | return self.get_report_url(auth, reporturl) 1009 | 1010 | def get_course_reportage_url(self, auth, courseid): 1011 | reporturl = self._get_base_reportage_url() + '&courseid=' + courseid 1012 | return self.get_report_url(auth, reporturl) 1013 | 1014 | def get_widget_url(self, auth, widgettype, widgetSettings): 1015 | """ 1016 | Gets the URL to a specific Reportage widget, using the provided 1017 | widget settings. 1018 | 1019 | Arguments: 1020 | auth -- the Reportage authentication string, as retrieved from 1021 | get_reportage_auth 1022 | widgettype -- the widget type desired (for example, learnerSummary) 1023 | widgetSettings -- the WidgetSettings object for the widget type 1024 | """ 1025 | reportUrl = (self._get_reportage_service_url() + 1026 | 'Reportage/scormreports/widgets/') 1027 | widgetUrlTypeLib = { 1028 | 'allSummary':'summary/SummaryWidget.php?srt=allLearnersAllCourses', 1029 | 'courseSummary':'summary/SummaryWidget.php?srt=singleCourse', 1030 | 'learnerSummary':'summary/SummaryWidget.php?srt=singleLearner', 1031 | 'learnerCourse':'summary/SummaryWidget.php?srt=' 1032 | 'singleLearnerSingleCourse', 1033 | 'courseActivities':'DetailsWidget.php?drt=courseActivities', 1034 | 'learnerRegistration':'DetailsWidget.php?drt=learnerRegistration', 1035 | 'courseComments':'DetailsWidget.php?drt=courseComments', 1036 | 'learnerComments':'DetailsWidget.php?drt=learnerComments', 1037 | 'courseInteractions':'DetailsWidget.php?drt=courseInteractions', 1038 | 'learnerInteractions':'DetailsWidget.php?drt=learnerInteractions', 1039 | 'learnerActivities':'DetailsWidget.php?drt=learnerActivities', 1040 | 'courseRegistration':'DetailsWidget.php?drt=courseRegistration', 1041 | 'learnerCourseActivities':'DetailsWidget.php?drt=' 1042 | 'learnerCourseActivities', 1043 | 'learnerTranscript':'DetailsWidget.php?drt=learnerTranscript', 1044 | 'learnerCourseInteractions':'DetailsWidget.php?drt=' 1045 | 'learnerCourseInteractions', 1046 | 'learnerCourseComments':'DetailsWidget.php?drt=' 1047 | 'learnerCourseComments', 1048 | 'allLearners':'ViewAllDetailsWidget.php?viewall=learners', 1049 | 'allCourses':'ViewAllDetailsWidget.php?viewall=courses'} 1050 | reportUrl += widgetUrlTypeLib[widgettype] 1051 | reportUrl += '&appId='+self.service.config.appid 1052 | reportUrl += widgetSettings.get_url_encoding() 1053 | reportUrl = self.get_report_url(auth, reportUrl) 1054 | return reportUrl 1055 | 1056 | 1057 | class WidgetSettings(object): 1058 | def __init__(self, dateRangeSettings, tagSettings): 1059 | self.dateRangeSettings = dateRangeSettings 1060 | self.tagSettings = tagSettings 1061 | 1062 | self.courseId = None 1063 | self.learnerId = None 1064 | 1065 | self.showTitle = True; 1066 | self.vertical = False; 1067 | self.public = True; 1068 | self.standalone = True; 1069 | self.iframe = False; 1070 | self.expand = True; 1071 | self.scriptBased = True; 1072 | self.divname = ''; 1073 | self.embedded = True; 1074 | self.viewall = True; 1075 | self.export = True; 1076 | 1077 | 1078 | def get_url_encoding(self): 1079 | """ 1080 | Returns the widget settings as encoded URL parameters to add to a 1081 | Reportage widget URL. 1082 | """ 1083 | widgetUrlStr = ''; 1084 | if self.courseId is not None: 1085 | widgetUrlStr += '&courseId=' + self.courseId 1086 | if self.learnerId is not None: 1087 | widgetUrlStr += '&learnerId=' + self.learnerId 1088 | 1089 | widgetUrlStr += '&showTitle=' + 'self.showTitle'.lower() 1090 | widgetUrlStr += '&standalone=' + 'self.standalone'.lower() 1091 | if self.iframe: 1092 | widgetUrlStr += '&iframe=true' 1093 | widgetUrlStr += '&expand=' + 'self.expand'.lower() 1094 | widgetUrlStr += '&scriptBased=' + 'self.scriptBased'.lower() 1095 | widgetUrlStr += '&divname=' + self.divname 1096 | widgetUrlStr += '&vertical=' + 'self.vertical'.lower() 1097 | widgetUrlStr += '&embedded=' + 'self.embedded'.lower() 1098 | 1099 | if self.dateRangeSettings is not None: 1100 | widgetUrlStr += self.dateRangeSettings.get_url_encoding() 1101 | 1102 | if self.tagSettings is not None: 1103 | widgetUrlStr += self.tagSettings.get_url_encoding() 1104 | 1105 | return widgetUrlStr 1106 | 1107 | 1108 | class DateRangeSettings(object): 1109 | def __init__(self, dateRangeType, dateRangeStart, 1110 | dateRangeEnd, dateCriteria): 1111 | self.dateRangeType=dateRangeType 1112 | self.dateRangeStart=dateRangeStart 1113 | self.dateRangeEnd=dateRangeEnd 1114 | self.dateCriteria=dateCriteria 1115 | 1116 | def get_url_encoding(self): 1117 | """ 1118 | Returns the DateRangeSettings as encoded URL parameters to add to a 1119 | Reportage widget URL. 1120 | """ 1121 | dateRangeStr = '' 1122 | if self.dateRangeType == 'selection': 1123 | dateRangeStr +='&dateRangeType=c' 1124 | dateRangeStr +='&dateRangeStart=' + self.dateRangeStart 1125 | dateRangeStr +='&dateRangeEnd=' + self.dateRangeEnd 1126 | else: 1127 | dateRangeStr +='&dateRangeType=' + self.dateRangeType 1128 | 1129 | dateRangeStr += '&dateCriteria=' + self.dateCriteria 1130 | return dateRangeStr 1131 | 1132 | class TagSettings(object): 1133 | def __init__(self): 1134 | self.tags = {'course':[],'learner':[],'registration':[]} 1135 | 1136 | def add(self, tagType, tagValue): 1137 | self.tags[tagType].append(tagValue) 1138 | 1139 | def get_tag_str(self, tagType): 1140 | return ','.join(set(self.tags[tagType])) + "|_all" 1141 | 1142 | def get_view_tag_str(self, tagType): 1143 | return ','.join(set(self.tags[tagType])) 1144 | 1145 | def get_url_encoding(self): 1146 | tagUrlStr = '' 1147 | for k in list(self.tags.keys()): 1148 | if len(set(self.tags[k])) > 0: 1149 | tagUrlStr += '&' + k + 'Tags=' + self.get_tag_str(k) 1150 | tagUrlStr += ('&view' + k.capitalize() + 'TagGroups=' + 1151 | self.get_view_tag_str(k)) 1152 | return tagUrlStr 1153 | 1154 | 1155 | class ScormCloudError(Exception): 1156 | def __init__(self, msg, json=None): 1157 | self.msg = msg 1158 | self.json = json 1159 | def __str__(self): 1160 | return repr(self.msg) 1161 | 1162 | class AsyncImportResult(object): 1163 | def __init__(self, status, message, progress, import_results): 1164 | self.status = status 1165 | self.message = message 1166 | self.progress = progress 1167 | self.import_results = import_results 1168 | 1169 | def __repr__(self): 1170 | return ("AsyncImportResult(status='{}', message='{}', progress='{}', " + 1171 | "import_results={})").format(self.status, self.message, 1172 | self.progress, self.import_results) 1173 | 1174 | @classmethod 1175 | def result_from_xmldoc(cls, xmldoc): 1176 | status = xmldoc.getElementsByTagName('status')[0].childNodes[0].nodeValue 1177 | message = xmldoc.getElementsByTagName('message')[0].childNodes[0].nodeValue 1178 | progress = xmldoc.getElementsByTagName('progress')[0].childNodes[0].nodeValue 1179 | 1180 | import_results = ImportResult.list_from_result(xmldoc) 1181 | 1182 | return cls(status, message, progress, import_results) 1183 | 1184 | 1185 | class ImportResult(object): 1186 | wasSuccessful = False 1187 | title = "" 1188 | message = "" 1189 | parserWarnings = [] 1190 | 1191 | def __init__(self, importResultElement): 1192 | if importResultElement is not None: 1193 | self.wasSuccessful = (importResultElement.attributes['successful'] 1194 | .value == 'true') 1195 | self.title = (importResultElement.getElementsByTagName("title")[0] 1196 | .childNodes[0].nodeValue) 1197 | self.message = (importResultElement 1198 | .getElementsByTagName("message")[0] 1199 | .childNodes[0].nodeValue) 1200 | xmlpw = importResultElement.getElementsByTagName("warning") 1201 | for pw in xmlpw: 1202 | self.parserWarnings.append(pw.childNodes[0].nodeValue) 1203 | 1204 | def __repr__(self): 1205 | return ("ImportResult(wasSuccessful={}, title='{}', message='{}', " + 1206 | "parserWarnings={})").format(self.wasSuccessful, self.title, 1207 | self.message, self.parserWarnings) 1208 | 1209 | @classmethod 1210 | def list_from_result(cls, xmldoc): 1211 | """ 1212 | Returns a list of ImportResult objects by parsing the raw result of an 1213 | API method that returns importresult elements. 1214 | 1215 | Arguments: 1216 | data -- the raw result of the API method 1217 | """ 1218 | allResults = []; 1219 | importresults = xmldoc.getElementsByTagName("importresult") 1220 | for ir in importresults: 1221 | allResults.append(cls(ir)) 1222 | return allResults 1223 | 1224 | class CourseData(object): 1225 | courseId = "" 1226 | numberOfVersions = 1 1227 | numberOfRegistrations = 0 1228 | title = "" 1229 | 1230 | def __init__(self, courseDataElement): 1231 | if courseDataElement is not None: 1232 | self.courseId = courseDataElement.attributes['id'].value 1233 | self.numberOfVersions = (courseDataElement.attributes['versions'] 1234 | .value) 1235 | self.numberOfRegistrations = (courseDataElement 1236 | .attributes['registrations'].value) 1237 | self.title = courseDataElement.attributes['title'].value; 1238 | 1239 | @classmethod 1240 | def list_from_result(cls, xmldoc): 1241 | """ 1242 | Returns a list of CourseData objects by parsing the raw result of an 1243 | API method that returns course elements. 1244 | 1245 | Arguments: 1246 | data -- the raw result of the API method 1247 | """ 1248 | allResults = []; 1249 | courses = xmldoc.getElementsByTagName("course") 1250 | for course in courses: 1251 | allResults.append(cls(course)) 1252 | return allResults 1253 | 1254 | class UploadToken(object): 1255 | server = "" 1256 | tokenid = "" 1257 | def __init__(self, server, tokenid): 1258 | self.server = server 1259 | self.tokenid = tokenid 1260 | 1261 | class PostbackData(object): 1262 | registrationId = "" 1263 | 1264 | @classmethod 1265 | def list_from_result(cls, xmldoc): 1266 | """ 1267 | Returns a list of RegistrationData objects by parsing the result of an 1268 | API method that returns registration elements. 1269 | 1270 | Arguments: 1271 | data -- the raw result of the API method 1272 | """ 1273 | allResults = []; 1274 | regs = xmldoc.getElementsByTagName("registration") 1275 | for reg in regs: 1276 | allResults.append(cls(reg)) 1277 | return allResults 1278 | 1279 | class RegistrationData(object): 1280 | courseId = "" 1281 | registrationId = "" 1282 | 1283 | def __init__(self, regDataElement): 1284 | if regDataElement is not None: 1285 | self.courseId = regDataElement.attributes['courseid'].value 1286 | self.registrationId = regDataElement.attributes['id'].value 1287 | 1288 | @classmethod 1289 | def list_from_result(cls, xmldoc): 1290 | """ 1291 | Returns a list of RegistrationData objects by parsing the result of an 1292 | API method that returns registration elements. 1293 | 1294 | Arguments: 1295 | data -- the raw result of the API method 1296 | """ 1297 | allResults = []; 1298 | regs = xmldoc.getElementsByTagName("registration") 1299 | for reg in regs: 1300 | allResults.append(cls(reg)) 1301 | return allResults 1302 | 1303 | class ActivityProviderCallbackData(object): 1304 | @classmethod 1305 | def activity_provider_from_result(cls, xmldoc): 1306 | allResults = [] 1307 | ret = "Could not retrieve data" 1308 | url = xmldoc.getElementsByTagName("activityProvider") 1309 | try: 1310 | for reg in url: 1311 | id = reg.getElementsByTagName("id")[0].firstChild.nodeValue 1312 | allowedEndpoints = "" 1313 | permissionsLevel = "" 1314 | 1315 | try: 1316 | allowedEndpoints = reg.getElementsByTagName("allowedEndpoints")[0].firstChild.nodeValue 1317 | except AttributeError: 1318 | allowedEndpoints = "None" 1319 | try: 1320 | permissionsLevel = reg.getElementsByTagName("permissionsLevel")[0].firstChild.nodeValue 1321 | except AttributeError: 1322 | permissionsLevel = "None" 1323 | allResults.append(ActivityProviderData(id, allowedEndpoints, permissionsLevel)) 1324 | ret = allResults[0] 1325 | except IndexError: 1326 | logging.exception('index error') 1327 | return ret 1328 | 1329 | @classmethod 1330 | def activity_providers_from_result(cls, xmldoc): 1331 | allResults = [] 1332 | ret = "Could not retrieve data" 1333 | accountProviderList = xmldoc.getElementsByTagName("activityProviderList") 1334 | try: 1335 | for reg in accountProviderList: 1336 | for act in reg.getElementsByTagName("activityProvider"): 1337 | id = act.getElementsByTagName("id")[0].firstChild.nodeValue 1338 | allowedEndpoints = "" 1339 | permissionsLevel = "" 1340 | try: 1341 | allowedEndpoints = act.getElementsByTagName("allowedEndpoints")[0].firstChild.nodeValue 1342 | except AttributeError: 1343 | allowedEndpoints = "None" 1344 | try: 1345 | permissionsLevel = act.getElementsByTagName("permissionsLevel")[0].firstChild.nodeValue 1346 | except AttributeError: 1347 | permissionsLevel = "None" 1348 | allResults.append(ActivityProviderData(id, allowedEndpoints, permissionsLevel)) 1349 | ret = allResults 1350 | except IndexError: 1351 | logging.exception('index error') 1352 | 1353 | return ret 1354 | 1355 | class ActivityProviderData(object): 1356 | def __init__(self, id, allowedEndpoints, permissionsLevel): 1357 | self.id = id 1358 | self.allowedEndpoints = allowedEndpoints 1359 | self.permissionsLevel = permissionsLevel 1360 | 1361 | class ApplicationCallbackData(object): 1362 | success = False 1363 | lrsUrl = "" 1364 | 1365 | def __init__(self, applicationDataElement): 1366 | if applicationDataElement is not None: 1367 | self.lrsUrl = applicationDataElement.childNodes[0].nodeValue 1368 | 1369 | @classmethod 1370 | def get_success(cls, xmldoc): 1371 | """ 1372 | Returns true if the given xml doc contains the "success" node, false otherwise 1373 | 1374 | Arguments: 1375 | xmldoc -- the raw result of the API method 1376 | """ 1377 | suc = xmldoc.getElementsByTagName("success") 1378 | if suc[0].nodeName == 'success': 1379 | return True 1380 | return False 1381 | 1382 | @classmethod 1383 | def list_from_result(cls, xmldoc): 1384 | """ 1385 | Returns a string containing the LRS Auth Callback Url for the configured app 1386 | 1387 | Arguments: 1388 | xmldoc -- the raw result of the API method 1389 | """ 1390 | allResults = [] 1391 | ret = "Could not retrieve URL" 1392 | url = xmldoc.getElementsByTagName("application") 1393 | try: 1394 | for reg in url: 1395 | appId = reg.getElementsByTagName("appId")[0].firstChild.nodeValue 1396 | appName = reg.getElementsByTagName("name")[0].firstChild.nodeValue 1397 | allResults.append(app_summary_data(appId, appName)) 1398 | 1399 | ret = allResults[0] 1400 | except IndexError: 1401 | logging.exception('failed') 1402 | return ret 1403 | 1404 | @classmethod 1405 | def list_from_list(cls, xmldoc): 1406 | """ 1407 | applicationlist 1408 | """ 1409 | allResults = [] 1410 | ret = "Could not retrieve list" 1411 | li = xmldoc.getElementsByTagName("applicationlist") 1412 | try: 1413 | for reg in li: 1414 | t = reg.childNodes 1415 | for s in t: 1416 | appId = s.getElementsByTagName("appId")[0].firstChild.nodeValue 1417 | appName = s.getElementsByTagName("name")[0].firstChild.nodeValue 1418 | allResults.append(app_summary_data(appId, appName)) 1419 | except IndexError: 1420 | logging.exception('failed to convert to list') 1421 | 1422 | if len(allResults) > 0: 1423 | ret = allResults 1424 | 1425 | return ret 1426 | 1427 | class app_summary_data(object): 1428 | def __init__(self, appId, name): 1429 | self.appId = appId 1430 | self.name = name 1431 | 1432 | class SuperUserData(object): 1433 | success = False 1434 | 1435 | @classmethod 1436 | def get_success(cls, xmldoc): 1437 | suc = xmldoc.getElementsByTagName("success") 1438 | if suc[0].nodeName == 'success': 1439 | return True 1440 | return False 1441 | 1442 | @classmethod 1443 | def list_from_result(cls, xmldoc): 1444 | allResults = [] 1445 | ret = False 1446 | li = xmldoc.getElementsByTagName("applicationidlist") 1447 | try: 1448 | for reg in li: 1449 | for s in reg.getElementsByTagName("applicationid"): 1450 | allResults.append(s.firstChild.nodeValue) 1451 | except urllib.error.HTTPError: 1452 | logging.exception('failed') 1453 | 1454 | if len(allResults) > 0: 1455 | ret = allResults 1456 | 1457 | return ret 1458 | 1459 | class LrsCallbackData(object): 1460 | success = False 1461 | lrsUrl = "" 1462 | 1463 | def __init__(self, lrsDataElement): 1464 | if lrsDataElement is not None: 1465 | #self.wasSuccessful = (importResultElement.attributes['successful'] 1466 | self.lrsUrl = lrsDataElement.childNodes[0].nodeValue 1467 | 1468 | 1469 | @classmethod 1470 | def get_success(cls, xmldoc): 1471 | """ 1472 | Returns true if the given xml doc contains the "success" node, false otherwise 1473 | 1474 | Arguments: 1475 | xmldoc -- the raw result of the API method 1476 | """ 1477 | suc = xmldoc.getElementsByTagName("success") 1478 | if suc[0].nodeName == 'success': 1479 | return True 1480 | return False 1481 | 1482 | @classmethod 1483 | def list_from_result(cls, xmldoc): 1484 | """ 1485 | Returns a string containing the LRS Auth Callback Url for the configured app 1486 | 1487 | Arguments: 1488 | xmldoc -- the raw result of the API method 1489 | """ 1490 | allResults = []; 1491 | ret = "Could not retrieve URL" 1492 | url = xmldoc.getElementsByTagName("lrsAuthCallbackUrl") 1493 | try: 1494 | for reg in url: 1495 | allResults.append(cls(reg)) 1496 | 1497 | ret = allResults[0].lrsUrl 1498 | except IndexError: 1499 | logging.exception('failed') 1500 | return ret 1501 | 1502 | class ServiceRequest(object): 1503 | """ 1504 | Helper object that handles the details of web service URLs and parameter 1505 | encoding and signing. Set the web service method parameters on the 1506 | parameters attribute of the ServiceRequest object and then call 1507 | call_service with the method name to make a service request. 1508 | """ 1509 | def __init__(self, service): 1510 | self.service = service 1511 | self.parameters = dict() 1512 | self.file_ = None 1513 | 1514 | def call_service(self, method, serviceurl=None): 1515 | """ 1516 | Calls the specified web service method using any parameters set on the 1517 | ServiceRequest. 1518 | 1519 | Arguments: 1520 | method -- the full name of the web service method to call. 1521 | For example: rustici.registration.createRegistration 1522 | serviceurl -- (optional) used to override the service host URL for a 1523 | single call 1524 | """ 1525 | postparams = None 1526 | #if self.file_ is not None: 1527 | # TODO: Implement file upload 1528 | url = self.construct_url(method, serviceurl) 1529 | rawresponse = self.send_post(url, postparams) 1530 | response = self.get_xml(rawresponse) 1531 | return response 1532 | 1533 | 1534 | def download_file(self, method, pathToSave): 1535 | """ 1536 | Calls the specified web service method using any parameters set on the 1537 | ServiceRequest. Assumes that the resource returned is meant to be downloaded. 1538 | Returns the absolute path to the saved resource 1539 | 1540 | Arguments: 1541 | method -- the full name of the web service method to call. 1542 | For example: rustici.registration.createRegistration 1543 | pathToSave -- the absolute path where the file should be saved once downloaded. 1544 | """ 1545 | url = self.construct_url(method) 1546 | u = urllib2.urlopen(url) 1547 | filename = cgi.parse_header(u.headers.get("Content-Disposition"))[1]['filename'] 1548 | if 'path' in self.parameters and self.parameters['path'] is not None: 1549 | filename = os.path.split(self.parameters['path'])[1] 1550 | filepath = os.path.join(pathToSave, filename) 1551 | with open(filepath, 'wb') as f: 1552 | while True: 1553 | buffer = u.read(8192) 1554 | if not buffer: 1555 | break 1556 | f.write(buffer) 1557 | return filepath 1558 | 1559 | def construct_url(self, method, serviceurl=None): 1560 | """ 1561 | Gets the full URL for a Cloud web service call, including parameters. 1562 | 1563 | Arguments: 1564 | method -- the full name of the web service method to call. 1565 | For example: rustici.registration.createRegistration 1566 | serviceurl -- (optional) used to override the service host URL for a 1567 | single call 1568 | """ 1569 | params = {'method': method} 1570 | 1571 | # 'appid': self.service.config.appid, 1572 | # 'origin': self.service.config.origin, 1573 | # 'ts': datetime.datetime.utcnow().strftime('yyyyMMddHHmmss'), 1574 | # 'applib': 'python'} 1575 | for k, v in self.parameters.items(): 1576 | params[k] = v 1577 | url = self.service.config.serviceurl 1578 | if serviceurl is not None: 1579 | url = serviceurl 1580 | url = (ScormCloudUtilities.clean_cloud_host_url(url) + '?' + 1581 | self._encode_and_sign(params)) 1582 | return url 1583 | 1584 | def get_xml(self, raw): 1585 | """ 1586 | Parses the raw response string as XML and asserts that there was no 1587 | error in the result. 1588 | 1589 | Arguments: 1590 | raw -- the raw response string from an API method call 1591 | """ 1592 | xmldoc = minidom.parseString(raw) 1593 | x = etree.XML(raw) 1594 | 1595 | if logging.root.isEnabledFor(logging.DEBUG): 1596 | logging.debug(etree.tostring(x, pretty_print=True).decode('utf8')) 1597 | 1598 | rsp = xmldoc.documentElement 1599 | if rsp.attributes['stat'].value != 'ok': 1600 | err = rsp.firstChild 1601 | raise Exception('SCORM Cloud Error: %s - %s' % 1602 | (err.attributes['code'].value, 1603 | err.attributes['msg'].value)) 1604 | return xmldoc 1605 | 1606 | def send_post(self, url, postparams): 1607 | cloudsocket = urllib.request.urlopen(url, postparams, 2000) 1608 | reply = cloudsocket.read() 1609 | cloudsocket.close() 1610 | return reply 1611 | 1612 | def _encode_and_sign(self, dictionary): 1613 | """ 1614 | URL encodes the data in the dictionary, and signs it using the 1615 | given secret, if a secret was given. 1616 | 1617 | Arguments: 1618 | dictionary -- the dictionary containing the key/value parameter pairs 1619 | """ 1620 | dictionary['appid'] = self.service.config.appid 1621 | dictionary['origin'] = self.service.config.origin; 1622 | dictionary['ts'] = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") 1623 | dictionary['applib'] = "python" 1624 | signing = '' 1625 | values = list() 1626 | secret = self.service.config.secret 1627 | for key in sorted(list(dictionary.keys()), key=str.lower): 1628 | signing += key + dictionary[key] 1629 | values.append(key + '=' + urllib.parse.quote_plus(dictionary[key])) 1630 | 1631 | sig = md5(secret.encode('utf8') + signing.encode('utf8')).hexdigest() 1632 | values.append('sig=' + sig) 1633 | 1634 | return '&'.join(values) 1635 | 1636 | 1637 | class ScormCloudUtilities(object): 1638 | """ 1639 | Provides utility functions for working with the SCORM Cloud. 1640 | """ 1641 | 1642 | @staticmethod 1643 | def get_canonical_origin_string(organization, application, version): 1644 | """ 1645 | Helper function to build a proper origin string to provide to the 1646 | SCORM Cloud configuration. Takes the organization name, application 1647 | name, and application version. 1648 | 1649 | Arguments: 1650 | organization -- the name of the organization that created the software 1651 | using the Python Cloud library 1652 | application -- the name of the application software using the Python 1653 | Cloud library 1654 | version -- the version string for the application software 1655 | """ 1656 | namepattern = re.compile(r'[^a-z0-9]') 1657 | versionpattern = re.compile(r'[^a-z0-9\.\-]') 1658 | org = namepattern.sub('', organization.lower()) 1659 | app = namepattern.sub('', application.lower()) 1660 | ver = versionpattern.sub('', version.lower()) 1661 | return "%s.%s.%s" % (org, app, ver) 1662 | 1663 | @staticmethod 1664 | def clean_cloud_host_url(url): 1665 | """ 1666 | Simple function that helps ensure a working API URL. Assumes that the 1667 | URL of the host service ends with /api and processes the given URL to 1668 | meet this assumption. 1669 | 1670 | Arguments: 1671 | url -- the URL for the Cloud service, typically as entered by a user 1672 | in their configuration 1673 | """ 1674 | parts = url.split('/') 1675 | if not parts[len(parts) - 1] == 'api': 1676 | parts.append('api') 1677 | return '/'.join(parts) 1678 | --------------------------------------------------------------------------------