genelastic 0.7.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,8 @@ import argparse
2
2
  import logging
3
3
  from pathlib import Path
4
4
 
5
+ from biophony import DEFAULT_RATE, MutSimParams
6
+
5
7
  from genelastic.common import add_verbose_control_args
6
8
 
7
9
  from .logger import configure_logging
@@ -13,46 +15,45 @@ logger = logging.getLogger("genelastic")
13
15
 
14
16
 
15
17
  def read_args() -> argparse.Namespace:
16
- """Read arguments from command line."""
18
+ """Read arguments from the command line."""
17
19
  parser = argparse.ArgumentParser(
18
- description="Genetics data random generator.",
20
+ description="Random bundle generator. "
21
+ "A bundle is a YAML file format used to import genetic data into an Elasticsearch database. "
22
+ "It can contain one or more analyses; "
23
+ "each analysis including metadata, references to "
24
+ "a wet lab and bioinformatics process "
25
+ "and paths to a VCF file and optionally to a coverage file.",
19
26
  formatter_class=argparse.ArgumentDefaultsHelpFormatter,
20
27
  allow_abbrev=False,
21
28
  )
22
29
  add_verbose_control_args(parser)
23
30
  parser.add_argument(
24
- "-d",
25
- "--data-folder",
26
- dest="data_folder",
27
- required=True,
28
- help="Data destination folder.",
31
+ "output_dir",
32
+ help="Path where analyses VCF and coverage files will be generated.",
29
33
  type=Path,
30
34
  )
31
- parser.add_argument(
32
- "--log-file", dest="log_file", help="Path to a log file."
33
- )
35
+ parser.add_argument("--log-file", help="Path to a log file.")
34
36
  parser.add_argument(
35
37
  "-n",
36
38
  "--chrom-nb",
37
- dest="chrom_nb",
38
39
  type=int,
39
40
  default=5,
40
41
  help="Number of chromosomes to include in the generated VCF file.",
41
42
  )
42
43
  parser.add_argument(
43
44
  "-o",
44
- "--output-yaml-file",
45
- dest="output_file",
45
+ "--output-bundle",
46
46
  default=None,
47
- help="Output YAML file.",
47
+ help="Path where the YAML bundle file will be written. "
48
+ "If no path is provided, the bundle is written to stdout.",
48
49
  type=Path,
49
50
  )
50
51
  parser.add_argument(
51
- "-s",
52
- "--sequence-size",
52
+ "-l",
53
+ "--sequence-length",
53
54
  type=int,
54
55
  default=2000,
55
- help="Sequence size (number of nucleotides) generated for each chromosome.",
56
+ help="Sequence length (number of nucleotides) generated for each chromosome.",
56
57
  )
57
58
  parser.add_argument(
58
59
  "-c",
@@ -64,7 +65,7 @@ def read_args() -> argparse.Namespace:
64
65
  "-a",
65
66
  "--analyses",
66
67
  help="Number of analyses to generate. "
67
- "Each analysis is composed of a YAML bundle file declaring its wet lab and bioinformatics processes, "
68
+ "Each analysis will reference a wet lab and bioinformatics process, "
68
69
  "a VCF file and optionally a coverage file.",
69
70
  default=1,
70
71
  type=int,
@@ -72,10 +73,31 @@ def read_args() -> argparse.Namespace:
72
73
  parser.add_argument(
73
74
  "-p",
74
75
  "--processes",
75
- help="Number of Wet Lab and Bioinformatics processes to generate.",
76
+ help="Number of wet lab and bioinformatics processes to generate.",
76
77
  default=1,
77
78
  type=int,
78
79
  )
80
+ parser.add_argument(
81
+ "-s",
82
+ "--snp-rate",
83
+ help="Generated VCF SNP rate.",
84
+ type=float,
85
+ default=DEFAULT_RATE,
86
+ )
87
+ parser.add_argument(
88
+ "-i",
89
+ "--ins-rate",
90
+ help="Generated VCF insertion rate.",
91
+ type=float,
92
+ default=DEFAULT_RATE,
93
+ )
94
+ parser.add_argument(
95
+ "-d",
96
+ "--del-rate",
97
+ help="Generated VCF deletion rate.",
98
+ type=float,
99
+ default=DEFAULT_RATE,
100
+ )
79
101
  return parser.parse_args()
80
102
 
81
103
 
@@ -83,10 +105,10 @@ def main() -> None:
83
105
  """Entry point of the gen-data script."""
84
106
  # Read command line arguments
85
107
  args = read_args()
86
- folder = args.data_folder.resolve()
108
+ output_dir = args.output_dir.resolve()
87
109
 
88
- if not folder.is_dir():
89
- msg = f"ERROR: '{folder}' does not exist or is not a directory."
110
+ if not output_dir.is_dir():
111
+ msg = f"ERROR: '{output_dir}' does not exist or is not a directory."
90
112
  raise SystemExit(msg)
91
113
 
92
114
  if args.analyses < 1:
@@ -103,13 +125,18 @@ def main() -> None:
103
125
 
104
126
  # Write to stdout or file
105
127
  RandomBundle(
106
- folder,
128
+ output_dir,
107
129
  args.analyses,
108
130
  args.processes,
109
131
  args.chrom_nb,
110
- args.sequence_size,
132
+ args.sequence_length,
133
+ MutSimParams(
134
+ snp_rate=args.snp_rate,
135
+ ins_rate=args.ins_rate,
136
+ del_rate=args.del_rate,
137
+ ),
111
138
  do_gen_coverage=args.coverage,
112
- ).to_yaml(args.output_file)
139
+ ).to_yaml(args.output_bundle)
113
140
 
114
141
 
115
142
  if __name__ == "__main__":
@@ -1,6 +1,5 @@
1
1
  import copy
2
2
  import random
3
- import shutil
4
3
  import sys
5
4
  import tempfile
6
5
  import typing
@@ -8,7 +7,14 @@ from abc import ABC, abstractmethod
8
7
  from pathlib import Path
9
8
 
10
9
  import yaml
11
- from biophony import BioSeqGen, CovGen, Elements, FastaWriter, MutSim
10
+ from biophony import (
11
+ BioSeqGen,
12
+ CovGen,
13
+ Elements,
14
+ FastaWriter,
15
+ MutSim,
16
+ MutSimParams,
17
+ )
12
18
 
13
19
  from genelastic.common import (
14
20
  RandomAnalysisData,
@@ -184,25 +190,39 @@ class RandomBiProcess(RandomBundleItem):
184
190
 
185
191
 
186
192
  class RandomAnalysis(RandomBundleItem):
187
- """Generate a random analysis."""
193
+ """Generate a random analysis.
194
+
195
+ :param fasta_dir: Directory where to create the FASTA file used as a basis to generate the analysis VCF file.
196
+ :param output_dir: Directory where the analysis VCF file
197
+ (and coverage file if `do_gen_coverage` is set to True) is generated.
198
+
199
+ :raises RuntimeError: Could not generate a VCF file with the given simulation parameters.
200
+ """
188
201
 
189
202
  def __init__( # noqa: PLR0913
190
203
  self,
191
- folder: Path,
204
+ fasta_dir: Path,
205
+ output_dir: Path,
192
206
  seq_len: int,
193
207
  nb_chrom: int,
194
208
  wet_proc_id: str,
195
209
  bi_proc_id: str,
210
+ sim_params: MutSimParams,
196
211
  *,
197
212
  do_gen_coverage: bool,
198
213
  ) -> None:
199
- self._folder = folder
214
+ self._fasta_dir = fasta_dir
215
+ self._output_dir = output_dir
200
216
  self._seq_len = seq_len
201
217
  self._nb_chrom = nb_chrom
202
- self._sample_name = "HG0003"
203
- self._source = "CNRGH"
204
218
  self._wet_process_id = wet_proc_id
205
219
  self._bi_process_id = bi_proc_id
220
+
221
+ self._sample_name = "HG000" + str(random.randint(1, 9))
222
+ sim_params.sample_name = self._sample_name
223
+ self._sim_params = sim_params
224
+
225
+ self._source = "CNRGH"
206
226
  self._barcode = self._random_alphanum_str(n=6)
207
227
  self._reference_genome = "hg38"
208
228
  self._prefix = (
@@ -215,37 +235,32 @@ class RandomAnalysis(RandomBundleItem):
215
235
  self.gen_cov_file()
216
236
 
217
237
  def _gen_vcf_file(self) -> None:
218
- """Generate a dummy VCF file."""
219
- temp_dir = Path(tempfile.mkdtemp())
220
-
221
- try:
222
- fasta_out_file = temp_dir / "seq.fasta"
223
- vcf_out_file = self._folder / f"{self._prefix}.vcf"
238
+ """Generate a dummy VCF file.
224
239
 
225
- # 1 - Generate a FASTA file and save it to a temporary directory.
226
- gen = BioSeqGen(
227
- elements=Elements(), seqlen=self._seq_len, count=self._nb_chrom
228
- )
229
- with fasta_out_file.open("w", encoding="utf-8") as f:
230
- FastaWriter(f, header=False).write_seqs(gen)
240
+ :raises RuntimeError: The call to `mutation-simulator` returned a non-zero exit status.
241
+ """
242
+ fasta_out_file = self._fasta_dir / "seq.fasta"
243
+ vcf_out_file = self._output_dir / f"{self._prefix}.vcf"
231
244
 
232
- # 2 - Generate a VCF from the previously created FASTA file.
233
- MutSim(
234
- fasta_file=str(fasta_out_file),
235
- vcf_file=str(vcf_out_file),
236
- snp_rate=0.02,
237
- ins_rate=0.01,
238
- del_rate=0.01,
239
- ).run()
245
+ # 1 - Generate a FASTA file and save it to a temporary directory.
246
+ gen = BioSeqGen(
247
+ elements=Elements(), seqlen=self._seq_len, count=self._nb_chrom
248
+ )
249
+ with fasta_out_file.open("w", encoding="utf-8") as f:
250
+ FastaWriter(f, header=False).write_seqs(gen)
240
251
 
241
- finally:
242
- shutil.rmtree(temp_dir)
252
+ # 2 - Generate a VCF from the previously created FASTA file.
253
+ MutSim(
254
+ fasta_file=str(fasta_out_file),
255
+ vcf_file=str(vcf_out_file),
256
+ sim_params=self._sim_params,
257
+ ).run()
243
258
 
244
259
  def gen_cov_file(self) -> None:
245
260
  """Generate a dummy coverage file."""
246
261
  chrom_end = self._seq_len - 1
247
262
 
248
- output_path = self._folder / f"{self._prefix}.cov.tsv"
263
+ output_path = self._output_dir / f"{self._prefix}.cov.tsv"
249
264
  with output_path.open("w", encoding="utf-8") as f:
250
265
  for chrom in range(1, self._nb_chrom + 1):
251
266
  coverage = CovGen(
@@ -280,7 +295,7 @@ class RandomAnalysis(RandomBundleItem):
280
295
  "DUAL228",
281
296
  "DUAL289",
282
297
  ],
283
- "data_path": str(self._folder),
298
+ "data_path": str(self._output_dir),
284
299
  }
285
300
 
286
301
 
@@ -289,15 +304,16 @@ class RandomBundle(RandomBundleItem):
289
304
 
290
305
  def __init__( # noqa: PLR0913
291
306
  self,
292
- folder: Path,
307
+ output_dir: Path,
293
308
  analyses_count: int,
294
309
  processes_count: int,
295
310
  nb_chrom: int,
296
311
  seq_len: int,
312
+ sim_params: MutSimParams,
297
313
  *,
298
314
  do_gen_coverage: bool,
299
315
  ) -> None:
300
- self._folder = folder
316
+ self._output_dir = output_dir
301
317
  self._analyses_count = analyses_count
302
318
  self._processes_count = processes_count
303
319
  self._nb_chrom = nb_chrom
@@ -319,19 +335,26 @@ class RandomBundle(RandomBundleItem):
319
335
  self._bi_processes, self._analyses_count
320
336
  )
321
337
 
322
- self._analyses.extend(
323
- [
324
- RandomAnalysis(
325
- self._folder,
326
- self._seq_len,
327
- self._nb_chrom,
328
- str(self._assigned_wet_processes[i]["proc_id"]),
329
- str(self._assigned_bi_processes[i]["proc_id"]),
330
- do_gen_coverage=self._do_gen_coverage,
331
- ).to_dict()
332
- for i in range(self._analyses_count)
333
- ]
334
- )
338
+ with tempfile.TemporaryDirectory() as fasta_dir:
339
+ try:
340
+ self._analyses.extend(
341
+ [
342
+ RandomAnalysis(
343
+ Path(fasta_dir),
344
+ self._output_dir,
345
+ self._seq_len,
346
+ self._nb_chrom,
347
+ str(self._assigned_wet_processes[i]["proc_id"]),
348
+ str(self._assigned_bi_processes[i]["proc_id"]),
349
+ sim_params,
350
+ do_gen_coverage=self._do_gen_coverage,
351
+ ).to_dict()
352
+ for i in range(self._analyses_count)
353
+ ]
354
+ )
355
+ except RuntimeError as e:
356
+ msg = f"VCF file generation for one analysis failed. {e}"
357
+ raise SystemExit(msg) from None
335
358
 
336
359
  @staticmethod
337
360
  def _assign_processes(
@@ -0,0 +1,18 @@
1
+ from genelastic.common import parse_server_launch_args
2
+ from genelastic.common.server import start_dev_server, start_prod_server
3
+
4
+
5
+ def main() -> None:
6
+ app_module = "genelastic.ui.server:app"
7
+ args = parse_server_launch_args("Start UI server.", 8001)
8
+ if args.env == "dev":
9
+ start_dev_server(app_module, args)
10
+ elif args.env == "prod":
11
+ start_prod_server(app_module, args)
12
+ else:
13
+ msg = f"Environment '{args.env}' is not implemented."
14
+ raise NotImplementedError(msg)
15
+
16
+
17
+ if __name__ == "__main__":
18
+ main()
@@ -0,0 +1,86 @@
1
+ import requests
2
+ from flask import Blueprint, current_app, render_template
3
+
4
+ routes_bp = Blueprint("routes", __name__)
5
+
6
+
7
+ @routes_bp.route("/")
8
+ def home() -> str:
9
+ api_url = current_app.config["GENUI_API_URL"]
10
+ try:
11
+ version_reponse = requests.get(f"{api_url}version", timeout=20)
12
+ version = version_reponse.json().get("version")
13
+ wet_processes_reponse = requests.get(
14
+ f"{api_url}wet_processes", timeout=20
15
+ )
16
+ wet_processes = wet_processes_reponse.json()
17
+ bi_processes_reponse = requests.get(
18
+ f"{api_url}bi_processes", timeout=20
19
+ )
20
+ bi_processes = bi_processes_reponse.json()
21
+ analyses_reponse = requests.get(f"{api_url}analyses", timeout=20)
22
+ analyses = analyses_reponse.json()
23
+ except requests.exceptions.RequestException:
24
+ version = "API not reachable"
25
+ wet_processes = []
26
+ bi_processes = []
27
+ analyses = []
28
+ return render_template(
29
+ "home.html",
30
+ version=version,
31
+ wet_processes=wet_processes,
32
+ bi_processes=bi_processes,
33
+ analyses=analyses,
34
+ )
35
+
36
+
37
+ @routes_bp.route("/analyses")
38
+ def show_analyses() -> str:
39
+ api_url = current_app.config["GENUI_API_URL"]
40
+ try:
41
+ analyses_reponse = requests.get(f"{api_url}analyses", timeout=20)
42
+ analyses = analyses_reponse.json()
43
+ except requests.exceptions.RequestException:
44
+ analyses = ["Error fetching data."]
45
+
46
+ return render_template("analyses.html", analyses=analyses)
47
+
48
+
49
+ @routes_bp.route("/bi_processes")
50
+ def show_bi_processes() -> str:
51
+ api_url = current_app.config["GENUI_API_URL"]
52
+ try:
53
+ bi_processes_reponse = requests.get(
54
+ f"{api_url}bi_processes", timeout=20
55
+ )
56
+ bi_processes = bi_processes_reponse.json()
57
+ except requests.exceptions.RequestException:
58
+ bi_processes = ["Error fetching data."]
59
+
60
+ return render_template("bi_processes.html", bi_processes=bi_processes)
61
+
62
+
63
+ @routes_bp.route("/wet_processes")
64
+ def show_wet_processes() -> str:
65
+ api_url = current_app.config["GENUI_API_URL"]
66
+ try:
67
+ wet_processes_reponse = requests.get(
68
+ f"{api_url}wet_processes", timeout=20
69
+ )
70
+ wet_processes = wet_processes_reponse.json()
71
+ except requests.exceptions.RequestException:
72
+ wet_processes = ["Error fetching data."]
73
+
74
+ return render_template("wet_processes.html", wet_processes=wet_processes)
75
+
76
+
77
+ @routes_bp.route("/version")
78
+ def show_version() -> str:
79
+ api_url = current_app.config["GENUI_API_URL"]
80
+ try:
81
+ version_reponse = requests.get(f"{api_url}version", timeout=20)
82
+ version = version_reponse.json().get("version", "Version not found")
83
+ except requests.exceptions.RequestException:
84
+ version = "Error fetching version."
85
+
86
+ return render_template("version.html", version=version)
genelastic/ui/server.py CHANGED
@@ -1,87 +1,14 @@
1
- import requests
2
- from flask import Flask, render_template
1
+ from asgiref.wsgi import WsgiToAsgi
2
+ from flask import Flask
3
3
 
4
- app = Flask(__name__)
5
- app.config.from_object("src.genelastic.ui.settings.Config")
4
+ from .routes import routes_bp
6
5
 
7
6
 
8
- @app.route("/")
9
- def home() -> str:
10
- api_url = app.config["GENUI_API_URL"]
11
- try:
12
- version_reponse = requests.get(f"{api_url}version", timeout=20)
13
- version = version_reponse.json().get("version")
14
- wet_processes_reponse = requests.get(
15
- f"{api_url}wet_processes", timeout=20
16
- )
17
- wet_processes = wet_processes_reponse.json()
18
- bi_processes_reponse = requests.get(
19
- f"{api_url}bi_processes", timeout=20
20
- )
21
- bi_processes = bi_processes_reponse.json()
22
- analyses_reponse = requests.get(f"{api_url}analyses", timeout=20)
23
- analyses = analyses_reponse.json()
24
- except requests.exceptions.RequestException:
25
- version = "API not reachable"
26
- wet_processes = []
27
- bi_processes = []
28
- analyses = []
29
- return render_template(
30
- "home.html",
31
- version=version,
32
- wet_processes=wet_processes,
33
- bi_processes=bi_processes,
34
- analyses=analyses,
35
- )
7
+ def create_app() -> WsgiToAsgi:
8
+ flask_app = Flask(__name__)
9
+ flask_app.config.from_object("genelastic.ui.settings")
10
+ flask_app.register_blueprint(routes_bp)
11
+ return WsgiToAsgi(flask_app) # type: ignore[no-untyped-call]
36
12
 
37
13
 
38
- @app.route("/analyses")
39
- def show_analyses() -> str:
40
- api_url = app.config["GENUI_API_URL"]
41
- try:
42
- analyses_reponse = requests.get(f"{api_url}analyses", timeout=20)
43
- analyses = analyses_reponse.json()
44
- except requests.exceptions.RequestException:
45
- analyses = ["Error fetching data."]
46
-
47
- return render_template("analyses.html", analyses=analyses)
48
-
49
-
50
- @app.route("/bi_processes")
51
- def show_bi_processes() -> str:
52
- api_url = app.config["GENUI_API_URL"]
53
- try:
54
- bi_processes_reponse = requests.get(
55
- f"{api_url}bi_processes", timeout=20
56
- )
57
- bi_processes = bi_processes_reponse.json()
58
- except requests.exceptions.RequestException:
59
- bi_processes = ["Error fetching data."]
60
-
61
- return render_template("bi_processes.html", bi_processes=bi_processes)
62
-
63
-
64
- @app.route("/wet_processes")
65
- def show_wet_processes() -> str:
66
- api_url = app.config["GENUI_API_URL"]
67
- try:
68
- wet_processes_reponse = requests.get(
69
- f"{api_url}wet_processes", timeout=20
70
- )
71
- wet_processes = wet_processes_reponse.json()
72
- except requests.exceptions.RequestException:
73
- wet_processes = ["Error fetching data."]
74
-
75
- return render_template("wet_processes.html", wet_processes=wet_processes)
76
-
77
-
78
- @app.route("/version")
79
- def show_version() -> str:
80
- api_url = app.config["GENUI_API_URL"]
81
- try:
82
- version_reponse = requests.get(f"{api_url}version", timeout=20)
83
- version = version_reponse.json().get("version", "Version not found")
84
- except requests.exceptions.RequestException:
85
- version = "Error fetching version."
86
-
87
- return render_template("version.html", version=version)
14
+ app = create_app()
genelastic/ui/settings.py CHANGED
@@ -3,9 +3,5 @@ from environs import Env
3
3
  env = Env()
4
4
  env.read_env()
5
5
 
6
-
7
- class Config:
8
- """Flask config class."""
9
-
10
- # Charger toutes les variables d'environnement nécessaires
11
- GENUI_API_URL = env.url("GENUI_API_URL").geturl()
6
+ # Charger toutes les variables d'environnement nécessaires
7
+ GENUI_API_URL = env.url("GENUI_API_URL").geturl()
@@ -0,0 +1,11 @@
1
+ {% extends "layout.html" %}
2
+ {% block title %}Analyses{% endblock %}
3
+ {% block content %}
4
+ <h2>List of Analyses</h2>
5
+ <ul>
6
+ {% for analyse in analyses %}
7
+ <li>{{ analyse }}</li>
8
+ {% endfor %}
9
+ </ul>
10
+ <a href="/">Back to Home</a>
11
+ {% endblock %}
@@ -0,0 +1,11 @@
1
+ {% extends "layout.html" %}
2
+ {% block title %}Bi Processes{% endblock %}
3
+ {% block content %}
4
+ <h2>List of Bi Processes</h2>
5
+ <ul>
6
+ {% for bi_process in bi_processes %}
7
+ <li>{{ bi_process }}</li>
8
+ {% endfor %}
9
+ </ul>
10
+ <a href="/">Back to Home</a>
11
+ {% endblock %}
@@ -0,0 +1,4 @@
1
+ {% extends "layout.html" %}
2
+ {% block title %}Home{% endblock %}
3
+ {% block content %}
4
+ {% endblock %}
@@ -0,0 +1,34 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{% block title %}Genelastic{% endblock %}</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
8
+ rel="stylesheet"
9
+ integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
10
+ crossorigin="anonymous"
11
+ >
12
+
13
+ </head>
14
+ <body>
15
+ <h1>Welcome to DEMO - genelastic UI</h1>
16
+ <nav>
17
+ <a href="/">Home</a>
18
+ <a href="/analyses">Analyses</a>
19
+ <a href="/wet_processes">Wet Processes</a>
20
+ <a href="/bi_processes">Bi Processes</a>
21
+ <a href="/version">Version</a>
22
+ </nav>
23
+ <hr>
24
+ <div id="content">
25
+ {% block content %}{% endblock %}
26
+ </div>
27
+
28
+
29
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
30
+ integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
31
+ crossorigin="anonymous">
32
+ </script>
33
+ </body>
34
+ </html>
@@ -0,0 +1,9 @@
1
+ {% extends "layout.html" %}
2
+ {% block title %}Version{% endblock %}
3
+ {% block content %}
4
+ <h2 class="text-center">Genelastic Version</h2>
5
+ <p class="text-center">{{ version }}</p>
6
+ <div class="mt-4 text-center">
7
+ <a href="/" class="btn btn-primary">Back to Home</a>
8
+ </div>
9
+ {% endblock %}
@@ -0,0 +1,11 @@
1
+ {% extends "layout.html" %}
2
+ {% block title %}Wet Processes{% endblock %}
3
+ {% block content %}
4
+ <h2>List of Wet Processes</h2>
5
+ <ul>
6
+ {% for wet_process in wet_processes %}
7
+ <li>{{ wet_process }}</li>
8
+ {% endfor %}
9
+ </ul>
10
+ <a href="/">Back to Home</a>
11
+ {% endblock %}