feat: add legacy device migration flow
This commit is contained in:
@@ -12,6 +12,8 @@ from .views import (
|
||||
GenerateImageView,
|
||||
GenerateTitleView,
|
||||
ModelsView,
|
||||
MigrationRequestCreateView,
|
||||
MigrationRequestDetailView,
|
||||
RechargeCreateView,
|
||||
RechargeStatusView,
|
||||
WechatRechargeCallbackView,
|
||||
@@ -30,6 +32,16 @@ urlpatterns = [
|
||||
DeviceHeartbeatView.as_view(),
|
||||
name="api-client-device-heartbeat",
|
||||
),
|
||||
path(
|
||||
"v1/client/migration-requests",
|
||||
MigrationRequestCreateView.as_view(),
|
||||
name="api-client-migration-request-create",
|
||||
),
|
||||
path(
|
||||
"v1/client/migration-requests/<uuid:request_id>",
|
||||
MigrationRequestDetailView.as_view(),
|
||||
name="api-client-migration-request-detail",
|
||||
),
|
||||
path(
|
||||
"v1/client/releases/latest",
|
||||
ClientLatestReleaseView.as_view(),
|
||||
|
||||
@@ -66,12 +66,15 @@ from apps.billing.services import (
|
||||
from apps.portal.models import DownloadRelease
|
||||
from apps.licensing.authentication import DeviceSessionAuthentication
|
||||
from apps.licensing.services import (
|
||||
LicensingError,
|
||||
DeviceRegistrationError,
|
||||
DeviceSessionValidationError,
|
||||
create_migration_request,
|
||||
record_device_heartbeat,
|
||||
register_device,
|
||||
resolve_optional_device_session,
|
||||
)
|
||||
from apps.licensing.models import MigrationRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -478,6 +481,88 @@ class DeviceHeartbeatView(DeviceSessionApiView):
|
||||
)
|
||||
|
||||
|
||||
def _migration_request_response(migration_request, *, credential_token=None, confirmation_url=""):
|
||||
data = {
|
||||
"request_id": str(migration_request.request_id),
|
||||
"status": migration_request.status,
|
||||
"product_code": migration_request.device.product_code,
|
||||
"expires_at": migration_request.expires_at.isoformat(),
|
||||
"confirmed_at": (
|
||||
migration_request.confirmed_at.isoformat()
|
||||
if migration_request.confirmed_at
|
||||
else None
|
||||
),
|
||||
"confirmation_url": confirmation_url,
|
||||
}
|
||||
if credential_token is not None:
|
||||
data["device_credential_token"] = credential_token
|
||||
return data
|
||||
|
||||
|
||||
class MigrationRequestCreateView(ExternalApiView):
|
||||
def post(self, request):
|
||||
try:
|
||||
device = self.optional_client_device(request)
|
||||
except ApiRequestError as exc:
|
||||
return Response(exc.as_response_data(), status=exc.http_status)
|
||||
if device is None:
|
||||
return Response(
|
||||
api_error("device_session_required", "必须提供当前设备会话"),
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
try:
|
||||
migration_request, credential_token = create_migration_request(
|
||||
user=request.user,
|
||||
device=device,
|
||||
)
|
||||
except LicensingError as exc:
|
||||
response_status = (
|
||||
status.HTTP_403_FORBIDDEN
|
||||
if exc.code in {"migration_not_eligible", "license_expired"}
|
||||
else status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response(api_error(exc.code, exc.message), status=response_status)
|
||||
|
||||
confirmation_url = request.build_absolute_uri(
|
||||
f"/migration/confirm/{migration_request.request_id}"
|
||||
)
|
||||
return Response(
|
||||
_migration_request_response(
|
||||
migration_request,
|
||||
credential_token=credential_token,
|
||||
confirmation_url=confirmation_url,
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class MigrationRequestDetailView(ExternalApiView):
|
||||
def get(self, request, request_id):
|
||||
try:
|
||||
device = self.optional_client_device(request)
|
||||
except ApiRequestError as exc:
|
||||
return Response(exc.as_response_data(), status=exc.http_status)
|
||||
if device is None:
|
||||
return Response(
|
||||
api_error("device_session_required", "必须提供当前设备会话"),
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
migration_request = (
|
||||
MigrationRequest.objects.select_related("device")
|
||||
.filter(request_id=request_id, user=request.user, device=device)
|
||||
.first()
|
||||
)
|
||||
if migration_request is None:
|
||||
return Response(
|
||||
api_error("migration_request_not_found", "迁移请求不存在"),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return Response(
|
||||
_migration_request_response(migration_request),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
def _release_unpublished_response(platform: str) -> dict:
|
||||
return {
|
||||
"platform": platform,
|
||||
|
||||
@@ -8,16 +8,20 @@ from django.urls import path, reverse
|
||||
|
||||
from .models import (
|
||||
ClientDevice,
|
||||
DeviceCredential,
|
||||
DeviceBindingAudit,
|
||||
DeviceSession,
|
||||
LicenseEvent,
|
||||
LicenseSeat,
|
||||
LegacyMigrationGrant,
|
||||
MigrationRequest,
|
||||
SoftwareEntitlement,
|
||||
SoftwarePlan,
|
||||
)
|
||||
from .services import (
|
||||
LicensingError,
|
||||
grant_software_entitlement,
|
||||
create_legacy_migration_grant,
|
||||
renew_software_entitlement,
|
||||
revoke_software_entitlement,
|
||||
)
|
||||
@@ -351,3 +355,70 @@ class LicenseEventAdmin(ReadOnlyLicenseAdmin):
|
||||
"reason",
|
||||
)
|
||||
list_select_related = ("entitlement", "seat", "device", "actor")
|
||||
|
||||
|
||||
class LegacyMigrationGrantForm(EntitlementGrantForm):
|
||||
reason = forms.CharField(label="迁移原因", widget=forms.Textarea(attrs={"rows": 4}))
|
||||
|
||||
|
||||
@admin.register(LegacyMigrationGrant)
|
||||
class LegacyMigrationGrantAdmin(ReadOnlyLicenseAdmin):
|
||||
list_display = ("user", "product_code", "entitlement", "status", "actor", "created_at")
|
||||
list_filter = ("product_code", "status", "created_at")
|
||||
search_fields = ("user__username", "user__email", "reason")
|
||||
list_select_related = ("user", "entitlement", "actor")
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
return [
|
||||
path(
|
||||
"grant/",
|
||||
self.admin_site.admin_view(self.grant_view),
|
||||
name="licensing_legacymigrationgrant_grant",
|
||||
),
|
||||
] + urls
|
||||
|
||||
def grant_view(self, request):
|
||||
form = LegacyMigrationGrantForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
try:
|
||||
grant = create_legacy_migration_grant(
|
||||
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(
|
||||
reverse("admin:licensing_legacymigrationgrant_change", args=(grant.pk,))
|
||||
)
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
"title": "授予存量迁移资格",
|
||||
"opts": self.model._meta,
|
||||
"form": form,
|
||||
}
|
||||
return TemplateResponse(
|
||||
request,
|
||||
"admin/licensing/entitlement_operation.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(MigrationRequest)
|
||||
class MigrationRequestAdmin(ReadOnlyLicenseAdmin):
|
||||
list_display = ("request_id", "user", "device", "migration_grant", "status", "expires_at", "confirmed_at")
|
||||
list_filter = ("status", "device__product_code", "expires_at")
|
||||
search_fields = ("=request_id", "user__username", "user__email")
|
||||
list_select_related = ("user", "device", "migration_grant")
|
||||
|
||||
|
||||
@admin.register(DeviceCredential)
|
||||
class DeviceCredentialAdmin(ReadOnlyLicenseAdmin):
|
||||
list_display = ("token_prefix", "user", "product_code", "device", "entitlement", "expires_at", "revoked_at")
|
||||
list_filter = ("product_code", "revoked_at", "expires_at")
|
||||
search_fields = ("token_prefix", "user__username", "user__email")
|
||||
list_select_related = ("user", "device", "entitlement", "seat")
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-21 01:57
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('licensing', '0002_softwareentitlement_licenseseat_licenseevent_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='licenseevent',
|
||||
name='action',
|
||||
field=models.CharField(choices=[('granted', '人工授予'), ('renewed', '续期'), ('revoked', '撤销'), ('seat_assigned', '绑定席位'), ('seat_released', '解绑席位'), ('migration_granted', '迁移资格授予'), ('credential_issued', '设备凭证签发'), ('credential_revoked', '设备凭证吊销')], max_length=32, verbose_name='动作'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LegacyMigrationGrant',
|
||||
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='产品代码')),
|
||||
('eligibility_snapshot', models.JSONField(blank=True, default=dict, verbose_name='资格快照')),
|
||||
('status', models.CharField(choices=[('active', '有效'), ('revoked', '已撤销')], default='active', max_length=20, verbose_name='状态')),
|
||||
('reason', models.CharField(max_length=255, 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='更新时间')),
|
||||
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='legacy_migration_grants_performed', to=settings.AUTH_USER_MODEL, verbose_name='操作人')),
|
||||
('entitlement', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='legacy_migration_grant', to='licensing.softwareentitlement', verbose_name='迁移权益')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='legacy_migration_grants', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '存量迁移资格',
|
||||
'verbose_name_plural': '存量迁移资格',
|
||||
'db_table': 'legacy_migration_grant',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MigrationRequest',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('request_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='公开迁移请求 ID')),
|
||||
('credential_token_hash', models.CharField(editable=False, max_length=64, unique=True, verbose_name='待签发凭证哈希')),
|
||||
('credential_token_prefix', models.CharField(editable=False, max_length=20, verbose_name='待签发凭证前缀')),
|
||||
('status', models.CharField(choices=[('pending', '待确认'), ('confirmed', '已确认'), ('expired', '已过期'), ('revoked', '已撤销')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('expires_at', models.DateTimeField(verbose_name='确认截止时间')),
|
||||
('confirmed_at', models.DateTimeField(blank=True, null=True, 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='更新时间')),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='migration_requests', to='licensing.clientdevice', verbose_name='当前设备')),
|
||||
('migration_grant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='requests', to='licensing.legacymigrationgrant', verbose_name='迁移资格')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='migration_requests', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '迁移确认请求',
|
||||
'verbose_name_plural': '迁移确认请求',
|
||||
'db_table': 'migration_request',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeviceCredential',
|
||||
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='产品代码')),
|
||||
('token_hash', models.CharField(editable=False, max_length=64, unique=True, verbose_name='设备凭证哈希')),
|
||||
('token_prefix', models.CharField(editable=False, max_length=20, verbose_name='设备凭证前缀')),
|
||||
('expires_at', models.DateTimeField(verbose_name='凭证到期时间')),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='吊销时间')),
|
||||
('revoke_reason', models.CharField(blank=True, max_length=255, verbose_name='吊销原因')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='签发时间')),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='credentials', to='licensing.clientdevice', verbose_name='设备')),
|
||||
('entitlement', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_credentials', to='licensing.softwareentitlement', verbose_name='软件权益')),
|
||||
('seat', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_credentials', to='licensing.licenseseat', verbose_name='授权席位')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_credentials', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
|
||||
('migration_request', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='credential', to='licensing.migrationrequest', verbose_name='来源迁移请求')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '设备凭证',
|
||||
'verbose_name_plural': '设备凭证',
|
||||
'db_table': 'device_credential',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='legacymigrationgrant',
|
||||
index=models.Index(fields=['product_code', 'status'], name='legacy_migr_product_1a9e68_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='legacymigrationgrant',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'product_code'), name='legacy_migration_grant_user_product_unique'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='migrationrequest',
|
||||
index=models.Index(fields=['user', 'device', 'status'], name='migration_r_user_id_95c575_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='migrationrequest',
|
||||
index=models.Index(fields=['status', 'expires_at'], name='migration_r_status_3ec1e9_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='devicecredential',
|
||||
index=models.Index(fields=['user', 'product_code', 'revoked_at'], name='device_cred_user_id_7bb723_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='devicecredential',
|
||||
index=models.Index(fields=['device', 'revoked_at'], name='device_cred_device__92d6db_idx'),
|
||||
),
|
||||
]
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -269,6 +270,9 @@ class LicenseEvent(models.Model):
|
||||
REVOKED = "revoked", "撤销"
|
||||
SEAT_ASSIGNED = "seat_assigned", "绑定席位"
|
||||
SEAT_RELEASED = "seat_released", "解绑席位"
|
||||
MIGRATION_GRANTED = "migration_granted", "迁移资格授予"
|
||||
CREDENTIAL_ISSUED = "credential_issued", "设备凭证签发"
|
||||
CREDENTIAL_REVOKED = "credential_revoked", "设备凭证吊销"
|
||||
|
||||
entitlement = models.ForeignKey(
|
||||
SoftwareEntitlement,
|
||||
@@ -319,6 +323,194 @@ class LicenseEvent(models.Model):
|
||||
return f"{self.entitlement} {self.action}"
|
||||
|
||||
|
||||
class LegacyMigrationGrant(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = "active", "有效"
|
||||
REVOKED = "revoked", "已撤销"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
verbose_name="用户",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="legacy_migration_grants",
|
||||
)
|
||||
product_code = models.CharField(
|
||||
"产品代码",
|
||||
max_length=32,
|
||||
choices=ClientDevice.ProductCode.choices,
|
||||
)
|
||||
entitlement = models.OneToOneField(
|
||||
SoftwareEntitlement,
|
||||
verbose_name="迁移权益",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="legacy_migration_grant",
|
||||
)
|
||||
eligibility_snapshot = models.JSONField("资格快照", default=dict, blank=True)
|
||||
status = models.CharField(
|
||||
"状态",
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.ACTIVE,
|
||||
)
|
||||
reason = models.CharField("迁移原因", max_length=255)
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
verbose_name="操作人",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="legacy_migration_grants_performed",
|
||||
)
|
||||
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 = "legacy_migration_grant"
|
||||
verbose_name = "存量迁移资格"
|
||||
verbose_name_plural = "存量迁移资格"
|
||||
ordering = ("-created_at", "-id")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "product_code"),
|
||||
name="legacy_migration_grant_user_product_unique",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=("product_code", "status")),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} {self.product_code} 迁移资格"
|
||||
|
||||
|
||||
class MigrationRequest(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "待确认"
|
||||
CONFIRMED = "confirmed", "已确认"
|
||||
EXPIRED = "expired", "已过期"
|
||||
REVOKED = "revoked", "已撤销"
|
||||
|
||||
request_id = models.UUIDField("公开迁移请求 ID", default=uuid.uuid4, unique=True, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
verbose_name="用户",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="migration_requests",
|
||||
)
|
||||
device = models.ForeignKey(
|
||||
ClientDevice,
|
||||
verbose_name="当前设备",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="migration_requests",
|
||||
)
|
||||
migration_grant = models.ForeignKey(
|
||||
LegacyMigrationGrant,
|
||||
verbose_name="迁移资格",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="requests",
|
||||
)
|
||||
credential_token_hash = models.CharField("待签发凭证哈希", max_length=64, unique=True, editable=False)
|
||||
credential_token_prefix = models.CharField("待签发凭证前缀", max_length=20, editable=False)
|
||||
status = models.CharField(
|
||||
"状态",
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING,
|
||||
)
|
||||
expires_at = models.DateTimeField("确认截止时间")
|
||||
confirmed_at = models.DateTimeField("确认时间", null=True, blank=True)
|
||||
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 = "migration_request"
|
||||
verbose_name = "迁移确认请求"
|
||||
verbose_name_plural = "迁移确认请求"
|
||||
ordering = ("-created_at", "-id")
|
||||
indexes = [
|
||||
models.Index(fields=("user", "device", "status")),
|
||||
models.Index(fields=("status", "expires_at")),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} {self.device} {self.status}"
|
||||
|
||||
@staticmethod
|
||||
def generate_plaintext_credential_token() -> str:
|
||||
return f"dvc_cmhub_{secrets.token_urlsafe(32)}"
|
||||
|
||||
@staticmethod
|
||||
def hash_credential_token(raw_token: str) -> str:
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
def is_pending_at(self, now=None) -> bool:
|
||||
now = now or timezone.now()
|
||||
return self.status == self.Status.PENDING and self.expires_at > now
|
||||
|
||||
|
||||
class DeviceCredential(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
verbose_name="用户",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="device_credentials",
|
||||
)
|
||||
product_code = models.CharField(
|
||||
"产品代码",
|
||||
max_length=32,
|
||||
choices=ClientDevice.ProductCode.choices,
|
||||
)
|
||||
device = models.ForeignKey(
|
||||
ClientDevice,
|
||||
verbose_name="设备",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="credentials",
|
||||
)
|
||||
entitlement = models.ForeignKey(
|
||||
SoftwareEntitlement,
|
||||
verbose_name="软件权益",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="device_credentials",
|
||||
)
|
||||
seat = models.ForeignKey(
|
||||
LicenseSeat,
|
||||
verbose_name="授权席位",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="device_credentials",
|
||||
)
|
||||
migration_request = models.OneToOneField(
|
||||
MigrationRequest,
|
||||
verbose_name="来源迁移请求",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="credential",
|
||||
)
|
||||
token_hash = models.CharField("设备凭证哈希", max_length=64, unique=True, editable=False)
|
||||
token_prefix = models.CharField("设备凭证前缀", max_length=20, editable=False)
|
||||
expires_at = models.DateTimeField("凭证到期时间")
|
||||
revoked_at = models.DateTimeField("吊销时间", null=True, blank=True)
|
||||
revoke_reason = models.CharField("吊销原因", max_length=255, blank=True)
|
||||
created_at = models.DateTimeField("签发时间", auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "device_credential"
|
||||
verbose_name = "设备凭证"
|
||||
verbose_name_plural = "设备凭证"
|
||||
ordering = ("-created_at", "-id")
|
||||
indexes = [
|
||||
models.Index(fields=("user", "product_code", "revoked_at")),
|
||||
models.Index(fields=("device", "revoked_at")),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} {self.product_code} credential"
|
||||
|
||||
def is_active_at(self, now=None) -> bool:
|
||||
now = now or timezone.now()
|
||||
return self.revoked_at is None and self.expires_at > now
|
||||
|
||||
|
||||
class DeviceSession(models.Model):
|
||||
TOKEN_PREFIX_LENGTH = 12
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@ from django.utils import timezone
|
||||
|
||||
from apps.licensing.models import (
|
||||
ClientDevice,
|
||||
DeviceCredential,
|
||||
DeviceBindingAudit,
|
||||
DeviceSession,
|
||||
LegacyMigrationGrant,
|
||||
LicenseEvent,
|
||||
LicenseSeat,
|
||||
MigrationRequest,
|
||||
SoftwareEntitlement,
|
||||
SoftwarePlan,
|
||||
)
|
||||
@@ -389,3 +392,190 @@ def release_license_seat(*, seat: LicenseSeat, reason: str, actor=None, now=None
|
||||
actor=actor,
|
||||
)
|
||||
return locked_seat
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_legacy_migration_grant(
|
||||
*,
|
||||
user,
|
||||
plan: SoftwarePlan,
|
||||
reason: str,
|
||||
actor=None,
|
||||
eligibility_snapshot: dict | None = None,
|
||||
):
|
||||
reason = _required_reason(reason)
|
||||
if LegacyMigrationGrant.objects.filter(
|
||||
user=user,
|
||||
product_code=plan.product_code,
|
||||
).exists():
|
||||
raise LicensingError("migration_grant_exists", "该用户已有此产品的迁移资格")
|
||||
|
||||
entitlement = grant_software_entitlement(
|
||||
user=user,
|
||||
plan=plan,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
)
|
||||
snapshot = {
|
||||
"source": "manual",
|
||||
"plan_id": plan.id,
|
||||
"plan_name": plan.name,
|
||||
"plan_device_limit": plan.device_limit,
|
||||
"plan_duration_days": plan.duration_days,
|
||||
}
|
||||
snapshot.update(eligibility_snapshot or {})
|
||||
grant = LegacyMigrationGrant.objects.create(
|
||||
user=user,
|
||||
product_code=plan.product_code,
|
||||
entitlement=entitlement,
|
||||
eligibility_snapshot=snapshot,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
)
|
||||
_create_license_event(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.MIGRATION_GRANTED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
metadata={"migration_grant_id": grant.id},
|
||||
)
|
||||
return grant
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_migration_request(*, user, device: ClientDevice, now=None):
|
||||
now = now or timezone.now()
|
||||
if device.user_id != user.id:
|
||||
raise LicensingError("device_mismatch", "设备不属于当前账号")
|
||||
if device.status != ClientDevice.Status.ACTIVE:
|
||||
raise LicensingError("device_revoked", "设备已被吊销")
|
||||
|
||||
grant = (
|
||||
LegacyMigrationGrant.objects.select_related("entitlement")
|
||||
.select_for_update()
|
||||
.filter(
|
||||
user=user,
|
||||
product_code=device.product_code,
|
||||
status=LegacyMigrationGrant.Status.ACTIVE,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if grant is None:
|
||||
raise LicensingError("migration_not_eligible", "当前账号没有可用的存量迁移资格")
|
||||
if not grant.entitlement.is_usable_at(now):
|
||||
raise LicensingError("license_expired", "迁移权益已过期或不可用")
|
||||
if DeviceCredential.objects.filter(
|
||||
device=device,
|
||||
entitlement=grant.entitlement,
|
||||
revoked_at__isnull=True,
|
||||
).exists():
|
||||
raise LicensingError("device_credential_exists", "当前设备已完成迁移绑定")
|
||||
|
||||
raw_token = MigrationRequest.generate_plaintext_credential_token()
|
||||
request = MigrationRequest.objects.create(
|
||||
user=user,
|
||||
device=device,
|
||||
migration_grant=grant,
|
||||
credential_token_hash=MigrationRequest.hash_credential_token(raw_token),
|
||||
credential_token_prefix=raw_token[:20],
|
||||
expires_at=now + timedelta(seconds=max(60, settings.MIGRATION_REQUEST_TTL_SECONDS)),
|
||||
)
|
||||
return request, raw_token
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def confirm_migration_request(*, request_id, user, now=None):
|
||||
now = now or timezone.now()
|
||||
request = (
|
||||
MigrationRequest.objects.select_for_update()
|
||||
.select_related("user", "device", "migration_grant", "migration_grant__entitlement")
|
||||
.filter(request_id=request_id)
|
||||
.first()
|
||||
)
|
||||
if request is None:
|
||||
raise LicensingError("migration_request_not_found", "迁移请求不存在")
|
||||
if request.user_id != user.id:
|
||||
raise LicensingError("migration_request_forbidden", "迁移请求不属于当前账号")
|
||||
existing_credential = DeviceCredential.objects.filter(migration_request=request).first()
|
||||
if existing_credential is not None:
|
||||
return request, existing_credential, False
|
||||
if not request.is_pending_at(now):
|
||||
raise LicensingError("migration_request_expired", "迁移请求已过期或不可用")
|
||||
grant = request.migration_grant
|
||||
if grant.status != LegacyMigrationGrant.Status.ACTIVE:
|
||||
raise LicensingError("migration_not_eligible", "迁移资格已撤销")
|
||||
if not grant.entitlement.is_usable_at(now):
|
||||
raise LicensingError("license_expired", "迁移权益已过期或不可用")
|
||||
|
||||
seat = assign_license_seat(
|
||||
entitlement=grant.entitlement,
|
||||
device=request.device,
|
||||
reason="存量迁移网页登录确认绑定",
|
||||
actor=user,
|
||||
now=now,
|
||||
)
|
||||
existing_device_credential = DeviceCredential.objects.filter(
|
||||
device=request.device,
|
||||
entitlement=grant.entitlement,
|
||||
revoked_at__isnull=True,
|
||||
).first()
|
||||
if existing_device_credential is not None:
|
||||
raise LicensingError("device_credential_exists", "当前设备已完成迁移绑定")
|
||||
|
||||
credential = DeviceCredential.objects.create(
|
||||
user=user,
|
||||
product_code=request.device.product_code,
|
||||
device=request.device,
|
||||
entitlement=grant.entitlement,
|
||||
seat=seat,
|
||||
migration_request=request,
|
||||
token_hash=request.credential_token_hash,
|
||||
token_prefix=request.credential_token_prefix,
|
||||
expires_at=grant.entitlement.grace_expires_at,
|
||||
)
|
||||
request.status = MigrationRequest.Status.CONFIRMED
|
||||
request.confirmed_at = now
|
||||
request.save(update_fields=("status", "confirmed_at", "updated_at"))
|
||||
_create_license_event(
|
||||
entitlement=grant.entitlement,
|
||||
seat=seat,
|
||||
device=request.device,
|
||||
action=LicenseEvent.Action.CREDENTIAL_ISSUED,
|
||||
reason="存量迁移网页登录确认签发设备凭证",
|
||||
actor=user,
|
||||
metadata={"migration_request_id": str(request.request_id)},
|
||||
)
|
||||
return request, credential, True
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def revoke_device_credential(*, credential: DeviceCredential, reason: str, actor=None, now=None):
|
||||
reason = _required_reason(reason)
|
||||
now = now or timezone.now()
|
||||
locked_credential = (
|
||||
DeviceCredential.objects.select_for_update()
|
||||
.select_related("seat", "entitlement", "device")
|
||||
.get(pk=credential.pk)
|
||||
)
|
||||
if locked_credential.revoked_at is not None:
|
||||
return locked_credential
|
||||
|
||||
locked_credential.revoked_at = now
|
||||
locked_credential.revoke_reason = reason
|
||||
locked_credential.save(update_fields=("revoked_at", "revoke_reason"))
|
||||
_create_license_event(
|
||||
entitlement=locked_credential.entitlement,
|
||||
seat=locked_credential.seat,
|
||||
device=locked_credential.device,
|
||||
action=LicenseEvent.Action.CREDENTIAL_REVOKED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
)
|
||||
if locked_credential.seat.device_id == locked_credential.device_id:
|
||||
release_license_seat(
|
||||
seat=locked_credential.seat,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
now=now,
|
||||
)
|
||||
return locked_credential
|
||||
|
||||
@@ -11,20 +11,27 @@ from rest_framework.test import APIClient
|
||||
from apps.licensing.models import (
|
||||
ClientDevice,
|
||||
DeviceBindingAudit,
|
||||
DeviceCredential,
|
||||
DeviceSession,
|
||||
LegacyMigrationGrant,
|
||||
LicenseEvent,
|
||||
LicenseSeat,
|
||||
MigrationRequest,
|
||||
SoftwareEntitlement,
|
||||
SoftwarePlan,
|
||||
)
|
||||
from apps.licensing.services import (
|
||||
LicensingError,
|
||||
assign_license_seat,
|
||||
confirm_migration_request,
|
||||
create_legacy_migration_grant,
|
||||
create_migration_request,
|
||||
grant_software_entitlement,
|
||||
record_device_heartbeat,
|
||||
release_license_seat,
|
||||
renew_software_entitlement,
|
||||
revoke_software_entitlement,
|
||||
register_device,
|
||||
)
|
||||
from apps.users.models import ApiKey, User, UserWallet
|
||||
|
||||
@@ -446,3 +453,175 @@ class LicenseSeatConcurrencyTests(TransactionTestCase):
|
||||
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})
|
||||
|
||||
|
||||
class LegacyMigrationFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="migration-user",
|
||||
email="migration@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.other_user = User.objects.create_user(
|
||||
username="migration-other",
|
||||
email="migration-other@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.plan = SoftwarePlan.objects.create(
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
name="存量迁移套餐",
|
||||
duration_days=30,
|
||||
price=Decimal("1.00"),
|
||||
device_limit=1,
|
||||
)
|
||||
self.api_key, self.raw_api_key = ApiKey.create_for_user(self.user, name="legacy")
|
||||
self.device, self.device_session_token = self.register_device_session()
|
||||
self.client = APIClient()
|
||||
|
||||
def register_device_session(self):
|
||||
result = register_device(
|
||||
user=self.user,
|
||||
api_key=self.api_key,
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
device_id_version="v1",
|
||||
device_id="migration-device-id",
|
||||
public_key="migration-device-public-key",
|
||||
platform=ClientDevice.Platform.WINDOWS,
|
||||
client_version="0.2.0",
|
||||
)
|
||||
return result.device, result.session_token
|
||||
|
||||
def grant_migration(self):
|
||||
return create_legacy_migration_grant(
|
||||
user=self.user,
|
||||
plan=self.plan,
|
||||
reason="历史付费用户迁移",
|
||||
eligibility_snapshot={"legacy_customer_id": "legacy-001"},
|
||||
)
|
||||
|
||||
def api_headers(self):
|
||||
return {
|
||||
"HTTP_AUTHORIZATION": f"Bearer {self.raw_api_key}",
|
||||
"HTTP_X_DEVICE_SESSION": self.device_session_token,
|
||||
}
|
||||
|
||||
def test_request_confirm_poll_and_revoke_flow_keeps_credential_hashed(self):
|
||||
self.grant_migration()
|
||||
create_response = self.client.post(
|
||||
reverse("api-client-migration-request-create"),
|
||||
{},
|
||||
format="json",
|
||||
**self.api_headers(),
|
||||
)
|
||||
self.assertEqual(create_response.status_code, 201)
|
||||
raw_credential = create_response.data["device_credential_token"]
|
||||
self.assertTrue(create_response.data["confirmation_url"].endswith(create_response.data["request_id"]))
|
||||
migration_request = MigrationRequest.objects.get(
|
||||
request_id=create_response.data["request_id"]
|
||||
)
|
||||
self.assertNotEqual(migration_request.credential_token_hash, raw_credential)
|
||||
|
||||
pending = self.client.get(
|
||||
reverse(
|
||||
"api-client-migration-request-detail",
|
||||
args=(migration_request.request_id,),
|
||||
),
|
||||
**self.api_headers(),
|
||||
)
|
||||
self.assertEqual(pending.status_code, 200)
|
||||
self.assertEqual(pending.data["status"], MigrationRequest.Status.PENDING)
|
||||
self.assertNotIn("device_credential_token", pending.data)
|
||||
|
||||
self.client.force_login(self.user)
|
||||
confirm_url = reverse("portal-migration-confirm", args=(migration_request.request_id,))
|
||||
self.assertEqual(self.client.get(confirm_url).status_code, 200)
|
||||
self.assertEqual(self.client.post(confirm_url).status_code, 302)
|
||||
self.assertEqual(self.client.post(confirm_url).status_code, 302)
|
||||
|
||||
credential = DeviceCredential.objects.get(migration_request=migration_request)
|
||||
self.assertNotEqual(credential.token_hash, raw_credential)
|
||||
self.assertEqual(credential.token_hash, MigrationRequest.hash_credential_token(raw_credential))
|
||||
self.assertEqual(credential.seat.device_id, self.device.id)
|
||||
self.assertEqual(DeviceCredential.objects.count(), 1)
|
||||
self.assertEqual(
|
||||
LicenseEvent.objects.filter(
|
||||
action=LicenseEvent.Action.CREDENTIAL_ISSUED,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
confirmed = self.client.get(
|
||||
reverse(
|
||||
"api-client-migration-request-detail",
|
||||
args=(migration_request.request_id,),
|
||||
),
|
||||
**self.api_headers(),
|
||||
)
|
||||
self.assertEqual(confirmed.status_code, 200)
|
||||
self.assertEqual(confirmed.data["status"], MigrationRequest.Status.CONFIRMED)
|
||||
|
||||
revoke = self.client.post(
|
||||
reverse("portal-device-credential-revoke", args=(credential.pk,))
|
||||
)
|
||||
self.assertEqual(revoke.status_code, 302)
|
||||
credential.refresh_from_db()
|
||||
credential.seat.refresh_from_db()
|
||||
self.assertIsNotNone(credential.revoked_at)
|
||||
self.assertIsNone(credential.seat.device_id)
|
||||
self.assertTrue(
|
||||
LicenseEvent.objects.filter(
|
||||
action=LicenseEvent.Action.CREDENTIAL_REVOKED,
|
||||
reason="用户自助解绑设备",
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_request_requires_eligible_current_device_and_web_confirmation_same_user(self):
|
||||
no_grant = self.client.post(
|
||||
reverse("api-client-migration-request-create"),
|
||||
{},
|
||||
format="json",
|
||||
**self.api_headers(),
|
||||
)
|
||||
self.assertEqual(no_grant.status_code, 403)
|
||||
self.assertEqual(no_grant.data["error"]["code"], "migration_not_eligible")
|
||||
|
||||
self.grant_migration()
|
||||
missing_device_session = self.client.post(
|
||||
reverse("api-client-migration-request-create"),
|
||||
{},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.raw_api_key}",
|
||||
)
|
||||
self.assertEqual(missing_device_session.status_code, 401)
|
||||
self.assertEqual(missing_device_session.data["error"]["code"], "device_session_required")
|
||||
|
||||
created = self.client.post(
|
||||
reverse("api-client-migration-request-create"),
|
||||
{},
|
||||
format="json",
|
||||
**self.api_headers(),
|
||||
)
|
||||
migration_request = MigrationRequest.objects.get(request_id=created.data["request_id"])
|
||||
self.client.force_login(self.other_user)
|
||||
confirmation = self.client.get(
|
||||
reverse("portal-migration-confirm", args=(migration_request.request_id,))
|
||||
)
|
||||
self.assertEqual(confirmation.status_code, 403)
|
||||
self.assertFalse(DeviceCredential.objects.exists())
|
||||
|
||||
def test_expired_request_and_duplicate_migration_grant_are_rejected(self):
|
||||
self.grant_migration()
|
||||
with self.assertRaisesRegex(LicensingError, "已有此产品的迁移资格"):
|
||||
self.grant_migration()
|
||||
migration_request, _raw_token = create_migration_request(
|
||||
user=self.user,
|
||||
device=self.device,
|
||||
now=timezone.now() - timedelta(minutes=20),
|
||||
)
|
||||
with self.assertRaisesRegex(LicensingError, "迁移请求已过期"):
|
||||
confirm_migration_request(
|
||||
request_id=migration_request.request_id,
|
||||
user=self.user,
|
||||
now=timezone.now(),
|
||||
)
|
||||
self.assertFalse(DeviceCredential.objects.exists())
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "portal/base.html" %}
|
||||
|
||||
{% block title %}设备授权 | 虾皮圈{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="container py-5">
|
||||
<h1 class="h3 mb-4">设备授权</h1>
|
||||
<div class="table-responsive"><table class="table align-middle">
|
||||
<thead><tr><th>产品</th><th>设备平台</th><th>客户端版本</th><th>到期时间</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>{% for credential in credentials %}<tr>
|
||||
<td>{{ credential.get_product_code_display }}</td><td>{{ credential.device.get_platform_display }}</td><td>{{ credential.device.client_version }}</td><td>{{ credential.expires_at }}</td>
|
||||
<td>{% if credential.revoked_at %}已吊销{% else %}有效{% endif %}</td>
|
||||
<td>{% if not credential.revoked_at %}<form method="post" action="{% url 'portal-device-credential-revoke' credential.pk %}">{% csrf_token %}<button class="btn btn-outline-danger btn-sm">解绑</button></form>{% endif %}</td>
|
||||
</tr>{% empty %}<tr><td colspan="6" class="text-muted">暂无已确认设备。</td></tr>{% endfor %}</tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "portal/base.html" %}
|
||||
|
||||
{% block title %}确认设备迁移 | 虾皮圈{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="container py-5" style="max-width: 720px;">
|
||||
<h1 class="h3 mb-3">确认设备迁移</h1>
|
||||
<p class="text-muted">确认后,当前设备会占用一个软件授权席位。</p>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">产品</dt><dd class="col-sm-8">{{ migration_request.device.get_product_code_display }}</dd>
|
||||
<dt class="col-sm-4">客户端版本</dt><dd class="col-sm-8">{{ migration_request.device.client_version }}</dd>
|
||||
<dt class="col-sm-4">确认截止</dt><dd class="col-sm-8">{{ migration_request.expires_at }}</dd>
|
||||
<dt class="col-sm-4">状态</dt><dd class="col-sm-8">{{ migration_request.get_status_display }}</dd>
|
||||
</dl>
|
||||
{% if migration_request.status == "pending" %}
|
||||
<form method="post">{% csrf_token %}<button type="submit" class="btn btn-primary">确认当前设备</button></form>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -5,8 +5,11 @@ from .views import (
|
||||
ApiKeyDeleteView,
|
||||
ApiKeyListCreateView,
|
||||
DashboardView,
|
||||
DeviceCredentialListView,
|
||||
DeviceCredentialRevokeView,
|
||||
HomeView,
|
||||
ModelCatalogView,
|
||||
MigrationConfirmView,
|
||||
RechargePageView,
|
||||
RechargeRecordListView,
|
||||
UsageRecordListView,
|
||||
@@ -22,6 +25,17 @@ urlpatterns = [
|
||||
path("apikeys/<int:pk>/delete", ApiKeyDeleteView.as_view(), name="portal-apikey-delete"),
|
||||
path("models", ModelCatalogView.as_view(), name="portal-models"),
|
||||
path("recharge", RechargePageView.as_view(), name="portal-recharge"),
|
||||
path(
|
||||
"migration/confirm/<uuid:request_id>",
|
||||
MigrationConfirmView.as_view(),
|
||||
name="portal-migration-confirm",
|
||||
),
|
||||
path("migration/devices", DeviceCredentialListView.as_view(), name="portal-device-credentials"),
|
||||
path(
|
||||
"migration/credentials/<int:pk>/revoke",
|
||||
DeviceCredentialRevokeView.as_view(),
|
||||
name="portal-device-credential-revoke",
|
||||
),
|
||||
path("records/recharge", RechargeRecordListView.as_view(), name="portal-recharge-records"),
|
||||
path("records/usage", UsageRecordListView.as_view(), name="portal-usage-records"),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Sum
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
@@ -17,6 +18,12 @@ from apps.billing.services import (
|
||||
get_balance_snapshot,
|
||||
)
|
||||
from apps.users.models import ApiKey
|
||||
from apps.licensing.models import DeviceCredential, MigrationRequest
|
||||
from apps.licensing.services import (
|
||||
LicensingError,
|
||||
confirm_migration_request,
|
||||
revoke_device_credential,
|
||||
)
|
||||
|
||||
from .forms import ApiKeyCreateForm, RechargeCreateForm
|
||||
from .models import DownloadRelease, ImportTemplate
|
||||
@@ -243,6 +250,69 @@ class RechargePageView(LoginRequiredMixin, FormView):
|
||||
return redirect(f"{recharge_url}?order_no={order.order_no}")
|
||||
|
||||
|
||||
class MigrationConfirmView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "portal/migration_confirm.html"
|
||||
|
||||
def get_migration_request(self):
|
||||
migration_request = get_object_or_404(
|
||||
MigrationRequest.objects.select_related("device", "migration_grant__entitlement"),
|
||||
request_id=self.kwargs["request_id"],
|
||||
)
|
||||
if migration_request.user_id != self.request.user.id:
|
||||
raise PermissionDenied("迁移请求不属于当前账号")
|
||||
return migration_request
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["migration_request"] = self.get_migration_request()
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
migration_request = self.get_migration_request()
|
||||
try:
|
||||
_request, _credential, created = confirm_migration_request(
|
||||
request_id=migration_request.request_id,
|
||||
user=request.user,
|
||||
)
|
||||
except LicensingError as exc:
|
||||
messages.error(request, exc.message)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
"设备迁移已确认。" if created else "该设备迁移已确认,无需重复操作。",
|
||||
)
|
||||
return redirect("portal-migration-confirm", request_id=migration_request.request_id)
|
||||
|
||||
|
||||
class DeviceCredentialListView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "portal/device_credentials.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["credentials"] = (
|
||||
DeviceCredential.objects.filter(user=self.request.user)
|
||||
.select_related("device", "entitlement", "seat")
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class DeviceCredentialRevokeView(LoginRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
credential = get_object_or_404(DeviceCredential, pk=pk, user=request.user)
|
||||
try:
|
||||
revoke_device_credential(
|
||||
credential=credential,
|
||||
reason="用户自助解绑设备",
|
||||
actor=request.user,
|
||||
)
|
||||
except LicensingError as exc:
|
||||
messages.error(request, exc.message)
|
||||
else:
|
||||
messages.success(request, "设备已解绑,原设备凭证已吊销。")
|
||||
return redirect("portal-device-credentials")
|
||||
|
||||
|
||||
class RechargeRecordListView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "portal/recharge_records.html"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user