Skip to content

Walk through Example

In this example let us create an Esmerald application that will be using the Esmerald Admin but with some complexity and organisation.

In this walk-through we will be also making sure we use as many Esmerald built-ins as possible.

The following example will be using Saffier but the same example is applied to Edgy.

What we will be using

What is the structure of this example

Lets do it!

Initial project setup

Generate the project

First we will be gerating the project to work on. Using the Esmerald directives this should be easy and lets call it myproject.

$ esmerald createproject myproject

You should now have a folder structure similar to this:

.
├── Makefile
└── myproject
    ├── apps
       └── __init__.py
    ├── configs
       ├── development
          ├── __init__.py
          └── settings.py
       ├── __init__.py
       ├── settings.py
       └── testing
           ├── __init__.py
           └── settings.py
    ├── __init__.py
    ├── main.py
    ├── serve.py
    ├── tests
       ├── __init__.py
       └── test_app.py
    └── urls.py

This is the default folder structure generated by createproject command from Esmerald but you can ready more about Esmerald directives in its official documentation

From now on, let us work inside the new project.

$ cd myproject/

Generate an Esmerald application

Since we want the project to have a clean design and clean structure, let us then create an Esmerald application.

Lets cd to apps.

$ cd myproject/apps/

Here we want to create an accounts application. What is this? Simply a package that will handle with all things user and user management inmyproject`.

$ esmerald createproject accounts

Now we have a structure like this:

.
├── Makefile
└── myproject
    ├── apps
       ├── accounts
          ├── directives
             ├── __init__.py
             └── operations
                 └── __init__.py
          ├── __init__.py
          ├── tests.py
          └── v1
              ├── __init__.py
              ├── schemas.py
              ├── urls.py
              └── views.py
       └── __init__.py
    ├── configs
       ├── development
          ├── __init__.py
          └── settings.py
       ├── __init__.py
       ├── settings.py
       └── testing
           ├── __init__.py
           └── settings.py
    ├── __init__.py
    ├── main.py
    ├── serve.py
    ├── tests
       ├── __init__.py
       └── test_app.py
    └── urls.py

Great! We now have a nice structured project with an accounts application where we will be managing the Saffier models.

Create the user model

Now that we have our initial structure, it is time to create our User model. As mentioned at the very beginning, we will be using the Esmerald contrib since a lot is already provided.

Let us cd to accounts and create a models.py.

$ cd myproject/apps/accounts
$ touch models.py
.
├── Makefile
└── myproject
    ├── apps
       ├── accounts
          ├── directives
             ├── __init__.py
             └── operations
                 └── __init__.py
          ├── __init__.py
          ├── models.py
          ├── tests.py
          └── v1
              ├── __init__.py
              ├── schemas.py
              ├── urls.py
              └── views.py
       └── __init__.py
...

Open the file and add the following:

from esmerald.conf import settings
from esmerald.contrib.auth.saffier.base_user import AbstractUser

database, models = settings.db_access


class User(AbstractUser):
    """Inherits from the user base"""

    class Meta:
        registry = models

    def __str__(self):
        return f"{self.email}"

This creates a model User that inherits from the AbstractUser from Esmerald and uses the models for the registry. More details about the registry in the official Saffier documentation.

Let the line database, models = settings.db_access be like that as we will be updating the application settings later on to reflect the db_access.

Make the user model accessible

To make sure Saffier can see the models and then use them to manage the migrations, let us add the User to the __init__.py of the accounts.

Open myproject/apps/accounts/init.py and add the following line:

from .models import User as User

This should be enough to expose our models to the application.

Add an initial view to application

Since we want an application, it will be nice to add at least a welcome view to it.

Go to myproject/apps/accounts/v1/views.py and add the following:

from esmerald import JSON, get


@get("/welcome")
async def welcome() -> JSON:
    return JSON({"detail": "Welcome to myproject"})

And then go to myproject/apps/accounts/v1/urls.py to connect the handler with a Gateway.

from esmerald import Gateway

from .views import welcome

route_patterns = [Gateway(handler=welcome, name="welcome")]

So far so good. We now have views, urls, models and applications. The application is becoming more complex.

Add the newly created endpoints to the main application routing system

This system is not a default from Esmerald, instead is simply one of the many designs that you can adopt when managing URL endpoints in your application. When using the Esmerald createproject directive, it simply generates one structure that makes it simpler to do it.

Now it is time to add those accounts urls to the main application routing system.

Go to myproject/urls.py and add the following:

from esmerald import Include

route_patterns = [Include(path="/accounts", namespace="accounts.v1.urls")]

The way the routing system works with Esmerald can be found in the official documentation.

The reason why we did in this way it is because the createaproject generates a unique and clean structure where inside the main.py file the function get_application imports the urls.py as the initial route.

myproject/main.py
def get_application():
    """
    This is optional. The function is only used for organisation purposes.
    """
    build_path()

    app = Esmerald(
        routes=[Include(namespace="myproject.urls")],
    )

    return app

This can of course be changed but we want to simplify as much as possible in this example.

Access the welcome endpoint

After all of this, you can now start the project and access the OpenAPI docs and test your URL.

Go back to your myproject root folder and run:

esmerald runserver

Access the http://localhost:8000/docs/swagger and you should be able to see the welcome endpoint.

Generate migrations

Now it is time to generate some migrations for our example. Remember the User model created before and the way it was exposed? Well it is time now to make sure we are able to generate migrations with saffier.

First we need to initialise the migration system for our myproject and according to Saffier we need to expose some environment variables or use the --app parameter.

For the simplificy of this example, let us use environment variables.

We will be using Postgres with a database called mydb.

  1. Expose our database url:
export SAFFIER_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/mydb
  1. Expose the ESMERALD_SETTINGS_MODULE we need to use for our development purposes.
export ESMERALD_SETTINGS_MODULE=myproject.configs.development.settings.DevelopmentAppSettings

This matches the location of the settings.py file inside the generated scaffold for development. These settings inherit from the AppSettings that should be used for production.

.
├── docker-compose.yml
├── Makefile
└── myproject
    ├── apps
       ├── accounts
          ├── directives
             ├── __init__.py
             └── operations
                 └── __init__.py
          ├── __init__.py
          ├── models.py
          ├── tests.py
          └── v1
              ├── __init__.py
              ├── schemas.py
              ├── urls.py
              └── views.py
       └── __init__.py
    ├── configs
       ├── development
          ├── __init__.py
          └── settings.py
       ├── __init__.py
       ├── settings.py
       └── testing
           ├── __init__.py
           └── settings.py
    ├── __init__.py
    ├── main.py
    ├── serve.py
    ├── tests
       ├── __init__.py
       └── test_app.py
    └── urls.py

Initialisation

Now that we have our environment ready with the environment variables in the right place, inside the root of myproject, run:

saffier init

This will generate the migrations folder and you should now have something like this:

.
├── docker-compose.yml
├── Makefile
├── migrations
   ├── alembic.ini
   ├── env.py
   ├── README
   ├── script.py.mako
   └── versions
└── myproject
    ├── apps

Pretty cool, right? Well Saffier has its own migration system on the top of Alembic and makes it easier to manage.

Update the settings

Well, you came far if you are reading this. Remember exposing the ESMERALD_SETTINGS_MODULE before? And do you remember the DevelopmentAppSettings inherits from AppSettings?

This is very useful because it means we only need to update in one place and the rest will simply use it.

Go to myproject/configs/settings.py and update the contents with the following.

import os
from functools import cached_property
from typing import Optional

from esmerald.conf.enums import EnvironmentType
from esmerald.conf.global_settings import EsmeraldAPISettings
from esmerald.config.jwt import JWTConfig
from saffier import Database, Registry


class AppSettings(EsmeraldAPISettings):
    app_name: str = "My application in production mode."
    title: str = "My project"
    environment: Optional[str] = EnvironmentType.PRODUCTION
    secret_key: str = "esmerald-insecure-)&e5_#d@%z8h+p23r-6a8nhh!sc##^8x"

    @cached_property
    def db_access(self):
        database = Database(os.environ["SAFFIER_DATABASE_URL"])
        registry = Registry(database=database)
        return database, registry

    @property
    def jwt_config(self) -> JWTConfig:
        return JWTConfig(signing_key=self.secret_key)

These are very important configurations. Let us go through. If you remember at the beginning of this example, the User model was declared and there was a db_access being called from the esmerald.conf.settings.

Check

The highlighted section is how you have created that property and made it globaly available in your application and the reason why is globaly available it is because you have also exposed the ESMERALD_SETTINGS_MODULE environment variable before which means the application will be running using your custom settings and not the default Esmerald settings. 🔥

The jwt_config let it be like this as it will be useful later on 😁.

Update the main file

Now it is time to update the main.py file and add the Saffier migration management system.

Go to myproject/main.py and update with the following:

import os
import sys
from pathlib import Path

from esmerald import Esmerald, Include, settings
from saffier import Migrate, Registry


def build_path():
    """
    Builds the path of the project and project root.
    """
    Path(__file__).resolve().parent.parent
    SITE_ROOT = os.path.dirname(os.path.realpath(__file__))

    if SITE_ROOT not in sys.path:
        sys.path.append(SITE_ROOT)
        sys.path.append(os.path.join(SITE_ROOT, "apps"))


def get_migrations(app: Esmerald, registry: Registry) -> None:
    """
    Manages the saffier migrations
    """
    Migrate(app, registry)


def get_application():
    """
    This is optional. The function is only used for organisation purposes.
    """
    build_path()

    database, registry = settings.db_access

    app = Esmerald(
        routes=[Include(namespace="myproject.urls")],
        on_startup=[database.connect],
        on_shutdown=[database.disconnect],
    )

    # Migrations
    get_migrations(app, registry)

    return app


app = get_application()

Well, now the things are getting more complete, aren't they? So this is very simple to explain:

  1. Import the settings from the esmerald.conf to allow to access the db_access property. The db_access property as it was declared in the settings, is a cached_property and that is very helpful because we want the registry used in the models to be exactly the same as the one used for the migrations.
  2. on_startup/on_shutdown events are used and needed for the application to interact with the database.
  3. get_migrations() is a new function we created to simply make it readable and cleaner to us.

Warning

Because the db_access is the same everywhere in the models and in the application, we can simply just pass the registry to the Migrate object of saffier.

Run your first migration

Now you can run your first migration and generate your database tables.

Go to the root of myproject and run:

$ saffier makemigrations
$ saffier migrate

Your migrations folder should now contain your first migration file with the declaration of the User model.

Generate a superuser

Remember the reason why using the Esmerald contrib? Well, one of the reasons it is because it is ready to manage users, staff and superusers out-of-the box as well.

We now want to create a superuser that will be later used to login into the Esmerald admin. To perform this operation we will be using another Esmerald directive.

Go to myproject/apps/accounts/directives/operations and run:

$ touch createsuperuser.py

It should like this

.
├── Makefile
├── migrations
│   ├── alembic.ini
│   ├── env.py
│   ├── README
│   ├── script.py.mako
│   └── versions
└── myproject
    ├── apps
       ├── accounts
          ├── directives
             ├── __init__.py
             └── operations
                 ├── createsuperuser.py
                 └── __init__.py
          ├── __init__.py
          ├── models.py
...

Edit the new createsuperuser.py and add the following.

import argparse
import random
import string
from typing import Any, Type

from accounts.models import User
from asyncpg.exceptions import UniqueViolationError
from esmerald.core.directives import BaseDirective
from esmerald.core.terminal import Print

printer = Print()


class Directive(BaseDirective):
    help: str = "Creates a superuser"

    def add_arguments(self, parser: Type["argparse.ArgumentParser"]) -> Any:
        parser.add_argument("--first-name", dest="first_name", type=str, required=True)
        parser.add_argument("--last-name", dest="last_name", type=str, required=True)
        parser.add_argument("--username", dest="username", type=str, required=True)
        parser.add_argument("--email", dest="email", type=str, required=True)
        parser.add_argument("--password", dest="password", type=str, required=True)

    def get_random_string(self, length=10):
        letters = string.ascii_lowercase
        result_str = "".join(random.choice(letters) for i in range(length))
        return result_str

    async def handle(self, *args: Any, **options: Any) -> Any:
        """
        Generates a superuser
        """
        first_name = options["first_name"]
        last_name = options["last_name"]
        username = options["username"]
        email = options["email"]
        password = options["password"]

        try:
            user = await User.query.create_superuser(
                first_name=first_name,
                last_name=last_name,
                username=username,
                email=email,
                password=password,
            )
        except UniqueViolationError:
            printer.write_error(f"User with email {email} already exists.")
            return

        printer.write_success(f"Superuser {user.email} created successfully.")

This is an extremely powerfull custom directive from Esmerald that allows to run custom scripts within your Esmerald application.

We can now create a super user by running the following command inside the root of myproject:

esmerald run --directive createsuperuser --first-name John --last-name Doe --email john.doe@example.com --username johnd --password pass1234

Add the Esmerald admin

Now it is time for the last bit. Adding the Esmerald admin to your application. If you complete all the previous steps, this one should be easy!

Create the admin file

Well, here this is not mandatory, you can have all the admin views inside your main.py file as a lot of people usually do but since it was mentioned the clean design using Esmerald, let us keep it in a separate file called admin.py.

Go to myproject/ and run:

$ touch admin.py

You should now have a structure like this:

.
├── Makefile
├── migrations
│   ├── alembic.ini
│   ├── env.py
│   ├── README
│   ├── script.py.mako
│   └── versions
└── myproject
    ├── admin.py
...

Create the admin views

Now it is time to create the admin views. This is a very important step since Saffier is kinda unique in the way it does things.

Esmerald admin being derived from SQLAdmin has some particularities. Saffier natively is very fast since it is created on the top of SQLAlchemy Core but SQLAdmin expects declarative_models types.

Saffier also provides that and those are the ones that should be used for the admin. You can see more details about how Saffier manages the declarative_models.

Now, go to myproject/admin.py and add the following:

from accounts.models import User as UserModel

from esmerald_admin import Admin, ModelView

# Declarative Models
User = UserModel.declarative()


class UserAdmin(ModelView, model=User):
    column_list = [User.id, User.username, User.email, User.first_name, User.last_name]


def get_views(admin: Admin) -> None:
    """Generates the admin views and it is used in
    the `main.py` file.
    """
    admin.add_model_view(UserAdmin)

As you can see, the User model is a normal Saffier model but for the admin what we want is the declarative version of it, the one that SQLAdmin expects to receive and use.

Warning

Make sure you read the official documentation to understand in detail how to work with declarative models and the relations.

Important

When SQLAdmin refers to ForeignKey relationships using ajax, Saffier declarative models add a suffix _relation to the name of the declared ForeignKey.

Example:

import saffier
from saffier import Database, Registry

database = Database("sqlite:///db.sqlite")
models = Registry(database=database)


class User(saffier.Model):
    is_active = saffier.BooleanField(default=True)
    first_name = saffier.CharField(max_length=50)
    last_name = saffier.CharField(max_length=50)
    email = saffier.EmailField(max_lengh=100)
    password = saffier.CharField(max_length=1000)

    class Meta:
        registry = models


class Thread(saffier.Model):
    sender = saffier.ForeignKey(
        User,
        on_delete=saffier.CASCADE,
        related_name="sender",
    )
    receiver = saffier.ForeignKey(
        User,
        on_delete=saffier.CASCADE,
        related_name="receiver",
    )
    message = saffier.TextField()

    class Meta:
        registry = models

As you can see, the model Thread has two foreign keys, sender and receiver. In a normal Saffier ORM operation, this remains as is but if you generate the declarative() model from Saffier then it will create automatically the following fields:

  • sender_relation
  • receiver_relation

And this becomes like the following:

Example

class UserAdmin(ModelAdmin, model=User):
    column_list = [User.id, User.email, User.first_name, User.last_name]
    form_ajax_refs = {
        'sender_relation': {
            'fields': ('email', 'first_name'),
        },
        'receiver_relation': {
            'fields': ('email', 'first_name'),
        }
    }

Connect the admin with Esmerald

Now it is the time to connect your newly created admin with your Esmerald application.

Go to myproject/main.py and update to the following:

import os
import sys
from pathlib import Path

from esmerald import Esmerald, Include, settings
from saffier import Migrate, Registry

from esmerald_admin import Admin
from esmerald_admin.backends.email import EmailAdminAuth


def build_path():
    """
    Builds the path of the project and project root.
    """
    Path(__file__).resolve().parent.parent
    SITE_ROOT = os.path.dirname(os.path.realpath(__file__))

    if SITE_ROOT not in sys.path:
        sys.path.append(SITE_ROOT)
        sys.path.append(os.path.join(SITE_ROOT, "apps"))


def get_migrations(app: Esmerald, registry: Registry) -> None:
    """
    Manages the saffier migrations
    """
    Migrate(app, registry)


def get_admin(app, registry):
    """Starts the Esmerald admin"""
    from accounts.models import User

    from .admin import get_views

    auth_backend = EmailAdminAuth(
        secret_key=settings.secret_key,
        auth_model=User,
        config=settings.jwt_config,
    )
    admin = Admin(app, registry.engine, authentication_backend=auth_backend)

    # Get the views
    get_views(admin)


def get_application():
    """
    This is optional. The function is only used for organisation purposes.
    """
    build_path()

    database, registry = settings.db_access

    app = Esmerald(
        routes=[Include(namespace="myproject.urls")],
        on_startup=[database.connect],
        on_shutdown=[database.disconnect],
    )

    # Migrations
    get_migrations(app, registry)

    # Admin
    get_admin(app, registry)

    return app


app = get_application()

This now contains all the needed ingredients for the admin to work.

  • The Admin is imported from esmerald_admin package.
  • The EmailAdminAuth is being used as the backend authentication of the admin.
  • The get_views() created in my_project/admin.py is being imported inside the get_admin().
  • The jwt_config previously set in the settings its being used in this admin configuration.
  • The get_admin is being called upon the instantiation of the Esmerald application.

Access the admin

Now it is time to finally access the admin.

Go to the root of myproject and run:

$ esmerald runserver

This will start the server in development mode and then in your browser go to http://localhost:8000/admin/ and login using the information previously created when the super user was created.

email: "john.doe@example.com"
password: "pass1234"
username: "johnd"

Warning

Although in the UI you see username, the EmailAdminAuth uses the email to login. If you really want to use the username instead of the email, use the UsernameAdminAuth instead.

And this is it. Quite a complex structure, clean, tidy and ready to use with Esmerald admin.

Notes

As described numerous times, 90% of the functionalities used with Esmerald admin are the same as the SQLAdmin as it is built on the top of it so make sure you also read the docs and start the repository as it was a lot of work from @aminalaee to get this working so well and without his work, this would not be possible.