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
4 changes: 2 additions & 2 deletions inventory/templates/inventory/sale_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -1346,9 +1346,9 @@ <h5 class="payment-title mb-3">{% if request.LANGUAGE_CODE == 'en' %}Payment Met
<i class="bi bi-wallet2"></i>
{% if request.LANGUAGE_CODE == 'en' %}Balance{% else %}余额{% endif %}
</button>
<button type="button" class="payment-btn" data-payment="credit">
<button type="button" class="payment-btn" data-payment="other">
<i class="bi bi-journal-text"></i>
{% if request.LANGUAGE_CODE == 'en' %}Credit{% else %}挂账{% endif %}
{% if request.LANGUAGE_CODE == 'en' %}Other{% else %}其他{% endif %}
</button>
</div>
</div>
Expand Down
9 changes: 6 additions & 3 deletions inventory/templates/inventory/sale_item_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -483,9 +483,12 @@ <h4 class="card-title mb-0">添加销售商品</h4>
<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
35 changes: 35 additions & 0 deletions inventory/templates/inventory/system/delete_backup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{% extends 'inventory/base.html' %}

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

{% block content %}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card border-danger">
<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">
<p>确定要删除备份 <strong>{{ backup_name }}</strong> 吗?</p>
<p class="text-danger">此操作不可恢复。</p>

<form method="post">
{% csrf_token %}
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="confirm" name="confirm" required>
<label class="form-check-label" for="confirm">
我确认要删除此备份
</label>
</div>
<div class="d-flex justify-content-between">
<a href="{% url 'backup_list' %}" class="btn btn-outline-secondary">取消</a>
<button type="submit" class="btn btn-danger">确认删除</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
41 changes: 41 additions & 0 deletions inventory/tests/test_sale_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,47 @@ def test_delete_item_persists_recalculated_total(self):
sale.refresh_from_db()
self.assertEqual(sale.total_amount, Decimal('20.00')) # 删除后总额已落库

def test_get_delete_item_is_not_allowed(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_detail_get_does_not_rewrite_persisted_amounts(self):
sale = self._make_sale(status='COMPLETED')
Sale.objects.filter(pk=sale.pk).update(
total_amount=Decimal('99.00'),
discount_amount=Decimal('0.00'),
final_amount=Decimal('99.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('99.00'))
self.assertEqual(sale.final_amount, Decimal('99.00'))

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

response = self.client.post(
reverse('sale_complete', args=[sale.id]),
{'payment_method': 'credit'},
)

self.assertRedirects(response, reverse('sale_complete', args=[sale.id]))
sale.refresh_from_db()
self.assertEqual(sale.status, 'DRAFT')
self.assertEqual(sale.payment_method, 'cash')

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

Expand Down
10 changes: 10 additions & 0 deletions inventory/tests/test_sales_balance_payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,13 @@ def test_sale_create_balance_payment_rolls_back_when_balance_is_insufficient(sel
self.assertEqual(self.member.balance, Decimal('5.00'))
self.inventory.refresh_from_db()
self.assertEqual(self.inventory.quantity, 10)

def test_sale_create_rejects_unsupported_payment_method(self):
response = self.client.post(reverse('sale_create'), self.sale_post_data('credit'))

self.assertRedirects(response, reverse('sale_create'))
self.assertFalse(Sale.objects.exists())
self.inventory.refresh_from_db()
self.assertEqual(self.inventory.quantity, 10)
self.member.refresh_from_db()
self.assertEqual(self.member.balance, Decimal('100.00'))
116 changes: 102 additions & 14 deletions inventory/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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 @@ -258,6 +259,21 @@ def setUp(self):
os.makedirs(self.backup_root, exist_ok=True)
os.makedirs(self.temp_dir, exist_ok=True)

def _write_backup_info(self, backup_name, includes_media=False):
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': includes_media,
},
backup_info,
)
return backup_dir

def test_delete_backup_rejects_parent_directory_traversal(self):
sentinel_path = os.path.join(self.temp_parent.name, 'keep.txt')
with open(sentinel_path, 'w', encoding='utf-8') as sentinel:
Expand All @@ -270,10 +286,31 @@ def test_delete_backup_rejects_parent_directory_traversal(self):
self.assertTrue(os.path.exists(sentinel_path))
self.assertTrue(os.path.isdir(self.backup_root))

def test_delete_backup_confirmation_page_renders(self):
backup_name = 'snapshot'
self._write_backup_info(backup_name)

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

self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'inventory/system/delete_backup.html')
self.assertContains(response, backup_name)

def test_restore_backup_page_renders_with_backup_context(self):
backup_name = 'snapshot'
self._write_backup_info(backup_name)

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.assertTemplateUsed(response, 'inventory/system/restore_backup.html')
self.assertContains(response, backup_name)

def test_restore_backup_flushes_records_missing_from_snapshot(self):
backup_name = 'snapshot'
backup_dir = os.path.join(self.backup_root, backup_name)
os.makedirs(backup_dir, exist_ok=True)
backup_dir = self._write_backup_info(backup_name)
db_file = os.path.join(backup_dir, 'db.json')

with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir):
Expand All @@ -292,17 +329,6 @@ def test_restore_backup_flushes_records_missing_from_snapshot(self):
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': False,
},
backup_info,
)

category = Category.objects.create(name='备份后分类')
product = Product.objects.create(
barcode='post-backup-product',
Expand All @@ -315,9 +341,71 @@ 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())


class LogFileViewTest(TestCase):
"""系统日志文件操作回归测试"""

def setUp(self):
self.client = Client()
self.user = User.objects.create_superuser(
username='log-admin',
password='log-pass',
email='log@example.com',
)
self.client.force_login(self.user)

self.log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs')
os.makedirs(self.log_dir, exist_ok=True)
self.addCleanup(self._cleanup_test_logs)

def _cleanup_test_logs(self):
for file_name in ('download-test.log', 'delete-test.log'):
file_path = os.path.join(self.log_dir, file_name)
if os.path.exists(file_path):
os.remove(file_path)

def _write_log_file(self, file_name):
file_path = os.path.join(self.log_dir, file_name)
with open(file_path, 'w', encoding='utf-8') as log_file:
log_file.write('test log line\n')
return file_path

def test_download_log_file_records_nullable_content_type(self):
self._write_log_file('download-test.log')

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

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

def test_delete_log_file_records_nullable_content_type(self):
file_path = self._write_log_file('delete-test.log')

response = self.client.post(
reverse('delete_log_file', args=['delete-test.log']),
{'confirm': 'on'},
)

self.assertRedirects(response, reverse('log_list'))
self.assertFalse(os.path.exists(file_path))
self.assertTrue(
LogEntry.objects.filter(
user=self.user,
object_id='delete-test.log',
content_type__isnull=True,
).exists()
)
Loading