views.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. from django.shortcuts import render, redirect, get_object_or_404
  2. from django.contrib.auth.decorators import login_required
  3. from django.contrib.auth import authenticate, login, logout
  4. from django.contrib import messages
  5. from django.http import HttpResponse, JsonResponse
  6. from django.db.models import Count, Q
  7. from django.core.paginator import Paginator
  8. from django.conf import settings
  9. from openpyxl import load_workbook
  10. from io import BytesIO
  11. from datetime import date, timedelta
  12. from .models import Asset, Category, AssetChangeLog
  13. from .forms import AssetForm, AssetImportForm, CategoryForm
  14. from .excel_utils import (
  15. export_assets_to_excel, generate_import_template,
  16. import_assets_from_excel, STATUS_MAP,
  17. )
  18. # ─── 认证 ────────────────────────────────────────
  19. def login_view(request):
  20. if request.user.is_authenticated:
  21. return redirect('dashboard')
  22. if request.method == 'POST':
  23. username = request.POST.get('username', '')
  24. password = request.POST.get('password', '')
  25. user = authenticate(request, username=username, password=password)
  26. if user is not None:
  27. login(request, user)
  28. next_url = request.GET.get('next', '/')
  29. return redirect(next_url)
  30. else:
  31. messages.error(request, '用户名或密码错误')
  32. return render(request, 'assetapp/login.html')
  33. def logout_view(request):
  34. logout(request)
  35. return redirect('login')
  36. # ─── 仪表盘 ──────────────────────────────────────
  37. @login_required
  38. def dashboard(request):
  39. total_assets = Asset.objects.count()
  40. status_stats = Asset.objects.values('status').annotate(count=Count('id'))
  41. status_data = {item['status']: item['count'] for item in status_stats}
  42. category_stats = Asset.objects.values('category__name').annotate(
  43. count=Count('id')
  44. ).order_by('-count')
  45. # 即将过保(30天内)
  46. today = date.today()
  47. expiring_soon = Asset.objects.filter(
  48. warranty_expire__lte=today + timedelta(days=30),
  49. warranty_expire__gte=today,
  50. status='in_use',
  51. ).count()
  52. expired = Asset.objects.filter(
  53. warranty_expire__lt=today,
  54. status='in_use',
  55. ).count()
  56. # 最近变更
  57. recent_changes = AssetChangeLog.objects.select_related('operator')[:10]
  58. context = {
  59. 'total_assets': total_assets,
  60. 'status_data': status_data,
  61. 'status_map': STATUS_MAP,
  62. 'category_stats': category_stats,
  63. 'expiring_soon': expiring_soon,
  64. 'expired': expired,
  65. 'recent_changes': recent_changes,
  66. }
  67. return render(request, 'assetapp/dashboard.html', context)
  68. # ─── 资产列表 ─────────────────────────────────────
  69. @login_required
  70. def asset_list(request):
  71. queryset = Asset.objects.select_related('category')
  72. # 搜索(模糊搜索所有字段)
  73. search = request.GET.get('search', '').strip()
  74. if search:
  75. queryset = queryset.filter(
  76. Q(asset_number__icontains=search) |
  77. Q(name__icontains=search) |
  78. Q(serial_number__icontains=search) |
  79. Q(ip_address__icontains=search) |
  80. Q(bmc_address__icontains=search) |
  81. Q(brand__icontains=search) |
  82. Q(model__icontains=search) |
  83. Q(location__icontains=search) |
  84. Q(cabinet__icontains=search) |
  85. Q(cabinet_position__icontains=search) |
  86. Q(responsible_person__icontains=search) |
  87. Q(department__icontains=search) |
  88. Q(user__icontains=search) |
  89. Q(business_type__icontains=search) |
  90. Q(gpu_type__icontains=search) |
  91. Q(gpu_count__icontains=search)
  92. )
  93. # 筛选
  94. category_id = request.GET.get('category')
  95. if category_id:
  96. queryset = queryset.filter(category_id=category_id)
  97. status = request.GET.get('status')
  98. if status:
  99. queryset = queryset.filter(status=status)
  100. cabinet = request.GET.get('cabinet')
  101. if cabinet:
  102. queryset = queryset.filter(cabinet__icontains=cabinet)
  103. business_type = request.GET.get('business_type')
  104. if business_type:
  105. queryset = queryset.filter(business_type=business_type)
  106. # 排序
  107. sort = request.GET.get('sort', 'id')
  108. valid_sorts = ['id', '-id', 'asset_number', '-asset_number', 'name', '-name',
  109. 'created_at', '-created_at', 'updated_at', '-updated_at',
  110. 'purchase_date', '-purchase_date']
  111. if sort not in valid_sorts:
  112. sort = '-created_at'
  113. queryset = queryset.order_by(sort)
  114. # 分页
  115. paginator = Paginator(queryset, settings.ASSETS_PER_PAGE)
  116. page_number = request.GET.get('page', 1)
  117. page_obj = paginator.get_page(page_number)
  118. categories = Category.objects.all()
  119. cabinets = sorted(set(
  120. Asset.objects.values_list('cabinet', flat=True)
  121. .exclude(cabinet='')
  122. .exclude(cabinet=None)
  123. ))
  124. business_types = sorted(set(
  125. Asset.objects.values_list('business_type', flat=True)
  126. .exclude(business_type='')
  127. .exclude(business_type=None)
  128. ))
  129. context = {
  130. 'page_obj': page_obj,
  131. 'search': search,
  132. 'categories': categories,
  133. 'current_category': category_id,
  134. 'current_status': status,
  135. 'current_cabinet': cabinet,
  136. 'current_business_type': business_type,
  137. 'cabinets': cabinets,
  138. 'business_types': business_types,
  139. 'status_map': STATUS_MAP,
  140. 'sort': sort,
  141. }
  142. return render(request, 'assetapp/asset_list.html', context)
  143. # ─── 资产详情 ─────────────────────────────────────
  144. @login_required
  145. def asset_detail(request, pk):
  146. asset = get_object_or_404(Asset.objects.select_related('category', 'created_by'), pk=pk)
  147. change_logs = asset.change_logs.select_related('operator')[:20]
  148. context = {
  149. 'asset': asset,
  150. 'change_logs': change_logs,
  151. 'status_map': STATUS_MAP,
  152. }
  153. return render(request, 'assetapp/asset_detail.html', context)
  154. # ─── 资产创建 ─────────────────────────────────────
  155. @login_required
  156. def asset_create(request):
  157. if request.method == 'POST':
  158. form = AssetForm(request.POST)
  159. if form.is_valid():
  160. asset = form.save(commit=False)
  161. asset.created_by = request.user
  162. asset.save()
  163. AssetChangeLog.objects.create(
  164. asset=asset,
  165. asset_number=asset.asset_number,
  166. action='create',
  167. description='创建资产',
  168. operator=request.user,
  169. )
  170. messages.success(request, f'资产 {asset.asset_number} 创建成功!')
  171. return redirect('asset_detail', pk=asset.pk)
  172. else:
  173. form = AssetForm()
  174. context = {'form': form, 'action': 'create'}
  175. return render(request, 'assetapp/asset_form.html', context)
  176. # ─── 资产编辑 ─────────────────────────────────────
  177. @login_required
  178. def asset_update(request, pk):
  179. asset = get_object_or_404(Asset, pk=pk)
  180. if request.method == 'POST':
  181. form = AssetForm(request.POST, instance=asset)
  182. if form.is_valid():
  183. # 记录变更
  184. changes = []
  185. for field in form.changed_data:
  186. old_val = str(form.initial.get(field, ''))
  187. new_val = str(form.cleaned_data.get(field, ''))
  188. changes.append(f'{field}: {old_val} → {new_val}')
  189. AssetChangeLog.objects.create(
  190. asset=asset,
  191. asset_number=asset.asset_number,
  192. action='update',
  193. field_name=field,
  194. old_value=str(form.initial.get(field, '')),
  195. new_value=str(form.cleaned_data.get(field, '')),
  196. operator=request.user,
  197. )
  198. asset = form.save()
  199. if changes:
  200. messages.success(request, f'已更新 {len(changes)} 个字段')
  201. return redirect('asset_detail', pk=asset.pk)
  202. else:
  203. form = AssetForm(instance=asset)
  204. context = {'form': form, 'action': 'update', 'asset': asset}
  205. return render(request, 'assetapp/asset_form.html', context)
  206. # ─── 资产删除 ─────────────────────────────────────
  207. @login_required
  208. def asset_delete(request, pk):
  209. asset = get_object_or_404(Asset, pk=pk)
  210. if request.method == 'POST':
  211. AssetChangeLog.objects.create(
  212. asset_number=asset.asset_number,
  213. action='delete',
  214. description=f'删除资产: {asset.name}',
  215. operator=request.user,
  216. )
  217. asset.delete()
  218. messages.success(request, f'资产 {asset.asset_number} 已删除')
  219. return redirect('asset_list')
  220. context = {'asset': asset}
  221. return render(request, 'assetapp/asset_confirm_delete.html', context)
  222. # ─── Excel导出 ────────────────────────────────────
  223. @login_required
  224. def asset_export(request):
  225. queryset = Asset.objects.select_related('category')
  226. # 支持筛选导出
  227. category_id = request.GET.get('category')
  228. if category_id:
  229. queryset = queryset.filter(category_id=category_id)
  230. status = request.GET.get('status')
  231. if status:
  232. queryset = queryset.filter(status=status)
  233. wb = export_assets_to_excel(queryset)
  234. output = BytesIO()
  235. wb.save(output)
  236. output.seek(0)
  237. AssetChangeLog.objects.create(
  238. asset_number='-',
  239. action='export',
  240. description=f'导出 {queryset.count()} 条资产记录',
  241. operator=request.user,
  242. )
  243. today_str = date.today().strftime('%Y%m%d')
  244. response = HttpResponse(
  245. output.read(),
  246. content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  247. )
  248. response['Content-Disposition'] = f'attachment; filename=硬件资产_{today_str}.xlsx'
  249. return response
  250. # ─── Excel导入 ────────────────────────────────────
  251. @login_required
  252. def asset_import(request):
  253. if request.method == 'POST':
  254. form = AssetImportForm(request.POST, request.FILES)
  255. if form.is_valid():
  256. try:
  257. wb = load_workbook(request.FILES['excel_file'])
  258. ws = wb.active
  259. category_map = {c.name: c for c in Category.objects.all()}
  260. results = import_assets_from_excel(ws, category_map, operator=request.user)
  261. if results['success'] > 0:
  262. messages.success(request, f"成功导入 {results['success']} 条资产")
  263. if results['skipped'] > 0:
  264. messages.warning(request, f"跳过 {results['skipped']} 条(资产编号重复)")
  265. if results['errors']:
  266. for error in results['errors'][:10]:
  267. messages.error(request, error)
  268. if len(results['errors']) > 10:
  269. messages.error(request, f"...还有 {len(results['errors']) - 10} 条错误")
  270. except Exception as e:
  271. messages.error(request, f'导入失败: {str(e)}')
  272. return redirect('asset_list')
  273. else:
  274. form = AssetImportForm()
  275. return render(request, 'assetapp/asset_import.html', {'form': form})
  276. # ─── 下载导入模板 ──────────────────────────────────
  277. @login_required
  278. def download_template(request):
  279. wb = generate_import_template()
  280. output = BytesIO()
  281. wb.save(output)
  282. output.seek(0)
  283. response = HttpResponse(
  284. output.read(),
  285. content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  286. )
  287. response['Content-Disposition'] = 'attachment; filename=资产导入模板.xlsx'
  288. return response
  289. # ─── 变更记录 ─────────────────────────────────────
  290. @login_required
  291. def change_log_list(request):
  292. logs = AssetChangeLog.objects.select_related('operator', 'asset')
  293. asset_number = request.GET.get('asset_number', '').strip()
  294. if asset_number:
  295. logs = logs.filter(asset_number__icontains=asset_number)
  296. action = request.GET.get('action')
  297. if action:
  298. logs = logs.filter(action=action)
  299. paginator = Paginator(logs, 30)
  300. page_number = request.GET.get('page', 1)
  301. page_obj = paginator.get_page(page_number)
  302. context = {
  303. 'page_obj': page_obj,
  304. 'asset_number': asset_number,
  305. 'current_action': action,
  306. }
  307. return render(request, 'assetapp/changelog.html', context)
  308. # ─── 分类管理 ─────────────────────────────────────
  309. @login_required
  310. def category_list(request):
  311. categories = Category.objects.annotate(asset_count=Count('assets')).order_by('name')
  312. return render(request, 'assetapp/category_list.html', {'categories': categories})
  313. @login_required
  314. def category_create(request):
  315. if request.method == 'POST':
  316. form = CategoryForm(request.POST)
  317. if form.is_valid():
  318. form.save()
  319. messages.success(request, f'分类 "{form.instance.name}" 创建成功')
  320. return redirect('category_list')
  321. else:
  322. form = CategoryForm()
  323. return render(request, 'assetapp/category_form.html', {'form': form, 'action': 'create'})
  324. @login_required
  325. def category_update(request, pk):
  326. category = get_object_or_404(Category, pk=pk)
  327. if request.method == 'POST':
  328. form = CategoryForm(request.POST, instance=category)
  329. if form.is_valid():
  330. form.save()
  331. messages.success(request, f'分类 "{category.name}" 已更新')
  332. return redirect('category_list')
  333. else:
  334. form = CategoryForm(instance=category)
  335. return render(request, 'assetapp/category_form.html', {'form': form, 'action': 'update', 'category': category})
  336. @login_required
  337. def category_delete(request, pk):
  338. category = get_object_or_404(Category, pk=pk)
  339. asset_count = category.assets.count()
  340. if asset_count > 0:
  341. messages.error(request, f'分类 "{category.name}" 下还有 {asset_count} 个资产,无法删除')
  342. return redirect('category_list')
  343. if request.method == 'POST':
  344. category.delete()
  345. messages.success(request, f'分类 "{category.name}" 已删除')
  346. return redirect('category_list')
  347. return render(request, 'assetapp/category_confirm_delete.html', {'category': category})