feat: add public model catalog discovery
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from apps.ai.aliases import REQUIRED_CAPABILITIES
|
||||
from apps.ai.models import AiModel, ModelAlias
|
||||
from apps.ai.providers import AiProviderConfigError, resolve_api_type
|
||||
from apps.billing.models import PricingRule
|
||||
|
||||
|
||||
PRICING_STATUS_PRICED = "priced"
|
||||
PRICING_STATUS_UNPRICED = "unpriced"
|
||||
PUBLIC_DEFAULT_RESOLUTION = "default"
|
||||
|
||||
|
||||
def _has_required_capability(model_alias: ModelAlias) -> bool:
|
||||
required_capability = REQUIRED_CAPABILITIES.get(model_alias.operation_type)
|
||||
if required_capability is None:
|
||||
return False
|
||||
return required_capability in model_alias.ai_model.capabilities_set()
|
||||
|
||||
|
||||
def _requires_image_input(ai_model: AiModel, operation_type: str) -> bool:
|
||||
if operation_type != ModelAlias.OperationType.IMAGE:
|
||||
return False
|
||||
try:
|
||||
resolved_api_type = resolve_api_type(ai_model.api_type, ai_model.url)
|
||||
except AiProviderConfigError:
|
||||
return False
|
||||
return resolved_api_type == AiModel.ApiType.IMAGES_EDITS
|
||||
|
||||
|
||||
def _pricing_rule_to_public_price(rule: PricingRule) -> dict:
|
||||
return {
|
||||
"resolution": rule.resolution or PUBLIC_DEFAULT_RESOLUTION,
|
||||
"points_cost": rule.points_cost,
|
||||
}
|
||||
|
||||
|
||||
def _sort_public_prices(prices: list[dict]) -> list[dict]:
|
||||
return sorted(
|
||||
prices,
|
||||
key=lambda price: (
|
||||
price["resolution"] != PUBLIC_DEFAULT_RESOLUTION,
|
||||
price["resolution"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_public_model_catalog() -> list[dict]:
|
||||
aliases = [
|
||||
model_alias
|
||||
for model_alias in ModelAlias.objects.select_related("ai_model")
|
||||
.filter(is_active=True, ai_model__is_active=True)
|
||||
.order_by("operation_type", "alias", "id")
|
||||
if _has_required_capability(model_alias)
|
||||
]
|
||||
if not aliases:
|
||||
return []
|
||||
|
||||
alias_keys = {(item.operation_type, item.alias) for item in aliases}
|
||||
prices_by_alias = defaultdict(list)
|
||||
pricing_rules = PricingRule.objects.filter(
|
||||
is_active=True,
|
||||
operation_type__in={operation for operation, _alias in alias_keys},
|
||||
alias__in={alias for _operation, alias in alias_keys},
|
||||
).order_by("operation_type", "alias", "resolution", "id")
|
||||
|
||||
for rule in pricing_rules:
|
||||
key = (rule.operation_type, rule.alias)
|
||||
if key in alias_keys:
|
||||
prices_by_alias[key].append(_pricing_rule_to_public_price(rule))
|
||||
|
||||
catalog = []
|
||||
for model_alias in aliases:
|
||||
key = (model_alias.operation_type, model_alias.alias)
|
||||
prices = _sort_public_prices(prices_by_alias.get(key, []))
|
||||
catalog.append(
|
||||
{
|
||||
"alias": model_alias.alias,
|
||||
"operation_type": model_alias.operation_type,
|
||||
"capabilities": sorted(model_alias.ai_model.capabilities_set()),
|
||||
"requires_image": _requires_image_input(
|
||||
model_alias.ai_model,
|
||||
model_alias.operation_type,
|
||||
),
|
||||
"pricing_status": (
|
||||
PRICING_STATUS_PRICED if prices else PRICING_STATUS_UNPRICED
|
||||
),
|
||||
"prices": prices,
|
||||
}
|
||||
)
|
||||
return catalog
|
||||
+151
-1
@@ -18,7 +18,8 @@ from rest_framework.test import APIClient
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.api.authentication import ApiKeyAuthentication
|
||||
from apps.api.views import ExternalApiView
|
||||
from apps.api.throttles import GenerateRateThrottle
|
||||
from apps.api.views import ExternalApiView, ModelsView
|
||||
from apps.ai.models import AiModel, ModelAlias
|
||||
from apps.ai.providers import (
|
||||
AiCapabilityError,
|
||||
@@ -239,6 +240,155 @@ class BalanceApiTests(TestCase):
|
||||
self.assertEqual(response.data["error"]["code"], "unauthorized")
|
||||
|
||||
|
||||
class ModelsCatalogApiTests(TestCase):
|
||||
url = "/api/v1/models"
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username=f"models-user-{suffix}",
|
||||
email=f"models-user-{suffix}@example.com",
|
||||
password="password",
|
||||
)
|
||||
self.api_key, self.raw_key = ApiKey.create_for_user(self.user, name="models")
|
||||
self.client = APIClient()
|
||||
|
||||
def auth_header(self, raw_key: str | None = None) -> dict:
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {raw_key or self.raw_key}"}
|
||||
|
||||
def create_alias(
|
||||
self,
|
||||
*,
|
||||
alias: str,
|
||||
operation_type: str = ModelAlias.OperationType.TITLE,
|
||||
capabilities: list[str] | None = None,
|
||||
api_type: str = AiModel.ApiType.CHAT,
|
||||
url: str = "https://provider-secret.example/v1/chat/completions",
|
||||
model_sku: str = "secret-sku-gpt-5.5",
|
||||
model_active: bool = True,
|
||||
alias_active: bool = True,
|
||||
) -> ModelAlias:
|
||||
ai_model = AiModel.objects.create(
|
||||
name=f"{alias}-{uuid.uuid4().hex[:8]}",
|
||||
url=url,
|
||||
model=model_sku,
|
||||
api_type=api_type,
|
||||
api_key_encrypted="encrypted-provider-key",
|
||||
capabilities=capabilities if capabilities is not None else ["text"],
|
||||
extra_body={"internal": "provider-extra-secret"},
|
||||
is_active=model_active,
|
||||
)
|
||||
return ModelAlias.objects.create(
|
||||
alias=alias,
|
||||
operation_type=operation_type,
|
||||
ai_model=ai_model,
|
||||
is_active=alias_active,
|
||||
)
|
||||
|
||||
def test_models_returns_public_alias_catalog_without_internal_fields(self):
|
||||
title_alias = self.create_alias(alias="title-standard", capabilities=["text"])
|
||||
image_alias = self.create_alias(
|
||||
alias="image-edit",
|
||||
operation_type=ModelAlias.OperationType.IMAGE,
|
||||
capabilities=["image", "vision"],
|
||||
api_type=AiModel.ApiType.IMAGES_EDITS,
|
||||
url="https://provider-secret.example/v1/images/edits",
|
||||
model_sku="secret-sku-image-2",
|
||||
)
|
||||
PricingRule.objects.create(
|
||||
operation_type=title_alias.operation_type,
|
||||
alias=title_alias.alias,
|
||||
resolution="",
|
||||
points_cost=2,
|
||||
)
|
||||
PricingRule.objects.create(
|
||||
operation_type=image_alias.operation_type,
|
||||
alias=image_alias.alias,
|
||||
resolution="",
|
||||
points_cost=10,
|
||||
)
|
||||
PricingRule.objects.create(
|
||||
operation_type=image_alias.operation_type,
|
||||
alias=image_alias.alias,
|
||||
resolution="1k",
|
||||
points_cost=12,
|
||||
)
|
||||
|
||||
response = self.client.get(self.url, **self.auth_header())
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn(GenerateRateThrottle, ModelsView.throttle_classes)
|
||||
models = {item["alias"]: item for item in response.data["models"]}
|
||||
self.assertEqual(set(models), {"title-standard", "image-edit"})
|
||||
self.assertEqual(
|
||||
set(models["title-standard"]),
|
||||
{
|
||||
"alias",
|
||||
"operation_type",
|
||||
"capabilities",
|
||||
"requires_image",
|
||||
"pricing_status",
|
||||
"prices",
|
||||
},
|
||||
)
|
||||
self.assertEqual(models["title-standard"]["operation_type"], "title")
|
||||
self.assertEqual(models["title-standard"]["capabilities"], ["text"])
|
||||
self.assertFalse(models["title-standard"]["requires_image"])
|
||||
self.assertEqual(models["title-standard"]["pricing_status"], "priced")
|
||||
self.assertEqual(
|
||||
models["title-standard"]["prices"],
|
||||
[{"resolution": "default", "points_cost": 2}],
|
||||
)
|
||||
self.assertEqual(models["image-edit"]["capabilities"], ["image", "vision"])
|
||||
self.assertTrue(models["image-edit"]["requires_image"])
|
||||
self.assertEqual(
|
||||
models["image-edit"]["prices"],
|
||||
[
|
||||
{"resolution": "default", "points_cost": 10},
|
||||
{"resolution": "1K", "points_cost": 12},
|
||||
],
|
||||
)
|
||||
response_body = json.dumps(response.data, ensure_ascii=False)
|
||||
self.assertNotIn("secret-sku", response_body)
|
||||
self.assertNotIn("provider-secret.example", response_body)
|
||||
self.assertNotIn("encrypted-provider-key", response_body)
|
||||
self.assertNotIn("provider-extra-secret", response_body)
|
||||
self.assertNotIn("api_key", response_body)
|
||||
self.assertNotIn("api_key_encrypted", response_body)
|
||||
self.assertNotIn("extra_body", response_body)
|
||||
self.assertNotIn("url", response_body)
|
||||
self.assertNotIn("model_used", response_body)
|
||||
|
||||
def test_models_rejects_missing_invalid_and_session_only_authentication(self):
|
||||
missing = self.client.get(self.url)
|
||||
invalid = self.client.get(self.url, **self.auth_header("sk_cmhub_invalid"))
|
||||
self.client.force_login(self.user)
|
||||
session_only = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(missing.status_code, 401)
|
||||
self.assertEqual(missing.data["error"]["code"], "unauthorized")
|
||||
self.assertEqual(invalid.status_code, 401)
|
||||
self.assertEqual(invalid.data["error"]["code"], "unauthorized")
|
||||
self.assertEqual(session_only.status_code, 401)
|
||||
self.assertEqual(session_only.data["error"]["code"], "unauthorized")
|
||||
|
||||
def test_models_only_lists_callable_active_aliases_and_allows_unpriced_alias(self):
|
||||
self.create_alias(alias="title-unpriced", capabilities=["text"])
|
||||
self.create_alias(alias="title-inactive-alias", alias_active=False)
|
||||
self.create_alias(alias="title-inactive-model", model_active=False)
|
||||
self.create_alias(alias="title-wrong-capability", capabilities=["image"])
|
||||
|
||||
response = self.client.get(self.url, **self.auth_header())
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data["models"]), 1)
|
||||
item = response.data["models"][0]
|
||||
self.assertEqual(item["alias"], "title-unpriced")
|
||||
self.assertEqual(item["pricing_status"], "unpriced")
|
||||
self.assertEqual(item["prices"], [])
|
||||
|
||||
|
||||
@override_settings(
|
||||
PAYMENT_CALLBACK_MODE="mock",
|
||||
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
|
||||
|
||||
@@ -5,6 +5,7 @@ from .views import (
|
||||
BalanceView,
|
||||
GenerateImageView,
|
||||
GenerateTitleView,
|
||||
ModelsView,
|
||||
RechargeCreateView,
|
||||
RechargeStatusView,
|
||||
WechatRechargeCallbackView,
|
||||
@@ -12,6 +13,7 @@ from .views import (
|
||||
|
||||
urlpatterns = [
|
||||
path("v1/balance", BalanceView.as_view(), name="api-balance"),
|
||||
path("v1/models", ModelsView.as_view(), name="api-models"),
|
||||
path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"),
|
||||
path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"),
|
||||
path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"),
|
||||
|
||||
@@ -25,6 +25,7 @@ from apps.api.serializers import (
|
||||
RechargeStatusRequestSerializer,
|
||||
)
|
||||
from apps.api.throttles import GenerateRateThrottle, throttle_api_auth_failure
|
||||
from apps.ai.catalog import get_public_model_catalog
|
||||
from apps.billing.models import RechargeOrder
|
||||
from apps.billing.payment_gateways import (
|
||||
PaymentOrderCreateError,
|
||||
@@ -117,6 +118,14 @@ class BalanceView(ExternalApiView):
|
||||
)
|
||||
|
||||
|
||||
class ModelsView(ExternalApiView):
|
||||
def get(self, request):
|
||||
return Response(
|
||||
{"models": get_public_model_catalog()},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class PortalSessionApiView(APIView):
|
||||
authentication_classes = (SessionAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-dashboard' %}">控制台</a>
|
||||
<a class="btn btn-sm btn-primary" href="{% url 'portal-recharge' %}">充值</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-apikeys' %}">API Key</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-models' %}">可用模型</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-recharge-records' %}">充值记录</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-usage-records' %}">消费记录</a>
|
||||
<form method="post" action="{% url 'portal-logout' %}">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "portal/base.html" %}
|
||||
|
||||
{% block title %}可用模型 - cmhub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
|
||||
<h1 class="h3 mb-0">可用模型</h1>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-apikeys' %}">API Key</a>
|
||||
</div>
|
||||
|
||||
<div class="cmhub-surface">
|
||||
{% if models %}
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">别名</th>
|
||||
<th scope="col">类型</th>
|
||||
<th scope="col">能力</th>
|
||||
<th scope="col">原图</th>
|
||||
<th scope="col">价格</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in models %}
|
||||
<tr>
|
||||
<td><code>{{ item.alias }}</code></td>
|
||||
<td>
|
||||
{% if item.operation_type == "title" %}
|
||||
生成标题
|
||||
{% elif item.operation_type == "image" %}
|
||||
生成图片
|
||||
{% else %}
|
||||
{{ item.operation_type }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% for capability in item.capabilities %}
|
||||
<span class="badge text-bg-light border">{{ capability }}</span>
|
||||
{% empty %}
|
||||
<span class="text-muted">无</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.requires_image %}
|
||||
<span class="badge text-bg-warning">需要</span>
|
||||
{% else %}
|
||||
<span class="badge text-bg-light border">不需要</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.prices %}
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for price in item.prices %}
|
||||
<span class="badge text-bg-light border">
|
||||
{% if price.resolution == "default" %}默认{% else %}{{ price.resolution }}{% endif %}:{{ price.points_cost }} 点
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="badge text-bg-secondary">暂未定价</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">暂无可用模型</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
+87
-1
@@ -10,7 +10,14 @@ from django.test import Client, TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.billing.models import CallRecord, ExchangeRate, PointsLedger, RechargeOrder
|
||||
from apps.ai.models import AiModel, ModelAlias
|
||||
from apps.billing.models import (
|
||||
CallRecord,
|
||||
ExchangeRate,
|
||||
PointsLedger,
|
||||
PricingRule,
|
||||
RechargeOrder,
|
||||
)
|
||||
from apps.users.models import ApiKey, UserWallet
|
||||
|
||||
|
||||
@@ -77,6 +84,32 @@ class PortalAccountFlowTests(TestCase):
|
||||
status=status,
|
||||
)
|
||||
|
||||
def create_model_alias(
|
||||
self,
|
||||
*,
|
||||
alias: str,
|
||||
operation_type: str = ModelAlias.OperationType.TITLE,
|
||||
capabilities: list[str] | None = None,
|
||||
api_type: str = AiModel.ApiType.CHAT,
|
||||
url: str = "https://provider-secret.example/v1/chat/completions",
|
||||
model_sku: str = "secret-sku-gpt-5.5",
|
||||
) -> ModelAlias:
|
||||
ai_model = AiModel.objects.create(
|
||||
name=f"{alias}-{uuid.uuid4().hex[:8]}",
|
||||
url=url,
|
||||
model=model_sku,
|
||||
api_type=api_type,
|
||||
api_key_encrypted="encrypted-provider-key",
|
||||
capabilities=capabilities if capabilities is not None else ["text"],
|
||||
extra_body={"internal": "provider-extra-secret"},
|
||||
is_active=True,
|
||||
)
|
||||
return ModelAlias.objects.create(
|
||||
alias=alias,
|
||||
operation_type=operation_type,
|
||||
ai_model=ai_model,
|
||||
)
|
||||
|
||||
def test_signup_creates_unverified_user_wallet_with_zero_points_and_no_ledger(self):
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
email = f"signup-{suffix}@example.com"
|
||||
@@ -164,6 +197,59 @@ class PortalAccountFlowTests(TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response["Location"].startswith("/login?next="))
|
||||
|
||||
def test_models_page_requires_session_login(self):
|
||||
response = self.client.get("/models")
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response["Location"].startswith("/login?next="))
|
||||
|
||||
def test_models_page_lists_public_aliases_prices_and_unpriced_state(self):
|
||||
user = self.create_verified_user()
|
||||
title_alias = self.create_model_alias(alias="title-standard", capabilities=["text"])
|
||||
image_alias = self.create_model_alias(
|
||||
alias="image-edit",
|
||||
operation_type=ModelAlias.OperationType.IMAGE,
|
||||
capabilities=["image", "vision"],
|
||||
api_type=AiModel.ApiType.IMAGES_EDITS,
|
||||
url="https://provider-secret.example/v1/images/edits",
|
||||
model_sku="secret-sku-image-2",
|
||||
)
|
||||
PricingRule.objects.create(
|
||||
operation_type=title_alias.operation_type,
|
||||
alias=title_alias.alias,
|
||||
resolution="",
|
||||
points_cost=2,
|
||||
)
|
||||
PricingRule.objects.create(
|
||||
operation_type=image_alias.operation_type,
|
||||
alias=image_alias.alias,
|
||||
resolution="1k",
|
||||
points_cost=12,
|
||||
is_active=False,
|
||||
)
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.get("/models")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.context["models"]), 2)
|
||||
self.assertContains(response, "可用模型")
|
||||
self.assertContains(response, "title-standard")
|
||||
self.assertContains(response, "生成标题")
|
||||
self.assertContains(response, "默认")
|
||||
self.assertContains(response, "2 点")
|
||||
self.assertContains(response, "image-edit")
|
||||
self.assertContains(response, "生成图片")
|
||||
self.assertContains(response, "需要")
|
||||
self.assertContains(response, "暂未定价")
|
||||
self.assertNotContains(response, "secret-sku")
|
||||
self.assertNotContains(response, "provider-secret.example")
|
||||
self.assertNotContains(response, "encrypted-provider-key")
|
||||
self.assertNotContains(response, "provider-extra-secret")
|
||||
self.assertNotContains(response, "api_key")
|
||||
self.assertNotContains(response, "api_key_encrypted")
|
||||
self.assertNotContains(response, "extra_body")
|
||||
|
||||
def test_create_api_key_shows_plaintext_once_and_stores_only_hash(self):
|
||||
user = self.create_verified_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
@@ -6,6 +6,7 @@ from .views import (
|
||||
ApiKeyDeleteView,
|
||||
ApiKeyListCreateView,
|
||||
DashboardView,
|
||||
ModelCatalogView,
|
||||
RechargePageView,
|
||||
RechargeRecordListView,
|
||||
UsageRecordListView,
|
||||
@@ -19,6 +20,7 @@ urlpatterns = [
|
||||
path("dashboard", DashboardView.as_view(), name="portal-dashboard"),
|
||||
path("apikeys", ApiKeyListCreateView.as_view(), name="portal-apikeys"),
|
||||
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("records/recharge", RechargeRecordListView.as_view(), name="portal-recharge-records"),
|
||||
path("records/usage", UsageRecordListView.as_view(), name="portal-usage-records"),
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
from apps.billing.pricing import NoExchangeRateError
|
||||
from apps.billing.models import PointsLedger, RechargeOrder
|
||||
@@ -139,6 +140,15 @@ class ApiKeyDeleteView(LoginRequiredMixin, View):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user