From f359aa13e00ca38f96b68ccd4854a49e6b5ca5ea Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 6 Nov 2025 13:34:51 -0500 Subject: [PATCH 01/36] add selection to application review UI --- .../application-dialog/application-dialog.dialog.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html index b1c16a23e..25d5b52f0 100644 --- a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html +++ b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html @@ -115,5 +115,13 @@

[formControl]="notes" name="notes"> + + Select Preferred Level + + @for(level of hiringService.activeHiringlevels(); track level.id) { + {{ level.title }} + } + + } From f5c2198d93da80e3c94a6de2b3bd75b0c0f1cf4c Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 6 Nov 2025 16:04:21 -0500 Subject: [PATCH 02/36] service layer changes --- backend/services/academics/hiring.py | 3 +++ .../application-dialog.dialog.html | 7 ++++++- .../application-dialog.dialog.ts | 15 +++++++++++++++ frontend/src/app/hiring/hiring.models.ts | 1 + 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index 15726edda..76d22aa4d 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -130,13 +130,16 @@ def update_status( persisted.status != request.status or persisted.preference != request.preference or persisted.notes != request.notes + or persisted.level != request.level, ): + print("update", request.level, persisted.level) updates.append( { "id": persisted.id, "status": request.status, "preference": request.preference, "notes": request.notes, + "level": request.level, } ) diff --git a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html index 25d5b52f0..7d8f244cd 100644 --- a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html +++ b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html @@ -115,9 +115,14 @@

[formControl]="notes" name="notes"> + +
+

Preferred Hiring Level

+
+ Select Preferred Level - + @for(level of hiringService.activeHiringlevels(); track level.id) { {{ level.title }} } diff --git a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts index e96d52754..a61cc07ea 100644 --- a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts +++ b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts @@ -11,6 +11,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { ApplicationReviewOverview, ApplicationReviewStatus, + HiringLevel, HiringStatus } from '../../hiring.models'; import { FormControl } from '@angular/forms'; @@ -33,6 +34,8 @@ export interface ApplicationDialogData { export class ApplicationDialog implements OnInit, OnDestroy { notes = new FormControl(''); notesSubcription!: Subscription; + preferredLevel = new FormControl(undefined); + preferredLevelSubscription!: Subscription; constructor( protected hiringService: HiringService, @@ -50,12 +53,18 @@ export class ApplicationDialog implements OnInit, OnDestroy { .subscribe((_) => { this.saveData(); }); + this.preferredLevel.setValue(this.data.review.level); + this.preferredLevelSubscription = this.preferredLevel.valueChanges + .subscribe((_) => { + this.saveData(); + }); } } /** Unsubsribe from the notes subscription when the page is closed. */ ngOnDestroy(): void { this.notesSubcription.unsubscribe(); + this.preferredLevelSubscription.unsubscribe(); } youtubeVideoId(): string | undefined { @@ -68,14 +77,20 @@ export class ApplicationDialog implements OnInit, OnDestroy { if (this.data.review.status == ApplicationReviewStatus.NOT_PREFERRED) { this.data.status!.not_preferred[this.data.review.preference].notes = this.notes.value ?? ''; + this.data.status!.not_preferred[this.data.review.preference].level = + this.preferredLevel.value ?? null; } if (this.data.review.status == ApplicationReviewStatus.NOT_PROCESSED) { this.data.status!.not_processed[this.data.review.preference].notes = this.notes.value ?? ''; + this.data.status!.not_processed[this.data.review.preference].level = + this.preferredLevel.value ?? null; } if (this.data.review.status == ApplicationReviewStatus.PREFERRED) { this.data.status!.preferred[this.data.review.preference].notes = this.notes.value ?? ''; + this.data.status!.preferred[this.data.review.preference].level = + this.preferredLevel.value ?? null; } // Persist the data diff --git a/frontend/src/app/hiring/hiring.models.ts b/frontend/src/app/hiring/hiring.models.ts index 5ba3bd823..ac5ac4883 100644 --- a/frontend/src/app/hiring/hiring.models.ts +++ b/frontend/src/app/hiring/hiring.models.ts @@ -40,6 +40,7 @@ export interface ApplicationReviewOverview { preference: number; notes: string; applicant_course_ranking: number; + level: HiringLevel | null; } export interface HiringStatus { From a2d2af806f274160ba63e3777556d869c3844d50 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 7 Nov 2025 22:07:50 -0500 Subject: [PATCH 03/36] schema changes --- .../hiring/application_review_entity.py | 8 ++++++++ .../academics/hiring/hiring_level_entity.py | 6 ++++++ .../academics/hiring/application_review.py | 5 +++++ backend/services/academics/hiring.py | 19 +++++++++++++------ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/backend/entities/academics/hiring/application_review_entity.py b/backend/entities/academics/hiring/application_review_entity.py index 30dd3326c..35af525eb 100644 --- a/backend/entities/academics/hiring/application_review_entity.py +++ b/backend/entities/academics/hiring/application_review_entity.py @@ -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: @@ -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: @@ -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: @@ -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), ) diff --git a/backend/entities/academics/hiring/hiring_level_entity.py b/backend/entities/academics/hiring/hiring_level_entity.py index fc0d708b0..29e08c0ba 100644 --- a/backend/entities/academics/hiring/hiring_level_entity.py +++ b/backend/entities/academics/hiring/hiring_level_entity.py @@ -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( diff --git a/backend/models/academics/hiring/application_review.py b/backend/models/academics/hiring/application_review.py index 4fd704f8a..066b98f65 100644 --- a/backend/models/academics/hiring/application_review.py +++ b/backend/models/academics/hiring/application_review.py @@ -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 @@ -28,6 +30,7 @@ class ApplicationReview(BaseModel): status: ApplicationReviewStatus = ApplicationReviewStatus.NOT_PROCESSED preference: int notes: str + level: HiringLevel | None = None class ApplicationReviewOverview(ApplicationReview): @@ -39,6 +42,7 @@ class ApplicationReviewOverview(ApplicationReview): preference: int notes: str applicant_course_ranking: int + level: HiringLevel | None = None class HiringStatus(BaseModel): @@ -76,3 +80,4 @@ class ApplicationReviewCsvRow(BaseModel): status: ApplicationReviewStatus = ApplicationReviewStatus.NOT_PROCESSED preference: int notes: str + level: str | None diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index 76d22aa4d..14ea8e360 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -126,20 +126,26 @@ 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 - or persisted.level != request.level, + persisted.status, + persisted.preference, + persisted.notes, + persisted.level_id, + ) != ( + request.status, + request.preference, + request.notes, + requested_level_id, ): - print("update", request.level, persisted.level) + print("update", requested_level_id, persisted.level_id) updates.append( { "id": persisted.id, "status": request.status, "preference": request.preference, "notes": request.notes, - "level": request.level, + "level_id": requested_level_id, } ) @@ -488,6 +494,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 ), From 07499991484d3b2f832a0a3e99de9ab6e8acf23c Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 7 Nov 2025 22:20:45 -0500 Subject: [PATCH 04/36] front end fetch --- .../application-dialog/application-dialog.dialog.html | 2 +- .../dialogs/application-dialog/application-dialog.dialog.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html index 7d8f244cd..9d5eea4b5 100644 --- a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html +++ b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html @@ -122,7 +122,7 @@

Select Preferred Level - + @for(level of hiringService.activeHiringlevels(); track level.id) { {{ level.title }} } diff --git a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts index a61cc07ea..00fbf4263 100644 --- a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts +++ b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts @@ -43,6 +43,7 @@ export class ApplicationDialog implements OnInit, OnDestroy { @Inject(MAT_DIALOG_DATA) public data: ApplicationDialogData ) { this.notes.setValue(data.review.notes); + this.preferredLevel.setValue(data.review.level); } /** Save the notes data as the user types, with a debounce of 200ms. */ @@ -67,6 +68,10 @@ export class ApplicationDialog implements OnInit, OnDestroy { this.preferredLevelSubscription.unsubscribe(); } + /** Compare hiring levels by their ID. */ + compareLevels = (a?: HiringLevel | null, b?: HiringLevel | null) => + (a?.id ?? null) === (b?.id ?? null); + youtubeVideoId(): string | undefined { let splitUrl = this.data.review.application.intro_video_url?.split('?v='); return splitUrl?.length ?? 0 > 0 ? splitUrl![1] : undefined; From a8286e374ded4e034e93eb7d99f2d13151838a63 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 7 Nov 2025 22:20:56 -0500 Subject: [PATCH 05/36] remove print --- backend/services/academics/hiring.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index 14ea8e360..12c89551c 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -138,7 +138,6 @@ def update_status( request.notes, requested_level_id, ): - print("update", requested_level_id, persisted.level_id) updates.append( { "id": persisted.id, From 560d488af4d1bbb6e55d0c53c58b26384c77606e Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Sat, 8 Nov 2025 21:19:05 -0500 Subject: [PATCH 06/36] read only --- .../application-dialog/application-dialog.dialog.html | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html index 9d5eea4b5..5800171ac 100644 --- a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html +++ b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html @@ -99,10 +99,14 @@

@if(data.viewOnly) { -
-

Instructor's Notes

+
+

Instructor's Notes

+

{{ data.review.notes }}

+
+

Preferred Hiring Level

+

{{ data.review.level ? data.review.level.title : 'None' }}

} @else {

Your Notes

From 0ed6a22ce7a10ab9fd8e938e575704b7da147a77 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Mon, 10 Nov 2025 17:39:27 -0500 Subject: [PATCH 07/36] coverage calculation initial --- .../hiring-preferences.component.css | 18 +++++- .../hiring-preferences.component.html | 11 ++++ .../hiring-preferences.component.ts | 60 ++++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css index a8ed44757..48cfbbeff 100644 --- a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css +++ b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css @@ -1,3 +1,19 @@ +.header-container { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + margin-top: 8px; + padding-left: 24px; + + ::ng-deep .mat-pane { + max-width: 80vw !important; + height: 20vh; + /* margin-bottom: 32px !important; */ + margin: 0px !important; + } +} + .container { display: flex; flex-direction: row; @@ -111,4 +127,4 @@ application-card { display: flex; flex-direction: row; gap: 12px; -} +} \ No newline at end of file diff --git a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html index 323a76b97..130085edd 100644 --- a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html +++ b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html @@ -1,3 +1,14 @@ +
+ + + Estimated Coverage + + +

The estimated coverage is based on the number of enrolled students against the number and hiring level of applicants in the prefered column.

+

Estimated Coverage: {{ coverage.toFixed(2) }}

+
+
+
diff --git a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.ts b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.ts index 073d8aea9..92514b9d7 100644 --- a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.ts +++ b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.ts @@ -16,9 +16,13 @@ import { Component, WritableSignal, signal } from '@angular/core'; import { ApplicationReviewOverview, ApplicationReviewStatus, - HiringStatus + HiringStatus, + HiringLevelClassification } from '../hiring.models'; import { HiringService } from '../hiring.service'; +import { MyCoursesService } from '../../my-courses/my-courses.service'; +import { AcademicsService } from '../../academics/academics.service'; +import { forkJoin } from 'rxjs'; import { ActivatedRoute } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -48,10 +52,14 @@ export class HiringPreferencesComponent { isDropProcessing: boolean = false; + coverage: number = 0; + /** Constructor */ constructor( private route: ActivatedRoute, protected hiringService: HiringService, + protected myCoursesService: MyCoursesService, + protected academicsService: AcademicsService, protected dialog: MatDialog, private snackBar: MatSnackBar ) { @@ -64,7 +72,10 @@ export class HiringPreferencesComponent { this.notPreferred = hiringStatus.not_preferred; this.notProcessed = hiringStatus.not_processed; this.preferred = hiringStatus.preferred; + this.recomputeEstimatedCoverage(); }); + // Load enrollment for this course site (sum of section enrollments) + this.loadEnrollment(); } drop(event: CdkDragDrop) { @@ -116,6 +127,7 @@ export class HiringPreferencesComponent { this.notPreferred = hiringStatus.not_preferred; this.notProcessed = hiringStatus.not_processed; this.preferred = hiringStatus.preferred; + this.recomputeEstimatedCoverage(); this.isDropProcessing = false; }, error: (error) => { @@ -125,6 +137,51 @@ export class HiringPreferencesComponent { }); } + /** Sum enrolled students across all sections in this course site. */ + private totalEnrollment: number = 0; + private loadEnrollment() { + this.myCoursesService.getCourseSite(this.courseSiteId).subscribe((site) => { + const sectionIds = site.section_ids ?? []; + if (sectionIds.length === 0) { + this.totalEnrollment = 0; + this.recomputeEstimatedCoverage(); + return; + } + forkJoin(sectionIds.map((id) => this.academicsService.getSection(id))).subscribe( + (sections) => { + this.totalEnrollment = sections.reduce( + (sum, s) => sum + (s.enrolled ?? 0), + 0 + ); + this.recomputeEstimatedCoverage(); + } + ); + }); + } + + /** Estimate coverage from preferred applicants using selected levels. */ + private recomputeEstimatedCoverage() { + let assignedLoad = 0; + for (const review of this.preferred) { + const level = review.level; + if (!level) { + continue; + } + if ( + level.classification === HiringLevelClassification.MS || + level.classification === HiringLevelClassification.PHD + ) { + assignedLoad += level.load; + } else if (level.classification === HiringLevelClassification.UG) { + assignedLoad += level.load * 0.25; + } else { + // IOR or unknown classification contributes 0 in this estimate + } + } + // Mirror admin calculation: (enrollment / 60) - assigned load + this.coverage = this.totalEnrollment / 60.0 - assignedLoad; + } + private saveErrorSnackBar(error: Error) { let message = 'Error Saving Preferences: '; if (error instanceof HttpErrorResponse) { @@ -167,6 +224,7 @@ export class HiringPreferencesComponent { this.notPreferred = hiringStatus.not_preferred; this.notProcessed = hiringStatus.not_processed; this.preferred = hiringStatus.preferred; + this.recomputeEstimatedCoverage(); }); }); } From 2314e5d60fa8d003e521208062f8df8047d93a15 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 11 Nov 2025 13:39:38 -0500 Subject: [PATCH 08/36] Add instructor preferred hiring level to hiring admin --- backend/services/academics/hiring.py | 4 +++- .../edit-assignment.dialog.css | 9 +++++++++ .../edit-assignment.dialog.html | 18 +++++++++++++++++- .../edit-assignment.dialog.ts | 6 ++++++ .../quick-create-assignment.dialog.css | 14 +++++++++++--- .../quick-create-assignment.dialog.html | 17 ++++++++++++++++- .../quick-create-assignment.dialog.ts | 7 +++++++ 7 files changed, 69 insertions(+), 6 deletions(-) diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index 12c89551c..8a3b708ac 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -674,7 +674,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() @@ -690,6 +691,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 = [ diff --git a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.css b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.css index 1773a8244..22e8ce819 100644 --- a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.css +++ b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.css @@ -2,6 +2,7 @@ mat-form-field { width: 100%; margin-top: 10px; } + ::ng-deep .user-chips { margin-bottom: 4px !important; } @@ -13,4 +14,12 @@ mat-form-field { width: 100%; justify-content: space-between; margin-bottom: 16px; +} + +.option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 8px; } \ No newline at end of file diff --git a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html index 414f27c62..0834d22a7 100644 --- a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html +++ b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html @@ -7,6 +7,7 @@

Edit Assignment

@@ -15,8 +16,23 @@

Edit Assignment

Select Level + + {{ editAssignmentForm.get('level')?.value?.title }} + @for(level of hiringService.activeHiringlevels(); track level.id) { - {{ level.title }} + + + {{ level.title }} + @if(isInstructorPreferred(level)) { + recommend + } + + } diff --git a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts index 290178b6d..3ad25ae9a 100644 --- a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts +++ b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts @@ -124,6 +124,12 @@ export class EditAssignmentDialog { ); } + isInstructorPreferred(level: HiringLevel): boolean { + const app = this.getApplication(); + const preferredId = app?.level?.id ?? null; + return preferredId != null && level.id === preferredId; + } + openApplicationDialog(): void { this.dialog.open(ApplicationDialog, { height: '600px', diff --git a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.css b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.css index 5279d2420..0b5abd014 100644 --- a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.css +++ b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.css @@ -16,12 +16,20 @@ mat-form-field { margin-bottom: 16px; } -table.priorities > tbody > tr > td.studentPriority, -table.priorities > tbody > tr > td.instructorPriority { +.option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 8px; +} + +table.priorities>tbody>tr>td.studentPriority, +table.priorities>tbody>tr>td.instructorPriority { text-align: center; } -table > tbody> tr.selected { +table>tbody>tr.selected { color: green; font-weight: bold; } \ No newline at end of file diff --git a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html index cff54c8c9..e9a33d25f 100644 --- a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html +++ b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html @@ -18,8 +18,23 @@

Quick Create Assignment

Select Level + + {{ createAssignmentForm.get('level')?.value?.title }} + @for(level of hiringService.activeHiringlevels(); track level.id) { - {{ level.title }} + + + {{ level.title }} + @if(isInstructorPreferred(level)) { + recommend + } + + } diff --git a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts index c7aec00ba..501727350 100644 --- a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts +++ b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts @@ -169,6 +169,13 @@ export class QuickCreateAssignmentDialog { ); } + /** Returns true if the given level matches the instructor's preferred level for this applicant. */ + isInstructorPreferred(level: HiringLevel): boolean { + const app = this.getApplication(); + const preferredId = app?.level?.id ?? null; + return preferredId != null && level.id === preferredId; + } + openApplicationDialog(): void { this.dialog.open(ApplicationDialog, { height: '600px', From 95959a1f6e167e217029d40f7a781d225b472672 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 11 Nov 2025 13:40:24 -0500 Subject: [PATCH 09/36] fix undefined unsubscribe error on read only review dialog --- .../dialogs/application-dialog/application-dialog.dialog.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts index 00fbf4263..2dd7875ee 100644 --- a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts +++ b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts @@ -64,8 +64,8 @@ export class ApplicationDialog implements OnInit, OnDestroy { /** Unsubsribe from the notes subscription when the page is closed. */ ngOnDestroy(): void { - this.notesSubcription.unsubscribe(); - this.preferredLevelSubscription.unsubscribe(); + this.notesSubcription?.unsubscribe(); + this.preferredLevelSubscription?.unsubscribe(); } /** Compare hiring levels by their ID. */ From dab462c277a4cc13949b451131da5cab9e6bc0ac Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 11 Nov 2025 15:55:19 -0500 Subject: [PATCH 10/36] coverage UI --- .../hiring-preferences.component.css | 23 ++++--------- .../hiring-preferences.component.html | 33 ++++++++++++------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css index 48cfbbeff..9b3dd33dd 100644 --- a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css +++ b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css @@ -1,19 +1,3 @@ -.header-container { - display: flex; - flex-direction: column; - gap: 16px; - width: 100%; - margin-top: 8px; - padding-left: 24px; - - ::ng-deep .mat-pane { - max-width: 80vw !important; - height: 20vh; - /* margin-bottom: 32px !important; */ - margin: 0px !important; - } -} - .container { display: flex; flex-direction: row; @@ -127,4 +111,11 @@ application-card { display: flex; flex-direction: row; gap: 12px; +} + +.coverage-row { + display: inline-flex; + align-items: center; + margin-left: 8px; + gap: 4px; } \ No newline at end of file diff --git a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html index 130085edd..4f6b398e8 100644 --- a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html +++ b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html @@ -1,14 +1,3 @@ -
- - - Estimated Coverage - - -

The estimated coverage is based on the number of enrolled students against the number and hiring level of applicants in the prefered column.

-

Estimated Coverage: {{ coverage.toFixed(2) }}

-
-
-
@@ -92,6 +81,28 @@
Preferred +
+ + {{ coverage.toFixed(2) }} + + @if(coverage <= -0.5) { + keyboard_double_arrow_down + } @else if(coverage >= 0.5) { + keyboard_double_arrow_up + } @else { + done_all + } +
recommend diff --git a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html index e9a33d25f..03e9ad15c 100644 --- a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html +++ b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html @@ -28,7 +28,7 @@

Quick Create Assignment

@if(isInstructorPreferred(level)) { recommend From e4411603271a66f62e1100f50080881824040f2e Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 11 Nov 2025 15:59:58 -0500 Subject: [PATCH 12/36] icon change --- .../dialogs/edit-assignment-dialog/edit-assignment.dialog.html | 2 +- .../quick-create-assignment.dialog.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html index 43db26859..90b281bdb 100644 --- a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html +++ b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html @@ -28,7 +28,7 @@

Edit Assignment

class="font-primary" matTooltip="Instructor's preferred level" matTooltipPosition="right" - >recommendschool } diff --git a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html index 03e9ad15c..9b68e8e7d 100644 --- a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html +++ b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html @@ -30,7 +30,7 @@

Quick Create Assignment

class="font-primary" matTooltip="Instructor's preferred level" matTooltipPosition="right" - >recommendschool } From eb4575a2709be78fba5c1a895b0c67caa89b1342 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 11 Nov 2025 16:28:58 -0500 Subject: [PATCH 13/36] hiring admin coverage alignment fix --- .../src/app/hiring/hiring-admin/hiring-admin.component.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/hiring/hiring-admin/hiring-admin.component.css b/frontend/src/app/hiring/hiring-admin/hiring-admin.component.css index c6ceb3311..ce150de9e 100644 --- a/frontend/src/app/hiring/hiring-admin/hiring-admin.component.css +++ b/frontend/src/app/hiring/hiring-admin/hiring-admin.component.css @@ -29,11 +29,11 @@ mat-card-header { tr.example-detail-row { height: 0; } - + .example-element-row td { border-bottom-width: 0; } - + .example-element-detail { overflow: hidden; display: flex; @@ -60,6 +60,7 @@ course-hiring-card { .row { display: flex; flex-direction: row; + align-items: center; } .totals-row { From fbccb61d8c5e1fe90a0a36dd7407ceb5d4586a1e Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Wed, 12 Nov 2025 14:35:51 -0500 Subject: [PATCH 14/36] clean up --- backend/api/academics/hiring.py | 12 +++++++ backend/services/academics/hiring.py | 20 +++++++++++ .../hiring-preferences.component.ts | 36 +++++-------------- frontend/src/app/hiring/hiring.service.ts | 4 +++ 4 files changed, 45 insertions(+), 27 deletions(-) diff --git a/backend/api/academics/hiring.py b/backend/api/academics/hiring.py index 6f60a24d5..76802958f 100644 --- a/backend/api/academics/hiring.py +++ b/backend/api/academics/hiring.py @@ -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, diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index 8a3b708ac..a5032ad88 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -990,6 +990,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]: diff --git a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.ts b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.ts index 92514b9d7..231770762 100644 --- a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.ts +++ b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.ts @@ -22,7 +22,6 @@ import { import { HiringService } from '../hiring.service'; import { MyCoursesService } from '../../my-courses/my-courses.service'; import { AcademicsService } from '../../academics/academics.service'; -import { forkJoin } from 'rxjs'; import { ActivatedRoute } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -53,6 +52,7 @@ export class HiringPreferencesComponent { isDropProcessing: boolean = false; coverage: number = 0; + totalEnrollment: number = 0; /** Constructor */ constructor( @@ -65,6 +65,12 @@ export class HiringPreferencesComponent { ) { // Load route data this.courseSiteId = this.route.parent!.snapshot.params['courseSiteId']; + // Load the total enrollment for this course site. + this.hiringService + .getCourseSiteEnrollment(this.courseSiteId) + .subscribe((total) => { + this.totalEnrollment = total ?? 0; + }); // Load the initial hiring status. this.hiringService .getStatus(this.courseSiteId) @@ -74,8 +80,6 @@ export class HiringPreferencesComponent { this.preferred = hiringStatus.preferred; this.recomputeEstimatedCoverage(); }); - // Load enrollment for this course site (sum of section enrollments) - this.loadEnrollment(); } drop(event: CdkDragDrop) { @@ -137,28 +141,6 @@ export class HiringPreferencesComponent { }); } - /** Sum enrolled students across all sections in this course site. */ - private totalEnrollment: number = 0; - private loadEnrollment() { - this.myCoursesService.getCourseSite(this.courseSiteId).subscribe((site) => { - const sectionIds = site.section_ids ?? []; - if (sectionIds.length === 0) { - this.totalEnrollment = 0; - this.recomputeEstimatedCoverage(); - return; - } - forkJoin(sectionIds.map((id) => this.academicsService.getSection(id))).subscribe( - (sections) => { - this.totalEnrollment = sections.reduce( - (sum, s) => sum + (s.enrolled ?? 0), - 0 - ); - this.recomputeEstimatedCoverage(); - } - ); - }); - } - /** Estimate coverage from preferred applicants using selected levels. */ private recomputeEstimatedCoverage() { let assignedLoad = 0; @@ -175,10 +157,10 @@ export class HiringPreferencesComponent { } else if (level.classification === HiringLevelClassification.UG) { assignedLoad += level.load * 0.25; } else { - // IOR or unknown classification contributes 0 in this estimate + // IOR + assignedLoad += 0; } } - // Mirror admin calculation: (enrollment / 60) - assigned load this.coverage = this.totalEnrollment / 60.0 - assignedLoad; } diff --git a/frontend/src/app/hiring/hiring.service.ts b/frontend/src/app/hiring/hiring.service.ts index baac830b8..7569b4dbb 100644 --- a/frontend/src/app/hiring/hiring.service.ts +++ b/frontend/src/app/hiring/hiring.service.ts @@ -177,4 +177,8 @@ export class HiringService { `/api/hiring/conflict_check/${applicationId}` ); } + + getCourseSiteEnrollment(courseSiteId: number): Observable { + return this.http.get(`/api/hiring/${courseSiteId}/enrollment`); + } } From e3e325882103aa672325c2eb6e3049edcb1a1bea Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Thu, 13 Nov 2025 16:08:29 -0500 Subject: [PATCH 15/36] added flagged button and field to models --- .../hiring/hiring_assignment_entity.py | 7 ++ .../academics/hiring/hiring_assignment.py | 4 + backend/services/academics/hiring.py | 1 + frontend/package-lock.json | 117 ++++++++++++------ .../create-assignment.dialog.ts | 1 + .../edit-assignment.dialog.ts | 1 + .../quick-create-assignment.dialog.ts | 1 + .../hiring-summary.component.css | 12 ++ .../hiring-summary.component.html | 24 +++- .../hiring-summary.component.ts | 1 + frontend/src/app/hiring/hiring.models.ts | 4 + 11 files changed, 134 insertions(+), 39 deletions(-) diff --git a/backend/entities/academics/hiring/hiring_assignment_entity.py b/backend/entities/academics/hiring/hiring_assignment_entity.py index fffb1da18..5224b28c6 100644 --- a/backend/entities/academics/hiring/hiring_assignment_entity.py +++ b/backend/entities/academics/hiring/hiring_assignment_entity.py @@ -106,6 +106,9 @@ class HiringAssignmentEntity(EntityBase): # Stores the timestamp for the last time the assignment was updated. modified: Mapped[datetime] = mapped_column(DateTime, nullable=False) + # Stores whether the assignment is flagged for further review in the summary. + flagged: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + @classmethod def from_draft_model(cls, overview: HiringAssignmentDraft) -> Self: return cls( @@ -119,6 +122,7 @@ def from_draft_model(cls, overview: HiringAssignmentDraft) -> Self: position_number=overview.position_number, epar=overview.epar, i9=overview.i9, + flagged=overview.flagged, notes=overview.notes, created=overview.created, modified=overview.modified, @@ -134,6 +138,7 @@ def to_overview_model(self) -> HiringAssignmentOverview: epar=self.epar, i9=self.i9, notes=self.notes, + flagged=self.flagged, ) def to_summary_overview_model(self) -> HiringAssignmentSummaryOverview: @@ -159,6 +164,7 @@ def to_summary_overview_model(self) -> HiringAssignmentSummaryOverview: epar=self.epar, i9=self.i9, notes=self.notes, + flagged=self.flagged, ) def to_csv_row(self) -> HiringAssignmentCsvRow: @@ -181,6 +187,7 @@ def to_csv_row(self) -> HiringAssignmentCsvRow: epar=self.epar, position_number=self.position_number, i9=self.i9, + flagged=self.flagged, notes=self.notes, status=self.status, level_title=self.hiring_level.title, diff --git a/backend/models/academics/hiring/hiring_assignment.py b/backend/models/academics/hiring/hiring_assignment.py index 695811060..b7718d5f9 100644 --- a/backend/models/academics/hiring/hiring_assignment.py +++ b/backend/models/academics/hiring/hiring_assignment.py @@ -31,6 +31,7 @@ class HiringAssignmentDraft(BaseModel): epar: str i9: bool notes: str + flagged: bool created: datetime modified: datetime @@ -44,6 +45,7 @@ class HiringAssignmentOverview(BaseModel): epar: str i9: bool notes: str + flagged: bool class HiringAssignmentSummaryOverview(BaseModel): @@ -61,6 +63,7 @@ class HiringAssignmentSummaryOverview(BaseModel): epar: str i9: bool notes: str + flagged: bool class HiringAssignmentCsvRow(BaseModel): @@ -80,6 +83,7 @@ class HiringAssignmentCsvRow(BaseModel): level_title: str level_load: str level_salary: str + flagged: bool class HiringAssignmentSummaryCsvRow(BaseModel): diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index 15726edda..71e29cba1 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -739,6 +739,7 @@ def update_hiring_assignment( assignment_entity.epar = assignment.epar assignment_entity.i9 = assignment.i9 assignment_entity.notes = assignment.notes + assignment_entity.flagged = assignment.flagged assignment_entity.modified = datetime.now() self._session.commit() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2745f7dff..87ab2b405 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -336,6 +336,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@angular-devkit/architect/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular-devkit/architect/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -391,7 +406,6 @@ "integrity": "sha512-yuC2vN4VL48JhnsaOa9J/o0Jl+cxOklRNQp5J2/ypMuRROaVCrZAPiX+ChSHh++kHYMpj8+ggNrrUwRNfMKACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "18.2.21", "jsonc-parser": "3.3.1", @@ -411,7 +425,6 @@ "integrity": "sha512-Lno6GNbJME85wpc/uqn+wamBxvfZJZFYSH8+oAkkyjU/hk8r5+X8DuyqsKAa0m8t46zSTUsonHsQhVe5vgrZeQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "8.17.1", "ajv-formats": "3.0.1", @@ -539,6 +552,7 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.1.tgz", "integrity": "sha512-mexSwaikVE2s+GDhB9fuagEvxbnKHWsqLlO7/R2nY9tTUxBO3drWe3p0D5GxG/EsEyzZU+86ED867q/JmAiVvw==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -689,6 +703,7 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.4.tgz", "integrity": "sha512-5UzrN854pnQH+Qw6XZRxx2zWkcOxKrzWPLXe+gHFxFhxWUZfJKGcTJeAj8bnmyb+C3lqBbGpoNQPQ8pFXQGEaQ==", "license": "MIT", + "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -897,6 +912,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@angular/cli/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular/cli/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -951,6 +981,7 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.1.tgz", "integrity": "sha512-7Ru3BO4MOBQRMu9GJS+061cUsevKNsNAMxXnQtcqEaNyntUg2v0XiMdv4I7pQGtkQjFK17bKAxQ97jqxJfqsRQ==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -967,6 +998,7 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.1.tgz", "integrity": "sha512-zRYAdAG/hsJegXapKxElLU6Q5in8UG9Pbxyh90k89qsZwkuv+CfxVY5OBS2xjk1azt808++yhjfvbO/Em+HMKg==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -980,6 +1012,7 @@ "integrity": "sha512-aFfGHi/ApYxmvF4cCS0TypcviQ/Xy+0fwTTrLC8znPC1vObBn0DUA0I6D5dP+xlOTx8PFLkgndNYa2f6RIluvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "7.28.3", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -1042,6 +1075,7 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.1.tgz", "integrity": "sha512-O03k9ivZ2CvoHXiXGH5WKlWlTtxF2UGMwGXWnV54vGViHwNcvU5Z3h6Ve6mdU9dYMHK9sGljYZnkRpwI3B8mnQ==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1067,6 +1101,7 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.1.tgz", "integrity": "sha512-P7cmfK1ldXS8KuPTwwIUTZs5AxhbPNumlumq+nfNJZAxv8/PQJh2W729M/EKHG8rB8cXjoo1K+olExnJNPVDTw==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1102,6 +1137,7 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.1.tgz", "integrity": "sha512-JiQWRvyVZDH0N9p+pnMOuTFGaw7jPakWDQCJBOBBLdE6AyOiy8YPBImRMrjNNIEqg36h1a8H32rBorf2TL3ExA==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1214,6 +1250,7 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2461,6 +2498,7 @@ "integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/checkbox": "^4.2.1", "@inquirer/confirm": "^5.1.14", @@ -4755,6 +4793,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@schematics/angular/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@schematics/angular/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -4967,6 +5020,7 @@ "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5024,6 +5078,7 @@ "integrity": "sha512-U7iTAtGgJk6DPX9wIWPPOlt1gO57097G06gIcl0N0EEnNw8RGD62c+2/DiP/zL7KrkqnnqF7gtFGR7YgzPllTA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.1.0", "@typescript-eslint/types": "8.1.0", @@ -5139,6 +5194,7 @@ "integrity": "sha512-ypRueFNKTIFwqPeJBfeIpxZ895PQhNyH4YID6js0UoBImWYoSjBsahUn9KMiJXh94uOjVBgHD9AmkyPsPnFwJA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.1.0", @@ -5231,6 +5287,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5458,8 +5515,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/base64id": { "version": "2.0.0", @@ -5520,7 +5576,6 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -5619,6 +5674,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5653,7 +5709,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -5870,6 +5925,7 @@ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6023,7 +6079,6 @@ "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8" } @@ -6262,7 +6317,6 @@ "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "clone": "^1.0.2" }, @@ -6703,6 +6757,7 @@ "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", @@ -6763,6 +6818,7 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8112,8 +8168,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "5.3.2", @@ -8318,7 +8373,6 @@ "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -8356,7 +8410,6 @@ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -8499,7 +8552,8 @@ "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.2.0.tgz", "integrity": "sha512-tSAtdrvWybZkQmmaIoDgnvHG8ORUNw5kEVlO5CvrXj02Jjr9TZrmjFq7FUiOUzJiOP2wLGYT6PgrQgQF4R1xiw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/js-tokens": { "version": "4.0.0", @@ -8611,6 +8665,7 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -8952,6 +9007,7 @@ "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -9082,7 +9138,6 @@ "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -9100,7 +9155,6 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -9117,7 +9171,6 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -9135,7 +9188,6 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9361,7 +9413,6 @@ "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } @@ -9539,7 +9590,6 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -10293,7 +10343,6 @@ "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", @@ -10318,7 +10367,6 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10335,7 +10383,6 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10353,7 +10400,6 @@ "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "restore-cursor": "^3.1.0" }, @@ -10367,7 +10413,6 @@ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mimic-fn": "^2.1.0" }, @@ -10384,7 +10429,6 @@ "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" @@ -10398,8 +10442,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/ora/node_modules/supports-color": { "version": "7.2.0", @@ -10407,7 +10450,6 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10808,6 +10850,7 @@ "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11089,6 +11132,7 @@ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -11476,7 +11520,6 @@ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -11790,6 +11833,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -12246,7 +12290,6 @@ "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">= 8" } @@ -12375,7 +12418,6 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -12695,7 +12737,8 @@ "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tuf-js": { "version": "3.1.0", @@ -12770,6 +12813,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12911,8 +12955,7 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -12961,6 +13004,7 @@ "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -13150,7 +13194,6 @@ "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "defaults": "^1.0.3" } @@ -13425,6 +13468,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13443,7 +13487,8 @@ "version": "0.15.1", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT" + "license": "MIT", + "peer": true } } } diff --git a/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts index 824bd1113..e008807ec 100644 --- a/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts +++ b/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts @@ -81,6 +81,7 @@ export class CreateAssignmentDialog { epar: this.createAssignmentForm.get('epar')!.value ?? '', i9: this.createAssignmentForm.get('i9')!.value ?? false, notes: this.createAssignmentForm.get('notes')!.value ?? '', + flagged: false, created: new Date(), modified: new Date() }; diff --git a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts index 290178b6d..3eb790585 100644 --- a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts +++ b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts @@ -97,6 +97,7 @@ export class EditAssignmentDialog { epar: this.editAssignmentForm.get('epar')!.value ?? '', i9: this.editAssignmentForm.get('i9')!.value ?? false, notes: this.editAssignmentForm.get('notes')!.value ?? '', + flagged: false, created: new Date(), // Will be overwritten anyway modified: new Date() }; diff --git a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts index c7aec00ba..a4f8cfd83 100644 --- a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts +++ b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts @@ -142,6 +142,7 @@ export class QuickCreateAssignmentDialog { epar: this.createAssignmentForm.get('epar')!.value ?? '', i9: this.createAssignmentForm.get('i9')!.value ?? false, notes: this.createAssignmentForm.get('notes')!.value ?? '', + flagged: false, created: new Date(), modified: new Date() }; diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css index 6f542b9c9..cdb72ad5b 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css @@ -41,4 +41,16 @@ mat-form-field.pos_number { max-width: 100% !important; margin-right: 32px !important; } +} + +.flag-btn { + background: transparent; + border: none; + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 4px; + display: inline-flex; + align-items: center; + justify-content: center; } \ No newline at end of file diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html index c9fe1bf9b..c0951b9da 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html @@ -28,9 +28,27 @@
Name
- -
-

{{ element.user.first_name }} {{ element.user.last_name }}
{{ element.user.pid }}

+ +
+ + + +

+ {{ element.user.first_name }} {{ element.user.last_name }}
{{ element.user.pid }} +

diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts index 70d502005..4e5900b96 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts @@ -162,6 +162,7 @@ export class HiringSummaryComponent { epar: assignment.epar, i9: assignment.i9, notes: assignment.notes, + flagged: assignment.flagged, created: new Date(), // will be overrided modified: new Date() }; diff --git a/frontend/src/app/hiring/hiring.models.ts b/frontend/src/app/hiring/hiring.models.ts index 5ba3bd823..572d2efce 100644 --- a/frontend/src/app/hiring/hiring.models.ts +++ b/frontend/src/app/hiring/hiring.models.ts @@ -90,6 +90,7 @@ export interface HiringAssignmentDraft { notes: string; created: Date; modified: Date; + flagged: boolean; } export interface HiringAssignmentOverview { @@ -101,6 +102,7 @@ export interface HiringAssignmentOverview { epar: string; i9: boolean; notes: string; + flagged: boolean; } export const hiringAssignmentOverviewToDraft = ( @@ -121,6 +123,7 @@ export const hiringAssignmentOverviewToDraft = ( epar: assignment.epar, i9: assignment.i9, notes: assignment.notes, + flagged: assignment.flagged, created: new Date(), // overwritten anyway modified: new Date() // overwritten anyway }; @@ -161,6 +164,7 @@ export interface HiringAssignmentSummaryOverview { epar: string; i9: boolean; notes: string; + flagged: boolean; } export interface ReleasedHiringAssignment { From 6e34e8c692dbcf320e5f2e7b1bb2eb85d24f5dea Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Wed, 19 Nov 2025 14:35:12 -0500 Subject: [PATCH 16/36] added flagged fields --- backend/entities/academics/hiring/hiring_assignment_entity.py | 3 +-- backend/models/academics/hiring/hiring_assignment.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/entities/academics/hiring/hiring_assignment_entity.py b/backend/entities/academics/hiring/hiring_assignment_entity.py index 5224b28c6..b32b2722f 100644 --- a/backend/entities/academics/hiring/hiring_assignment_entity.py +++ b/backend/entities/academics/hiring/hiring_assignment_entity.py @@ -107,7 +107,7 @@ class HiringAssignmentEntity(EntityBase): modified: Mapped[datetime] = mapped_column(DateTime, nullable=False) # Stores whether the assignment is flagged for further review in the summary. - flagged: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + flagged: Mapped[bool] = mapped_column(Boolean, default=False) @classmethod def from_draft_model(cls, overview: HiringAssignmentDraft) -> Self: @@ -187,7 +187,6 @@ def to_csv_row(self) -> HiringAssignmentCsvRow: epar=self.epar, position_number=self.position_number, i9=self.i9, - flagged=self.flagged, notes=self.notes, status=self.status, level_title=self.hiring_level.title, diff --git a/backend/models/academics/hiring/hiring_assignment.py b/backend/models/academics/hiring/hiring_assignment.py index b7718d5f9..a18591185 100644 --- a/backend/models/academics/hiring/hiring_assignment.py +++ b/backend/models/academics/hiring/hiring_assignment.py @@ -83,7 +83,6 @@ class HiringAssignmentCsvRow(BaseModel): level_title: str level_load: str level_salary: str - flagged: bool class HiringAssignmentSummaryCsvRow(BaseModel): From 45a4eafb8cc5b30334fe66c8e0652d696fe3499c Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Wed, 19 Nov 2025 14:35:41 -0500 Subject: [PATCH 17/36] updated fake hiring data --- backend/test/services/academics/hiring/hiring_data.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/test/services/academics/hiring/hiring_data.py b/backend/test/services/academics/hiring/hiring_data.py index f272da7f6..71b6e9b50 100644 --- a/backend/test/services/academics/hiring/hiring_data.py +++ b/backend/test/services/academics/hiring/hiring_data.py @@ -252,6 +252,7 @@ notes="Some notes here", created=datetime.now(), modified=datetime.now(), + flagged=False, ) updated_hiring_assignment = HiringAssignmentDraft( @@ -267,6 +268,7 @@ notes="Some notes here", created=datetime.now(), modified=datetime.now(), + flagged=False, ) new_hiring_assignment = HiringAssignmentDraft( @@ -282,6 +284,7 @@ notes="Some notes here", created=datetime.now(), modified=datetime.now(), + flagged=False, ) hiring_assignments = [hiring_assignment] From 02dae1cd186445be28c7c39a4ca753e56082479b Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Wed, 19 Nov 2025 15:20:08 -0500 Subject: [PATCH 18/36] added proper styling for flagged button --- .../hiring-summary.component.css | 43 ++++++++++++++----- .../hiring-summary.component.html | 37 +++++++++------- .../hiring-summary.component.ts | 1 + 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css index cdb72ad5b..278a6cda0 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css @@ -43,14 +43,35 @@ mat-form-field.pos_number { } } -.flag-btn { - background: transparent; - border: none; - cursor: pointer; - font-size: 18px; - line-height: 1; - padding: 4px; - display: inline-flex; - align-items: center; - justify-content: center; -} \ No newline at end of file +.material-symbols-outlined { + font-variation-settings: + 'FILL' 0, + 'wght' 500, + 'GRAD' 0, + 'opsz' 24; + transition: font-variation-settings 0.15s ease, color 0.15s ease; +} + +.flag-icon-btn { + transition: color 0.15s ease; +} + +.flag-icon-btn .mat-icon { + color: #ffffff; +} + +.flag-icon-btn.flagged .mat-icon { + color: #cfbcff !important; +} + +.flag-icon-btn.flagged .material-symbols-outlined { + font-variation-settings: + 'FILL' 1, + 'wght' 500, + 'GRAD' 0, + 'opsz' 24; +} + +.flag-icon-btn:hover .mat-icon { + color: #9a83db !important; +} diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html index c0951b9da..2659c524a 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html @@ -24,28 +24,33 @@
+ + + + + @@ -67,7 +66,7 @@ @@ -79,7 +78,7 @@ @@ -138,7 +137,7 @@ @@ -147,7 +146,7 @@ @@ -23,8 +22,7 @@ class="clickable-td" mat-cell *matCellDef="let element" - (click)="editAssignment(element)"> - {{ element.level.title }} (${{ element.level.salary.toFixed(2) }}) + (click)="openAuditLog(element, $event)"> {{ element.level.title }} (${{ element.level.salary.toFixed(2) }}) @@ -33,8 +31,7 @@ class="clickable-td" mat-cell *matCellDef="let element" - (click)="editAssignment(element)"> - {{ element.position_number }} + (click)="openAuditLog(element, $event)"> {{ element.position_number }} @@ -43,8 +40,7 @@ class="clickable-td" mat-cell *matCellDef="let element" - (click)="editAssignment(element)"> - {{ element.epar }} + (click)="openAuditLog(element, $event)"> {{ element.epar }} @@ -67,12 +63,16 @@ Draft Commit Final - + diff --git a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts index 0762d62f2..58978488f 100644 --- a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts +++ b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts @@ -32,12 +32,13 @@ import { EditAssignmentDialogData } from '../../dialogs/edit-assignment-dialog/edit-assignment.dialog'; import { ApplicationDialog } from '../../dialogs/application-dialog/application-dialog.dialog'; +import { AuditLogDialog } from '../../dialogs/audit-log-dialog/audit-log-dialog.dialog'; @Component({ - selector: 'course-hiring-card', - templateUrl: './course-hiring-card.widget.html', - styleUrl: './course-hiring-card.widget.css', - standalone: false + selector: 'course-hiring-card', + templateUrl: './course-hiring-card.widget.html', + styleUrl: './course-hiring-card.widget.css', + standalone: false }) export class CourseHiringCardWidget implements OnInit { @Input() termId!: string; @@ -167,6 +168,19 @@ export class CourseHiringCardWidget implements OnInit { }); } + /** Opens the audit log dialog for a specific assignment. */ + openAuditLog(assignment: HiringAssignmentOverview, event: MouseEvent): void { + event.stopPropagation(); + + this.dialog.open(AuditLogDialog, { + width: '600px', + data: { + assignmentId: assignment.id, + applicantName: `${assignment.user.first_name} ${assignment.user.last_name}` + } + }); + } + chipSelected(user: PublicProfile): boolean { return ( this.item()! From 7b46690ee8cbc1a3fa3d66945693a950622d451e Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Wed, 3 Dec 2025 15:44:38 -0500 Subject: [PATCH 32/36] auto assign level --- backend/services/academics/hiring.py | 70 ++++++++++++++++++- .../quick-create-assignment.dialog.ts | 50 ++++++++----- 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index a5032ad88..cd5ce16d7 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -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] = {} @@ -156,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. diff --git a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts index 501727350..ef7f47404 100644 --- a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts +++ b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts @@ -76,27 +76,39 @@ export class QuickCreateAssignmentDialog { let review = data.courseAdmin.reviews.find( (r) => r.applicant_id == data.user.id )!; - let program = review.application.program_pursued!; - let defaultLevelSearch: string | null; - switch (program) { - case 'PhD': - defaultLevelSearch = '1.0 PhD TA'; - break; - case 'PhD (ABD)': - defaultLevelSearch = '1.0 PhD (ABD) TA'; - break; - case 'BS/MS': - case 'MS': - defaultLevelSearch = '1.0 MS TA'; - break; - default: - defaultLevelSearch = '10h UTA'; - break; + + let level: HiringLevel | undefined; + + // Try to use the instructor's preferred level + if (review.level?.id) { + level = this.hiringService.getHiringLevel(review.level.id); } + + // Fall back to title-based search if no preferred level is set or found + if (!level) { + let program = review.application.program_pursued!; + let defaultLevelSearch: string | null; + switch (program) { + case 'PhD': + defaultLevelSearch = '1.0 PhD TA'; + break; + case 'PhD (ABD)': + defaultLevelSearch = '1.0 PhD (ABD) TA'; + break; + case 'BS/MS': + case 'MS': + defaultLevelSearch = '1.0 MS TA'; + break; + default: + defaultLevelSearch = '10h UTA'; + break; + } - const level = this.hiringService - .hiringLevels() - .find((level) => level.title == defaultLevelSearch); + level = this.hiringService + .hiringLevels() + .find((level) => level.title == defaultLevelSearch); + } + if (level) { this.createAssignmentForm.get('level')?.setValue(level); } From bb5040e8af77e10c520e70a39935bc0c27d0198b Mon Sep 17 00:00:00 2001 From: Christian Lee Date: Sat, 6 Dec 2025 18:13:56 -0500 Subject: [PATCH 33/36] Add Frontend Functionality to HR Onboarding page --- .../audit-log-dialog.dialog.css | 67 +++++++++++++++++++ .../hiring-summary.component.css | 17 +++++ .../hiring-summary.component.html | 4 +- .../hiring-summary.component.ts | 23 ++++++- frontend/src/app/hiring/hiring.models.ts | 2 +- .../course-hiring-card.widget.html | 28 +++++--- 6 files changed, 130 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css index 43c91031d..07611dadc 100644 --- a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css +++ b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css @@ -1,4 +1,71 @@ mat-form-field { width: 100%; margin-top: 10px; +} + +/* Center the loading spinner */ +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 150px; +} + +/* Center the empty state message */ +.empty-state { + text-align: center; + color: var(--text-secondary, #666); + padding: 40px 0; + font-style: italic; +} + +/* The container for the list items */ +.audit-list { + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 8px; +} + +/* Individual Log Entry */ +.audit-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Header: Puts Name on left, Date on right */ +.audit-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 4px; +} + +/* Name Style */ +.audit-user { + font-weight: 600; /* Semibold equivalent */ + font-size: 1rem; +} + +/* Date Badge Style */ +.audit-time { + font-size: 0.85rem; + color: var(--text-secondary, #666); + background-color: #f5f5f5; /* Light gray background */ + padding: 2px 8px; + border-radius: 12px; +} + +/* The actual text of what changed */ +.audit-detail { + margin: 0; + color: var(--text-primary, #333); + line-height: 1.4; + white-space: pre-wrap; /* Keeps formatting if you use newlines later */ +} + +/* Fix the divider spacing */ +mat-divider { + margin-top: 12px; } \ No newline at end of file diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css index 3386d22ac..988dbbdbd 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css @@ -54,3 +54,20 @@ mat-form-field.pos_number { font-variation-settings: 'FILL' 1; } + +/* Add this new style for the cell */ +.clickable-cell { + cursor: pointer; + font-weight: 500; + color: var(--primary-color, #007bb2); /* Optional: make it look like a link */ +} + +.clickable-cell:hover { + text-decoration: underline; +} + +.pid-text { + font-weight: normal; + color: black; + font-size: 0.9em; +} \ No newline at end of file diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html index 1e29dc8fa..a8d48868c 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html @@ -51,7 +51,9 @@ - - @@ -22,7 +31,8 @@ class="clickable-td" mat-cell *matCellDef="let element" - (click)="openAuditLog(element, $event)"> {{ element.level.title }} (${{ element.level.salary.toFixed(2) }}) + (click)="editAssignment(element)"> + {{ element.level.title }} (${{ element.level.salary.toFixed(2) }}) @@ -31,7 +41,8 @@ class="clickable-td" mat-cell *matCellDef="let element" - (click)="openAuditLog(element, $event)"> {{ element.position_number }} + (click)="editAssignment(element)"> + {{ element.position_number }} @@ -40,7 +51,8 @@ class="clickable-td" mat-cell *matCellDef="let element" - (click)="openAuditLog(element, $event)"> {{ element.epar }} + (click)="editAssignment(element)"> + {{ element.epar }} From 739389a784b54c58398e89df6f84113f992890ae Mon Sep 17 00:00:00 2001 From: Christian Lee Date: Sat, 6 Dec 2025 18:42:46 -0500 Subject: [PATCH 34/36] Add notes update field --- backend/services/academics/hiring.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index fa2310899..1de023d2a 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -753,9 +753,12 @@ def update_hiring_assignment( changes.append( f"Pos Num: {assignment_entity.position_number} -> {assignment.position_number}" ) - # Check Notes (Only log that notes changed, not the full text, to keep logs clean) - if assignment_entity.notes != assignment.notes: - changes.append("Updated Notes") + # Check Notes + old_notes = assignment_entity.notes or "" + new_notes = assignment.notes or "" + + if old_notes != new_notes: + changes.append(f"Notes: '{old_notes}' -> '{new_notes}'") # Check Hiring Level if assignment_entity.hiring_level_id != assignment.level.id: changes.append( From c98302d020ff6fd2747ba9496dd69ba0e030b9ef Mon Sep 17 00:00:00 2001 From: Christian Lee Date: Sat, 6 Dec 2025 21:26:23 -0500 Subject: [PATCH 35/36] Add Tests --- .../services/academics/hiring/hiring_test.py | 72 ++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/backend/test/services/academics/hiring/hiring_test.py b/backend/test/services/academics/hiring/hiring_test.py index 47ac5ce5c..0c7732171 100644 --- a/backend/test/services/academics/hiring/hiring_test.py +++ b/backend/test/services/academics/hiring/hiring_test.py @@ -19,6 +19,9 @@ from .....services.academics import HiringService from .....services.application import ApplicationService from .....services.academics.course_site import CourseSiteService +from .....models.academics.hiring.hiring_assignment_audit import ( + HiringAssignmentAuditOverview, +) # Injected Service Fixtures from .fixtures import hiring_svc @@ -209,6 +212,7 @@ def test_update_hiring_assignment_not_found(hiring_svc: HiringService): ) pytest.fail() + def test_update_hiring_assigment_flag(hiring_svc: HiringService): """Ensures that the admin can update the flagged status of a hiring assignment.""" assignment = hiring_svc.update_hiring_assignment( @@ -324,9 +328,7 @@ def test_get_hiring_summary_overview_all(hiring_svc: HiringService): ) assert summary is not None assert len(summary.items) > 0 - assert all( - assignment.flagged in [True, False] for assignment in summary.items - ) + assert all(assignment.flagged in [True, False] for assignment in summary.items) def test_get_hiring_summary_overview_flagged(hiring_svc: HiringService): @@ -365,3 +367,67 @@ def test_get_hiring_summary_overview_invalid_flagged(hiring_svc: HiringService): assert all(assignment.flagged in [True, False] for assignment in summary.items) +def test_update_hiring_assignment_creates_audit_log(hiring_svc: HiringService): + """Ensures that updating an assignment creates an audit log entry.""" + hiring_svc.update_hiring_assignment( + user_data.root, hiring_data.updated_hiring_assignment + ) + + history = hiring_svc.get_audit_history( + user_data.root, hiring_data.hiring_assignment.id + ) + + assert len(history) == 1 + assert history[0].changed_by_user.id == user_data.root.id + assert "Status: COMMIT -> FINAL" in history[0].change_details + + +def test_update_hiring_assignment_audit_details_notes(hiring_svc: HiringService): + """Ensures notes updates are formatted correctly using the 'Old -> New' format.""" + assignment = hiring_data.hiring_assignment.model_copy() + assignment.notes = "New Notes Value" + + hiring_svc.update_hiring_assignment(user_data.root, assignment) + + history = hiring_svc.get_audit_history(user_data.root, assignment.id) + assert len(history) == 1 + assert "Notes: 'Some notes here' -> 'New Notes Value'" in history[0].change_details + + +def test_update_hiring_assignment_audit_details_flagged(hiring_svc: HiringService): + """Ensures flagged status changes are logged.""" + assignment = hiring_data.hiring_assignment.model_copy() + assignment.flagged = True + + hiring_svc.update_hiring_assignment(user_data.root, assignment) + + history = hiring_svc.get_audit_history(user_data.root, assignment.id) + assert len(history) == 1 + assert "Flagged: False -> True" in history[0].change_details + + +def test_get_audit_history_ordering(hiring_svc: HiringService): + """Ensures audit logs are returned in reverse chronological order (newest first).""" + a1 = hiring_data.hiring_assignment.model_copy() + a1.position_number = "update_1" + hiring_svc.update_hiring_assignment(user_data.root, a1) + + a2 = hiring_data.hiring_assignment.model_copy() + a2.position_number = "update_2" + hiring_svc.update_hiring_assignment(user_data.root, a2) + + history = hiring_svc.get_audit_history( + user_data.root, hiring_data.hiring_assignment.id + ) + + assert len(history) == 2 + assert "update_2" in history[0].change_details + assert "update_1" in history[1].change_details + + +def test_get_audit_history_permissions(hiring_svc: HiringService): + """Ensures that non-admins cannot view audit history.""" + with pytest.raises(UserPermissionException): + hiring_svc.get_audit_history( + user_data.student, hiring_data.hiring_assignment.id + ) From 003a26c6c30a8eb093dfabb008be0cb9b67323b1 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Mon, 8 Dec 2025 14:26:40 -0500 Subject: [PATCH 36/36] Revert "Merge branch 'main' into instructor-coverage" This reverts commit f14fa12207b268ea3f5b419869a9cba175100b7a, reversing changes made to 7b46690ee8cbc1a3fa3d66945693a950622d451e. --- backend/api/academics/hiring.py | 22 +-- backend/entities/__init__.py | 7 +- .../hiring/hiring_assignment_audit_entity.py | 31 ---- .../hiring/hiring_assignment_entity.py | 6 - ...d03df5_migration_for_applicant_flagging.py | 28 --- .../academics/hiring/hiring_assignment.py | 7 - .../hiring/hiring_assignment_audit.py | 31 ---- backend/services/academics/hiring.py | 97 +---------- .../services/academics/hiring/hiring_data.py | 37 +--- .../services/academics/hiring/hiring_test.py | 128 -------------- frontend/package-lock.json | 117 ++++--------- .../audit-log-dialog.dialog.css | 71 -------- .../audit-log-dialog.dialog.html | 40 ----- .../audit-log-dialog.dialog.ts | 36 ---- .../create-assignment.dialog.ts | 1 - .../edit-assignment.dialog.ts | 1 - .../quick-create-assignment.dialog.ts | 1 - .../hiring-summary.component.css | 29 ---- .../hiring-summary.component.html | 109 +++++------- .../hiring-summary.component.ts | 162 ++++-------------- frontend/src/app/hiring/hiring.models.ts | 11 -- frontend/src/app/hiring/hiring.module.ts | 8 +- frontend/src/app/hiring/hiring.service.ts | 12 -- .../course-hiring-card.widget.html | 26 +-- .../course-hiring-card.widget.ts | 22 +-- frontend/src/app/pagination.ts | 4 +- 26 files changed, 141 insertions(+), 903 deletions(-) delete mode 100644 backend/entities/academics/hiring/hiring_assignment_audit_entity.py delete mode 100644 backend/migrations/versions/0a57afd03df5_migration_for_applicant_flagging.py delete mode 100644 backend/models/academics/hiring/hiring_assignment_audit.py delete mode 100644 frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css delete mode 100644 frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.html delete mode 100644 frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.ts diff --git a/backend/api/academics/hiring.py b/backend/api/academics/hiring.py index 1541fea5b..76802958f 100644 --- a/backend/api/academics/hiring.py +++ b/backend/api/academics/hiring.py @@ -15,9 +15,6 @@ from ...models.academics.hiring.hiring_assignment import * from ...models.academics.hiring.hiring_level import * from ...models.academics.hiring.conflict_check import ConflictCheck -from ...models.academics.hiring.hiring_assignment_audit import ( - HiringAssignmentAuditOverview, -) from ...api.authentication import registered_user from ...models.user import User @@ -185,7 +182,6 @@ def get_hiring_summary_overview( page_size: int = 100, order_by: str = "", filter: str = "", - flagged: HiringAssignmentFlagFilter = HiringAssignmentFlagFilter.ALL, subject: User = Depends(registered_user), hiring_service: HiringService = Depends(), ) -> Paginated[HiringAssignmentSummaryOverview]: @@ -196,7 +192,7 @@ def get_hiring_summary_overview( page=page, page_size=page_size, order_by=order_by, filter=filter ) return hiring_service.get_hiring_summary_overview( - subject, term_id, flagged, pagination_params + subject, term_id, pagination_params ) @@ -423,19 +419,3 @@ def row_iter(): f"attachment; filename=applicants_{term_id}.csv" ) return response - - -@api.get( - "/assignments/{assignment_id}/history", - tags=["Hiring"], - response_model=list[HiringAssignmentAuditOverview], -) -def get_assignment_history( - assignment_id: int, - subject: User = Depends(registered_user), - hiring_service: HiringService = Depends(), -) -> list[HiringAssignmentAuditOverview]: - """ - Get the change history for a specific hiring assignment. - """ - return hiring_service.get_audit_history(subject, assignment_id) diff --git a/backend/entities/__init__.py b/backend/entities/__init__.py index 78c5a381a..5ab2a911d 100644 --- a/backend/entities/__init__.py +++ b/backend/entities/__init__.py @@ -5,11 +5,11 @@ 1. Loads all entities into the application derived from `entities.EntityBase`. In doing so, many of SQLAlchemy's features around metadata (creation/updating/dropping of tables) are possible by virtue of importing from this file directly. - + 2. An index module of all entities which makes importing entities easier. Rather than importing from the modules directly, you can import them from the entities `package`, e.g. `from entities import UserEntity`. - -When adding a new entity to the application be sure to import it here. As a reminder, all identifiers + +When adding a new entity to the application be sure to import it here. As a reminder, all identifiers global to a module are available for import from other modules.""" from .entity_base import EntityBase @@ -33,7 +33,6 @@ from .article_author_entity import article_author_table from .academics.hiring.hiring_assignment_entity import HiringAssignmentEntity -from .academics.hiring.hiring_assignment_audit_entity import HiringAssignmentAuditEntity from .academics.hiring.hiring_level_entity import HiringLevelEntity __authors__ = ["Kris Jordan"] diff --git a/backend/entities/academics/hiring/hiring_assignment_audit_entity.py b/backend/entities/academics/hiring/hiring_assignment_audit_entity.py deleted file mode 100644 index 761207249..000000000 --- a/backend/entities/academics/hiring/hiring_assignment_audit_entity.py +++ /dev/null @@ -1,31 +0,0 @@ -from sqlalchemy import Integer, String, ForeignKey, DateTime -from sqlalchemy.orm import Mapped, mapped_column, relationship -from datetime import datetime -from ...entity_base import EntityBase - -__authors__ = ["Christian Lee"] -__copyright__ = "Copyright 2025" -__license__ = "MIT" - - -class HiringAssignmentAuditEntity(EntityBase): - """Schema for the `academics__hiring__assignment_audit` table.""" - - __tablename__ = "academics__hiring__assignment_audit" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - - # The assignment being modified - hiring_assignment_id: Mapped[int] = mapped_column( - ForeignKey("academics__hiring__assignment.id") - ) - - # Who made the change - changed_by_user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) - changed_by_user: Mapped["UserEntity"] = relationship("UserEntity") - - # When it happened - change_timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) - - # What changed (e.g. "Status: Draft -> Commit") - change_details: Mapped[str] = mapped_column(String) diff --git a/backend/entities/academics/hiring/hiring_assignment_entity.py b/backend/entities/academics/hiring/hiring_assignment_entity.py index b32b2722f..fffb1da18 100644 --- a/backend/entities/academics/hiring/hiring_assignment_entity.py +++ b/backend/entities/academics/hiring/hiring_assignment_entity.py @@ -106,9 +106,6 @@ class HiringAssignmentEntity(EntityBase): # Stores the timestamp for the last time the assignment was updated. modified: Mapped[datetime] = mapped_column(DateTime, nullable=False) - # Stores whether the assignment is flagged for further review in the summary. - flagged: Mapped[bool] = mapped_column(Boolean, default=False) - @classmethod def from_draft_model(cls, overview: HiringAssignmentDraft) -> Self: return cls( @@ -122,7 +119,6 @@ def from_draft_model(cls, overview: HiringAssignmentDraft) -> Self: position_number=overview.position_number, epar=overview.epar, i9=overview.i9, - flagged=overview.flagged, notes=overview.notes, created=overview.created, modified=overview.modified, @@ -138,7 +134,6 @@ def to_overview_model(self) -> HiringAssignmentOverview: epar=self.epar, i9=self.i9, notes=self.notes, - flagged=self.flagged, ) def to_summary_overview_model(self) -> HiringAssignmentSummaryOverview: @@ -164,7 +159,6 @@ def to_summary_overview_model(self) -> HiringAssignmentSummaryOverview: epar=self.epar, i9=self.i9, notes=self.notes, - flagged=self.flagged, ) def to_csv_row(self) -> HiringAssignmentCsvRow: diff --git a/backend/migrations/versions/0a57afd03df5_migration_for_applicant_flagging.py b/backend/migrations/versions/0a57afd03df5_migration_for_applicant_flagging.py deleted file mode 100644 index 63a5687df..000000000 --- a/backend/migrations/versions/0a57afd03df5_migration_for_applicant_flagging.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Migration for applicant-flagging - -Revision ID: 0a57afd03df5 -Revises: a9f09b49d862 -Create Date: 2025-11-24 16:09:49.309576 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '0a57afd03df5' -down_revision = 'a9f09b49d862' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('academics__hiring__assignment', sa.Column('flagged', sa.Boolean(), nullable=False, default=False)) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('academics__hiring__assignment', 'flagged') - # ### end Alembic commands ### diff --git a/backend/models/academics/hiring/hiring_assignment.py b/backend/models/academics/hiring/hiring_assignment.py index fde5270ac..695811060 100644 --- a/backend/models/academics/hiring/hiring_assignment.py +++ b/backend/models/academics/hiring/hiring_assignment.py @@ -18,10 +18,6 @@ class HiringAssignmentStatus(Enum): COMMIT = "Commit" FINAL = "Final" -class HiringAssignmentFlagFilter(Enum): - ALL = "all" - FLAGGED = "flagged" - NOT_FLAGGED = "not_flagged" class HiringAssignmentDraft(BaseModel): id: int | None = None @@ -35,7 +31,6 @@ class HiringAssignmentDraft(BaseModel): epar: str i9: bool notes: str - flagged: bool created: datetime modified: datetime @@ -49,7 +44,6 @@ class HiringAssignmentOverview(BaseModel): epar: str i9: bool notes: str - flagged: bool class HiringAssignmentSummaryOverview(BaseModel): @@ -67,7 +61,6 @@ class HiringAssignmentSummaryOverview(BaseModel): epar: str i9: bool notes: str - flagged: bool class HiringAssignmentCsvRow(BaseModel): diff --git a/backend/models/academics/hiring/hiring_assignment_audit.py b/backend/models/academics/hiring/hiring_assignment_audit.py deleted file mode 100644 index a22431559..000000000 --- a/backend/models/academics/hiring/hiring_assignment_audit.py +++ /dev/null @@ -1,31 +0,0 @@ -from pydantic import BaseModel -from datetime import datetime -from ... import PublicUser - -__authors__ = ["Christian Lee"] -__copyright__ = "Copyright 2025" -__license__ = "MIT" - - -class HiringAssignmentAudit(BaseModel): - """ - Pydantic model to represent a snapshot of changes to a Hiring Assignment. - """ - - id: int | None = None - hiring_assignment_id: int - changed_by_user_id: int - change_timestamp: datetime - change_details: str - - -class HiringAssignmentAuditOverview(BaseModel): - """ - Model for displaying audit logs in the UI. - Includes the full user details instead of just an ID. - """ - - id: int - change_timestamp: datetime - change_details: str - changed_by_user: PublicUser diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index bf3d9fd25..cd5ce16d7 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -28,9 +28,6 @@ from ...entities.section_application_table import section_application_table from ...entities.academics.hiring.hiring_level_entity import HiringLevelEntity from ...entities.academics.hiring.hiring_assignment_entity import HiringAssignmentEntity -from ...entities.academics.hiring.hiring_assignment_audit_entity import ( - HiringAssignmentAuditEntity, -) from ..exceptions import CoursePermissionException, ResourceNotFoundException from ...services import PermissionService @@ -44,9 +41,6 @@ from ...models.academics.hiring.phd_application import PhDApplicationReview from ...models.academics.hiring.hiring_assignment import * from ...models.academics.hiring.hiring_level import * -from ...models.academics.hiring.hiring_assignment_audit import ( - HiringAssignmentAuditOverview, -) __authors__ = ["Ajay Gandecha", "Kris Jordan"] __copyright__ = "Copyright 2024" @@ -816,43 +810,6 @@ def update_hiring_assignment( raise ResourceNotFoundException( f"No hiring assignment with ID: {assignment.id}" ) - changes = [] - # Check Status - if assignment_entity.status != assignment.status: - changes.append( - f"Status: {assignment_entity.status.name} -> {assignment.status.name}" - ) - # Check Flagged - if assignment_entity.flagged != assignment.flagged: - changes.append( - f"Flagged: {assignment_entity.flagged} -> {assignment.flagged}" - ) - # Check Position Number - if assignment_entity.position_number != assignment.position_number: - changes.append( - f"Pos Num: {assignment_entity.position_number} -> {assignment.position_number}" - ) - # Check Notes - old_notes = assignment_entity.notes or "" - new_notes = assignment.notes or "" - - if old_notes != new_notes: - changes.append(f"Notes: '{old_notes}' -> '{new_notes}'") - # Check Hiring Level - if assignment_entity.hiring_level_id != assignment.level.id: - changes.append( - f"Level ID: {assignment_entity.hiring_level_id} -> {assignment.level.id}" - ) - # If we detected changes, save the audit row - if changes: - audit_entry = HiringAssignmentAuditEntity( - hiring_assignment_id=assignment_entity.id, - changed_by_user_id=subject.id, - change_timestamp=datetime.now(), - change_details=", ".join(changes), - ) - self._session.add(audit_entry) - # 3. Update the data and commit assert assignment.level.id is not None assignment_entity.hiring_level_id = assignment.level.id @@ -861,7 +818,6 @@ def update_hiring_assignment( assignment_entity.epar = assignment.epar assignment_entity.i9 = assignment.i9 assignment_entity.notes = assignment.notes - assignment_entity.flagged = assignment.flagged assignment_entity.modified = datetime.now() self._session.commit() @@ -930,11 +886,7 @@ def update_hiring_level(self, subject: User, level: HiringLevel) -> HiringLevel: return level_entity.to_model() def get_hiring_summary_overview( - self, - subject: User, - term_id: str, - flagged: HiringAssignmentFlagFilter, - pagination_params: PaginationParams, + self, subject: User, term_id: str, pagination_params: PaginationParams ) -> Paginated[HiringAssignmentSummaryOverview]: """ Returns the hires to show on a summary page for a given term. @@ -942,7 +894,6 @@ def get_hiring_summary_overview( Args: subject: The user making the request term_id: The term to get assignments for - flagged: Filter for flagged assignments ('flagged', 'not_flagged', or 'all') pagination_params: Parameters for pagination and filtering Raises: @@ -979,32 +930,26 @@ def get_hiring_summary_overview( ) base_query = base_query.where(criteria) - # 5. Apply flagged filter if present - if flagged == HiringAssignmentFlagFilter.FLAGGED: - base_query = base_query.where(HiringAssignmentEntity.flagged.is_(True)) - elif flagged == HiringAssignmentFlagFilter.NOT_FLAGGED: - base_query = base_query.where(HiringAssignmentEntity.flagged.is_(False)) - - # 6. Create count query from base query + # 5. Create count query from base query count_query = select(func.count()).select_from(base_query.subquery()) - # 7. Create assignment query with eager loading + # 6. Create assignment query with eager loading assignment_query = base_query.options( joinedload(HiringAssignmentEntity.course_site) .joinedload(CourseSiteEntity.sections) .joinedload(SectionEntity.staff), ) - # 8. Apply pagination + # 7. Apply pagination offset = pagination_params.page * pagination_params.page_size limit = pagination_params.page_size assignment_query = assignment_query.offset(offset).limit(limit) - # 9. Execute queries + # 8. Execute queries length = self._session.scalar(count_query) or 0 assignment_entities = self._session.scalars(assignment_query).unique().all() - # 10. Build and return response + # 9. Build and return response return Paginated( items=[ assignment.to_summary_overview_model() @@ -1366,33 +1311,3 @@ def iter_applicants_for_term_csv(self, subject: User, term_id: str): "preferred_sections": ", ".join(preferred_sections_list), "instructor_selections": instructor_selections_field, } - - def get_audit_history( - self, subject: User, assignment_id: int - ) -> list[HiringAssignmentAuditOverview]: - """ - Retrieves the audit history for a specific hiring assignment. - """ - # 1. Check permissions - self._permission.enforce(subject, "hiring.admin", "*") - - # 2. Query the audit table - query = ( - select(HiringAssignmentAuditEntity) - .where(HiringAssignmentAuditEntity.hiring_assignment_id == assignment_id) - .order_by(HiringAssignmentAuditEntity.change_timestamp.desc()) - .options(joinedload(HiringAssignmentAuditEntity.changed_by_user)) - ) - - audit_entities = self._session.scalars(query).all() - - # 3. Convert to Pydantic models - return [ - HiringAssignmentAuditOverview( - id=audit.id, - change_timestamp=audit.change_timestamp, - change_details=audit.change_details, - changed_by_user=audit.changed_by_user.to_public_model(), - ) - for audit in audit_entities - ] diff --git a/backend/test/services/academics/hiring/hiring_data.py b/backend/test/services/academics/hiring/hiring_data.py index 8feeb28f1..f272da7f6 100644 --- a/backend/test/services/academics/hiring/hiring_data.py +++ b/backend/test/services/academics/hiring/hiring_data.py @@ -252,23 +252,6 @@ notes="Some notes here", created=datetime.now(), modified=datetime.now(), - flagged=False, -) - -hiring_assignment_flagged = HiringAssignmentDraft( - id=1, - user_id=user_data.student.id, - term_id=term_data.current_term.id, - course_site_id=office_hours_data.comp_110_site.id, - level=uta_level, - status=HiringAssignmentStatus.COMMIT, - position_number="sample", - epar="12345", - i9=True, - notes="Some notes here", - created=datetime.now(), - modified=datetime.now(), - flagged=True, ) updated_hiring_assignment = HiringAssignmentDraft( @@ -284,7 +267,6 @@ notes="Some notes here", created=datetime.now(), modified=datetime.now(), - flagged=False, ) new_hiring_assignment = HiringAssignmentDraft( @@ -300,26 +282,9 @@ notes="Some notes here", created=datetime.now(), modified=datetime.now(), - flagged=False, -) - -new_flagged_hiring_assignment = HiringAssignmentDraft( - id=3, - user_id=user_data.instructor.id, - term_id=term_data.current_term.id, - course_site_id=office_hours_data.comp_110_site.id, - level=uta_level, - status=HiringAssignmentStatus.FINAL, - position_number="sample", - epar="12345", - i9=True, - notes="Some notes here", - created=datetime.now(), - modified=datetime.now(), - flagged=True, ) -hiring_assignments = [hiring_assignment, new_flagged_hiring_assignment] +hiring_assignments = [hiring_assignment] def insert_fake_data(session: Session): diff --git a/backend/test/services/academics/hiring/hiring_test.py b/backend/test/services/academics/hiring/hiring_test.py index 80f129da5..60e27be23 100644 --- a/backend/test/services/academics/hiring/hiring_test.py +++ b/backend/test/services/academics/hiring/hiring_test.py @@ -23,14 +23,9 @@ from .....entities.academics.hiring.application_review_entity import ( ApplicationReviewEntity, ) -from .....models.academics.hiring.hiring_assignment_audit import ( - HiringAssignmentAuditOverview, -) - from sqlalchemy import select from sqlalchemy.orm import Session - # Injected Service Fixtures from .fixtures import hiring_svc from ..course_site_test import course_site_svc @@ -44,8 +39,6 @@ from ...office_hours.office_hours_data import fake_data_fixture as insert_order_5 from .hiring_data import fake_data_fixture as insert_order_6 -from backend.models.pagination import PaginationParams - # Test data from ... import user_data @@ -223,15 +216,6 @@ def test_update_hiring_assignment_not_found(hiring_svc: HiringService): pytest.fail() -def test_update_hiring_assigment_flag(hiring_svc: HiringService): - """Ensures that the admin can update the flagged status of a hiring assignment.""" - assignment = hiring_svc.update_hiring_assignment( - user_data.root, hiring_data.hiring_assignment_flagged - ) - assert assignment is not None - assert assignment.flagged is True - - def test_delete_hiring_assignment(hiring_svc: HiringService): """Ensures that the admin can delete hiring assignments.""" hiring_svc.delete_hiring_assignment( @@ -353,115 +337,3 @@ def test_get_course_site_total_enrollment_checks_permission(hiring_svc: HiringSe user_data.ambassador, office_hours_data.comp_110_site.id ) pytest.fail() -def test_get_hiring_summary_overview_all(hiring_svc: HiringService): - """Test that the hiring summary overview returns all assignments.""" - term_id = term_data.current_term.id - pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") - summary = hiring_svc.get_hiring_summary_overview( - user_data.root, term_id, "all", pagination_params - ) - assert summary is not None - assert len(summary.items) > 0 - assert all(assignment.flagged in [True, False] for assignment in summary.items) - - -def test_get_hiring_summary_overview_flagged(hiring_svc: HiringService): - """Test that the hiring summary overview filters for flagged assignments.""" - term_id = term_data.current_term.id - pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") - summary = hiring_svc.get_hiring_summary_overview( - user_data.root, term_id, "flagged", pagination_params - ) - assert summary is not None - assert len(summary.items) > 0 - assert all(assignment.flagged is True for assignment in summary.items) - - -def test_get_hiring_summary_overview_not_flagged(hiring_svc: HiringService): - """Test that the hiring summary overview filters for not flagged assignments.""" - term_id = term_data.current_term.id - pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") - summary = hiring_svc.get_hiring_summary_overview( - user_data.root, term_id, "not_flagged", pagination_params - ) - assert summary is not None - assert len(summary.items) > 0 - assert all(assignment.flagged is False for assignment in summary.items) - - -def test_get_hiring_summary_overview_invalid_flagged(hiring_svc: HiringService): - """Test that an invalid flagged filter returns all flagged/non-flagged assignments.""" - term_id = term_data.current_term.id - pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") - summary = hiring_svc.get_hiring_summary_overview( - user_data.root, term_id, "invalid_flagged", pagination_params - ) - - assert len(summary.items) > 0 - assert all(assignment.flagged in [True, False] for assignment in summary.items) - - -def test_update_hiring_assignment_creates_audit_log(hiring_svc: HiringService): - """Ensures that updating an assignment creates an audit log entry.""" - hiring_svc.update_hiring_assignment( - user_data.root, hiring_data.updated_hiring_assignment - ) - - history = hiring_svc.get_audit_history( - user_data.root, hiring_data.hiring_assignment.id - ) - - assert len(history) == 1 - assert history[0].changed_by_user.id == user_data.root.id - assert "Status: COMMIT -> FINAL" in history[0].change_details - - -def test_update_hiring_assignment_audit_details_notes(hiring_svc: HiringService): - """Ensures notes updates are formatted correctly using the 'Old -> New' format.""" - assignment = hiring_data.hiring_assignment.model_copy() - assignment.notes = "New Notes Value" - - hiring_svc.update_hiring_assignment(user_data.root, assignment) - - history = hiring_svc.get_audit_history(user_data.root, assignment.id) - assert len(history) == 1 - assert "Notes: 'Some notes here' -> 'New Notes Value'" in history[0].change_details - - -def test_update_hiring_assignment_audit_details_flagged(hiring_svc: HiringService): - """Ensures flagged status changes are logged.""" - assignment = hiring_data.hiring_assignment.model_copy() - assignment.flagged = True - - hiring_svc.update_hiring_assignment(user_data.root, assignment) - - history = hiring_svc.get_audit_history(user_data.root, assignment.id) - assert len(history) == 1 - assert "Flagged: False -> True" in history[0].change_details - - -def test_get_audit_history_ordering(hiring_svc: HiringService): - """Ensures audit logs are returned in reverse chronological order (newest first).""" - a1 = hiring_data.hiring_assignment.model_copy() - a1.position_number = "update_1" - hiring_svc.update_hiring_assignment(user_data.root, a1) - - a2 = hiring_data.hiring_assignment.model_copy() - a2.position_number = "update_2" - hiring_svc.update_hiring_assignment(user_data.root, a2) - - history = hiring_svc.get_audit_history( - user_data.root, hiring_data.hiring_assignment.id - ) - - assert len(history) == 2 - assert "update_2" in history[0].change_details - assert "update_1" in history[1].change_details - - -def test_get_audit_history_permissions(hiring_svc: HiringService): - """Ensures that non-admins cannot view audit history.""" - with pytest.raises(UserPermissionException): - hiring_svc.get_audit_history( - user_data.student, hiring_data.hiring_assignment.id - ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 87ab2b405..2745f7dff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -336,21 +336,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@angular-devkit/architect/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/architect/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -406,6 +391,7 @@ "integrity": "sha512-yuC2vN4VL48JhnsaOa9J/o0Jl+cxOklRNQp5J2/ypMuRROaVCrZAPiX+ChSHh++kHYMpj8+ggNrrUwRNfMKACQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@angular-devkit/core": "18.2.21", "jsonc-parser": "3.3.1", @@ -425,6 +411,7 @@ "integrity": "sha512-Lno6GNbJME85wpc/uqn+wamBxvfZJZFYSH8+oAkkyjU/hk8r5+X8DuyqsKAa0m8t46zSTUsonHsQhVe5vgrZeQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "8.17.1", "ajv-formats": "3.0.1", @@ -552,7 +539,6 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.1.tgz", "integrity": "sha512-mexSwaikVE2s+GDhB9fuagEvxbnKHWsqLlO7/R2nY9tTUxBO3drWe3p0D5GxG/EsEyzZU+86ED867q/JmAiVvw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -703,7 +689,6 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.4.tgz", "integrity": "sha512-5UzrN854pnQH+Qw6XZRxx2zWkcOxKrzWPLXe+gHFxFhxWUZfJKGcTJeAj8bnmyb+C3lqBbGpoNQPQ8pFXQGEaQ==", "license": "MIT", - "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -912,21 +897,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@angular/cli/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/cli/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -981,7 +951,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.1.tgz", "integrity": "sha512-7Ru3BO4MOBQRMu9GJS+061cUsevKNsNAMxXnQtcqEaNyntUg2v0XiMdv4I7pQGtkQjFK17bKAxQ97jqxJfqsRQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -998,7 +967,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.1.tgz", "integrity": "sha512-zRYAdAG/hsJegXapKxElLU6Q5in8UG9Pbxyh90k89qsZwkuv+CfxVY5OBS2xjk1azt808++yhjfvbO/Em+HMKg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1012,7 +980,6 @@ "integrity": "sha512-aFfGHi/ApYxmvF4cCS0TypcviQ/Xy+0fwTTrLC8znPC1vObBn0DUA0I6D5dP+xlOTx8PFLkgndNYa2f6RIluvg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.3", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -1075,7 +1042,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.1.tgz", "integrity": "sha512-O03k9ivZ2CvoHXiXGH5WKlWlTtxF2UGMwGXWnV54vGViHwNcvU5Z3h6Ve6mdU9dYMHK9sGljYZnkRpwI3B8mnQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1101,7 +1067,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.1.tgz", "integrity": "sha512-P7cmfK1ldXS8KuPTwwIUTZs5AxhbPNumlumq+nfNJZAxv8/PQJh2W729M/EKHG8rB8cXjoo1K+olExnJNPVDTw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1137,7 +1102,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.1.tgz", "integrity": "sha512-JiQWRvyVZDH0N9p+pnMOuTFGaw7jPakWDQCJBOBBLdE6AyOiy8YPBImRMrjNNIEqg36h1a8H32rBorf2TL3ExA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1250,7 +1214,6 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2498,7 +2461,6 @@ "integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.2.1", "@inquirer/confirm": "^5.1.14", @@ -4793,21 +4755,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@schematics/angular/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@schematics/angular/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -5020,7 +4967,6 @@ "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5078,7 +5024,6 @@ "integrity": "sha512-U7iTAtGgJk6DPX9wIWPPOlt1gO57097G06gIcl0N0EEnNw8RGD62c+2/DiP/zL7KrkqnnqF7gtFGR7YgzPllTA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.1.0", "@typescript-eslint/types": "8.1.0", @@ -5194,7 +5139,6 @@ "integrity": "sha512-ypRueFNKTIFwqPeJBfeIpxZ895PQhNyH4YID6js0UoBImWYoSjBsahUn9KMiJXh94uOjVBgHD9AmkyPsPnFwJA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.1.0", @@ -5287,7 +5231,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5515,7 +5458,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/base64id": { "version": "2.0.0", @@ -5576,6 +5520,7 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -5674,7 +5619,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5709,6 +5653,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -5925,7 +5870,6 @@ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6079,6 +6023,7 @@ "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8" } @@ -6317,6 +6262,7 @@ "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "clone": "^1.0.2" }, @@ -6757,7 +6703,6 @@ "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", @@ -6818,7 +6763,6 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8168,7 +8112,8 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/ignore": { "version": "5.3.2", @@ -8373,6 +8318,7 @@ "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -8410,6 +8356,7 @@ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8552,8 +8499,7 @@ "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.2.0.tgz", "integrity": "sha512-tSAtdrvWybZkQmmaIoDgnvHG8ORUNw5kEVlO5CvrXj02Jjr9TZrmjFq7FUiOUzJiOP2wLGYT6PgrQgQF4R1xiw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/js-tokens": { "version": "4.0.0", @@ -8665,7 +8611,6 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -9007,7 +8952,6 @@ "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -9138,6 +9082,7 @@ "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -9155,6 +9100,7 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -9171,6 +9117,7 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -9188,6 +9135,7 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9413,6 +9361,7 @@ "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } @@ -9590,6 +9539,7 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -10343,6 +10293,7 @@ "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", @@ -10367,6 +10318,7 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10383,6 +10335,7 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10400,6 +10353,7 @@ "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "restore-cursor": "^3.1.0" }, @@ -10413,6 +10367,7 @@ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mimic-fn": "^2.1.0" }, @@ -10429,6 +10384,7 @@ "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" @@ -10442,7 +10398,8 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ora/node_modules/supports-color": { "version": "7.2.0", @@ -10450,6 +10407,7 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10850,7 +10808,6 @@ "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11132,7 +11089,6 @@ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -11520,6 +11476,7 @@ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -11833,7 +11790,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -12290,6 +12246,7 @@ "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">= 8" } @@ -12418,6 +12375,7 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -12737,8 +12695,7 @@ "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tuf-js": { "version": "3.1.0", @@ -12813,7 +12770,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12955,7 +12911,8 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/utils-merge": { "version": "1.0.1", @@ -13004,7 +12961,6 @@ "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -13194,6 +13150,7 @@ "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "defaults": "^1.0.3" } @@ -13468,7 +13425,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13487,8 +13443,7 @@ "version": "0.15.1", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT", - "peer": true + "license": "MIT" } } } diff --git a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css deleted file mode 100644 index 07611dadc..000000000 --- a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css +++ /dev/null @@ -1,71 +0,0 @@ -mat-form-field { - width: 100%; - margin-top: 10px; -} - -/* Center the loading spinner */ -.loading-container { - display: flex; - justify-content: center; - align-items: center; - min-height: 150px; -} - -/* Center the empty state message */ -.empty-state { - text-align: center; - color: var(--text-secondary, #666); - padding: 40px 0; - font-style: italic; -} - -/* The container for the list items */ -.audit-list { - display: flex; - flex-direction: column; - gap: 16px; - padding-top: 8px; -} - -/* Individual Log Entry */ -.audit-item { - display: flex; - flex-direction: column; - gap: 4px; -} - -/* Header: Puts Name on left, Date on right */ -.audit-header { - display: flex; - justify-content: space-between; - align-items: baseline; - margin-bottom: 4px; -} - -/* Name Style */ -.audit-user { - font-weight: 600; /* Semibold equivalent */ - font-size: 1rem; -} - -/* Date Badge Style */ -.audit-time { - font-size: 0.85rem; - color: var(--text-secondary, #666); - background-color: #f5f5f5; /* Light gray background */ - padding: 2px 8px; - border-radius: 12px; -} - -/* The actual text of what changed */ -.audit-detail { - margin: 0; - color: var(--text-primary, #333); - line-height: 1.4; - white-space: pre-wrap; /* Keeps formatting if you use newlines later */ -} - -/* Fix the divider spacing */ -mat-divider { - margin-top: 12px; -} \ No newline at end of file diff --git a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.html b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.html deleted file mode 100644 index e80e5278b..000000000 --- a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.html +++ /dev/null @@ -1,40 +0,0 @@ -

Audit Log: {{ data.applicantName }}

- - - @if(!(history$ | async)) { -
- -
- } - - @else { - @let history = (history$ | async) ?? []; - - @if(history.length === 0) { -
-

No changes recorded.

-
- } @else { -
- @for(log of history; track log.id) { -
-
- - {{ log.changed_by_user.first_name }} {{ log.changed_by_user.last_name }} - - - {{ log.change_timestamp | date:'medium' }} - -
-

{{ log.change_details }}

- -
- } -
- } - } -
- - - - \ No newline at end of file diff --git a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.ts b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.ts deleted file mode 100644 index c4dd9af9c..000000000 --- a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @author Christian Lee - * @copyright 2025 - * @license MIT - */ - -import { Component, Inject, OnInit } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { HiringService } from '../../hiring.service'; -import { HiringAssignmentAuditOverview } from '../../hiring.models'; -import { Observable } from 'rxjs'; - -export interface AuditLogDialogData { - assignmentId: number; - applicantName: string; -} - -@Component({ - selector: 'app-audit-log-dialog', - templateUrl: './audit-log-dialog.dialog.html', - styleUrls: ['./audit-log-dialog.dialog.css'], - standalone: false -}) -export class AuditLogDialog implements OnInit { - history$: Observable | undefined; - - constructor( - protected dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: AuditLogDialogData, - private hiringService: HiringService - ) {} - - ngOnInit() { - this.history$ = this.hiringService.getAuditHistory(this.data.assignmentId); - } -} diff --git a/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts index e008807ec..824bd1113 100644 --- a/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts +++ b/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts @@ -81,7 +81,6 @@ export class CreateAssignmentDialog { epar: this.createAssignmentForm.get('epar')!.value ?? '', i9: this.createAssignmentForm.get('i9')!.value ?? false, notes: this.createAssignmentForm.get('notes')!.value ?? '', - flagged: false, created: new Date(), modified: new Date() }; diff --git a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts index 413d88260..3ad25ae9a 100644 --- a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts +++ b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts @@ -97,7 +97,6 @@ export class EditAssignmentDialog { epar: this.editAssignmentForm.get('epar')!.value ?? '', i9: this.editAssignmentForm.get('i9')!.value ?? false, notes: this.editAssignmentForm.get('notes')!.value ?? '', - flagged: false, created: new Date(), // Will be overwritten anyway modified: new Date() }; diff --git a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts index 2aa33b736..ef7f47404 100644 --- a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts +++ b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts @@ -154,7 +154,6 @@ export class QuickCreateAssignmentDialog { epar: this.createAssignmentForm.get('epar')!.value ?? '', i9: this.createAssignmentForm.get('i9')!.value ?? false, notes: this.createAssignmentForm.get('notes')!.value ?? '', - flagged: false, created: new Date(), modified: new Date() }; diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css index 988dbbdbd..6f542b9c9 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css @@ -14,9 +14,6 @@ mat-card-header { .header-actions { margin-left: auto; - display: flex; - align-items: center; - gap: 0.5em; } .term-selector { @@ -44,30 +41,4 @@ mat-form-field.pos_number { max-width: 100% !important; margin-right: 32px !important; } -} - -.flag-icon-btn { - transition: color 0.15s ease; -} - -.flag-selected { - font-variation-settings: - 'FILL' 1; -} - -/* Add this new style for the cell */ -.clickable-cell { - cursor: pointer; - font-weight: 500; - color: var(--primary-color, #007bb2); /* Optional: make it look like a link */ -} - -.clickable-cell:hover { - text-decoration: underline; -} - -.pid-text { - font-weight: normal; - color: black; - font-size: 0.9em; } \ No newline at end of file diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html index a8d48868c..c9fe1bf9b 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html @@ -8,16 +8,7 @@ {{ selectedTerm().name }} Onboarding
- - All - Flagged - Not Flagged - - - + Select Term
- -
+
Flagged
+
+ +
Name
- - -

{{ element.user.first_name }} {{ element.user.last_name }}
{{ element.user.pid }}

diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts index 4e5900b96..88462a786 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts @@ -81,6 +81,7 @@ export class HiringSummaryComponent { DEFAULT_PAGINATION_PARAMS; public displayedColumns: string[] = [ + 'flagged', 'name', 'course', 'level', From e5c4c68273ad054beb88d240cea0c121772c35c0 Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Wed, 19 Nov 2025 16:04:39 -0500 Subject: [PATCH 19/36] added extra flagged assignment --- .../services/academics/hiring/hiring_data.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/test/services/academics/hiring/hiring_data.py b/backend/test/services/academics/hiring/hiring_data.py index 71b6e9b50..d1161fa54 100644 --- a/backend/test/services/academics/hiring/hiring_data.py +++ b/backend/test/services/academics/hiring/hiring_data.py @@ -255,6 +255,22 @@ flagged=False, ) +hiring_assignment_flagged = HiringAssignmentDraft( + id=2, + user_id=user_data.student.id, + term_id=term_data.current_term.id, + course_site_id=office_hours_data.comp_110_site.id, + level=uta_level, + status=HiringAssignmentStatus.COMMIT, + position_number="sample", + epar="12345", + i9=True, + notes="Some notes here", + created=datetime.now(), + modified=datetime.now(), + flagged=True, +) + updated_hiring_assignment = HiringAssignmentDraft( id=1, user_id=user_data.student.id, @@ -287,7 +303,7 @@ flagged=False, ) -hiring_assignments = [hiring_assignment] +hiring_assignments = [hiring_assignment, hiring_assignment_flagged] def insert_fake_data(session: Session): From af0015f91c88e8817edfaaf32806296ed6077e9c Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Wed, 19 Nov 2025 17:58:09 -0500 Subject: [PATCH 20/36] fixed styling and ordering bug --- .../hiring-summary.component.html | 88 ++++++++----------- .../hiring-summary.component.ts | 14 ++- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html index 2659c524a..b3aa3bfa1 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html @@ -23,33 +23,27 @@
- + +
+ - - + - + - - + + - + + - - + + - - + + - - + + - - + +
-
Flagged
-
+
Flagged
-
Name
-
+
Name

{{ element.user.first_name }} {{ element.user.last_name }}
{{ element.user.pid }} @@ -57,6 +51,7 @@

Course
@@ -68,6 +63,7 @@ +
Hiring Level
@@ -82,79 +78,73 @@ + -
-
Epar
-
+
Epar
-
Pos #
-
+
Pos #
-
Notes
-
+
Notes
+ [(ngModel)]="element.notes" + (change)="updateAssignment(element)">
-
I9?
-
+
I9?
+ [(ngModel)]="element.i9" + (change)="updateAssignment(element)" /> -
Status
-
+
Status
+ [(ngModel)]="element.status" + (change)="updateAssignment(element)"> Commit Final
@@ -165,4 +155,4 @@ (page)="handlePageEvent($event)">
- + \ No newline at end of file diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts index 88462a786..23bbc91d9 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts @@ -149,8 +149,7 @@ export class HiringSummaryComponent { } /** Save changes */ - updateAssignment(assignmentIndex: number) { - let assignment = this.assignmentsPage()!.items[assignmentIndex]!; + updateAssignment(assignment: HiringAssignmentSummaryOverview) { let draft: HiringAssignmentDraft = { id: assignment.id, user_id: assignment.user.id, @@ -170,6 +169,17 @@ export class HiringSummaryComponent { this.hiringService.updateHiringAssignment(draft).subscribe((_) => {}); } + /** Gets ordered hiring assignments */ + getOrderedAssignments(): HiringAssignmentSummaryOverview[] { + const assignments = this.assignmentsPage()?.items ?? []; + if (assignments.length === 0) { + return []; + } + let sorted_assn = [...assignments].sort((a, b) => Number(b.flagged) - Number(a.flagged)); + console.log(sorted_assn); + return sorted_assn; + } + /** Export CSV button pressed */ exportCsv() { this.hiringService.downloadHiringSummaryCsv(this.selectedTerm()!.id); From ad28cc397dd87cc661bd65266fab970b6281031a Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Thu, 20 Nov 2025 15:55:36 -0500 Subject: [PATCH 21/36] added flagging filter --- .../hiring-summary/hiring-summary.component.css | 3 +++ .../hiring-summary.component.html | 9 +++++++++ .../hiring-summary/hiring-summary.component.ts | 17 ++++++++++++++--- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css index 278a6cda0..b0852fcff 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css @@ -14,6 +14,9 @@ mat-card-header { .header-actions { margin-left: auto; + display: flex; + align-items: center; + gap: 0.5em; } .term-selector { diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html index b3aa3bfa1..bb2c6cb51 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html @@ -8,6 +8,15 @@ {{ selectedTerm().name }} Onboarding
+ + All + Flagged + Not Flagged + + Select Term diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts index 23bbc91d9..2a76d026a 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts @@ -110,6 +110,9 @@ export class HiringSummaryComponent { }); }); + /** Current filter mode for applicants*/ + public filterMode: WritableSignal<'all' | 'flagged' | 'not_flagged'> = signal('all'); + /** Constructor */ constructor( private route: ActivatedRoute, @@ -175,9 +178,17 @@ export class HiringSummaryComponent { if (assignments.length === 0) { return []; } - let sorted_assn = [...assignments].sort((a, b) => Number(b.flagged) - Number(a.flagged)); - console.log(sorted_assn); - return sorted_assn; + + let filtered = assignments; + const mode = this.filterMode(); + if (mode === 'flagged') { + filtered = assignments.filter((a) => a.flagged); + } else if (mode === 'not_flagged') { + filtered = assignments.filter((a) => !a.flagged); + } + + // Return a sorted copy with flagged items first + return [...filtered].sort((a, b) => Number(b.flagged) - Number(a.flagged)); } /** Export CSV button pressed */ From 63a15ea166802dc53365f93cb190b108c0b90242 Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Mon, 24 Nov 2025 16:07:03 -0500 Subject: [PATCH 22/36] added test for flag switch --- backend/test/services/academics/hiring/hiring_data.py | 4 ++-- backend/test/services/academics/hiring/hiring_test.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/test/services/academics/hiring/hiring_data.py b/backend/test/services/academics/hiring/hiring_data.py index d1161fa54..dfb395f67 100644 --- a/backend/test/services/academics/hiring/hiring_data.py +++ b/backend/test/services/academics/hiring/hiring_data.py @@ -256,7 +256,7 @@ ) hiring_assignment_flagged = HiringAssignmentDraft( - id=2, + id=1, user_id=user_data.student.id, term_id=term_data.current_term.id, course_site_id=office_hours_data.comp_110_site.id, @@ -303,7 +303,7 @@ flagged=False, ) -hiring_assignments = [hiring_assignment, hiring_assignment_flagged] +hiring_assignments = [hiring_assignment] def insert_fake_data(session: Session): diff --git a/backend/test/services/academics/hiring/hiring_test.py b/backend/test/services/academics/hiring/hiring_test.py index ff8f45e4a..1480b3700 100644 --- a/backend/test/services/academics/hiring/hiring_test.py +++ b/backend/test/services/academics/hiring/hiring_test.py @@ -207,6 +207,14 @@ def test_update_hiring_assignment_not_found(hiring_svc: HiringService): ) pytest.fail() +def test_update_hiring_assigment_flag(hiring_svc: HiringService): + """Ensures that the admin can update the flagged status of a hiring assignment.""" + assignment = hiring_svc.update_hiring_assignment( + user_data.root, hiring_data.hiring_assignment_flagged + ) + assert assignment is not None + assert assignment.flagged is True + def test_delete_hiring_assignment(hiring_svc: HiringService): """Ensures that the admin can delete hiring assignments.""" From 603c45afa07970fd8d65846f20d701694a7a7245 Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Mon, 24 Nov 2025 16:15:07 -0500 Subject: [PATCH 23/36] added migration file --- ...d03df5_migration_for_applicant_flagging.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 backend/migrations/versions/0a57afd03df5_migration_for_applicant_flagging.py diff --git a/backend/migrations/versions/0a57afd03df5_migration_for_applicant_flagging.py b/backend/migrations/versions/0a57afd03df5_migration_for_applicant_flagging.py new file mode 100644 index 000000000..63a5687df --- /dev/null +++ b/backend/migrations/versions/0a57afd03df5_migration_for_applicant_flagging.py @@ -0,0 +1,28 @@ +"""Migration for applicant-flagging + +Revision ID: 0a57afd03df5 +Revises: a9f09b49d862 +Create Date: 2025-11-24 16:09:49.309576 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0a57afd03df5' +down_revision = 'a9f09b49d862' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('academics__hiring__assignment', sa.Column('flagged', sa.Boolean(), nullable=False, default=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('academics__hiring__assignment', 'flagged') + # ### end Alembic commands ### From 9c4c7ba17e6f60080d0c4f6122a6c2bfd340e194 Mon Sep 17 00:00:00 2001 From: Christian Lee Date: Mon, 24 Nov 2025 16:26:02 -0500 Subject: [PATCH 24/36] Added Audit Log to Updated Changes --- backend/entities/__init__.py | 7 ++-- .../hiring/hiring_assignment_audit_entity.py | 30 +++++++++++++++ .../hiring/hiring_assignment_audit.py | 18 +++++++++ backend/services/academics/hiring.py | 37 +++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 backend/entities/academics/hiring/hiring_assignment_audit_entity.py create mode 100644 backend/models/academics/hiring/hiring_assignment_audit.py diff --git a/backend/entities/__init__.py b/backend/entities/__init__.py index 5ab2a911d..78c5a381a 100644 --- a/backend/entities/__init__.py +++ b/backend/entities/__init__.py @@ -5,11 +5,11 @@ 1. Loads all entities into the application derived from `entities.EntityBase`. In doing so, many of SQLAlchemy's features around metadata (creation/updating/dropping of tables) are possible by virtue of importing from this file directly. - + 2. An index module of all entities which makes importing entities easier. Rather than importing from the modules directly, you can import them from the entities `package`, e.g. `from entities import UserEntity`. - -When adding a new entity to the application be sure to import it here. As a reminder, all identifiers + +When adding a new entity to the application be sure to import it here. As a reminder, all identifiers global to a module are available for import from other modules.""" from .entity_base import EntityBase @@ -33,6 +33,7 @@ from .article_author_entity import article_author_table from .academics.hiring.hiring_assignment_entity import HiringAssignmentEntity +from .academics.hiring.hiring_assignment_audit_entity import HiringAssignmentAuditEntity from .academics.hiring.hiring_level_entity import HiringLevelEntity __authors__ = ["Kris Jordan"] diff --git a/backend/entities/academics/hiring/hiring_assignment_audit_entity.py b/backend/entities/academics/hiring/hiring_assignment_audit_entity.py new file mode 100644 index 000000000..0b9affaf1 --- /dev/null +++ b/backend/entities/academics/hiring/hiring_assignment_audit_entity.py @@ -0,0 +1,30 @@ +from sqlalchemy import Integer, String, ForeignKey, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from ...entity_base import EntityBase + +__authors__ = ["Christian Lee"] +__copyright__ = "Copyright 2025" +__license__ = "MIT" + + +class HiringAssignmentAuditEntity(EntityBase): + """Schema for the `academics__hiring__assignment_audit` table.""" + + __tablename__ = "academics__hiring__assignment_audit" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # The assignment being modified + hiring_assignment_id: Mapped[int] = mapped_column( + ForeignKey("academics__hiring__assignment.id") + ) + + # Who made the change + changed_by_user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + + # When it happened + change_timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + # What changed (e.g. "Status: Draft -> Commit") + change_details: Mapped[str] = mapped_column(String) diff --git a/backend/models/academics/hiring/hiring_assignment_audit.py b/backend/models/academics/hiring/hiring_assignment_audit.py new file mode 100644 index 000000000..7e3ef19a9 --- /dev/null +++ b/backend/models/academics/hiring/hiring_assignment_audit.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from datetime import datetime + +__authors__ = ["Christian Lee"] +__copyright__ = "Copyright 2025" +__license__ = "MIT" + + +class HiringAssignmentAudit(BaseModel): + """ + Pydantic model to represent a snapshot of changes to a Hiring Assignment. + """ + + id: int | None = None + hiring_assignment_id: int + changed_by_user_id: int + change_timestamp: datetime + change_details: str diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index 71e29cba1..e120d33ee 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -28,6 +28,9 @@ from ...entities.section_application_table import section_application_table from ...entities.academics.hiring.hiring_level_entity import HiringLevelEntity from ...entities.academics.hiring.hiring_assignment_entity import HiringAssignmentEntity +from ...entities.academics.hiring.hiring_assignment_audit_entity import ( + HiringAssignmentAuditEntity, +) from ..exceptions import CoursePermissionException, ResourceNotFoundException from ...services import PermissionService @@ -731,6 +734,40 @@ def update_hiring_assignment( raise ResourceNotFoundException( f"No hiring assignment with ID: {assignment.id}" ) + changes = [] + # Check Status + if assignment_entity.status != assignment.status: + changes.append( + f"Status: {assignment_entity.status.name} -> {assignment.status.name}" + ) + # Check Flagged + if assignment_entity.flagged != assignment.flagged: + changes.append( + f"Flagged: {assignment_entity.flagged} -> {assignment.flagged}" + ) + # Check Position Number + if assignment_entity.position_number != assignment.position_number: + changes.append( + f"Pos Num: {assignment_entity.position_number} -> {assignment.position_number}" + ) + # Check Notes (Only log that notes changed, not the full text, to keep logs clean) + if assignment_entity.notes != assignment.notes: + changes.append("Updated Notes") + # Check Hiring Level + if assignment_entity.hiring_level_id != assignment.level.id: + changes.append( + f"Level ID: {assignment_entity.hiring_level_id} -> {assignment.level.id}" + ) + # If we detected changes, save the audit row + if changes: + audit_entry = HiringAssignmentAuditEntity( + hiring_assignment_id=assignment_entity.id, + changed_by_user_id=subject.id, + change_timestamp=datetime.now(), + change_details=", ".join(changes), + ) + self._session.add(audit_entry) + # 3. Update the data and commit assert assignment.level.id is not None assignment_entity.hiring_level_id = assignment.level.id From cf082f3087675fbb71f61ea26655e67e8d4bb66d Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Mon, 24 Nov 2025 17:36:22 -0500 Subject: [PATCH 25/36] added paginator separator check and backend flagged filtering --- backend/api/academics/hiring.py | 3 +- backend/services/academics/hiring.py | 23 +++-- .../hiring-summary.component.ts | 87 ++++++++++--------- frontend/src/app/pagination.ts | 4 +- 4 files changed, 70 insertions(+), 47 deletions(-) diff --git a/backend/api/academics/hiring.py b/backend/api/academics/hiring.py index 6f60a24d5..52d2edeb1 100644 --- a/backend/api/academics/hiring.py +++ b/backend/api/academics/hiring.py @@ -170,6 +170,7 @@ def get_hiring_summary_overview( page_size: int = 100, order_by: str = "", filter: str = "", + flagged: str = "all", subject: User = Depends(registered_user), hiring_service: HiringService = Depends(), ) -> Paginated[HiringAssignmentSummaryOverview]: @@ -180,7 +181,7 @@ def get_hiring_summary_overview( page=page, page_size=page_size, order_by=order_by, filter=filter ) return hiring_service.get_hiring_summary_overview( - subject, term_id, pagination_params + subject, term_id, flagged, pagination_params ) diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index 71e29cba1..f9c7153e4 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -808,7 +808,11 @@ def update_hiring_level(self, subject: User, level: HiringLevel) -> HiringLevel: return level_entity.to_model() def get_hiring_summary_overview( - self, subject: User, term_id: str, pagination_params: PaginationParams + self, + subject: User, + term_id: str, + flagged: str, + pagination_params: PaginationParams, ) -> Paginated[HiringAssignmentSummaryOverview]: """ Returns the hires to show on a summary page for a given term. @@ -816,6 +820,7 @@ def get_hiring_summary_overview( Args: subject: The user making the request term_id: The term to get assignments for + flagged: Filter for flagged assignments ('flagged', 'not_flagged', or 'all') pagination_params: Parameters for pagination and filtering Raises: @@ -852,26 +857,32 @@ def get_hiring_summary_overview( ) base_query = base_query.where(criteria) - # 5. Create count query from base query + # 5. Apply flagged filter if present + if flagged == "flagged": + base_query = base_query.where(HiringAssignmentEntity.flagged.is_(True)) + elif flagged == "not_flagged": + base_query = base_query.where(HiringAssignmentEntity.flagged.is_(False)) + + # 6. Create count query from base query count_query = select(func.count()).select_from(base_query.subquery()) - # 6. Create assignment query with eager loading + # 7. Create assignment query with eager loading assignment_query = base_query.options( joinedload(HiringAssignmentEntity.course_site) .joinedload(CourseSiteEntity.sections) .joinedload(SectionEntity.staff), ) - # 7. Apply pagination + # 8. Apply pagination offset = pagination_params.page * pagination_params.page_size limit = pagination_params.page_size assignment_query = assignment_query.offset(offset).limit(limit) - # 8. Execute queries + # 9. Execute queries length = self._session.scalar(count_query) or 0 assignment_entities = self._session.scalars(assignment_query).unique().all() - # 9. Build and return response + # 10. Build and return response return Paginated( items=[ assignment.to_summary_overview_model() diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts index 2a76d026a..3c137a4a2 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts @@ -53,22 +53,29 @@ export class HiringSummaryComponent { return this.terms.find((term) => term.id === this.selectedTermId())!; }); + /** Current filter mode for applicants*/ + public filterMode: WritableSignal<'all' | 'flagged' | 'not_flagged'> = signal('all'); + /** Effect that updates the hiring data when the selected term changes. */ selectedTermEffect = effect(() => { - if (this.selectedTermId()) { - const term = this.terms.find( - (term) => term.id === this.selectedTermId() - )!; - // Load paginated data - this.assignmentsPaginator.changeApiRoute( - `/api/hiring/summary/${term.id}` - ); - - this.assignmentsPaginator - .loadPage(this.previousPaginationParams) - .subscribe((page) => { - this.assignmentsPage.set(page); - }); + const termId = this.selectedTermId(); + // We check for termId existence to avoid running this before data is ready + if (termId) { + // Pass the current filter mode to the URL construction + this.updatePaginatorUrl(termId, this.filterMode()); + this.refreshData(); + } + }); + + /** Effect that updates the hiring data when the filter mode changes. */ + filterModeEffect = effect(() => { + const mode = this.filterMode(); + const termId = this.selectedTermId(); + + // Only update if we have a term selected + if (termId) { + this.updatePaginatorUrl(termId, mode); + this.refreshData(); } }); @@ -104,15 +111,9 @@ export class HiringSummaryComponent { paginationParams.filter = this.searchBarQuery(); // Refresh the data - this.assignmentsPaginator.loadPage(paginationParams).subscribe((page) => { - this.assignmentsPage.set(page); - this.previousPaginationParams = paginationParams; - }); + this.refreshData(paginationParams); }); - /** Current filter mode for applicants*/ - public filterMode: WritableSignal<'all' | 'flagged' | 'not_flagged'> = signal('all'); - /** Constructor */ constructor( private route: ActivatedRoute, @@ -126,17 +127,37 @@ export class HiringSummaryComponent { }; this.terms = data.terms; - this.selectedTermId.set(data.currentTerm?.id ?? undefined); + const termId = data.currentTerm?.id; + this.selectedTermId.set(termId); // Load paginated data + const initialUrl = termId + ? `/api/hiring/summary/${termId}?flagged=${this.filterMode()}` + : ''; + this.assignmentsPaginator = new Paginator( - `/api/hiring/summary/${data.currentTerm!.id}` + initialUrl + ); + + if (termId) { + this.refreshData(); + } + } + + /** Helper to update the paginator API URL with term and flag params */ + private updatePaginatorUrl(termId: string, flagMode: string) { + this.assignmentsPaginator.changeApiRoute( + `/api/hiring/summary/${termId}?flagged=${flagMode}` ); + } + /** Helper to trigger the loadPage logic */ + private refreshData(params: PaginationParams = this.previousPaginationParams) { this.assignmentsPaginator - .loadPage(this.previousPaginationParams) + .loadPage(params) .subscribe((page) => { this.assignmentsPage.set(page); + this.previousPaginationParams = params; }); } @@ -145,10 +166,7 @@ export class HiringSummaryComponent { let paginationParams = this.assignmentsPage()!.params; paginationParams.page = e.pageIndex; paginationParams.page_size = e.pageSize; - this.assignmentsPaginator.loadPage(paginationParams).subscribe((page) => { - this.assignmentsPage.set(page); - this.previousPaginationParams = paginationParams; - }); + this.refreshData(paginationParams); } /** Save changes */ @@ -179,20 +197,11 @@ export class HiringSummaryComponent { return []; } - let filtered = assignments; - const mode = this.filterMode(); - if (mode === 'flagged') { - filtered = assignments.filter((a) => a.flagged); - } else if (mode === 'not_flagged') { - filtered = assignments.filter((a) => !a.flagged); - } - - // Return a sorted copy with flagged items first - return [...filtered].sort((a, b) => Number(b.flagged) - Number(a.flagged)); + return [...assignments].sort((a, b) => Number(b.flagged) - Number(a.flagged)); } /** Export CSV button pressed */ exportCsv() { this.hiringService.downloadHiringSummaryCsv(this.selectedTerm()!.id); } -} +} \ No newline at end of file diff --git a/frontend/src/app/pagination.ts b/frontend/src/app/pagination.ts index 61d888f56..6e0753ce9 100644 --- a/frontend/src/app/pagination.ts +++ b/frontend/src/app/pagination.ts @@ -123,9 +123,11 @@ abstract class PaginatorAbstraction { // Stpres the previous pagination parameters used this.previousParams = paramStrings; + const separator = this.api.includes('?') ? '&' : '?'; + // Determines the query for the URL based on the new paramateres. let query = new URLSearchParams(paramStrings); - let route = this.api + '?' + query.toString(); + let route = this.api + separator + query.toString(); // Determine if an operator function is necessary if (operator) { From 1507a16fbd3abfac4aff546075e574dcdd8f9580 Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Mon, 24 Nov 2025 17:46:32 -0500 Subject: [PATCH 26/36] added tests for getting hiring summary --- backend/api/academics/hiring.py | 2 +- .../services/academics/hiring/hiring_data.py | 18 ++++++- .../services/academics/hiring/hiring_test.py | 54 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/backend/api/academics/hiring.py b/backend/api/academics/hiring.py index 52d2edeb1..e67ef0d03 100644 --- a/backend/api/academics/hiring.py +++ b/backend/api/academics/hiring.py @@ -180,7 +180,7 @@ def get_hiring_summary_overview( pagination_params = PaginationParams( page=page, page_size=page_size, order_by=order_by, filter=filter ) - return hiring_service.get_hiring_summary_overview( + return hiring_service.erview( subject, term_id, flagged, pagination_params ) diff --git a/backend/test/services/academics/hiring/hiring_data.py b/backend/test/services/academics/hiring/hiring_data.py index dfb395f67..8feeb28f1 100644 --- a/backend/test/services/academics/hiring/hiring_data.py +++ b/backend/test/services/academics/hiring/hiring_data.py @@ -303,7 +303,23 @@ flagged=False, ) -hiring_assignments = [hiring_assignment] +new_flagged_hiring_assignment = HiringAssignmentDraft( + id=3, + user_id=user_data.instructor.id, + term_id=term_data.current_term.id, + course_site_id=office_hours_data.comp_110_site.id, + level=uta_level, + status=HiringAssignmentStatus.FINAL, + position_number="sample", + epar="12345", + i9=True, + notes="Some notes here", + created=datetime.now(), + modified=datetime.now(), + flagged=True, +) + +hiring_assignments = [hiring_assignment, new_flagged_hiring_assignment] def insert_fake_data(session: Session): diff --git a/backend/test/services/academics/hiring/hiring_test.py b/backend/test/services/academics/hiring/hiring_test.py index 1480b3700..47ac5ce5c 100644 --- a/backend/test/services/academics/hiring/hiring_test.py +++ b/backend/test/services/academics/hiring/hiring_test.py @@ -33,6 +33,8 @@ from ...office_hours.office_hours_data import fake_data_fixture as insert_order_5 from .hiring_data import fake_data_fixture as insert_order_6 +from backend.models.pagination import PaginationParams + # Test data from ... import user_data @@ -311,3 +313,55 @@ 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_hiring_summary_overview_all(hiring_svc: HiringService): + """Test that the hiring summary overview returns all assignments.""" + term_id = term_data.current_term.id + pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") + summary = hiring_svc.get_hiring_summary_overview( + user_data.root, term_id, "all", pagination_params + ) + assert summary is not None + assert len(summary.items) > 0 + assert all( + assignment.flagged in [True, False] for assignment in summary.items + ) + + +def test_get_hiring_summary_overview_flagged(hiring_svc: HiringService): + """Test that the hiring summary overview filters for flagged assignments.""" + term_id = term_data.current_term.id + pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") + summary = hiring_svc.get_hiring_summary_overview( + user_data.root, term_id, "flagged", pagination_params + ) + assert summary is not None + assert len(summary.items) > 0 + assert all(assignment.flagged is True for assignment in summary.items) + + +def test_get_hiring_summary_overview_not_flagged(hiring_svc: HiringService): + """Test that the hiring summary overview filters for not flagged assignments.""" + term_id = term_data.current_term.id + pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") + summary = hiring_svc.get_hiring_summary_overview( + user_data.root, term_id, "not_flagged", pagination_params + ) + assert summary is not None + assert len(summary.items) > 0 + assert all(assignment.flagged is False for assignment in summary.items) + + +def test_get_hiring_summary_overview_invalid_flagged(hiring_svc: HiringService): + """Test that an invalid flagged filter returns all flagged/non-flagged assignments.""" + term_id = term_data.current_term.id + pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") + summary = hiring_svc.get_hiring_summary_overview( + user_data.root, term_id, "invalid_flagged", pagination_params + ) + + assert len(summary.items) > 0 + assert all(assignment.flagged in [True, False] for assignment in summary.items) + + From c44f28b8f9347d184b408edb6b3e0fedf333ef92 Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Wed, 26 Nov 2025 13:53:17 -0500 Subject: [PATCH 27/36] fixed service call --- backend/api/academics/hiring.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/academics/hiring.py b/backend/api/academics/hiring.py index e67ef0d03..52d2edeb1 100644 --- a/backend/api/academics/hiring.py +++ b/backend/api/academics/hiring.py @@ -180,7 +180,7 @@ def get_hiring_summary_overview( pagination_params = PaginationParams( page=page, page_size=page_size, order_by=order_by, filter=filter ) - return hiring_service.erview( + return hiring_service.get_hiring_summary_overview( subject, term_id, flagged, pagination_params ) From 92e4aaf80484ffafb08b367cd45f3abd690aacea Mon Sep 17 00:00:00 2001 From: Ajay Gandecha Date: Sun, 30 Nov 2025 21:44:20 -0500 Subject: [PATCH 28/36] chore: add optimistic update rollback on error, modify styling This commit ensures that optimistic updating on flagging is rolled back upon an error. In addition, hard-coded styles are removed and replaced with material colors. --- .../hiring-summary.component.css | 28 +------ .../hiring-summary.component.html | 21 +++-- .../hiring-summary.component.ts | 77 ++++++++++++++----- 3 files changed, 68 insertions(+), 58 deletions(-) diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css index b0852fcff..3386d22ac 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css @@ -46,35 +46,11 @@ mat-form-field.pos_number { } } -.material-symbols-outlined { - font-variation-settings: - 'FILL' 0, - 'wght' 500, - 'GRAD' 0, - 'opsz' 24; - transition: font-variation-settings 0.15s ease, color 0.15s ease; -} - .flag-icon-btn { transition: color 0.15s ease; } -.flag-icon-btn .mat-icon { - color: #ffffff; -} - -.flag-icon-btn.flagged .mat-icon { - color: #cfbcff !important; -} - -.flag-icon-btn.flagged .material-symbols-outlined { +.flag-selected { font-variation-settings: - 'FILL' 1, - 'wght' 500, - 'GRAD' 0, - 'opsz' 24; -} - -.flag-icon-btn:hover .mat-icon { - color: #9a83db !important; + 'FILL' 1; } diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html index bb2c6cb51..1e29dc8fa 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html @@ -17,7 +17,7 @@ Not Flagged - + Select Term
-

{{ element.course }}
+

{{ element.course }}
{{ element.instructors }}

-

+

${{ element.level.salary.toFixed(2) }} @@ -96,7 +95,7 @@

@@ -111,7 +110,7 @@ @@ -127,7 +126,7 @@ rows="1" matInput [(ngModel)]="element.notes" - (change)="updateAssignment(element)"> + (change)="updateAssignment({ assignment: element })">
+ (change)="updateAssignment({ assignment: element })" /> + (change)="updateAssignment({ assignment: element })"> Commit Final diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts index 3c137a4a2..288bfeb72 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts @@ -19,6 +19,7 @@ import { HiringAssignmentSummaryOverview } from '../hiring.models'; import { PageEvent } from '@angular/material/paginator'; +import { MatSnackBar } from '@angular/material/snack-bar'; const DEFAULT_PAGINATION_PARAMS = { page: 0, @@ -28,10 +29,10 @@ const DEFAULT_PAGINATION_PARAMS = { } as PaginationParams; @Component({ - selector: 'app-hiring-summary', - templateUrl: './hiring-summary.component.html', - styleUrl: './hiring-summary.component.css', - standalone: false + selector: 'app-hiring-summary', + templateUrl: './hiring-summary.component.html', + styleUrl: './hiring-summary.component.css', + standalone: false }) export class HiringSummaryComponent { /** Route for the routing module */ @@ -54,7 +55,8 @@ export class HiringSummaryComponent { }); /** Current filter mode for applicants*/ - public filterMode: WritableSignal<'all' | 'flagged' | 'not_flagged'> = signal('all'); + public filterMode: WritableSignal<'all' | 'flagged' | 'not_flagged'> = + signal('all'); /** Effect that updates the hiring data when the selected term changes. */ selectedTermEffect = effect(() => { @@ -71,7 +73,7 @@ export class HiringSummaryComponent { filterModeEffect = effect(() => { const mode = this.filterMode(); const termId = this.selectedTermId(); - + // Only update if we have a term selected if (termId) { this.updatePaginatorUrl(termId, mode); @@ -117,6 +119,7 @@ export class HiringSummaryComponent { /** Constructor */ constructor( private route: ActivatedRoute, + private snackbar: MatSnackBar, protected hiringService: HiringService, protected academicsService: AcademicsService ) { @@ -131,8 +134,8 @@ export class HiringSummaryComponent { this.selectedTermId.set(termId); // Load paginated data - const initialUrl = termId - ? `/api/hiring/summary/${termId}?flagged=${this.filterMode()}` + const initialUrl = termId + ? `/api/hiring/summary/${termId}?flagged=${this.filterMode()}` : ''; this.assignmentsPaginator = new Paginator( @@ -147,18 +150,18 @@ export class HiringSummaryComponent { /** Helper to update the paginator API URL with term and flag params */ private updatePaginatorUrl(termId: string, flagMode: string) { this.assignmentsPaginator.changeApiRoute( - `/api/hiring/summary/${termId}?flagged=${flagMode}` + `/api/hiring/summary/${termId}?flagged=${flagMode}` ); } /** Helper to trigger the loadPage logic */ - private refreshData(params: PaginationParams = this.previousPaginationParams) { - this.assignmentsPaginator - .loadPage(params) - .subscribe((page) => { - this.assignmentsPage.set(page); - this.previousPaginationParams = params; - }); + private refreshData( + params: PaginationParams = this.previousPaginationParams + ) { + this.assignmentsPaginator.loadPage(params).subscribe((page) => { + this.assignmentsPage.set(page); + this.previousPaginationParams = params; + }); } /** Handles a pagination event for the future office hours table */ @@ -169,8 +172,27 @@ export class HiringSummaryComponent { this.refreshData(paginationParams); } - /** Save changes */ - updateAssignment(assignment: HiringAssignmentSummaryOverview) { + toggleAssignmentFlag(assignment: HiringAssignmentSummaryOverview) { + // Optimistically update the flag state + assignment.flagged = !assignment.flagged; + // Call the service to update. If there is an error, we will rollback + // the optimistic update and display a snackbar notification. + this.updateAssignment({ + assignment, + onError: () => { + assignment.flagged = !assignment.flagged; + } + }); + } + + /** Handles updating an assignment for all inputs */ + updateAssignment({ + assignment, + onError + }: { + assignment: HiringAssignmentSummaryOverview; + onError?: () => void; + }) { let draft: HiringAssignmentDraft = { id: assignment.id, user_id: assignment.user.id, @@ -187,7 +209,18 @@ export class HiringSummaryComponent { created: new Date(), // will be overrided modified: new Date() }; - this.hiringService.updateHiringAssignment(draft).subscribe((_) => {}); + // Call the service to update. If there is an error, we will rollback + // the optimistic update and display a snackbar notification. + this.hiringService.updateHiringAssignment(draft).subscribe({ + error: (_) => { + onError?.(); + this.snackbar.open( + 'Failed to update assignment. Please try again.', + 'Close', + { duration: 5000 } + ); + } + }); } /** Gets ordered hiring assignments */ @@ -197,11 +230,13 @@ export class HiringSummaryComponent { return []; } - return [...assignments].sort((a, b) => Number(b.flagged) - Number(a.flagged)); + return [...assignments].sort( + (a, b) => Number(b.flagged) - Number(a.flagged) + ); } /** Export CSV button pressed */ exportCsv() { this.hiringService.downloadHiringSummaryCsv(this.selectedTerm()!.id); } -} \ No newline at end of file +} From 3bb6e82a28c21b66492224dd01788cbcebc66b95 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Mon, 1 Dec 2025 12:32:04 -0500 Subject: [PATCH 29/36] tests --- .../services/academics/hiring/hiring_test.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/backend/test/services/academics/hiring/hiring_test.py b/backend/test/services/academics/hiring/hiring_test.py index ff8f45e4a..60e27be23 100644 --- a/backend/test/services/academics/hiring/hiring_test.py +++ b/backend/test/services/academics/hiring/hiring_test.py @@ -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 @@ -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 @@ -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 ( @@ -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() From 7c31d0dcdb233c9b0c00071c4a32612363240c66 Mon Sep 17 00:00:00 2001 From: Armaan Punj Date: Mon, 1 Dec 2025 15:23:56 -0500 Subject: [PATCH 30/36] added enum for flag filter on BE --- backend/api/academics/hiring.py | 2 +- backend/models/academics/hiring/hiring_assignment.py | 4 ++++ backend/services/academics/hiring.py | 6 +++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/api/academics/hiring.py b/backend/api/academics/hiring.py index 52d2edeb1..dd0d90648 100644 --- a/backend/api/academics/hiring.py +++ b/backend/api/academics/hiring.py @@ -170,7 +170,7 @@ def get_hiring_summary_overview( page_size: int = 100, order_by: str = "", filter: str = "", - flagged: str = "all", + flagged: HiringAssignmentFlagFilter = HiringAssignmentFlagFilter.ALL, subject: User = Depends(registered_user), hiring_service: HiringService = Depends(), ) -> Paginated[HiringAssignmentSummaryOverview]: diff --git a/backend/models/academics/hiring/hiring_assignment.py b/backend/models/academics/hiring/hiring_assignment.py index a18591185..fde5270ac 100644 --- a/backend/models/academics/hiring/hiring_assignment.py +++ b/backend/models/academics/hiring/hiring_assignment.py @@ -18,6 +18,10 @@ class HiringAssignmentStatus(Enum): COMMIT = "Commit" FINAL = "Final" +class HiringAssignmentFlagFilter(Enum): + ALL = "all" + FLAGGED = "flagged" + NOT_FLAGGED = "not_flagged" class HiringAssignmentDraft(BaseModel): id: int | None = None diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index f9c7153e4..3014c354e 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -811,7 +811,7 @@ def get_hiring_summary_overview( self, subject: User, term_id: str, - flagged: str, + flagged: HiringAssignmentFlagFilter, pagination_params: PaginationParams, ) -> Paginated[HiringAssignmentSummaryOverview]: """ @@ -858,9 +858,9 @@ def get_hiring_summary_overview( base_query = base_query.where(criteria) # 5. Apply flagged filter if present - if flagged == "flagged": + if flagged == HiringAssignmentFlagFilter.FLAGGED: base_query = base_query.where(HiringAssignmentEntity.flagged.is_(True)) - elif flagged == "not_flagged": + elif flagged == HiringAssignmentFlagFilter.NOT_FLAGGED: base_query = base_query.where(HiringAssignmentEntity.flagged.is_(False)) # 6. Create count query from base query From 88920d9e169ea4beaa97b477e694e88d40e5fa67 Mon Sep 17 00:00:00 2001 From: Christian Lee Date: Wed, 3 Dec 2025 01:28:04 -0500 Subject: [PATCH 31/36] Added frontend functionality for Audit Log --- backend/api/academics/hiring.py | 19 +++++++++ .../hiring/hiring_assignment_audit_entity.py | 3 +- .../hiring/hiring_assignment_audit.py | 13 ++++++ backend/services/academics/hiring.py | 33 +++++++++++++++ .../audit-log-dialog.dialog.css | 4 ++ .../audit-log-dialog.dialog.html | 40 +++++++++++++++++++ .../audit-log-dialog.dialog.ts | 36 +++++++++++++++++ frontend/src/app/hiring/hiring.models.ts | 7 ++++ frontend/src/app/hiring/hiring.module.ts | 8 +++- frontend/src/app/hiring/hiring.service.ts | 12 ++++++ .../course-hiring-card.widget.html | 18 ++++----- .../course-hiring-card.widget.ts | 22 ++++++++-- 12 files changed, 199 insertions(+), 16 deletions(-) create mode 100644 frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css create mode 100644 frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.html create mode 100644 frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.ts diff --git a/backend/api/academics/hiring.py b/backend/api/academics/hiring.py index 52d2edeb1..354f9bc72 100644 --- a/backend/api/academics/hiring.py +++ b/backend/api/academics/hiring.py @@ -15,6 +15,9 @@ from ...models.academics.hiring.hiring_assignment import * from ...models.academics.hiring.hiring_level import * from ...models.academics.hiring.conflict_check import ConflictCheck +from ...models.academics.hiring.hiring_assignment_audit import ( + HiringAssignmentAuditOverview, +) from ...api.authentication import registered_user from ...models.user import User @@ -408,3 +411,19 @@ def row_iter(): f"attachment; filename=applicants_{term_id}.csv" ) return response + + +@api.get( + "/assignments/{assignment_id}/history", + tags=["Hiring"], + response_model=list[HiringAssignmentAuditOverview], +) +def get_assignment_history( + assignment_id: int, + subject: User = Depends(registered_user), + hiring_service: HiringService = Depends(), +) -> list[HiringAssignmentAuditOverview]: + """ + Get the change history for a specific hiring assignment. + """ + return hiring_service.get_audit_history(subject, assignment_id) diff --git a/backend/entities/academics/hiring/hiring_assignment_audit_entity.py b/backend/entities/academics/hiring/hiring_assignment_audit_entity.py index 0b9affaf1..761207249 100644 --- a/backend/entities/academics/hiring/hiring_assignment_audit_entity.py +++ b/backend/entities/academics/hiring/hiring_assignment_audit_entity.py @@ -1,5 +1,5 @@ from sqlalchemy import Integer, String, ForeignKey, DateTime -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime from ...entity_base import EntityBase @@ -22,6 +22,7 @@ class HiringAssignmentAuditEntity(EntityBase): # Who made the change changed_by_user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + changed_by_user: Mapped["UserEntity"] = relationship("UserEntity") # When it happened change_timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) diff --git a/backend/models/academics/hiring/hiring_assignment_audit.py b/backend/models/academics/hiring/hiring_assignment_audit.py index 7e3ef19a9..a22431559 100644 --- a/backend/models/academics/hiring/hiring_assignment_audit.py +++ b/backend/models/academics/hiring/hiring_assignment_audit.py @@ -1,5 +1,6 @@ from pydantic import BaseModel from datetime import datetime +from ... import PublicUser __authors__ = ["Christian Lee"] __copyright__ = "Copyright 2025" @@ -16,3 +17,15 @@ class HiringAssignmentAudit(BaseModel): changed_by_user_id: int change_timestamp: datetime change_details: str + + +class HiringAssignmentAuditOverview(BaseModel): + """ + Model for displaying audit logs in the UI. + Includes the full user details instead of just an ID. + """ + + id: int + change_timestamp: datetime + change_details: str + changed_by_user: PublicUser diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index 0c7f05496..fa2310899 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -44,6 +44,9 @@ from ...models.academics.hiring.phd_application import PhDApplicationReview from ...models.academics.hiring.hiring_assignment import * from ...models.academics.hiring.hiring_level import * +from ...models.academics.hiring.hiring_assignment_audit import ( + HiringAssignmentAuditOverview, +) __authors__ = ["Ajay Gandecha", "Kris Jordan"] __copyright__ = "Copyright 2024" @@ -1261,3 +1264,33 @@ def iter_applicants_for_term_csv(self, subject: User, term_id: str): "preferred_sections": ", ".join(preferred_sections_list), "instructor_selections": instructor_selections_field, } + + def get_audit_history( + self, subject: User, assignment_id: int + ) -> list[HiringAssignmentAuditOverview]: + """ + Retrieves the audit history for a specific hiring assignment. + """ + # 1. Check permissions + self._permission.enforce(subject, "hiring.admin", "*") + + # 2. Query the audit table + query = ( + select(HiringAssignmentAuditEntity) + .where(HiringAssignmentAuditEntity.hiring_assignment_id == assignment_id) + .order_by(HiringAssignmentAuditEntity.change_timestamp.desc()) + .options(joinedload(HiringAssignmentAuditEntity.changed_by_user)) + ) + + audit_entities = self._session.scalars(query).all() + + # 3. Convert to Pydantic models + return [ + HiringAssignmentAuditOverview( + id=audit.id, + change_timestamp=audit.change_timestamp, + change_details=audit.change_details, + changed_by_user=audit.changed_by_user.to_public_model(), + ) + for audit in audit_entities + ] diff --git a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css new file mode 100644 index 000000000..43c91031d --- /dev/null +++ b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css @@ -0,0 +1,4 @@ +mat-form-field { + width: 100%; + margin-top: 10px; +} \ No newline at end of file diff --git a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.html b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.html new file mode 100644 index 000000000..e80e5278b --- /dev/null +++ b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.html @@ -0,0 +1,40 @@ +

Audit Log: {{ data.applicantName }}

+ + + @if(!(history$ | async)) { +
+ +
+ } + + @else { + @let history = (history$ | async) ?? []; + + @if(history.length === 0) { +
+

No changes recorded.

+
+ } @else { +
+ @for(log of history; track log.id) { +
+
+ + {{ log.changed_by_user.first_name }} {{ log.changed_by_user.last_name }} + + + {{ log.change_timestamp | date:'medium' }} + +
+

{{ log.change_details }}

+ +
+ } +
+ } + } +
+ + + + \ No newline at end of file diff --git a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.ts b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.ts new file mode 100644 index 000000000..c4dd9af9c --- /dev/null +++ b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.ts @@ -0,0 +1,36 @@ +/** + * @author Christian Lee + * @copyright 2025 + * @license MIT + */ + +import { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { HiringService } from '../../hiring.service'; +import { HiringAssignmentAuditOverview } from '../../hiring.models'; +import { Observable } from 'rxjs'; + +export interface AuditLogDialogData { + assignmentId: number; + applicantName: string; +} + +@Component({ + selector: 'app-audit-log-dialog', + templateUrl: './audit-log-dialog.dialog.html', + styleUrls: ['./audit-log-dialog.dialog.css'], + standalone: false +}) +export class AuditLogDialog implements OnInit { + history$: Observable | undefined; + + constructor( + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AuditLogDialogData, + private hiringService: HiringService + ) {} + + ngOnInit() { + this.history$ = this.hiringService.getAuditHistory(this.data.assignmentId); + } +} diff --git a/frontend/src/app/hiring/hiring.models.ts b/frontend/src/app/hiring/hiring.models.ts index 572d2efce..1e8ca9caf 100644 --- a/frontend/src/app/hiring/hiring.models.ts +++ b/frontend/src/app/hiring/hiring.models.ts @@ -185,3 +185,10 @@ export interface ConflictCheck { assignments: HiringAssignmentSummaryOverview[]; priorities: ApplicationPriority[]; } + +export interface HiringAssignmentAuditOverview { + id: number; + change_timestamp: string; // Dates often come as strings from JSON + change_details: string; + changed_by_user: PublicProfile; +} diff --git a/frontend/src/app/hiring/hiring.module.ts b/frontend/src/app/hiring/hiring.module.ts index 057f8dabb..94f937ea2 100644 --- a/frontend/src/app/hiring/hiring.module.ts +++ b/frontend/src/app/hiring/hiring.module.ts @@ -53,6 +53,8 @@ import { HiringSummaryComponent } from './hiring-summary/hiring-summary.componen import { MatCheckboxModule } from '@angular/material/checkbox'; import { HiringAssignmentsComponent } from './hiring-assignments/hiring-assignments.component'; import { HiringPageComponent } from './hiring-page/hiring-page.component'; +import { AuditLogDialog } from './dialogs/audit-log-dialog/audit-log-dialog.dialog'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; /* UI Widgets */ @@ -70,7 +72,8 @@ import { HiringPageComponent } from './hiring-page/hiring-page.component'; EditAssignmentDialog, HiringSummaryComponent, HiringAssignmentsComponent, - HiringPageComponent + HiringPageComponent, + AuditLogDialog ], imports: [ CommonModule, @@ -100,7 +103,8 @@ import { HiringPageComponent } from './hiring-page/hiring-page.component'; MatButtonToggleModule, MatChipsModule, MatSlideToggleModule, - MatCheckboxModule + MatCheckboxModule, + MatProgressSpinnerModule ] }) export class HiringModule {} diff --git a/frontend/src/app/hiring/hiring.service.ts b/frontend/src/app/hiring/hiring.service.ts index baac830b8..4c03ac21b 100644 --- a/frontend/src/app/hiring/hiring.service.ts +++ b/frontend/src/app/hiring/hiring.service.ts @@ -16,6 +16,7 @@ import { HiringAdminOverview, HiringAssignmentDraft, HiringAssignmentOverview, + HiringAssignmentAuditOverview, HiringLevel, HiringStatus } from './hiring.models'; @@ -72,6 +73,17 @@ export class HiringService { ); } + /** + * Returns recent changes made to the assignment + * @param assignmentId ID of the assignment to fetch history for + * @returns Observable list of audit logs + */ + getAuditHistory(assignmentId: number) { + return this.http.get( + `/api/hiring/assignments/${assignmentId}/history` + ); + } + private hiringLevelsSignal: WritableSignal = signal([]); hiringLevels = this.hiringLevelsSignal.asReadonly(); activeHiringlevels = computed(() => { diff --git a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html index 20a33a280..4f3cf77f8 100644 --- a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html +++ b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html @@ -13,8 +13,7 @@ class="clickable-td" mat-cell *matCellDef="let element" - (click)="editAssignment(element)"> - + (click)="openAuditLog(element, $event)">
Name
+

{{ element.user.first_name }} {{ element.user.last_name }}
{{ element.user.pid }} diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts index 288bfeb72..7a1aed929 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts @@ -20,6 +20,8 @@ import { } from '../hiring.models'; import { PageEvent } from '@angular/material/paginator'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; +import { AuditLogDialog } from '../dialogs/audit-log-dialog/audit-log-dialog.dialog'; const DEFAULT_PAGINATION_PARAMS = { page: 0, @@ -121,7 +123,8 @@ export class HiringSummaryComponent { private route: ActivatedRoute, private snackbar: MatSnackBar, protected hiringService: HiringService, - protected academicsService: AcademicsService + protected academicsService: AcademicsService, + private dialog: MatDialog ) { // Initialize data from resolvers const data = this.route.snapshot.data as { @@ -223,6 +226,24 @@ export class HiringSummaryComponent { }); } + /** Opens the audit log dialog for a specific assignment */ + openAuditLog( + assignment: HiringAssignmentSummaryOverview, + event?: MouseEvent + ) { + if (event) { + event.stopPropagation(); + } + + this.dialog.open(AuditLogDialog, { + data: { + assignmentId: assignment.id, + applicantName: `${assignment.user.first_name} ${assignment.user.last_name}` + }, + width: '600px' + }); + } + /** Gets ordered hiring assignments */ getOrderedAssignments(): HiringAssignmentSummaryOverview[] { const assignments = this.assignmentsPage()?.items ?? []; diff --git a/frontend/src/app/hiring/hiring.models.ts b/frontend/src/app/hiring/hiring.models.ts index 1e8ca9caf..ae1028a09 100644 --- a/frontend/src/app/hiring/hiring.models.ts +++ b/frontend/src/app/hiring/hiring.models.ts @@ -188,7 +188,7 @@ export interface ConflictCheck { export interface HiringAssignmentAuditOverview { id: number; - change_timestamp: string; // Dates often come as strings from JSON + change_timestamp: Date; change_details: string; changed_by_user: PublicProfile; } diff --git a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html index 4f3cf77f8..99debd8d5 100644 --- a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html +++ b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html @@ -9,11 +9,20 @@ class="surface-container-card">

Hire + + + + Avatar + {{ element.user.first_name }} {{ element.user.last_name }} + +
- - - - - - +
Flagged
- -
- - + - - - - - + - - - + - - - + - - - + - - - + -
Name
-
-

- {{ element.user.first_name }} {{ element.user.last_name }}
{{ element.user.pid }} -

+
+
Name
+
+
+

{{ element.user.first_name }} {{ element.user.last_name }}
{{ element.user.pid }}

Course
-

{{ element.course }}
+

{{ element.course }}
{{ element.instructors }}

Hiring Level
-

+

${{ element.level.salary.toFixed(2) }} @@ -88,73 +59,79 @@

Epar
+ +
Epar
+
Pos #
+ +
Pos #
+
Notes
+ +
Notes
+
+ [(ngModel)]="(assignmentsPage()?.items ?? [])[i].notes" + (change)="updateAssignment(i)">
I9?
+ +
I9?
+
+ [(ngModel)]="(assignmentsPage()?.items ?? [])[i].i9" + (change)="updateAssignment(i)" />
Status
+ +
Status
+
+ [(ngModel)]="(assignmentsPage()?.items ?? [])[i].status" + (change)="updateAssignment(i)"> Commit Final
@@ -165,4 +142,4 @@ (page)="handlePageEvent($event)">
- \ No newline at end of file + diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts index 7a1aed929..70d502005 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts @@ -19,9 +19,6 @@ import { HiringAssignmentSummaryOverview } from '../hiring.models'; import { PageEvent } from '@angular/material/paginator'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { MatDialog } from '@angular/material/dialog'; -import { AuditLogDialog } from '../dialogs/audit-log-dialog/audit-log-dialog.dialog'; const DEFAULT_PAGINATION_PARAMS = { page: 0, @@ -31,10 +28,10 @@ const DEFAULT_PAGINATION_PARAMS = { } as PaginationParams; @Component({ - selector: 'app-hiring-summary', - templateUrl: './hiring-summary.component.html', - styleUrl: './hiring-summary.component.css', - standalone: false + selector: 'app-hiring-summary', + templateUrl: './hiring-summary.component.html', + styleUrl: './hiring-summary.component.css', + standalone: false }) export class HiringSummaryComponent { /** Route for the routing module */ @@ -56,30 +53,22 @@ export class HiringSummaryComponent { return this.terms.find((term) => term.id === this.selectedTermId())!; }); - /** Current filter mode for applicants*/ - public filterMode: WritableSignal<'all' | 'flagged' | 'not_flagged'> = - signal('all'); - /** Effect that updates the hiring data when the selected term changes. */ selectedTermEffect = effect(() => { - const termId = this.selectedTermId(); - // We check for termId existence to avoid running this before data is ready - if (termId) { - // Pass the current filter mode to the URL construction - this.updatePaginatorUrl(termId, this.filterMode()); - this.refreshData(); - } - }); - - /** Effect that updates the hiring data when the filter mode changes. */ - filterModeEffect = effect(() => { - const mode = this.filterMode(); - const termId = this.selectedTermId(); - - // Only update if we have a term selected - if (termId) { - this.updatePaginatorUrl(termId, mode); - this.refreshData(); + if (this.selectedTermId()) { + const term = this.terms.find( + (term) => term.id === this.selectedTermId() + )!; + // Load paginated data + this.assignmentsPaginator.changeApiRoute( + `/api/hiring/summary/${term.id}` + ); + + this.assignmentsPaginator + .loadPage(this.previousPaginationParams) + .subscribe((page) => { + this.assignmentsPage.set(page); + }); } }); @@ -92,7 +81,6 @@ export class HiringSummaryComponent { DEFAULT_PAGINATION_PARAMS; public displayedColumns: string[] = [ - 'flagged', 'name', 'course', 'level', @@ -115,16 +103,17 @@ export class HiringSummaryComponent { paginationParams.filter = this.searchBarQuery(); // Refresh the data - this.refreshData(paginationParams); + this.assignmentsPaginator.loadPage(paginationParams).subscribe((page) => { + this.assignmentsPage.set(page); + this.previousPaginationParams = paginationParams; + }); }); /** Constructor */ constructor( private route: ActivatedRoute, - private snackbar: MatSnackBar, protected hiringService: HiringService, - protected academicsService: AcademicsService, - private dialog: MatDialog + protected academicsService: AcademicsService ) { // Initialize data from resolvers const data = this.route.snapshot.data as { @@ -133,38 +122,18 @@ export class HiringSummaryComponent { }; this.terms = data.terms; - const termId = data.currentTerm?.id; - this.selectedTermId.set(termId); + this.selectedTermId.set(data.currentTerm?.id ?? undefined); // Load paginated data - const initialUrl = termId - ? `/api/hiring/summary/${termId}?flagged=${this.filterMode()}` - : ''; - this.assignmentsPaginator = new Paginator( - initialUrl + `/api/hiring/summary/${data.currentTerm!.id}` ); - if (termId) { - this.refreshData(); - } - } - - /** Helper to update the paginator API URL with term and flag params */ - private updatePaginatorUrl(termId: string, flagMode: string) { - this.assignmentsPaginator.changeApiRoute( - `/api/hiring/summary/${termId}?flagged=${flagMode}` - ); - } - - /** Helper to trigger the loadPage logic */ - private refreshData( - params: PaginationParams = this.previousPaginationParams - ) { - this.assignmentsPaginator.loadPage(params).subscribe((page) => { - this.assignmentsPage.set(page); - this.previousPaginationParams = params; - }); + this.assignmentsPaginator + .loadPage(this.previousPaginationParams) + .subscribe((page) => { + this.assignmentsPage.set(page); + }); } /** Handles a pagination event for the future office hours table */ @@ -172,30 +141,15 @@ export class HiringSummaryComponent { let paginationParams = this.assignmentsPage()!.params; paginationParams.page = e.pageIndex; paginationParams.page_size = e.pageSize; - this.refreshData(paginationParams); - } - - toggleAssignmentFlag(assignment: HiringAssignmentSummaryOverview) { - // Optimistically update the flag state - assignment.flagged = !assignment.flagged; - // Call the service to update. If there is an error, we will rollback - // the optimistic update and display a snackbar notification. - this.updateAssignment({ - assignment, - onError: () => { - assignment.flagged = !assignment.flagged; - } + this.assignmentsPaginator.loadPage(paginationParams).subscribe((page) => { + this.assignmentsPage.set(page); + this.previousPaginationParams = paginationParams; }); } - /** Handles updating an assignment for all inputs */ - updateAssignment({ - assignment, - onError - }: { - assignment: HiringAssignmentSummaryOverview; - onError?: () => void; - }) { + /** Save changes */ + updateAssignment(assignmentIndex: number) { + let assignment = this.assignmentsPage()!.items[assignmentIndex]!; let draft: HiringAssignmentDraft = { id: assignment.id, user_id: assignment.user.id, @@ -208,52 +162,10 @@ export class HiringSummaryComponent { epar: assignment.epar, i9: assignment.i9, notes: assignment.notes, - flagged: assignment.flagged, created: new Date(), // will be overrided modified: new Date() }; - // Call the service to update. If there is an error, we will rollback - // the optimistic update and display a snackbar notification. - this.hiringService.updateHiringAssignment(draft).subscribe({ - error: (_) => { - onError?.(); - this.snackbar.open( - 'Failed to update assignment. Please try again.', - 'Close', - { duration: 5000 } - ); - } - }); - } - - /** Opens the audit log dialog for a specific assignment */ - openAuditLog( - assignment: HiringAssignmentSummaryOverview, - event?: MouseEvent - ) { - if (event) { - event.stopPropagation(); - } - - this.dialog.open(AuditLogDialog, { - data: { - assignmentId: assignment.id, - applicantName: `${assignment.user.first_name} ${assignment.user.last_name}` - }, - width: '600px' - }); - } - - /** Gets ordered hiring assignments */ - getOrderedAssignments(): HiringAssignmentSummaryOverview[] { - const assignments = this.assignmentsPage()?.items ?? []; - if (assignments.length === 0) { - return []; - } - - return [...assignments].sort( - (a, b) => Number(b.flagged) - Number(a.flagged) - ); + this.hiringService.updateHiringAssignment(draft).subscribe((_) => {}); } /** Export CSV button pressed */ diff --git a/frontend/src/app/hiring/hiring.models.ts b/frontend/src/app/hiring/hiring.models.ts index 2bc8750d1..ac5ac4883 100644 --- a/frontend/src/app/hiring/hiring.models.ts +++ b/frontend/src/app/hiring/hiring.models.ts @@ -91,7 +91,6 @@ export interface HiringAssignmentDraft { notes: string; created: Date; modified: Date; - flagged: boolean; } export interface HiringAssignmentOverview { @@ -103,7 +102,6 @@ export interface HiringAssignmentOverview { epar: string; i9: boolean; notes: string; - flagged: boolean; } export const hiringAssignmentOverviewToDraft = ( @@ -124,7 +122,6 @@ export const hiringAssignmentOverviewToDraft = ( epar: assignment.epar, i9: assignment.i9, notes: assignment.notes, - flagged: assignment.flagged, created: new Date(), // overwritten anyway modified: new Date() // overwritten anyway }; @@ -165,7 +162,6 @@ export interface HiringAssignmentSummaryOverview { epar: string; i9: boolean; notes: string; - flagged: boolean; } export interface ReleasedHiringAssignment { @@ -186,10 +182,3 @@ export interface ConflictCheck { assignments: HiringAssignmentSummaryOverview[]; priorities: ApplicationPriority[]; } - -export interface HiringAssignmentAuditOverview { - id: number; - change_timestamp: Date; - change_details: string; - changed_by_user: PublicProfile; -} diff --git a/frontend/src/app/hiring/hiring.module.ts b/frontend/src/app/hiring/hiring.module.ts index 94f937ea2..057f8dabb 100644 --- a/frontend/src/app/hiring/hiring.module.ts +++ b/frontend/src/app/hiring/hiring.module.ts @@ -53,8 +53,6 @@ import { HiringSummaryComponent } from './hiring-summary/hiring-summary.componen import { MatCheckboxModule } from '@angular/material/checkbox'; import { HiringAssignmentsComponent } from './hiring-assignments/hiring-assignments.component'; import { HiringPageComponent } from './hiring-page/hiring-page.component'; -import { AuditLogDialog } from './dialogs/audit-log-dialog/audit-log-dialog.dialog'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; /* UI Widgets */ @@ -72,8 +70,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; EditAssignmentDialog, HiringSummaryComponent, HiringAssignmentsComponent, - HiringPageComponent, - AuditLogDialog + HiringPageComponent ], imports: [ CommonModule, @@ -103,8 +100,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; MatButtonToggleModule, MatChipsModule, MatSlideToggleModule, - MatCheckboxModule, - MatProgressSpinnerModule + MatCheckboxModule ] }) export class HiringModule {} diff --git a/frontend/src/app/hiring/hiring.service.ts b/frontend/src/app/hiring/hiring.service.ts index 2832f2c6b..7569b4dbb 100644 --- a/frontend/src/app/hiring/hiring.service.ts +++ b/frontend/src/app/hiring/hiring.service.ts @@ -16,7 +16,6 @@ import { HiringAdminOverview, HiringAssignmentDraft, HiringAssignmentOverview, - HiringAssignmentAuditOverview, HiringLevel, HiringStatus } from './hiring.models'; @@ -73,17 +72,6 @@ export class HiringService { ); } - /** - * Returns recent changes made to the assignment - * @param assignmentId ID of the assignment to fetch history for - * @returns Observable list of audit logs - */ - getAuditHistory(assignmentId: number) { - return this.http.get( - `/api/hiring/assignments/${assignmentId}/history` - ); - } - private hiringLevelsSignal: WritableSignal = signal([]); hiringLevels = this.hiringLevelsSignal.asReadonly(); activeHiringlevels = computed(() => { diff --git a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html index 99debd8d5..20a33a280 100644 --- a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html +++ b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html @@ -9,20 +9,12 @@ class="surface-container-card"> Hire - - - - Avatar - {{ element.user.first_name }} {{ element.user.last_name }} - - + + @@ -75,16 +67,12 @@ Draft Commit Final - -
diff --git a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts index 58978488f..0762d62f2 100644 --- a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts +++ b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts @@ -32,13 +32,12 @@ import { EditAssignmentDialogData } from '../../dialogs/edit-assignment-dialog/edit-assignment.dialog'; import { ApplicationDialog } from '../../dialogs/application-dialog/application-dialog.dialog'; -import { AuditLogDialog } from '../../dialogs/audit-log-dialog/audit-log-dialog.dialog'; @Component({ - selector: 'course-hiring-card', - templateUrl: './course-hiring-card.widget.html', - styleUrl: './course-hiring-card.widget.css', - standalone: false + selector: 'course-hiring-card', + templateUrl: './course-hiring-card.widget.html', + styleUrl: './course-hiring-card.widget.css', + standalone: false }) export class CourseHiringCardWidget implements OnInit { @Input() termId!: string; @@ -168,19 +167,6 @@ export class CourseHiringCardWidget implements OnInit { }); } - /** Opens the audit log dialog for a specific assignment. */ - openAuditLog(assignment: HiringAssignmentOverview, event: MouseEvent): void { - event.stopPropagation(); - - this.dialog.open(AuditLogDialog, { - width: '600px', - data: { - assignmentId: assignment.id, - applicantName: `${assignment.user.first_name} ${assignment.user.last_name}` - } - }); - } - chipSelected(user: PublicProfile): boolean { return ( this.item()! diff --git a/frontend/src/app/pagination.ts b/frontend/src/app/pagination.ts index 6e0753ce9..61d888f56 100644 --- a/frontend/src/app/pagination.ts +++ b/frontend/src/app/pagination.ts @@ -123,11 +123,9 @@ abstract class PaginatorAbstraction { // Stpres the previous pagination parameters used this.previousParams = paramStrings; - const separator = this.api.includes('?') ? '&' : '?'; - // Determines the query for the URL based on the new paramateres. let query = new URLSearchParams(paramStrings); - let route = this.api + separator + query.toString(); + let route = this.api + '?' + query.toString(); // Determine if an operator function is necessary if (operator) {