Implement: Home Page with MUI Components (#13)
Reviewed and merged by Reid (Hermes Reviewer) Co-authored-by: crisleo-hermes <hermes@codescripters.org> Co-committed-by: crisleo-hermes <hermes@codescripters.org>
This commit was merged in pull request #13.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import JobApplication, JobUpdate
|
||||
|
||||
|
||||
@admin.register(JobApplication)
|
||||
class JobApplicationAdmin(admin.ModelAdmin):
|
||||
list_display = ["company_name", "position_title", "status", "created_at", "updated_at"]
|
||||
list_filter = ["status"]
|
||||
search_fields = ["company_name", "position_title"]
|
||||
|
||||
|
||||
@admin.register(JobUpdate)
|
||||
class JobUpdateAdmin(admin.ModelAdmin):
|
||||
list_display = ["job_application", "from_status", "to_status", "created_at"]
|
||||
list_filter = ["to_status"]
|
||||
search_fields = ["job_application__company_name"]
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class JobsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'jobs'
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-26 00:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='JobApplication',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('company_name', models.CharField(max_length=255)),
|
||||
('position_title', models.CharField(max_length=255)),
|
||||
('status', models.CharField(choices=[('APPLIED', 'Applied'), ('SCREENING', 'Screening'), ('INTERVIEW', 'Interview'), ('OFFER', 'Offer'), ('REJECTED', 'Rejected'), ('WITHDRAWN', 'Withdrawn')], default='APPLIED', max_length=20)),
|
||||
('notes', models.TextField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobUpdate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('from_status', models.CharField(blank=True, choices=[('APPLIED', 'Applied'), ('SCREENING', 'Screening'), ('INTERVIEW', 'Interview'), ('OFFER', 'Offer'), ('REJECTED', 'Rejected'), ('WITHDRAWN', 'Withdrawn')], max_length=20, null=True)),
|
||||
('to_status', models.CharField(choices=[('APPLIED', 'Applied'), ('SCREENING', 'Screening'), ('INTERVIEW', 'Interview'), ('OFFER', 'Offer'), ('REJECTED', 'Rejected'), ('WITHDRAWN', 'Withdrawn')], max_length=20)),
|
||||
('notes', models.TextField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('metrics', models.JSONField(blank=True, default=dict)),
|
||||
('job_application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='jobs.jobapplication')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Job Update',
|
||||
'verbose_name_plural': 'Job Updates',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class StatusChoices(models.TextChoices):
|
||||
APPLIED = "APPLIED", "Applied"
|
||||
SCREENING = "SCREENING", "Screening"
|
||||
INTERVIEW = "INTERVIEW", "Interview"
|
||||
OFFER = "OFFER", "Offer"
|
||||
REJECTED = "REJECTED", "Rejected"
|
||||
WITHDRAWN = "WITHDRAWN", "Withdrawn"
|
||||
|
||||
|
||||
class JobApplication(models.Model):
|
||||
company_name = models.CharField(max_length=255)
|
||||
position_title = models.CharField(max_length=255)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=StatusChoices.choices,
|
||||
default=StatusChoices.APPLIED,
|
||||
)
|
||||
notes = models.TextField(blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.company_name} - {self.position_title}"
|
||||
|
||||
|
||||
class JobUpdate(models.Model):
|
||||
job_application = models.ForeignKey(
|
||||
JobApplication,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="updates",
|
||||
)
|
||||
from_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=StatusChoices.choices,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
to_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=StatusChoices.choices,
|
||||
)
|
||||
notes = models.TextField(blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Store metrics as JSON at update time for historical accuracy
|
||||
metrics = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Job Update"
|
||||
verbose_name_plural = "Job Updates"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Update #{self.id}: {self.job_application.company_name} → {self.to_status}"
|
||||
@@ -0,0 +1,45 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import JobApplication, JobUpdate
|
||||
|
||||
|
||||
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)
|
||||
job_id = serializers.IntegerField(source="job_application.id", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = JobUpdate
|
||||
fields = [
|
||||
"id",
|
||||
"job_id",
|
||||
"company_name",
|
||||
"position_title",
|
||||
"from_status",
|
||||
"to_status",
|
||||
"notes",
|
||||
"created_at",
|
||||
"metrics",
|
||||
]
|
||||
|
||||
|
||||
class JobApplicationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = JobApplication
|
||||
fields = [
|
||||
"id",
|
||||
"company_name",
|
||||
"position_title",
|
||||
"status",
|
||||
"notes",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class DashboardMetricsSerializer(serializers.Serializer):
|
||||
total_applications = serializers.IntegerField()
|
||||
status_breakdown = serializers.DictField(child=serializers.IntegerField())
|
||||
interviews_count = serializers.IntegerField()
|
||||
offers_count = serializers.IntegerField()
|
||||
rejection_rate = serializers.FloatField()
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from . import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"applications", views.JobApplicationViewSet, basename="job-application")
|
||||
router.register(r"updates", views.JobUpdateViewSet, basename="job-update")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
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.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import JobApplication, JobUpdate
|
||||
from .serializers import (
|
||||
DashboardMetricsSerializer,
|
||||
JobApplicationSerializer,
|
||||
JobUpdateSerializer,
|
||||
)
|
||||
|
||||
|
||||
class JobApplicationViewSet(viewsets.ModelViewSet):
|
||||
queryset = JobApplication.objects.all().prefetch_related("updates")
|
||||
serializer_class = JobApplicationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
class JobUpdateViewSet(viewsets.ModelViewSet):
|
||||
queryset = JobUpdate.objects.select_related("job_application").all()
|
||||
serializer_class = JobUpdateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def latest(self, request: Request) -> Response:
|
||||
"""Return the 3 most recent job updates with job info and metrics."""
|
||||
updates = (
|
||||
self.get_queryset()
|
||||
.select_related("job_application")
|
||||
.order_by("-created_at")[:3]
|
||||
)
|
||||
serializer = self.get_serializer(updates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@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()
|
||||
status_counts = dict(
|
||||
JobApplication.objects.values("status")
|
||||
.annotate(count=Count("id"))
|
||||
.values_list("status", "count")
|
||||
)
|
||||
interview_count = JobApplication.objects.filter(
|
||||
Q(status="INTERVIEW") | Q(status="OFFER")
|
||||
).count()
|
||||
offer_count = JobApplication.objects.filter(status="OFFER").count()
|
||||
rejection_rate = (
|
||||
round(
|
||||
status_counts.get("REJECTED", 0) / total * 100, 1
|
||||
)
|
||||
if total > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
serializer = DashboardMetricsSerializer(
|
||||
instance={
|
||||
"total_applications": total,
|
||||
"status_breakdown": status_counts,
|
||||
"interviews_count": interview_count,
|
||||
"offers_count": offer_count,
|
||||
"rejection_rate": rejection_rate,
|
||||
}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
+36
-3
@@ -15,14 +15,25 @@ ALLOWED_HOSTS: list[str] = ["*"]
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# Third-party
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
# Local
|
||||
"jobs",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
@@ -38,6 +49,7 @@ TEMPLATES = [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -48,10 +60,10 @@ WSGI_APPLICATION = "project.wsgi.application"
|
||||
# Parse DATABASE_URL
|
||||
_database_url = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgres://jobtracker:jobtracker@localhost:5432/jobtracker",
|
||||
"postgres://jobtracker:***@localhost:5432/jobtracker",
|
||||
)
|
||||
|
||||
# postgres://user:password@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(":")
|
||||
@@ -75,4 +87,25 @@ TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ALL_ORIGINS = os.environ.get("CORS_ALLOW_ALL_ORIGINS", "False").lower() in ("true", "1", "yes")
|
||||
CORS_ALLOWED_ORIGINS = os.environ.get(
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
"http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000",
|
||||
).split(",")
|
||||
|
||||
# REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
],
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
],
|
||||
}
|
||||
|
||||
# Static files for admin
|
||||
STATIC_URL = "/static/"
|
||||
+5
-2
@@ -1,4 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns: list = []
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/", include("jobs.urls")),
|
||||
]
|
||||
+3
-1
@@ -5,9 +5,11 @@ description = "Job Tracker API"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"django>=5.1,<6.0",
|
||||
"django-cors-headers>=4.9.0",
|
||||
"djangorestframework>=3.17.1",
|
||||
"psycopg2-binary>=2.9",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
Generated
+29
@@ -8,12 +8,16 @@ version = "1.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-cors-headers" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "psycopg2-binary" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "django", specifier = ">=5.1,<6.0" },
|
||||
{ name = "django-cors-headers", specifier = ">=4.9.0" },
|
||||
{ name = "djangorestframework", specifier = ">=3.17.1" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9" },
|
||||
]
|
||||
|
||||
@@ -40,6 +44,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-cors-headers"
|
||||
version = "4.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework"
|
||||
version = "3.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg2-binary"
|
||||
version = "2.9.12"
|
||||
|
||||
Reference in New Issue
Block a user