django-fast-treenode 2.0.11__py3-none-any.whl → 2.1.1__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.
Files changed (57) hide show
  1. {django_fast_treenode-2.0.11.dist-info → django_fast_treenode-2.1.1.dist-info}/LICENSE +2 -2
  2. django_fast_treenode-2.1.1.dist-info/METADATA +158 -0
  3. django_fast_treenode-2.1.1.dist-info/RECORD +64 -0
  4. {django_fast_treenode-2.0.11.dist-info → django_fast_treenode-2.1.1.dist-info}/WHEEL +1 -1
  5. treenode/admin/__init__.py +9 -0
  6. treenode/admin/admin.py +295 -0
  7. treenode/admin/changelist.py +65 -0
  8. treenode/admin/mixins.py +302 -0
  9. treenode/apps.py +12 -1
  10. treenode/cache.py +2 -2
  11. treenode/forms.py +8 -10
  12. treenode/managers/__init__.py +21 -0
  13. treenode/managers/adjacency.py +203 -0
  14. treenode/managers/closure.py +278 -0
  15. treenode/models/__init__.py +2 -1
  16. treenode/models/adjacency.py +343 -0
  17. treenode/models/classproperty.py +3 -0
  18. treenode/models/closure.py +23 -24
  19. treenode/models/factory.py +12 -2
  20. treenode/models/mixins/__init__.py +23 -0
  21. treenode/models/mixins/ancestors.py +65 -0
  22. treenode/models/mixins/children.py +81 -0
  23. treenode/models/mixins/descendants.py +66 -0
  24. treenode/models/mixins/family.py +63 -0
  25. treenode/models/mixins/logical.py +68 -0
  26. treenode/models/mixins/node.py +210 -0
  27. treenode/models/mixins/properties.py +156 -0
  28. treenode/models/mixins/roots.py +96 -0
  29. treenode/models/mixins/siblings.py +99 -0
  30. treenode/models/mixins/tree.py +344 -0
  31. treenode/signals.py +26 -0
  32. treenode/static/treenode/css/tree_widget.css +201 -31
  33. treenode/static/treenode/css/treenode_admin.css +48 -41
  34. treenode/static/treenode/js/tree_widget.js +269 -131
  35. treenode/static/treenode/js/treenode_admin.js +131 -171
  36. treenode/templates/admin/tree_node_changelist.html +6 -0
  37. treenode/templates/admin/treenode_ajax_rows.html +7 -0
  38. treenode/tests/tests.py +488 -0
  39. treenode/urls.py +10 -6
  40. treenode/utils/__init__.py +2 -0
  41. treenode/utils/aid.py +46 -0
  42. treenode/utils/base16.py +38 -0
  43. treenode/utils/base36.py +3 -1
  44. treenode/utils/db.py +116 -0
  45. treenode/utils/exporter.py +2 -0
  46. treenode/utils/importer.py +0 -1
  47. treenode/utils/radix.py +61 -0
  48. treenode/version.py +2 -2
  49. treenode/views.py +118 -43
  50. treenode/widgets.py +91 -43
  51. django_fast_treenode-2.0.11.dist-info/METADATA +0 -698
  52. django_fast_treenode-2.0.11.dist-info/RECORD +0 -42
  53. treenode/admin.py +0 -439
  54. treenode/docs/Documentation +0 -636
  55. treenode/managers.py +0 -419
  56. treenode/models/proxy.py +0 -669
  57. {django_fast_treenode-2.0.11.dist-info → django_fast_treenode-2.1.1.dist-info}/top_level.txt +0 -0
treenode/utils/db.py ADDED
@@ -0,0 +1,116 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ DB Vendor Utility Module
4
+
5
+ This module provides a utility function for converting integers
6
+ to Base36 string representation.
7
+
8
+ Features:
9
+ - Converts integers into a more compact Base36 format.
10
+ - Maintains lexicographic order when padded with leading zeros.
11
+ - Supports negative numbers.
12
+
13
+ Version: 2.1.0
14
+ Author: Timur Kady
15
+ Email: timurkady@yandex.com
16
+ """
17
+
18
+ import logging
19
+ from django.apps import apps
20
+ from django.db import connection
21
+
22
+ from ..models import TreeNodeModel
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def create_indexes(model):
28
+ """Create indexes for the descendants of TreeNodeModel."""
29
+ vendor = connection.vendor
30
+ sender = "Django Fast TeeNode"
31
+ table = model._meta.db_table
32
+
33
+ with connection.cursor() as cursor:
34
+ if vendor == "postgresql":
35
+ cursor.execute(
36
+ "SELECT indexname FROM pg_indexes WHERE tablename = %s AND indexname = %s;",
37
+ [table, f"idx_{table}_btree"]
38
+ )
39
+ if not cursor.fetchone():
40
+ cursor.execute(
41
+ f"CREATE INDEX idx_{table}_btree ON {table} USING BTREE (id);"
42
+ )
43
+ logger.info(f"{sender}: GIN index for table {table} created.")
44
+
45
+ # Если существует первичный ключ, выполняем кластеризацию
46
+ cursor.execute(
47
+ "SELECT relname FROM pg_class WHERE relname = %s;",
48
+ [f"{table}_pkey"]
49
+ )
50
+ if cursor.fetchone():
51
+ cursor.execute(f"CLUSTER {table} USING {table}_pkey;")
52
+ logger.info(f"{sender}: Table {table} is clustered.")
53
+
54
+ elif vendor == "mysql":
55
+ cursor.execute("SHOW TABLE STATUS WHERE Name = %s;", [table])
56
+ columns = [col[0] for col in cursor.description]
57
+ row = cursor.fetchone()
58
+ if row:
59
+ table_status = dict(zip(columns, row))
60
+ engine = table_status.get("Engine", "").lower()
61
+ if engine != "innodb":
62
+ cursor.execute(f"ALTER TABLE {table} ENGINE = InnoDB;")
63
+ logger.info(
64
+ f"{sender}: Table {table} has been converted to InnoDB."
65
+ )
66
+
67
+ elif vendor in ["microsoft", "oracle"]:
68
+ if vendor == "microsoft":
69
+ cursor.execute(
70
+ "SELECT name FROM sys.indexes WHERE name = %s AND object_id = OBJECT_ID(%s);",
71
+ [f"idx_{table}_cluster", table]
72
+ )
73
+ else:
74
+ cursor.execute(
75
+ "SELECT index_name FROM user_indexes WHERE index_name = %s;",
76
+ [f"IDX_{table.upper()}_CLUSTER"]
77
+ )
78
+ if not cursor.fetchone():
79
+ cursor.execute(
80
+ f"CREATE CLUSTERED INDEX idx_{table}_cluster ON {table} (id);")
81
+ logger.info(
82
+ f"{sender}: CLUSTERED index for table {table} created."
83
+ )
84
+
85
+ elif vendor == "sqlite":
86
+ # Kick those on SQLite
87
+ logger.warning(
88
+ f"{sender} Unable to create GIN and CLUSTER indexes for SQLite."
89
+ )
90
+ else:
91
+ logger.warning(
92
+ f"{sender}: Unknown vendor. Index creation cancelled."
93
+ )
94
+
95
+
96
+ def post_migrate_update(sender, **kwargs):
97
+ """Update indexes and tn_closure field only when necessary."""
98
+ # Перебираем все зарегистрированные модели
99
+ for model in apps.get_models():
100
+ # Check that the model inherits from TreeNodeModel and
101
+ # is not abstract
102
+ if issubclass(model, TreeNodeModel) and not model._meta.abstract:
103
+ # Create GIN and CLUSTER indexrs
104
+ create_indexes(model)
105
+ # Get ClosureModel
106
+ closure_model = model.closure_model
107
+ # Check node counts
108
+ al_count = model.objects.exists()
109
+ cl_counts = closure_model.objects.exclude(node=None).exists()
110
+
111
+ if al_count and not cl_counts:
112
+ # Call update_tree()
113
+ model.update_tree()
114
+
115
+
116
+ # The End
@@ -192,3 +192,5 @@ class TreeNodeExporter:
192
192
  writer.writerow({key: str(value) for key, value in row.items()})
193
193
 
194
194
  return response
195
+
196
+ # The End
@@ -325,5 +325,4 @@ class TreeNodeImporter:
325
325
  text = self.get_text_content()
326
326
  return list(csv.DictReader(StringIO(text), delimiter="\t"))
327
327
 
328
-
329
328
  # The End
@@ -0,0 +1,61 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Implementation of the Radix Sort algorithm.
4
+
5
+ Radix Sort is a non-comparative sorting algorithm. It avoids comparisons by
6
+ creating and distributing elements into buckets according to their radix.
7
+
8
+ It is used as a replacement for numpy when sorting materialized paths and
9
+ tree node indices.
10
+
11
+ Version: 2.1.0
12
+ Author: Timur Kady
13
+ Email: timurkady@yandex.com
14
+ """
15
+
16
+ from collections import defaultdict
17
+
18
+
19
+ def counting_sort(pairs, index):
20
+ """Sort pairs (key, string) by character at position index."""
21
+ count = defaultdict(list)
22
+
23
+ # Distribution of pairs into baskets
24
+ for key, s in pairs:
25
+ key_char = s[index] if index < len(s) else ''
26
+ count[key_char].append((key, s))
27
+
28
+ # Collect sorted pairs
29
+ sorted_pairs = []
30
+ for key_char in sorted(count.keys()):
31
+ sorted_pairs.extend(count[key_char])
32
+
33
+ return sorted_pairs
34
+
35
+
36
+ def radix_sort_pairs(pairs, max_length):
37
+ """Radical sorting of pairs (key, string) by string."""
38
+ for i in range(max_length - 1, -1, -1):
39
+ pairs = counting_sort(pairs, i)
40
+ return pairs
41
+
42
+
43
+ def quick_sort(pairs):
44
+ """
45
+ Sort tree objects by materialized path.
46
+
47
+ pairs = [{obj.id: obj.path} for obj in objs]
48
+ Returns a list of id (pk) objects sorted by their materialized path.
49
+ """
50
+ # Get the maximum length of the string
51
+ max_length = max(len(s) for _, s in pairs)
52
+
53
+ # Sort pairs by rows
54
+ sorted_pairs = radix_sort_pairs(pairs, max_length)
55
+
56
+ # Access keys in sorted order
57
+ sorted_keys = [key for key, _ in sorted_pairs]
58
+ return sorted_keys
59
+
60
+
61
+ # The End
treenode/version.py CHANGED
@@ -4,10 +4,10 @@ TreeNode Version Module
4
4
 
5
5
  This module defines the current version of the TreeNode package.
6
6
 
7
- Version: 2.0.0
7
+ Version: 2.1.0
8
8
  Author: Timur Kady
9
9
  Email: timurkady@yandex.com
10
10
  """
11
11
 
12
12
 
13
- __version__ = '2.0.0'
13
+ __version__ = '2.1.0'
treenode/views.py CHANGED
@@ -14,34 +14,41 @@ Features:
14
14
  - Uses optimized QuerySets for efficient database queries.
15
15
  - Handles validation and error responses gracefully.
16
16
 
17
- Version: 2.0.11
17
+ Version: 2.1.0
18
18
  Author: Timur Kady
19
19
  Email: timurkady@yandex.com
20
20
  """
21
21
 
22
-
23
- from django.http import JsonResponse
24
- from django.views import View
25
22
  from django.apps import apps
26
- import numpy as np
27
- from django.core.exceptions import ObjectDoesNotExist
23
+ from django.http import JsonResponse
28
24
  from django.utils.translation import gettext_lazy as _
25
+ from django.views import View
26
+
27
+ import logging
28
+
29
+ logger = logging.getLogger(__name__)
29
30
 
30
31
 
31
32
  class TreeNodeAutocompleteView(View):
32
- """Returns JSON data for Select2 with tree structure."""
33
+ """
34
+ Return JSON data for Select2 with tree structure.
35
+
36
+ Lazy load tree nodes for Select2 scrolling with reference node support.
37
+ """
33
38
 
34
39
  def get(self, request):
35
- """Get method."""
36
- q = request.GET.get("q", "")
37
- model_label = request.GET.get("model") # Получаем модель
40
+ """
41
+ Process an AJAX request to lazily load tree nodes.
38
42
 
39
- if not model_label:
40
- return JsonResponse(
41
- {"error": "Missing model parameter"},
42
- status=400
43
- )
43
+ Operation logic:
44
+ """
45
+ # Search Processing
46
+ q = request.GET.get("q", "")
47
+ if q:
48
+ return self.search(request)
44
49
 
50
+ # Model extracting
51
+ model_label = request.GET.get("model")
45
52
  try:
46
53
  model = apps.get_model(model_label)
47
54
  except LookupError:
@@ -50,23 +57,37 @@ class TreeNodeAutocompleteView(View):
50
57
  status=400
51
58
  )
52
59
 
53
- queryset = model.objects.filter(name__icontains=q)
54
- # Sorting
55
- tn_orders = np.array([obj.tn_order for obj in queryset])
56
- sorted_indices = np.argsort(tn_orders)
57
- queryset_list = list(queryset.iterator())
58
- sorted_queryset = [queryset_list[int(idx)] for idx in sorted_indices]
59
-
60
+ # select_id not specified
61
+ queryset_list = model.get_roots()
62
+
63
+ select_id = request.GET.get("select_id", "")
64
+ if select_id:
65
+ select = model.objects.filter(pk=select_id).first()
66
+ if not select:
67
+ return JsonResponse(
68
+ {"error": f"Invalid select_id: {select_id}"},
69
+ status=400
70
+ )
71
+ breadcrumbs = select.get_breadcrumbs()
72
+ # Delete self
73
+ del breadcrumbs[-1]
74
+ for pk in breadcrumbs:
75
+ parent = model.model.objects.filter(pk=pk).first()
76
+ children = parent.get_children()
77
+ queryset_list.extend(children)
78
+
79
+ nodes = model._sort_node_list(queryset_list)
80
+ # Generate a response
60
81
  results = [
61
82
  {
62
83
  "id": node.pk,
63
- "text": node.name,
64
- "level": node.get_level(),
84
+ "text": str(node),
85
+ "level": node.get_depth(),
65
86
  "is_leaf": node.is_leaf(),
66
87
  }
67
- for node in sorted_queryset
88
+ for node in nodes
68
89
  ]
69
-
90
+ # Add the "Root" option to the top of the list
70
91
  root_option = {
71
92
  "id": "",
72
93
  "text": _("Root"),
@@ -75,20 +96,60 @@ class TreeNodeAutocompleteView(View):
75
96
  }
76
97
  results.insert(0, root_option)
77
98
 
78
- return JsonResponse({"results": results})
99
+ response_data = {"results": results}
100
+ return JsonResponse(response_data)
79
101
 
102
+ def search(self, request):
103
+ """Search processing."""
104
+ # Chack search query
105
+ q = request.GET.get("q", "")
106
+ if not q:
107
+ return JsonResponse({"results": []})
80
108
 
81
- class GetChildrenCountView(View):
82
- """Return the number of children for a given parent node."""
109
+ # Model extracting
110
+ model_label = request.GET.get("model")
111
+ try:
112
+ model = apps.get_model(model_label)
113
+ except LookupError:
114
+ return JsonResponse(
115
+ {"error": f"Invalid model: {model_label}"},
116
+ status=400
117
+ )
83
118
 
84
- def get(self, request):
85
- """Get method."""
86
- parent_id = request.GET.get("parent_id")
87
- model_label = request.GET.get("model") # Получаем модель
119
+ # Search
120
+ params = {}
121
+ treenode_field = model.treenode_display_field
122
+ if not treenode_field:
123
+ return {"results": ""}
88
124
 
89
- if not model_label or not parent_id:
90
- return JsonResponse({"error": "Missing parameters"}, status=400)
125
+ params[f"{treenode_field}__icontains"] = q
126
+ queryset = model.objects.filter(**params)[:15]
127
+ queryset_list = list(queryset)
128
+ nodes = model._sort_node_list(queryset_list)
129
+ results = [
130
+ {
131
+ "id": node.pk,
132
+ "text": node.name,
133
+ "level": node.get_depth(),
134
+ "is_leaf": node.is_leaf(),
135
+ }
136
+ for node in nodes
137
+ ]
138
+ return JsonResponse({"results": results})
139
+
140
+
141
+ class ChildrenView(View):
142
+ """Return JSON data for Select2 with node children."""
91
143
 
144
+ def get(self, request):
145
+ """Process an AJAX request to load node children."""
146
+ # Get reference_id
147
+ reference_id = request.GET.get("reference_id", "")
148
+ if not reference_id:
149
+ return JsonResponse({"results": []})
150
+
151
+ # Model extracting
152
+ model_label = request.GET.get("model")
92
153
  try:
93
154
  model = apps.get_model(model_label)
94
155
  except LookupError:
@@ -96,14 +157,28 @@ class GetChildrenCountView(View):
96
157
  {"error": f"Invalid model: {model_label}"},
97
158
  status=400
98
159
  )
99
-
100
- try:
101
- parent_node = model.objects.get(pk=parent_id)
102
- children_count = parent_node.get_children_count()
103
- except ObjectDoesNotExist:
160
+ obj = model.objects.filter(pk=reference_id).first()
161
+ if not obj:
104
162
  return JsonResponse(
105
- {"error": "Parent node not found"},
106
- status=404
163
+ {"error": f"Invalid reference_id: {reference_id}"},
164
+ status=400
107
165
  )
108
166
 
109
- return JsonResponse({"children_count": children_count})
167
+ if obj.is_leaf():
168
+ return JsonResponse({"results": []})
169
+
170
+ queryset_list = obj.get_children()
171
+ nodes = model._sort_node_list(queryset_list)
172
+ results = [
173
+ {
174
+ "id": node.pk,
175
+ "text": node.name,
176
+ "level": node.get_depth(),
177
+ "is_leaf": node.is_leaf(),
178
+ }
179
+ for node in nodes
180
+ ]
181
+ return JsonResponse({"results": results})
182
+
183
+
184
+ # The End
treenode/widgets.py CHANGED
@@ -2,80 +2,128 @@
2
2
  """
3
3
  TreeNode Widgets Module
4
4
 
5
- This module defines custom form widgets for handling hierarchical data
6
- within Django's admin interface. It includes a Select2-based widget
7
- for tree-structured data selection.
8
-
9
- Features:
10
- - `TreeWidget`: A custom Select2 widget that enhances usability for
11
- hierarchical models.
12
- - Automatically fetches hierarchical data via AJAX.
13
- - Supports dynamic model binding for reusable implementations.
14
- - Integrates with Django’s form system.
15
-
16
- Version: 2.0.11
5
+ This module defines a custom form widget for handling hierarchical data
6
+ within Django's admin interface. It replaces the standard <select> dropdown
7
+ with a fully customizable tree selection UI.
8
+
9
+ Version: 2.1.0
17
10
  Author: Timur Kady
18
11
  Email: timurkady@yandex.com
19
12
  """
20
13
 
21
-
14
+ import json
22
15
  from django import forms
16
+ from django.urls import reverse
17
+ from django.utils.safestring import mark_safe
18
+ from django.utils.translation import gettext_lazy as _
23
19
 
24
20
 
25
- class TreeWidget(forms.Select):
26
- """Custom Select2 widget for hierarchical data."""
21
+ class TreeWidget(forms.Widget):
22
+ """Custom widget for hierarchical tree selection."""
27
23
 
28
24
  class Media:
29
- """Mrta class."""
30
-
31
- css = {
32
- "all": (
33
- "https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css",
34
- "treenode/css/tree_widget.css",
35
- )
36
- }
37
- js = (
38
- "https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js",
39
- "treenode/js/tree_widget.js",
40
- )
25
+ """Meta class to define required CSS and JS files."""
26
+
27
+ css = {"all": ("treenode/css/tree_widget.css",)}
28
+ js = ("treenode/js/tree_widget.js",)
41
29
 
42
30
  def build_attrs(self, base_attrs, extra_attrs=None):
43
- """Add attributes for Select2 integration."""
31
+ """Build attributes for the widget."""
44
32
  attrs = super().build_attrs(base_attrs, extra_attrs)
45
- attrs.setdefault("data-url", "/treenode/tree-autocomplete/")
46
- existing_class = attrs.get("class", "")
47
- attrs["class"] = f"{existing_class} tree-widget".strip()
48
- if "placeholder" in attrs:
49
- del attrs["placeholder"]
33
+ attrs.setdefault("data-url", reverse("tree_autocomplete"))
34
+ attrs.setdefault("data-url-children", reverse("tree_children"))
35
+ attrs["class"] = f"{attrs.get('class', '')} tree-widget".strip()
50
36
 
51
- # Force passing `model`
52
37
  if "data-forward" not in attrs:
53
38
  model = getattr(self, "model", None)
39
+
54
40
  if not model and hasattr(self.choices, "queryset"):
55
41
  model = self.choices.queryset.model
56
42
  if model is None:
57
43
  raise ValueError("TreeWidget: model not passed or not defined")
58
44
 
59
45
  try:
60
- label = model._meta.app_label
61
- model_name = model._meta.model_name
62
- model_label = f"{label}.{model_name}"
63
- attrs["data-forward"] = f'{{"model": "{model_label}"}}'
46
+ forward_data = json.dumps({"model": model._meta.label})
47
+ attrs["data-forward"] = forward_data.replace('"', "&quot;")
64
48
  except AttributeError as e:
65
- raise ValueError(
66
- "TreeWidget: model object is not a valid Django model"
67
- ) from e
49
+ raise ValueError("TreeWidget: invalid Django model") from e
68
50
 
69
- # Force focus to current value
70
51
  if self.choices:
71
52
  try:
72
53
  current_value = self.value()
73
54
  if current_value:
74
55
  attrs["data-selected"] = str(current_value)
75
56
  except Exception:
76
- # In case the value is missing
77
57
  pass
78
58
 
79
59
  return attrs
80
60
 
61
+ def optgroups(self, name, value, attrs=None):
62
+ """
63
+ Override optgroups to return an empty structure.
64
+
65
+ The dropdown will be rendered dynamically via JS.
66
+ """
67
+ return []
68
+
69
+ def render(self, name, value, attrs=None, renderer=None):
70
+ """Render widget as a hidden input + tree container structure."""
71
+
72
+ attrs = self.build_attrs(attrs)
73
+ attrs["name"] = name
74
+ attrs["type"] = "hidden"
75
+ if value:
76
+ attrs["value"] = str(value)
77
+
78
+ # If value is set, try to get string representation of instance,
79
+ # otherwise print "Root"
80
+ if value not in [None, "", "None"]:
81
+ try:
82
+ from django.apps import apps
83
+ # Define the model: first self.model if it is defined,
84
+ # otherwise via choices
85
+ model = getattr(self, "model", None)
86
+ if model is None and hasattr(self, "choices") and getattr(self.choices, "queryset", None):
87
+ model = self.choices.queryset.model
88
+ if model is not None:
89
+ instance = model.objects.get(pk=value)
90
+ selected_value = str(instance)
91
+ else:
92
+ selected_value = str(value)
93
+ except Exception:
94
+ selected_value = str(value)
95
+ else:
96
+ selected_value = _("Root")
97
+
98
+ # Remove the 'tree-widget' class from the input so it doesn't interfere
99
+ # with container initialization
100
+ if "class" in attrs:
101
+ attrs["class"] = " ".join(
102
+ [cl for cl in attrs["class"].split() if cl != "tree-widget"])
103
+
104
+ html = """
105
+ <div class="tree-widget">
106
+ <div class="tree-widget-display">
107
+ <span class="selected-node">{selected_value}</span>
108
+ <span class="tree-dropdown-arrow">▼</span>
109
+ </div>
110
+ <input {attrs} />
111
+ <div class="tree-widget-dropdown">
112
+ <div class="tree-search-wrapper">
113
+ <span class="tree-search-icon">&#x1F50E;&#xFE0E;</span>
114
+ <input type="text" class="tree-search" placeholder="{search_placeholder}" />
115
+ <button type="button" class="tree-search-clear">&times;</button>
116
+ </div>
117
+ <ul class="tree-list"></ul>
118
+ </div>
119
+ </div>
120
+ """.format(
121
+ attrs=" ".join(f'{key}="{val}"' for key, val in attrs.items()),
122
+ search_placeholder=_("Search node..."),
123
+ selected_value=selected_value
124
+ )
125
+
126
+ return mark_safe(html)
127
+
128
+
81
129
  # The End