diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue
index 17a608603b..bb7245c21d 100644
--- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue
@@ -4,6 +4,8 @@
+
+
{{ $tr('inviteSubheading') }}
@@ -64,6 +66,7 @@
+
+
+
\ No newline at end of file
diff --git a/contentcuration/contentcuration/migrations/0152_channel_support_token.py b/contentcuration/contentcuration/migrations/0152_channel_support_token.py
new file mode 100644
index 0000000000..e8141600a8
--- /dev/null
+++ b/contentcuration/contentcuration/migrations/0152_channel_support_token.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.24 on 2025-02-23 09:13
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contentcuration', '0151_alter_assessmentitem_type'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='channel',
+ name='support_token',
+ field=models.OneToOneField(blank=True, help_text='Token for support access', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='support_channels', to='contentcuration.secrettoken', verbose_name='support token'),
+ ),
+ ]
diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py
index 8999c30f53..31f9973afb 100644
--- a/contentcuration/contentcuration/models.py
+++ b/contentcuration/contentcuration/models.py
@@ -801,6 +801,15 @@ class Channel(models.Model):
verbose_name="secret tokens",
blank=True,
)
+ support_token = models.OneToOneField(
+ SecretToken,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='support_channels',
+ verbose_name="support token",
+ help_text="Token for support access",
+ )
source_url = models.CharField(max_length=200, blank=True, null=True)
demo_server_url = models.CharField(max_length=200, blank=True, null=True)
diff --git a/contentcuration/contentcuration/tests/views/test_admin.py b/contentcuration/contentcuration/tests/views/test_admin.py
new file mode 100644
index 0000000000..eb2ad7a8f9
--- /dev/null
+++ b/contentcuration/contentcuration/tests/views/test_admin.py
@@ -0,0 +1,87 @@
+import uuid
+from django.urls import reverse
+from rest_framework import status
+from contentcuration.models import Channel, SecretToken
+from contentcuration.tests import testdata
+from contentcuration.tests.base import StudioTestCase
+
+class SupportTokenRedirectTestCase(StudioTestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super(SupportTokenRedirectTestCase, cls).setUpClass()
+
+ @property
+ def channel_metadata(self):
+ return {
+ "name": "Test Channel",
+ "id": uuid.uuid4().hex,
+ "description": "A test channel for support token creation.",
+ }
+
+ def setUp(self):
+ """
+ Set up test data before running each test.
+ """
+ self.setUpBase()
+
+ self.user.is_admin = True
+ self.user.save()
+
+ self.channel = Channel.objects.create(actor_id=self.user.id, **self.channel_metadata)
+
+ self.valid_token_str = "bepud-dibub-dizok"
+ self.support_token = SecretToken.objects.create(token=self.valid_token_str)
+
+ self.channel.secret_tokens.add(self.support_token)
+ self.channel.support_token = self.support_token
+ self.channel.save()
+
+ self.client = self.admin_client()
+
+ def test_valid_token_redirects_to_channel(self):
+ """
+ Test that a valid token redirects to the correct channel page.
+ """
+ url = reverse("support_token_redirect", kwargs={"token": self.valid_token_str})
+ response = self.client.get(url, format="json")
+
+ self.assertEqual(response.status_code, status.HTTP_302_FOUND)
+ self.assertEqual(response.url, f"channels/{self.channel.id}")
+
+ def test_invalid_token_format(self):
+ """
+ Test that an invalid token format returns 400 Bad Request.
+ """
+ url = reverse("support_token_redirect", kwargs={"token": "invalid_token_123"})
+ response = self.client.get(url, format="json")
+
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertIn("Invalid token format", response.json()["Error"])
+
+ def test_token_not_found(self):
+ """
+ Test that a non-existent token returns 404 Not Found.
+ """
+ url = reverse("support_token_redirect", kwargs={"token": "bepud-befud-bidup"})
+ response = self.client.get(url, format="json")
+
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+ self.assertIn("Invalid token", response.json()["Error"])
+
+
+ def test_unauthorized_user_forbidden(self):
+ """
+ Test that a non-admin user gets 403 Forbidden.
+ """
+ self.client.logout()
+ non_admin_user = testdata.user()
+ non_admin_user.is_admin = False
+ non_admin_user.save()
+ self.client.force_login(non_admin_user)
+
+ url = reverse("support_token_redirect", kwargs={"token": self.valid_token_str})
+ response = self.client.get(url, format="json")
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py
index 17549ab128..084b75b5bb 100644
--- a/contentcuration/contentcuration/tests/viewsets/test_channel.py
+++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py
@@ -12,7 +12,7 @@
from contentcuration import models
from contentcuration import models as cc
from contentcuration.constants import channel_history
-from contentcuration.models import ContentNode
+from contentcuration.models import ContentNode, SecretToken
from contentcuration.tests import testdata
from contentcuration.tests.base import StudioAPITestCase
from contentcuration.tests.viewsets.base import generate_create_event
@@ -706,3 +706,85 @@ def _perform_action(self, url_path, channel_id):
self.client.force_authenticate(user=user)
response = self.client.get(reverse(url_path, kwargs={"pk": channel_id}), format="json")
return response
+
+class SupportTokenTestCase(StudioAPITestCase):
+
+ @property
+ def channel_metadata(self):
+ return {
+ "name": "Test Channel",
+ "id": uuid.uuid4().hex,
+ "description": "A test channel for support token creation.",
+ }
+
+ def setUp(self):
+ super(SupportTokenTestCase, self).setUp()
+ self.user = testdata.user()
+ self.channel = models.Channel.objects.create(actor_id=self.user.id, **self.channel_metadata)
+ self.channel.editors.add(self.user)
+ self.client.force_authenticate(user=self.user)
+ self.channel.save()
+
+ def test_create_support_token(self):
+ """
+ Ensure a support token is created successfully for a channel.
+ """
+ url = reverse("channel-support-token", kwargs={"pk": self.channel.id})
+ response = self.client.post(url, format="json")
+ self.assertEqual(response.status_code, 201, response.content)
+
+ self.channel.refresh_from_db()
+ self.assertIsNotNone(self.channel.support_token)
+
+ # Ensure token exists in SecretToken model
+ token_exists = models.SecretToken.objects.filter(token=self.channel.support_token.token).exists()
+ self.assertTrue(token_exists, "Support token was not stored in the SecretToken table.")
+
+ def test_cannot_create_duplicate_support_token(self):
+ """
+ Ensure creating a support token fails if one already exists.
+ """
+ # First request creates the token
+ url = reverse("channel-support-token", kwargs={"pk": self.channel.id})
+ response = self.client.post(url, format="json")
+ self.assertEqual(response.status_code, 201, response.content)
+
+ # Second request should fail with 409 CONFLICT
+ response = self.client.post(url, format="json")
+ self.assertEqual(response.status_code, 409, response.content)
+
+ def test_get_support_token_existing(self):
+ """
+ Ensure the API returns the support token when it exists.
+ """
+ # Create a support token
+ # token_str = SecretToken.generate_new_token()
+ support_token = SecretToken.objects.create(token = "test-support-token")
+ self.channel.support_token = support_token
+ self.channel.save()
+
+ url = reverse("channel-support-token", kwargs={"pk": self.channel.id})
+ response = self.client.get(url, format="json")
+ print(response.content)
+
+ self.assertEqual(response.status_code, 200, response.content)
+ self.assertEqual(response.json()["support_token"], "test-support-token")
+
+ def test_get_support_token_channel_not_found(self):
+ """
+ Ensure the API returns 404 when the channel does not exist.
+ """
+ url = reverse("channel-support-token", kwargs={"pk": "non-existent-id"})
+ response = self.client.get(url, format="json")
+
+ self.assertEqual(response.status_code, 404, response.content)
+
+ def test_get_support_token_none(self):
+ """
+ Ensure the API returns null when no support token exists.
+ """
+ url = reverse("channel-support-token", kwargs={"pk": self.channel.id})
+ response = self.client.get(url, format="json")
+
+ self.assertEqual(response.status_code, 200, response.content)
+ self.assertIsNone(response.json()["support_token"])
diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py
index 8047ca0bf4..9cdd2a41ba 100644
--- a/contentcuration/contentcuration/urls.py
+++ b/contentcuration/contentcuration/urls.py
@@ -137,6 +137,7 @@ def get_redirect_url(self, *args, **kwargs):
# Add admin endpoints
urlpatterns += [
re_path(r'^api/send_custom_email/$', admin_views.send_custom_email, name='send_custom_email'),
+ re_path(r'^api/support_token_redirect/(?P[^/]+)/$', admin_views.support_token_redirect, name='support_token_redirect'),
]
urlpatterns += [re_path(r'^jsreverse/$', django_js_reverse_views.urls_js, name='js_reverse')]
diff --git a/contentcuration/contentcuration/views/admin.py b/contentcuration/contentcuration/views/admin.py
index 7a55052cbb..53683c63fe 100644
--- a/contentcuration/contentcuration/views/admin.py
+++ b/contentcuration/contentcuration/views/admin.py
@@ -1,14 +1,18 @@
import json
+import re
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ObjectDoesNotExist
+from django.shortcuts import redirect
from django.shortcuts import render
+from django.shortcuts import get_object_or_404
from rest_framework.authentication import BasicAuthentication
from rest_framework.authentication import SessionAuthentication
from rest_framework.authentication import TokenAuthentication
from rest_framework.decorators import api_view
from rest_framework.decorators import authentication_classes
+from rest_framework.decorators import permission_classes
from rest_framework.response import Response
from .json_dump import json_for_parse_from_data
@@ -17,6 +21,9 @@
from contentcuration.tasks import sendcustomemails_task
from contentcuration.utils.messages import get_messages
from contentcuration.views.base import current_user_for_context
+from contentcuration.models import SecretToken, Channel
+from contentcuration.viewsets.user import IsAdminUser
+from contentcuration.viewsets.user import IsAuthenticated
@is_admin
@@ -30,6 +37,30 @@ def send_custom_email(request):
return Response({"success": True})
+@api_view(['GET'])
+@permission_classes([IsAuthenticated, IsAdminUser])
+def support_token_redirect(request, token):
+ try:
+ # Inline regex for validating Proquint tokens
+ token_regex = re.compile(
+ r"^([bdfghjklmnprstvz][aeiou][bdfghjklmnprstvz][aeiou][bdfghjklmnprstvz])"
+ r"(-[bdfghjklmnprstvz][aeiou][bdfghjklmnprstvz][aeiou][bdfghjklmnprstvz])*$"
+ )
+
+ if not token_regex.fullmatch(token):
+ return Response({"Error": "Invalid token format"}, status=400)
+
+ token_instance = SecretToken.objects.filter(token=token).first()
+ if not token_instance:
+ return Response({"Error": "Invalid token"}, status=404)
+
+ channel = get_object_or_404(Channel, support_token__token=token)
+
+ # Redirect to the channel edit page
+ return redirect(f"channels/{channel.id}")
+
+ except Channel.DoesNotExist:
+ return Response({"Error": "Channel not found for token"}, status=404)
@login_required
@browser_is_supported
diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py
index 5cd9b84104..f7021665cb 100644
--- a/contentcuration/contentcuration/viewsets/channel.py
+++ b/contentcuration/contentcuration/viewsets/channel.py
@@ -32,6 +32,7 @@
from rest_framework.serializers import CharField
from rest_framework.serializers import FloatField
from rest_framework.serializers import IntegerField
+from rest_framework.status import HTTP_409_CONFLICT
from rest_framework.status import HTTP_201_CREATED
from rest_framework.status import HTTP_204_NO_CONTENT
from search.models import ChannelFullTextSearch
@@ -777,7 +778,42 @@ def _get_channel_content_languages(self, channel_id, main_tree_id=None) -> List[
logging.error(str(e))
unique_lang_ids = []
return unique_lang_ids
+
+ @action(detail=True, methods=["get", "post"], url_path="support_token", url_name="support-token")
+ def support_token(self, request, pk=None) -> Union[JsonResponse, HttpResponse, Response]:
+ """
+ Handles both:
+ - GET: Retrieve the existing support token for this channel.
+ - POST: Create a new support token for this channel.
+ """
+ if not self._channel_exists(pk):
+ return HttpResponseNotFound("No channel matching: {}".format(pk))
+
+ channel = self.get_edit_queryset().get(pk=pk)
+
+ # Handle GET: Retrieve the support token
+ if request.method == "GET":
+ token_value = None
+ if channel.support_token:
+ token_value = channel.support_token.token
+ return JsonResponse({"support_token": token_value})
+
+ # Handle POST: Create a new support token
+ if request.method == "POST":
+ if channel.support_token is not None:
+ return Response(
+ {"error": "Support token already exists for this channel."},
+ status=HTTP_409_CONFLICT
+ )
+
+ token_str = SecretToken.generate_new_token()
+ support_token = SecretToken.objects.create(token=token_str)
+
+ channel.support_token = support_token
+ channel.save(update_fields=["support_token"])
+ data = self.serialize_object(pk=channel.pk)
+ return Response(data, status=HTTP_201_CREATED)
@method_decorator(
cache_page(