feat: implement API security measures phase 1

This commit is contained in:
Marko (Hermes Implementer)
2026-05-27 00:24:09 +00:00
parent a0b3ae7e34
commit c2fc7bc15f
21 changed files with 1050 additions and 44 deletions
@@ -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
View File
@@ -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}"
+25
View File
@@ -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
View File
@@ -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