Since at least August-2020 it’s become possible to implement custom code for the Open edX platform without forking the edx-platform repository. Not only is it possible but it’s considered best practice to organize both your custom code as well as any platform modifications into separate pip-installable projects. This article, which includes practical code exercises, will quickly get you up to speed on the right way to get started leveraging the Open edX plugin architecture.
Summary
In the spring of 2019 at the Open edX annual conference in San Diego, Nimisha Asthagiri, chief architect & senior director of engineering at edX, laid out a roadmap to transition the Open edX platform into a tighter more stable core surrounded by a vibrant ecosystem of plugins. Presentation slides are available at this link. At the time, no such plugins existed, but today this vision is fully realized. Additionally, edX has refactored some its legacy code as plugins, and these make for excellent guides on how to approach a multitude of coding situations.
We can organize Open edX’s refactored code into two distinct groups. First, in the left column below, some legacy apps which have been refactored as plugins, but (as of the Lilac release) they still reside in the edx-platform repository. And second, in the right column below, a set of Open edX plugins available for download on PyPi.
edx-platform internal plugin apps
PyPi installed plugin apps
All of these apps use the Open edX plugin architecture and share a common configuration pattern which we’ll review more closely. Note that the app configuration is the same regardless of whether the source code resides in the edx-platform repository or if its been packaged and distributed via PyPi. The plugin management code itself is implemented partially within edx-platform.openedx.core.djangoapps.plugins and also in a separate pip-installed package named edx-django-utils. Reviewing this source code showed me most of what I needed to know about the Open edX plugin architecture, and its actually quite simple.
There are three technical distinctions between a traditional Django app versus an Open edX plugin. Respectively these regard:
- Registering your app (eg “plugin”) with Django
- Registering your app’s URL with Django, and
- Registering your app’s custom settings with Django.
Technically speaking, an Open edX plugin is a traditional Django app by every measure of the definition, with no exceptions whatsoever. A traditional Django app becomes an Open edX plugin by defining a dict named plugin_app
in the apps.py module. This dict describes traditional Django configuration information about the settings and the URL to which the app is registered. There potentially are also some considerations in setup.py which are best discovered by reviewing the underlying source code of existing Open edX plugins distributed on PyPi.
You should consider packaging your code as a plugin, not least because this will encapsulate your custom code into a single repository, making it easier to find and maintain after deployment. Secondarily, it simplifies future Open edX platform upgrades, simply because upgrading a stock version of edx-platform is often a lot easier than upgrading a fork. Third, packaging your code as a plugin is a prerequisite if you have any aspirations of fully open-sourcing your project in the future.
Importantly, the edX engineering team has developed a set of internal tools for creating their own plugins, and you should definitely take advantage of these so that your projects are structured, organized and named according to Python, Django and Open edX community best practices.
Related, I created this Youtube video introducing my pet project, Open edX Plugin Example, which contains 26 coding tips that will make you a better Open edX plugin programmer.
I. Registering your plugin with Django
For a traditional Django app in Open edX (LMS or CMS) you would insert your dot-notated app definition into the Python/Django list INSTALLED_APPS
. But with the Open edX plugin architecture your app is automagically registered. Cool, huh? It’s plugin manager introspects your installed package during service startup, and if it encounters a correctly structured dict inside your apps.py class definition named plugin_app
then it will add your app to INSTALLED_APPS
.
Since Open edX is a large platform, where in this list you place your app often matters, and you may have had previous experience having to tinker with your apps location in the list in order to avoid Python import errors. Though this likely has not changed, I amazingly have not run into a problem of this nature with Open edX plugins. I don’t know if the Open edX plugin manager is making smart deliberate choices about where in the list to insert my apps, or if I’m simply lucky. Hopefully like me, you won’t run into problems on this front.
II. Registering your plugin’s URL with Django
Similarly, for a traditional Django app in LMS/CMS you would set the url for an app by editing the project’s urls.py module. So for example, to add a url to the LMS you would traditionally edit the Python module edx-platform/lms/urls.py. But with the Open edX plugin architecture, you can register your url with Django at run-time from within the apps.py module of your Django app, as follows:
from django.apps import AppConfig from edx_django_utils.plugins import PluginURLs class MyAppConfig(AppConfig): name = "my_app" plugin_app = { PluginURLs.CONFIG: { # The three dict attributes literally equate to the following # lines of code being injected into edx-platform/lms/urls.py: # # import myapp.urls.py # url(r"^my-app/", include((urls, "my_app"), namespace="my_app")), # ProjectType.LMS: { PluginURLs.NAMESPACE: name, PluginURLs.REGEX: "^my-app/", PluginURLs.RELATIVE_PATH: "urls", } } }
Note that the line PluginURLs.RELATIVE_PATH: "urls",
describes the location of your urls.py module within your plugin, which typically would reside in your repository in the same root location as apps.py itself.
III. Registering your plugin’s settings with Django
Lastly, for a traditional Django app in LMS/CMS you would add your Django settings by directly modifying one of the settings modules. For example, if your app included a parameter MY_APP_DISCO_BALL = ['Orange', 'Blue', 'Green', 'Pink']
then you might add this to edx-platform/lms/settings/common.py
.
In an Open edX plugin however, you define your settings in similarly-named modules in your project and then bind these modules at run-time to one of the Open edX settings modules like in this example:
from django.apps import AppConfig from edx_django_utils.plugins import PluginSettings from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType class MyAppConfig(AppConfig): plugin_app = { PluginURLs.CONFIG: { # This dict causes all constants defined in this settings/common.py and settings.production.py # to be injected into edx-platform/lms/envs/common.py and edx-platform/lms/envs/production.py # Refer to settings/common.py and settings.production.py for example implementation patterns. PluginSettings.CONFIG: { ProjectType.LMS: { SettingsType.PRODUCTION: {PluginSettings.RELATIVE_PATH: "settings.production"}, SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: "settings.common"}, } } }
Note that the fragment {PluginSettings.RELATIVE_PATH: "settings.production"}
describes the location of a Python module named production.py located in ./settings/production.py relative to the location of your apps.py module.
IV. Putting it all together: a complete example
Combining these two code patterns yields the following.
import logging from django.apps import AppConfig from edx_django_utils.plugins import PluginSettings, PluginURLs from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType log = logging.getLogger(__name__) class MyAppConfig(AppConfig): name = "my_app" label = "my_app" verbose_name = "My Open edX Plugin" # See: https://edx.readthedocs.io/projects/edx-django-utils/en/latest/edx_django_utils.plugins.html plugin_app = { PluginURLs.CONFIG: { ProjectType.LMS: { PluginURLs.NAMESPACE: name, PluginURLs.REGEX: "^my-app/", PluginURLs.RELATIVE_PATH: "urls", } }, PluginSettings.CONFIG: { ProjectType.LMS: { SettingsType.PRODUCTION: {PluginSettings.RELATIVE_PATH: "settings.production"}, SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: "settings.common"}, } }, } def ready(self): log.debug("{label} is ready.".format(label=self.label))
V. Packaging your code with setup.py
A proper setup.py is best explained by way of example. Additionally, Cookiecutter, further explained in this section VI, should take care of most/all of the setup.py content. You should defer to Cookiecutter on any items that conflict with what you see below in this example.
import os from setuptools import find_packages, setup # allow setup.py to be run from any path os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name="my_app", version="0.1.0", packages=find_packages(), package_data={"": ["*.html"]}, # include any Mako templates found in this repo. include_package_data=True, license="Proprietary", description="Adds a sparking winter wonderland to normal Open edX environments", long_description="", author="Lawrence McDaniel", author_email="lpm0073@gmail.com", url="https://github.com/lpm0073/my-app", install_requires=[ # don't add packages that are already required by open-edx. # this only increases the risk version conflicts in production. "django-environ==0.7.0", "django-extensions==3.1.3", ], zip_safe=False, keywords="Django edx", classifiers=[ "Development Status :: 3 - Alpha", "Framework :: Django", "Framework :: Django :: 2.2", "Framework :: Django :: 3.0", "Framework :: Django :: 3.1", "Framework :: Django :: 3.2", "Intended Audience :: Developers", "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", "Natural Language :: English", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", ], entry_points={ # IMPORTANT: ensure that this entry_points coincides with that of edx-platform # and also that you are not introducing any name collisions. # https://github.com/edx/edx-platform/blob/master/setup.py#L88 "lms.djangoapp": [ "my_app = my_app.apps:MyAppConfig", ], }, extras_require={ "Django": ["Django>=2.2,<2.3"], }, )
VI. Creating your own plugin, the right way
The edX engineering team maintains a set of Cookiecutter templates, located here https://github.com/edx/edx-cookiecutters. They use these internally for creating new code projects. There are four different Cookiecutter templates in this repo and it is important that you choose the correct one for your project.
If you’re unfamiliar, Cookiecutter is a command-line utility that creates projects from cookiecutters (project templates), e.g. creating a Python package project from a Python package project template. Cookiecutter was created by Audrey Roy Greenfeld, co-author of the popular “Two Scoops of Django” series of books. So to be clear, Audrey Roy Greenfeld created the Cookiecutter command-line utility, and the edX team created and maintains a collection of Open edX Cookiecutter templates for starting new plugin projects.
You should also use these for starting your project. Installation and usage instructions are in the README of the repository. A couple words of caution about this repo. First, the installation instructions refer to a somewhat-nonstandard virtual environment manager named virtualenvwrapper. It is important that you use this virtual environment manager with this repo because it will ensure that the virtual environment installs the correct version of Python (Python 3.8 as of October 2021). Second, I had trouble installing virtualenvwrapper on my macMini M1; in part because I already have a couple other virtual environment managers installed. But I also got the impression that the authors are not mac guys, as they don’t offer a Homebrew installation method.
edX Cookiecutters
I hope you found this helpful. Contributors are welcome. My contact information is on my web site. Please help me improve this article by leaving a comment below. Thank you!
Hi Lawrence,
First I would like to thank you for your effort in trying to explain what in my perspective are complicated concepts in a delightfully easy to follow and understand way.
I’ve also been reading your answers on the Edx developers community, and I really appreciate the way you try to explain things to people who ask for help. So thank you.
It would be extremely helpful if you could make a post explaining how to start the development process on edX platform (installing Tutor and setting up the edX platform code locally and building the docker images, mounting the containers in a way that any changes in the code would be reflected in the running instance, also how to save any changes ) for newbies like my self, who aren’t familiar with technologies used in the platform, I must admit is overwhelming!
Again thank you so much for all your hard work and I hope to see more enlightening posts from you.
Hi Lawrence, thank you very much for creating such a great examples. As a someone who just got start with the open-edx platform and docker, I found that it’s hard for a beginner like me to understand how to test your own plugin on our local machine. I tried to followed the official website about local testing https://edx.readthedocs.io/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html#local-testing.
I did as it suggests, `docker exec` into the lms container and then `make requirements`. After make requirement the `django_redis` is missed, I have to `pip install` it by myself. When I finally get into the shell opened by django, and ran the command in #local-testing, I cannot find your example plugins in the list.
Would you mind create a short video about how to deploy the plugins we created into the docker and do some test? That would be extremely helpful for a beginner like me.
Thanks again for creating such great examples!
The link for `edx-django-util` in the apps.py (the putting together section) is not accessible. It should be https://edx.readthedocs.io/projects/edx-django-utils/en/latest/plugins/decisions/0001-plugin-contexts.html
thank you!! 🙂
f we had to rank the reasons we love WordPress, plugins would be near the top of the list. Due to its open-source nature, anybody can add new functionality to the platform. The best way to get started is by creating your own plugins.