When you work on the Oppia codebase, you'll often need to figure out what file contains the code driving some feature. This tutorial will show you how to identify these files by working through an example. Suppose we are trying to cultivate a learning community among Oppia learners. To help with that, we want to add a field to user profiles where users can describe what concepts they have experience with. This will help other learners figure out who they can ask for help.
Before working through this tutorial, you should read through the [[Overview of Oppia's codebase|Overview-of-the-Oppia-codebase]].
Warning: This is NOT a tutorial on how to make a code change to Oppia. We will be writing code to support editing and viewing the new user experience field, but our changes will only be enough to confirm that we have identified all the relevant files. Our code will not be ready for production. Instead, working through the steps here will give you the understanding you need to write a design doc.
In this tutorial, we'll work from the more user-facing code all the way back to the database. This will let us see the results of our changes in a local development server at each step.
First, let's take a look at the page we want to modify. Start up a development server:
python -m scripts.startA development server will open at http://localhost:8181. Create a new account and navigate to the preferences page at http://localhost:8181/preferences. It should look like this:
We want to add a new field under the user bio to store the user's experience. First, we need to find the HTML file that provides the user's bio. Here are two approaches:
- Use your browser's developer tools to inspect the page's HTML. Find an HTML class you think will be unique, for example
e2e-test-user-bio. Then search the codebase for that string. - Rely on your knowledge of how the code files in Oppia are organized. We know that the HTML files for each component are under
core/templates/pages. In that folder, we see apreferences-pagedirectory, which looks promising since we are trying to edit the preferences page. Inside that folder, we see thepreferences-page.component.htmlfile.
Either approach should lead you to core/templates/pages/preferences-page/preferences-page.component.html.
-
Find the code for the bio field. You can find this block by noticing from your browser's development tools that the
e2e-test-user-bioclass is associated with the bio field. If you search for that string, you should find a section of code that looks like this:<div class="form-group row"> <label for="label-target-user-bio" class="col-lg-4 col-md-4 col-sm-4">{{ 'I18N_PREFERENCES_BIO' | translate }}</label> <div class="col-lg-8 col-md-8 col-sm-8"> <textarea id="label-target-user-bio" class="oppia-bio-border e2e-test-user-bio oppia-autofocus" [(ngModel)]="userBio" [attr.value]="userBio" (blur)="saveUserBio(userBio)" (change)="registerBioChanged()" rows="5" maxlength="2000"> </textarea> <span class="form-text oppia-form-text">{{ 'I18N_PREFERENCES_BIO_EXPLAIN_TEXT' | translate }}</span> </div> </div> <!-- Add the experience field here --> </div>
Pay close attention to the indentation! The last
</div>doesn't close thedivtag at the top, it closes adivfrom earlier in the code. Therefore, we want to add the experience field where the comment (which I added) is. -
To confirm that we've found the right spot, add the following lines just below the block of code that adds the bio field.
<div class="form-group row"> <label for="label-target-user-experience" class="col-lg-4 col-md-4 col-sm-4">{{ 'I18N_PREFERENCES_EXPERIENCE' | translate }}</label> <div class="col-lg-8 col-md-8 col-sm-8"> <textarea id="label-target-user-experience" class="oppia-experience-border" rows="5" maxlength="2000"> </textarea> <span class="form-text oppia-form-text">{{ 'I18N_PREFERENCES_BIO_EXPLAIN_TEXT' | translate }}</span> </div> </div>
-
Restart the development by killing it with Ctrl-C and then running
python -m scripts.startagain. Load the preferences page again, and you should see something like this:Oops! That
I18N_PREFERENCES_EXPERIENCEtext should instead say something likePreferences. Translations are stored inassets/i18n, so We need to add a translation in the English file,assets/i18n/en.json:"I18N_PREFERENCES_BIO": "Bio", + "I18N_PREFERENCES_EXPERIENCE": "Experience", "I18N_PREFERENCES_BIO_EXPLAIN_TEXT": "This field is optional. Anything you write here is public and world-viewable.",Now let's try reloading the development server again. Now you should see this:
The presence of the user experience field confirms that we edited the right file.
-
Next we need to update the component's
*.tsfile to add some logic to our field. This file will have the same path as the HTML file we just edited, except it will end in.tsinstead of.html. The file iscore/templates/pages/preferences-page/preferences-page.component.ts, and there you'll find aPreferencesPageComponentclass. Add to that class an instance variable for the user experience with a default value so we can see that it works:userBio: string; + userExperience: string = 'I have learned a lot!'; defaultDashboard: string;Let's also add a method to handle saving the user bio:
saveUserExperience(userExperience: string): void { console.log(`DEBUG: Saved user experience: ${userExperience}`); }
In this case we haven't written the backend code to support saving, but we'll be able to see in the console log that the method was called.
-
Now we can update the component HTML file to use this new logic. Update the
textareatag we added as follows:<textarea id="label-target-user-experience" class="oppia-experience-border" + [(ngModel)]="userExperience" + (blur)="saveUserExperience(userExperience)" rows="5" maxlength="2000"> </textarea>[(ngModel)]="userExperience"tells Angular to bind the value of thetextareaelement to the value of theuserExperienceattribute of the component class.(blur)=saveUserExperience(userExperience)causes thesaveUserExperiencemethod we wrote to be called with the value of theuserExperienceinstance field whenever theblurevent occurs. For more details, see Angular's NgModel docs and Angular's documentation on event binding.Now when we restart the server and load the preferences page, we see our default user experience text:
If you change the text in the experience field, say to
I have learned a lot! Yay!, and then click away, you should see the console message from ourconsole.logstatement:DEBUG: Saved user experience: I have learned a lot! Yay!. -
Next, let's tell the component to send updates to the experience field to the backend by changing the
saveUserExperiencemethod that we referenced in our HTML code above.saveUserExperience(userExperience: string): void { - console.log(`DEBUG: Saved user experience: ${userExperience}`); + this._saveDataItem('user_experience', userExperience); }Now when you try to change the experience field text, you'll see an error in the console:
ERROR Error: Uncaught (in promise): HttpErrorResponse: {"headers":{"normalizedNames":{},"lazyUpdate":null},"status":400,"statusText":"Bad Request","url":"http://localhost:8181/preferenceshandler/data","ok":false,"name":"HttpErrorResponse","message":"Http failure response for http://localhost:8181/preferenceshandler/data: 400 Bad Request","error":{"status_code":400,"error":"Invalid update type: user_experience"}}You should also see a similar error in your terminal output:
InvalidInputException: Invalid update type: user_experienceThis is because the backend isn't set up to handle updates to our new field. Note that we skipped the user backend API service. Since we are seeing an error in the terminal, we know our update to the user experience field is getting to the backend code, so the frontend's backend API service doesn't need to be changed. You should take a look at
core/templates/services/user-backend-api.service.tsthough to understand how it works. In particular, we care about theupdateUserPreferencesDataAsyncmethod:async updatePreferencesDataAsync( updateType: string, data: boolean | string | string[] | SubscriptionSummary[]): Promise<UpdatePreferencesResponse> { return this.http.put<UpdatePreferencesResponse>(this.PREFERENCES_DATA_URL, { update_type: updateType, data: data }).toPromise(); }
Next let's identify the backend files involved in handling updates to the user's profile.
-
We see that
updatePreferencesDataAsyncissues a PUT request againstPREFERENCES_DATA_URL, which is/preferenceshandler/data. This path is assigned to thePREFERENCES_DATA_URLconstant infeconf.py, and if you search for that constant inmain.py, you'll see it mapped to theprofile.PreferencesHandlercontroller, which is you can find incore/controllers/profile.py. In that controller, add code to handle theuser_experienceupdate type:if update_type == 'user_bio': if len(data) > feconf.MAX_BIO_LENGTH_IN_CHARS: raise self.InvalidInputException( 'User bio exceeds maximum character limit: %s' % feconf.MAX_BIO_LENGTH_IN_CHARS) else: user_services.update_user_bio(self.user_id, data) + elif update_type == 'user_experience': + if len(data) > feconf.MAX_BIO_LENGTH_IN_CHARS: + raise self.InvalidInputException( + 'User experience exceeds maximum character limit: %s' + % feconf.MAX_BIO_LENGTH_IN_CHARS) + else: + print( + 'DEBUG Controller received update: ' + + data)Note that since we set the bio and experience maximum lengths to be the same, we are re-using the
MAX_BIO_LENGTH_IN_CHARSvariable for the user experience.When you restart the development server and update your experience field, you should see no console errors in your browser and the debugging message we added in your terminal:
DEBUG Controller received update: I have learned a lot! Yay! -
Next, lets add a service to the
core/domain/user_services.pyto handle updating the user experience field. This will ensure our code follows the pattern used for updating the user bio, which we saw from the controller calls auser_services.update_user_biofunction. Here's the code for our service:def update_user_experience(user_id, user_experience): """Updates user_experience of user with given user_id. Args: user_id: str. The unique ID of the user. user_experience: str. New user experience to be set. """ user_settings = get_user_settings(user_id, strict=True) user_settings.user_experience = user_experience print( 'DEBUG: update_user_experience: ' + user_experience) _save_user_settings(user_settings)
Now we can replace our print statement in the controller with a call to this service:
elif update_type == 'user_experience': if len(data) > feconf.MAX_BIO_LENGTH_IN_CHARS: raise self.InvalidInputException( 'User experience exceeds maximum character limit: %s' % feconf.MAX_BIO_LENGTH_IN_CHARS) else: - print( - 'DEBUG Controller received update: ' + - data) + user_services.update_user_experience(self.user_id, data)When you restart the development server and update your experience field, you should see our print statement output in the terminal:
DEBUG: update_user_experience: I have learned a lot! Yay! -
Next, we need to update the domain model. Let's take a moment to look at how the
_save_user_settingsfunction works. Here's the code:def _save_user_settings(user_settings): """Commits a user settings object to the datastore. Args: user_settings: UserSettings. The user setting domain object to be saved. """ user_settings.validate() user_settings_dict = user_settings.to_dict() # If user with the given user_id already exists, update that model # with the given user settings, otherwise, create a new one. user_model = user_models.UserSettingsModel.get_by_id(user_settings.user_id) if user_model is not None: user_model.populate(**user_settings_dict) else: user_settings_dict['id'] = user_settings.user_id user_model = user_models.UserSettingsModel(**user_settings_dict) # TODO(#12755): Remove update_roles_and_banned_fields call once roles and # banned fields are in use. update_roles_and_banned_fields(user_model) user_model.update_timestamps() user_model.put()
We first convert the new user settings object to a dictionary, then we fetch the existing user settings model and populate it, and finally we put the updated model back into the datastore. This means that just need to tell the
UserSettingsdomain model incore/domain/user_domain.pyabout ouruser_experiencefield.First, let's add a
user_experienceargument to the constructor, assign that argument to an instance field, and update the appropriate docstrings:@@ -55,6 +55,7 @@ class UserSettings: a dataURI string. default_dashboard: str or None. The default dashboard of the user. user_bio: str. User-specified biography. + user_experience: str. User-specified experience description. subject_interests: list(str) or None. Subject interests specified by the user. first_contribution_msec: float or None. The time in milliseconds when @@ -77,7 +78,7 @@ class UserSettings: profile_picture_data_url=None, default_dashboard=None, creator_dashboard_display_pref=( constants.ALLOWED_CREATOR_DASHBOARD_DISPLAY_PREFS['CARD']), - user_bio='', subject_interests=None, first_contribution_msec=None, + user_bio='', user_experience='', subject_interests=None, first_contribution_msec=None, preferred_language_codes=None, preferred_site_language_code=None, preferred_audio_language_code=None, pin=None, display_alias=None, deleted=False, created_on=None): @@ -106,6 +107,7 @@ class UserSettings: creator_dashboard_display_pref: str. The creator dashboard of the user. user_bio: str. User-specified biography. + user_experience: str. User-specified experience description. subject_interests: list(str) or None. Subject interests specified by the user. first_contribution_msec: float or None. The time in milliseconds @@ -140,6 +142,7 @@ class UserSettings: self.default_dashboard = default_dashboard self.creator_dashboard_display_pref = creator_dashboard_display_pref self.user_bio = user_bio + self.user_experience = user_experience self.subject_interests = ( subject_interests if subject_interests else []) self.first_contribution_msec = first_contribution_msec
Next, we need to update the
to_dictfunction to include theuser_experiencefield:@@ -293,6 +296,7 @@ class UserSettings: 'creator_dashboard_display_pref': ( self.creator_dashboard_display_pref), 'user_bio': self.user_bio, + 'user_experience': self.user_experience, 'subject_interests': self.subject_interests, 'first_contribution_msec': self.first_contribution_msec, 'preferred_language_codes': self.preferred_language_codes,
Now if you restart the development server, you should get an error as soon as you log in:
AttributeError: type object 'UserSettingsModel' has no attribute 'user_experience'This happens because we haven't updated the storage model yet, so there is no
user_experiencefield to put into the domain model. Let's fix that next. -
The user storage model is defined in
core/storage/user/gae_models.py. We can find this file this because all storage models are defined ingae_models.pyfiles. Add an instance field to theUserSettingsModelclass:# User specified experience description. user_experience = datastore_services.TextProperty( indexed=False, default='')
Now if you restart the development server and log in, you should not see any errors.
Now we can supposedly change the user's experience field and save it to the datastore, but we need to confirm that this is actually happening. Let's add support for viewing the updated field to make sure everything's working.
Now that we have the user experience field added to all the models, we can update the backend code to serve that field when loading a user's preferences.
-
The backend storage models and domain models know about the user experience field, but we need to tell the user services to include the field when converting a storage model to a domain model. If you trace through how user models are retrieved through the user services, you'll find that the
_get_user_settings_from_modelfunction incore/domain/user_services.pyextracts the instance fields from the storage model. Let's add code to that function:user_bio=user_settings_model.user_bio, + user_experience=user_settings_model.user_experience, subject_interests=user_settings_model.subject_interests,Now let's check our work by adding a print statement to
get_user_settingsincore/domain/user_services.py:user_settings = get_users_settings([user_id])[0] if strict and user_settings is None: logging.error('Could not find user with id %s' % user_id) raise Exception('User not found.') + if user_settings: + print( + 'DEBUG get_user_settings experience: ' + + user_settings.user_experience) return user_settingsYou can also remove the other print statements in that file to clean up the output. Now when you restart the server, set the user experience, and reload the preferences page, you should see in the terminal output that your updated experience text was retrieved from the datastore.
-
Now the backend models will contain our new field. Since these models get used throughout the backend, most of the backend code already handles our new field correctly. We just need to update the controller to include the user experience field when it converts the backend domain model into a dictionary. Update the
PreferencesHandler.get()method incore/controllers/profile.py:self.values.update({ 'preferred_language_codes': user_settings.preferred_language_codes, 'preferred_site_language_code': ( user_settings.preferred_site_language_code), 'preferred_audio_language_code': ( user_settings.preferred_audio_language_code), 'profile_picture_data_url': user_settings.profile_picture_data_url, 'default_dashboard': user_settings.default_dashboard, 'user_bio': user_settings.user_bio, + 'user_experience': user_settings.user_experience, 'subject_interests': user_settings.subject_interests, 'can_receive_email_updates': ( user_email_preferences.can_receive_email_updates), 'can_receive_editor_role_email': ( user_email_preferences.can_receive_editor_role_email), 'can_receive_feedback_message_email': ( user_email_preferences.can_receive_feedback_message_email), 'can_receive_subscription_email': ( user_email_preferences.can_receive_subscription_email), 'subscription_list': subscription_list }) + print(self.values) self.render_json(self.values)To clean up the output, you should also remove the print statements we put in
core/domain/user_services.py. Now, restart the development server and load the preferences page. In your terminal, you should see a dictionary printed with'user_experience'mapped to''. Next, add some text to the experience field and reload the page. In the terminal, you should now see that the dictionary maps'user_experience'to the text you submitted.
Now the backend is providing us with the updated user experience text, but we need to tell the frontend how to display it.
-
Update the
PreferencesBackendDicttype incore/templates/services/user-backend-api.service.tsto add auser_experiencefield:export interface PreferencesBackendDict { 'preferred_language_codes': string[]; 'preferred_site_language_code': string; 'preferred_audio_language_code': string; 'profile_picture_data_url': string; 'default_dashboard': string; 'user_bio': string; + 'user_experience': string; 'subject_interests': string; 'can_receive_email_updates': boolean; 'can_receive_editor_role_email': boolean; 'can_receive_feedback_message_email': boolean; 'can_receive_subscription_email': boolean; 'subscription_list': SubscriptionSummary[]; }This reflects the fact that when we fetch a user's preferences from the backend API, the dictionary will include the user experience field from the backend domain model.
-
Load the
user_experiencefield in the preferences page component'sngOnInitmethod:preferencesPromise.then((data) => { this.userBio = data.user_bio; + this.userExperience = data.user_experience;We can also make the default value for user experience an empty string.
Now you should be able to log in, set the user experience, and reload the page to see your submitted text preserved in the experience field.
Congratulations, you've found all the files needed to make a full-stack change! Hopefully working through this exercise has helped you understand how all the parts of Oppia fit together. Now you can take what you've learned about finding code in Oppia to do something similar for your own change. Doing so will prepare you to write a design doc.
This tutorial gets you ready to write a design doc; there's a lot more to a full-stack change! For example:
- Considering data privacy and ethics in your design doc.
- Creating stacked pull requests to make reviews easier.
- Writing tests and documentation.
- Considering how to design the appearance of your frontend changes.
You can find a copy of the completed code on GitHub.



