Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f359aa1
add selection to application review UI
jeff-zhang12 Nov 6, 2025
f5c2198
service layer changes
jeff-zhang12 Nov 6, 2025
a2d2af8
schema changes
jeff-zhang12 Nov 8, 2025
0749999
front end fetch
jeff-zhang12 Nov 8, 2025
a8286e3
remove print
jeff-zhang12 Nov 8, 2025
560d488
read only
jeff-zhang12 Nov 9, 2025
0ed6a22
coverage calculation initial
jeff-zhang12 Nov 10, 2025
2314e5d
Add instructor preferred hiring level to hiring admin
jeff-zhang12 Nov 11, 2025
95959a1
fix undefined unsubscribe error on read only review dialog
jeff-zhang12 Nov 11, 2025
dab462c
coverage UI
jeff-zhang12 Nov 11, 2025
2199eae
tooltip wording change
jeff-zhang12 Nov 11, 2025
e441160
icon change
jeff-zhang12 Nov 11, 2025
eb4575a
hiring admin coverage alignment fix
jeff-zhang12 Nov 11, 2025
fbccb61
clean up
jeff-zhang12 Nov 12, 2025
e3e3258
added flagged button and field to models
armaanpunj Nov 13, 2025
6e34e8c
added flagged fields
armaanpunj Nov 19, 2025
45a4eaf
updated fake hiring data
armaanpunj Nov 19, 2025
02dae1c
added proper styling for flagged button
armaanpunj Nov 19, 2025
e5c4c68
added extra flagged assignment
armaanpunj Nov 19, 2025
af0015f
fixed styling and ordering bug
armaanpunj Nov 19, 2025
ad28cc3
added flagging filter
armaanpunj Nov 20, 2025
63a15ea
added test for flag switch
armaanpunj Nov 24, 2025
603c45a
added migration file
armaanpunj Nov 24, 2025
9c4c7ba
Added Audit Log to Updated Changes
chjlee1 Nov 24, 2025
cf082f3
added paginator separator check and backend flagged filtering
armaanpunj Nov 24, 2025
1507a16
added tests for getting hiring summary
armaanpunj Nov 24, 2025
81581f6
Merge branch 'applicant-flagging' into applicant-audits
chjlee1 Nov 25, 2025
c44f28b
fixed service call
armaanpunj Nov 26, 2025
92e4aaf
chore: add optimistic update rollback on error, modify styling
ajaygandecha Dec 1, 2025
3bb6e82
tests
jeff-zhang12 Dec 1, 2025
26ac54f
Merge branch 'applicant-flagging' into applicant-audits
chjlee1 Dec 1, 2025
7c31d0d
added enum for flag filter on BE
armaanpunj Dec 1, 2025
88920d9
Added frontend functionality for Audit Log
chjlee1 Dec 3, 2025
7b46690
auto assign level
jeff-zhang12 Dec 3, 2025
bb5040e
Add Frontend Functionality to HR Onboarding page
chjlee1 Dec 6, 2025
739389a
Add notes update field
chjlee1 Dec 6, 2025
c98302d
Add Tests
chjlee1 Dec 7, 2025
e3ba659
Merge pull request #1 from jeff-zhang12/applicant-audits
jeff-zhang12 Dec 7, 2025
345bbd9
Merge pull request #2 from jeff-zhang12/applicant-flagging
jeff-zhang12 Dec 7, 2025
f14fa12
Merge branch 'main' into instructor-coverage
jeff-zhang12 Dec 7, 2025
003a26c
Revert "Merge branch 'main' into instructor-coverage"
jeff-zhang12 Dec 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/api/academics/hiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,18 @@ def update_status(
return hiring_service.update_status(subject, course_site_id, hiring_status)


@api.get("/{course_site_id}/enrollment", tags=["Hiring"])
def get_course_site_total_enrollment(
course_site_id: int,
subject: User = Depends(registered_user),
hiring_service: HiringService = Depends(),
) -> int:
"""
Returns the total enrollment across all sections in a course site.
"""
return hiring_service.get_course_site_total_enrollment(subject, course_site_id)


@api.get("/summary/{term_id}", tags=["Hiring"])
def get_hiring_summary_overview(
term_id: str,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ class ApplicationReviewEntity(EntityBase):
preference: Mapped[int] = mapped_column(Integer)
# Notes
notes: Mapped[str] = mapped_column(String)
# Level
level_id: Mapped[int | None] = mapped_column(
ForeignKey("academics__hiring__level.id"), nullable=True
)
level: Mapped["HiringLevelEntity"] = relationship(back_populates="hiring_reviews")

@classmethod
def from_model(cls, model: ApplicationReview) -> Self:
Expand All @@ -76,6 +81,7 @@ def from_model(cls, model: ApplicationReview) -> Self:
status=model.status,
preference=model.preference,
notes=model.notes,
level_id=(model.level.id if model.level else None),
)

def to_overview_model(self) -> ApplicationReviewOverview:
Expand Down Expand Up @@ -133,6 +139,7 @@ def to_overview_model(self) -> ApplicationReviewOverview:
applicant_id=self.application.user_id,
applicant_course_ranking=applicant_preference_for_course
+ 1, # Increment since starting index is 0.
level=(self.level.to_model() if self.level else None),
)

def to_csv_row(self) -> ApplicationReviewCsvRow:
Expand Down Expand Up @@ -177,4 +184,5 @@ def to_csv_row(self) -> ApplicationReviewCsvRow:
status=self.status,
preference=self.preference,
notes=self.notes,
level=(self.level.title if self.level else None),
)
6 changes: 6 additions & 0 deletions backend/entities/academics/hiring/hiring_level_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ class HiringLevelEntity(EntityBase):
back_populates="hiring_level"
)

# Application reviews with this level
# NOTE: This defines a one-to-many relationship between the application reviews and level tables.
hiring_reviews: Mapped[list["ApplicationReviewEntity"]] = relationship(
back_populates="level"
)

@classmethod
def from_model(cls, model: HiringLevel) -> Self:
return cls(
Expand Down
5 changes: 5 additions & 0 deletions backend/models/academics/hiring/application_review.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pydantic import BaseModel
from enum import Enum

from .hiring_level import HiringLevel

from ...application import Comp227, ApplicationUnderReview
from ...public_user import PublicUser

Expand Down Expand Up @@ -28,6 +30,7 @@ class ApplicationReview(BaseModel):
status: ApplicationReviewStatus = ApplicationReviewStatus.NOT_PROCESSED
preference: int
notes: str
level: HiringLevel | None = None


class ApplicationReviewOverview(ApplicationReview):
Expand All @@ -39,6 +42,7 @@ class ApplicationReviewOverview(ApplicationReview):
preference: int
notes: str
applicant_course_ranking: int
level: HiringLevel | None = None


class HiringStatus(BaseModel):
Expand Down Expand Up @@ -76,3 +80,4 @@ class ApplicationReviewCsvRow(BaseModel):
status: ApplicationReviewStatus = ApplicationReviewStatus.NOT_PROCESSED
preference: int
notes: str
level: str | None
109 changes: 104 additions & 5 deletions backend/services/academics/hiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,32 @@ def update_status(
subject, "hiring.get_status", f"course_site/{course_site_id}"
)

# Step 2: Update the values for all reviews.
# Step 2: Assign default levels to preferred applications that don't have one.
preferred_reviews_without_level = [
review for review in hiring_status.preferred if review.level is None
]
if preferred_reviews_without_level:
applications = {
app.id: app
for app in self._session.scalars(
select(ApplicationEntity).where(
ApplicationEntity.id.in_(
[
review.application_id
for review in preferred_reviews_without_level
]
)
)
).all()
}
for review in preferred_reviews_without_level:
application = applications.get(review.application_id)
if application:
default_level = self._get_default_level_for_application(application)
if default_level:
review.level = default_level

# Step 3: Update the values for all reviews.

# Retrieve all reviews, indexed by ID for efficient searching.
hiring_status_reviews_by_id: dict[int, ApplicationReviewOverview] = {}
Expand All @@ -126,17 +151,25 @@ def update_status(
updates: list[dict] = []
for persisted in site_entity.application_reviews:
request = hiring_status_reviews_by_id[persisted.id]
requested_level_id = request.level.id if request.level else None
if (
persisted.status != request.status
or persisted.preference != request.preference
or persisted.notes != request.notes
persisted.status,
persisted.preference,
persisted.notes,
persisted.level_id,
) != (
request.status,
request.preference,
request.notes,
requested_level_id,
):
updates.append(
{
"id": persisted.id,
"status": request.status,
"preference": request.preference,
"notes": request.notes,
"level_id": requested_level_id,
}
)

Expand All @@ -148,6 +181,49 @@ def update_status(
# Reload the data and return the hiring status.
return self.get_status(subject, course_site_id)

def _get_default_level_for_application(
self, application: ApplicationEntity
) -> HiringLevel | None:
"""
Gets the default level for an application based on its type.
Finds the level that matches the classification, has load of 1.0, and is active.

Args:
application: The application entity to get default level for

Returns:
The default level model, or None if not found
"""
# Determine classification based on application type
classification: HiringLevelClassification | None = None

if application.type == "new_uta":
classification = HiringLevelClassification.UG
elif application.type == "gta":
if application.program_pursued:
# Check if it's a PhD program
if application.program_pursued in ("PhD", "PhD (ABD)"):
classification = HiringLevelClassification.PHD
# Check if it's MS or BS/MS
elif application.program_pursued in ("MS", "BS/MS"):
classification = HiringLevelClassification.MS

if not classification:
return None

# Find the level that matches classification, has load 1.0, and is active
level_query = (
select(HiringLevelEntity)
.where(HiringLevelEntity.classification == classification)
.where(HiringLevelEntity.load == 1.0)
.where(HiringLevelEntity.is_active == True)
)
level_entity = self._session.scalar(level_query)

if level_entity:
return level_entity.to_model()
return None

def create_missing_course_sites_for_term(self, subject: User, term_id: str) -> bool:
"""
Creates missing course sites for a given term.
Expand Down Expand Up @@ -485,6 +561,7 @@ def _to_review_models(
status=review.status,
preference=review.preference,
notes=review.notes,
level=(review.level.to_model() if review.level else None),
applicant_course_ranking=applicant_preferences.get(
review.application_id, 999
),
Expand Down Expand Up @@ -665,7 +742,8 @@ def get_hiring_admin_course_overview(
.options(
joinedload(ApplicationReviewEntity.application).joinedload(
ApplicationEntity.user
)
),
joinedload(ApplicationReviewEntity.level),
)
)
preferred_review_entities = self._session.scalars(preferred_review_query).all()
Expand All @@ -681,6 +759,7 @@ def to_overview(review: ApplicationReviewEntity) -> ApplicationReviewOverview:
application=review.application.to_review_overview_model(),
applicant_id=review.application.user_id,
applicant_course_ranking=0,
level=(review.level.to_model() if review.level else None),
)

reviews = [
Expand Down Expand Up @@ -979,6 +1058,26 @@ def get_hiring_assignments_for_course_site(
params=pagination_params,
)

def get_course_site_total_enrollment(
self, subject: User, course_site_id: int
) -> int:
"""
Returns the sum of enrolled students across all sections in a course site.
"""
site_entity = self._load_course_site(course_site_id)
if not self._is_instructor(subject, site_entity):
self._permission.enforce(
subject, "hiring.get_status", f"course_site/{course_site_id}"
)

sections_query = select(SectionEntity).where(
SectionEntity.course_site_id == course_site_id
)
total_enrollment = 0
for section in self._session.scalars(sections_query).all():
total_enrollment += section.enrolled
return total_enrollment

def get_assignment_summary_for_instructors_csv(
self, subject: User, course_site_id: int
) -> list[HiringAssignmentSummaryCsvRow]:
Expand Down
34 changes: 34 additions & 0 deletions backend/test/services/academics/hiring/hiring_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
from .....services.academics import HiringService
from .....services.application import ApplicationService
from .....services.academics.course_site import CourseSiteService
from .....entities.academics.section_entity import SectionEntity
from .....entities.academics.hiring.application_review_entity import (
ApplicationReviewEntity,
)
from sqlalchemy import select
from sqlalchemy.orm import Session

# Injected Service Fixtures
from .fixtures import hiring_svc
Expand Down Expand Up @@ -98,6 +104,7 @@ def test_update_status(hiring_svc: HiringService):
status.not_preferred[0].status = ApplicationReviewStatus.PREFERRED
status.not_preferred[0].preference = 1
status.preferred[0].notes = "Updated notes!"
status.preferred[0].level = hiring_data.uta_level
status.not_processed[0].preference = 1
status.not_processed[1].preference = 0

Expand All @@ -109,6 +116,7 @@ def test_update_status(hiring_svc: HiringService):
assert len(new_status.preferred) == 2
assert new_status.preferred[0].application_id == hiring_data.application_two.id
assert new_status.preferred[0].notes == "Updated notes!"
assert new_status.preferred[0].level.id == hiring_data.uta_level.id
assert new_status.preferred[1].application_id == hiring_data.application_one.id
assert new_status.not_processed[0].application_id == hiring_data.application_four.id
assert (
Expand Down Expand Up @@ -303,3 +311,29 @@ def test_get_phd_applicants(hiring_svc: HiringService):
assert len(applicants) > 0
for applicant in applicants:
assert applicant.program_pursued in {"PhD", "PhD (ABD)"}


def test_get_course_site_total_enrollment(hiring_svc: HiringService, session: Session):
"""Verify total enrollment sums section enrollments for a course site."""
from ...office_hours import office_hours_data

course_site_id = office_hours_data.comp_110_site.id
total = hiring_svc.get_course_site_total_enrollment(
user_data.instructor, course_site_id
)
sections = session.scalars(
select(SectionEntity).where(SectionEntity.course_site_id == course_site_id)
).all()
expected = sum(s.enrolled for s in sections)
assert total == expected


def test_get_course_site_total_enrollment_checks_permission(hiring_svc: HiringService):
"""Ambassador should not be able to read instructor-only enrollment."""
from ...office_hours import office_hours_data

with pytest.raises(UserPermissionException):
hiring_svc.get_course_site_total_enrollment(
user_data.ambassador, office_hours_data.comp_110_site.id
)
pytest.fail()
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,14 @@ <h2 mat-dialog-title>
<mat-divider />

@if(data.viewOnly) {
<div class="row">
<p mat-card-subtitle>Instructor's Notes</p>
<div class="row">
<p mat-card-subtitle>Instructor's Notes</p>
</div>
<p markdown>{{ data.review.notes }}</p>
<div class="row">
<p mat-card-subtitle>Preferred Hiring Level</p>
</div>
<p>{{ data.review.level ? data.review.level.title : 'None' }}</p>
} @else {
<div class="row">
<p mat-card-subtitle>Your Notes</p>
Expand All @@ -115,5 +119,18 @@ <h2 mat-dialog-title>
[formControl]="notes"
name="notes"></textarea>
</mat-form-field>

<div class="row">
<p mat-card-subtitle>Preferred Hiring Level</p>
</div>

<mat-form-field appearance="outline">
<mat-label>Select Preferred Level</mat-label>
<mat-select [formControl]="preferredLevel" [compareWith]="compareLevels">
@for(level of hiringService.activeHiringlevels(); track level.id) {
<mat-option [value]="level">{{ level.title }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</mat-dialog-content>
Loading