mirror of
https://github.com/sissbruecker/linkding
synced 2024-11-10 06:04:15 +00:00
Allow configuring landing page for unauthenticated users (#808)
* allow configuring landing page * add tests
This commit is contained in:
parent
36749c398b
commit
5eadb3ede3
12 changed files with 261 additions and 46 deletions
38
bookmarks/migrations/0037_globalsettings.py
Normal file
38
bookmarks/migrations/0037_globalsettings.py
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
|
40
bookmarks/tests/test_root_view.py
Normal file
40
bookmarks/tests/test_root_view.py
Normal 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"))
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
|
@ -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
18
bookmarks/views/root.py
Normal 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"))
|
|
@ -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):
|
||||||
|
|
Loading…
Reference in a new issue