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/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 15726edda..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] = {} @@ -126,10 +151,17 @@ def update_status( updates: list[dict] = [] for persisted in site_entity.application_reviews: request = hiring_status_reviews_by_id[persisted.id] + requested_level_id = request.level.id if request.level else None if ( - persisted.status != request.status - or persisted.preference != request.preference - or persisted.notes != request.notes + persisted.status, + persisted.preference, + persisted.notes, + persisted.level_id, + ) != ( + request.status, + request.preference, + request.notes, + requested_level_id, ): updates.append( { @@ -137,6 +169,7 @@ def update_status( "status": request.status, "preference": request.preference, "notes": request.notes, + "level_id": requested_level_id, } ) @@ -148,6 +181,49 @@ def update_status( # Reload the data and return the hiring status. return self.get_status(subject, course_site_id) + def _get_default_level_for_application( + self, application: ApplicationEntity + ) -> HiringLevel | None: + """ + Gets the default level for an application based on its type. + Finds the level that matches the classification, has load of 1.0, and is active. + + Args: + application: The application entity to get default level for + + Returns: + The default level model, or None if not found + """ + # Determine classification based on application type + classification: HiringLevelClassification | None = None + + if application.type == "new_uta": + classification = HiringLevelClassification.UG + elif application.type == "gta": + if application.program_pursued: + # Check if it's a PhD program + if application.program_pursued in ("PhD", "PhD (ABD)"): + classification = HiringLevelClassification.PHD + # Check if it's MS or BS/MS + elif application.program_pursued in ("MS", "BS/MS"): + classification = HiringLevelClassification.MS + + if not classification: + return None + + # Find the level that matches classification, has load 1.0, and is active + level_query = ( + select(HiringLevelEntity) + .where(HiringLevelEntity.classification == classification) + .where(HiringLevelEntity.load == 1.0) + .where(HiringLevelEntity.is_active == True) + ) + level_entity = self._session.scalar(level_query) + + if level_entity: + return level_entity.to_model() + return None + def create_missing_course_sites_for_term(self, subject: User, term_id: str) -> bool: """ Creates missing course sites for a given term. @@ -485,6 +561,7 @@ def _to_review_models( status=review.status, preference=review.preference, notes=review.notes, + level=(review.level.to_model() if review.level else None), applicant_course_ranking=applicant_preferences.get( review.application_id, 999 ), @@ -665,7 +742,8 @@ def get_hiring_admin_course_overview( .options( joinedload(ApplicationReviewEntity.application).joinedload( ApplicationEntity.user - ) + ), + joinedload(ApplicationReviewEntity.level), ) ) preferred_review_entities = self._session.scalars(preferred_review_query).all() @@ -681,6 +759,7 @@ def to_overview(review: ApplicationReviewEntity) -> ApplicationReviewOverview: application=review.application.to_review_overview_model(), applicant_id=review.application.user_id, applicant_course_ranking=0, + level=(review.level.to_model() if review.level else None), ) reviews = [ @@ -979,6 +1058,26 @@ def get_hiring_assignments_for_course_site( params=pagination_params, ) + def get_course_site_total_enrollment( + self, subject: User, course_site_id: int + ) -> int: + """ + Returns the sum of enrolled students across all sections in a course site. + """ + site_entity = self._load_course_site(course_site_id) + if not self._is_instructor(subject, site_entity): + self._permission.enforce( + subject, "hiring.get_status", f"course_site/{course_site_id}" + ) + + sections_query = select(SectionEntity).where( + SectionEntity.course_site_id == course_site_id + ) + total_enrollment = 0 + for section in self._session.scalars(sections_query).all(): + total_enrollment += section.enrolled + return total_enrollment + def get_assignment_summary_for_instructors_csv( self, subject: User, course_site_id: int ) -> list[HiringAssignmentSummaryCsvRow]: 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() 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..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

@@ -115,5 +119,18 @@

[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..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 @@ -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, @@ -40,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. */ @@ -50,14 +54,24 @@ 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.notesSubcription?.unsubscribe(); + 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; @@ -68,14 +82,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/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..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 @@ -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)) { + school + } + + } 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..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 @@ -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)) { + school + } + + } 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..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); } @@ -169,6 +181,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', 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 { 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..9b3dd33dd 100644 --- a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css +++ b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css @@ -112,3 +112,10 @@ application-card { 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 323a76b97..4f6b398e8 100644 --- a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html +++ b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html @@ -81,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 + } +