views.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  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(brand__icontains=search) |
  81. Q(model__icontains=search) |
  82. Q(location__icontains=search) |
  83. Q(responsible_person__icontains=search) |
  84. Q(department__icontains=search) |
  85. Q(user__icontains=search)
  86. )
  87. # 筛选
  88. category_id = request.GET.get('category')
  89. if category_id:
  90. queryset = queryset.filter(category_id=category_id)
  91. status = request.GET.get('status')
  92. if status:
  93. queryset = queryset.filter(status=status)
  94. location = request.GET.get('location')
  95. if location:
  96. queryset = queryset.filter(location__icontains=location)
  97. # 排序
  98. sort = request.GET.get('sort', 'id')
  99. valid_sorts = ['id', '-id', 'asset_number', '-asset_number', 'name', '-name',
  100. 'created_at', '-created_at', 'updated_at', '-updated_at',
  101. 'purchase_date', '-purchase_date']
  102. if sort not in valid_sorts:
  103. sort = '-created_at'
  104. queryset = queryset.order_by(sort)
  105. # 分页
  106. paginator = Paginator(queryset, settings.ASSETS_PER_PAGE)
  107. page_number = request.GET.get('page', 1)
  108. page_obj = paginator.get_page(page_number)
  109. categories = Category.objects.all()
  110. locations = Asset.objects.values_list('location', flat=True).exclude(location='').distinct()
  111. context = {
  112. 'page_obj': page_obj,
  113. 'search': search,
  114. 'categories': categories,
  115. 'current_category': category_id,
  116. 'current_status': status,
  117. 'current_location': location,
  118. 'locations': locations,
  119. 'status_map': STATUS_MAP,
  120. 'sort': sort,
  121. }
  122. return render(request, 'assetapp/asset_list.html', context)
  123. # ─── 资产详情 ─────────────────────────────────────
  124. @login_required
  125. def asset_detail(request, pk):
  126. asset = get_object_or_404(Asset.objects.select_related('category', 'created_by'), pk=pk)
  127. change_logs = asset.change_logs.select_related('operator')[:20]
  128. context = {
  129. 'asset': asset,
  130. 'change_logs': change_logs,
  131. 'status_map': STATUS_MAP,
  132. }
  133. return render(request, 'assetapp/asset_detail.html', context)
  134. # ─── 资产创建 ─────────────────────────────────────
  135. @login_required
  136. def asset_create(request):
  137. if request.method == 'POST':
  138. form = AssetForm(request.POST)
  139. if form.is_valid():
  140. asset = form.save(commit=False)
  141. asset.created_by = request.user
  142. asset.save()
  143. AssetChangeLog.objects.create(
  144. asset=asset,
  145. asset_number=asset.asset_number,
  146. action='create',
  147. description='创建资产',
  148. operator=request.user,
  149. )
  150. messages.success(request, f'资产 {asset.asset_number} 创建成功!')
  151. return redirect('asset_detail', pk=asset.pk)
  152. else:
  153. form = AssetForm()
  154. context = {'form': form, 'action': 'create'}
  155. return render(request, 'assetapp/asset_form.html', context)
  156. # ─── 资产编辑 ─────────────────────────────────────
  157. @login_required
  158. def asset_update(request, pk):
  159. asset = get_object_or_404(Asset, pk=pk)
  160. if request.method == 'POST':
  161. form = AssetForm(request.POST, instance=asset)
  162. if form.is_valid():
  163. # 记录变更
  164. changes = []
  165. for field in form.changed_data:
  166. old_val = str(form.initial.get(field, ''))
  167. new_val = str(form.cleaned_data.get(field, ''))
  168. changes.append(f'{field}: {old_val} → {new_val}')
  169. AssetChangeLog.objects.create(
  170. asset=asset,
  171. asset_number=asset.asset_number,
  172. action='update',
  173. field_name=field,
  174. old_value=str(form.initial.get(field, '')),
  175. new_value=str(form.cleaned_data.get(field, '')),
  176. operator=request.user,
  177. )
  178. asset = form.save()
  179. if changes:
  180. messages.success(request, f'已更新 {len(changes)} 个字段')
  181. return redirect('asset_detail', pk=asset.pk)
  182. else:
  183. form = AssetForm(instance=asset)
  184. context = {'form': form, 'action': 'update', 'asset': asset}
  185. return render(request, 'assetapp/asset_form.html', context)
  186. # ─── 资产删除 ─────────────────────────────────────
  187. @login_required
  188. def asset_delete(request, pk):
  189. asset = get_object_or_404(Asset, pk=pk)
  190. if request.method == 'POST':
  191. AssetChangeLog.objects.create(
  192. asset_number=asset.asset_number,
  193. action='delete',
  194. description=f'删除资产: {asset.name}',
  195. operator=request.user,
  196. )
  197. asset.delete()
  198. messages.success(request, f'资产 {asset.asset_number} 已删除')
  199. return redirect('asset_list')
  200. context = {'asset': asset}
  201. return render(request, 'assetapp/asset_confirm_delete.html', context)
  202. # ─── Excel导出 ────────────────────────────────────
  203. @login_required
  204. def asset_export(request):
  205. queryset = Asset.objects.select_related('category')
  206. # 支持筛选导出
  207. category_id = request.GET.get('category')
  208. if category_id:
  209. queryset = queryset.filter(category_id=category_id)
  210. status = request.GET.get('status')
  211. if status:
  212. queryset = queryset.filter(status=status)
  213. wb = export_assets_to_excel(queryset)
  214. output = BytesIO()
  215. wb.save(output)
  216. output.seek(0)
  217. AssetChangeLog.objects.create(
  218. asset_number='-',
  219. action='export',
  220. description=f'导出 {queryset.count()} 条资产记录',
  221. operator=request.user,
  222. )
  223. today_str = date.today().strftime('%Y%m%d')
  224. response = HttpResponse(
  225. output.read(),
  226. content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  227. )
  228. response['Content-Disposition'] = f'attachment; filename=硬件资产_{today_str}.xlsx'
  229. return response
  230. # ─── Excel导入 ────────────────────────────────────
  231. @login_required
  232. def asset_import(request):
  233. if request.method == 'POST':
  234. form = AssetImportForm(request.POST, request.FILES)
  235. if form.is_valid():
  236. try:
  237. wb = load_workbook(request.FILES['excel_file'])
  238. ws = wb.active
  239. category_map = {c.name: c for c in Category.objects.all()}
  240. results = import_assets_from_excel(ws, category_map, operator=request.user)
  241. if results['success'] > 0:
  242. messages.success(request, f"成功导入 {results['success']} 条资产")
  243. if results['skipped'] > 0:
  244. messages.warning(request, f"跳过 {results['skipped']} 条(资产编号重复)")
  245. if results['errors']:
  246. for error in results['errors'][:10]:
  247. messages.error(request, error)
  248. if len(results['errors']) > 10:
  249. messages.error(request, f"...还有 {len(results['errors']) - 10} 条错误")
  250. except Exception as e:
  251. messages.error(request, f'导入失败: {str(e)}')
  252. return redirect('asset_list')
  253. else:
  254. form = AssetImportForm()
  255. return render(request, 'assetapp/asset_import.html', {'form': form})
  256. # ─── 下载导入模板 ──────────────────────────────────
  257. @login_required
  258. def download_template(request):
  259. wb = generate_import_template()
  260. output = BytesIO()
  261. wb.save(output)
  262. output.seek(0)
  263. response = HttpResponse(
  264. output.read(),
  265. content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  266. )
  267. response['Content-Disposition'] = 'attachment; filename=资产导入模板.xlsx'
  268. return response
  269. # ─── 变更记录 ─────────────────────────────────────
  270. @login_required
  271. def change_log_list(request):
  272. logs = AssetChangeLog.objects.select_related('operator', 'asset')
  273. asset_number = request.GET.get('asset_number', '').strip()
  274. if asset_number:
  275. logs = logs.filter(asset_number__icontains=asset_number)
  276. action = request.GET.get('action')
  277. if action:
  278. logs = logs.filter(action=action)
  279. paginator = Paginator(logs, 30)
  280. page_number = request.GET.get('page', 1)
  281. page_obj = paginator.get_page(page_number)
  282. context = {
  283. 'page_obj': page_obj,
  284. 'asset_number': asset_number,
  285. 'current_action': action,
  286. }
  287. return render(request, 'assetapp/changelog.html', context)
  288. # ─── 分类管理 ─────────────────────────────────────
  289. @login_required
  290. def category_list(request):
  291. categories = Category.objects.annotate(asset_count=Count('assets')).order_by('name')
  292. return render(request, 'assetapp/category_list.html', {'categories': categories})
  293. @login_required
  294. def category_create(request):
  295. if request.method == 'POST':
  296. form = CategoryForm(request.POST)
  297. if form.is_valid():
  298. form.save()
  299. messages.success(request, f'分类 "{form.instance.name}" 创建成功')
  300. return redirect('category_list')
  301. else:
  302. form = CategoryForm()
  303. return render(request, 'assetapp/category_form.html', {'form': form, 'action': 'create'})
  304. @login_required
  305. def category_update(request, pk):
  306. category = get_object_or_404(Category, pk=pk)
  307. if request.method == 'POST':
  308. form = CategoryForm(request.POST, instance=category)
  309. if form.is_valid():
  310. form.save()
  311. messages.success(request, f'分类 "{category.name}" 已更新')
  312. return redirect('category_list')
  313. else:
  314. form = CategoryForm(instance=category)
  315. return render(request, 'assetapp/category_form.html', {'form': form, 'action': 'update', 'category': category})
  316. @login_required
  317. def category_delete(request, pk):
  318. category = get_object_or_404(Category, pk=pk)
  319. asset_count = category.assets.count()
  320. if asset_count > 0:
  321. messages.error(request, f'分类 "{category.name}" 下还有 {asset_count} 个资产,无法删除')
  322. return redirect('category_list')
  323. if request.method == 'POST':
  324. category.delete()
  325. messages.success(request, f'分类 "{category.name}" 已删除')
  326. return redirect('category_list')
  327. return render(request, 'assetapp/category_confirm_delete.html', {'category': category})