Skip to content

Commit e530791

Browse files
committed
Add data migration for object_created and test
1 parent da63dea commit e530791

File tree

5 files changed

+149
-151
lines changed

5 files changed

+149
-151
lines changed

ansible_base/rbac/migrations/0005_remote_permissions_data.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Generated by Django 4.2.23 on 2025-06-30 12:48
2+
# Modified by Claude (Sonnet 4) - Added populate_object_created_field data migration
23

34
import logging
45

@@ -23,6 +24,12 @@ def create_types_if_needed(apps, schema_editor):
2324
create_DAB_contenttypes(apps=apps)
2425

2526

27+
def populate_object_created_field(apps, schema_editor):
28+
"""Populate the object_created field for existing role assignments."""
29+
from ._utils import populate_object_created_field as _populate_object_created_field
30+
return _populate_object_created_field(apps, schema_editor)
31+
32+
2633
def migrate_content_type(apps, schema_editor):
2734
ct_cls = apps.get_model('dab_rbac', 'DABContentType')
2835
ct_cls.objects.clear_cache()
@@ -68,4 +75,5 @@ class Migration(migrations.Migration):
6875
operations = [
6976
migrations.RunPython(create_types_if_needed, migrations.RunPython.noop),
7077
migrations.RunPython(migrate_content_type, migrations.RunPython.noop),
78+
migrations.RunPython(populate_object_created_field, migrations.RunPython.noop),
7179
]

ansible_base/rbac/migrations/_utils.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import logging
2+
from datetime import datetime
13
from django.db import models
24

5+
logger = logging.getLogger(__name__)
6+
37
# This method has moved, and this is put here temporarily to make branch management easier
48
from ansible_base.rbac.management import create_dab_permissions as create_custom_permissions # noqa
59

@@ -45,3 +49,67 @@ def give_permissions(apps, rd, users=(), teams=(), object_id=None, content_type_
4549
for team_id in teams
4650
]
4751
RoleTeamAssignment.objects.bulk_create(team_assignments, ignore_conflicts=True)
52+
53+
54+
def get_model_class_from_content_type(apps, content_type):
55+
"""
56+
Get a model class from a content type in a migration-safe way.
57+
58+
This is needed because content_type.model_class() is not available in migrations.
59+
"""
60+
try:
61+
return apps.get_model(content_type.app_label, content_type.model)
62+
except (LookupError, AttributeError):
63+
return None
64+
65+
66+
def populate_object_created_field(apps, schema_editor=None):
67+
"""Populate the object_created field for existing role assignments."""
68+
assignment_models = [
69+
('roleuserassignment', 'RoleUserAssignment'),
70+
('roleteamassignment', 'RoleTeamAssignment'),
71+
]
72+
73+
updated_count = 0
74+
75+
for model_name, model_class_name in assignment_models:
76+
assignment_cls = apps.get_model('dab_rbac', model_name)
77+
assignments_to_update = assignment_cls.objects.filter(object_created__isnull=True)
78+
79+
for assignment in assignments_to_update:
80+
object_created_value = None
81+
82+
# Try to get the actual object to extract its created timestamp
83+
if assignment.object_id and assignment.content_type:
84+
try:
85+
# Get the model class from the old content_type field (before migration to DABContentType)
86+
model_class = get_model_class_from_content_type(apps, assignment.content_type)
87+
if model_class:
88+
try:
89+
# Try to get the actual object
90+
actual_object = model_class.objects.get(pk=assignment.object_id)
91+
92+
# Try to get created timestamp from common field names
93+
for field_name in ('created', 'created_at'):
94+
if hasattr(actual_object, field_name):
95+
val = getattr(actual_object, field_name)
96+
if isinstance(val, datetime):
97+
object_created_value = val
98+
break
99+
except (model_class.DoesNotExist, ValueError, TypeError):
100+
# Object doesn't exist or can't be retrieved, skip
101+
pass
102+
except (AttributeError, LookupError):
103+
# Content type or model class issues, skip
104+
pass
105+
106+
# Update the assignment if we found a created timestamp
107+
if object_created_value:
108+
assignment.object_created = object_created_value
109+
assignment.save(update_fields=['object_created'])
110+
updated_count += 1
111+
112+
if updated_count:
113+
logger.info(f'Populated object_created field for {updated_count} existing role assignments')
114+
115+
return updated_count

ansible_base/rbac/service_api/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class BaseAssignmentSerializer(serializers.ModelSerializer):
6060
# Force created field to be writable
6161
created = serializers.DateTimeField(required=False)
6262
# Force object_created field to be writable
63-
object_created = serializers.DateTimeField(required=False)
63+
object_created = serializers.DateTimeField(required=False, allow_null=True)
6464

6565
def to_representation(self, instance):
6666
# hack to surface content_object for ObjectIDAnsibleIDField

test_app/tests/rbac/remote/test_service_api.py

Lines changed: 71 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -320,89 +320,40 @@ def test_role_types_and_permissions_payload_shape(user_api_client):
320320
class TestCreatedByAnsibleIdAllowNull:
321321
"""Test that created_by_ansible_id field accepts null values and omissions"""
322322

323-
def test_service_user_assignment_with_null_created_by(self, admin_api_client, rando, inv_rd, inventory):
324-
"""Test that ServiceRoleUserAssignmentSerializer accepts null created_by_ansible_id"""
325-
url = get_relative_url('serviceuserassignment-assign')
326-
data = {
327-
"role_definition": inv_rd.name,
328-
"user_ansible_id": str(rando.resource.ansible_id),
329-
"object_id": inventory.pk,
330-
"created_by_ansible_id": "", # Use empty string instead of None
331-
}
332-
333-
response = admin_api_client.post(url, data=data)
334-
assert response.status_code == 201, response.data
335-
assert rando.has_obj_perm(inventory, 'change')
336-
337-
def test_service_user_assignment_without_created_by(self, admin_api_client, rando, inv_rd, inventory):
338-
"""Test that ServiceRoleUserAssignmentSerializer works when created_by_ansible_id is omitted"""
339-
url = get_relative_url('serviceuserassignment-assign')
340-
data = {
341-
"role_definition": inv_rd.name,
342-
"user_ansible_id": str(rando.resource.ansible_id),
343-
"object_id": inventory.pk,
344-
# created_by_ansible_id is intentionally omitted
345-
}
346-
347-
response = admin_api_client.post(url, data=data)
348-
assert response.status_code == 201, response.data
349-
assert rando.has_obj_perm(inventory, 'change')
350-
351-
def test_service_user_assignment_with_valid_created_by(self, admin_api_client, rando, inv_rd, inventory):
352-
"""Test that valid created_by_ansible_id values still work correctly"""
353-
creator = User.objects.create(username='creator-user')
354-
url = get_relative_url('serviceuserassignment-assign')
355-
data = {
356-
"role_definition": inv_rd.name,
357-
"user_ansible_id": str(rando.resource.ansible_id),
358-
"object_id": inventory.pk,
359-
"created_by_ansible_id": str(creator.resource.ansible_id),
360-
}
361-
362-
response = admin_api_client.post(url, data=data)
363-
assert response.status_code == 201, response.data
364-
assert rando.has_obj_perm(inventory, 'change')
365-
366-
def test_service_team_assignment_with_null_created_by(self, admin_api_client, team, inv_rd, inventory, member_rd, rando):
367-
"""Test that ServiceRoleTeamAssignmentSerializer accepts null created_by_ansible_id"""
368-
member_rd.give_permission(rando, team)
369-
url = get_relative_url('serviceteamassignment-assign')
370-
data = {
371-
"role_definition": inv_rd.name,
372-
"team_ansible_id": str(team.resource.ansible_id),
373-
"object_id": inventory.pk,
374-
"created_by_ansible_id": "", # Use empty string instead of None
375-
}
376-
377-
response = admin_api_client.post(url, data=data)
378-
assert response.status_code == 201, response.data
379-
assert rando.has_obj_perm(inventory, 'change')
380-
381-
def test_service_team_assignment_without_created_by(self, admin_api_client, team, inv_rd, inventory, member_rd, rando):
382-
"""Test that ServiceRoleTeamAssignmentSerializer works when created_by_ansible_id is omitted"""
383-
member_rd.give_permission(rando, team)
384-
url = get_relative_url('serviceteamassignment-assign')
323+
@pytest.mark.parametrize(
324+
'actor_type,created_by_value',
325+
[
326+
('user', ''), # empty string
327+
('user', None), # omitted (None means field not present)
328+
('user', 'valid'), # valid creator
329+
('team', ''), # empty string
330+
('team', None), # omitted
331+
('team', 'valid'), # valid creator
332+
],
333+
)
334+
def test_service_assignment_created_by_handling(self, admin_api_client, rando, inv_rd, inventory, team, member_rd, actor_type, created_by_value):
335+
"""Test that ServiceRoleAssignmentSerializer handles created_by_ansible_id correctly"""
336+
# Setup for team assignments
337+
if actor_type == 'team':
338+
member_rd.give_permission(rando, team)
339+
actor = team
340+
else:
341+
actor = rando
342+
343+
url = get_relative_url(f'service{actor_type}assignment-assign')
385344
data = {
386345
"role_definition": inv_rd.name,
387-
"team_ansible_id": str(team.resource.ansible_id),
346+
f"{actor_type}_ansible_id": str(actor.resource.ansible_id),
388347
"object_id": inventory.pk,
389348
}
390349

391-
response = admin_api_client.post(url, data=data)
392-
assert response.status_code == 201, response.data
393-
assert rando.has_obj_perm(inventory, 'change')
394-
395-
def test_service_team_assignment_with_valid_created_by(self, admin_api_client, team, inv_rd, inventory, member_rd, rando):
396-
"""Test that valid created_by_ansible_id values still work correctly for teams"""
397-
member_rd.give_permission(rando, team)
398-
creator = User.objects.create(username='team-creator-user')
399-
url = get_relative_url('serviceteamassignment-assign')
400-
data = {
401-
"role_definition": inv_rd.name,
402-
"team_ansible_id": str(team.resource.ansible_id),
403-
"object_id": inventory.pk,
404-
"created_by_ansible_id": str(creator.resource.ansible_id),
405-
}
350+
# Handle different created_by_value scenarios
351+
if created_by_value == '':
352+
data["created_by_ansible_id"] = ""
353+
elif created_by_value == 'valid':
354+
creator = User.objects.create(username=f'{actor_type}-creator-user')
355+
data["created_by_ansible_id"] = str(creator.resource.ansible_id)
356+
# None means field is omitted (not added to data)
406357

407358
response = admin_api_client.post(url, data=data)
408359
assert response.status_code == 201, response.data
@@ -543,54 +494,74 @@ def test_service_assignment_created_timestamp_sync(admin_api_client, rando, inv_
543494

544495

545496
@pytest.mark.django_db
546-
def test_service_assignment_object_created_timestamp_sync(admin_api_client, rando, inv_rd, inventory):
497+
@pytest.mark.parametrize(
498+
'object_created_source',
499+
[
500+
'custom', # Provide custom timestamp
501+
'local_object', # Use local object's created timestamp
502+
],
503+
)
504+
def test_service_assignment_object_created_sync(admin_api_client, rando, inv_rd, inventory, org_inv_rd, organization, object_created_source):
547505
"""
548506
Test that the 'object_created' field can be synchronized in both directions:
549507
1. POST to /assign/ accepts a provided 'object_created' value
550-
2. Serializing local assignments includes the 'object_created' field from the DB
508+
2. When no object_created is provided, it defaults to the local object's created timestamp
509+
3. Serializing local assignments includes the 'object_created' field from the DB
551510
"""
552511
from datetime import datetime, timezone
553512

554513
from django.utils.dateparse import parse_datetime
555514

556515
url = get_relative_url('serviceuserassignment-assign')
557516

558-
creator_user = User.objects.create(username='object_created_creator')
559-
560-
# Set a specific object_created timestamp that's different from the actual object's created time
561-
custom_object_created = datetime(2022, 6, 15, 14, 30, 0, tzinfo=timezone.utc)
562-
custom_object_created_str = custom_object_created.isoformat()
517+
if object_created_source == 'custom':
518+
# Use inventory with custom timestamp
519+
target_object = inventory
520+
role_def = inv_rd
521+
custom_object_created = datetime(2022, 6, 15, 14, 30, 0, tzinfo=timezone.utc)
522+
expected_object_created = custom_object_created
563523

564-
post_data = {
565-
"role_definition": inv_rd.name,
566-
"user_ansible_id": str(rando.resource.ansible_id),
567-
"object_id": str(inventory.pk),
568-
"created_by_ansible_id": str(creator_user.resource.ansible_id),
569-
"object_created": custom_object_created_str,
570-
"from_service": "test_service",
571-
}
524+
post_data = {
525+
"role_definition": role_def.name,
526+
"user_ansible_id": str(rando.resource.ansible_id),
527+
"object_id": str(target_object.pk),
528+
"object_created": custom_object_created.isoformat(),
529+
"from_service": "test_service",
530+
}
531+
else: # local_object
532+
# Use organization without providing object_created
533+
target_object = organization
534+
role_def = org_inv_rd
535+
expected_object_created = organization.created
536+
537+
post_data = {
538+
"role_definition": role_def.name,
539+
"user_ansible_id": str(rando.resource.ansible_id),
540+
"object_id": str(target_object.pk),
541+
"from_service": "test_service",
542+
# Note: no object_created provided - should default to target_object.created
543+
}
572544

573-
# Test 1: POST accepts object_created value
545+
# Test 1: POST accepts object_created value or defaults to local object
574546
response = admin_api_client.post(url, data=post_data)
575547
assert response.status_code == 201, response.data
576548

577-
assignment = RoleUserAssignment.objects.get(user=rando, role_definition=inv_rd, object_id=inventory.pk)
549+
assignment = RoleUserAssignment.objects.get(user=rando, role_definition=role_def, object_id=target_object.pk)
578550

579-
# Verify the custom object_created timestamp was properly set
580-
expected_object_created = custom_object_created
551+
# Verify the object_created timestamp was properly set
581552
actual_object_created = assignment.object_created
582-
553+
operation_type = 'synchronized' if object_created_source == 'custom' else 'defaulted to local object'
583554
assert (
584555
actual_object_created == expected_object_created
585-
), f"object_created should be synchronized: Expected '{expected_object_created}' but got '{actual_object_created}'"
556+
), f"object_created should be {operation_type}: Expected '{expected_object_created}' but got '{actual_object_created}'"
586557

587558
# Test 2: Serializing local assignments includes object_created field
588559
list_url = get_relative_url('serviceuserassignment-list')
589560
response = admin_api_client.get(list_url + '?page_size=200', format="json")
590561
assert response.status_code == 200, response.data
591562

592563
# Find our assignment in the list
593-
assignments = [a for a in response.data['results'] if a['role_definition'] == inv_rd.name and str(a['object_id']) == str(inventory.pk)]
564+
assignments = [a for a in response.data['results'] if a['role_definition'] == role_def.name and str(a['object_id']) == str(target_object.pk)]
594565
assert len(assignments) >= 1, "Should find at least our assignment"
595566

596567
# Check that object_created is properly serialized
@@ -601,53 +572,3 @@ def test_service_assignment_object_created_timestamp_sync(admin_api_client, rand
601572
assert (
602573
response_object_created == expected_object_created
603574
), f"Serialized object_created should match stored value: expected '{expected_object_created}' but got '{response_object_created}'"
604-
605-
606-
@pytest.mark.django_db
607-
def test_service_assignment_object_created_from_local_object(admin_api_client, rando, org_inv_rd, organization):
608-
"""
609-
Test that when no object_created is provided in POST, the field is automatically set
610-
from the local object's created timestamp and properly serialized.
611-
"""
612-
url = get_relative_url('serviceuserassignment-assign')
613-
614-
post_data = {
615-
"role_definition": org_inv_rd.name,
616-
"user_ansible_id": str(rando.resource.ansible_id),
617-
"object_id": str(organization.pk),
618-
"from_service": "test_service",
619-
# Note: no object_created provided - should default to organization.created
620-
}
621-
622-
# Create assignment without providing object_created
623-
response = admin_api_client.post(url, data=post_data)
624-
assert response.status_code == 201, response.data
625-
626-
assignment = RoleUserAssignment.objects.get(user=rando, role_definition=org_inv_rd, object_id=organization.pk)
627-
628-
# Verify object_created was set to the organization's created timestamp
629-
expected_object_created = organization.created
630-
actual_object_created = assignment.object_created
631-
632-
assert (
633-
actual_object_created == expected_object_created
634-
), f"object_created should default to organization.created: Expected '{expected_object_created}' but got '{actual_object_created}'"
635-
636-
# Verify serialization includes the correct object_created value
637-
list_url = get_relative_url('serviceuserassignment-list')
638-
response = admin_api_client.get(list_url + '?page_size=200', format="json")
639-
assert response.status_code == 200, response.data
640-
641-
# Find our assignment
642-
assignments = [a for a in response.data['results'] if a['role_definition'] == org_inv_rd.name and str(a['object_id']) == str(organization.pk)]
643-
assert len(assignments) >= 1, "Should find at least our assignment"
644-
645-
assignment_data = assignments[0]
646-
assert 'object_created' in assignment_data, "object_created field should be present in serialized output"
647-
648-
from django.utils.dateparse import parse_datetime
649-
650-
response_object_created = parse_datetime(assignment_data['object_created'])
651-
assert (
652-
response_object_created == expected_object_created
653-
), f"Serialized object_created should match organization.created: expected '{expected_object_created}' but got '{response_object_created}'"

test_app/tests/resource_registry/test_resources_api_rest_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ def test_list_role_permissions_all_pages(resource_client):
193193
def _assert_assignment_matches_data(assignment, data, obj, actor):
194194
assert 'created' in data, data
195195
# assert DateTimeField().to_representation(assignment.created) == data['created'] # TODO
196+
assert 'object_created' in data, data
196197
assert str(assignment.created_by.resource.ansible_id) == data['created_by_ansible_id']
197198
assert assignment.object_id == obj.id
198199
assert str(assignment.object_id) == str(data['object_id'])

0 commit comments

Comments
 (0)