Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions kolibri/core/analytics/test/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@


class PingbackNotificationTestCase(APITestCase):
databases = "__all__"

@classmethod
def setUpTestData(cls):
provision_device()
Expand Down Expand Up @@ -71,6 +73,8 @@ def test_filter_by_semantic_versioning(self):


class PingbackNotificationDismissedTestCase(APITestCase):
databases = "__all__"

@classmethod
def setUpTestData(cls):
provision_device()
Expand Down
14 changes: 0 additions & 14 deletions kolibri/core/auth/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@

from .constants import collection_kinds
from .constants import role_kinds
from .middleware import clear_user_cache_on_delete
from .models import Classroom
from .models import Collection
from .models import Facility
Expand Down Expand Up @@ -593,7 +592,6 @@ def destroy(self, request, *args, **kwargs):
user = self.get_object()
user.date_deleted = now()
user.save()
self._invalidate_removed_users_session([user])
try:
cleanup_expired_deleted_users.enqueue()
except JobRunning:
Expand All @@ -603,22 +601,10 @@ def destroy(self, request, *args, **kwargs):
# Bulk deletion
return self.bulk_destroy(request, *args, **kwargs)

def _invalidate_removed_users_session(self, users):
"""
Invalidate removed users sessions by clearing their cache.
So the next time they make a request, the auth middleware will try to fetch
the most up-to-date information for the user, and won't find the user
since it was soft deleted.
"""
for user in users:
clear_user_cache_on_delete(None, user)

def perform_bulk_destroy(self, objects):
if objects.filter(id=self.request.user.id).exists():
raise PermissionDenied("Super user cannot delete self")
removed_users = list(objects)
objects.update(date_deleted=now())
self._invalidate_removed_users_session(removed_users)

def perform_update(self, serializer):
instance = serializer.save()
Expand Down
27 changes: 27 additions & 0 deletions kolibri/core/auth/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
The appropriate classes should be listed in the AUTHENTICATION_BACKENDS. Note that authentication
backends are checked in the order they're listed.
"""
from django.contrib.sessions.backends.cached_db import SessionStore as CachedDBStore

from kolibri.core.auth.models import FacilityUser
from kolibri.core.auth.models import Session


FACILITY_CREDENTIAL_KEY = "facility"
Expand Down Expand Up @@ -71,3 +74,27 @@ def get_user(self, user_id):
return FacilityUser.objects.get(pk=user_id)
except FacilityUser.DoesNotExist:
return None


class SessionStore(CachedDBStore):
@classmethod
def get_model_class(cls):
return Session

def create_model_instance(self, data):
obj = super().create_model_instance(data)
try:
user_id = data.get("_auth_user_id")
except (ValueError, TypeError):
user_id = None
obj.user_id = user_id
return obj

@classmethod
def delete_all_sessions(cls, user_ids):
store = cls()
sessions = store.get_model_class().objects.filter(user_id__in=user_ids)
session_keys = sessions.values_list("session_key", flat=True)
for session_key in session_keys:
store._cache.delete(store.cache_key_prefix + session_key)
sessions.delete()
44 changes: 44 additions & 0 deletions kolibri/core/auth/migrations/0031_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Generated by Django 3.2.25 on 2025-09-17 02:30
import morango.models.fields.uuids
from django.db import migrations
from django.db import models


class Migration(migrations.Migration):

dependencies = [
("kolibriauth", "0030_alter_facilityuser_managers"),
]

operations = [
migrations.CreateModel(
name="Session",
fields=[
(
"session_key",
models.CharField(
max_length=40,
primary_key=True,
serialize=False,
verbose_name="session key",
),
),
("session_data", models.TextField(verbose_name="session data")),
(
"expire_date",
models.DateTimeField(db_index=True, verbose_name="expire date"),
),
(
"user_id",
morango.models.fields.uuids.UUIDField(
blank=True, db_index=True, null=True
),
),
],
options={
"verbose_name": "session",
"verbose_name_plural": "sessions",
"abstract": False,
},
),
]
110 changes: 107 additions & 3 deletions kolibri/core/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.models import UserManager
from django.contrib.sessions.base_session import AbstractBaseSession
from django.core import validators
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError
Expand All @@ -35,6 +36,8 @@
from morango.models import Certificate
from morango.models import SyncableModel
from morango.models import SyncableModelManager
from morango.models import SyncableModelQuerySet
from morango.models import UUIDField
from mptt.models import TreeForeignKey

from .constants import collection_kinds
Expand Down Expand Up @@ -78,12 +81,44 @@
from kolibri.core.errors import KolibriValidationError
from kolibri.core.fields import DateTimeTzField
from kolibri.core.fields import JSONField
from kolibri.core.utils.model_router import KolibriModelRouter
from kolibri.core.utils.validators import JSON_Schema_Validator
from kolibri.deployment.default.sqlite_db_names import SESSIONS
from kolibri.plugins.app.utils import interface
from kolibri.utils.time_utils import local_now

logger = logging.getLogger(__name__)


class Session(AbstractBaseSession):
"""
Custom session model with user_id tracking for session management.
Inherits from Django's AbstractBaseSession and adds user_id field.
"""

user_id = UUIDField(blank=True, null=True, db_index=True)

@classmethod
def get_session_store_class(cls):
from .backends import SessionStore

return SessionStore

@classmethod
def delete_all_sessions(cls, user_ids):
store_class = cls.get_session_store_class()
store_class.delete_all_sessions(user_ids)


class SessionRouter(KolibriModelRouter):
"""
Determine how to route database calls for custom Session model.
"""

MODEL_CLASSES = {Session}
DB_NAME = SESSIONS


DEMOGRAPHIC_FIELDS_KEY = "demographic_fields"


Expand Down Expand Up @@ -631,7 +666,43 @@ def filter_readable(self, queryset):
return queryset.none()


class FacilityUserModelManager(SyncableModelManager, UserManager):
class FacilityUserQuerySet(SyncableModelQuerySet):
def update(self, **kwargs):
# Check if date_deleted is being set to a non-null value (soft delete)
user_ids = []
if "date_deleted" in kwargs and kwargs["date_deleted"] is not None:
# Get user IDs that are currently not soft deleted
user_ids = list(
self.filter(date_deleted__isnull=True).values_list("id", flat=True)
)

# Perform the update
result = super().update(**kwargs)

# Clean up sessions for soft deleted users
if user_ids:
Session.delete_all_sessions(user_ids)

return result

def delete(self):
# Get user IDs before deletion for session cleanup
user_ids = list(self.values_list("id", flat=True))

# Perform the deletion
result = super().delete()

# Clean up sessions after successful deletion
Session.delete_all_sessions(user_ids)
return result


class BaseFacilityUserModelManager(SyncableModelManager, UserManager):
def get_queryset(self):
return FacilityUserQuerySet(self.model, using=self._db)


class FacilityUserModelManager(BaseFacilityUserModelManager):
def get_queryset(self):
return super().get_queryset().filter(date_deleted__isnull=True)

Expand Down Expand Up @@ -718,7 +789,7 @@ def get_or_create_os_user(self, auth_token, facility=None):
return user


class SoftDeletedFacilityUserModelManager(SyncableModelManager, UserManager):
class SoftDeletedFacilityUserModelManager(BaseFacilityUserModelManager):
"""
Custom manager for FacilityUser that only returns users who have a non-NULL value in their date_deleted field.
"""
Expand Down Expand Up @@ -773,7 +844,7 @@ def validate_role_kinds(kinds):
return kinds


class AllObjectsFacilityUserModelManager(SyncableModelManager, UserManager):
class AllObjectsFacilityUserModelManager(BaseFacilityUserModelManager):
def get_queryset(self):
return super(AllObjectsFacilityUserModelManager, self).get_queryset()

Expand Down Expand Up @@ -1038,6 +1109,39 @@ def __str__(self):
user=self.full_name or self.username, facility=self.facility
)

def __init__(self, *args, **kwargs):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(non blocking opinion) - __init__ feels like it should always be at the top of a class to me but as I type this I'm feeling like this is largely based on vibes 😆 -- I think maybe I just like to see overrides at the top of a class so I can first answer the question "what is the class doing in relation to it's parent's implementation" then the second question of "now what does this class do uniquely".

In any case - __str__ is overridden down here too so the rest fit in well enough 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah - I think I have a similar intuition - the only advantage to it being here is that its closer to where the intialized value is used.

super().__init__(*args, **kwargs)
# Track the original date_deleted value to detect changes
self._original_date_deleted = self.date_deleted

def save(self, *args, **kwargs):
# Check if user is being soft deleted (date_deleted changed from null to not-null)
is_being_soft_deleted = (
self._original_date_deleted is None
and self.date_deleted is not None
and self.pk is not None
)

# Call the parent save method first
result = super().save(*args, **kwargs)

# Clean up sessions after successful save
if is_being_soft_deleted:
Session.delete_all_sessions([self.id])

# Update the original value tracker
self._original_date_deleted = self.date_deleted
return result

def delete(self, *args, **kwargs):
"""
Override delete to ensure sessions are cleaned up during hard delete.
"""
user_id = self.id
result = super().delete(*args, **kwargs)
Session.delete_all_sessions([user_id])
return result

def has_perm(self, perm, obj=None):
# ensure the superuser has full access to the Django admin
if self.is_superuser:
Expand Down
Loading
Loading