Skip to content

Commit 01eb162

Browse files
authored
AAP-32143 Make the JT name uniqueness enforced at the database level (ansible#15956)
* Make the JT name uniqueness enforced at the database level * Forgot demo project fixture * New approach, done by adding a new field * Update for linters and failures * Fix logical error in migration test * Revert some test changes based on review comment * Do not rename first template, add test * Avoid name-too-long rename errors * Insert migration into place * Move existing files with git * Bump migrations of existing * Update migration test * Awkward bump * Fix migration file link * update test reference again
1 parent 20a512b commit 01eb162

18 files changed

+297
-39
lines changed

awx/main/migrations/0204_inventorygroupvariableswithhistory_and_more.py renamed to awx/main/migrations/0199_inventorygroupvariableswithhistory_and_more.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
class Migration(migrations.Migration):
88

99
dependencies = [
10-
('main', '0203_delete_token_cleanup_job'),
10+
('main', '0198_alter_inventorysource_source_and_more'),
1111
]
1212

1313
operations = [
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Generated by Django 4.2.20 on 2025-04-22 15:54
2+
3+
import logging
4+
5+
from django.db import migrations, models
6+
7+
from awx.main.migrations._db_constraints import _rename_duplicates
8+
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
def rename_jts(apps, schema_editor):
14+
cls = apps.get_model('main', 'JobTemplate')
15+
_rename_duplicates(cls)
16+
17+
18+
def rename_projects(apps, schema_editor):
19+
cls = apps.get_model('main', 'Project')
20+
_rename_duplicates(cls)
21+
22+
23+
def change_inventory_source_org_unique(apps, schema_editor):
24+
cls = apps.get_model('main', 'InventorySource')
25+
r = cls.objects.update(org_unique=False)
26+
logger.info(f'Set database constraint rule for {r} inventory source objects')
27+
28+
29+
class Migration(migrations.Migration):
30+
31+
dependencies = [
32+
('main', '0199_inventorygroupvariableswithhistory_and_more'),
33+
]
34+
35+
operations = [
36+
migrations.RunPython(rename_jts, migrations.RunPython.noop),
37+
migrations.RunPython(rename_projects, migrations.RunPython.noop),
38+
migrations.AddField(
39+
model_name='unifiedjobtemplate',
40+
name='org_unique',
41+
field=models.BooleanField(blank=True, default=True, editable=False, help_text='Used internally to selectively enforce database constraint on name'),
42+
),
43+
migrations.RunPython(change_inventory_source_org_unique, migrations.RunPython.noop),
44+
migrations.AddConstraint(
45+
model_name='unifiedjobtemplate',
46+
constraint=models.UniqueConstraint(
47+
condition=models.Q(('org_unique', True)), fields=('polymorphic_ctype', 'name', 'organization'), name='ujt_hard_name_constraint'
48+
),
49+
),
50+
]

awx/main/migrations/0199_delete_profile.py renamed to awx/main/migrations/0201_delete_profile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
class Migration(migrations.Migration):
77
dependencies = [
8-
('main', '0198_alter_inventorysource_source_and_more'),
8+
('main', '0200_template_name_constraint'),
99
]
1010

1111
operations = [

awx/main/migrations/0200_remove_sso_app_content.py renamed to awx/main/migrations/0202_remove_sso_app_content.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
class Migration(migrations.Migration):
77
dependencies = [
8-
('main', '0199_delete_profile'),
8+
('main', '0201_delete_profile'),
99
]
1010

1111
operations = [

awx/main/migrations/0201_alter_inventorysource_source_and_more.py renamed to awx/main/migrations/0203_alter_inventorysource_source_and_more.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
class Migration(migrations.Migration):
77

88
dependencies = [
9-
('main', '0200_remove_sso_app_content'),
9+
('main', '0202_remove_sso_app_content'),
1010
]
1111

1212
operations = [

awx/main/migrations/0202_alter_oauth2application_unique_together_and_more.py renamed to awx/main/migrations/0204_alter_oauth2application_unique_together_and_more.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
class Migration(migrations.Migration):
77

88
dependencies = [
9-
('main', '0201_alter_inventorysource_source_and_more'),
9+
('main', '0203_alter_inventorysource_source_and_more'),
1010
]
1111

1212
operations = [

awx/main/migrations/0203_delete_token_cleanup_job.py renamed to awx/main/migrations/0205_delete_token_cleanup_job.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
class Migration(migrations.Migration):
99

1010
dependencies = [
11-
('main', '0202_alter_oauth2application_unique_together_and_more'),
11+
('main', '0204_alter_oauth2application_unique_together_and_more'),
1212
]
1313

1414
operations = [
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import logging
2+
3+
from django.db.models import Count
4+
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
def _rename_duplicates(cls):
10+
field = cls._meta.get_field('name')
11+
max_len = field.max_length
12+
for organization_id in cls.objects.order_by().values_list('organization_id', flat=True).distinct():
13+
duplicate_data = cls.objects.values('name').filter(organization_id=organization_id).annotate(count=Count('name')).order_by().filter(count__gt=1)
14+
for data in duplicate_data:
15+
name = data['name']
16+
for idx, ujt in enumerate(cls.objects.filter(name=name, organization_id=organization_id).order_by('created')):
17+
if idx > 0:
18+
suffix = f'_dup{idx}'
19+
max_chars = max_len - len(suffix)
20+
if len(ujt.name) >= max_chars:
21+
ujt.name = ujt.name[:max_chars] + suffix
22+
else:
23+
ujt.name = ujt.name + suffix
24+
logger.info(f'Renaming duplicate {cls._meta.model_name} to `{ujt.name}` because of duplicate name entry')
25+
ujt.save(update_fields=['name'])

awx/main/models/inventory.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,8 +1120,10 @@ def _get_unified_job_field_names(cls):
11201120

11211121
def save(self, *args, **kwargs):
11221122
# if this is a new object, inherit organization from its inventory
1123-
if not self.pk and self.inventory and self.inventory.organization_id and not self.organization_id:
1124-
self.organization_id = self.inventory.organization_id
1123+
if not self.pk:
1124+
self.org_unique = False # needed to exclude from unique (name, organization) constraint
1125+
if self.inventory and self.inventory.organization_id and not self.organization_id:
1126+
self.organization_id = self.inventory.organization_id
11251127

11261128
# If update_fields has been specified, add our field names to it,
11271129
# if it hasn't been specified, then we're just doing a normal save.

awx/main/models/jobs.py

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -358,26 +358,6 @@ def save(self, *args, **kwargs):
358358
update_fields.append('organization_id')
359359
return super(JobTemplate, self).save(*args, **kwargs)
360360

361-
def validate_unique(self, exclude=None):
362-
"""Custom over-ride for JT specifically
363-
because organization is inferred from project after full_clean is finished
364-
thus the organization field is not yet set when validation happens
365-
"""
366-
errors = []
367-
for ut in JobTemplate.SOFT_UNIQUE_TOGETHER:
368-
kwargs = {'name': self.name}
369-
if self.project:
370-
kwargs['organization'] = self.project.organization_id
371-
else:
372-
kwargs['organization'] = None
373-
qs = JobTemplate.objects.filter(**kwargs)
374-
if self.pk:
375-
qs = qs.exclude(pk=self.pk)
376-
if qs.exists():
377-
errors.append('%s with this (%s) combination already exists.' % (JobTemplate.__name__, ', '.join(set(ut) - {'polymorphic_ctype'})))
378-
if errors:
379-
raise ValidationError(errors)
380-
381361
def create_unified_job(self, **kwargs):
382362
prevent_slicing = kwargs.pop('_prevent_slicing', False)
383363
slice_ct = self.get_effective_slice_ct(kwargs)
@@ -404,6 +384,26 @@ def create_unified_job(self, **kwargs):
404384
WorkflowJobNode.objects.create(**create_kwargs)
405385
return job
406386

387+
def validate_unique(self, exclude=None):
388+
"""Custom over-ride for JT specifically
389+
because organization is inferred from project after full_clean is finished
390+
thus the organization field is not yet set when validation happens
391+
"""
392+
errors = []
393+
for ut in JobTemplate.SOFT_UNIQUE_TOGETHER:
394+
kwargs = {'name': self.name}
395+
if self.project:
396+
kwargs['organization'] = self.project.organization_id
397+
else:
398+
kwargs['organization'] = None
399+
qs = JobTemplate.objects.filter(**kwargs)
400+
if self.pk:
401+
qs = qs.exclude(pk=self.pk)
402+
if qs.exists():
403+
errors.append('%s with this (%s) combination already exists.' % (JobTemplate.__name__, ', '.join(set(ut) - {'polymorphic_ctype'})))
404+
if errors:
405+
raise ValidationError(errors)
406+
407407
def get_absolute_url(self, request=None):
408408
return reverse('api:job_template_detail', kwargs={'pk': self.pk}, request=request)
409409

0 commit comments

Comments
 (0)