Boilerplate for SaaS product, with Django
While building our product, we had to write a backend to handle common SaaS features such as users, subscriptions, plans, accounts, etc. Since we shut down, we decided to open source the SaaS backend for anyone to use. Fyi, you can find our other open source work here.
This article explains the database schema for any generic SaaS tool and the codebase for common APIs such as
- Login
- Signup
- Reset Password
- Info about an invited user to pre-fill a signup form
- Invite team member
- Me
Database models for SaaS
To keep track of all the different registered businesses and the plans that those businesses are subscribed to, we need a schema that looks like this.
Each registered business might also have multiple users using that account.
We originally used this schema to build SessionFox which is a mobile app session recording tool that we wanted to sell as a SaaS and therefore designed our schema in this way.
I will now explain how you can extend each of these models for whatever SaaS you are building.
Plan
To store information about the different kinds of plans that the SaaS offers. You might want to offer a free plan with limited capabilities and one or more paid plans with additional functionality.
Beyond this, there could be more fields in this model depending upon the use case. In the context of SessionFox, we offered plans based on number of session recordings and number of applications, so the additional fields in the Plan model for SessionFox were
- Max recordings
- Max applications
Subscription
Represents a plan subscription by a business. It will therefore contain a reference to business and plan. Can be used to check if there is an active subscription present for a given business and if so, which plan is the business subscribed to.
Can also be used to renew, upgrade or deactivate a subscription based on the plan duration expiry, special credits for a particular business, etc.
Start time and End time represent one cycle of a plan. For instance, if a plan has a duration of 15 days, and the subscription began on the 15th of August, start time would be 15th of August and end time would be 30th of August.
There will be a periodic task that retrieves all subscriptions where end time has passed and based on the subscribed plan, this task will decide whether to renew the plan, or deactivate the subscription. For running periodic tasks, we use timeloop.
In the context of SessionFox, when a new business signs up, a subscription is automatically created with a free plan, and since most of the new signups were beta users, we needed a way to upgrade those users to an unlimited plan. To do this, we created an entry in the plan model called unlimited plan where max_recordings and max_applications was a huge number and changed their corresponding subscription model entry to reference the unlimited plan.
You can also use this model to store information that resets when a new plan cycle starts. For instance, in SessionFox once the business reached the max recording limit, we had to prevent any further recordings from that business. So we kept a field called current_recording_count in this model. This field is incremented whenever a new session recording is created and when it reaches the max limit of the corresponding plan, the recording API will return a response appropriately.
Since we were already running a periodic task to update the start time and end time of the subscription, we used the same task to reset the current_recording_count, so that for the next plan cycle, it starts from zero.
Business
Represents a subscriber of the SaaS tool.
Any additional fields that is specific to a business can be put in this model. For SessionFox, in order for the client to call the APIs, an API_KEY would be issued and this field can be set in the business model.
BusinessTeamMember
Now that we have all models required to track SaaS subscriptions for a business, we need a model to keep track of all the users that are accessing the product. These users could be
- Multiple team members from the same business
- Bots
Regardless of whether the user is a bot or a team member from the business, we need a way to define what permissions a particular user will posses. In the context of SessionFox, API calls need to be made to the server by the SDK as well as the dashboard. In order to do this, the SDK would be given an access token, but this access token should not posses permissions to access the dashboard APIs.
Restricting API access using permissions is something that is made quite trivial using django-rest-framework, which I will be talking about in detail in the later stages of this article.
There is also an activation_key in this model. This will come in handy when another team member is invited to use the account.
The invitee must be sent a link containing the activation key and opening this link must result in a redirection to the signup form which must be pre-filled with some of the existing information of the invitee including the business id by calling an API with the activation_key.
Django make things easier, here’s how
- Combined with django-rest-framework, API creation becomes very trivial
- Easy to manage authentication and permissions using auth token.
The database models mentioned above can be translated into django models like this.
Managing users and permission
The BusinessTeamMember model represents a single user. However there is no need to explicitly have fields for username and password. Instead we can use django users. This Model provided by django abstracts the user authentication system and it automatically stores passwords as hashes. The user can also be activated or deactivated by using the is_active field. Hence the BusinessTeamMember model can simply contain a foreign key to the django user model.
Using django rest framework, every django user created in the system can be assigned an access token, and if this access token is present in the header of an API call, django-rest-framework automatically queries the user to which this token belongs and attaches it to the API request. You can find out more about django-rest-frameworks authentication system here
Now we need a mechanism to have different access control on different APIs. This is done using django-rest-frameworks permissions. An API can be defined as follows
For SessionFox, we needed a way for our staff to login to any of the business’ account for Support/QA purposes. We also needed a user that can make API calls from the SDK. To do this, for every signup instead of creating a single BusinessTeamMember, we create 3 as shown below.
The auth token of the sdk_user is what we give out to a business as their API_KEY once they finish the signup process. They will then include this API_KEY in the manifest file of their Android app. And since we will restrict the permissions of an SDK user, even if this API_KEY gets leaked, no real damage can be done.
If a user needs to invite other users to use the account, we can simply expose an API that creates a new BusinessTeamMember with an activation key that is then mailed to the invitees. Once the invitee confirms activation from his mailbox, the is_active field in the user model can be toggled and a new user in the system is created.
If the invitee clicks on the activation link, he can be redirected to the signup form where the email address is pre-filled and the activation key is kept hidden. In order to pre-fill the form, an API can be called with the activation_key in the query params as follows:
When signup API is called, instead of following the regular signup flow, since this user already exists and only needs to be activated and have the password changed, you can do the following –
Business logic inside serializers
Django-rest-framework enables you to
- Validate request data
- Run business logic for the API
- Serialize response
And the best part is that all the above three operations can be encapsulated inside a serializer class. If the API inherits CreateAPIView
, first validate()
method inside the serializer is called to check if the incoming request is valid or not, after this create()
method is called with the validated data as the input. Therefore all business logic can be kept inside create()
. And any additional validations that need to be done on the request can be inside validate()
.
If a validate()
returns a validation error, a 400 response code is automatically sent.