Initial commit
This commit is contained in:
commit
8318812081
|
@ -0,0 +1,9 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) <year> <copyright holders>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -0,0 +1,104 @@
|
||||||
|
# django-crud-mixins
|
||||||
|
|
||||||
|
CRUD and form mixins for Django.
|
||||||
|
Useful for single-page-applications using Gridstack.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
Add to your `requirements.txt` file:
|
||||||
|
```
|
||||||
|
git+https://git.zm.is/XF/django-crud-mixins
|
||||||
|
```
|
||||||
|
|
||||||
|
## View helpers
|
||||||
|
The view helpers help create simple CRUD views for your application. They are geared towards a single-page application using Gridstack, but can be used on full pages as well by specifying a `type` of `page` always.
|
||||||
|
|
||||||
|
The view permission helpers add a `user=request.user` argument to all queryset filters, ensuring your views can only access the data of the requesting user. Additional filters can be set by overriding the `set_extra_args` method (detailed below).
|
||||||
|
|
||||||
|
Import the helpers from your view:
|
||||||
|
```
|
||||||
|
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectRead, ObjectUpdate
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, add the views:
|
||||||
|
```python
|
||||||
|
class AccountList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
|
||||||
|
list_template = "partials/account-list.html"
|
||||||
|
model = Account
|
||||||
|
page_title = "List of accounts"
|
||||||
|
|
||||||
|
list_url_name = "accounts"
|
||||||
|
list_url_args = ["type"]
|
||||||
|
|
||||||
|
submit_url_name = "account_create"
|
||||||
|
|
||||||
|
|
||||||
|
class AccountCreate(LoginRequiredMixin, OTPRequiredMixin, ObjectCreate):
|
||||||
|
model = Account
|
||||||
|
form_class = AccountForm
|
||||||
|
|
||||||
|
submit_url_name = "account_create"
|
||||||
|
|
||||||
|
|
||||||
|
class AccountUpdate(LoginRequiredMixin, OTPRequiredMixin, ObjectUpdate):
|
||||||
|
model = Account
|
||||||
|
form_class = AccountForm
|
||||||
|
|
||||||
|
submit_url_name = "account_update"
|
||||||
|
|
||||||
|
|
||||||
|
class AccountDelete(LoginRequiredMixin, OTPRequiredMixin, ObjectDelete):
|
||||||
|
model = Account
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variables
|
||||||
|
These variables can be added to the classes to adjust functionality:
|
||||||
|
|
||||||
|
Basic:
|
||||||
|
* `list_template`: the name of the template for the list view
|
||||||
|
* `model`: the model you want to view with this helper
|
||||||
|
* `page_title`: the page title to render
|
||||||
|
* `page_subtitle`: the page subtitle to render
|
||||||
|
|
||||||
|
List URLs:
|
||||||
|
* `list_url_name`: the name of the list URL to include in the context, passed as `list_url`
|
||||||
|
* `list_url_args`: arguments for the above, taken from view kwargs, or locals
|
||||||
|
|
||||||
|
Submit URLs:
|
||||||
|
* `submit_url_name`: the name of the submit URL to include in the context, passed as `submit_url` -- used in the form
|
||||||
|
* `submit_url_args`: arguments for the above, taken from view kwargs, or locals
|
||||||
|
|
||||||
|
For `ObjectList` only:
|
||||||
|
* `delete_all_url_name`: the name of the delete-all URL to include in the context, passed as `delete_all_url` -- used in the form's "Delete all" button
|
||||||
|
* `widget_options`: options for the Gristack widget
|
||||||
|
|
||||||
|
For `ObjectCreate` and `ObjectUpdate` only:
|
||||||
|
* `hide_cancel`: whether to hide the cancel button in the form
|
||||||
|
|
||||||
|
For `ObjectUpdate` only:
|
||||||
|
* `pk_required`: whether the primary key `pk` is required in the URL kwargs
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
These methods can be added to the classes to adjust functionality:
|
||||||
|
|
||||||
|
For `ObjectCreate` and `ObjectUpdate` only:
|
||||||
|
* `post_save(self, obj)`: called after the object has been saved
|
||||||
|
|
||||||
|
For `ObjectCreate` only:
|
||||||
|
* `pre_save_mutate(self, user, obj)`: called before the object is saved, AbortSave can be raised with an error message to abort the save
|
||||||
|
|
||||||
|
|
||||||
|
These methods can be used on all classes, as they are inherited from the `RestrictedViewMixin`:
|
||||||
|
* `set_extra_args(self, user)`: adjusts the queryset filter with extra parameters, set `self.extra_permission_args` from this method to a dictionary of arguments
|
||||||
|
|
||||||
|
## Form permission helper
|
||||||
|
The form permission helper `RestrictedFormMixin` can be used as a mixin in your forms:
|
||||||
|
```python
|
||||||
|
class YourModelForm(RestrictedFormMixin, ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = YourModel
|
||||||
|
fields = (
|
||||||
|
"name",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
It's that simple!
|
||||||
|
The form will automatically add a `user=request.user` argument to all queryset filters, ensuring your forms can only access the data of the requesting user.
|
|
@ -0,0 +1,29 @@
|
||||||
|
# pyproject.toml
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "django-crud-mixins"
|
||||||
|
version = "1.0.1"
|
||||||
|
description = "CRUD mixins for Django class-based views"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [{ name = "Mark Veidemanis", email = "m@zm.is" }]
|
||||||
|
license = { file = "LICENSE" }
|
||||||
|
classifiers = [
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Framework :: Django :: 4.1",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
]
|
||||||
|
keywords = ["django", "mixins", "helpers", "crud"]
|
||||||
|
dependencies = [
|
||||||
|
"django",
|
||||||
|
"django-rest-framework",
|
||||||
|
]
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://git.zm.is/XF/django-crud-mixins"
|
|
@ -0,0 +1,100 @@
|
||||||
|
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
|
||||||
|
class RestrictedViewMixin:
|
||||||
|
"""
|
||||||
|
This mixin overrides two helpers in order to pass the user object to the filters.
|
||||||
|
get_queryset alters the objects returned for list views.
|
||||||
|
get_form_kwargs passes the request object to the form class. Remaining permissions
|
||||||
|
checks are in forms.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
allow_empty = True
|
||||||
|
queryset = None
|
||||||
|
model = None
|
||||||
|
paginate_by = None
|
||||||
|
paginate_orphans = 0
|
||||||
|
context_object_name = None
|
||||||
|
paginator_class = Paginator
|
||||||
|
page_kwarg = "page"
|
||||||
|
ordering = None
|
||||||
|
|
||||||
|
def set_extra_args(self, user):
|
||||||
|
"""
|
||||||
|
This function is overriden to filter the objects by the requesting user.
|
||||||
|
"""
|
||||||
|
self.extra_permission_args = {}
|
||||||
|
|
||||||
|
def get_queryset(self, **kwargs):
|
||||||
|
"""
|
||||||
|
This function is overriden to filter the objects by the requesting user.
|
||||||
|
"""
|
||||||
|
self.set_extra_args(self.request.user)
|
||||||
|
if self.queryset is not None:
|
||||||
|
queryset = self.queryset
|
||||||
|
if isinstance(queryset, QuerySet):
|
||||||
|
# queryset = queryset.all()
|
||||||
|
queryset = queryset.filter(
|
||||||
|
user=self.request.user, **self.extra_permission_args
|
||||||
|
)
|
||||||
|
elif self.model is not None:
|
||||||
|
queryset = self.model._default_manager.filter(
|
||||||
|
user=self.request.user, **self.extra_permission_args
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"%(cls)s is missing a QuerySet. Define "
|
||||||
|
"%(cls)s.model, %(cls)s.queryset, or override "
|
||||||
|
"%(cls)s.get_queryset()." % {"cls": self.__class__.__name__}
|
||||||
|
)
|
||||||
|
if hasattr(self, "get_ordering"):
|
||||||
|
ordering = self.get_ordering()
|
||||||
|
if ordering:
|
||||||
|
if isinstance(ordering, str):
|
||||||
|
ordering = (ordering,)
|
||||||
|
queryset = queryset.order_by(*ordering)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
"""Passes the request object to the form class.
|
||||||
|
This is necessary to only display members that belong to a given user"""
|
||||||
|
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs["request"] = self.request
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class RestrictedFormMixin:
|
||||||
|
"""
|
||||||
|
This mixin is used to restrict the queryset of a form to the current user.
|
||||||
|
The request object is passed from the view.
|
||||||
|
Fieldargs is used to pass additional arguments to the queryset filter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fieldargs = {}
|
||||||
|
|
||||||
|
# TODO: implement set_extra_args to check more permissions here
|
||||||
|
# for completeness, however as views open forms, the permissions
|
||||||
|
# are already checked there, so it may not be necessary.
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# self.fieldargs = {}
|
||||||
|
self.request = kwargs.pop("request")
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields:
|
||||||
|
# Check it's not something like a CharField which has no queryset
|
||||||
|
if not hasattr(self.fields[field], "queryset"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
model = self.fields[field].queryset.model
|
||||||
|
# Check if the model has a user field
|
||||||
|
try:
|
||||||
|
model._meta.get_field("user")
|
||||||
|
# Add the user to the queryset filters
|
||||||
|
self.fields[field].queryset = model.objects.filter(
|
||||||
|
user=self.request.user, **self.fieldargs.get(field, {})
|
||||||
|
)
|
||||||
|
except FieldDoesNotExist:
|
||||||
|
pass
|
|
@ -0,0 +1,44 @@
|
||||||
|
// var modal = document.querySelector('.modal'); // assuming you have only 1
|
||||||
|
var modal = document.getElementById("modal");
|
||||||
|
var html = document.querySelector('html');
|
||||||
|
|
||||||
|
var disableModal = function() {
|
||||||
|
modal.classList.remove('is-active');
|
||||||
|
html.classList.remove('is-clipped');
|
||||||
|
var modal_refresh = document.getElementsByClassName("modal-refresh");
|
||||||
|
for(var i = 0; i < modal_refresh.length; i++) {
|
||||||
|
modal_refresh[i].remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var elements = document.querySelectorAll('.modal-background');
|
||||||
|
for(var i = 0; i < elements.length; i++) {
|
||||||
|
elements[i].addEventListener('click', function(e) {
|
||||||
|
// elements[i].preventDefault();
|
||||||
|
disableModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var elements = document.querySelectorAll('.modal-close');
|
||||||
|
for(var i = 0; i < elements.length; i++) {
|
||||||
|
elements[i].addEventListener('click', function(e) {
|
||||||
|
// elements[i].preventDefault();
|
||||||
|
disableModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateButtons() {
|
||||||
|
var elements = document.querySelectorAll('.modal-close-button');
|
||||||
|
for(var i = 0; i < elements.length; i++) {
|
||||||
|
elements[i].addEventListener('click', function(e) {
|
||||||
|
// elements[i].preventDefault();
|
||||||
|
disableModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activateButtons();
|
||||||
|
// modal.querySelector('.modal-close-button').addEventListener('click', function(e) {
|
||||||
|
// e.preventDefault();
|
||||||
|
// modal.classList.remove('is-active');
|
||||||
|
// html.classList.remove('is-clipped');
|
||||||
|
// });
|
|
@ -0,0 +1 @@
|
||||||
|
<button class="modal-close is-large" aria-label="close"></button>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<i
|
||||||
|
class="fa-solid fa-xmark has-text-grey-light float-right"
|
||||||
|
onclick='grid.removeWidget("widget-{{ unique }}");grid.compact();'></i>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<i
|
||||||
|
class="fa-solid fa-xmark has-text-grey-light float-right"
|
||||||
|
data-script="on click remove the closest <nav/>"></i>
|
|
@ -0,0 +1,72 @@
|
||||||
|
{% load pretty %}
|
||||||
|
{% include 'partials/notify.html' %}
|
||||||
|
|
||||||
|
{% if live is not None %}
|
||||||
|
<h1 class="title">Live {{ context_object_name_singular }} info</h1>
|
||||||
|
<table class="table is-fullwidth is-hoverable">
|
||||||
|
<thead>
|
||||||
|
<th>attribute</th>
|
||||||
|
<th>value</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% block live_tbody %}
|
||||||
|
{% for key, item in live.items %}
|
||||||
|
{% if key in pretty %}
|
||||||
|
<tr>
|
||||||
|
<th>{{ key }}</th>
|
||||||
|
<td>
|
||||||
|
{% if item is not None %}
|
||||||
|
<pre>{{ item|pretty }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<th>{{ key }}</th>
|
||||||
|
<td>
|
||||||
|
{% if item is not None %}
|
||||||
|
{{ item }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if object is not None %}
|
||||||
|
<h1 class="title">{{ title_singular }} info</h1>
|
||||||
|
<table class="table is-fullwidth is-hoverable">
|
||||||
|
<thead>
|
||||||
|
<th>attribute</th>
|
||||||
|
<th>value</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% block tbody %}
|
||||||
|
{% for key, item in object.items %}
|
||||||
|
{% if key in pretty %}
|
||||||
|
<tr>
|
||||||
|
<th>{{ key }}</th>
|
||||||
|
<td>
|
||||||
|
{% if item is not None %}
|
||||||
|
<pre>{{ item|pretty }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<th>{{ key }}</th>
|
||||||
|
<td>
|
||||||
|
{% if item is not None %}
|
||||||
|
{{ item }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
|
@ -0,0 +1,7 @@
|
||||||
|
<div id="notification">
|
||||||
|
{% if message is not None %}
|
||||||
|
<div class="notification is-{{ class }}" hx-ext="remove-me" remove-me="3s">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
|
@ -0,0 +1,34 @@
|
||||||
|
{% include 'partials/notify.html' %}
|
||||||
|
{% if page_title is not None %}
|
||||||
|
<h1 class="title is-4">{{ page_title }}</h1>
|
||||||
|
{% endif %}
|
||||||
|
{% if page_subtitle is not None %}
|
||||||
|
<h1 class="subtitle">{{ page_subtitle }}</h1>
|
||||||
|
{% endif %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% load crispy_forms_bulma_field %}
|
||||||
|
<form
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{{ submit_url }}"
|
||||||
|
hx-target="#modals-here"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
{% if hide_cancel is not True %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-light modal-close-button">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="button modal-close-button">Submit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
{% include 'partials/notify.html' %}
|
||||||
|
{% if page_title is not None %}
|
||||||
|
<h1 class="title is-4">{{ page_title }}</h1>
|
||||||
|
{% endif %}
|
||||||
|
{% if page_subtitle is not None %}
|
||||||
|
<h1 class="subtitle">{{ page_subtitle }}</h1>
|
||||||
|
{% endif %}
|
||||||
|
<div class="buttons">
|
||||||
|
|
||||||
|
{% if submit_url is not None %}
|
||||||
|
<button
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="{{ submit_url }}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#modals-here"
|
||||||
|
class="button">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
</span>
|
||||||
|
<span>{{ title_singular }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if delete_all_url is not None %}
|
||||||
|
<button
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-delete="{{ delete_all_url }}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#modals-here"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Are you sure you wish to delete all {{ context_object_name }}?"
|
||||||
|
class="button">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</span>
|
||||||
|
<span>Delete all {{ context_object_name }} </span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include detail_template %}
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
{% include 'partials/notify.html' %}
|
||||||
|
{% if page_title is not None %}
|
||||||
|
<h1 class="title is-4">{{ page_title }}</h1>
|
||||||
|
{% endif %}
|
||||||
|
{% if page_subtitle is not None %}
|
||||||
|
<h1 class="subtitle">{{ page_subtitle }}</h1>
|
||||||
|
{% endif %}
|
||||||
|
<div class="buttons">
|
||||||
|
|
||||||
|
{% if submit_url is not None %}
|
||||||
|
<button
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="{{ submit_url }}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#modals-here"
|
||||||
|
class="button">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
</span>
|
||||||
|
<span>{{ title_singular }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if delete_all_url is not None %}
|
||||||
|
<button
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-delete="{{ delete_all_url }}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#modals-here"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Are you sure you wish to delete all {{ context_object_name }}?"
|
||||||
|
class="button">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</span>
|
||||||
|
<span>Delete all {{ context_object_name }} </span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include list_template %}
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<script src="{% static 'modal.js' %}"></script>
|
||||||
|
{% block scripts %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<div id="modal" class="modal is-active is-clipped">
|
||||||
|
<div class="modal-background"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="box">
|
||||||
|
{% block modal_content %}
|
||||||
|
{% include window_content %}
|
||||||
|
{% endblock %}
|
||||||
|
{% include 'partials/close-modal.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include window_content %}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,17 @@
|
||||||
|
|
||||||
|
<nav class="panel">
|
||||||
|
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
|
||||||
|
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
|
||||||
|
{% block close_button %}
|
||||||
|
{% include 'partials/close-window.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block heading %}
|
||||||
|
{% endblock %}
|
||||||
|
</p>
|
||||||
|
<article class="panel-block is-active">
|
||||||
|
<div class="control">
|
||||||
|
{% block panel_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</nav>
|
|
@ -0,0 +1,37 @@
|
||||||
|
<div id="widget">
|
||||||
|
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
|
||||||
|
<div class="grid-stack-item-content">
|
||||||
|
|
||||||
|
<nav class="panel">
|
||||||
|
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
|
||||||
|
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
|
||||||
|
{% block close_button %}
|
||||||
|
{% include 'partials/close-widget.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
<i
|
||||||
|
class="fa-solid fa-arrows-minimize has-text-grey-light float-right"
|
||||||
|
onclick='grid.compact();'></i>
|
||||||
|
{% block heading %}
|
||||||
|
{{ title }}
|
||||||
|
{% endblock %}
|
||||||
|
</p>
|
||||||
|
<article class="panel-block is-active">
|
||||||
|
<div class="control">
|
||||||
|
{% block panel_content %}
|
||||||
|
{% include window_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{% block custom_script %}
|
||||||
|
{% endblock %}
|
||||||
|
var widget_event = new Event('load-widget');
|
||||||
|
document.dispatchEvent(widget_event);
|
||||||
|
</script>
|
||||||
|
{% block custom_end %}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,10 @@
|
||||||
|
<magnet-block attract-distance="10" align-to="outer|center" class="floating-window">
|
||||||
|
{% extends 'wm/panel.html' %}
|
||||||
|
{% block heading %}
|
||||||
|
{{ title }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block panel_content %}
|
||||||
|
{% include window_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</magnet-block>
|
|
@ -0,0 +1,442 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.http import Http404, HttpResponse, HttpResponseBadRequest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.views.generic.detail import DetailView
|
||||||
|
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||||
|
from django.views.generic.list import ListView
|
||||||
|
from rest_framework.parsers import FormParser
|
||||||
|
|
||||||
|
from mixins.restrictions import RestrictedViewMixin
|
||||||
|
|
||||||
|
|
||||||
|
class AbortSave(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectNameMixin(object):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
if self.model is None:
|
||||||
|
self.title = self.context_object_name.title()
|
||||||
|
self.title_singular = self.context_object_name_singular.title()
|
||||||
|
else:
|
||||||
|
self.title_singular = self.model._meta.verbose_name.title() # Hook
|
||||||
|
self.context_object_name_singular = self.title_singular.lower() # hook
|
||||||
|
self.title = self.model._meta.verbose_name_plural.title() # Hooks
|
||||||
|
self.context_object_name = self.title.lower() # hooks
|
||||||
|
|
||||||
|
self.context_object_name = self.context_object_name.replace(" ", "")
|
||||||
|
self.context_object_name_singular = (
|
||||||
|
self.context_object_name_singular.replace(" ", "")
|
||||||
|
)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectList(RestrictedViewMixin, ObjectNameMixin, ListView):
|
||||||
|
allowed_types = ["modal", "widget", "window", "page"]
|
||||||
|
window_content = "window-content/objects.html"
|
||||||
|
list_template = None
|
||||||
|
|
||||||
|
page_title = None
|
||||||
|
page_subtitle = None
|
||||||
|
|
||||||
|
list_url_name = None
|
||||||
|
# WARNING: TAKEN FROM locals()
|
||||||
|
list_url_args = ["type"]
|
||||||
|
|
||||||
|
submit_url_name = None
|
||||||
|
submit_url_args = ["type"]
|
||||||
|
|
||||||
|
delete_all_url_name = None
|
||||||
|
widget_options = None
|
||||||
|
|
||||||
|
# copied from BaseListView
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
type = kwargs.get("type", None)
|
||||||
|
if not type:
|
||||||
|
return HttpResponseBadRequest("No type specified")
|
||||||
|
if type not in self.allowed_types:
|
||||||
|
return HttpResponseBadRequest("Invalid type specified")
|
||||||
|
|
||||||
|
self.request = request
|
||||||
|
self.object_list = self.get_queryset(**kwargs)
|
||||||
|
if isinstance(self.object_list, HttpResponse):
|
||||||
|
return self.object_list
|
||||||
|
if isinstance(self.object_list, HttpResponseBadRequest):
|
||||||
|
return self.object_list
|
||||||
|
allow_empty = self.get_allow_empty()
|
||||||
|
|
||||||
|
self.template_name = f"wm/{type}.html"
|
||||||
|
unique = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
list_url_args = {}
|
||||||
|
for arg in self.list_url_args:
|
||||||
|
if arg in locals():
|
||||||
|
list_url_args[arg] = locals()[arg]
|
||||||
|
elif arg in kwargs:
|
||||||
|
list_url_args[arg] = kwargs[arg]
|
||||||
|
|
||||||
|
orig_type = type
|
||||||
|
if type == "page":
|
||||||
|
type = "modal"
|
||||||
|
|
||||||
|
if not allow_empty:
|
||||||
|
# When pagination is enabled and object_list is a queryset,
|
||||||
|
# it's better to do a cheap query than to load the unpaginated
|
||||||
|
# queryset in memory.
|
||||||
|
if self.get_paginate_by(self.object_list) is not None and hasattr(
|
||||||
|
self.object_list, "exists"
|
||||||
|
):
|
||||||
|
is_empty = not self.object_list.exists()
|
||||||
|
else:
|
||||||
|
is_empty = not self.object_list
|
||||||
|
if is_empty:
|
||||||
|
raise Http404("Empty list")
|
||||||
|
|
||||||
|
submit_url_args = {}
|
||||||
|
for arg in self.submit_url_args:
|
||||||
|
if arg in locals():
|
||||||
|
submit_url_args[arg] = locals()[arg]
|
||||||
|
elif arg in kwargs:
|
||||||
|
submit_url_args[arg] = kwargs[arg]
|
||||||
|
|
||||||
|
context = self.get_context_data()
|
||||||
|
context["title"] = self.title + f" ({type})"
|
||||||
|
context["title_singular"] = self.title_singular
|
||||||
|
context["unique"] = unique
|
||||||
|
context["window_content"] = self.window_content
|
||||||
|
context["list_template"] = self.list_template
|
||||||
|
context["page_title"] = self.page_title
|
||||||
|
context["page_subtitle"] = self.page_subtitle
|
||||||
|
context["type"] = type
|
||||||
|
context["context_object_name"] = self.context_object_name
|
||||||
|
context["context_object_name_singular"] = self.context_object_name_singular
|
||||||
|
|
||||||
|
if self.submit_url_name is not None:
|
||||||
|
context["submit_url"] = reverse(
|
||||||
|
self.submit_url_name, kwargs=submit_url_args
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.list_url_name is not None:
|
||||||
|
context["list_url"] = reverse(self.list_url_name, kwargs=list_url_args)
|
||||||
|
|
||||||
|
if self.delete_all_url_name:
|
||||||
|
context["delete_all_url"] = reverse(self.delete_all_url_name)
|
||||||
|
if self.widget_options:
|
||||||
|
context["widget_options"] = self.widget_options
|
||||||
|
|
||||||
|
# Return partials for HTMX
|
||||||
|
if self.request.htmx:
|
||||||
|
if request.headers["HX-Target"] == self.context_object_name + "-table":
|
||||||
|
self.template_name = self.list_template
|
||||||
|
elif orig_type == "page":
|
||||||
|
self.template_name = self.list_template
|
||||||
|
else:
|
||||||
|
context["window_content"] = self.list_template
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectCreate(RestrictedViewMixin, ObjectNameMixin, CreateView):
|
||||||
|
allowed_types = ["modal", "widget", "window", "page"]
|
||||||
|
window_content = "window-content/object-form.html"
|
||||||
|
parser_classes = [FormParser]
|
||||||
|
|
||||||
|
page_title = None
|
||||||
|
page_subtitle = None
|
||||||
|
|
||||||
|
model = None
|
||||||
|
submit_url_name = None
|
||||||
|
submit_url_args = ["type"]
|
||||||
|
|
||||||
|
request = None
|
||||||
|
|
||||||
|
# Whether to hide the cancel button in the form
|
||||||
|
hide_cancel = False
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.title = "Create " + self.context_object_name_singular
|
||||||
|
|
||||||
|
def post_save(self, obj):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def pre_save_mutate(self, user, obj):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
obj = form.save(commit=False)
|
||||||
|
if self.request is None:
|
||||||
|
raise Exception("Request is None")
|
||||||
|
obj.user = self.request.user
|
||||||
|
try:
|
||||||
|
self.pre_save_mutate(self.request.user, obj)
|
||||||
|
except AbortSave as e:
|
||||||
|
context = {"message": f"Failed to save: {e}", "class": "danger"}
|
||||||
|
return self.render_to_response(context)
|
||||||
|
obj.save()
|
||||||
|
form.save_m2m()
|
||||||
|
self.post_save(obj)
|
||||||
|
context = {"message": "Object created", "class": "success"}
|
||||||
|
response = self.render_to_response(context)
|
||||||
|
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||||
|
return response
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
"""If the form is invalid, render the invalid form."""
|
||||||
|
return self.get(self.request, **self.kwargs, form=form)
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
type = kwargs.get("type", None)
|
||||||
|
if not type:
|
||||||
|
return HttpResponseBadRequest("No type specified")
|
||||||
|
if type not in self.allowed_types:
|
||||||
|
return HttpResponseBadRequest("Invalid type specified")
|
||||||
|
self.template_name = f"wm/{type}.html"
|
||||||
|
unique = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
self.request = request
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
if type == "widget":
|
||||||
|
self.hide_cancel = True
|
||||||
|
|
||||||
|
if type == "page":
|
||||||
|
type = "modal"
|
||||||
|
|
||||||
|
self.object = None
|
||||||
|
|
||||||
|
submit_url_args = {}
|
||||||
|
for arg in self.submit_url_args:
|
||||||
|
if arg in locals():
|
||||||
|
submit_url_args[arg] = locals()[arg]
|
||||||
|
elif arg in kwargs:
|
||||||
|
submit_url_args[arg] = kwargs[arg]
|
||||||
|
submit_url = reverse(self.submit_url_name, kwargs=submit_url_args)
|
||||||
|
|
||||||
|
context = self.get_context_data()
|
||||||
|
form = kwargs.get("form", None)
|
||||||
|
if form:
|
||||||
|
context["form"] = form
|
||||||
|
context["unique"] = unique
|
||||||
|
context["window_content"] = self.window_content
|
||||||
|
context["context_object_name"] = self.context_object_name
|
||||||
|
context["context_object_name_singular"] = self.context_object_name_singular
|
||||||
|
context["submit_url"] = submit_url
|
||||||
|
context["type"] = type
|
||||||
|
context["hide_cancel"] = self.hide_cancel
|
||||||
|
if self.page_title:
|
||||||
|
context["page_title"] = self.page_title
|
||||||
|
if self.page_subtitle:
|
||||||
|
context["page_subtitle"] = self.page_subtitle
|
||||||
|
response = self.render_to_response(context)
|
||||||
|
# response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||||
|
return response
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
self.request = request
|
||||||
|
self.template_name = "partials/notify.html"
|
||||||
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectRead(RestrictedViewMixin, ObjectNameMixin, DetailView):
|
||||||
|
allowed_types = ["modal", "widget", "window", "page"]
|
||||||
|
window_content = "window-content/object.html"
|
||||||
|
detail_template = "partials/generic-detail.html"
|
||||||
|
|
||||||
|
page_title = None
|
||||||
|
page_subtitle = None
|
||||||
|
|
||||||
|
model = None
|
||||||
|
# submit_url_name = None
|
||||||
|
|
||||||
|
detail_url_name = None
|
||||||
|
# WARNING: TAKEN FROM locals()
|
||||||
|
detail_url_args = ["type"]
|
||||||
|
|
||||||
|
request = None
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
type = kwargs.get("type", None)
|
||||||
|
if not type:
|
||||||
|
return HttpResponseBadRequest("No type specified")
|
||||||
|
if type not in self.allowed_types:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
self.template_name = f"wm/{type}.html"
|
||||||
|
unique = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
detail_url_args = {}
|
||||||
|
for arg in self.detail_url_args:
|
||||||
|
if arg in locals():
|
||||||
|
detail_url_args[arg] = locals()[arg]
|
||||||
|
elif arg in kwargs:
|
||||||
|
detail_url_args[arg] = kwargs[arg]
|
||||||
|
|
||||||
|
self.request = request
|
||||||
|
self.object = self.get_object(**kwargs)
|
||||||
|
if isinstance(self.object, HttpResponse):
|
||||||
|
return self.object
|
||||||
|
|
||||||
|
orig_type = type
|
||||||
|
if type == "page":
|
||||||
|
type = "modal"
|
||||||
|
|
||||||
|
context = self.get_context_data()
|
||||||
|
|
||||||
|
context["title"] = self.title + f" ({type})"
|
||||||
|
context["title_singular"] = self.title_singular
|
||||||
|
context["unique"] = unique
|
||||||
|
context["window_content"] = self.window_content
|
||||||
|
context["detail_template"] = self.detail_template
|
||||||
|
if self.page_title:
|
||||||
|
context["page_title"] = self.page_title
|
||||||
|
if self.page_subtitle:
|
||||||
|
context["page_subtitle"] = self.page_subtitle
|
||||||
|
context["type"] = type
|
||||||
|
context["context_object_name"] = self.context_object_name
|
||||||
|
context["context_object_name_singular"] = self.context_object_name_singular
|
||||||
|
|
||||||
|
if self.detail_url_name is not None:
|
||||||
|
context["detail_url"] = reverse(
|
||||||
|
self.detail_url_name, kwargs=detail_url_args
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return partials for HTMX
|
||||||
|
if self.request.htmx:
|
||||||
|
if request.headers["HX-Target"] == self.context_object_name + "-info":
|
||||||
|
self.template_name = self.detail_template
|
||||||
|
elif orig_type == "page":
|
||||||
|
self.template_name = self.detail_template
|
||||||
|
else:
|
||||||
|
context["window_content"] = self.detail_template
|
||||||
|
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectUpdate(RestrictedViewMixin, ObjectNameMixin, UpdateView):
|
||||||
|
allowed_types = ["modal", "widget", "window", "page"]
|
||||||
|
window_content = "window-content/object-form.html"
|
||||||
|
parser_classes = [FormParser]
|
||||||
|
|
||||||
|
page_title = None
|
||||||
|
page_subtitle = None
|
||||||
|
|
||||||
|
model = None
|
||||||
|
submit_url_name = None
|
||||||
|
submit_url_args = ["type", "pk"]
|
||||||
|
|
||||||
|
request = None
|
||||||
|
|
||||||
|
# Whether pk is required in the get request
|
||||||
|
pk_required = True
|
||||||
|
|
||||||
|
# Whether to hide the cancel button in the form
|
||||||
|
hide_cancel = False
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.title = "Update " + self.context_object_name_singular
|
||||||
|
|
||||||
|
def post_save(self, obj):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
obj = form.save(commit=False)
|
||||||
|
if self.request is None:
|
||||||
|
raise Exception("Request is None")
|
||||||
|
obj.save()
|
||||||
|
form.save_m2m()
|
||||||
|
self.post_save(obj)
|
||||||
|
context = {"message": "Object updated", "class": "success"}
|
||||||
|
response = self.render_to_response(context)
|
||||||
|
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||||
|
return response
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
"""If the form is invalid, render the invalid form."""
|
||||||
|
return self.get(self.request, **self.kwargs, form=form)
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
self.request = request
|
||||||
|
type = kwargs.get("type", None)
|
||||||
|
pk = kwargs.get("pk", None)
|
||||||
|
if not type:
|
||||||
|
return HttpResponseBadRequest("No type specified")
|
||||||
|
if not pk:
|
||||||
|
if self.pk_required:
|
||||||
|
return HttpResponseBadRequest("No pk specified")
|
||||||
|
if type not in self.allowed_types:
|
||||||
|
return HttpResponseBadRequest("Invalid type specified")
|
||||||
|
self.template_name = f"wm/{type}.html"
|
||||||
|
unique = str(uuid.uuid4())[:8]
|
||||||
|
if type == "widget":
|
||||||
|
self.hide_cancel = True
|
||||||
|
|
||||||
|
if type == "page":
|
||||||
|
type = "modal"
|
||||||
|
|
||||||
|
self.object = self.get_object()
|
||||||
|
|
||||||
|
submit_url_args = {}
|
||||||
|
for arg in self.submit_url_args:
|
||||||
|
if arg in locals():
|
||||||
|
submit_url_args[arg] = locals()[arg]
|
||||||
|
elif arg in kwargs:
|
||||||
|
submit_url_args[arg] = kwargs[arg]
|
||||||
|
submit_url = reverse(self.submit_url_name, kwargs=submit_url_args)
|
||||||
|
|
||||||
|
context = self.get_context_data()
|
||||||
|
form = kwargs.get("form", None)
|
||||||
|
if form:
|
||||||
|
context["form"] = form
|
||||||
|
context["title"] = self.title + f" ({type})"
|
||||||
|
context["title_singular"] = self.title_singular
|
||||||
|
context["unique"] = unique
|
||||||
|
context["window_content"] = self.window_content
|
||||||
|
context["context_object_name"] = self.context_object_name
|
||||||
|
context["context_object_name_singular"] = self.context_object_name_singular
|
||||||
|
context["submit_url"] = submit_url
|
||||||
|
context["type"] = type
|
||||||
|
context["hide_cancel"] = self.hide_cancel
|
||||||
|
if self.page_title:
|
||||||
|
context["page_title"] = self.page_title
|
||||||
|
if self.page_subtitle:
|
||||||
|
context["page_subtitle"] = self.page_subtitle
|
||||||
|
response = self.render_to_response(context)
|
||||||
|
# response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||||
|
return response
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
self.request = request
|
||||||
|
self.template_name = "partials/notify.html"
|
||||||
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectDelete(RestrictedViewMixin, ObjectNameMixin, DeleteView):
|
||||||
|
model = None
|
||||||
|
template_name = "partials/notify.html"
|
||||||
|
|
||||||
|
# Overriden to prevent success URL from being used
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Call the delete() method on the fetched object and then redirect to the
|
||||||
|
success URL.
|
||||||
|
"""
|
||||||
|
self.object = self.get_object()
|
||||||
|
# success_url = self.get_success_url()
|
||||||
|
self.object.delete()
|
||||||
|
context = {"message": "Object deleted", "class": "success"}
|
||||||
|
response = self.render_to_response(context)
|
||||||
|
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||||
|
return response
|
||||||
|
|
||||||
|
# This will be used in newer Django versions, until then we get a warning
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""
|
||||||
|
Call the delete() method on the fetched object.
|
||||||
|
"""
|
||||||
|
self.object = self.get_object()
|
||||||
|
self.object.delete()
|
||||||
|
context = {"message": "Object deleted", "class": "success"}
|
||||||
|
response = self.render_to_response(context)
|
||||||
|
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||||
|
return response
|
Loading…
Reference in New Issue