2020-05-25 19:30:06 +00:00
|
|
|
import string
|
2020-05-04 14:57:09 +00:00
|
|
|
from datetime import datetime
|
2020-05-25 19:30:06 +00:00
|
|
|
import random
|
2020-05-04 14:57:09 +00:00
|
|
|
|
2020-05-19 20:11:43 +00:00
|
|
|
import jinja2
|
|
|
|
from SpiffWorkflow import Task as SpiffTask, WorkflowException
|
2020-04-19 19:14:10 +00:00
|
|
|
from SpiffWorkflow.bpmn.specs.ManualTask import ManualTask
|
|
|
|
from SpiffWorkflow.bpmn.specs.ScriptTask import ScriptTask
|
|
|
|
from SpiffWorkflow.bpmn.specs.UserTask import UserTask
|
2020-05-04 14:57:09 +00:00
|
|
|
from SpiffWorkflow.dmn.specs.BusinessRuleTask import BusinessRuleTask
|
2020-04-19 19:14:10 +00:00
|
|
|
from SpiffWorkflow.specs import CancelTask, StartTask
|
2020-06-01 21:42:28 +00:00
|
|
|
from SpiffWorkflow.util.deep_merge import DeepMerge
|
2020-05-04 14:57:09 +00:00
|
|
|
from flask import g
|
2020-05-19 20:11:43 +00:00
|
|
|
from jinja2 import Template
|
2020-04-15 15:13:32 +00:00
|
|
|
|
2020-05-14 17:43:23 +00:00
|
|
|
from crc import db, app
|
2020-04-15 15:13:32 +00:00
|
|
|
from crc.api.common import ApiError
|
2020-04-21 15:43:43 +00:00
|
|
|
from crc.models.api_models import Task, MultiInstanceType
|
2020-05-19 20:11:43 +00:00
|
|
|
from crc.models.file import LookupDataModel
|
2020-05-04 14:57:09 +00:00
|
|
|
from crc.models.stats import TaskEventModel
|
2020-05-29 08:42:48 +00:00
|
|
|
from crc.models.study import StudyModel
|
|
|
|
from crc.models.user import UserModel
|
2020-05-29 05:39:39 +00:00
|
|
|
from crc.models.workflow import WorkflowModel, WorkflowStatus
|
2020-04-15 15:13:32 +00:00
|
|
|
from crc.services.file_service import FileService
|
2020-05-19 20:11:43 +00:00
|
|
|
from crc.services.lookup_service import LookupService
|
2020-05-29 08:42:48 +00:00
|
|
|
from crc.services.study_service import StudyService
|
2020-04-15 15:13:32 +00:00
|
|
|
from crc.services.workflow_processor import WorkflowProcessor, CustomBpmnScriptEngine
|
|
|
|
|
|
|
|
|
|
|
|
class WorkflowService(object):
|
2020-05-04 14:57:09 +00:00
|
|
|
TASK_ACTION_COMPLETE = "Complete"
|
|
|
|
TASK_ACTION_TOKEN_RESET = "Backwards Move"
|
|
|
|
TASK_ACTION_HARD_RESET = "Restart (Hard)"
|
|
|
|
TASK_ACTION_SOFT_RESET = "Restart (Soft)"
|
|
|
|
|
2020-04-15 15:13:32 +00:00
|
|
|
"""Provides tools for processing workflows and tasks. This
|
|
|
|
should at some point, be the only way to work with Workflows, and
|
|
|
|
the workflow Processor should be hidden behind this service.
|
|
|
|
This will help maintain a structure that avoids circular dependencies.
|
|
|
|
But for now, this contains tools for converting spiff-workflow models into our
|
2020-05-30 19:37:04 +00:00
|
|
|
own API models with additional information and capabilities and
|
|
|
|
handles the testing of a workflow specification by completing it with
|
|
|
|
random selections, attempting to mimic a front end as much as possible. """
|
2020-04-15 15:13:32 +00:00
|
|
|
|
2020-05-29 08:42:48 +00:00
|
|
|
@staticmethod
|
|
|
|
def make_test_workflow(spec_id):
|
|
|
|
user = db.session.query(UserModel).filter_by(uid="test").first()
|
|
|
|
if not user:
|
|
|
|
db.session.add(UserModel(uid="test"))
|
|
|
|
study = db.session.query(StudyModel).filter_by(user_uid="test").first()
|
|
|
|
if not study:
|
|
|
|
db.session.add(StudyModel(user_uid="test", title="test"))
|
|
|
|
db.session.commit()
|
2020-05-29 05:39:39 +00:00
|
|
|
workflow_model = WorkflowModel(status=WorkflowStatus.not_started,
|
|
|
|
workflow_spec_id=spec_id,
|
|
|
|
last_updated=datetime.now(),
|
2020-05-29 08:42:48 +00:00
|
|
|
study=study)
|
|
|
|
return workflow_model
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def delete_test_data():
|
2020-06-12 18:13:27 +00:00
|
|
|
for study in db.session.query(StudyModel).filter(StudyModel.user_uid == "test"):
|
2020-05-29 08:42:48 +00:00
|
|
|
StudyService.delete_study(study.id)
|
|
|
|
db.session.commit()
|
2020-05-30 19:37:04 +00:00
|
|
|
|
|
|
|
user = db.session.query(UserModel).filter_by(uid="test").first()
|
|
|
|
if user:
|
|
|
|
db.session.delete(user)
|
2020-05-29 08:42:48 +00:00
|
|
|
|
|
|
|
@staticmethod
|
2020-05-30 21:21:57 +00:00
|
|
|
def test_spec(spec_id, required_only=False):
|
2020-05-30 19:37:04 +00:00
|
|
|
"""Runs a spec through it's paces to see if it results in any errors.
|
|
|
|
Not fool-proof, but a good sanity check. Returns the final data
|
2020-05-30 21:21:57 +00:00
|
|
|
output form the last task if successful.
|
|
|
|
|
|
|
|
required_only can be set to true, in which case this will run the
|
|
|
|
spec, only completing the required fields, rather than everything.
|
|
|
|
"""
|
2020-05-29 08:42:48 +00:00
|
|
|
|
|
|
|
workflow_model = WorkflowService.make_test_workflow(spec_id)
|
|
|
|
|
2020-05-29 05:39:39 +00:00
|
|
|
try:
|
|
|
|
processor = WorkflowProcessor(workflow_model, validate_only=True)
|
|
|
|
except WorkflowException as we:
|
2020-05-29 08:42:48 +00:00
|
|
|
WorkflowService.delete_test_data()
|
2020-06-03 19:03:22 +00:00
|
|
|
raise ApiError.from_workflow_exception("workflow_validation_exception", str(we), we)
|
2020-04-15 15:13:32 +00:00
|
|
|
|
2020-05-29 05:39:39 +00:00
|
|
|
while not processor.bpmn_workflow.is_completed():
|
2020-04-15 15:13:32 +00:00
|
|
|
try:
|
2020-05-29 05:39:39 +00:00
|
|
|
processor.bpmn_workflow.do_engine_steps()
|
|
|
|
tasks = processor.bpmn_workflow.get_tasks(SpiffTask.READY)
|
2020-04-15 15:13:32 +00:00
|
|
|
for task in tasks:
|
|
|
|
task_api = WorkflowService.spiff_task_to_api_task(
|
2020-05-15 19:54:53 +00:00
|
|
|
task,
|
|
|
|
add_docs_and_forms=True) # Assure we try to process the documenation, and raise those errors.
|
2020-05-30 21:21:57 +00:00
|
|
|
WorkflowService.populate_form_with_random_data(task, task_api, required_only)
|
2020-04-15 15:13:32 +00:00
|
|
|
task.complete()
|
|
|
|
except WorkflowException as we:
|
2020-05-29 08:42:48 +00:00
|
|
|
WorkflowService.delete_test_data()
|
2020-06-03 19:03:22 +00:00
|
|
|
raise ApiError.from_workflow_exception("workflow_validation_exception", str(we), we)
|
2020-05-30 19:37:04 +00:00
|
|
|
|
2020-05-29 08:42:48 +00:00
|
|
|
WorkflowService.delete_test_data()
|
2020-05-30 19:37:04 +00:00
|
|
|
return processor.bpmn_workflow.last_task.data
|
2020-04-15 15:13:32 +00:00
|
|
|
|
2020-05-25 19:30:06 +00:00
|
|
|
@staticmethod
|
2020-05-30 21:21:57 +00:00
|
|
|
def populate_form_with_random_data(task, task_api, required_only):
|
2020-05-25 19:30:06 +00:00
|
|
|
"""populates a task with random data - useful for testing a spec."""
|
|
|
|
|
|
|
|
if not hasattr(task.task_spec, 'form'): return
|
|
|
|
|
2020-06-02 22:17:00 +00:00
|
|
|
form_data = task.data # Just like with the front end, we start with what was already there, and modify it.
|
2020-05-25 19:30:06 +00:00
|
|
|
for field in task_api.form.fields:
|
2020-05-30 21:21:57 +00:00
|
|
|
if required_only and (not field.has_validation(Task.VALIDATION_REQUIRED) or
|
|
|
|
field.get_validation(Task.VALIDATION_REQUIRED).lower().strip() != "true"):
|
|
|
|
continue # Don't include any fields that aren't specifically marked as required.
|
2020-06-02 22:17:00 +00:00
|
|
|
if field.has_property("read_only") and field.get_property("read_only").lower().strip() == "true":
|
|
|
|
continue # Don't mess about with read only fields.
|
2020-05-30 19:37:04 +00:00
|
|
|
if field.has_property(Task.PROP_OPTIONS_REPEAT):
|
|
|
|
group = field.get_property(Task.PROP_OPTIONS_REPEAT)
|
|
|
|
if group not in form_data:
|
|
|
|
form_data[group] = [{},{},{}]
|
|
|
|
for i in range(3):
|
|
|
|
form_data[group][i][field.id] = WorkflowService.get_random_data_for_field(field, task)
|
|
|
|
else:
|
|
|
|
form_data[field.id] = WorkflowService.get_random_data_for_field(field, task)
|
|
|
|
if task.data is None:
|
|
|
|
task.data = {}
|
|
|
|
task.data.update(form_data)
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def get_random_data_for_field(field, task):
|
|
|
|
if field.type == "enum":
|
|
|
|
if len(field.options) > 0:
|
|
|
|
random_choice = random.choice(field.options)
|
|
|
|
if isinstance(random_choice, dict):
|
|
|
|
return random.choice(field.options)['id']
|
2020-05-25 19:30:06 +00:00
|
|
|
else:
|
2020-05-30 19:37:04 +00:00
|
|
|
# fixme: why it is sometimes an EnumFormFieldOption, and other times not?
|
|
|
|
return random_choice.id ## Assume it is an EnumFormFieldOption
|
|
|
|
else:
|
|
|
|
raise ApiError.from_task("invalid_enum", "You specified an enumeration field (%s),"
|
|
|
|
" with no options" % field.id, task)
|
|
|
|
elif field.type == "autocomplete":
|
|
|
|
lookup_model = LookupService.get_lookup_model(task, field)
|
|
|
|
if field.has_property(Task.PROP_LDAP_LOOKUP): # All ldap records get the same person.
|
|
|
|
return {
|
2020-05-27 00:06:50 +00:00
|
|
|
"label": "dhf8r",
|
|
|
|
"value": "Dan Funk",
|
|
|
|
"data": {
|
|
|
|
"uid": "dhf8r",
|
|
|
|
"display_name": "Dan Funk",
|
|
|
|
"given_name": "Dan",
|
|
|
|
"email_address": "dhf8r@virginia.edu",
|
|
|
|
"department": "Depertment of Psychocosmographictology",
|
|
|
|
"affiliation": "Rousabout",
|
2020-05-30 19:37:04 +00:00
|
|
|
"sponsor_type": "Staff"}
|
2020-05-27 00:06:50 +00:00
|
|
|
}
|
2020-05-30 19:37:04 +00:00
|
|
|
elif lookup_model:
|
|
|
|
data = db.session.query(LookupDataModel).filter(
|
|
|
|
LookupDataModel.lookup_file_model == lookup_model).limit(10).all()
|
|
|
|
options = []
|
|
|
|
for d in data:
|
2020-06-01 15:00:56 +00:00
|
|
|
options.append({"id": d.value, "label": d.label})
|
2020-05-30 19:37:04 +00:00
|
|
|
return random.choice(options)
|
2020-05-25 19:30:06 +00:00
|
|
|
else:
|
2020-06-03 19:03:22 +00:00
|
|
|
raise ApiError.from_task("unknown_lookup_option", "The settings for this auto complete field "
|
2020-05-30 19:37:04 +00:00
|
|
|
"are incorrect: %s " % field.id, task)
|
|
|
|
elif field.type == "long":
|
|
|
|
return random.randint(1, 1000)
|
|
|
|
elif field.type == 'boolean':
|
|
|
|
return random.choice([True, False])
|
|
|
|
elif field.type == 'file':
|
|
|
|
# fixme: produce some something sensible for files.
|
|
|
|
return random.randint(1, 100)
|
|
|
|
# fixme: produce some something sensible for files.
|
|
|
|
elif field.type == 'files':
|
|
|
|
return random.randrange(1, 100)
|
|
|
|
else:
|
|
|
|
return WorkflowService._random_string()
|
2020-05-25 19:30:06 +00:00
|
|
|
|
2020-05-27 00:06:50 +00:00
|
|
|
def __get_options(self):
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
2020-05-25 19:30:06 +00:00
|
|
|
@staticmethod
|
|
|
|
def _random_string(string_length=10):
|
|
|
|
"""Generate a random string of fixed length """
|
|
|
|
letters = string.ascii_lowercase
|
|
|
|
return ''.join(random.choice(letters) for i in range(string_length))
|
|
|
|
|
2020-04-15 15:13:32 +00:00
|
|
|
@staticmethod
|
2020-05-06 17:01:38 +00:00
|
|
|
def spiff_task_to_api_task(spiff_task, add_docs_and_forms=False):
|
2020-04-19 19:14:10 +00:00
|
|
|
task_type = spiff_task.task_spec.__class__.__name__
|
|
|
|
|
|
|
|
if isinstance(spiff_task.task_spec, UserTask):
|
|
|
|
task_type = "UserTask"
|
|
|
|
elif isinstance(spiff_task.task_spec, ManualTask):
|
|
|
|
task_type = "ManualTask"
|
|
|
|
elif isinstance(spiff_task.task_spec, BusinessRuleTask):
|
|
|
|
task_type = "BusinessRuleTask"
|
|
|
|
elif isinstance(spiff_task.task_spec, CancelTask):
|
|
|
|
task_type = "CancelTask"
|
|
|
|
elif isinstance(spiff_task.task_spec, ScriptTask):
|
|
|
|
task_type = "ScriptTask"
|
|
|
|
elif isinstance(spiff_task.task_spec, StartTask):
|
|
|
|
task_type = "StartTask"
|
|
|
|
else:
|
|
|
|
task_type = "NoneTask"
|
|
|
|
|
2020-04-21 15:43:43 +00:00
|
|
|
info = spiff_task.task_info()
|
|
|
|
if info["is_looping"]:
|
|
|
|
mi_type = MultiInstanceType.looping
|
|
|
|
elif info["is_sequential_mi"]:
|
|
|
|
mi_type = MultiInstanceType.sequential
|
|
|
|
elif info["is_parallel_mi"]:
|
|
|
|
mi_type = MultiInstanceType.parallel
|
|
|
|
else:
|
|
|
|
mi_type = MultiInstanceType.none
|
2020-04-19 19:14:10 +00:00
|
|
|
|
2020-05-14 17:43:23 +00:00
|
|
|
props = {}
|
2020-04-21 16:07:59 +00:00
|
|
|
if hasattr(spiff_task.task_spec, 'extensions'):
|
|
|
|
for id, val in spiff_task.task_spec.extensions.items():
|
2020-05-14 17:43:23 +00:00
|
|
|
props[id] = val
|
2020-04-21 16:07:59 +00:00
|
|
|
|
2020-04-15 15:13:32 +00:00
|
|
|
task = Task(spiff_task.id,
|
|
|
|
spiff_task.task_spec.name,
|
|
|
|
spiff_task.task_spec.description,
|
2020-04-19 19:14:10 +00:00
|
|
|
task_type,
|
2020-04-15 15:13:32 +00:00
|
|
|
spiff_task.get_state_name(),
|
|
|
|
None,
|
2020-04-17 17:30:32 +00:00
|
|
|
"",
|
2020-05-15 19:54:53 +00:00
|
|
|
{},
|
2020-04-21 15:43:43 +00:00
|
|
|
mi_type,
|
|
|
|
info["mi_count"],
|
2020-04-21 16:07:59 +00:00
|
|
|
info["mi_index"],
|
2020-05-15 20:38:37 +00:00
|
|
|
process_name=spiff_task.task_spec._wf_spec.description,
|
2020-05-15 19:54:53 +00:00
|
|
|
properties=props
|
|
|
|
)
|
2020-04-15 15:13:32 +00:00
|
|
|
|
2020-05-06 17:01:38 +00:00
|
|
|
# Only process the form and documentation if requested.
|
|
|
|
# The task should be in a completed or a ready state, and should
|
|
|
|
# not be a previously completed MI Task.
|
|
|
|
if add_docs_and_forms:
|
2020-05-15 19:54:53 +00:00
|
|
|
task.data = spiff_task.data
|
2020-04-15 15:13:32 +00:00
|
|
|
if hasattr(spiff_task.task_spec, "form"):
|
|
|
|
task.form = spiff_task.task_spec.form
|
|
|
|
for field in task.form.fields:
|
2020-04-22 23:40:40 +00:00
|
|
|
WorkflowService.process_options(spiff_task, field)
|
2020-04-17 17:30:32 +00:00
|
|
|
task.documentation = WorkflowService._process_documentation(spiff_task)
|
2020-05-14 21:13:47 +00:00
|
|
|
|
|
|
|
# All ready tasks should have a valid name, and this can be computed for
|
|
|
|
# some tasks, particularly multi-instance tasks that all have the same spec
|
|
|
|
# but need different labels.
|
|
|
|
if spiff_task.state == SpiffTask.READY:
|
2020-05-15 19:54:53 +00:00
|
|
|
task.properties = WorkflowService._process_properties(spiff_task, props)
|
2020-05-14 17:43:23 +00:00
|
|
|
|
2020-05-15 19:54:53 +00:00
|
|
|
# Replace the title with the display name if it is set in the task properties,
|
|
|
|
# otherwise strip off the first word of the task, as that should be following
|
|
|
|
# a BPMN standard, and should not be included in the display.
|
|
|
|
if task.properties and "display_name" in task.properties:
|
|
|
|
task.title = task.properties['display_name']
|
|
|
|
elif task.title and ' ' in task.title:
|
|
|
|
task.title = task.title.partition(' ')[2]
|
2020-05-14 21:13:47 +00:00
|
|
|
|
2020-04-15 15:13:32 +00:00
|
|
|
return task
|
|
|
|
|
2020-05-14 17:43:23 +00:00
|
|
|
@staticmethod
|
|
|
|
def _process_properties(spiff_task, props):
|
|
|
|
"""Runs all the property values through the Jinja2 processor to inject data."""
|
2020-05-15 19:54:53 +00:00
|
|
|
for k, v in props.items():
|
2020-05-14 17:43:23 +00:00
|
|
|
try:
|
|
|
|
template = Template(v)
|
|
|
|
props[k] = template.render(**spiff_task.data)
|
|
|
|
except jinja2.exceptions.TemplateError as ue:
|
|
|
|
app.logger.error("Failed to process task property %s " % str(ue))
|
2020-05-15 19:54:53 +00:00
|
|
|
return props
|
2020-05-14 17:43:23 +00:00
|
|
|
|
2020-04-15 15:13:32 +00:00
|
|
|
@staticmethod
|
2020-04-17 17:30:32 +00:00
|
|
|
def _process_documentation(spiff_task):
|
2020-04-15 15:13:32 +00:00
|
|
|
"""Runs the given documentation string through the Jinja2 processor to inject data
|
2020-04-17 17:30:32 +00:00
|
|
|
create loops, etc... - If a markdown file exists with the same name as the task id,
|
|
|
|
it will use that file instead of the documentation. """
|
|
|
|
|
|
|
|
documentation = spiff_task.task_spec.documentation if hasattr(spiff_task.task_spec, "documentation") else ""
|
|
|
|
|
|
|
|
try:
|
|
|
|
doc_file_name = spiff_task.task_spec.name + ".md"
|
|
|
|
data_model = FileService.get_workflow_file_data(spiff_task.workflow, doc_file_name)
|
|
|
|
raw_doc = data_model.data.decode("utf-8")
|
|
|
|
except ApiError:
|
|
|
|
raw_doc = documentation
|
|
|
|
|
|
|
|
if not raw_doc:
|
|
|
|
return ""
|
2020-04-15 15:13:32 +00:00
|
|
|
|
|
|
|
try:
|
2020-04-17 17:30:32 +00:00
|
|
|
template = Template(raw_doc)
|
|
|
|
return template.render(**spiff_task.data)
|
2020-04-15 15:13:32 +00:00
|
|
|
except jinja2.exceptions.TemplateError as ue:
|
2020-06-01 15:00:56 +00:00
|
|
|
raise ApiError.from_task(code="template_error", message="Error processing template for task %s: %s" %
|
|
|
|
(spiff_task.task_spec.name, str(ue)), task=spiff_task)
|
2020-05-31 17:48:00 +00:00
|
|
|
except TypeError as te:
|
2020-06-01 15:00:56 +00:00
|
|
|
raise ApiError.from_task(code="template_error", message="Error processing template for task %s: %s" %
|
|
|
|
(spiff_task.task_spec.name, str(te)), task=spiff_task)
|
2020-04-15 15:13:32 +00:00
|
|
|
# TODO: Catch additional errors and report back.
|
|
|
|
|
|
|
|
@staticmethod
|
2020-04-22 23:40:40 +00:00
|
|
|
def process_options(spiff_task, field):
|
|
|
|
|
2020-05-19 20:11:43 +00:00
|
|
|
# If this is an auto-complete field, do not populate options, a lookup will happen later.
|
|
|
|
if field.type == Task.FIELD_TYPE_AUTO_COMPLETE:
|
2020-04-22 23:40:40 +00:00
|
|
|
pass
|
2020-05-29 05:39:39 +00:00
|
|
|
elif field.has_property(Task.PROP_OPTIONS_FILE):
|
|
|
|
lookup_model = LookupService.get_lookup_model(spiff_task, field)
|
2020-04-22 23:40:40 +00:00
|
|
|
data = db.session.query(LookupDataModel).filter(LookupDataModel.lookup_file_model == lookup_model).all()
|
2020-05-11 21:04:05 +00:00
|
|
|
if not hasattr(field, 'options'):
|
|
|
|
field.options = []
|
2020-04-22 23:40:40 +00:00
|
|
|
for d in data:
|
|
|
|
field.options.append({"id": d.value, "name": d.label})
|
|
|
|
|
2020-05-04 14:57:09 +00:00
|
|
|
@staticmethod
|
2020-06-16 16:54:51 +00:00
|
|
|
def log_task_action(user_uid, workflow_model, spiff_task, action,
|
|
|
|
version, updated_data=None):
|
2020-05-04 14:57:09 +00:00
|
|
|
task = WorkflowService.spiff_task_to_api_task(spiff_task)
|
|
|
|
task_event = TaskEventModel(
|
|
|
|
study_id=workflow_model.study_id,
|
2020-06-12 17:46:10 +00:00
|
|
|
user_uid=user_uid,
|
2020-05-04 14:57:09 +00:00
|
|
|
workflow_id=workflow_model.id,
|
|
|
|
workflow_spec_id=workflow_model.workflow_spec_id,
|
2020-06-01 21:42:28 +00:00
|
|
|
spec_version=version,
|
2020-05-04 14:57:09 +00:00
|
|
|
action=action,
|
|
|
|
task_id=task.id,
|
|
|
|
task_name=task.name,
|
|
|
|
task_title=task.title,
|
|
|
|
task_type=str(task.type),
|
|
|
|
task_state=task.state,
|
2020-06-01 21:42:28 +00:00
|
|
|
task_data=updated_data,
|
2020-05-15 20:38:37 +00:00
|
|
|
mi_type=task.multi_instance_type.value, # Some tasks have a repeat behavior.
|
|
|
|
mi_count=task.multi_instance_count, # This is the number of times the task could repeat.
|
|
|
|
mi_index=task.multi_instance_index, # And the index of the currently repeating task.
|
|
|
|
process_name=task.process_name,
|
2020-05-04 14:57:09 +00:00
|
|
|
date=datetime.now(),
|
|
|
|
)
|
|
|
|
db.session.add(task_event)
|
|
|
|
db.session.commit()
|
2020-05-29 05:39:39 +00:00
|
|
|
|