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:
TianyangZhang 2026-03-24 17:46:14 +08:00
parent cc39c22e87
commit 0ccd943255
9 changed files with 217 additions and 2 deletions

View File

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

View File

@ -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')},
},
),
]

View File

@ -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 = '投递记录'

View File

@ -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']

View File

@ -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={},
)

View File

@ -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)),
]

View File

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