|
|
@@ -0,0 +1,416 @@
|
|
|
+from django.shortcuts import render, redirect, get_object_or_404
|
|
|
+from django.contrib.auth.decorators import login_required
|
|
|
+from django.contrib.auth import authenticate, login, logout
|
|
|
+from django.contrib import messages
|
|
|
+from django.http import HttpResponse, JsonResponse
|
|
|
+from django.db.models import Count, Q
|
|
|
+from django.core.paginator import Paginator
|
|
|
+from django.conf import settings
|
|
|
+from openpyxl import load_workbook
|
|
|
+from io import BytesIO
|
|
|
+from datetime import date, timedelta
|
|
|
+
|
|
|
+from .models import Asset, Category, AssetChangeLog
|
|
|
+from .forms import AssetForm, AssetImportForm, CategoryForm
|
|
|
+from .excel_utils import (
|
|
|
+ export_assets_to_excel, generate_import_template,
|
|
|
+ import_assets_from_excel, STATUS_MAP,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+# ─── 认证 ────────────────────────────────────────
|
|
|
+
|
|
|
+def login_view(request):
|
|
|
+ if request.user.is_authenticated:
|
|
|
+ return redirect('dashboard')
|
|
|
+
|
|
|
+ if request.method == 'POST':
|
|
|
+ username = request.POST.get('username', '')
|
|
|
+ password = request.POST.get('password', '')
|
|
|
+ user = authenticate(request, username=username, password=password)
|
|
|
+ if user is not None:
|
|
|
+ login(request, user)
|
|
|
+ next_url = request.GET.get('next', '/')
|
|
|
+ return redirect(next_url)
|
|
|
+ else:
|
|
|
+ messages.error(request, '用户名或密码错误')
|
|
|
+
|
|
|
+ return render(request, 'assetapp/login.html')
|
|
|
+
|
|
|
+
|
|
|
+def logout_view(request):
|
|
|
+ logout(request)
|
|
|
+ return redirect('login')
|
|
|
+
|
|
|
+
|
|
|
+# ─── 仪表盘 ──────────────────────────────────────
|
|
|
+
|
|
|
+@login_required
|
|
|
+def dashboard(request):
|
|
|
+ total_assets = Asset.objects.count()
|
|
|
+ status_stats = Asset.objects.values('status').annotate(count=Count('id'))
|
|
|
+ status_data = {item['status']: item['count'] for item in status_stats}
|
|
|
+
|
|
|
+ category_stats = Asset.objects.values('category__name').annotate(
|
|
|
+ count=Count('id')
|
|
|
+ ).order_by('-count')
|
|
|
+
|
|
|
+ # 即将过保(30天内)
|
|
|
+ today = date.today()
|
|
|
+ expiring_soon = Asset.objects.filter(
|
|
|
+ warranty_expire__lte=today + timedelta(days=30),
|
|
|
+ warranty_expire__gte=today,
|
|
|
+ status='in_use',
|
|
|
+ ).count()
|
|
|
+ expired = Asset.objects.filter(
|
|
|
+ warranty_expire__lt=today,
|
|
|
+ status='in_use',
|
|
|
+ ).count()
|
|
|
+
|
|
|
+ # 最近变更
|
|
|
+ recent_changes = AssetChangeLog.objects.select_related('operator')[:10]
|
|
|
+
|
|
|
+ context = {
|
|
|
+ 'total_assets': total_assets,
|
|
|
+ 'status_data': status_data,
|
|
|
+ 'status_map': STATUS_MAP,
|
|
|
+ 'category_stats': category_stats,
|
|
|
+ 'expiring_soon': expiring_soon,
|
|
|
+ 'expired': expired,
|
|
|
+ 'recent_changes': recent_changes,
|
|
|
+ }
|
|
|
+ return render(request, 'assetapp/dashboard.html', context)
|
|
|
+
|
|
|
+
|
|
|
+# ─── 资产列表 ─────────────────────────────────────
|
|
|
+
|
|
|
+@login_required
|
|
|
+def asset_list(request):
|
|
|
+ queryset = Asset.objects.select_related('category')
|
|
|
+
|
|
|
+ # 搜索
|
|
|
+ search = request.GET.get('search', '').strip()
|
|
|
+ if search:
|
|
|
+ queryset = queryset.filter(
|
|
|
+ Q(asset_number__icontains=search) |
|
|
|
+ Q(name__icontains=search) |
|
|
|
+ Q(serial_number__icontains=search) |
|
|
|
+ Q(ip_address__icontains=search) |
|
|
|
+ Q(brand__icontains=search) |
|
|
|
+ Q(model__icontains=search) |
|
|
|
+ Q(location__icontains=search) |
|
|
|
+ Q(responsible_person__icontains=search)
|
|
|
+ )
|
|
|
+
|
|
|
+ # 筛选
|
|
|
+ category_id = request.GET.get('category')
|
|
|
+ if category_id:
|
|
|
+ queryset = queryset.filter(category_id=category_id)
|
|
|
+
|
|
|
+ status = request.GET.get('status')
|
|
|
+ if status:
|
|
|
+ queryset = queryset.filter(status=status)
|
|
|
+
|
|
|
+ location = request.GET.get('location')
|
|
|
+ if location:
|
|
|
+ queryset = queryset.filter(location__icontains=location)
|
|
|
+
|
|
|
+ # 排序
|
|
|
+ sort = request.GET.get('sort', '-created_at')
|
|
|
+ valid_sorts = ['asset_number', '-asset_number', 'name', '-name',
|
|
|
+ 'created_at', '-created_at', 'updated_at', '-updated_at',
|
|
|
+ 'purchase_date', '-purchase_date']
|
|
|
+ if sort not in valid_sorts:
|
|
|
+ sort = '-created_at'
|
|
|
+ queryset = queryset.order_by(sort)
|
|
|
+
|
|
|
+ # 分页
|
|
|
+ paginator = Paginator(queryset, settings.ASSETS_PER_PAGE)
|
|
|
+ page_number = request.GET.get('page', 1)
|
|
|
+ page_obj = paginator.get_page(page_number)
|
|
|
+
|
|
|
+ categories = Category.objects.all()
|
|
|
+ locations = Asset.objects.values_list('location', flat=True).exclude(location='').distinct()
|
|
|
+
|
|
|
+ context = {
|
|
|
+ 'page_obj': page_obj,
|
|
|
+ 'search': search,
|
|
|
+ 'categories': categories,
|
|
|
+ 'current_category': category_id,
|
|
|
+ 'current_status': status,
|
|
|
+ 'current_location': location,
|
|
|
+ 'locations': locations,
|
|
|
+ 'status_map': STATUS_MAP,
|
|
|
+ 'sort': sort,
|
|
|
+ }
|
|
|
+ return render(request, 'assetapp/asset_list.html', context)
|
|
|
+
|
|
|
+
|
|
|
+# ─── 资产详情 ─────────────────────────────────────
|
|
|
+
|
|
|
+@login_required
|
|
|
+def asset_detail(request, pk):
|
|
|
+ asset = get_object_or_404(Asset.objects.select_related('category', 'created_by'), pk=pk)
|
|
|
+ change_logs = asset.change_logs.select_related('operator')[:20]
|
|
|
+ context = {
|
|
|
+ 'asset': asset,
|
|
|
+ 'change_logs': change_logs,
|
|
|
+ 'status_map': STATUS_MAP,
|
|
|
+ }
|
|
|
+ return render(request, 'assetapp/asset_detail.html', context)
|
|
|
+
|
|
|
+
|
|
|
+# ─── 资产创建 ─────────────────────────────────────
|
|
|
+
|
|
|
+@login_required
|
|
|
+def asset_create(request):
|
|
|
+ if request.method == 'POST':
|
|
|
+ form = AssetForm(request.POST)
|
|
|
+ if form.is_valid():
|
|
|
+ asset = form.save(commit=False)
|
|
|
+ asset.created_by = request.user
|
|
|
+ asset.save()
|
|
|
+
|
|
|
+ AssetChangeLog.objects.create(
|
|
|
+ asset=asset,
|
|
|
+ asset_number=asset.asset_number,
|
|
|
+ action='create',
|
|
|
+ description='创建资产',
|
|
|
+ operator=request.user,
|
|
|
+ )
|
|
|
+
|
|
|
+ messages.success(request, f'资产 {asset.asset_number} 创建成功!')
|
|
|
+ return redirect('asset_detail', pk=asset.pk)
|
|
|
+ else:
|
|
|
+ form = AssetForm()
|
|
|
+
|
|
|
+ context = {'form': form, 'action': 'create'}
|
|
|
+ return render(request, 'assetapp/asset_form.html', context)
|
|
|
+
|
|
|
+
|
|
|
+# ─── 资产编辑 ─────────────────────────────────────
|
|
|
+
|
|
|
+@login_required
|
|
|
+def asset_update(request, pk):
|
|
|
+ asset = get_object_or_404(Asset, pk=pk)
|
|
|
+
|
|
|
+ if request.method == 'POST':
|
|
|
+ form = AssetForm(request.POST, instance=asset)
|
|
|
+ if form.is_valid():
|
|
|
+ # 记录变更
|
|
|
+ changes = []
|
|
|
+ for field in form.changed_data:
|
|
|
+ old_val = str(form.initial.get(field, ''))
|
|
|
+ new_val = str(form.cleaned_data.get(field, ''))
|
|
|
+ changes.append(f'{field}: {old_val} → {new_val}')
|
|
|
+
|
|
|
+ AssetChangeLog.objects.create(
|
|
|
+ asset=asset,
|
|
|
+ asset_number=asset.asset_number,
|
|
|
+ action='update',
|
|
|
+ field_name=field,
|
|
|
+ old_value=str(form.initial.get(field, '')),
|
|
|
+ new_value=str(form.cleaned_data.get(field, '')),
|
|
|
+ operator=request.user,
|
|
|
+ )
|
|
|
+
|
|
|
+ asset = form.save()
|
|
|
+
|
|
|
+ if changes:
|
|
|
+ messages.success(request, f'已更新 {len(changes)} 个字段')
|
|
|
+ return redirect('asset_detail', pk=asset.pk)
|
|
|
+ else:
|
|
|
+ form = AssetForm(instance=asset)
|
|
|
+
|
|
|
+ context = {'form': form, 'action': 'update', 'asset': asset}
|
|
|
+ return render(request, 'assetapp/asset_form.html', context)
|
|
|
+
|
|
|
+
|
|
|
+# ─── 资产删除 ─────────────────────────────────────
|
|
|
+
|
|
|
+@login_required
|
|
|
+def asset_delete(request, pk):
|
|
|
+ asset = get_object_or_404(Asset, pk=pk)
|
|
|
+
|
|
|
+ if request.method == 'POST':
|
|
|
+ AssetChangeLog.objects.create(
|
|
|
+ asset_number=asset.asset_number,
|
|
|
+ action='delete',
|
|
|
+ description=f'删除资产: {asset.name}',
|
|
|
+ operator=request.user,
|
|
|
+ )
|
|
|
+ asset.delete()
|
|
|
+ messages.success(request, f'资产 {asset.asset_number} 已删除')
|
|
|
+ return redirect('asset_list')
|
|
|
+
|
|
|
+ context = {'asset': asset}
|
|
|
+ return render(request, 'assetapp/asset_confirm_delete.html', context)
|
|
|
+
|
|
|
+
|
|
|
+# ─── Excel导出 ────────────────────────────────────
|
|
|
+
|
|
|
+@login_required
|
|
|
+def asset_export(request):
|
|
|
+ queryset = Asset.objects.select_related('category')
|
|
|
+
|
|
|
+ # 支持筛选导出
|
|
|
+ category_id = request.GET.get('category')
|
|
|
+ if category_id:
|
|
|
+ queryset = queryset.filter(category_id=category_id)
|
|
|
+
|
|
|
+ status = request.GET.get('status')
|
|
|
+ if status:
|
|
|
+ queryset = queryset.filter(status=status)
|
|
|
+
|
|
|
+ wb = export_assets_to_excel(queryset)
|
|
|
+
|
|
|
+ output = BytesIO()
|
|
|
+ wb.save(output)
|
|
|
+ output.seek(0)
|
|
|
+
|
|
|
+ AssetChangeLog.objects.create(
|
|
|
+ asset_number='-',
|
|
|
+ action='export',
|
|
|
+ description=f'导出 {queryset.count()} 条资产记录',
|
|
|
+ operator=request.user,
|
|
|
+ )
|
|
|
+
|
|
|
+ today_str = date.today().strftime('%Y%m%d')
|
|
|
+ response = HttpResponse(
|
|
|
+ output.read(),
|
|
|
+ content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
|
+ )
|
|
|
+ response['Content-Disposition'] = f'attachment; filename=硬件资产_{today_str}.xlsx'
|
|
|
+ return response
|
|
|
+
|
|
|
+
|
|
|
+# ─── Excel导入 ────────────────────────────────────
|
|
|
+
|
|
|
+@login_required
|
|
|
+def asset_import(request):
|
|
|
+ if request.method == 'POST':
|
|
|
+ form = AssetImportForm(request.POST, request.FILES)
|
|
|
+ if form.is_valid():
|
|
|
+ try:
|
|
|
+ wb = load_workbook(request.FILES['excel_file'])
|
|
|
+ ws = wb.active
|
|
|
+
|
|
|
+ category_map = {c.name: c for c in Category.objects.all()}
|
|
|
+ results = import_assets_from_excel(ws, category_map, operator=request.user)
|
|
|
+
|
|
|
+ if results['success'] > 0:
|
|
|
+ messages.success(request, f"成功导入 {results['success']} 条资产")
|
|
|
+
|
|
|
+ if results['skipped'] > 0:
|
|
|
+ messages.warning(request, f"跳过 {results['skipped']} 条(资产编号重复)")
|
|
|
+
|
|
|
+ if results['errors']:
|
|
|
+ for error in results['errors'][:10]:
|
|
|
+ messages.error(request, error)
|
|
|
+ if len(results['errors']) > 10:
|
|
|
+ messages.error(request, f"...还有 {len(results['errors']) - 10} 条错误")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ messages.error(request, f'导入失败: {str(e)}')
|
|
|
+
|
|
|
+ return redirect('asset_list')
|
|
|
+ else:
|
|
|
+ form = AssetImportForm()
|
|
|
+
|
|
|
+ return render(request, 'assetapp/asset_import.html', {'form': form})
|
|
|
+
|
|
|
+
|
|
|
+# ─── 下载导入模板 ──────────────────────────────────
|
|
|
+
|
|
|
+@login_required
|
|
|
+def download_template(request):
|
|
|
+ wb = generate_import_template()
|
|
|
+ output = BytesIO()
|
|
|
+ wb.save(output)
|
|
|
+ output.seek(0)
|
|
|
+
|
|
|
+ response = HttpResponse(
|
|
|
+ output.read(),
|
|
|
+ content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
|
+ )
|
|
|
+ response['Content-Disposition'] = 'attachment; filename=资产导入模板.xlsx'
|
|
|
+ return response
|
|
|
+
|
|
|
+
|
|
|
+# ─── 变更记录 ─────────────────────────────────────
|
|
|
+
|
|
|
+@login_required
|
|
|
+def change_log_list(request):
|
|
|
+ logs = AssetChangeLog.objects.select_related('operator', 'asset')
|
|
|
+
|
|
|
+ asset_number = request.GET.get('asset_number', '').strip()
|
|
|
+ if asset_number:
|
|
|
+ logs = logs.filter(asset_number__icontains=asset_number)
|
|
|
+
|
|
|
+ action = request.GET.get('action')
|
|
|
+ if action:
|
|
|
+ logs = logs.filter(action=action)
|
|
|
+
|
|
|
+ paginator = Paginator(logs, 30)
|
|
|
+ page_number = request.GET.get('page', 1)
|
|
|
+ page_obj = paginator.get_page(page_number)
|
|
|
+
|
|
|
+ context = {
|
|
|
+ 'page_obj': page_obj,
|
|
|
+ 'asset_number': asset_number,
|
|
|
+ 'current_action': action,
|
|
|
+ }
|
|
|
+ return render(request, 'assetapp/changelog.html', context)
|
|
|
+
|
|
|
+
|
|
|
+# ─── 分类管理 ─────────────────────────────────────
|
|
|
+
|
|
|
+@login_required
|
|
|
+def category_list(request):
|
|
|
+ categories = Category.objects.annotate(asset_count=Count('assets')).order_by('name')
|
|
|
+ return render(request, 'assetapp/category_list.html', {'categories': categories})
|
|
|
+
|
|
|
+
|
|
|
+@login_required
|
|
|
+def category_create(request):
|
|
|
+ if request.method == 'POST':
|
|
|
+ form = CategoryForm(request.POST)
|
|
|
+ if form.is_valid():
|
|
|
+ form.save()
|
|
|
+ messages.success(request, f'分类 "{form.instance.name}" 创建成功')
|
|
|
+ return redirect('category_list')
|
|
|
+ else:
|
|
|
+ form = CategoryForm()
|
|
|
+
|
|
|
+ return render(request, 'assetapp/category_form.html', {'form': form, 'action': 'create'})
|
|
|
+
|
|
|
+
|
|
|
+@login_required
|
|
|
+def category_update(request, pk):
|
|
|
+ category = get_object_or_404(Category, pk=pk)
|
|
|
+ if request.method == 'POST':
|
|
|
+ form = CategoryForm(request.POST, instance=category)
|
|
|
+ if form.is_valid():
|
|
|
+ form.save()
|
|
|
+ messages.success(request, f'分类 "{category.name}" 已更新')
|
|
|
+ return redirect('category_list')
|
|
|
+ else:
|
|
|
+ form = CategoryForm(instance=category)
|
|
|
+
|
|
|
+ return render(request, 'assetapp/category_form.html', {'form': form, 'action': 'update', 'category': category})
|
|
|
+
|
|
|
+
|
|
|
+@login_required
|
|
|
+def category_delete(request, pk):
|
|
|
+ category = get_object_or_404(Category, pk=pk)
|
|
|
+ asset_count = category.assets.count()
|
|
|
+ if asset_count > 0:
|
|
|
+ messages.error(request, f'分类 "{category.name}" 下还有 {asset_count} 个资产,无法删除')
|
|
|
+ return redirect('category_list')
|
|
|
+
|
|
|
+ if request.method == 'POST':
|
|
|
+ category.delete()
|
|
|
+ messages.success(request, f'分类 "{category.name}" 已删除')
|
|
|
+ return redirect('category_list')
|
|
|
+
|
|
|
+ return render(request, 'assetapp/category_confirm_delete.html', {'category': category})
|