feat: add import template download link
This commit is contained in:
+60
-1
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import DownloadRelease
|
||||
from .models import DownloadRelease, ImportTemplate
|
||||
|
||||
|
||||
@admin.register(DownloadRelease)
|
||||
@@ -64,3 +64,62 @@ class DownloadReleaseAdmin(admin.ModelAdmin):
|
||||
if not obj.sha256:
|
||||
return "-"
|
||||
return f"{obj.sha256[:12]}..."
|
||||
|
||||
|
||||
@admin.register(ImportTemplate)
|
||||
class ImportTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"is_current",
|
||||
"download_source",
|
||||
"sha256_short",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("is_current",)
|
||||
search_fields = ("name", "sha256", "external_url", "file", "notes")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
fieldsets = (
|
||||
(
|
||||
"模板信息",
|
||||
{
|
||||
"fields": (
|
||||
"name",
|
||||
"is_current",
|
||||
"notes",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"下载配置",
|
||||
{
|
||||
"fields": (
|
||||
"file",
|
||||
"external_url",
|
||||
"sha256",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"时间",
|
||||
{
|
||||
"fields": (
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="下载来源")
|
||||
def download_source(self, obj):
|
||||
if obj.external_url:
|
||||
return "外部链接"
|
||||
if obj.file:
|
||||
return "本地文件"
|
||||
return "未配置"
|
||||
|
||||
@admin.display(description="SHA256")
|
||||
def sha256_short(self, obj):
|
||||
if not obj.sha256:
|
||||
return "-"
|
||||
return f"{obj.sha256[:12]}..."
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-08 08:15
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portal', '0002_downloadrelease_force_update'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ImportTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=128, verbose_name='模板名称')),
|
||||
('file', models.FileField(blank=True, upload_to='import_templates/', verbose_name='模板文件')),
|
||||
('external_url', models.URLField(blank=True, verbose_name='外部下载地址')),
|
||||
('sha256', models.CharField(blank=True, max_length=64, validators=[django.core.validators.RegexValidator(message='SHA256 必须是 64 位十六进制字符。', regex='^[A-Fa-f0-9]{64}$')], verbose_name='SHA256')),
|
||||
('is_current', models.BooleanField(default=False, verbose_name='当前模板')),
|
||||
('notes', models.TextField(blank=True, 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': 'import_template',
|
||||
'ordering': ('-is_current', '-created_at', '-id'),
|
||||
'indexes': [models.Index(fields=['is_current'], name='it_current_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -70,3 +70,64 @@ class DownloadRelease(models.Model):
|
||||
platform=self.platform,
|
||||
is_current=True,
|
||||
).exclude(pk=self.pk).update(is_current=False)
|
||||
|
||||
|
||||
class ImportTemplate(models.Model):
|
||||
name = models.CharField("模板名称", max_length=128)
|
||||
file = models.FileField("模板文件", upload_to="import_templates/", blank=True)
|
||||
external_url = models.URLField("外部下载地址", blank=True)
|
||||
sha256 = models.CharField(
|
||||
"SHA256",
|
||||
max_length=64,
|
||||
blank=True,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r"^[A-Fa-f0-9]{64}$",
|
||||
message="SHA256 必须是 64 位十六进制字符。",
|
||||
)
|
||||
],
|
||||
)
|
||||
is_current = models.BooleanField("当前模板", default=False)
|
||||
notes = models.TextField("说明", blank=True)
|
||||
created_at = models.DateTimeField("创建时间", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("更新时间", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "import_template"
|
||||
verbose_name = "导入模板"
|
||||
verbose_name_plural = "导入模板"
|
||||
ordering = ("-is_current", "-created_at", "-id")
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=("is_current",),
|
||||
name="it_current_idx",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def download_url(self) -> str:
|
||||
if self.external_url:
|
||||
return self.external_url
|
||||
if self.file:
|
||||
return self.file.url
|
||||
return ""
|
||||
|
||||
@property
|
||||
def has_download(self) -> bool:
|
||||
return bool(self.download_url)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if not self.file and not self.external_url:
|
||||
raise ValidationError("模板文件和外部下载地址至少填写一个。")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
super().save(*args, **kwargs)
|
||||
if self.is_current:
|
||||
type(self).objects.filter(is_current=True).exclude(pk=self.pk).update(
|
||||
is_current=False
|
||||
)
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{% else %}
|
||||
<a class="btn btn-outline-secondary btn-lg disabled" aria-disabled="true" href="#download">客户端下载暂未发布</a>
|
||||
{% endif %}
|
||||
{% if current_import_template_download_url %}
|
||||
<a class="btn btn-outline-secondary btn-lg" href="{{ current_import_template_download_url }}">下载导入模板</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="home-proof" aria-label="服务要点">
|
||||
<span class="home-chip">注册送 100 点</span>
|
||||
@@ -140,6 +143,9 @@
|
||||
<p>当前版本已发布,可下载后填入 cmhub API Key 使用。</p>
|
||||
<div class="home-actions">
|
||||
<a class="btn btn-primary" href="{{ current_release.download_url }}">下载客户端</a>
|
||||
{% if current_import_template_download_url %}
|
||||
<a class="btn btn-outline-secondary" href="{{ current_import_template_download_url }}">下载导入模板</a>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<a class="btn btn-outline-secondary" href="{% url 'portal-apikeys' %}">管理 API Key</a>
|
||||
{% else %}
|
||||
@@ -170,6 +176,9 @@
|
||||
<p>后台尚未配置当前 Windows 版本。发布后这里会展示版本号、下载按钮、SHA256 和版本说明。</p>
|
||||
<div class="home-actions">
|
||||
<a class="btn btn-primary disabled" aria-disabled="true" href="#">暂未发布</a>
|
||||
{% if current_import_template_download_url %}
|
||||
<a class="btn btn-outline-secondary" href="{{ current_import_template_download_url }}">下载导入模板</a>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<a class="btn btn-outline-secondary" href="{% url 'portal-dashboard' %}">进入控制台</a>
|
||||
{% else %}
|
||||
|
||||
+108
-1
@@ -3,6 +3,7 @@ from decimal import Decimal
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import get_user, get_user_model
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core import mail
|
||||
@@ -20,7 +21,7 @@ from apps.billing.models import (
|
||||
RechargeOrder,
|
||||
SignupBonusGrant,
|
||||
)
|
||||
from apps.portal.models import DownloadRelease
|
||||
from apps.portal.models import DownloadRelease, ImportTemplate
|
||||
from apps.users.models import ApiKey, UserWallet
|
||||
|
||||
|
||||
@@ -132,6 +133,25 @@ class PortalAccountFlowTests(TestCase):
|
||||
release_notes=release_notes,
|
||||
)
|
||||
|
||||
def create_import_template(
|
||||
self,
|
||||
*,
|
||||
name: str = "导入模板",
|
||||
is_current: bool = True,
|
||||
external_url: str = "https://download.example.com/import-template.xlsx",
|
||||
file_name: str = "",
|
||||
sha256: str = "e" * 64,
|
||||
notes: str = "商品导入 Excel 模板",
|
||||
) -> ImportTemplate:
|
||||
return ImportTemplate.objects.create(
|
||||
name=name,
|
||||
is_current=is_current,
|
||||
external_url=external_url,
|
||||
file=file_name,
|
||||
sha256=sha256,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
def assert_nav_link_active(self, response, *, href: str, label: str):
|
||||
html = response.content.decode("utf-8")
|
||||
self.assertIn(
|
||||
@@ -259,6 +279,7 @@ class PortalAccountFlowTests(TestCase):
|
||||
self.assertContains(response, "登录")
|
||||
self.assertContains(response, "客户端暂未发布")
|
||||
self.assertContains(response, "暂未发布")
|
||||
self.assertNotContains(response, "下载导入模板")
|
||||
self.assertNotContains(response, "/login?next=")
|
||||
|
||||
def test_homepage_shows_current_download_release(self):
|
||||
@@ -279,6 +300,63 @@ class PortalAccountFlowTests(TestCase):
|
||||
self.assertContains(response, "修复下载入口并补充 SHA256")
|
||||
self.assertContains(response, "未知发布者")
|
||||
|
||||
def test_homepage_shows_current_import_template_download_link(self):
|
||||
self.create_download_release()
|
||||
import_template = self.create_import_template(
|
||||
name="蝦皮圈導入模板",
|
||||
external_url="https://download.example.com/shopee-import-template.xlsx",
|
||||
sha256="f" * 64,
|
||||
)
|
||||
|
||||
response = self.client.get("/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context["current_import_template"], import_template)
|
||||
self.assertEqual(
|
||||
response.context["current_import_template_download_url"],
|
||||
"https://download.example.com/shopee-import-template.xlsx",
|
||||
)
|
||||
self.assertContains(response, "下载客户端")
|
||||
self.assertContains(response, "下载导入模板")
|
||||
self.assertContains(
|
||||
response,
|
||||
"https://download.example.com/shopee-import-template.xlsx",
|
||||
)
|
||||
|
||||
def test_homepage_builds_absolute_import_template_file_url_without_local_path(self):
|
||||
self.create_import_template(
|
||||
external_url="",
|
||||
file_name="import_templates/cmhub-import-template.xlsx",
|
||||
)
|
||||
|
||||
response = self.client.get("/", secure=True)
|
||||
|
||||
expected_url = "https://testserver/media/import_templates/cmhub-import-template.xlsx"
|
||||
response_body = response.content.decode("utf-8")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.context["current_import_template_download_url"],
|
||||
expected_url,
|
||||
)
|
||||
self.assertContains(response, expected_url)
|
||||
self.assertNotIn(str(settings.MEDIA_ROOT), response_body)
|
||||
self.assertNotIn("MEDIA_ROOT", response_body)
|
||||
|
||||
def test_homepage_prefers_import_template_external_url_over_file(self):
|
||||
self.create_import_template(
|
||||
external_url="https://cdn.example.com/current-template.xlsx",
|
||||
file_name="import_templates/local-template.xlsx",
|
||||
)
|
||||
|
||||
response = self.client.get("/", secure=True)
|
||||
|
||||
self.assertEqual(
|
||||
response.context["current_import_template_download_url"],
|
||||
"https://cdn.example.com/current-template.xlsx",
|
||||
)
|
||||
self.assertContains(response, "https://cdn.example.com/current-template.xlsx")
|
||||
self.assertNotContains(response, "/media/import_templates/local-template.xlsx")
|
||||
|
||||
def test_homepage_shows_dashboard_entry_for_authenticated_user(self):
|
||||
user = self.create_verified_user()
|
||||
self.client.force_login(user)
|
||||
@@ -299,6 +377,35 @@ class PortalAccountFlowTests(TestCase):
|
||||
self.assertFalse(old_release.is_current)
|
||||
self.assertTrue(new_release.is_current)
|
||||
|
||||
def test_import_template_only_keeps_one_current(self):
|
||||
old_template = self.create_import_template(
|
||||
name="旧模板",
|
||||
sha256="1" * 64,
|
||||
)
|
||||
new_template = self.create_import_template(
|
||||
name="新模板",
|
||||
sha256="2" * 64,
|
||||
)
|
||||
|
||||
old_template.refresh_from_db()
|
||||
new_template.refresh_from_db()
|
||||
|
||||
self.assertFalse(old_template.is_current)
|
||||
self.assertTrue(new_template.is_current)
|
||||
|
||||
def test_import_template_admin_exposes_download_fields(self):
|
||||
registered_admin = admin.site._registry[ImportTemplate]
|
||||
|
||||
self.assertIn("is_current", registered_admin.list_display)
|
||||
self.assertIn("download_source", registered_admin.list_display)
|
||||
self.assertIn("is_current", registered_admin.list_filter)
|
||||
template_fields = registered_admin.fieldsets[0][1]["fields"]
|
||||
download_fields = registered_admin.fieldsets[1][1]["fields"]
|
||||
self.assertIn("name", template_fields)
|
||||
self.assertIn("file", download_fields)
|
||||
self.assertIn("external_url", download_fields)
|
||||
self.assertIn("sha256", download_fields)
|
||||
|
||||
def test_apikeys_requires_session_login(self):
|
||||
response = self.client.get("/apikeys")
|
||||
|
||||
|
||||
+23
-1
@@ -19,7 +19,7 @@ from apps.billing.services import (
|
||||
from apps.users.models import ApiKey
|
||||
|
||||
from .forms import ApiKeyCreateForm, RechargeCreateForm
|
||||
from .models import DownloadRelease
|
||||
from .models import DownloadRelease, ImportTemplate
|
||||
|
||||
|
||||
NEW_API_KEY_SESSION_KEY = "portal_new_api_key"
|
||||
@@ -98,6 +98,14 @@ def paginate_records(request, queryset):
|
||||
}
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -111,6 +119,20 @@ class HomeView(TemplateView):
|
||||
.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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user