feat: add client subscription startup policy

This commit is contained in:
QiuSW
2026-07-28 15:14:48 +08:00
parent f0f34156da
commit 1c100c5d7e
15 changed files with 290 additions and 21 deletions
+101 -2
View File
@@ -14,6 +14,7 @@ from django.conf import settings
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.core.files.base import ContentFile
from django.core.management import call_command
from django.test import TestCase, override_settings
@@ -463,6 +464,12 @@ class ClientLatestReleaseApiTests(TestCase):
def setUp(self):
cache.clear()
self.client = APIClient()
self.client_policy_override = override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="off",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T00:00:00+08:00",
)
self.client_policy_override.enable()
self.addCleanup(self.client_policy_override.disable)
def create_release(
self,
@@ -502,7 +509,17 @@ class ClientLatestReleaseApiTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertNotIn(GenerateRateThrottle, ClientLatestReleaseView.throttle_classes)
self.assertEqual(response["Cache-Control"], "no-store")
self.assertEqual(response.data["platform"], "windows")
self.assertEqual(
response.data["client_policy"],
{
"policy_version": 1,
"subscription_check_enabled": False,
"subscription_enforcement_enabled": False,
"updated_at": "2026-07-28T00:00:00+08:00",
},
)
self.assertEqual(
set(response.data["release"]),
{
@@ -624,9 +641,79 @@ class ClientLatestReleaseApiTests(TestCase):
"platform": "windows",
"release": None,
"message": "暂未发布",
"client_policy": {
"policy_version": 1,
"subscription_check_enabled": False,
"subscription_enforcement_enabled": False,
"updated_at": "2026-07-28T00:00:00+08:00",
},
},
)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="observe",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T09:00:00+08:00",
)
def test_latest_release_returns_observe_client_policy_without_release(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Cache-Control"], "no-store")
self.assertIsNone(response.data["release"])
self.assertEqual(
response.data["client_policy"],
{
"policy_version": 1,
"subscription_check_enabled": True,
"subscription_enforcement_enabled": False,
"updated_at": "2026-07-28T09:00:00+08:00",
},
)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="enforce",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T10:00:00+08:00",
)
def test_latest_release_returns_enforce_client_policy_with_release(self):
self.create_release()
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data["client_policy"],
{
"policy_version": 1,
"subscription_check_enabled": True,
"subscription_enforcement_enabled": True,
"updated_at": "2026-07-28T10:00:00+08:00",
},
)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="invalid",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T10:00:00+08:00",
)
def test_latest_release_rejects_invalid_client_policy_configuration(self):
with self.assertRaises(ImproperlyConfigured):
self.client.get(self.url)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="enforce",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="",
)
def test_latest_release_rejects_missing_enforced_policy_timestamp(self):
with self.assertRaises(ImproperlyConfigured):
self.client.get(self.url)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="observe",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28 10:00:00",
)
def test_latest_release_rejects_naive_policy_timestamp(self):
with self.assertRaises(ImproperlyConfigured):
self.client.get(self.url)
def test_latest_release_returns_unpublished_when_current_release_has_no_download_url(self):
self.create_release(external_url="", file_name="")
@@ -668,7 +755,16 @@ class ClientLatestReleaseApiTests(TestCase):
response = self.client.get(self.url, secure=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(set(response.data), {"platform", "release"})
self.assertEqual(set(response.data), {"platform", "release", "client_policy"})
self.assertEqual(
set(response.data["client_policy"]),
{
"policy_version",
"subscription_check_enabled",
"subscription_enforcement_enabled",
"updated_at",
},
)
self.assertEqual(
set(response.data["release"]),
{
@@ -686,7 +782,6 @@ class ClientLatestReleaseApiTests(TestCase):
"id",
"is_current",
"created_at",
"updated_at",
"MEDIA_ROOT",
str(settings.MEDIA_ROOT),
"user",
@@ -1466,6 +1561,7 @@ class GenerateApiTests(TestCase):
self.assertEqual(data["manage_url"], "https://cm.example.test/subscription")
self.assertEqual(data["access_source"], "open_mode")
self.assertEqual(data["entitlement_status"], "required")
self.assertFalse(data["real_entitlement_allowed"])
self.assertTrue(data["notice_id"])
self.assertFalse(SoftwareEntitlement.objects.filter(user=self.user).exists())
@@ -1482,6 +1578,7 @@ class GenerateApiTests(TestCase):
self.assertTrue(data["allowed"])
self.assertEqual(data["access_source"], "shadow_fallback")
self.assertEqual(data["entitlement_status"], "required")
self.assertFalse(data["real_entitlement_allowed"])
self.assertEqual(data["plan"]["code"], "shadow-fallback")
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="open")
@@ -1571,6 +1668,7 @@ class GenerateApiTests(TestCase):
)
self.assertEqual(data["access_source"], "entitlement")
self.assertEqual(data["entitlement_status"], "active")
self.assertTrue(data["real_entitlement_allowed"])
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="enforce")
def test_cmshopee_subscription_status_is_expired_after_grace_period(self):
@@ -1589,6 +1687,7 @@ class GenerateApiTests(TestCase):
self.assertFalse(data["allowed"])
self.assertEqual(data["code"], "subscription_expired")
self.assertEqual(data["entitlement_status"], "expired")
self.assertFalse(data["real_entitlement_allowed"])
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="enforce")
def test_cmshopee_enforcement_rejects_without_subscription(self):
+19 -7
View File
@@ -1,5 +1,6 @@
import logging
from config.client_subscription_policy import get_client_subscription_policy
from django.http import HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
@@ -651,11 +652,12 @@ class MigrationRequestDetailView(ExternalApiView):
)
def _release_unpublished_response(platform: str) -> dict:
def _release_unpublished_response(platform: str, client_policy: dict) -> dict:
return {
"platform": platform,
"release": None,
"message": "暂未发布",
"client_policy": client_policy,
}
@@ -673,15 +675,18 @@ class ClientLatestReleaseView(APIView):
permission_classes = (AllowAny,)
def get(self, request):
client_policy = get_client_subscription_policy()
platform = (
request.query_params.get("platform")
or DownloadRelease.Platform.WINDOWS
).strip().lower()
if platform not in DownloadRelease.Platform.values:
return Response(
response = Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
response["Cache-Control"] = "no-store"
return response
release = (
DownloadRelease.objects.filter(platform=platform, is_current=True)
@@ -689,19 +694,23 @@ class ClientLatestReleaseView(APIView):
.first()
)
if release is None:
return Response(
_release_unpublished_response(platform),
response = Response(
_release_unpublished_response(platform, client_policy),
status=status.HTTP_200_OK,
)
response["Cache-Control"] = "no-store"
return response
download_url = _absolute_download_url(request, release)
if not download_url:
return Response(
_release_unpublished_response(platform),
response = Response(
_release_unpublished_response(platform, client_policy),
status=status.HTTP_200_OK,
)
response["Cache-Control"] = "no-store"
return response
return Response(
response = Response(
{
"platform": platform,
"release": {
@@ -713,9 +722,12 @@ class ClientLatestReleaseView(APIView):
"size_bytes": release.size_bytes,
"published_at": timezone.localtime(release.updated_at).isoformat(),
},
"client_policy": client_policy,
},
status=status.HTTP_200_OK,
)
response["Cache-Control"] = "no-store"
return response
class PortalSessionApiView(APIView):