diff --git a/djautotask/__init__.py b/djautotask/__init__.py index f648f2f..cfedf1b 100644 --- a/djautotask/__init__.py +++ b/djautotask/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -VERSION = (1, 6, 8, 'final') +VERSION = (1, 6, 9, 'final') # pragma: no cover if VERSION[-1] != "final": diff --git a/djautotask/admin.py b/djautotask/admin.py index 88ba7bf..6eb2ef1 100644 --- a/djautotask/admin.py +++ b/djautotask/admin.py @@ -305,7 +305,8 @@ class ResourceServiceDeskRoleAdmin(admin.ModelAdmin): @admin.register(models.Contract) class ContractAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'number', 'account', 'status_name') + list_display = ('id', 'name', 'number', 'account', 'status_name', + 'contract_exclusion_set_id') search_fields = ('id', 'name', 'number') list_filter = ('status',) @@ -402,3 +403,18 @@ class ProjectNoteTypeAdmin(admin.ModelAdmin): @admin.register(models.CompanyAlert) class CompanyAlertsAdmin(admin.ModelAdmin): list_display = ('id', 'alert_text', 'alert_type', 'account') + + +@admin.register(models.ContractExclusionSet) +class ContractExclusionSetAdmin(admin.ModelAdmin): + list_display = ('id', 'name', 'is_active') + + +@admin.register(models.ContractExclusionSetExcludedWorkType) +class ContractExcludedWorkTypeAdmin(admin.ModelAdmin): + list_display = ('id', 'contract_exclusion_set', 'excluded_work_type') + + +@admin.register(models.ContractExclusionSetExcludedRole) +class ContractExcludedRoleAdmin(admin.ModelAdmin): + list_display = ('id', 'contract_exclusion_set', 'excluded_role') diff --git a/djautotask/api.py b/djautotask/api.py index a64826c..77d2228 100644 --- a/djautotask/api.py +++ b/djautotask/api.py @@ -721,6 +721,18 @@ class ContractsAPIClient(AutotaskAPIClient): API = 'Contracts' +class ContractExclusionSetAPIClient(AutotaskAPIClient): + API = 'ContractExclusionSets' + + +class ContractsExcludedWorkTypeAPIClient(AutotaskAPIClient): + API = 'ContractExclusionSetExcludedWorkTypes' + + +class ContractsExcludedRoleAPIClient(AutotaskAPIClient): + API = 'ContractExclusionSetExcludedRoles' + + class AccountPhysicalLocationsAPIClient(AutotaskAPIClient): API = 'CompanyLocations' diff --git a/djautotask/management/commands/atsync.py b/djautotask/management/commands/atsync.py index ccbfbe9..d0d4035 100644 --- a/djautotask/management/commands/atsync.py +++ b/djautotask/management/commands/atsync.py @@ -134,6 +134,21 @@ def __init__(self, *args, **kwargs): ('task_predecessor', sync.TaskPredecessorSynchronizer, _('Task Predecessor')), ('contact', sync.ContactSynchronizer, _('Contact')), + ( + 'contract_exclusion_set', + sync.ContractExclusionSetSynchronizer, + _('Contract Exclusion Set') + ), + ( + 'contract_excluded_work_type', + sync.ContractExcludedWorkTypeSynchronizer, + _('Contract Excluded Work Type') + ), + ( + 'contract_excluded_role', + sync.ContractExcludedRoleSynchronizer, + _('Contract Excluded Role') + ), ) self.synchronizer_map = OrderedDict() for name, synchronizer, obj_name in synchronizers: diff --git a/djautotask/migrations/0118_contractexclusionset_and_more.py b/djautotask/migrations/0118_contractexclusionset_and_more.py new file mode 100644 index 0000000..19796be --- /dev/null +++ b/djautotask/migrations/0118_contractexclusionset_and_more.py @@ -0,0 +1,90 @@ +# Generated by Django 4.2.16 on 2024-10-30 23:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('djautotask', '0117_alter_tasknote_task_alter_ticketnote_ticket'), + ] + + operations = [ + migrations.CreateModel( + name='ContractExclusionSet', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('name', models.CharField(max_length=255)), + ], + ), + migrations.AddField( + model_name='contract', + name='contract_exclusion_set_id', + field=models.IntegerField(blank=True, null=True), + ), + migrations.CreateModel( + name='ContractExclusionSetExcludedWorkType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('contract_exclusion_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='djautotask.contractexclusionset')), + ('excluded_work_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='djautotask.billingcode')), + ], + ), + migrations.CreateModel( + name='ContractExclusionSetExcludedRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('contract_exclusion_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='djautotask.contractexclusionset')), + ('excluded_role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='djautotask.role')), + ], + ), + migrations.AddField( + model_name='contractexclusionset', + name='excluded_roles', + field=models.ManyToManyField(related_name='excluded_role_sets', through='djautotask.ContractExclusionSetExcludedRole', to='djautotask.role'), + ), + migrations.AddField( + model_name='contractexclusionset', + name='excluded_work_types', + field=models.ManyToManyField(related_name='excluded_work_type_sets', through='djautotask.ContractExclusionSetExcludedWorkType', to='djautotask.billingcode'), + ), + migrations.CreateModel( + name='ContractExcludedRoleTracker', + fields=[ + ], + options={ + 'db_table': 'djautotask_contractexclusionsetrole', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('djautotask.contractexclusionsetexcludedrole',), + ), + migrations.CreateModel( + name='ContractExcludedWorkTypeTracker', + fields=[ + ], + options={ + 'db_table': 'djautotask_contractexclusionsetworktype', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('djautotask.contractexclusionsetexcludedworktype',), + ), + migrations.CreateModel( + name='ContractExclusionSetTracker', + fields=[ + ], + options={ + 'db_table': 'djautotask_contractexclusionset', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('djautotask.contractexclusionset',), + ), + ] diff --git a/djautotask/migrations/0120_merge_20241101_1316.py b/djautotask/migrations/0120_merge_20241101_1316.py new file mode 100644 index 0000000..1b84884 --- /dev/null +++ b/djautotask/migrations/0120_merge_20241101_1316.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.16 on 2024-11-01 13:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('djautotask', '0118_contractexclusionset_and_more'), + ('djautotask', '0119_merge_20241024_1521'), + ] + + operations = [ + ] diff --git a/djautotask/models.py b/djautotask/models.py index 5843b31..92397bf 100644 --- a/djautotask/models.py +++ b/djautotask/models.py @@ -948,10 +948,12 @@ class Contract(models.Model): number = models.CharField(blank=True, null=True, max_length=50) status = models.CharField( max_length=20, blank=True, null=True, choices=STATUS_CHOICES) - account = models.ForeignKey( 'Account', blank=True, null=True, on_delete=models.SET_NULL ) + contract_exclusion_set_id = models.IntegerField( + blank=True, null=True + ) class Meta: ordering = ('name',) @@ -960,6 +962,42 @@ def __str__(self): return self.name +class ContractExclusionSet(models.Model): + description = models.TextField(blank=True, null=True) + is_active = models.BooleanField(default=True) + name = models.CharField(max_length=255) + excluded_work_types = models.ManyToManyField( + 'BillingCode', through='ContractExclusionSetExcludedWorkType', + related_name='excluded_work_type_sets' + ) + excluded_roles = models.ManyToManyField( + 'Role', through='ContractExclusionSetExcludedRole', + related_name='excluded_role_sets' + ) + + def __str__(self): + return self.name + + +class ContractExclusionSetExcludedWorkType(models.Model): + contract_exclusion_set = models.ForeignKey('ContractExclusionSet', + on_delete=models.CASCADE) + excluded_work_type = models.ForeignKey('BillingCode', + on_delete=models.CASCADE) + + def __str__(self): + return str(self.id) or '' + + +class ContractExclusionSetExcludedRole(models.Model): + contract_exclusion_set = models.ForeignKey('ContractExclusionSet', + on_delete=models.CASCADE) + excluded_role = models.ForeignKey('Role', on_delete=models.CASCADE) + + def __str__(self): + return str(self.id) or '' + + class ServiceCall(TimeStampedModel): description = models.TextField(blank=True, null=True, max_length=2000) duration = models.DecimalField( @@ -1463,6 +1501,30 @@ class Meta: db_table = 'djautotask_contract' +class ContractExclusionSetTracker(ContractExclusionSet): + tracker = FieldTracker() + + class Meta: + proxy = True + db_table = 'djautotask_contractexclusionset' + + +class ContractExcludedWorkTypeTracker(ContractExclusionSetExcludedWorkType): + tracker = FieldTracker() + + class Meta: + proxy = True + db_table = 'djautotask_contractexclusionsetworktype' + + +class ContractExcludedRoleTracker(ContractExclusionSetExcludedRole): + tracker = FieldTracker() + + class Meta: + proxy = True + db_table = 'djautotask_contractexclusionsetrole' + + class ServiceCallTracker(ServiceCall): tracker = FieldTracker() diff --git a/djautotask/sync.py b/djautotask/sync.py index 4c93870..12e6b94 100644 --- a/djautotask/sync.py +++ b/djautotask/sync.py @@ -1812,6 +1812,8 @@ def _assign_field_data(self, instance, object_data): instance.name = object_data.get('contractName') instance.number = object_data.get('contractNumber') instance.status = str(object_data.get('status')) + instance.contract_exclusion_set_id = \ + object_data.get('contractExclusionSetID') self.set_relations(instance, object_data) @@ -2003,6 +2005,76 @@ def active_ids(self): return active_ids +class ContractExclusionSetSynchronizer(Synchronizer): + client_class = api.ContractExclusionSetAPIClient + model_class = models.ContractExclusionSetTracker + last_updated_field = None + + def _assign_field_data(self, instance, object_data): + instance.id = object_data['id'] + instance.name = object_data['name'] + instance.is_active = object_data['isActive'] + instance.description = object_data['description'] + + return instance + + +class ContractExcludedWorkTypeSynchronizer(BatchQueryMixin, Synchronizer): + client_class = api.ContractsExcludedWorkTypeAPIClient + model_class = models.ContractExcludedWorkTypeTracker + condition_field_name = 'contractExclusionSetID' + last_updated_field = None + + related_meta = { + 'contractExclusionSetID': + (models.ContractExclusionSet, 'contract_exclusion_set'), + 'excludedWorkTypeID': (models.BillingCode, 'excluded_work_type') + } + + def _assign_field_data(self, instance, object_data): + instance.id = object_data['id'] + self.set_relations(instance, object_data) + + return instance + + @property + def active_ids(self): + active_ids = models.ContractExclusionSet.objects.exclude( + is_active=False + ).values_list( + 'id', flat=True + ) + return active_ids + + +class ContractExcludedRoleSynchronizer(BatchQueryMixin, Synchronizer): + client_class = api.ContractsExcludedRoleAPIClient + model_class = models.ContractExcludedRoleTracker + condition_field_name = 'contractExclusionSetID' + last_updated_field = None + + related_meta = { + 'contractExclusionSetID': + (models.ContractExclusionSet, 'contract_exclusion_set'), + 'excludedRoleID': (models.Role, 'excluded_role') + } + + def _assign_field_data(self, instance, object_data): + instance.id = object_data['id'] + self.set_relations(instance, object_data) + + return instance + + @property + def active_ids(self): + active_ids = models.ContractExclusionSet.objects.exclude( + is_active=False + ).values_list( + 'id', flat=True + ) + return active_ids + + class NoteTypeSynchronizer(PicklistSynchronizer): # Ticket note types are including task note types, and there are other # note types currently not used. e.g. project note types diff --git a/djautotask/tests/fixture_utils.py b/djautotask/tests/fixture_utils.py index 5a9e324..eaa3d97 100644 --- a/djautotask/tests/fixture_utils.py +++ b/djautotask/tests/fixture_utils.py @@ -339,6 +339,31 @@ def init_contracts(): return synchronizer.sync() +def init_contract_exclusion_sets(): + models.ContractExclusionSet.objects.all().delete() + mocks.service_api_get_contract_exclusion_sets_call( + fixtures.API_CONTRACT_EXCLUSION_SET) + synchronizer = sync.ContractExclusionSetSynchronizer() + return synchronizer.sync() + + +def init_contract_exclusion_roles(): + models.ContractExclusionSetExcludedRole.objects.all() \ + .delete() + mocks.service_api_get_contract_excluded_roles_call( + fixtures.API_CONTRACT_EXCLUSION_ROLE) + synchronizer = sync.ContractExcludedRoleSynchronizer() + return synchronizer.sync() + + +def init_contract_exclusion_work_types(): + models.ContractExclusionSetExcludedWorkType.objects.all().delete() + mocks.service_api_get_contract_excluded_work_types_call( + fixtures.API_CONTRACT_EXCLUSION_WORK_TYPE) + synchronizer = sync.ContractExcludedWorkTypeSynchronizer() + return synchronizer.sync() + + def init_service_calls(): models.ServiceCall.objects.all().delete() mocks.service_api_get_service_calls_call(fixtures.API_SERVICE_CALL) diff --git a/djautotask/tests/fixtures.py b/djautotask/tests/fixtures.py index 29c4820..da2df96 100644 --- a/djautotask/tests/fixtures.py +++ b/djautotask/tests/fixtures.py @@ -1272,6 +1272,7 @@ 'serviceLevelAgreementID': 1, 'purchaseOrderNumber': "", 'internalCurrencySetupFee': 995.0000, + 'contractExclusionSetID': 1, } ] API_CONTRACT = { @@ -1445,4 +1446,39 @@ API_COMPANY_ALERTS = { "items": API_COMPANY_ALERT_ITEMS, "pageDetails": API_PAGE_DETAILS -} \ No newline at end of file +} + +API_CONTRACT_EXCLUSION_SET_ITEM = { + "id": 1, + "description": "Sample Set", + "isActive": True, + "name": "Set1", +} +API_CONTRACT_EXCLUSION_SET_ITEMS = [API_CONTRACT_EXCLUSION_SET_ITEM] +API_CONTRACT_EXCLUSION_SET = { + "items": API_CONTRACT_EXCLUSION_SET_ITEMS, + "pageDetails": API_PAGE_DETAILS +} + +API_CONTRACT_EXCLUSION_ROLE_ITEM = { + "id": 1, + "contractExclusionSetID": 1, + "excludedRoleID": 8 +} +API_CONTRACT_EXCLUSION_ROLE_ITEMS = [API_CONTRACT_EXCLUSION_ROLE_ITEM] +API_CONTRACT_EXCLUSION_ROLE = { + "items": API_CONTRACT_EXCLUSION_ROLE_ITEMS, + "pageDetails": API_PAGE_DETAILS +} + + +API_CONTRACT_EXCLUSION_WORK_TYPE_ITEM = { + "id": 1, + "contractExclusionSetID": 1, + "excludedWorkTypeID": 2 +} +API_CONTRACT_EXCLUSION_WORK_TYPE_ITEMS = [API_CONTRACT_EXCLUSION_WORK_TYPE_ITEM] +API_CONTRACT_EXCLUSION_WORK_TYPE = { + "items": API_CONTRACT_EXCLUSION_WORK_TYPE_ITEMS, + "pageDetails": API_PAGE_DETAILS +} diff --git a/djautotask/tests/mocks.py b/djautotask/tests/mocks.py index 09bac79..1cac0ab 100644 --- a/djautotask/tests/mocks.py +++ b/djautotask/tests/mocks.py @@ -59,6 +59,21 @@ def service_api_get_contracts_call(return_value): return create_mock_call(method_name, return_value) +def service_api_get_contract_exclusion_sets_call(return_value): + method_name = 'djautotask.api.ContractExclusionSetAPIClient.get' + return create_mock_call(method_name, return_value) + + +def service_api_get_contract_excluded_roles_call(return_value): + method_name = 'djautotask.api.ContractsExcludedRoleAPIClient.get' + return create_mock_call(method_name, return_value) + + +def service_api_get_contract_excluded_work_types_call(return_value): + method_name = 'djautotask.api.ContractsExcludedWorkTypeAPIClient.get' + return create_mock_call(method_name, return_value) + + def service_api_get_billing_codes_call(return_value): method_name = 'djautotask.api.BillingCodesAPIClient.get' return create_mock_call(method_name, return_value) diff --git a/djautotask/tests/test_commands.py b/djautotask/tests/test_commands.py index fc16d94..22fd9cc 100644 --- a/djautotask/tests/test_commands.py +++ b/djautotask/tests/test_commands.py @@ -611,6 +611,47 @@ def setUp(self): fixture_utils.init_contracts() +class TestSyncContractExclusionSetCommand(AbstractBaseSyncTest, TestCase): + args = ( + mocks.service_api_get_contract_exclusion_sets_call, + fixtures.API_CONTRACT_EXCLUSION_SET, + 'contract_exclusion_set', + ) + + def setUp(self): + super().setUp() + fixture_utils.init_contract_exclusion_sets() + + +class TestSyncContractExclusionRoleCommand(AbstractBaseSyncTest, TestCase): + args = ( + mocks.service_api_get_contract_excluded_roles_call, + fixtures.API_CONTRACT_EXCLUSION_ROLE, + 'contract_excluded_role', + ) + + def setUp(self): + super().setUp() + fixture_utils.init_roles() + fixture_utils.init_contract_exclusion_sets() + fixture_utils.init_contract_exclusion_roles() + + +class TestSyncContractExclusionWorkTypeCommand(AbstractBaseSyncTest, + TestCase): + args = ( + mocks.service_api_get_contract_excluded_work_types_call, + fixtures.API_CONTRACT_EXCLUSION_WORK_TYPE, + 'contract_excluded_work_type', + ) + + def setUp(self): + super().setUp() + fixture_utils.init_billing_codes() + fixture_utils.init_contract_exclusion_sets() + fixture_utils.init_contract_exclusion_work_types() + + class TestSyncServiceCallCommand(AbstractBaseSyncTest, TestCase): args = ( mocks.service_api_get_service_calls_call, @@ -786,6 +827,9 @@ def setUp(self): TestSyncTaskPredecessor, TestSyncContactCommand, TestSyncCompanyAlertsCommand, + TestSyncContractExclusionSetCommand, + TestSyncContractExclusionRoleCommand, + TestSyncContractExclusionWorkTypeCommand, ] self.test_args = [] @@ -868,6 +912,11 @@ def test_full_sync(self): 'task_predecessor': models.TaskPredecessor, 'contact': models.Contact, 'company_alert': models.CompanyAlert, + 'contract_exclusion_set': models.ContractExclusionSet, + 'contract_excluded_role': + models.ContractExclusionSetExcludedRole, + 'contract_excluded_work_type': + models.ContractExclusionSetExcludedWorkType, } run_sync_command() pre_full_sync_counts = {} @@ -890,7 +939,9 @@ def test_full_sync(self): 'service_call_task_resource', 'task_predecessor', 'task', - 'time_entry' + 'time_entry', + 'contract_excluded_role', + 'contract_excluded_work_type', ): # Assert that there were objects to get deleted, then change # to zero to verify the output formats correctly. @@ -971,6 +1022,15 @@ def _call_service_api(self): fixtures.API_TASK_PREDECESSOR) mocks.service_api_get_company_alerts_call( fixtures.API_COMPANY_ALERTS) + mocks.service_api_get_contract_exclusion_sets_call( + fixtures.API_CONTRACT_EXCLUSION_SET + ) + mocks.service_api_get_contract_excluded_roles_call( + fixtures.API_CONTRACT_EXCLUSION_ROLE + ) + mocks.service_api_get_contract_excluded_work_types_call( + fixtures.API_CONTRACT_EXCLUSION_WORK_TYPE + ) def _call_empty_service_api(self): mocks.service_api_get_ticket_udf_call(fixtures.API_EMPTY_FIELDS) @@ -1025,4 +1085,10 @@ def _call_empty_service_api(self): mocks.service_api_get_project_note_types_call( fixtures.API_EMPTY_FIELDS) mocks.service_api_get_task_picklist_call(fixtures.API_EMPTY_FIELDS) - mocks.service_api_get_company_alerts_call(fixtures.API_COMPANY_ALERTS) + mocks.service_api_get_company_alerts_call(fixtures.API_EMPTY) + mocks.service_api_get_contract_exclusion_sets_call( + fixtures.API_EMPTY) + mocks.service_api_get_contract_excluded_roles_call( + fixtures.API_EMPTY) + mocks.service_api_get_contract_excluded_work_types_call( + fixtures.API_EMPTY) diff --git a/djautotask/tests/test_sync.py b/djautotask/tests/test_sync.py index 72427e9..e2e76d6 100644 --- a/djautotask/tests/test_sync.py +++ b/djautotask/tests/test_sync.py @@ -1159,6 +1159,78 @@ def _assert_fields(self, instance, object_data): self.assertEqual(instance.number, object_data['contractNumber']) self.assertEqual(instance.status, str(object_data['status'])) self.assertEqual(instance.account.id, object_data['companyID']) + self.assertEqual(instance.contract_exclusion_set_id, + object_data['contractExclusionSetID']) + + +class TestContractExclusionSetSynchronizer(SynchronizerTestMixin, TestCase): + synchronizer_class = sync.ContractExclusionSetSynchronizer + model_class = models.ContractExclusionSetTracker + fixture = fixtures.API_CONTRACT_EXCLUSION_SET + update_field = 'name' + + def setUp(self): + super().setUp() + self._sync(self.fixture) + + def _call_api(self, return_data): + return mocks.service_api_get_contract_exclusion_sets_call( + return_data) + + def _assert_fields(self, instance, object_data): + self.assertEqual(instance.id, object_data['id']) + self.assertEqual(instance.description, object_data['description']) + self.assertEqual(instance.is_active, object_data['isActive']) + self.assertEqual(instance.name, object_data['name']) + + +class TestContractExclusionRoleSynchronizer(SynchronizerTestMixin, TestCase): + synchronizer_class = sync.ContractExcludedRoleSynchronizer + model_class = models.ContractExcludedRoleTracker + fixture = fixtures.API_CONTRACT_EXCLUSION_ROLE + update_field = 'excluded_role_id' + + def setUp(self): + super().setUp() + fixture_utils.init_roles() + fixture_utils.init_contract_exclusion_sets() + self._sync(self.fixture) + + def _call_api(self, return_data): + return mocks.service_api_get_contract_excluded_roles_call( + return_data) + + def _assert_fields(self, instance, object_data): + self.assertEqual(instance.id, object_data['id']) + self.assertEqual(instance.contract_exclusion_set.id, + object_data['contractExclusionSetID']) + self.assertEqual(instance.excluded_role.id, + object_data['excludedRoleID']) + + +class TestContractExclusionWorkTypeSynchronizer(SynchronizerTestMixin, + TestCase): + synchronizer_class = sync.ContractExcludedWorkTypeSynchronizer + model_class = models.ContractExcludedWorkTypeTracker + fixture = fixtures.API_CONTRACT_EXCLUSION_WORK_TYPE + update_field = 'excluded_work_type_id' + + def setUp(self): + super().setUp() + fixture_utils.init_billing_codes() + fixture_utils.init_contract_exclusion_sets() + self._sync(self.fixture) + + def _call_api(self, return_data): + return mocks.service_api_get_contract_excluded_work_types_call( + return_data) + + def _assert_fields(self, instance, object_data): + self.assertEqual(instance.id, object_data['id']) + self.assertEqual(instance.contract_exclusion_set.id, + object_data['contractExclusionSetID']) + self.assertEqual(instance.excluded_work_type.id, + object_data['excludedWorkTypeID']) class TestCompanyAlertsSynchronizer(SynchronizerTestMixin, TestCase):