Skip to content

Commit ea265e4

Browse files
authored
Merge pull request #1516 from kizniche/copilot/add-update-custom-function-module
Add ability to update custom Function module in-place
2 parents 381bac9 + 2c2ee32 commit ea265e4

File tree

7 files changed

+435
-4
lines changed

7 files changed

+435
-4
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# Mycodo files
22
test/
33
tests/
4+
!mycodo/tests/
45
site/
56
env/
67
.venv
78
/databases/flask_secret_key
89
/mycodo/config_override.py
10+
/mycodo/flask_session/
911
*.db
1012
*.pem
1113
*.bak

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ sudo service mycodoflask restart
3737
### Features
3838

3939
- Add API endpoints: /log and /dependency ([#1430](https://github.com/kizniche/Mycodo/issues/1430))
40+
- Add ability to update (overwrite) a custom Function module without deleting it
4041
- Add Input: ENS160 ([#1434](https://github.com/kizniche/Mycodo/pull/1434))
4142
- Add Output: Waveshare 8-Channel Raspberry Pi Relay Board B ([#1434](https://github.com/kizniche/Mycodo/pull/1434))
4243
- Add Function: Camera: rpicam ([#1487](https://github.com/kizniche/Mycodo/pull/1487))

mycodo/mycodo_flask/forms/forms_settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ class ControllerDel(FlaskForm):
182182
delete_controller = SubmitField(TRANSLATIONS['delete']['title'])
183183

184184

185+
class ControllerMod(FlaskForm):
186+
update_controller_file = FileField()
187+
update_controller = SubmitField(lazy_gettext('Replace'))
188+
189+
185190
#
186191
# Settings (Action)
187192
#

mycodo/mycodo_flask/routes_settings.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ def settings_function():
122122

123123
form_controller = forms_settings.Controller()
124124
form_controller_delete = forms_settings.ControllerDel()
125+
form_controller_update = forms_settings.ControllerMod()
125126

126127
# Get list of custom functions
127128
excluded_files = ['__init__.py', '__pycache__']
@@ -134,6 +135,8 @@ def settings_function():
134135
utils_settings.settings_function_import(form_controller)
135136
elif form_controller_delete.delete_controller.data:
136137
utils_settings.settings_function_delete(form_controller_delete)
138+
elif form_controller_update.update_controller.data:
139+
utils_settings.settings_function_update(form_controller_delete, form_controller_update)
137140

138141
return redirect(url_for('routes_settings.settings_function'))
139142

@@ -155,7 +158,8 @@ def settings_function():
155158
return render_template('settings/function.html',
156159
dict_controllers=dict_controllers,
157160
form_controller=form_controller,
158-
form_controller_delete=form_controller_delete)
161+
form_controller_delete=form_controller_delete,
162+
form_controller_update=form_controller_update)
159163

160164

161165
@blueprint.route('/settings/action', methods=('GET', 'POST'))

mycodo/mycodo_flask/templates/settings/function.html

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,24 @@ <h3>{{_('Imported Function Modules')}}</h3>
4242

4343
{% for each_controller in dict_controllers %}
4444

45-
<form method="post" action="/settings/function">
45+
<form method="post" action="/settings/function" enctype="multipart/form-data">
4646
{{form_controller_delete.csrf_token}}
4747
{{form_controller_delete.controller_id(value=each_controller)}}
4848

4949
<tr>
5050
<td>{{each_controller}}</td>
5151
<td>{{dict_controllers[each_controller]['function_name']}}</td>
5252
<td>
53-
<div class="col-12 small-gutters">
54-
{{form_controller_delete.delete_controller(class_='btn btn-primary btn-block', **{'onclick':'return confirm("Are you sure you want to delete this?")'})}}
53+
<div class="row small-gutters">
54+
<div class="col-auto">
55+
{{form_controller_delete.delete_controller(class_='btn btn-primary btn-block', **{'onclick':'return confirm("Are you sure you want to delete this?")'})}}
56+
</div>
57+
<div class="col-auto">
58+
<span class="btn btn-sm btn-file"><input id="update_controller_file_{{each_controller}}" name="update_controller_file" type="file" /></span>
59+
</div>
60+
<div class="col-auto">
61+
{{form_controller_update.update_controller(class_='btn btn-primary btn-block', **{'onclick':'return confirm("Are you sure you want to update this? This will overwrite the existing module.")'})}}
62+
</div>
5563
</div>
5664
</td>
5765
</tr>

mycodo/mycodo_flask/utils/utils_settings.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,128 @@ def settings_function_delete(form):
617617
flash_success_errors(error, action, url_for('routes_settings.settings_function'))
618618

619619

620+
def settings_function_update(form_del, form):
621+
"""
622+
Receive a function module file, check it for errors, replace the existing module
623+
"""
624+
action = '{action} {controller}'.format(
625+
action=gettext("Update"),
626+
controller=TRANSLATIONS['controller']['title'])
627+
error = []
628+
629+
controller_info = None
630+
existing_controller_info = None
631+
632+
try:
633+
install_dir = os.path.abspath(INSTALL_DIRECTORY)
634+
tmp_directory = os.path.join(install_dir, 'mycodo/functions/tmp_functions')
635+
assure_path_exists(tmp_directory)
636+
assure_path_exists(PATH_FUNCTIONS_CUSTOM)
637+
tmp_name = 'tmp_function_testing.py'
638+
full_path_tmp = os.path.join(tmp_directory, tmp_name)
639+
640+
controller_device_name = form_del.controller_id.data
641+
642+
if not form.update_controller_file.data:
643+
error.append('No file present')
644+
elif form.update_controller_file.data.filename == '':
645+
error.append('No file name')
646+
else:
647+
form.update_controller_file.data.save(full_path_tmp)
648+
649+
if not error:
650+
# Load and validate the uploaded (new) module
651+
try:
652+
controller_info, status = load_module_from_file(full_path_tmp, 'functions')
653+
if not controller_info or not hasattr(controller_info, 'FUNCTION_INFORMATION'):
654+
error.append("Could not load FUNCTION_INFORMATION dictionary from "
655+
"the uploaded controller module")
656+
except Exception:
657+
error.append("Could not load uploaded file as a python module:\n"
658+
"{}".format(traceback.format_exc()))
659+
660+
# Load the existing (old) module from disk to extract its function_name_unique
661+
existing_file_path = os.path.join(
662+
PATH_FUNCTIONS_CUSTOM, '{}.py'.format(controller_device_name.lower()))
663+
try:
664+
existing_controller_info, status = load_module_from_file(existing_file_path, 'functions')
665+
if not existing_controller_info or not hasattr(existing_controller_info, 'FUNCTION_INFORMATION'):
666+
error.append("Could not load FUNCTION_INFORMATION dictionary from "
667+
"the existing controller module")
668+
except Exception:
669+
error.append("Could not load existing controller module as a python module:\n"
670+
"{}".format(traceback.format_exc()))
671+
672+
if not error:
673+
if 'function_name_unique' not in controller_info.FUNCTION_INFORMATION:
674+
error.append(
675+
"'function_name_unique' not found in "
676+
"FUNCTION_INFORMATION dictionary")
677+
elif controller_info.FUNCTION_INFORMATION['function_name_unique'] == '':
678+
error.append("'function_name_unique' is empty")
679+
elif (controller_info.FUNCTION_INFORMATION['function_name_unique'].lower() !=
680+
existing_controller_info.FUNCTION_INFORMATION['function_name_unique'].lower()):
681+
error.append(
682+
"'function_name_unique' must match the existing module name '{}', "
683+
"but '{}' was found".format(
684+
existing_controller_info.FUNCTION_INFORMATION['function_name_unique'],
685+
controller_info.FUNCTION_INFORMATION['function_name_unique']))
686+
687+
if 'function_name' not in controller_info.FUNCTION_INFORMATION:
688+
error.append("'function_name' not found in FUNCTION_INFORMATION dictionary")
689+
elif controller_info.FUNCTION_INFORMATION['function_name'] == '':
690+
error.append("'function_name' is empty")
691+
692+
if 'dependencies_module' in controller_info.FUNCTION_INFORMATION:
693+
if not isinstance(controller_info.FUNCTION_INFORMATION['dependencies_module'], list):
694+
error.append("'dependencies_module' must be a list of tuples")
695+
else:
696+
for each_dep in controller_info.FUNCTION_INFORMATION['dependencies_module']:
697+
if not isinstance(each_dep, tuple):
698+
error.append("'dependencies_module' must be a list of tuples")
699+
elif len(each_dep) != 3:
700+
error.append("'dependencies_module': tuples in list must have 3 items")
701+
elif not each_dep[0] or not each_dep[1] or not each_dep[2]:
702+
error.append(
703+
"'dependencies_module': tuples in list must "
704+
"not be empty")
705+
elif each_dep[0] not in ['internal', 'pip-pypi', 'apt']:
706+
error.append(
707+
"'dependencies_module': first in tuple "
708+
"must be 'internal', 'pip-pypi', "
709+
"or 'apt'")
710+
711+
if not error:
712+
# Determine filename from the uploaded module's function_name_unique
713+
unique_name = '{}.py'.format(controller_info.FUNCTION_INFORMATION['function_name_unique'].lower())
714+
715+
# Move module from temp directory to function directory, overwriting the existing module
716+
full_path_final = os.path.join(PATH_FUNCTIONS_CUSTOM, unique_name)
717+
os.rename(full_path_tmp, full_path_final)
718+
719+
# Reload frontend to refresh the controllers
720+
cmd = '{path}/mycodo/scripts/mycodo_wrapper frontend_reload 2>&1'.format(
721+
path=install_dir)
722+
subprocess.Popen(cmd, shell=True)
723+
flash('Frontend reloaded to scan for updated Controller Modules', 'success')
724+
725+
# Restart the backend if any Controller using this module is currently activated
726+
controller_activated = CustomController.query.filter(
727+
CustomController.device == controller_device_name,
728+
CustomController.is_activated.is_(True)).count()
729+
if controller_activated:
730+
cmd = '{path}/mycodo/scripts/mycodo_wrapper daemon_restart 2>&1'.format(
731+
path=install_dir)
732+
subprocess.Popen(cmd, shell=True)
733+
flash('Backend restarted to apply updated Controller Module', 'success')
734+
735+
except Exception as err:
736+
logger.exception("Function Update")
737+
error.append("Exception: {}".format(err))
738+
739+
flash_success_errors(error, action, url_for('routes_settings.settings_function'))
740+
741+
620742
def settings_action_import(form):
621743
"""
622744
Receive an action module file, check it for errors, add it to Mycodo controller list

0 commit comments

Comments
 (0)