feat: add software entitlement foundations
This commit is contained in:
+248
-2
@@ -1,6 +1,26 @@
|
||||
from django.contrib import admin
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import path, reverse
|
||||
|
||||
from .models import ClientDevice, DeviceBindingAudit, DeviceSession
|
||||
from .models import (
|
||||
ClientDevice,
|
||||
DeviceBindingAudit,
|
||||
DeviceSession,
|
||||
LicenseEvent,
|
||||
LicenseSeat,
|
||||
SoftwareEntitlement,
|
||||
SoftwarePlan,
|
||||
)
|
||||
from .services import (
|
||||
LicensingError,
|
||||
grant_software_entitlement,
|
||||
renew_software_entitlement,
|
||||
revoke_software_entitlement,
|
||||
)
|
||||
|
||||
|
||||
def _masked_fingerprint(value: str) -> str:
|
||||
@@ -105,3 +125,229 @@ class DeviceBindingAuditAdmin(admin.ModelAdmin):
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(SoftwarePlan)
|
||||
class SoftwarePlanAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"product_code",
|
||||
"duration_days",
|
||||
"price",
|
||||
"device_limit",
|
||||
"grace_days",
|
||||
"status",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("product_code", "status")
|
||||
search_fields = ("name",)
|
||||
list_editable = ("status",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
class EntitlementGrantForm(forms.Form):
|
||||
user = forms.ModelChoiceField(queryset=get_user_model().objects.all(), label="用户")
|
||||
plan = forms.ModelChoiceField(
|
||||
queryset=SoftwarePlan.objects.filter(status=SoftwarePlan.Status.ACTIVE),
|
||||
label="套餐",
|
||||
)
|
||||
reason = forms.CharField(label="授予原因", widget=forms.Textarea(attrs={"rows": 4}))
|
||||
|
||||
def clean_reason(self):
|
||||
reason = self.cleaned_data["reason"].strip()
|
||||
if not reason:
|
||||
raise forms.ValidationError("必须填写操作原因。")
|
||||
return reason
|
||||
|
||||
|
||||
class EntitlementReasonForm(forms.Form):
|
||||
reason = forms.CharField(label="操作原因", widget=forms.Textarea(attrs={"rows": 4}))
|
||||
|
||||
def clean_reason(self):
|
||||
reason = self.cleaned_data["reason"].strip()
|
||||
if not reason:
|
||||
raise forms.ValidationError("必须填写操作原因。")
|
||||
return reason
|
||||
|
||||
|
||||
@admin.register(SoftwareEntitlement)
|
||||
class SoftwareEntitlementAdmin(admin.ModelAdmin):
|
||||
change_form_template = "admin/licensing/softwareentitlement/change_form.html"
|
||||
list_display = (
|
||||
"user",
|
||||
"product_code",
|
||||
"plan_name",
|
||||
"plan_device_limit",
|
||||
"status",
|
||||
"starts_at",
|
||||
"expires_at",
|
||||
"grace_expires_at",
|
||||
)
|
||||
list_filter = ("product_code", "status", "expires_at")
|
||||
search_fields = ("user__username", "user__email", "plan_name")
|
||||
list_select_related = ("user", "source_plan")
|
||||
readonly_fields = (
|
||||
"user",
|
||||
"product_code",
|
||||
"source_plan",
|
||||
"plan_name",
|
||||
"plan_duration_days",
|
||||
"plan_price",
|
||||
"plan_device_limit",
|
||||
"plan_grace_days",
|
||||
"status",
|
||||
"starts_at",
|
||||
"expires_at",
|
||||
"grace_expires_at",
|
||||
"revoked_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
fields = readonly_fields
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path(
|
||||
"grant/",
|
||||
self.admin_site.admin_view(self.grant_view),
|
||||
name="licensing_softwareentitlement_grant",
|
||||
),
|
||||
path(
|
||||
"<path:object_id>/renew/",
|
||||
self.admin_site.admin_view(self.renew_view),
|
||||
name="licensing_softwareentitlement_renew",
|
||||
),
|
||||
path(
|
||||
"<path:object_id>/revoke/",
|
||||
self.admin_site.admin_view(self.revoke_view),
|
||||
name="licensing_softwareentitlement_revoke",
|
||||
),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def grant_view(self, request):
|
||||
form = EntitlementGrantForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
try:
|
||||
entitlement = grant_software_entitlement(
|
||||
user=form.cleaned_data["user"],
|
||||
plan=form.cleaned_data["plan"],
|
||||
reason=form.cleaned_data["reason"],
|
||||
actor=request.user,
|
||||
)
|
||||
except LicensingError as exc:
|
||||
form.add_error(None, exc.message)
|
||||
else:
|
||||
self.message_user(request, "软件权益已授予。", messages.SUCCESS)
|
||||
return HttpResponseRedirect(self._change_url(entitlement))
|
||||
return self._operation_response(
|
||||
request,
|
||||
title="手工授予软件权益",
|
||||
form=form,
|
||||
)
|
||||
|
||||
def renew_view(self, request, object_id):
|
||||
entitlement = get_object_or_404(SoftwareEntitlement, pk=object_id)
|
||||
return self._entitlement_operation_view(
|
||||
request,
|
||||
entitlement=entitlement,
|
||||
action="renew",
|
||||
title="续期软件权益",
|
||||
)
|
||||
|
||||
def revoke_view(self, request, object_id):
|
||||
entitlement = get_object_or_404(SoftwareEntitlement, pk=object_id)
|
||||
return self._entitlement_operation_view(
|
||||
request,
|
||||
entitlement=entitlement,
|
||||
action="revoke",
|
||||
title="撤销软件权益",
|
||||
)
|
||||
|
||||
def _entitlement_operation_view(self, request, *, entitlement, action, title):
|
||||
form = EntitlementReasonForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
try:
|
||||
operation = (
|
||||
renew_software_entitlement
|
||||
if action == "renew"
|
||||
else revoke_software_entitlement
|
||||
)
|
||||
operation(
|
||||
entitlement=entitlement,
|
||||
reason=form.cleaned_data["reason"],
|
||||
actor=request.user,
|
||||
)
|
||||
except LicensingError as exc:
|
||||
form.add_error(None, exc.message)
|
||||
else:
|
||||
self.message_user(request, f"软件权益已{'续期' if action == 'renew' else '撤销'}。", messages.SUCCESS)
|
||||
return HttpResponseRedirect(self._change_url(entitlement))
|
||||
return self._operation_response(
|
||||
request,
|
||||
title=title,
|
||||
form=form,
|
||||
entitlement=entitlement,
|
||||
)
|
||||
|
||||
def _operation_response(self, request, *, title, form, entitlement=None):
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
"title": title,
|
||||
"opts": self.model._meta,
|
||||
"form": form,
|
||||
"entitlement": entitlement,
|
||||
}
|
||||
return TemplateResponse(
|
||||
request,
|
||||
"admin/licensing/entitlement_operation.html",
|
||||
context,
|
||||
)
|
||||
|
||||
def _change_url(self, entitlement):
|
||||
return reverse("admin:licensing_softwareentitlement_change", args=(entitlement.pk,))
|
||||
|
||||
|
||||
class ReadOnlyLicenseAdmin(admin.ModelAdmin):
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return tuple(field.name for field in self.model._meta.fields)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(LicenseSeat)
|
||||
class LicenseSeatAdmin(ReadOnlyLicenseAdmin):
|
||||
list_display = ("entitlement", "seat_number", "device", "bound_at", "released_at")
|
||||
list_filter = ("entitlement__product_code", "bound_at", "released_at")
|
||||
search_fields = (
|
||||
"entitlement__user__username",
|
||||
"entitlement__user__email",
|
||||
"device__user__username",
|
||||
)
|
||||
list_select_related = ("entitlement", "entitlement__user", "device")
|
||||
|
||||
|
||||
@admin.register(LicenseEvent)
|
||||
class LicenseEventAdmin(ReadOnlyLicenseAdmin):
|
||||
list_display = ("entitlement", "action", "seat", "device", "actor", "reason", "created_at")
|
||||
list_filter = ("action", "entitlement__product_code", "created_at")
|
||||
search_fields = (
|
||||
"entitlement__user__username",
|
||||
"entitlement__user__email",
|
||||
"reason",
|
||||
)
|
||||
list_select_related = ("entitlement", "seat", "device", "actor")
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-21 01:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('licensing', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SoftwareEntitlement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('product_code', models.CharField(choices=[('cmshopee', '虾皮圈优化助手')], max_length=32, verbose_name='产品代码')),
|
||||
('plan_name', models.CharField(max_length=120, verbose_name='套餐名称快照')),
|
||||
('plan_duration_days', models.PositiveIntegerField(verbose_name='套餐有效天数快照')),
|
||||
('plan_price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='套餐价格快照')),
|
||||
('plan_device_limit', models.PositiveSmallIntegerField(verbose_name='设备数量快照')),
|
||||
('plan_grace_days', models.PositiveSmallIntegerField(default=0, verbose_name='宽限天数快照')),
|
||||
('status', models.CharField(choices=[('active', '有效'), ('revoked', '已撤销'), ('expired', '已过期')], default='active', max_length=20, verbose_name='状态')),
|
||||
('starts_at', models.DateTimeField(verbose_name='开始时间')),
|
||||
('expires_at', models.DateTimeField(verbose_name='到期时间')),
|
||||
('grace_expires_at', models.DateTimeField(verbose_name='宽限截止时间')),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='撤销时间')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='software_entitlements', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '软件权益',
|
||||
'verbose_name_plural': '软件权益',
|
||||
'db_table': 'software_entitlement',
|
||||
'ordering': ('-expires_at', '-id'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LicenseSeat',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('seat_number', models.PositiveSmallIntegerField(verbose_name='席位序号')),
|
||||
('bound_at', models.DateTimeField(blank=True, null=True, verbose_name='绑定时间')),
|
||||
('released_at', models.DateTimeField(blank=True, null=True, verbose_name='解绑时间')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='license_seats', to='licensing.clientdevice', verbose_name='绑定设备')),
|
||||
('entitlement', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='seats', to='licensing.softwareentitlement', verbose_name='软件权益')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '授权席位',
|
||||
'verbose_name_plural': '授权席位',
|
||||
'db_table': 'license_seat',
|
||||
'ordering': ('entitlement_id', 'seat_number'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LicenseEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action', models.CharField(choices=[('granted', '人工授予'), ('renewed', '续期'), ('revoked', '撤销'), ('seat_assigned', '绑定席位'), ('seat_released', '解绑席位')], max_length=32, verbose_name='动作')),
|
||||
('reason', models.CharField(max_length=255, verbose_name='原因')),
|
||||
('metadata', models.JSONField(blank=True, default=dict, verbose_name='附加信息')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='license_events_performed', to=settings.AUTH_USER_MODEL, verbose_name='操作人')),
|
||||
('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='license_events', to='licensing.clientdevice', verbose_name='关联设备')),
|
||||
('seat', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='licensing.licenseseat', verbose_name='授权席位')),
|
||||
('entitlement', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='licensing.softwareentitlement', verbose_name='软件权益')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '授权事件',
|
||||
'verbose_name_plural': '授权事件',
|
||||
'db_table': 'license_event',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SoftwarePlan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('product_code', models.CharField(choices=[('cmshopee', '虾皮圈优化助手')], max_length=32, verbose_name='产品代码')),
|
||||
('name', models.CharField(max_length=120, verbose_name='套餐名称')),
|
||||
('duration_days', models.PositiveIntegerField(verbose_name='有效天数')),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='套餐价格')),
|
||||
('device_limit', models.PositiveSmallIntegerField(verbose_name='设备数量')),
|
||||
('grace_days', models.PositiveSmallIntegerField(default=0, verbose_name='宽限天数')),
|
||||
('status', models.CharField(choices=[('active', '启用'), ('inactive', '停用')], default='active', max_length=20, verbose_name='状态')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '软件套餐',
|
||||
'verbose_name_plural': '软件套餐',
|
||||
'db_table': 'software_plan',
|
||||
'ordering': ('product_code', 'name', 'id'),
|
||||
'indexes': [models.Index(fields=['product_code', 'status'], name='software_pl_product_3580ff_idx')],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('duration_days__gt', 0)), name='software_plan_duration_days_positive'), models.CheckConstraint(condition=models.Q(('price__gt', 0)), name='software_plan_price_positive'), models.CheckConstraint(condition=models.Q(('device_limit__gt', 0)), name='software_plan_device_limit_positive')],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='softwareentitlement',
|
||||
name='source_plan',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='entitlements', to='licensing.softwareplan', verbose_name='来源套餐'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='licenseseat',
|
||||
index=models.Index(fields=['device', 'updated_at'], name='license_sea_device__85c03f_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='licenseseat',
|
||||
constraint=models.UniqueConstraint(fields=('entitlement', 'seat_number'), name='license_seat_entitlement_number_unique'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='licenseseat',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('seat_number__gt', 0)), name='license_seat_number_positive'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='licenseevent',
|
||||
index=models.Index(fields=['entitlement', 'created_at'], name='license_eve_entitle_a66e88_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='licenseevent',
|
||||
index=models.Index(fields=['device', 'created_at'], name='license_eve_device__e70841_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='softwareentitlement',
|
||||
index=models.Index(fields=['user', 'product_code', 'status'], name='software_en_user_id_cd6e26_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='softwareentitlement',
|
||||
index=models.Index(fields=['product_code', 'expires_at'], name='software_en_product_c6ffbf_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='softwareentitlement',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('plan_duration_days__gt', 0)), name='software_entitlement_duration_positive'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='softwareentitlement',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('plan_price__gt', 0)), name='software_entitlement_price_positive'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='softwareentitlement',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('plan_device_limit__gt', 0)), name='software_entitlement_device_limit_positive'),
|
||||
),
|
||||
]
|
||||
@@ -5,7 +5,9 @@ import hmac
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
@@ -79,6 +81,244 @@ class ClientDevice(models.Model):
|
||||
return hashlib.sha256(public_key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class SoftwarePlan(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = "active", "启用"
|
||||
INACTIVE = "inactive", "停用"
|
||||
|
||||
product_code = models.CharField(
|
||||
"产品代码",
|
||||
max_length=32,
|
||||
choices=ClientDevice.ProductCode.choices,
|
||||
)
|
||||
name = models.CharField("套餐名称", max_length=120)
|
||||
duration_days = models.PositiveIntegerField("有效天数")
|
||||
price = models.DecimalField("套餐价格", max_digits=12, decimal_places=2)
|
||||
device_limit = models.PositiveSmallIntegerField("设备数量")
|
||||
grace_days = models.PositiveSmallIntegerField("宽限天数", default=0)
|
||||
status = models.CharField(
|
||||
"状态",
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.ACTIVE,
|
||||
)
|
||||
created_at = models.DateTimeField("创建时间", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("更新时间", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "software_plan"
|
||||
verbose_name = "软件套餐"
|
||||
verbose_name_plural = "软件套餐"
|
||||
ordering = ("product_code", "name", "id")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(duration_days__gt=0),
|
||||
name="software_plan_duration_days_positive",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=Q(price__gt=0),
|
||||
name="software_plan_price_positive",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=Q(device_limit__gt=0),
|
||||
name="software_plan_device_limit_positive",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=("product_code", "status")),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.product_code} {self.name}"
|
||||
|
||||
|
||||
class SoftwareEntitlement(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = "active", "有效"
|
||||
REVOKED = "revoked", "已撤销"
|
||||
EXPIRED = "expired", "已过期"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
verbose_name="用户",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="software_entitlements",
|
||||
)
|
||||
product_code = models.CharField(
|
||||
"产品代码",
|
||||
max_length=32,
|
||||
choices=ClientDevice.ProductCode.choices,
|
||||
)
|
||||
source_plan = models.ForeignKey(
|
||||
SoftwarePlan,
|
||||
verbose_name="来源套餐",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="entitlements",
|
||||
)
|
||||
plan_name = models.CharField("套餐名称快照", max_length=120)
|
||||
plan_duration_days = models.PositiveIntegerField("套餐有效天数快照")
|
||||
plan_price = models.DecimalField("套餐价格快照", max_digits=12, decimal_places=2)
|
||||
plan_device_limit = models.PositiveSmallIntegerField("设备数量快照")
|
||||
plan_grace_days = models.PositiveSmallIntegerField("宽限天数快照", default=0)
|
||||
status = models.CharField(
|
||||
"状态",
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.ACTIVE,
|
||||
)
|
||||
starts_at = models.DateTimeField("开始时间")
|
||||
expires_at = models.DateTimeField("到期时间")
|
||||
grace_expires_at = models.DateTimeField("宽限截止时间")
|
||||
revoked_at = models.DateTimeField("撤销时间", null=True, blank=True)
|
||||
created_at = models.DateTimeField("创建时间", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("更新时间", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "software_entitlement"
|
||||
verbose_name = "软件权益"
|
||||
verbose_name_plural = "软件权益"
|
||||
ordering = ("-expires_at", "-id")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(plan_duration_days__gt=0),
|
||||
name="software_entitlement_duration_positive",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=Q(plan_price__gt=0),
|
||||
name="software_entitlement_price_positive",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=Q(plan_device_limit__gt=0),
|
||||
name="software_entitlement_device_limit_positive",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=("user", "product_code", "status")),
|
||||
models.Index(fields=("product_code", "expires_at")),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} {self.product_code} {self.plan_name}"
|
||||
|
||||
def clean(self) -> None:
|
||||
super().clean()
|
||||
if self.expires_at and self.starts_at and self.expires_at <= self.starts_at:
|
||||
raise ValidationError({"expires_at": "到期时间必须晚于开始时间。"})
|
||||
if (
|
||||
self.grace_expires_at
|
||||
and self.expires_at
|
||||
and self.grace_expires_at < self.expires_at
|
||||
):
|
||||
raise ValidationError({"grace_expires_at": "宽限截止时间不能早于到期时间。"})
|
||||
|
||||
def is_usable_at(self, now=None) -> bool:
|
||||
now = now or timezone.now()
|
||||
return self.status == self.Status.ACTIVE and self.grace_expires_at > now
|
||||
|
||||
|
||||
class LicenseSeat(models.Model):
|
||||
entitlement = models.ForeignKey(
|
||||
SoftwareEntitlement,
|
||||
verbose_name="软件权益",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="seats",
|
||||
)
|
||||
seat_number = models.PositiveSmallIntegerField("席位序号")
|
||||
device = models.ForeignKey(
|
||||
ClientDevice,
|
||||
verbose_name="绑定设备",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="license_seats",
|
||||
)
|
||||
bound_at = models.DateTimeField("绑定时间", null=True, blank=True)
|
||||
released_at = models.DateTimeField("解绑时间", null=True, blank=True)
|
||||
created_at = models.DateTimeField("创建时间", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("更新时间", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "license_seat"
|
||||
verbose_name = "授权席位"
|
||||
verbose_name_plural = "授权席位"
|
||||
ordering = ("entitlement_id", "seat_number")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("entitlement", "seat_number"),
|
||||
name="license_seat_entitlement_number_unique",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=Q(seat_number__gt=0),
|
||||
name="license_seat_number_positive",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=("device", "updated_at")),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.entitlement} #{self.seat_number}"
|
||||
|
||||
|
||||
class LicenseEvent(models.Model):
|
||||
class Action(models.TextChoices):
|
||||
GRANTED = "granted", "人工授予"
|
||||
RENEWED = "renewed", "续期"
|
||||
REVOKED = "revoked", "撤销"
|
||||
SEAT_ASSIGNED = "seat_assigned", "绑定席位"
|
||||
SEAT_RELEASED = "seat_released", "解绑席位"
|
||||
|
||||
entitlement = models.ForeignKey(
|
||||
SoftwareEntitlement,
|
||||
verbose_name="软件权益",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="events",
|
||||
)
|
||||
seat = models.ForeignKey(
|
||||
LicenseSeat,
|
||||
verbose_name="授权席位",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="events",
|
||||
)
|
||||
device = models.ForeignKey(
|
||||
ClientDevice,
|
||||
verbose_name="关联设备",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="license_events",
|
||||
)
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
verbose_name="操作人",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="license_events_performed",
|
||||
)
|
||||
action = models.CharField("动作", max_length=32, choices=Action.choices)
|
||||
reason = models.CharField("原因", max_length=255)
|
||||
metadata = models.JSONField("附加信息", default=dict, blank=True)
|
||||
created_at = models.DateTimeField("创建时间", auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "license_event"
|
||||
verbose_name = "授权事件"
|
||||
verbose_name_plural = "授权事件"
|
||||
ordering = ("-created_at", "-id")
|
||||
indexes = [
|
||||
models.Index(fields=("entitlement", "created_at")),
|
||||
models.Index(fields=("device", "created_at")),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.entitlement} {self.action}"
|
||||
|
||||
|
||||
class DeviceSession(models.Model):
|
||||
TOKEN_PREFIX_LENGTH = 12
|
||||
|
||||
|
||||
+212
-1
@@ -7,7 +7,15 @@ from django.conf import settings
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.licensing.models import ClientDevice, DeviceBindingAudit, DeviceSession
|
||||
from apps.licensing.models import (
|
||||
ClientDevice,
|
||||
DeviceBindingAudit,
|
||||
DeviceSession,
|
||||
LicenseEvent,
|
||||
LicenseSeat,
|
||||
SoftwareEntitlement,
|
||||
SoftwarePlan,
|
||||
)
|
||||
|
||||
|
||||
class DeviceRegistrationError(Exception):
|
||||
@@ -21,6 +29,13 @@ class DeviceSessionValidationError(DeviceRegistrationError):
|
||||
pass
|
||||
|
||||
|
||||
class LicensingError(Exception):
|
||||
def __init__(self, code: str, message: str):
|
||||
self.code = code
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceRegistrationResult:
|
||||
device: ClientDevice
|
||||
@@ -178,3 +193,199 @@ def resolve_optional_device_session(*, user, raw_token: str) -> DeviceSession |
|
||||
if session.device.user_id != user.id:
|
||||
raise DeviceSessionValidationError("device_mismatch", "设备会话不属于当前账号")
|
||||
return session
|
||||
|
||||
|
||||
def _required_reason(reason: str) -> str:
|
||||
normalized_reason = str(reason or "").strip()
|
||||
if not normalized_reason:
|
||||
raise LicensingError("reason_required", "必须填写操作原因")
|
||||
return normalized_reason
|
||||
|
||||
|
||||
def _create_license_event(
|
||||
*,
|
||||
entitlement: SoftwareEntitlement,
|
||||
action: str,
|
||||
reason: str,
|
||||
actor=None,
|
||||
seat: LicenseSeat | None = None,
|
||||
device: ClientDevice | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> LicenseEvent:
|
||||
return LicenseEvent.objects.create(
|
||||
entitlement=entitlement,
|
||||
seat=seat,
|
||||
device=device,
|
||||
actor=actor,
|
||||
action=action,
|
||||
reason=_required_reason(reason),
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def grant_software_entitlement(*, user, plan: SoftwarePlan, reason: str, actor=None, starts_at=None):
|
||||
reason = _required_reason(reason)
|
||||
if plan.status != SoftwarePlan.Status.ACTIVE:
|
||||
raise LicensingError("plan_inactive", "套餐已停用,不能授予权益")
|
||||
|
||||
now = timezone.now()
|
||||
starts_at = starts_at or now
|
||||
expires_at = starts_at + timedelta(days=plan.duration_days)
|
||||
grace_expires_at = expires_at + timedelta(days=plan.grace_days)
|
||||
entitlement = SoftwareEntitlement.objects.create(
|
||||
user=user,
|
||||
product_code=plan.product_code,
|
||||
source_plan=plan,
|
||||
plan_name=plan.name,
|
||||
plan_duration_days=plan.duration_days,
|
||||
plan_price=plan.price,
|
||||
plan_device_limit=plan.device_limit,
|
||||
plan_grace_days=plan.grace_days,
|
||||
starts_at=starts_at,
|
||||
expires_at=expires_at,
|
||||
grace_expires_at=grace_expires_at,
|
||||
)
|
||||
LicenseSeat.objects.bulk_create(
|
||||
[
|
||||
LicenseSeat(entitlement=entitlement, seat_number=seat_number)
|
||||
for seat_number in range(1, plan.device_limit + 1)
|
||||
]
|
||||
)
|
||||
_create_license_event(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.GRANTED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
metadata={
|
||||
"source_plan_id": plan.id,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"device_limit": plan.device_limit,
|
||||
},
|
||||
)
|
||||
return entitlement
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def renew_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=None):
|
||||
reason = _required_reason(reason)
|
||||
now = now or timezone.now()
|
||||
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
|
||||
if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED:
|
||||
raise LicensingError("entitlement_revoked", "已撤销权益不能续期")
|
||||
|
||||
extension_start = max(now, locked_entitlement.expires_at)
|
||||
locked_entitlement.expires_at = extension_start + timedelta(
|
||||
days=locked_entitlement.plan_duration_days
|
||||
)
|
||||
locked_entitlement.grace_expires_at = locked_entitlement.expires_at + timedelta(
|
||||
days=locked_entitlement.plan_grace_days
|
||||
)
|
||||
locked_entitlement.status = SoftwareEntitlement.Status.ACTIVE
|
||||
locked_entitlement.revoked_at = None
|
||||
locked_entitlement.save(
|
||||
update_fields=(
|
||||
"expires_at",
|
||||
"grace_expires_at",
|
||||
"status",
|
||||
"revoked_at",
|
||||
"updated_at",
|
||||
)
|
||||
)
|
||||
_create_license_event(
|
||||
entitlement=locked_entitlement,
|
||||
action=LicenseEvent.Action.RENEWED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
metadata={
|
||||
"extension_start": extension_start.isoformat(),
|
||||
"expires_at": locked_entitlement.expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
return locked_entitlement
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def revoke_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=None):
|
||||
reason = _required_reason(reason)
|
||||
now = now or timezone.now()
|
||||
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
|
||||
if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED:
|
||||
return locked_entitlement
|
||||
|
||||
locked_entitlement.status = SoftwareEntitlement.Status.REVOKED
|
||||
locked_entitlement.revoked_at = now
|
||||
locked_entitlement.save(update_fields=("status", "revoked_at", "updated_at"))
|
||||
_create_license_event(
|
||||
entitlement=locked_entitlement,
|
||||
action=LicenseEvent.Action.REVOKED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
)
|
||||
return locked_entitlement
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def assign_license_seat(*, entitlement: SoftwareEntitlement, device: ClientDevice, reason: str, actor=None, now=None):
|
||||
reason = _required_reason(reason)
|
||||
now = now or timezone.now()
|
||||
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
|
||||
if not locked_entitlement.is_usable_at(now):
|
||||
raise LicensingError("entitlement_unavailable", "软件权益当前不可用")
|
||||
if device.user_id != locked_entitlement.user_id:
|
||||
raise LicensingError("device_user_mismatch", "设备不属于权益用户")
|
||||
if device.product_code != locked_entitlement.product_code:
|
||||
raise LicensingError("device_product_mismatch", "设备产品与权益不匹配")
|
||||
if device.status != ClientDevice.Status.ACTIVE:
|
||||
raise LicensingError("device_revoked", "设备已被吊销")
|
||||
|
||||
seats = list(
|
||||
LicenseSeat.objects.select_for_update()
|
||||
.filter(entitlement=locked_entitlement)
|
||||
.order_by("seat_number")
|
||||
)
|
||||
existing_seat = next((seat for seat in seats if seat.device_id == device.id), None)
|
||||
if existing_seat is not None:
|
||||
return existing_seat
|
||||
available_seat = next((seat for seat in seats if seat.device_id is None), None)
|
||||
if available_seat is None:
|
||||
raise LicensingError("seat_limit_reached", "设备席位已用完")
|
||||
|
||||
available_seat.device = device
|
||||
available_seat.bound_at = now
|
||||
available_seat.released_at = None
|
||||
available_seat.save(update_fields=("device", "bound_at", "released_at", "updated_at"))
|
||||
_create_license_event(
|
||||
entitlement=locked_entitlement,
|
||||
seat=available_seat,
|
||||
device=device,
|
||||
action=LicenseEvent.Action.SEAT_ASSIGNED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
)
|
||||
return available_seat
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def release_license_seat(*, seat: LicenseSeat, reason: str, actor=None, now=None):
|
||||
reason = _required_reason(reason)
|
||||
now = now or timezone.now()
|
||||
locked_seat = LicenseSeat.objects.select_for_update().select_related("entitlement", "device").get(
|
||||
pk=seat.pk
|
||||
)
|
||||
if locked_seat.device_id is None:
|
||||
return locked_seat
|
||||
|
||||
previous_device = locked_seat.device
|
||||
locked_seat.device = None
|
||||
locked_seat.released_at = now
|
||||
locked_seat.save(update_fields=("device", "released_at", "updated_at"))
|
||||
_create_license_event(
|
||||
entitlement=locked_seat.entitlement,
|
||||
seat=locked_seat,
|
||||
device=previous_device,
|
||||
action=LicenseEvent.Action.SEAT_RELEASED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
)
|
||||
return locked_seat
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if entitlement %}
|
||||
<p>{{ entitlement }}</p>
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<div class="submit-row">
|
||||
<input type="submit" value="确认" class="default">
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
|
||||
{% block object-tools-items %}
|
||||
{{ block.super }}
|
||||
{% if original %}
|
||||
<li><a href="renew/">续期</a></li>
|
||||
<li><a href="revoke/">撤销</a></li>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
+289
-3
@@ -1,12 +1,31 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.db import close_old_connections
|
||||
from django.test import TestCase, TransactionTestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.licensing.models import ClientDevice, DeviceBindingAudit, DeviceSession
|
||||
from apps.licensing.services import record_device_heartbeat
|
||||
from apps.licensing.models import (
|
||||
ClientDevice,
|
||||
DeviceBindingAudit,
|
||||
DeviceSession,
|
||||
LicenseEvent,
|
||||
LicenseSeat,
|
||||
SoftwareEntitlement,
|
||||
SoftwarePlan,
|
||||
)
|
||||
from apps.licensing.services import (
|
||||
LicensingError,
|
||||
assign_license_seat,
|
||||
grant_software_entitlement,
|
||||
record_device_heartbeat,
|
||||
release_license_seat,
|
||||
renew_software_entitlement,
|
||||
revoke_software_entitlement,
|
||||
)
|
||||
from apps.users.models import ApiKey, User, UserWallet
|
||||
|
||||
|
||||
@@ -160,3 +179,270 @@ class DeviceRegistrationApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
device.refresh_from_db()
|
||||
self.assertGreater(device.last_seen_at, stale_time)
|
||||
|
||||
|
||||
class SoftwareEntitlementServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="entitlement-user",
|
||||
email="entitlement@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.operator = User.objects.create_user(
|
||||
username="entitlement-operator",
|
||||
email="operator@example.com",
|
||||
password="test-password",
|
||||
is_staff=True,
|
||||
)
|
||||
self.plan = SoftwarePlan.objects.create(
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
name="月度套餐",
|
||||
duration_days=30,
|
||||
price=Decimal("19.90"),
|
||||
device_limit=1,
|
||||
grace_days=3,
|
||||
)
|
||||
|
||||
def create_device(self, suffix):
|
||||
device_id = f"v1:entitlement-device-{suffix}"
|
||||
public_key = f"entitlement-public-key-{suffix}"
|
||||
return ClientDevice.objects.create(
|
||||
user=self.user,
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
device_id_version="v1",
|
||||
device_fingerprint=ClientDevice.fingerprint_device_id("v1", device_id),
|
||||
public_key_fingerprint=ClientDevice.fingerprint_public_key(public_key),
|
||||
platform=ClientDevice.Platform.WINDOWS,
|
||||
client_version="0.1.0",
|
||||
)
|
||||
|
||||
def grant_entitlement(self, **kwargs):
|
||||
return grant_software_entitlement(
|
||||
user=self.user,
|
||||
plan=self.plan,
|
||||
reason="客服人工授予",
|
||||
actor=self.operator,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_grant_snapshots_plan_creates_fixed_seats_and_audit_event(self):
|
||||
entitlement = self.grant_entitlement()
|
||||
self.plan.name = "已调整套餐"
|
||||
self.plan.price = Decimal("29.90")
|
||||
self.plan.device_limit = 3
|
||||
self.plan.save()
|
||||
entitlement.refresh_from_db()
|
||||
|
||||
self.assertEqual(entitlement.plan_name, "月度套餐")
|
||||
self.assertEqual(entitlement.plan_price, Decimal("19.90"))
|
||||
self.assertEqual(entitlement.plan_device_limit, 1)
|
||||
self.assertEqual(LicenseSeat.objects.filter(entitlement=entitlement).count(), 1)
|
||||
event = LicenseEvent.objects.get(entitlement=entitlement)
|
||||
self.assertEqual(event.action, LicenseEvent.Action.GRANTED)
|
||||
self.assertEqual(event.reason, "客服人工授予")
|
||||
self.assertEqual(event.actor, self.operator)
|
||||
|
||||
def test_renew_extends_from_later_of_now_or_existing_expiry(self):
|
||||
now = timezone.now()
|
||||
entitlement = self.grant_entitlement(starts_at=now - timedelta(days=10))
|
||||
previous_expiry = entitlement.expires_at
|
||||
|
||||
renewed = renew_software_entitlement(
|
||||
entitlement=entitlement,
|
||||
reason="用户续订",
|
||||
actor=self.operator,
|
||||
now=now,
|
||||
)
|
||||
|
||||
self.assertEqual(renewed.expires_at, previous_expiry + timedelta(days=30))
|
||||
self.assertEqual(renewed.grace_expires_at, renewed.expires_at + timedelta(days=3))
|
||||
self.assertEqual(renewed.status, SoftwareEntitlement.Status.ACTIVE)
|
||||
self.assertTrue(
|
||||
LicenseEvent.objects.filter(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.RENEWED,
|
||||
reason="用户续订",
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_assign_release_and_revoke_are_audited_and_never_exceed_seat_limit(self):
|
||||
entitlement = self.grant_entitlement()
|
||||
first_device = self.create_device("one")
|
||||
second_device = self.create_device("two")
|
||||
|
||||
seat = assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=first_device,
|
||||
reason="首次绑定",
|
||||
actor=self.operator,
|
||||
)
|
||||
repeated = assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=first_device,
|
||||
reason="重复绑定",
|
||||
actor=self.operator,
|
||||
)
|
||||
self.assertEqual(seat.pk, repeated.pk)
|
||||
with self.assertRaisesRegex(LicensingError, "设备席位已用完"):
|
||||
assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=second_device,
|
||||
reason="超额绑定",
|
||||
actor=self.operator,
|
||||
)
|
||||
|
||||
released = release_license_seat(
|
||||
seat=seat,
|
||||
reason="客服解绑",
|
||||
actor=self.operator,
|
||||
)
|
||||
assigned_again = assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=second_device,
|
||||
reason="重新绑定",
|
||||
actor=self.operator,
|
||||
)
|
||||
self.assertIsNone(released.device_id)
|
||||
self.assertEqual(assigned_again.device_id, second_device.id)
|
||||
self.assertEqual(
|
||||
LicenseEvent.objects.filter(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.SEAT_ASSIGNED,
|
||||
).count(),
|
||||
2,
|
||||
)
|
||||
self.assertTrue(
|
||||
LicenseEvent.objects.filter(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.SEAT_RELEASED,
|
||||
).exists()
|
||||
)
|
||||
|
||||
revoked = revoke_software_entitlement(
|
||||
entitlement=entitlement,
|
||||
reason="退款撤销",
|
||||
actor=self.operator,
|
||||
)
|
||||
self.assertEqual(revoked.status, SoftwareEntitlement.Status.REVOKED)
|
||||
with self.assertRaisesRegex(LicensingError, "软件权益当前不可用"):
|
||||
assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=first_device,
|
||||
reason="撤销后绑定",
|
||||
actor=self.operator,
|
||||
)
|
||||
|
||||
def test_manual_operations_require_reason(self):
|
||||
with self.assertRaisesRegex(LicensingError, "必须填写操作原因"):
|
||||
grant_software_entitlement(user=self.user, plan=self.plan, reason="")
|
||||
|
||||
|
||||
class SoftwareEntitlementAdminTests(TestCase):
|
||||
def setUp(self):
|
||||
self.operator = User.objects.create_user(
|
||||
username="licensing-admin",
|
||||
email="licensing-admin@example.com",
|
||||
password="test-password",
|
||||
is_staff=True,
|
||||
)
|
||||
self.user = User.objects.create_user(
|
||||
username="licensing-target",
|
||||
email="licensing-target@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.plan = SoftwarePlan.objects.create(
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
name="后台套餐",
|
||||
duration_days=30,
|
||||
price=Decimal("9.90"),
|
||||
device_limit=2,
|
||||
)
|
||||
self.grant_url = reverse("admin:licensing_softwareentitlement_grant")
|
||||
|
||||
def test_admin_grant_requires_staff_and_reason_then_writes_event(self):
|
||||
anonymous = self.client.get(self.grant_url)
|
||||
self.assertEqual(anonymous.status_code, 302)
|
||||
|
||||
self.client.force_login(self.operator)
|
||||
missing_reason = self.client.post(
|
||||
self.grant_url,
|
||||
{"user": self.user.pk, "plan": self.plan.pk, "reason": ""},
|
||||
)
|
||||
self.assertEqual(missing_reason.status_code, 200)
|
||||
self.assertFalse(SoftwareEntitlement.objects.exists())
|
||||
|
||||
response = self.client.post(
|
||||
self.grant_url,
|
||||
{"user": self.user.pk, "plan": self.plan.pk, "reason": "后台补偿"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
entitlement = SoftwareEntitlement.objects.get(user=self.user)
|
||||
self.assertTrue(
|
||||
LicenseEvent.objects.filter(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.GRANTED,
|
||||
reason="后台补偿",
|
||||
actor=self.operator,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class LicenseSeatConcurrencyTests(TransactionTestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="seat-concurrency-user",
|
||||
email="seat-concurrency@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.plan = SoftwarePlan.objects.create(
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
name="单席位套餐",
|
||||
duration_days=30,
|
||||
price=Decimal("9.90"),
|
||||
device_limit=1,
|
||||
)
|
||||
self.entitlement = grant_software_entitlement(
|
||||
user=self.user,
|
||||
plan=self.plan,
|
||||
reason="并发测试授予",
|
||||
)
|
||||
self.first_device = self.create_device("first")
|
||||
self.second_device = self.create_device("second")
|
||||
|
||||
def create_device(self, suffix):
|
||||
return ClientDevice.objects.create(
|
||||
user=self.user,
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
device_id_version="v1",
|
||||
device_fingerprint=ClientDevice.fingerprint_device_id("v1", f"concurrent-{suffix}"),
|
||||
public_key_fingerprint=ClientDevice.fingerprint_public_key(f"key-{suffix}"),
|
||||
platform=ClientDevice.Platform.WINDOWS,
|
||||
client_version="0.1.0",
|
||||
)
|
||||
|
||||
def test_concurrent_assignments_do_not_exceed_fixed_seat_limit(self):
|
||||
def assign(device_id):
|
||||
close_old_connections()
|
||||
try:
|
||||
entitlement = SoftwareEntitlement.objects.get(pk=self.entitlement.pk)
|
||||
device = ClientDevice.objects.get(pk=device_id)
|
||||
seat = assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=device,
|
||||
reason="并发绑定",
|
||||
)
|
||||
return ("assigned", seat.device_id)
|
||||
except LicensingError as exc:
|
||||
return (exc.code, None)
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
outcomes = list(
|
||||
executor.map(assign, (self.first_device.pk, self.second_device.pk))
|
||||
)
|
||||
|
||||
self.assertEqual(sum(outcome[0] == "assigned" for outcome in outcomes), 1)
|
||||
self.assertEqual(sum(outcome[0] == "seat_limit_reached" for outcome in outcomes), 1)
|
||||
seat = LicenseSeat.objects.get(entitlement=self.entitlement)
|
||||
self.assertIn(seat.device_id, {self.first_device.pk, self.second_device.pk})
|
||||
|
||||
Reference in New Issue
Block a user