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¶
- Esmerald directives - To generate a project scaffold with a given organisation.
- Esmerald contrib models for Saffier - To be easier to use some of the functionalities.
- Saffier ORM - The main ORM for this package.
- Esmerald admin EmailAdminAuth - The backend auth for the admin.
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 in
myproject`.
$ 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.
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
.
- Expose our database url:
export SAFFIER_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/mydb
- 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:
- Import the
settings
from theesmerald.conf
to allow to access thedb_access
property. Thedb_access
property as it was declared in the settings, is acached_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. on_startup/on_shutdown
events are used and needed for the application to interact with the database.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 fromesmerald_admin
package. - The EmailAdminAuth is being used
as the backend authentication of the
admin
. - The
get_views()
created inmy_project/admin.py
is being imported inside theget_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.