diff --git a/TEKDB/TEKDB/admin.py b/TEKDB/TEKDB/admin.py index 64ee642b..170d82f9 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 @@ -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", @@ -532,8 +547,26 @@ class CitationsAdmin(RecordAdminProxy, RecordModelAdmin): "enteredbyname", "enteredbydate", ) + readonly_fields = ( + "searchable_fields_display", + "enteredbydate", + "enteredbyname", + "enteredbytitle", + "enteredbytribe", + "modifiedbyname", + "modifiedbytitle", + "modifiedbytribe", + "modifiedbydate", + ) + fieldsets = ( - (None, {"classes": ("citation-ref-type",), "fields": ("referencetype",)}), + ( + None, + { + "classes": ("citation-ref-type",), + "fields": ("referencetype",), + }, + ), ( "Bibliographic Source", { @@ -551,7 +584,7 @@ class CitationsAdmin(RecordAdminProxy, RecordModelAdmin): "placeofinterview", ("journal", "journalpages"), "preparedfor", - # 'rawcitation', + "keywords", "comments", ), }, @@ -576,6 +609,7 @@ class CitationsAdmin(RecordAdminProxy, RecordModelAdmin): ) }, ), + ("Guide", {"fields": ("searchable_fields_display",)}), ) add_form_template = "%s/TEKDB/templates/admin/CitationsForm.html" % BASE_DIR @@ -844,8 +878,9 @@ def has_add_permission(self, request): @admin.register(Media) -class MediaAdmin(RecordAdminProxy, RecordModelAdmin): +class MediaAdmin(RecordAdminProxy, RecordModelAdmin, SearchableFieldsGuideMixin): readonly_fields = ( + "searchable_fields_display", "medialink", "enteredbyname", "enteredbytribe", @@ -866,6 +901,7 @@ class MediaAdmin(RecordAdminProxy, RecordModelAdmin): "enteredbyname", "enteredbydate", ) + fieldsets = ( ( None, @@ -876,6 +912,7 @@ class MediaAdmin(RecordAdminProxy, RecordModelAdmin): "medialink", "mediadescription", "mediabulkupload", + "keywords", ) }, ), @@ -899,6 +936,7 @@ class MediaAdmin(RecordAdminProxy, RecordModelAdmin): ) }, ), + ("Guide", {"fields": ("searchable_fields_display",)}), ) from TEKDB.settings import BASE_DIR @@ -920,7 +958,7 @@ class MediaAdmin(RecordAdminProxy, RecordModelAdmin): # class PlacesAdmin(NestedRecordAdminProxy, OSMGeoAdmin, RecordModelAdmin): @admin.register(Places) -class PlacesAdmin(NestedRecordAdminProxy, RecordModelAdmin): +class PlacesAdmin(NestedRecordAdminProxy, RecordModelAdmin, SearchableFieldsGuideMixin): list_display = ( "indigenousplacename", "englishplacename", @@ -930,6 +968,18 @@ class PlacesAdmin(NestedRecordAdminProxy, RecordModelAdmin): "enteredbyname", "enteredbydate", ) + readonly_fields = ( + "searchable_fields_display", + "enteredbyname", + "enteredbytitle", + "enteredbytribe", + "enteredbydate", + "modifiedbyname", + "modifiedbytitle", + "modifiedbytribe", + "modifiedbydate", + ) + fieldsets = ( ( None, @@ -940,6 +990,7 @@ class PlacesAdmin(NestedRecordAdminProxy, RecordModelAdmin): ("planningunitid", "primaryhabitat"), "tribeid", "geometry", + "keywords", ) }, ), @@ -964,6 +1015,7 @@ class PlacesAdmin(NestedRecordAdminProxy, RecordModelAdmin): ) }, ), + ("Guide", {"fields": ("searchable_fields_display",)}), ) search_fields = ( @@ -996,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", @@ -1006,6 +1060,18 @@ class ResourcesAdmin(NestedRecordAdminProxy, RecordModelAdmin): "enteredbyname", "enteredbydate", ) + readonly_fields = ( + "searchable_fields_display", + "enteredbyname", + "enteredbytitle", + "enteredbytribe", + "enteredbydate", + "modifiedbyname", + "modifiedbytitle", + "modifiedbytribe", + "modifiedbydate", + ) + fieldsets = ( ( None, @@ -1014,6 +1080,7 @@ class ResourcesAdmin(NestedRecordAdminProxy, RecordModelAdmin): ("commonname", "indigenousname"), ("genus", "species"), "resourceclassificationgroup", + "keywords", ) }, ), @@ -1037,6 +1104,7 @@ class ResourcesAdmin(NestedRecordAdminProxy, RecordModelAdmin): ) }, ), + ("Guide", {"fields": ("searchable_fields_display",)}), ) search_fields = ( "commonname", @@ -1054,7 +1122,9 @@ class ResourcesAdmin(NestedRecordAdminProxy, RecordModelAdmin): @admin.register(ResourcesActivityEvents) -class ResourcesActivityEventsAdmin(RecordAdminProxy, RecordModelAdmin): +class ResourcesActivityEventsAdmin( + RecordAdminProxy, RecordModelAdmin, SearchableFieldsGuideMixin +): list_display = ( "placeresourceid", "excerpt_text", @@ -1064,8 +1134,23 @@ class ResourcesActivityEventsAdmin(RecordAdminProxy, RecordModelAdmin): "enteredbyname", "enteredbydate", ) + readonly_fields = ( + "searchable_fields_display", + "enteredbyname", + "enteredbytitle", + "enteredbytribe", + "enteredbydate", + "modifiedbyname", + "modifiedbytitle", + "modifiedbytribe", + "modifiedbydate", + ) + fieldsets = ( - (None, {"fields": ("placeresourceid",)}), + ( + None, + {"fields": ("placeresourceid",)}, + ), ( "Activity", { @@ -1078,6 +1163,7 @@ class ResourcesActivityEventsAdmin(RecordAdminProxy, RecordModelAdmin): "customaryuse", "timing", "timingdescription", + "keywords", ) }, ), @@ -1101,6 +1187,7 @@ class ResourcesActivityEventsAdmin(RecordAdminProxy, RecordModelAdmin): ) }, ), + ("Guide", {"fields": ("searchable_fields_display",)}), ) search_fields = ( "placeresourceid__resourceid__commonname", 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/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'), + ), + ] 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 4f12cf6e..598f378f 100644 --- a/TEKDB/TEKDB/models.py +++ b/TEKDB/TEKDB/models.py @@ -125,6 +125,53 @@ def run_keyword_search(model, keyword, fields, fk_fields, weight_lookup, sort_fi return results +def list_queryable_fields(cls): + human_readable_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 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"): + 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): class Meta: abstract = True @@ -254,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", @@ -512,7 +570,35 @@ 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"), + ("primaryhabitat", "habitat"), + ("tribeid", "tribe"), + ("placealtindigenousname", "altindigenousname"), + ] + WEIGHT_LOOKUP = { + "keywords": "A", + "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 +693,30 @@ 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", - } + if fields is None: + fields = cls.FIELDS + if fk_fields is None: + fk_fields = cls.FK_FIELDS - sort_field = "indigenousplacename" + weight_lookup = cls.WEIGHT_LOOKUP + sort_field = cls.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.""" + return list_queryable_fields(cls) + def image(self): return settings.RECORD_ICONS["place"] @@ -796,7 +872,22 @@ def __str__(self): return self.resourceclassificationgroup or "" -class Resources(Reviewable, Queryable, Record, ModeratedModel): +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", + "species": "C", + "resourceclassificationgroup": "B", + "resourcealtindigenousname": "A", + } + SORT_FIELD = "commonname" resourceid = models.AutoField(db_column="resourceid", primary_key=True) commonname = models.CharField( db_column="commonname", @@ -846,29 +937,30 @@ 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", - } + if fields is None: + fields = cls.FIELDS + if fk_fields is None: + fk_fields = cls.FK_FIELDS - sort_field = "commonname" + weight_lookup = cls.WEIGHT_LOOKUP + sort_field = cls.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.""" + return list_queryable_fields(cls) + def image(self): return settings.RECORD_ICONS["resource"] @@ -1350,7 +1442,51 @@ 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", + "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 = { + "keywords": "A", + "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 ) @@ -1468,47 +1604,19 @@ 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" + if fields is None: + fields = cls.FIELDS + if fk_fields is None: + fk_fields = cls.FK_FIELDS + weight_lookup = cls.WEIGHT_LOOKUP + sort_field = cls.SORT_FIELD return run_keyword_search( ResourcesActivityEvents, @@ -1519,6 +1627,11 @@ 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.""" + return list_queryable_fields(cls) + def image(self): return settings.RECORD_ICONS["activity"] @@ -1779,7 +1892,42 @@ 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", + "placeofinterview", + "title", + "seriestitle", + "seriesvolume", + "serieseditor", + "publisher", + "publishercity", + "preparedfor", + ] + FK_FIELDS = [ + ("referencetype", "documenttype"), + ("authortype", "authortype"), + ] + WEIGHT_LOOKUP = { + "keywords": "A", + "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, @@ -1882,7 +2030,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", @@ -1905,81 +2053,29 @@ 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' - } + if fields is None: + fields = cls.FIELDS + if fk_fields is None: + fk_fields = cls.FK_FIELDS - sort_field = "referencetext" + weight_lookup = cls.WEIGHT_LOOKUP + sort_field = cls.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.""" + return list_queryable_fields(cls) def image(self): return settings.RECORD_ICONS["citation"] @@ -2720,14 +2816,26 @@ 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, KeywordSearchable): + FIELDS = [ + "keywords", + "medianame", + "mediadescription", + "medialink", + "mediafile", + ] + FK_FIELDS = [("mediatype", "mediatype")] + WEIGHT_LOOKUP = { + "keywords": "A", + "medianame": "A", + "mediadescription": "B", + "medialink": "B", + "mediafile": "B", + "mediatype": "C", + } + SORT_FIELD = "medianame" - -class Media(Reviewable, Queryable, Record, ModeratedModel): mediaid = models.AutoField(db_column="mediaid", primary_key=True) mediatype = models.ForeignKey( LookupMediaType, @@ -2791,40 +2899,30 @@ 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", - } + if fields is None: + fields = cls.FIELDS + if fk_fields is None: + fk_fields = cls.FK_FIELDS - sort_field = "medianame" + weight_lookup = cls.WEIGHT_LOOKUP + sort_field = cls.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.""" + return list_queryable_fields(cls) - # 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 394ddab7..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 ### @@ -468,6 +486,23 @@ 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", + "Keywords", + "Planning Unit", + "Primary Habitat", + "Tribe", + "Alternate Name", + ] + self.assertEqual(search_fields, expected_fields) + # Resources class ResourcesTest(ITKSearchTest): @@ -518,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 ### @@ -590,6 +642,20 @@ 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", + "Keywords", + "Broad Species Group", + "Alt Name", + ] + self.assertEqual(search_fields, expected_fields) + # ResourcesActivityEvents ('Activities') class ResourcesActivityEventsTest(ITKSearchTest): @@ -617,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 @@ -662,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() @@ -687,6 +771,30 @@ 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 = [ + "Keywords", + "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): @@ -712,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) @@ -749,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 @@ -825,6 +951,27 @@ 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 = [ + "Keywords", + "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): @@ -852,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 @@ -860,6 +1024,19 @@ 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 = [ + "Keywords", + "Name", + "Description", + "Historic Location", + "File", + "Type", + ] + self.assertEqual(search_fields, expected_fields) + # MediaBulkUpload class MediaBulkUploadTest(ITKSearchTest):