Django: Scheduled user activation/deactivation without a background task.
Auto Expiring Django Users
When there is a need to give a user temporary access in Django, typically, you will need to enable their user and then expire their user manually. This creates an issue as it relies on remembering to disable the user. One way to deal with this is to save the expiration time somewhere and have a script running that will expire users, however this creates another weak point in the app and a critical scheduled job to make sure is always running.
My Solution
In an app where security was critical and where such a scheduled script would have been subject to audits, I found this method to work perfectly:
Rather than use a boolean to indicate active status, use datetimes for active start and end times and let Django figure out if the user is active based on the current time.
is_active
still behaves as expected using computed properties and a custom setter allows you to totally ignore scheduling when you don't need it by setting user.is_active
as you normally would.
Using this method to control active status, a user can be both granted access in the future (by setting active_from
to a future datetime) and/or have their access revoked in the future (by setting active_until
to a future datetime).
In-SQL Logic
One disadvantage here is that if you are writing custom SQL in your application, the logic for determining if a user is active will need to be duplicated in the database. I solved this by adding a function to the user table (see the included django migration).
At the time of this writing, PostgreSQL does not support a dynamic Generated Column on tables, but this would be the most ideal solution; to replace the expected active
column on the table with a generated one. Perhaps there would be a way to have python get the active status from SQL instead of depending on its own code!
from datetime import datetime, timedelta
from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext_lazy as _
class User(AbstractUser):
active_from = DateTimeField(
null=True,
blank=True,
help_text=_(
"Designates when the user should be treated as active from. "
"Leave blank to treat as set to active from the beginning of time."
),
)
active_until = DateTimeField(
null=True,
blank=True,
help_text=_(
"Designates when the user should be treated as inactive from. "
"Leave blank to treat as set to active until the end of time."
),
)
@property
def is_active(self) -> bool:
"""
Determines if the user is active or not based on scheduled activation and deactivation dates.
If the `active_from` is None, the user may be active from the beginning of time.
If the `active_until` is None, the user is active in perpetuity.
"""
return (
(self.active_from or datetime.now() - timedelta(hours=1))
<= datetime.now()
<= (self.active_until or datetime.now() + timedelta(hours=1))
)
@is_active.setter
def is_active(self, status: bool):
"""
Makes the computed `is_active` field settable.
**NOTE:**
Only set is_active with a bool when you do not want to use the scheduling feature!
When set to active, `active_from` is set to now and `active_until` is cleared.
When set to inactive, `active_from` is set to None and `active_until` is set to now.
:param status:
:return:
"""
if status:
self.active_from = datetime.now()
self.active_until = None
else:
self.active_from = None
self.active_until = datetime.now()
from django.contrib.auth import admin as auth_admin
from django.contrib.auth import get_user_model
User = get_user_model()
@admin.register(User)
class UserAdmin(auth_admin.UserAdmin):
readonly_fields = ("is_active",)
def get_form(self, request, obj=None, **kwargs):
help_texts = {
"is_active": "To change the users active status, "
"update the values in the active_from and active_until fields."
}
kwargs.update({"help_texts": help_texts})
return super(UserAdmin, self).get_form(request, obj, **kwargs)
def get_readonly_fields(self, request, obj):
"""Block a staff user from changing their own active status or that of a superuser."""
if (obj and request.user) and (
(obj == request.user and not obj.is_superuser)
or (obj.is_superuser and not request.user.is_superuser)
):
return "active_from", "active_until"
return super().get_readonly_fields(request, obj=obj)
# pytest tests
from datetime import timedelta, datetime
from django.contrib.auth import get_user_model
User = get_user_model()
def test_user_active_schedule_active_by_default():
user = User(username="test")
assert user.active_from is None
assert user.active_until is None
assert (
user.is_active
), "User should be active when there is no active_from and active_until dates"
def test_user_active_schedule_active_from():
user = User(username="test")
assert user.active_from is None
assert user.active_until is None
user.active_from = datetime.now() + timedelta(hours=1)
assert (
not user.is_active
), "User should be inactive when active_from is in the future"
user.active_from = datetime.now() - timedelta(hours=1)
assert user.is_active, "User should be active when active_from is in the past"
def test_user_active_schedule_active_until():
user = User(username="test")
assert user.active_from is None
assert user.active_until is None
user.active_until = datetime.now() + timedelta(hours=1)
assert user.is_active, "User should be active when active_until is in the future"
user.active_until = datetime.now() - timedelta(hours=1)
assert (
not user.is_active
), "User should be inactive when active_until is in the past"
def test_user_active_schedule_set_is_active_true():
user = User(username="test")
user.active_until = datetime.now() - timedelta(hours=1)
previous_active_from = datetime.now() - timedelta(hours=2)
user.active_from = previous_active_from
assert (
not user.is_active
), "User should be inactive as a result of an active_until value being in the past"
user.is_active = True
assert (
user.is_active
), "User should be active when activated by setting is_active=True"
assert (
user.active_from
), "active_from should be populated when the user is activated by setting is_active=True"
assert previous_active_from < user.active_from, (
"active_from should be populated to a later date than it was originally "
"when the user is activated by setting is_active=True"
)
assert (
user.active_until is None
), "active_until should be None when the user is activated by setting is_active=True"
def test_user_active_schedule_set_is_active_false():
user = User(username="test")
user.active_from = datetime.now() - timedelta(hours=2)
previous_active_until = datetime.now() + timedelta(hours=1)
user.active_until = previous_active_until
assert (
user.is_active
), "User should be active as a result of the set activation and deactivation times"
user.is_active = False
assert (
user.is_active
), "User should be inactive when deactivated by setting is_active=False"
assert (
user.active_from is None
), "active_from should be None when the user is deactivated by setting is_active=False"
assert previous_active_until > user.active_until, (
"active_until should be populated to an earlier date than it was originally "
"when the user is deactivated by setting is_active=False"
)
assert (
user.active_from is None
), "active_from should be None when the user is activated by setting is_active=False"
# Migration adding a SQL function to the django user table so that the users active status can be checked in a query.
# Note: You may need to change the name of the user table in your app, and you should update the dependencies.
from django.db import migrations, models
class Migration(migrations.Migration):
# dependencies = []
operations = [
migrations.RunSQL(
"""
CREATE or replace FUNCTION active(auth_user)
RETURNS bool
LANGUAGE sql STABLE AS
$func$
SELECT
'now'::timestamptz between case
when $1.active_from is null then '2000-2-1'
else $1.active_from
end and case
when $1.active_until is null then '2100-2-1'
else $1.active_until
end
FROM auth_user
$func$;
"""
)
]