Browse Source

Menu links implementation (#1286)

* Initial menu links implementation

* Format with black

* Update footer_menu_links templatetag and remove unused imports

* Requested changes

* Replace model manager with utility functions and fix cache versions usage

* Add tests to menus admin

* Formatting with black

* Fix imports order and node description
pyneda 5 years ago
parent
commit
eb29ffea37

+ 3 - 0
devproject/settings.py

@@ -194,6 +194,7 @@ INSTALLED_APPS = [
     "misago.graphql",
     "misago.faker",
     "misago.sso",
+    "misago.menus",
 ]
 
 INTERNAL_IPS = ["127.0.0.1"]
@@ -284,6 +285,8 @@ TEMPLATES = [
                 "misago.search.context_processors.search_providers",
                 "misago.themes.context_processors.theme",
                 "misago.legal.context_processors.legal_links",
+                "misago.menus.context_processors.navbar",
+                "misago.menus.context_processors.footer",
                 "misago.users.context_processors.user_links",
                 # Data preloaders
                 "misago.conf.context_processors.preload_settings_json",

+ 2 - 0
misago/conftest.py

@@ -13,6 +13,7 @@ from .threads.test import post_thread
 from .users import BANS_CACHE
 from .users.models import AnonymousUser
 from .users.test import create_test_superuser, create_test_user
+from .menus import MENU_LINKS_CACHE
 
 
 def get_cache_versions():
@@ -22,6 +23,7 @@ def get_cache_versions():
         SETTINGS_CACHE: "abcdefgh",
         SOCIALAUTH_CACHE: "abcdefgh",
         THEME_CACHE: "abcdefgh",
+        MENU_LINKS_CACHE: "abcdefgh",
     }
 
 

+ 3 - 0
misago/menus/__init__.py

@@ -0,0 +1,3 @@
+default_app_config = "misago.menus.apps.MisagoMenusConfig"
+
+MENU_LINKS_CACHE = "menus"

+ 35 - 0
misago/menus/admin/__init__.py

@@ -0,0 +1,35 @@
+from django.conf.urls import url
+from django.utils.translation import gettext_lazy as _
+
+from .views import (
+    DeleteMenuLink,
+    EditMenuLink,
+    MenuLinksList,
+    MoveDownMenuLink,
+    MoveUpMenuLink,
+    NewMenuLink,
+)
+
+
+class MisagoAdminExtension:
+    def register_urlpatterns(self, urlpatterns):
+        # Menu links
+        urlpatterns.namespace(r"^links/", "links", "settings")
+        urlpatterns.patterns(
+            "settings:links",
+            url(r"^$", MenuLinksList.as_view(), name="index"),
+            url(r"^(?P<page>\d+)/$", MenuLinksList.as_view(), name="index"),
+            url(r"^new/$", NewMenuLink.as_view(), name="new"),
+            url(r"^edit/(?P<pk>\d+)/$", EditMenuLink.as_view(), name="edit"),
+            url(r"^delete/(?P<pk>\d+)/$", DeleteMenuLink.as_view(), name="delete"),
+            url(r"^down/(?P<pk>(\w|-)+)/$", MoveDownMenuLink.as_view(), name="down"),
+            url(r"^up/(?P<pk>(\w|-)+)/$", MoveUpMenuLink.as_view(), name="up"),
+        )
+
+    def register_navigation_nodes(self, site):
+        site.add_node(
+            name=_("Menu links"),
+            description=_("Add custom links to navbar and footer menus."),
+            parent="settings",
+            namespace="links",
+        )

+ 49 - 0
misago/menus/admin/forms.py

@@ -0,0 +1,49 @@
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from ..models import MenuLink
+from ..cache import clear_menus_cache
+
+
+class MenuLinkForm(forms.ModelForm):
+    link = forms.URLField(
+        label=_("Link"),
+        help_text=_("URL where the link should point to."),
+        required=True,
+    )
+    title = forms.CharField(
+        label=_("Title"), help_text=_("Title that will be used"), required=True
+    )
+    position = forms.ChoiceField(
+        label=_("Position"),
+        choices=MenuLink.LINK_POSITION_CHOICES,
+        help_text=_("Position/s the link should be located"),
+    )
+    css_class = forms.CharField(
+        label=_("CSS Class"),
+        help_text=_(
+            "Optional CSS class used to customize this link appearance in templates."
+        ),
+        required=False,
+    )
+    target = forms.CharField(
+        label=_("Target"),
+        help_text=_(
+            "Optional target attribute that this link will use (ex. '_blank')."
+        ),
+        required=False,
+    )
+    rel = forms.CharField(
+        label=_("Rel"),
+        help_text=_("Optional rel attribute that this link will use (ex. 'nofollow')."),
+        required=False,
+    )
+
+    class Meta:
+        model = MenuLink
+        fields = ["link", "title", "position", "css_class", "target", "rel"]
+
+    def save(self):
+        link = super().save()
+        clear_menus_cache()
+        return link

+ 8 - 0
misago/menus/admin/ordering.py

@@ -0,0 +1,8 @@
+from ..models import MenuLink
+
+
+def get_next_free_order():
+    last = MenuLink.objects.last()
+    if last:
+        return last.order + 1
+    return 0

+ 0 - 0
misago/menus/admin/tests/__init__.py


+ 32 - 0
misago/menus/admin/tests/conftest.py

@@ -0,0 +1,32 @@
+import pytest
+from django.urls import reverse
+
+from ...models import MenuLink
+
+
+@pytest.fixture
+def list_url(admin_client):
+    return reverse("misago:admin:settings:links:index")
+
+
+@pytest.fixture
+def menu_link(superuser):
+    return MenuLink.objects.create(
+        position=MenuLink.POSITION_TOP,
+        title="Test TMLA",
+        link="https://top_menu_link_admin.com",
+        order=0,
+    )
+
+
+@pytest.fixture
+def other_menu_link(superuser):
+    return MenuLink.objects.create(
+        position=MenuLink.POSITION_BOTH,
+        title="Other Menu Link",
+        link="https://other_menu_link.com",
+        css_class="other-menu-link",
+        rel="noopener noreferrer",
+        target="_blank",
+        order=1,
+    )

+ 94 - 0
misago/menus/admin/tests/test_admin_views.py

@@ -0,0 +1,94 @@
+import pytest
+from django.urls import reverse
+
+from ....test import assert_contains
+from ...models import MenuLink
+
+
+def test_nav_contains_menus_link(admin_client, list_url):
+    response = admin_client.get(list_url)
+    assert_contains(response, reverse("misago:admin:settings:links:index"))
+
+
+def test_empty_list_renders(admin_client, list_url):
+    response = admin_client.get(list_url)
+    assert response.status_code == 200
+
+
+def test_list_renders_menu_link(admin_client, list_url, menu_link):
+    response = admin_client.get(list_url)
+    assert_contains(response, menu_link.title)
+
+
+def test_menu_links_can_be_mass_deleted(admin_client, list_url, superuser):
+    links = []
+    for _ in range(10):
+        link = MenuLink.objects.create(
+            position=MenuLink.POSITION_FOOTER,
+            title="Test Link {}".format(_),
+            link="https://links{}.com".format(_),
+        )
+        links.append(link.pk)
+
+    assert MenuLink.objects.count() == 10
+
+    response = admin_client.post(
+        list_url, data={"action": "delete", "selected_items": links}
+    )
+    assert response.status_code == 302
+    assert MenuLink.objects.count() == 0
+
+
+def test_creation_form_renders(admin_client):
+    response = admin_client.get(reverse("misago:admin:settings:links:new"))
+    assert response.status_code == 200
+
+
+def test_form_creates_new_menu_link(admin_client):
+    response = admin_client.post(
+        reverse("misago:admin:settings:links:new"),
+        {
+            "position": MenuLink.POSITION_FOOTER,
+            "title": "Test Link",
+            "link": "https://admin.com/links/",
+        },
+    )
+
+    link = MenuLink.objects.get()
+    assert link.position == MenuLink.POSITION_FOOTER
+    assert link.title == "Test Link"
+    assert link.link == "https://admin.com/links/"
+
+
+def test_edit_form_renders(admin_client, menu_link):
+    response = admin_client.get(
+        reverse("misago:admin:settings:links:edit", kwargs={"pk": menu_link.pk})
+    )
+    assert_contains(response, menu_link.title)
+
+
+def test_edit_form_updates_menu_links(admin_client, menu_link):
+    response = admin_client.post(
+        reverse("misago:admin:settings:links:edit", kwargs={"pk": menu_link.pk}),
+        data={
+            "position": menu_link.POSITION_BOTH,
+            "title": "Test Edited",
+            "link": "https://example.com/edited/",
+        },
+    )
+    assert response.status_code == 302
+
+    menu_link.refresh_from_db()
+    assert menu_link.position == MenuLink.POSITION_BOTH
+    assert menu_link.title == "Test Edited"
+    assert menu_link.link == "https://example.com/edited/"
+
+
+def test_menu_link_can_be_deleted(admin_client, menu_link):
+    response = admin_client.post(
+        reverse("misago:admin:settings:links:delete", kwargs={"pk": menu_link.pk})
+    )
+    assert response.status_code == 302
+
+    with pytest.raises(MenuLink.DoesNotExist):
+        menu_link.refresh_from_db()

+ 102 - 0
misago/menus/admin/tests/test_ordering_menu_links.py

@@ -0,0 +1,102 @@
+from django.urls import reverse
+
+from ....cache.test import assert_invalidates_cache
+from ... import MENU_LINKS_CACHE
+
+
+def test_top_menu_link_can_be_moved_down(admin_client, menu_link, other_menu_link):
+    menu_link.order = 0
+    menu_link.save()
+
+    other_menu_link.order = 1
+    other_menu_link.save()
+
+    admin_client.post(
+        reverse("misago:admin:settings:links:down", kwargs={"pk": menu_link.pk})
+    )
+
+    menu_link.refresh_from_db()
+    assert menu_link.order == 1
+    other_menu_link.refresh_from_db()
+    assert other_menu_link.order == 0
+
+
+def test_top_menu_link_cant_be_moved_up(admin_client, menu_link, other_menu_link):
+    menu_link.order = 0
+    menu_link.save()
+
+    other_menu_link.order = 1
+    other_menu_link.save()
+
+    admin_client.post(
+        reverse("misago:admin:settings:links:up", kwargs={"pk": menu_link.pk})
+    )
+
+    menu_link.refresh_from_db()
+    assert menu_link.order == 0
+    other_menu_link.refresh_from_db()
+    assert other_menu_link.order == 1
+
+
+def test_bottom_menu_link_cant_be_moved_down(admin_client, menu_link, other_menu_link):
+    menu_link.order = 1
+    menu_link.save()
+
+    other_menu_link.order = 0
+    other_menu_link.save()
+
+    admin_client.post(
+        reverse("misago:admin:settings:links:down", kwargs={"pk": menu_link.pk})
+    )
+
+    menu_link.refresh_from_db()
+    assert menu_link.order == 1
+    other_menu_link.refresh_from_db()
+    assert other_menu_link.order == 0
+
+
+def test_bottom_menu_link_can_be_moved_up(admin_client, menu_link, other_menu_link):
+    menu_link.order = 1
+    menu_link.save()
+
+    other_menu_link.order = 0
+    other_menu_link.save()
+
+    admin_client.post(
+        reverse("misago:admin:settings:links:up", kwargs={"pk": menu_link.pk})
+    )
+
+    menu_link.refresh_from_db()
+    assert menu_link.order == 0
+    other_menu_link.refresh_from_db()
+    assert other_menu_link.order == 1
+
+
+def test_moving_menu_link_down_invalidates_menu_links_cache(
+    admin_client, menu_link, other_menu_link
+):
+    menu_link.order = 0
+    menu_link.save()
+
+    other_menu_link.order = 1
+    other_menu_link.save()
+
+    with assert_invalidates_cache(MENU_LINKS_CACHE):
+        admin_client.post(
+            reverse("misago:admin:settings:links:down", kwargs={"pk": menu_link.pk})
+        )
+
+
+def test_moving_menu_link_up_invalidates_menu_links_cache(
+    admin_client, menu_link, other_menu_link
+):
+    menu_link.order = 1
+    menu_link.save()
+
+    other_menu_link.order = 0
+    other_menu_link.save()
+
+    with assert_invalidates_cache(MENU_LINKS_CACHE):
+        admin_client.post(
+            reverse("misago:admin:settings:links:up", kwargs={"pk": menu_link.pk})
+        )

+ 106 - 0
misago/menus/admin/views.py

@@ -0,0 +1,106 @@
+from django.contrib import messages
+from django.utils.translation import gettext_lazy as _
+
+from ...admin.views import generic
+from ..models import MenuLink
+from ..cache import clear_menus_cache
+
+from .forms import MenuLinkForm
+from .ordering import get_next_free_order
+
+
+class MenuLinkAdmin(generic.AdminBaseMixin):
+    root_link = "misago:admin:settings:links:index"
+    model = MenuLink
+    form_class = MenuLinkForm
+    templates_dir = "misago/admin/menulinks"
+    message_404 = _("Requested MenuLink does not exist.")
+
+    def handle_form(self, form, request, target):
+        form.save()
+
+        if self.message_submit:
+            messages.success(request, self.message_submit % {"title": target.title})
+
+
+class MenuLinksList(MenuLinkAdmin, generic.ListView):
+    ordering = (("order", None),)
+    selection_label = _("With MenuLinks: 0")
+    empty_selection_label = _("Select MenuLinks")
+    mass_actions = [
+        {
+            "action": "delete",
+            "name": _("Delete MenuLinks"),
+            "confirmation": _("Are you sure you want to delete those MenuLinks?"),
+        }
+    ]
+
+    def action_delete(self, request, items):
+        items.delete()
+        clear_menus_cache()
+        messages.success(request, _("Selected MenuLinks have been deleted."))
+
+
+class NewMenuLink(MenuLinkAdmin, generic.ModelFormView):
+    message_submit = _('New MenuLink "%(title)s" has been saved.')
+
+    def handle_form(self, form, request, target):
+        super().handle_form(form, request, target)
+        form.instance.order = get_next_free_order()
+        form.instance.save()
+        clear_menus_cache()
+
+
+class EditMenuLink(MenuLinkAdmin, generic.ModelFormView):
+    message_submit = _('MenuLink "%(title)s" has been edited.')
+
+    def handle_form(self, form, request, target):
+        super().handle_form(form, request, target)
+        form.instance.save()
+        clear_menus_cache()
+
+
+class DeleteMenuLink(MenuLinkAdmin, generic.ButtonView):
+    def button_action(self, request, target):
+        target.delete()
+        clear_menus_cache()
+        message = _('MenuLink "%(title)s" has been deleted.')
+        messages.success(request, message % {"title": target.title})
+
+
+class MoveDownMenuLink(MenuLinkAdmin, generic.ButtonView):
+    def button_action(self, request, target):
+        try:
+            other_target = MenuLink.objects.filter(order__gt=target.order)
+            other_target = other_target.earliest("order")
+        except MenuLink.DoesNotExist:
+            other_target = None
+
+        if other_target:
+            other_target.order, target.order = target.order, other_target.order
+            other_target.save(update_fields=["order"])
+            target.save(update_fields=["order"])
+            clear_menus_cache()
+
+            message = _("Menu link to %(link)s has been moved after %(other)s.")
+            targets_names = {"link": target, "other": other_target}
+            messages.success(request, message % targets_names)
+
+
+class MoveUpMenuLink(MenuLinkAdmin, generic.ButtonView):
+    def button_action(self, request, target):
+        try:
+            other_target = MenuLink.objects.filter(order__lt=target.order)
+            other_target = other_target.latest("order")
+        except MenuLink.DoesNotExist:
+            other_target = None
+
+        if other_target:
+            other_target.order, target.order = target.order, other_target.order
+            other_target.save(update_fields=["order"])
+            target.save(update_fields=["order"])
+            clear_menus_cache()
+
+            message = _("Menu link to %(link)s has been moved before %(other)s.")
+            targets_names = {"link": target, "other": other_target}
+            messages.success(request, message % targets_names)

+ 7 - 0
misago/menus/apps.py

@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class MisagoMenusConfig(AppConfig):
+    name = "misago.menus"
+    label = "misago_menus"
+    verbose_name = "Misago Menus"

+ 22 - 0
misago/menus/cache.py

@@ -0,0 +1,22 @@
+from django.core.cache import cache
+
+from . import MENU_LINKS_CACHE
+from ..cache.versions import invalidate_cache
+
+
+def get_menus_cache(cache_versions):
+    key = get_cache_key(cache_versions)
+    return cache.get(key)
+
+
+def set_menus_cache(cache_versions, menu_links):
+    key = get_cache_key(cache_versions)
+    cache.set(key, menu_links)
+
+
+def get_cache_key(cache_versions):
+    return "%s_%s" % (MENU_LINKS_CACHE, cache_versions[MENU_LINKS_CACHE])
+
+
+def clear_menus_cache():
+    invalidate_cache(MENU_LINKS_CACHE)

+ 9 - 0
misago/menus/context_processors.py

@@ -0,0 +1,9 @@
+from .menu_links import get_top_menu_links, get_footer_menu_links
+
+
+def navbar(request):
+    return {"top_menu_links": get_top_menu_links(request.cache_versions)}
+
+
+def footer(request):
+    return {"footer_menu_links": get_footer_menu_links(request.cache_versions)}

+ 40 - 0
misago/menus/menu_links.py

@@ -0,0 +1,40 @@
+from django.db.models import Q
+
+from .cache import set_menus_cache, get_menus_cache
+from .models import MenuLink
+
+
+def get_top_menu_links(cache_versions):
+    return get_links(cache_versions).get(MenuLink.POSITION_TOP)
+
+
+def get_footer_menu_links(cache_versions):
+    return get_links(cache_versions).get(MenuLink.POSITION_FOOTER)
+
+
+def get_links(cache_versions):
+    links = get_menus_cache(cache_versions)
+    if links is None:
+        links = get_links_from_db()
+        set_menus_cache(cache_versions, links)
+    return links
+
+
+def get_links_from_db():
+    links = {
+        MenuLink.POSITION_TOP: _get_footer_menu_links_from_db(),
+        MenuLink.POSITION_FOOTER: _get_top_menu_links_from_db(),
+    }
+    return links
+
+
+def _get_footer_menu_links_from_db():
+    return MenuLink.objects.filter(
+        Q(position=MenuLink.POSITION_TOP) | Q(position=MenuLink.POSITION_BOTH)
+    ).values()
+
+
+def _get_top_menu_links_from_db():
+    return MenuLink.objects.filter(
+        Q(position=MenuLink.POSITION_FOOTER) | Q(position=MenuLink.POSITION_BOTH)
+    ).values()

+ 52 - 0
misago/menus/migrations/0001_initial.py

@@ -0,0 +1,52 @@
+# Generated by Django 2.2.3 on 2019-09-11 07:13
+
+from django.db import migrations, models
+from ...cache.operations import StartCacheVersioning
+from .. import MENU_LINKS_CACHE
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [("misago_cache", "0001_initial")]
+
+    operations = [
+        migrations.CreateModel(
+            name="MenuLink",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("link", models.URLField()),
+                ("title", models.CharField(max_length=150)),
+                (
+                    "position",
+                    models.CharField(
+                        choices=[
+                            ("top", "Header navbar"),
+                            ("footer", "Footer"),
+                            ("both", "Header and footer"),
+                        ],
+                        max_length=20,
+                    ),
+                ),
+                ("order", models.IntegerField(default=0)),
+                ("css_class", models.CharField(blank=True, max_length=255, null=True)),
+                ("target", models.CharField(blank=True, max_length=100, null=True)),
+                ("rel", models.CharField(blank=True, max_length=100, null=True)),
+            ],
+            options={
+                "ordering": ("order",),
+                "get_latest_by": "order",
+                "unique_together": {("link", "position")},
+            },
+        ),
+        StartCacheVersioning(MENU_LINKS_CACHE),
+    ]

+ 0 - 0
misago/menus/migrations/__init__.py


+ 35 - 0
misago/menus/models.py

@@ -0,0 +1,35 @@
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+
+class MenuLinkManager(models.Manager):
+    pass
+
+
+class MenuLink(models.Model):
+    POSITION_TOP = "top"
+    POSITION_FOOTER = "footer"
+    POSITION_BOTH = "both"
+    LINK_POSITION_CHOICES = [
+        (POSITION_TOP, _("Header navbar")),
+        (POSITION_FOOTER, _("Footer")),
+        (POSITION_BOTH, _("Header and footer")),
+    ]
+
+    link = models.URLField()
+    title = models.CharField(max_length=150)
+    position = models.CharField(max_length=20, choices=LINK_POSITION_CHOICES)
+    order = models.IntegerField(default=0)
+    css_class = models.CharField(max_length=255, null=True, blank=True)
+    target = models.CharField(max_length=100, null=True, blank=True)
+    rel = models.CharField(max_length=100, null=True, blank=True)
+
+    objects = MenuLinkManager()
+
+    class Meta:
+        unique_together = ("link", "position")
+        ordering = ("order",)
+        get_latest_by = "order"
+
+    def __str__(self):
+        return self.link

+ 0 - 0
misago/menus/tests/__init__.py


+ 57 - 0
misago/menus/tests/conftest.py

@@ -0,0 +1,57 @@
+import pytest
+
+from ..models import MenuLink
+from ..menu_links import get_footer_menu_links, get_top_menu_links
+
+
+@pytest.fixture
+def menu_link_top(db):
+    return MenuLink.objects.create(
+        link="https://top_menu_link.com",
+        title="Top Menu Link",
+        position=MenuLink.POSITION_TOP,
+    )
+
+
+@pytest.fixture
+def menu_link_footer(db):
+    return MenuLink.objects.create(
+        link="https://footer_menu_link.com",
+        title="Footer Menu Link",
+        position=MenuLink.POSITION_FOOTER,
+    )
+
+
+@pytest.fixture
+def menu_link_both(db):
+    return MenuLink.objects.create(
+        link="https://both_positions_menu_link.com",
+        title="Both Positions Menu Link",
+        position=MenuLink.POSITION_BOTH,
+    )
+
+
+@pytest.fixture
+def menu_link_with_attributes(db):
+    return MenuLink.objects.create(
+        link="https://menu_link_with_attributes.com",
+        title="Menu link with attributes",
+        position=MenuLink.POSITION_BOTH,
+        rel="noopener nofollow",
+        target="_blank",
+        css_class="test-link-css-class",
+    )
+
+
+@pytest.fixture
+def links_footer(
+    db, cache_versions, menu_link_footer, menu_link_both, menu_link_with_attributes
+):
+    return get_footer_menu_links(cache_versions)
+
+
+@pytest.fixture
+def links_top(
+    db, cache_versions, menu_link_top, menu_link_both, menu_link_with_attributes
+):
+    return get_top_menu_links(cache_versions)

+ 15 - 0
misago/menus/tests/test_context_processor.py

@@ -0,0 +1,15 @@
+from unittest.mock import Mock
+
+from ..context_processors import footer, navbar
+
+
+def test_footer_menu_links_context_processor(links_footer, cache_versions):
+    result = footer(Mock(cache_versions=cache_versions))
+    assert isinstance(result, dict)
+    assert len(links_footer) == len(result["footer_menu_links"])
+
+
+def test_top_menu_links_context_processor(links_top, cache_versions):
+    result = navbar(Mock(cache_versions=cache_versions))
+    assert isinstance(result, dict)
+    assert len(links_top) == len(result["top_menu_links"])

+ 44 - 0
misago/menus/tests/test_menulink_frontend.py

@@ -0,0 +1,44 @@
+from django.urls import reverse
+from bs4 import BeautifulSoup
+
+
+def test_top_menu_link_in_frontend(client, menu_link_top):
+    response = client.get(reverse("misago:index"))
+    assert response.status_code == 200
+    parser = BeautifulSoup(response.content, "html.parser")
+    link = parser.find("a", href=menu_link_top.link)
+    assert link is not None
+    assert menu_link_top.title == link.getText().strip()
+
+
+def test_footer_menu_link_in_frontend(client, menu_link_footer):
+    response = client.get(reverse("misago:index"))
+    assert response.status_code == 200
+    parser = BeautifulSoup(response.content, "html.parser")
+    link = parser.find("a", href=menu_link_footer.link)
+    assert link is not None
+    assert menu_link_footer.title == link.getText().strip()
+
+
+def test_both_menus_link_in_frontend(client, menu_link_both):
+    response = client.get(reverse("misago:index"))
+    assert response.status_code == 200
+    parser = BeautifulSoup(response.content, "html.parser")
+    links = parser.find_all("a", href=menu_link_both.link)
+    assert links != []
+    assert len(links) == 2
+    for link in links:
+        assert link is not None
+        assert menu_link_both.title == link.getText().strip()
+
+
+def test_menu_link_attributes_in_frontend(client, menu_link_with_attributes):
+    response = client.get(reverse("misago:index"))
+    assert response.status_code == 200
+    parser = BeautifulSoup(response.content, "html.parser")
+    link = parser.find("a", href=menu_link_with_attributes.link)
+    assert link is not None
+    assert menu_link_with_attributes.title == link.getText().strip()
+    assert menu_link_with_attributes.rel.split() == link["rel"]
+    assert menu_link_with_attributes.target == link["target"]
+    assert menu_link_with_attributes.css_class.split() == link["class"]

+ 62 - 0
misago/templates/misago/admin/menulinks/form.html

@@ -0,0 +1,62 @@
+{% extends "misago/admin/generic/form.html" %}
+{% load i18n misago_admin_form %}
+
+
+{% block title %}
+{% if target.pk %}
+{% trans target.title %}
+{% else %}
+{% trans "New menu link" %}
+{% endif %} | {{ active_link.name }} | {{ block.super }}
+{% endblock title %}
+
+
+{% block page-header %}
+{{ block.super }}
+{% if target.pk %}
+  <small>
+    {{ target.title }}
+  </small>
+{% endif %}
+{% endblock page-header %}
+
+
+{% block page-actions %}
+
+{% endblock %}
+
+
+{% block form-header %}
+{% if target.pk %}
+  {% trans "Edit link" %}
+{% else %}
+  {% trans "New link" %}
+{% endif %}
+{% endblock %}
+
+
+{% block form-body %}
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Basic settings" %}</legend>
+
+    {% form_row form.link %}
+    {% form_row form.title %}
+    {% form_row form.position %}
+
+  </fieldset>
+</div>
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Extra configuration" %}</legend>
+
+    <div class="alert alert-info" role="alert">
+      {% trans "Optional link configurations." %}
+    </div>
+    {% form_row form.css_class %}
+    {% form_row form.target %}
+    {% form_row form.rel %}
+
+  </fieldset>
+</div>
+{% endblock form-body %}

+ 133 - 0
misago/templates/misago/admin/menulinks/list.html

@@ -0,0 +1,133 @@
+{% extends "misago/admin/generic/list.html" %}
+{% load i18n misago_admin_form misago_capture %}
+
+
+{% block page-actions %}
+<div class="col-auto page-action">
+  <a href="{% url 'misago:admin:settings:links:new' %}" class="btn btn-primary btn-sm">
+    <span class="fa fa-plus-circle"></span>
+    {% trans "New menu link" %}
+  </a>
+</div>
+{% endblock %}
+
+
+{% block table-header %}
+<th>{% trans "Menu Link" %}</th>
+<th >{% trans "Link" %}</th>
+<th style="width: 180px;">{% trans "Position" %}</th>
+<th style="width: 250px;">{% trans "CSS Class" %}</th>
+<th style="width: 250px;">{% trans "Target" %}</th>
+<th style="width: 250px;">{% trans "Rel" %}</th>
+<th style="width: 1px;">&nbsp;</th>
+<th style="width: 1px;">&nbsp;</th>
+<th style="width: 1px;">&nbsp;</th>
+{% endblock table-header %}
+
+
+{% block table-row %}
+
+<td class="pr-0 small">
+  <a href="{% url 'misago:admin:settings:links:edit' pk=item.pk %}" class="item-name">
+    {{ item.title }}
+  </a>
+</td>
+<td class="small">
+  {{ item.link }}
+</td>
+<td class="small">
+  {{ item.get_position_display }}
+</td>
+<td class="small">
+  {{ item.css_class }}
+</td>
+<td class="small">
+  {{ item.target }}
+</td>
+<td class="small">
+  {{ item.rel }}
+</td>
+{% include "misago/admin/generic/list_extra_actions.html" %}
+<td>
+  {% if not forloop.last %}
+    <form action="{% url 'misago:admin:settings:links:down' pk=item.pk %}" method="post">
+      {% csrf_token %}
+      <button class="btn btn-light btn-sm" data-tooltip="top" title="{% trans 'Move down' %}">
+        <span class="fa fa-chevron-down"></span>
+      </button>
+    </form>
+  {% else %}
+    <button class="btn btn-light btn-sm" disabled>
+      <span class="fa fa-chevron-down"></span>
+    </button>
+  {% endif %}
+</td>
+<td>
+  {% if not forloop.first %}
+    <form action="{% url 'misago:admin:settings:links:up' pk=item.pk %}" method="post">
+      {% csrf_token %}
+      <button class="btn btn-light btn-sm" data-tooltip="top" title="{% trans 'Move up' %}">
+        <span class="fa fa-chevron-up"></span>
+      </button>
+    </form>
+  {% else %}
+    <button class="btn btn-light btn-sm" disabled>
+      <span class="fa fa-chevron-up"></span>
+    </button>
+  {% endif %}
+</td>
+<td>
+  <div class="dropdown">
+    <button class="btn btn-light btn-sm dropdown-toggle" type="button" id="item-optioms-{{ item.pk }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+      <i class="fas fa-ellipsis-h"></i>
+    </button>
+    <div class="dropdown-menu dropdown-menu-right" aria-labelledby="item-optioms-{{ item.pk }}">
+      <a class="dropdown-item" href="{% url 'misago:admin:settings:links:edit' pk=item.pk %}">
+        {% trans "Edit menu link" %}
+      </a>
+      <form action="{% url 'misago:admin:settings:links:delete' pk=item.pk %}" method="post" data-delete-confirmation="true">
+        {% csrf_token %}
+        <button class="dropdown-item">
+          {% trans "Remove menu link" %}
+        </button>
+      </form>
+    </div>
+  </div>
+</td>
+{% endblock %}
+
+
+{% block blankslate %}
+<td colspan="8">
+  {% if active_filters %}
+    {% trans "No menu links matching criteria exist." %}
+  {% else %}
+    {% trans "No menu links are set." %}
+  {% endif %}
+</td>
+{% endblock blankslate %}
+
+
+{% block javascripts %}
+{{ block.super }}
+<script type="text/javascript">
+  window.misago.initConfirmation(
+    "[data-delete-confirmation]",
+    "{% trans 'Are you sure you want to remove this menu link?' %}"
+  )
+</script>
+{% endblock %}
+
+
+{% block filters-modal-body %}
+<div class="row">
+  <div class="col">
+    {% form_row filter_form.position %}
+  </div>
+</div>
+<div class="row">
+  <div class="col">
+    {% form_row filter_form.content %}
+  </div>
+</div>
+{% endblock filters-modal-body %}

+ 8 - 1
misago/templates/misago/footer.html

@@ -10,13 +10,20 @@
         </p>
       </noscript>
 
-      {% if TERMS_OF_SERVICE_URL or PRIVACY_POLICY_URL or settings.forum_footnote %}
+      {% if TERMS_OF_SERVICE_URL or PRIVACY_POLICY_URL or settings.forum_footnote or footer_menu_links %}
         <ul class="list-inline footer-nav">
           {% if settings.forum_footnote %}
             <li class="site-footnote">
               {{ settings.forum_footnote }}
             </li>
           {% endif %}
+          {% for link in footer_menu_links %}
+            <li>
+                <a href="{{ link.link }}"{% if link.css_class %} class="{{ link.css_class}}"{% endif %}{% if link.rel %} rel="{{link.rel}}"{% endif %}{% if link.target %} target="{{link.target}}"{% endif %}>
+                  {{ link.title }}
+                </a>
+            </li>
+          {% endfor %}
           {% if TERMS_OF_SERVICE_URL %}
             <li>
               <a href="{{ TERMS_OF_SERVICE_URL }}">{% trans "Terms of service" %}</a>

+ 8 - 0
misago/templates/misago/navbar.html

@@ -42,6 +42,14 @@
           {% trans "Users" %}
         </a>
       </li>
+      {% for link in top_menu_links %}
+      <li>
+          <a href="{{ link.link }}"{% if link.css_class %} class="{{ link.css_class}}"{% endif %}{% if link.rel %} rel="{{link.rel}}"{% endif %}{% if link.target %} target="{{link.target}}"{% endif %}>
+            {{ link.title }}
+          </a>
+      </li>
+      {% endfor %}
+
     </ul>
 
     <div id="user-menu-mount"></div>