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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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
@@ -0,0 +1,10 @@
1
+ class InvalidJson(Exception):
2
+ pass
3
+
4
+
5
+ class UnsupportedJsonData(Exception):
6
+ pass
7
+
8
+
9
+ class MissingJsonRequiredProp(Exception):
10
+ pass
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,7 @@
1
+ from mptt.managers import TreeManager
2
+
3
+
4
+ class MenuItemManager(TreeManager):
5
+
6
+ def enabled(self, *args, **kwargs):
7
+ return self.filter(*args, enabled=True, **kwargs)
@@ -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
@@ -0,0 +1,8 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ app_name = "lineup"
6
+ urlpatterns = [
7
+ path("", views.RebuildTreeView.as_view(), name="rebuild"),
8
+ ]
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'))