Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions ifcbdb/dashboard/migrations/0053_reserveddatasetname.py
Original file line number Diff line number Diff line change
@@ -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"],
},
),
]
9 changes: 9 additions & 0 deletions ifcbdb/dashboard/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
81 changes: 70 additions & 11 deletions ifcbdb/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 *

Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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"))
Expand All @@ -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")

Expand All @@ -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"))
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion ifcbdb/secure/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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")

Expand Down
62 changes: 45 additions & 17 deletions ifcbdb/templates/secure/edit-dataset.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,15 @@
</div>
<div class="form-row">
<div class="form-group col">
<input type="hidden" id="original-name" value="{{ form.name.value }}" />
<label for="id_name">Name</label>
{{ form.name }}
{% if form.name.errors %}<span class="text-danger">{{ form.name.errors.as_text }}</span>{% endif %}
<div class="mt-2" id="id_name_message" data-default-href="/timeline?dataset=" >
The URL to this dataset is: <span><a href="/timeline?dataset={{ form.name.value }}">/timeline?dataset=<strong>{{ form.name.value }}</strong></a><span>
<div class="mt-2" id="id_name_message" data-default-href="/timeline?dataset=" ></div>
<div class="mt-2 alert alert-warning d-none" id="rename-warning">
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.
</div>
</div>
</div>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <strong>will be: </strong>';
message += $name_message_el.data('default-href');
message += '<strong>' + name +'</strong>';
$name_message_el.html(message);

})

$('#sync-button').click(function() {
var payload = {
Expand Down Expand Up @@ -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 ? "<strong>will be</strong>" : "is";
const text = isNew || hasChanged
? `${defaultUrl}<strong>${name}</strong>`
: `<a href="${defaultUrl}${name}">/timeline?dataset=<strong>${name}</strong></a>`;

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();
});
</script>

{% endblock %}