Repository: sissbruecker/linkding
Pull request #809 (2024-08-31 18:21:04)

Allow configuring guest user profile
Allows configuring a guest user profile in the global settings. The profile can be used to customize how the publicly shared bookmark list is displayed for guest users.

Closes https://github.com/sissbruecker/linkding/issues/714
Closes https://github.com/sissbruecker/linkding/issues/533


Patch:
diff --git a/bookmarks/middlewares.py b/bookmarks/middlewares.py
index 981137f..af7ff12 100644
--- a/bookmarks/middlewares.py
+++ b/bookmarks/middlewares.py
@@ -1,13 +1,17 @@
 from django.conf import settings
 from django.contrib.auth.middleware import RemoteUserMiddleware
 
-from bookmarks.models import UserProfile
+from bookmarks.models import UserProfile, GlobalSettings
 
 
 class CustomRemoteUserMiddleware(RemoteUserMiddleware):
     header = settings.LD_AUTH_PROXY_USERNAME_HEADER
 
 
+standard_profile = UserProfile()
+standard_profile.enable_favicons = True
+
+
 class UserProfileMiddleware:
     def __init__(self, get_response):
         self.get_response = get_response
@@ -16,8 +20,16 @@ class UserProfileMiddleware:
         if request.user.is_authenticated:
             request.user_profile = request.user.profile
         else:
-            request.user_profile = UserProfile()
-            request.user_profile.enable_favicons = True
+            # check if a custom profile for guests exists, otherwise use standard profile
+            guest_profile = None
+            try:
+                global_settings = GlobalSettings.get()
+                if global_settings.guest_profile_user:
+                    guest_profile = global_settings.guest_profile_user.profile
+            except:
+                pass
+
+            request.user_profile = guest_profile or standard_profile
 
         response = self.get_response(request)
 
diff --git a/bookmarks/migrations/0038_globalsettings_guest_profile_user.py b/bookmarks/migrations/0038_globalsettings_guest_profile_user.py
new file mode 100644
index 0000000..6950e9c
--- /dev/null
+++ b/bookmarks/migrations/0038_globalsettings_guest_profile_user.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.0.8 on 2024-08-31 17:54
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookmarks", "0037_globalsettings"),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="globalsettings",
+            name="guest_profile_user",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+    ]
diff --git a/bookmarks/models.py b/bookmarks/models.py
index 71dda82..f4db7d5 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -508,6 +508,9 @@ class GlobalSettings(models.Model):
         blank=False,
         default=LANDING_PAGE_LOGIN,
     )
+    guest_profile_user = models.ForeignKey(
+        get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
+    )
 
     @classmethod
     def get(cls):
@@ -526,6 +529,8 @@ class GlobalSettings(models.Model):
 class GlobalSettingsForm(forms.ModelForm):
     class Meta:
         model = GlobalSettings
-        fields = [
-            "landing_page",
-        ]
+        fields = ["landing_page", "guest_profile_user"]
+
+    def __init__(self, *args, **kwargs):
+        super(GlobalSettingsForm, self).__init__(*args, **kwargs)
+        self.fields["guest_profile_user"].empty_label = "Standard profile"
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index eae24bf..741d8c0 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -249,7 +249,17 @@ reddit.com/r/Music music reddit</pre>
             <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.
+              The page that unauthenticated users are redirected to when accessing the root URL.
+            </div>
+          </div>
+          <div class="form-group">
+            <label for="{{ global_settings_form.guest_profile_user.id_for_label }}" class="form-label">Guest user
+              profile</label>
+            {{ global_settings_form.guest_profile_user|add_class:"form-select width-25 width-sm-100" }}
+            <div class="form-input-hint">
+              The user profile to use for users that are not logged in. This will affect how publicly shared bookmarks
+              are displayed regarding theme, bookmark list settings, etc. You can either use your own profile or create
+              a dedicated user for this purpose. By default, a standard profile with fixed settings is used.
             </div>
           </div>
 


Test patch:
diff --git a/bookmarks/tests/test_root_view.py b/bookmarks/tests/test_root_view.py
index a02b598..d9f978b 100644
--- a/bookmarks/tests/test_root_view.py
+++ b/bookmarks/tests/test_root_view.py
@@ -5,7 +5,7 @@ from bookmarks.models import GlobalSettings
 from bookmarks.tests.helpers import BookmarkFactoryMixin
 
 
-class AnonymousViewTestCase(TestCase, BookmarkFactoryMixin):
+class RootViewTestCase(TestCase, BookmarkFactoryMixin):
     def test_unauthenticated_user_redirect_to_login_by_default(self):
         response = self.client.get(reverse("bookmarks:root"))
         self.assertRedirects(response, reverse("login"))
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index 568a7c7..0f82726 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -469,10 +469,13 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
     def test_update_global_settings(self):
         superuser = self.setup_superuser()
         self.client.force_login(superuser)
+        selectable_user = self.setup_user()
 
+        # Update global settings
         form_data = {
             "update_global_settings": "",
             "landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
+            "guest_profile_user": selectable_user.id,
         }
         response = self.client.post(reverse("bookmarks:settings.general"), form_data)
         self.assertEqual(response.status_code, 200)
@@ -480,6 +483,22 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
 
         global_settings = GlobalSettings.get()
         self.assertEqual(global_settings.landing_page, form_data["landing_page"])
+        self.assertEqual(global_settings.guest_profile_user, selectable_user)
+
+        # Revert settings
+        form_data = {
+            "update_global_settings": "",
+            "landing_page": GlobalSettings.LANDING_PAGE_LOGIN,
+            "guest_profile_user": "",
+        }
+        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()
+        global_settings.refresh_from_db()
+        self.assertEqual(global_settings.landing_page, form_data["landing_page"])
+        self.assertIsNone(global_settings.guest_profile_user)
 
     def test_update_global_settings_should_not_be_called_without_respective_form_action(
         self,
diff --git a/bookmarks/tests/test_user_profile_middleware.py b/bookmarks/tests/test_user_profile_middleware.py
new file mode 100644
index 0000000..1713df0
--- /dev/null
+++ b/bookmarks/tests/test_user_profile_middleware.py
@@ -0,0 +1,47 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import UserProfile, GlobalSettings
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+from bookmarks.middlewares import standard_profile
+
+
+class UserProfileMiddlewareTestCase(TestCase, BookmarkFactoryMixin):
+    def test_unauthenticated_user_should_use_standard_profile_by_default(self):
+        response = self.client.get(reverse("login"))
+
+        self.assertEqual(standard_profile, response.wsgi_request.user_profile)
+
+    def test_unauthenticated_user_should_use_custom_configured_profile(self):
+        guest_user = self.setup_user()
+        guest_user_profile = guest_user.profile
+        guest_user_profile.theme = UserProfile.THEME_DARK
+        guest_user_profile.save()
+
+        global_settings = GlobalSettings.get()
+        global_settings.guest_profile_user = guest_user
+        global_settings.save()
+
+        response = self.client.get(reverse("login"))
+
+        self.assertEqual(guest_user_profile, response.wsgi_request.user_profile)
+
+    def test_authenticated_user_should_use_own_profile(self):
+        guest_user = self.setup_user()
+        guest_user_profile = guest_user.profile
+        guest_user_profile.theme = UserProfile.THEME_DARK
+        guest_user_profile.save()
+
+        global_settings = GlobalSettings.get()
+        global_settings.guest_profile_user = guest_user
+        global_settings.save()
+
+        user = self.get_or_create_test_user()
+        user_profile = user.profile
+        user_profile.theme = UserProfile.THEME_LIGHT
+        user_profile.save()
+        self.client.force_login(user)
+
+        response = self.client.get(reverse("login"), follow=True)
+
+        self.assertEqual(user_profile, response.wsgi_request.user_profile)