commit c1565169cdc45b3486b3a099fdddf4f350d21c6b Author: xiaji Date: Mon May 18 17:27:59 2026 +0800 Initial commit: 视频主设备管理系统 - 完整DRF后端项目 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f090e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +venv/ +env/ +*.pyc +__pycache__/ +*.pyo +*.pyd +.Python +*.so +media/ +static/ +db.sqlite3 +*.log +.env +.idea/ +.vscode/ +*.swp +*.swo +*~ diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..f914f76 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..52582b1 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,140 @@ +""" +Django settings for config project. + +Generated by 'django-admin startproject' using Django 5.0.6. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" + +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = 'django-insecure-$6qm&o2(b=hi7snx-dvgcbvbd9p6y%hsk-@=ddv593%r(f)l%%' + +DEBUG = True + +ALLOWED_HOSTS = ['*'] + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_filters', + 'drf_yasg', + 'device_management', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +LANGUAGE_CODE = 'zh-hans' + +TIME_ZONE = 'Asia/Shanghai' + +USE_I18N = True + +USE_TZ = True + +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'static' + +MEDIA_URL = 'media/' +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ], + 'DEFAULT_FILTER_BACKENDS': [ + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', + 'rest_framework.filters.OrderingFilter', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, +} + +SWAGGER_SETTINGS = { + 'SECURITY_DEFINITIONS': { + 'Bearer': { + 'type': 'apiKey', + 'name': 'Authorization', + 'in': 'header' + } + }, + 'USE_SESSION_AUTH': True, +} + +from datetime import timedelta + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': False, + 'BLACKLIST_AFTER_ROTATION': False, +} diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..15c6516 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,40 @@ +""" +URL configuration for config project. +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static + +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, +) + +schema_view = get_schema_view( + openapi.Info( + title="视频主设备管理系统 API", + default_version='v1', + description="视频编码器、解码器、矩阵、NVR等设备管理系统API文档", + terms_of_service="https://www.example.com/terms/", + contact=openapi.Contact(email="contact@example.com"), + license=openapi.License(name="BSD License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) + +urlpatterns = [ + path('admin/', admin.site.urls), + path('api/', include('device_management.urls')), + path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('api-auth/', include('rest_framework.urls')), + path('swagger/', schema_view.without_ui(cache_timeout=0), name='schema-json'), + path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..99d09d2 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/device_management/__init__.py b/device_management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/device_management/admin.py b/device_management/admin.py new file mode 100644 index 0000000..2784b0f --- /dev/null +++ b/device_management/admin.py @@ -0,0 +1,65 @@ +from django.contrib import admin +from .models import ( + Device, DeviceSerial, DeviceIP, MaintenanceRecord, DeviceAttachment +) + + +class DeviceSerialInline(admin.TabularInline): + model = DeviceSerial + extra = 1 + + +class DeviceIPInline(admin.TabularInline): + model = DeviceIP + extra = 1 + + +class MaintenanceRecordInline(admin.TabularInline): + model = MaintenanceRecord + extra = 1 + + +class DeviceAttachmentInline(admin.TabularInline): + model = DeviceAttachment + extra = 1 + + +@admin.register(Device) +class DeviceAdmin(admin.ModelAdmin): + list_display = [ + 'id', 'device_name', 'location', 'building', 'status', + 'responsible_person', 'enable_date', 'service_duration_days' + ] + list_filter = ['status', 'location', 'building'] + search_fields = ['device_name', 'location', 'brand', 'model'] + inlines = [DeviceSerialInline, DeviceIPInline, MaintenanceRecordInline, DeviceAttachmentInline] + date_hierarchy = 'enable_date' + + +@admin.register(DeviceSerial) +class DeviceSerialAdmin(admin.ModelAdmin): + list_display = ['id', 'device', 'serial_number', 'serial_type', 'is_primary'] + list_filter = ['serial_type', 'is_primary'] + search_fields = ['serial_number'] + + +@admin.register(DeviceIP) +class DeviceIPAdmin(admin.ModelAdmin): + list_display = ['id', 'device', 'ip_address', 'ip_type', 'is_primary'] + list_filter = ['ip_type', 'is_primary'] + search_fields = ['ip_address'] + + +@admin.register(MaintenanceRecord) +class MaintenanceRecordAdmin(admin.ModelAdmin): + list_display = ['id', 'device', 'maintenance_date', 'maintenance_by', 'cost'] + list_filter = ['maintenance_date', 'maintenance_by'] + search_fields = ['fault_description', 'repair_content'] + date_hierarchy = 'maintenance_date' + + +@admin.register(DeviceAttachment) +class DeviceAttachmentAdmin(admin.ModelAdmin): + list_display = ['id', 'device', 'file_name', 'file_type', 'uploaded_at'] + list_filter = ['file_type', 'uploaded_at'] + search_fields = ['file_name'] diff --git a/device_management/apps.py b/device_management/apps.py new file mode 100644 index 0000000..46acc9f --- /dev/null +++ b/device_management/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DeviceManagementConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'device_management' diff --git a/device_management/migrations/0001_initial.py b/device_management/migrations/0001_initial.py new file mode 100644 index 0000000..f113951 --- /dev/null +++ b/device_management/migrations/0001_initial.py @@ -0,0 +1,120 @@ +# Generated by Django 5.2.14 on 2026-05-18 09:21 + +import device_management.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Device', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('location', models.CharField(max_length=100, verbose_name='地点')), + ('building', models.CharField(blank=True, max_length=50, null=True, verbose_name='楼栋')), + ('floor', models.CharField(blank=True, max_length=20, null=True, verbose_name='楼层')), + ('cabinet', models.CharField(blank=True, max_length=50, null=True, verbose_name='机柜')), + ('device_name', models.CharField(max_length=100, verbose_name='设备名称')), + ('model', models.CharField(blank=True, max_length=100, null=True, verbose_name='型号')), + ('brand', models.CharField(blank=True, max_length=100, null=True, verbose_name='品牌')), + ('mac_address', models.CharField(blank=True, max_length=17, null=True, verbose_name='MAC地址')), + ('enable_date', models.DateField(blank=True, null=True, verbose_name='启用日期')), + ('last_inspection_date', models.DateField(blank=True, null=True, verbose_name='最后巡检日期')), + ('thumbnail', models.ImageField(blank=True, null=True, upload_to=device_management.models.device_thumbnail_path, verbose_name='缩略图')), + ('status', models.CharField(choices=[('normal', '正常'), ('warning', '告警'), ('offline', '离线'), ('repair', '维修中'), ('scrap', '已报废')], default='normal', max_length=20, verbose_name='状态')), + ('responsible_person', models.CharField(blank=True, max_length=50, null=True, verbose_name='负责人')), + ('warranty_expire', models.DateField(blank=True, null=True, verbose_name='保修到期日期')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '设备', + 'verbose_name_plural': '设备', + 'db_table': 'device', + 'indexes': [models.Index(fields=['location', 'building'], name='device_locatio_6a4f0a_idx'), models.Index(fields=['status'], name='device_status_6321a6_idx')], + }, + ), + migrations.CreateModel( + name='DeviceAttachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=device_management.models.device_attachment_path, verbose_name='文件')), + ('file_name', models.CharField(max_length=255, verbose_name='文件名')), + ('file_type', models.CharField(blank=True, max_length=50, null=True, verbose_name='文件类型')), + ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='上传时间')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='device_management.device', verbose_name='设备')), + ], + options={ + 'verbose_name': '设备附件', + 'verbose_name_plural': '设备附件', + 'db_table': 'device_attachment', + }, + ), + migrations.CreateModel( + name='DeviceIP', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip_address', models.CharField(max_length=45, unique=True, verbose_name='IP地址')), + ('ip_type', models.CharField(default='management', max_length=20, verbose_name='IP类型')), + ('is_primary', models.BooleanField(default=False, verbose_name='是否主IP')), + ('subnet_mask', models.CharField(blank=True, max_length=15, null=True, verbose_name='子网掩码')), + ('gateway', models.CharField(blank=True, max_length=45, null=True, verbose_name='网关')), + ('vlan_id', models.CharField(blank=True, max_length=10, null=True, verbose_name='VLAN ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ips', to='device_management.device', verbose_name='设备')), + ], + options={ + 'verbose_name': '设备IP地址', + 'verbose_name_plural': '设备IP地址', + 'db_table': 'device_ip', + }, + ), + migrations.CreateModel( + name='DeviceSerial', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('serial_number', models.CharField(max_length=100, unique=True, verbose_name='序列号')), + ('serial_type', models.CharField(default='main', max_length=50, verbose_name='序列号类型')), + ('is_primary', models.BooleanField(default=False, verbose_name='是否主序列号')), + ('remark', models.CharField(blank=True, max_length=255, null=True, verbose_name='备注')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='serials', to='device_management.device', verbose_name='设备')), + ], + options={ + 'verbose_name': '设备序列号', + 'verbose_name_plural': '设备序列号', + 'db_table': 'device_serial', + }, + ), + migrations.CreateModel( + name='MaintenanceRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('maintenance_date', models.DateField(verbose_name='维修日期')), + ('fault_description', models.TextField(blank=True, null=True, verbose_name='故障描述')), + ('repair_content', models.TextField(blank=True, null=True, verbose_name='维修内容')), + ('replaced_parts', models.CharField(blank=True, max_length=255, null=True, verbose_name='更换配件')), + ('maintenance_by', models.CharField(blank=True, max_length=50, null=True, verbose_name='维修人')), + ('cost', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='费用')), + ('remark', models.TextField(blank=True, null=True, verbose_name='备注')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='maintenance_records', to='device_management.device', verbose_name='设备')), + ], + options={ + 'verbose_name': '维修记录', + 'verbose_name_plural': '维修记录', + 'db_table': 'maintenance_record', + 'ordering': ['-maintenance_date'], + }, + ), + ] diff --git a/device_management/migrations/__init__.py b/device_management/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/device_management/models.py b/device_management/models.py new file mode 100644 index 0000000..e8823e4 --- /dev/null +++ b/device_management/models.py @@ -0,0 +1,170 @@ +import os +from datetime import date +from django.db import models +from django.utils.translation import gettext_lazy as _ +from PIL import Image +from io import BytesIO +from django.core.files.base import ContentFile +from loguru import logger + + +class DeviceStatus(models.TextChoices): + NORMAL = 'normal', _('正常') + WARNING = 'warning', _('告警') + OFFLINE = 'offline', _('离线') + REPAIR = 'repair', _('维修中') + SCRAP = 'scrap', _('已报废') + + +def device_attachment_path(instance, filename): + return f'devices/{instance.id}/attachments/{filename}' + + +def device_thumbnail_path(instance, filename): + return f'devices/{instance.id}/thumbnails/{filename}' + + +class Device(models.Model): + location = models.CharField(max_length=100, verbose_name='地点') + building = models.CharField(max_length=50, blank=True, null=True, verbose_name='楼栋') + floor = models.CharField(max_length=20, blank=True, null=True, verbose_name='楼层') + cabinet = models.CharField(max_length=50, blank=True, null=True, verbose_name='机柜') + device_name = models.CharField(max_length=100, verbose_name='设备名称') + model = models.CharField(max_length=100, blank=True, null=True, verbose_name='型号') + brand = models.CharField(max_length=100, blank=True, null=True, verbose_name='品牌') + mac_address = models.CharField(max_length=17, blank=True, null=True, verbose_name='MAC地址') + enable_date = models.DateField(blank=True, null=True, verbose_name='启用日期') + last_inspection_date = models.DateField(blank=True, null=True, verbose_name='最后巡检日期') + thumbnail = models.ImageField(upload_to=device_thumbnail_path, blank=True, null=True, verbose_name='缩略图') + status = models.CharField( + max_length=20, + choices=DeviceStatus.choices, + default=DeviceStatus.NORMAL, + verbose_name='状态' + ) + responsible_person = models.CharField(max_length=50, blank=True, null=True, verbose_name='负责人') + warranty_expire = models.DateField(blank=True, null=True, verbose_name='保修到期日期') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'device' + verbose_name = '设备' + verbose_name_plural = '设备' + indexes = [ + models.Index(fields=['location', 'building']), + models.Index(fields=['status']), + ] + + def __str__(self): + return f'{self.device_name} - {self.location}' + + @property + def service_duration_days(self): + if self.enable_date: + return (date.today() - self.enable_date).days + return 0 + + @property + def is_warranty_expired(self): + if self.warranty_expire: + return date.today() > self.warranty_expire + return False + + def generate_thumbnail(self, image_field, size=(200, 200)): + if not image_field: + return + + try: + img = Image.open(image_field) + if img.mode in ('RGBA', 'P'): + img = img.convert('RGB') + + img.thumbnail(size) + thumb_io = BytesIO() + img.save(thumb_io, format='JPEG', quality=85) + + thumb_name = f'thumb_{os.path.basename(image_field.name)}' + self.thumbnail.save(thumb_name, ContentFile(thumb_io.getvalue()), save=False) + logger.info(f'Generated thumbnail for device {self.id}') + except Exception as e: + logger.error(f'Failed to generate thumbnail: {e}') + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + +class DeviceSerial(models.Model): + device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name='serials', verbose_name='设备') + serial_number = models.CharField(max_length=100, unique=True, verbose_name='序列号') + serial_type = models.CharField(max_length=50, default='main', verbose_name='序列号类型') + is_primary = models.BooleanField(default=False, verbose_name='是否主序列号') + remark = models.CharField(max_length=255, blank=True, null=True, verbose_name='备注') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'device_serial' + verbose_name = '设备序列号' + verbose_name_plural = '设备序列号' + + def __str__(self): + return f'{self.serial_number} ({self.serial_type})' + + +class DeviceIP(models.Model): + device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name='ips', verbose_name='设备') + ip_address = models.CharField(max_length=45, unique=True, verbose_name='IP地址') + ip_type = models.CharField(max_length=20, default='management', verbose_name='IP类型') + is_primary = models.BooleanField(default=False, verbose_name='是否主IP') + subnet_mask = models.CharField(max_length=15, blank=True, null=True, verbose_name='子网掩码') + gateway = models.CharField(max_length=45, blank=True, null=True, verbose_name='网关') + vlan_id = models.CharField(max_length=10, blank=True, null=True, verbose_name='VLAN ID') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'device_ip' + verbose_name = '设备IP地址' + verbose_name_plural = '设备IP地址' + + def __str__(self): + return f'{self.ip_address} ({self.ip_type})' + + +class MaintenanceRecord(models.Model): + device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name='maintenance_records', verbose_name='设备') + maintenance_date = models.DateField(verbose_name='维修日期') + fault_description = models.TextField(blank=True, null=True, verbose_name='故障描述') + repair_content = models.TextField(blank=True, null=True, verbose_name='维修内容') + replaced_parts = models.CharField(max_length=255, blank=True, null=True, verbose_name='更换配件') + maintenance_by = models.CharField(max_length=50, blank=True, null=True, verbose_name='维修人') + cost = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True, verbose_name='费用') + remark = models.TextField(blank=True, null=True, verbose_name='备注') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'maintenance_record' + verbose_name = '维修记录' + verbose_name_plural = '维修记录' + ordering = ['-maintenance_date'] + + def __str__(self): + return f'{self.device.device_name} - {self.maintenance_date}' + + +class DeviceAttachment(models.Model): + device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name='attachments', verbose_name='设备') + file = models.FileField(upload_to=device_attachment_path, verbose_name='文件') + file_name = models.CharField(max_length=255, verbose_name='文件名') + file_type = models.CharField(max_length=50, blank=True, null=True, verbose_name='文件类型') + uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name='上传时间') + + class Meta: + db_table = 'device_attachment' + verbose_name = '设备附件' + verbose_name_plural = '设备附件' + + def __str__(self): + return self.file_name diff --git a/device_management/serializers.py b/device_management/serializers.py new file mode 100644 index 0000000..b10b605 --- /dev/null +++ b/device_management/serializers.py @@ -0,0 +1,138 @@ +from rest_framework import serializers +from django.db import transaction +from .models import ( + Device, DeviceSerial, DeviceIP, MaintenanceRecord, DeviceAttachment +) +from loguru import logger + + +class DeviceSerialSerializer(serializers.ModelSerializer): + class Meta: + model = DeviceSerial + fields = ['id', 'serial_number', 'serial_type', 'is_primary', 'remark'] + + +class DeviceIPSerializer(serializers.ModelSerializer): + class Meta: + model = DeviceIP + fields = ['id', 'ip_address', 'ip_type', 'is_primary', 'subnet_mask', 'gateway', 'vlan_id'] + + +class MaintenanceRecordSerializer(serializers.ModelSerializer): + class Meta: + model = MaintenanceRecord + fields = ['id', 'maintenance_date', 'fault_description', 'repair_content', + 'replaced_parts', 'maintenance_by', 'cost', 'remark'] + + +class DeviceAttachmentSerializer(serializers.ModelSerializer): + class Meta: + model = DeviceAttachment + fields = ['id', 'file', 'file_name', 'file_type', 'uploaded_at'] + read_only_fields = ['uploaded_at'] + + +class DeviceSerializer(serializers.ModelSerializer): + serials = DeviceSerialSerializer(many=True, required=False) + ips = DeviceIPSerializer(many=True, required=False) + maintenance_records = MaintenanceRecordSerializer(many=True, read_only=True) + attachments = DeviceAttachmentSerializer(many=True, read_only=True) + service_duration_days = serializers.IntegerField(read_only=True) + is_warranty_expired = serializers.BooleanField(read_only=True) + primary_serial = serializers.SerializerMethodField() + primary_ip = serializers.SerializerMethodField() + latest_maintenance = serializers.SerializerMethodField() + + class Meta: + model = Device + fields = ['id', 'location', 'building', 'floor', 'cabinet', 'device_name', + 'model', 'brand', 'mac_address', 'enable_date', 'last_inspection_date', + 'thumbnail', 'status', 'responsible_person', 'warranty_expire', + 'service_duration_days', 'is_warranty_expired', 'primary_serial', + 'primary_ip', 'latest_maintenance', 'serials', 'ips', + 'maintenance_records', 'attachments', 'created_at', 'updated_at'] + read_only_fields = ['created_at', 'updated_at'] + + def get_primary_serial(self, obj): + primary = obj.serials.filter(is_primary=True).first() + if primary: + return DeviceSerialSerializer(primary).data + return None + + def get_primary_ip(self, obj): + primary = obj.ips.filter(is_primary=True).first() + if primary: + return DeviceIPSerializer(primary).data + return None + + def get_latest_maintenance(self, obj): + latest = obj.maintenance_records.first() + if latest: + return MaintenanceRecordSerializer(latest).data + return None + + @transaction.atomic + def create(self, validated_data): + serials_data = validated_data.pop('serials', []) + ips_data = validated_data.pop('ips', []) + + device = Device.objects.create(**validated_data) + + for serial_data in serials_data: + DeviceSerial.objects.create(device=device, **serial_data) + + for ip_data in ips_data: + DeviceIP.objects.create(device=device, **ip_data) + + logger.info(f'Created device: {device.id} - {device.device_name}') + return device + + @transaction.atomic + def update(self, instance, validated_data): + serials_data = validated_data.pop('serials', None) + ips_data = validated_data.pop('ips', None) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + if serials_data is not None: + instance.serials.all().delete() + for serial_data in serials_data: + DeviceSerial.objects.create(device=instance, **serial_data) + + if ips_data is not None: + instance.ips.all().delete() + for ip_data in ips_data: + DeviceIP.objects.create(device=instance, **ip_data) + + logger.info(f'Updated device: {instance.id}') + return instance + + +class DeviceListSerializer(serializers.ModelSerializer): + primary_serial_number = serializers.SerializerMethodField() + primary_ip_address = serializers.SerializerMethodField() + latest_maintenance_summary = serializers.SerializerMethodField() + service_duration_days = serializers.IntegerField(read_only=True) + + class Meta: + model = Device + fields = ['id', 'location', 'building', 'device_name', 'model', 'brand', + 'status', 'responsible_person', 'enable_date', 'thumbnail', + 'primary_serial_number', 'primary_ip_address', + 'latest_maintenance_summary', 'service_duration_days'] + + def get_primary_serial_number(self, obj): + primary = obj.serials.filter(is_primary=True).first() + return primary.serial_number if primary else None + + def get_primary_ip_address(self, obj): + primary = obj.ips.filter(is_primary=True).first() + return primary.ip_address if primary else None + + def get_latest_maintenance_summary(self, obj): + latest = obj.maintenance_records.first() + if latest: + return f"{latest.maintenance_date}: {latest.fault_description[:50]}..." if latest.fault_description else str(latest.maintenance_date) + return None diff --git a/device_management/tests.py b/device_management/tests.py new file mode 100644 index 0000000..de8bdc0 --- /dev/null +++ b/device_management/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/device_management/urls.py b/device_management/urls.py new file mode 100644 index 0000000..45fea90 --- /dev/null +++ b/device_management/urls.py @@ -0,0 +1,17 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import ( + DeviceViewSet, DeviceSerialViewSet, DeviceIPViewSet, + MaintenanceRecordViewSet, DeviceAttachmentViewSet +) + +router = DefaultRouter() +router.register(r'devices', DeviceViewSet, basename='device') +router.register(r'serials', DeviceSerialViewSet, basename='deviceserial') +router.register(r'ips', DeviceIPViewSet, basename='deviceip') +router.register(r'maintenance', MaintenanceRecordViewSet, basename='maintenancerecord') +router.register(r'attachments', DeviceAttachmentViewSet, basename='deviceattachment') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/device_management/views.py b/device_management/views.py new file mode 100644 index 0000000..9be157e --- /dev/null +++ b/device_management/views.py @@ -0,0 +1,210 @@ +import os +from datetime import date +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from django_filters import rest_framework as filters +from rest_framework import viewsets, status, parsers, renderers +from rest_framework.decorators import action, api_view, parser_classes +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticatedOrReadOnly, IsAuthenticated +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Font +import pandas as pd +from loguru import logger + +from .models import ( + Device, DeviceSerial, DeviceIP, MaintenanceRecord, DeviceAttachment +) +from .serializers import ( + DeviceSerializer, DeviceListSerializer, DeviceSerialSerializer, + DeviceIPSerializer, MaintenanceRecordSerializer, DeviceAttachmentSerializer +) + + +class DeviceFilter(filters.FilterSet): + location = filters.CharFilter(lookup_expr='icontains') + building = filters.CharFilter(lookup_expr='icontains') + from .models import DeviceStatus + status = filters.ChoiceFilter(choices=DeviceStatus.choices) + device_name = filters.CharFilter(lookup_expr='icontains') + brand = filters.CharFilter(lookup_expr='icontains') + enable_date_from = filters.DateFilter(field_name='enable_date', lookup_expr='gte') + enable_date_to = filters.DateFilter(field_name='enable_date', lookup_expr='lte') + warranty_expire_soon = filters.BooleanFilter(method='filter_warranty_expire_soon') + + class Meta: + model = Device + fields = ['location', 'building', 'status', 'device_name', 'brand'] + + def filter_warranty_expire_soon(self, queryset, name, value): + if value: + from datetime import timedelta + soon_date = date.today() + timedelta(days=30) + return queryset.filter(warranty_expire__lte=soon_date, warranty_expire__gte=date.today()) + return queryset + + +class DeviceViewSet(viewsets.ModelViewSet): + queryset = Device.objects.all().prefetch_related('serials', 'ips', 'maintenance_records') + serializer_class = DeviceSerializer + filterset_class = DeviceFilter + search_fields = ['device_name', 'location', 'building', 'brand', 'model'] + ordering_fields = ['id', 'device_name', 'location', 'enable_date', 'created_at'] + permission_classes = [IsAuthenticatedOrReadOnly] + + def get_serializer_class(self): + if self.action == 'list': + return DeviceListSerializer + return DeviceSerializer + + @action(detail=True, methods=['post'], parser_classes=[parsers.MultiPartParser]) + def upload_attachment(self, request, pk=None): + device = self.get_object() + file_obj = request.FILES.get('file') + + if not file_obj: + return Response({'error': 'No file provided'}, status=status.HTTP_400_BAD_REQUEST) + + file_type = request.data.get('file_type', '') + attachment = DeviceAttachment.objects.create( + device=device, + file=file_obj, + file_name=file_obj.name, + file_type=file_type + ) + + if file_type.startswith('image/') or file_obj.name.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp')): + device.generate_thumbnail(file_obj) + device.save() + + logger.info(f'Uploaded attachment {attachment.id} for device {device.id}') + return Response(DeviceAttachmentSerializer(attachment).data, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=['get']) + def export_excel(self, request): + queryset = self.filter_queryset(self.get_queryset()) + + wb = Workbook() + ws = wb.active + ws.title = '设备列表' + + headers = ['ID', '地点', '楼栋', '设备名称', '型号', '品牌', '状态', + '负责人', '启用日期', '主序列号', '主IP', '最近维修简述', '服役天数'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = Font(bold=True) + + for row, device in enumerate(queryset, 2): + primary_serial = device.serials.filter(is_primary=True).first() + primary_ip = device.ips.filter(is_primary=True).first() + latest_maintenance = device.maintenance_records.first() + + ws.cell(row=row, column=1, value=device.id) + ws.cell(row=row, column=2, value=device.location) + ws.cell(row=row, column=3, value=device.building or '') + ws.cell(row=row, column=4, value=device.device_name) + ws.cell(row=row, column=5, value=device.model or '') + ws.cell(row=row, column=6, value=device.brand or '') + ws.cell(row=row, column=7, value=device.get_status_display()) + ws.cell(row=row, column=8, value=device.responsible_person or '') + ws.cell(row=row, column=9, value=str(device.enable_date) if device.enable_date else '') + ws.cell(row=row, column=10, value=primary_serial.serial_number if primary_serial else '') + ws.cell(row=row, column=11, value=primary_ip.ip_address if primary_ip else '') + ws.cell(row=row, column=12, value=str(latest_maintenance) if latest_maintenance else '') + ws.cell(row=row, column=13, value=device.service_duration_days) + + response = HttpResponse(content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + response['Content-Disposition'] = f'attachment; filename="devices_{date.today()}.xlsx"' + wb.save(response) + + logger.info(f'Exported {queryset.count()} devices to Excel') + return response + + @action(detail=False, methods=['post'], parser_classes=[parsers.MultiPartParser]) + def import_excel(self, request): + file_obj = request.FILES.get('file') + if not file_obj: + return Response({'error': 'No file provided'}, status=status.HTTP_400_BAD_REQUEST) + + try: + df = pd.read_excel(file_obj) + created_count = 0 + + for _, row in df.iterrows(): + device_data = { + 'location': str(row.get('地点', '')), + 'building': str(row.get('楼栋', '')) if pd.notna(row.get('楼栋')) else None, + 'device_name': str(row.get('设备名称', '')), + 'model': str(row.get('型号', '')) if pd.notna(row.get('型号')) else None, + 'brand': str(row.get('品牌', '')) if pd.notna(row.get('品牌')) else None, + 'status': str(row.get('状态', 'normal')) if pd.notna(row.get('状态')) else 'normal', + 'responsible_person': str(row.get('负责人', '')) if pd.notna(row.get('负责人')) else None, + } + + device = Device.objects.create(**device_data) + + if pd.notna(row.get('主序列号')): + DeviceSerial.objects.create( + device=device, + serial_number=str(row.get('主序列号')), + serial_type='main', + is_primary=True + ) + + if pd.notna(row.get('主IP')): + DeviceIP.objects.create( + device=device, + ip_address=str(row.get('主IP')), + ip_type='management', + is_primary=True + ) + + created_count += 1 + + logger.info(f'Imported {created_count} devices from Excel') + return Response({'message': f'成功导入 {created_count} 台设备', 'count': created_count}) + + except Exception as e: + logger.error(f'Excel import failed: {e}') + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class DeviceSerialViewSet(viewsets.ModelViewSet): + queryset = DeviceSerial.objects.all() + serializer_class = DeviceSerialSerializer + search_fields = ['serial_number', 'serial_type'] + permission_classes = [IsAuthenticatedOrReadOnly] + + +class DeviceIPViewSet(viewsets.ModelViewSet): + queryset = DeviceIP.objects.all() + serializer_class = DeviceIPSerializer + search_fields = ['ip_address', 'ip_type'] + permission_classes = [IsAuthenticatedOrReadOnly] + + +class MaintenanceRecordViewSet(viewsets.ModelViewSet): + queryset = MaintenanceRecord.objects.all() + serializer_class = MaintenanceRecordSerializer + filterset_fields = ['device_id', 'maintenance_by'] + search_fields = ['fault_description', 'repair_content', 'maintenance_by'] + permission_classes = [IsAuthenticatedOrReadOnly] + + +class DeviceAttachmentViewSet(viewsets.ModelViewSet): + queryset = DeviceAttachment.objects.all() + serializer_class = DeviceAttachmentSerializer + parser_classes = [parsers.MultiPartParser, parsers.FormParser] + permission_classes = [IsAuthenticatedOrReadOnly] + + def perform_create(self, serializer): + device_id = self.request.data.get('device') + device = get_object_or_404(Device, id=device_id) + file_obj = self.request.FILES.get('file') + + attachment = serializer.save( + device=device, + file_name=file_obj.name if file_obj else '' + ) + + logger.info(f'Created attachment {attachment.id}') diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..192aaab --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3f6eb78 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +Django==4.2.11 +djangorestframework==3.15.1 +djangorestframework-simplejwt==5.3.1 +drf-yasg==1.21.7 +Pillow==10.3.0 +mysqlclient==2.2.4 +django-filter==24.2 +openpyxl==3.1.2 +pandas==2.2.2 +numpy==1.26.4 +loguru==0.7.2