feat: implement API security measures phase 1
This commit is contained in:
+12
-2
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
|
||||
from accounts.models import User
|
||||
from accounts.models import BlacklistedToken, User
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
@@ -36,4 +36,14 @@ class UserAdmin(BaseUserAdmin):
|
||||
)
|
||||
list_display = ("email", "first_name", "last_name", "is_staff")
|
||||
search_fields = ("email", "first_name", "last_name")
|
||||
ordering = ("email",)
|
||||
ordering = ("email",)
|
||||
|
||||
|
||||
@admin.register(BlacklistedToken)
|
||||
class BlacklistedTokenAdmin(admin.ModelAdmin):
|
||||
"""Admin config for BlacklistedToken."""
|
||||
|
||||
list_display = ("jti", "user", "created_at", "expires_at")
|
||||
list_select_related = ("user",)
|
||||
search_fields = ("jti", "user__email")
|
||||
ordering = ("-created_at",)
|
||||
@@ -0,0 +1,149 @@
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied, ValidationError as DjangoValidationError
|
||||
from django.http import Http404
|
||||
from rest_framework import exceptions, status
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
|
||||
logger = logging.getLogger("django.request")
|
||||
|
||||
|
||||
def _safe_error_response(
|
||||
detail: str,
|
||||
code: str,
|
||||
status_code: int,
|
||||
) -> Response:
|
||||
"""Return a consistent error response with no stack traces."""
|
||||
return Response(
|
||||
{"error": detail, "code": code},
|
||||
status=status_code,
|
||||
)
|
||||
|
||||
|
||||
def api_exception_handler(exc: Exception, context: dict) -> Response | None:
|
||||
"""
|
||||
Custom DRF exception handler that:
|
||||
- Never exposes stack traces, file paths, or Python internals
|
||||
- Maps common exceptions to user-safe messages
|
||||
- Logs full traceback to django.request logger
|
||||
- Returns consistent {error, code} format
|
||||
"""
|
||||
# Always log the full traceback
|
||||
logger.error(
|
||||
"API Exception: %s: %s\n%s",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
"".join(traceback.format_tb(exc.__traceback__)),
|
||||
)
|
||||
|
||||
# PermissionDenied -> 403
|
||||
if isinstance(exc, PermissionDenied):
|
||||
return _safe_error_response(
|
||||
"You do not have permission to perform this action.",
|
||||
"permission_denied",
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Http404 -> 404
|
||||
if isinstance(exc, Http404):
|
||||
return _safe_error_response(
|
||||
"The requested resource was not found.",
|
||||
"not_found",
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Django ValidationError -> 400
|
||||
if isinstance(exc, DjangoValidationError):
|
||||
return _safe_error_response(
|
||||
str(exc) if isinstance(exc.message, str) else "Validation error.",
|
||||
"validation_error",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# DRF APIException (includes AuthenticationFailed, NotAuthenticated, ParseError, etc.)
|
||||
if isinstance(exc, APIException):
|
||||
# Use DRF's standard handling but map to our format
|
||||
response = drf_exception_handler(exc, context)
|
||||
if response is not None:
|
||||
# Ensure response is our safe format
|
||||
detail = _extract_detail(response.data)
|
||||
return _safe_error_response(
|
||||
detail,
|
||||
_get_error_code(exc),
|
||||
response.status_code,
|
||||
)
|
||||
|
||||
# DRF Throttled
|
||||
if isinstance(exc, exceptions.Throttled):
|
||||
return _safe_error_response(
|
||||
"Request rate limit exceeded. Please try again later.",
|
||||
"throttled",
|
||||
exc.status_code,
|
||||
)
|
||||
|
||||
# AuthenticationFailed / NotAuthenticated
|
||||
if isinstance(exc, exceptions.AuthenticationFailed):
|
||||
return _safe_error_response(
|
||||
str(exc.detail) if hasattr(exc, "detail") else "Authentication failed.",
|
||||
"authentication_failed",
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if isinstance(exc, exceptions.NotAuthenticated):
|
||||
return _safe_error_response(
|
||||
"Authentication credentials were not provided.",
|
||||
"not_authenticated",
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Catch-all for unhandled exceptions
|
||||
if not settings.DEBUG:
|
||||
return _safe_error_response(
|
||||
"Internal server error.",
|
||||
"internal_error",
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# In DEBUG mode, let DRF's default handler show the traceback
|
||||
return drf_exception_handler(exc, context)
|
||||
|
||||
|
||||
def _extract_detail(data: dict | list | str) -> str:
|
||||
"""Extract the first meaningful error string from DRF error data."""
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
result = _extract_detail(item)
|
||||
if result:
|
||||
return result
|
||||
if isinstance(data, dict):
|
||||
# Try 'detail' first, then first field error
|
||||
if "detail" in data:
|
||||
return _extract_detail(data["detail"])
|
||||
for _key, value in data.items():
|
||||
result = _extract_detail(value)
|
||||
if result:
|
||||
return result
|
||||
return "An error occurred."
|
||||
|
||||
|
||||
def _get_error_code(exc: APIException) -> str:
|
||||
"""Map exception class to a stable error code string."""
|
||||
mapping: dict[type, str] = {
|
||||
exceptions.AuthenticationFailed: "authentication_failed",
|
||||
exceptions.NotAuthenticated: "not_authenticated",
|
||||
exceptions.PermissionDenied: "permission_denied",
|
||||
exceptions.NotFound: "not_found",
|
||||
exceptions.MethodNotAllowed: "method_not_allowed",
|
||||
exceptions.NotAcceptable: "not_acceptable",
|
||||
exceptions.UnsupportedMediaType: "unsupported_media_type",
|
||||
exceptions.Throttled: "throttled",
|
||||
exceptions.ParseError: "parse_error",
|
||||
exceptions.ValidationError: "validation_error",
|
||||
}
|
||||
return mapping.get(type(exc), "error")
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Add security-related HTTP headers to every response."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
|
||||
if not settings.DEBUG:
|
||||
response["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
|
||||
response.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.setdefault("X-Frame-Options", "DENY")
|
||||
response.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.setdefault(
|
||||
"Permissions-Policy",
|
||||
"geolocation=(), microphone=(), camera=()",
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BlacklistedToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('jti', models.UUIDField(help_text='JWT ID from the token payload', unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField(help_text='When the token would have naturally expired')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blacklisted_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Blacklisted Token',
|
||||
'verbose_name_plural': 'Blacklisted Tokens',
|
||||
'db_table': 'accounts_blacklisted_token',
|
||||
'indexes': [models.Index(fields=['jti'], name='accounts_bl_jti_88a727_idx'), models.Index(fields=['expires_at'], name='accounts_bl_expires_1a061a_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
+31
-1
@@ -26,4 +26,34 @@ class User(AbstractUser):
|
||||
verbose_name_plural = "Users"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.email
|
||||
return self.email
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Check if user belongs to the admin group."""
|
||||
return self.groups.filter(name="admin").exists() or self.is_superuser
|
||||
|
||||
|
||||
class BlacklistedToken(models.Model):
|
||||
"""Stores blacklisted JWT tokens for revocation."""
|
||||
|
||||
jti = models.UUIDField(unique=True, help_text="JWT ID from the token payload")
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="blacklisted_tokens",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(help_text="When the token would have naturally expired")
|
||||
|
||||
class Meta:
|
||||
db_table = "accounts_blacklisted_token"
|
||||
verbose_name = "Blacklisted Token"
|
||||
verbose_name_plural = "Blacklisted Tokens"
|
||||
indexes = [
|
||||
models.Index(fields=["jti"]),
|
||||
models.Index(fields=["expires_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Blacklisted {self.jti} (user {self.user_id})"
|
||||
@@ -0,0 +1,33 @@
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
|
||||
class IsAdminOrReadOnly(BasePermission):
|
||||
"""Admin users can perform any action; others can only read."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return request.user.is_admin
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return request.user.is_admin
|
||||
|
||||
|
||||
class IsOwnerOrAdmin(BasePermission):
|
||||
"""Users can only access their own resources; admins can access all."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return bool(request.user and request.user.is_authenticated)
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.user.is_admin:
|
||||
return True
|
||||
# Expect obj to have a `user` FK pointing to the owning user
|
||||
user_field = getattr(obj, "user", None)
|
||||
if user_field is not None:
|
||||
return user_field == request.user
|
||||
return False
|
||||
@@ -1,8 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
import re
|
||||
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from rest_framework import serializers
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
@@ -12,14 +15,27 @@ EMAIL_REGEX: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[
|
||||
MIN_PASSWORD_LENGTH: int = 8
|
||||
|
||||
|
||||
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.")
|
||||
value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", value)
|
||||
return value
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.Serializer):
|
||||
"""Validate and create a new user account."""
|
||||
|
||||
email = serializers.EmailField(max_length=254)
|
||||
password = serializers.CharField(write_only=True)
|
||||
password_confirm = serializers.CharField(write_only=True)
|
||||
first_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
|
||||
last_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
|
||||
first_name = SanitizedCharField(max_length=150, required=False, allow_blank=True, default="")
|
||||
last_name = SanitizedCharField(max_length=150, required=False, allow_blank=True, default="")
|
||||
|
||||
def validate_email(self, value: str) -> str:
|
||||
"""Validate email format and check for duplicates."""
|
||||
@@ -30,11 +46,15 @@ class RegisterSerializer(serializers.Serializer):
|
||||
return value.lower()
|
||||
|
||||
def validate_password(self, value: str) -> str:
|
||||
"""Enforce minimum password length."""
|
||||
"""Enforce minimum password length and Django validators."""
|
||||
if len(value) < MIN_PASSWORD_LENGTH:
|
||||
raise serializers.ValidationError(
|
||||
f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
||||
)
|
||||
try:
|
||||
validate_password(value)
|
||||
except DjangoValidationError as e:
|
||||
raise serializers.ValidationError(" ".join(e.messages))
|
||||
return value
|
||||
|
||||
def validate(self, attrs: dict[str, object]) -> dict[str, object]:
|
||||
@@ -51,7 +71,6 @@ class RegisterSerializer(serializers.Serializer):
|
||||
"""Create and return the new user."""
|
||||
validated_data.pop("password_confirm")
|
||||
validated_data["password"] = make_password(validated_data["password"])
|
||||
# Ensure username is blank rather than None for unique constraint
|
||||
validated_data.setdefault("username", "")
|
||||
return User.objects.create(**validated_data)
|
||||
|
||||
@@ -89,8 +108,30 @@ class LoginSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Public user profile serializer."""
|
||||
"""Public user profile serializer — limited fields, no email."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("id", "email", "first_name", "last_name")
|
||||
fields = ("id", "first_name", "last_name")
|
||||
|
||||
|
||||
class RefreshRequestSerializer(serializers.Serializer):
|
||||
"""Validate a refresh token request."""
|
||||
|
||||
refresh = serializers.CharField(required=True)
|
||||
|
||||
def validate_refresh(self, value: str) -> str:
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("Refresh token is required.")
|
||||
return value.strip()
|
||||
|
||||
|
||||
class LogoutSerializer(serializers.Serializer):
|
||||
"""Validate a logout request."""
|
||||
|
||||
refresh = serializers.CharField(required=True)
|
||||
|
||||
def validate_refresh(self, value: str) -> str:
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("Refresh token is required.")
|
||||
return value.strip()
|
||||
@@ -0,0 +1,12 @@
|
||||
from rest_framework.throttling import SimpleRateThrottle
|
||||
|
||||
|
||||
class UserRateThrottle(SimpleRateThrottle):
|
||||
"""Throttle authenticated requests by user ID."""
|
||||
|
||||
scope = "user"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
if request.user and request.user.is_authenticated:
|
||||
return self.cache_format % {"scope": self.scope, "ident": request.user.pk}
|
||||
return None # AnonRateThrottle handles anonymous requests
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import TokenVerifyView
|
||||
|
||||
from accounts import views
|
||||
|
||||
@@ -7,4 +8,8 @@ app_name = "accounts"
|
||||
urlpatterns = [
|
||||
path("register/", views.register_view, name="register"),
|
||||
path("login/", views.login_view, name="login"),
|
||||
path("token/refresh/", views.token_refresh_view, name="token-refresh"),
|
||||
path("token/verify/", TokenVerifyView.as_view(), name="token-verify"),
|
||||
path("logout/", views.logout_view, name="logout"),
|
||||
path("me/", views.me_view, name="me"),
|
||||
]
|
||||
+73
-1
@@ -6,8 +6,16 @@ from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework_simplejwt.exceptions import TokenError
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from accounts.serializers import LoginSerializer, RegisterSerializer, UserSerializer
|
||||
from accounts.serializers import (
|
||||
LoginSerializer,
|
||||
LogoutSerializer,
|
||||
RefreshRequestSerializer,
|
||||
RegisterSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
|
||||
|
||||
class AuthRateThrottle(AnonRateThrottle):
|
||||
@@ -46,4 +54,68 @@ def login_view(request: Request) -> Response:
|
||||
"refresh": validated_data["refresh"],
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
@throttle_classes([AuthRateThrottle])
|
||||
def token_refresh_view(request: Request) -> Response:
|
||||
"""Refresh an access token using a refresh token.
|
||||
|
||||
Uses SimpleJWT's built-in rotation and blacklisting.
|
||||
"""
|
||||
serializer = RefreshRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
refresh_token_str: str = serializer.validated_data["refresh"]
|
||||
try:
|
||||
refresh = RefreshToken(refresh_token_str)
|
||||
access = str(refresh.access_token)
|
||||
new_refresh = str(refresh)
|
||||
except TokenError as e:
|
||||
return Response(
|
||||
{"error": str(e), "code": "token_invalid"},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"access": access, "refresh": new_refresh},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
def logout_view(request: Request) -> Response:
|
||||
"""Blacklist a refresh token (log out).
|
||||
|
||||
This allows explicit token revocation on logout.
|
||||
"""
|
||||
serializer = LogoutSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
refresh_token_str: str = serializer.validated_data["refresh"]
|
||||
try:
|
||||
refresh = RefreshToken(refresh_token_str)
|
||||
refresh.blacklist()
|
||||
except TokenError:
|
||||
# If token is already invalid/blacklisted, still consider logout successful
|
||||
pass
|
||||
except AttributeError:
|
||||
# If blacklist app not installed
|
||||
pass
|
||||
|
||||
return Response(
|
||||
{"message": "Successfully logged out."},
|
||||
status=status.HTTP_205_RESET_CONTENT,
|
||||
)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
def me_view(request: Request) -> Response:
|
||||
"""Return the current authenticated user's profile."""
|
||||
return Response(
|
||||
UserSerializer(request.user).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
+26
-5
@@ -22,6 +22,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.staticfiles",
|
||||
# Third-party
|
||||
"rest_framework",
|
||||
"rest_framework_simplejwt.token_blacklist",
|
||||
"corsheaders",
|
||||
# Local
|
||||
"accounts",
|
||||
@@ -31,6 +32,7 @@ INSTALLED_APPS = [
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"accounts.middleware.SecurityHeadersMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -65,7 +67,7 @@ _database_url = os.environ.get(
|
||||
"postgres://jobtracker:***@localhost:5432/jobtracker",
|
||||
)
|
||||
|
||||
# postgres://user:***@host:port/dbname → django settings dict
|
||||
# postgres://user:***@host:port/dbname -> django settings dict
|
||||
_db_parts = _database_url.replace("postgres://", "").split("@")
|
||||
_credentials, _host_db = _db_parts[0], _db_parts[1]
|
||||
_db_user, _db_pass = _credentials.split(":")
|
||||
@@ -91,26 +93,45 @@ REST_FRAMEWORK = {
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
),
|
||||
"DEFAULT_PERMISSION_CLASSES": (
|
||||
"rest_framework.permissions.AllowAny",
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
),
|
||||
"DEFAULT_RENDERER_CLASSES": (
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
),
|
||||
"EXCEPTION_HANDLER": "accounts.exceptions.api_exception_handler",
|
||||
"DEFAULT_THROTTLE_CLASSES": [
|
||||
"rest_framework.throttling.AnonRateThrottle",
|
||||
"accounts.throttles.UserRateThrottle",
|
||||
],
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"anon": os.environ.get("DJANGO_THROTTLE_ANON_RATE", "10/hour"),
|
||||
"auth": os.environ.get("DJANGO_THROTTLE_AUTH_RATE", "5/minute"),
|
||||
"user": os.environ.get("DJANGO_THROTTLE_USER_RATE", "60/minute"),
|
||||
},
|
||||
}
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(hours=24),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=30),
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
|
||||
"ROTATE_REFRESH_TOKENS": True,
|
||||
"BLACKLIST_AFTER_ROTATION": True,
|
||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||
}
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
"OPTIONS": {"min_length": 8},
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ALL_ORIGINS = os.environ.get("CORS_ALLOW_ALL_ORIGINS", "False").lower() in ("true", "1", "yes")
|
||||
CORS_ALLOWED_ORIGINS = os.environ.get(
|
||||
@@ -125,4 +146,4 @@ USE_TZ = True
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
Reference in New Issue
Block a user