feat: item image now is required

This commit is contained in:
2025-09-21 13:47:19 +08:00
parent 8d12600df4
commit d2ab8d12f6
13 changed files with 1330 additions and 265 deletions
+40 -8
View File
@@ -1,20 +1,35 @@
from django.contrib import admin
from .models import Item, ItemUsage, Category
from .models import Item, ItemUsage, Category, ItemImage, UsageImage
class ItemImageInline(admin.TabularInline):
"""物品图片内联编辑"""
model = ItemImage
extra = 1
fields = ['image', 'description', 'is_primary']
class UsageImageInline(admin.TabularInline):
"""使用记录图片内联编辑"""
model = UsageImage
extra = 1
fields = ['image', 'image_type', 'description']
@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
list_display = ['name', 'serial_number', 'category', 'status', 'location', 'created_at']
list_display = ['name', 'serial_number', 'category', 'status', 'location', 'owner', 'created_at']
list_filter = ['status', 'category', 'created_at']
search_fields = ['name', 'serial_number', 'description']
search_fields = ['name', 'serial_number', 'description', 'owner']
readonly_fields = ['created_at', 'updated_at']
inlines = [ItemImageInline]
fieldsets = (
('基本信息', {
'fields': ('name', 'description', 'serial_number', 'category')
}),
('状态和位置', {
'fields': ('status', 'location')
'fields': ('status', 'location', 'owner')
}),
('购买信息', {
'fields': ('purchase_date', 'value')
@@ -28,16 +43,17 @@ class ItemAdmin(admin.ModelAdmin):
@admin.register(ItemUsage)
class ItemUsageAdmin(admin.ModelAdmin):
list_display = ['item', 'user', 'start_time', 'end_time', 'is_returned', 'purpose']
list_display = ['item', 'user', 'borrower_contact', 'start_time', 'end_time', 'is_returned', 'purpose']
list_filter = ['is_returned', 'start_time', 'item__category']
search_fields = ['item__name', 'user__username', 'purpose']
search_fields = ['item__name', 'user', 'purpose', 'borrower_contact']
readonly_fields = ['created_at']
inlines = [UsageImageInline]
fieldsets = (
('使用信息', {
'fields': ('item', 'user', 'purpose', 'notes')
'fields': ('item', 'user', 'borrower_contact', 'purpose', 'notes')
}),
('时间信息', {
'fields': ('start_time', 'end_time', 'is_returned')
'fields': ('start_time', 'end_time', 'expected_return_time', 'is_returned')
}),
('状况记录', {
'fields': ('condition_before', 'condition_after')
@@ -49,6 +65,22 @@ class ItemUsageAdmin(admin.ModelAdmin):
)
@admin.register(ItemImage)
class ItemImageAdmin(admin.ModelAdmin):
list_display = ['item', 'description', 'is_primary', 'created_at']
list_filter = ['is_primary', 'created_at']
search_fields = ['item__name', 'description']
readonly_fields = ['created_at']
@admin.register(UsageImage)
class UsageImageAdmin(admin.ModelAdmin):
list_display = ['usage', 'image_type', 'description', 'created_at']
list_filter = ['image_type', 'created_at']
search_fields = ['usage__item__name', 'usage__user', 'description']
readonly_fields = ['created_at']
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'description', 'created_at']
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,143 @@
from django.core.management.base import BaseCommand
from items.models import Category
# 添加物品类别:python manage.py create_default_categories
class Command(BaseCommand):
help = '创建默认的物品类别'
def handle(self, *args, **options):
# 默认物品类别列表
default_categories = [
{
'name': '电子设备',
'description': '包括电脑、手机、平板等电子产品'
},
{
'name': '办公用品',
'description': '办公桌椅、文具、打印机等办公设备'
},
{
'name': '实验器材',
'description': '实验室设备、仪器、工具等'
},
{
'name': '家具',
'description': '桌子、椅子、柜子等家具用品'
},
{
'name': '图书资料',
'description': '书籍、文档、资料等'
},
{
'name': '工具设备',
'description': '维修工具、测量仪器等'
},
{
'name': '音响设备',
'description': '音响、麦克风、投影仪等音视频设备'
},
{
'name': '运动器材',
'description': '体育用品、健身器材等'
},
{
'name': '清洁用品',
'description': '清洁工具、清洁剂等'
},
{
'name': '网络设备',
'description': '路由器、交换机、网线等网络相关设备'
},
{
'name': '安全设备',
'description': '监控摄像头、门禁系统、报警器等安全设备'
},
{
'name': '医疗用品',
'description': '急救包、体温计、血压计等医疗相关用品'
},
{
'name': '厨房用具',
'description': '微波炉、咖啡机、餐具等厨房设备'
},
{
'name': '照明设备',
'description': '台灯、吊灯、应急灯等照明用品'
},
{
'name': '存储设备',
'description': '硬盘、U盘、移动硬盘等存储设备'
},
{
'name': '车辆工具',
'description': '汽车配件、维修工具、车载设备等'
},
{
'name': '服装用品',
'description': '工作服、防护服、鞋帽等服装类物品'
},
{
'name': '教学用品',
'description': '黑板、白板、教学模型等教学设备'
},
{
'name': '通讯设备',
'description': '对讲机、电话、传真机等通讯设备'
},
{
'name': '空调制冷',
'description': '空调、风扇、加湿器等温度调节设备'
},
{
'name': '消防设备',
'description': '灭火器、烟雾报警器、消防栓等消防用品'
},
{
'name': '软件许可',
'description': '软件授权、许可证、数字资产等'
},
{
'name': '包装材料',
'description': '纸箱、胶带、包装袋等包装用品'
},
{
'name': '园艺用品',
'description': '花盆、园艺工具、肥料等园艺相关用品'
},
{
'name': '其他',
'description': '其他未分类的物品'
}
]
created_count = 0
updated_count = 0
for category_data in default_categories:
category, created = Category.objects.get_or_create(
name=category_data['name'],
defaults={'description': category_data['description']}
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f'创建类别: {category.name}')
)
else:
# 更新描述(如果不同)
if category.description != category_data['description']:
category.description = category_data['description']
category.save()
updated_count += 1
self.stdout.write(
self.style.WARNING(f'更新类别: {category.name}')
)
self.stdout.write(
self.style.SUCCESS(
f'\n任务完成!创建了 {created_count} 个新类别,更新了 {updated_count} 个类别。'
)
)
+87
View File
@@ -1,4 +1,27 @@
from django.db import models
import os
import uuid
def get_item_image_path(instance, filename):
"""生成物品图片的存储路径"""
# 如果物品还没有ID,先生成一个临时文件夹名
item_id = instance.item.id if instance.item.id else str(uuid.uuid4())
return f'items/{item_id}/initial/{filename}'
def get_usage_borrow_image_path(instance, filename):
"""生成借用时图片的存储路径"""
item_id = instance.usage.item.id if instance.usage.item.id else str(uuid.uuid4())
usage_id = instance.usage.id if instance.usage.id else str(uuid.uuid4())
return f'items/{item_id}/usage/{usage_id}/borrow/{filename}'
def get_usage_return_image_path(instance, filename):
"""生成归还时图片的存储路径"""
item_id = instance.usage.item.id if instance.usage.item.id else str(uuid.uuid4())
usage_id = instance.usage.id if instance.usage.id else str(uuid.uuid4())
return f'items/{item_id}/usage/{usage_id}/return/{filename}'
class Item(models.Model):
@@ -34,6 +57,23 @@ class Item(models.Model):
return f"{self.name} ({self.serial_number})"
class ItemImage(models.Model):
"""物品图片模型"""
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='images', verbose_name='物品')
image = models.ImageField(upload_to=get_item_image_path, verbose_name='图片')
description = models.CharField(max_length=200, blank=True, verbose_name='图片描述')
is_primary = models.BooleanField(default=False, verbose_name='是否为主图')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='上传时间')
class Meta:
verbose_name = '物品图片'
verbose_name_plural = '物品图片'
ordering = ['-is_primary', '-created_at']
def __str__(self):
return f"{self.item.name} - 图片"
class ItemUsage(models.Model):
"""物品使用记录模型"""
item = models.ForeignKey(Item, on_delete=models.CASCADE, verbose_name='物品')
@@ -58,6 +98,53 @@ class ItemUsage(models.Model):
return f"{self.item.name} - {self.user} ({self.start_time.strftime('%Y-%m-%d %H:%M')})"
class UsageImage(models.Model):
"""使用记录图片模型"""
IMAGE_TYPE_CHOICES = [
('borrow', '借用时'),
('return', '归还时'),
]
usage = models.ForeignKey(ItemUsage, on_delete=models.CASCADE, related_name='images', verbose_name='使用记录')
image = models.ImageField(upload_to='usage_images/', verbose_name='图片')
image_type = models.CharField(max_length=10, choices=IMAGE_TYPE_CHOICES, verbose_name='图片类型')
description = models.CharField(max_length=200, blank=True, verbose_name='图片描述')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='上传时间')
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# 保存后重新组织文件路径
if self.image and self.usage_id and self.usage.item_id:
import os
import shutil
from django.conf import settings
old_path = self.image.path
if self.image_type == 'borrow':
new_relative_path = f'items/{self.usage.item_id}/usage/{self.usage_id}/borrow/{os.path.basename(old_path)}'
else:
new_relative_path = f'items/{self.usage.item_id}/usage/{self.usage_id}/return/{os.path.basename(old_path)}'
new_path = os.path.join(settings.MEDIA_ROOT, new_relative_path)
# 创建目录
os.makedirs(os.path.dirname(new_path), exist_ok=True)
# 移动文件
if os.path.exists(old_path) and old_path != new_path:
shutil.move(old_path, new_path)
self.image.name = new_relative_path
super().save(update_fields=['image'])
class Meta:
verbose_name = '使用记录图片'
verbose_name_plural = '使用记录图片'
ordering = ['-created_at']
def __str__(self):
return f"{self.usage.item.name} - {self.get_image_type_display()}"
class Category(models.Model):
"""物品类别模型"""
name = models.CharField(max_length=50, unique=True, verbose_name='类别名称')
+59 -4
View File
@@ -1,7 +1,7 @@
from django.contrib.auth.models import User
from rest_framework import serializers
from .models import Item, ItemUsage, Category
from .models import Item, ItemUsage, Category, ItemImage, UsageImage
class UserSerializer(serializers.ModelSerializer):
@@ -16,15 +16,47 @@ class CategorySerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'description', 'created_at']
class ItemImageSerializer(serializers.ModelSerializer):
"""物品图片序列化器"""
image_url = serializers.SerializerMethodField()
class Meta:
model = ItemImage
fields = ['id', 'image', 'image_url', 'description', 'is_primary', 'created_at']
def get_image_url(self, obj):
request = self.context.get('request')
if obj.image and request:
return request.build_absolute_uri(obj.image.url)
return None
class UsageImageSerializer(serializers.ModelSerializer):
"""使用记录图片序列化器"""
image_url = serializers.SerializerMethodField()
class Meta:
model = UsageImage
fields = ['id', 'image', 'image_url', 'image_type', 'description', 'created_at']
def get_image_url(self, obj):
request = self.context.get('request')
if obj.image and request:
return request.build_absolute_uri(obj.image.url)
return None
class ItemSerializer(serializers.ModelSerializer):
current_user = serializers.SerializerMethodField()
images = ItemImageSerializer(many=True, read_only=True)
primary_image = serializers.SerializerMethodField()
class Meta:
model = Item
fields = [
'id', 'name', 'description', 'serial_number', 'category', 'status',
'location', 'owner', 'purchase_date', 'value', 'created_at', 'updated_at',
'current_user'
'current_user', 'images', 'primary_image'
]
def get_current_user(self, obj):
@@ -39,20 +71,43 @@ class ItemSerializer(serializers.ModelSerializer):
}
return None
def get_primary_image(self, obj):
"""获取主图片"""
primary_image = obj.images.filter(is_primary=True).first()
if primary_image:
request = self.context.get('request')
if request:
return request.build_absolute_uri(primary_image.image.url)
return None
class ItemUsageSerializer(serializers.ModelSerializer):
item_name = serializers.CharField(source='item.name', read_only=True)
item_serial = serializers.CharField(source='item.serial_number', read_only=True)
images = UsageImageSerializer(many=True, read_only=True)
borrow_images = serializers.SerializerMethodField()
return_images = serializers.SerializerMethodField()
class Meta:
model = ItemUsage
fields = [
'id', 'item', 'item_name', 'item_serial', 'user', 'borrower_contact',
'start_time', 'end_time', 'purpose', 'notes', 'is_returned',
'condition_before', 'condition_after', 'expected_return_time', 'created_at'
'condition_before', 'condition_after', 'expected_return_time', 'created_at',
'images', 'borrow_images', 'return_images'
]
read_only_fields = ['created_at']
def get_borrow_images(self, obj):
"""获取借用时图片"""
borrow_images = obj.images.filter(image_type='borrow')
return UsageImageSerializer(borrow_images, many=True, context=self.context).data
def get_return_images(self, obj):
"""获取归还时图片"""
return_images = obj.images.filter(image_type='return')
return UsageImageSerializer(return_images, many=True, context=self.context).data
class ItemDetailSerializer(ItemSerializer):
"""物品详情序列化器,包含使用历史"""
@@ -64,4 +119,4 @@ class ItemDetailSerializer(ItemSerializer):
def get_usage_history(self, obj):
"""获取物品的使用历史"""
usages = ItemUsage.objects.filter(item=obj).order_by('-start_time')[:10]
return ItemUsageSerializer(usages, many=True).data
return ItemUsageSerializer(usages, many=True, context=self.context).data
+2
View File
@@ -8,6 +8,8 @@ router.register(r'items', views.ItemViewSet)
router.register(r'usages', views.ItemUsageViewSet)
router.register(r'item_categories', views.CategoryViewSet)
router.register(r'users', views.UserViewSet)
router.register(r'item-images', views.ItemImageViewSet)
router.register(r'usage-images', views.UsageImageViewSet)
urlpatterns = [
path('api/', include(router.urls)),
+145 -4
View File
@@ -4,17 +4,19 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework.parsers import MultiPartParser, FormParser
from .models import Item, ItemUsage, Category
from .models import Item, ItemUsage, Category, ItemImage, UsageImage
from .serializers import (
ItemSerializer, ItemDetailSerializer, ItemUsageSerializer,
CategorySerializer, UserSerializer
CategorySerializer, UserSerializer, ItemImageSerializer, UsageImageSerializer
)
class ItemViewSet(viewsets.ModelViewSet):
"""物品管理API"""
authentication_classes = [JWTAuthentication]
parser_classes = [MultiPartParser, FormParser]
queryset = Item.objects.all()
serializer_class = ItemSerializer
@@ -23,6 +25,80 @@ class ItemViewSet(viewsets.ModelViewSet):
return ItemDetailSerializer
return ItemSerializer
def create(self, request, *args, **kwargs):
"""创建物品,强制要求上传至少一张图片"""
# 检查是否上传了图片
images = request.FILES.getlist('images')
if not images:
return Response(
{'error': '创建物品时必须上传至少一张图片'},
status=status.HTTP_400_BAD_REQUEST
)
# 创建物品
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
item = serializer.save()
# 保存图片
for i, image in enumerate(images):
ItemImage.objects.create(
item=item,
image=image,
description=request.data.get(f'image_descriptions[{i}]', ''),
is_primary=(i == 0) # 第一张图片设为主图
)
# 返回包含图片的完整数据
response_serializer = ItemDetailSerializer(item, context={'request': request})
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'])
def upload_images(self, request, pk=None):
"""上传物品图片"""
item = self.get_object()
images = request.FILES.getlist('images')
if not images:
return Response(
{'error': '请选择要上传的图片'},
status=status.HTTP_400_BAD_REQUEST
)
uploaded_images = []
for i, image in enumerate(images):
item_image = ItemImage.objects.create(
item=item,
image=image,
description=request.data.get(f'image_descriptions[{i}]', ''),
is_primary=False
)
uploaded_images.append(item_image)
serializer = ItemImageSerializer(uploaded_images, many=True, context={'request': request})
return Response(serializer.data)
@action(detail=True, methods=['post'])
def set_primary_image(self, request, pk=None):
"""设置主图片"""
item = self.get_object()
image_id = request.data.get('image_id')
try:
# 取消当前主图
item.images.filter(is_primary=True).update(is_primary=False)
# 设置新主图
new_primary = item.images.get(id=image_id)
new_primary.is_primary = True
new_primary.save()
return Response({'message': '主图设置成功'})
except ItemImage.DoesNotExist:
return Response(
{'error': '图片不存在'},
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=True, methods=['post'])
def borrow(self, request, pk=None):
"""借用物品"""
@@ -57,11 +133,22 @@ class ItemViewSet(viewsets.ModelViewSet):
condition_before=condition_before
)
# 保存借用时的图片
borrow_images = request.FILES.getlist('borrow_images')
for i, image in enumerate(borrow_images):
UsageImage.objects.create(
usage=usage,
image=image,
image_type='borrow',
description=request.data.get(f'borrow_image_descriptions[{i}]', '')
)
# 更新物品状态
item.status = 'in_use'
item.save()
return Response(ItemUsageSerializer(usage).data)
response_serializer = ItemUsageSerializer(usage, context={'request': request})
return Response(response_serializer.data)
@action(detail=True, methods=['post'])
def return_item(self, request, pk=None):
@@ -86,11 +173,22 @@ class ItemViewSet(viewsets.ModelViewSet):
current_usage.notes = request.data.get('return_notes', current_usage.notes)
current_usage.save()
# 保存归还时的图片
return_images = request.FILES.getlist('return_images')
for i, image in enumerate(return_images):
UsageImage.objects.create(
usage=current_usage,
image=image,
image_type='return',
description=request.data.get(f'return_image_descriptions[{i}]', '')
)
# 更新物品状态
item.status = 'available'
item.save()
return Response(ItemUsageSerializer(current_usage).data)
response_serializer = ItemUsageSerializer(current_usage, context={'request': request})
return Response(response_serializer.data)
@action(detail=False)
def available(self, request):
@@ -110,9 +208,36 @@ class ItemViewSet(viewsets.ModelViewSet):
class ItemUsageViewSet(viewsets.ModelViewSet):
"""使用记录管理API"""
authentication_classes = [JWTAuthentication]
parser_classes = [MultiPartParser, FormParser]
queryset = ItemUsage.objects.all()
serializer_class = ItemUsageSerializer
@action(detail=True, methods=['post'])
def upload_images(self, request, pk=None):
"""上传使用记录图片"""
usage = self.get_object()
images = request.FILES.getlist('images')
image_type = request.data.get('image_type', 'borrow')
if not images:
return Response(
{'error': '请选择要上传的图片'},
status=status.HTTP_400_BAD_REQUEST
)
uploaded_images = []
for i, image in enumerate(images):
usage_image = UsageImage.objects.create(
usage=usage,
image=image,
image_type=image_type,
description=request.data.get(f'image_descriptions[{i}]', '')
)
uploaded_images.append(usage_image)
serializer = UsageImageSerializer(uploaded_images, many=True, context={'request': request})
return Response(serializer.data)
@action(detail=False)
def current(self, request):
"""获取当前使用中的记录"""
@@ -143,3 +268,19 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet):
authentication_classes = [JWTAuthentication]
queryset = User.objects.all()
serializer_class = UserSerializer
class ItemImageViewSet(viewsets.ModelViewSet):
"""物品图片管理API"""
authentication_classes = [JWTAuthentication]
parser_classes = [MultiPartParser, FormParser]
queryset = ItemImage.objects.all()
serializer_class = ItemImageSerializer
class UsageImageViewSet(viewsets.ModelViewSet):
"""使用记录图片管理API"""
authentication_classes = [JWTAuthentication]
parser_classes = [MultiPartParser, FormParser]
queryset = UsageImage.objects.all()
serializer_class = UsageImageSerializer