django-lineup 1.0.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.
- django_lineup-1.0.1.dist-info/METADATA +242 -0
- django_lineup-1.0.1.dist-info/RECORD +27 -0
- django_lineup-1.0.1.dist-info/WHEEL +5 -0
- django_lineup-1.0.1.dist-info/licenses/LICENSE +14 -0
- django_lineup-1.0.1.dist-info/top_level.txt +1 -0
- lineup/__init__.py +1 -0
- lineup/admin.py +69 -0
- lineup/apps.py +11 -0
- lineup/exceptions.py +10 -0
- lineup/forms.py +10 -0
- lineup/management/__init__.py +1 -0
- lineup/management/commands/__init__.py +1 -0
- lineup/management/commands/import_menu_from_json.py +35 -0
- lineup/managers.py +7 -0
- lineup/migrations/0001_initial.py +41 -0
- lineup/migrations/__init__.py +0 -0
- lineup/models.py +139 -0
- lineup/static/css/lineup.css +0 -0
- lineup/static/js/lineup.js +0 -0
- lineup/templates/admin/lineup/menuitem/change_list.html +13 -0
- lineup/templates/lineup/base.html +21 -0
- lineup/templates/lineup/breadcrumbs.html +7 -0
- lineup/templates/lineup/menu.html +21 -0
- lineup/templatetags/__init__.py +1 -0
- lineup/templatetags/lineup_tags.py +170 -0
- lineup/urls.py +8 -0
- lineup/views.py +9 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-lineup
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Navigation system for Django sites
|
|
5
|
+
Author-email: Otto <opensource@otto.srl>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/otto-torino/django-lineup
|
|
8
|
+
Project-URL: Documentation, https://django-lineup.readthedocs.io/en/latest/
|
|
9
|
+
Project-URL: Repository, https://github.com/otto-torino/django-lineup
|
|
10
|
+
Project-URL: Bug Tracker, https://github.com/otto-torino/django-lineup/issues
|
|
11
|
+
Keywords: django,navigation,menu,mptt
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 6.0
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/x-rst
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: Django>=4.2
|
|
29
|
+
Requires-Dist: django-mptt>=0.17.0
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
=============================
|
|
33
|
+
Django Lineup
|
|
34
|
+
=============================
|
|
35
|
+
|
|
36
|
+
.. image:: https://badge.fury.io/py/django-lineup.svg
|
|
37
|
+
:target: https://badge.fury.io/py/django-lineup
|
|
38
|
+
|
|
39
|
+
.. image:: https://travis-ci.org/otto-torino/django-lineup.svg?branch=master
|
|
40
|
+
:target: https://travis-ci.com/github/otto-torino/django-lineup
|
|
41
|
+
|
|
42
|
+
.. image:: https://codecov.io/gh/otto-torino/django-lineup/branch/master/graph/badge.svg
|
|
43
|
+
:target: https://codecov.io/gh/otto-torino/django-lineup
|
|
44
|
+
|
|
45
|
+
.. image:: https://static.pepy.tech/badge/django-lineup
|
|
46
|
+
:target: https://pepy.tech/project/django-lineup
|
|
47
|
+
|
|
48
|
+
Multiple navigation system for django sites.
|
|
49
|
+
|
|
50
|
+
Django Lineup lets you manage a tree of items. Each first level node represents a menu you can include in your templates.
|
|
51
|
+
|
|
52
|
+
.. image:: images/lineup.png
|
|
53
|
+
|
|
54
|
+
Documentation
|
|
55
|
+
-------------
|
|
56
|
+
|
|
57
|
+
The full documentation is at https://django-lineup.readthedocs.io.
|
|
58
|
+
|
|
59
|
+
Quickstart
|
|
60
|
+
----------
|
|
61
|
+
|
|
62
|
+
Install Django Lineup::
|
|
63
|
+
|
|
64
|
+
pip install django-lineup
|
|
65
|
+
|
|
66
|
+
``django-mptt`` is installed automatically as an internal dependency; it does
|
|
67
|
+
not need to be added to your project's ``INSTALLED_APPS``.
|
|
68
|
+
|
|
69
|
+
Add it to your `INSTALLED_APPS`:
|
|
70
|
+
|
|
71
|
+
.. code-block:: python
|
|
72
|
+
|
|
73
|
+
INSTALLED_APPS = (
|
|
74
|
+
...
|
|
75
|
+
'lineup.apps.LineupConfig',
|
|
76
|
+
...
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
Add to your main `urls.py`:
|
|
80
|
+
|
|
81
|
+
.. code-block:: python
|
|
82
|
+
|
|
83
|
+
...
|
|
84
|
+
path("lineup/", include("lineup.urls", namespace="lineup")),
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
Make sure the ``requests`` context processor is included (it is by default):
|
|
88
|
+
|
|
89
|
+
.. code-block:: python
|
|
90
|
+
|
|
91
|
+
TEMPLATES = [
|
|
92
|
+
{
|
|
93
|
+
'OPTIONS': {
|
|
94
|
+
'context_processors': [
|
|
95
|
+
"django.template.context_processors.request",
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
Render a menu:::
|
|
102
|
+
|
|
103
|
+
{% load lineup_tags %}
|
|
104
|
+
{% lineup_menu 'my-root-item-slug '%}
|
|
105
|
+
|
|
106
|
+
Render the breadcrumbs:::
|
|
107
|
+
|
|
108
|
+
{% load lineup_tags %}
|
|
109
|
+
{% lineup_breadcrumbs 'my-root-item-slug '%}
|
|
110
|
+
|
|
111
|
+
Import a menu from a json:::
|
|
112
|
+
|
|
113
|
+
$ python manage.py import_menu_from_json
|
|
114
|
+
|
|
115
|
+
Json example:::
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
"label": "Main Menu",
|
|
119
|
+
"slug": "main-menu",
|
|
120
|
+
"order": 0,
|
|
121
|
+
"children": [
|
|
122
|
+
{
|
|
123
|
+
"label": "Tools",
|
|
124
|
+
"slug": "tools",
|
|
125
|
+
"order": 0,
|
|
126
|
+
"children": [
|
|
127
|
+
{
|
|
128
|
+
"label": "DNS Tools",
|
|
129
|
+
"slug": "dns-tools",
|
|
130
|
+
"order": 0,
|
|
131
|
+
"login_required": true,
|
|
132
|
+
"children": [
|
|
133
|
+
{
|
|
134
|
+
"label": "DMARC DNS Tools",
|
|
135
|
+
"slug": "dmarc-dns-tools",
|
|
136
|
+
"link": "/dmarc-tools/",
|
|
137
|
+
"title": "DMARC Rulez",
|
|
138
|
+
"order": 0
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"label": "Password Generator",
|
|
144
|
+
"slug": "password-generator",
|
|
145
|
+
"order": 1
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"label": "Disabled Item",
|
|
151
|
+
"slug": "disabled-item",
|
|
152
|
+
"order": 1,
|
|
153
|
+
"enabled": false,
|
|
154
|
+
"children": [
|
|
155
|
+
{
|
|
156
|
+
"label": "Disabled child",
|
|
157
|
+
"slug": "disabled-child",
|
|
158
|
+
"order": 0
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"label": "Perm Item",
|
|
164
|
+
"slug": "perm-item",
|
|
165
|
+
"order": 2,
|
|
166
|
+
"permissions": [
|
|
167
|
+
"add_permission",
|
|
168
|
+
"view_session"
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
Features
|
|
175
|
+
--------
|
|
176
|
+
|
|
177
|
+
- Multiple menus supported
|
|
178
|
+
- Visibility logic: login required / permissions
|
|
179
|
+
- Render menu tree templatetags
|
|
180
|
+
- Breadcrumbs templetetag
|
|
181
|
+
- Import a menu from json management command
|
|
182
|
+
- Rebuild tree button in admin
|
|
183
|
+
- `Django Baton <https://github.com/otto-torino/django-baton>`_ integration to highlight different menu in the admin
|
|
184
|
+
|
|
185
|
+
Running Tests
|
|
186
|
+
-------------
|
|
187
|
+
|
|
188
|
+
Does the code actually work?
|
|
189
|
+
|
|
190
|
+
::
|
|
191
|
+
|
|
192
|
+
source <YOURVIRTUALENV>/bin/activate
|
|
193
|
+
(myenv) $ pip install -r requirements_test.txt
|
|
194
|
+
(myenv) $ python runtests.py
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
Development commands
|
|
198
|
+
---------------------
|
|
199
|
+
|
|
200
|
+
::
|
|
201
|
+
|
|
202
|
+
pip install -r requirements_dev.txt
|
|
203
|
+
invoke -l
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
Example app
|
|
207
|
+
---------------------
|
|
208
|
+
|
|
209
|
+
This example is provided as a convenience feature to allow potential users to try the app straight from the app repo without having to create a django project.
|
|
210
|
+
|
|
211
|
+
It can also be used to develop the app in place.
|
|
212
|
+
|
|
213
|
+
To run this example, follow these instructions:
|
|
214
|
+
|
|
215
|
+
1. Navigate to the root directory of your application (same as `manage.py`)
|
|
216
|
+
2. Install the requirements for the package:
|
|
217
|
+
|
|
218
|
+
pip install -r requirements_test.txt
|
|
219
|
+
|
|
220
|
+
3. Apply migrations
|
|
221
|
+
|
|
222
|
+
python manage.py migrate
|
|
223
|
+
|
|
224
|
+
4. Run the server
|
|
225
|
+
|
|
226
|
+
python manage.py runserver
|
|
227
|
+
|
|
228
|
+
5. Access from the browser at `http://127.0.0.1:8000`
|
|
229
|
+
6. Admin user account is admin:admin
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
Credits
|
|
233
|
+
-------
|
|
234
|
+
Django Lineup is developed by Otto SRL.
|
|
235
|
+
|
|
236
|
+
Tools used in rendering this package:
|
|
237
|
+
|
|
238
|
+
* Cookiecutter_
|
|
239
|
+
* `cookiecutter-djangopackage`_
|
|
240
|
+
|
|
241
|
+
.. _Cookiecutter: https://github.com/audreyr/cookiecutter
|
|
242
|
+
.. _`cookiecutter-djangopackage`: https://github.com/pydanny/cookiecutter-djangopackage
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
django_lineup-1.0.1.dist-info/licenses/LICENSE,sha256=xVzjK5SKF_i6osJ2PlbUGdoqJ2yBGNJgDPbrE0WlL8k,1071
|
|
2
|
+
lineup/__init__.py,sha256=d4QHYmS_30j0hPN8NmNPnQ_Z0TphDRbu4MtQj9cT9e8,22
|
|
3
|
+
lineup/admin.py,sha256=QoOCry9m1B9tkL0q-5fYfsltJgnMgwNtdO5PBvM1Hmw,2535
|
|
4
|
+
lineup/apps.py,sha256=juHiBf84m8AWiC8T37VJTyQO2fHrFsOx6C3RzHbUGh4,374
|
|
5
|
+
lineup/exceptions.py,sha256=2VkmNeSGC4OjBFrOxXRB1WLKNl2mLG2crXx9xO81rNk,141
|
|
6
|
+
lineup/forms.py,sha256=N2q85OrPd1SZxzgf6slvE9HOBUIrASJ1yWj8AX2_9yg,340
|
|
7
|
+
lineup/managers.py,sha256=TQSCpX6_SwHwO-coCXmp4X-nlP6qumfJ4Yo9AmacHsA,175
|
|
8
|
+
lineup/models.py,sha256=OBFIjSnQs2cQs2JdwHEOwlo9OD3e109i7tydqBauY4E,4336
|
|
9
|
+
lineup/urls.py,sha256=7LbwCS7JHGeMb6Vcm0bPow3-QjoCuIQD-kOce9f4j90,152
|
|
10
|
+
lineup/views.py,sha256=QAqwdee-OFB4WyVXOLrXuc1Ipro6r5D7nEVo4gEd4m4,299
|
|
11
|
+
lineup/management/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
12
|
+
lineup/management/commands/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
13
|
+
lineup/management/commands/import_menu_from_json.py,sha256=MB1LCLUZvS6HkrZWdslNrohlF2aw8EKYcVfFdcn4_hw,1024
|
|
14
|
+
lineup/migrations/0001_initial.py,sha256=DxDz_OkmiYvazF_RFfhAqms5IkRccBBOFY8jHL3j1k4,2467
|
|
15
|
+
lineup/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
lineup/static/css/lineup.css,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
lineup/static/js/lineup.js,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
lineup/templates/admin/lineup/menuitem/change_list.html,sha256=b7GIvxJHGqVTzV9ha_HruO4l5aHTZvvA-mCHrYJ4h9A,499
|
|
19
|
+
lineup/templates/lineup/base.html,sha256=zzaUPjEgvwxg-Gn2rZ6gIMWphucmEGvAU_skUXJIcDA,661
|
|
20
|
+
lineup/templates/lineup/breadcrumbs.html,sha256=6iBTYAVeTCGWTrjULjPSD3OnviRePiHIGAl2a6nr8_c,327
|
|
21
|
+
lineup/templates/lineup/menu.html,sha256=WLE9tsecTnE9SLgWLOjY9OwO_gpLbN38bx4bgU161Ts,887
|
|
22
|
+
lineup/templatetags/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
23
|
+
lineup/templatetags/lineup_tags.py,sha256=RLd67gmQjzvyt0V3Sib1ATGBI2wb0VMKmy9bbeToLKA,5792
|
|
24
|
+
django_lineup-1.0.1.dist-info/METADATA,sha256=atXff-NG11HsaB4-8-URirwvZIfWQuqN-HL0_fddwfs,6132
|
|
25
|
+
django_lineup-1.0.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
26
|
+
django_lineup-1.0.1.dist-info/top_level.txt,sha256=6vFg2PzbsMrFEZHnUq1mv4jRZGnOFHc6Vf1pBfzxWm0,7
|
|
27
|
+
django_lineup-1.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
|
|
2
|
+
MIT License
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2020, Otto SRL
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
7
|
+
|
|
8
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
9
|
+
|
|
10
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lineup
|
lineup/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.1"
|
lineup/admin.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
from django.contrib import admin
|
|
5
|
+
from django.utils.html import format_html
|
|
6
|
+
from django.utils.translation import gettext_lazy as _
|
|
7
|
+
|
|
8
|
+
from mptt.admin import MPTTModelAdmin
|
|
9
|
+
|
|
10
|
+
from .models import MenuItem
|
|
11
|
+
from .forms import MenuItemForm
|
|
12
|
+
|
|
13
|
+
baton = 'baton' in sys.modules
|
|
14
|
+
|
|
15
|
+
if baton:
|
|
16
|
+
from baton.admin import RelatedDropdownFilter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MenuItemInline(admin.StackedInline):
|
|
20
|
+
'''
|
|
21
|
+
Tabular Inline View for MenuItem
|
|
22
|
+
'''
|
|
23
|
+
model = MenuItem
|
|
24
|
+
form = MenuItemForm
|
|
25
|
+
extra = 1
|
|
26
|
+
classes = ('collapse-entry', 'expand-first', )
|
|
27
|
+
prepopulated_fields = {'slug': ('label',)}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@admin.register(MenuItem)
|
|
31
|
+
class MenuItemAdmin(MPTTModelAdmin):
|
|
32
|
+
form = MenuItemForm
|
|
33
|
+
change_list_template = "admin/lineup/menuitem/change_list.html"
|
|
34
|
+
list_display = ('indented_slug', 'label', 'parent', 'link', 'order', 'login_required', 'enabled', )
|
|
35
|
+
list_filter = (('parent', RelatedDropdownFilter, ) if baton else 'parent', 'enabled', 'login_required', )
|
|
36
|
+
list_editable = ('order', )
|
|
37
|
+
search_fields = ('label', )
|
|
38
|
+
filter_horizontal = ('permissions', )
|
|
39
|
+
inlines = [MenuItemInline, ]
|
|
40
|
+
prepopulated_fields = {'slug': ('label',)}
|
|
41
|
+
fieldsets = (
|
|
42
|
+
(_('Main'), {
|
|
43
|
+
'fields': ('parent', 'label', 'slug', 'link', 'title', 'order', 'extras' ),
|
|
44
|
+
'classes': ('baton-tabs-init', 'baton-tab-fs-permissions', 'baton-tab-inline-children', ),
|
|
45
|
+
'description': _('A menu item without parent identifies a new menu.')
|
|
46
|
+
|
|
47
|
+
}),
|
|
48
|
+
(_('Visibility'), {
|
|
49
|
+
'fields': ('enabled', 'login_required', 'permissions', ),
|
|
50
|
+
'classes': ('tab-fs-permissions', ),
|
|
51
|
+
'description': _('Yuo can decide to disable or restrict the visibility of this voice and consequently of all its children. Keep in mind that also if a child is publicly visible, but this voice requires a login, then the child will not be visible to not logged users. The same happens for permissions restrictions.')
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@admin.display(description=_('slug'), ordering='slug')
|
|
56
|
+
def indented_slug(self, obj):
|
|
57
|
+
return format_html(
|
|
58
|
+
'<span style="padding-left: {}px">{}</span>',
|
|
59
|
+
8 + getattr(self, 'mptt_level_indent', 10) * obj.level,
|
|
60
|
+
obj.slug,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def baton_cl_rows_attributes(self, request, cl):
|
|
64
|
+
data = {}
|
|
65
|
+
for item in MenuItem.objects.filter(parent__isnull=True):
|
|
66
|
+
data[item.id] = {
|
|
67
|
+
'class': 'table-info',
|
|
68
|
+
}
|
|
69
|
+
return json.dumps(data)
|
lineup/apps.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# -*- coding: utf-8
|
|
2
|
+
from django.apps import AppConfig
|
|
3
|
+
from django.utils.translation import gettext_lazy as _
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LineupConfig(AppConfig):
|
|
7
|
+
# Keep the primary key type in sync with the historical migration even
|
|
8
|
+
# when the host project defaults to BigAutoField.
|
|
9
|
+
default_auto_field = 'django.db.models.AutoField'
|
|
10
|
+
name = 'lineup'
|
|
11
|
+
verbose_name = _('Menu')
|
lineup/exceptions.py
ADDED
lineup/forms.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
|
|
3
|
+
class MenuItemForm(forms.ModelForm):
|
|
4
|
+
def __init__(self, *args, **kwargs):
|
|
5
|
+
super().__init__(*args, **kwargs)
|
|
6
|
+
permissions = self.fields.get("permissions")
|
|
7
|
+
if permissions:
|
|
8
|
+
permissions.queryset = permissions.queryset.select_related(
|
|
9
|
+
"content_type"
|
|
10
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# import the logging library
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
6
|
+
|
|
7
|
+
from lineup.models import MenuItem
|
|
8
|
+
|
|
9
|
+
# Get an instance of a logger
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Command(BaseCommand):
|
|
14
|
+
help = 'Imports a menu tree from a json file'
|
|
15
|
+
|
|
16
|
+
def add_arguments(self, parser):
|
|
17
|
+
parser.add_argument('json_path', type=str)
|
|
18
|
+
|
|
19
|
+
def handle(self, *args, **options):
|
|
20
|
+
path = options['json_path']
|
|
21
|
+
if not os.path.isfile(path):
|
|
22
|
+
raise CommandError('File %s does not exist' % path)
|
|
23
|
+
|
|
24
|
+
# read file
|
|
25
|
+
with open(path, 'r') as json_file:
|
|
26
|
+
json_data = json_file.read()
|
|
27
|
+
|
|
28
|
+
if json_data:
|
|
29
|
+
try:
|
|
30
|
+
MenuItem.from_json(json_data)
|
|
31
|
+
self.stdout.write(self.style.SUCCESS('Successfully imported menu'))
|
|
32
|
+
except Exception as e:
|
|
33
|
+
raise CommandError('Cannot import menu: %s' % str(e))
|
|
34
|
+
else:
|
|
35
|
+
raise CommandError('File %s is empty' % path)
|
lineup/managers.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Generated by Django 4.2.23 on 2025-06-26 12:37
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
import django.db.models.deletion
|
|
5
|
+
import mptt.fields
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
initial = True
|
|
11
|
+
|
|
12
|
+
dependencies = [
|
|
13
|
+
('auth', '0012_alter_user_first_name_max_length'),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
operations = [
|
|
17
|
+
migrations.CreateModel(
|
|
18
|
+
name='MenuItem',
|
|
19
|
+
fields=[
|
|
20
|
+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
21
|
+
('label', models.CharField(help_text='Display name on the website.', max_length=50, verbose_name='label')),
|
|
22
|
+
('slug', models.SlugField(help_text='Unique identifier for the menu voice.', unique=True, verbose_name='slug')),
|
|
23
|
+
('link', models.CharField(blank=True, max_length=255, null=True, verbose_name='link')),
|
|
24
|
+
('title', models.CharField(blank=True, max_length=255, null=True, verbose_name='title')),
|
|
25
|
+
('order', models.IntegerField(verbose_name='order')),
|
|
26
|
+
('enabled', models.BooleanField(default=True, verbose_name='enabled')),
|
|
27
|
+
('login_required', models.BooleanField(default=False, help_text='If this is checked, only logged-in users will be able to view the item.', verbose_name='login required')),
|
|
28
|
+
('extras', models.CharField(blank=True, help_text='Comma separated list of extra-attributes, e.g.: <code>icon="fa fa-user",data-tooltip="Go home!"</code>', max_length=255, null=True, verbose_name='extras')),
|
|
29
|
+
('lft', models.PositiveIntegerField(editable=False)),
|
|
30
|
+
('rght', models.PositiveIntegerField(editable=False)),
|
|
31
|
+
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
|
|
32
|
+
('level', models.PositiveIntegerField(editable=False)),
|
|
33
|
+
('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='lineup.menuitem', verbose_name='parent')),
|
|
34
|
+
('permissions', models.ManyToManyField(blank=True, help_text='If empty, the menu item will be publicly visible, otherwise only users with at least one of the selected permissions could see it.', to='auth.permission', verbose_name='permissions')),
|
|
35
|
+
],
|
|
36
|
+
options={
|
|
37
|
+
'verbose_name': 'Menu item',
|
|
38
|
+
'verbose_name_plural': 'Menu items',
|
|
39
|
+
},
|
|
40
|
+
),
|
|
41
|
+
]
|
|
File without changes
|
lineup/models.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from django.contrib.auth.models import Permission
|
|
7
|
+
from django.db import models
|
|
8
|
+
from django.utils.translation import gettext_lazy as _
|
|
9
|
+
from django.utils.safestring import mark_safe
|
|
10
|
+
from django.db import transaction
|
|
11
|
+
from django.db.models.signals import post_delete, post_save
|
|
12
|
+
from django.dispatch import receiver
|
|
13
|
+
from django.core.cache import cache
|
|
14
|
+
|
|
15
|
+
from mptt.models import MPTTModel, TreeForeignKey
|
|
16
|
+
|
|
17
|
+
from .managers import MenuItemManager
|
|
18
|
+
from .exceptions import InvalidJson, UnsupportedJsonData, MissingJsonRequiredProp
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MenuItem(MPTTModel):
|
|
24
|
+
label = models.CharField(
|
|
25
|
+
_("label"), max_length=50, help_text=_("Display name on the website.")
|
|
26
|
+
)
|
|
27
|
+
slug = models.SlugField(
|
|
28
|
+
_("slug"), unique=True, help_text=_("Unique identifier for the menu voice.")
|
|
29
|
+
)
|
|
30
|
+
parent = TreeForeignKey(
|
|
31
|
+
"self",
|
|
32
|
+
verbose_name=_("parent"),
|
|
33
|
+
on_delete=models.CASCADE,
|
|
34
|
+
null=True,
|
|
35
|
+
blank=True,
|
|
36
|
+
related_name="children",
|
|
37
|
+
)
|
|
38
|
+
link = models.CharField(_("link"), max_length=255, blank=True, null=True)
|
|
39
|
+
title = models.CharField(_("title"), max_length=255, blank=True, null=True)
|
|
40
|
+
order = models.IntegerField(_("order"))
|
|
41
|
+
enabled = models.BooleanField(_("enabled"), default=True)
|
|
42
|
+
login_required = models.BooleanField(
|
|
43
|
+
_("login required"),
|
|
44
|
+
default=False,
|
|
45
|
+
help_text=_(
|
|
46
|
+
"If this is checked, only logged-in users will be able to view the item."
|
|
47
|
+
), # noqa
|
|
48
|
+
)
|
|
49
|
+
permissions = models.ManyToManyField(
|
|
50
|
+
Permission,
|
|
51
|
+
verbose_name=_("permissions"),
|
|
52
|
+
blank=True,
|
|
53
|
+
help_text=_(
|
|
54
|
+
"If empty, the menu item will be publicly visible, otherwise only users with at least one of the selected permissions could see it." # noqa
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
extras = models.CharField(
|
|
58
|
+
_("extras"),
|
|
59
|
+
max_length=255,
|
|
60
|
+
blank=True,
|
|
61
|
+
null=True,
|
|
62
|
+
help_text=mark_safe(_("Comma separated list of extra-attributes, e.g.: <code>icon=\"fa fa-user\",data-tooltip=\"Go home!\"</code>")),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
objects = MenuItemManager()
|
|
66
|
+
|
|
67
|
+
active = False
|
|
68
|
+
with_active = False
|
|
69
|
+
|
|
70
|
+
def __str__(self):
|
|
71
|
+
return "{}".format(self.label)
|
|
72
|
+
|
|
73
|
+
class Meta:
|
|
74
|
+
verbose_name = _("Menu item")
|
|
75
|
+
verbose_name_plural = _("Menu items")
|
|
76
|
+
|
|
77
|
+
class MPTTMeta:
|
|
78
|
+
order_insertion_by = ("order",)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_json(cls, json_string):
|
|
82
|
+
try:
|
|
83
|
+
d = json.loads(json_string)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
raise InvalidJson("Cannot parse provided json: %s" % str(e))
|
|
86
|
+
|
|
87
|
+
if not isinstance(d, dict):
|
|
88
|
+
raise UnsupportedJsonData(
|
|
89
|
+
"Provided json should be a dictionary containing a single root voice"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
with transaction.atomic():
|
|
93
|
+
res = cls.create_item_from_dict(d)
|
|
94
|
+
|
|
95
|
+
return res
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def create_item_from_dict(cls, d, parent=None):
|
|
99
|
+
if "label" not in d or "slug" not in d or "order" not in d:
|
|
100
|
+
raise MissingJsonRequiredProp(
|
|
101
|
+
"label, slug and order properties are mandatory"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
item = MenuItem.objects.create(
|
|
105
|
+
parent=parent,
|
|
106
|
+
label=d.get("label"),
|
|
107
|
+
slug=d.get("slug"),
|
|
108
|
+
order=d.get("order"),
|
|
109
|
+
link=d.get("link", None),
|
|
110
|
+
title=d.get("title", None),
|
|
111
|
+
enabled=d.get("enabled", True),
|
|
112
|
+
login_required=d.get("login_required", False),
|
|
113
|
+
extras=d.get("extras", None),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
for permission_code in d.get("permissions", []):
|
|
117
|
+
item.permissions.add(Permission.objects.get(codename=permission_code))
|
|
118
|
+
|
|
119
|
+
for child in d.get("children", []):
|
|
120
|
+
cls.create_item_from_dict(child, item)
|
|
121
|
+
|
|
122
|
+
return item
|
|
123
|
+
|
|
124
|
+
def is_active(self, path, prefix=""):
|
|
125
|
+
return self.link and self.link == path
|
|
126
|
+
|
|
127
|
+
def extras_dict(self):
|
|
128
|
+
try:
|
|
129
|
+
return dict(
|
|
130
|
+
[re.sub('"|\'', '', i).strip() for i in s.split("=")]
|
|
131
|
+
for s in self.extras.split(",")
|
|
132
|
+
)
|
|
133
|
+
except Exception:
|
|
134
|
+
return {}
|
|
135
|
+
|
|
136
|
+
@receiver(post_save, sender=MenuItem)
|
|
137
|
+
@receiver(post_delete, sender=MenuItem)
|
|
138
|
+
def clear_lineup_cache(sender , **kwargs):
|
|
139
|
+
cache.delete("lineup")
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{% extends "admin/change_list.html" %}
|
|
2
|
+
{% load admin_list i18n %}
|
|
3
|
+
|
|
4
|
+
{% block result_list %}
|
|
5
|
+
{% if action_form and actions_on_top and cl.show_admin_actions %}{% admin_actions %}{% endif %}
|
|
6
|
+
{% result_list cl %}
|
|
7
|
+
{% if action_form and actions_on_bottom and cl.show_admin_actions %}{% admin_actions %}{% endif %}
|
|
8
|
+
{% endblock %}
|
|
9
|
+
|
|
10
|
+
{% block object-tools-items %}
|
|
11
|
+
<li><a href="{% url 'lineup:rebuild' %}" class="rebuildlink">{% trans "Rebuild" %}</a></li>
|
|
12
|
+
{{ block.super }}
|
|
13
|
+
{% endblock %}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
{% comment %}
|
|
3
|
+
As the developer of this package, don't place anything here if you can help it
|
|
4
|
+
since this allows developers to have interoperability between your template
|
|
5
|
+
structure and their own.
|
|
6
|
+
|
|
7
|
+
Example: Developer melding the 2SoD pattern to fit inside with another pattern::
|
|
8
|
+
|
|
9
|
+
{% extends "base.html" %}
|
|
10
|
+
{% load static %}
|
|
11
|
+
|
|
12
|
+
<!-- Their site uses old school block layout -->
|
|
13
|
+
{% block extra_js %}
|
|
14
|
+
|
|
15
|
+
<!-- Your package using 2SoD block layout -->
|
|
16
|
+
{% block javascript %}
|
|
17
|
+
<script src="{% static 'js/ninja.js' %}" type="text/javascript"></script>
|
|
18
|
+
{% endblock javascript %}
|
|
19
|
+
|
|
20
|
+
{% endblock extra_js %}
|
|
21
|
+
{% endcomment %}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{% spaceless %}
|
|
2
|
+
{% if items|length %}
|
|
3
|
+
<div class="lineup-breadcrumbs">
|
|
4
|
+
{% for item in items %}<a{% if item.link %} href="{{ item.link }}"{% endif %}{% if item.title %} title="{{ item.title }}"{% endif %}>{{ item }}</a>{% if not forloop.last %} › {% endif %}{% endfor %}
|
|
5
|
+
</div>
|
|
6
|
+
{% endif %}
|
|
7
|
+
{% endspaceless %}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{% load lineup_tags %}
|
|
2
|
+
{% spaceless %}
|
|
3
|
+
{% comment %}
|
|
4
|
+
item is a dictionary:
|
|
5
|
+
- instance: MenuItem instance
|
|
6
|
+
- parent: the parent item
|
|
7
|
+
- children: list of child items
|
|
8
|
+
- active: whether the item is active or not
|
|
9
|
+
- has_active: whether the item contains an active item or not
|
|
10
|
+
{% endcomment %}
|
|
11
|
+
{% if items|length %}
|
|
12
|
+
<ul id="lineup-{{ slug }}" class="level-{{ level }}">
|
|
13
|
+
{% for item in items %}
|
|
14
|
+
<li class="{% if item.active %}active {% elif item.has_active %}has-active {% endif %}{% if item.children|length %}has-children {% endif %}">
|
|
15
|
+
<a{% if item.instance.link %} href="{{ item.instance.link }}"{% endif %}{% if item.instance.title %} title="{{ item.instance.title }}"{% endif %}>{{ item.instance.label }}</a>
|
|
16
|
+
{% lineup_menu item %}
|
|
17
|
+
</li>
|
|
18
|
+
{% endfor %}
|
|
19
|
+
</ul>
|
|
20
|
+
{% endif %}
|
|
21
|
+
{% endspaceless %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import logging
|
|
3
|
+
from django.core.cache import cache
|
|
4
|
+
|
|
5
|
+
from django import template
|
|
6
|
+
from django.contrib.auth.models import Permission
|
|
7
|
+
from django.db.models import Q, Count
|
|
8
|
+
from django.urls import LocalePrefixPattern
|
|
9
|
+
from django.utils.translation import get_language
|
|
10
|
+
from ..models import MenuItem
|
|
11
|
+
|
|
12
|
+
register = template.Library()
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_all_user_permissions_id_list(user):
|
|
17
|
+
"""
|
|
18
|
+
Returns a flat list of all user permissions ids
|
|
19
|
+
"""
|
|
20
|
+
# individual permissions
|
|
21
|
+
perms = list(Permission.objects.filter(user=user).values_list("id", flat=True))
|
|
22
|
+
# permissions that the user has via a group
|
|
23
|
+
group_perms = list(
|
|
24
|
+
Permission.objects.filter(group__user=user).values_list("id", flat=True)
|
|
25
|
+
)
|
|
26
|
+
return perms + group_perms
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def set_active_voice(path, items):
|
|
30
|
+
"""
|
|
31
|
+
Sets the active property to the active voice, and with_active to
|
|
32
|
+
all its parents
|
|
33
|
+
"""
|
|
34
|
+
lang_code = get_language()
|
|
35
|
+
path_to_check = path
|
|
36
|
+
|
|
37
|
+
if lang_code:
|
|
38
|
+
prefix_to_remove = f"/{lang_code}"
|
|
39
|
+
|
|
40
|
+
if path.startswith(prefix_to_remove):
|
|
41
|
+
path_to_check = path.removeprefix(prefix_to_remove)
|
|
42
|
+
if not path_to_check.startswith('/'):
|
|
43
|
+
path_to_check = '/' + path_to_check
|
|
44
|
+
|
|
45
|
+
for item in items:
|
|
46
|
+
if item.get("instance").is_active(path_to_check):
|
|
47
|
+
item["active"] = True
|
|
48
|
+
parent = item.get("parent")
|
|
49
|
+
while parent is not None:
|
|
50
|
+
parent["has_active"] = True
|
|
51
|
+
parent = parent.get("parent")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_tree(context, root, parent=None):
|
|
55
|
+
# get current user
|
|
56
|
+
user = context.get("user")
|
|
57
|
+
|
|
58
|
+
# if root is not enabled, do not show children
|
|
59
|
+
if not root.enabled:
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
# if root visibility restrictions and user is not authenticated do not show children
|
|
63
|
+
if not user.is_authenticated and (
|
|
64
|
+
root.login_required or root.permissions_count > 0
|
|
65
|
+
): # noqa
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
if not user.is_authenticated:
|
|
69
|
+
# unlogged user sees only public items
|
|
70
|
+
items = root.children.enabled(login_required=False, permissions=None).annotate(permissions_count=Count("permissions"))
|
|
71
|
+
elif user.is_superuser:
|
|
72
|
+
# superuser sees all enabled items
|
|
73
|
+
items = root.children.enabled().annotate(permissions_count=Count("permissions"))
|
|
74
|
+
else:
|
|
75
|
+
# logged in user which is not superuse should check for permissions
|
|
76
|
+
permissions = context.get("lineup_permissions", None)
|
|
77
|
+
if permissions is None:
|
|
78
|
+
permissions = get_all_user_permissions_id_list(user)
|
|
79
|
+
# put in context to avoid querying again at every iteration
|
|
80
|
+
context["lineup_permissions"] = permissions
|
|
81
|
+
|
|
82
|
+
items = root.children.enabled(
|
|
83
|
+
Q(permissions__id__in=permissions) | Q(permissions=None)
|
|
84
|
+
).annotate(permissions_count=Count("permissions")).distinct()
|
|
85
|
+
|
|
86
|
+
# parent needed to traverse upward for has-active functionality
|
|
87
|
+
el = {"instance": root, "parent": parent}
|
|
88
|
+
el["children"] = [create_tree(context, child, el) for child in items]
|
|
89
|
+
|
|
90
|
+
# search active voice
|
|
91
|
+
if "request" in context:
|
|
92
|
+
path = context["request"].META["PATH_INFO"]
|
|
93
|
+
set_active_voice(path, el["children"])
|
|
94
|
+
|
|
95
|
+
return el
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@register.inclusion_tag("lineup/menu.html", takes_context=True)
|
|
99
|
+
def lineup_menu(context, item):
|
|
100
|
+
"""
|
|
101
|
+
Renders the whole tree if called recursively with different item param.
|
|
102
|
+
- If item is a slug, the whole tree dictionary is created. and the first
|
|
103
|
+
level items rendered.
|
|
104
|
+
- If item is a dict, it just continues traversing rendering its children.
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(item, str):
|
|
107
|
+
try:
|
|
108
|
+
# cache depends on user (permissions) and path (active item)
|
|
109
|
+
path = ""
|
|
110
|
+
user = context.get("user")
|
|
111
|
+
if "request" in context:
|
|
112
|
+
path = context["request"].META["PATH_INFO"]
|
|
113
|
+
t = cache.get_or_set("lineup", {}, None)
|
|
114
|
+
key = "%s:%s:%s" % (item, user.id, path)
|
|
115
|
+
if t.get(key, None) is None:
|
|
116
|
+
root = MenuItem.objects.prefetch_related("children", "permissions").annotate(permissions_count=Count("permissions")).get(slug=item)
|
|
117
|
+
tree = create_tree(context, root)
|
|
118
|
+
items = tree.get("children", [])
|
|
119
|
+
slug = item
|
|
120
|
+
level = root.level
|
|
121
|
+
t[key] = (items, slug, level)
|
|
122
|
+
cache.set("lineup", t, None)
|
|
123
|
+
else:
|
|
124
|
+
(items, slug, level) = t[key]
|
|
125
|
+
except MenuItem.DoesNotExist:
|
|
126
|
+
logger.error("Provided lineup menu slug %s not found" % item)
|
|
127
|
+
return context
|
|
128
|
+
elif isinstance(item, dict):
|
|
129
|
+
items = item.get("children")
|
|
130
|
+
slug = item.get("instance").slug
|
|
131
|
+
level = item.get("instance").level
|
|
132
|
+
|
|
133
|
+
else:
|
|
134
|
+
items = []
|
|
135
|
+
slug = None
|
|
136
|
+
level = None
|
|
137
|
+
|
|
138
|
+
context["items"] = items
|
|
139
|
+
context["slug"] = slug
|
|
140
|
+
context["level"] = level
|
|
141
|
+
|
|
142
|
+
return context
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@register.inclusion_tag("lineup/breadcrumbs.html", takes_context=True)
|
|
146
|
+
def lineup_breadcrumbs(context, slug):
|
|
147
|
+
"""
|
|
148
|
+
Renders menu breadcrumbs.
|
|
149
|
+
"""
|
|
150
|
+
if "request" in context:
|
|
151
|
+
path = context["request"].META["PATH_INFO"]
|
|
152
|
+
active_items = MenuItem.objects.enabled(link=path)
|
|
153
|
+
|
|
154
|
+
found = False
|
|
155
|
+
items = []
|
|
156
|
+
for active_item in active_items:
|
|
157
|
+
items.append(active_item)
|
|
158
|
+
parent = active_item.parent
|
|
159
|
+
while parent is not None and parent.enabled:
|
|
160
|
+
items.insert(0, parent)
|
|
161
|
+
parent = parent.parent
|
|
162
|
+
if items.pop(0).slug == slug:
|
|
163
|
+
found = True
|
|
164
|
+
break
|
|
165
|
+
|
|
166
|
+
if found:
|
|
167
|
+
context["items"] = items
|
|
168
|
+
else:
|
|
169
|
+
context["items"] = []
|
|
170
|
+
return context
|
lineup/urls.py
ADDED
lineup/views.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from django.urls import reverse
|
|
2
|
+
from django.views.generic import View
|
|
3
|
+
from django.shortcuts import redirect
|
|
4
|
+
from .models import MenuItem
|
|
5
|
+
|
|
6
|
+
class RebuildTreeView(View):
|
|
7
|
+
def get(self, request):
|
|
8
|
+
MenuItem.objects.rebuild()
|
|
9
|
+
return redirect(reverse('admin:lineup_menuitem_changelist'))
|