class AssetVersion(models.Model): uid = KpiUidField(uid_prefix='v') asset = models.ForeignKey('Asset', related_name='asset_versions') name = models.CharField(null=True, max_length=255) date_modified = models.DateTimeField(default=timezone.now) # preserving _reversion_version in case we don't save all that we # need to in the first migration from reversion to AssetVersion _reversion_version = models.OneToOneField( Version, null=True, on_delete=models.SET_NULL, ) version_content = JSONBField() uid_aliases = JSONBField(null=True) deployed_content = JSONBField(null=True) _deployment_data = JSONBField(default=False) deployed = models.BooleanField(default=False) class Meta: ordering = ['-date_modified'] def _deployed_content(self): if self.deployed_content is not None: return self.deployed_content legacy_names = self._reversion_version is not None if legacy_names: return to_xlsform_structure(self.version_content, deprecated_autoname=True) else: return to_xlsform_structure(self.version_content, move_autonames=True) def to_formpack_schema(self): return { 'content': expand_content(self._deployed_content()), 'version': self.uid, 'version_id_key': '__version__', } def _content_hash(self): # used to determine changes in the content from version to version # not saved, only compared with other asset_versions (in tests and # migration scripts, initially) _json_string = json.dumps( { 'version_content': self.version_content, 'deployed_content': self.deployed_content, 'deployed': self.deployed, }, sort_keys=True) return hashlib.sha1(_json_string).hexdigest() def __unicode__(self): return '{}@{} T{}{}'.format( self.asset.uid, self.uid, self.date_modified.strftime('%Y-%m-%d %H:%m'), ' (deployed)' if self.deployed else '')
class PerUserSetting(models.Model): """ A configuration setting that has different values depending on whether not a user matches certain criteria """ user_queries = JSONBField( help_text=_('A JSON representation of a *list* of Django queries, ' 'e.g. `[{"email__iendswith": "@kobotoolbox.org"}, ' '{"email__iendswith": "@kbtdev.org"}]`. ' 'A matching user is one who would be returned by ANY of ' 'the queries in the list.')) name = models.CharField(max_length=255, unique=True, default='INTERCOM_APP_ID') # The only one for now! value_when_matched = models.CharField(max_length=2048, blank=True) value_when_not_matched = models.CharField(max_length=2048, blank=True) def user_matches(self, user, ignore_invalid_queries=True): if user.is_anonymous(): user = get_anonymous_user() manager = user._meta.model.objects queryset = manager.none() for user_query in self.user_queries: try: queryset |= manager.filter(**user_query) except (FieldError, TypeError): if ignore_invalid_queries: return False else: raise return queryset.filter(pk=user.pk).exists() def get_for_user(self, user): if self.user_matches(user): return self.value_when_matched else: return self.value_when_not_matched def clean(self): user = User.objects.first() if not user: return try: self.user_matches(user, ignore_invalid_queries=False) except FieldError as e: raise ValidationError({'user_queries': e.message}) except TypeError: raise ValidationError( {'user_queries': _('JSON structure is incorrect.')}) def __str__(self): return self.name
class AssetFile(models.Model): # More to come! MAP_LAYER = 'map_layer' TYPE_CHOICES = ((MAP_LAYER, MAP_LAYER), ) uid = KpiUidField(uid_prefix='af') asset = models.ForeignKey('Asset', related_name='asset_files') # Keep track of the uploading user, who could be anyone with `change_asset` # rights, not just the asset owner user = models.ForeignKey('auth.User', related_name='asset_files') file_type = models.CharField(choices=TYPE_CHOICES, max_length=32) name = models.CharField(max_length=255) date_created = models.DateTimeField(default=timezone.now) # TODO: Handle deletion! The file won't be deleted automatically when the # object is removed from the database content = PrivateFileField(upload_to=upload_to, max_length=380) metadata = JSONBField(default=dict)
class Hook(models.Model): # Export types XML = "xml" JSON = "json" # Authentication levels NO_AUTH = "no_auth" BASIC_AUTH = "basic_auth" # Export types list EXPORT_TYPE_CHOICES = ((XML, XML), (JSON, JSON)) # Authentication levels list AUTHENTICATION_LEVEL_CHOICES = ((NO_AUTH, NO_AUTH), (BASIC_AUTH, BASIC_AUTH)) asset = models.ForeignKey("kpi.Asset", related_name="hooks", on_delete=models.CASCADE) uid = KpiUidField(uid_prefix="h") name = models.CharField(max_length=255, blank=False) endpoint = models.CharField(max_length=500, blank=False) active = models.BooleanField(default=True) export_type = models.CharField(choices=EXPORT_TYPE_CHOICES, default=JSON, max_length=10) auth_level = models.CharField(choices=AUTHENTICATION_LEVEL_CHOICES, default=NO_AUTH, max_length=10) settings = JSONBField(default=dict) date_created = models.DateTimeField(default=timezone.now) date_modified = models.DateTimeField(default=timezone.now) class Meta: ordering = ["name"] def __init__(self, *args, **kwargs): self.__totals = {} return super(Hook, self).__init__(*args, **kwargs) def save(self, *args, **kwargs): # Update date_modified each time object is saved self.date_modified = timezone.now() super(Hook, self).save(*args, **kwargs) def __unicode__(self): return u"%s:%s - %s" % (self.asset, self.name, self.endpoint) def get_service_definition(self): mod = import_module("kobo.apps.hook.services.service_{}".format( self.export_type)) return getattr(mod, "ServiceDefinition") @property def success_count(self): if not self.__totals: self._get_totals() return self.__totals.get(HOOK_LOG_SUCCESS) @property def failed_count(self): if not self.__totals: self._get_totals() return self.__totals.get(HOOK_LOG_FAILED) @property def pending_count(self): if not self.__totals: self._get_totals() return self.__totals.get(HOOK_LOG_PENDING) def _get_totals(self): # TODO add some cache queryset = self.logs.values("status").annotate( values_count=models.Count("status")) queryset.query.clear_ordering(True) # Initialize totals self.__totals = { HOOK_LOG_SUCCESS: 0, HOOK_LOG_FAILED: 0, HOOK_LOG_PENDING: 0 } for record in queryset: self.__totals[record.get("status")] = record.get("values_count") def reset_totals(self): # TODO remove cache when it's enabled self.__totals = {}
class Asset(ObjectPermissionMixin, TagStringMixin, DeployableMixin, XlsExportable, FormpackXLSFormUtils, models.Model): name = models.CharField(max_length=255, blank=True, default='') date_created = models.DateTimeField(auto_now_add=True) date_modified = models.DateTimeField(auto_now=True) content = JSONField(null=True) summary = JSONField(null=True, default=dict) report_styles = JSONBField(default=dict) asset_type = models.CharField(choices=ASSET_TYPES, max_length=20, default='survey') parent = models.ForeignKey('Collection', related_name='assets', null=True, blank=True) owner = models.ForeignKey('auth.User', related_name='assets', null=True) editors_can_change_permissions = models.BooleanField(default=True) uid = KpiUidField(uid_prefix='a') tags = TaggableManager(manager=KpiTaggableManager) settings = jsonbfield.fields.JSONField(default=dict) # _deployment_data should be accessed through the `deployment` property # provided by `DeployableMixin` _deployment_data = JSONField(default=dict) permissions = GenericRelation(ObjectPermission) objects = AssetManager() @property def kind(self): return self._meta.model_name class Meta: ordering = ('-date_modified', ) permissions = ( # change_, add_, and delete_asset are provided automatically # by Django ('view_asset', _('Can view asset')), ('share_asset', _("Can change asset's sharing settings")), # Permissions for collected data, i.e. submissions ('add_submissions', _('Can submit data to asset')), ('view_submissions', _('Can view submitted data for asset')), ('change_submissions', _('Can modify submitted data for asset')), ('delete_submissions', _('Can delete submitted data for asset')), ('share_submissions', _("Can change sharing settings for " "asset's submitted data")), # TEMPORARY Issue #1161: A flag to indicate that permissions came # solely from `sync_kobocat_xforms` and not from any user # interaction with KPI ('from_kc_only', 'INTERNAL USE ONLY; DO NOT ASSIGN')) # Assignable permissions that are stored in the database ASSIGNABLE_PERMISSIONS = ('view_asset', 'change_asset', 'add_submissions', 'view_submissions', 'change_submissions') # Calculated permissions that are neither directly assignable nor stored # in the database, but instead implied by assignable permissions CALCULATED_PERMISSIONS = ('share_asset', 'delete_asset', 'share_submissions', 'delete_submissions') # Certain Collection permissions carry over to Asset MAPPED_PARENT_PERMISSIONS = { 'view_collection': 'view_asset', 'change_collection': 'change_asset' } # Granting some permissions implies also granting other permissions IMPLIED_PERMISSIONS = { # Format: explicit: (implied, implied, ...) 'change_asset': ('view_asset', ), 'add_submissions': ('view_asset', ), 'view_submissions': ('view_asset', ), 'change_submissions': ('view_submissions', ) } # Some permissions must be copied to KC KC_PERMISSIONS_MAP = { # keys are KC's codenames, values are KPI's 'change_submissions': 'change_xform', # "Can Edit" in KC UI 'view_submissions': 'view_xform', # "Can View" in KC UI 'add_submissions': 'report_xform', # "Can submit to" in KC UI } KC_CONTENT_TYPE_KWARGS = {'app_label': 'logger', 'model': 'xform'} # todo: test and implement this method # def restore_version(self, uid): # _version_to_restore = self.asset_versions.get(uid=uid) # self.content = _version_to_restore.version_content # self.name = _version_to_restore.name def to_ss_structure(self): return flatten_content(self.content, in_place=False) def _populate_summary(self): if self.content is None: self.content = {} self.summary = {} return analyzer = AssetContentAnalyzer(**self.content) self.summary = analyzer.summary def adjust_content_on_save(self): ''' This is called on save by default if content exists. Can be disabled / skipped by calling with parameter: asset.save(adjust_content=False) ''' self._standardize(self.content) self._adjust_active_translation(self.content) self._strip_empty_rows(self.content) self._assign_kuids(self.content) self._autoname(self.content) self._unlink_list_items(self.content) self._remove_empty_expressions(self.content) settings = self.content['settings'] _title = settings.pop('form_title', None) id_string = settings.get('id_string') filename = self.summary.pop('filename', None) if filename: # if we have filename available, set the id_string # and/or form_title from the filename. if not id_string: id_string = sluggify_label(filename) settings['id_string'] = id_string if not _title: _title = filename if self.asset_type != 'survey': # instead of deleting the settings, simply clear them out self.content['settings'] = {} if _title is not None: self.name = _title def save(self, *args, **kwargs): if self.content is None: self.content = {} # in certain circumstances, we don't want content to # be altered on save. (e.g. on asset.deploy()) if kwargs.pop('adjust_content', True): self.adjust_content_on_save() # populate summary self._populate_summary() # infer asset_type only between question and block if self.asset_type in ['question', 'block']: row_count = self.summary.get('row_count') if row_count == 1: self.asset_type = 'question' elif row_count > 1: self.asset_type = 'block' self._populate_report_styles() _create_version = kwargs.pop('create_version', True) super(Asset, self).save(*args, **kwargs) if _create_version: self.asset_versions.create( name=self.name, version_content=self.content, _deployment_data=self._deployment_data, # asset_version.deployed is set in the # DeploymentSerializer deployed=False, ) def rename_translation(self, _from, _to): if not self._has_translations(self.content, 2): raise ValueError('no translations available') self._rename_translation(self.content, _from, _to) def to_clone_dict(self, version_uid=None): if version_uid: version = self.asset_versions.get(uid=version_uid) else: version = self.asset_versions.first() return { 'name': version.name, 'content': version.version_content, 'asset_type': self.asset_type, 'tag_string': self.tag_string, } def clone(self, version_uid=None): # not currently used, but this is how "to_clone_dict" should work return Asset.objects.create(**self.to_clone_dict(version_uid)) def revert_to_version(self, version_uid): av = self.asset_versions.get(uid=version_uid) self.content = av.version_content self.save() def _populate_report_styles(self): default = self.report_styles.get(DEFAULT_REPORTS_KEY, {}) specifieds = self.report_styles.get(SPECIFIC_REPORTS_KEY, {}) kuids_to_variable_names = self.report_styles.get('kuid_names', {}) for (index, row) in enumerate(self.content.get('survey', [])): if '$kuid' not in row: if 'name' in row: row['$kuid'] = json_hash([self.uid, row['name']]) else: row['$kuid'] = json_hash([self.uid, index, row]) _identifier = row.get('name', row['$kuid']) kuids_to_variable_names[_identifier] = row['$kuid'] if _identifier not in specifieds: specifieds[_identifier] = {} self.report_styles = { DEFAULT_REPORTS_KEY: default, SPECIFIC_REPORTS_KEY: specifieds, 'kuid_names': kuids_to_variable_names, } def get_ancestors_or_none(self): # ancestors are ordered from farthest to nearest if self.parent is not None: return self.parent.get_ancestors(include_self=True) else: return None @property def latest_version(self): return self.asset_versions.order_by('-date_modified').first() @property def deployed_versions(self): return self.asset_versions.filter( deployed=True).order_by('-date_modified') @property def latest_deployed_version(self): return self.deployed_versions.first() @property def version_id(self): latest_version = self.latest_version if latest_version: return latest_version.uid @property def snapshot(self): return self._snapshot(regenerate=False) @transaction.atomic def _snapshot(self, regenerate=True): asset_version = self.asset_versions.first() _note = None if self.asset_type in ['question', 'block']: _note = ('Note: This item is a {} and must be included in ' 'a form before deploying'.format(self.asset_type)) try: snapshot = AssetSnapshot.objects.get(asset=self, asset_version=asset_version) if regenerate: snapshot.delete() snapshot = False except AssetSnapshot.MultipleObjectsReturned: # how did multiple snapshots get here? snaps = AssetSnapshot.objects.filter(asset=self, asset_version=asset_version) snaps.delete() snapshot = False except AssetSnapshot.DoesNotExist: snapshot = False if not snapshot: if self.name != '': form_title = self.name else: _settings = self.content.get('settings', {}) form_title = _settings.get('id_string', 'Untitled') self._append(self.content, settings={ 'form_title': form_title, }) snapshot = AssetSnapshot.objects.create( asset=self, asset_version=asset_version, source=self.content) return snapshot def __unicode__(self): return u'{} ({})'.format(self.name, self.uid)
class Asset(ObjectPermissionMixin, TagStringMixin, DeployableMixin, XlsExportable, FormpackXLSFormUtils, models.Model): name = models.CharField(max_length=255, blank=True, default='') date_created = models.DateTimeField(auto_now_add=True) date_modified = models.DateTimeField(auto_now=True) content = JSONField(null=True) summary = JSONField(null=True, default=dict) report_styles = JSONBField(default=dict) report_custom = JSONBField(default=dict) map_styles = LazyDefaultJSONBField(default=dict) map_custom = LazyDefaultJSONBField(default=dict) asset_type = models.CharField(choices=ASSET_TYPES, max_length=20, default=ASSET_TYPE_SURVEY) parent = models.ForeignKey('Collection', related_name='assets', null=True, blank=True) owner = models.ForeignKey('auth.User', related_name='assets', null=True) editors_can_change_permissions = models.BooleanField(default=True) uid = KpiUidField(uid_prefix='a') tags = TaggableManager(manager=KpiTaggableManager) settings = jsonbfield.fields.JSONField(default=dict) # _deployment_data should be accessed through the `deployment` property # provided by `DeployableMixin` _deployment_data = JSONField(default=dict) permissions = GenericRelation(ObjectPermission) objects = AssetManager() @property def kind(self): return 'asset' class Meta: ordering = ('-date_modified', ) permissions = ( # change_, add_, and delete_asset are provided automatically # by Django ('view_asset', _('Can view asset')), ('share_asset', _("Can change asset's sharing settings")), # Permissions for collected data, i.e. submissions ('add_submissions', _('Can submit data to asset')), ('view_submissions', _('Can view submitted data for asset')), ('change_submissions', _('Can modify submitted data for asset')), ('delete_submissions', _('Can delete submitted data for asset')), ('share_submissions', _("Can change sharing settings for " "asset's submitted data")), ('validate_submissions', _("Can validate submitted data asset")), # TEMPORARY Issue #1161: A flag to indicate that permissions came # solely from `sync_kobocat_xforms` and not from any user # interaction with KPI ('from_kc_only', 'INTERNAL USE ONLY; DO NOT ASSIGN')) # Assignable permissions that are stored in the database ASSIGNABLE_PERMISSIONS = ( 'view_asset', 'change_asset', 'add_submissions', 'view_submissions', 'change_submissions', 'validate_submissions', ) # Calculated permissions that are neither directly assignable nor stored # in the database, but instead implied by assignable permissions CALCULATED_PERMISSIONS = ('share_asset', 'delete_asset', 'share_submissions', 'delete_submissions') # Certain Collection permissions carry over to Asset MAPPED_PARENT_PERMISSIONS = { 'view_collection': 'view_asset', 'change_collection': 'change_asset' } # Granting some permissions implies also granting other permissions IMPLIED_PERMISSIONS = { # Format: explicit: (implied, implied, ...) 'change_asset': ('view_asset', ), 'add_submissions': ('view_asset', ), 'view_submissions': ('view_asset', ), 'change_submissions': ('view_submissions', ), 'validate_submissions': ('view_submissions', ) } # Some permissions must be copied to KC KC_PERMISSIONS_MAP = { # keys are KC's codenames, values are KPI's 'change_submissions': 'change_xform', # "Can Edit" in KC UI 'view_submissions': 'view_xform', # "Can View" in KC UI 'add_submissions': 'report_xform', # "Can submit to" in KC UI 'validate_submissions': 'validate_xform', # "Can Validate" in KC UI } KC_CONTENT_TYPE_KWARGS = {'app_label': 'logger', 'model': 'xform'} # KC records anonymous access as flags on the `XForm` KC_ANONYMOUS_PERMISSIONS_XFORM_FLAGS = { 'view_submissions': { 'shared': True, 'shared_data': True } } # todo: test and implement this method # def restore_version(self, uid): # _version_to_restore = self.asset_versions.get(uid=uid) # self.content = _version_to_restore.version_content # self.name = _version_to_restore.name def to_ss_structure(self): return flatten_content(self.content, in_place=False) def _populate_summary(self): if self.content is None: self.content = {} self.summary = {} return analyzer = AssetContentAnalyzer(**self.content) self.summary = analyzer.summary def adjust_content_on_save(self): ''' This is called on save by default if content exists. Can be disabled / skipped by calling with parameter: asset.save(adjust_content=False) ''' self._standardize(self.content) self._make_default_translation_first(self.content) self._strip_empty_rows(self.content) self._assign_kuids(self.content) self._autoname(self.content) self._unlink_list_items(self.content) self._remove_empty_expressions(self.content) settings = self.content['settings'] _title = settings.pop('form_title', None) id_string = settings.get('id_string') filename = self.summary.pop('filename', None) if filename: # if we have filename available, set the id_string # and/or form_title from the filename. if not id_string: id_string = sluggify_label(filename) settings['id_string'] = id_string if not _title: _title = filename if not self.asset_type in [ASSET_TYPE_SURVEY, ASSET_TYPE_TEMPLATE]: # instead of deleting the settings, simply clear them out self.content['settings'] = {} if _title is not None: self.name = _title def save(self, *args, **kwargs): if self.content is None: self.content = {} # in certain circumstances, we don't want content to # be altered on save. (e.g. on asset.deploy()) if kwargs.pop('adjust_content', True): self.adjust_content_on_save() # populate summary self._populate_summary() # infer asset_type only between question and block if self.asset_type in [ASSET_TYPE_QUESTION, ASSET_TYPE_BLOCK]: row_count = self.summary.get('row_count') if row_count == 1: self.asset_type = ASSET_TYPE_QUESTION elif row_count > 1: self.asset_type = ASSET_TYPE_BLOCK self._populate_report_styles() _create_version = kwargs.pop('create_version', True) super(Asset, self).save(*args, **kwargs) if _create_version: self.asset_versions.create( name=self.name, version_content=self.content, _deployment_data=self._deployment_data, # asset_version.deployed is set in the # DeploymentSerializer deployed=False, ) def rename_translation(self, _from, _to): if not self._has_translations(self.content, 2): raise ValueError('no translations available') self._rename_translation(self.content, _from, _to) def to_clone_dict(self, version_uid=None, version=None): """ Returns a dictionary of the asset based on version_uid or version. If `version` is specified, there are no needs to provide `version_uid` and make another request to DB. :param version_uid: string :param version: AssetVersion :return: dict """ if not isinstance(version, AssetVersion): if version_uid: version = self.asset_versions.get(uid=version_uid) else: version = self.asset_versions.first() return { 'name': version.name, 'content': version.version_content, 'asset_type': self.asset_type, 'tag_string': self.tag_string, } def clone(self, version_uid=None): # not currently used, but this is how "to_clone_dict" should work return Asset.objects.create(**self.to_clone_dict(version_uid)) def revert_to_version(self, version_uid): av = self.asset_versions.get(uid=version_uid) self.content = av.version_content self.save() def _populate_report_styles(self): default = self.report_styles.get(DEFAULT_REPORTS_KEY, {}) specifieds = self.report_styles.get(SPECIFIC_REPORTS_KEY, {}) kuids_to_variable_names = self.report_styles.get('kuid_names', {}) for (index, row) in enumerate(self.content.get('survey', [])): if '$kuid' not in row: if 'name' in row: row['$kuid'] = json_hash([self.uid, row['name']]) else: row['$kuid'] = json_hash([self.uid, index, row]) _identifier = row.get('name', row['$kuid']) kuids_to_variable_names[_identifier] = row['$kuid'] if _identifier not in specifieds: specifieds[_identifier] = {} self.report_styles = { DEFAULT_REPORTS_KEY: default, SPECIFIC_REPORTS_KEY: specifieds, 'kuid_names': kuids_to_variable_names, } def get_ancestors_or_none(self): # ancestors are ordered from farthest to nearest if self.parent is not None: return self.parent.get_ancestors(include_self=True) else: return None @property def latest_version(self): versions = None try: versions = self.prefetched_latest_versions except AttributeError: versions = self.asset_versions.order_by('-date_modified') try: return versions[0] except IndexError: return None @property def deployed_versions(self): return self.asset_versions.filter( deployed=True).order_by('-date_modified') @property def latest_deployed_version(self): return self.deployed_versions.first() @property def version_id(self): # Avoid reading the propery `self.latest_version` more than once, since # it may execute a database query each time it's read latest_version = self.latest_version if latest_version: return latest_version.uid @property def version__content_hash(self): # Avoid reading the propery `self.latest_version` more than once, since # it may execute a database query each time it's read latest_version = self.latest_version if latest_version: return latest_version.content_hash @property def snapshot(self): return self._snapshot(regenerate=False) @transaction.atomic def _snapshot(self, regenerate=True): asset_version = self.latest_version try: snapshot = AssetSnapshot.objects.get(asset=self, asset_version=asset_version) if regenerate: snapshot.delete() snapshot = False except AssetSnapshot.MultipleObjectsReturned: # how did multiple snapshots get here? snaps = AssetSnapshot.objects.filter(asset=self, asset_version=asset_version) snaps.delete() snapshot = False except AssetSnapshot.DoesNotExist: snapshot = False if not snapshot: if self.name != '': form_title = self.name else: _settings = self.content.get('settings', {}) form_title = _settings.get('id_string', 'Untitled') self._append(self.content, settings={ 'form_title': form_title, }) snapshot = AssetSnapshot.objects.create( asset=self, asset_version=asset_version, source=self.content) return snapshot def __unicode__(self): return u'{} ({})'.format(self.name, self.uid) @property def has_active_hooks(self): """ Returns if asset has active hooks. Useful to update `kc.XForm.has_kpi_hooks` field. :return: {boolean} """ return self.hooks.filter(active=True).exists() @staticmethod def optimize_queryset_for_list(queryset): ''' Used by serializers to improve performance when listing assets ''' queryset = queryset.defer( # Avoid pulling these `JSONField`s from the database because: # * they are stored as plain text, and just deserializing them # to Python objects is CPU-intensive; # * they are often huge; # * we don't need them for list views. 'content', 'report_styles' ).select_related('owner__username', ).prefetch_related( # We previously prefetched `permissions__content_object`, but that # actually pulled the entirety of each permission's linked asset # from the database! For now, the solution is to remove # `content_object` here *and* from # `ObjectPermissionNestedSerializer`. 'permissions__permission', 'permissions__user', # `Prefetch(..., to_attr='prefetched_list')` stores the prefetched # related objects in a list (`prefetched_list`) that we can use in # other methods to avoid additional queries; see: # https://docs.djangoproject.com/en/1.8/ref/models/querysets/#prefetch-objects Prefetch('tags', to_attr='prefetched_tags'), Prefetch( 'asset_versions', queryset=AssetVersion.objects.order_by('-date_modified').only( 'uid', 'asset', 'date_modified', 'deployed'), to_attr='prefetched_latest_versions', ), ) return queryset