feat: add import template download link

This commit is contained in:
QiuSW
2026-07-08 16:29:48 +08:00
parent f1b34f8466
commit 5cdbd87ce2
13 changed files with 335 additions and 19 deletions
+60 -1
View File
@@ -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')],
},
),
]
+61
View File
@@ -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
)
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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