From f228ff0697442becda2d6ed7e0f31448a0c4a329 Mon Sep 17 00:00:00 2001 From: TianyangZhang Date: Tue, 24 Mar 2026 17:39:58 +0800 Subject: [PATCH] feat: add Job model with search/filter and role-based access Co-Authored-By: Claude Sonnet 4.6 --- offer_backend/apps/jobs/filters.py | 13 ++++++ .../apps/jobs/migrations/0001_initial.py | 35 +++++++++++++++ .../apps/jobs/migrations/__init__.py | 0 offer_backend/apps/jobs/models.py | 29 ++++++++++++ offer_backend/apps/jobs/serializers.py | 27 +++++++++++ offer_backend/apps/jobs/tests/__init__.py | 0 offer_backend/apps/jobs/tests/test_jobs.py | 45 +++++++++++++++++++ offer_backend/apps/jobs/urls.py | 10 ++++- offer_backend/apps/jobs/views.py | 40 +++++++++++++++++ 9 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 offer_backend/apps/jobs/filters.py create mode 100644 offer_backend/apps/jobs/migrations/0001_initial.py create mode 100644 offer_backend/apps/jobs/migrations/__init__.py create mode 100644 offer_backend/apps/jobs/models.py create mode 100644 offer_backend/apps/jobs/serializers.py create mode 100644 offer_backend/apps/jobs/tests/__init__.py create mode 100644 offer_backend/apps/jobs/tests/test_jobs.py create mode 100644 offer_backend/apps/jobs/views.py diff --git a/offer_backend/apps/jobs/filters.py b/offer_backend/apps/jobs/filters.py new file mode 100644 index 0000000..b14e44f --- /dev/null +++ b/offer_backend/apps/jobs/filters.py @@ -0,0 +1,13 @@ +import django_filters +from .models import Job + + +class JobFilter(django_filters.FilterSet): + title = django_filters.CharFilter(lookup_expr='icontains') + category = django_filters.CharFilter(lookup_expr='exact') + location = django_filters.CharFilter(lookup_expr='icontains') + organization = django_filters.NumberFilter(field_name='organization__id') + + class Meta: + model = Job + fields = ['title', 'category', 'location', 'organization'] diff --git a/offer_backend/apps/jobs/migrations/0001_initial.py b/offer_backend/apps/jobs/migrations/0001_initial.py new file mode 100644 index 0000000..d6022b3 --- /dev/null +++ b/offer_backend/apps/jobs/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.20 on 2026-03-24 09:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Job', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='职位名称')), + ('category', models.CharField(max_length=50, verbose_name='职位类别')), + ('location', models.CharField(max_length=100, verbose_name='工作地点')), + ('salary', models.CharField(max_length=50, verbose_name='薪资范围')), + ('description', models.TextField(blank=True, verbose_name='职位描述')), + ('status', models.CharField(choices=[('draft', '草稿'), ('published', '已发布'), ('closed', '已关闭')], default='draft', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='organizations.organization')), + ], + options={ + 'verbose_name': '职位', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/offer_backend/apps/jobs/migrations/__init__.py b/offer_backend/apps/jobs/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/offer_backend/apps/jobs/models.py b/offer_backend/apps/jobs/models.py new file mode 100644 index 0000000..a530a94 --- /dev/null +++ b/offer_backend/apps/jobs/models.py @@ -0,0 +1,29 @@ +from django.db import models + + +class Job(models.Model): + STATUS_CHOICES = [ + ('draft', '草稿'), + ('published', '已发布'), + ('closed', '已关闭'), + ] + organization = models.ForeignKey( + 'organizations.Organization', + on_delete=models.CASCADE, + related_name='jobs' + ) + title = models.CharField(max_length=100, verbose_name='职位名称') + category = models.CharField(max_length=50, verbose_name='职位类别') + location = models.CharField(max_length=100, verbose_name='工作地点') + salary = models.CharField(max_length=50, verbose_name='薪资范围') + description = models.TextField(verbose_name='职位描述', blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + verbose_name = '职位' + + def __str__(self): + return self.title diff --git a/offer_backend/apps/jobs/serializers.py b/offer_backend/apps/jobs/serializers.py new file mode 100644 index 0000000..8dc5fc1 --- /dev/null +++ b/offer_backend/apps/jobs/serializers.py @@ -0,0 +1,27 @@ +from rest_framework import serializers +from .models import Job +from apps.organizations.serializers import OrganizationSerializer +from apps.organizations.models import Organization + + +class JobListSerializer(serializers.ModelSerializer): + organization_name = serializers.CharField(source='organization.name', read_only=True) + + class Meta: + model = Job + fields = ['id', 'title', 'category', 'location', 'salary', + 'organization', 'organization_name', 'status', 'created_at'] + + +class JobDetailSerializer(serializers.ModelSerializer): + organization = OrganizationSerializer(read_only=True) + organization_id = serializers.PrimaryKeyRelatedField( + source='organization', + queryset=Organization.objects.all(), + write_only=True + ) + + class Meta: + model = Job + fields = ['id', 'title', 'category', 'location', 'salary', + 'description', 'organization', 'organization_id', 'status', 'created_at'] diff --git a/offer_backend/apps/jobs/tests/__init__.py b/offer_backend/apps/jobs/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/offer_backend/apps/jobs/tests/test_jobs.py b/offer_backend/apps/jobs/tests/test_jobs.py new file mode 100644 index 0000000..00fa0fc --- /dev/null +++ b/offer_backend/apps/jobs/tests/test_jobs.py @@ -0,0 +1,45 @@ +import pytest +from django.contrib.auth import get_user_model +from apps.organizations.models import Organization +from apps.jobs.models import Job + +User = get_user_model() + +@pytest.fixture +def org(): + return Organization.objects.create(name='测试公司', email='test@test.com') + +@pytest.fixture +def admin_user(org): + return User.objects.create_user( + username='admin1', password='pass123', + role='admin', organization=org + ) + +@pytest.mark.django_db +class TestJobModel: + def test_create_job(self, org): + job = Job.objects.create( + organization=org, title='Python工程师', + category='技术', location='北京', salary='20k-30k', + description='职位描述', status='published' + ) + assert job.status == 'published' + assert str(job) == 'Python工程师' + +@pytest.mark.django_db +class TestJobAPI: + def test_public_can_list_published_jobs(self, client, org): + Job.objects.create( + organization=org, title='已发布职位', + status='published', category='技术', + location='北京', salary='10k' + ) + Job.objects.create( + organization=org, title='草稿职位', + status='draft', category='技术', + location='北京', salary='10k' + ) + response = client.get('/api/jobs/public/') + assert response.status_code == 200 + assert response.data['count'] == 1 diff --git a/offer_backend/apps/jobs/urls.py b/offer_backend/apps/jobs/urls.py index e39cb2c..f156e0d 100644 --- a/offer_backend/apps/jobs/urls.py +++ b/offer_backend/apps/jobs/urls.py @@ -1,3 +1,9 @@ -from django.urls import path +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import JobPublicViewSet, JobManageViewSet -urlpatterns = [] +router = DefaultRouter() +router.register('public', JobPublicViewSet, basename='job-public') +router.register('manage', JobManageViewSet, basename='job-manage') + +urlpatterns = [path('', include(router.urls))] diff --git a/offer_backend/apps/jobs/views.py b/offer_backend/apps/jobs/views.py new file mode 100644 index 0000000..0b2d4ef --- /dev/null +++ b/offer_backend/apps/jobs/views.py @@ -0,0 +1,40 @@ +from rest_framework import viewsets, permissions +from .models import Job +from .serializers import JobListSerializer, JobDetailSerializer +from .filters import JobFilter +from apps.accounts.permissions import IsAdminOrSuperAdmin + + +class JobPublicViewSet(viewsets.ReadOnlyModelViewSet): + """公开只读,仅返回已发布职位""" + queryset = Job.objects.filter(status='published').select_related('organization') + filterset_class = JobFilter + search_fields = ['title', 'description', 'location'] + permission_classes = [permissions.AllowAny] + + def get_serializer_class(self): + if self.action == 'retrieve': + return JobDetailSerializer + return JobListSerializer + + +class JobManageViewSet(viewsets.ModelViewSet): + """管理端:公司管理员管理本公司职位""" + permission_classes = [IsAdminOrSuperAdmin] + + def get_serializer_class(self): + if self.action in ['retrieve', 'create', 'update', 'partial_update']: + return JobDetailSerializer + return JobListSerializer + + def get_queryset(self): + user = self.request.user + if user.is_superadmin: + return Job.objects.all().select_related('organization') + return Job.objects.filter(organization=user.organization).select_related('organization') + + def perform_create(self, serializer): + if self.request.user.is_admin: + serializer.save(organization=self.request.user.organization) + else: + serializer.save()