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 CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ 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
Expand Down
3 changes: 2 additions & 1 deletion enterprise/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 14 additions & 1 deletion enterprise/api/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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(),
Copy link
Contributor

Choose a reason for hiding this comment

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

joined_date is redundant with created. Remove the joined_date field.

}
)

# 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

Expand Down
4 changes: 3 additions & 1 deletion enterprise/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
31 changes: 31 additions & 0 deletions enterprise/migrations/0243_add_admin_invite_join_dates.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
55 changes: 55 additions & 0 deletions enterprise/migrations/0244_backfill_admin_dates.py
Original file line number Diff line number Diff line change
@@ -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
),
]
36 changes: 36 additions & 0 deletions enterprise/migrations/0245_make_invited_date_required.py
Original file line number Diff line number Diff line change
@@ -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.'
),
)
)
10 changes: 10 additions & 0 deletions enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 74 additions & 0 deletions test_admin_dates.py
Original file line number Diff line number Diff line change
@@ -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!")
Loading