I have a inlineformset_factory that I am trying to dynamically add forms to.
I found some code from stack overflow here to help with this.
However, when I try to save the formset, I get an error that says:
[{'transaction_line_account_1': ['“6b5e3acb-18f9-0-97b2-61e76fd101c2” is not a valid UUID.'], 'transaction_line_account_2': ['“8463b972-65ad-0-804a-d916d0b162d5” is not a valid UUID.'], 'transaction_line_tag': ['“a34b8f5b-619d-0-ba3e-0c00392dc6b1” is not a valid UUID.']}]
These are all valid UUID’s in my database.
This error only occurs with forms that are added using the javascript. If the form is included as an “extra” in the inlineformset_factory, it saves just fine. I am lost as to what might be happening.
My form(s):
class TransactionLineForm(ModelForm):
class Meta:
model = TransactionLine
fields = [
'transaction_line_account_1',
'transaction_line_account_2',
'transaction_line_merchant',
'transaction_line_amount',
'transaction_line_description',
'transaction_line_tag',
]
labels = {
'transaction_line_account_1': 'Account',
'transaction_line_account_2': 'Category (or Account)',
'transaction_line_merchant': 'Merchant',
'transaction_line_amount': 'Amount',
'transaction_line_description': 'Description',
'transaction_line_tag': 'Tag(s)',
}
widgets = {
'transaction_line_account_1': forms.Select(attrs={'class': 'bg-white mb-5 w-full px-3 py-3 border-2 border-blue-500 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50', 'required': True}),
'transaction_line_account_2': forms.Select(attrs={'class': 'bg-white mb-5 w-full px-3 py-3 border-2 border-blue-500 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50', 'required': True}),
'transaction_line_merchant': forms.Select(attrs={'class': 'bg-white mb-5 w-full px-3 py-3 border-2 border-blue-500 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50'}),
'transaction_line_amount': forms.NumberInput(attrs={'class': 'mb-5 w-full px-3 py-3 border-2 border-blue-500 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50', 'required': True}),
'transaction_line_description': forms.TextInput(attrs={'class': 'mb-5 w-full px-3 py-3 border-2 border-blue-500 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50', 'required': True}),
'transaction_line_tag': forms.SelectMultiple(attrs={'class': 'mb-5 w-full px-3 py-3 border-2 border-blue-500 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50'}),
}
def __init__(self, *args, **kwargs):
self.current_user = kwargs.pop('current_user', None)
super().__init__(*args, **kwargs)
self.fields['transaction_line_account_1'] = GroupedModelChoiceField(queryset=Account.objects.filter(Q(account_class__account_class='Asset') | Q(account_class__account_class='Liability'), household=self.current_user.household).order_by('account_class__account_subtype', 'account_name'), choices_groupby='account_class', widget=forms.Select(attrs={'class': 'bg-white mb-5 w-full px-3 py-3 border-2 border-blue-500 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50', 'required': True}), label='Account')
self.fields['transaction_line_account_2'] = GroupedModelChoiceField(queryset=Account.objects.filter(household=self.current_user.household).exclude(Q(account_class__account_class='Income') | Q(account_class__account_class='Expense'), account_parent__isnull=True).exclude(account_name='Opening Balance').annotate(custom_order=Case(When(account_class__account_class='Income', then=Value(2)),When(account_class__account_class='Expense', then=Value(1)),output_field=IntegerField())).order_by('-custom_order','account_parent__account_name', 'account_name'), choices_groupby='account_parent', widget=forms.Select(attrs={'class': 'bg-white mb-5 w-full px-3 py-3 border-2 border-blue-500 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50', 'required': True}), label='Category (or Account)')
self.fields['transaction_line_merchant'].queryset = Merchant.objects.filter(household=self.current_user.household).exclude(merchant_name='Opening Balance Merchant').order_by('merchant_name')
self.fields['transaction_line_tag'].queryset = Tag.objects.filter(household=self.current_user.household).exclude(tag_type='Parent').order_by('tag_name')
TransactionLineFormset = inlineformset_factory(Transaction, TransactionLine, form=TransactionLineForm, extra=2, can_delete=True)
My view:
@login_required
def CashflowTransactionCreate(request):
transaction_form = TransactionForm(request.POST or None)
transaction_line_formset = TransactionLineFormset(form_kwargs={'current_user': request.user}) # form_kwargs are used to pass the request.user to the inner form of the formset
context = {'transaction_form': transaction_form, 'transaction_line_formset': transaction_line_formset}
if request.method == 'POST':
# Check and create Transaction header
if transaction_form.is_valid():
transaction_form_instance = transaction_form.save(commit=False)
transaction_form_instance.household = request.user.household
transaction_form_instance.created_by = request.user
transaction_line_formset = TransactionLineFormset(request.POST, instance=transaction_form_instance, form_kwargs={'current_user': request.user})
if transaction_line_formset.is_valid():
# DO LOGIC CHECKS
transaction_line_formset_instance = transaction_line_formset.save(commit=False)
for form in transaction_line_formset_instance:
# If the transaction line accounts are the same, throw error.
if form.transaction_line_account_1 == form.transaction_line_account_2:
messages.error(request, 'Transaction line accounts cannot be the same. You must use different accounts for transfers.')
return render(request, 'cashflow/transaction-create.html', context)
# Set transaction line status
form.transaction_line_status = 'Reviewed'
transaction_form_instance.save()
transaction_line_formset.save()
return redirect('transaction-list')
else:
print(transaction_line_formset.errors)
else:
print(transaction_form.errors)
return render(request, 'cashflow/transaction-create.html', context)
Template:
{% extends 'base.html' %}
{% load static %}
{% block content %}
<section class="pt-10">
<div class="mb-10">
<h1 class="text-4xl text-center">Create a New Transaction</h1>
</div>
<div class="flex justify-center">
<template id="id_formset_empty_form">{{ transaction_line_formset.empty_form }}</template>
<form method="POST" id="id_html_form" autocomplete="off">
{% csrf_token %}
{{ transaction_line_formset.management_form }}
{{ transaction_form.as_p }}
<hr class="pb-2 border-black h-5px">
<table id="id_formset_container">
{{ transaction_line_formset }}
</table>
<div id="id_formset_add_button" class="px-6 py-3 mb-2 font-medium text-white bg-blue-500 hover:bg-blue-600 rounded transition duration-200">Add lines</div>
<br>
<button id="id_formset_submit_button" class="px-6 py-3 font-medium text-white bg-blue-500 hover:bg-blue-600 rounded transition duration-200" type="submit">Create Transaction</button>
</form>
</div>
</section>
<script type="text/javascript" src="{% static 'js/cashflow.js' %}" onload="currentDate()"></script>
{% endblock content %}
Javascript:
// DYNAMICALLY ADD FORMS TO TRANSACTION LINE FORMSET
window.addEventListener('load', (event) => {
// get form template and total number of forms from management form
const templateForm = document.getElementById('id_formset_empty_form');
const inputTotalForms = document.querySelector('input[id$="-TOTAL_FORMS"]');
const inputInitialForms = document.querySelector('input[id$="-INITIAL_FORMS"]');
// get our container (e.g. <table>, <ul>, or <div>) and "Add" button
const containerFormSet = document.getElementById('id_formset_container');
const buttonAdd = document.getElementById('id_formset_add_button');
const buttonSubmit = document.getElementById('id_formset_submit_button');
// event handlers
buttonAdd.onclick = addForm;
buttonSubmit.onclick = updateNameAttributes;
// form counters (note: proper form index bookkeeping is necessary
// because django's formset will create empty forms for any missing
// indices, and will discard forms with indices >= TOTAL_FORMS, which can
// lead to funny behavior in some edge cases)
const initialForms = Number(inputInitialForms.value);
let extraFormIndices = [];
let nextFormIndex = initialForms;
function addForm () {
// create DocumentFragment from template
const formFragment = templateForm.content.cloneNode(true);
// a django form is rendered as_table (default), as_ul, or as_p, so
// the fragment will contain one or more <tr>, <li>, or <p> elements,
// respectively.
for (let element of formFragment.children) {
// replace the __prefix__ placeholders from the empty form by the
// actual form index
element.innerHTML = element.innerHTML.replace(
/(?<=\w+-)(__prefix__|\d+)(?=-\w+)/g,
nextFormIndex.toString());
// add a custom attribute to simplify bookkeeping
element.dataset.formIndex = nextFormIndex.toString();
// add a delete click handler (if formset can_delete)
setDeleteHandler(element);
}
// move the fragment's children onto the DOM
// (the fragment is empty afterwards)
containerFormSet.appendChild(formFragment);
// keep track of form indices
extraFormIndices.push(nextFormIndex++);
}
function removeForm (event) {
// remove all elements with form-index matching that of the delete-input
const formIndex = event.target.dataset.formIndex;
for (let element of getFormElements(formIndex)) {
element.remove();
}
// remove form index from array
let indexIndex = extraFormIndices.indexOf(Number(formIndex));
if (indexIndex > -1) {
extraFormIndices.splice(indexIndex, 1);
}
}
function setDeleteHandler (containerElement) {
// modify DELETE checkbox in containerElement, if the checkbox exists
// (these checboxes are added by formset if can_delete)
const inputDelete = containerElement.querySelector('input[id$="-DELETE"]');
if (inputDelete) {
// duplicate the form index instead of relying on parentElement (more robust)
inputDelete.dataset.formIndex = containerElement.dataset.formIndex;
inputDelete.onclick = removeForm;
}
}
function getFormElements(index) {
// the data-form-index attribute is available as dataset.formIndex
// https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes#javascript_access
return containerFormSet.querySelectorAll('[data-form-index="' + index + '"]');
}
function updateNameAttributes (event) {
// make sure the name indices are consecutive and smaller than
// TOTAL_FORMS (the name attributes end up as dict keys on the server)
// note we do not need to update the indices in the id attributes etc.
for (let [consecutiveIndex, formIndex] of extraFormIndices.entries()) {
for (let formElement of getFormElements(formIndex)){
for (let element of formElement.querySelectorAll('input, select')) {
if ('name' in element) {
element.name = element.name.replace(
/(?<=\w+-)(__prefix__|\d+)(?=-\w+)/g,
(initialForms + consecutiveIndex).toString());
}
}
}
}
updateTotalFormCount();
}
function updateTotalFormCount (event) {
// note we could simply do initialForms + extraFormIndices.length
// to get the total form count, but that does not work if we have
// validation errors on forms that were added dynamically
const firstElement = templateForm.content.querySelector('input, select');
// select the first input or select element, then count how many ids
// with the same suffix occur in the formset container
if (firstElement) {
let suffix = firstElement.id.split('__prefix__')[1];
let selector = firstElement.tagName.toLowerCase() + '[id$="' + suffix + '"]';
let allElementsForId = containerFormSet.querySelectorAll(selector);
// update total form count
inputTotalForms.value = allElementsForId.length;
}
}
}, false);
Model(s):
class Transaction(models.Model): # Stores the transaction header
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, primary_key=True)
household = models.ForeignKey(Household, on_delete=models.PROTECT, null=False, blank=False)
transaction_date = models.DateField(null=False, blank=False)
transaction_description = models.CharField(null=False, blank=False, max_length=250)
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
created_time = models.DateTimeField(auto_now_add=True)
def __str__(self):
return str(self.transaction_description)
class TransactionLine(models.Model): # Stores all the line details of the transactions
TRANSACTION_LINE_STATUS_CHOICES = (
('Pending', 'Pending'),
('Reviewed', 'Reviewed'),
('Reconciled', 'Reconciled'),
)
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, primary_key=True)
transaction = models.ForeignKey(Transaction, on_delete=models.PROTECT, null=False, blank=False)
transaction_line_account_1 = models.ForeignKey(Account, on_delete=models.PROTECT, null=False, blank=False, related_name='transaction_line_account_1')
transaction_line_account_2 = models.ForeignKey(Account, on_delete=models.PROTECT, null=False, blank=False, related_name='transaction_line_account_2')
transaction_line_merchant = models.ForeignKey(Merchant, on_delete=models.PROTECT, null=False, blank=False)
transaction_line_tag = models.ManyToManyField(Tag, blank=True)
transaction_line_amount = models.DecimalField(max_digits=12, decimal_places=2, null=False, blank=False)
transaction_line_description = models.CharField(null=False, blank=False, max_length=255)
transaction_line_status = models.CharField(choices=TRANSACTION_LINE_STATUS_CHOICES, max_length=15, null=False, blank=False)
def __str__(self):
return str(self.transaction_line_description)