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.

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:

  1. Registering your app (eg “plugin”) with Django
  2. Registering your app’s URL with Django, and
  3. 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.

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

  • cookiecutter-django-app. In most cases this is what you should use. This scaffolds an empty project that is intended to be bundled as a pip-installable Open edX plugin. Oddly, the Cookiecutter template does not include any of the plugin_app code fragments for apps.py that I’ve demonstrated in this article, thus, you’ll have to copy/paste these yourself. However, it does take care of a lot of other minutia that is important and easy to overlook.
  • cookiecutter-xblock. Ditto, but for creating a pip-installable XBlock.

  • cookiecutter-python-library. It is unlikely that you would ever use this template. There are several good Cookiecutter templates available for creating a generic Python package, including from the Cookiecutter site itself. This template really only exists to help ensure that edX engineers conform to internal coding policies and standards when creating new Python packages (which probably is not often even for them).

  • cookiecutter-django-ida. It is also exceedingly unlikely that you would ever use this template which is for creating standalone web applications (eg “Independently Deployable Apps”). So for example, the Open edX LMS is an independently-deployed application, and so is Course Management Studio.

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!