Skip to content

Feature/edu role survey model - #10198

Draft
gmorador-tribu wants to merge 6 commits into
mainfrom
feature/edu-role-survey-model
Draft

gmorador-tribu wants to merge 6 commits into
mainfrom
feature/edu-role-survey-model

Conversation

@gmorador-tribu

@gmorador-tribu gmorador-tribu commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #10196 — do not merge until that migration has been applied in every environment and region.

Part 2 of the EDU role survey (product-backlog#1751). Code only, no migrations.

The answer lives in its own user_survey_response table, created by #10196, rather than in two columns on user. UserService.update_preferences upserts it on (user_id, survey), and the upsert only replaces a dismissal: an instructor or not_instructor answer is never overwritten, not even by two requests sent at the same time.

Why this is a draft

h doesn't run migrations on deploy, and merging deploys automatically. If this lands before 2fdc26d19dbc has been applied, anything that loads User.survey_responses fails with UndefinedTable: deleting a user, and any PATCH that sends instructor_survey_response. It comes out of draft once Tasks → "db current revision" reports 2fdc26d19dbc on staging and on prod in both regions (us-west-1 and ca-central-1).


Comment thread h/models/feature.py
"instructor_survey": (
"Show the EDU role survey in the client sidebar. Also the kill switch: "
"h stops sending the survey as soon as this is off. First-party only, "
"and enabling it for `everyone` will not widen it past the first-party "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"and enabling it for everyone will not widen it past the first-party "

Is this actually true?

Comment thread h/models/user.py Outdated
The column allows an answer to be replaced rather than being write-once,
because product plans to re-run the survey for the people who dismissed it
so they get to answer a second time. Nothing replaces one yet: the API
drops or rejects a second answer (h.views.api.profile.update_preferences),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so they get to answer a second time. Nothing replaces one yet: the API drops or rejects a second answer

There's no such check. UserService.update_preferences assigns edu_role_survey_response and edu_role_survey_responded_at unconditionally, so a retry after a lost response, or a dismissal from a second sidebar, silently replaces an earlier answer.

I'd either add the write-once guard or drop the sentence.

Declare the user_survey_response table added by migration 2fdc26d19dbc as
the UserSurveyResponse model, with User.survey_responses for the rows of a
user. Two enums name the valid values: Survey (edu_role) and
EduRoleSurveyResponse (instructor, not_instructor, dismissed).

The CheckConstraint is built from the enums so the model and the migration
can't drift apart.

Nothing reads or writes the table yet: no eligibility logic, no feature
flag, no API change.

DO NOT MERGE until migration 2fdc26d19dbc has been applied everywhere:
Tasks -> "db upgrade to head" on staging and on prod, in every region, and
Tasks -> "db current revision" reporting 2fdc26d19dbc. h doesn't run
migrations on deploy, so merging this first would leave the code declaring
a table that doesn't exist, and every load of User.survey_responses would
fail with UndefinedTable.
Register the instructor_survey feature flag and the
client.survey.instructor_role.shown analytics event.

Both are inert: nothing checks the flag and nothing emits the event yet. The
flag's row is created switched off by Feature.all(), and it gates the survey
from the server side so that turning it off stops h sending the survey from
the next profile fetch on, with no client release to propagate through the CDN
and the browser extension.

The flag description spells out that it is first-party only, and that ticking
`everyone` does not widen it past the first-party authority -- FeatureService
checks `everyone` before it looks at the user's authority.
Accept instructor_survey_response in update_preferences, validate it against
the EduRoleSurveyResponse enum, and record it in user_survey_response along
with the timestamp.

The answer is upserted on (user_id, survey), and the upsert only replaces a
dismissal: product plans to re-run the survey for the people who dismissed
it, so a dismissal has to be replaceable, but an "instructor" or
"not_instructor" answer is final. The rule is part of the same INSERT ... ON
CONFLICT DO UPDATE ... WHERE statement, so two answers sent at once can't
both see a dismissal and replace each other. The enum's docstring is updated
to say so -- it previously claimed we only ask once, which stopped being true
with that decision.

update_preferences stops being a staticmethod, because it now needs the
session. Every caller already calls it on the service instance.

The upsert goes around the session, which may already hold the user's
answers, so it RETURNs the row with populate_existing to refresh an answer
already loaded, and expires User.survey_responses to pick up a new one.

Validation failures raise TypeError, which the profile view already turns into
an HTTPBadRequest, so a bad value is a 400 rather than an IntegrityError from
the table's CHECK constraint.
The survey is only ever offered to first-party users, so only they can answer
it. Without this a third-party account -- an LMS user who could never have
been shown the panel -- can PATCH an answer straight into the column the
HubSpot sync will read, polluting the dataset the survey exists to collect.

Follows the authority enforcement in h/views/api/users.py::create: an explicit
check in the view raising ValidationError, which is h's HTTPBadRequest
subclass, rather than a bare HTTPBadRequest.

The check sits inside the existing try block on purpose. `preferences` is
unvalidated JSON straight off the request body, so `"..." in preferences`
raises TypeError for a non-mapping body such as {"preferences": null}; above
the try that would escape as a 500, where today the `**preferences` below
makes it a 400. There's a regression test for it.

This is only part of the eligibility the read side applies -- the feature flag
and the EDU domain check need h/session.py and h/util/edu_domains.py, and land
with them. The comment in the view says so.
…lder

Two documentation fixes to text that is read at the moment a decision is
made.

The EduRoleSurveyResponse docstring now says what
UserSurveyResponse.responded_at holds: when the answer sitting beside it was
recorded, which is the first answer unless a later one replaced a
dismissal. Whoever builds the HubSpot sync reads this docstring.

The warning that EDU_DOMAINS is a placeholder that matches every *.edu address
lived only in a comment in h/util/edu_domains.py. The person about to tick
`first_party` on the flag is reading the description in the admin feature UI,
which said nothing about it, so the warning was not where the mistake gets
made. It is in the flag description now.
The instructor_survey description said the flag is the only thing keeping the
placeholder domain list from real users. _feature_overrides turns any flag on
for any user, logged out included, from ?__feature__[<name>] in the query
string -- on the PATCH as much as the GET. The description names that
exception now; the comment in h/session.py already did.

This branch has not been deployed

No deployments
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.

2 participants