diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e0df5d2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Noderabbit Inc., d.b.a. Appsembler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..689e50f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include LICENSE +include README.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5ef2a5 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +![Accredible Logo](https://s3.amazonaws.com/accredible-cdn/accredible_logo_sm.png) + +# Accredible OpenEdX Module + +## Overview +The Accredible platform enables organizations to create, manage and distribute digital credentials as digital certificates or open badges. + +An example digital certificate and badge can be viewed here: https://www.credential.net/10000005 + +This module enables you to issue dynamic, digital certificates using the [Accredible](https://accredible.com) API on your OpenEdx LMS instance. They act as a replacement for the PDF certificates normally generated for your courses. + +The module has been tested in edx-platform v0.1+. + +## Example Output +![Example Digital Certificate](https://s3.amazonaws.com/accredible-cdn/example-digital-certificate.png) + +![Example Open Badge](https://s3.amazonaws.com/accredible-cdn/example-digital-badge.png) + +### Pre-installation +Before installing the module please visit [https://accredible.com](https://accredible.com) and obtain a API key. + +### Installation +To install the git submodule onto your OpenEdx instance please ensure you have your API key and then follow these steps: + + 1. Locate your OpenEdx platform and navigate to **edx-platform**. + 2. Run `sudo git submodule add https://github.com/accredible/accredible_certificate /edx/app/edxapp/edx-platform/lms/djangoapps/accredible_certificate` + 3. Still within **edx-platform**, edit lms/envs/common.py. Around line 1326 you will the *INSTALLED_APPS* section, include accredible_certificate there by adding the line: `'accredible_certificate',` and saving the updated file. + +Now we can check the installation by running shell commands to see if our additional functions are available. Within the **edx-plaform** directory run `sudo -u www-data /edx/bin/python.edxapp ./manage.py lms --settings=aws help` + + Within the long list you should see the following output: + +> [accredible_certificates] +> change_accredible_certs_status +> generate_accredible_certs + +### Issuing Certificates +Your account has a default template for how your certificates will appear which you can edit from your dashboard. + +If you'd like to issue certificates and update their appearance *before* they are published (sent to your students) then please follow the instructions in **Method A**. If you'd like to issue certificates and have them delivered directly to students without amending their appearance then please follow the instructions in **Method B**. Both methods issue certificates for every student in the course. + +####Method A +To issue certificates and update their appearance *before* they are published (sent to your students): + + 1. Login to your server and navigate to your **edx-platform** directory to use manage.py + 2. Run the command: `sudo -u www-data /edx/bin/python.edxapp ./manage.py lms --settings aws generate_accredible_certs -c edX/DemoX/Demo_Course -a -s True` where < API_KEY > is replaced with the API key provided by Accredible and where edX/DemoX/Demo_Course is replaced by the course key that you'd like to generate certificates for. + 3. Go to the Accredible management console and in your account amend the certificate design to meet your requirements. + 4. In the Accredible management console publish the certificates and they will be delivered to your students. + 5. Back in your console within the **edx-platform** directory, run the command: `sudo -u www-data /edx/bin/python.edxapp ./manage.py lms --settings aws change_accredible_certs_status -c edX/DemoX/Demo_Course -a `. This command pull data from the Accredible API to change the status of the certificates from *generating* to *available for download*. Your students can now view their certificates through the LMS and via their email. + +####Method B +To issue certificates and have them delivered directly to students without amending their appearance: + + 1. From the **edx-platform** directory run the command `sudo -u www-data /edx/bin/python.edxapp ./manage.py lms --settings aws generate_accredible_certs -c edX/DemoX/Demo_Course -a ` where < API_KEY > is replaced with the API key provided by Accredible and where edX/DemoX/Demo_Course is replaced by the course key that you'd like to generate certificates for. + +### Support +If you have any issues, suggestions or questions then please send an email to support@accredible.com or submit an issue to https://github.com/accredible/acms-php-api/issues + + diff --git a/accredible_certificate/__init__.py b/accredible_certificate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accredible_certificate/apps.py b/accredible_certificate/apps.py new file mode 100644 index 0000000..db21375 --- /dev/null +++ b/accredible_certificate/apps.py @@ -0,0 +1,35 @@ +""" +Provides application configuration for Accredible. +As well as default values for running Accredible along with functions to +add entries to the Django conf settings needed to run Accredible. +""" + +from django.apps import AppConfig +from openedx.core.djangoapps.plugins.constants import ( + ProjectType, SettingsType, PluginURLs, PluginSettings +) + +class AccredibleConfig(AppConfig): + """ + Provides application configuration for Accredible. + """ + + name = 'accredible_certificate' + verbose_name = 'accredible_certificate' + + plugin_app = { + PluginURLs.CONFIG: { + ProjectType.LMS: { + PluginURLs.NAMESPACE: u'accredible_certificate', + PluginURLs.REGEX: u'request_certificate', + PluginURLs.RELATIVE_PATH: u'accredible_certificate.views.request_certificate', + } + }, + + PluginSettings.CONFIG: { + ProjectType.LMS: { + SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: u'settings.common'}, + SettingsType.AWS: {PluginSettings.RELATIVE_PATH: u'settings.aws'}, + } + }, + } \ No newline at end of file diff --git a/accredible_certificate/management/__init__.py b/accredible_certificate/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accredible_certificate/management/commands/__init__.py b/accredible_certificate/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accredible_certificate/management/commands/change_accredible_certs_status.py b/accredible_certificate/management/commands/change_accredible_certs_status.py new file mode 100644 index 0000000..fe6a3e5 --- /dev/null +++ b/accredible_certificate/management/commands/change_accredible_certs_status.py @@ -0,0 +1,80 @@ +from django.core.management.base import BaseCommand, CommandError +from certificates.models import certificate_status_for_student +from accredible_certificate.queue import CertificateGeneration +from django.contrib.auth.models import User +from optparse import make_option +from django.conf import settings +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from certificates.models import CertificateStatuses +from certificates.models import GeneratedCertificate +import datetime +from pytz import UTC +import requests +import json + + + +class Command(BaseCommand): + + help = """ + Find all students that need certificates for courses that have finished and + put their cert requests on the accredible API. + + Other commands can be private: true or not? + Per use need to think about it as when I completed that Edx Linux course now the certificate generated at that time so might be in use + """ + + option_list = BaseCommand.option_list + ( + make_option('-c', '--course', + metavar='COURSE_ID', + dest='course', + default=False, + help='Grade and generate certificates ' + 'for a specific course'), + make_option('-a', '--api_key', + metavar='API_KEY', + dest='api_key', + default=None, + help='API key for accredible Certificate, if don\'t have one' + 'Visit https://accredible.com/issuer/sign_up and get one') + ) + + def handle(self, *args, **options): + + # Will only generate a certificate if the current + # status is in the unavailable state, can be set + # to something else with the force flag + + + if options['course']: + # try to parse out the course from the serialized form + try: + course = CourseKey.from_string(options['course']) + except InvalidKeyError: + print("Course id {} could not be parsed as a CourseKey; falling back to SSCK.from_dep_str".format(options['course'])) + course = SlashSeparatedCourseKey.from_deprecated_string(options['course']) + course_id = course + else: + raise CommandError("You must specify a course") + + if options['api_key']: + api_key = options['api_key'] + else: + raise CommandError("You must give a api_key, if don't have one visit: https://accredible.com/issuer/sign_up") + + user_emails = [] + r = requests.get("https://api.accredible.com/v1/credentials?achievement_id="+ course_id.to_deprecated_string() + "&&full_view=true",headers={'Authorization':'Token token=' + api_key, 'Content-Type':'application/json'}) + for certificate in r.json()["credentials"]: + if certificate["approve"] == True: + user_emails.append(certificate["recipient"]["email"]) + + + for certificate in GeneratedCertificate.objects.filter(course_id=course_id, status="generating"): + if certificate.user.email in user_emails: + certificate.status = "downloadable" + certificate.save() + print certificate.name \ No newline at end of file diff --git a/accredible_certificate/management/commands/generate_accredible_certs.py b/accredible_certificate/management/commands/generate_accredible_certs.py new file mode 100644 index 0000000..d62a6b9 --- /dev/null +++ b/accredible_certificate/management/commands/generate_accredible_certs.py @@ -0,0 +1,117 @@ +""" +from management command to find all students that need certificates for +courses that have finished, and put their cert requests to the Accredible API. +""" +from django.core.management.base import BaseCommand, CommandError +from certificates.models import certificate_status_for_student +from accredible_certificate.queue import CertificateGeneration +from django.contrib.auth.models import User +from optparse import make_option +from django.conf import settings +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from certificates.models import CertificateStatuses +import datetime +from pytz import UTC + + + +class Command(BaseCommand): + + help = """ + Find all students that need certificates for courses that have finished and + put their cert requests on the accredible API. + + Other commands can be private: true or not? + Per use need to think about it as when I completed that Edx Linux course now the certificate generated at that time so might be in use + """ + + option_list = BaseCommand.option_list + ( + make_option('-c', '--course', + metavar='COURSE_ID', + dest='course', + default=False, + help='Grade and generate certificates ' + 'for a specific course'), + make_option('-a', '--api_key', + metavar='API_KEY', + dest='api_key', + default=None, + help='API key for accredible Certificate, if don\'t have one' + 'Visit https://accredible.com/issuer/sign_up and get one'), + make_option('-f', '--force-gen', + metavar='STATUS', + dest='force', + default=False, + help='Will generate new certificates for only those users ' + 'whose entry in the certificate table matches STATUS. ' + 'STATUS can be generating, unavailable, deleted, error ' + 'or notpassing.'), + make_option('-s', '--styling', + metavar='STYLING', + dest='styling', + default=False, + help='Pass True to styling if you want to desgin credentials after generating them' + 'Visit Accredible Management Console for editing it.' + 'Then Run xyz command after that student will be informed and can certificate on their dashboard'), + ) + + def handle(self, *args, **options): + + # Will only generate a certificate if the current + # status is in the unavailable state, can be set + # to something else with the force flag + + if options['force']: + valid_statuses = getattr(CertificateStatuses, options['force']) + else: + valid_statuses = [CertificateStatuses.unavailable] + + if options['course']: + # try to parse out the course from the serialized form + try: + course = CourseKey.from_string(options['course']) + except InvalidKeyError: + print("Course id {} could not be parsed as a CourseKey; falling back to SSCK.from_dep_str".format(options['course'])) + course = SlashSeparatedCourseKey.from_deprecated_string(options['course']) + ended_courses = [course] + else: + raise CommandError("You must specify a course") + + if options['api_key']: + api_key = options['api_key'] + else: + raise CommandError("You must give a api_key, if don't have one visit: https://accredible.com/issuer/sign_up") + + if options['styling']: + if options['styling'] == 'True': + new_status = "generating" + else: + raise CommandError("You must give true if want to do styling, no any other argument") + else: + new_status = "downloadable" + + + for course_key in ended_courses: + # prefetch all chapters/sequentials by saying depth=2 + course = modulestore().get_course(course_key, depth=2) + + print "Fetching enrolled students for {0}".format(course_key.to_deprecated_string()) + enrolled_students = User.objects.filter( + courseenrollment__course_id=course_key) + + xq = CertificateGeneration(api_key=api_key) + total = enrolled_students.count() + print "Total number of students: " + str(total) + for student in enrolled_students: + if certificate_status_for_student( + student, course_key)['status'] in valid_statuses: + ret = xq.add_cert(student, course_key, new_status, course=course) + print ret + + + + diff --git a/accredible_certificate/queue.py b/accredible_certificate/queue.py new file mode 100644 index 0000000..9a3a96e --- /dev/null +++ b/accredible_certificate/queue.py @@ -0,0 +1,235 @@ +from certificates.models import GeneratedCertificate +from certificates.models import certificate_status_for_student +from certificates.models import CertificateStatuses as status +from certificates.models import CertificateWhitelist + +from courseware import grades, courses +from django.test.client import RequestFactory +from capa.xqueue_interface import XQueueInterface +from capa.xqueue_interface import make_xheader, make_hashkey +from django.conf import settings +from requests.auth import HTTPBasicAuth +from student.models import UserProfile, CourseEnrollment +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +import json +import random +import logging +import lxml.html +from lxml.etree import XMLSyntaxError, ParserError +import requests +from xmodule.modulestore.django import modulestore +from util.db import outer_atomic +from django.db import transaction + +logger = logging.getLogger(__name__) + + +class CertificateGeneration(object): + """ + AccredibleCertificate generates an + accredible certificates for students + + See certificates/models.py for valid state transitions, + summary of methods: + + add_cert: Add a new certificate. Puts a single + request on the queue for the student/course. + Once the certificate is generated a post + will be made to the update_certificate + view which will save the certificate + download URL. + + regen_cert: Regenerate an existing certificate. + For a user that already has a certificate + this will delete the existing one and + generate a new cert. + + + del_cert: Delete an existing certificate + For a user that already has a certificate + this will delete his cert. + + """ + + def __init__(self, request=None, api_key=None): + + # Get basic auth (username/password) for + # xqueue connection if it's in the settings + + + if request is None: + factory = RequestFactory() + self.request = factory.get('/') + else: + self.request = request + + self.whitelist = CertificateWhitelist.objects.all() + self.restricted = UserProfile.objects.filter(allow_certificate=False) + self.api_key = api_key + + @transaction.non_atomic_requests + def add_cert(self, student, course_id, defined_status="downloadable", course=None, forced_grade=None, template_file=None, title='None'): + """ + Request a new certificate for a student. + + Arguments: + student - User.object + course_id - courseenrollment.course_id (CourseKey) + forced_grade - a string indicating a grade parameter to pass with + the certificate request. If this is given, grading + will be skipped. + + Will change the certificate status to 'generating' or 'downloadable'. + + Certificate must be in the 'unavailable', 'error', + 'deleted' or 'generating' state. + + If a student has a passing grade or is in the whitelist + table for the course a request will be made for a new cert. + + If a student has allow_certificate set to False in the + userprofile table the status will change to 'restricted' + + If a student does not have a passing grade the status + will change to status.notpassing + + Returns the student's status + """ + + VALID_STATUSES = [status.generating, + status.unavailable, + status.deleted, + status.error, + status.notpassing] + + cert_status = certificate_status_for_student(student, course_id)['status'] + + new_status = cert_status + + if cert_status in VALID_STATUSES: + # grade the student + + # re-use the course passed in optionally so we don't have to re-fetch everything + # for every student + if course is None: + course = courses.get_course_by_id(course_id) + + profile = UserProfile.objects.get(user=student) + profile_name = profile.name + + # Needed + self.request.user = student + self.request.session = {} + + course_name = course.display_name or course_id.to_deprecated_string() + description = '' + for section_key in ['short_description', 'description','overview']: + loc = loc = course.location.replace(category='about', name=section_key) + try: + if modulestore().get_item(loc).data: + description = modulestore().get_item(loc).data + break + except: + print "this course don't have " +section_key + + if not description: + description = "course_description" + + + is_whitelisted = self.whitelist.filter(user=student, course_id=course_id, whitelist=True).exists() + + grade = grades.grade(student, course) + enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(student, course_id) + mode_is_verified = (enrollment_mode == GeneratedCertificate.MODES.verified) + + cert_mode = GeneratedCertificate.MODES.honor + + if forced_grade: + grade['grade'] = forced_grade + + cert, __ = GeneratedCertificate.objects.get_or_create(user=student, course_id=course_id) + + cert.mode = cert_mode + cert.user = student + cert.grade = grade['percent'] + cert.course_id = course_id + cert.name = profile_name + + # Strip HTML from grade range label + grade_contents = int(grade['percent'] * 100) # convert percent to points as an integer + + if is_whitelisted or grade_contents is not None: + + # check to see whether the student is on the + # the embargoed country restricted list + # otherwise, put a new certificate request + # on the queue + print grade_contents + if self.restricted.filter(user=student).exists(): + new_status = status.restricted + cert.status = new_status + cert.save() + else: + contents = { + 'action': 'create', + 'username': student.username, + 'course_id': course_id.to_deprecated_string(), + 'course_name': course_name, + 'name': profile_name, + 'grade': grade_contents + } + + if defined_status == "generating": + approve = False + else: + approve = True + + # check to see if this is a BETA course + course_name = course_name.strip() + if course_name.startswith("BETA") or course_name.startswith("Beta") or course_name.startswith("beta"): + course_name = course_name[4:].strip() + + grade_into_string = ''.join('{}{}'.format(key, val) for key, val in grade.items()) + payload = { + "credential": + { + "name": course_name, + "group_name": course_name, + "description": description, + "achievement_id": contents['course_id'] , + "course_link": "/courses/" +contents['course_id'] + "/about", + "approve": approve, + "template_name": contents['course_id'], + "grade": contents['grade'], + "recipient": { + "name": contents['name'], + "email": student.email + } + } + } + + payload = json.dumps(payload) + + r = requests.post('https://api.accredible.com/v1/credentials', payload, headers={'Authorization':'Token token=' + self.api_key, 'Content-Type':'application/json'}) + + if r.status_code == 200: + json_response = r.json() + cert.status = defined_status + cert.key = json_response["credential"]["id"] + if 'private' in json_response: + cert.download_url = "https://wwww.accredible.com/" + str(json_response["credential"]["id"]) + "?key" + str(json_response["private_key"]) + else: + cert.download_url = "https://www.accredible.com/" + str(cert.key) + cert.save() + else: + new_status = "errors" + + + else: + cert_status = status.notpassing + cert.status = cert_status + cert.save() + + return new_status + + diff --git a/accredible_certificate/views.py b/accredible_certificate/views.py new file mode 100644 index 0000000..e3371bc --- /dev/null +++ b/accredible_certificate/views.py @@ -0,0 +1,126 @@ +"""URL handlers related to certificate handling by LMS""" +from dogapi import dog_stats_api +import json +import logging + +from django.contrib.auth.models import User +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt + +from capa.xqueue_interface import XQUEUE_METRIC_NAME +from certificates.models import certificate_status_for_student, CertificateStatuses, GeneratedCertificate +from accredible_certificate.queue import CertificateGeneration +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from util.db import outer_atomic +from django.db import transaction +from django.conf import settings + +from courseware.courses import ( + get_course, +) + +logger = logging.getLogger(__name__) + +@transaction.non_atomic_requests +@csrf_exempt +def request_certificate(request): + """Request the on-demand creation of a certificate for some user, course. + A request doesn't imply a guarantee that such a creation will take place. + We intentionally use the same machinery as is used for doing certification + at the end of a course run, so that we can be sure users get graded and + then if and only if they pass, do they get a certificate issued. + """ + if request.method == "POST": + if request.user.is_authenticated(): + # Enter your api key here + xqci = CertificateGeneration(api_key=settings.APPSEMBLER_FEATURES['ACCREDIBLE_API_KEY']) + username = request.user.username + student = User.objects.get(username=username) + course_key = SlashSeparatedCourseKey.from_deprecated_string(request.POST.get('course_id')) + course = get_course(course_key) + + status = certificate_status_for_student(student, course_key)['status'] + if status in [CertificateStatuses.unavailable, CertificateStatuses.notpassing, CertificateStatuses.error]: + logger.info('Grading and certification requested for user {} in course {} via /request_certificate call'.format(username, course_key)) + status = xqci.add_cert(student, course_key, course=course) + return HttpResponse(json.dumps({'add_status': status}), content_type='application/json') + return HttpResponse(json.dumps({'add_status': 'ERRORANONYMOUSUSER'}), content_type='application/json') + + +@csrf_exempt +# this method not needed as no xqueue server here +def update_certificate(request): + """ + Will update GeneratedCertificate for a new certificate or + modify an existing certificate entry. + See models.py for a state diagram of certificate states + This view should only ever be accessed by the xqueue server + """ + + status = CertificateStatuses + if request.method == "POST": + + xqueue_body = json.loads(request.POST.get('xqueue_body')) + xqueue_header = json.loads(request.POST.get('xqueue_header')) + + try: + course_key = SlashSeparatedCourseKey.from_deprecated_string(xqueue_body['course_id']) + + cert = GeneratedCertificate.objects.get( + user__username=xqueue_body['username'], + course_id=course_key, + key=xqueue_header['lms_key']) + + except GeneratedCertificate.DoesNotExist: + logger.critical('Unable to lookup certificate\n' + 'xqueue_body: {0}\n' + 'xqueue_header: {1}'.format( + xqueue_body, xqueue_header)) + + return HttpResponse(json.dumps({ + 'return_code': 1, + 'content': 'unable to lookup key'}), + content_type='application/json') + + if 'error' in xqueue_body: + cert.status = status.error + if 'error_reason' in xqueue_body: + + # Hopefully we will record a meaningful error + # here if something bad happened during the + # certificate generation process + # + # example: + # (aamorm BerkeleyX/CS169.1x/2012_Fall) + # : + # HTTP error (reason=error(32, 'Broken pipe'), filename=None) : + # certificate_agent.py:175 + + + cert.error_reason = xqueue_body['error_reason'] + else: + if cert.status in [status.generating, status.regenerating]: + cert.download_uuid = xqueue_body['download_uuid'] + cert.verify_uuid = xqueue_body['verify_uuid'] + cert.download_url = xqueue_body['url'] + cert.status = status.downloadable + elif cert.status in [status.deleting]: + cert.status = status.deleted + else: + logger.critical('Invalid state for cert update: {0}'.format( + cert.status)) + return HttpResponse(json.dumps({ + 'return_code': 1, + 'content': 'invalid cert status'}), + content_type='application/json') + + dog_stats_api.increment(XQUEUE_METRIC_NAME, tags=[ + u'action:update_certificate', + u'course_id:{}'.format(cert.course_id) + ]) + + cert.save() + return HttpResponse(json.dumps({'return_code': 0}), + content_type='application/json') \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..4ce90ca --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[metadata] +name = Accredible +version = 0.1 +description = A Django app to conduct OpenEDX to Accredible. +long_description = file: README.rst \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..3149ad4 --- /dev/null +++ b/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup + +setup( + name='accredible_certificate', + version='0.0.1', + license='MIT', + description='Reporting and data retrieval for Open edX', + entry_points={ + 'lms.djangoapp': [ + 'accredible_certificate = accredible_certificate.apps:AccredibleConfig', + ], +}, +) \ No newline at end of file