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):