Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions inventory/templates/inventory/sale_item_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -476,16 +476,19 @@ <h4 class="card-title mb-0">添加销售商品</h4>
</tr>
</thead>
<tbody id="sale-items">
{% for item in sale.saleitems.all %}
{% for item in items %}
<tr>
<td>{{ item.product.name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.actual_price }}</td>
<td>{{ item.subtotal }}</td>
<td>
<a href="{% url 'sale_item_delete' sale.id item.id %}" class="btn btn-sm btn-outline-danger" title="删除">
<i class="bi bi-trash"></i>
</a>
<form method="post" action="{% url 'sale_item_delete' sale.id item.id %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" title="删除">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
Expand Down
60 changes: 60 additions & 0 deletions inventory/templates/inventory/system/delete_backup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{% extends 'inventory/base.html' %}

{% block title %}删除备份 - {{ block.super }}{% endblock %}

{% block content %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 class="card-title mb-0">删除备份</h2>
<p class="text-muted">确认删除系统备份</p>
</div>
<a href="{% url 'backup_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> 返回备份列表
</a>
</div>
</div>
</div>
</div>
</div>

<div class="row">
<div class="col-md-8 col-lg-6 mx-auto">
<div class="card">
<div class="card-header bg-danger text-white">
<h5 class="card-title mb-0">
<i class="bi bi-exclamation-triangle-fill me-2"></i> 删除确认
</h5>
</div>
<div class="card-body">
<div class="alert alert-danger">
<strong>警告:此操作不可逆!</strong>
<p class="mb-0 mt-2">备份 <strong>{{ backup_name }}</strong> 将被永久删除,删除后无法用于恢复系统数据。</p>
</div>

<form method="post" action="{% url 'delete_backup' backup_name %}">
{% csrf_token %}
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" name="confirm" id="confirm_delete_backup" required>
<label class="form-check-label" for="confirm_delete_backup">
我确认要永久删除此备份
</label>
</div>

<div class="d-flex justify-content-between">
<a href="{% url 'backup_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i> 取消
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i> 确认删除
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
38 changes: 38 additions & 0 deletions inventory/tests/test_sale_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,44 @@ def test_delete_item_persists_recalculated_total(self):
sale.refresh_from_db()
self.assertEqual(sale.total_amount, Decimal('20.00')) # 删除后总额已落库

def test_delete_item_requires_post_and_preserves_inventory_on_get(self):
sale = self._make_sale(status='DRAFT')
item = sale.items.get()
self.inventory.refresh_from_db()
before = self.inventory.quantity

response = self.client.get(reverse('sale_item_delete', args=[sale.id, item.id]))

self.assertEqual(response.status_code, 405)
self.assertTrue(SaleItem.objects.filter(pk=item.pk).exists())
self.inventory.refresh_from_db()
self.assertEqual(self.inventory.quantity, before)

def test_sale_item_create_page_lists_existing_items(self):
sale = self._make_sale(status='DRAFT')

response = self.client.get(reverse('sale_item_create', args=[sale.id]))

self.assertEqual(response.status_code, 200)
self.assertContains(response, self.product.name)

def test_sale_detail_does_not_rewrite_persisted_amounts(self):
sale = self._make_sale(status='COMPLETED')
Sale.objects.filter(pk=sale.pk).update(
total_amount=Decimal('1.00'),
discount_amount=Decimal('0.00'),
final_amount=Decimal('1.00'),
balance_paid=Decimal('20.00'),
)

response = self.client.get(reverse('sale_detail', args=[sale.id]))

self.assertEqual(response.status_code, 200)
sale.refresh_from_db()
self.assertEqual(sale.total_amount, Decimal('1.00'))
self.assertEqual(sale.final_amount, Decimal('1.00'))
self.assertEqual(sale.balance_paid, Decimal('20.00'))

def test_sale_complete_page_renders_for_draft_sale(self):
sale = self._make_sale(status='DRAFT')

Expand Down
129 changes: 128 additions & 1 deletion inventory/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import json
import os
import tempfile
from unittest.mock import patch

from django.core import management
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import User, Permission, Group
from decimal import Decimal

Expand Down Expand Up @@ -315,9 +317,134 @@ def test_restore_backup_flushes_records_missing_from_snapshot(self):
with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir):
response = self.client.post(
reverse('restore_backup', args=[backup_name]),
{'confirm': 'on'},
{'confirm_restore': 'on'},
)

self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], reverse('system_settings'))
self.assertFalse(Product.objects.filter(pk=product.pk).exists())

def test_restore_backup_page_uses_view_context(self):
backup_name = 'snapshot'
backup_dir = os.path.join(self.backup_root, backup_name)
os.makedirs(backup_dir, exist_ok=True)
with open(os.path.join(backup_dir, 'backup_info.json'), 'w', encoding='utf-8') as backup_info:
json.dump(
{
'name': backup_name,
'created_at': '2026-05-30T11:00:00',
'created_by': self.user.username,
'includes_media': False,
},
backup_info,
)

with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir):
response = self.client.get(reverse('restore_backup', args=[backup_name]))

self.assertEqual(response.status_code, 200)
self.assertContains(response, backup_name)
self.assertContains(response, 'confirm_restore')

def test_restore_backup_media_stage_failure_preserves_current_data(self):
backup_name = 'snapshot'
backup_dir = os.path.join(self.backup_root, backup_name)
media_root = os.path.join(self.temp_parent.name, 'media')
backup_media_dir = os.path.join(backup_dir, 'media')
os.makedirs(backup_media_dir, exist_ok=True)
os.makedirs(media_root, exist_ok=True)
db_file = os.path.join(backup_dir, 'db.json')
current_media_file = os.path.join(media_root, 'current.txt')
with open(current_media_file, 'w', encoding='utf-8') as current_media:
current_media.write('keep current media')
with open(os.path.join(backup_media_dir, 'backup.txt'), 'w', encoding='utf-8') as backup_media:
backup_media.write('backup media')

with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir, MEDIA_ROOT=media_root):
management.call_command(
'dumpdata',
'--exclude',
'auth.permission',
'--exclude',
'contenttypes',
'--exclude',
'sessions.session',
'--indent',
'4',
'--output',
db_file,
verbosity=0,
)

with open(os.path.join(backup_dir, 'backup_info.json'), 'w', encoding='utf-8') as backup_info:
json.dump(
{
'name': backup_name,
'created_at': '2026-05-30T11:00:00',
'created_by': self.user.username,
'includes_media': True,
},
backup_info,
)

category = Category.objects.create(name='备份后分类')
product = Product.objects.create(
barcode='post-backup-media-product',
name='备份后媒体测试商品',
category=category,
price=Decimal('10.00'),
cost=Decimal('5.00'),
)

with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir, MEDIA_ROOT=media_root):
with patch('inventory.views.system.backup.shutil.copytree', side_effect=OSError('copy failed')):
response = self.client.post(
reverse('restore_backup', args=[backup_name]),
{'confirm_restore': 'on', 'restore_media': 'on'},
)

self.assertEqual(response.status_code, 200)
self.assertTrue(os.path.exists(current_media_file))
self.assertTrue(Product.objects.filter(pk=product.pk).exists())

def test_delete_backup_confirmation_page_and_post_delete(self):
backup_name = 'snapshot'
backup_dir = os.path.join(self.backup_root, backup_name)
os.makedirs(backup_dir, exist_ok=True)
with open(os.path.join(backup_dir, 'backup_info.json'), 'w', encoding='utf-8') as backup_info:
json.dump(
{
'name': backup_name,
'created_at': '2026-05-30T11:00:00',
'created_by': self.user.username,
'includes_media': False,
},
backup_info,
)

with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir):
get_response = self.client.get(reverse('delete_backup', args=[backup_name]))
post_response = self.client.post(reverse('delete_backup', args=[backup_name]), {'confirm': 'on'})

self.assertEqual(get_response.status_code, 200)
self.assertEqual(post_response.status_code, 302)
self.assertFalse(os.path.exists(backup_dir))

def test_download_log_file_records_nullable_content_type(self):
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs')
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, 'download-test.log')
with open(log_file, 'w', encoding='utf-8') as log:
log.write('download me')
self.addCleanup(lambda: os.path.exists(log_file) and os.remove(log_file))

response = self.client.get(reverse('download_log_file', args=['download-test.log']))

self.assertEqual(response.status_code, 200)
self.assertTrue(
LogEntry.objects.filter(
user=self.user,
object_id='download-test.log',
content_type__isnull=True,
).exists()
)
Loading