TL;DR: In this series, you will learn how to create modern web applications with Python, Flask, and Angular. You will use this stack to build a SPA and a backend API to expose exams and questions so users can test their knowledge regarding different technologies. In this GitHub repository, you can find the final code created throughout the third part of the series (this one).
So far, this series contains three parts:
- Part 1 includes topics like bootstrapping the Flask application, managing Entities with SQLAlchemy ORM, and bootstrapping the Angular application.
- Part 2 includes topics like securing Flask Apps, handling Angular forms, and securing Angular Apps.
- Part 3 (this one) includes topics like configuring Angular Material, handling Authorization, and migrating Databases with Alembic.
What You Will Build
In this series, you will use Python, Flask, and Angular to build a web application based on a modern architecture. With Angular, you will build a SPA (Single Page App) that allows users to browse through exams and questions. These users, when authenticated, will be able to test their knowledge regarding a specific topic by choosing one of the multiple choices that a question exposes. Then, when your users submit their answers, your backend will check if they are right or wrong, record the result, and send back this result to users.
In this part of the series, you will start by installing and configuring Angular Material to add a nice interface to your app with ease. Then, you will use Angular Material components like Button, Card, Dialog, and Toolbar to enhance the look and feel of your application.
After that, you will refactor both the frontend and the backend apps to support some more features like question management. Throughout the process, you will learn how you can take advantage of roles to limit what some type of users can do. For example, although it might be a good idea to let the community help adding new exams and questions, you will want to restrict which users can update and delete existing ones.
"I'm building modern webapps with Angular, Flask, and Python!"
Tweet This
Installing and Configuring Angular Material
The official website contains some good documentation on how to install, configure, and use Angular Material on Angular applications. However, for newcomers, the information might look a little scattered. So, in this article, you are going to focus on what is needed to get up and running with this UI (User Interface) component framework as fast as possible.
So, the first thing you will need to do is to install some dependencies:
# make sure you move the cursor to the frontend directory
cd frontend
# install dependencies with NPM
npm i @angular/material @angular/cdk hammerjs
You will need the first two libraries in the command above, @angular/material
and @angular/cdk
, to use Angular Material in any application. You will also install the third library, hammerjs
, to add gesture support to your app.
After installing these dependencies, you will need to update the index.html
file. The new version of this file will add two other external dependencies (the Roboto font and Material Icons) and will make Angular Material default style apply to all native elements on your app:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Online Exams</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
</head>
<body>
<section class="mat-typography">
<app-root></app-root>
</section>
</body>
</html>
Then, you will want to update the styles.css
file to apply an Angular Material theme (in this case Indigo Pink) and to remove the margin applied by browsers on the body
element:
@import "~@angular/material/prebuilt-themes/indigo-pink.css";
body {
margin: 0;
}
Also, before moving into the process of adding Angular Material components, you will need to update the main.ts
file to apply hammer.js
:
import 'hammerjs';
// ... everything else ...
With these changes in place, you have properly installed and configured Angular Material in your application. So, it's a good time to save your work:
git add . && git commit -m "installing and configuring Angular Material"
Using Angular Material Components
Now that you finished configuring Angular Material, you can move forward and make use of the first components to build an appealing UI. More precisely, you will start by using the Toolbar and Button components to add a navigation bar to the top of your application.
To use these components, you will need to add them to your AppModule
. So, open the app.module.ts
file and update it as follows:
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {MatToolbarModule, MatButtonModule} from '@angular/material';
// ... other imports ...
// ... const appRoutes
@NgModule({
// ... declarations ...
imports: [
// ... other imports ...
NoopAnimationsModule,
MatToolbarModule,
MatButtonModule,
],
// ... providers and boostrap ...
})
// ... export class AppModule ...
Note: In the code snippet above, you are using
NoopAnimationsModule
to avoid adding another dependency to your project. If you are interested in using animations, check this URL.
Then, you can refactor the AppComponent
class to use these components. So, open the app.component.ts
file and replace its code with the following one:
import {Component, OnInit} from '@angular/core';
import * as Auth0 from 'auth0-web';
@Component({
selector: 'app-root',
template: `
<mat-toolbar color="primary" class="mat-elevation-z10">
<button mat-button>Online Exams</button>
<button mat-button>About</button>
<!-- This fills the remaining space of the current row -->
<span class="fill-remaining-space"></span>
<button mat-button (click)="signIn()" *ngIf="!authenticated">Sign In</button>
<button mat-button (click)="signOut()" *ngIf="authenticated">Sign Out</button>
</mat-toolbar>
<router-outlet></router-outlet>
`,
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
authenticated = false;
signIn = Auth0.signIn;
signOut = Auth0.signOut;
ngOnInit() {
const self = this;
Auth0.subscribe((authenticated) => (self.authenticated = authenticated));
}
}
The new version of this file adds the mat-toolbar
element to the top of the page with four buttons:
- Online Exams: You will make this button redirect users to the home page.
- About: You will create a view called About and make this button redirect users there.
- Sign In: You added this button to allow visitors to sign in to your application.
- Sign Out: You added this button to allow users to close their current session.
What is interesting about these changes is that, since the AppComponent
also defines where other views will appear (through the router-outlet
element), the mat-toolbar
element will be visible to all your views.
Note that you also made use of the Button component provided by the Angular Material framework by adding the mat-button
directive to button
elements. Besides that, you have added a span
element to your toolbar that uses a class called fill-remaining-space
. The goal of this class is to make this element fill all the unused space in the navbar. As you haven't defined the rules of this class yet, open the app.component.css
file and add the following code to it:
.fill-remaining-space {
/* This fills the remaining space, by using flexbox.
Every toolbar row uses a flexbox row layout. */
flex: 1 1 auto;
}
Now, as you have moved the Sign In and Sign Out buttons to the AppComponent
, you can remove them and the related code from the ExamsComponent
. So, open the exams.component.ts
file and replace its code with this:
import * as Auth0 from 'auth0-web';
import {Component, OnDestroy, OnInit} from '@angular/core';
import {Subscription} from 'rxjs/Subscription';
import {Exam} from './exam.model';
import {ExamsApiService} from './exams-api.service';
@Component({
selector: 'exams',
template: `
<div>
<button routerLink="/new-exam">New Exam</button>
<ul>
<li *ngFor="let exam of examsList">
{{exam.title}}
</li>
</ul>
</div>
`
})
export class ExamsComponent implements OnInit, OnDestroy {
examsListSubs: Subscription;
examsList: Exam[];
authenticated = false;
constructor(private examsApi: ExamsApiService) { }
ngOnInit() {
this.examsListSubs = this.examsApi
.getExams()
.subscribe(res => {
this.examsList = res;
},
console.error
);
const self = this;
Auth0.subscribe((authenticated) => (self.authenticated = authenticated));
}
ngOnDestroy() {
this.examsListSubs.unsubscribe();
}
}
That's it! If you run your application now, you will see a nice blueish navigation bar.
# not really needed, but if you want to see exams run the Flask app
cd ../backend
./bootstrap.sh &
cd ../frontend
# start the local development server
npm start
Before proceeding to the next section, save your work!
git add . && git commit -m "adding navbar to the Angular app"
"Using Angular Material is easy and straightforward."
Tweet This
Using Angular Material Cards
After adding a nice navigation bar to your Angular application, you can enhance the look and feel of the view that shows existing exams. In this view, you can use the Card component to show each exam in separately and some nice action buttons to enable users to add new exams and to start practicing their knowledge.
To make these changes in your app, you will need to update the app.module.ts
file as follows:
import {
MatToolbarModule, MatButtonModule, MatCardModule
} from '@angular/material';
// ... other imports and appRoutes definition ...
@NgModule({
// ... declarations ...
imports: [
// ... other imports ...
MatCardModule,
],
// ... providers and bootstrap ...
})
// ... export class AppModule ...
After that, you will have to change the template
property of the ExamsComponent
and add a style sheet (styleUrls
) to it. So, open the exams.component.ts
file and update it as follows:
// ... import statements ...
@Component({
selector: 'exams',
template: `
<h2>Exams</h2>
<p>Choose an exam and start studying.</p>
<div class="exams">
<mat-card class="example-card" *ngFor="let exam of examsList" class="mat-elevation-z5">
<mat-card-content>
<mat-card-title>{{exam.title}}</mat-card-title>
<mat-card-subtitle>{{exam.description}}</mat-card-subtitle>
<p>
Etiam enim purus, vehicula nec dapibus quis, egestas eu quam.
Nullam eleifend auctor leo, vitae rhoncus mi sodales vel.
Aenean fermentum laoreet volutpat. Integer quam orci,
molestie non nibh suscipit, faucibus euismod sapien.
</p>
<button mat-raised-button color="accent">Start Exam</button>
</mat-card-content>
</mat-card>
</div>
<button mat-fab color="primary" *ngIf="authenticated"
class="new-exam" routerLink="/new-exam">
<i class="material-icons">note_add</i>
</button>
`,
styleUrls: ['exams.component.css'],
})
// ... export class ExamsComponent ...
In this version of the ExamsComponent
, you are iterating over the examsList
retrieved from the Flask application to create mat-card
elements. Each of these elements is composed of a mat-card-title
to show the exams titles and a mat-card-subtitle
to show its descriptions. Also, you added a p
element with some placeholder text to simulate the long description property of the exams. Soon, you will replace this placeholder with real data retrieved from the backend. Still on the mat-card
elements, you are adding a pink action button (<button mat-raised-button color="accent"
) that will enable users to start taking the exam in question.
Lastly, you are adding another action button, this time blue (<button mat-fab color="primary" ...
), to enable authenticated users (*ngIf="authenticated"
) to access the form where users can add new exams.
Now, to make your UI more appealing, you will need to create the exams.component.css
file in the same directory of exams.component.ts
and add the following code to it:
div.exams {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 15px;
}
button.new-exam {
position: fixed;
bottom: 15px;
right: 15px;
}
@media (max-width: 720px) {
div.exams {
grid-template-columns: 1fr;
}
}
These CSS rules take advantage of the Grid layout system to show two columns of exams on large devices and a single column on smaller ones (max-width: 720px
). It also defines that the button to add new exams will stay fixed on the screen on the bottom right corner.
After refactoring the ExamsComponent
, you will need to make a small change to the AppComponent
to set a maximum width to your views and to make them appear in the middle of the screen on large devices. So, open the app.component.ts
file and encapsulate the <router-outlet></router-outlet>
element inside a new div
:
<div class="view-container">
<router-outlet></router-outlet>
</div>
Then, in the app.component.css
file, add the following rule:
/* ... other rules ... */
div.view-container {
padding: 15px;
margin: 0 auto;
max-width: 940px;
}
Now, if you check your Angular application again (remember, you can always start it by simply issuing npm start
in the frontend
directory), you will see a screen similar to the following one:
In the screen, the user is signed in (hence, the Sign Out button and the action button to add new exams) and there are three exams persisted on the backend.
Not bad, huh? Time to save your work:
git add . && git commit -m "using Angular Material Cards and CSS Grid layout to show exams"
Handling Authorization Through Roles
Now that you enhanced the UI of your application, it's time to enable users to remove existing exams. To do that, you will categorize your users into two groups: admin
and user
. To make things easier, you will just define that your own user (i.e. the one that contains your email address) will be the admin
and everyone else will be set as a normal user
. You will handle this logic through Auth0 Rules.
So, open the Rules section on your Auth0 dashboard and hit the Create Rule button. Then, in the Pick a rule template page, click on the empty rule option. After that, Auth0 will redirect you to a form with two fields, one to input the name of the rule (you can call it Online Exams Roles) and one to input its source code.
After defining a name for your new rule, paste the following code into it:
// Set 'admin' role for bruno.krebs@auth0.com and 'user' for everyone else
// Save app_metadata to ID and access tokens
function (user, context, callback) {
// Roles should only be set to verified users.
if (!user.email || !user.email_verified) {
return callback(null, user, context);
}
user.app_metadata = user.app_metadata || {};
var addRolesToUser = (user, cb) => {
if (user.email === 'bruno.krebs@auth0.com') {
cb(null, ['admin']);
} else {
cb(null, ['user']);
}
};
addRolesToUser(user, (err, roles) => {
if (err) return callback(err);
user.app_metadata.roles = roles;
auth0.users.updateAppMetadata(user.user_id, user.app_metadata).then(() => {
const namespace = 'https://online-exams.com/roles';
const userRoles = user.app_metadata.roles;
context.idToken[namespace] = userRoles;
context.accessToken[namespace] = userRoles;
callback(null, user, context);
}).catch(callback);
});
}
This rule is quite simple. It just checks the email
address of the user that is authenticating and, if it is bruno.krebs@auth0.com
, it sets the admin
role to them. If it is not this email address, it sets user
as the user's role. You will want to replace the email address used in the code snippet above with your own one.
Note: The namespace identifier used above can be any non-Auth0 HTTP or HTTPS URL and does not have to point to an actual resource. Auth0 enforces this recommendation from OIDC regarding additional claims and will silently exclude any claims that do not have a namespace. You can read more about implementing custom claims with Auth0 here.
With this rule in place, you can focus on your source code again. In your backend, the first thing you will do is to create a decorator to facilitate defining which endpoints need which roles. So, open the auth.py
file (it in the ./backend/src/
directory) and add the following code to it:
def requires_role(required_role):
def decorator(f):
def wrapper(**args):
token = get_token_auth_header()
unverified_claims = jwt.get_unverified_claims(token)
# search current token for the expected role
if unverified_claims.get('https://online-exams.com/roles'):
roles = unverified_claims['https://online-exams.com/roles']
for role in roles:
if role == required_role:
return f(**args)
raise AuthError({
'code': 'insuficient_roles',
'description': 'You do not have the roles needed to perform this operation.'
}, 401)
return wrapper
return decorator
What this code does is to define a decorator called requires_role
role that takes one argument: required_role
. Then, with this argument, it creates a wrapper around the annotated function that checks if any request reaching it contains a valid token with the required_role
.
Note: It is important that you use the same namespace (e.g.
https://online-exams.com/roles
) used while creating your Auth0 rule.
If the token in question contains the expected role, the wrapper calls the original function (the endpoint). Otherwise, it raises an AuthError
stating that the user lacks privilege.
To use this decorator, you will create a new endpoint that allows administrators (i.e. users with the admin
role) to delete existing exams. So, open the file that contains your endpoint definitions (./backend/src/main.py
) and adds the following code to it:
# coding=utf-8
# ... other import statements ...
from auth import requires_auth, requires_role
# ... app definition and other endpoints ...
@app.route('/exams/<examId>', methods=['DELETE'])
@requires_role('admin')
def delete_exam(examId):
session = Session()
exam = session.query(Exam).filter_by(id=examId).first()
session.delete(exam)
session.commit()
session.close()
return '', 201
# ... error handler ...
Here, you are simply defining a new endpoint that accepts DELETE
requests to remove exams. These requests must contain the exam id (<examId>
) and must be issued by a user with the admin
role (@requires_role('admin')
). If these premises are fulfilled, the endpoint fetches an instance from the database and use it on the session.delete
call.
From the backend perspective, that's it. Now, you can refactor your frontend code to support the new feature. To do this, you can start by opening the exams-api.service.ts
file (it resides in the ./frontend/src/app/exams/
directory) and add the following method to the service:
// ... import statements ...
@Injectable()
export class ExamsApiService {
// ... constructor and other methods ...
deleteExam(examId: number) {
const httpOptions = {
headers: new HttpHeaders({
'Authorization': `Bearer ${Auth0.getAccessToken()}`
})
};
return this.http
.delete(`${API_URL}/exams/${examId}`, httpOptions);
}
}
The code in this new method is trivial, right? It just calls the delete
method, on the HttpClient
instance, pointing to the endpoint that you created in your backend. As you were probably expecting, you need to add the access_token
to this call so your backend can validate it and its roles.
Now, to use this new method, open the exams.component.ts
file and update it as follows:
// ... import statements
@Component({
selector: 'exams',
template: `
<h2>Exams</h2>
<p>Choose an exam and start studying.</p>
<div class="exams">
<mat-card class="example-card" *ngFor="let exam of examsList" class="mat-elevation-z5">
<mat-card-content>
<mat-card-title>{{exam.title}}</mat-card-title>
<mat-card-subtitle>{{exam.description}}</mat-card-subtitle>
<p>
Etiam enim purus, vehicula nec dapibus quis, egestas eu quam.
Nullam eleifend auctor leo, vitae rhoncus mi sodales vel.
Aenean fermentum laoreet volutpat. Integer quam orci,
molestie non nibh suscipit, faucibus euismod sapien.
</p>
<button mat-raised-button color="accent">Start Exam</button>
<button mat-button color="warn" *ngIf="isAdmin()"
(click)="delete(exam.id)">
Delete
</button>
</mat-card-content>
</mat-card>
</div>
<button mat-fab color="primary" *ngIf="authenticated"
class="new-exam" routerLink="/new-exam">
<i class="material-icons">note_add</i>
</button>
`,
styleUrls: ['exams.component.css'],
})
export class ExamsComponent implements OnInit, OnDestroy {
// ... class properties, constructor, and other methods ...
delete(examId: number) {
this.examsApi
.deleteExam(examId)
.subscribe(() => {
this.examsListSubs = this.examsApi
.getExams()
.subscribe(res => {
this.examsList = res;
},
console.error
)
}, console.error);
}
isAdmin() {
if (!Auth0.isAuthenticated()) return false;
const roles = Auth0.getProfile()['https://online-exams.com/roles'];
return roles.includes('admin');
}
}
The only difference on the template
of this component is that, now, it includes a Delete button that is showed to admins only (*ngIf="isAdmin()"
). When clicked, this button calls the delete
method to issue the DELETE
request to the backend. Then, when the request finishes, this method calls the getExams
method on examsApi
to update the list of exams.
That's it! It was easy to use roles to control who can delete exams and who cannot, right?
Hey! Don't forget to save your progress:
git add . && git commit -m "enabling admins to delete exams"
Migrating Databases with Alembic
After dealing with authorization and roles, you will want to improve your application adding new features. As adding new features usually means making changes to your database (so it can support these new features), you will need a database migration tool to upgrade the database schema. Why? Mainly because you don't want to lose your data when upgrading and because you want to keep track of the changes in the long run.
So, in this section, you are going to learn about Alembic, a migration tool developed by the author of SQLAlchemy. As you will see, its usage is quite simple. First, you will need to install the alembic
package in your Python application:
# move to the backend directory
cd ./backend
# make sure pipenv is activated
pipenv shell
# install alembic
pipenv install alembic
After installing alembic
, you can use issue the following command to make it auto-configure itself:
# run it in the backend directory
alembic init migrations
This command will create most of the files in the migrations
directory. You won't need to touch the files in this directory now. Actually, the one file you will need to change is the alembic.ini
file that resides outside it (you will find it in the backend
directory itself).
Opening this file, you will see a property called sqlalchemy.url
. Replace the value of this property so Alembic can connect to your database. If you followed the instructions to the letter, you can replace it as follows:
sqlalchemy.url = postgresql://postgres:0NLIN3-ex4m@localhost:5432/online-exam
Now, Alembic is able to connect itself to your database, so it's time to create your first migration. To illustrate the process, you will create a simple migration to add the long_description
column to the exams
table. So, in your terminal, issue the following code:
alembic revision -m "add long_description to exams"
This will make Alembic create a new file (under ./backend/migrations/versions
) called something similar to 28bab18a07f0_add_long_description_to_exams.py
. Opening this file you will see that it contains an import
section, some variable, and two functions: upgrade
and downgrade
. For now, you are just interested in the first function, upgrade
. In this function, you will write a one-liner code to make Alembic and SQLAlchemy add the long_description
column to the exams
table:
# ... imports and other variables ...
def upgrade():
op.add_column('exams', sa.Column(
'long_description',
sa.Text,
nullable=False,
server_default='Default exam description'))
# ... downgrade ...
Note: This migration defines that the
long_description
column does not accept null values and that its default value is "Default exam description". So, any exam previously persisted in your database will have this value in thelong_description
column.
Now, to run this migration, issue the following command in your terminal:
alembic upgrade head
After running it, you will need to update the Exam
entity in your Python application. So, open the exam.py
file and update it as follows:
# ... import statements ...
class Exam(Entity, Base):
# ... __tablename__ and other properties ...
def __init__(self, title, description, long_description, created_by):
# ... __init__ and other properties ...
self.long_description = long_description
class ExamSchema(Schema):
# ... other fields ...
long_description = fields.Str()
Lastly, you will need to make a small change to the main.py
file to make your endpoint aware of the new property:
# ... imports, definitions, and other endpoints ...
@app.route('/exams', methods=['POST'])
@requires_auth
def add_exam():
# mount exam object
posted_exam = ExamSchema(only=('title', 'description', 'long_description')) \
.load(request.get_json())
# ... the rest of the function ...
# ... imports, definitions, and other endpoints ...
With these changes in place, your Python backend application is now ready to persist long descriptions of exams. So, in the next section, you will refactor the frontend app to support it as well.
Note: Here, you learned just the basics about Alembic. For a much more complete explanation of how it works and what you can do with this tool, check the official Alembic documentation.
Time to save your progress!
git add . && git commit -m "configuring alembic and adding the long_description column"
Refactoring the Exam's Form
In this section, you will make your frontend application support the long_description
property. Also, you will Angular Material components to make the exam's form look better. So, for starters, you will need to import the MatInputModule
into your AppModule
. To do this, open the app.module.ts
file and update it as follows:
import {
MatToolbarModule, MatButtonModule, MatCardModule, MatInputModule
} from '@angular/material';
// ... other imports ...
@NgModule({
// ... declarations ...
imports: [
// ... other imports ...
MatInputModule,
],
// ... providers and bootstrap ...
})
// ... export class AppModule ...
After that, you will refactor the ExamFormComponent
. So, open the exam-form.component.ts
file and replace its content with this:
import {Component} from '@angular/core';
import {ExamsApiService} from "./exams-api.service";
import {Router} from "@angular/router";
@Component({
selector: 'exam-form',
template: `
<mat-card>
<h2>New Exam</h2>
<mat-form-field class="full-width">
<input matInput
placeholder="Title"
(keyup)="updateTitle($event)">
</mat-form-field>
<mat-form-field class="full-width">
<input matInput
placeholder="Description"
(keyup)="updateDescription($event)">
</mat-form-field>
<mat-form-field class="full-width">
<textarea rows="5"
matInput
placeholder="Long Description"
(keyup)="updateLongDescription($event)"></textarea>
</mat-form-field>
<button mat-raised-button
color="primary"
(click)="saveExam()">
Save Exam
</button>
</mat-card>
`,
styles: [`
.exams-form {
min-width: 150px;
max-width: 500px;
width: 100%;
}
.full-width {
width: 100%;
}
`]
})
export class ExamFormComponent {
exam = {
title: '',
description: '',
long_description: '',
};
constructor(private examsApi: ExamsApiService, private router: Router) { }
updateTitle(event: any) {
this.exam.title = event.target.value;
}
updateDescription(event: any) {
this.exam.description = event.target.value;
}
updateLongDescription(event: any) {
this.exam.long_description = event.target.value;
}
saveExam() {
this.examsApi
.saveExam(this.exam)
.subscribe(
() => this.router.navigate(['/']),
error => alert(error.message)
);
}
}
The new version of this component includes a whole new template
where you are using three Angular Material components: Card (mat-card
), Button (mat-raised-button
), and Form Field (mat-form-field
). It also includes a CSS rule to make form fields (mat-form-field
) fill all the horizontal space (width: 100%;
). Lastly, it includes the long_description
field in the exam
variable and a method called updateLongDescription
to update this field.
If you run your application now and head to this form, you will see a screen like the following:
So, to wrap up this new property, open the exams.component.ts
file and replace the long description placeholder (the "Etiam enim purus, vehicula ..." text inside <p></p>
) with this:
{{exam.long_description}}
Done! Now, both your backend and your frontend support the long description property of exams. So it's time to save your progress:
git add . && git commit -m "supporting long_description on the frontend"
Conclusion and Next Steps
In the third part of this series, you learned how to install and configure Angular Material components. Then, you used these components to add a nice interface to your Angular application. After that, you learned how to use Auth0 rules to add roles to your users and made both your backend and frontend projects aware of these roles. In the end, you learned about Alembic, a database migration tool, and used it to add a new property to exams: their long description.
In the upcoming articles, you are going to enable users to add questions to your exams and you will also let them start taking these exams. Also, you will start preparing your code for CI/CD (Continuous Integration and Continuous Delivery) tools. These tools will help you automate the development and release pipeline.
Stay tuned!