Skip to content

Commit

Permalink
Add start of Form file upload support, addresses tetra-framework#67. …
Browse files Browse the repository at this point in the history
…Still needs error checking, validation, and cleanup of files.
  • Loading branch information
gsxdsm committed Nov 18, 2024
1 parent 2172bf0 commit 9f2de5f
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 38 deletions.
38 changes: 36 additions & 2 deletions tetra/components/base.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import logging
from copy import copy
from typing import Optional, Self, Any
from typing import Optional, Self, Any, Dict
from types import FunctionType
from enum import Enum
import inspect
import re
import itertools
import uuid
from weakref import WeakKeyDictionary
from functools import wraps
from threading import local

from django import forms
from django.core.exceptions import ImproperlyConfigured
from django.core.files.storage import default_storage
from django.db import models
from django.db.models import QuerySet
from django.forms import Form, modelform_factory, BaseForm, FileField
Expand Down Expand Up @@ -489,6 +491,8 @@ def make_script(cls, component_var=None) -> str:
method_data = copy(method)
method_data["endpoint"] = (cls._component_url(method["name"]),)
component_server_methods.append(method_data)

component_server_methods.append({"name": "_upload_temp_file", "endpoint": cls._component_url("_upload_temp_file")})
if not component_var:
component_var = cls.script if cls.has_script() else "{}"
return render_to_string(
Expand Down Expand Up @@ -725,6 +729,7 @@ class FormComponent(Component, metaclass=FormComponentMetaClass):
form_class: type(forms.BaseForm) = None
form_submitted: bool = False
form_errors: dict = {} # TODO: make protected + include in render context
form_temp_files: dict = {}

_form: Form = None

Expand Down Expand Up @@ -762,7 +767,10 @@ def _add_alpine_models_to_fields(self, form) -> None:
for field_name, field in form.fields.items():
if field_name in self._public_properties:
if isinstance(field, FileField):
pass
form.fields[field_name].widget.attrs.update({"@change": "_uploadFile"})
if hasattr(field, "temp_file"):
# TODO: Check if we need to send back the temp file name and which attribute to use, might not be necessary
form.fields[field_name].widget.attrs.update({"data-tetra-temp-file": field.temp_file})
else:
# form.fields[field_name].initial = getattr(self, field_name)
form.fields[field_name].widget.attrs.update({"x-model": field_name})
Expand Down Expand Up @@ -802,6 +810,17 @@ def submit(self) -> None:
The component will validate the data against the form, and if the form is valid,
it will call form_valid(), else form_invalid().
"""

# find all temporary files in the form and read them and write to the form fields
for field_name, file_details in self.form_temp_files.items():
if file_details:
storage = self._form.fields[field_name].storage if hasattr(self._form.fields[field_name], 'storage') else default_storage
with storage.open(file_details["temp_name"], 'rb') as file:
storage.save(file_details["original_name"], file)
# TODO: Add error checking and double check the form value is being set correctly
storage.delete(file_details["temp_name"])
self._form.fields[field_name].initial = file_details["original_name"]

self.form_submitted = True

if self._form.is_valid():
Expand All @@ -816,6 +835,21 @@ def submit(self) -> None:
setattr(self, attr, TetraJSONEncoder().default(value))
self.form_invalid(self._form)

@public
def _upload_temp_file(self, form_field, original_name, file) -> str | None:
"""Uploads a file to the server temporarily."""
# TODO: Add validation
if file and form_field in self._form.fields:
temp_file_name = f"tetra_temp_upload/{uuid.uuid4()}"
storage = self._form.fields[form_field].storage if hasattr(self._form.fields[form_field], 'storage') else default_storage
storage.save(temp_file_name, file)
# TODO: Add error checking, double check this - it seems like we need call setattr as well as setting directly?
self.form_temp_files[form_field] = dict(temp_name = temp_file_name, original_name = original_name)
setattr(self, self.form_temp_files[form_field]["temp_name"], temp_file_name)
setattr(self, self.form_temp_files[form_field]["original_name"], original_name)
return temp_file_name
return None

def clear(self):
"""Clears the form data (sets all values to defaults) and renders the
component."""
Expand Down
64 changes: 50 additions & 14 deletions tetra/js/tetra.core.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ const Tetra = {
window.history.pushState(null, '', url);
}
},
_uploadFile(event) {
// TODO: Consider how multiple files can be handled
const file = event.target.files[0];
const method = '_upload_temp_file';
const endpoint = this.__serverMethods.find(item => item.name === '_upload_temp_file').endpoint;
const args = [event.target.name, event.target.files[0].name];
Tetra.callServerMethodWithFile(this, method, endpoint, file, args).then((result) => {
//TODO: Determine if we need to do anything with the resulting filename
//event.target.dataset.tetraTempFileName = result;
//this._updateData(result);
});

},
// Tetra private:
__initServerWatchers() {
this.__serverMethods.forEach(item => {
Expand Down Expand Up @@ -198,21 +211,9 @@ const Tetra = {
});
},

async callServerMethod(component, methodName, methodEndpoint, args) {
// TODO: error handling
let body = Tetra.getStateWithChildren(component);
body.args = args;
const response = await fetch(methodEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': window.__tetra_csrfToken,
},
mode: 'same-origin',
body: Tetra.jsonEncode(body),
});
async handleServerMethodResponse(response, component) {
if (response.status === 200) {
const respData = Tetra.jsonDecode(await response.text());
const respData = Tetra.jsonDecode(await response.text());
if (respData.success) {
let loadingResources = [];
respData.js.forEach(src => {
Expand Down Expand Up @@ -249,6 +250,41 @@ const Tetra = {
throw new Error(`Server responded with an error ${response.status} (${response.statusText})`);
}
},
async callServerMethod(component, methodName, methodEndpoint, args) {
// TODO: error handling
let body = Tetra.getStateWithChildren(component);
body.args = args;
const response = await fetch(methodEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': window.__tetra_csrfToken,
},
mode: 'same-origin',
body: Tetra.jsonEncode(body),
});
return await this.handleServerMethodResponse(response, component);
},

async callServerMethodWithFile(component, methodName, methodEndpoint, file, args) {
// TODO: error handling
let state = Tetra.getStateWithChildren(component);
state.args = args;
let formData = new FormData();
formData.append('file', file);
formData.append('state', Tetra.jsonEncode(state));
//body.args = args;
const response = await fetch(methodEndpoint, {
method: 'POST',
headers: {
//'Content-Type': 'application/json',
'X-CSRFToken': window.__tetra_csrfToken,
},
mode: 'same-origin',
body: formData,
});
return await this.handleServerMethodResponse(response, component);
},

jsonReplacer(key, value) {
if (value instanceof Date) {
Expand Down
51 changes: 39 additions & 12 deletions tetra/static/tetra/js/tetra.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 9f2de5f

Please sign in to comment.