From 0ccd94325535f6a1f661a32f45473db18678befe Mon Sep 17 00:00:00 2001 From: TianyangZhang Date: Tue, 24 Mar 2026 17:46:14 +0800 Subject: [PATCH] feat: add Application model with status tracking and email notifications Co-Authored-By: Claude Sonnet 4.6 --- offer_backend/apps/applications/emails.py | 22 ++++++++++ .../applications/migrations/0001_initial.py | 36 ++++++++++++++++ .../apps/applications/migrations/__init__.py | 0 offer_backend/apps/applications/models.py | 25 +++++++++++ .../apps/applications/serializers.py | 41 ++++++++++++++++++ .../apps/applications/tests/__init__.py | 0 .../applications/tests/test_applications.py | 43 +++++++++++++++++++ offer_backend/apps/applications/urls.py | 14 +++++- offer_backend/apps/applications/views.py | 38 ++++++++++++++++ 9 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 offer_backend/apps/applications/emails.py create mode 100644 offer_backend/apps/applications/migrations/0001_initial.py create mode 100644 offer_backend/apps/applications/migrations/__init__.py create mode 100644 offer_backend/apps/applications/models.py create mode 100644 offer_backend/apps/applications/serializers.py create mode 100644 offer_backend/apps/applications/tests/__init__.py create mode 100644 offer_backend/apps/applications/tests/test_applications.py create mode 100644 offer_backend/apps/applications/views.py diff --git a/offer_backend/apps/applications/emails.py b/offer_backend/apps/applications/emails.py new file mode 100644 index 0000000..d9c2f28 --- /dev/null +++ b/offer_backend/apps/applications/emails.py @@ -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, + ) diff --git a/offer_backend/apps/applications/migrations/0001_initial.py b/offer_backend/apps/applications/migrations/0001_initial.py new file mode 100644 index 0000000..a149416 --- /dev/null +++ b/offer_backend/apps/applications/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/offer_backend/apps/applications/migrations/__init__.py b/offer_backend/apps/applications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/offer_backend/apps/applications/models.py b/offer_backend/apps/applications/models.py new file mode 100644 index 0000000..6c27488 --- /dev/null +++ b/offer_backend/apps/applications/models.py @@ -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 = '投递记录' diff --git a/offer_backend/apps/applications/serializers.py b/offer_backend/apps/applications/serializers.py new file mode 100644 index 0000000..abb3578 --- /dev/null +++ b/offer_backend/apps/applications/serializers.py @@ -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'] diff --git a/offer_backend/apps/applications/tests/__init__.py b/offer_backend/apps/applications/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/offer_backend/apps/applications/tests/test_applications.py b/offer_backend/apps/applications/tests/test_applications.py new file mode 100644 index 0000000..657c00e --- /dev/null +++ b/offer_backend/apps/applications/tests/test_applications.py @@ -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={}, + ) diff --git a/offer_backend/apps/applications/urls.py b/offer_backend/apps/applications/urls.py index e39cb2c..e9b8307 100644 --- a/offer_backend/apps/applications/urls.py +++ b/offer_backend/apps/applications/urls.py @@ -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//status/', ApplicationStatusUpdateView.as_view()), + path('', include(router.urls)), +] diff --git a/offer_backend/apps/applications/views.py b/offer_backend/apps/applications/views.py new file mode 100644 index 0000000..2fe189f --- /dev/null +++ b/offer_backend/apps/applications/views.py @@ -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)