feat: implement API security measures phase 1
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.1.7 on 2026-05-27 00:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def assign_existing_applications_to_first_superuser(apps, schema_editor):
|
||||
"""Assign existing JobApplication rows to the first superuser."""
|
||||
JobApplication = apps.get_model("jobs", "JobApplication")
|
||||
User = apps.get_model("accounts", "User")
|
||||
admin = User.objects.filter(is_superuser=True).order_by("id").first()
|
||||
if admin is not None:
|
||||
JobApplication.objects.filter(user__isnull=True).update(user=admin)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jobs', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 1. Add user FK as nullable initially so existing rows can be migrated
|
||||
migrations.AddField(
|
||||
model_name='jobapplication',
|
||||
name='user',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='job_applications',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
help_text='User who owns this job application.',
|
||||
),
|
||||
),
|
||||
# 2. Assign existing rows to the first superuser
|
||||
migrations.RunPython(
|
||||
assign_existing_applications_to_first_superuser,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
# 3. Make user non-nullable now that all rows have a value
|
||||
migrations.AlterField(
|
||||
model_name='jobapplication',
|
||||
name='user',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='job_applications',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
help_text='User who owns this job application.',
|
||||
),
|
||||
),
|
||||
# 4. Add the unique constraint
|
||||
migrations.AddConstraint(
|
||||
model_name='jobapplication',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=('user', 'company_name', 'position_title'),
|
||||
name='unique_user_job_application',
|
||||
),
|
||||
),
|
||||
]
|
||||
+15
-1
@@ -1,5 +1,7 @@
|
||||
from django.db import models
|
||||
|
||||
from accounts.models import User
|
||||
|
||||
|
||||
class StatusChoices(models.TextChoices):
|
||||
APPLIED = "APPLIED", "Applied"
|
||||
@@ -11,6 +13,12 @@ class StatusChoices(models.TextChoices):
|
||||
|
||||
|
||||
class JobApplication(models.Model):
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="job_applications",
|
||||
help_text="User who owns this job application.",
|
||||
)
|
||||
company_name = models.CharField(max_length=255)
|
||||
position_title = models.CharField(max_length=255)
|
||||
status = models.CharField(
|
||||
@@ -24,6 +32,12 @@ class JobApplication(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "company_name", "position_title"],
|
||||
name="unique_user_job_application",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.company_name} - {self.position_title}"
|
||||
@@ -57,4 +71,4 @@ class JobUpdate(models.Model):
|
||||
verbose_name_plural = "Job Updates"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Update #{self.id}: {self.job_application.company_name} → {self.to_status}"
|
||||
return f"Update #{self.id}: {self.job_application.company_name} -> {self.to_status}"
|
||||
@@ -3,6 +3,20 @@ from rest_framework import serializers
|
||||
from .models import JobApplication, JobUpdate
|
||||
|
||||
|
||||
class SanitizedCharField(serializers.CharField):
|
||||
"""CharField that strips control characters on deserialization."""
|
||||
|
||||
def to_internal_value(self, data: object) -> object:
|
||||
value = super().to_internal_value(data)
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if "\x00" in value:
|
||||
raise serializers.ValidationError("Input contains invalid characters.")
|
||||
import re
|
||||
value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", value)
|
||||
return value
|
||||
|
||||
|
||||
class JobUpdateSerializer(serializers.ModelSerializer):
|
||||
company_name = serializers.CharField(source="job_application.company_name", read_only=True)
|
||||
position_title = serializers.CharField(source="job_application.position_title", read_only=True)
|
||||
@@ -24,10 +38,16 @@ class JobUpdateSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class JobApplicationSerializer(serializers.ModelSerializer):
|
||||
user_id = serializers.IntegerField(read_only=True)
|
||||
notes = SanitizedCharField(required=False, allow_blank=True, default="")
|
||||
company_name = SanitizedCharField(max_length=255)
|
||||
position_title = SanitizedCharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
model = JobApplication
|
||||
fields = [
|
||||
"id",
|
||||
"user_id",
|
||||
"company_name",
|
||||
"position_title",
|
||||
"status",
|
||||
@@ -35,6 +55,11 @@ class JobApplicationSerializer(serializers.ModelSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["user_id"]
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["user"] = self.context["request"].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class DashboardMetricsSerializer(serializers.Serializer):
|
||||
|
||||
+29
-7
@@ -1,7 +1,7 @@
|
||||
from django.db.models import Count, Q
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
@@ -14,16 +14,30 @@ from .serializers import (
|
||||
|
||||
|
||||
class JobApplicationViewSet(viewsets.ModelViewSet):
|
||||
queryset = JobApplication.objects.all().prefetch_related("updates")
|
||||
serializer_class = JobApplicationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter by user for non-admin users; admins see all."""
|
||||
user = self.request.user
|
||||
qs = JobApplication.objects.all().prefetch_related("updates")
|
||||
if not user.is_admin:
|
||||
qs = qs.filter(user=user)
|
||||
return qs
|
||||
|
||||
|
||||
class JobUpdateViewSet(viewsets.ModelViewSet):
|
||||
queryset = JobUpdate.objects.select_related("job_application").all()
|
||||
serializer_class = JobUpdateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter by user via job_application for non-admin users."""
|
||||
user = self.request.user
|
||||
qs = JobUpdate.objects.select_related("job_application").all()
|
||||
if not user.is_admin:
|
||||
qs = qs.filter(job_application__user=user)
|
||||
return qs
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def latest(self, request: Request) -> Response:
|
||||
"""Return the 3 most recent job updates with job info and metrics."""
|
||||
@@ -38,16 +52,24 @@ class JobUpdateViewSet(viewsets.ModelViewSet):
|
||||
@action(detail=False, methods=["get"])
|
||||
def metrics(self, request: Request) -> Response:
|
||||
"""Return dashboard metrics: total apps, status breakdown, interview/offer counts."""
|
||||
total = JobApplication.objects.count()
|
||||
qs = self.get_queryset()
|
||||
|
||||
total = qs.values("job_application").distinct().count()
|
||||
# Get status counts from the distinct job applications the user owns
|
||||
app_qs = JobApplication.objects.all()
|
||||
if not request.user.is_admin:
|
||||
app_qs = app_qs.filter(user=request.user)
|
||||
|
||||
total = app_qs.count()
|
||||
status_counts = dict(
|
||||
JobApplication.objects.values("status")
|
||||
app_qs.values("status")
|
||||
.annotate(count=Count("id"))
|
||||
.values_list("status", "count")
|
||||
)
|
||||
interview_count = JobApplication.objects.filter(
|
||||
interview_count = app_qs.filter(
|
||||
Q(status="INTERVIEW") | Q(status="OFFER")
|
||||
).count()
|
||||
offer_count = JobApplication.objects.filter(status="OFFER").count()
|
||||
offer_count = app_qs.filter(status="OFFER").count()
|
||||
rejection_rate = (
|
||||
round(
|
||||
status_counts.get("REJECTED", 0) / total * 100, 1
|
||||
|
||||
Reference in New Issue
Block a user