Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/app/algorithms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class TimeBlockAssignmentResult:

assignments: list[SectionAssignment]
warnings: list[Warning]
warning_section_ids: list[int | None] = field(default_factory=list)


@dataclass
Expand Down
4 changes: 3 additions & 1 deletion backend/app/algorithms/time_assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ def assign_time_blocks(
cap = float(params.MaxTimeBlockCapacity) # 0.15

warnings: list[Warning] = []
warning_section_ids: list[int | None] = []
out: list[SectionAssignment] = []

# set of time_block_ids already assigned to them.
Expand Down Expand Up @@ -194,6 +195,7 @@ def prefs_for(nuid: int) -> Sequence[MeetingPreferenceInfo]:
BlockID=None,
)
)
warning_section_ids.append(assign.section_id)
# return all the SectionAssignments we have so far
# plus the ones with time_block_id=None to indicate unplaced
out.append(
Expand Down Expand Up @@ -228,7 +230,7 @@ def prefs_for(nuid: int) -> Sequence[MeetingPreferenceInfo]:

# Final sort by section_id for display
out.sort(key=lambda s: s.section_id)
return TimeBlockAssignmentResult(assignments=out, warnings=warnings)
return TimeBlockAssignmentResult(assignments=out, warnings=warnings, warning_section_ids=warning_section_ids)


# The following function is the exact same department cap logic used in assign_time_blocks
Expand Down
46 changes: 28 additions & 18 deletions backend/app/repositories/schedule_warning.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,35 @@ def sync_section_warnings(
schedule_id: int,
detected: list[WarningType],
) -> None:

# Replace strategy with dismissed preservation:
# - drop any row whose type is no longer detected (condition resolved)
# - for types still detected: keep dismissed rows, drop non-dismissed duplicates,
# and add exactly one non-dismissed row per type (unless a dismissed row already exists).
# Diff-based reconciliation can't clean up duplicates (error_check can emit the same
# type once per faculty), so it was orphaning rows.
existing = get_by_section(db, section_id)
existing_by_type = {w.type: w for w in existing}
detected_values = {wt.value for wt in detected}

for type_str, warning in existing_by_type.items():
if type_str not in detected_values:
db.delete(warning)

for wt in detected:
if wt.value not in existing_by_type:
db.add(
ScheduleWarning(
schedule_id=schedule_id,
section_id=section_id,
type=wt.value,
severity=str(wt.severity.value),
message=wt.value,
dismissed=False,
)
dismissed_types: set[str] = set()

for w in existing:
if w.type not in detected_values:
db.delete(w)
elif w.dismissed:
dismissed_types.add(w.type)
else:
db.delete(w)

for wt in set(detected):
if wt.value in dismissed_types:
continue
db.add(
ScheduleWarning(
schedule_id=schedule_id,
section_id=section_id,
type=wt.value,
severity=str(wt.severity.value),
message=wt.value,
dismissed=False,
)
)
db.commit()
40 changes: 37 additions & 3 deletions backend/app/routers/schedule_warning.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session

from app.core.auth import require_admin
from app.core.database import get_db
from app.core.enums import Severity, WarningType
from app.models.schedule_warning import ScheduleWarning as ScheduleWarningModel
from app.models.user import User
from app.repositories import schedule as schedule_repo
from app.repositories import schedule_warning as warning_repo
from app.schemas.warning import Warning, WarningResponse
from app.services.connection_manager import manager

router = APIRouter(prefix="/schedules", tags=["warnings"])

Expand All @@ -24,6 +27,7 @@ def _to_response(r: ScheduleWarningModel) -> WarningResponse:
CourseID=r.course_id,
BlockID=r.time_block_id,
dismissed=r.dismissed,
dismissed_by=r.dismissed_by,
)


Expand Down Expand Up @@ -74,10 +78,11 @@ def create_warning(


@router.patch("/{schedule_id}/warnings/{warning_id}/dismiss")
def dismiss_warning(
async def dismiss_warning(
schedule_id: int,
warning_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
if not schedule_repo.schedule_exists(db, schedule_id):
raise HTTPException(status_code=404, detail="Schedule not found")
Expand All @@ -87,15 +92,25 @@ def dismiss_warning(
raise HTTPException(status_code=404, detail="Warning not found")

warning.dismissed = True
warning.dismissed_by = f"{current_user.first_name} {current_user.last_name}"
db.commit()

await manager.broadcast(
schedule_id,
{
"type": "section_warnings",
"payload": {"section_id": warning.section_id, "warnings": []},
},
)
return {"warning_id": warning_id, "dismissed": True}


@router.patch("/{schedule_id}/warnings/{warning_id}/restore")
def restore_warning(
async def restore_warning(
schedule_id: int,
warning_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
if not schedule_repo.schedule_exists(db, schedule_id):
raise HTTPException(status_code=404, detail="Schedule not found")
Expand All @@ -105,15 +120,25 @@ def restore_warning(
raise HTTPException(status_code=404, detail="Warning not found")

warning.dismissed = False
warning.dismissed_by = None
db.commit()

await manager.broadcast(
schedule_id,
{
"type": "section_warnings",
"payload": {"section_id": warning.section_id, "warnings": []},
},
)
return {"warning_id": warning_id, "dismissed": False}


@router.delete("/{schedule_id}/warnings/{warning_id}", status_code=204)
def delete_warning(
async def delete_warning(
schedule_id: int,
warning_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
if not schedule_repo.schedule_exists(db, schedule_id):
raise HTTPException(status_code=404, detail="Schedule not found")
Expand All @@ -122,5 +147,14 @@ def delete_warning(
if warning is None or warning.schedule_id != schedule_id:
raise HTTPException(status_code=404, detail="Warning not found")

section_id = warning.section_id
db.delete(warning)
db.commit()

await manager.broadcast(
schedule_id,
{
"type": "section_warnings",
"payload": {"section_id": section_id, "warnings": []},
},
)
21 changes: 11 additions & 10 deletions backend/app/routers/section.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,18 @@ async def update_section(
},
)

if warnings:
await manager.broadcast(
updated.schedule_id,
{
"type": "section_warnings",
"payload": {
"section_id": section_id,
"warnings": [w.value for w in warnings],
},
# Broadcast unconditionally: an edit that clears the last warning still needs to
# prompt clients to refetch, otherwise stale warning state lingers in the UI.
await manager.broadcast(
updated.schedule_id,
{
"type": "section_warnings",
"payload": {
"section_id": section_id,
"warnings": [w.value for w in warnings],
},
)
},
)

return updated

Expand Down
1 change: 1 addition & 0 deletions backend/app/schemas/warning.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ class WarningResponse(Warning):

warning_id: int = Field(..., description="Unique warning ID")
dismissed: bool = Field(default=False, description="Whether this warning was dismissed")
dismissed_by: str | None = Field(default=None, description="Display name of the user who dismissed the warning")
section_id: int | None = Field(default=None, description="Directly linked section (manual-edit warnings only)")
Loading
Loading