From f6fb599e5546048b33ccc943113b83dccc1ee790 Mon Sep 17 00:00:00 2001 From: Paige Williams Date: Tue, 12 May 2026 14:08:05 -0700 Subject: [PATCH 01/11] display queryable fields for places --- TEKDB/TEKDB/admin.py | 21 ++++++- TEKDB/TEKDB/models.py | 105 +++++++++++++++++++++++-------- TEKDB/TEKDB/tests/test_models.py | 16 +++++ 3 files changed, 116 insertions(+), 26 deletions(-) diff --git a/TEKDB/TEKDB/admin.py b/TEKDB/TEKDB/admin.py index 64ee642b..2208aace 100644 --- a/TEKDB/TEKDB/admin.py +++ b/TEKDB/TEKDB/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin from django.contrib.gis.admin import OSMGeoAdmin -from django.utils.html import format_html +from django.utils.html import format_html, mark_safe from django.utils.translation import gettext_lazy as _ from dal import autocomplete from mimetypes import guess_type @@ -930,11 +930,30 @@ class PlacesAdmin(NestedRecordAdminProxy, RecordModelAdmin): "enteredbyname", "enteredbydate", ) + readonly_fields = ( + "searchable_fields_display", + "enteredbyname", + "enteredbytitle", + "enteredbytribe", + "enteredbydate", + "modifiedbyname", + "modifiedbytitle", + "modifiedbytribe", + "modifiedbydate", + ) + + @admin.display(description="Keyword-searchable fields") + def searchable_fields_display(self, instance): + fields = instance.__class__.human_readable_list_of_searchable_fields() + items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) + return format_html("", items) + fieldsets = ( ( None, { "fields": ( + "searchable_fields_display", ("indigenousplacename", "indigenousplacenamemeaning"), "englishplacename", ("planningunitid", "primaryhabitat"), diff --git a/TEKDB/TEKDB/models.py b/TEKDB/TEKDB/models.py index 4f12cf6e..6ad55793 100644 --- a/TEKDB/TEKDB/models.py +++ b/TEKDB/TEKDB/models.py @@ -125,6 +125,23 @@ def run_keyword_search(model, keyword, fields, fk_fields, weight_lookup, sort_fi return results +def list_queryable_fields(model): + """Returns a list of verbose names for for fields on the given model that are included in the keyword_search 'fields' or 'fk_fields lists.""" + queryable_fields = [] + if hasattr(model, "keyword_search_fields"): + queryable_fields += model.keyword_search_fields + if hasattr(model, "keyword_search_fk_fields"): + queryable_fields += [field[0] for field in model.keyword_search_fk_fields] + verbose_field_names = [] + for field in model._meta.fields: + if field.name in queryable_fields: + verbose_field_names.append(field.verbose_name) + for field in model._meta.related_objects: + if field.name in queryable_fields: + verbose_field_names.append(field.related_model._meta.verbose_name) + return verbose_field_names + + class ModeratedModel(models.Model): class Meta: abstract = True @@ -513,6 +530,32 @@ def __str__(self): class Places(Reviewable, Queryable, Record, ModeratedModel): + FIELDS = [ + "indigenousplacename", + "englishplacename", + "indigenousplacenamemeaning", + "Source", + "DigitizedBy", + ] + FK_FIELDS = [ + ("planningunitid", "planningunitname"), + ("primaryhabitat", "habitat"), + ("tribeid", "tribe"), + ("placealtindigenousname", "altindigenousname"), + ] + WEIGHT_LOOKUP = { + "indigenousplacename": "A", + "englishplacename": "A", + "indigenousplacenamemeaning": "A", + "Source": "C", + "DigitizedBy": "C", + "planningunitid": "B", + "primaryhabitat": "B", + "tribeid": "B", + "placealtindigenousname": "A", + } + SORT_FIELD = "indigenousplacename" + placeid = models.AutoField(db_column="placeid", primary_key=True) # PlaceID indigenousplacename = models.CharField( @@ -607,40 +650,52 @@ class Meta: verbose_name = "Place" verbose_name_plural = "Places" + @classmethod def keyword_search( + cls, keyword, # string - fields=[ - "indigenousplacename", - "englishplacename", - "indigenousplacenamemeaning", - "Source", - "DigitizedBy", - ], # fields to search - fk_fields=[ - ("planningunitid", "planningunitname"), - ("primaryhabitat", "habitat"), - ("tribeid", "tribe"), - ("placealtindigenousname", "altindigenousname"), - ], # fields to search for fk objects + fields=None, # fields to search + fk_fields=None, # fields to search for fk objects ): - weight_lookup = { - "indigenousplacename": "A", - "englishplacename": "A", - "indigenousplacenamemeaning": "A", - "Source": "C", - "DigitizedBy": "C", - "planningunitid": "B", - "primaryhabitat": "B", - "tribeid": "B", - "placealtindigenousname": "A", - } + instance = cls() + if fields is None: + fields = instance.FIELDS + if fk_fields is None: + fk_fields = instance.FK_FIELDS - sort_field = "indigenousplacename" + weight_lookup = instance.WEIGHT_LOOKUP + sort_field = instance.SORT_FIELD return run_keyword_search( Places, keyword, fields, fk_fields, weight_lookup, sort_field ) + @classmethod + def human_readable_list_of_searchable_fields(cls): + """Returns a human readable list of the fields that are included in the keyword_search 'fields' or 'fk_fields lists.""" + instance = cls() + + fk_related_field_lookup = {fk[0]: fk[1] for fk in instance.FK_FIELDS} + + human_readable_fields = [] + + for key in instance.WEIGHT_LOOKUP.keys(): + field = cls._meta.get_field(key) + if hasattr(field, "verbose_name"): + human_readable_fields.append(str(field.verbose_name).title()) + else: + # ManyToOneRel (reverse relation) — look up the related field via FK_FIELDS + related_field_name = fk_related_field_lookup.get(key) + if related_field_name: + related_field = field.related_model._meta.get_field( + related_field_name + ) + human_readable_fields.append( + str(related_field.verbose_name).title() + ) + + return human_readable_fields + def image(self): return settings.RECORD_ICONS["place"] diff --git a/TEKDB/TEKDB/tests/test_models.py b/TEKDB/TEKDB/tests/test_models.py index 394ddab7..be097190 100644 --- a/TEKDB/TEKDB/tests/test_models.py +++ b/TEKDB/TEKDB/tests/test_models.py @@ -468,6 +468,22 @@ def test_get_response_format_no_feature(self): self.assertIn("feature", response) self.assertIsNone(response["feature"]) + def test_human_readable_list_of_searchable_fields(self): + """Test that human_readable_list_of_searchable_fields returns a list of field names""" + search_fields = Places.human_readable_list_of_searchable_fields() + expected_fields = [ + "Indigenous Name", + "English Name", + "English Translation", + "Source", + "Digitized By", + "Planning Unit", + "Primary Habitat", + "Tribe", + "Alternate Name", + ] + self.assertEqual(search_fields, expected_fields) + # Resources class ResourcesTest(ITKSearchTest): From 08914434c1b5cbc21bdae890aaf43a839f62db45 Mon Sep 17 00:00:00 2001 From: Paige Williams Date: Tue, 12 May 2026 15:01:13 -0700 Subject: [PATCH 02/11] display searched fields for Activities --- TEKDB/TEKDB/admin.py | 28 ++++- TEKDB/TEKDB/models.py | 175 ++++++++++++++++++------------- TEKDB/TEKDB/tests/test_models.py | 23 ++++ 3 files changed, 151 insertions(+), 75 deletions(-) diff --git a/TEKDB/TEKDB/admin.py b/TEKDB/TEKDB/admin.py index 2208aace..5fc8a6e6 100644 --- a/TEKDB/TEKDB/admin.py +++ b/TEKDB/TEKDB/admin.py @@ -1083,8 +1083,34 @@ class ResourcesActivityEventsAdmin(RecordAdminProxy, RecordModelAdmin): "enteredbyname", "enteredbydate", ) + readonly_fields = ( + "searchable_fields_display", + "enteredbyname", + "enteredbytitle", + "enteredbytribe", + "enteredbydate", + "modifiedbyname", + "modifiedbytitle", + "modifiedbytribe", + "modifiedbydate", + ) + + @admin.display(description="Keyword-searchable fields") + def searchable_fields_display(self, instance): + fields = instance.__class__.human_readable_list_of_searchable_fields() + items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) + return format_html("", items) + fieldsets = ( - (None, {"fields": ("placeresourceid",)}), + ( + None, + { + "fields": ( + "searchable_fields_display", + "placeresourceid", + ) + }, + ), ( "Activity", { diff --git a/TEKDB/TEKDB/models.py b/TEKDB/TEKDB/models.py index 6ad55793..acd5ba1a 100644 --- a/TEKDB/TEKDB/models.py +++ b/TEKDB/TEKDB/models.py @@ -125,21 +125,51 @@ def run_keyword_search(model, keyword, fields, fk_fields, weight_lookup, sort_fi return results -def list_queryable_fields(model): - """Returns a list of verbose names for for fields on the given model that are included in the keyword_search 'fields' or 'fk_fields lists.""" - queryable_fields = [] - if hasattr(model, "keyword_search_fields"): - queryable_fields += model.keyword_search_fields - if hasattr(model, "keyword_search_fk_fields"): - queryable_fields += [field[0] for field in model.keyword_search_fk_fields] - verbose_field_names = [] - for field in model._meta.fields: - if field.name in queryable_fields: - verbose_field_names.append(field.verbose_name) - for field in model._meta.related_objects: - if field.name in queryable_fields: - verbose_field_names.append(field.related_model._meta.verbose_name) - return verbose_field_names +def list_queryable_fields(cls, instance): + human_readable_fields = [] + + for field_name in instance.FIELDS: + field = cls._meta.get_field(field_name) + if hasattr(field, "verbose_name"): + human_readable_fields.append(str(field.verbose_name).title()) + else: + human_readable_fields.append(str(field.name).title()) + + for field_name_tuple in instance.FK_FIELDS: + first_field_name = field_name_tuple[0] + first_field = cls._meta.get_field(first_field_name) + if hasattr(first_field, "verbose_name"): + first_verbose = str(first_field.verbose_name).title() + else: + first_verbose = str(first_field_name).title() + + if len(field_name_tuple) == 2: + # Simple case: forward FK uses the FK's verbose_name; reverse relation uses the related field's + if hasattr(first_field, "verbose_name"): + human_readable_fields.append(first_verbose) + else: + related_field = first_field.related_model._meta.get_field( + field_name_tuple[1] + ) + human_readable_fields.append(str(related_field.verbose_name).title()) + else: + # Multi-level traversal: show "[first field] - [last field]" + current_model = getattr(first_field, "related_model", None) + last_verbose = None + for field_name in field_name_tuple[1:]: + if current_model is None: + break + field = current_model._meta.get_field(field_name) + if hasattr(field, "verbose_name"): + last_verbose = str(field.verbose_name).title() + current_model = getattr(field, "related_model", None) + + if last_verbose: + human_readable_fields.append(f"{first_verbose} - {last_verbose}") + else: + human_readable_fields.append(first_verbose) + + return human_readable_fields class ModeratedModel(models.Model): @@ -674,27 +704,7 @@ def keyword_search( def human_readable_list_of_searchable_fields(cls): """Returns a human readable list of the fields that are included in the keyword_search 'fields' or 'fk_fields lists.""" instance = cls() - - fk_related_field_lookup = {fk[0]: fk[1] for fk in instance.FK_FIELDS} - - human_readable_fields = [] - - for key in instance.WEIGHT_LOOKUP.keys(): - field = cls._meta.get_field(key) - if hasattr(field, "verbose_name"): - human_readable_fields.append(str(field.verbose_name).title()) - else: - # ManyToOneRel (reverse relation) — look up the related field via FK_FIELDS - related_field_name = fk_related_field_lookup.get(key) - if related_field_name: - related_field = field.related_model._meta.get_field( - related_field_name - ) - human_readable_fields.append( - str(related_field.verbose_name).title() - ) - - return human_readable_fields + return list_queryable_fields(cls, instance) def image(self): return settings.RECORD_ICONS["place"] @@ -1406,6 +1416,44 @@ def __str__(self): class ResourcesActivityEvents(Reviewable, Queryable, Record, ModeratedModel): + FIELDS = [ + "relationshipdescription", + "activitylongdescription", + "gear", + "customaryuse", + "timingdescription", + ] + FK_FIELDS = [ + ("partused", "partused"), + ("placeresourceid", "resourceid", "commonname"), + ("placeresourceid", "placeid", "englishplacename"), + ("placeresourceid", "placeid", "indigenousplacename"), + ( + "placeresourceid", + "placeid", + "placealtindigenousname", + "altindigenousname", + ), + ("activityshortdescription", "activity"), + ("participants", "participants"), + ("technique", "techniques"), + ("timing", "timing"), + ] + WEIGHT_LOOKUP = { + "relationshipdescription": "B", + "activitylongdescription": "A", + "gear": "B", + "customaryuse": "B", + "timingdescription": "B", + "partused": "C", + "placeresourceid": "A", + "activityshortdescription": "A", + "participants": "C", + "technique": "C", + "timing": "C", + } + SORT_FIELD = "activitylongdescription" + resourceactivityid = models.AutoField( db_column="resourceactivityid", primary_key=True ) @@ -1523,47 +1571,20 @@ def excerpt_text(self): return unescape(strip_tags(self.relationshipdescription)) + @classmethod def keyword_search( + cls, keyword, # string - fields=[ - "relationshipdescription", - "activitylongdescription", - "gear", - "customaryuse", - "timingdescription", - ], # fields to search - fk_fields=[ - ("partused", "partused"), - ("placeresourceid", "resourceid", "commonname"), - ("placeresourceid", "placeid", "englishplacename"), - ("placeresourceid", "placeid", "indigenousplacename"), - ( - "placeresourceid", - "placeid", - "placealtindigenousname", - "altindigenousname", - ), - ("activityshortdescription", "activity"), - ("participants", "participants"), - ("technique", "techniques"), - ("timing", "timing"), - ], # fields to search for fk objects + fields=None, # fields to search + fk_fields=None, # fields to search for fk objects ): - weight_lookup = { - "relationshipdescription": "B", - "activitylongdescription": "A", - "gear": "B", - "customaryuse": "B", - "timingdescription": "B", - "partused": "C", - "placeresourceid": "A", - "activityshortdescription": "A", - "participants": "C", - "technique": "C", - "timing": "C", - } - - sort_field = "activitylongdescription" + instance = cls() + if fields is None: + fields = instance.FIELDS + if fk_fields is None: + fk_fields = instance.FK_FIELDS + weight_lookup = instance.WEIGHT_LOOKUP + sort_field = instance.SORT_FIELD return run_keyword_search( ResourcesActivityEvents, @@ -1574,6 +1595,12 @@ def keyword_search( sort_field, ) + @classmethod + def human_readable_list_of_searchable_fields(cls): + """Returns a human readable list of the fields that are included in the keyword_search 'fields' or 'fk_fields lists.""" + instance = cls() + return list_queryable_fields(cls, instance) + def image(self): return settings.RECORD_ICONS["activity"] diff --git a/TEKDB/TEKDB/tests/test_models.py b/TEKDB/TEKDB/tests/test_models.py index be097190..91910b40 100644 --- a/TEKDB/TEKDB/tests/test_models.py +++ b/TEKDB/TEKDB/tests/test_models.py @@ -703,6 +703,29 @@ def test_data(self): for key in keys: self.assertIn(key, data_keys) + def test_human_readable_list_of_searchable_fields(self): + """Test that human_readable_list_of_searchable_fields returns a list of field names""" + search_fields = ( + ResourcesActivityEvents.human_readable_list_of_searchable_fields() + ) + expected_fields = [ + "Excerpt", + "Full Activity Description", + "Gear", + "Customary Use", + "Timing Description", + "Part Used", + "Place Resource - Common Name", + "Place Resource - English Name", + "Place Resource - Indigenous Name", + "Place Resource - Alternate Name", + "Activity Type", + "Participants", + "Technique", + "Timing", + ] + self.assertEqual(search_fields, expected_fields) + # Citations (Bibliographic 'Sources') class CitationsTest(ITKSearchTest): From a22130ed471053a298659e129251b98899b98ac1 Mon Sep 17 00:00:00 2001 From: Paige Williams Date: Tue, 12 May 2026 15:18:05 -0700 Subject: [PATCH 03/11] display searched fields for Citations --- TEKDB/TEKDB/admin.py | 25 ++++++- TEKDB/TEKDB/models.py | 117 +++++++++++++------------------ TEKDB/TEKDB/tests/test_models.py | 20 ++++++ 3 files changed, 94 insertions(+), 68 deletions(-) diff --git a/TEKDB/TEKDB/admin.py b/TEKDB/TEKDB/admin.py index 5fc8a6e6..c3245c4d 100644 --- a/TEKDB/TEKDB/admin.py +++ b/TEKDB/TEKDB/admin.py @@ -532,8 +532,31 @@ class CitationsAdmin(RecordAdminProxy, RecordModelAdmin): "enteredbyname", "enteredbydate", ) + readonly_fields = ( + "searchable_fields_display", + "enteredbydate", + "modifiedbydate", + "enteredbyname", + "modifiedbyname", + ) + + @admin.display(description="Keyword-searchable fields") + def searchable_fields_display(self, instance): + fields = instance.__class__.human_readable_list_of_searchable_fields() + items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) + return format_html("
      {}
    ", items) + fieldsets = ( - (None, {"classes": ("citation-ref-type",), "fields": ("referencetype",)}), + ( + None, + { + "classes": ("citation-ref-type",), + "fields": ( + "searchable_fields_display", + "referencetype", + ), + }, + ), ( "Bibliographic Source", { diff --git a/TEKDB/TEKDB/models.py b/TEKDB/TEKDB/models.py index acd5ba1a..c38dffe3 100644 --- a/TEKDB/TEKDB/models.py +++ b/TEKDB/TEKDB/models.py @@ -1862,6 +1862,39 @@ def __str__(self): class Citations(Reviewable, Queryable, Record, ModeratedModel): + FIELDS = [ + "referencetext", + "authorprimary", + "authorsecondary", + "placeofinterview", + "title", + "seriestitle", + "seriesvolume", + "serieseditor", + "publisher", + "publishercity", + "preparedfor", + ] + FK_FIELDS = [ + ("referencetype", "documenttype"), + ("authortype", "authortype"), + ] + WEIGHT_LOOKUP = { + "referencetext": "A", + "authorprimary": "A", + "authorsecondary": "A", + "placeofinterview": "C", + "title": "A", + "seriestitle": "C", + "seriesvolume": "C", + "serieseditor": "C", + "publisher": "C", + "publishercity": "C", + "preparedfor": "C", + "referencetype": "B", + "authortype": "B", + } + SORT_FIELD = "referencetext" citationid = models.AutoField(db_column="citationid", primary_key=True) referencetype = models.ForeignKey( LookupReferenceType, @@ -1964,7 +1997,7 @@ class Citations(Reviewable, Queryable, Record, ModeratedModel): max_length=100, blank=True, null=True, - verbose_name="prepared_for", + verbose_name="prepared for", ) rawcitation = HTMLField( db_column="rawcitation", @@ -1987,81 +2020,31 @@ class Meta: verbose_name = "Bibliographic Source" verbose_name_plural = "Bibliographic Sources" + @classmethod def keyword_search( + cls, keyword, # string - fields=[ - "referencetext", - "authorprimary", - "authorsecondary", - "placeofinterview", - "title", - "seriestitle", - "seriesvolume", - "serieseditor", - "publisher", - "publishercity", - "preparedfor", - ], # fields to search - fk_fields=[ - ("referencetype", "documenttype"), - ("authortype", "authortype"), - # ('intervieweeid','interviewee'), - # ('interviewerid','interviewer') - ], # fields to search for fk objects + fields=None, # fields to search + fk_fields=None, # fields to search for fk objects ): - weight_lookup = { - "referencetext": "A", - "authorprimary": "A", - "authorsecondary": "A", - "placeofinterview": "C", - "title": "A", - "seriestitle": "C", - "seriesvolume": "C", - "serieseditor": "C", - "publisher": "C", - "publishercity": "C", - "preparedfor": "C", - "referencetype": "B", - "authortype": "B", - # 'intervieweeid': 'B', - # 'interviewerid': 'B' - } + instance = cls() + if fields is None: + fields = instance.FIELDS + if fk_fields is None: + fk_fields = instance.FK_FIELDS - sort_field = "referencetext" + weight_lookup = instance.WEIGHT_LOOKUP + sort_field = instance.SORT_FIELD return run_keyword_search( Citations, keyword, fields, fk_fields, weight_lookup, sort_field ) - # def keyword_search(keyword): - # reference_qs = LookupReferenceType.objects.filter(documenttype__icontains=keyword) - # reference_loi = [reference.pk for reference in reference_qs] - - # authortype_qs = LookupAuthorType.objects.filter(authortype__icontains=keyword) - # authortype_loi = [authortype.pk for authortype in authortype_qs] - - # people_qs = People.keyword_search(keyword) - # people_loi = [person.pk for person in people_qs] - - # return Citations.objects.filter( - # Q(referencetype__in=reference_loi) | - # Q(referencetext__icontains=keyword) | - # Q(authortype__in=authortype_loi) | - # Q(authorprimary__icontains=keyword) | - # Q(authorsecondary__icontains=keyword) | - # Q(intervieweeid__in=people_loi) | - # Q(interviewerid__in=people_loi) | - # Q(placeofinterview__icontains=keyword) | - # Q(title__icontains=keyword) | - # Q(seriestitle__icontains=keyword) | - # Q(seriesvolume__icontains=keyword) | - # Q(serieseditor__icontains=keyword) | - # Q(publisher__icontains=keyword) | - # Q(publishercity__icontains=keyword) | - # Q(preparedfor__icontains=keyword) | - # Q(comments__icontains=keyword) | - # Q(journal__icontains=keyword) - # ) + @classmethod + def human_readable_list_of_searchable_fields(cls): + """Returns a human readable list of the fields that are included in the keyword_search 'fields' or 'fk_fields lists.""" + instance = cls() + return list_queryable_fields(cls, instance) def image(self): return settings.RECORD_ICONS["citation"] diff --git a/TEKDB/TEKDB/tests/test_models.py b/TEKDB/TEKDB/tests/test_models.py index 91910b40..325f158a 100644 --- a/TEKDB/TEKDB/tests/test_models.py +++ b/TEKDB/TEKDB/tests/test_models.py @@ -864,6 +864,26 @@ def test_description_text(self): expected_description = "Text on marine flora and fauna" self.assertEqual(description_text, expected_description) + def test_human_readable_list_of_searchable_fields(self): + """Test that human_readable_list_of_searchable_fields returns a list of field names""" + search_fields = Citations.human_readable_list_of_searchable_fields() + expected_fields = [ + "Description", + "Primary Author", + "Secondary Author", + "Place Of Interview", + "Title", + "Series Title", + "Series Volume", + "Series Editor", + "Publisher", + "City", + "Prepared For", + "Reference Type", + "Author Type", + ] + self.assertEqual(search_fields, expected_fields) + # Media class MediaTest(ITKSearchTest): From 661cfb27c5a67aa69870dc84ae1531d39b0d5f86 Mon Sep 17 00:00:00 2001 From: Paige Williams Date: Tue, 12 May 2026 15:30:58 -0700 Subject: [PATCH 04/11] display searched fields for Media --- TEKDB/TEKDB/admin.py | 9 +++++ TEKDB/TEKDB/models.py | 56 ++++++++++++++++++-------------- TEKDB/TEKDB/tests/test_models.py | 12 +++++++ 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/TEKDB/TEKDB/admin.py b/TEKDB/TEKDB/admin.py index c3245c4d..150489ed 100644 --- a/TEKDB/TEKDB/admin.py +++ b/TEKDB/TEKDB/admin.py @@ -869,6 +869,7 @@ def has_add_permission(self, request): @admin.register(Media) class MediaAdmin(RecordAdminProxy, RecordModelAdmin): readonly_fields = ( + "searchable_fields_display", "medialink", "enteredbyname", "enteredbytribe", @@ -889,11 +890,19 @@ class MediaAdmin(RecordAdminProxy, RecordModelAdmin): "enteredbyname", "enteredbydate", ) + + @admin.display(description="Keyword-searchable fields") + def searchable_fields_display(self, instance): + fields = instance.__class__.human_readable_list_of_searchable_fields() + items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) + return format_html("
      {}
    ", items) + fieldsets = ( ( None, { "fields": ( + "searchable_fields_display", ("medianame", "mediatype", "limitedaccess"), "mediafile", "medialink", diff --git a/TEKDB/TEKDB/models.py b/TEKDB/TEKDB/models.py index c38dffe3..c0f9c651 100644 --- a/TEKDB/TEKDB/models.py +++ b/TEKDB/TEKDB/models.py @@ -2793,6 +2793,22 @@ def __str__(self): class Media(Reviewable, Queryable, Record, ModeratedModel): + FIELDS = [ + "medianame", + "mediadescription", + "medialink", + "mediafile", + ] + FK_FIELDS = [("mediatype", "mediatype")] + WEIGHT_LOOKUP = { + "medianame": "A", + "mediadescription": "B", + "medialink": "B", + "mediafile": "B", + "mediatype": "C", + } + SORT_FIELD = "medianame" + mediaid = models.AutoField(db_column="mediaid", primary_key=True) mediatype = models.ForeignKey( LookupMediaType, @@ -2856,40 +2872,32 @@ def __unicode__(self): def __str__(self): return "%s [ %s ]" % (self.medianame, self.mediatype) or "" + @classmethod def keyword_search( + cls, keyword, # string - fields=[ - "medianame", - "mediadescription", - "medialink", - "mediafile", - ], # fields to search - fk_fields=[("mediatype", "mediatype")], # fields to search for fk objects + fields=None, # fields to search + fk_fields=None, # fields to search for fk objects ): - weight_lookup = { - "medianame": "A", - "mediadescription": "B", - "medialink": "B", - "mediafile": "B", - "mediatype": "C", - } + instance = cls() + if fields is None: + fields = instance.FIELDS + if fk_fields is None: + fk_fields = instance.FK_FIELDS - sort_field = "medianame" + weight_lookup = instance.WEIGHT_LOOKUP + sort_field = instance.SORT_FIELD return run_keyword_search( Media, keyword, fields, fk_fields, weight_lookup, sort_field ) - # def keyword_search(keyword): - # type_qs = LookupMediaType.keyword_search(keyword) - # type_loi = [mtype.pk for mtype in type_qs] + @classmethod + def human_readable_list_of_searchable_fields(cls): + """Returns a human readable list of the fields that are included in the keyword_search 'fields' or 'fk_fields lists.""" + instance = cls() + return list_queryable_fields(cls, instance) - # return Media.objects.filter( - # Q(mediatype__in=type_loi) | - # Q(medianame__icontains=keyword)| - # Q(mediadescription__icontains=keyword) | - # Q(medialink__icontains=keyword) - # ) @property def description_text(self): from django.utils.html import strip_tags diff --git a/TEKDB/TEKDB/tests/test_models.py b/TEKDB/TEKDB/tests/test_models.py index 325f158a..913dd4bb 100644 --- a/TEKDB/TEKDB/tests/test_models.py +++ b/TEKDB/TEKDB/tests/test_models.py @@ -919,6 +919,18 @@ def test_media_id_collision(self): collision_result = test_model_id_collision(Media, insertion_object, self) self.assertTrue(collision_result) + def test_human_readable_list_of_searchable_fields(self): + """Test that human_readable_list_of_searchable_fields returns a list of field names""" + search_fields = Media.human_readable_list_of_searchable_fields() + expected_fields = [ + "Name", + "Description", + "Historic Location", + "File", + "Type", + ] + self.assertEqual(search_fields, expected_fields) + # MediaBulkUpload class MediaBulkUploadTest(ITKSearchTest): From dda4c8fdc9e2040d9f98749fae353d90d7c5d31c Mon Sep 17 00:00:00 2001 From: Paige Williams Date: Tue, 12 May 2026 15:42:17 -0700 Subject: [PATCH 05/11] display searched fields for Resources --- TEKDB/TEKDB/admin.py | 19 ++++++++++++++ TEKDB/TEKDB/models.py | 45 ++++++++++++++++++++++---------- TEKDB/TEKDB/tests/test_models.py | 13 +++++++++ 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/TEKDB/TEKDB/admin.py b/TEKDB/TEKDB/admin.py index 150489ed..d02c27e9 100644 --- a/TEKDB/TEKDB/admin.py +++ b/TEKDB/TEKDB/admin.py @@ -1057,11 +1057,30 @@ class ResourcesAdmin(NestedRecordAdminProxy, RecordModelAdmin): "enteredbyname", "enteredbydate", ) + readonly_fields = ( + "searchable_fields_display", + "enteredbyname", + "enteredbytitle", + "enteredbytribe", + "enteredbydate", + "modifiedbyname", + "modifiedbytitle", + "modifiedbytribe", + "modifiedbydate", + ) + + @admin.display(description="Keyword-searchable fields") + def searchable_fields_display(self, instance): + fields = instance.__class__.human_readable_list_of_searchable_fields() + items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) + return format_html("
      {}
    ", items) + fieldsets = ( ( None, { "fields": ( + "searchable_fields_display", ("commonname", "indigenousname"), ("genus", "species"), "resourceclassificationgroup", diff --git a/TEKDB/TEKDB/models.py b/TEKDB/TEKDB/models.py index c0f9c651..72ae2e62 100644 --- a/TEKDB/TEKDB/models.py +++ b/TEKDB/TEKDB/models.py @@ -862,6 +862,20 @@ def __str__(self): class Resources(Reviewable, Queryable, Record, ModeratedModel): + FIELDS = ["commonname", "indigenousname", "genus", "species"] + FK_FIELDS = [ + ("resourceclassificationgroup", "resourceclassificationgroup"), + ("resourcealtindigenousname", "altindigenousname"), + ] + WEIGHT_LOOKUP = { + "commonname": "A", + "indigenousname": "A", + "genus": "C", + "species": "C", + "resourceclassificationgroup": "B", + "resourcealtindigenousname": "A", + } + SORT_FIELD = "commonname" resourceid = models.AutoField(db_column="resourceid", primary_key=True) commonname = models.CharField( db_column="commonname", @@ -911,29 +925,32 @@ def __unicode__(self): def __str__(self): return self.commonname or "" + @classmethod def keyword_search( + cls, keyword, # string - fields=["commonname", "indigenousname", "genus", "species"], # fields to search - fk_fields=[ - ("resourceclassificationgroup", "resourceclassificationgroup"), - ("resourcealtindigenousname", "altindigenousname"), - ], # fields to search for fk objects + fields=None, # fields to search + fk_fields=None, # fields to search for fk objects ): - weight_lookup = { - "commonname": "A", - "indigenousname": "A", - "genus": "C", - "species": "C", - "resourceclassificationgroup": "B", - "resourcealtindigenousname": "A", - } + instance = cls() + if fields is None: + fields = instance.FIELDS + if fk_fields is None: + fk_fields = instance.FK_FIELDS - sort_field = "commonname" + weight_lookup = instance.WEIGHT_LOOKUP + sort_field = instance.SORT_FIELD return run_keyword_search( Resources, keyword, fields, fk_fields, weight_lookup, sort_field ) + @classmethod + def human_readable_list_of_searchable_fields(cls): + """Returns a human readable list of the fields that are included in the keyword_search 'fields' or 'fk_fields lists.""" + instance = cls() + return list_queryable_fields(cls, instance) + def image(self): return settings.RECORD_ICONS["resource"] diff --git a/TEKDB/TEKDB/tests/test_models.py b/TEKDB/TEKDB/tests/test_models.py index 913dd4bb..fe917827 100644 --- a/TEKDB/TEKDB/tests/test_models.py +++ b/TEKDB/TEKDB/tests/test_models.py @@ -606,6 +606,19 @@ def test_relationships(self): self.assertEqual(len(relationships), 4) + def test_human_readable_list_of_searchable_fields(self): + """Test that human_readable_list_of_searchable_fields returns a list of field names""" + search_fields = Resources.human_readable_list_of_searchable_fields() + expected_fields = [ + "Common Name", + "Indigenous Name", + "Genus", + "Species", + "Broad Species Group", + "Alt Name", + ] + self.assertEqual(search_fields, expected_fields) + # ResourcesActivityEvents ('Activities') class ResourcesActivityEventsTest(ITKSearchTest): From 6cccf0c0ba4a7c825ca4130e08245439ee06732c Mon Sep 17 00:00:00 2001 From: Paige Williams Date: Tue, 12 May 2026 15:45:09 -0700 Subject: [PATCH 06/11] add migration for prepared_for verbose name change --- .../0028_alter_citations_preparedfor.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 TEKDB/TEKDB/migrations/0028_alter_citations_preparedfor.py diff --git a/TEKDB/TEKDB/migrations/0028_alter_citations_preparedfor.py b/TEKDB/TEKDB/migrations/0028_alter_citations_preparedfor.py new file mode 100644 index 00000000..64cce0d7 --- /dev/null +++ b/TEKDB/TEKDB/migrations/0028_alter_citations_preparedfor.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.30 on 2026-05-12 22:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('TEKDB', '0027_alter_mediabulkupload_mediabulkname'), + ] + + operations = [ + migrations.AlterField( + model_name='citations', + name='preparedfor', + field=models.CharField(blank=True, db_column='preparedfor', max_length=100, null=True, verbose_name='prepared for'), + ), + ] From fcf1e0d9e898b874240dbf72e7192ff1660dd938 Mon Sep 17 00:00:00 2001 From: Paige Williams Date: Tue, 12 May 2026 15:48:03 -0700 Subject: [PATCH 07/11] add readonly_fields for CitationsAdmin --- TEKDB/TEKDB/admin.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/TEKDB/TEKDB/admin.py b/TEKDB/TEKDB/admin.py index d02c27e9..d2c9e65a 100644 --- a/TEKDB/TEKDB/admin.py +++ b/TEKDB/TEKDB/admin.py @@ -535,9 +535,14 @@ class CitationsAdmin(RecordAdminProxy, RecordModelAdmin): readonly_fields = ( "searchable_fields_display", "enteredbydate", - "modifiedbydate", "enteredbyname", + "enteredbytitle", + "enteredbytribe", + "modifiedbydate", "modifiedbyname", + "modifiedbytitle", + "modifiedbytribe", + "modifiedbydate", ) @admin.display(description="Keyword-searchable fields") From f97cd1e1fe9351b1e5dc609ddb98ffce5b849b37 Mon Sep 17 00:00:00 2001 From: Paige Williams Date: Tue, 12 May 2026 16:11:03 -0700 Subject: [PATCH 08/11] add KeywordSearchable model to use with searchable record types --- TEKDB/TEKDB/admin.py | 5 +++ ...media_keywords_places_keywords_and_more.py | 38 +++++++++++++++++ TEKDB/TEKDB/models.py | 42 +++++++++++++------ 3 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 TEKDB/TEKDB/migrations/0029_citations_keywords_media_keywords_places_keywords_and_more.py diff --git a/TEKDB/TEKDB/admin.py b/TEKDB/TEKDB/admin.py index d2c9e65a..9ba8cce1 100644 --- a/TEKDB/TEKDB/admin.py +++ b/TEKDB/TEKDB/admin.py @@ -579,6 +579,7 @@ def searchable_fields_display(self, instance): "placeofinterview", ("journal", "journalpages"), "preparedfor", + "keywords", # 'rawcitation', "comments", ), @@ -913,6 +914,7 @@ def searchable_fields_display(self, instance): "medialink", "mediadescription", "mediabulkupload", + "keywords", ) }, ), @@ -996,6 +998,7 @@ def searchable_fields_display(self, instance): ("planningunitid", "primaryhabitat"), "tribeid", "geometry", + "keywords", ) }, ), @@ -1089,6 +1092,7 @@ def searchable_fields_display(self, instance): ("commonname", "indigenousname"), ("genus", "species"), "resourceclassificationgroup", + "keywords", ) }, ), @@ -1179,6 +1183,7 @@ def searchable_fields_display(self, instance): "customaryuse", "timing", "timingdescription", + "keywords", ) }, ), diff --git a/TEKDB/TEKDB/migrations/0029_citations_keywords_media_keywords_places_keywords_and_more.py b/TEKDB/TEKDB/migrations/0029_citations_keywords_media_keywords_places_keywords_and_more.py new file mode 100644 index 00000000..70802e9a --- /dev/null +++ b/TEKDB/TEKDB/migrations/0029_citations_keywords_media_keywords_places_keywords_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.30 on 2026-05-12 23:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('TEKDB', '0028_alter_citations_preparedfor'), + ] + + operations = [ + migrations.AddField( + model_name='citations', + name='keywords', + field=models.TextField(blank=True, null=True, verbose_name='keywords'), + ), + migrations.AddField( + model_name='media', + name='keywords', + field=models.TextField(blank=True, null=True, verbose_name='keywords'), + ), + migrations.AddField( + model_name='places', + name='keywords', + field=models.TextField(blank=True, null=True, verbose_name='keywords'), + ), + migrations.AddField( + model_name='resources', + name='keywords', + field=models.TextField(blank=True, null=True, verbose_name='keywords'), + ), + migrations.AddField( + model_name='resourcesactivityevents', + name='keywords', + field=models.TextField(blank=True, null=True, verbose_name='keywords'), + ), + ] diff --git a/TEKDB/TEKDB/models.py b/TEKDB/TEKDB/models.py index 72ae2e62..00a0127b 100644 --- a/TEKDB/TEKDB/models.py +++ b/TEKDB/TEKDB/models.py @@ -301,6 +301,17 @@ class Meta: abstract = True +class KeywordSearchable(models.Model): + keywords = models.TextField( + blank=True, + null=True, + verbose_name="keywords", + ) + + class Meta: + abstract = True + + class Queryable(models.Model): enteredbyname = models.CharField( db_column="enteredbyname", @@ -559,13 +570,14 @@ def __str__(self): return self.habitat or "" -class Places(Reviewable, Queryable, Record, ModeratedModel): +class Places(Reviewable, Queryable, Record, ModeratedModel, KeywordSearchable): FIELDS = [ "indigenousplacename", "englishplacename", "indigenousplacenamemeaning", "Source", "DigitizedBy", + "keywords", ] FK_FIELDS = [ ("planningunitid", "planningunitname"), @@ -574,6 +586,7 @@ class Places(Reviewable, Queryable, Record, ModeratedModel): ("placealtindigenousname", "altindigenousname"), ] WEIGHT_LOOKUP = { + "keywords": "A", "indigenousplacename": "A", "englishplacename": "A", "indigenousplacenamemeaning": "A", @@ -861,13 +874,14 @@ def __str__(self): return self.resourceclassificationgroup or "" -class Resources(Reviewable, Queryable, Record, ModeratedModel): - FIELDS = ["commonname", "indigenousname", "genus", "species"] +class Resources(Reviewable, Queryable, Record, ModeratedModel, KeywordSearchable): + FIELDS = ["commonname", "indigenousname", "genus", "species", "keywords"] FK_FIELDS = [ ("resourceclassificationgroup", "resourceclassificationgroup"), ("resourcealtindigenousname", "altindigenousname"), ] WEIGHT_LOOKUP = { + "keywords": "A", "commonname": "A", "indigenousname": "A", "genus": "C", @@ -1432,8 +1446,13 @@ def __str__(self): return self.activity or "" -class ResourcesActivityEvents(Reviewable, Queryable, Record, ModeratedModel): +class ResourcesActivityEvents( + Reviewable, Queryable, Record, ModeratedModel, KeywordSearchable +): + """Aka 'Activity' - this is the relationship between a Place and a Resource that describes an activity that took place involving that resource at that place.""" + FIELDS = [ + "keywords", "relationshipdescription", "activitylongdescription", "gear", @@ -1457,6 +1476,7 @@ class ResourcesActivityEvents(Reviewable, Queryable, Record, ModeratedModel): ("timing", "timing"), ] WEIGHT_LOOKUP = { + "keywords": "A", "relationshipdescription": "B", "activitylongdescription": "A", "gear": "B", @@ -1878,8 +1898,9 @@ def __str__(self): return self.authortype or "" -class Citations(Reviewable, Queryable, Record, ModeratedModel): +class Citations(Reviewable, Queryable, Record, ModeratedModel, KeywordSearchable): FIELDS = [ + "keywords", "referencetext", "authorprimary", "authorsecondary", @@ -1897,6 +1918,7 @@ class Citations(Reviewable, Queryable, Record, ModeratedModel): ("authortype", "authortype"), ] WEIGHT_LOOKUP = { + "keywords": "A", "referencetext": "A", "authorprimary": "A", "authorsecondary": "A", @@ -2802,15 +2824,10 @@ class Meta: def __str__(self): return self.mediabulkname or "" - # @property - # def count(self): - # number of media items uploaded - - # Ability to edit Media - -class Media(Reviewable, Queryable, Record, ModeratedModel): +class Media(Reviewable, Queryable, Record, ModeratedModel, KeywordSearchable): FIELDS = [ + "keywords", "medianame", "mediadescription", "medialink", @@ -2818,6 +2835,7 @@ class Media(Reviewable, Queryable, Record, ModeratedModel): ] FK_FIELDS = [("mediatype", "mediatype")] WEIGHT_LOOKUP = { + "keywords": "A", "medianame": "A", "mediadescription": "B", "medialink": "B", From 944d337e4391c336413ceb651f9423cfa62f3e0f Mon Sep 17 00:00:00 2001 From: Paige Williams Date: Wed, 13 May 2026 12:06:47 -0700 Subject: [PATCH 09/11] update tests for keywords --- TEKDB/TEKDB/fixtures/all_dummy_data.json | 15 ++-- TEKDB/TEKDB/tests/test_models.py | 93 ++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/TEKDB/TEKDB/fixtures/all_dummy_data.json b/TEKDB/TEKDB/fixtures/all_dummy_data.json index 84fdcee7..a0f375c4 100644 --- a/TEKDB/TEKDB/fixtures/all_dummy_data.json +++ b/TEKDB/TEKDB/fixtures/all_dummy_data.json @@ -2684,7 +2684,8 @@ "geometry": "SRID=3857;POLYGON ((-13827397.042595 5154553.4109967, -13827459.14768 5154512.8038255, -13827406.597223 5154369.4843975, -13827339.714824 5154331.2658833, -13827234.61391 5154347.9864833, -13827158.176882 5154414.868883, -13827151.01091 5154496.0832255, -13827275.221081 5154543.8563682, -13827397.042595 5154553.4109967))", "Source": "ryan", "DigitizedBy": "ryan", - "DigitizedDate": "2017-09-11T07:00:00Z" + "DigitizedDate": "2017-09-11T07:00:00Z", + "keywords": "shark, rock, habitat, puget sound, beach, intertidal, foobarbaz" } }, { @@ -3155,7 +3156,8 @@ "species": "sp.", "specific": false, "resourceclassificationgroup": null, - "islocked": false + "islocked": false, + "keywords": "abalone, shellfish, marine, intertidal, invertebrate, foobarbaz" } }, { @@ -8184,7 +8186,8 @@ "customaryuse": null, "timing": null, "timingdescription": null, - "islocked": false + "islocked": false, + "keywords": "harvesting, female, shellfish, beach, hand gathering, foobarbaz" } }, { @@ -8243,7 +8246,8 @@ "preparedfor": null, "comments": "Text on flora and fauna harvested by Native Americans in the marine environment", "journal": null, - "journalpages": null + "journalpages": null, + "keywords": "marine harvesting, flora, fauna, text, foobarbaz" } }, { @@ -8340,7 +8344,8 @@ "mediadescription": "Photo taken by Manaha Herman, 2015", "medialink": "/usr/local/apps/TEKDB/TEKDB/media/gumboot_chiton.jpg", "mediafile": "gumboot_chiton_fT5Kxtm.jpg", - "limitedaccess": true + "limitedaccess": true, + "keywords": "chiton, gumboot chiton, photo, foobarbaz" } }, { diff --git a/TEKDB/TEKDB/tests/test_models.py b/TEKDB/TEKDB/tests/test_models.py index fe917827..e53179bd 100644 --- a/TEKDB/TEKDB/tests/test_models.py +++ b/TEKDB/TEKDB/tests/test_models.py @@ -386,6 +386,7 @@ def test_places_search(self): # * placealtindigenousname # * Source # * DigitizedBy + # * Keywords keyword = "place" place_results = Places.keyword_search(keyword) # do we get 3 results? also checks that we do not return all results in Place category @@ -400,6 +401,23 @@ def test_places_search(self): ) ) + def test_matches_keywords_field(self): + ##################################### + ### TEST KEYWORD FIELD SEARCH ### + ##################################### + # search 'foobarbaz' in keywords field + keyword = "foobarbaz" + place_results = Places.keyword_search(keyword) + self.assertEqual(place_results.count(), 2) + for result in place_results: + self.assertTrue(hasattr(result, "similarity")) + self.assertTrue( + ( + result.similarity + and result.similarity > settings.MIN_SEARCH_SIMILARITY + ) + ) + def test_search_foreign_key_field(self): ##################################### ### TEST FOREIGN KEY FIELD SEARCH ### @@ -477,6 +495,7 @@ def test_human_readable_list_of_searchable_fields(self): "English Translation", "Source", "Digitized By", + "Keywords", "Planning Unit", "Primary Habitat", "Tribe", @@ -534,6 +553,23 @@ def test_search_text_field(self): # self.assertTrue(resource.pk in [gumboot_chiton_id, skunk_cabbage_id, sea_cucumber_id]) self.assertTrue(resource.pk != chiton_id) + def test_matches_keywords_field(self): + ##################################### + ### TEST KEYWORD FIELD SEARCH ### + ##################################### + # search 'foobarbaz' in keywords field + keyword = "foobarbaz" + resources_results = Resources.keyword_search(keyword) + self.assertEqual(resources_results.count(), 2) + for result in resources_results: + self.assertTrue(hasattr(result, "similarity")) + self.assertTrue( + ( + result.similarity + and result.similarity > settings.MIN_SEARCH_SIMILARITY + ) + ) + def test_search_foreign_key_field(self): ##################################### ### TEST FOREIGN KEY FIELD SEARCH ### @@ -614,6 +650,7 @@ def test_human_readable_list_of_searchable_fields(self): "Indigenous Name", "Genus", "Species", + "Keywords", "Broad Species Group", "Alt Name", ] @@ -646,6 +683,23 @@ def test_activities_search(self): ) ) + def test_matches_keywords_field(self): + ##################################### + ### TEST KEYWORD FIELD SEARCH ### + ##################################### + # search 'foobarbaz' in keywords field + keyword = "foobarbaz" + activity_results = ResourcesActivityEvents.keyword_search(keyword) + self.assertEqual(activity_results.count(), 1) + for result in activity_results: + self.assertTrue(hasattr(result, "similarity")) + self.assertTrue( + ( + result.similarity + and result.similarity > settings.MIN_SEARCH_SIMILARITY + ) + ) + def test_activity_id_collision(self): """ Test that saving an activity can recover from an ID collision @@ -691,6 +745,7 @@ def test_data(self): "customaryuse": "customary use", "timing": LookupTiming.objects.all()[0], "timingdescription": "This is a description of the timing.", + "keywords": "test, activity", } ) activity.save() @@ -722,6 +777,7 @@ def test_human_readable_list_of_searchable_fields(self): ResourcesActivityEvents.human_readable_list_of_searchable_fields() ) expected_fields = [ + "Keywords", "Excerpt", "Full Activity Description", "Gear", @@ -764,6 +820,7 @@ def test_citations_search(self): # * preparedfor # * referencetype (foreign key) # * authortype (foreign key) (no records for testing) + # * keywords # X intervieweeid (foreign key) (not ready for testing) # X interviewerid (foreign key) (not ready for testing) @@ -801,6 +858,23 @@ def test_citations_search(self): # # * + def test_matches_keywords_field(self): + ##################################### + ### TEST KEYWORD FIELD SEARCH ### + ##################################### + # search 'foobarbaz' in keywords field + keyword = "foobarbaz" + citation_results = Citations.keyword_search(keyword) + self.assertEqual(citation_results.count(), 1) + for result in citation_results: + self.assertTrue(hasattr(result, "similarity")) + self.assertTrue( + ( + result.similarity + and result.similarity > settings.MIN_SEARCH_SIMILARITY + ) + ) + def test_citation_id_collision(self): """ Test that saving a citation can recover from an ID collision @@ -881,6 +955,7 @@ def test_human_readable_list_of_searchable_fields(self): """Test that human_readable_list_of_searchable_fields returns a list of field names""" search_fields = Citations.human_readable_list_of_searchable_fields() expected_fields = [ + "Keywords", "Description", "Primary Author", "Secondary Author", @@ -924,6 +999,23 @@ def test_media_search(self): ) ) + def test_matches_keywords_field(self): + ##################################### + ### TEST KEYWORD FIELD SEARCH ### + ##################################### + # search 'foobarbaz' in keywords field + keyword = "foobarbaz" + media_results = Media.keyword_search(keyword) + self.assertEqual(media_results.count(), 1) + for result in media_results: + self.assertTrue(hasattr(result, "similarity")) + self.assertTrue( + ( + result.similarity + and result.similarity > settings.MIN_SEARCH_SIMILARITY + ) + ) + def test_media_id_collision(self): """ Test that saving a media can recover from an ID collision @@ -936,6 +1028,7 @@ def test_human_readable_list_of_searchable_fields(self): """Test that human_readable_list_of_searchable_fields returns a list of field names""" search_fields = Media.human_readable_list_of_searchable_fields() expected_fields = [ + "Keywords", "Name", "Description", "Historic Location", From d6467a370ea0c8ed5b6be1dd12ad9a68592e0508 Mon Sep 17 00:00:00 2001 From: Paige Williams Date: Wed, 13 May 2026 13:24:28 -0700 Subject: [PATCH 10/11] add guide section with searchable fields in admin --- TEKDB/TEKDB/admin.py | 46 ++++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/TEKDB/TEKDB/admin.py b/TEKDB/TEKDB/admin.py index 9ba8cce1..4eb45778 100644 --- a/TEKDB/TEKDB/admin.py +++ b/TEKDB/TEKDB/admin.py @@ -549,17 +549,17 @@ class CitationsAdmin(RecordAdminProxy, RecordModelAdmin): def searchable_fields_display(self, instance): fields = instance.__class__.human_readable_list_of_searchable_fields() items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) - return format_html("
      {}
    ", items) + return format_html( + "
      {}
    ", + items, + ) fieldsets = ( ( None, { "classes": ("citation-ref-type",), - "fields": ( - "searchable_fields_display", - "referencetype", - ), + "fields": ("referencetype",), }, ), ( @@ -580,7 +580,6 @@ def searchable_fields_display(self, instance): ("journal", "journalpages"), "preparedfor", "keywords", - # 'rawcitation', "comments", ), }, @@ -605,6 +604,7 @@ def searchable_fields_display(self, instance): ) }, ), + ("Guide", {"fields": ("searchable_fields_display",)}), ) add_form_template = "%s/TEKDB/templates/admin/CitationsForm.html" % BASE_DIR @@ -901,14 +901,16 @@ class MediaAdmin(RecordAdminProxy, RecordModelAdmin): def searchable_fields_display(self, instance): fields = instance.__class__.human_readable_list_of_searchable_fields() items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) - return format_html("
      {}
    ", items) + return format_html( + "
      {}
    ", + items, + ) fieldsets = ( ( None, { "fields": ( - "searchable_fields_display", ("medianame", "mediatype", "limitedaccess"), "mediafile", "medialink", @@ -938,6 +940,7 @@ def searchable_fields_display(self, instance): ) }, ), + ("Guide", {"fields": ("searchable_fields_display",)}), ) from TEKDB.settings import BASE_DIR @@ -985,14 +988,16 @@ class PlacesAdmin(NestedRecordAdminProxy, RecordModelAdmin): def searchable_fields_display(self, instance): fields = instance.__class__.human_readable_list_of_searchable_fields() items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) - return format_html("
      {}
    ", items) + return format_html( + "
      {}
    ", + items, + ) fieldsets = ( ( None, { "fields": ( - "searchable_fields_display", ("indigenousplacename", "indigenousplacenamemeaning"), "englishplacename", ("planningunitid", "primaryhabitat"), @@ -1023,6 +1028,7 @@ def searchable_fields_display(self, instance): ) }, ), + ("Guide", {"fields": ("searchable_fields_display",)}), ) search_fields = ( @@ -1081,14 +1087,16 @@ class ResourcesAdmin(NestedRecordAdminProxy, RecordModelAdmin): def searchable_fields_display(self, instance): fields = instance.__class__.human_readable_list_of_searchable_fields() items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) - return format_html("
      {}
    ", items) + return format_html( + "
      {}
    ", + items, + ) fieldsets = ( ( None, { "fields": ( - "searchable_fields_display", ("commonname", "indigenousname"), ("genus", "species"), "resourceclassificationgroup", @@ -1116,6 +1124,7 @@ def searchable_fields_display(self, instance): ) }, ), + ("Guide", {"fields": ("searchable_fields_display",)}), ) search_fields = ( "commonname", @@ -1159,17 +1168,15 @@ class ResourcesActivityEventsAdmin(RecordAdminProxy, RecordModelAdmin): def searchable_fields_display(self, instance): fields = instance.__class__.human_readable_list_of_searchable_fields() items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) - return format_html("
      {}
    ", items) + return format_html( + "
      {}
    ", + items, + ) fieldsets = ( ( None, - { - "fields": ( - "searchable_fields_display", - "placeresourceid", - ) - }, + {"fields": ("placeresourceid",)}, ), ( "Activity", @@ -1207,6 +1214,7 @@ def searchable_fields_display(self, instance): ) }, ), + ("Guide", {"fields": ("searchable_fields_display",)}), ) search_fields = ( "placeresourceid__resourceid__commonname", From e3bb30cb3407c9ebddcea3e2b3209b395b02ec35 Mon Sep 17 00:00:00 2001 From: Paige Williams Date: Wed, 13 May 2026 14:43:23 -0700 Subject: [PATCH 11/11] use class constants, not instance; add SearchableFieldsGuideMixin --- TEKDB/TEKDB/admin.py | 75 ++++++++++++++----------------------------- TEKDB/TEKDB/models.py | 66 ++++++++++++++++--------------------- 2 files changed, 52 insertions(+), 89 deletions(-) diff --git a/TEKDB/TEKDB/admin.py b/TEKDB/TEKDB/admin.py index 4eb45778..170d82f9 100644 --- a/TEKDB/TEKDB/admin.py +++ b/TEKDB/TEKDB/admin.py @@ -404,6 +404,21 @@ class LocalityGISSelectionsInline(admin.TabularInline): #################### ### MODEL ADMINS ### #################### + +#### Mixins #### + + +class SearchableFieldsGuideMixin(admin.ModelAdmin): + @admin.display(description="Keyword-searchable fields") + def searchable_fields_display(self, instance): + fields = instance.__class__.human_readable_list_of_searchable_fields() + items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) + return format_html( + "
      {}
    ", + items, + ) + + #### PROXY MODELS #### # class RecordAdminProxy(VersionAdmin, ModerationAdmin): class RecordAdminProxy(VersionAdmin): @@ -521,7 +536,7 @@ def change_view(self, request, object_id, form_url="", extra_context={}): @admin.register(Citations) -class CitationsAdmin(RecordAdminProxy, RecordModelAdmin): +class CitationsAdmin(RecordAdminProxy, RecordModelAdmin, SearchableFieldsGuideMixin): list_display = ( "referencetype", "title_text", @@ -538,22 +553,12 @@ class CitationsAdmin(RecordAdminProxy, RecordModelAdmin): "enteredbyname", "enteredbytitle", "enteredbytribe", - "modifiedbydate", "modifiedbyname", "modifiedbytitle", "modifiedbytribe", "modifiedbydate", ) - @admin.display(description="Keyword-searchable fields") - def searchable_fields_display(self, instance): - fields = instance.__class__.human_readable_list_of_searchable_fields() - items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) - return format_html( - "
      {}
    ", - items, - ) - fieldsets = ( ( None, @@ -873,7 +878,7 @@ def has_add_permission(self, request): @admin.register(Media) -class MediaAdmin(RecordAdminProxy, RecordModelAdmin): +class MediaAdmin(RecordAdminProxy, RecordModelAdmin, SearchableFieldsGuideMixin): readonly_fields = ( "searchable_fields_display", "medialink", @@ -897,15 +902,6 @@ class MediaAdmin(RecordAdminProxy, RecordModelAdmin): "enteredbydate", ) - @admin.display(description="Keyword-searchable fields") - def searchable_fields_display(self, instance): - fields = instance.__class__.human_readable_list_of_searchable_fields() - items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) - return format_html( - "
      {}
    ", - items, - ) - fieldsets = ( ( None, @@ -962,7 +958,7 @@ def searchable_fields_display(self, instance): # class PlacesAdmin(NestedRecordAdminProxy, OSMGeoAdmin, RecordModelAdmin): @admin.register(Places) -class PlacesAdmin(NestedRecordAdminProxy, RecordModelAdmin): +class PlacesAdmin(NestedRecordAdminProxy, RecordModelAdmin, SearchableFieldsGuideMixin): list_display = ( "indigenousplacename", "englishplacename", @@ -984,15 +980,6 @@ class PlacesAdmin(NestedRecordAdminProxy, RecordModelAdmin): "modifiedbydate", ) - @admin.display(description="Keyword-searchable fields") - def searchable_fields_display(self, instance): - fields = instance.__class__.human_readable_list_of_searchable_fields() - items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) - return format_html( - "
      {}
    ", - items, - ) - fieldsets = ( ( None, @@ -1061,7 +1048,9 @@ def changelist_view(self, request, extra_context=None): @admin.register(Resources) -class ResourcesAdmin(NestedRecordAdminProxy, RecordModelAdmin): +class ResourcesAdmin( + NestedRecordAdminProxy, RecordModelAdmin, SearchableFieldsGuideMixin +): list_display = ( "commonname", "indigenousname", @@ -1083,15 +1072,6 @@ class ResourcesAdmin(NestedRecordAdminProxy, RecordModelAdmin): "modifiedbydate", ) - @admin.display(description="Keyword-searchable fields") - def searchable_fields_display(self, instance): - fields = instance.__class__.human_readable_list_of_searchable_fields() - items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) - return format_html( - "
      {}
    ", - items, - ) - fieldsets = ( ( None, @@ -1142,7 +1122,9 @@ def searchable_fields_display(self, instance): @admin.register(ResourcesActivityEvents) -class ResourcesActivityEventsAdmin(RecordAdminProxy, RecordModelAdmin): +class ResourcesActivityEventsAdmin( + RecordAdminProxy, RecordModelAdmin, SearchableFieldsGuideMixin +): list_display = ( "placeresourceid", "excerpt_text", @@ -1164,15 +1146,6 @@ class ResourcesActivityEventsAdmin(RecordAdminProxy, RecordModelAdmin): "modifiedbydate", ) - @admin.display(description="Keyword-searchable fields") - def searchable_fields_display(self, instance): - fields = instance.__class__.human_readable_list_of_searchable_fields() - items = mark_safe("".join(f"
  • {f}
  • " for f in fields)) - return format_html( - "
      {}
    ", - items, - ) - fieldsets = ( ( None, diff --git a/TEKDB/TEKDB/models.py b/TEKDB/TEKDB/models.py index 00a0127b..598f378f 100644 --- a/TEKDB/TEKDB/models.py +++ b/TEKDB/TEKDB/models.py @@ -125,17 +125,17 @@ def run_keyword_search(model, keyword, fields, fk_fields, weight_lookup, sort_fi return results -def list_queryable_fields(cls, instance): +def list_queryable_fields(cls): human_readable_fields = [] - for field_name in instance.FIELDS: + for field_name in cls.FIELDS: field = cls._meta.get_field(field_name) if hasattr(field, "verbose_name"): human_readable_fields.append(str(field.verbose_name).title()) else: human_readable_fields.append(str(field.name).title()) - for field_name_tuple in instance.FK_FIELDS: + for field_name_tuple in cls.FK_FIELDS: first_field_name = field_name_tuple[0] first_field = cls._meta.get_field(first_field_name) if hasattr(first_field, "verbose_name"): @@ -700,14 +700,13 @@ def keyword_search( fields=None, # fields to search fk_fields=None, # fields to search for fk objects ): - instance = cls() if fields is None: - fields = instance.FIELDS + fields = cls.FIELDS if fk_fields is None: - fk_fields = instance.FK_FIELDS + fk_fields = cls.FK_FIELDS - weight_lookup = instance.WEIGHT_LOOKUP - sort_field = instance.SORT_FIELD + weight_lookup = cls.WEIGHT_LOOKUP + sort_field = cls.SORT_FIELD return run_keyword_search( Places, keyword, fields, fk_fields, weight_lookup, sort_field @@ -716,8 +715,7 @@ def keyword_search( @classmethod def human_readable_list_of_searchable_fields(cls): """Returns a human readable list of the fields that are included in the keyword_search 'fields' or 'fk_fields lists.""" - instance = cls() - return list_queryable_fields(cls, instance) + return list_queryable_fields(cls) def image(self): return settings.RECORD_ICONS["place"] @@ -946,14 +944,13 @@ def keyword_search( fields=None, # fields to search fk_fields=None, # fields to search for fk objects ): - instance = cls() if fields is None: - fields = instance.FIELDS + fields = cls.FIELDS if fk_fields is None: - fk_fields = instance.FK_FIELDS + fk_fields = cls.FK_FIELDS - weight_lookup = instance.WEIGHT_LOOKUP - sort_field = instance.SORT_FIELD + weight_lookup = cls.WEIGHT_LOOKUP + sort_field = cls.SORT_FIELD return run_keyword_search( Resources, keyword, fields, fk_fields, weight_lookup, sort_field @@ -962,8 +959,7 @@ def keyword_search( @classmethod def human_readable_list_of_searchable_fields(cls): """Returns a human readable list of the fields that are included in the keyword_search 'fields' or 'fk_fields lists.""" - instance = cls() - return list_queryable_fields(cls, instance) + return list_queryable_fields(cls) def image(self): return settings.RECORD_ICONS["resource"] @@ -1615,13 +1611,12 @@ def keyword_search( fields=None, # fields to search fk_fields=None, # fields to search for fk objects ): - instance = cls() if fields is None: - fields = instance.FIELDS + fields = cls.FIELDS if fk_fields is None: - fk_fields = instance.FK_FIELDS - weight_lookup = instance.WEIGHT_LOOKUP - sort_field = instance.SORT_FIELD + fk_fields = cls.FK_FIELDS + weight_lookup = cls.WEIGHT_LOOKUP + sort_field = cls.SORT_FIELD return run_keyword_search( ResourcesActivityEvents, @@ -1635,8 +1630,7 @@ def keyword_search( @classmethod def human_readable_list_of_searchable_fields(cls): """Returns a human readable list of the fields that are included in the keyword_search 'fields' or 'fk_fields lists.""" - instance = cls() - return list_queryable_fields(cls, instance) + return list_queryable_fields(cls) def image(self): return settings.RECORD_ICONS["activity"] @@ -2066,14 +2060,13 @@ def keyword_search( fields=None, # fields to search fk_fields=None, # fields to search for fk objects ): - instance = cls() if fields is None: - fields = instance.FIELDS + fields = cls.FIELDS if fk_fields is None: - fk_fields = instance.FK_FIELDS + fk_fields = cls.FK_FIELDS - weight_lookup = instance.WEIGHT_LOOKUP - sort_field = instance.SORT_FIELD + weight_lookup = cls.WEIGHT_LOOKUP + sort_field = cls.SORT_FIELD return run_keyword_search( Citations, keyword, fields, fk_fields, weight_lookup, sort_field @@ -2082,8 +2075,7 @@ def keyword_search( @classmethod def human_readable_list_of_searchable_fields(cls): """Returns a human readable list of the fields that are included in the keyword_search 'fields' or 'fk_fields lists.""" - instance = cls() - return list_queryable_fields(cls, instance) + return list_queryable_fields(cls) def image(self): return settings.RECORD_ICONS["citation"] @@ -2914,14 +2906,13 @@ def keyword_search( fields=None, # fields to search fk_fields=None, # fields to search for fk objects ): - instance = cls() if fields is None: - fields = instance.FIELDS + fields = cls.FIELDS if fk_fields is None: - fk_fields = instance.FK_FIELDS + fk_fields = cls.FK_FIELDS - weight_lookup = instance.WEIGHT_LOOKUP - sort_field = instance.SORT_FIELD + weight_lookup = cls.WEIGHT_LOOKUP + sort_field = cls.SORT_FIELD return run_keyword_search( Media, keyword, fields, fk_fields, weight_lookup, sort_field @@ -2930,8 +2921,7 @@ def keyword_search( @classmethod def human_readable_list_of_searchable_fields(cls): """Returns a human readable list of the fields that are included in the keyword_search 'fields' or 'fk_fields lists.""" - instance = cls() - return list_queryable_fields(cls, instance) + return list_queryable_fields(cls) @property def description_text(self):