diff --git a/bookmarks/migrations/0037_globalsettings.py b/bookmarks/migrations/0037_globalsettings.py
new file mode 100644
index 0000000..48aa8e5
--- /dev/null
+++ b/bookmarks/migrations/0037_globalsettings.py
@@ -0,0 +1,38 @@
+# Generated by Django 5.0.8 on 2024-08-31 12:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookmarks", "0036_userprofile_auto_tagging_rules"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="GlobalSettings",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "landing_page",
+ models.CharField(
+ choices=[
+ ("login", "Login"),
+ ("shared_bookmarks", "Shared Bookmarks"),
+ ],
+ default="login",
+ max_length=50,
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/bookmarks/models.py b/bookmarks/models.py
index a8120d5..71dda82 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -492,3 +492,40 @@ class FeedToken(models.Model):
def __str__(self):
return self.key
+
+
+class GlobalSettings(models.Model):
+ LANDING_PAGE_LOGIN = "login"
+ LANDING_PAGE_SHARED_BOOKMARKS = "shared_bookmarks"
+ LANDING_PAGE_CHOICES = [
+ (LANDING_PAGE_LOGIN, "Login"),
+ (LANDING_PAGE_SHARED_BOOKMARKS, "Shared Bookmarks"),
+ ]
+
+ landing_page = models.CharField(
+ max_length=50,
+ choices=LANDING_PAGE_CHOICES,
+ blank=False,
+ default=LANDING_PAGE_LOGIN,
+ )
+
+ @classmethod
+ def get(cls):
+ instance = GlobalSettings.objects.first()
+ if not instance:
+ instance = GlobalSettings()
+ instance.save()
+ return instance
+
+ def save(self, *args, **kwargs):
+ if not self.pk and GlobalSettings.objects.exists():
+ raise Exception("There is already one instance of GlobalSettings")
+ return super(GlobalSettings, self).save(*args, **kwargs)
+
+
+class GlobalSettingsForm(forms.ModelForm):
+ class Meta:
+ model = GlobalSettings
+ fields = [
+ "landing_page",
+ ]
diff --git a/bookmarks/templates/bookmarks/layout.html b/bookmarks/templates/bookmarks/layout.html
index b75959d..3ec9d9c 100644
--- a/bookmarks/templates/bookmarks/layout.html
+++ b/bookmarks/templates/bookmarks/layout.html
@@ -114,16 +114,16 @@
{% endif %}
-
+
LINKDING
{% if request.user.is_authenticated %}
{# Only show nav items menu when logged in #}
{% include 'bookmarks/nav_menu.html' %}
- {% elif has_public_shares %}
- {# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
-
Shared bookmarks
+ {% else %}
+ {# Otherwise show login link #}
+
Login
{% endif %}
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index d12c64e..eae24bf 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -7,13 +7,14 @@
{% include 'settings/nav.html' %}
{# Profile section #}
+ {% if success_message %}
+ {{ success_message }}
+ {% endif %}
+ {% if error_message %}
+ {{ error_message }}
+ {% endif %}
+
- {% if success_message %}
- {{ success_message }}
- {% endif %}
- {% if error_message %}
- {{ error_message }}
- {% endif %}
Profile
Change password
@@ -238,6 +239,27 @@ reddit.com/r/Music music reddit
+ {# Global settings section #}
+ {% if global_settings_form %}
+
+ {% endif %}
+
{# Import section #}
Import
diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py
index 1e8b092..dd97f2d 100644
--- a/bookmarks/tests/helpers.py
+++ b/bookmarks/tests/helpers.py
@@ -24,6 +24,11 @@ class BookmarkFactoryMixin:
return self.user
+ def setup_superuser(self):
+ return User.objects.create_superuser(
+ "superuser", "superuser@example.com", "password123"
+ )
+
def setup_bookmark(
self,
is_archived: bool = False,
diff --git a/bookmarks/tests/test_anonymous_view.py b/bookmarks/tests/test_anonymous_view.py
deleted file mode 100644
index 8b8515f..0000000
--- a/bookmarks/tests/test_anonymous_view.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from django.test import TestCase
-from django.urls import reverse
-
-from bookmarks.tests.helpers import BookmarkFactoryMixin
-
-
-class AnonymousViewTestCase(TestCase, BookmarkFactoryMixin):
- def assertSharedBookmarksLinkCount(self, response, count):
- url = reverse("bookmarks:shared")
- self.assertContains(
- response,
- f'Shared bookmarks',
- count=count,
- )
-
- def test_publicly_shared_bookmarks_link(self):
- # should not render link if no public shares exist
- user = self.setup_user(enable_sharing=True)
- self.setup_bookmark(user=user, shared=True)
-
- response = self.client.get(reverse("login"))
- self.assertSharedBookmarksLinkCount(response, 0)
-
- # should render link if public shares exist
- user.profile.enable_public_sharing = True
- user.profile.save()
-
- response = self.client.get(reverse("login"))
- self.assertSharedBookmarksLinkCount(response, 1)
diff --git a/bookmarks/tests/test_root_view.py b/bookmarks/tests/test_root_view.py
new file mode 100644
index 0000000..a02b598
--- /dev/null
+++ b/bookmarks/tests/test_root_view.py
@@ -0,0 +1,40 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import GlobalSettings
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class AnonymousViewTestCase(TestCase, BookmarkFactoryMixin):
+ def test_unauthenticated_user_redirect_to_login_by_default(self):
+ response = self.client.get(reverse("bookmarks:root"))
+ self.assertRedirects(response, reverse("login"))
+
+ def test_unauthenticated_redirect_to_shared_bookmarks_if_configured_in_global_settings(
+ self,
+ ):
+ settings = GlobalSettings.get()
+ settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS
+ settings.save()
+
+ response = self.client.get(reverse("bookmarks:root"))
+ self.assertRedirects(response, reverse("bookmarks:shared"))
+
+ def test_authenticated_user_always_redirected_to_bookmarks(self):
+ self.client.force_login(self.get_or_create_test_user())
+
+ response = self.client.get(reverse("bookmarks:root"))
+ self.assertRedirects(response, reverse("bookmarks:index"))
+
+ settings = GlobalSettings.get()
+ settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS
+ settings.save()
+
+ response = self.client.get(reverse("bookmarks:root"))
+ self.assertRedirects(response, reverse("bookmarks:index"))
+
+ settings.landing_page = GlobalSettings.LANDING_PAGE_LOGIN
+ settings.save()
+
+ response = self.client.get(reverse("bookmarks:root"))
+ self.assertRedirects(response, reverse("bookmarks:index"))
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index 80a6651..568a7c7 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -6,7 +6,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from requests import RequestException
-from bookmarks.models import UserProfile
+from bookmarks.models import UserProfile, GlobalSettings
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info
@@ -465,3 +465,63 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while...", count=0
)
+
+ def test_update_global_settings(self):
+ superuser = self.setup_superuser()
+ self.client.force_login(superuser)
+
+ form_data = {
+ "update_global_settings": "",
+ "landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
+ }
+ response = self.client.post(reverse("bookmarks:settings.general"), form_data)
+ self.assertEqual(response.status_code, 200)
+ self.assertSuccessMessage(response.content.decode(), "Global settings updated")
+
+ global_settings = GlobalSettings.get()
+ self.assertEqual(global_settings.landing_page, form_data["landing_page"])
+
+ def test_update_global_settings_should_not_be_called_without_respective_form_action(
+ self,
+ ):
+ superuser = self.setup_superuser()
+ self.client.force_login(superuser)
+
+ form_data = {
+ "landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
+ }
+ response = self.client.post(reverse("bookmarks:settings.general"), form_data)
+ self.assertEqual(response.status_code, 200)
+ self.assertSuccessMessage(
+ response.content.decode(), "Global settings updated", count=0
+ )
+
+ def test_update_global_settings_checks_for_superuser(self):
+ form_data = {
+ "update_global_settings": "",
+ "landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
+ }
+ response = self.client.post(reverse("bookmarks:settings.general"), form_data)
+ self.assertEqual(response.status_code, 403)
+
+ def test_global_settings_only_visible_for_superuser(self):
+ response = self.client.get(reverse("bookmarks:settings.general"))
+ html = response.content.decode()
+
+ self.assertInHTML(
+ "Global settings
",
+ html,
+ count=0,
+ )
+
+ superuser = self.setup_superuser()
+ self.client.force_login(superuser)
+
+ response = self.client.get(reverse("bookmarks:settings.general"))
+ html = response.content.decode()
+
+ self.assertInHTML(
+ "Global settings
",
+ html,
+ count=1,
+ )
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index f92dd39..fd855e3 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -1,6 +1,5 @@
from django.urls import path, include
from django.urls import re_path
-from django.views.generic import RedirectView
from bookmarks import views
from bookmarks.api.routes import router
@@ -14,10 +13,8 @@ from bookmarks.views import partials
app_name = "bookmarks"
urlpatterns = [
- # Redirect root to bookmarks index
- re_path(
- r"^$", RedirectView.as_view(pattern_name="bookmarks:index", permanent=False)
- ),
+ # Root view handling redirection based on user authentication
+ re_path(r"^$", views.root, name="root"),
# Bookmarks
path("bookmarks", views.bookmarks.index, name="index"),
path("bookmarks/action", views.bookmarks.index_action, name="index.action"),
diff --git a/bookmarks/views/__init__.py b/bookmarks/views/__init__.py
index 3f26b62..b9a9f0e 100644
--- a/bookmarks/views/__init__.py
+++ b/bookmarks/views/__init__.py
@@ -4,3 +4,4 @@ from .settings import *
from .toasts import *
from .health import health
from .manifest import manifest
+from .root import root
diff --git a/bookmarks/views/root.py b/bookmarks/views/root.py
new file mode 100644
index 0000000..8b72846
--- /dev/null
+++ b/bookmarks/views/root.py
@@ -0,0 +1,18 @@
+from django.http import HttpResponseRedirect
+from django.urls import reverse
+
+from bookmarks.models import GlobalSettings
+
+
+def root(request):
+ # Redirect unauthenticated users to the configured landing page
+ if not request.user.is_authenticated:
+ settings = GlobalSettings.get()
+
+ if settings.landing_page == GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS:
+ return HttpResponseRedirect(reverse("bookmarks:shared"))
+ else:
+ return HttpResponseRedirect(reverse("login"))
+
+ # Redirect authenticated users to the bookmarks page
+ return HttpResponseRedirect(reverse("bookmarks:index"))
diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py
index 582c2a2..3c8a1f5 100644
--- a/bookmarks/views/settings.py
+++ b/bookmarks/views/settings.py
@@ -6,13 +6,20 @@ import requests
from django.conf import settings as django_settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
from django.db.models import prefetch_related_objects
from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import render
from django.urls import reverse
from rest_framework.authtoken.models import Token
-from bookmarks.models import Bookmark, UserProfileForm, FeedToken
+from bookmarks.models import (
+ Bookmark,
+ UserProfileForm,
+ FeedToken,
+ GlobalSettings,
+ GlobalSettingsForm,
+)
from bookmarks.services import exporter, tasks
from bookmarks.services import importer
from bookmarks.utils import app_version
@@ -23,6 +30,7 @@ logger = logging.getLogger(__name__)
@login_required
def general(request):
profile_form = None
+ global_settings_form = None
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
success_message = _find_message_with_tag(
@@ -37,6 +45,9 @@ def general(request):
if "update_profile" in request.POST:
profile_form = update_profile(request)
success_message = "Profile updated"
+ if "update_global_settings" in request.POST:
+ global_settings_form = update_global_settings(request)
+ success_message = "Global settings updated"
if "refresh_favicons" in request.POST:
tasks.schedule_refresh_favicons(request.user)
success_message = "Scheduled favicon update. This may take a while..."
@@ -52,11 +63,15 @@ def general(request):
if not profile_form:
profile_form = UserProfileForm(instance=request.user_profile)
+ if request.user.is_superuser and not global_settings_form:
+ global_settings_form = GlobalSettingsForm(instance=GlobalSettings.get())
+
return render(
request,
"settings/general.html",
{
"form": profile_form,
+ "global_settings_form": global_settings_form,
"enable_refresh_favicons": enable_refresh_favicons,
"has_snapshot_support": has_snapshot_support,
"success_message": success_message,
@@ -83,6 +98,17 @@ def update_profile(request):
return form
+def update_global_settings(request):
+ user = request.user
+ if not user.is_superuser:
+ raise PermissionDenied()
+
+ form = GlobalSettingsForm(request.POST, instance=GlobalSettings.get())
+ if form.is_valid():
+ form.save()
+ return form
+
+
# Cache API call response, for one hour when using get_ttl_hash with default params
@lru_cache(maxsize=1)
def get_version_info(ttl_hash=None):