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..a110e08a 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,15 @@ 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).""" + 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 +96,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 +252,42 @@ def filter_parameters_bin_query(method): return bin_qs +def check_for_dataset_redirect(request, from_querystring=True): + """ + 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 + """ + + # 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 None: + return None + + if from_querystring: + params = request.GET.copy() + params["dataset"] = reserved.dataset.name + + url = request.path + "?" + params.urlencode() + else: + kwargs = dict(request.resolver_match.kwargs) + kwargs["dataset_name"] = reserved.dataset.name + + url = reverse(request.resolver_match.url_name, kwargs=kwargs) + + return HttpResponsePermanentRedirect(url) + 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 +349,9 @@ def timeline_page(request, team_name=None): def bin_page(request, team_name=None): + if redirect_response := check_for_dataset_redirect(request): + 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 +372,9 @@ def bin_page(request, team_name=None): def image_page(request, team_name=None): + if redirect_response := check_for_dataset_redirect(request): + return redirect_response + bin_id = request.GET.get("bin") image_id = request.GET.get("image") @@ -346,6 +397,9 @@ def image_page(request, team_name=None): def comments_page(request): + if redirect_response := check_for_dataset_redirect(request): + 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")) @@ -439,25 +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): - return _details(request, bin_id=bin_id, route="dataset", dataset_name=dataset_name ) + 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): + # 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): - 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): - return _image_details(request, image_id, bin_id, dataset_name) + 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: diff --git a/ifcbdb/secure/views.py b/ifcbdb/secure/views.py index 11c6c668..650c61c3 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,16 @@ 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: + new_dataset_name = form.cleaned_data.get("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") 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