From b9c2ac39f8da7d878b912e08fe839bc6627c80d0 Mon Sep 17 00:00:00 2001 From: Mike Chagnon Date: Thu, 14 May 2026 07:44:27 -0700 Subject: [PATCH 1/3] store previously used dataset names --- .../migrations/0053_reserveddatasetname.py | 40 +++++++ ifcbdb/dashboard/models.py | 9 ++ ifcbdb/dashboard/views.py | 110 +++++++++++++++++- ifcbdb/secure/views.py | 16 ++- 4 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 ifcbdb/dashboard/migrations/0053_reserveddatasetname.py diff --git a/ifcbdb/dashboard/migrations/0053_reserveddatasetname.py b/ifcbdb/dashboard/migrations/0053_reserveddatasetname.py new file mode 100644 index 00000000..1bac6e03 --- /dev/null +++ b/ifcbdb/dashboard/migrations/0053_reserveddatasetname.py @@ -0,0 +1,40 @@ +# Generated manually for ReservedDatasetName model + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("dashboard", "0052_team_short_description"), + ] + + operations = [ + migrations.CreateModel( + name="ReservedDatasetName", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=64, unique=True, db_index=True)), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ( + "dataset", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reserved_names", + to="dashboard.dataset", + ), + ), + ], + options={ + "ordering": ["-timestamp"], + }, + ), + ] diff --git a/ifcbdb/dashboard/models.py b/ifcbdb/dashboard/models.py index 40170396..6aa64fa4 100644 --- a/ifcbdb/dashboard/models.py +++ b/ifcbdb/dashboard/models.py @@ -437,6 +437,15 @@ def team(self): def __str__(self): return self.name + +class ReservedDatasetName(models.Model): + name = models.CharField(max_length=64, unique=True, db_index=True) + dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, related_name="reserved_names") + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-timestamp"] + class DataDirectory(models.Model): # directory types RAW = 'raw' diff --git a/ifcbdb/dashboard/views.py b/ifcbdb/dashboard/views.py index 97aab275..5db28ecd 100644 --- a/ifcbdb/dashboard/views.py +++ b/ifcbdb/dashboard/views.py @@ -13,7 +13,7 @@ from django.shortcuts import render, get_object_or_404, reverse from django.http import \ HttpResponse, FileResponse, Http404, HttpResponseBadRequest, JsonResponse, \ - HttpResponseRedirect, HttpResponseNotFound, StreamingHttpResponse + HttpResponseRedirect, HttpResponsePermanentRedirect, HttpResponseNotFound, StreamingHttpResponse from django.views.decorators.cache import cache_control from django.views.decorators.http import require_POST from django.utils import timezone @@ -25,7 +25,8 @@ from ifcb.data.imageio import format_image from ifcb.data.adc import schema_names -from .models import Dataset, Bin, Instrument, Timeline, bin_query, Tag, Comment, normalize_tag_name, Team, TeamDataset +from .models import Dataset, Bin, Instrument, Timeline, bin_query, Tag, Comment, normalize_tag_name, \ + Team, TeamDataset, ReservedDatasetName from .forms import DatasetSearchForm from common.utilities import * @@ -76,6 +77,16 @@ def datasets(request, team_name=None): "team_panels": team_panels, }) +def resolve_dataset_name(name): + """Get dataset by current or reserved name. Returns (dataset, is_reserved).""" + print("A") + try: + return Dataset.objects.get(name=name), False + except Dataset.DoesNotExist: + reserved = ReservedDatasetName.objects.select_related("dataset").get(name=name) + return reserved.dataset, True + + def bin_in_dataset_or_404(bin, dataset): "bin can be either a Bin instance or a bin pid" "dataset can be either a Dataset instance or a dataset name" @@ -86,8 +97,8 @@ def bin_in_dataset_or_404(bin, dataset): if not dataset: return bin, None try: - dataset = Dataset.objects.get(name=dataset) - except Dataset.DoesNotExist: + dataset, is_reserved = resolve_dataset_name(dataset) + except (Dataset.DoesNotExist, ReservedDatasetName.DoesNotExist): raise Http404(f'No such dataset {dataset}') if dataset in list(bin.datasets.all()): return bin, dataset @@ -242,7 +253,35 @@ def filter_parameters_bin_query(method): return bin_qs +def check_for_dataset_redirect(request): + """ + Checks the 'dataset' querystring parameter to ensure it matches an existing database. First checking + for a dataset by name, then falling back to reserved dataset names. If the dataset is reserved, this + returns a 301 redirect response to transfer the user to the url w/ the correct dataset name + """ + dataset_name = request.GET.get("dataset") + if not dataset_name: + return None + + if Dataset.objects.filter(name=dataset_name).exists(): + return None + + reserved = ReservedDatasetName.objects.select_related("dataset").filter(name=dataset_name).first() + if reserved is not None: + params = request.GET.copy() + params["dataset"] = reserved.dataset.name + + # TODO: Remove debugging + print(f"Directing dataset from {dataset_name} to {reserved.dataset.name}") + + return HttpResponseRedirect(request.path + "?" + params.urlencode()) + + return None + def timeline_page(request, team_name=None): + if redirect_response := check_for_dataset_redirect(request): + return redirect_response + bin_id = request.GET.get("bin") dataset_name = request.GET.get("dataset") tags = request_get_tags(request.GET.get("tags")) @@ -304,6 +343,11 @@ def timeline_page(request, team_name=None): def bin_page(request, team_name=None): + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR bin_page") + return redirect_response + dataset_name = request.GET.get("dataset",None) instrument_number = request_get_instrument(request.GET.get("instrument")) tags = request_get_tags(request.GET.get("tags")) @@ -324,6 +368,11 @@ def bin_page(request, team_name=None): def image_page(request, team_name=None): + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR image_page") + return redirect_response + bin_id = request.GET.get("bin") image_id = request.GET.get("image") @@ -346,6 +395,11 @@ def image_page(request, team_name=None): def comments_page(request): + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR comments_page") + return redirect_response + dataset_name = request.GET.get("dataset") instrument_number = request_get_instrument(request.GET.get("instrument")) tags = request_get_tags(request.GET.get("tags")) @@ -441,16 +495,35 @@ def _image_details(request, image_id, bin_id, dataset_name=None, instrument_numb def legacy_dataset_page(request, dataset_name, bin_id): - return _details(request, bin_id=bin_id, route="dataset", dataset_name=dataset_name ) + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR legacy_dataset_page") + return redirect_response + + return _details(request, bin_id=bin_id, route="dataset", dataset_name=dataset_name) def legacy_dataset_redirect(request, dataset_name): + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR legacy_dataset_redirect") + return redirect_response + return HttpResponseRedirect(reverse("timeline_page") + "?dataset=" + dataset_name) def legacy_bin_page(request, dataset_name, bin_id): - return _details(request, bin_id=bin_id, route="dataset", dataset_name=dataset_name) + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR legacy_bin_page") + return redirect_response + return _details(request, bin_id=bin_id, route="dataset", dataset_name=dataset_name) def legacy_image_page(request, dataset_name, bin_id, image_id): + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR legacy_image_page") + return redirect_response + return _image_details(request, image_id, bin_id, dataset_name) @@ -968,6 +1041,11 @@ def query_timeline(metric, start, end, resolution): # TODO: This is also where page caching could occur... def bin_data(request, bin_id): + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR bin_data") + return redirect_response + dataset_name = request.GET.get("dataset") instrument_number = request_get_instrument(request.GET.get("instrument")) @@ -1038,6 +1116,11 @@ def nearest_bin(request): }) def most_recent_bin(request): + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR most_recent_bin") + return redirect_response + dataset_name = request.GET.get("dataset") _ = get_object_or_404(Dataset, name=dataset_name) @@ -1170,6 +1253,11 @@ def bin_location(request): def filter_options(request): + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR filter_options") + return redirect_response + dataset_name = request.GET.get("dataset") tags = request_get_tags(request.GET.get("tags")) instrument_number = request_get_instrument(request.GET.get("instrument")) @@ -1249,6 +1337,11 @@ def tag_list(request): return JsonResponse({'tags': list(tags)}) def tags(request): + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR tags") + return redirect_response + dataset_name = request.GET.get("dataset") instrument_number = request_get_instrument(request.GET.get("instrument")) if dataset_name is not None: @@ -1334,6 +1427,11 @@ def export_metadata_view(request, dataset_name=None): return response def sync_bin(request): + # TODO: Test + if redirect_response := check_for_dataset_redirect(request): + print("RR sync_bin") + return redirect_response + dataset_name = request.GET.get("dataset") bin_id = request.GET.get('bin') dataset = get_object_or_404(Dataset, name=dataset_name) diff --git a/ifcbdb/secure/views.py b/ifcbdb/secure/views.py index 11c6c668..ad8a15ad 100644 --- a/ifcbdb/secure/views.py +++ b/ifcbdb/secure/views.py @@ -14,7 +14,7 @@ import pandas as pd from dashboard.models import Dataset, Instrument, DataDirectory, Tag, TagEvent, Bin, Comment, AppSettings, Team, \ - TeamUser, TeamDataset, TeamRole, bin_query, bin_management_query + TeamUser, TeamDataset, TeamRole, bin_query, bin_management_query, ReservedDatasetName from .forms import DatasetForm, InstrumentForm, DirectoryForm, MetadataUploadForm, AppSettingsForm, TagForm, \ MergeTagForm, UserForm, TeamForm, BinSearchForm, BinActionForm @@ -183,6 +183,7 @@ def edit_dataset(request, id): status = request.GET.get("status") dataset = get_object_or_404(Dataset, pk=id) if int(id) > 0 else Dataset() + original_dataset_name = dataset.name is_new = dataset.pk is None # Non-superadmins (essentially team captains) can only manage their own teams' datasets. They can create new @@ -205,6 +206,19 @@ def edit_dataset(request, id): if form.is_valid(): instance = form.save() + # Handle reserved dataset names on rename (only for existing datasets) + if not is_new: + # TODO: Still need a warning to the user about effects from renaming + # TODO: Is old name going to be correct? won't the form change the dataset name (same as originalteam issue) + new_dataset_name = form.cleaned_data.get("name") + + print(f"Old: {original_dataset_name}, New: {new_dataset_name}") + if original_dataset_name != new_dataset_name: + # Delete any existing reservation for the new name (allows reclaiming) + ReservedDatasetName.objects.filter(name=new_dataset_name).delete() + # Reserve the old name + ReservedDatasetName.objects.create(name=original_dataset_name, dataset=instance) + existing = TeamDataset.objects.filter(dataset_id=dataset.id).first() team = form.cleaned_data.get("team") From 814d1edcfddb76d6cd384b48e21f567a20b98b7f Mon Sep 17 00:00:00 2001 From: Mike Chagnon Date: Thu, 28 May 2026 22:17:14 -0700 Subject: [PATCH 2/3] redirect from previously used dataset names --- ifcbdb/dashboard/views.py | 93 ++++++++++++--------------------------- 1 file changed, 27 insertions(+), 66 deletions(-) diff --git a/ifcbdb/dashboard/views.py b/ifcbdb/dashboard/views.py index 5db28ecd..a110e08a 100644 --- a/ifcbdb/dashboard/views.py +++ b/ifcbdb/dashboard/views.py @@ -79,7 +79,6 @@ def datasets(request, team_name=None): def resolve_dataset_name(name): """Get dataset by current or reserved name. Returns (dataset, is_reserved).""" - print("A") try: return Dataset.objects.get(name=name), False except Dataset.DoesNotExist: @@ -253,30 +252,37 @@ def filter_parameters_bin_query(method): return bin_qs -def check_for_dataset_redirect(request): +def check_for_dataset_redirect(request, from_querystring=True): """ - Checks the 'dataset' querystring parameter to ensure it matches an existing database. First checking - for a dataset by name, then falling back to reserved dataset names. If the dataset is reserved, this - returns a 301 redirect response to transfer the user to the url w/ the correct dataset name + Checks the dataset name to ensure it matches an active dataset. First checking for a dataset by name, then falling + back to reserved dataset names. If the dataset is reserved, this returns a 301 redirect response to transfer the + user to the url w/ the correct dataset name """ - dataset_name = request.GET.get("dataset") - if not dataset_name: - return None - if Dataset.objects.filter(name=dataset_name).exists(): + # The from_querystring flag determines whether the dataset name should be pulled from the querystring (with the + # name "dataset", or from the route's path as a keyword argument named "dataset_name" + dataset_name = request.GET.get("dataset") if from_querystring \ + else request.resolver_match.kwargs.get("dataset_name") + + if not dataset_name or Dataset.objects.filter(name=dataset_name).exists(): return None reserved = ReservedDatasetName.objects.select_related("dataset").filter(name=dataset_name).first() - if reserved is not None: + if reserved is None: + return None + + if from_querystring: params = request.GET.copy() params["dataset"] = reserved.dataset.name - # TODO: Remove debugging - print(f"Directing dataset from {dataset_name} to {reserved.dataset.name}") + url = request.path + "?" + params.urlencode() + else: + kwargs = dict(request.resolver_match.kwargs) + kwargs["dataset_name"] = reserved.dataset.name - return HttpResponseRedirect(request.path + "?" + params.urlencode()) + url = reverse(request.resolver_match.url_name, kwargs=kwargs) - return None + return HttpResponsePermanentRedirect(url) def timeline_page(request, team_name=None): if redirect_response := check_for_dataset_redirect(request): @@ -343,9 +349,7 @@ def timeline_page(request, team_name=None): def bin_page(request, team_name=None): - # TODO: Test if redirect_response := check_for_dataset_redirect(request): - print("RR bin_page") return redirect_response dataset_name = request.GET.get("dataset",None) @@ -368,9 +372,7 @@ def bin_page(request, team_name=None): def image_page(request, team_name=None): - # TODO: Test if redirect_response := check_for_dataset_redirect(request): - print("RR image_page") return redirect_response bin_id = request.GET.get("bin") @@ -395,9 +397,7 @@ def image_page(request, team_name=None): def comments_page(request): - # TODO: Test if redirect_response := check_for_dataset_redirect(request): - print("RR comments_page") return redirect_response dataset_name = request.GET.get("dataset") @@ -493,44 +493,30 @@ def _image_details(request, image_id, bin_id, dataset_name=None, instrument_numb "sample_type": sample_type, }) - def legacy_dataset_page(request, dataset_name, bin_id): - # TODO: Test - if redirect_response := check_for_dataset_redirect(request): - print("RR legacy_dataset_page") + if redirect_response := check_for_dataset_redirect(request, False): return redirect_response return _details(request, bin_id=bin_id, route="dataset", dataset_name=dataset_name) def legacy_dataset_redirect(request, dataset_name): - # TODO: Test - if redirect_response := check_for_dataset_redirect(request): - print("RR legacy_dataset_redirect") - return redirect_response - + # This method does not directly check the dataset name to see if it was deleted, but the timeline page that this + # redirects will do so, and cover that requirement return HttpResponseRedirect(reverse("timeline_page") + "?dataset=" + dataset_name) -def legacy_bin_page(request, dataset_name, bin_id): - # TODO: Test - if redirect_response := check_for_dataset_redirect(request): - print("RR legacy_bin_page") - return redirect_response - - return _details(request, bin_id=bin_id, route="dataset", dataset_name=dataset_name) +# Deprecated 2026-05-28 - there were no routes or other calls to this method +# def legacy_bin_page(request, dataset_name, bin_id): +# return _details(request, bin_id=bin_id, route="dataset", dataset_name=dataset_name) def legacy_image_page(request, dataset_name, bin_id, image_id): - # TODO: Test - if redirect_response := check_for_dataset_redirect(request): - print("RR legacy_image_page") + if redirect_response := check_for_dataset_redirect(request, False): return redirect_response return _image_details(request, image_id, bin_id, dataset_name) - def legacy_image_page_alt(request, bin_id, image_id): return _image_details(request, image_id, bin_id) - def _details(request, bin_id=None, route=None, dataset_name=None, tags=None, instrument_number=None, cruise=None, bin_reset=False, default_start_date=None, default_end_date=None, sample_type=None): if not bin_id and not dataset_name and not tags and not instrument_number and not cruise and not sample_type: @@ -1041,11 +1027,6 @@ def query_timeline(metric, start, end, resolution): # TODO: This is also where page caching could occur... def bin_data(request, bin_id): - # TODO: Test - if redirect_response := check_for_dataset_redirect(request): - print("RR bin_data") - return redirect_response - dataset_name = request.GET.get("dataset") instrument_number = request_get_instrument(request.GET.get("instrument")) @@ -1116,11 +1097,6 @@ def nearest_bin(request): }) def most_recent_bin(request): - # TODO: Test - if redirect_response := check_for_dataset_redirect(request): - print("RR most_recent_bin") - return redirect_response - dataset_name = request.GET.get("dataset") _ = get_object_or_404(Dataset, name=dataset_name) @@ -1253,11 +1229,6 @@ def bin_location(request): def filter_options(request): - # TODO: Test - if redirect_response := check_for_dataset_redirect(request): - print("RR filter_options") - return redirect_response - dataset_name = request.GET.get("dataset") tags = request_get_tags(request.GET.get("tags")) instrument_number = request_get_instrument(request.GET.get("instrument")) @@ -1337,11 +1308,6 @@ def tag_list(request): return JsonResponse({'tags': list(tags)}) def tags(request): - # TODO: Test - if redirect_response := check_for_dataset_redirect(request): - print("RR tags") - return redirect_response - dataset_name = request.GET.get("dataset") instrument_number = request_get_instrument(request.GET.get("instrument")) if dataset_name is not None: @@ -1427,11 +1393,6 @@ def export_metadata_view(request, dataset_name=None): return response def sync_bin(request): - # TODO: Test - if redirect_response := check_for_dataset_redirect(request): - print("RR sync_bin") - return redirect_response - dataset_name = request.GET.get("dataset") bin_id = request.GET.get('bin') dataset = get_object_or_404(Dataset, name=dataset_name) From 12e789b6e37f46f46fa12d6017ab555df24e3eed Mon Sep 17 00:00:00 2001 From: Mike Chagnon Date: Mon, 1 Jun 2026 10:29:09 -0700 Subject: [PATCH 3/3] improve edit dataset ui --- ifcbdb/secure/views.py | 3 -- ifcbdb/templates/secure/edit-dataset.html | 62 ++++++++++++++++------- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/ifcbdb/secure/views.py b/ifcbdb/secure/views.py index ad8a15ad..650c61c3 100644 --- a/ifcbdb/secure/views.py +++ b/ifcbdb/secure/views.py @@ -208,11 +208,8 @@ def edit_dataset(request, id): # Handle reserved dataset names on rename (only for existing datasets) if not is_new: - # TODO: Still need a warning to the user about effects from renaming - # TODO: Is old name going to be correct? won't the form change the dataset name (same as originalteam issue) new_dataset_name = form.cleaned_data.get("name") - print(f"Old: {original_dataset_name}, New: {new_dataset_name}") if original_dataset_name != new_dataset_name: # Delete any existing reservation for the new name (allows reclaiming) ReservedDatasetName.objects.filter(name=new_dataset_name).delete() diff --git a/ifcbdb/templates/secure/edit-dataset.html b/ifcbdb/templates/secure/edit-dataset.html index ac64d653..c96a4a64 100644 --- a/ifcbdb/templates/secure/edit-dataset.html +++ b/ifcbdb/templates/secure/edit-dataset.html @@ -46,11 +46,15 @@
+ {{ form.name }} {% if form.name.errors %}{{ form.name.errors.as_text }}{% endif %} -
- The URL to this dataset is: /timeline?dataset={{ form.name.value }} +
+
+ Existing links will redirect to the new URL automatically, but only until + the prior name is reclaimed by another dataset. This redirect only applies to web pages, not + API calls.
@@ -180,6 +184,7 @@ var _csrf = "{{ csrf_token }}"; {% if dataset.id > 0 and request.user.is_superuser %} + function dataset_sync_status() { $.getJSON("{% url 'secure:sync_dataset_status' dataset.id %}", function(data) { if(data.state == 'LOCKED') { // job hasn't started yet @@ -225,21 +230,6 @@ } }); } - $('#id_name').keyup(function(){ - let $name_field = $('#id_name'); - let name = $name_field.val() - if(name.indexOf(" ")!=-1){ - name = name.split(" ").join(""); - $name_field.val(name) - } - - let $name_message_el = $('#id_name_message') - let message = 'The URL to this dataset will be: '; - message += $name_message_el.data('default-href'); - message += '' + name +''; - $name_message_el.html(message); - - }) $('#sync-button').click(function() { var payload = { @@ -273,6 +263,44 @@ }); }, 10); {% endif %} + + function updateNameDetails() { + const name = $('#id_name').val(); + const original = $("#original-name").val().toLowerCase(); + const isNew = !original.trim(); + const hasChanged = original && name.toLowerCase() !== original; + const message = $("#id_name_message"); + const defaultUrl = message.data('default-href'); + + // Show a warning only when the url is changing, which does not include if the dataset is new + $("#rename-warning").toggleClass("d-none", !hasChanged); + + // Dynamically change the message so that it's not a link only when the name has changed + const state = isNew || hasChanged ? "will be" : "is"; + const text = isNew || hasChanged + ? `${defaultUrl}${name}` + : `/timeline?dataset=${name}`; + + message.html(`The URL to this dataset ${state}: ${text}`); + } + + document.addEventListener("DOMContentLoaded", function(){ + $('#id_name') + .on('keydown', function(e) { + // Prevent spaces from entering the input to avoid character position issues with the other input handler + if (e.key === ' ') { + e.preventDefault(); + return false; + } + }) + .on('input', function(){ + this.value = this.value.replace(/\s/g, ''); + + updateNameDetails(); + }); + + updateNameDetails(); + }); {% endblock %} \ No newline at end of file