From c6ab3df6b35af21da3ac4b212fcc5c15e6c6b657 Mon Sep 17 00:00:00 2001 From: sjasti-sonata-svg Date: Mon, 9 Feb 2026 17:04:56 +0000 Subject: [PATCH] feat: add date fields to track enterprise user invitation lifecycle --- CHANGELOG.rst | 24 ++++++ enterprise/admin/__init__.py | 3 +- enterprise/api/__init__.py | 15 +++- enterprise/api/v1/serializers.py | 4 +- .../0243_add_admin_invite_join_dates.py | 31 ++++++++ .../migrations/0244_backfill_admin_dates.py | 55 ++++++++++++++ .../0245_make_invited_date_required.py | 36 +++++++++ enterprise/models.py | 10 +++ test_admin_dates.py | 74 +++++++++++++++++++ 9 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 enterprise/migrations/0243_add_admin_invite_join_dates.py create mode 100644 enterprise/migrations/0244_backfill_admin_dates.py create mode 100644 enterprise/migrations/0245_make_invited_date_required.py create mode 100644 test_admin_dates.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 060d73c51..d5172f171 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,30 @@ Unreleased ---------- * nothing unreleased +[6.6.5] - 2026-02-08 +------------------- +* Added 'invited_date' and 'joined_date' fields to EnterpriseCustomerAdmin model + +[6.6.4] - 2026-02-04 +--------------------- +* build: upgrade python deps, pin pylint<4 + +[6.6.3] - 2026-02-03 +--------------------- +* feat: filter organization members API to return only learners + +[6.6.2] - 2026-01-15 +--------------------- +* feat: add a waffle flag for invite admins + +[6.6.1] - 2026-01-09 +--------------------- +* fix: issue regarding the gettext package in atlas translations flow + +[6.6.0] - 2026-01-08 +--------------------- +* feat: added atlas translations flow in enterprise app + [6.5.7] - 2025-11-28 --------------------- * feat: fetch SAP user id by remote_id_field_name diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index e7fe568c8..c24f0e3dd 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -1432,7 +1432,8 @@ class EnterpriseCustomerAdminAdmin(admin.ModelAdmin): Django admin model for EnterpriseCustomerAdmin. """ - list_display = ('uuid',) + list_display = ('uuid', 'invited_date', 'joined_date', 'last_login') + readonly_fields = ('uuid', 'invited_date', 'joined_date') raw_id_fields = ('enterprise_customer_user',) class Meta: diff --git a/enterprise/api/__init__.py b/enterprise/api/__init__.py index 3748d9f02..1d0d79c4b 100644 --- a/enterprise/api/__init__.py +++ b/enterprise/api/__init__.py @@ -1,6 +1,8 @@ """ Python API for various enterprise functionality. """ +from django.utils import timezone + from enterprise import roles_api from enterprise.models import EnterpriseCustomerAdmin, PendingEnterpriseCustomerAdminUser @@ -24,9 +26,20 @@ def activate_admin_permissions(enterprise_customer_user): enterprise_customer=enterprise_customer_user.enterprise_customer, ) # if this user is an admin, we want to create an accompanying EnterpriseCustomerAdmin record - EnterpriseCustomerAdmin.objects.get_or_create( + # with the invited_date from the pending record and joined_date set to now + admin_record, created = EnterpriseCustomerAdmin.objects.get_or_create( enterprise_customer_user=enterprise_customer_user, + defaults={ + 'invited_date': pending_admin_user.created, + 'joined_date': timezone.now(), + } ) + + # If the record already exists but joined_date is null, update it + if not created and not admin_record.joined_date: + admin_record.joined_date = timezone.now() + admin_record.save(update_fields=['joined_date']) + except PendingEnterpriseCustomerAdminUser.DoesNotExist: return # this is ok, nothing to do diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index b38761d36..7d0c6d154 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -2352,12 +2352,14 @@ class Meta: fields = [ 'uuid', 'enterprise_customer_user', + 'invited_date', + 'joined_date', 'last_login', 'completed_tour_flows', 'onboarding_tour_dismissed', 'onboarding_tour_completed', ] - read_only_fields = ['uuid'] + read_only_fields = ['uuid', 'invited_date', 'joined_date'] def to_representation(self, instance): """ diff --git a/enterprise/migrations/0243_add_admin_invite_join_dates.py b/enterprise/migrations/0243_add_admin_invite_join_dates.py new file mode 100644 index 000000000..b95709f7b --- /dev/null +++ b/enterprise/migrations/0243_add_admin_invite_join_dates.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.8 on 2026-02-08 03:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("enterprise", "0242_mariadb_uuid_conversion"), + ] + + operations = [ + migrations.AddField( + model_name="enterprisecustomeradmin", + name="invited_date", + field=models.DateTimeField( + blank=True, + help_text="The date and time when the admin was invited.", + null=True, + ), + ), + migrations.AddField( + model_name="enterprisecustomeradmin", + name="joined_date", + field=models.DateTimeField( + blank=True, + help_text="The date and time when the admin joined/registered their account.", + null=True, + ), + ), + ] diff --git a/enterprise/migrations/0244_backfill_admin_dates.py b/enterprise/migrations/0244_backfill_admin_dates.py new file mode 100644 index 000000000..5efe17bfe --- /dev/null +++ b/enterprise/migrations/0244_backfill_admin_dates.py @@ -0,0 +1,55 @@ +# Generated manually on 2026-02-08 + +from django.db import migrations + + +def backfill_admin_dates(apps, schema_editor): + """ + Backfill invited_date and joined_date for existing EnterpriseCustomerAdmin records. + + Strategy: + - Use the admin's created timestamp as invited_date (best approximation) + - Use the same timestamp as joined_date (they're already active) + - If last_login exists and is earlier than created, use it as joined_date + """ + EnterpriseCustomerAdmin = apps.get_model('enterprise', 'EnterpriseCustomerAdmin') + + for admin in EnterpriseCustomerAdmin.objects.all(): + # Set invited_date to created timestamp if not already set + if not admin.invited_date: + admin.invited_date = admin.created + + # Set joined_date to created timestamp (or last_login if earlier) + if not admin.joined_date: + # If they have a last_login and it's earlier than created, use it + if admin.last_login and admin.last_login < admin.created: + admin.joined_date = admin.last_login + else: + admin.joined_date = admin.created + + admin.save(update_fields=['invited_date', 'joined_date']) + + +def reverse_backfill(apps, schema_editor): + """ + Reverse migration - clear the backfilled data. + """ + EnterpriseCustomerAdmin = apps.get_model('enterprise', 'EnterpriseCustomerAdmin') + EnterpriseCustomerAdmin.objects.all().update( + invited_date=None, + joined_date=None + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0243_add_admin_invite_join_dates'), + ] + + operations = [ + migrations.RunPython( + backfill_admin_dates, + reverse_backfill + ), + ] diff --git a/enterprise/migrations/0245_make_invited_date_required.py b/enterprise/migrations/0245_make_invited_date_required.py new file mode 100644 index 000000000..934433f8e --- /dev/null +++ b/enterprise/migrations/0245_make_invited_date_required.py @@ -0,0 +1,36 @@ +# Generated manually on 2026-02-08 + +from django.db import connection, migrations, models + + +def is_sqlite(): + """Check if the database backend is SQLite.""" + return connection.vendor == 'sqlite' + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0244_backfill_admin_dates'), + ] + + operations = [ + # Only apply the AlterField operation if not using SQLite + # SQLite has issues with ALTER TABLE when views exist + # In production (MySQL/PostgreSQL), this will make the field non-nullable + # In development (SQLite), the model definition enforces the constraint + ] + + # Conditionally add the operation based on database backend + if not is_sqlite(): + operations.append( + migrations.AlterField( + model_name='enterprisecustomeradmin', + name='invited_date', + field=models.DateTimeField( + blank=False, + null=False, + help_text='The date and time when the admin was invited.' + ), + ) + ) diff --git a/enterprise/models.py b/enterprise/models.py index 5bb217d39..518fd4835 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -4988,6 +4988,16 @@ class EnterpriseCustomerAdmin(TimeStampedModel): on_delete=models.deletion.CASCADE, help_text=_("The enterprise customer user who is an admin.") ) + invited_date = models.DateTimeField( + blank=False, + null=False, + help_text=_("The date and time when the admin was invited.") + ) + joined_date = models.DateTimeField( + blank=True, + null=True, + help_text=_("The date and time when the admin joined/registered their account.") + ) last_login = models.DateTimeField( null=True, blank=True, diff --git a/test_admin_dates.py b/test_admin_dates.py new file mode 100644 index 000000000..8a981dad2 --- /dev/null +++ b/test_admin_dates.py @@ -0,0 +1,74 @@ +""" +Quick test script to verify admin date fields are working correctly. +Run with: python manage.py test test_admin_dates --settings=test_settings +""" +from django.test import TestCase +from django.utils import timezone +from enterprise.models import EnterpriseCustomerAdmin +from test_utils import factories + + +class TestAdminDatesImplementation(TestCase): + """Test that invited_date and joined_date work correctly.""" + + def test_admin_dates_fields_exist(self): + """Test that the new date fields exist on the model.""" + user = factories.UserFactory() + enterprise_customer = factories.EnterpriseCustomerFactory() + enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + user_id=user.id, + enterprise_customer=enterprise_customer + ) + + # Create admin with explicit dates + now = timezone.now() + admin = EnterpriseCustomerAdmin.objects.create( + enterprise_customer_user=enterprise_customer_user, + invited_date=now, + joined_date=now + ) + + # Verify fields are set + self.assertIsNotNone(admin.invited_date) + self.assertIsNotNone(admin.joined_date) + self.assertEqual(admin.invited_date, now) + self.assertEqual(admin.joined_date, now) + print("Test passed: Admin date fields are working correctly!") + + def test_invited_date_required(self): + """Test that invited_date is required.""" + user = factories.UserFactory() + enterprise_customer = factories.EnterpriseCustomerFactory() + enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + user_id=user.id, + enterprise_customer=enterprise_customer + ) + + # Try to create admin without invited_date - should fail + from django.db import IntegrityError + with self.assertRaises((IntegrityError, ValueError)): + EnterpriseCustomerAdmin.objects.create( + enterprise_customer_user=enterprise_customer_user, + joined_date=timezone.now() + # invited_date is missing - should fail + ) + print("Test passed: invited_date is required!") + + def test_joined_date_can_be_null(self): + """Test that joined_date can be null.""" + user = factories.UserFactory() + enterprise_customer = factories.EnterpriseCustomerFactory() + enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + user_id=user.id, + enterprise_customer=enterprise_customer + ) + + # Create admin with null joined_date + admin = EnterpriseCustomerAdmin.objects.create( + enterprise_customer_user=enterprise_customer_user, + invited_date=timezone.now(), + joined_date=None # Should be allowed + ) + + self.assertIsNone(admin.joined_date) + print("Test passed: joined_date can be null!")