From c54372a7fa5e7003bc4ba4127ebf480e749a9a80 Mon Sep 17 00:00:00 2001 From: Franklin-F Date: Thu, 17 Apr 2025 16:58:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E7=90=86=E7=AE=80=E5=8E=86=E8=AF=A6?= =?UTF-8?q?=E7=BB=86=E4=BF=A1=E6=81=AF=20=E5=8A=A0=E5=85=A5=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/migrations/__init__.py | 0 authorize/api/resume_authorize.py | 46 +++++++++++--- authorize/api/website_authorize.py | 26 ++++++++ logs/admin.py | 8 +++ logs/models.py | 30 ++++++++- .../commands/import_resume_details.py | 62 +++++++++++++++++++ resumes/models.py | 25 +++++--- 7 files changed, 180 insertions(+), 17 deletions(-) delete mode 100644 accounts/migrations/__init__.py create mode 100644 resumes/management/commands/import_resume_details.py diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/authorize/api/resume_authorize.py b/authorize/api/resume_authorize.py index 53ff1b0..db794a3 100644 --- a/authorize/api/resume_authorize.py +++ b/authorize/api/resume_authorize.py @@ -6,11 +6,13 @@ from authorize.schemas import ResumeAccessRequestIn from resumes.models import ResumeDetail from utils.auth import jwt_auth from utils.permissions import login_required, manager_required +from logs.models import LogEntry resume_authorize_router = Router(tags=["简历(详情信息)授权管理"]) -@resume_authorize_router.post("/apply", auth=jwt_auth, summary="申请简历详情[普]", description="普通用户申请查看某一份简历详情") +@resume_authorize_router.post("/apply", auth=jwt_auth, summary="申请简历详情[普]", + description="普通用户申请查看某一份简历详情") @login_required def apply_resume_access(request, data: ResumeAccessRequestIn): user = request.user @@ -32,11 +34,18 @@ def apply_resume_access(request, data: ResumeAccessRequestIn): resume=resume, reason=data.reason or "" ) - + LogEntry.objects.create( + user=user, + action="apply_resume", + target_type="resume", + target_id=resume.id, + message="申请查看简历" + ) return {"success": True, "message": "申请已提交,等待审批"} -@resume_authorize_router.get("/pending", auth=jwt_auth, summary="待审批简历[分]", description="分管理查看自己网站下的待审批简历详情申请") +@resume_authorize_router.get("/pending", auth=jwt_auth, summary="待审批简历[分]", + description="分管理查看自己网站下的待审批简历详情申请") @manager_required def list_pending_resume_requests(request): manager = request.user @@ -61,7 +70,8 @@ def list_pending_resume_requests(request): return {"success": True, "items": data} -@resume_authorize_router.post("/approve", auth=jwt_auth, summary="审批简历详情[分]", description="分管理审批某个用户的简历查看申请") +@resume_authorize_router.post("/approve", auth=jwt_auth, summary="审批简历详情[分]", + description="分管理审批某个用户的简历查看申请") @manager_required def approve_resume_request(request, request_id: int = Query(...), approve: bool = Query(...)): req = get_object_or_404(ResumeDetailAccessRequest, id=request_id) @@ -72,10 +82,19 @@ def approve_resume_request(request, request_id: int = Query(...), approve: bool req.status = "approved" if approve else "rejected" req.save() + LogEntry.objects.create( + user=request.user, + action="approve_resume", + target_type="resume", + target_id=req.resume.id, + message=f"审批简历:{req.user.username} -> {req.status}" + ) + return {"success": True, "message": f"已{'通过' if approve else '拒绝'}对简历 {req.resume.id} 的访问申请"} -@resume_authorize_router.get("/history", auth=jwt_auth, summary="我的简历申请记录[普]", description="普通用户查看自己申请的简历详情访问记录") +@resume_authorize_router.get("/history", auth=jwt_auth, summary="我的简历申请记录[普]", + description="普通用户查看自己申请的简历详情访问记录") @login_required def my_resume_request_history(request): user = request.user @@ -97,7 +116,8 @@ def my_resume_request_history(request): return {"success": True, "items": data} -@resume_authorize_router.post("/manual-authorize", auth=jwt_auth, summary="手动授权简历详情[分]", description="分管理跳过申请流程,直接授权某用户查看指定简历") +@resume_authorize_router.post("/manual-authorize", auth=jwt_auth, summary="手动授权简历详情[分]", + description="分管理跳过申请流程,直接授权某用户查看指定简历") @manager_required def manually_authorize_resume(request, user_id: int = Query(...), resume_id: int = Query(...)): user = get_object_or_404(User, id=user_id) @@ -119,10 +139,20 @@ def manually_authorize_resume(request, user_id: int = Query(...), resume_id: int record.status = "approved" record.save() + LogEntry.objects.create( + user=request.user, + action="manual_grant_resume", + target_type="resume", + target_id=resume.id, + message=f"手动授权 {user.username} 查看简历" + ) + + return {"success": True, "message": f"已手动授权 {user.username} 访问简历 {resume.id}"} -@resume_authorize_router.get("/granted", auth=jwt_auth, summary="我已获授权的简历ID[普]", description="普通用户查看当前已被授权访问的简历ID列表") +@resume_authorize_router.get("/granted", auth=jwt_auth, summary="我已获授权的简历ID[普]", + description="普通用户查看当前已被授权访问的简历ID列表") @login_required def list_granted_resume_ids(request): user = request.user @@ -134,4 +164,4 @@ def list_granted_resume_ids(request): status="approved" ).values_list("resume_id", flat=True) - return {"success": True, "resume_ids": list(ids)} \ No newline at end of file + return {"success": True, "resume_ids": list(ids)} diff --git a/authorize/api/website_authorize.py b/authorize/api/website_authorize.py index 15e406c..07494ff 100644 --- a/authorize/api/website_authorize.py +++ b/authorize/api/website_authorize.py @@ -7,6 +7,7 @@ from resumes.models import ResumeDetail from websites.models import Website from utils.auth import jwt_auth from utils.permissions import manager_required, login_required +from logs.models import LogEntry website_authorize_router = Router(tags=["网站(简历一般信息)授权管理"]) @@ -30,6 +31,15 @@ def authorize_user(request, data: AuthorizeIn): WebsiteAccessRequest.objects.filter(user=target_user, website_id__in=data.website_ids).update(status="approved") + for wid in data.website_ids: + LogEntry.objects.create( + user=manager, + action="manual_grant_website", + target_type="website", + target_id=wid, + message=f"手动授权 {target_user.username} 访问网站" + ) + return { "success": True, "message": f"已授权 {target_user.username} 访问 {len(data.website_ids)} 个网站", @@ -48,6 +58,14 @@ def request_access(request, data: AccessRequestIn): WebsiteAccessRequest.objects.create(user=user, website=site, reason=data.reason or "") + LogEntry.objects.create( + user=user, + action="apply_website", + target_type="website", + target_id=site.id, + message="申请访问网站" + ) + return {"success": True, "message": "申请已提交,等待分管理审批"} @@ -90,6 +108,14 @@ def approve_request(request, request_id: int = Query(...), approve: bool = Query if approve: r.user.authorized_websites.add(r.website) + LogEntry.objects.create( + user=request.user, + action="approve_website", + target_type="website", + target_id=r.website.id, + message=f"审批网站:{r.user.username} -> {r.status}" + ) + return {"success": True, "message": f"已{'通过' if approve else '拒绝'} {r.user.username} 的访问申请"} diff --git a/logs/admin.py b/logs/admin.py index 8c38f3f..575ed70 100644 --- a/logs/admin.py +++ b/logs/admin.py @@ -1,3 +1,11 @@ from django.contrib import admin # Register your models here. +from logs.models import LogEntry + +@admin.register(LogEntry) +class LogEntryAdmin(admin.ModelAdmin): + list_display = ("user", "action", "target_type", "target_id", "message", "created_at") + list_filter = ("action", "target_type", "created_at") + search_fields = ("user__username", "message", "target_type") + readonly_fields = ("user", "action", "target_type", "target_id", "message", "created_at") \ No newline at end of file diff --git a/logs/models.py b/logs/models.py index 71a8362..058bb51 100644 --- a/logs/models.py +++ b/logs/models.py @@ -1,3 +1,31 @@ from django.db import models +from django.contrib.auth import get_user_model -# Create your models here. +User = get_user_model() + + +class LogEntry(models.Model): + ACTION_CHOICES = [ + ("apply_resume", "申请查看简历"), + ("approve_resume", "审批简历申请"), + ("manual_grant_resume", "手动授权简历"), + ("apply_website", "申请访问网站"), + ("approve_website", "审批网站申请"), + ("manual_grant_website", "手动授权网站"), + ] + + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="operation_logs", + verbose_name="操作者") + action = models.CharField(max_length=50, choices=ACTION_CHOICES, verbose_name="操作类型") + target_type = models.CharField(max_length=50, verbose_name="目标类型", help_text="如 resume, website") + target_id = models.IntegerField(verbose_name="目标ID") + message = models.TextField(blank=True, verbose_name="说明") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="操作时间") + + class Meta: + verbose_name = "操作日志" + verbose_name_plural = "操作日志" + ordering = ["-created_at"] + + def __str__(self): + return f"[{self.get_action_display()}] {self.user} -> {self.target_type}#{self.target_id}" diff --git a/resumes/management/commands/import_resume_details.py b/resumes/management/commands/import_resume_details.py new file mode 100644 index 0000000..3f2bb13 --- /dev/null +++ b/resumes/management/commands/import_resume_details.py @@ -0,0 +1,62 @@ +from django.core.management.base import BaseCommand +import pandas as pd +from resumes.models import ResumeBasic, ResumeDetail +from django.db import transaction + + +class Command(BaseCommand): + help = "导入护理类简历详情数据到 ResumeDetail 模型" + + def add_arguments(self, parser): + parser.add_argument("filepath", type=str, help="Excel 文件路径") + + def handle(self, *args, **options): + filepath = options["filepath"] + df = pd.read_excel(filepath) + + success, skipped, errors = 0, 0, [] + + for _, row in df.iterrows(): + raw_resume_id = row.get("resume_id") + phone = row.get("phone") + email = row.get("email") + + if pd.isna(raw_resume_id): + skipped += 1 + continue + + resume_id = None + try: + resume_id = int(str(raw_resume_id).strip().split(".")[0]) # 去掉小数点尾巴等干扰 + except (ValueError, TypeError): + errors.append(f"resume_id 非法格式: {raw_resume_id}") + continue + + try: + basic = ResumeBasic.objects.get(resume_id=resume_id) + ResumeDetail.objects.update_or_create( + resume=basic, + defaults={ + "unlinked_resume_id": None, + "phone": str(phone).strip() if not pd.isna(phone) else "", + "email": str(email).strip() if not pd.isna(email) else "" + } + ) + success += 1 + except ResumeBasic.DoesNotExist: + ResumeDetail.objects.update_or_create( + unlinked_resume_id=resume_id, + defaults={ + "resume": None, + "phone": str(phone).strip() if not pd.isna(phone) else "", + "email": str(email).strip() if not pd.isna(email) else "" + } + ) + success += 1 + errors.append(f"resume_id={resume_id} 无对应 ResumeBasic,已记录至 unlinked_resume_id") + + self.stdout.write(self.style.SUCCESS(f"成功导入 {success} 条,跳过 {skipped} 条")) + if errors: + self.stdout.write(self.style.WARNING("以下数据未关联基础简历:")) + for msg in errors: + self.stdout.write(f" - {msg}") diff --git a/resumes/models.py b/resumes/models.py index 9f8b8f9..39fbd09 100644 --- a/resumes/models.py +++ b/resumes/models.py @@ -30,19 +30,23 @@ class ResumeBasic(models.Model): help_text="求职状态") work_1_experience = models.TextField(null=True, blank=True, verbose_name="工作1经历", help_text="工作1经历") - work_1_time = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作1时间", help_text="工作1时间") + work_1_time = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作1时间", + help_text="工作1时间") work_1_description = models.TextField(null=True, blank=True, verbose_name="工作1内容", help_text="工作1内容") work_2_experience = models.TextField(null=True, blank=True, verbose_name="工作2经历", help_text="工作2经历") - work_2_time = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作2时间", help_text="工作2时间") + work_2_time = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作2时间", + help_text="工作2时间") work_2_description = models.TextField(null=True, blank=True, verbose_name="工作2内容", help_text="工作2内容") work_3_experience = models.TextField(null=True, blank=True, verbose_name="工作3经历", help_text="工作3经历") - work_3_time = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作3时间", help_text="工作3时间") + work_3_time = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作3时间", + help_text="工作3时间") work_3_description = models.TextField(null=True, blank=True, verbose_name="工作3内容", help_text="工作3内容") work_4_experience = models.TextField(null=True, blank=True, verbose_name="工作4经历", help_text="工作4经历") - work_4_time = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作4时间", help_text="工作4时间") + work_4_time = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作4时间", + help_text="工作4时间") work_4_description = models.TextField(null=True, blank=True, verbose_name="工作4内容", help_text="工作4内容") height = models.IntegerField(null=True, blank=True, verbose_name="身高", help_text="身高") @@ -60,7 +64,8 @@ class ResumeBasic(models.Model): help_text="从事行业") expected_salary = models.CharField(max_length=255, null=True, blank=True, verbose_name="期望薪资", help_text="期望薪资") - available_time = models.CharField(max_length=255, null=True, blank=True, verbose_name="到岗时间", help_text="到岗时间") + available_time = models.CharField(max_length=255, null=True, blank=True, verbose_name="到岗时间", + help_text="到岗时间") job_property = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作性质", help_text="工作性质") job_location = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作地点", @@ -82,16 +87,20 @@ class ResumeBasic(models.Model): verbose_name = "简历" verbose_name_plural = "简历列表" + class ResumeDetail(models.Model): resume = models.OneToOneField( ResumeBasic, - on_delete=models.CASCADE, - primary_key=True, + on_delete=models.SET_NULL, + null=True, + blank=True, + unique=True, related_name="detail", verbose_name="简历" ) + unlinked_resume_id = models.IntegerField(null=True, blank=True, verbose_name="无法关联的简历ID") phone = models.CharField(max_length=20, verbose_name="联系方式", blank=True) - + email = models.EmailField(verbose_name="邮箱", blank=True) updated_at = models.DateTimeField(auto_now=True) class Meta: