django-conditions documentation¶
About¶
django-conditions is a Django app that allows creation of conditional logic in admin. Some possible use cases:
- Segment your user base into cohorts with targeted messaging
- Provide different rewards to users depending on their expected value
- In a game, define the winning objectives of a mission/quest
- and many more…
django-conditions supports Django 2.2-3.2 and Python 3.7-3.10.
Installation¶
First install the django-conditions
package:
pip install django-conditions
Then add conditions
to your INSTALLED_APPS
setting:
## settings.py
INSTALLED_APPS = [
...
'conditions',
]
Contents¶
Basic Usage¶
Start by defining a condition in code:
## condition_types.py
from conditions import Condition
class FullName(Condition):
# The name that appears in the db and represents your condition
condstr = 'FULL_NAME'
# Normal conditions define eval_bool, which takes in a user
# and returns a boolean
def eval_bool(self, user, **kwargs):
return bool(user.first_name and user.last_name)
Then add a ConditionsField to your model:
## models.py
from django.db import models
from conditions import ConditionsField, conditions_from_module
import condition_types
class Campaign(models.Model):
text = models.TextField()
# The ConditionsField requires the definitions of all possible conditions
# conditions_from_module can take an imported module and sort this out for you
target = ConditionsField(definitions=conditions_from_module(condition_types))
In the model’s change form on admin, you can enter JSON to represent when you want your condition to be satisfied.
{
"all": ["FULL_NAME"]
}
Now you can use the logic you created in admin to determine the outcome of an event:
## views.py
from django.http import HttpResponse
from conditions import eval_conditions
from models import Campaign
def profile(request):
for campaign in Campaign.objects.all():
if eval_conditions(campaign, 'target', request.user):
return HttpReponse(campaign.text)
return HttpResponse("Nothing new to see.")
Use django-conditions in your Django projects to change simple logic without having to re-deploy, and pass on the power to product managers and other non-engineers.
Condition Lists¶
Once you’ve created a few Conditions, you can combine them in many different ways.
To require multiple conditions to be satisfied at the same time, use an “all” list:
{
"all": ["FULL_NAME", "ACTIVE", "SUPERUSER"]
}
To allow just one of a set of conditions, use an “any” list:
{
"any": ["FULL_NAME", "FB_CONNECTED", "EMAIL_VERIFIED"]
}
Of course the lists may be nested:
{
"all": [
"ACTIVE",
"SUPERUSER",
{
"any": [
"FULL_NAME",
"FB_CONNECTED",
"EMAIL_VERIFIED"
]
}
]
}
You may also add NOT
to the beginning of any condition to evaluate its negation:
{
"all": ["FULL_NAME", "ACTIVE", "NOT SUPERUSER", "NOT STAFF"]
}
Operators and Operands¶
Conditions that subclass CompareCondition are slightly more advanced than regular Conditions. You can use them to make comparisons with numbers (or other operands).
from conditions import CompareCondition
class Level(CompareCondition):
condstr = 'LEVEL'
# CompareConditions define eval_operand instead of eval_bool
# which returns an operand instead
def eval_operand(self, user, **kwargs):
return user.profile.level
In JSON, numbers can be compared using the normal boolean operators you see in Python (<
, <=
, ==
, !=
, >
, and >=
):
{
"all": ["FULL_NAME", "LEVEL >= 5"]
}
By default, a CompareCondition expects a float as the operand, but that can be overwritten by defining the cast_operand attribute. You can also define your own operators by writing your own operators function:
import datetime
from conditions import CompareCondition
class DateJoined(CompareCondition):
condstr = 'DATE_JOINED'
cast_operand = lambda self, timestamp: datetime.datetime.strptime(timestamp, "%m/%d/%Y")
@classmethod
def operators(cls):
return {
'<': datetime.datetime.__lt__,
'<=': datetime.datetime.__le__,
'==': datetime.datetime.__eq__,
'!=': datetime.datetime.__ne__,
'>=': datetime.datetime.__ge__,
'>': datetime.datetime.__gt__,
}
def eval_operand(self, user, **kwargs):
return user.date_joined.strftime("%m/%d/%Y")
In the admin interface, an appropriate example is randomly generated for each Condition available. When the operand is a number, a number will be generated randomly for the example automatically. However, with other types of operands this isn’t possible so the example will simply show SOME_OPERAND_HERE
. If you like, you can instead show an appropriate example randomly selected from a list you define:
class DateJoined(CompareCondition):
condstr = 'DATE_JOINED'
cast_operand = timestamp2datetime
operand_examples = ['04/23/1995', '01/01/2015', '08/13/2014',]
...
Using Keys¶
Sometimes you want to generalize a Condition so that the user entering the Conditions JSON can provide an arbitrary string that changes how the Condition gets evaluated slightly. In these cases, you can use a key:
class EmailDomain(Condition):
condstr = 'EMAIL_DOMAIN'
def eval_bool(self, user, **kwargs):
domain = user.email.split('@')[1]
return domain == self.key
{
"any": ["EMAIL_DOMAIN gmail.com", "EMAIL_DOMAIN yahoo.com"]
}
When the Conditions "EMAIL_DOMAIN gmail.com"
and "EMAIL_DOMAIN yahoo.com"
get evaluated, self.key
will contain the strings “gmail.com” and “yahoo.com” respectively.
Of course, the admin interface once again does not know what keys are appropriate so by default, the random example will simply say SOME_KEY_HERE
. You can define key_examples
as a list similar to operand_examples
, or if the set of all possible keys is finite, you may define keys_allowed
to actually restrict the user entering the Conditions JSON to one of the options from a list:
class EmailDomain(Condition):
condstr = 'EMAIL_DOMAIN'
key_examples = ['gmail.com', 'yahoo.com', 'hotmail.com']
...
Providing Additional Context¶
Sometimes you want to evaluate a condition on more information than just a user object. For these cases, you can provide any arbitrary keyword arguments to eval_conditions
:
## views.py
from django.http import HttpResponse
from conditions import eval_conditions
from models import Campaign
def room(request, room_num):
for campaign in Campaign.objects.all():
if eval_conditions(campaign, 'target', request.user, room=room_num):
return HttpReponse(campaign.text)
return HttpResponse("Nothing in this room.")
Any keyword arguments are then passed through to every condition that needs to be evaluated:
class InRoom(Condition):
condstr = 'IN_ROOM'
def eval_bool(self, user, **kwargs):
room_num = kwargs['room']
return room_num == int(self.key)
Handling Exceptions¶
When an exception is raised in the evaluation of a Condition, it fails silently by returning False.
# An example where the condition will raise a ValueError
class Broken(Condition):
condstr = 'BROKEN'
def eval_bool(self, user, **kwargs):
raise ValueError("This condition is broken.")
Failing silently often avoids major problems, but generally, you still want to know when these issues are popping up. django-conditions provides a simple way using Python’s built-in logging framework.
To record these exceptions, add a logger for condition
in the LOGGING
configuration dictionary, and handle it however you like. Here is an example, modified from Django’s logging page:
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'file': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': '/path/to/django/debug.log',
},
},
'loggers': {
'django.request': {
'handlers': ['file'],
'level': 'DEBUG',
'propagate': True,
},
'condition': {
'handlers': ['file'],
'level': 'DEBUG',
}
},
}