Skip to content

Commit

Permalink
global assessment search (#1143)
Browse files Browse the repository at this point in the history
* start search form

* basic search

* continue

* move url

* write tests

* add paginator

* add paginator to search

* search only published labels

* add basic order by

* add identifiers

* add unpublished icon to unpublished labels

* add boolean public property

* add visual caption

* reduce query count

* add data pivot search

* fix confusing logic

* add loading icon, label tweaks

* lint

---------

Co-authored-by: casey1173 <[email protected]>
  • Loading branch information
shapiromatron and caseyhans authored Jan 11, 2025
1 parent ed2fc4c commit 34a73ab
Show file tree
Hide file tree
Showing 15 changed files with 569 additions and 8 deletions.
88 changes: 88 additions & 0 deletions hawc/apps/assessment/actions/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q, QuerySet
from django.db.models.base import ModelBase

from ...myuser.models import HAWCUser
from ...study.models import Study
from ..models import Assessment, Label, LabeledItem


def search_studies(
query: str,
all_public: bool = False,
public: QuerySet[Assessment] | None = None,
all_internal: bool = False,
internal: QuerySet[Assessment] | None = None,
user: HAWCUser | None = None,
) -> QuerySet[Study]:
filters = Q()

if all_public or public:
filters1 = dict(
full_citation__icontains=query,
published=True,
assessment__public_on__isnull=False,
assessment__hide_from_public_page=False,
)
if not all_public and public:
filters1.update(assessment__in=public)
filters |= Q(**filters1)

if user and (all_internal or internal):
internal_assessments = Assessment.objects.all().user_can_view(user)
if not all_internal and internal:
internal_assessments = internal_assessments.filter(id__in=internal)
filters2 = dict(
full_citation__icontains=query,
assessment__in=internal_assessments,
)
filters |= Q(**filters2)

if not bool(filters):
return Study.objects.none()

return Study.objects.filter(filters)


def search_visuals(
model_cls: ModelBase,
query: str,
all_public: bool = False,
public: QuerySet[Assessment] | None = None,
all_internal: bool = False,
internal: QuerySet[Assessment] | None = None,
user: HAWCUser | None = None,
) -> QuerySet:
filters = Q()

ct = ContentType.objects.get_for_model(model_cls)

if all_public or public:
filters1 = dict(
published=True,
assessment__public_on__isnull=False,
assessment__hide_from_public_page=False,
)
published_labeled_items = LabeledItem.objects.filter(
label__in=Label.objects.filter(name__icontains=query, published=True), content_type=ct
).values_list("object_id", flat=True)
if not all_public and public:
filters1.update(assessment__in=public)
filters |= (Q(title__icontains=query) | Q(id__in=published_labeled_items)) & Q(**filters1)

if user and (all_internal or internal):
internal_assessments = Assessment.objects.all().user_can_view(user)
if not all_internal and internal:
internal_assessments = internal_assessments.filter(id__in=internal)
filters2 = dict(
assessment__in=internal_assessments,
)
labeled_items = LabeledItem.objects.filter(
label__in=Label.objects.filter(name__icontains=query), content_type=ct
).values_list("object_id", flat=True)
filters |= (Q(title__icontains=query) | Q(id__in=labeled_items)) & Q(**filters2)

if not bool(filters):
return model_cls.objects.none()

return model_cls.objects.filter(filters)
143 changes: 143 additions & 0 deletions hawc/apps/assessment/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.core.mail import mail_admins
from django.db import transaction
from django.db.models import QuerySet
from django.db.models.base import ModelBase
from django.urls import reverse, reverse_lazy
from django.utils import timezone

Expand All @@ -30,8 +31,10 @@
from ..common.widgets import DateCheckboxInput
from ..myuser.autocomplete import UserAutocomplete
from ..study.autocomplete import StudyAutocomplete
from ..summary import models as summary_models
from ..vocab.constants import VocabularyNamespace
from . import autocomplete, constants, models
from .actions import search


class AssessmentForm(forms.ModelForm):
Expand Down Expand Up @@ -560,6 +563,146 @@ def clean(self):
return self.cleaned_data


class SearchForm(forms.Form):
all_public = forms.BooleanField(required=False, initial=True, label="All public assessments")
public = forms.ModelMultipleChoiceField(
queryset=models.Assessment.objects.all(),
required=False,
label="Public Assessments",
)
all_internal = forms.BooleanField(
required=False, initial=True, label="All internal assessments"
)
internal = forms.ModelMultipleChoiceField(
queryset=models.Assessment.objects.all(), required=False, label="Internal assessments"
)
type = forms.ChoiceField(
required=True,
label="Search for",
choices=(
("study", "Studies"),
("visual", "Visuals"),
("data-pivot", "Data Pivots"),
),
)
order_by = forms.ChoiceField(
required=True,
initial="-last_updated",
choices=(
("name", "↑ Title"),
("-name", "↓ Title"),
("last_updated", "↑ Last Updated"),
("-last_updated", "↓ Last Updated"),
),
)
query = forms.CharField(max_length=128, required=False)

order_by_override = {
("visual", "name"): "title",
("visual", "-name"): "-title",
("data-pivot", "name"): "title",
("data-pivot", "-name"): "-title",
("study", "name"): "short_citation",
("study", "-name"): "-short_citation",
}
model_class: dict[str, ModelBase] = {
"visual": summary_models.Visual,
"data-pivot": summary_models.DataPivotQuery,
}

def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
self.fields["public"].queryset = self.fields["public"].queryset.public().order_by("name")
if self.user.is_anonymous:
self.fields.pop("all_internal")
self.fields.pop("internal")
else:
self.fields["internal"].queryset = (
self.fields["internal"].queryset.user_can_view(self.user).order_by("name")
)

@property
def helper(self):
helper = BaseFormHelper(self)
helper.form_method = "GET"
helper.form_class = "p-3"

internal_fields = tuple() if self.user.is_anonymous else ("all_internal", "internal")

helper.layout = cfl.Layout(
cfl.Row(
cfl.Column("query", css_class="col-6"),
cfl.Column("type", css_class="col-3"),
cfl.Column("order_by", css_class="col-3"),
),
cfl.Fieldset(
"Assessment Search Options",
cfl.Row(
cfl.Column("all_public", "public"),
cfl.Column(*internal_fields),
),
),
cfl.Row(
cfl.Column(
cfl.Submit("search", "Search", css_class="btn-block"),
cfl.HTML(
"""<i class="fa fa-spinner fa-spin htmx-indicator align-items-center d-flex ml-2" id="spinner" aria-hidden="true"></i>"""
),
css_class="col-md-5 offset-md-4",
),
),
)
helper.attrs.update(
**{
"hx-get": ".",
"hx-target": "#results",
"hx-swap": "outerHTML",
"hx-select": "#results",
"hx-trigger": "submit",
"hx-push-url": "true",
"hx-indicator": "#spinner",
}
)
return helper

def search(self):
data = self.cleaned_data
order_by = self.order_by_override.get((data["type"], data["order_by"]), data["order_by"])
match data["type"]:
case "study":
return (
search.search_studies(
query=data["query"],
all_public=data["all_public"],
public=data["public"],
all_internal=data.get("all_internal", False),
internal=data.get("internal", None),
user=self.user if self.user.is_authenticated else None,
)
.select_related("assessment")
.prefetch_related("identifiers")
.order_by(order_by)
)
case "visual" | "data-pivot":
return (
search.search_visuals(
model_cls=self.model_class[data["type"]],
query=data["query"],
all_public=data["all_public"],
public=data["public"],
all_internal=data.get("all_internal", False),
internal=data.get("internal", None),
user=self.user if self.user.is_authenticated else None,
)
.select_related("assessment")
.prefetch_related("labels__label")
.order_by(order_by)
)
case _:
raise ValueError("Unknown Type")


class DatasetForm(forms.ModelForm):
revision_version = forms.IntegerField(
disabled=True,
Expand Down
8 changes: 6 additions & 2 deletions hawc/apps/assessment/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,10 @@ def _has_data(self, app: str, model: str, filter: str = "study__assessment") ->
"""
return apps.get_model(app, model).objects.filter(**{filter: self}).count() > 0

@property
def is_public(self) -> bool:
return self.public_on is not None

@property
def has_lit_data(self) -> bool:
return self._has_data("lit", "Reference", filter="assessment")
Expand Down Expand Up @@ -1007,9 +1011,9 @@ def __str__(self) -> str:
return self.name

def user_can_view(self, user) -> bool:
return (
return self.assessment.user_can_edit_object(user) or (
self.published and self.assessment.user_can_view_object(user)
) or self.assessment.user_can_edit_object(user)
)

def get_absolute_url(self) -> str:
return reverse("assessment:dataset_detail", args=(self.id,))
Expand Down
2 changes: 1 addition & 1 deletion hawc/apps/assessment/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def get(cls, assessment) -> typing.Self:

def get_perms() -> typing.Self:
return cls(
public=(assessment.public_on is not None),
public=(assessment.is_public),
editable=assessment.editable,
project_manager={user.id for user in assessment.project_manager.all()},
team_members={user.id for user in assessment.team_members.all()},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% if anchor_tag %}
<a class="{% if not big %} tny {% endif %} {{extra_classes}} label m-1" href="{{label.get_labelled_items_url}}" style="background-color: {{label.color}}; color: {{label.text_color}};" title="{{label.description}}">{{label.name}}</a>
<a class="{% if not big %} tny {% endif %} {{extra_classes}} label m-1" href="{{label.get_labelled_items_url}}" style="background-color: {{label.color}}; color: {{label.text_color}};" title="{{label.description}}">{% if not label.published %}<i class="fa fa-eye-slash mr-1" title="Unpublished label (not be visible to the public)" aria-hidden="true"></i>{% endif %}{{label.name}}</a>
{% else %}
<div class="{% if not big %} tny {% endif %} {{extra_classes}} label m-1" label_url="{{label.get_labelled_items_url}}" style="background-color: {{label.color}}; color: {{label.text_color}};" title="{{label.description}}">{{label.name}}</div>
<div class="{% if not big %} tny {% endif %} {{extra_classes}} label m-1" label_url="{{label.get_labelled_items_url}}" style="background-color: {{label.color}}; color: {{label.text_color}};" title="{{label.description}}">{% if not label.published %}<i class="fa fa-eye-slash mr-1" title="Unpublished label (not be visible to the public)" aria-hidden="true"></i>{% endif %}{{label.name}}</div>
{% endif %}
Loading

0 comments on commit 34a73ab

Please sign in to comment.