Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UDF - CRUD + schema preview #891

Merged
merged 40 commits into from Sep 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
19a093d
Preliminary work on replacing prefilter logic with filtersets
rabstejnek Aug 10, 2023
98f69fc
Data pivots largely done
rabstejnek Aug 21, 2023
cdede6f
Visuals largely done
rabstejnek Aug 21, 2023
7cee82b
Cleanup, linting
rabstejnek Aug 22, 2023
89d2f58
Improvements
rabstejnek Aug 23, 2023
228d876
Fix tests
rabstejnek Aug 23, 2023
ca90cf1
create initial form library classes
munnsmunns Aug 24, 2023
c2175de
migrate needed files and add new app
munnsmunns Aug 28, 2023
14a4d64
add to apps
munnsmunns Aug 28, 2023
ab0e7eb
add validation to form
munnsmunns Aug 28, 2023
66e0031
add migration and admin site
munnsmunns Aug 29, 2023
b4e1e37
add url and get form view running
munnsmunns Aug 29, 2023
249b751
improve form layout
munnsmunns Aug 29, 2023
7d74a7d
migrate tests from hero
munnsmunns Aug 29, 2023
aaf6159
ruff
munnsmunns Aug 29, 2023
0c8a9bc
enable feature flag for tests
munnsmunns Aug 29, 2023
7cb09df
fix settings file
munnsmunns Aug 29, 2023
3d1ae6d
skip failing test for now
munnsmunns Aug 29, 2023
34aab55
add more fields from review
munnsmunns Aug 31, 2023
b13a478
add fields to form
munnsmunns Aug 31, 2023
a272f5d
add schema preview
munnsmunns Aug 31, 2023
0efe1d3
lint
munnsmunns Aug 31, 2023
cc8829c
lint
munnsmunns Aug 31, 2023
b64f959
fix test
shapiromatron Sep 7, 2023
441f6b5
set the creator outside with data from request, not form fields
shapiromatron Sep 7, 2023
b3ee0a8
updates from review
munnsmunns Sep 12, 2023
9c8dfac
remove comment
munnsmunns Sep 12, 2023
300736c
fix urls
munnsmunns Sep 12, 2023
bde5073
Merge branch 'form-library' of https://github.com/shapiromatron/hawc …
munnsmunns Sep 13, 2023
6a8ee02
add basic list and detail views
munnsmunns Sep 18, 2023
5b54c55
add update view
munnsmunns Sep 19, 2023
0275afa
add inline preview of schema
munnsmunns Sep 19, 2023
22732ff
Merge branch 'main' into dynamic-form-preview
shapiromatron Sep 19, 2023
1002b17
updates
shapiromatron Sep 19, 2023
e607fa4
remove url arg
shapiromatron Sep 19, 2023
aab2f47
show deprecated on update
shapiromatron Sep 19, 2023
9bad352
remove actions header
shapiromatron Sep 19, 2023
99d234f
update detail
shapiromatron Sep 19, 2023
0b71a4b
fix create success url
munnsmunns Sep 20, 2023
16d9404
delete unnecessary function
munnsmunns Sep 20, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion hawc/apps/common/dynamic_forms/schemas.py
Expand Up @@ -4,6 +4,7 @@
from django.forms import HiddenInput, JSONField
from pydantic import BaseModel, conlist, root_validator, validator

from ..forms import DynamicFormField
from . import fields, forms


Expand Down Expand Up @@ -96,4 +97,4 @@ def to_form_field(self, prefix, form_kwargs=None, *args, **kwargs):
"""Get dynamic form field for this schema."""
if len(self.fields) == 0:
return JSONField(widget=HiddenInput(), required=False)
return forms.DynamicFormField(prefix, self.to_form, form_kwargs, *args, **kwargs)
return DynamicFormField(prefix, self.to_form, form_kwargs, *args, **kwargs)
40 changes: 40 additions & 0 deletions hawc/apps/common/forms.py
Expand Up @@ -456,6 +456,46 @@ def validate(self, value):
raise forms.ValidationError(f'The value of "{self.check_value}" is required.')


class WidgetButtonMixin:
"""Mixin that adds a button to be associated with a field."""

_template_name = "common/widgets/btn.html"

def __init__(
self,
btn_attrs=None,
btn_content="",
btn_stretch=True,
btn_append=True,
*args,
**kwargs,
):
"""Apply button settings."""
self.btn_attrs = {} if btn_attrs is None else btn_attrs.copy()
self.btn_content = btn_content
self.btn_stretch = btn_stretch
self.btn_append = btn_append
super().__init__(*args, **kwargs)

def get_context(self, name, value, attrs):
"""Add button settings to context."""
context = super().get_context(name, value, attrs)
context["widget"]["btn_attrs"] = self.btn_attrs
context["widget"]["btn_content"] = self.btn_content
context["widget"]["btn_stretch"] = self.btn_stretch
context["widget"]["btn_append"] = self.btn_append
return context

def render(self, name, value, attrs=None, renderer=None):
"""Add to the context, then render."""
context = self.get_context(name, value, attrs)
return self._render(self._template_name, context, renderer)


class TextareaButton(WidgetButtonMixin, forms.Textarea):
"""Custom widget that adds a button associated with a textarea."""


class DynamicFormField(forms.JSONField):
"""Field to display dynamic form inline."""

Expand Down
3 changes: 3 additions & 0 deletions hawc/apps/common/templates/common/widgets/attrs.html
@@ -0,0 +1,3 @@
{% for name, value in attrs.items %}
{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}
{% endfor %}
14 changes: 14 additions & 0 deletions hawc/apps/common/templates/common/widgets/btn.html
@@ -0,0 +1,14 @@
<!-- need is-invalid class since bootstrap doesn't handle input groups correctly: https://stackoverflow.com/a/65571586 -->
<div class="input-group is-invalid">
{% if widget.btn_append %}
{% include widget.template_name %}
<div class="input-group-append{% if not widget.btn_stretch %} align-self-start{% endif %}">
<button type="button" {% include "common/widgets/attrs.html" with attrs=widget.btn_attrs only %}>{{widget.btn_content}}</button>
</div>
{% else %}
<div class="input-group-prepend{% if not widget.btn_stretch %} align-self-start{% endif %}">
<button type="button" {% include "common/widgets/attrs.html" with attrs=widget.btn_attrs only %}>{{widget.btn_content}}</button>
</div>
{% include widget.template_name %}
{% endif %}
</div>
2 changes: 2 additions & 0 deletions hawc/apps/common/templates/common/widgets/dynamic_form.html
@@ -0,0 +1,2 @@
{% load crispy_forms_tags %}
{% crispy widget.value %}
1 change: 1 addition & 0 deletions hawc/apps/common/templates/common/widgets/error.html
@@ -0,0 +1 @@
{% if message %}<div class="alert alert-danger"><i class="fa fa-fw fa-exclamation-triangle"></i>{{message}}</div>{% endif %}
15 changes: 14 additions & 1 deletion hawc/apps/common/views.py
@@ -1,5 +1,6 @@
import logging
from collections.abc import Callable, Iterable
from functools import wraps
from typing import Any
from urllib.parse import urlparse

Expand All @@ -10,7 +11,7 @@
from django.core.exceptions import FieldError, PermissionDenied
from django.db import models, transaction
from django.forms.models import model_to_dict
from django.http import HttpRequest, HttpResponseRedirect
from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.template import RequestContext
from django.urls import Resolver404, resolve, reverse
Expand Down Expand Up @@ -779,3 +780,15 @@ def get_app_config(self, context) -> WebappConfig:
create_visual_url=create_url if can_edit else None,
),
)


def htmx_required(func):
"""Require request to be have HX-Request header."""

@wraps(func)
def wrapper(request, *args, **kwargs):
if request.headers.get("HX-Request", "") != "true":
return HttpResponseBadRequest("An HTMX request is required")
return func(request, *args, **kwargs)

return wrapper
49 changes: 40 additions & 9 deletions hawc/apps/udf/forms.py
@@ -1,9 +1,11 @@
from crispy_forms import bootstrap as cfb
from crispy_forms import layout as cfl
from django import forms
from django.urls import reverse
from django.urls import reverse, reverse_lazy

from hawc.apps.common.autocomplete.forms import AutocompleteSelectMultipleWidget
from hawc.apps.common.dynamic_forms.schemas import Schema
from hawc.apps.common.forms import BaseFormHelper, PydanticValidator
from hawc.apps.common.forms import BaseFormHelper, PydanticValidator, TextareaButton
from hawc.apps.myuser.autocomplete import UserAutocomplete

from .models import UserDefinedForm
Expand All @@ -13,6 +15,16 @@ class UDFForm(forms.ModelForm):
schema = forms.JSONField(
initial=Schema(fields=[]).dict(),
validators=[PydanticValidator(Schema)],
widget=TextareaButton(
btn_attrs={
"hx-post": reverse_lazy("udf:schema_preview"),
"hx-target": "#schema-preview-frame",
"hx-swap": "innerHTML",
"class": "ml-2",
},
btn_content="Preview",
btn_stretch=False,
),
)

def __init__(self, *args, **kwargs):
Expand All @@ -23,19 +35,38 @@ def __init__(self, *args, **kwargs):

class Meta:
model = UserDefinedForm
exclude = ("parent_form", "creator", "created", "last_updated")
fields = ("name", "description", "schema", "editors", "deprecated")
widgets = {
"editors": AutocompleteSelectMultipleWidget(UserAutocomplete),
}

@property
def helper(self):
self.fields["description"].widget.attrs["rows"] = 3
cancel_url = reverse("portal") # TODO: replace temp cancel url
helper = BaseFormHelper(
self,
legend_text="Create a custom data extraction form",
cancel_url=cancel_url,
submit_text="Create Form",
cancel_url = reverse("udf:udf_list")
form_actions = [
cfl.Submit("save", "Save"),
cfl.HTML(f'<a role="button" class="btn btn-light" href="{cancel_url}">Cancel</a>'),
]
legend_text = "Update a custom form" if self.instance.id else "Create a custom form"
helper = BaseFormHelper(self)
helper.layout = cfl.Layout(
munnsmunns marked this conversation as resolved.
Show resolved Hide resolved
cfl.HTML(f"<legend>{legend_text}</legend>"),
cfl.Row("name", "description"),
cfl.Row(
"schema",
cfl.Fieldset(
"Form Preview", cfl.Div(css_id="schema-preview-frame"), css_class="col-md-6"
),
),
"editors",
"deprecated" if self.instance.id else None,
cfb.FormActions(*form_actions, css_class="form-actions"),
)
return helper


class SchemaPreviewForm(forms.Form):
"""Form for previewing a Dynamic Form schema."""

schema = forms.JSONField(validators=[PydanticValidator(Schema)])
7 changes: 7 additions & 0 deletions hawc/apps/udf/models.py
@@ -1,6 +1,7 @@
import reversion
from django.conf import settings
from django.db import models
from django.urls import reverse


class UserDefinedForm(models.Model):
Expand All @@ -26,5 +27,11 @@ class Meta:
unique_together = (("creator", "name"),)
ordering = ("-last_updated",)

def get_absolute_url(self):
return reverse("udf:udf_detail", args=(self.pk,))

def user_can_edit(self, user):
return self.creator == user or user in self.editors.all()


reversion.register(UserDefinedForm)
7 changes: 7 additions & 0 deletions hawc/apps/udf/templates/udf/schema_preview.html
@@ -0,0 +1,7 @@
{% load crispy_forms_tags %}

{% if valid %}
{% crispy form %}
{% else %}
{% include "common/widgets/error.html" with message="Unable to load preview; invalid schema." %}
{% endif %}
23 changes: 23 additions & 0 deletions hawc/apps/udf/templates/udf/udf_detail.html
@@ -0,0 +1,23 @@
{% extends "crumbless.html" %}

{% load hawc %}
{% load bs4 %}
{% load crispy_forms_tags %}

{% block content %}
<div class="d-flex">
<h2>{{object.name}}</h2>
{% if view.user_can_edit %}
{% actions %}
<a class="dropdown-item" href="{% url 'udf:udf_update' object.pk %}">Update</a>
{% endactions %}
{% endif %}
</div>
<p>Description: {{object.description}}</p>
<p>Deprecated: {{object.deprecated}}</p>
<p>Creator: {{object.creator}}</p>
<p>Created: {{object.created}}</p>
<p>Last Updated: {{object.last_updated}}</p>
<h3 class="py-3">Form Preview:</h3>
{% crispy form %}
{% endblock content %}
35 changes: 35 additions & 0 deletions hawc/apps/udf/templates/udf/udf_list.html
@@ -0,0 +1,35 @@
{% extends 'crumbless.html' %}

{% load bs4 %}

{% block content %}
<div class="dropdown btn-group float-right">
<a class="btn btn-primary dropdown-toggle" data-toggle="dropdown">Actions</a>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="{% url 'udf:udf_create' %}">Create a form</a>
</div>
</div>
<h2>User Defined Forms</h2>
{% if object_list %}
<table id="mainTbl" class="table table-sm table-striped">
{% bs4_colgroup '20,40,20,20' %}
{% bs4_thead 'Name,Description,Creator,Last Updated' %}
<tbody>
{% for object in object_list %}
<tr>
<td>
<p class='mb-0'><a href="{{object.get_absolute_url}}">{{object.name}}</a></p>
</td>
<td>{{object.description}}</td>
<td>{{object.creator}}</td>
<td>{{object.last_updated|date:"Y-m-d"}}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{% alert info %}No forms found.{% endalert %}
{% endif %}

{% include "includes/paginator.html" with plural_object_name="forms" %}
{% endblock content %}
4 changes: 4 additions & 0 deletions hawc/apps/udf/urls.py
Expand Up @@ -6,7 +6,11 @@
app_name = "udf"
urlpatterns = (
[
path("", views.UDFListView.as_view(), name="udf_list"),
path("create/", views.CreateUDFView.as_view(), name="udf_create"),
path("<int:pk>/update/", views.UpdateUDFView.as_view(), name="udf_update"),
path("<int:pk>/", views.UDFDetailView.as_view(), name="udf_detail"),
path("preview/", views.SchemaPreview.as_view(), name="schema_preview"),
]
if settings.HAWC_FEATURES.ENABLE_UDF
else []
Expand Down
76 changes: 70 additions & 6 deletions hawc/apps/udf/views.py
@@ -1,20 +1,84 @@
from django.urls import reverse
from django.views.generic.edit import CreateView
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, FormView, UpdateView

from hawc.apps.common.views import LoginRequiredMixin, MessageMixin
from hawc.apps.common import dynamic_forms
from hawc.apps.common.views import LoginRequiredMixin, MessageMixin, htmx_required

from .forms import UDFForm
from . import models
from .forms import SchemaPreviewForm, UDFForm


class UDFListView(LoginRequiredMixin, ListView):
template_name = "udf/udf_list.html"
model = models.UserDefinedForm


class UDFDetailView(LoginRequiredMixin, DetailView):
template_name = "udf/udf_detail.html"
model = models.UserDefinedForm

def get_context_data(self, **kwargs):
form = dynamic_forms.Schema.parse_obj(self.object.schema).to_form()
kwargs.update(form=form)
return super().get_context_data(**kwargs)

def user_can_edit(self):
return self.object.user_can_edit(self.request.user)
munnsmunns marked this conversation as resolved.
Show resolved Hide resolved


class CreateUDFView(LoginRequiredMixin, MessageMixin, CreateView):
template_name = "udf/udf_form.html"
form_class = UDFForm
success_url = reverse_lazy("udf:udf_list")
success_message = "Form created."
shapiromatron marked this conversation as resolved.
Show resolved Hide resolved

def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update(user=self.request.user)
return kwargs

def get_success_url(self) -> str:
return reverse("portal")

class UpdateUDFView(LoginRequiredMixin, MessageMixin, UpdateView):
template_name = "udf/udf_form.html"
form_class = UDFForm
model = models.UserDefinedForm
success_url = reverse_lazy("udf:udf_list")
success_message = "Form updated."

def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update(user=self.request.user)
return kwargs


@method_decorator(htmx_required, name="dispatch")
class SchemaPreview(LoginRequiredMixin, FormView):
"""Custom form schema preview view. Utilizes HTMX."""

template_name = "udf/schema_preview.html"

form_class = SchemaPreviewForm
http_method_names = ["post"]
field_name = "schema"

def get_form_kwargs(self):
"""Get form keyword arguments (the schema)."""
return {"data": {"schema": self.request.POST.get(self.field_name)}}

def get_context_data(self, **kwargs):
"""Get context data used in the template. Add field and modal ID."""
kwargs.update(field=self.field_name, modal_id=f"{self.field_name}-preview")
return super().get_context_data(**kwargs)

def form_valid(self, form):
"""Process a valid dynamic form/schema."""
dynamic_form = dynamic_forms.Schema.parse_obj(form.cleaned_data["schema"]).to_form(
prefix=self.field_name
)
return self.render_to_response(self.get_context_data(valid=True, form=dynamic_form))

def form_invalid(self, form):
"""Process invalid dynamic form/schema."""
return self.render_to_response(self.get_context_data(valid=False))