Files
cmhub/apps/portal/views.py
T

386 lines
14 KiB
Python

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
from django.urls import reverse, reverse_lazy
from django.views import View
from django.views.generic import FormView, TemplateView
from apps.ai.catalog import get_public_model_catalog
from apps.billing.payment_gateways import PaymentOrderCreateError, payment_callback_mode
from apps.billing.pricing import NoExchangeRateError
from apps.billing.models import PointsLedger, RechargeOrder
from apps.billing.services import (
RechargeOrderCreateError,
create_recharge_order,
get_balance_snapshot,
)
from apps.users.models import ApiKey
from apps.licensing.models import DeviceCredential, MigrationRequest, SoftwareEntitlement, SoftwareOrder
from apps.licensing.services import (
LicensingError,
SoftwareOrderError,
confirm_migration_request,
create_software_order,
revoke_device_credential,
)
from .forms import ApiKeyCreateForm, RechargeCreateForm, SoftwareOrderCreateForm
from .models import DownloadRelease, ImportTemplate
NEW_API_KEY_SESSION_KEY = "portal_new_api_key"
RECORDS_PAGE_SIZE = 50
def _sum_or_zero(queryset, field_name: str):
return queryset.aggregate(total=Sum(field_name))["total"] or 0
def get_portal_account_summary(user) -> dict:
paid_orders = RechargeOrder.objects.filter(
user=user,
status=RechargeOrder.Status.PAID,
)
consume_entries = PointsLedger.objects.filter(
user=user,
change_type=PointsLedger.ChangeType.CONSUME,
)
refund_entries = PointsLedger.objects.filter(
user=user,
change_type=PointsLedger.ChangeType.REFUND,
)
recharge_entries = PointsLedger.objects.filter(
user=user,
change_type=PointsLedger.ChangeType.RECHARGE,
)
signup_bonus_entries = PointsLedger.objects.filter(
user=user,
change_type=PointsLedger.ChangeType.SIGNUP_BONUS,
)
consumed_points = abs(int(_sum_or_zero(consume_entries, "points_delta")))
refunded_points = int(_sum_or_zero(refund_entries, "points_delta"))
recharge_points = int(_sum_or_zero(recharge_entries, "points_delta"))
signup_bonus_points = int(_sum_or_zero(signup_bonus_entries, "points_delta"))
return {
"recharge_total_amount": _sum_or_zero(paid_orders, "amount_money"),
"recharge_points_total": recharge_points,
"signup_bonus_points_total": signup_bonus_points,
"credited_points_total": recharge_points + signup_bonus_points,
"consumed_points_total": consumed_points,
"refunded_points_total": refunded_points,
"net_used_points": consumed_points - refunded_points,
"paid_recharge_count": paid_orders.count(),
}
def get_recharge_orders_for_user(user):
return RechargeOrder.objects.filter(user=user).order_by("-created_at", "-id")
def get_usage_ledger_entries_for_user(user):
return (
PointsLedger.objects.filter(
user=user,
change_type__in=(
PointsLedger.ChangeType.CONSUME,
PointsLedger.ChangeType.REFUND,
PointsLedger.ChangeType.SIGNUP_BONUS,
),
)
.select_related("ref_call", "ref_call__api_key")
.order_by("-created_at", "-id")
)
def paginate_records(request, queryset):
paginator = Paginator(queryset, RECORDS_PAGE_SIZE)
page_obj = paginator.get_page(request.GET.get("page"))
return {
"paginator": paginator,
"page_obj": page_obj,
"is_paginated": page_obj.has_other_pages(),
"records_total_count": paginator.count,
"records_page_size": RECORDS_PAGE_SIZE,
}
def build_public_download_url(request, url: str) -> str:
if not url:
return ""
if url.startswith("/"):
return request.build_absolute_uri(url)
return url
class HomeView(TemplateView):
template_name = "portal/home.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["current_release"] = (
DownloadRelease.objects.filter(
platform=DownloadRelease.Platform.WINDOWS,
is_current=True,
)
.order_by("-created_at", "-id")
.first()
)
current_import_template = (
ImportTemplate.objects.filter(is_current=True)
.order_by("-created_at", "-id")
.first()
)
context["current_import_template"] = current_import_template
context["current_import_template_download_url"] = (
build_public_download_url(
self.request,
current_import_template.download_url
if current_import_template is not None
else "",
)
)
return context
class DashboardView(LoginRequiredMixin, TemplateView):
template_name = "portal/dashboard.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["balance"] = get_balance_snapshot(self.request.user)
context.update(get_portal_account_summary(self.request.user))
context["recent_recharge_orders"] = get_recharge_orders_for_user(
self.request.user
)[:5]
context["recent_usage_entries"] = get_usage_ledger_entries_for_user(
self.request.user
)[:5]
return context
class ApiKeyListCreateView(LoginRequiredMixin, FormView):
template_name = "portal/apikeys.html"
form_class = ApiKeyCreateForm
success_url = reverse_lazy("portal-apikeys")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["api_keys"] = ApiKey.objects.filter(user=self.request.user).order_by(
"-created_at",
"-id",
)
context["new_api_key"] = self.request.session.pop(NEW_API_KEY_SESSION_KEY, None)
return context
def form_valid(self, form):
name = form.cleaned_data["name"].strip()
api_key, raw_key = ApiKey.create_for_user(self.request.user, name=name)
self.request.session[NEW_API_KEY_SESSION_KEY] = {
"name": api_key.name,
"key_prefix": api_key.key_prefix,
"raw_key": raw_key,
}
messages.success(self.request, "API Key 已生成")
return super().form_valid(form)
class ApiKeyDeleteView(LoginRequiredMixin, View):
def post(self, request, pk):
api_key = get_object_or_404(ApiKey, pk=pk, user=request.user)
if api_key.status != ApiKey.Status.REVOKED:
api_key.status = ApiKey.Status.REVOKED
api_key.save(update_fields=("status", "updated_at"))
messages.success(request, "API Key 已删除")
return redirect("portal-apikeys")
class ModelCatalogView(LoginRequiredMixin, TemplateView):
template_name = "portal/models.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["models"] = get_public_model_catalog()
return context
class RechargePageView(LoginRequiredMixin, FormView):
template_name = "portal/recharge.html"
form_class = RechargeCreateForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
order_no = str(self.request.GET.get("order_no") or "").strip()
current_order = None
if order_no:
current_order = RechargeOrder.objects.filter(
user=self.request.user,
order_no=order_no,
).first()
context["balance"] = get_balance_snapshot(self.request.user)
context.update(get_portal_account_summary(self.request.user))
current_payment_mode = payment_callback_mode()
context["current_order"] = current_order
context["payment_callback_mode"] = current_payment_mode
context["is_mock_payment_mode"] = current_payment_mode == "mock"
context["recent_recharge_orders"] = get_recharge_orders_for_user(
self.request.user
)[:5]
return context
def form_valid(self, form):
try:
order = create_recharge_order(
user=self.request.user,
amount=form.cleaned_data["amount"],
pay_method=form.cleaned_data["pay_method"],
)
except NoExchangeRateError:
form.add_error(None, "未配置当前币种汇率,暂时无法充值。")
return self.form_invalid(form)
except RechargeOrderCreateError:
form.add_error(None, "充值下单参数错误,请检查金额和支付方式。")
return self.form_invalid(form)
except PaymentOrderCreateError:
form.add_error(None, "支付下单失败,请稍后重试。")
return self.form_invalid(form)
messages.success(self.request, "充值订单已创建,请扫码支付。")
recharge_url = reverse("portal-recharge")
return redirect(f"{recharge_url}?order_no={order.order_no}")
class SubscriptionPageView(LoginRequiredMixin, FormView):
template_name = "portal/subscription.html"
form_class = SoftwareOrderCreateForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
order_no = str(self.request.GET.get("order_no") or "").strip()
context["current_order"] = SoftwareOrder.objects.filter(
user=self.request.user,
order_no=order_no,
).first() if order_no else None
context["entitlement"] = (
SoftwareEntitlement.objects.filter(
user=self.request.user,
product_code="cmshopee",
)
.order_by("-expires_at", "-id")
.first()
)
context["recent_orders"] = SoftwareOrder.objects.filter(
user=self.request.user,
product_code="cmshopee",
).order_by("-created_at", "-id")[:10]
context["is_mock_payment_mode"] = payment_callback_mode() == "mock"
return context
def form_valid(self, form):
try:
order = create_software_order(
user=self.request.user,
plan=form.cleaned_data["plan"],
pay_method=form.cleaned_data["pay_method"],
)
except SoftwareOrderError as exc:
form.add_error(None, exc.message)
return self.form_invalid(form)
except PaymentOrderCreateError:
form.add_error(None, "支付下单失败,请稍后重试。")
return self.form_invalid(form)
messages.success(self.request, "软件订阅订单已创建,请扫码支付。")
subscription_url = reverse("portal-subscription")
return redirect(f"{subscription_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"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
orders = get_recharge_orders_for_user(self.request.user)
pagination = paginate_records(self.request, orders)
context.update(get_portal_account_summary(self.request.user))
context.update(pagination)
context["recharge_orders"] = pagination["page_obj"].object_list
return context
class UsageRecordListView(LoginRequiredMixin, TemplateView):
template_name = "portal/usage_records.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
entries = get_usage_ledger_entries_for_user(self.request.user)
pagination = paginate_records(self.request, entries)
context.update(get_portal_account_summary(self.request.user))
context.update(pagination)
context["usage_entries"] = pagination["page_obj"].object_list
return context