diff --git a/ifcbdb/common/auth.py b/ifcbdb/common/auth.py index 2767875e..0eada01e 100644 --- a/ifcbdb/common/auth.py +++ b/ifcbdb/common/auth.py @@ -1,4 +1,6 @@ from django.contrib.auth.models import User, Group +from dashboard.models import TeamUser +from .constants import TeamRoles # At the moment, this is simply a wrapper around the superadmin flag. In the future, this, and possibly other # methods, will be used to determine access rules based on which teams a user is associated with and what @@ -12,7 +14,40 @@ def is_admin(user): # This one is also just a wrapper around the staff flag. This is likely what will be used to determine if a user # has access to things "quickly" without having to check through associated teams and roles on those records def is_staff(user): + if not user.is_authenticated: + return False + if not user.is_staff: return False return user.is_staff + + +def can_manage_teams(user): + if not user.is_authenticated: + return False + + if user.is_superuser or user.is_staff: + return True + + # Team captains have limited access to the admin to manage their own teams + is_team_captain = TeamUser.objects \ + .filter(user=user) \ + .filter(role_id=TeamRoles.CAPTAIN.value) \ + .exists() + if is_team_captain: + return True + + return False + +def can_access_settings(user): + if not user.is_authenticated: + return False + + if user.is_superuser or user.is_staff: + return True + + if can_manage_teams(user): + return True + + return False diff --git a/ifcbdb/dashboard/migrations/0047_team_default_dataset.py b/ifcbdb/dashboard/migrations/0047_team_default_dataset.py new file mode 100644 index 00000000..a4ac32d5 --- /dev/null +++ b/ifcbdb/dashboard/migrations/0047_team_default_dataset.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.21 on 2025-08-24 21:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0046_auto_20250721_2039'), + ] + + operations = [ + migrations.AddField( + model_name='team', + name='default_dataset', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dashboard.dataset'), + ), + ] diff --git a/ifcbdb/dashboard/models.py b/ifcbdb/dashboard/models.py index 48965e97..90f20a3c 100644 --- a/ifcbdb/dashboard/models.py +++ b/ifcbdb/dashboard/models.py @@ -928,10 +928,14 @@ class AppSettings(models.Model): class Team(models.Model): timestamp = models.DateTimeField(auto_now_add=True) name = models.CharField(max_length=50, blank=False, null=False) + default_dataset = models.ForeignKey(Dataset, null=True, blank=True, on_delete=models.SET_NULL) users = models.ManyToManyField(User, through='TeamUser', related_name='teams') datasets = models.ManyToManyField(Dataset, through='TeamDataset', related_name='teams') + def __str__(self): + return self.name + class TeamRole(models.Model): name = models.CharField(max_length=50, blank=False, null=False) diff --git a/ifcbdb/dashboard/templatetags/nav.py b/ifcbdb/dashboard/templatetags/nav.py index 4bf796e2..fa186e37 100644 --- a/ifcbdb/dashboard/templatetags/nav.py +++ b/ifcbdb/dashboard/templatetags/nav.py @@ -5,6 +5,7 @@ from dashboard.models import Dataset, Instrument, Tag, bin_query, AppSettings, \ DEFAULT_LATITUDE, DEFAULT_LONGITUDE, DEFAULT_ZOOM_LEVEL +from common import auth register = template.Library() @@ -20,6 +21,10 @@ def app_settings(): return mark_safe(settings) +@register.simple_tag(takes_context=False) +def can_access_settings(user): + return auth.can_access_settings(user) + @register.inclusion_tag('dashboard/_dataset_switcher.html') def dataset_switcher(): datasets = Dataset.objects.all() @@ -41,7 +46,7 @@ def dataset_nav(): @register.inclusion_tag("dashboard/_timeline-filters.html", takes_context=True) def timeline_filters(context): return { - } +} @register.inclusion_tag("dashboard/_comments-nav.html", takes_context=True) diff --git a/ifcbdb/secure/forms.py b/ifcbdb/secure/forms.py index eef67af3..c2a26383 100644 --- a/ifcbdb/secure/forms.py +++ b/ifcbdb/secure/forms.py @@ -27,6 +27,8 @@ class DatasetForm(forms.ModelForm): longitude = forms.FloatField(required=False, widget=forms.TextInput( attrs={"class": "form-control form-control-sm", "placeholder": "Longitude"} )) + team = forms.ModelChoiceField(queryset=Team.objects.all(), required=False, + widget=forms.Select(attrs={"class": "form-control form-control-sm"})) class Meta: model = Dataset @@ -40,7 +42,7 @@ class Meta: "funding": forms.TextInput(attrs={"class": "form-control form-control-sm", "placeholder": ""}), "depth": forms.TextInput(attrs={"class": "form-control form-control-sm", "placeholder": "Depth"}), "is_active": forms.CheckboxInput(attrs={"class": "custom-control-input"}), - "is_private": forms.CheckboxInput(attrs={"class": "custom-control-input"}) + "is_private": forms.CheckboxInput(attrs={"class": "custom-control-input"}), } def clean_doi(self): @@ -65,6 +67,10 @@ def __init__(self, *args, **kwargs): self.fields["latitude"].initial = instance.location.y self.fields["longitude"].initial = instance.location.x + team_dataset = TeamDataset.objects.filter(dataset=instance).first() + if team_dataset is not None: + self.fields["team"].initial = team_dataset.team + if waffle.switch_is_active(Features.PRIVATE_DATASETS): self.fields["is_private"].widget = forms.HiddenInput() @@ -277,12 +283,20 @@ class Meta: class TeamForm(forms.ModelForm): - assigned_dataset_ids = forms.CharField(required=False, max_length=1000, widget=forms.HiddenInput()) assigned_users_json = forms.CharField(required=False, widget=forms.HiddenInput()) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # New teams, which can only be created by a superadmin, can use any dataset that is not already associated with + # another team. On edits, the only allowed values are those already assigned to this team + if self.instance.pk: + dataset_choices = Dataset.objects.filter(teamdataset__team=self.instance) + else: + dataset_choices = Dataset.objects.filter(teamdataset__isnull=True) + + self.fields["default_dataset"].queryset = dataset_choices + def clean_name(self): name = self.cleaned_data.get("name") @@ -294,8 +308,9 @@ def clean_name(self): class Meta: model = Team - fields = ["id", "name", ] + fields = ["id", "name", "default_dataset", ] widgets = { "name": forms.TextInput(attrs={"class": "form-control form-control-sm", "placeholder": "Name"}), + "default_dataset": forms.Select(attrs={"class": "form-control form-control-sm"}), } diff --git a/ifcbdb/secure/views.py b/ifcbdb/secure/views.py index ef29ffd2..5cffae47 100644 --- a/ifcbdb/secure/views.py +++ b/ifcbdb/secure/views.py @@ -13,7 +13,7 @@ TeamUser, TeamDataset, TeamRole from .forms import DatasetForm, InstrumentForm, DirectoryForm, MetadataUploadForm, AppSettingsForm, UserForm, TeamForm from common import auth -from common.constants import Features +from common.constants import Features, TeamRoles from django.core.cache import cache from celery.result import AsyncResult @@ -23,12 +23,16 @@ @login_required def index(request): - # The settings page is restricted to super admins and staff, the latter of which is what will be used to determine - # if the given user has access to something they can manage (based on their associated teams and roles) - if not auth.is_admin(request.user) and not auth.is_staff(request.user): - return redirect(reverse("secure:index")) + if not auth.can_access_settings(request.user): + return redirect("/") + + can_manage_teams = auth.can_manage_teams(request.user) + has_settings_to_manage = request.user.is_superuser or can_manage_teams - return render(request, 'secure/index.html') + return render(request, 'secure/index.html', { + "has_settings_to_manage": has_settings_to_manage, + "can_manage_teams": can_manage_teams, + }) @login_required @@ -77,7 +81,7 @@ def user_management(request): @login_required @waffle_switch('Teams') def team_management(request): - if not auth.is_admin(request.user): + if not auth.can_manage_teams(request.user): return redirect(reverse("secure:index")) return render(request, 'secure/team-management.html', { @@ -97,12 +101,22 @@ def dt_datasets(request): @waffle_switch('Teams') def dt_teams(request): - if not auth.is_admin(request.user): - return HttpResponseForbidden() + if not auth.can_manage_teams(request.user): + return redirect(reverse("secure:index")) teams = Team.objects.all() \ .annotate(user_count=Count("users", distinct=True)) \ - .annotate(dataset_count=Count("datasets", distinct=True)) #\ + .annotate(dataset_count=Count("datasets", distinct=True)) + + # Limit teams list if not a super user + if not auth.is_admin(request.user): + allowed_team_ids = TeamUser.objects \ + .filter(user=request.user) \ + .filter(role_id=TeamRoles.CAPTAIN.value) \ + .values_list("team_id", flat=True) + print(allowed_team_ids) + + teams = teams.filter(id__in=allowed_team_ids) return JsonResponse({ "data": [ @@ -146,6 +160,30 @@ def edit_dataset(request, id): if form.is_valid(): instance = form.save() + existing = TeamDataset.objects.filter(dataset_id=dataset.id).first() + team = form.cleaned_data.get("team") + original_team = existing.team if existing else None + is_team_removed = False + + # Save the associated team, if any + if team is None and existing is not None: + is_team_removed = True + existing.delete() + + if team is not None and existing is None: + TeamDataset.objects.create(team=team, dataset=instance) + + if team is not None and existing is not None and existing.team != team: + is_team_removed = True + existing.team = team + existing.save() + + # If a team was removed (or changed to something else) but it was the default dataset for that team, + # clear the value + if is_team_removed and original_team is not None and original_team.default_dataset == instance: + original_team.default_dataset = None + original_team.save() + status = "created" if id == 0 else "updated" return redirect(reverse("secure:edit-dataset", kwargs={"id": instance.id}) + "?status=" + status) else: @@ -295,16 +333,36 @@ def edit_user(request, id): @login_required def edit_team(request, id): - if not auth.is_admin(request.user): + if not auth.can_manage_teams(request.user): return redirect(reverse("secure:index")) team = get_object_or_404(Team, pk=id) if int(id) > 0 else Team() + is_new = team.pk is None + + # Non-superadmins (essentially team captains) can only manage their own teams + if not auth.is_admin(request.user): + is_team_captain = TeamUser.objects \ + .filter(team=team) \ + .filter(user=request.user) \ + .filter(role_id=TeamRoles.CAPTAIN.value) \ + .exists() + if not is_team_captain: + return redirect(reverse("secure:team-management")) if request.POST: form = TeamForm(request.POST, instance=team) if form.is_valid(): - instance = form.save(commit=False) - instance.save() + instance = form.save() + + # If this is a new team, and a default dataset is selected, make sure to associate it with + # the team. The only allowed values for team should already be datasets not already associated + # with any other team + if is_new and instance.default_dataset is not None: + # Datasets can only be associated with a single dataset right now, even though it's a many-to-many + # relationship that could support more. Because of this, make sure that the dataset selected is + # not already associated with another team + if not TeamDataset.objects.filter(dataset=instance.default_dataset).exists(): + TeamDataset.objects.create(team=instance, dataset=instance.default_dataset) assigned_users_json = form.cleaned_data.get("assigned_users_json") assigned_users = json.loads(assigned_users_json) @@ -332,37 +390,10 @@ def edit_team(request, id): # Remove any user relationships that have been unassigned TeamUser.objects.filter(team=instance).exclude(user_id__in=assigned_user_ids).delete() - # Update assigned datasets - assigned_dataset_ids = request.POST.get("assigned_dataset_ids") - assigned_dataset_ids = list(map(int, assigned_dataset_ids.split(","))) if assigned_dataset_ids else [] - - # Remove any dataset relationships that have been unassigned - TeamDataset.objects.filter(team=instance).exclude(dataset_id__in=assigned_dataset_ids).delete() - - # Add any new dataset relationships - existing_ids = list(TeamDataset.objects.filter(team=instance).values_list("dataset_id", flat=True)) - ids_to_add = set(assigned_dataset_ids) - set(existing_ids) - for id in ids_to_add: - team_dataset = TeamDataset() - team_dataset.team = instance - team_dataset.dataset_id = id - team_dataset.save() - return redirect(reverse("secure:team-management")) else: form = TeamForm(instance=team) - datasets = Dataset.objects.all().order_by("name") - - if team.pk: - assigned_dataset_ids = list(TeamDataset.objects.filter(team=team).values_list("dataset_id", flat=True)) - - assigned_datasets = datasets.filter(id__in=assigned_dataset_ids) - available_datasets = datasets.exclude(id__in=assigned_dataset_ids) - else: - assigned_datasets = [] - available_datasets = datasets - team_users = TeamUser.objects \ .filter(team=team) \ .select_related("user") \ @@ -383,15 +414,25 @@ def edit_team(request, id): role_options = TeamRole.objects.all() + assigned_team_datasets = TeamDataset.objects \ + .select_related("dataset") \ + .filter(team=team).order_by("dataset__name") \ + .order_by("dataset__name") + assigned_datasets_json = json.dumps([ + { + "name": team_dataset.dataset.name + } + for team_dataset in assigned_team_datasets + ]) + return render(request, "secure/edit-team.html", { "team": team, "form": form, - "assigned_datasets": assigned_datasets, - "available_datasets": available_datasets, "is_admin": auth.is_admin(request.user), "all_users": all_users, "assigned_users_json": assigned_users_json, "role_options": role_options, + "assigned_datasets_json": assigned_datasets_json, }) diff --git a/ifcbdb/templates/base.html b/ifcbdb/templates/base.html index 8868e97a..b25cd0d1 100644 --- a/ifcbdb/templates/base.html +++ b/ifcbdb/templates/base.html @@ -45,7 +45,8 @@