diff --git a/ifcbdb/common/constants.py b/ifcbdb/common/constants.py index 4ddbea93..57967c64 100644 --- a/ifcbdb/common/constants.py +++ b/ifcbdb/common/constants.py @@ -17,4 +17,10 @@ class BinManagementActions(Enum): UNASSIGN_DATASET = "unassign-dataset" # Metadata column names -BIN_ID_COLUMNS = ['id','pid','lid','bin','bin_id','sample','sample_id','filename'] \ No newline at end of file +BIN_ID_COLUMNS = ['id','pid','lid','bin','bin_id','sample','sample_id','filename'] + +class BinManagementDatasetFilters(Enum): + UNASSIGNED = "__unassigned__" + +class BinManagementTeamFilters(Enum): + UNASSIGNED = "__unassigned__" diff --git a/ifcbdb/dashboard/models.py b/ifcbdb/dashboard/models.py index 40170396..7d1ac934 100644 --- a/ifcbdb/dashboard/models.py +++ b/ifcbdb/dashboard/models.py @@ -39,7 +39,7 @@ from .tasks import mosaic_coordinates_task from .mosaic import Mosaic -from common.constants import TeamRoles +from common.constants import TeamRoles, BinManagementDatasetFilters, BinManagementTeamFilters logger = logging.getLogger(__name__) @@ -226,7 +226,10 @@ def bin_query(dataset_name=None, start=None, end=None, tags=[], qs = Timeline(qs).time_range(start, end) if dataset_name: - qs = qs.filter(datasets__name=dataset_name) + if dataset_name == BinManagementDatasetFilters.UNASSIGNED.value: + qs = qs.filter(datasets=None) + else: + qs = qs.filter(datasets__name=dataset_name) if tags is not None: for tag in tags: @@ -241,10 +244,14 @@ def bin_query(dataset_name=None, start=None, end=None, tags=[], if sample_type not in [None, ""]: qs = qs.filter(sample_type__iexact=sample_type) + if team_names is not None and len(team_names) > 0: - team_ids = list(Team.objects.filter(name__in=team_names).values_list("id", flat=True)) + if BinManagementTeamFilters.UNASSIGNED.value in team_names: + qs = qs.filter(team__isnull=True) + else: + team_ids = list(Team.objects.filter(name__in=team_names).values_list("id", flat=True)) - qs = qs.filter(team_id__in=team_ids) + qs = qs.filter(team_id__in=team_ids) return qs diff --git a/ifcbdb/secure/forms.py b/ifcbdb/secure/forms.py index 66a82959..73b9b7af 100644 --- a/ifcbdb/secure/forms.py +++ b/ifcbdb/secure/forms.py @@ -8,7 +8,9 @@ Bin, Team, TeamUser, TeamDataset, \ DEFAULT_LATITUDE, DEFAULT_LONGITUDE, DEFAULT_ZOOM_LEVEL, normalize_tag_name from common import auth -from common.constants import BinManagementActions, TeamRoles +from common.constants import BinManagementActions, TeamRoles, BinManagementDatasetFilters, BinManagementTeamFilters + +from common.constants import BinManagementTeamFilters, BinManagementDatasetFilters MIN_LATITUDE = -90 MAX_LATITUDE = 90 @@ -412,10 +414,8 @@ class BinSearchForm(forms.Form): # just the team that's selected (if enabled). This removes the validation logic on those values, # but that is covered by the validation on the team, since that will restrict all results down # to just bins associated with a team the user has access to - team = forms.ModelChoiceField( + team = forms.CharField( required=False, - queryset=Team.objects.none(), - empty_label=" ", widget=forms.Select(attrs={"class": input_classes})) dataset = forms.CharField( required=False, @@ -434,14 +434,22 @@ class BinSearchForm(forms.Form): widget=forms.Select(attrs={"class": input_classes})) def __init__(self, *args, **kwargs): - user = kwargs.pop("user") if "user" in kwargs else None + self.user = kwargs.pop("user") if "user" in kwargs else None super().__init__(*args, **kwargs) is_teams_enabled = waffle.switch_is_active("Teams") - teams = auth.get_associated_teams(user) + teams = auth.get_associated_teams(self.user) + + # The teams list is based on what the user has access two. Only superadmins are allowed search for bins not + # yet associated with a team + team_choices = [("", " ")] + if auth.is_admin(self.user): + team_choices.append((BinManagementTeamFilters.UNASSIGNED.value, "Unassigned")) + for team in teams: + team_choices.append((team.pk, team.name)) - self.fields["team"].queryset = teams.order_by("name") + self.fields["team"].widget.choices = team_choices self.fields["dataset"].widget.attrs["disabled"] = is_teams_enabled self.fields["instrument"].widget.attrs["disabled"] = is_teams_enabled self.fields["tag"].widget.attrs["disabled"] = is_teams_enabled @@ -455,6 +463,11 @@ def clean(self): start_date = self.cleaned_data.get("start_date") end_date = self.cleaned_data.get("end_date") + # Prevent non-admins from selecting "Unassigned" + team = self.cleaned_data.get("team") + if team == BinManagementTeamFilters.UNASSIGNED.value and not auth.is_admin(self.user): + raise ValidationError("You do not have permission to search for team-less bins") + # Ensure the user has entered at least one piece of criteria to prevent searching through # the entire database of bins values = [value for key, value in cleaned_data.items() if key != "team" and value not in [None, ""]] @@ -472,7 +485,7 @@ def build_dataset_choices(bins): .distinct() \ .values_list("name", flat=True) - return [""] + list(datasets) + return ["", BinManagementDatasetFilters.UNASSIGNED.value] + list(datasets) @staticmethod def build_tag_choices(bins): diff --git a/ifcbdb/secure/views.py b/ifcbdb/secure/views.py index 11c6c668..9ae87343 100644 --- a/ifcbdb/secure/views.py +++ b/ifcbdb/secure/views.py @@ -10,7 +10,6 @@ from django.http import JsonResponse, Http404, HttpResponseForbidden, HttpResponse, StreamingHttpResponse from django.shortcuts import render, get_object_or_404, redirect, reverse - import pandas as pd from dashboard.models import Dataset, Instrument, DataDirectory, Tag, TagEvent, Bin, Comment, AppSettings, Team, \ @@ -20,8 +19,8 @@ from dashboard.accession import export_metadata from common import auth -from common.constants import Features, TeamRoles, BinManagementActions, BIN_ID_COLUMNS - +from common.constants import Features, TeamRoles, BinManagementActions, BinManagementDatasetFilters, \ + BinManagementTeamFilters from django.core.cache import cache from celery.result import AsyncResult @@ -970,15 +969,21 @@ def bin_management_criteria(request): return redirect(reverse("secure:index")) team_id = request.POST.get("team") - team = get_object_or_404(Team, pk=team_id) - # Ensure non-admins have selected a team they are associated with if not auth.is_admin(request.user): - teams = auth.get_associated_teams(request.user) - if not team in teams: + # Only super-admins can search for bins w/o a team + if team_id == BinManagementTeamFilters.UNASSIGNED.value: + return HttpResponseForbidden() + + # Ensure this user has access to the team + matching_teams = auth.get_associated_teams(request.user).filter(id=team_id) + if not matching_teams.exists(): return HttpResponseForbidden() - bins = Bin.objects.filter(team=team) + if team_id == BinManagementTeamFilters.UNASSIGNED.value: + bins = Bin.objects.filter(team__isnull=True) + else: + bins = Bin.objects.filter(team_id=team_id) datasets = BinSearchForm.build_dataset_choices(bins) instruments = BinSearchForm.build_instrument_choices(bins) @@ -1087,7 +1092,7 @@ def bin_management_execute(request): def build_bin_query_from_form_data(user, form): dataset = form.cleaned_data.get("dataset") - team = form.cleaned_data.get("team") + team_value = form.cleaned_data.get("team") start_date = form.cleaned_data.get("start_date") end_date = form.cleaned_data.get("end_date") instrument = form.cleaned_data.get("instrument") @@ -1096,6 +1101,10 @@ def build_bin_query_from_form_data(user, form): tag = form.cleaned_data.get("tag") tags = [tag] if tag else [] + # Pull the selected team from the form data. Only one selection is possible in the UI right now, even though the bin + # query supports a list + team_names = request_get_team_name(team_value) + return bin_management_query( user, start=start_date, @@ -1105,7 +1114,7 @@ def build_bin_query_from_form_data(user, form): tags=tags, cruise=cruise, sample_type=sample_type, - team_names=[team.name] if team is not None else None) + team_names=team_names) def update_skip(bin_qs, is_skipped): total = 0 @@ -1169,4 +1178,16 @@ def request_get_instrument(instrument_string): if i is not None and i: if i.lower().startswith('ifcb'): i = i[4:] - return int(i) \ No newline at end of file + return int(i) + + +def request_get_team_name(team_value: str): + if not team_value: + return None + + if team_value == BinManagementTeamFilters.UNASSIGNED.value: + return [team_value] + + team = Team.objects.filter(pk=int(team_value)).first() + + return [team.name] if team else None diff --git a/ifcbdb/templates/secure/bin-management.html b/ifcbdb/templates/secure/bin-management.html index 15558991..a3893e78 100644 --- a/ifcbdb/templates/secure/bin-management.html +++ b/ifcbdb/templates/secure/bin-management.html @@ -409,7 +409,9 @@