class Order(AdminAbsoluteUrlMixin, models.Model, metaclass=TransitionWorkflowBase): status = TransitionField( default=OrderStatus.new.id, choices=OrderStatus(), ) remarks = models.CharField(max_length=255, blank=True, default='') @classmethod @transition_action(return_attachment=True) def pack(cls, instances, **kwargs): requester = kwargs.get('requester') path = os.path.join(tempfile.gettempdir(), 'test.txt') with open(path, 'w') as f: f.write('test') return add_attachment_from_disk(instances, path, requester, 'pack action') @classmethod @transition_action( return_attachment=True, verbose_name='Go to post office', run_after=['pack'], ) def go_to_post_office(cls, instances, **kwargs): pass @classmethod @transition_action( return_attachment=False, ) def generate_exception(cls, instances, request, **kwargs): raise Exception('exception')
class AccessCard(AdminAbsoluteUrlMixin, TimeStampMixin, Regionalizable, models.Model, metaclass=TransitionWorkflowBaseWithPermissions): visual_number = models.CharField( max_length=255, null=False, blank=False, unique=True, help_text=_('Number visible on the access card')) system_number = models.CharField( max_length=255, null=False, blank=False, unique=True, help_text=_('Internal number in the access system')) issue_date = models.DateField(null=True, blank=True, help_text=_('Date of issue to the User')) notes = models.TextField(null=True, blank=True, help_text=_('Optional notes')) user = models.ForeignKey(RalphUser, null=True, blank=True, related_name='+', help_text=_('User of the card'), on_delete=models.SET_NULL) owner = models.ForeignKey(RalphUser, null=True, blank=True, related_name='+', help_text=('Owner of the card'), on_delete=models.SET_NULL) status = TransitionField(choices=AccessCardStatus(), default=AccessCardStatus.new.id, null=False, blank=False, help_text=_('Access card status')) access_zones = TreeManyToManyField(AccessZone, blank=True, related_name='access_cards') def __str__(self): return _('Access Card: {}').format(self.visual_number) @classmethod def get_autocomplete_queryset(cls): return cls._default_manager.exclude( status=AccessCardStatus.liquidated.id) @classmethod @transition_action() def unassign_user(cls, instances, **kwargs): for instance in instances: kwargs['history_kwargs'][instance.pk]['affected_user'] = str( instance.user) instance.user = None @classmethod @transition_action( form_fields={ 'user': { 'field': forms.CharField(label=_('User')), 'autocomplete_field': 'user', 'default_value': partial(autocomplete_user, field_name='user') } }, ) def assign_user(cls, instances, **kwargs): user = get_user_model().objects.get(pk=int(kwargs['user'])) for instance in instances: instance.user = user @classmethod @transition_action( form_fields={ 'owner': { 'field': forms.CharField(label=_('Owner')), 'autocomplete_field': 'owner', 'default_value': partial(autocomplete_user, field_name='owner') } }, help_text=_('assign owner'), ) def assign_owner(cls, instances, **kwargs): owner = get_user_model().objects.get(pk=int(kwargs['owner'])) for instance in instances: instance.owner = owner @classmethod @transition_action() def unassign_owner(cls, instances, **kwargs): for instance in instances: kwargs['history_kwargs'][instance.pk]['affected_owner'] = str( instance.owner) instance.owner = None @classmethod @transition_action() def clear_access_zones(cls, instances, requester, **kwargs): for instance in instances: instance.access_zones.clear() @classmethod @transition_action( form_fields={'notes': { 'field': forms.CharField(label=_('notes')), }}) def add_notes(cls, instances, **kwargs): for instance in instances: instance.notes = '{}\n{}'.format(instance.notes, kwargs['notes']) @classmethod @transition_action(run_after=['release_report']) def assign_requester_as_an_owner(cls, instances, requester, **kwargs): """Assign current user as an owner""" for instance in instances: instance.owner = requester instance.save() @classmethod @transition_action( form_fields={ 'accept': { 'field': forms.BooleanField( label=_('I have read and fully understand and ' 'accept the agreement.')) }, }) def accept_asset_release_agreement(cls, instances, requester, **kwargs): pass @classmethod @transition_action(form_fields={ 'report_language': { 'field': forms.ModelChoiceField( label=_('Release report language'), queryset=ReportLanguage.objects.all().order_by('-default'), empty_label=None), 'exclude_from_history': True } }, return_attachment=True, run_after=['assign_owner', 'assign_user']) def release_report(cls, instances, requester, transition_id, **kwargs): report_name = get_report_name_for_transition_id(transition_id) return generate_report(instances=instances, name=report_name, requester=requester, language=kwargs['report_language'], context=cls._get_report_context(instances)) @classmethod @transition_action(run_after=['release_report']) def send_attachments_to_user(cls, requester, transition_id, **kwargs): context_func = get_hook('back_office.transition_action.email_context') send_transition_attachments_to_user(requester=requester, transition_id=transition_id, context_func=context_func, **kwargs) @classmethod def _get_report_context(cls, instances): context = [{ 'visual_number': obj.visual_number, } for obj in instances] return context
class AsyncOrder(AdminAbsoluteUrlMixin, models.Model, metaclass=TransitionWorkflowBase): status = TransitionField( default=OrderStatus.new.id, choices=OrderStatus(), ) name = models.CharField(max_length=100) counter = models.PositiveSmallIntegerField(default=1) username = models.CharField(max_length=100, null=True, blank=True) foo = models.ForeignKey(Foo, null=True, blank=True) @classmethod @transition_action( verbose_name='Long running action', form_fields={'name': { 'field': forms.CharField(label='name'), }}, is_async=True, run_after=['freezing_action']) def long_running_action(cls, instances, **kwargs): for instance in instances: instance.counter += 1 instance.name = kwargs['name'] instance.save() @classmethod @transition_action(verbose_name='Another long running action', form_fields={ 'foo': { 'field': forms.CharField(label='Foo'), 'autocomplete_field': 'foo', } }, is_async=True, run_after=['long_running_action']) def long_running_action_with_precondition(cls, instances, **kwargs): instance = instances[0] # only one instance in asyc action instance.counter += 1 instance.save() kwargs['shared_params'][instance.pk]['counter'] = instance.counter kwargs['history_kwargs'][ instance.pk]['hist_counter'] = instance.counter if instance.counter < 5: raise RescheduleAsyncTransitionActionLater() instance.foo = kwargs['foo'] instance.save() @classmethod @transition_action(verbose_name='Assign user', run_after=['long_running_action']) def assing_user(cls, instances, requester, **kwargs): for instance in instances: instance.username = requester.username instance.save() @classmethod @transition_action( verbose_name='Failing action', is_async=True, ) def failing_action(cls, instances, **kwargs): kwargs['shared_params'][instances[0].pk]['test'] = 'failing' raise ValueError() @classmethod @transition_action( verbose_name='Freezing action', is_async=True, ) def freezing_action(cls, instances, **kwargs): kwargs['shared_params'][instances[0].pk]['test'] = 'freezing' raise FreezeAsyncTransition()
class BackOfficeAsset(Regionalizable, Asset): _allow_in_dashboard = True warehouse = models.ForeignKey(Warehouse, on_delete=models.PROTECT) owner = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, related_name='assets_as_owner', ) user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, related_name='assets_as_user', ) location = models.CharField(max_length=128, null=True, blank=True) purchase_order = models.CharField(max_length=50, null=True, blank=True) loan_end_date = models.DateField( null=True, blank=True, default=None, verbose_name=_('Loan end date'), ) status = TransitionField( default=BackOfficeAssetStatus.new.id, choices=BackOfficeAssetStatus(), ) imei = NullableCharField(max_length=18, null=True, blank=True, unique=True, verbose_name=_('IMEI')) imei2 = NullableCharField(max_length=18, null=True, blank=True, unique=True, verbose_name=_('IMEI 2')) office_infrastructure = models.ForeignKey(OfficeInfrastructure, null=True, blank=True) class Meta: verbose_name = _('Back Office Asset') verbose_name_plural = _('Back Office Assets') @property def country_code(self): iso2 = Country.name_from_id(int(self.region.country)).upper() return iso2_to_iso3(iso2) def __str__(self): return '{} (BC: {} / SN: {})'.format(self.hostname or '-', self.barcode or '-', self.sn or '-') def __repr__(self): return '<BackOfficeAsset: {}>'.format(self.id) def validate_imei(self, imei): return IMEI_SINCE_2003.match(imei) or IMEI_UNTIL_2003.match(imei) def clean(self): super().clean() if self.imei and not self.validate_imei(self.imei): raise ValidationError({ 'imei': _('%(imei)s is not IMEI format') % { 'imei': self.imei } }) if self.imei2 and not self.validate_imei(self.imei2): raise ValidationError({ 'imei2': _('%(imei)s is not IMEI format') % { 'imei': self.imei2 } # noqa }) def is_liquidated(self, date=None): date = date or datetime.date.today() # check if asset has status 'liquidated' and if yes, check if it has # this status on given date if (self.status == BackOfficeAssetStatus.liquidated and self._liquidated_at(date)): return True return False def generate_hostname(self, commit=True, template_vars=None): def render_template(template): template = Template(template) context = Context(template_vars or {}) return template.render(context) logger.warning( 'Generating new hostname for {} using {} old hostname {}'.format( self, template_vars, self.hostname)) prefix = render_template(ASSET_HOSTNAME_TEMPLATE.get('prefix', ''), ) postfix = render_template(ASSET_HOSTNAME_TEMPLATE.get('postfix', ''), ) counter_length = ASSET_HOSTNAME_TEMPLATE.get('counter_length', 5) last_hostname = AssetLastHostname.increment_hostname(prefix, postfix) self.hostname = last_hostname.formatted_hostname(fill=counter_length) if commit: self.save() # TODO: add message when hostname was changed def _try_assign_hostname(self, commit=False, country=None, force=False): if self.model.category and self.model.category.code: template_vars = { 'code': self.model.category.code, 'country_code': country or self.country_code, } if (force or not self.hostname or self.country_code not in self.hostname): self.generate_hostname(commit, template_vars) @classmethod def get_autocomplete_queryset(cls): return cls._default_manager.exclude( status=BackOfficeAssetStatus.liquidated.id) @classmethod @transition_action(form_fields={ 'user': { 'field': forms.CharField(label=_('User')), 'autocomplete_field': 'user', 'default_value': partial(autocomplete_user, field_name='user') } }, run_after=['unassign_user']) def assign_user(cls, instances, **kwargs): user = get_user_model().objects.get(pk=int(kwargs['user'])) for instance in instances: instance.user = user @classmethod @transition_action( form_fields={ 'owner': { 'field': forms.CharField(label=_('Owner')), 'autocomplete_field': 'owner', 'default_value': partial(autocomplete_user, field_name='owner') } }, help_text=_( 'During this transition owner will be assigned as well as new ' 'hostname might be generated for asset (only for particular model ' 'categories and only if owner\'s country has changed)'), run_after=['unassign_owner']) def assign_owner(cls, instances, **kwargs): owner = get_user_model().objects.get(pk=int(kwargs['owner'])) for instance in instances: instance.owner = owner @classmethod @transition_action(form_fields={ 'licences': { 'field': forms.ModelMultipleChoiceField( queryset=Licence.objects.all(), label=_('Licence'), required=False, ), 'autocomplete_field': 'licence', 'autocomplete_model': 'licences.BaseObjectLicence', 'widget_options': { 'multi': True }, } }, run_after=['unassign_licences']) def assign_licence(cls, instances, **kwargs): for instance in instances: for obj in kwargs['licences']: BaseObjectLicence.objects.get_or_create( base_object=instance, licence_id=obj.id, ) @classmethod @transition_action(run_after=['loan_report', 'return_report']) def unassign_owner(cls, instances, **kwargs): for instance in instances: kwargs['history_kwargs'][instance.pk]['affected_owner'] = str( instance.owner) instance.owner = None @classmethod @transition_action(run_after=['loan_report', 'return_report']) def unassign_user(cls, instances, **kwargs): for instance in instances: kwargs['history_kwargs'][instance.pk]['affected_user'] = str( instance.user) instance.user = None @classmethod @transition_action( form_fields={ 'loan_end_date': { 'field': forms.DateField( label=_('Loan end date'), widget=forms.TextInput(attrs={'class': 'datepicker'})) } }, ) def assign_loan_end_date(cls, instances, **kwargs): for instance in instances: instance.loan_end_date = kwargs['loan_end_date'] @classmethod @transition_action() def unassign_loan_end_date(cls, instances, **kwargs): for instance in instances: instance.loan_end_date = None @classmethod @transition_action( form_fields={ 'warehouse': { 'field': forms.CharField(label=_('Warehouse')), 'autocomplete_field': 'warehouse' } }) def assign_warehouse(cls, instances, **kwargs): warehouse = Warehouse.objects.get(pk=int(kwargs['warehouse'])) for instance in instances: instance.warehouse = warehouse @classmethod @transition_action( form_fields={ 'office_infrastructure': { 'field': forms.CharField(label=_('Office infrastructure')), 'autocomplete_field': 'office_infrastructure' } }, ) def assign_office_infrastructure(cls, instances, **kwargs): office_inf = OfficeInfrastructure.objects.get( pk=int(kwargs['office_infrastructure'])) for instance in instances: instance.office_infrastructure = office_inf @classmethod @transition_action(form_fields={ 'remarks': { 'field': forms.CharField(label=_('Remarks')), } }) def add_remarks(cls, instances, **kwargs): for instance in instances: instance.remarks = '{}\n{}'.format(instance.remarks, kwargs['remarks']) @classmethod @transition_action(form_fields={ 'task_url': { 'field': forms.URLField(label=_('task URL')), } }) def assign_task_url(cls, instances, **kwargs): for instance in instances: instance.task_url = kwargs['task_url'] @classmethod @transition_action() def unassign_licences(cls, instances, **kwargs): BaseObjectLicence.objects.filter(base_object__in=instances).delete() @classmethod @transition_action( form_fields={ 'country': { 'field': forms.ChoiceField( label=_('Country'), choices=Country(), **{ 'initial': Country.from_name( settings.CHANGE_HOSTNAME_ACTION_DEFAULT_COUNTRY. lower() # noqa: E501 ).id } if settings.CHANGE_HOSTNAME_ACTION_DEFAULT_COUNTRY else {}), } }, ) def change_hostname(cls, instances, **kwargs): country_id = kwargs['country'] country_name = Country.name_from_id(int(country_id)).upper() iso3_country_name = iso2_to_iso3(country_name) for instance in instances: instance._try_assign_hostname(country=iso3_country_name, force=True) @classmethod @transition_action( form_fields={ 'user': { 'field': forms.CharField(label=_('User')), 'autocomplete_field': 'user', }, 'owner': { 'field': forms.CharField(label=_('Owner')), 'autocomplete_field': 'owner', 'condition': lambda obj, actions: bool(obj.owner), } }) def change_user_and_owner(cls, instances, **kwargs): UserModel = get_user_model() # noqa user_id = kwargs.get('user', None) user = UserModel.objects.get(id=user_id) owner_id = kwargs.get('owner', None) for instance in instances: instance.user = user if not owner_id: instance.owner = user else: instance.owner = UserModel.objects.get(id=owner_id) instance.location = user.location @classmethod def _get_report_context(cls, instances): data_instances = [{ 'sn': obj.sn, 'model': str(obj.model), 'imei': obj.imei, 'imei2': obj.imei2, 'barcode': obj.barcode, } for obj in instances] return data_instances @classmethod @transition_action(precondition=_check_assets_owner) def must_be_owner_of_asset(cls, instances, **kwargs): """Only a precondition matters""" pass @classmethod @transition_action(precondition=_check_assets_user) def must_be_user_of_asset(cls, instances, **kwargs): """Only a precondition matters""" pass @classmethod @transition_action( form_fields={ 'accept': { 'field': forms.BooleanField( label=_('I have read and fully understand and ' 'accept the agreement.')) }, }) def accept_asset_release_agreement(cls, instances, requester, **kwargs): pass @classmethod @transition_action(run_after=['release_report']) def assign_requester_as_an_owner(cls, instances, requester, **kwargs): """Assign current user as an owner""" for instance in instances: instance.owner = requester instance.save() @classmethod @transition_action(form_fields={ 'report_language': { 'field': forms.ModelChoiceField( label=_('Release report language'), queryset=ReportLanguage.objects.all().order_by('-default'), empty_label=None), 'exclude_from_history': True } }, return_attachment=True, run_after=['assign_owner', 'assign_user']) def release_report(cls, instances, requester, transition_id, **kwargs): report_name = get_report_name_for_transition_id(transition_id) return generate_report(instances=instances, name=report_name, requester=requester, language=kwargs['report_language'], context=cls._get_report_context(instances)) @classmethod @transition_action( run_after=['release_report', 'return_report', 'loan_report']) def send_attachments_to_user(cls, requester, transition_id, **kwargs): context_func = get_hook('back_office.transition_action.email_context') send_transition_attachments_to_user(requester=requester, transition_id=transition_id, context_func=context_func, **kwargs) @classmethod @transition_action( form_fields={ 'report_language': { 'field': forms.ModelChoiceField( label=_('Return report language'), queryset=ReportLanguage.objects.all().order_by('-default'), empty_label=None), 'exclude_from_history': True }, }, return_attachment=True, precondition=_check_user_assigned, ) def return_report(cls, instances, requester, **kwargs): return generate_report(instances=instances, name='return', requester=requester, language=kwargs['report_language'], context=cls._get_report_context(instances)) @classmethod @transition_action( form_fields={ 'report_language': { 'field': forms.ModelChoiceField( label=_('Loan report language'), queryset=ReportLanguage.objects.all().order_by('-default'), empty_label=None), 'exclude_from_history': True } }, return_attachment=True, run_after=['assign_owner', 'assign_user', 'assign_loan_end_date']) def loan_report(cls, instances, requester, **kwargs): return generate_report(name='loan', requester=requester, instances=instances, language=kwargs['report_language'], context=cls._get_report_context(instances)) @classmethod @transition_action(verbose_name=_('Convert to DataCenter Asset'), disable_save_object=True, only_one_action=True, form_fields={ 'rack': { 'field': forms.CharField(label=_('Rack')), 'autocomplete_field': 'rack', 'autocomplete_model': 'data_center.DataCenterAsset' }, 'position': { 'field': forms.IntegerField(label=_('Position')), }, 'model': { 'field': forms.CharField(label=_('Model')), 'autocomplete_field': 'model', 'autocomplete_model': 'data_center.DataCenterAsset' }, 'service_env': { 'field': forms.CharField(label=_('Service env')), 'autocomplete_field': 'service_env', 'autocomplete_model': 'data_center.DataCenterAsset' } }) def convert_to_data_center_asset(cls, instances, **kwargs): from ralph.data_center.models.physical import DataCenterAsset, Rack # noqa from ralph.back_office.helpers import bo_asset_to_dc_asset_status_converter # noqa with transaction.atomic(): for i, instance in enumerate(instances): data_center_asset = DataCenterAsset() data_center_asset.rack = Rack.objects.get(pk=kwargs['rack']) data_center_asset.position = kwargs['position'] data_center_asset.service_env = ServiceEnvironment.objects.get( pk=kwargs['service_env']) data_center_asset.model = AssetModel.objects.get( pk=kwargs['model']) target_status = int( Transition.objects.values_list('target', flat=True).get( pk=kwargs['transition_id']) # noqa ) data_center_asset.status = bo_asset_to_dc_asset_status_converter( # noqa instance.status, target_status) move_parents_models(instance, data_center_asset, exclude_copy_fields=[ 'rack', 'model', 'service_env', 'status' ]) data_center_asset.save() # Save new asset to list, required to redirect url. # RunTransitionView.get_success_url() instances[i] = data_center_asset @classmethod @transition_action() def assign_hostname_if_empty_or_country_not_match(cls, instances, **kwargs): for instance in instances: instance._try_assign_hostname(commit=False, force=False)
class VirtualServer(PreviousStateMixin, DNSaaSPublisherMixin, AdminAbsoluteUrlMixin, NetworkableBaseObject, BaseObject): # parent field for VirtualServer is hypervisor! # TODO: limit parent to DataCenterAsset and CloudHost status = TransitionField( default=VirtualServerStatus.new.id, choices=VirtualServerStatus(), ) type = models.ForeignKey(VirtualServerType, related_name='virtual_servers') hostname = NullableCharField( blank=True, default=None, max_length=255, null=True, verbose_name=_('hostname'), unique=True, ) sn = NullableCharField( max_length=200, verbose_name=_('SN'), blank=True, default=None, null=True, unique=True, ) # TODO: remove this field cluster = models.ForeignKey(Cluster, blank=True, null=True) previous_dc_host_update_fields = ['hostname'] _allow_in_dashboard = True @cached_property def polymorphic_parent(self): return self.parent.last_descendant if self.parent_id else None def get_location(self): if (self.parent_id and self.parent.content_type_id == ContentType.objects.get_for_model(DataCenterAsset).id): parent = self.parent.asset.datacenterasset location = parent.get_location() if parent.hostname: location.append(parent.hostname) else: location = [] return location @property def model(self): return self.type @cached_property def rack_id(self): return self.rack.id if self.rack else None @cached_property def rack(self): if self.parent_id: polymorphic_parent = self.polymorphic_parent.last_descendant if (isinstance(polymorphic_parent, (DataCenterAsset, CloudHost))): return polymorphic_parent.rack return None class Meta: verbose_name = _('Virtual server (VM)') verbose_name_plural = _('Virtual servers (VM)') def __str__(self): return 'VirtualServer: {} ({})'.format(self.hostname, self.sn)
class Cluster( PreviousStateMixin, DNSaaSPublisherMixin, AdminAbsoluteUrlMixin, WithManagementIPMixin, NetworkableBaseObject, BaseObject, models.Model ): name = models.CharField(_('name'), max_length=255, blank=True, null=True) hostname = NullableCharField( unique=True, null=True, blank=True, max_length=255, verbose_name=_('hostname') ) type = models.ForeignKey(ClusterType) base_objects = models.ManyToManyField( BaseObject, verbose_name=_('Assigned base objects'), through='BaseObjectCluster', related_name='+', ) status = TransitionField( default=ClusterStatus.in_use.id, choices=ClusterStatus(), ) def __str__(self): return '{} ({})'.format(self.name or self.hostname, self.type) def get_location(self): return self.masters[0].get_location() if self.masters else [] @property def model(self): return self.type @cached_property def masters(self): return self.get_masters(cast_base_object=True) def get_masters(self, cast_base_object=False): # prevents cyclic import from ralph.virtual.models import CloudHost, VirtualServer # noqa result = [] for obj in self.baseobjectcluster_set.all(): if obj.is_master: bo = obj.base_object # fetch final object if it's base object if cast_base_object and not isinstance( bo, # list equal to BaseObjectCluster.base_object.limit_models (Database, DataCenterAsset, CloudHost, VirtualServer) ): bo = bo.last_descendant result.append(bo) return result @cached_property def rack_id(self): return self.rack.id if self.rack else None @cached_property def rack(self): for master in self.masters: if isinstance(master, DataCenterAsset) and master.rack_id: return master.rack return None def _validate_name_hostname(self): if not self.name and not self.hostname: error_message = [_('At least one of name or hostname is required')] raise ValidationError( {'name': error_message, 'hostname': error_message} ) def clean(self): errors = {} for validator in [ super().clean, self._validate_name_hostname, ]: try: validator() except ValidationError as e: e.update_error_dict(errors) if errors: raise ValidationError(errors)
class AccessCard(AdminAbsoluteUrlMixin, TimeStampMixin, Regionalizable, models.Model, metaclass=TransitionWorkflowBaseWithPermissions): visual_number = models.CharField( max_length=255, null=False, blank=False, unique=True, help_text=_('Number visible on the access card')) system_number = models.CharField( max_length=255, null=False, blank=False, unique=True, help_text=_('Internal number in the access system')) issue_date = models.DateField(null=True, blank=True, help_text=_('Date of issue to the User')) notes = models.TextField(null=True, blank=True, help_text=_('Optional notes')) user = models.ForeignKey(RalphUser, null=True, blank=True, related_name='+', help_text=_('User of the card'), on_delete=models.SET_NULL) owner = models.ForeignKey(RalphUser, null=True, blank=True, related_name='+', help_text=('Owner of the card'), on_delete=models.SET_NULL) status = TransitionField(choices=AccessCardStatus(), default=AccessCardStatus.new.id, null=False, blank=False, help_text=_('Access card status')) access_zones = TreeManyToManyField(AccessZone, blank=True, related_name='access_cards') def __str__(self): return _('Access Card: {}').format(self.visual_number) @classmethod def get_autocomplete_queryset(cls): return cls._default_manager.exclude( status=AccessCardStatus.liquidated.id) @classmethod @transition_action() def unassign_user(cls, instances, **kwargs): for instance in instances: kwargs['history_kwargs'][instance.pk]['affected_user'] = str( instance.user) instance.user = None @classmethod @transition_action( form_fields={ 'user': { 'field': forms.CharField(label=_('User')), 'autocomplete_field': 'user', 'default_value': partial(autocomplete_user, field_name='user') } }, ) def assign_user(cls, instances, **kwargs): user = get_user_model().objects.get(pk=int(kwargs['user'])) for instance in instances: instance.user = user @classmethod @transition_action( form_fields={ 'owner': { 'field': forms.CharField(label=_('Owner')), 'autocomplete_field': 'owner', 'default_value': partial(autocomplete_user, field_name='owner') } }, help_text=_('assign owner'), ) def assign_owner(cls, instances, **kwargs): owner = get_user_model().objects.get(pk=int(kwargs['owner'])) for instance in instances: instance.owner = owner @classmethod @transition_action() def unassign_owner(cls, instances, **kwargs): for instance in instances: kwargs['history_kwargs'][instance.pk]['affected_owner'] = str( instance.owner) instance.owner = None
class SIMCard(AdminAbsoluteUrlMixin, TimeStampMixin, models.Model, metaclass=TransitionWorkflowBase): pin1 = models.CharField( max_length=8, null=True, blank=True, help_text=_('Required numeric characters only.'), validators=PIN_CODE_VALIDATORS ) puk1 = models.CharField( max_length=16, help_text=_('Required numeric characters only.'), validators=PUK_CODE_VALIDATORS ) pin2 = models.CharField( max_length=8, null=True, blank=True, help_text=_('Required numeric characters only.'), validators=PIN_CODE_VALIDATORS ) puk2 = models.CharField( max_length=16, null=True, blank=True, help_text=_('Required numeric characters only.'), validators=PUK_CODE_VALIDATORS) carrier = models.ForeignKey( CellularCarrier, on_delete=models.PROTECT, ) card_number = models.CharField( max_length=22, unique=True, validators=[ MinLengthValidator(1), MaxLengthValidator(22), RegexValidator( regex='^\d+$', message=_('Required numeric characters only.'), ) ] ) phone_number = models.CharField( max_length=16, unique=True, help_text=_('ex. +2920181234'), validators=[ MinLengthValidator(1), MaxLengthValidator(16), RegexValidator( regex='^\+\d+$', message='Phone number must have +2920181234 format.' ) ] ) warehouse = models.ForeignKey(Warehouse, on_delete=models.PROTECT) owner = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='owned_simcards', ) user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='used_simcards', ) status = TransitionField( default=SIMCardStatus.new.id, choices=SIMCardStatus(), ) remarks = models.TextField(blank=True) quarantine_until = models.DateField( null=True, blank=True, help_text=_('End of quarantine date.') ) features = models.ManyToManyField( SIMCardFeatures, blank=True, ) property_of = models.ForeignKey( AssetHolder, on_delete=models.PROTECT, null=True, blank=True, ) def __str__(self): return _('SIM Card: {}').format(self.phone_number) @classmethod @transition_action( form_fields={ 'user': { 'field': forms.CharField(label=_('User')), 'autocomplete_field': 'user', 'default_value': partial(autocomplete_user, field_name='user') } }, run_after=['unassign_user'] ) def assign_user(cls, instances, **kwargs): user = get_user_model().objects.get(pk=int(kwargs['user'])) for instance in instances: instance.user = user @classmethod @transition_action( form_fields={ 'accept': { 'field': forms.BooleanField( label=_( 'I have read and fully understand and ' 'accept the agreement.' ) ) }, } ) def accept_asset_release_agreement(cls, instances, requester, **kwargs): pass @classmethod @transition_action( form_fields={ 'report_language': { 'field': forms.ModelChoiceField( label=_('Release report language'), queryset=ReportLanguage.objects.all().order_by('-default'), empty_label=None ), 'exclude_from_history': True } }, return_attachment=True, run_after=['assign_owner', 'assign_user'] ) def release_report(cls, instances, requester, transition_id, **kwargs): report_name = get_report_name_for_transition_id(transition_id) return generate_report( instances=instances, name=report_name, requester=requester, language=kwargs['report_language'], context=cls._get_report_context(instances) ) @classmethod def _get_report_context(cls, instances): context = [ { 'card_number': obj.card_number, 'carrier': obj.carrier.name, 'pin1': obj.pin1, 'puk1': obj.puk1, 'phone_number': obj.phone_number, } for obj in instances ] return context @classmethod @transition_action( run_after=['release_report'] ) def assign_requester_as_an_owner(cls, instances, requester, **kwargs): """Assign current user as an owner""" for instance in instances: instance.owner = requester instance.save() @classmethod @transition_action( form_fields={ 'owner': { 'field': forms.CharField(label=_('Owner')), 'autocomplete_field': 'owner', 'default_value': partial(autocomplete_user, field_name='owner') } }, help_text=_('assign owner'), run_after=['unassign_owner'] ) def assign_owner(cls, instances, **kwargs): owner = get_user_model().objects.get(pk=int(kwargs['owner'])) for instance in instances: instance.owner = owner @classmethod @transition_action(run_after=['release_report']) def send_attachments_to_user( cls, requester, transition_id, **kwargs ): context_func = get_hook( 'back_office.transition_action.email_context' ) send_transition_attachments_to_user( requester=requester, transition_id=transition_id, context_func=context_func, **kwargs ) @classmethod @transition_action( form_fields={ 'warehouse': { 'field': forms.CharField(label=_('Warehouse')), 'autocomplete_field': 'warehouse', 'default_value': partial( autocomplete_user, field_name='warehouse' ) } } ) def assign_warehouse(cls, instances, **kwargs): warehouse = Warehouse.objects.get(pk=int(kwargs['warehouse'])) for instance in instances: instance.warehouse = warehouse @classmethod @transition_action( run_after=['loan_report', 'return_report'] ) def unassign_owner(cls, instances, **kwargs): for instance in instances: kwargs['history_kwargs'][instance.pk][ 'affected_owner' ] = str(instance.owner) instance.owner = None @classmethod @transition_action( run_after=['loan_report', 'return_report'] ) def unassign_user(cls, instances, **kwargs): for instance in instances: kwargs['history_kwargs'][instance.pk][ 'affected_user' ] = str(instance.user) instance.user = None @classmethod @transition_action( form_fields={ 'task_url': { 'field': forms.URLField(label=_('task URL')), } } ) def assign_task_url(cls, instances, **kwargs): for instance in instances: instance.task_url = kwargs['task_url']
class SIMCard(AdminAbsoluteUrlMixin, TimeStampMixin, models.Model, metaclass=TransitionWorkflowBase): pin1 = models.CharField(max_length=8, null=True, blank=True, help_text=_('Required numeric characters only.'), validators=PIN_CODE_VALIDATORS) puk1 = models.CharField(max_length=16, help_text=_('Required numeric characters only.'), validators=PUK_CODE_VALIDATORS) pin2 = models.CharField(max_length=8, null=True, blank=True, help_text=_('Required numeric characters only.'), validators=PIN_CODE_VALIDATORS) puk2 = models.CharField(max_length=16, null=True, blank=True, help_text=_('Required numeric characters only.'), validators=PUK_CODE_VALIDATORS) carrier = models.ForeignKey( CellularCarrier, on_delete=models.PROTECT, ) card_number = models.CharField( max_length=22, unique=True, validators=[ MinLengthValidator(1), MaxLengthValidator(22), RegexValidator( regex='^\d+$', message=_('Required numeric characters only.'), ) ]) phone_number = models.CharField( max_length=16, unique=True, help_text=_('ex. +2920181234'), validators=[ MinLengthValidator(1), MaxLengthValidator(16), RegexValidator( regex='^\+\d+$', message='Phone number must have +2920181234 format.') ]) warehouse = models.ForeignKey(Warehouse, on_delete=models.PROTECT) owner = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='owned_simcards', ) user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='used_simcards', ) status = TransitionField( default=SIMCardStatus.new.id, choices=SIMCardStatus(), ) remarks = models.TextField(blank=True) quarantine_until = models.DateField(null=True, blank=True, help_text=_('End of quarantine date.')) features = models.ManyToManyField( SIMCardFeatures, blank=True, ) def __str__(self): return _('SIM Card: {}').format(self.phone_number) @classmethod @transition_action(form_fields={ 'user': { 'field': forms.CharField(label=_('User')), 'autocomplete_field': 'user', 'default_value': partial(autocomplete_user, field_name='user') } }, run_after=['unassign_user']) def assign_user(cls, instances, **kwargs): user = get_user_model().objects.get(pk=int(kwargs['user'])) for instance in instances: instance.user = user @classmethod @transition_action(form_fields={ 'owner': { 'field': forms.CharField(label=_('Owner')), 'autocomplete_field': 'owner', 'default_value': partial(autocomplete_user, field_name='owner') } }, help_text=_('text'), run_after=['unassign_owner']) def assign_owner(cls, instances, **kwargs): owner = get_user_model().objects.get(pk=int(kwargs['owner'])) for instance in instances: instance.owner = owner @classmethod @transition_action( form_fields={ 'warehouse': { 'field': forms.CharField(label=_('Warehouse')), 'autocomplete_field': 'warehouse', 'default_value': partial(autocomplete_user, field_name='warehouse') } }) def assign_warehouse(cls, instances, **kwargs): warehouse = Warehouse.objects.get(pk=int(kwargs['warehouse'])) for instance in instances: instance.warehouse = warehouse @classmethod @transition_action(run_after=['loan_report', 'return_report']) def unassign_owner(cls, instances, **kwargs): for instance in instances: kwargs['history_kwargs'][instance.pk]['affected_owner'] = str( instance.owner) instance.owner = None @classmethod @transition_action(run_after=['loan_report', 'return_report']) def unassign_user(cls, instances, **kwargs): for instance in instances: kwargs['history_kwargs'][instance.pk]['affected_user'] = str( instance.user) instance.user = None @classmethod @transition_action(form_fields={ 'task_url': { 'field': forms.URLField(label=_('task URL')), } }) def assign_task_url(cls, instances, **kwargs): for instance in instances: instance.task_url = kwargs['task_url']
class Accessory( AdminAbsoluteUrlMixin, TimeStampMixin, Regionalizable, models.Model, metaclass=TransitionWorkflowBaseWithPermissions ): manufacturer = models.ForeignKey( Manufacturer, on_delete=models.PROTECT, blank=True, null=True ) category = TreeForeignKey( Category, null=True, related_name='+' ) accessory_name = models.CharField( max_length=255, null=False, blank=False, help_text=_('Accessory name') ) product_number = models.CharField( max_length=255, null=False, blank=False, unique=True, help_text=_('Number of accessories') ) user = models.ManyToManyField( settings.AUTH_USER_MODEL, through='AccessoryUser', related_name='+' ) owner = models.ForeignKey( RalphUser, null=True, blank=True, related_name='+', help_text=_('Accessory owner'), on_delete=models.SET_NULL ) status = TransitionField( choices=AccessoryStatus(), default=AccessoryStatus.new.id, null=False, blank=False, help_text=_('Accessory status') ) number_bought = models.IntegerField( verbose_name=_('number of purchased items') ) warehouse = models.ForeignKey( Warehouse, on_delete=models.PROTECT ) objects = models.Manager() @classmethod @transition_action( form_fields={ 'restock': { 'field': forms.IntegerField(label=_('restock'),) } }, ) def restock(cls, instances, **kwargs): restock = int(kwargs['restock']) for instance in instances: instance.number_bought += restock @classmethod @transition_action( form_fields={ 'accessory_send': { 'field': forms.IntegerField(label=_('accessory_send'),) } }, ) def accessory_send(cls, instances, **kwargs): accessory_send = int(kwargs['accessory_send']) for instance in instances: instance.number_bought -= accessory_send @classmethod @transition_action( form_fields={ 'user': { 'field': forms.CharField(label=_('User')), 'autocomplete_field': 'user', }, 'quantity': { 'field': forms.IntegerField(label=_('Quantity')), } }, ) def release_accessories(cls, instances, **kwargs): user = get_user_model().objects.get(pk=int(kwargs['user'])) quantity = kwargs['quantity'] accessory_user, created = AccessoryUser.objects.get_or_create( user=user, accessory=instances[0], defaults={'quantity': quantity} ) if not created: accessory_user.quantity += quantity accessory_user.save() polymorphic_objects = PolymorphicQuerySet.as_manager() objects_used_free = AccessoryUsedFreeManager() objects_used_free_with_related = AccessoryUsedFreeRelatedObjectsManager() def __str__(self): return "{} x {} - ({})".format( self.number_bought, self.accessory_name, self.product_number, ) @cached_property def autocomplete_str(self): return "{} ({} free) x {} - ({})".format( self.number_bought, self.free, self.accessory_name, self.product_number, ) @cached_property def used(self): if not self.pk: return 0 try: return (self.user_count or 0) except AttributeError: users_qs = self.user.through.objects.filter(accessory=self) def get_sum(qs): return qs.aggregate(sum=Sum('quantity'))['sum'] or 0 return sum(map(get_sum, [users_qs])) used._permission_field = 'number_bought' @cached_property def free(self): if not self.pk: return 0 return self.number_bought - self.used free._permission_field = 'number_bought'
class DataCenterAsset( PreviousStateMixin, DNSaaSPublisherMixin, WithManagementIPMixin, NetworkableBaseObject, AutocompleteTooltipMixin, Asset ): _allow_in_dashboard = True previous_dc_host_update_fields = ['hostname'] rack = models.ForeignKey( Rack, null=True, blank=False, on_delete=models.PROTECT ) status = TransitionField( default=DataCenterAssetStatus.new.id, choices=DataCenterAssetStatus(), ) position = models.IntegerField(null=True, blank=True) orientation = models.PositiveIntegerField( choices=Orientation(), default=Orientation.front.id, ) slot_no = models.CharField( blank=True, help_text=_('Fill it if asset is blade server'), max_length=3, null=True, validators=[ RegexValidator( regex=VALID_SLOT_NUMBER_FORMAT, message=_( "Slot number should be a number from range 1-16 with " "an optional postfix 'A' or 'B' (e.g. '16A')" ), code='invalid_slot_no' ) ], verbose_name=_('slot number'), ) firmware_version = models.CharField( null=True, blank=True, max_length=256, verbose_name=_('firmware version'), ) bios_version = models.CharField( null=True, blank=True, max_length=256, verbose_name=_('BIOS version'), ) connections = models.ManyToManyField( 'self', through='Connection', symmetrical=False, ) source = models.PositiveIntegerField( blank=True, choices=AssetSource(), db_index=True, null=True, verbose_name=_("source"), ) delivery_date = models.DateField(null=True, blank=True) production_year = models.PositiveSmallIntegerField(null=True, blank=True) production_use_date = models.DateField(null=True, blank=True) autocomplete_tooltip_fields = [ 'rack', 'barcode', 'sn', ] _summary_fields = [ ('hostname', 'Hostname'), ('location', 'Location'), ('model__name', 'Model'), ] class Meta: verbose_name = _('data center asset') verbose_name_plural = _('data center assets') def __str__(self): return '{} (BC: {} / SN: {})'.format( self.hostname or '-', self.barcode or '-', self.sn or '-' ) def __repr__(self): return '<DataCenterAsset: {}>'.format(self.id) def save(self, *args, **kwargs): super().save(*args, **kwargs) if self.pk: # When changing rack we search and save all descendants if self._previous_state['rack_id'] != self.rack_id: DataCenterAsset.objects.filter( parent=self ).update(rack=self.rack) # When changing position if is blade, # we search and save all descendants if self._previous_state['position'] != self.position: DataCenterAsset.objects.filter( parent=self ).update(position=self.position) def get_orientation_desc(self): return Orientation.name_from_id(self.orientation) def get_location(self): location = [] if self.rack: location.extend([ self.rack.server_room.data_center.name, self.rack.server_room.name, self.rack.name ]) if self.position: location.append(str(self.position)) if self.slot_no: location.append(str(self.slot_no)) return location @property def is_blade(self): if self.model_id and self.model.has_parent: return True return False @property def cores_count(self): """Returns cores count assigned to device in Ralph""" asset_cores_count = self.model.cores_count if self.model else 0 return asset_cores_count @cached_property def location(self): """ Additional column 'location' display filter by: data center, server_room, rack, position (if is blade) """ base_url = reverse('admin:data_center_datacenterasset_changelist') position = self.position if self.is_blade: position = generate_html_link( base_url, label=position, params={ 'rack': self.rack_id, 'position__start': self.position, 'position__end': self.position }, ) result = [ generate_html_link( base_url, label=self.rack.server_room.data_center.name, params={ 'rack__server_room__data_center': self.rack.server_room.data_center_id }, ), generate_html_link( base_url, label=self.rack.server_room.name, params={'rack__server_room': self.rack.server_room_id}, ), generate_html_link( base_url, label=self.rack.name, params={'rack': self.rack_id}, ) ] if self.rack and self.rack.server_room else [] if self.position: result.append(str(position)) if self.slot_no: result.append(str(self.slot_no)) return ' / '.join(result) if self.rack else '—' def _validate_orientation(self): """ Validate if orientation is valid for given position. """ if self.position is None: return if self.position == 0 and not Orientation.is_width(self.orientation): msg = 'Valid orientations for picked position are: {}'.format( ', '.join( choice.desc for choice in Orientation.WIDTH.choices ) ) raise ValidationError({'orientation': [msg]}) if self.position > 0 and not Orientation.is_depth(self.orientation): msg = 'Valid orientations for picked position are: {}'.format( ', '.join( choice.desc for choice in Orientation.DEPTH.choices ) ) raise ValidationError({'orientation': [msg]}) def _validate_position(self): """ Validate if position not empty when rack requires it. """ if ( self.rack and self.position is None and self.rack.require_position ): msg = 'Position is required for this rack' raise ValidationError({'position': [msg]}) def _validate_position_in_rack(self): """ Validate if position is in rack height range. """ if ( self.rack and self.position is not None and self.position > self.rack.max_u_height ): msg = 'Position is higher than "max u height" = {}'.format( self.rack.max_u_height, ) raise ValidationError({'position': [msg]}) if self.position is not None and self.position < 0: msg = 'Position should be 0 or greater' raise ValidationError({'position': msg}) def _validate_slot_no(self): if self.model_id: if self.model.has_parent and not self.slot_no: raise ValidationError({ 'slot_no': 'Slot number is required when asset is blade' }) if not self.model.has_parent and self.slot_no: raise ValidationError({ 'slot_no': ( 'Slot number cannot be filled when asset is not blade' ) }) if self.parent: dc_asset_with_slot_no = DataCenterAsset.objects.filter( parent=self.parent, slot_no=self.slot_no, orientation=self.orientation, ).exclude(pk=self.pk).first() if dc_asset_with_slot_no: message = mark_safe( ( 'Slot is already occupied by: ' '<a href="{}" target="_blank">{}</a>' ).format( reverse( 'admin:data_center_datacenterasset_change', args=[dc_asset_with_slot_no.id] ), dc_asset_with_slot_no ) ) raise ValidationError({ 'slot_no': message }) def clean(self): # TODO: this should be default logic of clean method; # we could register somehow validators (or take each func with # _validate prefix) and call it here errors = {} for validator in [ super().clean, self._validate_orientation, self._validate_position, self._validate_position_in_rack, self._validate_slot_no ]: try: validator() except ValidationError as e: e.update_error_dict(errors) if errors: raise ValidationError(errors) def get_related_assets(self): """Returns the children of a blade chassis""" orientations = [Orientation.front, Orientation.back] assets_by_orientation = [] for orientation in orientations: assets_by_orientation.append(list( DataCenterAsset.objects.select_related('model').filter( parent=self, orientation=orientation, model__has_parent=True, ).exclude(id=self.id) )) assets = [ Gap.generate_gaps(assets) for assets in assets_by_orientation ] return chain(*assets) @classmethod def get_autocomplete_queryset(cls): return cls._default_manager.exclude( status=DataCenterAssetStatus.liquidated.id ) @classmethod @transition_action( verbose_name=_('Change rack'), form_fields={ 'rack': { 'field': forms.CharField(widget=AutocompleteWidget( field=rack, admin_site=ralph_site )), } } ) def change_rack(cls, instances, **kwargs): rack = Rack.objects.get(pk=kwargs['rack']) for instance in instances: instance.rack = rack @classmethod @transition_action( verbose_name=_('Convert to BackOffice Asset'), disable_save_object=True, only_one_action=True, form_fields={ 'warehouse': { 'field': forms.CharField(label=_('Warehouse')), 'autocomplete_field': 'warehouse', 'autocomplete_model': 'back_office.BackOfficeAsset' }, 'region': { 'field': forms.CharField(label=_('Region')), 'autocomplete_field': 'region', 'autocomplete_model': 'back_office.BackOfficeAsset' } } ) def convert_to_backoffice_asset(cls, instances, **kwargs): with transaction.atomic(): for i, instance in enumerate(instances): back_office_asset = BackOfficeAsset() back_office_asset.region = Region.objects.get( pk=kwargs['region'] ) back_office_asset.warehouse = Warehouse.objects.get( pk=kwargs['warehouse'] ) target_status = int( Transition.objects.values_list('target', flat=True).get(pk=kwargs['transition_id']) # noqa ) back_office_asset.status = dc_asset_to_bo_asset_status_converter( # noqa instance.status, target_status ) move_parents_models( instance, back_office_asset, exclude_copy_fields=['status'] ) # Save new asset to list, required to redirect url. # RunTransitionView.get_success_url() instances[i] = back_office_asset @classmethod @transition_action( verbose_name=_('Cleanup scm status'), ) def cleanup_scm_statuscheck(cls, instances, **kwargs): with transaction.atomic(): for instance in instances: try: instance.scmstatuscheck.delete() except DataCenterAsset.scmstatuscheck.\ RelatedObjectDoesNotExist: pass @classmethod @transition_action( verbose_name=_('Assign additional IP and hostname pair'), form_fields={ 'network_pk': { 'field': forms.ChoiceField( label=_('Select network') ), 'choices': assign_additional_hostname_choices, 'exclude_from_history': True, }, }, ) def assign_additional_hostname(cls, instances, network_pk, **kwargs): """ Assign new hostname for instances based on selected network. """ network = Network.objects.get(pk=network_pk) env = network.network_environment with transaction.atomic(): for instance in instances: ethernet = Ethernet.objects.create(base_object=instance) ethernet.ipaddress = network.issue_next_free_ip() ethernet.ipaddress.hostname = env.issue_next_free_hostname() ethernet.ipaddress.save() ethernet.save()
class Accessory( AdminAbsoluteUrlMixin, TimeStampMixin, Regionalizable, models.Model, metaclass=TransitionWorkflowBaseWithPermissions ): manufacturer = models.CharField( max_length=255, null=False, blank=False, unique=False, help_text=_('Accessory manufacturer') ) accessory_type = models.CharField( max_length=255, null=False, blank=False, unique=False, help_text=_('Accessory type') ) accessory_name = models.CharField( max_length=255, null=False, blank=False, unique=True, help_text=_('Accessory name') ) product_number = models.CharField( max_length=255, null=False, blank=False, unique=True, help_text=_('Number of accessories') ) user = models.ManyToManyField( settings.AUTH_USER_MODEL, through='AccessoryUser', related_name='+' ) owner = models.ForeignKey( RalphUser, null=True, blank=True, related_name='+', help_text=_('Accessory owner'), on_delete=models.SET_NULL ) status = TransitionField( choices=AccessoryStatus(), default=AccessoryStatus.new.id, null=False, blank=False, help_text=_('Accessory status') ) number_bought = models.IntegerField( verbose_name=_('number of purchased items') ) warehouse = models.ForeignKey( Warehouse, on_delete=models.PROTECT ) @classmethod @transition_action( form_fields={ 'restock': { 'field': forms.IntegerField(label=_('restock'),) } }, ) def restock(cls, instances, **kwargs): restock = int(kwargs['restock']) for instance in instances: instance.number_bought += restock @classmethod @transition_action( form_fields={ 'user': { 'field': forms.CharField(label=_('User')), 'autocomplete_field': 'user', }, 'quantity': { 'field': forms.IntegerField(label=_('Quantity')), } }, ) def release_accessories(cls, instances, **kwargs): user = get_user_model().objects.get(pk=int(kwargs['user'])) AccessoryUser.objects.create( user=user, quantity=kwargs['quantity'], accessory_id=instances[0].id )