feat: add Application model with status tracking and email notifications
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cc39c22e87
commit
0ccd943255
|
|
@ -0,0 +1,22 @@
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
STATUS_LABELS = {
|
||||||
|
'viewed': '已查看',
|
||||||
|
'interviewing': '面试邀请',
|
||||||
|
'hired': '恭喜录用',
|
||||||
|
'rejected': '很遗憾未通过',
|
||||||
|
}
|
||||||
|
|
||||||
|
def notify_status_change(application):
|
||||||
|
label = STATUS_LABELS.get(application.status)
|
||||||
|
if not label or not application.applicant.email:
|
||||||
|
return
|
||||||
|
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@offer.com')
|
||||||
|
send_mail(
|
||||||
|
subject=f'【招聘通知】您投递的"{application.job.title}"状态更新:{label}',
|
||||||
|
message=f'您好 {application.applicant.username},\n\n您投递的职位"{application.job.title}"状态已更新为:{label}。\n\n请登录平台查看详情。',
|
||||||
|
from_email=from_email,
|
||||||
|
recipient_list=[application.applicant.email],
|
||||||
|
fail_silently=True,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Generated by Django 4.2.20 on 2026-03-24 09:45
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('jobs', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Application',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('resume_snapshot', models.JSONField(verbose_name='简历快照')),
|
||||||
|
('status', models.CharField(choices=[('pending', '待查看'), ('viewed', '已查看'), ('interviewing', '面试中'), ('hired', '已录用'), ('rejected', '已拒绝')], default='pending', max_length=20)),
|
||||||
|
('note', models.TextField(blank=True, verbose_name='HR备注')),
|
||||||
|
('applied_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('applicant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='jobs.job')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '投递记录',
|
||||||
|
'ordering': ['-applied_at'],
|
||||||
|
'unique_together': {('job', 'applicant')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class Application(models.Model):
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('pending', '待查看'),
|
||||||
|
('viewed', '已查看'),
|
||||||
|
('interviewing', '面试中'),
|
||||||
|
('hired', '已录用'),
|
||||||
|
('rejected', '已拒绝'),
|
||||||
|
]
|
||||||
|
job = models.ForeignKey('jobs.Job', on_delete=models.CASCADE, related_name='applications')
|
||||||
|
applicant = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='applications'
|
||||||
|
)
|
||||||
|
resume_snapshot = models.JSONField(verbose_name='简历快照')
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||||
|
note = models.TextField(blank=True, verbose_name='HR备注')
|
||||||
|
applied_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [['job', 'applicant']]
|
||||||
|
ordering = ['-applied_at']
|
||||||
|
verbose_name = '投递记录'
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Application
|
||||||
|
|
||||||
|
class ApplicationCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Application
|
||||||
|
fields = ['job']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
request = self.context['request']
|
||||||
|
try:
|
||||||
|
resume = request.user.resume
|
||||||
|
except Exception:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{'detail': '请先完善简历后再投递'}
|
||||||
|
)
|
||||||
|
if not resume.name:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{'detail': '请先完善简历后再投递'}
|
||||||
|
)
|
||||||
|
return Application.objects.create(
|
||||||
|
job=validated_data['job'],
|
||||||
|
applicant=request.user,
|
||||||
|
resume_snapshot=resume.to_snapshot(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class ApplicationSerializer(serializers.ModelSerializer):
|
||||||
|
job_title = serializers.CharField(source='job.title', read_only=True)
|
||||||
|
company_name = serializers.CharField(source='job.organization.name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Application
|
||||||
|
fields = ['id', 'job', 'job_title', 'company_name',
|
||||||
|
'resume_snapshot', 'status', 'note', 'applied_at']
|
||||||
|
read_only_fields = ['resume_snapshot', 'applied_at']
|
||||||
|
|
||||||
|
class ApplicationStatusSerializer(serializers.ModelSerializer):
|
||||||
|
"""HR 更新状态"""
|
||||||
|
class Meta:
|
||||||
|
model = Application
|
||||||
|
fields = ['status', 'note']
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from apps.organizations.models import Organization
|
||||||
|
from apps.jobs.models import Job
|
||||||
|
from apps.resumes.models import Resume
|
||||||
|
from apps.applications.models import Application
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup(db):
|
||||||
|
org = Organization.objects.create(name='公司', email='co@test.com')
|
||||||
|
seeker = User.objects.create_user(username='seeker1', password='pass', role='seeker')
|
||||||
|
job = Job.objects.create(
|
||||||
|
organization=org, title='测试职位', category='技术',
|
||||||
|
location='北京', salary='15k', status='published'
|
||||||
|
)
|
||||||
|
resume = Resume.objects.create(user=seeker, name='张三')
|
||||||
|
return {'org': org, 'seeker': seeker, 'job': job, 'resume': resume}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestApplicationModel:
|
||||||
|
def test_create_application(self, setup):
|
||||||
|
app = Application.objects.create(
|
||||||
|
job=setup['job'],
|
||||||
|
applicant=setup['seeker'],
|
||||||
|
resume_snapshot=setup['resume'].to_snapshot(),
|
||||||
|
)
|
||||||
|
assert app.status == 'pending'
|
||||||
|
assert app.resume_snapshot['name'] == '张三'
|
||||||
|
|
||||||
|
def test_cannot_apply_twice(self, setup):
|
||||||
|
Application.objects.create(
|
||||||
|
job=setup['job'],
|
||||||
|
applicant=setup['seeker'],
|
||||||
|
resume_snapshot=setup['resume'].to_snapshot(),
|
||||||
|
)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
Application.objects.create(
|
||||||
|
job=setup['job'],
|
||||||
|
applicant=setup['seeker'],
|
||||||
|
resume_snapshot={},
|
||||||
|
)
|
||||||
|
|
@ -1,3 +1,13 @@
|
||||||
from django.urls import path
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import ApplyView, MyApplicationsView, ApplicationManageViewSet, ApplicationStatusUpdateView
|
||||||
|
|
||||||
urlpatterns = []
|
router = DefaultRouter()
|
||||||
|
router.register('manage', ApplicationManageViewSet, basename='application-manage')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('apply/', ApplyView.as_view()),
|
||||||
|
path('mine/', MyApplicationsView.as_view()),
|
||||||
|
path('manage/<int:pk>/status/', ApplicationStatusUpdateView.as_view()),
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
from rest_framework import generics, viewsets
|
||||||
|
from .models import Application
|
||||||
|
from .serializers import ApplicationCreateSerializer, ApplicationSerializer, ApplicationStatusSerializer
|
||||||
|
from .emails import notify_status_change
|
||||||
|
from apps.accounts.permissions import IsSeeker, IsAdminOrSuperAdmin
|
||||||
|
|
||||||
|
class ApplyView(generics.CreateAPIView):
|
||||||
|
serializer_class = ApplicationCreateSerializer
|
||||||
|
permission_classes = [IsSeeker]
|
||||||
|
|
||||||
|
class MyApplicationsView(generics.ListAPIView):
|
||||||
|
serializer_class = ApplicationSerializer
|
||||||
|
permission_classes = [IsSeeker]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Application.objects.filter(applicant=self.request.user).select_related('job__organization')
|
||||||
|
|
||||||
|
class ApplicationManageViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""HR 查看本公司投递"""
|
||||||
|
serializer_class = ApplicationSerializer
|
||||||
|
permission_classes = [IsAdminOrSuperAdmin]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
if user.is_superadmin:
|
||||||
|
return Application.objects.all().select_related('job__organization', 'applicant')
|
||||||
|
return Application.objects.filter(
|
||||||
|
job__organization=user.organization
|
||||||
|
).select_related('job__organization', 'applicant')
|
||||||
|
|
||||||
|
class ApplicationStatusUpdateView(generics.UpdateAPIView):
|
||||||
|
serializer_class = ApplicationStatusSerializer
|
||||||
|
permission_classes = [IsAdminOrSuperAdmin]
|
||||||
|
queryset = Application.objects.all()
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
instance = serializer.save()
|
||||||
|
notify_status_change(instance)
|
||||||
Loading…
Reference in New Issue