435 lines
18 KiB
Python
435 lines
18 KiB
Python
import io
|
||
from collections import Counter
|
||
from django.db.models import Count, Q
|
||
from django.http import HttpResponse
|
||
from rest_framework import viewsets, permissions, generics, status
|
||
from rest_framework.decorators import action
|
||
from rest_framework.response import Response
|
||
from rest_framework.views import APIView
|
||
from rest_framework.filters import SearchFilter
|
||
from rest_framework.pagination import PageNumberPagination
|
||
from django_filters.rest_framework import DjangoFilterBackend
|
||
import openpyxl
|
||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||
from openpyxl.worksheet.datavalidation import DataValidation
|
||
from .models import Job, JobFavorite
|
||
from .serializers import JobListSerializer, JobDetailSerializer, JobFavoriteSerializer
|
||
from .filters import JobFilter
|
||
from apps.accounts.permissions import IsAdminOrSuperAdmin, IsSeeker
|
||
from apps.organizations.models import Organization
|
||
from apps.applications.models import Application
|
||
from .school_data import classify_school
|
||
|
||
|
||
class JobPublicPagination(PageNumberPagination):
|
||
page_size = 20
|
||
page_size_query_param = 'page_size'
|
||
max_page_size = 500
|
||
|
||
|
||
class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
||
"""公开只读,仅返回已发布职位"""
|
||
queryset = Job.objects.filter(status='published').select_related('organization')
|
||
filterset_class = JobFilter
|
||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||
search_fields = ['title', 'description', 'location']
|
||
permission_classes = [permissions.AllowAny]
|
||
pagination_class = JobPublicPagination
|
||
|
||
def get_serializer_class(self):
|
||
if self.action == 'retrieve':
|
||
return JobDetailSerializer
|
||
return JobListSerializer
|
||
|
||
@action(detail=True, methods=['post'], permission_classes=[IsSeeker])
|
||
def favorite(self, request, pk=None):
|
||
job = self.get_object()
|
||
fav, created = JobFavorite.objects.get_or_create(user=request.user, job=job)
|
||
if not created:
|
||
fav.delete()
|
||
return Response({'collected': False})
|
||
return Response({'collected': True})
|
||
|
||
|
||
class MyFavoritesView(generics.ListAPIView):
|
||
"""求职者的收藏列表"""
|
||
serializer_class = JobFavoriteSerializer
|
||
permission_classes = [IsSeeker]
|
||
|
||
def get_queryset(self):
|
||
return JobFavorite.objects.filter(user=self.request.user).select_related(
|
||
'job', 'job__organization'
|
||
)
|
||
|
||
|
||
class JobManageViewSet(viewsets.ModelViewSet):
|
||
"""管理端:公司管理员管理本公司职位"""
|
||
permission_classes = [IsAdminOrSuperAdmin]
|
||
|
||
def get_serializer_class(self):
|
||
if self.action in ['retrieve', 'create', 'update', 'partial_update']:
|
||
return JobDetailSerializer
|
||
return JobListSerializer
|
||
|
||
def get_queryset(self):
|
||
user = self.request.user
|
||
if user.is_superadmin:
|
||
return Job.objects.all().select_related('organization')
|
||
# 防御 organization 为空的情况
|
||
if not user.organization_id:
|
||
return Job.objects.none()
|
||
return Job.objects.filter(organization=user.organization).select_related('organization')
|
||
|
||
def perform_create(self, serializer):
|
||
if self.request.user.is_admin:
|
||
# Admin 强制使用自己公司,忽略请求体中的 organization_id
|
||
serializer.save(organization=self.request.user.organization)
|
||
else:
|
||
# 超管需要在请求体中提供 organization_id
|
||
serializer.save()
|
||
|
||
@action(detail=False, methods=['get'], url_path='template')
|
||
def download_template(self, request):
|
||
"""下载职位导入Excel模板"""
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = '职位导入模板'
|
||
|
||
# 表头定义: (列名, 是否必填, 列宽, 示例值)
|
||
columns = [
|
||
('职位名称', True, 20, 'Python开发工程师'),
|
||
('职位类别', True, 15, '技术'),
|
||
('工作地点', True, 15, '北京'),
|
||
('薪资范围', True, 15, '15k-25k'),
|
||
('学历要求', True, 15, '本科及以下'),
|
||
('招聘人数', False, 12, '3'),
|
||
('职位描述', False, 40, '负责后端系统开发与维护'),
|
||
('状态', False, 12, '已发布'),
|
||
]
|
||
if request.user.is_superadmin:
|
||
columns.insert(0, ('所属公司名称', True, 25, '华远科技有限公司'))
|
||
|
||
col_map_by_name = {name: idx for idx, (name, _, _, _) in enumerate(columns, 1)}
|
||
|
||
header_font = Font(bold=True, color='FFFFFF', size=11)
|
||
required_fill = PatternFill(start_color='2196F3', end_color='2196F3', fill_type='solid')
|
||
optional_fill = PatternFill(start_color='90CAF9', end_color='90CAF9', fill_type='solid')
|
||
thin_border = Border(
|
||
left=Side(style='thin'), right=Side(style='thin'),
|
||
top=Side(style='thin'), bottom=Side(style='thin'),
|
||
)
|
||
|
||
for col_idx, (name, required, width, _) in enumerate(columns, 1):
|
||
cell = ws.cell(row=1, column=col_idx, value=f'{name}(必填)' if required else name)
|
||
cell.font = header_font
|
||
cell.fill = required_fill if required else optional_fill
|
||
cell.alignment = Alignment(horizontal='center')
|
||
cell.border = thin_border
|
||
ws.column_dimensions[openpyxl.utils.get_column_letter(col_idx)].width = width
|
||
|
||
# 写入示例数据行
|
||
for col_idx, (_, _, _, example) in enumerate(columns, 1):
|
||
cell = ws.cell(row=2, column=col_idx, value=example)
|
||
cell.border = thin_border
|
||
|
||
# 数据验证:学历要求下拉
|
||
edu_col = openpyxl.utils.get_column_letter(col_map_by_name['学历要求'])
|
||
edu_dv = DataValidation(type='list', formula1='"博士,硕士,本科及以下"', allow_blank=False)
|
||
edu_dv.error = '请选择:博士、硕士、本科及以下'
|
||
edu_dv.errorTitle = '学历要求错误'
|
||
ws.add_data_validation(edu_dv)
|
||
edu_dv.add(f'{edu_col}2:{edu_col}500')
|
||
|
||
# 数据验证:状态下拉
|
||
status_col = openpyxl.utils.get_column_letter(col_map_by_name['状态'])
|
||
status_dv = DataValidation(type='list', formula1='"草稿,已发布"', allow_blank=True)
|
||
status_dv.error = '请选择:草稿、已发布'
|
||
status_dv.errorTitle = '状态错误'
|
||
ws.add_data_validation(status_dv)
|
||
status_dv.add(f'{status_col}2:{status_col}500')
|
||
|
||
# 数据验证:所属公司下拉(超管)
|
||
if request.user.is_superadmin:
|
||
org_names = list(Organization.objects.values_list('name', flat=True).order_by('name'))
|
||
if org_names:
|
||
# 将公司列表写入隐藏sheet,用名称引用实现下拉
|
||
org_ws = wb.create_sheet('_org_data')
|
||
for i, name in enumerate(org_names, 1):
|
||
org_ws.cell(row=i, column=1, value=name)
|
||
org_ws.sheet_state = 'hidden'
|
||
|
||
org_col = openpyxl.utils.get_column_letter(col_map_by_name['所属公司名称'])
|
||
org_dv = DataValidation(
|
||
type='list',
|
||
formula1=f"'_org_data'!$A$1:$A${len(org_names)}",
|
||
allow_blank=False
|
||
)
|
||
org_dv.error = '请从下拉列表中选择公司,不可填写系统外的公司'
|
||
org_dv.errorTitle = '公司名称错误'
|
||
ws.add_data_validation(org_dv)
|
||
org_dv.add(f'{org_col}2:{org_col}500')
|
||
|
||
# 添加说明 sheet
|
||
info_ws = wb.create_sheet('填写说明')
|
||
info_ws.column_dimensions['A'].width = 20
|
||
info_ws.column_dimensions['B'].width = 60
|
||
notes = [
|
||
('字段', '说明'),
|
||
('职位名称', '必填,最多100个字符'),
|
||
('职位类别', '必填,如:技术、产品、设计、运营、市场等'),
|
||
('工作地点', '必填,如:北京、上海、深圳'),
|
||
('薪资范围', '必填,如:15k-25k、面议'),
|
||
('学历要求', '必填,可选值:博士、硕士、本科及以下'),
|
||
('招聘人数', '选填,默认为1,填写正整数'),
|
||
('职位描述', '选填,职位的详细描述'),
|
||
('状态', '选填,可选值:草稿、已发布,默认为草稿'),
|
||
]
|
||
if request.user.is_superadmin:
|
||
notes.insert(1, ('所属公司名称', '超管必填,填写系统中已有的公司名称'))
|
||
for row_idx, (field, desc) in enumerate(notes, 1):
|
||
a = info_ws.cell(row=row_idx, column=1, value=field)
|
||
b = info_ws.cell(row=row_idx, column=2, value=desc)
|
||
if row_idx == 1:
|
||
a.font = Font(bold=True)
|
||
b.font = Font(bold=True)
|
||
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
buf.seek(0)
|
||
response = HttpResponse(
|
||
buf.getvalue(),
|
||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
)
|
||
response['Content-Disposition'] = 'attachment; filename="job_import_template.xlsx"'
|
||
return response
|
||
|
||
@action(detail=False, methods=['post'], url_path='import-excel')
|
||
def import_excel(self, request):
|
||
"""通过Excel批量导入职位"""
|
||
file = request.FILES.get('file')
|
||
if not file:
|
||
return Response({'detail': '请上传Excel文件'}, status=status.HTTP_400_BAD_REQUEST)
|
||
if not file.name.endswith(('.xlsx', '.xls')):
|
||
return Response({'detail': '仅支持 .xlsx 或 .xls 格式'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
try:
|
||
wb = openpyxl.load_workbook(file, read_only=True)
|
||
except Exception:
|
||
return Response({'detail': 'Excel文件解析失败,请检查文件格式'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
ws = wb.active
|
||
rows = list(ws.iter_rows(min_row=1, values_only=True))
|
||
if len(rows) < 2:
|
||
return Response({'detail': 'Excel中没有数据行'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# 解析表头,建立列名到索引的映射
|
||
headers = [str(h).replace('(必填)', '').strip() if h else '' for h in rows[0]]
|
||
col_map = {name: idx for idx, name in enumerate(headers)}
|
||
|
||
user = request.user
|
||
is_super = user.is_superadmin
|
||
education_choices = {'博士', '硕士', '本科及以下'}
|
||
status_choices = {'draft', 'published', '草稿', '已发布'}
|
||
status_map = {'草稿': 'draft', '已发布': 'published', 'draft': 'draft', 'published': 'published'}
|
||
|
||
# 超管需要提前加载公司名映射
|
||
org_map = {}
|
||
if is_super:
|
||
org_map = {o.name: o for o in Organization.objects.all()}
|
||
|
||
created = 0
|
||
errors = []
|
||
for row_idx, row in enumerate(rows[1:], start=2):
|
||
def get_val(col_name):
|
||
idx = col_map.get(col_name)
|
||
if idx is not None and idx < len(row):
|
||
v = row[idx]
|
||
return str(v).strip() if v is not None else ''
|
||
return ''
|
||
|
||
title = get_val('职位名称')
|
||
category = get_val('职位类别')
|
||
location = get_val('工作地点')
|
||
salary = get_val('薪资范围')
|
||
education = get_val('学历要求')
|
||
headcount_str = get_val('招聘人数')
|
||
description = get_val('职位描述')
|
||
job_status = get_val('状态')
|
||
|
||
# 跳过全空行
|
||
if not any([title, category, location, salary]):
|
||
continue
|
||
|
||
# 校验必填字段
|
||
missing = []
|
||
if not title:
|
||
missing.append('职位名称')
|
||
if not category:
|
||
missing.append('职位类别')
|
||
if not location:
|
||
missing.append('工作地点')
|
||
if not salary:
|
||
missing.append('薪资范围')
|
||
if not education:
|
||
education = '本科及以下'
|
||
|
||
if education not in education_choices:
|
||
errors.append(f'第{row_idx}行:学历要求必须为 博士/硕士/本科及以下')
|
||
continue
|
||
|
||
if missing:
|
||
errors.append(f'第{row_idx}行:缺少必填字段 {", ".join(missing)}')
|
||
continue
|
||
|
||
# 招聘人数
|
||
headcount = 1
|
||
if headcount_str:
|
||
try:
|
||
headcount = int(float(headcount_str))
|
||
if headcount < 1:
|
||
raise ValueError
|
||
except (ValueError, TypeError):
|
||
errors.append(f'第{row_idx}行:招聘人数必须为正整数')
|
||
continue
|
||
|
||
# 状态:支持中文和英文
|
||
job_status = status_map.get(job_status, 'draft')
|
||
|
||
# 确定所属公司
|
||
org = None
|
||
if is_super:
|
||
org_name = get_val('所属公司名称')
|
||
if not org_name:
|
||
errors.append(f'第{row_idx}行:超级管理员必须填写所属公司名称')
|
||
continue
|
||
org = org_map.get(org_name)
|
||
if not org:
|
||
errors.append(f'第{row_idx}行:公司"{org_name}"不存在')
|
||
continue
|
||
else:
|
||
org = user.organization
|
||
if not org:
|
||
return Response({'detail': '当前账号未关联公司,无法导入'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
Job.objects.create(
|
||
organization=org,
|
||
title=title,
|
||
category=category,
|
||
location=location,
|
||
salary=salary,
|
||
education=education,
|
||
headcount=headcount,
|
||
description=description,
|
||
status=job_status,
|
||
)
|
||
created += 1
|
||
|
||
result = {'created': created, 'errors': errors}
|
||
if created == 0 and errors:
|
||
return Response(result, status=status.HTTP_400_BAD_REQUEST)
|
||
return Response(result)
|
||
|
||
|
||
class DashboardView(APIView):
|
||
"""管理后台 Dashboard 统计数据"""
|
||
permission_classes = [IsAdminOrSuperAdmin]
|
||
|
||
def get(self, request):
|
||
user = request.user
|
||
is_super = user.is_superadmin
|
||
|
||
# 根据角色过滤数据范围
|
||
if is_super:
|
||
jobs_qs = Job.objects.all()
|
||
apps_qs = Application.objects.all()
|
||
else:
|
||
jobs_qs = Job.objects.filter(organization=user.organization)
|
||
apps_qs = Application.objects.filter(job__organization=user.organization)
|
||
|
||
# 职位统计
|
||
job_stats = jobs_qs.aggregate(
|
||
published=Count('id', filter=Q(status='published')),
|
||
closed=Count('id', filter=Q(status='closed')),
|
||
draft=Count('id', filter=Q(status='draft')),
|
||
total=Count('id'),
|
||
)
|
||
|
||
# 投递统计
|
||
app_stats = apps_qs.aggregate(
|
||
total=Count('id'),
|
||
pending=Count('id', filter=Q(status='pending')),
|
||
viewed=Count('id', filter=Q(status='viewed')),
|
||
interviewing=Count('id', filter=Q(status='interviewing')),
|
||
hired=Count('id', filter=Q(status='hired')),
|
||
rejected=Count('id', filter=Q(status='rejected')),
|
||
)
|
||
|
||
# 按投递人去重统计学历和院校分布(每人只算一次,取最高学历)
|
||
DEGREE_RANK = {'博士': 4, 'MBA': 3, '硕士': 2, '本科': 1, '其他': 0}
|
||
school_type_counter = Counter()
|
||
degree_counter = Counter()
|
||
|
||
def pick_top_edu(edu_list):
|
||
"""从教育经历中取最高学历的那条"""
|
||
best = None
|
||
best_rank = -1
|
||
for edu in edu_list:
|
||
d = edu.get('degree', '').strip()
|
||
rank = DEGREE_RANK.get(d, 0)
|
||
if rank > best_rank:
|
||
best_rank = rank
|
||
best = edu
|
||
return best or (edu_list[0] if edu_list else None)
|
||
|
||
# 按 applicant_id 去重,每个人只取一条投递快照
|
||
seen_applicants = set()
|
||
for applicant_id, snapshot in apps_qs.values_list('applicant_id', 'resume_snapshot'):
|
||
if applicant_id in seen_applicants:
|
||
continue
|
||
seen_applicants.add(applicant_id)
|
||
edu_list = snapshot.get('education', []) if snapshot else []
|
||
edu_list = [e for e in edu_list if e.get('school', '').strip() or e.get('degree', '').strip()]
|
||
top = pick_top_edu(edu_list)
|
||
if top:
|
||
degree = top.get('degree', '').strip()
|
||
if degree:
|
||
degree_counter[degree] += 1
|
||
school_name = top.get('school', '').strip()
|
||
school_type_counter[classify_school(school_name)] += 1
|
||
|
||
# 如果没有投递数据,从所有简历统计
|
||
if not seen_applicants:
|
||
from apps.resumes.models import Resume
|
||
for resume in Resume.objects.all():
|
||
edu_list = [e for e in (resume.education or []) if e.get('school', '').strip() or e.get('degree', '').strip()]
|
||
top = pick_top_edu(edu_list)
|
||
if top:
|
||
degree = top.get('degree', '').strip()
|
||
if degree:
|
||
degree_counter[degree] += 1
|
||
school_name = top.get('school', '').strip()
|
||
school_type_counter[classify_school(school_name)] += 1
|
||
|
||
# 保证 985/211/其他 顺序固定
|
||
school_type_dist = [
|
||
{'name': '985', 'value': school_type_counter.get('985', 0)},
|
||
{'name': '211', 'value': school_type_counter.get('211', 0)},
|
||
{'name': '其他', 'value': school_type_counter.get('其他', 0)},
|
||
]
|
||
degree_dist = [{'name': k, 'value': v} for k, v in degree_counter.items()]
|
||
|
||
# 职位类别分布(top 10)
|
||
category_dist = list(
|
||
jobs_qs.values('category')
|
||
.annotate(count=Count('id'))
|
||
.order_by('-count')[:10]
|
||
)
|
||
|
||
return Response({
|
||
'job_stats': job_stats,
|
||
'app_stats': app_stats,
|
||
'school_type_dist': school_type_dist,
|
||
'degree_dist': degree_dist,
|
||
'category_dist': [{'name': c['category'], 'value': c['count']} for c in category_dist],
|
||
})
|