From b2d0871a5ea3fbf8ac2150de6a90eee233bd8ab9 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 20 Dec 2016 09:55:08 -0500 Subject: [PATCH 1/4] Refactor of accessible_objects for polymorphic model query simplification, with break-out into an intermediary state that can be used in access methods --- awx/main/access.py | 60 +++++++++---------- awx/main/models/mixins.py | 30 +++++++--- awx/main/models/unified_jobs.py | 18 ++++++ tools/data_generators/presets.tsv | 30 +++++----- .../rbac_dummy_data_generator.py | 40 ++++++++----- 5 files changed, 110 insertions(+), 68 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 460dfe7b4c..e99a77dd88 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1825,25 +1825,22 @@ class JobEventAccess(BaseAccess): class UnifiedJobTemplateAccess(BaseAccess): ''' I can see a unified job template whenever I can see the same project, - inventory source or job template. Unified job templates do not include - projects without SCM configured or inventory sources without a cloud - source. + inventory source, WFJT, or job template. Unified job templates do not include + inventory sources without a cloud source. ''' model = UnifiedJobTemplate def get_queryset(self): - qs = self.model.objects.all() - project_qs = self.user.get_queryset(Project).filter(scm_type__in=[s[0] for s in Project.SCM_TYPE_CHOICES]) - inventory_source_qs = self.user.get_queryset(InventorySource).filter(source__in=CLOUD_INVENTORY_SOURCES) - job_template_qs = self.user.get_queryset(JobTemplate) - system_job_template_qs = self.user.get_queryset(SystemJobTemplate) - workflow_job_template_qs = self.user.get_queryset(WorkflowJobTemplate) - qs = qs.filter(Q(Project___in=project_qs) | - Q(InventorySource___in=inventory_source_qs) | - Q(JobTemplate___in=job_template_qs) | - Q(systemjobtemplate__in=system_job_template_qs) | - Q(workflowjobtemplate__in=workflow_job_template_qs)) + if self.user.is_superuser or self.user.is_system_auditor: + qs = self.model.objects.all() + else: + qs = self.model.objects.filter( + Q(pk__in=self.model.accessible_pk_qs(self.user, 'read_role')) | + Q(inventorysource__inventory__id__in=Inventory._accessible_pk_qs( + Inventory, self.user, 'read_role'))) + qs = qs.exclude(inventorysource__source="") + qs = qs.select_related( 'created_by', 'modified_by', @@ -1882,25 +1879,25 @@ class UnifiedJobAccess(BaseAccess): model = UnifiedJob def get_queryset(self): - qs = self.model.objects.all() - project_update_qs = self.user.get_queryset(ProjectUpdate) - inventory_update_qs = self.user.get_queryset(InventoryUpdate).filter(source__in=CLOUD_INVENTORY_SOURCES) - job_qs = self.user.get_queryset(Job) - ad_hoc_command_qs = self.user.get_queryset(AdHocCommand) - system_job_qs = self.user.get_queryset(SystemJob) - workflow_job_qs = self.user.get_queryset(WorkflowJob) - qs = qs.filter(Q(ProjectUpdate___in=project_update_qs) | - Q(InventoryUpdate___in=inventory_update_qs) | - Q(Job___in=job_qs) | - Q(AdHocCommand___in=ad_hoc_command_qs) | - Q(SystemJob___in=system_job_qs) | - Q(WorkflowJob___in=workflow_job_qs)) - qs = qs.select_related( + if self.user.is_superuser or self.user.is_system_auditor: + qs = self.model.objects.all() + else: + inv_pk_qs = Inventory._accessible_pk_qs(Inventory, self.user, 'read_role') + inv_update_qs = InventoryUpdate.objects.filter(inventory_source__inventory__id__in=inv_pk_qs) + ad_hoc_command_qs = AdHocCommand.objects.filter(inventory__id__in=inv_pk_qs) + org_auditor_qs = Organization.objects.filter( + Q(admin_role__members=self.user) | Q(auditor_role__members=self.user)) + qs = self.model.objects.filter( + Q(unified_job_template__id__in=UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role')) | + Q(inventoryupdate__in=inv_update_qs) | + Q(adhoccommand__in=ad_hoc_command_qs) | + Q(job__inventory__organization__in=org_auditor_qs) | + Q(job__project__organization__in=org_auditor_qs) + ) + qs = qs.prefetch_related( 'created_by', 'modified_by', 'unified_job_node__workflow_job', - ) - qs = qs.prefetch_related( 'unified_job_template', ) @@ -1922,6 +1919,9 @@ class UnifiedJobAccess(BaseAccess): # 'job_template__credential', # 'job_template__cloud_credential', #) + # Maybe we can do these, like: + # 'projectupdate__project', + # 'inventoryupdate__inventory' return qs.all() diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 07a346964b..645626bca3 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -3,6 +3,7 @@ import json # Django from django.db import models +from django.db.models import Q from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import User # noqa @@ -37,8 +38,9 @@ class ResourceMixin(models.Model): ''' return ResourceMixin._accessible_objects(cls, accessor, role_field) + @staticmethod - def _accessible_objects(cls, accessor, role_field): + def _accessible_pk_qs(cls, accessor, role_field, content_types=None): if type(accessor) == User: ancestor_roles = accessor.roles.all() elif type(accessor) == Role: @@ -47,14 +49,24 @@ class ResourceMixin(models.Model): accessor_type = ContentType.objects.get_for_model(accessor) ancestor_roles = Role.objects.filter(content_type__pk=accessor_type.id, object_id=accessor.id) - qs = cls.objects.filter(pk__in = - RoleAncestorEntry.objects.filter( - ancestor__in=ancestor_roles, - content_type_id = ContentType.objects.get_for_model(cls).id, - role_field = role_field - ).values_list('object_id').distinct() - ) - return qs + + if content_types is not None: + return RoleAncestorEntry.objects.filter( + ancestor__in = ancestor_roles, + content_type_id__in = content_types, + role_field = role_field + ).values_list('object_id').distinct() + + return RoleAncestorEntry.objects.filter( + ancestor__in = ancestor_roles, + content_type_id = ContentType.objects.get_for_model(cls).id, + role_field = role_field + ).values_list('object_id').distinct() + + + @staticmethod + def _accessible_objects(cls, accessor, role_field): + return cls.objects.filter(pk__in = ResourceMixin._accessible_pk_qs(cls, accessor, role_field)) def get_permissions(self, accessor): diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index b210b3ce6d..87cdb90d73 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -20,6 +20,7 @@ from django.utils.translation import ugettext_lazy as _ from django.utils.timezone import now from django.utils.encoding import smart_text from django.apps import apps +from django.contrib.contenttypes.models import ContentType # Django-Polymorphic from polymorphic import PolymorphicModel @@ -30,6 +31,7 @@ from djcelery.models import TaskMeta # AWX from awx.main.models.base import * # noqa from awx.main.models.schedules import Schedule +from awx.main.models.mixins import ResourceMixin from awx.main.utils import ( decrypt_field, _inventory_updates, copy_model_by_class, copy_m2m_relationships @@ -166,6 +168,22 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio else: return super(UnifiedJobTemplate, self).unique_error_message(model_class, unique_check) + @classmethod + def accessible_pk_qs(cls, accessor, role_field): + ''' + A re-implementation of accessible pk queryset for the "normal" unified JTs. + Does not return inventory sources or system JTs, these should + be handled inside of get_queryset where it is utilized. + ''' + # Algorithmic option, if we don't want hardcoded class names + # ujt_name_list = [c.__name__.lower() for c in cls.__subclasses__()] + # ujt_name_list.remove('inventorysource') + subclass_content_types = ContentType.objects.filter( + model__in=['project', 'jobtemplate', 'systemjobtemplate', 'workflowjobtemplate'] + ).values_list('id', flat=True) + + return ResourceMixin._accessible_pk_qs(cls, accessor, role_field, content_types=subclass_content_types) + def _perform_unique_checks(self, unique_checks): # Handle the list of unique fields returned above. Replace with an # appropriate error message for the remaining field(s) in the unique diff --git a/tools/data_generators/presets.tsv b/tools/data_generators/presets.tsv index f0315eb702..5ba5b4557f 100644 --- a/tools/data_generators/presets.tsv +++ b/tools/data_generators/presets.tsv @@ -1,15 +1,15 @@ -resource medium -organizations 500 -users 5000 -teams 500 -projects 1000 -job-templates 2000 -credentials 2000 -inventories 2000 -inventory-groups 500 -inventory-hosts 2500 -wfjts 100 -nodes 1000 -labels 1000 -jobs 1000 -job-events 1000 \ No newline at end of file +resource medium jan2017 +organizations 500 1 +users 5000 3 +teams 500 2 +projects 1000 30 +job-templates 2000 127 +credentials 2000 50 +inventories 2000 6 +inventory-groups 500 15 +inventory-hosts 2500 15 +wfjts 100 0 +nodes 1000 0 +labels 1000 0 +jobs 1000 157208 +job-events 1000 3370942 \ No newline at end of file diff --git a/tools/data_generators/rbac_dummy_data_generator.py b/tools/data_generators/rbac_dummy_data_generator.py index 151b9d45e7..910568f8d6 100755 --- a/tools/data_generators/rbac_dummy_data_generator.py +++ b/tools/data_generators/rbac_dummy_data_generator.py @@ -7,6 +7,7 @@ import sys # Python from collections import defaultdict from optparse import make_option, OptionParser +import logging # Django @@ -84,6 +85,7 @@ options = vars(options) if options['preset']: + print ' Using preset data numbers set ' + str(options['preset']) # Read the numbers of resources from presets file, if provided presets_filename = os.path.abspath(os.path.join( os.path.dirname(os.path.abspath(__file__)), 'presets.tsv')) @@ -603,22 +605,28 @@ try: wfjt.labels.add(next(label_gen)) wfjt_idx += 1 + # Disable logging here, because it will mess up output format + logger = logging.getLogger('awx.main') + logger.propagate = False + print('# Creating %d jobs' % n_jobs) group_idx = 0 job_template_idx = 0 + job_i = 0 for n in spread(n_jobs, n_job_templates): job_template = job_templates[job_template_idx] for i in range(n): sys.stdout.write('\r Assigning %d to %s: %d ' % (n, job_template.name, i+ 1)) sys.stdout.flush() - job_stat = 'successful' if len(jobs) % 4 == 0: job_stat = 'failed' elif len(jobs) % 11 == 0: job_stat = 'canceled' + else: + job_stat = 'successful' job, _ = Job.objects.get_or_create( job_template=job_template, - status=job_stat, name=job_template.name, + status=job_stat, name="%s-%d" % (job_template.name, job_i), project=job_template.project, inventory=job_template.inventory, credential=job_template.credential, cloud_credential=job_template.cloud_credential, @@ -626,25 +634,29 @@ try: ) job._is_new = _ jobs.append(job) + job_i += 1 if not job._is_new: + job_template_idx += 1 + group_idx += 1 continue - if i == n: + if i+1 == n: job_template.last_job = job if job_template.pk % 5 == 0: job_template.current_job = job job_template.save() - with transaction.atomic(): - if job_template.inventory: - inv_groups = [g for g in job_template.inventory.groups.all()] - if len(inv_groups): - JobHostSummary.objects.bulk_create([ - JobHostSummary( - job=job, host=h, host_name=h.name, processed=1, - created=now(), modified=now() - ) - for h in inv_groups[group_idx % len(inv_groups)].hosts.all()[:100] - ]) + if job._is_new: + with transaction.atomic(): + if job_template.inventory: + inv_groups = [g for g in job_template.inventory.groups.all()] + if len(inv_groups): + JobHostSummary.objects.bulk_create([ + JobHostSummary( + job=job, host=h, host_name=h.name, processed=1, + created=now(), modified=now() + ) + for h in inv_groups[group_idx % len(inv_groups)].hosts.all()[:100] + ]) group_idx += 1 job_template_idx += 1 if n: From 36c68178dd74df304feb4c47cb4e563b72694c40 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Sat, 21 Jan 2017 12:45:40 -0500 Subject: [PATCH 2/4] cleanup from accessible_objects refactor --- awx/main/models/mixins.py | 16 ++++------ awx/main/models/unified_jobs.py | 8 ++--- tools/data_generators/presets.tsv | 30 +++++++++---------- .../rbac_dummy_data_generator.py | 17 +++++++---- 4 files changed, 35 insertions(+), 36 deletions(-) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 645626bca3..d70756251f 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -3,7 +3,6 @@ import json # Django from django.db import models -from django.db.models import Q from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import User # noqa @@ -38,7 +37,6 @@ class ResourceMixin(models.Model): ''' return ResourceMixin._accessible_objects(cls, accessor, role_field) - @staticmethod def _accessible_pk_qs(cls, accessor, role_field, content_types=None): if type(accessor) == User: @@ -50,17 +48,15 @@ class ResourceMixin(models.Model): ancestor_roles = Role.objects.filter(content_type__pk=accessor_type.id, object_id=accessor.id) - if content_types is not None: - return RoleAncestorEntry.objects.filter( - ancestor__in = ancestor_roles, - content_type_id__in = content_types, - role_field = role_field - ).values_list('object_id').distinct() + if content_types is None: + ct_kwarg = dict(content_type_id = ContentType.objects.get_for_model(cls).id) + else: + ct_kwarg = dict(content_type_id__in = content_types) return RoleAncestorEntry.objects.filter( ancestor__in = ancestor_roles, - content_type_id = ContentType.objects.get_for_model(cls).id, - role_field = role_field + role_field = role_field, + **ct_kwarg ).values_list('object_id').distinct() diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 87cdb90d73..896e740c0c 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -175,12 +175,10 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio Does not return inventory sources or system JTs, these should be handled inside of get_queryset where it is utilized. ''' - # Algorithmic option, if we don't want hardcoded class names - # ujt_name_list = [c.__name__.lower() for c in cls.__subclasses__()] - # ujt_name_list.remove('inventorysource') + ujt_names = [c.__name__.lower() for c in cls.__subclasses__() + if c.__name__.lower() not in ['inventorysource', 'systemjobtemplate']] subclass_content_types = ContentType.objects.filter( - model__in=['project', 'jobtemplate', 'systemjobtemplate', 'workflowjobtemplate'] - ).values_list('id', flat=True) + model__in=ujt_names).values_list('id', flat=True) return ResourceMixin._accessible_pk_qs(cls, accessor, role_field, content_types=subclass_content_types) diff --git a/tools/data_generators/presets.tsv b/tools/data_generators/presets.tsv index 5ba5b4557f..02e2d8f28f 100644 --- a/tools/data_generators/presets.tsv +++ b/tools/data_generators/presets.tsv @@ -1,15 +1,15 @@ -resource medium jan2017 -organizations 500 1 -users 5000 3 -teams 500 2 -projects 1000 30 -job-templates 2000 127 -credentials 2000 50 -inventories 2000 6 -inventory-groups 500 15 -inventory-hosts 2500 15 -wfjts 100 0 -nodes 1000 0 -labels 1000 0 -jobs 1000 157208 -job-events 1000 3370942 \ No newline at end of file +resource medium Jan2017 jobs1k jobs10k +organizations 500 1 1 1 +users 5000 3 3 3 +teams 500 2 2 2 +projects 1000 30 30 30 +job_templates 2000 127 127 127 +credentials 2000 50 50 50 +inventories 2000 6 6 6 +inventory_groups 500 15 15 15 +inventory_hosts 2500 15 15 15 +wfjts 100 0 0 0 +nodes 1000 0 0 0 +labels 1000 0 0 0 +jobs 1000 157208 1000 10000 +job_events 1000 3370942 20000 200000 \ No newline at end of file diff --git a/tools/data_generators/rbac_dummy_data_generator.py b/tools/data_generators/rbac_dummy_data_generator.py index 910568f8d6..8fe910d401 100755 --- a/tools/data_generators/rbac_dummy_data_generator.py +++ b/tools/data_generators/rbac_dummy_data_generator.py @@ -490,7 +490,8 @@ try: if project_idx == 0 and i == 0: job_template.admin_role.members.add(jt_admin) project_idx += 1 - print('') + if n > 0: + print('') print('# Creating %d Workflow Job Templates' % n_wfjts) org_idx = 0 @@ -511,7 +512,8 @@ try: wfjt._is_new = _ wfjts.append(wfjt) org_idx += 1 - print('') + if n: + print('') print('# Creating %d Workflow Job Template nodes' % n_nodes) wfjt_idx = 0 @@ -561,7 +563,8 @@ try: parent_node.success_nodes.add(node) parent_idx = (parent_idx + 7) % len(wfjt_nodes) wfjt_idx += 1 - print('') + if n: + print('') print('# Creating %d Labels' % n_labels) org_idx = 0 @@ -580,7 +583,8 @@ try: ) labels.append(label) org_idx += 1 - print('') + if n: + print('') label_gen = yield_choice(labels) print('# Adding labels to job templates') @@ -636,10 +640,9 @@ try: jobs.append(job) job_i += 1 if not job._is_new: - job_template_idx += 1 group_idx += 1 continue - if i+1 == n: + if i + 1 == n: job_template.last_job = job if job_template.pk % 5 == 0: job_template.current_job = job @@ -689,3 +692,5 @@ try: except Rollback: print('Rolled back changes') pass + +print('') From f265d2fe0f4d1e92372bedf4dfaae5805ed00095 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Sun, 22 Jan 2017 17:09:29 -0500 Subject: [PATCH 3/4] tighten up code for new UJ queryset --- awx/main/access.py | 11 +++-------- awx/main/models/unified_jobs.py | 4 ++-- tools/data_generators/rbac_dummy_data_generator.py | 2 +- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index e99a77dd88..ded2c18239 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1883,14 +1883,12 @@ class UnifiedJobAccess(BaseAccess): qs = self.model.objects.all() else: inv_pk_qs = Inventory._accessible_pk_qs(Inventory, self.user, 'read_role') - inv_update_qs = InventoryUpdate.objects.filter(inventory_source__inventory__id__in=inv_pk_qs) - ad_hoc_command_qs = AdHocCommand.objects.filter(inventory__id__in=inv_pk_qs) org_auditor_qs = Organization.objects.filter( Q(admin_role__members=self.user) | Q(auditor_role__members=self.user)) qs = self.model.objects.filter( - Q(unified_job_template__id__in=UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role')) | - Q(inventoryupdate__in=inv_update_qs) | - Q(adhoccommand__in=ad_hoc_command_qs) | + Q(unified_job_template_id__in=UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role')) | + Q(inventoryupdate__inventory_source__inventory__id__in=inv_pk_qs) | + Q(adhoccommand__inventory__id__in=inv_pk_qs) | Q(job__inventory__organization__in=org_auditor_qs) | Q(job__project__organization__in=org_auditor_qs) ) @@ -1919,9 +1917,6 @@ class UnifiedJobAccess(BaseAccess): # 'job_template__credential', # 'job_template__cloud_credential', #) - # Maybe we can do these, like: - # 'projectupdate__project', - # 'inventoryupdate__inventory' return qs.all() diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 896e740c0c..fdbe566fc2 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -177,8 +177,8 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio ''' ujt_names = [c.__name__.lower() for c in cls.__subclasses__() if c.__name__.lower() not in ['inventorysource', 'systemjobtemplate']] - subclass_content_types = ContentType.objects.filter( - model__in=ujt_names).values_list('id', flat=True) + subclass_content_types = list(ContentType.objects.filter( + model__in=ujt_names).values_list('id', flat=True)) return ResourceMixin._accessible_pk_qs(cls, accessor, role_field, content_types=subclass_content_types) diff --git a/tools/data_generators/rbac_dummy_data_generator.py b/tools/data_generators/rbac_dummy_data_generator.py index 8fe910d401..a2c69e35c9 100755 --- a/tools/data_generators/rbac_dummy_data_generator.py +++ b/tools/data_generators/rbac_dummy_data_generator.py @@ -669,11 +669,11 @@ try: job_idx = 0 for n in spread(n_job_events, n_jobs): job = jobs[job_idx] + # Check if job already has events, for idempotence if not job._is_new: continue sys.stdout.write('\r Creating %d job events for job %d' % (n, job.id)) sys.stdout.flush() - # Check if job already has events, for idempotence JobEvent.objects.bulk_create([ JobEvent( created=now(), From c22cf2f7e6acf93dc07cc253816dbd76adb9f65e Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Wed, 25 Jan 2017 09:16:07 -0500 Subject: [PATCH 4/4] more presets and timing functionality to data generator --- tools/data_generators/presets.tsv | 30 +++++++++---------- .../rbac_dummy_data_generator.py | 5 ++++ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/tools/data_generators/presets.tsv b/tools/data_generators/presets.tsv index 02e2d8f28f..c07e33fd13 100644 --- a/tools/data_generators/presets.tsv +++ b/tools/data_generators/presets.tsv @@ -1,15 +1,15 @@ -resource medium Jan2017 jobs1k jobs10k -organizations 500 1 1 1 -users 5000 3 3 3 -teams 500 2 2 2 -projects 1000 30 30 30 -job_templates 2000 127 127 127 -credentials 2000 50 50 50 -inventories 2000 6 6 6 -inventory_groups 500 15 15 15 -inventory_hosts 2500 15 15 15 -wfjts 100 0 0 0 -nodes 1000 0 0 0 -labels 1000 0 0 0 -jobs 1000 157208 1000 10000 -job_events 1000 3370942 20000 200000 \ No newline at end of file +resource medium Jan2017 jobs1k jobs10k jobs50k jobs100k +organizations 500 1 1 1 1 1 +users 5000 3 3 3 3 3 +teams 500 2 2 2 2 2 +projects 1000 30 30 30 30 30 +job_templates 2000 127 127 127 127 127 +credentials 2000 50 50 50 50 50 +inventories 2000 6 6 6 6 6 +inventory_groups 500 15 15 15 15 15 +inventory_hosts 2500 15 15 15 15 15 +wfjts 100 0 0 0 0 0 +nodes 1000 0 0 0 0 0 +labels 1000 0 0 0 0 0 +jobs 1000 157208 1000 10000 50000 100000 +job_events 1000 3370942 20000 200000 1000000 2000000 \ No newline at end of file diff --git a/tools/data_generators/rbac_dummy_data_generator.py b/tools/data_generators/rbac_dummy_data_generator.py index a2c69e35c9..4d0a319f5a 100755 --- a/tools/data_generators/rbac_dummy_data_generator.py +++ b/tools/data_generators/rbac_dummy_data_generator.py @@ -7,6 +7,7 @@ import sys # Python from collections import defaultdict from optparse import make_option, OptionParser +from datetime import datetime import logging @@ -184,6 +185,9 @@ def mock_save(self, *args, **kwargs): PrimordialModel.save = mock_save +startTime = datetime.now() + + try: with transaction.atomic(): @@ -694,3 +698,4 @@ except Rollback: pass print('') +print('script execution time: {}'.format(datetime.now() - startTime))