Compare commits

..

26 Commits

Author SHA1 Message Date
1123189a4a 修复简历ID字段的唯一性约束,添加source与resume_id的联合唯一性 2025-04-27 21:59:56 +08:00
c54372a7fa 整理简历详细信息
加入导入脚本
2025-04-17 16:58:00 +08:00
c0962dd877 删除两个没什么用的app 2025-04-17 14:10:17 +08:00
9e0f36e77e 普通用户查看“我当前被授权访问的简历ID列表” 2025-04-17 14:05:58 +08:00
1257aeff5d 简历 详情手动权限 2025-04-17 14:03:24 +08:00
d4714d3f43 完善简历权限相关的接口 2025-04-17 13:04:52 +08:00
273b9713e0 整理视图接口将简历详情独立 2025-04-17 11:55:50 +08:00
8a95eb6a7d 完善Bug 和 普通用户 所有网站的视图 2025-04-16 18:44:28 +08:00
e18d7a20dc 完善Bug 和 普通用户 所有网站的视图 2025-04-16 17:12:05 +08:00
5474141c26 完善Bug 和 普通用户 所有网站的视图 2025-04-16 10:38:32 +08:00
5235fe1e77 完善用户注册流程,支持注册码验证与管理,新增用户授权网站列表接口 2025-04-16 07:40:31 +08:00
7557735ee7 包收集 2025-04-15 16:59:09 +08:00
41cb92ddeb 完善剩下的 准备测试 2025-04-15 16:43:50 +08:00
b6214de247 修改简历详情
完善授权
2025-04-15 16:33:40 +08:00
ae6db6ec44 完善权限体系
新增授权接口
2025-04-15 16:01:11 +08:00
b6a0abd9ee 完善权限体系 2025-04-15 15:16:58 +08:00
7efa258a28 用户注册和用户登录的模型和视图完成 2025-04-15 14:55:20 +08:00
549dd20043 简历的基础信息 视图加入简单参数 2025-04-15 14:18:22 +08:00
1b4248e865 导入玉田招聘网站成功 更改模型类工作时间为串 2025-04-15 14:01:13 +08:00
8d3719206f 导入遵化网站完善 2025-04-15 13:29:46 +08:00
286cbe907b ninjia api 简历(基础信息)接口 和文档 导入 Excle 目前未完善 2025-04-15 10:17:47 +08:00
1366de574b 添加ResumeBasic和Website模型,扩展简历数据结构 2025-04-13 21:14:31 +08:00
27a05d630f 添加项目配置文件和更新.gitignore以忽略IDE相关文件 2025-04-13 20:12:41 +08:00
209f18cd63 更新: 使用环境变量配置数据库连接 2025-04-13 20:08:58 +08:00
fcaaf67795 修复:配置 2025-04-13 00:19:07 +08:00
00bc2bdff5 更换数据库 2025-04-13 00:02:44 +08:00
55 changed files with 1475 additions and 36 deletions

56
.gitignore vendored Normal file
View File

@ -0,0 +1,56 @@
# === Python 缓存 ===
__pycache__/
*.py[cod]
*$py.class
# === 环境变量文件 ===
.env
.env.*
# === 虚拟环境目录 ===
venv/
.venv/
env/
ENV/
env.bak/
venv.bak/
# === 安装构建缓存 ===
*.egg
*.egg-info/
.eggs/
dist/
build/
pip-log.txt
# === 测试相关缓存文件 ===
.coverage
.tox/
nosetests.xml
coverage.xml
*.cover
*.py,cover
# === 数据库相关 ===
*.sqlite3
db.sqlite3
# === 日志文件 ===
*.log
logs/
# === 静态与媒体文件Django ===
media/
static/
staticfiles/
# === IDE 配置 ===
.idea/ # PyCharm
*.iml
*.ipr
*.iws
.vscode/ # VS Code
# === 系统自动生成文件 ===
.DS_Store # macOS
Thumbs.db # Windows

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@ -1,6 +0,0 @@
from django.apps import AppConfig
class AccessControlConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'access_control'

View File

@ -1,3 +0,0 @@
from django.db import models
# Create your models here.

View File

@ -1,3 +1,25 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DefaultUserAdmin
from .models import User
# Register your models here.
@admin.register(User)
class UserAdmin(DefaultUserAdmin):
list_display = ("username", "email", "role", "source_manager", "is_active", "is_staff", "last_login")
list_filter = ("role", "is_active", "is_staff", "is_superuser")
search_fields = ("username", "email")
filter_horizontal = ("groups", "user_permissions", "managed_websites", "authorized_websites")
def get_fieldsets(self, request, obj=None):
base = list(super().get_fieldsets(request, obj))
role_fields = ["role"]
if obj and obj.role == "user":
role_fields.append("source_manager")
base.append(("角色和权限", {"fields": role_fields}))
if obj:
if obj.role == "manager":
base.append(("管理权限", {"fields": ("managed_websites",)}))
elif obj.role == "user":
base.append(("访问权限", {"fields": ("authorized_websites",)}))
return base

87
accounts/api/auth.py Normal file
View File

@ -0,0 +1,87 @@
from ninja import Router, Form
from django.contrib.auth import get_user_model
from rest_framework_simplejwt.tokens import RefreshToken
from django.db.models import Q
from invites.models import RegistrationCode
auth_router = Router(tags=["认证"])
User = get_user_model()
@auth_router.post("/register")
def register(
request,
username: str = Form(...),
password: str = Form(...),
email: str = Form(...),
code: str = Form(...)
):
if User.objects.filter(username=username).exists():
return {"success": False, "message": "用户名已存在"}
try:
reg = RegistrationCode.objects.get(code=code)
if not reg.is_available():
return {"success": False, "message": "注册码已达使用上限"}
except RegistrationCode.DoesNotExist:
return {"success": False, "message": "注册码无效"}
user = User(
username=username,
email=email,
role="user",
source_manager=reg.manager
)
user.set_password(password)
user.save()
reg.used_count += 1
reg.save()
RegistrationCode.objects.create(code=reg, user=user)
refresh = RefreshToken.for_user(user)
return {
"success": True,
"message": "注册成功",
"user": {
"id": user.id,
"username": user.username,
"role": user.role,
},
"token": {
"access": str(refresh.access_token),
"refresh": str(refresh),
}
}
@auth_router.post("/login")
def login(
request,
username: str = Form(...),
password: str = Form(...),
):
user = User.objects.filter(Q(username=username) | Q(email=username)).first()
if not user or not user.check_password(password):
return {"success": False, "message": "用户名或密码错误"}
if not user.is_active:
return {"success": False, "message": "账号未激活"}
refresh = RefreshToken.for_user(user)
return {
"success": True,
"message": "登录成功",
"user": {
"id": user.id,
"username": user.username,
"role": user.role,
},
"token": {
"access": str(refresh.access_token),
"refresh": str(refresh),
}
}

18
accounts/api/user.py Normal file
View File

@ -0,0 +1,18 @@
from ninja import Router
from utils.permissions import login_required
from utils.auth import jwt_auth
user_router = Router(tags=["用户信息"])
@user_router.get("/me", auth=jwt_auth)
@login_required
def get_user_info(request):
user = request.user
return {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"is_active": user.is_active,
"is_staff": user.is_staff,
}

View File

@ -4,3 +4,6 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'
def ready(self):
import accounts.signals

View File

@ -1,3 +1,43 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from websites.models import Website
# Create your models here.
class User(AbstractUser):
ROLE_CHOICES = [
('admin', '管理员'),
('manager', '分管理'),
('user', '普通用户'),
]
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user', help_text="用户角色")
managed_websites = models.ManyToManyField(
Website,
blank=True,
related_name="managers",
help_text="分管理可管理的网站"
)
authorized_websites = models.ManyToManyField(
Website,
blank=True,
related_name="authorized_users",
help_text="普通用户被授权可访问的网站"
)
source_manager = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="brought_users",
limit_choices_to={"role": "manager"},
verbose_name="所属分管理"
)
def is_admin(self):
return self.role == 'admin'
def is_manager(self):
return self.role == 'manager'
def is_user(self):
return self.role == 'user'
def __str__(self):
return f"{self.username} ({self.get_role_display()})"

19
accounts/signals.py Normal file
View File

@ -0,0 +1,19 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from accounts.models import User
from invites.models import RegistrationCode
import uuid
@receiver(post_save, sender=User)
def create_registration_code_for_manager(sender, instance, created, **kwargs):
if instance.role == "manager":
if created or not RegistrationCode.objects.filter(manager=instance).exists():
RegistrationCode.objects.create(
code=str(uuid.uuid4()).replace("-", "")[:12],
manager=instance,
description=f"{instance.username} 的默认邀请码",
usage_limit=10
)
elif instance.role == "user":
RegistrationCode.objects.filter(manager=instance).update(usage_limit=0)

View File

@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@ -1,3 +0,0 @@
from django.db import models
# Create your models here.

13
api.py Normal file
View File

@ -0,0 +1,13 @@
from ninja import NinjaAPI
from resumes.api.views import router as resume_router
from accounts.api.auth import auth_router
from accounts.api.user import user_router
from authorize.api.website_authorize import website_authorize_router
from authorize.api.resume_authorize import resume_authorize_router
api = NinjaAPI(title="简历管理 API")
api.add_router("/resumes", resume_router)
api.add_router("/auth", auth_router)
api.add_router("/users", user_router)
api.add_router("/website", website_authorize_router)
api.add_router("/resume", resume_authorize_router)

18
authorize/admin.py Normal file
View File

@ -0,0 +1,18 @@
from django.contrib import admin
from authorize.models import WebsiteAccessRequest, ResumeDetailAccessRequest
# Register your models here.
@admin.register(WebsiteAccessRequest)
class WebsiteAccessRequestAdmin(admin.ModelAdmin):
list_display = ('user', 'website', 'status', 'reason', 'created_at', 'updated_at')
list_filter = ('status', 'website', 'created_at')
search_fields = ('user__username', 'website__name', 'reason')
ordering = ('-created_at',)
readonly_fields = ('created_at', 'updated_at')
@admin.register(ResumeDetailAccessRequest)
class ResumeDetailAccessRequestAdmin(admin.ModelAdmin):
list_display = ('user', 'resume', 'reason', 'status', 'created_at')
list_filter = ('status', 'created_at')
search_fields = ('user__username', 'resume__id')

View File

@ -0,0 +1,167 @@
from ninja import Router, Query
from django.shortcuts import get_object_or_404
from accounts.models import User
from authorize.models import ResumeDetailAccessRequest
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="普通用户申请查看某一份简历详情")
@login_required
def apply_resume_access(request, data: ResumeAccessRequestIn):
user = request.user
if not user.is_user():
return {"success": False, "message": "仅普通用户可申请查看简历"}
resume = get_object_or_404(ResumeDetail, id=data.resume_id)
exists = ResumeDetailAccessRequest.objects.filter(
user=user, resume=resume, status="pending"
).exists()
if exists:
return {"success": False, "message": "您已申请过该简历,正在等待审批"}
ResumeDetailAccessRequest.objects.create(
user=user,
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="分管理查看自己网站下的待审批简历详情申请")
@manager_required
def list_pending_resume_requests(request):
manager = request.user
manageable_ids = manager.managed_websites.values_list("id", flat=True)
requests = ResumeDetailAccessRequest.objects.filter(
resume__source_id__in=manageable_ids,
status="pending"
)
data = [
{
"id": r.id,
"user": r.user.username,
"resume_id": r.resume.id,
"reason": r.reason,
"created_at": r.created_at,
}
for r in requests
]
return {"success": True, "items": data}
@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)
if req.resume.source not in request.user.managed_websites.all():
return {"success": False, "message": "无权审批该申请"}
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="普通用户查看自己申请的简历详情访问记录")
@login_required
def my_resume_request_history(request):
user = request.user
if not user.is_user():
return {"success": False, "message": "仅普通用户可查看"}
records = ResumeDetailAccessRequest.objects.filter(user=user).order_by("-created_at")
data = [
{
"resume_id": r.resume.id,
"reason": r.reason,
"status": r.status,
"created_at": r.created_at,
}
for r in records
]
return {"success": True, "items": data}
@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)
resume = get_object_or_404(ResumeDetail, id=resume_id)
if not user.is_user():
return {"success": False, "message": "仅能授权给普通用户"}
if resume.source not in request.user.managed_websites.all():
return {"success": False, "message": "无权授权该简历"}
record, created = ResumeDetailAccessRequest.objects.get_or_create(
user=user,
resume=resume,
defaults={"status": "approved", "reason": "由分管理手动授权"}
)
if not created:
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列表")
@login_required
def list_granted_resume_ids(request):
user = request.user
if not user.is_user():
return {"success": False, "message": "仅普通用户可访问"}
ids = ResumeDetailAccessRequest.objects.filter(
user=user,
status="approved"
).values_list("resume_id", flat=True)
return {"success": True, "resume_ids": list(ids)}

View File

@ -0,0 +1,147 @@
from ninja import Router, Query
from django.shortcuts import get_object_or_404
from accounts.models import User
from authorize.models import WebsiteAccessRequest, ResumeDetailAccessRequest
from authorize.schemas import ResumeAccessRequestIn, AccessRequestIn, AuthorizeIn
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=["网站(简历一般信息)授权管理"])
@website_authorize_router.post("/authorize", auth=jwt_auth, summary="分管手动授权网站[分管]",
description="分管理授权普通用户访问指定网站")
@manager_required
def authorize_user(request, data: AuthorizeIn):
manager = request.user
target_user = get_object_or_404(User, id=data.user_id)
if target_user.role != "user":
return {"success": False, "message": "只能授权给普通用户"}
managed_ids = set(manager.managed_websites.values_list("id", flat=True))
for wid in data.website_ids:
if wid not in managed_ids:
return {"success": False, "message": f"无权授权网站ID{wid}"}
target_user.authorized_websites.add(*data.website_ids)
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)} 个网站",
}
@website_authorize_router.post("/apply", auth=jwt_auth, summary="申请网站授权[普]",
description="普通用户发起网站访问申请")
@login_required
def request_access(request, data: AccessRequestIn):
user = request.user
site = get_object_or_404(Website, id=data.website_id)
if WebsiteAccessRequest.objects.filter(user=user, website=site, status="pending").exists():
return {"success": False, "message": "您已申请,正在等待审批"}
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": "申请已提交,等待分管理审批"}
@website_authorize_router.get("/pending", auth=jwt_auth, summary="待审批列表[分管]",
description="分管理查看自己负责的网站的待审批访问申请")
@manager_required
def list_pending_requests(request):
manager = request.user
managed_ids = manager.managed_websites.values_list("id", flat=True)
requests = WebsiteAccessRequest.objects.filter(website_id__in=managed_ids, status="pending")
return {
"success": True,
"items": [
{
"id": r.id,
"user": r.user.username,
"website": r.website.name,
"reason": r.reason,
"created_at": r.created_at,
}
for r in requests
]
}
@website_authorize_router.post("/approve", auth=jwt_auth, summary="审批网站授权[分管]",
description="分管理审批网站访问申请(通过或拒绝)")
@manager_required
def approve_request(request, request_id: int = Query(...), approve: bool = Query(True)):
r = get_object_or_404(WebsiteAccessRequest, id=request_id)
if r.website not in request.user.managed_websites.all():
return {"success": False, "message": "无权审批此申请"}
r.status = "approved" if approve else "rejected"
r.save()
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} 的访问申请"}
@website_authorize_router.get("/my-sites", auth=jwt_auth, summary="我的网站列表[普]",
description="展示当前用户可申请与已授权的网站列表,并标记授权状态")
@login_required
def list_user_sites_with_status(request):
user = request.user
if not user.is_user():
return {"success": False, "message": "仅普通用户可访问"}
if not user.source_manager:
return {"success": False, "message": "您尚未绑定所属分管理,无法申请网站"}
# 可申请的网站(所属分管理可管理)
manageable_sites = user.source_manager.managed_websites.all()
authorized_site_ids = set(user.authorized_websites.values_list("id", flat=True))
websites = []
for site in manageable_sites:
websites.append({
"id": site.id,
"name": site.name,
"db_alias": site.db_alias,
"authorized": site.id in authorized_site_ids
})
return {"success": True, "websites": websites}

View File

@ -1,6 +1,6 @@
from django.apps import AppConfig
class AdminPanelConfig(AppConfig):
class AuthorizeConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'admin_panel'
name = 'authorize'

47
authorize/models.py Normal file
View File

@ -0,0 +1,47 @@
from django.db import models
from accounts.models import User
from websites.models import Website
from resumes.models import ResumeDetail
# Create your models here.
class WebsiteAccessRequest(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
website = models.ForeignKey(Website, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
status = models.CharField(
max_length=20,
choices=[("pending", "待审批"), ("approved", "已通过"), ("rejected", "已拒绝")],
default="pending"
)
reason = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "网站访问申请"
verbose_name_plural = "网站访问申请"
def __str__(self):
return f"{self.user.username} 申请网站 {self.website.name} ({self.status})"
class ResumeDetailAccessRequest(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="申请用户")
resume = models.ForeignKey(ResumeDetail, on_delete=models.CASCADE, verbose_name="目标简历")
reason = models.TextField(blank=True, verbose_name="申请理由")
status = models.CharField(
max_length=20,
choices=[("pending", "待审批"), ("approved", "已通过"), ("rejected", "已拒绝")],
default="pending",
verbose_name="审批状态"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="申请时间")
class Meta:
unique_together = ("user", "resume")
verbose_name = "简历详情访问申请"
verbose_name_plural = "简历详情访问申请"
def __str__(self):
return f"{self.user.username} 申请查看简历 {self.resume.id} ({self.status})"

18
authorize/schemas.py Normal file
View File

@ -0,0 +1,18 @@
from ninja import Schema, Query
from pydantic import Field
from typing import List, Optional
class AuthorizeIn(Schema):
user_id: int = Field(..., description="被授权的用户ID")
website_ids: List[int] = Field(..., description="要授权的网站ID列表")
class AccessRequestIn(Schema):
website_id: int = Field(...)
reason: Optional[str] = Field(None, description="申请原因")
class ResumeAccessRequestIn(Schema):
resume_id: int = Field(..., description="简历ID")
reason: Optional[str] = Field(None, description="申请理由")

View File

@ -0,0 +1,2 @@
import pymysql
pymysql.install_as_MySQLdb()

View File

@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ts_reshub.settings')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_asgi_application()

View File

@ -9,12 +9,16 @@ 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/
"""
import environ
import os
from pathlib import Path
env = environ.Env()
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
environ.Env.read_env(os.path.join(BASE_DIR, '.env'))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
@ -37,6 +41,15 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'ninja',
# 你自己的 apps 👇
'accounts',
'websites',
'resumes',
'logs',
'invites',
'authorize'
]
MIDDLEWARE = [
@ -49,7 +62,7 @@ MIDDLEWARE = [
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'ts_reshub.urls'
ROOT_URLCONF = 'core.urls'
TEMPLATES = [
{
@ -67,7 +80,7 @@ TEMPLATES = [
},
]
WSGI_APPLICATION = 'ts_reshub.wsgi.application'
WSGI_APPLICATION = 'core.wsgi.application'
# Database
@ -75,11 +88,17 @@ WSGI_APPLICATION = 'ts_reshub.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'ENGINE': 'django.db.backends.mysql',
'NAME': env('DB_NAME'),
'USER': env('DB_USER'),
'PASSWORD': env('DB_PASSWORD'),
'HOST': env('DB_HOST'),
'PORT': env('DB_PORT'),
'OPTIONS': {
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
}
}
}
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
@ -99,18 +118,23 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
AUTH_USER_MODEL = 'accounts.User'
# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'UTC'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
}
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/

View File

@ -16,7 +16,8 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import path
from api import api
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', api.urls),
]

10
invites/admin.py Normal file
View File

@ -0,0 +1,10 @@
from django.contrib import admin
from invites.models import RegistrationCode
@admin.register(RegistrationCode)
class RegistrationCodeAdmin(admin.ModelAdmin):
list_display = ("code", "manager", "usage_limit", "used_count", "created_at")
list_filter = ("manager",)
search_fields = ("code", "manager__username")
readonly_fields = ("used_count", "created_at")

6
invites/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class InvitesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "invites"

25
invites/models.py Normal file
View File

@ -0,0 +1,25 @@
from django.db import models
from accounts.models import User
class RegistrationCode(models.Model):
code = models.CharField(max_length=32, unique=True, verbose_name="注册码")
manager = models.ForeignKey(
User,
on_delete=models.CASCADE,
limit_choices_to={"role": "manager"},
verbose_name="对应分管理"
)
description = models.CharField(max_length=100, blank=True, verbose_name="说明")
usage_limit = models.IntegerField(default=1, verbose_name="最多使用次数")
used_count = models.IntegerField(default=0, verbose_name="已使用次数")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
class Meta:
verbose_name = "注册码申请"
verbose_name_plural = "注册码申请"
def __str__(self):
return f"{self.code} ({self.used_count}/{self.usage_limit})"
def is_available(self):
return self.used_count < self.usage_limit

View File

@ -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")

View File

@ -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}"

BIN
requirements.txt Normal file

Binary file not shown.

View File

@ -1,3 +1,26 @@
from django.contrib import admin
from .models import *
# Register your models here.
class ResumeDetailInline(admin.StackedInline):
model = ResumeDetail
extra = 0
@admin.register(ResumeDetail)
class ResumeDetailAdmin(admin.ModelAdmin):
list_display = ("resume", "phone", "updated_at")
search_fields = ("resume__name", "phone")
@admin.register(ResumeBasic)
class ResumeBasicAdmin(admin.ModelAdmin):
list_display = (
'resume_id', 'name', 'age', 'gender', 'job_status', 'education',
'expected_position', 'last_active_time', 'update_time'
)
search_fields = ('name', 'phone', 'resume_id')
list_filter = ('job_status', 'gender', 'education', 'highest_education', 'source')
ordering = ('-update_time',)
@admin.display(description='数据来源')
def source_name(self, obj):
print(obj.source.name)
return obj.source.name if obj.source else "-"

0
resumes/api/__init__.py Normal file
View File

42
resumes/api/detail.py Normal file
View File

@ -0,0 +1,42 @@
from ninja import Router
from ninja.errors import HttpError
from django.shortcuts import get_object_or_404
from resumes.models import ResumeBasic, ResumeDetail
from utils.auth import jwt_auth
from utils.permissions import login_required
router = Router(tags=["简历详情"])
@router.get("/{resume_id}", auth=jwt_auth)
@login_required
def get_resume_detail(request, resume_id: int):
user = request.user
resume = get_object_or_404(ResumeBasic, id=resume_id)
# ✅ 权限校验
if user.role == "admin":
pass
elif user.role == "manager":
if resume.source_id not in user.managed_websites.values_list("id", flat=True):
raise HttpError(403, "无权查看该简历")
elif user.role == "user":
if resume.source_id not in user.authorized_websites.values_list("id", flat=True):
raise HttpError(403, "无权查看该简历")
# ✅ 获取详情模型(可选)
detail = ResumeDetail.objects.filter(resume_id=resume.id).first()
return {
"id": resume.id,
"name": resume.name,
"age": resume.age,
"job_status": resume.job_status,
"source_id": resume.source_id,
"phone": detail.phone if detail else None,
"extra": {
"教育经历": getattr(detail, "education", None),
"项目经历": getattr(detail, "projects", None),
}
}

59
resumes/api/schemas.py Normal file
View File

@ -0,0 +1,59 @@
from ninja import Schema
from typing import Optional, List
from datetime import datetime
# 简历单条记录输出结构
class ResumeBasicOut(Schema):
id: int
resume_id: int
name: Optional[str]
job_region: Optional[str]
birthday: Optional[str]
education: Optional[str]
school: Optional[str]
expected_position: Optional[str]
last_active_time: Optional[str]
marital_status: Optional[str]
current_location: Optional[str]
age: Optional[int]
phone: Optional[str]
gender: Optional[str]
job_type: Optional[str]
job_status: Optional[str]
work_1_experience: Optional[str]
work_1_time: Optional[str]
work_1_description: Optional[str]
work_2_experience: Optional[str]
work_2_time: Optional[str]
work_2_description: Optional[str]
work_3_experience: Optional[str]
work_3_time: Optional[str]
work_3_description: Optional[str]
work_4_experience: Optional[str]
work_4_time: Optional[str]
work_4_description: Optional[str]
height: Optional[int]
weight: Optional[int]
work_years: Optional[str]
highest_education: Optional[str]
ethnicity: Optional[str]
update_time: Optional[datetime]
job_function: Optional[str]
intended_position: Optional[str]
industry: Optional[str]
expected_salary: Optional[str]
available_time: Optional[str]
job_property: Optional[str]
job_location: Optional[str]
crawl_keywords: Optional[str]
source_id: Optional[int]
# 分页响应结构
class PaginatedResumes(Schema):
count: int
items: List[ResumeBasicOut]

48
resumes/api/views.py Normal file
View File

@ -0,0 +1,48 @@
from ninja import Router, Query
from accounts.models import User
from resumes.models import ResumeBasic
from resumes.api.schemas import ResumeBasicOut, PaginatedResumes
from typing import Optional
from utils.auth import jwt_auth
from utils.permissions import login_required
router = Router(tags=["简历"])
@router.get("/", response=PaginatedResumes, auth=jwt_auth)
@login_required
def list_resumes(
request,
job_status: Optional[str] = Query(None),
age: Optional[int] = Query(None),
name: Optional[str] = Query(None),
source_id: Optional[int] = Query(None),
keyword: Optional[str] = Query(None),
limit: int = 10,
offset: int = 0
):
user = request.user
qs = ResumeBasic.objects.all()
if user.is_admin():
pass # 管理员访问全部
elif user.is_manager():
qs = qs.filter(source_id__in=user.managed_websites.values_list("id", flat=True))
elif user.is_user():
qs = qs.filter(source_id__in=user.authorized_websites.values_list("id", flat=True))
if job_status:
qs = qs.filter(job_status=job_status)
if age:
qs = qs.filter(age=age)
if name:
qs = qs.filter(name__icontains=name)
if source_id:
qs = qs.filter(source_id=source_id)
if keyword:
qs = qs.filter(crawl_keywords__icontains=keyword)
total = qs.count()
results = qs[offset:offset + limit]
return {"count": total, "items": list(results)}

View File

View File

View File

@ -0,0 +1,139 @@
from datetime import datetime, timedelta
from django.core.management.base import BaseCommand
from pandas._libs.tslibs.timestamps import Timestamp
import pandas as pd
from resumes.models import ResumeBasic
import re
import traceback
class Command(BaseCommand):
help = "导入会计类简历(支持 --keyword 和 --source 参数)"
def add_arguments(self, parser):
parser.add_argument('--file', required=True, help='Excel 文件路径')
parser.add_argument('--keyword', default='', help='crawl_keywords 值')
parser.add_argument('--source', type=int, default=2, help='source_id 值')
def handle(self, *args, **options):
filepath = options['file']
default_keyword = options['keyword']
default_source = options['source']
df = pd.read_excel(filepath)
rename_map = {
'姓名': 'name', '性别': 'gender', '年龄': 'age',
'手机': 'phone', '婚姻状况': 'marital_status', '身高': 'height', '体重': 'weight',
'学历': 'education', '毕业学校': 'school', '工作经验': 'work_years',
'现居住地': 'current_location', '工作地点': 'job_location', '到岗时间': 'available_time',
'更新时间': 'update_time', '最高学历': 'education', '婚姻状态': 'marital_status',
'民族': 'ethnicity', '工作职能': 'job_function', '意向岗位': 'intended_position',
'从事行业': 'industry', '期望薪资': 'expected_salary', '工作性质': 'job_property',
'求职状态': 'job_status',
}
df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns}, inplace=True)
df['source_id'] = default_source
df['crawl_keywords'] = default_keyword
def parse_update_time(val):
if pd.isna(val):
return datetime(2019, 12, 12)
val = str(val)
now = datetime.now()
if "刚刚" in val:
return now
if "小时前" in val:
hours = int(re.search(r'\d+', val).group())
return now - timedelta(hours=hours)
if "天前" in val:
days = int(re.search(r'\d+', val).group())
return now - timedelta(days=days)
try:
dt = pd.to_datetime(val)
return dt.to_pydatetime()
except Exception:
return datetime(2019, 12, 12)
df['update_time'] = df['update_time'].apply(parse_update_time)
def val(v, field=None):
if v is None or pd.isna(v):
if field == 'update_time':
return datetime(2019, 12, 12)
return None
if field == 'update_time':
if isinstance(v, Timestamp):
return v.to_pydatetime()
if isinstance(v, str):
try:
return pd.to_datetime(v).to_pydatetime()
except Exception:
return datetime(2019, 12, 12)
if isinstance(v, datetime):
return v
return datetime(2019, 12, 12)
if isinstance(v, Timestamp):
return v.to_pydatetime()
return v
success_count = 0
fail_count = 0
errors = []
for i, row in df.iterrows():
try:
resume_id = val(row.get('resume_id'))
defaults = {
'name': val(row.get('name')),
'gender': val(row.get('gender')),
'age': val(row.get('age')),
'phone': val(row.get('phone')),
'marital_status': val(row.get('marital_status')),
'height': val(row.get('height')),
'weight': val(row.get('weight')),
'education': val(row.get('education')),
'school': val(row.get('school')),
'work_years': val(row.get('work_years')),
'current_location': val(row.get('current_location')),
'job_location': val(row.get('job_location')),
'available_time': val(row.get('available_time')),
'update_time': val(row['update_time'], field='update_time'),
'ethnicity': val(row.get('ethnicity')),
'job_function': val(row.get('job_function')),
'intended_position': val(row.get('intended_position')),
'industry': val(row.get('industry')),
'expected_salary': val(row.get('expected_salary')),
'job_property': val(row.get('job_property')),
'job_status': val(row.get('job_status')),
'source_id': val(row.get('source_id')),
'crawl_keywords': val(row.get('crawl_keywords')),
}
# 安全方式get_or_create + 逐字段 set
obj, _ = ResumeBasic.objects.get_or_create(resume_id=resume_id)
for k, v in defaults.items():
try:
setattr(obj, k, v)
except Exception as field_error:
print(f"[字段设置错误] {k} = {v!r} ({type(v)}) → {field_error}")
raise
obj.save()
success_count += 1
except Exception as e:
fail_count += 1
errors.append((i + 2, str(e)))
print(f"\n❌ 第 {i + 2} 行出错:{e}")
print(f"resume_id: {repr(resume_id)} ({type(resume_id).__name__})")
for k, v in defaults.items():
print(f"{k:<20} | {repr(v):<30} | {type(v).__name__}")
traceback.print_exc()
self.stdout.write(self.style.SUCCESS(f"导入完成!总数:{len(df)},成功:{success_count},失败:{fail_count}"))
if errors:
self.stdout.write(self.style.WARNING("失败记录如下:"))
for line_no, msg in errors:
self.stdout.write(f"{line_no} 行出错:{msg}")

View File

@ -0,0 +1,147 @@
from datetime import datetime, timedelta
from django.core.management.base import BaseCommand
from pandas._libs.tslibs.timestamps import Timestamp
import pandas as pd
from resumes.models import ResumeBasic
import re
import traceback
class Command(BaseCommand):
help = "导入会计类简历(支持 --keyword 和 --source 参数)"
def add_arguments(self, parser):
parser.add_argument('--file', required=True, help='Excel 文件路径')
parser.add_argument('--keyword', default='', help='crawl_keywords 值')
parser.add_argument('--source', type=int, default=1, help='source_id 值')
def handle(self, *args, **options):
filepath = options['file']
default_keyword = options['keyword']
default_source = options['source']
df = pd.read_excel(filepath)
rename_map = {
'姓名': 'name', '性别': 'gender', '年龄': 'age', '求职区域': 'job_location', '生日': 'birthday',
'学校': 'school', '期望职务': 'expected_position',
'手机': 'phone', '婚姻': 'marital_status', '身高': 'height', '体重': 'weight', '电话': 'phone',
'学历': 'education', '毕业学校': 'school', '工作经验': 'work_years',
'现居住地': 'current_location', '工作地点': 'job_location', '到岗时间': 'available_time',
'最后活跃时间': 'update_time', '最高学历': 'education', '婚姻状态': 'marital_status',
'民族': 'ethnicity', '工作职能': 'job_function', '意向岗位': 'intended_position',
'从事行业': 'industry', '期望薪资': 'expected_salary', '求职类型': 'job_property',
'现居地': 'current_location',
'求职状态': 'job_status', '工作1经历': 'work_1_experience', '工作1时间': 'work_1_time',
'工作1内容': 'work_1_description',
'工作2经历': 'work_2_experience', '工作2时间': 'work_2_time', '工作2内容': 'work_2_description',
'工作3经历': 'work_3_experience', '工作3时间': 'work_3_time', '工作3内容': 'work_3_description',
'工作4经历': 'work_4_experience', '工作4时间': 'work_4_time', '工作4内容': 'work_4_description',
}
df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns}, inplace=True)
df['source_id'] = default_source
df['crawl_keywords'] = default_keyword
def val(v, field=None):
if v is None or pd.isna(v):
if field == 'update_time':
return datetime(2019, 12, 12)
return None
if field == 'update_time':
if isinstance(v, Timestamp):
return v.to_pydatetime()
if isinstance(v, str):
try:
return pd.to_datetime(v).to_pydatetime()
except Exception:
return datetime(2019, 12, 12)
if isinstance(v, datetime):
return v
return datetime(2019, 12, 12)
if isinstance(v, Timestamp):
return v.to_pydatetime()
return v
success_count = 0
fail_count = 0
errors = []
for i, row in df.iterrows():
try:
resume_id = val(row.get('resume_id'))
# 自动从年龄字段中提取整数(如 "38岁" → 38
raw_age = row.get('age')
try:
extracted_age = int(re.search(r'\d+', str(raw_age)).group()) if raw_age else None
except:
extracted_age = None
defaults = {
'name': val(row.get('name')),
'gender': val(row.get('gender')),
'age': extracted_age,
'phone': val(row.get('phone')),
'marital_status': val(row.get('marital_status')),
'height': val(row.get('height')),
'weight': val(row.get('weight')),
'education': val(row.get('education')),
'school': val(row.get('school')),
'work_years': val(row.get('work_years')),
'current_location': val(row.get('current_location')),
'job_location': val(row.get('job_location')),
'available_time': val(row.get('available_time')),
'update_time': val(row['update_time'], field='update_time'),
'ethnicity': val(row.get('ethnicity')),
'job_function': val(row.get('job_function')),
'intended_position': val(row.get('intended_position')),
'industry': val(row.get('industry')),
'expected_salary': val(row.get('expected_salary')),
'job_property': val(row.get('job_property')),
'job_status': val(row.get('job_status')),
'source_id': val(row.get('source_id')),
'crawl_keywords': val(row.get('crawl_keywords')),
'birthday': val(row.get('birthday')),
'expected_position': val(row.get('expected_position')),
'work_1_experience': val(row.get('work_1_experience')),
'work_1_time': val(row.get('work_1_time')),
'work_1_description': val(row.get('work_1_description')),
'work_2_experience': val(row.get('work_2_experience')),
'work_2_time': val(row.get('work_2_time')),
'work_2_description': val(row.get('work_2_description')),
'work_3_experience': val(row.get('work_3_experience')),
'work_3_time': val(row.get('work_3_time')),
'work_3_description': val(row.get('work_3_description')),
'work_4_experience': val(row.get('work_4_experience')),
'work_4_time': val(row.get('work_4_time')),
'work_4_description': val(row.get('work_4_description')),
}
# 安全方式get_or_create + 逐字段 set
obj, _ = ResumeBasic.objects.get_or_create(resume_id=resume_id)
for k, v in defaults.items():
try:
setattr(obj, k, v)
except Exception as field_error:
print(f"[字段设置错误] {k} = {v!r} ({type(v)}) → {field_error}")
raise
obj.save()
success_count += 1
except Exception as e:
fail_count += 1
errors.append((i + 2, str(e)))
print(f"\n❌ 第 {i + 2} 行出错:{e}")
print(f"resume_id: {repr(resume_id)} ({type(resume_id).__name__})")
for k, v in defaults.items():
print(f"{k:<20} | {repr(v):<30} | {type(v).__name__}")
traceback.print_exc()
self.stdout.write(self.style.SUCCESS(f"导入完成!总数:{len(df)},成功:{success_count},失败:{fail_count}"))
if errors:
self.stdout.write(self.style.WARNING("失败记录如下:"))
for line_no, msg in errors:
self.stdout.write(f"{line_no} 行出错:{msg}")

View File

@ -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}")

View File

@ -1,3 +1,109 @@
from django.db import models
# Create your models here.
from django.db import models
from websites.models import Website
class ResumeBasic(models.Model):
resume_id = models.IntegerField(db_index=True, verbose_name="简历ID", help_text="resume_id")
name = models.CharField(max_length=255, null=True, blank=True, verbose_name="姓名", help_text="姓名")
job_region = models.CharField(max_length=255, null=True, blank=True, verbose_name="求职区域",
help_text="求职区域")
birthday = models.CharField(max_length=255, null=True, blank=True, verbose_name="生日", help_text="生日")
education = models.CharField(max_length=255, null=True, blank=True, verbose_name="学历", help_text="学历")
school = models.CharField(max_length=255, null=True, blank=True, verbose_name="学校", help_text="学校")
expected_position = models.CharField(max_length=255, null=True, blank=True, verbose_name="期望职务",
help_text="期望职务")
last_active_time = models.CharField(max_length=255, null=True, blank=True, verbose_name="最后活跃时间",
help_text="最后活跃时间")
marital_status = models.CharField(max_length=255, null=True, blank=True, verbose_name="婚姻", help_text="婚姻")
current_location = models.CharField(max_length=255, null=True, blank=True, verbose_name="现居地",
help_text="现居地")
age = models.IntegerField(null=True, blank=True, verbose_name="年龄", help_text="年龄")
phone = models.CharField(max_length=255, null=True, blank=True, verbose_name="电话", help_text="电话")
gender = models.CharField(max_length=255, null=True, blank=True, verbose_name="性别", help_text="性别")
job_type = models.CharField(max_length=255, null=True, blank=True, verbose_name="求职类型",
help_text="求职类型")
job_status = models.CharField(max_length=255, null=True, blank=True, verbose_name="求职状态",
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_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_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_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_description = models.TextField(null=True, blank=True, verbose_name="工作4内容", help_text="工作4内容")
height = models.IntegerField(null=True, blank=True, verbose_name="身高", help_text="身高")
weight = models.IntegerField(null=True, blank=True, verbose_name="体重", help_text="体重")
work_years = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作经验", help_text="工作经验")
highest_education = models.CharField(max_length=255, null=True, blank=True, verbose_name="最高学历",
help_text="最高学历")
ethnicity = models.CharField(max_length=255, null=True, blank=True, verbose_name="民族", help_text="民族")
update_time = models.DateTimeField(null=True, blank=True, verbose_name="更新时间", help_text="更新时间")
job_function = models.CharField(max_length=255, null=True, blank=True, verbose_name="工作职能",
help_text="工作职能")
intended_position = models.CharField(max_length=255, null=True, blank=True, verbose_name="意向岗位",
help_text="意向岗位")
industry = models.CharField(max_length=255, null=True, blank=True, verbose_name="从事行业",
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="到岗时间")
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="工作地点",
help_text="工作地点")
source = models.ForeignKey(
Website,
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="数据来源",
help_text="数据来源网站"
)
crawl_keywords = models.CharField(max_length=255, null=True, blank=True, verbose_name="关键字", help_text="关键字")
def __str__(self):
return f"{self.name} - {self.resume_id} - {self.name}"
class Meta:
verbose_name = "简历"
verbose_name_plural = "简历列表"
unique_together = ('source', 'resume_id')
class ResumeDetail(models.Model):
resume = models.OneToOneField(
ResumeBasic,
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:
verbose_name = "简历详情"
verbose_name_plural = "简历详情"

0
utils/__init__.py Normal file
View File

16
utils/auth.py Normal file
View File

@ -0,0 +1,16 @@
from ninja.security import HttpBearer
from rest_framework_simplejwt.authentication import JWTAuthentication
class JWTAuth(HttpBearer):
def authenticate(self, request, token):
jwt_auth = JWTAuthentication()
try:
validated_token = jwt_auth.get_validated_token(token)
user = jwt_auth.get_user(validated_token)
return user
except Exception:
return None
jwt_auth = JWTAuth()

32
utils/permissions.py Normal file
View File

@ -0,0 +1,32 @@
from functools import wraps
from ninja.errors import HttpError
def login_required(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
user = getattr(request, 'auth', None)
if not user or not user.is_authenticated:
raise HttpError(401, "请先登录")
request.user = user
return func(request, *args, **kwargs)
return wrapper
def manager_required(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
user = getattr(request, 'auth', None)
if not user or not user.is_authenticated or user.role not in ['admin', 'manager']:
raise HttpError(403, "仅分管理或管理员可访问")
request.user = user
return func(request, *args, **kwargs)
return wrapper
def admin_required(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
user = getattr(request, 'auth', None)
if not user or not user.is_authenticated or user.role != 'admin':
raise HttpError(403, "仅管理员可访问")
request.user = user
return func(request, *args, **kwargs)
return wrapper

View File

@ -1,3 +1,7 @@
from django.contrib import admin
from .models import *
# Register your models here.
@admin.register(Website)
class WebsiteAdmin(admin.ModelAdmin):
list_display = ('name', 'db_alias', 'db_alias')

View File

@ -1,3 +1,15 @@
from django.db import models
# Create your models here.
class Website(models.Model):
name = models.CharField(max_length=100, verbose_name="网站名称")
db_alias = models.CharField(max_length=50, unique=True, verbose_name="数据库别名")
description = models.TextField(blank=True, verbose_name="描述")
def __str__(self):
return self.name
class Meta:
verbose_name = "网站"
verbose_name_plural = "网站列表"