Commit 76a6bf8be1f4c9e0812b12b8e7ba68ea8ee692e5
Exists in
dev
Merge branch 'dev' of https://gitlab.irap.omp.eu/epallier/pyros into dev
Showing
15 changed files
with
322 additions
and
252 deletions
Show diff stats
CHANGELOG
1 | +04-04-2023 (AKo): v0.6.21.0 | |
2 | + - Improve check of ObsConfig load function and renaming functions of ObsConfig & PyrosUser | |
3 | + - Add schema (Md file) to explain workflow when user import a sequence file | |
4 | + - Enable real email when in prod (settings.py) | |
5 | + | |
1 | 6 | 20-03-2023 (AKo): v0.6.20.0 |
2 | 7 | - Add night_id to sequences, create folder for night_id |
3 | 8 | - Change sequences_pickle location (relocate in data folder) | ... | ... |
VERSION
install/requirements.txt
src/core/pyros_django/alert_manager/tests.py
src/core/pyros_django/common/tests.py
1 | 1 | # Santdard imports |
2 | -from utils.Logger import * | |
2 | +#from utils.Logger import * | |
3 | 3 | |
4 | 4 | # Django imports |
5 | 5 | from django.test import TestCase |
... | ... | @@ -11,7 +11,7 @@ from user_manager.models import Country |
11 | 11 | from common.models import * |
12 | 12 | from common.RequestBuilder import RequestBuilder |
13 | 13 | |
14 | -log = setupLogger("common", "common") | |
14 | +#log = setupLogger("common", "common") | |
15 | 15 | |
16 | 16 | """ |
17 | 17 | class RequestBuilderTests(TestCase): | ... | ... |
src/core/pyros_django/dashboard/views.py
... | ... | @@ -4,7 +4,7 @@ import re |
4 | 4 | import sys |
5 | 5 | import datetime |
6 | 6 | from datetime import timezone |
7 | -import utils.Logger as l | |
7 | +#import utils.Logger as l | |
8 | 8 | import json |
9 | 9 | from random import randint |
10 | 10 | import time,os |
... | ... | @@ -54,7 +54,7 @@ import vendor.guitastro.src.guitastro as guitastro |
54 | 54 | SUN_ELEV_DAY_THRESHOLD = -10 |
55 | 55 | MAX_LOGS_LINES = 100 |
56 | 56 | |
57 | -log = l.setupLogger("dashboard", "dashboard") | |
57 | +#log = l.setupLogger("dashboard", "dashboard") | |
58 | 58 | |
59 | 59 | def index(request): |
60 | 60 | config = OBSConfig(os.environ["PATH_TO_OBSCONF_FILE"],os.environ["unit_name"]) | ... | ... |
src/core/pyros_django/devices/Device.py
... | ... | @@ -4,7 +4,7 @@ import configparser |
4 | 4 | import abc |
5 | 5 | from pathlib import Path |
6 | 6 | import select |
7 | -import utils.Logger as L | |
7 | +#import utils.Logger as L | |
8 | 8 | import time |
9 | 9 | import os |
10 | 10 | from common.models import Log |
... | ... | @@ -19,7 +19,7 @@ RECONNECT_TIMEOUT_SECONDS = 20 |
19 | 19 | |
20 | 20 | class DeviceController(): |
21 | 21 | __metaclass__ = abc.ABCMeta |
22 | - logger = L.setupLogger("DeviceLogger", "Devices") | |
22 | + #logger = L.setupLogger("DeviceLogger", "Devices") | |
23 | 23 | name = "" |
24 | 24 | #config_file = "../config/socket_config.ini" |
25 | 25 | my_parent_abs_dir = Path(__file__).resolve().parent |
... | ... | @@ -170,7 +170,8 @@ class DeviceController(): |
170 | 170 | def log(self, device_name: str, message: str): |
171 | 171 | Log.objects.create(agent=self.name, message=message) |
172 | 172 | if DEBUG_FILE and settings.DEBUG: |
173 | - self.logger.info("From device : " + device_name + " -> " + message) | |
173 | + #self.logger.info("From device : " + device_name + " -> " + message) | |
174 | + pass | |
174 | 175 | return (0) |
175 | 176 | |
176 | 177 | def connect(self): | ... | ... |
src/core/pyros_django/obsconfig/obsconfig_class.py
... | ... | @@ -59,15 +59,17 @@ class OBSConfig: |
59 | 59 | self.CONFIG_PATH = os.path.dirname(observatory_config_file)+"/" |
60 | 60 | self.obs_config_path = self.CONFIG_PATH |
61 | 61 | #self.CONFIG_PATH = self.obs_config_path |
62 | + # If the pickle doesn't exists | |
62 | 63 | if os.path.isfile(self.CONFIG_PATH+self.pickle_file) == False: |
63 | 64 | return True |
64 | 65 | else: |
66 | + # pickle file exists | |
65 | 67 | pickle_file_mtime = os.path.getmtime( |
66 | 68 | self.CONFIG_PATH+self.pickle_file) |
67 | 69 | obs_config_mtime = os.path.getmtime(observatory_config_file) |
68 | 70 | |
69 | - obs_config = self.read_and_check_config_file( | |
70 | - observatory_config_file) | |
71 | + #obs_config = self.read_and_check_config_file( | |
72 | + # observatory_config_file) | |
71 | 73 | if obs_config_mtime > pickle_file_mtime: |
72 | 74 | # create obs file (yaml) from pickle["obsconfig"] with date of pickle within history folder-> nom ficher + année + mois + jour + datetime (avec secondes) -> YYYY/MM/DD H:m:s |
73 | 75 | pickle_datetime = datetime.utcfromtimestamp( |
... | ... | @@ -81,23 +83,32 @@ class OBSConfig: |
81 | 83 | with open(file_name, 'w') as f: |
82 | 84 | f.write(config_file.read()) |
83 | 85 | return True |
84 | - if obs_config == None: | |
85 | - print( | |
86 | - f"Error when trying to read config file (path of config file : {observatory_config_file}") | |
87 | - return -1 | |
88 | - self.obs_config = obs_config | |
89 | - # check last date of modification for devices files | |
90 | - for device in self.obs_config["OBSERVATORY"]["INVENTORY"]["DEVICES"]: | |
91 | - device_file = self.CONFIG_PATH+device["DEVICE"]["file"] | |
92 | - device_file_mtime = os.path.getmtime(device_file) | |
93 | - if device_file_mtime > pickle_file_mtime: | |
94 | - return True | |
95 | - | |
96 | - for computer in self.obs_config["OBSERVATORY"]["INVENTORY"]["COMPUTERS"]: | |
97 | - computer_file = self.CONFIG_PATH+computer["COMPUTER"]["file"] | |
98 | - computer_file_mtime = os.path.getmtime(computer_file) | |
99 | - if computer_file_mtime > pickle_file_mtime: | |
100 | - return True | |
86 | + # if obs_config == None: | |
87 | + # print( | |
88 | + # f"Error when trying to read config file (path of config file : {observatory_config_file}") | |
89 | + # return -1 | |
90 | + | |
91 | + for file in os.listdir(self.CONFIG_PATH): | |
92 | + # loop on each file in obsconfig folder exluding pickle itself and global obsconfig file (observatory_config_file) | |
93 | + if file != observatory_config_file and ".p" not in file: | |
94 | + file_path = self.CONFIG_PATH + file | |
95 | + if os.path.getmtime(file_path) > pickle_file_mtime: | |
96 | + return True | |
97 | + | |
98 | + # Old version not optimized | |
99 | + # self.obs_config = obs_config | |
100 | + # # check last date of modification for devices files | |
101 | + # for device in self.obs_config["OBSERVATORY"]["INVENTORY"]["DEVICES"]: | |
102 | + # device_file = self.CONFIG_PATH+device["DEVICE"]["file"] | |
103 | + # device_file_mtime = os.path.getmtime(device_file) | |
104 | + # if device_file_mtime > pickle_file_mtime: | |
105 | + # return True | |
106 | + | |
107 | + # for computer in self.obs_config["OBSERVATORY"]["INVENTORY"]["COMPUTERS"]: | |
108 | + # computer_file = self.CONFIG_PATH+computer["COMPUTER"]["file"] | |
109 | + # computer_file_mtime = os.path.getmtime(computer_file) | |
110 | + # if computer_file_mtime > pickle_file_mtime: | |
111 | + # return True | |
101 | 112 | return False |
102 | 113 | |
103 | 114 | def load(self, observatory_config_file): |
... | ... | @@ -1119,7 +1130,7 @@ class OBSConfig: |
1119 | 1130 | uneditable_fields[attribute] = attributes[attribute] |
1120 | 1131 | return uneditable_fields |
1121 | 1132 | |
1122 | - def getEditableAttributesOfChannel(self, unit_name: str, channel_name: str) -> list: | |
1133 | + def getEditableChannelAttributes(self, unit_name: str, channel_name: str) -> list: | |
1123 | 1134 | capabilities = self.getChannelCapabilities(unit_name, channel_name) |
1124 | 1135 | # merged_result = {} |
1125 | 1136 | # for capability in capabilities: |
... | ... | @@ -1154,7 +1165,7 @@ class OBSConfig: |
1154 | 1165 | def getAlbumByName(self, unit_name: str, name_of_album): |
1155 | 1166 | return self.get_albums(unit_name)["albums"][name_of_album] |
1156 | 1167 | |
1157 | - def getEditableAttributesOfMount(self, unit_name): | |
1168 | + def getEditableMountAttributes(self, unit_name): | |
1158 | 1169 | capabilities = self.get_device_capabilities( |
1159 | 1170 | self.get_device_for_agent(unit_name, "mount")["name"]) |
1160 | 1171 | merged_result = [] |
... | ... | @@ -1347,7 +1358,7 @@ def main(): |
1347 | 1358 | # print(config.getChannelCapabilities(unit_name,"OpticalChannel_up")) |
1348 | 1359 | # print(config.get_channel_groups(unit_name)) |
1349 | 1360 | # print(config.getEditableAttributesOfCapability(config.getChannelCapabilities(unit_name,"OpticalChannel_up")[0])) |
1350 | - # print(config.getEditableAttributesOfChannel(unit_name,"OpticalChannel_up")) | |
1361 | + # print(config.getEditableChannelAttributes(unit_name,"OpticalChannel_up")) | |
1351 | 1362 | config = OBSConfig("../../../../privatedev/config/tnc/observatory_tnc.yml") |
1352 | 1363 | unit_name = config.get_units_name()[0] |
1353 | 1364 | # dc = config.getDeviceControllerNameForAgent(unit_name,"mount")[0] |
... | ... | @@ -1356,8 +1367,8 @@ def main(): |
1356 | 1367 | # print(config.getChannelCapabilities(unit_name,"OpticalChannel_down2")) |
1357 | 1368 | # print(config.get_channel_groups(unit_name)) |
1358 | 1369 | # print(config.getEditableAttributesOfCapability(config.getChannelCapabilities(unit_name,"OpticalChannel_down2")[0])) |
1359 | - # print(config.getEditableAttributesOfChannel(unit_name,"OpticalChannel_down2")) | |
1360 | - #print(config.getEditableAttributesOfMount(unit_name)) | |
1370 | + # print(config.getEditableChannelAttributesnit_name,"OpticalChannel_down2")) | |
1371 | + #print(config.getEditableMountAttributes(unit_name)) | |
1361 | 1372 | config.get_devices() |
1362 | 1373 | |
1363 | 1374 | #print(config.get_output_data_device("TAROT_meteo").get("CV7")) | ... | ... |
src/core/pyros_django/pyros/settings.py
... | ... | @@ -231,9 +231,14 @@ INSTALLED_APPS = [ |
231 | 231 | 'obsconfig', |
232 | 232 | 'dashboard', |
233 | 233 | "api", |
234 | + #"silk", | |
234 | 235 | #'kombu.transport.django' |
235 | 236 | ] |
236 | 237 | |
238 | +#silk | |
239 | + | |
240 | +#SILKY_PYTHON_PROFILER = True | |
241 | + | |
237 | 242 | CHANNEL_LAYERS = { |
238 | 243 | "default": { |
239 | 244 | |
... | ... | @@ -263,7 +268,8 @@ MIDDLEWARE = [ |
263 | 268 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', |
264 | 269 | # For debug_toolbar |
265 | 270 | 'debug_toolbar.middleware.DebugToolbarMiddleware', |
266 | - | |
271 | + # silk | |
272 | + #"silk.middleware.SilkyMiddleware", | |
267 | 273 | ] |
268 | 274 | |
269 | 275 | ROOT_URLCONF = 'pyros.urls' |
... | ... | @@ -333,7 +339,7 @@ if MYSQL: |
333 | 339 | #CONN_MAX_AGE = 0 |
334 | 340 | # - Always persistant |
335 | 341 | #CONN_MAX_AGE = None |
336 | - 'CONN_MAX_AGE': 500, | |
342 | + 'CONN_MAX_AGE': 10, | |
337 | 343 | ''' |
338 | 344 | (See https://docs.djangoproject.com/fr/2.1/topics/testing/overview/#the-test-database) |
339 | 345 | Optional, but this allows to remember the default django test database name |
... | ... | @@ -493,7 +499,12 @@ else: |
493 | 499 | # from django.core.cache import cache |
494 | 500 | # cache.clear() |
495 | 501 | |
496 | -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' | |
502 | +if os.environ.get("IS_PROD") and os.environ.get("IS_PROD") == True : | |
503 | + # If in prod, use real email | |
504 | + EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' | |
505 | +else: | |
506 | + # else email aren't sent and are print in console instead | |
507 | + EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' | |
497 | 508 | |
498 | 509 | python_version = subprocess.run( "python --version | cut -d ' ' -f 2 | cut -d '.' -f 1,2",shell=True,stdout=subprocess.PIPE,universal_newlines=True) |
499 | 510 | python_version = python_version.stdout | ... | ... |
src/core/pyros_django/routine_manager/functions.py
... | ... | @@ -12,155 +12,33 @@ from django.db import IntegrityError |
12 | 12 | # Project imports |
13 | 13 | from src.core.pyros_django.obsconfig.obsconfig_class import OBSConfig |
14 | 14 | from django.http import HttpRequest |
15 | +#from silk.profiling.profiler import silk_profile | |
15 | 16 | |
17 | +#@silk_profile(name="check_sequence_file") | |
16 | 18 | def check_sequence_file_validity_and_save(yaml_content: dict, request: HttpRequest): |
17 | 19 | ''' Create a sequence in DB from the uploaded sequence (yaml_content) ''' |
18 | 20 | |
19 | - # Get boolean to simplified to know if the file is written in simplified mode (i.e. : Each field of the form is directly associated to its value) | |
20 | - is_simplified = yaml_content.get("simplified", False) | |
21 | - # Get scientific programs for the user who is submitting the sequence file | |
22 | - user_sp = request.user.get_scientific_programs() | |
23 | - # From user sp, get all SP that can observe / submit sequence for the current period | |
24 | - sp_list = ScientificProgram.objects.observable_programs().filter(id__in=user_sp) | |
25 | - | |
26 | 21 | # Create a sequence seq object (from yaml_content) to be saved in DB |
27 | 22 | seq = Sequence.objects.create() |
28 | 23 | seq.pyros_user = PyrosUser.objects.get(id=request.user.id) |
29 | 24 | |
30 | 25 | # Get the unit config |
31 | 26 | unit_name = os.environ["unit_name"] |
27 | + #with silk_profile(name="init obsconfig"): | |
32 | 28 | config = OBSConfig(os.environ["PATH_TO_OBSCONF_FILE"], unit_name) |
33 | - | |
34 | - # Create a Sequence form | |
35 | - sequence_form = SequenceForm(instance=seq, data_from_config=config.getEditableMountAttributes(config.unit_name), layouts = config.get_layouts(config.unit_name), sp_list=sp_list) | |
36 | 29 | result = { |
37 | 30 | "succeed": True, |
38 | 31 | "errors": [], |
39 | 32 | } |
40 | - | |
41 | - if is_simplified: | |
42 | - seq.scientific_program = sp_list[yaml_content["sequence"]["scientific_program"]] | |
33 | + # Get boolean to simplified to know if the file is written in simplified mode (i.e. : Each field of the form is directly associated to its value) | |
34 | + is_simplified = yaml_content.get("simplified", False) | |
35 | + # Get scientific programs for the user who is submitting the sequence file | |
36 | + user_sp = request.user.get_scientific_programs() | |
37 | + process_sequence(yaml_content, seq, config, is_simplified, result, user_sp) | |
43 | 38 | |
44 | - else: | |
45 | - # pour la lisibilité du code (et éviter la redondance) | |
46 | - # get scientific program field's attributes | |
47 | - yaml_seq_sp = yaml_content["sequence"]["scientific_program"] | |
48 | - sp_index_value = yaml_seq_sp["value"] | |
49 | - values = yaml_seq_sp["values"] | |
50 | - # Check if index of sp is valid (in range of possible index from values) | |
51 | - if sp_index_value < 0 or sp_index_value > len(values): | |
52 | - result["errors"].append(f"SP value isn't valid, index out of bounds ({sp_index_value} > {len(values)})") | |
53 | - sp_index_value = 0 | |
54 | - chosen_sp = ScientificProgram.objects.get(name=values[sp_index_value]) | |
55 | - # If the sp is associated to that user, associate the sp to the sequence | |
56 | - if chosen_sp in sp_list: | |
57 | - #seq.scientific_program = ScientificProgram.objects.get(name=yaml_content["sequence"]["scientific_program"]["values"][sp_index_value]) | |
58 | - seq.scientific_program = chosen_sp | |
59 | - else: | |
60 | - result["errors"].append(f"SP {chosen_sp.name} is not assigned to that user ") | |
39 | + process_albums(yaml_content, result, config, seq, is_simplified) | |
61 | 40 | |
62 | - seq.config_attributes = {} | |
63 | - | |
64 | - # Fill all Sequence form fields | |
65 | - # keys() inutile ? => for field in sequence_form.fields : | |
66 | - # Sinon, y a aussi => for key,val in sequence_form.fields.items(): | |
67 | - # => Ca éviterait de faire => sequence_form.fields[field] pour recuperer "val" | |
68 | - for field, field_attribute in sequence_form.fields.items(): | |
69 | 41 | |
70 | - #if sequence_form.fields[field].required == False or field == "scientific_program": | |
71 | - if not field_attribute.required or field=="scientific_program": | |
72 | - continue | |
73 | - # pour lisibilité, simplicité et éviter redondance | |
74 | - yaml_field = yaml_content["sequence"][field] | |
75 | - value = yaml_field if is_simplified else yaml_field["value"] | |
76 | - ''' (orig) | |
77 | - if is_simplified: | |
78 | - value = yaml_content["sequence"][field] | |
79 | - else: | |
80 | - value = yaml_content["sequence"][field]["value"] | |
81 | - ''' | |
82 | - # If the current field of the sequence isn't found in the file, add an error message to inform the user the missing field | |
83 | - if field not in yaml_content["sequence"]: | |
84 | - result["errors"].append(f"{field} not in yaml file") | |
85 | - else: | |
86 | - if is_simplified: | |
87 | - # If the field is a choicefield, get choices and associate the index to the real value | |
88 | - if sequence_form.fields[field].__dict__.get("_choices"): | |
89 | - # y a pas conflit ici avec la variable "value" définie au-dessus ? -> Non car on transforme l'ancien value qui est un index en une vraie valeur | |
90 | - values = [value[0] for value in sequence_form.fields[field].__dict__.get("_choices")] | |
91 | - value = values[value] | |
92 | - else: | |
93 | - if yaml_field.get("values"): | |
94 | - # Transform the original value which is an index to a "real" value from the "values" attributes | |
95 | - index_value = yaml_field["value"] | |
96 | - values = yaml_field["values"] | |
97 | - if index_value < 0 or index_value > len(yaml_field["values"]): | |
98 | - result["errors"].append(f"Value of {field} isn't valid, index out of bounds ({index_value} > {len(values)})") | |
99 | - index_value = 0 | |
100 | - value = yaml_field["values"][index_value] | |
101 | - else: | |
102 | - # Transform the string value to a datetime value | |
103 | - if field == "start_date": | |
104 | - if type(value) != datetime.datetime: | |
105 | - #value = datetime.datetime.strptime(yaml_content["sequence"][field]["value"],'%d/%m/%Y %H:%M:%S') | |
106 | - # ISO format | |
107 | - value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') | |
108 | - seq.__dict__[field] = value | |
109 | - ''' (orig) | |
110 | - else: | |
111 | - if not is_simplified: | |
112 | - if yaml_content["sequence"][field].get("values"): | |
113 | - index_value = yaml_content["sequence"][field]["value"] | |
114 | - values = yaml_content["sequence"][field]["values"] | |
115 | - if index_value < 0 or index_value > len(yaml_content["sequence"][field]["values"]): | |
116 | - result["errors"].append(f"Value of {field} isn't valid, index out of bounds ({index_value} > {len(values)})") | |
117 | - index_value = 0 | |
118 | - value = yaml_content["sequence"][field]["values"][index_value] | |
119 | - else: | |
120 | - if field == "start_date": | |
121 | - if type(value) != datetime.datetime: | |
122 | - #value = datetime.datetime.strptime(yaml_content["sequence"][field]["value"],'%d/%m/%Y %H:%M:%S') | |
123 | - # ISO format | |
124 | - value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') | |
125 | - seq.__dict__[field] = value | |
126 | - else: | |
127 | - if sequence_form.fields[field].__dict__.get("_choices"): | |
128 | - values = [value[0] for value in sequence_form.fields[field].__dict__.get("_choices")] | |
129 | - value = values[value] | |
130 | - ''' | |
131 | - | |
132 | - # suffisant ? => if field in seq.__dict__ | |
133 | - # If field is an attribute of the sequence, associate the field to the value | |
134 | - if field in seq.__dict__: | |
135 | - seq.__dict__[field] = value | |
136 | - else: | |
137 | - # else associate field & value in config_attributes sequence's field (JsonField) = variable fields of an sequence | |
138 | - seq.config_attributes[field] = value | |
139 | - | |
140 | - # Create ALBUMS | |
141 | - albums_from_file = yaml_content["sequence"]["ALBUMS"] | |
142 | - chosen_layout = seq.config_attributes["layout"] | |
143 | - if type(chosen_layout) == int: | |
144 | - layouts = config.get_layouts(config.unit_name)["layouts"] | |
145 | - chosen_layout = list(layouts.keys())[chosen_layout] | |
146 | - # Get album of the selected layout | |
147 | - layout_albums = config.getLayoutByName(unit_name=config.unit_name, name_of_layout=chosen_layout)["ALBUMS"] | |
148 | - | |
149 | - # check if we have all the albums of that layout described in the sequence file | |
150 | - if len(layout_albums) == len(albums_from_file): | |
151 | - for album in albums_from_file: | |
152 | - album = album["Album"] | |
153 | - if album["name"] not in layout_albums: | |
154 | - result["errors"].append(f"Album {album['name']} is not the chosen layout") | |
155 | - else: | |
156 | - # Create album | |
157 | - Album.objects.create(name=album["name"], sequence=seq, complete=True) | |
158 | - # Create plan for that album | |
159 | - plans = [a["Album"].get("Plans") for a in albums_from_file] | |
160 | - process_plans(plans, result, is_simplified, config, album, seq) | |
161 | - else: | |
162 | - result["errors"].append(f"The number of albums doesn't correspond to the chosen layout") | |
163 | - | |
164 | 42 | # optim possible ? |
165 | 43 | #[ process_plans(a["Album"].get("Plans")) for a in albums_from_file ] |
166 | 44 | # Puis écrire la fonction process_plans() |
... | ... | @@ -291,19 +169,51 @@ def check_sequence_file_validity_and_save(yaml_content: dict, request: HttpReque |
291 | 169 | result["sequence_id"] = seq.id |
292 | 170 | return result |
293 | 171 | |
172 | +def process_albums(yaml_content, result, config, seq, is_simplified): | |
173 | + # Create ALBUMS | |
174 | + albums_from_file = yaml_content["sequence"]["ALBUMS"] | |
175 | + chosen_layout = seq.config_attributes["layout"] | |
176 | + if type(chosen_layout) == int: | |
177 | + #with silk_profile(name="Get layout from config"): | |
178 | + layouts = config.get_layouts(config.unit_name)["layouts"] | |
179 | + chosen_layout = list(layouts)[chosen_layout] | |
180 | + # Get album of the selected layout | |
181 | + #with silk_profile(name="Get album of layout from config"): | |
182 | + layout_albums = config.getLayoutByName(unit_name=config.unit_name, name_of_layout=chosen_layout)["ALBUMS"] | |
183 | + | |
184 | + # check if we have all the albums of that layout described in the sequence file | |
185 | + #with silk_profile(name="Iterate on each album & plan (create)"): | |
186 | + if len(layout_albums) == len(albums_from_file): | |
187 | + for album in albums_from_file: | |
188 | + album = album["Album"] | |
189 | + if album["name"] not in layout_albums: | |
190 | + result["errors"].append(f"Album {album['name']} is not the chosen layout") | |
191 | + else: | |
192 | + # Create album | |
193 | + Album.objects.create(name=album["name"], sequence=seq, complete=True) | |
194 | + # Create plan for that album | |
195 | + plans = album.get("Plans") | |
196 | + process_plans(plans, result, is_simplified, config, album, seq) | |
197 | + else: | |
198 | + result["errors"].append(f"The number of albums doesn't correspond to the chosen layout") | |
199 | + | |
294 | 200 | |
201 | +#@silk_profile(name="process_plans") | |
295 | 202 | def process_plans(plans: dict, result: dict, is_simplified: bool, config: OBSConfig, album: dict, seq: dict): |
296 | 203 | if plans == None: |
297 | 204 | result["errors"].append(f"Album {album['name']} has no plans. Please add at least one plan") |
298 | 205 | # exit function |
299 | 206 | return None |
300 | 207 | for plan in plans: |
301 | - new_plan_object = Plan.objects.create(album=Album.objects.get(name=album["name"], sequence=seq), complete=True) | |
302 | - new_plan_object.config_attributes = {} | |
208 | + #new_plan_object = Plan.objects.create(album=Album.objects.get(name=album["name"], sequence=seq), complete=True) | |
209 | + #new_plan_object.config_attributes = {} | |
303 | 210 | plan = plan["Plan"] |
211 | + nb_images = 0 | |
304 | 212 | config_attributes = {} |
213 | + #with silk_profile(name="Create plan form"): | |
305 | 214 | plan_form = PlanForm(data_from_config=config.getEditableChannelAttributes(config.unit_name, list(config.get_channels(config.unit_name).keys())[0]), edited_plan=None) |
306 | 215 | # Process each plan field |
216 | + #with silk_profile(name="iterate on plan fields"): | |
307 | 217 | for field in plan_form.fields: |
308 | 218 | plan_field = plan[field] |
309 | 219 | ''' |
... | ... | @@ -311,78 +221,187 @@ def process_plans(plans: dict, result: dict, is_simplified: bool, config: OBSCon |
311 | 221 | max_value = None |
312 | 222 | value_type = None |
313 | 223 | ''' |
314 | - min_value = max_value = value_type = None | |
315 | - if field not in plan.keys(): | |
316 | - result["errors"].append(f"Missing field : '{field}' for plan {plans.index(plan)}") | |
317 | - continue | |
318 | - # TODO : ajouter max_value, min_value, suppression plan et album si invalides | |
319 | - if not is_simplified: | |
320 | - if plan_field.get("value_type"): | |
321 | - value_type = plan_field["value_type"] | |
322 | - if type(plan_field["value"]) == str and ast.literal_eval(plan_field["value"]) != value_type: | |
323 | - result["errors"].append(f"Field {field} value doesn't correspond to the assigned type (type required : {value_type})") | |
324 | - if plan_field.get("min_value"): | |
325 | - min_value = plan_field["min_value"] | |
326 | - if type(min_value) == str: | |
327 | - min_value = ast.literal_eval(min_value) | |
328 | - ''' | |
329 | - if type(plan_field["min_value"]) == str: | |
330 | - min_value = ast.literal_eval(plan_field["min_value"]) | |
331 | - else: | |
332 | - min_value = plan_field["min_value"] | |
333 | - ''' | |
334 | - if plan_field.get("max_value"): | |
335 | - max_value = plan_field["max_value"] | |
336 | - if type(max_value) == str: | |
337 | - max_value = ast.literal_eval(max_value) | |
338 | - ''' | |
339 | - if type(plan_field.get("max_value")) == str: | |
340 | - max_value = ast.literal_eval(plan_field["max_value"]) | |
341 | - else: | |
342 | - max_value = plan_field["max_value"] | |
343 | - ''' | |
344 | 224 | if field == "nb_images": |
345 | - new_plan_object.__dict__[field] = plan_field if is_simplified else plan_field["value"] | |
346 | - ''' | |
347 | - if is_simplified: | |
348 | - new_plan_object.__dict__[field] = plan_field | |
349 | - else: | |
350 | - new_plan_object.__dict__[field] = plan_field["value"] | |
351 | - ''' | |
225 | + nb_images = plan_field if is_simplified else plan_field["value"] | |
352 | 226 | else: |
353 | - # shortcut possible ? | |
354 | - #new_plan_object_field = new_plan_object.config_attributes[field] | |
355 | - if is_simplified: | |
356 | - new_plan_object.config_attributes[field] = plan_field | |
357 | - else: | |
358 | - if plan_field.get("values"): | |
359 | - index_value = plan_field["value"] | |
360 | - values = plan_field["values"] | |
361 | - if index_value < 0 or index_value > len(plan_field["values"]): | |
362 | - result["errors"].append(f"Value of Plan field '{field}' isn't valid, index out of bounds ({index_value} > {len(values)})") | |
363 | - index_value = 0 | |
364 | - value = plan_field["values"][index_value] | |
365 | - try: | |
366 | - # linked values | |
367 | - splitted_values = value.split(";") | |
368 | - config_attributes[field] = {} | |
369 | - for splitted_value in splitted_values: | |
370 | - subkey,subvalue = splitted_value.split(":") | |
371 | - config_attributes[field][subkey] = ast.literal_eval(subvalue) | |
372 | - # vaudrait mieux préciser l'exception ici | |
373 | - except: | |
374 | - # Do nothing, normal string | |
375 | - config_attributes[field] = ast.literal_eval(value) | |
376 | - new_plan_object.config_attributes[field] = config_attributes[field] | |
377 | - else: | |
378 | - if max_value and min_value: | |
379 | - if plan_field["value"] > max_value: | |
380 | - result["errors"].append(f"Plan field {field} doesn't respect max value") | |
381 | - if plan_field["value"] < min_value: | |
382 | - result["errors"].append(f"Plan field {field} doesn't respect min value") | |
383 | - new_plan_object.config_attributes[field] = plan_field["value"] | |
227 | + process_plan_field(result, config_attributes, plan_field, field, plans, plan, is_simplified) | |
384 | 228 | # end foreach plan field |
385 | - new_plan_object.save() | |
229 | + Plan.objects.create(album=Album.objects.get(name=album["name"], sequence=seq), complete=True, nb_images=nb_images, config_attributes=config_attributes) | |
230 | + | |
231 | +def process_plan_field(result, config_attributes, plan_field, field, plans, plan, is_simplified): | |
232 | + | |
233 | + | |
234 | + if field not in plan.keys(): | |
235 | + result["errors"].append(f"Missing field : '{field}' for plan {plans.index(plan)}") | |
236 | + # exit function | |
237 | + return None | |
238 | + if is_simplified: | |
239 | + #new_plan_object.config_attributes[field] = plan_field | |
240 | + config_attributes[field] = plan_field | |
241 | + else: | |
242 | + value_type, min_value, max_value = prepare_check_plan_field_value(plan_field, field, result) | |
243 | + check_and_set_plan_field_value(config_attributes, plan_field, field, result, value_type, min_value, max_value) | |
244 | + | |
245 | + | |
246 | +def check_and_set_plan_field_value(config_attributes, plan_field, field, result, value_type, min_value, max_value): | |
247 | + # if the value is a index of a list, get the value from this index | |
248 | + if plan_field.get("values"): | |
249 | + index_value = plan_field["value"] | |
250 | + values = plan_field["values"] | |
251 | + if index_value < 0 or index_value > len(plan_field["values"]): | |
252 | + result["errors"].append(f"Value of Plan field '{field}' isn't valid, index out of bounds ({index_value} > {len(values)})") | |
253 | + index_value = 0 | |
254 | + value = plan_field["values"][index_value] | |
255 | + try: | |
256 | + # linked values | |
257 | + splitted_values = value.split(";") | |
258 | + config_attributes[field] = {} | |
259 | + for splitted_value in splitted_values: | |
260 | + subkey,subvalue = splitted_value.split(":") | |
261 | + config_attributes[field][subkey] = ast.literal_eval(subvalue) | |
262 | + # vaudrait mieux préciser l'exception ici | |
263 | + except ValueError: | |
264 | + # Do nothing, normal string | |
265 | + config_attributes[field] = ast.literal_eval(value) | |
266 | + #new_plan_object.config_attributes[field] = config_attributes[field] | |
267 | + else: | |
268 | + # check min and max values if they exist | |
269 | + if max_value and min_value: | |
270 | + if plan_field["value"] > max_value: | |
271 | + result["errors"].append(f"Plan field {field} doesn't respect max value") | |
272 | + if plan_field["value"] < min_value: | |
273 | + result["errors"].append(f"Plan field {field} doesn't respect min value") | |
274 | + #new_plan_object.config_attributes[field] = plan_field["value"] | |
275 | + config_attributes[field] = plan_field["value"] | |
276 | + | |
277 | + | |
278 | +def prepare_check_plan_field_value(plan_field, field, result): | |
279 | + min_value = max_value = value_type = None | |
280 | + # get value type, min_value and max_value if they're in the plan form | |
281 | + if plan_field.get("value_type"): | |
282 | + value_type = plan_field["value_type"] | |
283 | + # If value type doesn't match with the value from the form, add an error to result | |
284 | + if type(plan_field["value"]) == str and ast.literal_eval(plan_field["value"]) != value_type: | |
285 | + result["errors"].append(f"Field {field} value doesn't correspond to the assigned type (type required : {value_type})") | |
286 | + if plan_field.get("min_value"): | |
287 | + min_value = plan_field["min_value"] | |
288 | + if type(min_value) == str: | |
289 | + min_value = ast.literal_eval(min_value) | |
290 | + if plan_field.get("max_value"): | |
291 | + max_value = plan_field["max_value"] | |
292 | + if type(max_value) == str: | |
293 | + max_value = ast.literal_eval(max_value) | |
294 | + return value_type, min_value, max_value | |
295 | + | |
296 | + | |
297 | +def process_sequence(yaml_content, seq, config, is_simplified, result, user_sp): | |
298 | + | |
299 | + # From user sp, get all SP that can observe / submit sequence for the current period | |
300 | + sp_list = ScientificProgram.objects.observable_programs().filter(id__in=user_sp) | |
301 | + # Create a Sequence form | |
302 | + sequence_form = SequenceForm(instance=seq, data_from_config=config.getEditableMountAttributes(config.unit_name), layouts = config.get_layouts(config.unit_name), sp_list=sp_list) | |
303 | + | |
304 | + if is_simplified: | |
305 | + seq.scientific_program = sp_list[yaml_content["sequence"]["scientific_program"]] | |
306 | + else: | |
307 | + # get scientific program field's attributes | |
308 | + yaml_seq_sp = yaml_content["sequence"]["scientific_program"] | |
309 | + sp_index_value = yaml_seq_sp["value"] | |
310 | + values = yaml_seq_sp["values"] | |
311 | + # Check if index of sp is valid (in range of possible index from values) | |
312 | + if sp_index_value < 0 or sp_index_value > len(values): | |
313 | + result["errors"].append(f"SP value isn't valid, index out of bounds ({sp_index_value} > {len(values)})") | |
314 | + sp_index_value = 0 | |
315 | + chosen_sp = ScientificProgram.objects.get(name=values[sp_index_value]) | |
316 | + # If the sp is associated to that user, associate the sp to the sequence | |
317 | + if chosen_sp in sp_list: | |
318 | + #seq.scientific_program = ScientificProgram.objects.get(name=yaml_content["sequence"]["scientific_program"]["values"][sp_index_value]) | |
319 | + seq.scientific_program = chosen_sp | |
320 | + else: | |
321 | + result["errors"].append(f"SP {chosen_sp.name} is not assigned to that user ") | |
322 | + | |
323 | + seq.config_attributes = {} | |
324 | + | |
325 | + # Fill all Sequence form fields | |
326 | + #with silk_profile(name="iterate sequence fields form"): | |
327 | + for field, field_attributes in sequence_form.fields.items(): | |
328 | + #if sequence_form.fields[field].required == False or field == "scientific_program": | |
329 | + if not field_attributes.required or field=="scientific_program": | |
330 | + continue | |
331 | + # pour lisibilité, simplicité et éviter redondance | |
332 | + yaml_field = yaml_content["sequence"][field] | |
333 | + value = yaml_field if is_simplified else yaml_field["value"] | |
334 | + ''' (orig) | |
335 | + if is_simplified: | |
336 | + value = yaml_content["sequence"][field] | |
337 | + else: | |
338 | + value = yaml_content["sequence"][field]["value"] | |
339 | + ''' | |
340 | + # If the current field of the sequence isn't found in the file, add an error message to inform the user the missing field | |
341 | + if field not in yaml_content["sequence"]: | |
342 | + result["errors"].append(f"{field} not in yaml file") | |
343 | + else: | |
344 | + if is_simplified: | |
345 | + # If the field is a choicefield, get choices and associate the index to the real value | |
346 | + if sequence_form.fields[field].__dict__.get("_choices"): | |
347 | + # y a pas conflit ici avec la variable "value" définie au-dessus ? -> Non car on transforme l'ancien value qui est un index en une vraie valeur | |
348 | + values = [value[0] for value in sequence_form.fields[field].__dict__.get("_choices")] | |
349 | + value = values[value] | |
350 | + # Transform the string value to a datetime value | |
351 | + if field == "start_date": | |
352 | + if type(value) != datetime.datetime: | |
353 | + #value = datetime.datetime.strptime(yaml_content["sequence"][field]["value"],'%d/%m/%Y %H:%M:%S') | |
354 | + # ISO format | |
355 | + value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') | |
356 | + seq.__dict__[field] = value | |
357 | + else: | |
358 | + if yaml_field.get("values"): | |
359 | + # Transform the original value which is an index to a "real" value from the "values" attributes | |
360 | + index_value = yaml_field["value"] | |
361 | + values = yaml_field["values"] | |
362 | + if index_value < 0 or index_value > len(yaml_field["values"]): | |
363 | + result["errors"].append(f"Value of {field} isn't valid, index out of bounds ({index_value} > {len(values)})") | |
364 | + index_value = 0 | |
365 | + value = yaml_field["values"][index_value] | |
366 | + else: | |
367 | + # Transform the string value to a datetime value | |
368 | + if field == "start_date": | |
369 | + if type(value) != datetime.datetime: | |
370 | + #value = datetime.datetime.strptime(yaml_content["sequence"][field]["value"],'%d/%m/%Y %H:%M:%S') | |
371 | + # ISO format | |
372 | + value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') | |
373 | + seq.__dict__[field] = value | |
374 | + ''' (orig) | |
375 | + else: | |
376 | + if not is_simplified: | |
377 | + if yaml_content["sequence"][field].get("values"): | |
378 | + index_value = yaml_content["sequence"][field]["value"] | |
379 | + values = yaml_content["sequence"][field]["values"] | |
380 | + if index_value < 0 or index_value > len(yaml_content["sequence"][field]["values"]): | |
381 | + result["errors"].append(f"Value of {field} isn't valid, index out of bounds ({index_value} > {len(values)})") | |
382 | + index_value = 0 | |
383 | + value = yaml_content["sequence"][field]["values"][index_value] | |
384 | + else: | |
385 | + if field == "start_date": | |
386 | + if type(value) != datetime.datetime: | |
387 | + #value = datetime.datetime.strptime(yaml_content["sequence"][field]["value"],'%d/%m/%Y %H:%M:%S') | |
388 | + # ISO format | |
389 | + value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') | |
390 | + seq.__dict__[field] = value | |
391 | + else: | |
392 | + if sequence_form.fields[field].__dict__.get("_choices"): | |
393 | + values = [value[0] for value in sequence_form.fields[field].__dict__.get("_choices")] | |
394 | + value = values[value] | |
395 | + ''' | |
396 | + | |
397 | + # suffisant ? => if field in seq.__dict__ | |
398 | + # If field is an attribute of the sequence, associate the field to the value | |
399 | + if field in seq.__dict__: | |
400 | + seq.__dict__[field] = value | |
401 | + else: | |
402 | + # else associate field & value in config_attributes sequence's field (JsonField) = variable fields of an sequence | |
403 | + seq.config_attributes[field] = value | |
404 | + | |
386 | 405 | |
387 | 406 | def create_sequence_pickle(sequence): |
388 | 407 | seq_dict = model_to_dict(sequence) |
... | ... | @@ -404,4 +423,6 @@ def create_sequence_pickle(sequence): |
404 | 423 | if not os.path.exists(data_path +f"sequences_pickle/P{period.id}/{sequence.night_id}"): |
405 | 424 | os.mkdir(data_path +f"sequences_pickle/P{period.id}/{sequence.night_id}") |
406 | 425 | seq_pickle_file_name = data_path +f"./sequences_pickle/P{period.id}/{sequence.night_id}/{sequence.id}.p" |
426 | + # get guitastro ephemeris | |
427 | + #fullseq_dict["ephem"] = fn.ephem(sequence.target) | |
407 | 428 | pickle.dump(fullseq_dict,open(seq_pickle_file_name,"wb")) |
408 | 429 | \ No newline at end of file | ... | ... |
src/core/pyros_django/routine_manager/templates/routine_manager/view_sequence.html
... | ... | @@ -44,14 +44,14 @@ |
44 | 44 | <td> </td> |
45 | 45 | <td><button type="submit" class="btn btn-success" name="action" value="check_validity">Check validity</button></td> |
46 | 46 | <td> </td> |
47 | - {% if seq.complete and seq.scientific_program in request.user.get_scientific_program and seq.status == "DRAFT" %} | |
47 | + {% if seq.complete and seq.scientific_program in request.user.get_scientific_programs and seq.status == "DRAFT" %} | |
48 | 48 | <td><button type="submit" class="btn btn-success" name="action" value="save_and_submit">Save and Submit</button></td> |
49 | 49 | <td> </td> |
50 | 50 | {% else %} |
51 | 51 | <td><button type="submit" class="btn btn-success" name="action" value="save_and_submit" disabled>Save and Submit</button></td> |
52 | 52 | <td> </td> |
53 | 53 | {% endif %} |
54 | - {% if seq.status == "TBP" and seq.scientific_program in request.user.get_scientific_program %} | |
54 | + {% if seq.status == "TBP" and seq.scientific_program in request.user.get_scientific_programs %} | |
55 | 55 | |
56 | 56 | <td><a href="{% url "unsubmit_sequence" seq.id %}" class="btn btn-warning" onclick="return confirm('The sequence will be unsubmitted')" |
57 | 57 | title="The sequence will be unsubmitted">Unsubmit sequence</a></td> | ... | ... |
src/core/pyros_django/routine_manager/tests.py
... | ... | @@ -268,9 +268,9 @@ class SequencesTests(TestCase): |
268 | 268 | pyros_user=self.usr1).order_by("-created").first() |
269 | 269 | seq_id = created_seq.id |
270 | 270 | config = OBSConfig(os.environ["PATH_TO_OBSCONF_FILE"]) |
271 | - sp_of_user = self.usr1.get_scientific_program() | |
271 | + sp_of_user = self.usr1.get_scientific_programs() | |
272 | 272 | sp_list = ScientificProgram.objects.observable_programs().filter(id__in=sp_of_user) |
273 | - sequence_form = SequenceForm(instance=created_seq, data_from_config=config.getEditableAttributesOfMount( | |
273 | + sequence_form = SequenceForm(instance=created_seq, data_from_config=config.getEditableMountAttributes( | |
274 | 274 | config.unit_name), layouts=config.get_layouts(config.unit_name), sp_list=sp_list) |
275 | 275 | layout = list(config.get_layouts(config.unit_name) |
276 | 276 | ["layouts"].values())[0]["name"] |
... | ... | @@ -317,7 +317,7 @@ class SequencesTests(TestCase): |
317 | 317 | # but this plan doesn't have config_attributes, we need to add them |
318 | 318 | plan = Plan.objects.get(album=album) |
319 | 319 | post_data = {} |
320 | - plan_form = PlanForm(data_from_config=config.getEditableAttributesOfChannel( | |
320 | + plan_form = PlanForm(data_from_config=config.getEditableChannelAttributes( | |
321 | 321 | config.unit_name, list(config.get_channels(config.unit_name).keys())[0]), edited_plan=None) |
322 | 322 | for field_name, field in plan_form.fields.items(): |
323 | 323 | if field.__dict__.get("_choices"): |
... | ... | @@ -359,9 +359,9 @@ class SequencesTests(TestCase): |
359 | 359 | pyros_user=self.usr1).order_by("-created").first() |
360 | 360 | seq_id = created_seq.id |
361 | 361 | config = OBSConfig(os.environ["PATH_TO_OBSCONF_FILE"]) |
362 | - sp_of_user = self.usr1.get_scientific_program() | |
362 | + sp_of_user = self.usr1.get_scientific_programs() | |
363 | 363 | sp_list = ScientificProgram.objects.observable_programs().filter(id__in=sp_of_user) |
364 | - sequence_form = SequenceForm(instance=created_seq, data_from_config=config.getEditableAttributesOfMount( | |
364 | + sequence_form = SequenceForm(instance=created_seq, data_from_config=config.getEditableMountAttributes( | |
365 | 365 | config.unit_name), layouts=config.get_layouts(config.unit_name), sp_list=sp_list) |
366 | 366 | layout = list(config.get_layouts(config.unit_name) |
367 | 367 | ["layouts"].values())[0]["name"] | ... | ... |
src/core/pyros_django/user_manager/models.py
... | ... | @@ -219,12 +219,13 @@ class PyrosUser(AbstractUser): |
219 | 219 | else: |
220 | 220 | return str |
221 | 221 | |
222 | - def get_scientific_program(self) -> QuerySet: | |
222 | + def get_scientific_programs(self) -> QuerySet: | |
223 | 223 | sp_where_user_is_sp_pi = ScientificProgram.objects.filter( |
224 | 224 | sp_pi=self.id) |
225 | - other_sp_of_user = ScientificProgram.objects.filter(id__in=SP_Period.objects.filter(id__in=SP_Period_User.objects.filter( | |
225 | + # Get all SP of user where he's not an SP PI | |
226 | + user_other_sp = ScientificProgram.objects.filter(id__in=SP_Period.objects.filter(id__in=SP_Period_User.objects.filter( | |
226 | 227 | user=PyrosUser.objects.get(username=self.username)).values("SP_Period")).values_list("scientific_program", flat=True)) |
227 | - sp_of_user = sp_where_user_is_sp_pi | other_sp_of_user | |
228 | + sp_of_user = sp_where_user_is_sp_pi | user_other_sp | |
228 | 229 | return sp_of_user |
229 | 230 | |
230 | 231 | def get_scientific_program_where_user_is_sp_pi(self) -> QuerySet: | ... | ... |
src/core/pyros_django/user_manager/views.py
... | ... | @@ -229,7 +229,7 @@ def users(request): |
229 | 229 | inactive_pyros_users = PyrosUser.objects.filter( |
230 | 230 | is_active=False).order_by("-id") |
231 | 231 | else: |
232 | - sp_of_current_user = current_user.get_scientific_program() | |
232 | + sp_of_current_user = current_user.get_scientific_programs() | |
233 | 233 | sp_periods_of_current_user = SP_Period.objects.filter( |
234 | 234 | scientific_program__in=sp_of_current_user) |
235 | 235 | common_scientific_programs = sp_of_current_user |
... | ... | @@ -325,7 +325,7 @@ def user_detail_view(request, pk): |
325 | 325 | "role") in ("Admin", "Unit-PI", "Unit-board") |
326 | 326 | CAN_VIEW_MOTIVE_OF_REGISTRATION = request.session.get("role") in ( |
327 | 327 | "Admin", "Unit-PI", "Unit-board") and len(user.motive_of_registration) > 0 |
328 | - scientific_programs = user.get_scientific_program() | |
328 | + scientific_programs = user.get_scientific_programs() | |
329 | 329 | except PyrosUser.DoesNotExist: |
330 | 330 | raise Http404("User does not exist") |
331 | 331 | return render(request, 'user_manager/user_detail.html', context={ | ... | ... |
... | ... | @@ -0,0 +1,20 @@ |
1 | +```mermaid | |
2 | +flowchart TD | |
3 | + A[Receive yaml file]-->B[Load yaml file] | |
4 | + B-->id1 | |
5 | + subgraph id1 [Transcript file into Sequence] | |
6 | + subgraph id2 [Process sequence] | |
7 | + C[Get scientific program of Sequence]-->D[Process form fields] | |
8 | + end | |
9 | + id2-->id3 | |
10 | + subgraph id3 [Process albums] | |
11 | + F[Get album name]-->G[Create album] | |
12 | + end | |
13 | + id3-->id4 | |
14 | + subgraph id4 [Process plan] | |
15 | + H[Process plan fields]-->I[Create plan] | |
16 | + end | |
17 | + end | |
18 | + id1-->J[Create Sequence] | |
19 | + J-->K[Return sequence id] | |
20 | +``` | |
0 | 21 | \ No newline at end of file | ... | ... |