diff --git a/ifcbdb/assets/js/site.js b/ifcbdb/assets/js/site.js index 8a742205..3c5c8d25 100644 --- a/ifcbdb/assets/js/site.js +++ b/ifcbdb/assets/js/site.js @@ -1,26 +1,49 @@ -// TODO: Move default location to settings -var defaultLat = 41.5507768; -var defaultLng = -70.6593102; -var minLatitude = -180; -var minLongitude = -180; -var zoomLevel = 6; -var GPS_PRECISION = 4; -var DEPTH_PRECISION = 1; -var PLOT_X_DEFAULT = "roi_x"; -var PLOT_Y_DEFAULT = "roi_y"; -var MAX_SELECTABLE_IMAGES = 25; -var _binFilterMode = "timeline"; - -$(function(){ - $("#dataset-switcher").change(function(){ +// These values are populated from app settings +let defaultLat = undefined; +let defaultLng = undefined; +let zoomLevel = undefined; + +// Constants +const minLatitude = -180; +const minLongitude = -180; +const GPS_PRECISION = 4; +const DEPTH_PRECISION = 1; +const PLOT_X_DEFAULT = "roi_x"; +const PLOT_Y_DEFAULT = "roi_y"; +const MAX_SELECTABLE_IMAGES = 25; + +let _binFilterMode = "timeline"; + +function initDashboard(appSettings) { + defaultLat = appSettings.default_latitude; + defaultLng = appSettings.default_longitude; + zoomLevel = appSettings.default_zoom_level; + + $('[data-toggle="tooltip"]').tooltip(); + + $('.navbar-toggler').on('click', function () { + $('.animated-burger').toggleClass('open'); + }); + + // hide navbar after a bit of scrolling + $(window).scroll(function (e) { + var scroll = $(window).scrollTop(); + if (scroll >= 150) { + $('.navbar').addClass("navbar-hide"); + } else { + $('.navbar').removeClass("navbar-hide"); + } + }); + + $("#dataset-switcher").change(function () { location.href = "/timeline?dataset=" + $(this).val(); }); - $("#go-to-bin").click(function(){ + $("#go-to-bin").click(function () { goToBin($("#go-to-bid-pid").val()); }); - $("#go-to-bid-pid").keypress(function(e){ + $("#go-to-bid-pid").keypress(function (e) { if (e.which == 13 /* Enter */) { goToBin($(this).val()); } @@ -33,7 +56,7 @@ $(function(){ $('[data-toggle="popover"]').popover('hide'); } }); -}) +} function isKnownLocation(lat, lng) { return parseFloat(lat) >= minLatitude && parseFloat(lng) >= minLongitude; @@ -339,17 +362,6 @@ function changeImage(img, src, blobImg, outlineImg){ }); } -/* Deprecated */ -/* -function buildColorArray(dataPoints, index) { - var colors = $.map(dataPoints, function(){ return "#1f77b4"; }); - if (index >= 0 && index < dataPoints.length) - colors[index] = "#bb0000"; - - return colors; -} -*/ - function highlightSelectedBinByDate() { if (_binTimestamp == null) return; @@ -566,6 +578,7 @@ function isFilteringUsed() { if (_sampleType != "" && _sampleType != "null") return true; } + $(function () { $('#dataset-popover').popover({ container: 'body', diff --git a/ifcbdb/dashboard/migrations/0037_appsettings.py b/ifcbdb/dashboard/migrations/0037_appsettings.py new file mode 100644 index 00000000..fecc98bc --- /dev/null +++ b/ifcbdb/dashboard/migrations/0037_appsettings.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.14 on 2024-11-01 19:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0036_datadirectory_unique_path'), + ] + + operations = [ + migrations.CreateModel( + name='AppSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('default_latitude', models.FloatField(default=41.5507768)), + ('default_longitude', models.FloatField(default=-70.6593102)), + ('default_zoom_level', models.IntegerField(default=6)), + ], + ), + ] diff --git a/ifcbdb/dashboard/models.py b/ifcbdb/dashboard/models.py index f9e79c21..7dbe51ce 100644 --- a/ifcbdb/dashboard/models.py +++ b/ifcbdb/dashboard/models.py @@ -44,6 +44,12 @@ FILL_VALUE = -9999999 SRID = 4326 +# The default latitude and longitude reference original values that were set prior to the ability to customize the +# default values. The location is, roughly, Woods Hole Oceanographic Institution +DEFAULT_LATITUDE = 41.5507768 +DEFAULT_LONGITUDE = -70.6593102 +DEFAULT_ZOOM_LEVEL = 6 + def do_nothing(*args, **kw): pass @@ -905,3 +911,9 @@ def __str__(self): else: return self.content +# settings + +class AppSettings(models.Model): + default_latitude = models.FloatField(blank=False, null=False, default=DEFAULT_LATITUDE) + default_longitude = models.FloatField(blank=False, null=False, default=DEFAULT_LONGITUDE) + default_zoom_level = models.IntegerField(blank=False, null=False, default=DEFAULT_ZOOM_LEVEL) diff --git a/ifcbdb/dashboard/templatetags/nav.py b/ifcbdb/dashboard/templatetags/nav.py index a07d8397..4bf796e2 100644 --- a/ifcbdb/dashboard/templatetags/nav.py +++ b/ifcbdb/dashboard/templatetags/nav.py @@ -1,10 +1,24 @@ +import json from django import template from django.shortcuts import reverse +from django.utils.html import mark_safe -from dashboard.models import Dataset, Instrument, Tag, bin_query +from dashboard.models import Dataset, Instrument, Tag, bin_query, AppSettings, \ + DEFAULT_LATITUDE, DEFAULT_LONGITUDE, DEFAULT_ZOOM_LEVEL register = template.Library() +@register.simple_tag(takes_context=False) +def app_settings(): + app_settings = AppSettings.objects.first() + + settings = json.dumps({ + "default_latitude": app_settings.default_latitude if app_settings else DEFAULT_LATITUDE, + "default_longitude": app_settings.default_longitude if app_settings else DEFAULT_LONGITUDE, + "default_zoom_level": app_settings.default_zoom_level if app_settings else DEFAULT_ZOOM_LEVEL, + }) + + return mark_safe(settings) @register.inclusion_tag('dashboard/_dataset_switcher.html') def dataset_switcher(): diff --git a/ifcbdb/secure/forms.py b/ifcbdb/secure/forms.py index 4a9b0cb6..dbce32c7 100644 --- a/ifcbdb/secure/forms.py +++ b/ifcbdb/secure/forms.py @@ -1,7 +1,20 @@ import re, os from django import forms -from dashboard.models import Dataset, Instrument, DataDirectory +from dashboard.models import Dataset, Instrument, DataDirectory, AppSettings, \ + DEFAULT_LATITUDE, DEFAULT_LONGITUDE, DEFAULT_ZOOM_LEVEL + + +MIN_LATITUDE = -90 +MAX_LATITUDE = 90 +MIN_LONGITUDE = -180 +MAX_LONGITUDE = 180 + +# Leaflet does not limit the zoom level, but appears to start having issues with very large numbers. Here, it's limited +# to 13 because that appears to be the limit of the basemap that's being used. Any higher than that, and it produces +# "map not available" errors +MIN_ZOOM_LEVEL = 0 +MAX_ZOOM_LEVEL = 13 class DatasetForm(forms.ModelForm): @@ -168,3 +181,46 @@ class Meta: class MetadataUploadForm(forms.Form): file = forms.FileField(label="Choose file", widget=forms.ClearableFileInput(attrs={"class": "custom-file-input"})) + + +class AppSettingsForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["default_latitude"].required = True + self.fields["default_longitude"].required = True + self.fields["default_zoom_level"].required = True + + def clean_default_latitude(self): + data = self.cleaned_data.get("default_latitude") + + if data < MIN_LATITUDE or data > MAX_LATITUDE: + raise forms.ValidationError(f"Default Latitude must be between {MIN_LATITUDE} and {MAX_LATITUDE}") + + return data + + def clean_default_longitude(self): + data = self.cleaned_data.get("default_longitude") + + if data < MIN_LONGITUDE or data > MAX_LONGITUDE: + raise forms.ValidationError(f"Default Longitude must be between {MIN_LONGITUDE} and {MAX_LONGITUDE}") + + return data + + def clean_default_zoom_level(self): + data = self.cleaned_data.get("default_zoom_level") + + if data < MIN_ZOOM_LEVEL or data > MAX_ZOOM_LEVEL: + raise forms.ValidationError(f"Default Zoom Level must be between {MIN_ZOOM_LEVEL} and {MAX_ZOOM_LEVEL}") + + return data + + class Meta: + model = AppSettings + fields = ["default_latitude", "default_longitude", "default_zoom_level", ] + widgets = { + "default_latitude": forms.TextInput(attrs={"class": "form-control form-control-sm"}), + "default_longitude": forms.TextInput(attrs={"class": "form-control form-control-sm"}), + "default_zoom_level": forms.TextInput(attrs={"class": "form-control form-control-sm"}), + } diff --git a/ifcbdb/secure/urls.py b/ifcbdb/secure/urls.py index dcf19ca5..18b19271 100644 --- a/ifcbdb/secure/urls.py +++ b/ifcbdb/secure/urls.py @@ -15,6 +15,7 @@ path('upload-metadata', views.upload_metadata, name='upload-metadata'), path('directory-management/', views.directory_management, name='directory-management'), path('edit-directory//', views.edit_directory, name='edit-directory'), + path('app-settings', views.app_settings, name='app-settings'), # Paths used for AJAX requests specifically for returning data formatted for DataTables path('api/dt/datasets', views.dt_datasets, name='datasets_dt'), diff --git a/ifcbdb/secure/views.py b/ifcbdb/secure/views.py index 6abfa92a..c6a13493 100644 --- a/ifcbdb/secure/views.py +++ b/ifcbdb/secure/views.py @@ -1,19 +1,17 @@ from django.contrib.auth.decorators import login_required from django import forms from django.views.decorators.http import require_POST, require_GET -from django.http import JsonResponse, Http404, HttpResponseForbidden +from django.http import JsonResponse, Http404, HttpResponseForbidden, HttpResponse 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 -from .forms import DatasetForm, InstrumentForm, DirectoryForm, MetadataUploadForm +from dashboard.models import Dataset, Instrument, DataDirectory, Tag, TagEvent, Bin, Comment, AppSettings +from .forms import DatasetForm, InstrumentForm, DirectoryForm, MetadataUploadForm, AppSettingsForm from django.core.cache import cache from celery.result import AsyncResult -# TODO: All of these methods need to be locked down properly - @login_required def index(request): @@ -22,6 +20,7 @@ def index(request): }) +@login_required def dataset_management(request): form = DatasetForm() @@ -30,6 +29,7 @@ def dataset_management(request): }) +@login_required def directory_management(request, dataset_id): dataset = get_object_or_404(Dataset, pk=dataset_id) @@ -38,6 +38,7 @@ def directory_management(request, dataset_id): }) +@login_required def instrument_management(request): form = InstrumentForm() @@ -46,6 +47,7 @@ def instrument_management(request): }) +@login_required def dt_datasets(request): datasets = list(Dataset.objects.all().values_list("name", "title", "is_active", "id")) @@ -54,6 +56,7 @@ def dt_datasets(request): }) +@login_required def dt_directories(request, dataset_id): directories = list(DataDirectory.objects.filter(dataset__id=dataset_id) .values_list("path", "kind", "priority", "whitelist", "blacklist", "id")) @@ -63,6 +66,7 @@ def dt_directories(request, dataset_id): }) +@login_required def edit_dataset(request, id): status = request.GET.get("status") @@ -88,6 +92,7 @@ def edit_dataset(request, id): }) +@login_required def edit_directory(request, dataset_id, id): if int(id) > 0: directory = get_object_or_404(DataDirectory, pk=id) @@ -116,6 +121,9 @@ def edit_directory(request, dataset_id, id): @require_POST def delete_directory(request, dataset_id, id): + if not request.user.is_authenticated: + return HttpResponseForbidden() + directory = get_object_or_404(DataDirectory, pk=id) if directory.dataset_id != dataset_id: @@ -125,8 +133,10 @@ def delete_directory(request, dataset_id, id): return JsonResponse({}) - def dt_instruments(request): + if not request.user.is_authenticated: + return HttpResponseForbidden() + instruments = list(Instrument.objects.all().values_list("number", "nickname", "id")) return JsonResponse({ @@ -134,6 +144,7 @@ def dt_instruments(request): }) +@login_required def edit_instrument(request, id): if int(id) > 0: instrument = get_object_or_404(Instrument, pk=id) @@ -161,10 +172,31 @@ def edit_instrument(request, id): "form": form, }) -# TODO: Handle login_required decorator better; currently returns HTML instead of JSON -@require_POST + @login_required +def app_settings(request): + instance = AppSettings.objects.first() or AppSettings() + confirm = False + + if request.POST: + form = AppSettingsForm(request.POST, instance=instance) + if form.is_valid(): + form.save() + confirm = True + else: + form = AppSettingsForm(instance=instance) + + return render(request, "secure/app-settings.html", { + "form": form, + "confirm": confirm, + }) + + +@require_POST def add_tag(request, bin_id): + if not request.user.is_authenticated: + return HttpResponseForbidden() + tag_name = request.POST.get("tag_name", "") bin = get_object_or_404(Bin, pid=bin_id) bin.add_tag(tag_name, user=request.user) @@ -175,8 +207,10 @@ def add_tag(request, bin_id): @require_POST -@login_required def remove_tag(request, bin_id): + if not request.user.is_authenticated: + return HttpResponseForbidden() + tag_name = request.POST.get("tag_name", "") bin = get_object_or_404(Bin, pid=bin_id) bin.delete_tag(tag_name) @@ -187,8 +221,10 @@ def remove_tag(request, bin_id): @require_POST -@login_required def add_comment(request, bin_id): + if not request.user.is_authenticated: + return HttpResponseForbidden() + text = request.POST.get("comment") bin = get_object_or_404(Bin, pid=bin_id) bin.add_comment(text, request.user) @@ -198,13 +234,13 @@ def add_comment(request, bin_id): }) @require_GET -@login_required def edit_comment(request, bin_id): - comment_id = request.GET.get("id") - comment = get_object_or_404(Comment, pk=comment_id) if not request.user.is_staff: return HttpResponseForbidden() + comment_id = request.GET.get("id") + comment = get_object_or_404(Comment, pk=comment_id) + return JsonResponse({ "id": comment.id, "content": comment.content @@ -212,17 +248,16 @@ def edit_comment(request, bin_id): @require_POST -@login_required def update_comment(request, bin_id): + if not request.user.is_staff: + return HttpResponseForbidden() + bin = get_object_or_404(Bin, pid=bin_id) comment_id = request.POST.get("id") content = request.POST.get("content") comment = get_object_or_404(Comment, pk=comment_id) - if not request.user.is_staff: - return HttpResponseForbidden() - comment.content = content comment.save() @@ -233,14 +268,13 @@ def update_comment(request, bin_id): @require_POST -@login_required def delete_comment(request, bin_id): - comment_id = request.POST.get("id") - comment = get_object_or_404(Comment, pk=comment_id) - if not request.user.is_staff: return HttpResponseForbidden() + comment_id = request.POST.get("id") + _ = get_object_or_404(Comment, pk=comment_id) + bin = get_object_or_404(Bin, pid=bin_id) bin.delete_comment(comment_id, request.user) @@ -263,8 +297,10 @@ def get_dataset_sync_task_id(dataset_id): # if there's no task ID the task is just about to start @require_POST -@login_required def sync_dataset(request, dataset_id): + if not request.user.is_authenticated: + return HttpResponseForbidden() + from dashboard.tasks import sync_dataset # params newest_only = request.POST.get('newest_only') == 'true' @@ -283,8 +319,10 @@ def sync_dataset(request, dataset_id): result = AsyncResult(r.task_id) return JsonResponse({ 'state': result.state }) -@login_required def sync_dataset_status(request, dataset_id): + if not request.user.is_authenticated: + return HttpResponseForbidden() + task_id = get_dataset_sync_task_id(dataset_id) if task_id is None: # there's no result, which means either @@ -299,10 +337,12 @@ def sync_dataset_status(request, dataset_id): }) @require_POST -@login_required def sync_cancel(request, dataset_id): + if not request.user.is_authenticated: + return HttpResponseForbidden() + cancel_key = dataset_sync_cancel_key(dataset_id) - added = cache.add(cancel_key,"cancel"); + added = cache.add(cancel_key,"cancel") if not added: return JsonResponse({ 'status': 'already_canceled'}) else: @@ -356,21 +396,25 @@ def upload_metadata(request): 'in_progress': in_progress, }) -@login_required def metadata_upload_status(request): + if not request.user.is_authenticated: + return HttpResponseForbidden() + task_id = cache.get(METADATA_UPLOAD_TASKID_KEY) if task_id is None: return JsonResponse({ 'state': 'PENDING' }) result = AsyncResult(task_id) - info = getattr(result, 'info', ''); + info = getattr(result, 'info', '') return JsonResponse({ 'state': result.state, 'info': info, }) @require_POST -@login_required def metadata_upload_cancel(request): + if not request.user.is_authenticated: + return HttpResponseForbidden() + added = cache.add(METADATA_UPLOAD_CANCEL_KEY, "cancel"); if not added: return JsonResponse({ 'status': 'already_canceled'}) @@ -378,8 +422,10 @@ def metadata_upload_cancel(request): return JsonResponse({ 'status': 'cancelling' }) @require_POST -@login_required def toggle_skip(request): + if not request.user.is_authenticated: + return HttpResponseForbidden() + bin_id = request.POST.get("bin_id") skipped = request.POST.get("skipped") == "true" diff --git a/ifcbdb/templates/base.html b/ifcbdb/templates/base.html index 6df7540b..eaff4fa5 100644 --- a/ifcbdb/templates/base.html +++ b/ifcbdb/templates/base.html @@ -136,29 +136,12 @@ - - {% block scripts %}{% endblock %} diff --git a/ifcbdb/templates/secure/app-settings.html b/ifcbdb/templates/secure/app-settings.html new file mode 100644 index 00000000..429002c8 --- /dev/null +++ b/ifcbdb/templates/secure/app-settings.html @@ -0,0 +1,62 @@ +{% extends 'base.html' %} + +{% block content %} + +
+
+ App Settings +
+
+
+ +{% if form.non_field_errors %} +
+ {% for err in form.non_field_errors %} +

{{ err }}

+ {% endfor %} +
+{% endif %} + +{% if confirm %} +
App Settings Saved Successfully!
+{% endif %} + +
+ {% csrf_token %} + {{ form.id }} + +
+
+ + {{ form.default_latitude }} + {% if form.default_latitude.errors %}{{ form.default_latitude.errors.as_text }}{% endif %} +
+
+
+
+ + {{ form.default_longitude }} + {% if form.default_longitude.errors %}{{ form.default_longitude.errors.as_text }}{% endif %} +
+
+
+
+ + {{ form.default_zoom_level }} + {% if form.default_zoom_level.errors %}{{ form.default_zoom_level.errors.as_text }}{% endif %} +
+
+
+ +
+
+ +
+
+ + + Back to Settings + +
+ +{% endblock %} \ No newline at end of file diff --git a/ifcbdb/templates/secure/index.html b/ifcbdb/templates/secure/index.html index 21a5e71d..4401b8e9 100644 --- a/ifcbdb/templates/secure/index.html +++ b/ifcbdb/templates/secure/index.html @@ -16,6 +16,7 @@
  • Dataset Management
  • Instrument Management
  • Upload Metadata
  • +
  • App Settings
  • {% if request.user.is_superuser %}
  • Administrative Settings
  • {% endif %}