diff --git a/importing/models.py b/importing/models.py
index e22239b0..aeb18cf4 100755
--- a/importing/models.py
+++ b/importing/models.py
@@ -107,6 +107,10 @@ def clean(self) -> None:
if not tz:
raise ValidationError("Station must have a timezone set.")
+ # If the file has changed, we reprocess the data
+ if self.pk and self.rawfile != self.__class__.objects.get(pk=self.pk).rawfile:
+ self.reprocess = True
+
if self.reprocess:
self.status = "N"
self.reprocess = False
diff --git a/importing/urls.py b/importing/urls.py
index b80fba78..b8a2fc59 100644
--- a/importing/urls.py
+++ b/importing/urls.py
@@ -1,9 +1,18 @@
from django.urls import path
-from .views import DataImportDetailView, DataImportListView
+from .views import (
+ DataImportCreateView,
+ DataImportDeleteView,
+ DataImportDetailView,
+ DataImportEditView,
+ DataImportListView,
+)
app_name = "importing"
urlpatterns = [
path("", DataImportListView.as_view(), name="dataimport_list"),
path("/", DataImportDetailView.as_view(), name="dataimport_detail"),
+ path("edit/", DataImportEditView.as_view(), name="dataimport_edit"),
+ path("create/", DataImportCreateView.as_view(), name="dataimport_create"),
+ path("delete/", DataImportDeleteView.as_view(), name="dataimport_delete"),
]
diff --git a/importing/views.py b/importing/views.py
index 2726ffc1..0c733a10 100644
--- a/importing/views.py
+++ b/importing/views.py
@@ -1,4 +1,10 @@
-from management.views import CustomDetailView, CustomTableView
+from management.views import (
+ CustomCreateView,
+ CustomDeleteView,
+ CustomDetailView,
+ CustomEditView,
+ CustomTableView,
+)
from .filters import DataImportFilter
from .models import DataImport
@@ -9,11 +15,33 @@ class DataImportDetailView(CustomDetailView):
"""View to view a data import."""
model = DataImport
- show_list_btn = True
class DataImportListView(CustomTableView):
+ """View to list all data imports."""
+
model = DataImport
table_class = DataImportTable
filterset_class = DataImportFilter
- show_refresh_btn = True
+
+
+class DataImportEditView(CustomEditView):
+ """View to edit a data import."""
+
+ model = DataImport
+ fields = ["visibility", "station", "format", "rawfile", "reprocess", "observations"]
+ foreign_key_fields = ["station", "format"]
+
+
+class DataImportCreateView(CustomCreateView):
+ """View to create a data import."""
+
+ model = DataImport
+ fields = ["visibility", "station", "format", "rawfile", "reprocess", "observations"]
+ foreign_key_fields = ["station", "format"]
+
+
+class DataImportDeleteView(CustomDeleteView):
+ """View to delete a data import."""
+
+ model = DataImport
diff --git a/management/admin.py b/management/admin.py
index 8f347e44..c9294c90 100755
--- a/management/admin.py
+++ b/management/admin.py
@@ -7,6 +7,7 @@
from guardian.shortcuts import get_objects_for_user
from .models import User
+from .permissions import get_queryset
# Set global preferences for the Django admin site
admin.site.site_header = "Paricia Administration"
@@ -65,7 +66,7 @@ def get_queryset(self, request):
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Limit the queryset for foreign key fields."""
if db_field.name in self.foreign_key_fields:
- kwargs["queryset"] = _get_queryset(db_field, request.user)
+ kwargs["queryset"] = get_queryset(db_field, request.user)
if db_field.name == "owner" and not request.user.is_superuser:
kwargs["initial"] = request.user.id
kwargs["disabled"] = True
@@ -80,25 +81,6 @@ def formfield_for_choice_field(self, db_field, request, **kwargs):
return super().formfield_for_choice_field(db_field, request, **kwargs)
-def _get_queryset(db_field, user):
- """Return a queryset based on the permissions of the user.
-
- Returns queryset of public objects and objects that the user has change permisions
- for. For the case of `Station` objects, having the `change` permission is
- necessary to include the object in the queryset - being `Public` is not enough.
-
- """
- app_name = db_field.related_model._meta.app_label
- model_name = db_field.related_model._meta.model_name
- user_objects = get_objects_for_user(user, f"{app_name}.change_{model_name}")
- public_objects = (
- db_field.related_model.objects.none()
- if model_name == "station"
- else db_field.related_model.objects.filter(visibility="public")
- )
- return user_objects | public_objects
-
-
class CustomUserAdmin(UserAdmin):
"""A slightly more restrictive user admin page."""
diff --git a/management/permissions.py b/management/permissions.py
index 51a24a72..21d5313f 100755
--- a/management/permissions.py
+++ b/management/permissions.py
@@ -1,5 +1,7 @@
"""Customised permissions."""
+from django.db import models as model
+from guardian.shortcuts import get_objects_for_user
from rest_framework.permissions import DjangoModelPermissions
@@ -17,3 +19,28 @@ class CustomDjangoModelPermissions(DjangoModelPermissions):
"PATCH": ["%(app_label)s.change_%(model_name)s"],
"DELETE": ["%(app_label)s.delete_%(model_name)s"],
}
+
+
+def get_queryset(db_field: model.Field, user: model.Model) -> model.QuerySet:
+ """Return a queryset based on the permissions of the user.
+
+ Returns queryset of public objects and objects that the user has change permisions
+ for. For the case of `Station` objects, having the `change` permission is
+ necessary to include the object in the queryset - being `Public` is not enough.
+
+ Args:
+ db_field (model.Field): Field to filter.
+ user (model.Model): User to check permissions for.
+
+ Returns:
+ model.QuerySet: Queryset of objects that the user has permissions for.
+ """
+ app_name = db_field.related_model._meta.app_label
+ model_name = db_field.related_model._meta.model_name
+ user_objects = get_objects_for_user(user, f"{app_name}.change_{model_name}")
+ public_objects = (
+ db_field.related_model.objects.none()
+ if model_name == "station"
+ else db_field.related_model.objects.filter(visibility="public")
+ )
+ return user_objects | public_objects
diff --git a/management/tools.py b/management/tools.py
new file mode 100644
index 00000000..44a62923
--- /dev/null
+++ b/management/tools.py
@@ -0,0 +1,41 @@
+from django.contrib.admin.utils import NestedObjects
+from django.db import models
+from django.utils.encoding import force_str
+from django.utils.text import capfirst
+
+
+def get_deleted_objects(
+ objs: list[models.Model],
+) -> tuple[list[str], dict[str, int], list[str]]:
+ """Return information about related objects to be deleted.
+
+ How to do this has been taken from https://stackoverflow.com/a/39533619/3778792
+
+ Args:
+ objs (list[models.Model]): List of objects to be deleted.
+
+ Returns:
+ tuple[list[str], dict[str, int], list[str]]: Tuple containing the following:
+ - List of strings representing the objects to be deleted.
+ - Dictionary containing the count of objects to be deleted for each model.
+ - List of strings representing the objects that are protected from deletion
+ """
+ collector = NestedObjects(using="default")
+ collector.collect(objs)
+
+ def format_callback(obj):
+ opts = obj._meta
+ no_edit_link = f"{capfirst(opts.verbose_name)}: {force_str(obj)}"
+ return no_edit_link
+
+ to_delete = collector.nested(format_callback)
+ protected = [format_callback(obj) for obj in collector.protected]
+ model_count = {
+ model._meta.verbose_name_plural: len(objs)
+ for model, objs in collector.model_objs.items()
+ }
+ if len(to_delete) == 0:
+ to_delete.append("None")
+ if len(protected) == 0:
+ protected.append("None")
+ return to_delete, model_count, protected
diff --git a/management/views.py b/management/views.py
index eeeb1191..f65bce0f 100755
--- a/management/views.py
+++ b/management/views.py
@@ -1,40 +1,19 @@
-from contextlib import suppress
-
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
-from django.db.models import ForeignKey, Model
+from django.db.models import Model
+from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
-from django.views.generic import CreateView, DetailView
+from django.views.generic import CreateView, DeleteView, DetailView
+from django.views.generic.edit import UpdateView
from django_filters.views import FilterView
from django_tables2 import SingleTableMixin
from guardian.shortcuts import get_objects_for_user
from .forms import CustomUserCreationForm
-
-
-def model_to_dict(instance: Model) -> dict:
- """Convert a model instance to a dictionary.
-
- For ForeignKey fields, the related model instance is converted to a string, so that
- a meaningful representation is shown in the dictionary instead of the primary key.
-
- Args:
- instance (Model): Model instance to convert.
-
- Returns:
- dict: Dictionary with the model instance data.
- """
- data = {}
- for field in instance._meta.fields:
- data[field.name] = field.value_from_object(instance)
- if isinstance(field, ForeignKey):
- with suppress(field.related_model.DoesNotExist):
- data[field.name] = str(
- field.related_model.objects.get(pk=data[field.name])
- )
- return data
+from .permissions import get_queryset
+from .tools import get_deleted_objects
class SignUpView(CreateView):
@@ -68,9 +47,7 @@ class CustomDetailView(LoginRequiredMixin, DetailView):
"""
template_name: str = "object_detail.html"
- show_list_btn: bool = False
- show_delete_btn: bool = False
- show_edit_btn: bool = False
+ fields = "__all__"
def get_object(self) -> Model:
obj = get_object_or_404(self.model, pk=self.kwargs["pk"])
@@ -82,17 +59,24 @@ def get_form(self):
class DetailForm(forms.ModelForm):
class Meta:
model = self.model
- fields = "__all__"
+ fields = self.fields
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ for field in self.fields.keys():
+ self.fields[field].widget.attrs["disabled"] = True
+ self.fields[field].widget.attrs["readonly"] = True
- return DetailForm(data=model_to_dict(self.object))
+ return DetailForm(instance=self.object)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["form"] = self.get_form()
context["title"] = self.model_description
- context["delete_url"] = self.delete_url if self.show_delete_btn else None
- context["edit_url"] = self.edit_url if self.show_edit_btn else None
- context["list_url"] = self.list_url if self.show_list_btn else None
+ context["delete_url"] = self.delete_url
+ context["edit_url"] = self.edit_url
+ context["list_url"] = self.list_url
+ context["pk"] = self.object.pk
return context
@property
@@ -154,8 +138,6 @@ class CustomTableView(LoginRequiredMixin, SingleTableMixin, FilterView):
template_name = "table.html"
paginate_by = 10
- show_refresh_btn: bool = False
- show_new_btn: bool = False
def get_queryset(self):
return get_objects_for_user(self.request.user, self.permission_required)
@@ -163,8 +145,8 @@ def get_queryset(self):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = self.title
- context["refresh"] = self.list_url if self.show_refresh_btn else None
- context["new_url"] = self.create_url if self.show_new_btn else None
+ context["list_url"] = self.list_url
+ context["create_url"] = self.create_url
return context
@property
@@ -190,3 +172,258 @@ def list_url(self) -> str:
@property
def create_url(self) -> str:
return f"{self.app_label}:{self.model_name}_create"
+
+
+class CustomEditView(LoginRequiredMixin, UpdateView):
+ """Generic edit view.
+
+ This view is used to edit a model object. The user must have the permission to edit
+ the object, otherwise a 403 error is returned.
+
+ The view includes a form with the object data, and the context includes the title of
+ the view and the URL to the list view. They follow the pattern
+ `app_label:model_name_action`. For example, the list URL for the `DataImport` model
+ would be `importing:dataimport_list`.
+
+ The permissions required to edit the object are `app_label.change_model_name`. For
+ example, the permission required to edit a `DataImport` object would be
+ `importing.change_dataimport`.
+
+ If successful or cancelled, the view redirects to the detail view of the created
+ object.
+
+ Users need to be logged in to access this view.
+
+ Attributes:
+ template_name (str): Template to be used.
+ """
+
+ template_name = "object_edit.html"
+ foreign_key_fields: list[str] = []
+
+ def get_form_class(self) -> forms.BaseModelForm:
+ class CustomCreateForm(forms.ModelForm):
+ foreign_key_fields = self.foreign_key_fields
+
+ class Meta:
+ model = self.model
+ fields = self.fields
+
+ def __init__(self, *args, **kwargs):
+ """Filter the queryset for foreign key fields based on the user.
+
+ Otherwise, the user would see all objects, even those they don't have
+ access to. We need to pop the user from the kwargs, as it's not a valid
+ argument for the form.
+ """
+ user = kwargs.pop("user") if "user" in kwargs else None
+ super().__init__(*args, **kwargs)
+ if user:
+ for field in self._meta.model._meta.fields:
+ if field.name in self.foreign_key_fields:
+ self.fields[field.name].queryset = get_queryset(field, user)
+
+ return CustomCreateForm
+
+ def get_object(self) -> Model:
+ obj = super().get_object()
+ if not self.request.user.has_perm(
+ f"{self.app_label}.change_{self.model_name}", obj
+ ):
+ raise PermissionDenied
+ return obj
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["title"] = self.model_description
+ context["detail_url"] = self.detail_url
+ context["pk"] = self.object.pk
+ return context
+
+ @property
+ def app_label(self) -> str:
+ return self.model._meta.app_label
+
+ @property
+ def model_name(self) -> str:
+ return self.model._meta.model_name
+
+ @property
+ def model_description(self) -> str:
+ return self.model._meta.verbose_name.title()
+
+ @property
+ def success_url(self) -> str:
+ return reverse_lazy(self.detail_url, kwargs={"pk": self.object.pk})
+
+ @property
+ def detail_url(self) -> str:
+ return f"{self.app_label}:{self.model_name}_detail"
+
+ def get_form_kwargs(self):
+ """Add the user to the form kwargs, so we can filter the options."""
+ kwargs = super().get_form_kwargs()
+ kwargs["user"] = self.request.user
+ return kwargs
+
+
+class CustomCreateView(LoginRequiredMixin, CreateView):
+ """Generic create view.
+
+ This view is used to create a new model object. The user must have the permission to
+ create the object, otherwise a 403 error is returned.
+
+ The view includes a form with the object data, and the context includes the title of
+ the view and the URL to the list view. They follow the pattern
+ `app_label:model_name_action`. For example, the list URL for the `DataImport` model
+ would be `importing:dataimport_list`.
+
+ If provided, the `foreign_key_fields` attribute is used to limit the queryset for
+ foreign key fields.
+
+ If successful, the view redirects to the detail view of the created object.
+
+ Users need to be logged in to access this view.
+
+ Attributes:
+ template_name (str): Template to be used.
+ """
+
+ template_name = "object_create.html"
+ foreign_key_fields: list[str] = []
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["title"] = self.model_description
+ context["list_url"] = self.list_url
+ return context
+
+ def get_form_class(self) -> forms.BaseModelForm:
+ class CustomCreateForm(forms.ModelForm):
+ foreign_key_fields = self.foreign_key_fields
+
+ class Meta:
+ model = self.model
+ fields = self.fields
+
+ def __init__(self, *args, **kwargs):
+ """Filter the queryset for foreign key fields based on the user.
+
+ Otherwise, the user would see all objects, even those they don't have
+ access to. We need to pop the user from the kwargs, as it's not a valid
+ argument for the form.
+ """
+ user = kwargs.pop("user") if "user" in kwargs else None
+ super().__init__(*args, **kwargs)
+ if user:
+ for field in self._meta.model._meta.fields:
+ if field.name in self.foreign_key_fields:
+ self.fields[field.name].queryset = get_queryset(field, user)
+
+ return CustomCreateForm
+
+ def form_valid(self, form: forms.ModelForm) -> HttpResponse:
+ """Set the owner of the object to the current user.
+
+ This is done before saving the object to the database.
+
+ Args:
+ form (forms.ModelForm): Form with the object data.
+
+ Returns:
+ HttpResponse: Redirect to the detail view of the created object.
+ """
+ if hasattr(form.instance, "owner"):
+ form.instance.owner = self.request.user
+ return super().form_valid(form)
+
+ @property
+ def app_label(self) -> str:
+ return self.model._meta.app_label
+
+ @property
+ def model_name(self) -> str:
+ return self.model._meta.model_name
+
+ @property
+ def model_description(self) -> str:
+ return self.model._meta.verbose_name.title()
+
+ @property
+ def success_url(self) -> str:
+ return reverse_lazy(self.detail_url, kwargs={"pk": self.object.pk})
+
+ @property
+ def detail_url(self) -> str:
+ return f"{self.app_label}:{self.model_name}_detail"
+
+ @property
+ def list_url(self) -> str:
+ return f"{self.app_label}:{self.model_name}_list"
+
+ def get_form_kwargs(self):
+ """Add the user to the form kwargs, so we can filter the options."""
+ kwargs = super().get_form_kwargs()
+ kwargs["user"] = self.request.user
+ return kwargs
+
+
+class CustomDeleteView(LoginRequiredMixin, DeleteView):
+ """Generic delete view.
+
+ This view is used to delete a model object. The user must have the permission to
+ delete the object, otherwise a 403 error is returned. A confirmation page is shown
+ with the related objects that will be deleted.
+
+ The permissions required to delete the object are `app_label.delete_model_name`. For
+ example, the permission required to delete a `DataImport` object would be
+ `importing.delete_dataimport`.
+
+ If successful, the view redirects to the list view.
+
+ Users need to be logged in to access this view.
+
+ Attributes:
+ template_name (str): Template to be used.
+ """
+
+ template_name = "object_delete.html"
+
+ def get_object(self) -> Model:
+ obj = super().get_object()
+ if not self.request.user.has_perm(
+ f"{self.app_label}.delete_{self.model_name}", obj
+ ):
+ raise PermissionDenied
+ return obj
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ deletable_objects, model_count, protected = get_deleted_objects([self.object])
+
+ context["deletable_objects"] = deletable_objects
+ context["model_count"] = dict(model_count).items()
+ context["protected"] = protected
+ context["title"] = self.model_description
+ context["list_url"] = self.list_url
+ return context
+
+ @property
+ def app_label(self) -> str:
+ return self.model._meta.app_label
+
+ @property
+ def model_name(self) -> str:
+ return self.model._meta.model_name
+
+ @property
+ def model_description(self) -> str:
+ return self.model._meta.verbose_name.title()
+
+ @property
+ def list_url(self) -> str:
+ return f"{self.app_label}:{self.model_name}_list"
+
+ @property
+ def success_url(self) -> str:
+ return reverse_lazy(self.list_url)
diff --git a/measurement/admin.py b/measurement/admin.py
index 22d2972a..f2795b94 100644
--- a/measurement/admin.py
+++ b/measurement/admin.py
@@ -2,7 +2,7 @@
from guardian.admin import GuardedModelAdmin
from guardian.shortcuts import get_objects_for_user
-from management.admin import _get_queryset
+from management.admin import get_queryset
from measurement.models import Measurement, Report
@@ -47,7 +47,7 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs):
request.user, "station.change_station"
)
elif db_field.name == "variable":
- kwargs["queryset"] = _get_queryset(db_field, request.user)
+ kwargs["queryset"] = get_queryset(db_field, request.user)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
diff --git a/templates/menu_bar.html b/templates/menu_bar.html
index a0aed1b1..5aaede24 100644
--- a/templates/menu_bar.html
+++ b/templates/menu_bar.html
@@ -10,6 +10,7 @@
{% if user.is_authenticated %}
Validation
+ Import data
{% endif %}
diff --git a/templates/object_create.html b/templates/object_create.html
new file mode 100644
index 00000000..fa595bc0
--- /dev/null
+++ b/templates/object_create.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+{% load django_bootstrap5 %}
+{% block content %}
+
+ {% block title %}
+ {{ title }}
+ {% endblock title %}
+ object creation
+
+
+{% endblock content %}
diff --git a/templates/object_delete.html b/templates/object_delete.html
new file mode 100644
index 00000000..8a9afa34
--- /dev/null
+++ b/templates/object_delete.html
@@ -0,0 +1,52 @@
+{% extends "base.html" %}
+{% load django_bootstrap5 %}
+{% block content %}
+
+ {% block title %}
+ {{ title }}
+ {% endblock title %}
+ object delete
+
+
+
+
+ Summary of objects to be deleted:
+
+
+
+
+
+ Name |
+ Amount |
+
+
+
+ {% for model_name, object_count in model_count %}
+
+ {{ model_name|capfirst }} |
+ {{ object_count }} |
+
+ {% endfor %}
+
+
+
+
+ Individual objects that are protected from deletion:
+
+
+ {{ protected|unordered_list }}
+
+
+ Individual objects to be deleted:
+
+
+ {{ deletable_objects|unordered_list }}
+
+
+{% endblock content %}
diff --git a/templates/object_detail.html b/templates/object_detail.html
index 90dabcb0..8d711129 100644
--- a/templates/object_detail.html
+++ b/templates/object_detail.html
@@ -1,5 +1,5 @@
{% extends "base.html" %}
-{% load render_table from django_tables2 %}
+{% load django_bootstrap5 %}
{% block content %}
{% block title %}
@@ -10,36 +10,16 @@
-
-
-
- Field |
- Value |
-
-
-
- {% for field in form %}
-
- {{ field.label }} |
- {{ field.value }} |
-
- {% endfor %}
-
-
+
+ {% bootstrap_form form layout='horizontal' %}
{% endblock content %}
diff --git a/templates/object_edit.html b/templates/object_edit.html
new file mode 100644
index 00000000..6bbce447
--- /dev/null
+++ b/templates/object_edit.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+{% load django_bootstrap5 %}
+{% block content %}
+
+ {% block title %}
+ {{ title }}
+ {% endblock title %}
+ object edit
+
+
+{% endblock content %}
diff --git a/templates/table.html b/templates/table.html
index a2c74c5d..77d373aa 100644
--- a/templates/table.html
+++ b/templates/table.html
@@ -1,7 +1,6 @@
{% extends "base.html" %}
{% load django_bootstrap5 %}
-{% load crispy_forms_tags
-%}
+{% load crispy_forms_tags %}
{% load render_table from django_tables2 %}
{% block content %}
@@ -10,12 +9,9 @@
{% endblock title %}
- {% if new_url %}
- New
- {% endif %}
- {% if refresh %}
- Refresh
- {% endif %}
+ Refresh
+ New
+
{% if filter %}