Allow configuring landing page for unauthenticated users (#808)

* allow configuring landing page

* add tests
This commit is contained in:
Sascha Ißbrücker 2024-08-31 15:39:22 +02:00 committed by GitHub
parent 36749c398b
commit 5eadb3ede3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 261 additions and 46 deletions

View file

@ -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,
),
),
],
),
]

View file

@ -492,3 +492,40 @@ class FeedToken(models.Model):
def __str__(self): def __str__(self):
return self.key 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",
]

View file

@ -114,16 +114,16 @@
</div> </div>
{% endif %} {% endif %}
<div class="d-flex justify-between"> <div class="d-flex justify-between">
<a href="{% url 'bookmarks:index' %}" class="d-flex align-center"> <a href="{% url 'bookmarks:root' %}" class="d-flex align-center">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo"> <img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>LINKDING</h1> <h1>LINKDING</h1>
</a> </a>
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
{# Only show nav items menu when logged in #} {# Only show nav items menu when logged in #}
{% include 'bookmarks/nav_menu.html' %} {% include 'bookmarks/nav_menu.html' %}
{% elif has_public_shares %} {% else %}
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #} {# Otherwise show login link #}
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a> <a href="{% url 'login' %}" class="btn btn-link">Login</a>
{% endif %} {% endif %}
</div> </div>
</header> </header>

View file

@ -7,13 +7,14 @@
{% include 'settings/nav.html' %} {% include 'settings/nav.html' %}
{# Profile section #} {# Profile section #}
<section class="content-area">
{% if success_message %} {% if success_message %}
<div class="toast toast-success mb-4">{{ success_message }}</div> <div class="toast toast-success mb-4">{{ success_message }}</div>
{% endif %} {% endif %}
{% if error_message %} {% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div> <div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %} {% endif %}
<section class="content-area">
<h2>Profile</h2> <h2>Profile</h2>
<p> <p>
<a href="{% url 'change_password' %}">Change password</a> <a href="{% url 'change_password' %}">Change password</a>
@ -238,6 +239,27 @@ reddit.com/r/Music music reddit</pre>
</form> </form>
</section> </section>
{# Global settings section #}
{% if global_settings_form %}
<section class="content-area">
<h2>Global settings</h2>
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
{% csrf_token %}
<div class="form-group">
<label for="{{ global_settings_form.landing_page.id_for_label }}" class="form-label">Landing page</label>
{{ global_settings_form.landing_page|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
The page that unauthorized users are redirected to when accessing the root URL.
</div>
</div>
<div class="form-group">
<input type="submit" name="update_global_settings" value="Save" class="btn btn-primary mt-2">
</div>
</form>
</section>
{% endif %}
{# Import section #} {# Import section #}
<section class="content-area"> <section class="content-area">
<h2>Import</h2> <h2>Import</h2>

View file

@ -24,6 +24,11 @@ class BookmarkFactoryMixin:
return self.user return self.user
def setup_superuser(self):
return User.objects.create_superuser(
"superuser", "superuser@example.com", "password123"
)
def setup_bookmark( def setup_bookmark(
self, self,
is_archived: bool = False, is_archived: bool = False,

View file

@ -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'<a href="{url}" class="btn btn-link">Shared bookmarks</a>',
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)

View file

@ -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"))

View file

@ -6,7 +6,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from requests import RequestException from requests import RequestException
from bookmarks.models import UserProfile from bookmarks.models import UserProfile, GlobalSettings
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info from bookmarks.views.settings import app_version, get_version_info
@ -465,3 +465,63 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertSuccessMessage( self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while...", count=0 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(
"<h2>Global settings</h2>",
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(
"<h2>Global settings</h2>",
html,
count=1,
)

View file

@ -1,6 +1,5 @@
from django.urls import path, include from django.urls import path, include
from django.urls import re_path from django.urls import re_path
from django.views.generic import RedirectView
from bookmarks import views from bookmarks import views
from bookmarks.api.routes import router from bookmarks.api.routes import router
@ -14,10 +13,8 @@ from bookmarks.views import partials
app_name = "bookmarks" app_name = "bookmarks"
urlpatterns = [ urlpatterns = [
# Redirect root to bookmarks index # Root view handling redirection based on user authentication
re_path( re_path(r"^$", views.root, name="root"),
r"^$", RedirectView.as_view(pattern_name="bookmarks:index", permanent=False)
),
# Bookmarks # Bookmarks
path("bookmarks", views.bookmarks.index, name="index"), path("bookmarks", views.bookmarks.index, name="index"),
path("bookmarks/action", views.bookmarks.index_action, name="index.action"), path("bookmarks/action", views.bookmarks.index_action, name="index.action"),

View file

@ -4,3 +4,4 @@ from .settings import *
from .toasts import * from .toasts import *
from .health import health from .health import health
from .manifest import manifest from .manifest import manifest
from .root import root

18
bookmarks/views/root.py Normal file
View file

@ -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"))

View file

@ -6,13 +6,20 @@ import requests
from django.conf import settings as django_settings from django.conf import settings as django_settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.db.models import prefetch_related_objects from django.db.models import prefetch_related_objects
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from rest_framework.authtoken.models import Token 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 exporter, tasks
from bookmarks.services import importer from bookmarks.services import importer
from bookmarks.utils import app_version from bookmarks.utils import app_version
@ -23,6 +30,7 @@ logger = logging.getLogger(__name__)
@login_required @login_required
def general(request): def general(request):
profile_form = None profile_form = None
global_settings_form = None
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
success_message = _find_message_with_tag( success_message = _find_message_with_tag(
@ -37,6 +45,9 @@ def general(request):
if "update_profile" in request.POST: if "update_profile" in request.POST:
profile_form = update_profile(request) profile_form = update_profile(request)
success_message = "Profile updated" 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: if "refresh_favicons" in request.POST:
tasks.schedule_refresh_favicons(request.user) tasks.schedule_refresh_favicons(request.user)
success_message = "Scheduled favicon update. This may take a while..." success_message = "Scheduled favicon update. This may take a while..."
@ -52,11 +63,15 @@ def general(request):
if not profile_form: if not profile_form:
profile_form = UserProfileForm(instance=request.user_profile) 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( return render(
request, request,
"settings/general.html", "settings/general.html",
{ {
"form": profile_form, "form": profile_form,
"global_settings_form": global_settings_form,
"enable_refresh_favicons": enable_refresh_favicons, "enable_refresh_favicons": enable_refresh_favicons,
"has_snapshot_support": has_snapshot_support, "has_snapshot_support": has_snapshot_support,
"success_message": success_message, "success_message": success_message,
@ -83,6 +98,17 @@ def update_profile(request):
return form 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 # Cache API call response, for one hour when using get_ttl_hash with default params
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_version_info(ttl_hash=None): def get_version_info(ttl_hash=None):