Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/modules/script.py: 0%
116 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
1import io
2import typing as t
3from viur.core.bones import *
4from viur.core.prototypes.tree import Tree, TreeSkel, SkelType
5from viur.core.modules.file import File
6from viur.core import db, conf, current, skeleton, tasks, errors
7from viur.core.decorators import exposed
8from viur.core.i18n import translate
9import zipfile
12class BaseScriptAbstractSkel(TreeSkel):
13 path = StringBone(
14 descr="Path",
15 readOnly=True,
16 unique=UniqueValue(UniqueLockMethod.SameValue, True, "This path is already taken!")
17 )
19 @classmethod
20 def fromClient(cls, skel, data, *args, **kwargs):
21 # Set script name when provided, so that the path can be regenerated
22 if name := data.get("name"):
23 skel["name"] = name
24 conf.main_app.script.update_path(skel)
26 ret = super().fromClient(skel, data, *args, **kwargs)
28 if not ret:
29 # in case the path failed because the unique value is already taken, rewrite the error for name field
30 for error in skel.errors:
31 if error.severity == ReadFromClientErrorSeverity.Invalid and error.fieldPath == ["path"]:
32 error.fieldPath = ["name"]
33 break
35 return ret
38class ScriptNodeSkel(BaseScriptAbstractSkel):
39 kindName = "viur-script-node"
41 rootNode = BooleanBone(
42 descr="Is root node?",
43 defaultValue=False,
44 )
46 plugin = BooleanBone(
47 descr="Is plugin?",
48 defaultValue=False
49 )
51 name = StringBone(
52 descr="Folder",
53 required=True,
54 vfunc=lambda value: None if File.is_valid_filename(value) else "Foldername is invalid"
55 )
58class ScriptLeafSkel(BaseScriptAbstractSkel):
59 kindName = "viur-script-leaf"
61 name = StringBone(
62 descr="Filename",
63 required=True,
64 vfunc=lambda value:
65 None if File.is_valid_filename(value) and value.endswith(".py") and value.removesuffix(".py")
66 else "Filename is invalid or doesn't have a '.py'-suffix",
67 )
69 script = PythonBone(
70 descr="Code",
71 )
73 access = SelectBone(
74 descr="Required access rights to run this Script",
75 values=lambda: {
76 right: translate(f"viur.core.modules.user.accessright.{right}", defaultText=right)
77 for right in sorted(conf.user.access_rights)
78 },
79 multiple=True,
80 )
83class Script(Tree):
84 """
85 Script is a system module used to serve a filesystem for scripts used by ViUR Scriptor and ViUR CLI.
86 """
88 leafSkelCls = ScriptLeafSkel
89 nodeSkelCls = ScriptNodeSkel
91 roles = {
92 "admin": "*",
93 }
95 def adminInfo(self):
96 return conf.script_admin_info or {}
98 def getAvailableRootNodes(self):
99 if not current.user.get():
100 return []
102 return [{
103 "name": "Scripts",
104 "key": self.rootnodeSkel(ensure=True)["key"],
105 }]
107 @exposed
108 def view(self, skelType: SkelType, key: db.KeyType, *args, **kwargs) -> t.Any:
109 try:
110 return super().view(skelType, key, *args, **kwargs)
111 except errors.NotFound:
112 # When key is not found, try to interpret key as path
113 if skel := self.viewSkel(skelType).all().mergeExternalFilter({"path": key}).getSkel():
114 return super().view(skelType, skel["key"], *args, **kwargs)
116 raise
118 def onEdit(self, skelType, skel):
119 self.update_path(skel)
120 super().onEdit(skelType, skel)
122 def onEdited(self, skelType, skel):
123 old_path = skel["path"]
124 self.update_path(skel)
125 if skel["path"] != old_path:
126 skel.patch({"path": skel["path"]})
128 if skelType == "node":
129 self.update_path_recursive("node", skel["path"], skel["key"])
130 self.update_path_recursive("leaf", skel["path"], skel["key"])
132 super().onEdited(skelType, skel)
134 @tasks.CallDeferred
135 def update_path_recursive(self, skel_type, path, parent_key, cursor=None):
136 """
137 Recursively updates all items under a given parent key.
138 """
139 query = self.editSkel(skel_type).all().filter("parententry", parent_key)
140 query.setCursor(cursor)
142 for skel in query.fetch(99):
143 new_path = path + "/" + skel["name"]
145 # only update when path changed
146 if new_path != skel["path"]:
147 skel["path"] = new_path # self.onEdit() is NOT required, as it resolves the path again.
148 skel.write()
149 self.onEdited(skel_type, skel) # triggers this recursion for nodes, again.
151 if cursor := query.getCursor():
152 self.update_path_recursive(skel_type, path, parent_key, cursor)
154 def update_path(self, skel):
155 """
156 Updates the path-value of a either a folder or a script file, by resolving the repository's root node.
157 """
158 path = [skel["name"]]
160 key = skel["parententry"]
161 while key:
162 parent_skel = self.viewSkel("node")
163 if not parent_skel.read(key) or parent_skel["key"] == skel["parentrepo"]:
164 break
166 if parent_skel["name"]:
167 path.insert(0, parent_skel["name"])
168 key = parent_skel["parententry"]
169 skel["path"] = "/".join(path)
171 @exposed
172 def get_importable(self):
174 def get_files_recursively(_importable_key):
175 res = []
176 importable_files_query = self.viewSkel("leaf").all().filter("parententry", _importable_key)
177 if not (importable_files_query := self.listFilter(importable_files_query)):
178 raise errors.Unauthorized()
179 for script_entry in importable_files_query.iter():
180 if script_entry["script"]:
181 res.append(script_entry)
182 importable_files_query = self.viewSkel("node").all().filter("parententry", _importable_key)
183 for folder_entry in importable_files_query.iter():
184 res.extend(get_files_recursively(folder_entry.key))
185 return res
187 # get importable key
188 qry_importable = (self.viewSkel("node").all()
189 .filter("parententry", self.rootnodeSkel(ensure=True)["key"])
190 .filter("name =", "importable"))
191 if not (qry_importable := self.listFilter(qry_importable)):
192 raise errors.Unauthorized()
194 importable_key = (entity := qry_importable.getEntry()) and entity.key
195 if not importable_key:
196 raise errors.NotFound("No importable folder defined")
198 importable_files = get_files_recursively(importable_key)
199 if not importable_files:
200 raise errors.NotFound("Importable folder is empty")
202 zip_buffer = io.BytesIO()
203 with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
204 for file in importable_files:
205 zip_file.writestr(file["path"], file["script"])
207 current.request.get().response.headers["Content-Disposition"] = "attachment; filename=importable.zip"
208 current.request.get().response.headers["Content-Type"] = "application/zip"
209 return zip_buffer.getvalue()