Skip to content

Commit 4bac4e5

Browse files
committed
commit 1234567890abcdef1234567890abcdef12345678
1 parent 40b5b79 commit 4bac4e5

40 files changed

Lines changed: 7975 additions & 17 deletions

README/PART_4_COMPLETION_SUMMARY.md

Lines changed: 446 additions & 0 deletions
Large diffs are not rendered by default.

backend/activity/signals.py

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.dispatch import receiver
33
from django.contrib.contenttypes.models import ContentType
44
from django.conf import settings
5+
from django.db import transaction
56
from .models import ActivityEvent
67

78

@@ -32,14 +33,25 @@ def on_post_save(sender, instance, created, **kwargs):
3233
return
3334
try:
3435
ct = ContentType.objects.get_for_model(instance.__class__)
35-
ActivityEvent.objects.create(
36-
actor=None, # actor unknown at signal time
37-
action='create' if created else 'update',
38-
object_type=ct,
39-
object_id=str(getattr(instance, 'pk')),
40-
path=f"signal:{ct.app_label}.{ct.model}",
41-
method='SIGNAL',
42-
)
36+
action = 'create' if created else 'update'
37+
ct_pk = ct.pk
38+
obj_pk = str(getattr(instance, 'pk'))
39+
label = f"signal:{ct.app_label}.{ct.model}"
40+
41+
def _create():
42+
try:
43+
ActivityEvent.objects.create(
44+
actor=None,
45+
action=action,
46+
object_type_id=ct_pk,
47+
object_id=obj_pk,
48+
path=label,
49+
method='SIGNAL',
50+
)
51+
except Exception:
52+
pass
53+
54+
transaction.on_commit(_create)
4355
except Exception:
4456
pass
4557

@@ -52,13 +64,23 @@ def on_post_delete(sender, instance, **kwargs):
5264
return
5365
try:
5466
ct = ContentType.objects.get_for_model(instance.__class__)
55-
ActivityEvent.objects.create(
56-
actor=None,
57-
action='delete',
58-
object_type=ct,
59-
object_id=str(getattr(instance, 'pk')),
60-
path=f"signal:{ct.app_label}.{ct.model}",
61-
method='SIGNAL',
62-
)
67+
ct_pk = ct.pk
68+
obj_pk = str(getattr(instance, 'pk'))
69+
label = f"signal:{ct.app_label}.{ct.model}"
70+
71+
def _create():
72+
try:
73+
ActivityEvent.objects.create(
74+
actor=None,
75+
action='delete',
76+
object_type_id=ct_pk,
77+
object_id=obj_pk,
78+
path=label,
79+
method='SIGNAL',
80+
)
81+
except Exception:
82+
pass
83+
84+
transaction.on_commit(_create)
6385
except Exception:
6486
pass

backend/config/asgi.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
django_asgi_app = get_asgi_application()
1919

2020
import iot_lab.routing # noqa: E402
21+
import employment.routing # noqa: E402
2122
from accounts.ws_auth import JwtCookieAuthMiddleware # noqa: E402
2223

2324
application = ProtocolTypeRouter(
@@ -26,7 +27,8 @@
2627
'websocket': AllowedHostsOriginValidator(
2728
JwtCookieAuthMiddleware(
2829
URLRouter(
29-
iot_lab.routing.websocket_urlpatterns,
30+
iot_lab.routing.websocket_urlpatterns +
31+
employment.routing.websocket_urlpatterns,
3032
)
3133
)
3234
),

backend/config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
'emails',
7575
'support',
7676
'billing',
77+
'employment',
7778
]
7879

7980
MIDDLEWARE = [

backend/config/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
path('api/admin/', include('emails.urls')),
4646
path('api/support/', include('support.urls')),
4747
path('api/billing/', include('billing.urls')),
48+
path('api/employment/', include('employment.urls')),
4849
]
4950

5051
# Serve media files in development

backend/employment/__init__.py

Whitespace-only changes.

backend/employment/admin.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from django.contrib import admin
2+
from .models import (
3+
Application, ApplicationDocument, Employee, EmploymentAuditLog,
4+
EmploymentNotification, Evaluation, Interview, JobPosting,
5+
)
6+
7+
8+
class ApplicationDocumentInline(admin.TabularInline):
9+
model = ApplicationDocument
10+
extra = 0
11+
readonly_fields = ('uploaded_at', 'file_size')
12+
13+
14+
class InterviewInline(admin.TabularInline):
15+
model = Interview
16+
extra = 0
17+
readonly_fields = ('created_at',)
18+
19+
20+
class EvaluationInline(admin.TabularInline):
21+
model = Evaluation
22+
extra = 0
23+
readonly_fields = ('created_at', 'overall_score')
24+
25+
26+
@admin.register(JobPosting)
27+
class JobPostingAdmin(admin.ModelAdmin):
28+
list_display = ('title', 'department', 'job_type', 'experience_level', 'status', 'created_at')
29+
list_filter = ('status', 'department', 'job_type', 'experience_level', 'is_remote')
30+
search_fields = ('title', 'description', 'requirements')
31+
readonly_fields = ('id', 'created_at', 'updated_at')
32+
33+
34+
@admin.register(Application)
35+
class ApplicationAdmin(admin.ModelAdmin):
36+
list_display = ('full_name', 'email', 'job', 'status', 'internal_rating', 'submitted_at')
37+
list_filter = ('status', 'job__department')
38+
search_fields = ('first_name', 'last_name', 'email')
39+
readonly_fields = ('id', 'submitted_at', 'updated_at')
40+
inlines = [ApplicationDocumentInline, InterviewInline, EvaluationInline]
41+
42+
43+
@admin.register(Interview)
44+
class InterviewAdmin(admin.ModelAdmin):
45+
list_display = ('application', 'round', 'format', 'status', 'interviewer', 'scheduled_at')
46+
list_filter = ('status', 'format', 'round')
47+
readonly_fields = ('id', 'created_at', 'updated_at')
48+
49+
50+
@admin.register(Evaluation)
51+
class EvaluationAdmin(admin.ModelAdmin):
52+
list_display = ('application', 'evaluator', 'recommendation', 'admin_decision', 'overall_score', 'created_at')
53+
list_filter = ('recommendation', 'admin_decision')
54+
readonly_fields = ('id', 'overall_score', 'created_at', 'updated_at')
55+
56+
57+
@admin.register(Employee)
58+
class EmployeeAdmin(admin.ModelAdmin):
59+
list_display = ('employee_id', 'full_name', 'department', 'role', 'status', 'start_date')
60+
list_filter = ('status', 'department', 'role', 'is_remote')
61+
search_fields = ('first_name', 'last_name', 'email', 'employee_id')
62+
readonly_fields = ('id', 'employee_id', 'created_at', 'updated_at')
63+
64+
65+
@admin.register(EmploymentNotification)
66+
class EmploymentNotificationAdmin(admin.ModelAdmin):
67+
list_display = ('notification_type', 'recipient_email', 'status', 'sent_at', 'created_at')
68+
list_filter = ('status', 'notification_type')
69+
readonly_fields = ('id', 'created_at')
70+
71+
72+
@admin.register(EmploymentAuditLog)
73+
class EmploymentAuditLogAdmin(admin.ModelAdmin):
74+
list_display = ('actor', 'action', 'resource_type', 'resource_id', 'created_at')
75+
list_filter = ('action', 'resource_type')
76+
readonly_fields = ('id', 'created_at')

backend/employment/apps.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.apps import AppConfig
2+
3+
4+
class EmploymentConfig(AppConfig):
5+
default_auto_field = 'django.db.models.BigAutoField'
6+
name = 'employment'
7+
verbose_name = 'Employment Console'

backend/employment/consumers.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""
2+
Employment Console — Django Channels WebSocket Consumer
3+
Architecture §2.7 — Real-Time Event System
4+
5+
Handles real-time events for:
6+
- Interview rooms (video signaling, chat, presence)
7+
- Application status updates
8+
- Admin notifications (new submissions, decisions)
9+
10+
Room naming conventions:
11+
interview_<interview_uuid> — Interview room (all participants)
12+
application_<app_uuid> — Application feed (HR/admin)
13+
employment_admin — Admin broadcast channel
14+
"""
15+
16+
import json
17+
from channels.generic.websocket import AsyncWebsocketConsumer
18+
19+
20+
class InterviewConsumer(AsyncWebsocketConsumer):
21+
"""
22+
Interview room WebSocket consumer.
23+
Powers: real-time chat, WebRTC signaling (offer/answer/ICE),
24+
coding test sync, presence tracking.
25+
"""
26+
27+
async def connect(self):
28+
self.interview_id = self.scope['url_route']['kwargs']['interview_id']
29+
self.room_name = f'interview_{self.interview_id}'
30+
self.user = self.scope.get('user')
31+
32+
if not self.user or not self.user.is_authenticated:
33+
await self.close(code=4001)
34+
return
35+
36+
await self.channel_layer.group_add(self.room_name, self.channel_name)
37+
await self.accept()
38+
39+
# Broadcast presence
40+
await self.channel_layer.group_send(self.room_name, {
41+
'type': 'presence',
42+
'event': 'joined',
43+
'user_id': self.user.id,
44+
'username': self.user.get_full_name() or self.user.username,
45+
})
46+
47+
async def disconnect(self, close_code):
48+
if hasattr(self, 'room_name'):
49+
await self.channel_layer.group_send(self.room_name, {
50+
'type': 'presence',
51+
'event': 'left',
52+
'user_id': self.user.id if self.user else None,
53+
'username': getattr(self.user, 'username', 'unknown'),
54+
})
55+
await self.channel_layer.group_discard(self.room_name, self.channel_name)
56+
57+
async def receive(self, text_data):
58+
try:
59+
data = json.loads(text_data)
60+
except (json.JSONDecodeError, TypeError):
61+
return
62+
63+
msg_type = data.get('type')
64+
65+
# WebRTC signaling passthrough
66+
if msg_type in ('offer', 'answer', 'ice_candidate'):
67+
await self.channel_layer.group_send(self.room_name, {
68+
'type': 'signal',
69+
'signal_type': msg_type,
70+
'payload': data.get('payload'),
71+
'from_user': self.user.id,
72+
'target_user': data.get('target_user'),
73+
})
74+
75+
# Chat message
76+
elif msg_type == 'chat':
77+
await self.channel_layer.group_send(self.room_name, {
78+
'type': 'chat_message',
79+
'message': data.get('message', ''),
80+
'from_user': self.user.id,
81+
'username': self.user.get_full_name() or self.user.username,
82+
})
83+
84+
# Coding test code sync
85+
elif msg_type == 'code_sync':
86+
await self.channel_layer.group_send(self.room_name, {
87+
'type': 'code_update',
88+
'code': data.get('code', ''),
89+
'language': data.get('language', 'python'),
90+
'from_user': self.user.id,
91+
})
92+
93+
# Coding test result
94+
elif msg_type == 'code_run':
95+
await self.channel_layer.group_send(self.room_name, {
96+
'type': 'code_result',
97+
'output': data.get('output', ''),
98+
'status': data.get('status', 'unknown'),
99+
'from_user': self.user.id,
100+
})
101+
102+
# ── Group message handlers ────────────────────────────────
103+
104+
async def signal(self, event):
105+
await self.send(text_data=json.dumps({
106+
'type': 'signal',
107+
'signal_type': event['signal_type'],
108+
'payload': event['payload'],
109+
'from_user': event['from_user'],
110+
'target_user': event.get('target_user'),
111+
}))
112+
113+
async def chat_message(self, event):
114+
await self.send(text_data=json.dumps({
115+
'type': 'chat',
116+
'message': event['message'],
117+
'from_user': event['from_user'],
118+
'username': event['username'],
119+
}))
120+
121+
async def code_update(self, event):
122+
await self.send(text_data=json.dumps({
123+
'type': 'code_sync',
124+
'code': event['code'],
125+
'language': event['language'],
126+
'from_user': event['from_user'],
127+
}))
128+
129+
async def code_result(self, event):
130+
await self.send(text_data=json.dumps({
131+
'type': 'code_run',
132+
'output': event['output'],
133+
'status': event['status'],
134+
'from_user': event['from_user'],
135+
}))
136+
137+
async def presence(self, event):
138+
await self.send(text_data=json.dumps({
139+
'type': 'presence',
140+
'event': event['event'],
141+
'user_id': event.get('user_id'),
142+
'username': event.get('username'),
143+
}))
144+
145+
146+
class EmploymentAdminConsumer(AsyncWebsocketConsumer):
147+
"""
148+
Admin/HR real-time feed.
149+
Broadcasts: new applications, status changes, interview completions, hire decisions.
150+
"""
151+
152+
ADMIN_GROUP = 'employment_admin'
153+
154+
async def connect(self):
155+
self.user = self.scope.get('user')
156+
if not self.user or not self.user.is_authenticated or not self.user.is_staff:
157+
await self.close(code=4003)
158+
return
159+
160+
await self.channel_layer.group_add(self.ADMIN_GROUP, self.channel_name)
161+
await self.accept()
162+
163+
async def disconnect(self, close_code):
164+
await self.channel_layer.group_discard(self.ADMIN_GROUP, self.channel_name)
165+
166+
async def receive(self, text_data):
167+
pass # Admin feed is broadcast-only from server
168+
169+
async def employment_event(self, event):
170+
await self.send(text_data=json.dumps({
171+
'type': event.get('event_name'),
172+
'payload': event.get('payload', {}),
173+
}))

backend/employment/management/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)