Skip to content

Email a class in batches and keep track of who was emailed - #125

Merged
pancakereport merged 5 commits into
mainfrom
email-in-chunks
Oct 16, 2026
Merged

pancakereport merged 5 commits into
mainfrom
email-in-chunks

Conversation

@pancakereport

@pancakereport pancakereport commented Oct 14, 2026 •

Copy link
Copy Markdown

Summary

Fixes #86: emailing a large class failed partway, and nobody was marked as emailed.

Why it failed:

  • One long request: the email page sent every email inside a single request, one at a time over one SMTP connection. The report works out to about 0.3 s per email (204 sent in about a minute). Heroku's gunicorn --timeout 61 killed the worker at about 61 s.
  • Progress lost: students were only marked as emailed in one commit after the last email, so when the worker died, none of the roughly 200 students who got the email were recorded.
  • Moving to GCP alone doesn't fix it: the Docker image's gunicorn timeout is 120 s (GUNICORN_TIMEOUT), roughly 400 students, and the lost-progress bug happens wherever a send stops partway.

Changes

  1. Save who was emailed after each batch (server/services/email/__init__.py): email_about_assignment sends in batches of 50 (SEND_BATCH_SIZE) and commits after each one, so a send that stops partway still records everyone who got the email.

  2. Pick up where a send stopped (server/views.py, email.html.j2): the email page fills TO with the seated students not emailed yet, instead of every student, and says who it left out:

    Leaving out 3 students already emailed. Email them again too. Leaving out 2 students without a seat.

    Students without a seat used to be in TO and always failed; now they're left out.

  3. Send a batch per request (server/views.py, email.html.j2):

    • New endpoint: POST /<exam>/students/email/send/ emails the students in to_addr and returns {"sent": [...], "failed": [...]} as JSON, or {"errors": ...} with a 400 if the form is invalid.
    • The Send button: the email-all page's Send sends 50 students per request through it. It ends with a summary, any failed addresses, and a link back to the students page. If a request fails, it stops, says so, and explains that reloading the page emails just the students not reached.
    • While sending: the page itself sends each batch, so closing it stops the rest. It shows a progress bar, "Emailing students: 100 of 285 done, about 1 min left…" (estimated from the batches so far), and a note to keep the page open; closing or reloading the page asks first. These go away when sending finishes.
    • CC and BCC go with every batch, so every student's email is CC'd as before, and course staff see a student's reply-all.
    • Unchanged: Send test, the single-student page and sending without JavaScript all work as before.

No request comes near a timeout however large the class is, on Heroku, staging or production. This needs no extra resources on staging: sending is waiting on the mail server, not CPU or memory, and Cloud Run only runs while a request is being served.

Tests

  • tests/unit/test_email_batches.py:
    • Per-batch saving: a send that crashes on its second batch leaves the first batch's students marked emailed, and every batch is sent.
    • Prefilled TO: the email page leaves out students without a seat and students already emailed, with the right note, and ?include_emailed=1 includes them again.
    • Endpoint: it reports who was sent and who failed, marks only the sent students, and returns a 400 with the field errors for an invalid form.
    • Batch script: it's on the email-all page, including after a Send test, and not on the single-student page.
  • tests/e2e/test_email_batches.py: in Chrome, with 121 seated students, Send makes three requests (50, 50 and 21 students), each with the CC. During each request the page shows the count so far and the progress bar, and leaving the page asks first; once done, both are gone and the page shows the summary and doesn't navigate away. The test server has no SMTP server, so every email fails, and no student is marked emailed.

9 of the 11 unit tests and the e2e test fail on main. Locally: unit 277, e2e 14, a11y 4 passing.

Also checked by hand in Chrome against the app with sending replaced by saving each email to a file, with 121 seated students, 3 of them already emailed:

  • Before sending: the page listed 118 in TO.
  • Sending: it sent them in three batches, 118 emails were saved, and the page reported "Emailed 118 of 118 students."
  • After a reload: TO was empty.

🤖 Generated with Claude Code

Emailing more than about 200 students failed (#86): every email went out
inside one request, about 0.3 s each, so a large class ran into the
server's timeout (61 s on Heroku, 120 s in the Docker image), and since
the students were only marked as emailed once at the end, none of those
who got the email were recorded.

- Save who was emailed after each batch of 50, so a send that stops
  partway still records the students who got their email.
- Prefill the email page with the seated students not emailed yet, and
  say who was left out, with a link to email them again too, so after a
  send that stopped partway the page picks up with the rest.
- Send from the page a batch of 50 students per request, through a new
  JSON endpoint, showing progress, so no request comes near a timeout
  however large the class is. CC and BCC go with the first batch only.
  Without JavaScript the form still sends everyone in one request.

Fixes #86

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Coverage

Coverage for unit tests for Python 3.14
FileStmtsMissCoverMissing
server
   __init__.py52492%55, 65–67, 87
   cache.py6183%12
   forms.py1381490%23–25, 41–43, 70, 93, 168–171, 178, 186
   models.py2112688%60, 64, 67, 70, 73, 76–81, 143–152, 182, 200, 257, 260, 283, 327, 341
   views.py76752032%39, 49–85, 95–129, 140–144, 161–170, 186–203, 214–221, 230–252, 263–276, 285, 296–299, 317–318, 320–325, 327–328, 342–366, 375–397, 406–433, 450–452, 462–504, 544–574, 583–613, 633, 647–650, 652–655, 664–694, 703–720, 751–755, 764–810, 816–868, 874–884, 889–910, 918–939, 971, 995, 1037–1046, 1048, 1064–1065, 1070–1083, 1097, 1102, 1129–1130
server/controllers
   __init__.py821779%27–28, 36–39, 43, 72, 76, 85, 101–110
   auth_controllers.py402342%22–51, 57–59
   health_controllers.py21767%18–19, 24–28
server/services/auth
   __init__.py28389%25–26, 30
server/services/canvas
   __init__.py806124%18–25, 29, 33, 37, 43, 55–64, 69–105, 109–124, 128
   fake_canvas.py392633%9, 12, 15, 20–25, 28–29, 37–46, 49–56
server/services/core
   assign.py76396%25, 28, 113
   data.py613444%13–18, 22–26, 30–34, 38–40, 44–55, 90–92, 101–102, 106–107
   export.py27678%36–38, 40–42
   room.py611870%11–33, 57, 70–71, 74
   student.py184597%33, 37–38, 47, 268
server/services/email
   __init__.py50394%47, 74, 92
   smtp.py922573%18, 49–51, 68–76, 88–89, 101–108, 112–114, 125–126
server/services/email/templates
   __init__.py33197%20
server/typings
   exception.py34488%42, 53, 59–60
server/utils
   date.py7186%30
   misc.py16756%13–19
TOTAL237580966% 

Tests Skipped Failures Errors Time
271 0 💤 0 ❌ 0 🔥 14.904s ⏱️

@github-actions

Copy link
Copy Markdown

Coverage

Coverage for e2e tests for Python 3.14
FileStmtsMissCoverMissing
server
   __init__.py521277%13, 16–22, 27, 55, 65–67, 87
   cache.py6183%12
   forms.py1382185%23–25, 41–43, 68–70, 92–98, 168–171, 178, 186
   models.py2117067%27, 60, 64, 67, 70, 73, 76–81, 111, 126–132, 136, 140, 143–152, 161–162, 169–170, 177–182, 189–197, 200, 214, 217–219, 222, 226, 238, 242, 246, 250–251, 257, 260, 280, 283, 304, 309, 314, 319, 324, 327, 341
   views.py76765614%38–40, 49–85, 95–129, 140–144, 161–170, 186–203, 214–221, 230–252, 263–276, 285, 296–299, 310–329, 342–366, 375–397, 406–433, 442–453, 462–504, 515–523, 531–535, 544–574, 583–613, 622–655, 664–694, 703–720, 726–729, 739–746, 751–755, 764–810, 816–868, 874–884, 889–910, 915–945, 950–954, 962–972, 976–978, 983–1016, 1026–1030, 1035–1059, 1064–1065, 1070–1083, 1092, 1097, 1102, 1118–1134, 1144–1145
server/controllers
   __init__.py824644%26–44, 47–49, 60–87, 90, 101–110, 113
   auth_controllers.py402635%14–16, 22–51, 57–59
   dev_login_controllers.py584424%17–30, 35–52, 60–61, 68–79, 84–101
   health_controllers.py211243%9, 14–19, 24–28
server/services/auth
   __init__.py28775%25–26, 30, 62–63, 68–69
server/services/canvas
   __init__.py806124%18–25, 29, 33, 37, 43, 55–64, 69–105, 109–124, 128
   fake_canvas.py392633%9, 12, 15, 20–25, 28–29, 37–46, 49–56
server/services/core
   assign.py765824%10–13, 16, 19, 22, 25, 28, 36, 40–46, 54–55, 63, 67, 83–148, 162–178
   data.py614133%13–18, 22–26, 30–34, 38–40, 44–55, 62, 72–74, 79, 90–92, 96–97, 101–102, 106–107
   export.py272219%11–45
   layout_preview.py925738%81, 85, 90, 94, 105–110, 116–119, 128–130, 140, 147, 159–162, 167–171, 175–177, 199–200, 206–209, 218–222, 227, 239–259
   room.py61568%11–33, 41–89
   student.py18415217%33, 37–38, 42, 46–48, 57–60, 70–72, 79, 88–93, 104–107, 110–114, 122–136, 140–143, 147, 151, 158–291
server/services/csv
   __init__.py261735%11–12, 20, 27–30, 40, 54–56, 63–68
server/services/email
   __init__.py503824%23–40, 44–81, 90–101
   smtp.py927321%18, 26–39, 43–52, 57–77, 83–115, 120–126
server/services/email/templates
   __init__.py332330%9–11, 14–17, 20, 30–41, 45–47
server/services/google
   __init__.py553929%21–36, 40–43, 47–53, 57–76
server/typings
   exception.py341362%20–36, 42, 47, 53, 59–60, 69, 72
server/utils
   date.py7443%17–19, 30
   misc.py161319%5–9, 13–19, 23
TOTAL2375158833% 

Tests Skipped Failures Errors Time
14 0 💤 0 ❌ 0 🔥 51.457s ⏱️

@codecov

codecov Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.42857% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 66.34%. Comparing base (ddb7ea0) to head (8b5f53e).

Files with missing lines Patch % Lines
server/services/email/__init__.py 90.90% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #125      +/-   ##
==========================================
+ Coverage   66.14%   66.34%   +0.19%     
==========================================
  Files          29       29              
  Lines        2369     2383      +14     
==========================================
+ Hits         1567     1581      +14     
  Misses        802      802              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

Copy link
Copy Markdown

🚀 Staging Preview Deployed!

URL: https://preview-seating-pr-125-137456085712.us-west1.run.app

@github-actions

Copy link
Copy Markdown

Coverage

Coverage for unit tests for Python 3.14
FileStmtsMissCoverMissing
server
   __init__.py52492%55, 65–67, 87
   cache.py6183%12
   forms.py1381490%23–25, 41–43, 70, 93, 168–171, 178, 186
   models.py2112688%60, 64, 67, 70, 73, 76–81, 143–152, 182, 200, 257, 260, 283, 327, 341
   views.py76752032%39, 49–85, 95–129, 140–144, 161–170, 186–203, 214–221, 230–252, 263–276, 285, 296–299, 317–318, 320–325, 327–328, 342–366, 375–397, 406–433, 450–452, 462–504, 544–574, 583–613, 633, 647–650, 652–655, 664–694, 703–720, 751–755, 764–810, 816–868, 874–884, 889–910, 918–939, 971, 995, 1040–1049, 1051, 1066–1067, 1072–1085, 1099, 1104, 1131–1132
server/controllers
   __init__.py821779%27–28, 36–39, 43, 72, 76, 85, 101–110
   auth_controllers.py402342%22–51, 57–59
   health_controllers.py21767%18–19, 24–28
server/services/auth
   __init__.py28389%25–26, 30
server/services/canvas
   __init__.py806124%18–25, 29, 33, 37, 43, 55–64, 69–105, 109–124, 128
   fake_canvas.py392633%9, 12, 15, 20–25, 28–29, 37–46, 49–56
server/services/core
   assign.py76396%25, 28, 113
   data.py613444%13–18, 22–26, 30–34, 38–40, 44–55, 90–92, 101–102, 106–107
   export.py27678%36–38, 40–42
   room.py611870%11–33, 57, 70–71, 74
   student.py184597%33, 37–38, 47, 268
server/services/email
   __init__.py50394%47, 74, 92
   smtp.py922573%18, 49–51, 68–76, 88–89, 101–108, 112–114, 125–126
server/services/email/templates
   __init__.py33197%20
server/typings
   exception.py34488%42, 53, 59–60
server/utils
   date.py7186%30
   misc.py16756%13–19
TOTAL237580966% 

Tests Skipped Failures Errors Time
277 0 💤 0 ❌ 0 🔥 14.370s ⏱️

@github-actions

Copy link
Copy Markdown

Coverage

Coverage for e2e tests for Python 3.14
FileStmtsMissCoverMissing
server
   __init__.py521277%13, 16–22, 27, 55, 65–67, 87
   cache.py6183%12
   forms.py1382185%23–25, 41–43, 68–70, 92–98, 168–171, 178, 186
   models.py2117067%27, 60, 64, 67, 70, 73, 76–81, 111, 126–132, 136, 140, 143–152, 161–162, 169–170, 177–182, 189–197, 200, 214, 217–219, 222, 226, 238, 242, 246, 250–251, 257, 260, 280, 283, 304, 309, 314, 319, 324, 327, 341
   views.py76765614%38–40, 49–85, 95–129, 140–144, 161–170, 186–203, 214–221, 230–252, 263–276, 285, 296–299, 310–329, 342–366, 375–397, 406–433, 442–453, 462–504, 515–523, 531–535, 544–574, 583–613, 622–655, 664–694, 703–720, 726–729, 739–746, 751–755, 764–810, 816–868, 874–884, 889–910, 915–945, 950–954, 962–972, 976–978, 983–1016, 1026–1030, 1037–1061, 1066–1067, 1072–1085, 1094, 1099, 1104, 1120–1136, 1146–1147
server/controllers
   __init__.py824644%26–44, 47–49, 60–87, 90, 101–110, 113
   auth_controllers.py402635%14–16, 22–51, 57–59
   dev_login_controllers.py584424%17–30, 35–52, 60–61, 68–79, 84–101
   health_controllers.py211243%9, 14–19, 24–28
server/services/auth
   __init__.py28775%25–26, 30, 62–63, 68–69
server/services/canvas
   __init__.py806124%18–25, 29, 33, 37, 43, 55–64, 69–105, 109–124, 128
   fake_canvas.py392633%9, 12, 15, 20–25, 28–29, 37–46, 49–56
server/services/core
   assign.py765824%10–13, 16, 19, 22, 25, 28, 36, 40–46, 54–55, 63, 67, 83–148, 162–178
   data.py614133%13–18, 22–26, 30–34, 38–40, 44–55, 62, 72–74, 79, 90–92, 96–97, 101–102, 106–107
   export.py272219%11–45
   layout_preview.py925738%81, 85, 90, 94, 105–110, 116–119, 128–130, 140, 147, 159–162, 167–171, 175–177, 199–200, 206–209, 218–222, 227, 239–259
   room.py61568%11–33, 41–89
   student.py18415217%33, 37–38, 42, 46–48, 57–60, 70–72, 79, 88–93, 104–107, 110–114, 122–136, 140–143, 147, 151, 158–291
server/services/csv
   __init__.py261735%11–12, 20, 27–30, 40, 54–56, 63–68
server/services/email
   __init__.py503824%23–40, 44–81, 90–101
   smtp.py927321%18, 26–39, 43–52, 57–77, 83–115, 120–126
server/services/email/templates
   __init__.py332330%9–11, 14–17, 20, 30–41, 45–47
server/services/google
   __init__.py553929%21–36, 40–43, 47–53, 57–76
server/typings
   exception.py341362%20–36, 42, 47, 53, 59–60, 69, 72
server/utils
   date.py7443%17–19, 30
   misc.py161319%5–9, 13–19, 23
TOTAL2375158833% 

Tests Skipped Failures Errors Time
14 0 💤 0 ❌ 0 🔥 33.325s ⏱️

CC is how course staff see a student's reply-all, so every student's
email has to carry it: send the form's CC and BCC with every batch, not
just the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Coverage

Coverage for unit tests for Python 3.14
FileStmtsMissCoverMissing
server
   __init__.py52492%55, 65–67, 87
   cache.py6183%12
   forms.py1381490%23–25, 41–43, 70, 93, 168–171, 178, 186
   models.py2112688%60, 64, 67, 70, 73, 76–81, 143–152, 182, 200, 257, 260, 283, 327, 341
   views.py76752032%39, 49–85, 95–129, 140–144, 161–170, 186–203, 214–221, 230–252, 263–276, 285, 296–299, 317–318, 320–325, 327–328, 342–366, 375–397, 406–433, 450–452, 462–504, 544–574, 583–613, 633, 647–650, 652–655, 664–694, 703–720, 751–755, 764–810, 816–868, 874–884, 889–910, 918–939, 971, 995, 1040–1049, 1051, 1066–1067, 1072–1085, 1099, 1104, 1131–1132
server/controllers
   __init__.py821779%27–28, 36–39, 43, 72, 76, 85, 101–110
   auth_controllers.py402342%22–51, 57–59
   health_controllers.py21767%18–19, 24–28
server/services/auth
   __init__.py28389%25–26, 30
server/services/canvas
   __init__.py806124%18–25, 29, 33, 37, 43, 55–64, 69–105, 109–124, 128
   fake_canvas.py392633%9, 12, 15, 20–25, 28–29, 37–46, 49–56
server/services/core
   assign.py76396%25, 28, 113
   data.py613444%13–18, 22–26, 30–34, 38–40, 44–55, 90–92, 101–102, 106–107
   export.py27678%36–38, 40–42
   room.py611870%11–33, 57, 70–71, 74
   student.py184597%33, 37–38, 47, 268
server/services/email
   __init__.py50394%47, 74, 92
   smtp.py922573%18, 49–51, 68–76, 88–89, 101–108, 112–114, 125–126
server/services/email/templates
   __init__.py33197%20
server/typings
   exception.py34488%42, 53, 59–60
server/utils
   date.py7186%30
   misc.py16756%13–19
TOTAL237580966% 

Tests Skipped Failures Errors Time
277 0 💤 0 ❌ 0 🔥 11.509s ⏱️

@github-actions

Copy link
Copy Markdown

Coverage

Coverage for e2e tests for Python 3.14
FileStmtsMissCoverMissing
server
   __init__.py521277%13, 16–22, 27, 55, 65–67, 87
   cache.py6183%12
   forms.py1382185%23–25, 41–43, 68–70, 92–98, 168–171, 178, 186
   models.py2117067%27, 60, 64, 67, 70, 73, 76–81, 111, 126–132, 136, 140, 143–152, 161–162, 169–170, 177–182, 189–197, 200, 214, 217–219, 222, 226, 238, 242, 246, 250–251, 257, 260, 280, 283, 304, 309, 314, 319, 324, 327, 341
   views.py76765614%38–40, 49–85, 95–129, 140–144, 161–170, 186–203, 214–221, 230–252, 263–276, 285, 296–299, 310–329, 342–366, 375–397, 406–433, 442–453, 462–504, 515–523, 531–535, 544–574, 583–613, 622–655, 664–694, 703–720, 726–729, 739–746, 751–755, 764–810, 816–868, 874–884, 889–910, 915–945, 950–954, 962–972, 976–978, 983–1016, 1026–1030, 1037–1061, 1066–1067, 1072–1085, 1094, 1099, 1104, 1120–1136, 1146–1147
server/controllers
   __init__.py824644%26–44, 47–49, 60–87, 90, 101–110, 113
   auth_controllers.py402635%14–16, 22–51, 57–59
   dev_login_controllers.py584424%17–30, 35–52, 60–61, 68–79, 84–101
   health_controllers.py211243%9, 14–19, 24–28
server/services/auth
   __init__.py28775%25–26, 30, 62–63, 68–69
server/services/canvas
   __init__.py806124%18–25, 29, 33, 37, 43, 55–64, 69–105, 109–124, 128
   fake_canvas.py392633%9, 12, 15, 20–25, 28–29, 37–46, 49–56
server/services/core
   assign.py765824%10–13, 16, 19, 22, 25, 28, 36, 40–46, 54–55, 63, 67, 83–148, 162–178
   data.py614133%13–18, 22–26, 30–34, 38–40, 44–55, 62, 72–74, 79, 90–92, 96–97, 101–102, 106–107
   export.py272219%11–45
   layout_preview.py925738%81, 85, 90, 94, 105–110, 116–119, 128–130, 140, 147, 159–162, 167–171, 175–177, 199–200, 206–209, 218–222, 227, 239–259
   room.py61568%11–33, 41–89
   student.py18415217%33, 37–38, 42, 46–48, 57–60, 70–72, 79, 88–93, 104–107, 110–114, 122–136, 140–143, 147, 151, 158–291
server/services/csv
   __init__.py261735%11–12, 20, 27–30, 40, 54–56, 63–68
server/services/email
   __init__.py503824%23–40, 44–81, 90–101
   smtp.py927321%18, 26–39, 43–52, 57–77, 83–115, 120–126
server/services/email/templates
   __init__.py332330%9–11, 14–17, 20, 30–41, 45–47
server/services/google
   __init__.py553929%21–36, 40–43, 47–53, 57–76
server/typings
   exception.py341362%20–36, 42, 47, 53, 59–60, 69, 72
server/utils
   date.py7443%17–19, 30
   misc.py161319%5–9, 13–19, 23
TOTAL2375158833% 

Tests Skipped Failures Errors Time
14 0 💤 0 ❌ 0 🔥 32.521s ⏱️

@cycomachead

Copy link
Copy Markdown

Does the db already track who was emailed? I guess so? (Do we ever expire this if the seat changes? separate issue..)

It seems like this requires the user to keep the page open, since the JS is still polling? If so we'll want to prevent people from closing the tab prematurely and maybe give a more clear estimate of progress.

This is still definitely better than what's there today, but I think we will still want a background job system.

@pancakereport

pancakereport commented Oct 15, 2026 •

Copy link
Copy Markdown
Author

Does the db already track who was emailed?

Yes, but it only writes to the DB after everyone is emailed. So if the emailing action times out before everyone is emailed, then the DB doesn't update.

Do we ever expire this if the seat changes?

This expires when reassigning one student on their assign page, bulk assigning students, deleting an assignment and assigning again later, and if on import a student's preferences no longer fit their seat, the seat is removed and their emailed status is reset.

There's two times where the email may be out of date but the email status remains: if you edit a room's name or time, and if you import the student with a new seat and with emailed as true.

It seems like this requires the user to keep the page open, since the JS is still polling? If so we'll want to prevent people from closing the tab prematurely and maybe give a more clear estimate of progress.

True, the user has to keep the page open for everything to finish. If the tab closes partway, the batch already sent finishes on the server and those students are marked emailed, but no subsequent batches are sent. Reloading/navigating back to the page will show accurately who has and who has not been emailed, but I could see staff assuming that they clicked email and didn't get any warnings so everyone got emailed.

The page sends each batch itself, so closing it stops the students not
emailed yet from being emailed. While sending, it now shows a progress
bar, a note to keep the page open, and once the first batch is back an
estimate of the time left; leaving the page asks first. All of it is
removed when sending finishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Coverage

Coverage for e2e tests for Python 3.14
FileStmtsMissCoverMissing
server
   __init__.py521277%13, 16–22, 27, 55, 65–67, 87
   cache.py6183%12
   forms.py1382185%23–25, 41–43, 68–70, 92–98, 168–171, 178, 186
   models.py2117067%27, 60, 64, 67, 70, 73, 76–81, 111, 126–132, 136, 140, 143–152, 161–162, 169–170, 177–182, 189–197, 200, 214, 217–219, 222, 226, 238, 242, 246, 250–251, 257, 260, 280, 283, 304, 309, 314, 319, 324, 327, 341
   views.py76765614%38–40, 49–85, 95–129, 140–144, 161–170, 186–203, 214–221, 230–252, 263–276, 285, 296–299, 310–329, 342–366, 375–397, 406–433, 442–453, 462–504, 515–523, 531–535, 544–574, 583–613, 622–655, 664–694, 703–720, 726–729, 739–746, 751–755, 764–810, 816–868, 874–884, 889–910, 915–945, 950–954, 962–972, 976–978, 983–1016, 1026–1030, 1037–1061, 1066–1067, 1072–1085, 1094, 1099, 1104, 1120–1136, 1146–1147
server/controllers
   __init__.py824644%26–44, 47–49, 60–87, 90, 101–110, 113
   auth_controllers.py402635%14–16, 22–51, 57–59
   dev_login_controllers.py584424%17–30, 35–52, 60–61, 68–79, 84–101
   health_controllers.py211243%9, 14–19, 24–28
server/services/auth
   __init__.py28775%25–26, 30, 62–63, 68–69
server/services/canvas
   __init__.py806124%18–25, 29, 33, 37, 43, 55–64, 69–105, 109–124, 128
   fake_canvas.py392633%9, 12, 15, 20–25, 28–29, 37–46, 49–56
server/services/core
   assign.py765824%10–13, 16, 19, 22, 25, 28, 36, 40–46, 54–55, 63, 67, 83–148, 162–178
   data.py614133%13–18, 22–26, 30–34, 38–40, 44–55, 62, 72–74, 79, 90–92, 96–97, 101–102, 106–107
   export.py272219%11–45
   layout_preview.py925738%81, 85, 90, 94, 105–110, 116–119, 128–130, 140, 147, 159–162, 167–171, 175–177, 199–200, 206–209, 218–222, 227, 239–259
   room.py61568%11–33, 41–89
   student.py19215917%33, 37–38, 42, 46–48, 57–60, 70–72, 79, 88–93, 104–107, 110–114, 122–136, 140–143, 152–157, 161, 165, 172–307
server/services/csv
   __init__.py261735%11–12, 20, 27–30, 40, 54–56, 63–68
server/services/email
   __init__.py503824%23–40, 44–81, 90–101
   smtp.py927321%18, 26–39, 43–52, 57–77, 83–115, 120–126
server/services/email/templates
   __init__.py332330%9–11, 14–17, 20, 30–41, 45–47
server/services/google
   __init__.py553929%21–36, 40–43, 47–53, 57–76
server/typings
   exception.py341362%20–36, 42, 47, 53, 59–60, 69, 72
server/utils
   date.py7443%17–19, 30
   misc.py161319%5–9, 13–19, 23
TOTAL2383159533% 

Tests Skipped Failures Errors Time
14 0 💤 0 ❌ 0 🔥 39.939s ⏱️

@pancakereport

Copy link
Copy Markdown
Author

I added a progress bar:
Screenshot 2026-10-15 at 2 36 52 PM
Screenshot 2026-10-15 at 2 37 06 PM

The time estimate is added after the first batch of 50 completes. It took about 1.5 minutes for ~300 students, so we should see about improving this further.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Coverage

Coverage for unit tests for Python 3.14
FileStmtsMissCoverMissing
server
   __init__.py52492%55, 65–67, 87
   cache.py6183%12
   forms.py1381490%23–25, 41–43, 70, 93, 168–171, 178, 186
   models.py2112688%60, 64, 67, 70, 73, 76–81, 143–152, 182, 200, 257, 260, 283, 327, 341
   views.py76751333%39, 49–85, 95–129, 140–144, 161–170, 186–203, 214–221, 230–252, 263–276, 285, 296–299, 317–318, 320–325, 327–328, 342–366, 375–397, 406–433, 450–452, 462–504, 544–574, 583–613, 633, 647–650, 652–655, 664–694, 703–720, 751–755, 764–810, 816–868, 880–882, 889–910, 918–939, 971, 995, 1040–1049, 1051, 1066–1067, 1072–1085, 1099, 1104, 1131–1132
server/controllers
   __init__.py821779%27–28, 36–39, 43, 72, 76, 85, 101–110
   auth_controllers.py402342%22–51, 57–59
   health_controllers.py21767%18–19, 24–28
server/services/auth
   __init__.py28389%25–26, 30
server/services/canvas
   __init__.py806124%18–25, 29, 33, 37, 43, 55–64, 69–105, 109–124, 128
   fake_canvas.py392633%9, 12, 15, 20–25, 28–29, 37–46, 49–56
server/services/core
   assign.py76396%25, 28, 113
   data.py613444%13–18, 22–26, 30–34, 38–40, 44–55, 90–92, 101–102, 106–107
   export.py27678%36–38, 40–42
   room.py611870%11–33, 57, 70–71, 74
   student.py192597%33, 37–38, 47, 284
server/services/email
   __init__.py50394%47, 74, 92
   smtp.py922573%18, 49–51, 68–76, 88–89, 101–108, 112–114, 125–126
server/services/email/templates
   __init__.py33197%20
server/typings
   exception.py34488%42, 53, 59–60
server/utils
   date.py7186%30
   misc.py16756%13–19
TOTAL238380266% 

Tests Skipped Failures Errors Time
294 0 💤 0 ❌ 0 🔥 13.969s ⏱️

@cycomachead

Copy link
Copy Markdown

Ah, I didn't realize we actually saved whether we had emailed a student. We should fix the data issue separately.

The progress bar is nice!, I would add a beforeunload hook in JS to reduce closing/navigating from the tab if it is active. That way you at least get a little reminder "please don't leave this tab" or accidentally close it.

Unfortunately, based on my experience, ~1.5 minutes for 300 emails is actually pretty good. The basic SMTP/sending APIs are just kinda slow. we should still move to a background job service after we get this new deploy running.

@pancakereport

Copy link
Copy Markdown
Author

The beforeunload hook is already there!

@pancakereport
pancakereport merged commit 4a78e5d into main Oct 16, 2026
13 checks passed
@pancakereport
pancakereport deleted the email-in-chunks branch October 16, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Application Error: Email Seating for >200 Students

2 participants