Skip to content

Commit f5d21a0

Browse files
authored
Merge pull request #7286 from akatsoulas/vpn-routing
Update routing options for ZD
2 parents a5be008 + 42054b4 commit f5d21a0

File tree

8 files changed

+205
-3
lines changed

8 files changed

+205
-3
lines changed

kitsune/products/admin.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,13 +227,14 @@ class ProductSupportConfigAdmin(admin.ModelAdmin):
227227
"enable_zendesk_support",
228228
"is_hybrid",
229229
"default_support_type",
230+
"subscription_only",
230231
)
231232
list_display_links = ("product",)
232233
list_editable = ("is_active",)
233234
list_filter = ("is_active", "default_support_type")
234235
search_fields = ("product__title", "product__slug")
235236
filter_horizontal = ("hybrid_support_groups",)
236-
autocomplete_fields = ("product", "forum_config", "zendesk_config")
237+
autocomplete_fields = ("product", "forum_config", "zendesk_config", "unsubscribed_redirect_product")
237238
readonly_fields = (
238239
"is_hybrid",
239240
"enable_forum_support",
@@ -272,6 +273,17 @@ class ProductSupportConfigAdmin(admin.ModelAdmin):
272273
"description": "Set default support type for all users and optionally for hybrid group members.",
273274
},
274275
),
276+
(
277+
"Subscription Routing",
278+
{
279+
"fields": ("subscription_only", "unsubscribed_redirect_product"),
280+
"description": (
281+
"When subscription_only is enabled, only users with an active product subscription "
282+
"can access support. Unsubscribed users are redirected to the specified product's AAQ, "
283+
"or the entire AAQ flow returns 404 if no redirect product is set."
284+
),
285+
},
286+
),
275287
(
276288
"Hybrid Support (Forums + Zendesk)",
277289
{

kitsune/products/managers.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ def locales_list(self):
3131
.distinct()
3232
)
3333

34+
SUPPORT_TYPE_REDIRECT = "redirect_unsubscribed"
35+
SUPPORT_TYPE_HIDE = "hide_unsubscribed"
36+
3437
def route_support_request(self, request, product):
3538
"""
3639
Determines which support channel to route a request to (forum or Zendesk).
@@ -44,7 +47,8 @@ def route_support_request(self, request, product):
4447
4548
Returns:
4649
tuple: (support_type, can_switch)
47-
- support_type: SUPPORT_TYPE_FORUM, SUPPORT_TYPE_ZENDESK, or None
50+
- support_type: SUPPORT_TYPE_FORUM, SUPPORT_TYPE_ZENDESK,
51+
SUPPORT_TYPE_REDIRECT, SUPPORT_TYPE_HIDE, or None
4852
- can_switch: whether user can toggle between channels
4953
- Returns (None, False) if no config exists - view should handle error
5054
"""
@@ -57,6 +61,18 @@ def route_support_request(self, request, product):
5761
return (None, False)
5862

5963
user = request.user
64+
65+
# Subscription gate: check before any other routing logic
66+
if support_config.subscription_only:
67+
is_subscribed = (
68+
user.is_authenticated
69+
and user.profile.products.filter(id=product.id).exists()
70+
)
71+
if not is_subscribed:
72+
if support_config.unsubscribed_redirect_product_id:
73+
return (self.SUPPORT_TYPE_REDIRECT, False)
74+
return (self.SUPPORT_TYPE_HIDE, False)
75+
6076
requested_type = request.GET.get("support_type")
6177

6278
# Validate requested_type
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 5.2.12 on 2026-03-05 14:14
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('products', '0034_zendeskconfig_brand_id'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='productsupportconfig',
16+
name='subscription_only',
17+
field=models.BooleanField(default=False, help_text="If enabled, only users with an active product subscription can access support. Unsubscribed users are redirected to another product's AAQ if one is configured, otherwise the support widget is hidden."),
18+
),
19+
migrations.AddField(
20+
model_name='productsupportconfig',
21+
name='unsubscribed_redirect_product',
22+
field=models.ForeignKey(blank=True, help_text="When subscription_only is enabled, redirect unsubscribed users to this product's AAQ. If not set, the support widget is hidden for unsubscribed users.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='redirect_target_for', to='products.product'),
23+
),
24+
]

kitsune/products/models.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,25 @@ class ProductSupportConfig(ModelBase):
323323
blank=True,
324324
help_text="Default support type for users in hybrid groups (if not set, uses default_support_type)",
325325
)
326+
subscription_only = models.BooleanField(
327+
default=False,
328+
help_text=(
329+
"If enabled, only users with an active product subscription can access support. "
330+
"Unsubscribed users are redirected to another product's AAQ if one is configured, "
331+
"otherwise the support widget is hidden."
332+
),
333+
)
334+
unsubscribed_redirect_product = models.ForeignKey(
335+
"Product",
336+
null=True,
337+
blank=True,
338+
on_delete=models.SET_NULL,
339+
related_name="redirect_target_for",
340+
help_text=(
341+
"When subscription_only is enabled, redirect unsubscribed users to this product's AAQ. "
342+
"If not set, the support widget is hidden for unsubscribed users."
343+
),
344+
)
326345

327346
objects = ProductSupportConfigManager()
328347

@@ -364,6 +383,15 @@ def clean(self):
364383
).exists():
365384
raise ValidationError("The selected forum configuration has no enabled locales.")
366385

386+
if not self.subscription_only and self.unsubscribed_redirect_product_id:
387+
raise ValidationError(
388+
{
389+
"unsubscribed_redirect_product": (
390+
"Redirect product should only be set when subscription_only is enabled."
391+
)
392+
}
393+
)
394+
367395
@property
368396
def is_hybrid(self):
369397
"""Returns True if both forum and Zendesk support are enabled."""

kitsune/products/tests/test_support_routing.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.contrib.auth.models import AnonymousUser
22
from django.test import RequestFactory, TestCase
33

4+
from kitsune.products.managers import ProductSupportConfigManager
45
from kitsune.products.models import ProductSupportConfig
56
from kitsune.products.tests import (
67
ProductFactory,
@@ -299,3 +300,89 @@ def test_no_support_channels_constraint(self):
299300
)
300301

301302
self.assertIn("at_least_one_support_channel", str(context.exception))
303+
304+
305+
class SubscriptionRoutingTests(TestCase):
306+
"""Tests for subscription_only routing in route_support_request()."""
307+
308+
def setUp(self):
309+
self.factory = RequestFactory()
310+
self.product = ProductFactory(slug="mozilla-vpn")
311+
self.zendesk_config = ZendeskConfigFactory()
312+
self.user = UserFactory()
313+
self.config = ProductSupportConfigFactory(
314+
product=self.product,
315+
forum_config=None,
316+
zendesk_config=self.zendesk_config,
317+
is_active=True,
318+
subscription_only=True,
319+
)
320+
321+
def test_subscriber_routes_to_zendesk(self):
322+
"""Subscribed user on a subscription_only product is routed to ZD."""
323+
self.user.profile.products.add(self.product)
324+
325+
request = self.factory.get("/")
326+
request.user = self.user
327+
328+
support_type, can_switch = ProductSupportConfig.objects.route_support_request(
329+
request, self.product
330+
)
331+
332+
self.assertEqual(support_type, ProductSupportConfig.SUPPORT_TYPE_ZENDESK)
333+
self.assertFalse(can_switch)
334+
335+
def test_non_subscriber_with_redirect_returns_redirect_sentinel(self):
336+
"""Non-subscribed user is returned SUPPORT_TYPE_REDIRECT when redirect product is set."""
337+
redirect_product = ProductFactory(slug="firefox")
338+
self.config.unsubscribed_redirect_product = redirect_product
339+
self.config.save()
340+
341+
request = self.factory.get("/")
342+
request.user = self.user # has no subscription
343+
344+
support_type, can_switch = ProductSupportConfig.objects.route_support_request(
345+
request, self.product
346+
)
347+
348+
self.assertEqual(support_type, ProductSupportConfigManager.SUPPORT_TYPE_REDIRECT)
349+
self.assertFalse(can_switch)
350+
351+
def test_non_subscriber_without_redirect_returns_hide_sentinel(self):
352+
"""Non-subscribed user is returned SUPPORT_TYPE_HIDE when no redirect product is set."""
353+
request = self.factory.get("/")
354+
request.user = self.user # has no subscription, no redirect product configured
355+
356+
support_type, can_switch = ProductSupportConfig.objects.route_support_request(
357+
request, self.product
358+
)
359+
360+
self.assertEqual(support_type, ProductSupportConfigManager.SUPPORT_TYPE_HIDE)
361+
self.assertFalse(can_switch)
362+
363+
def test_anonymous_user_returns_hide_sentinel(self):
364+
"""Anonymous users on a subscription_only product are treated as non-subscribers."""
365+
request = self.factory.get("/")
366+
request.user = AnonymousUser()
367+
368+
support_type, can_switch = ProductSupportConfig.objects.route_support_request(
369+
request, self.product
370+
)
371+
372+
self.assertEqual(support_type, ProductSupportConfigManager.SUPPORT_TYPE_HIDE)
373+
self.assertFalse(can_switch)
374+
375+
def test_subscription_only_false_bypasses_check(self):
376+
"""When subscription_only is False, all users pass through to normal routing."""
377+
self.config.subscription_only = False
378+
self.config.save()
379+
380+
request = self.factory.get("/")
381+
request.user = self.user # no subscription, but subscription_only is off
382+
383+
support_type, can_switch = ProductSupportConfig.objects.route_support_request(
384+
request, self.product
385+
)
386+
387+
self.assertEqual(support_type, ProductSupportConfig.SUPPORT_TYPE_ZENDESK)
388+
self.assertFalse(can_switch)

kitsune/products/views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from kitsune.flagit.views import get_hierarchical_topics
1010
from kitsune.products import get_product_redirect_response
11+
from kitsune.products.managers import ProductSupportConfigManager
1112
from kitsune.products.models import Product, Topic, TopicSlugHistory
1213
from kitsune.sumo.utils import get_aaq_context, set_aaq_context
1314
from kitsune.wiki.decorators import check_simple_wiki_locale
@@ -70,7 +71,9 @@ def product_landing(request: HttpRequest, slug: str) -> HttpResponse:
7071
"search_params": {"product": slug},
7172
"latest_version": latest_version,
7273
"featured": get_featured_articles(product=product, locale=request.LANGUAGE_CODE),
73-
"has_support_config": bool(aaq_context),
74+
"has_support_config": bool(aaq_context)
75+
and aaq_context.get("current_support_type")
76+
!= ProductSupportConfigManager.SUPPORT_TYPE_HIDE,
7477
"aaq_context": aaq_context,
7578
},
7679
)

kitsune/questions/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from kitsune.flagit.models import FlaggedObject
3838
from kitsune.flagit.views import get_hierarchical_topics
3939
from kitsune.products import get_product_redirect_response
40+
from kitsune.products.managers import ProductSupportConfigManager
4041
from kitsune.products.models import Product, ProductSupportConfig, Topic, TopicSlugHistory
4142
from kitsune.questions import config
4243
from kitsune.questions.events import QuestionReplyEvent, QuestionSolvedEvent
@@ -641,6 +642,13 @@ def aaq(request, product_slug=None, step=1, is_loginless=False):
641642
)
642643
return HttpResponseRedirect(reverse("products.product", args=[product.slug]))
643644

645+
# Handle subscription-gated products
646+
if support_type == ProductSupportConfigManager.SUPPORT_TYPE_REDIRECT:
647+
return HttpResponseRedirect(aaq_context["redirect_url"])
648+
649+
if support_type == ProductSupportConfigManager.SUPPORT_TYPE_HIDE:
650+
raise Http404
651+
644652
has_public_forum = aaq_context.get("has_public_forum", False)
645653

646654
context["ga_products"] = f"/{product_slug}/"

kitsune/sumo/utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,14 +424,33 @@ def get_aaq_context(request, product, multiple_products=False):
424424
Given a request and a product, determine the AAQ context and return it.
425425
426426
Returns an empty dict when there is no support config for the product.
427+
428+
When subscription_only is enabled and the user lacks a subscription:
429+
- current_support_type is SUPPORT_TYPE_REDIRECT with redirect_url set, or
430+
- current_support_type is SUPPORT_TYPE_HIDE (caller should return Http404)
427431
"""
432+
from kitsune.products.managers import ProductSupportConfigManager
428433
from kitsune.products.models import ProductSupportConfig
429434

430435
if not has_support_config(product):
431436
return {}
432437

433438
support_type, can_switch = ProductSupportConfig.objects.route_support_request(request, product)
434439

440+
if support_type == ProductSupportConfigManager.SUPPORT_TYPE_REDIRECT:
441+
config = ProductSupportConfig.objects.get(product=product, is_active=True)
442+
redirect_url = reverse(
443+
"questions.aaq_step2",
444+
kwargs={"product_slug": config.unsubscribed_redirect_product.slug},
445+
)
446+
return {
447+
"current_support_type": support_type,
448+
"redirect_url": redirect_url,
449+
}
450+
451+
if support_type == ProductSupportConfigManager.SUPPORT_TYPE_HIDE:
452+
return {"current_support_type": support_type}
453+
435454
aaq_context = {
436455
"product_slug": product.slug,
437456
"multiple_products": multiple_products,
@@ -467,6 +486,11 @@ def get_aaq_url(aaq_context, topic=None):
467486
468487
Expects a dict as returned by get_aaq_context (may be empty).
469488
"""
489+
from kitsune.products.managers import ProductSupportConfigManager
490+
491+
if aaq_context.get("current_support_type") == ProductSupportConfigManager.SUPPORT_TYPE_REDIRECT:
492+
return aaq_context.get("redirect_url")
493+
470494
is_ticketed = aaq_context.get("current_support_type") == "zendesk"
471495
has_forum = aaq_context.get("has_public_forum", False)
472496
multiple_products = aaq_context.get("multiple_products", False)

0 commit comments

Comments
 (0)