diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index a5e997bf2f..c57eeeb250 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -9,13 +9,13 @@ # Via Django auth.Group: - ".. no_pii:" : "No PII" + ".. no_pii:": "No PII" auth.Permission: - ".. no_pii:" : "No PII" + ".. no_pii:": "No PII" auth.User: ".. pii:": "Contains username, password, and email address, retired in AccountRetirementView" - ".. pii_types:" : username, email_address, password - ".. pii_retirement:" : local_api + ".. pii_types:": username, email_address, password + ".. pii_retirement:": local_api contenttypes.ContentType: ".. no_pii:": "No PII" admin.LogEntry: @@ -27,6 +27,66 @@ sessions.Session: sites.Site: ".. no_pii:": "No PII" +# Automatically generated edx-platform models that can't be annotated +calendar_sync.HistoricalUserCalendarSyncConfig: + ".. no_pii:": "No PII" +certificates.HistoricalCertificateAllowlist: + ".. no_pii:": "No PII" +certificates.HistoricalCertificateDateOverride: + ".. no_pii:": "No PII" +certificates.HistoricalCertificateInvalidation: + ".. no_pii:": "No PII" +certificates.HistoricalGeneratedCertificate: + ".. pii:": "PII can exist in the generated certificate linked to in this model. Certificate data is currently retained." + ".. pii_types:": "name, username" + ".. pii_retirement:": "retained" +course_apps.HistoricalCourseAppStatus: + ".. no_pii:": "No PII" +course_goals.HistoricalCourseGoal: + ".. no_pii:": "No PII" +course_live.HistoricalCourseLiveConfiguration: + ".. no_pii:": "No PII" +course_modes.HistoricalCourseMode: + ".. no_pii:": "No PII" +course_overviews.HistoricalCourseOverview: + ".. no_pii:": "No PII" +discussions.HistoricalDiscussionsConfiguration: + ".. no_pii:": "No PII" +entitlements.HistoricalCourseEntitlement: + ".. no_pii:": "No PII" +entitlements.HistoricalCourseEntitlementSupportDetail: + ".. no_pii:": "No PII" +experiments.HistoricalExperimentKeyValue: + ".. no_pii:": "No PII" +external_user_ids.HistoricalExternalId: + ".. no_pii:": "We store external_user_id here, but do not consider that PII under OEP-30." +external_user_ids.HistoricalExternalIdType: + ".. no_pii:": "No PII" +grades.HistoricalPersistentSubsectionGradeOverride: + ".. no_pii:": "No PII" +instructor_task.HistoricalInstructorTaskSchedule: + ".. no_pii:": "No PII" +program_enrollments.HistoricalProgramCourseEnrollment: + ".. no_pii:": "No PII" +program_enrollments.HistoricalProgramEnrollment: + ".. pii:": "PII is found in the external key for a program enrollment" + ".. pii_types:": "other" + ".. pii_retirement:": "local_api" +programs.HistoricalProgramDiscussionsConfiguration: + ".. no_pii:": "No PII" +programs.HistoricalProgramLiveConfiguration: + ".. no_pii:": "No PII" +schedules.HistoricalSchedule: + ".. no_pii:": "No PII" +split_modulestore_django.HistoricalSplitModulestoreCourseIndex: + ".. no_pii:": "No PII" +student.HistoricalCourseEnrollment: + ".. no_pii:": "No PII" +student.HistoricalManualEnrollmentAudit: + ".. pii:": "Contains enrolled_email, retired in LMSAccountRetirementView" + ".. pii_types:": "email_address" + ".. pii_retirement:": "local_api" + # Automatically generated models in edx-enterprise that can't be annotated there consent.HistoricalDataSharingConsent: ".. pii:": "The username field inherited from Consent contains PII." @@ -45,7 +105,7 @@ enterprise.HistoricalEnterpriseCustomerCatalog: enterprise.HistoricalEnterpriseCustomerEntitlement: ".. no_pii:": "No PII" -# Via ORA2 +# Via edx-ora2, these can be removed once the models are annotated for real assessment.Assessment: ".. no_pii:": "No PII" assessment.AssessmentFeedback: @@ -127,10 +187,24 @@ djcelery.TaskState: djcelery.WorkerState: ".. no_pii:": "No PII" +# Via django-celery-results +django_celery_results.ChordCounter: + ".. no_pii:": "No PII" +django_celery_results.GroupResult: + ".. no_pii:": "No PII" +django_celery_results.TaskResult: + ".. no_pii:": "No PII" + # Via edx-oauth2-provider https://github.com/edx/edx-oauth2-provider edx_oauth2_provider.TrustedClient: ".. no_pii:": "No PII" +# Via edx-name-affirmation, not part of the openedx org +edx_name_affirmation.HistoricalVerifiedName: + ".. pii:": "Contains name fields." + ".. pii_types:": "name" + ".. pii_retirement:": "local_api" + # Via VAL edxval.CourseVideo: ".. no_pii:": "No PII" @@ -149,6 +223,12 @@ edxval.VideoImage: edxval.VideoTranscript: ".. no_pii:": "No PII" +# Via PyLTI1p3 +lti1p3_tool_config.LtiTool: + ".. no_pii:": "No PII" +lti1p3_tool_config.LtiToolKey: + ".. no_pii:": "No PII" + # Via Milestones milestones.CourseContentMilestone: ".. no_pii:": "No PII" @@ -190,6 +270,10 @@ oauth2_provider.Grant: ".. pii:": "Contains 3rd party authentication secrets. Retired in DeactivateLogoutView." ".. pii_types:": password, other ".. pii_retirement:": local_api +oauth2_provider.IDToken: + ".. pii:": "Contains 3rd party authentication secrets, currently this is retained until the token times out, but should be retired explicitly with the other models from this package." + ".. pii_types:": password, other + ".. pii_retirement:": retained oauth2_provider.RefreshToken: ".. pii:": "Contains 3rd party authentication secrets. Retired in DeactivateLogoutView." ".. pii_types:": password, other @@ -250,6 +334,8 @@ submissions.StudentItem: ".. no_pii:": "No PII" submissions.Submission: ".. no_pii:": "No PII" +submissions.TeamSubmission: + ".. no_pii:": "No PII" # Via sorl-thumbnail https://github.com/jazzband/sorl-thumbnail thumbnail.KVStore: diff --git a/common/djangoapps/student/models/course_enrollment.py b/common/djangoapps/student/models/course_enrollment.py index 09862916e3..750ac66e38 100644 --- a/common/djangoapps/student/models/course_enrollment.py +++ b/common/djangoapps/student/models/course_enrollment.py @@ -1750,7 +1750,7 @@ class EnrollmentRefundConfiguration(ConfigurationModel): class BulkUnenrollConfiguration(ConfigurationModel): # lint-amnesty, pylint: disable=empty-docstring """ - + .. no_pii: """ csv_file = models.FileField( validators=[FileExtensionValidator(allowed_extensions=['csv'])], @@ -1763,6 +1763,8 @@ class BulkUnenrollConfiguration(ConfigurationModel): # lint-amnesty, pylint: di class BulkChangeEnrollmentConfiguration(ConfigurationModel): """ config model for the bulk_change_enrollment_csv command + + .. no_pii: """ csv_file = models.FileField( validators=[FileExtensionValidator(allowed_extensions=['csv'])], diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py index aa3de546ef..9d979beb19 100644 --- a/common/djangoapps/student/models/user.py +++ b/common/djangoapps/student/models/user.py @@ -1685,6 +1685,8 @@ class AllowedAuthUser(TimeStampedModel): class AccountRecoveryConfiguration(ConfigurationModel): """ configuration model for recover account management command + + .. no_pii: """ csv_file = models.FileField( validators=[FileExtensionValidator(allowed_extensions=['csv'])], @@ -1824,6 +1826,8 @@ class UserCelebration(TimeStampedModel): class UserPasswordToggleHistory(TimeStampedModel): """ Keeps track of user password disable/enable history + + .. no_pii: """ user = models.ForeignKey(User, related_name='password_toggle_history', on_delete=models.CASCADE) comment = models.CharField(max_length=255, help_text=_("Add a reason"), blank=True, null=True) diff --git a/lms/djangoapps/course_goals/models.py b/lms/djangoapps/course_goals/models.py index 73d69d6bd6..6169666000 100644 --- a/lms/djangoapps/course_goals/models.py +++ b/lms/djangoapps/course_goals/models.py @@ -79,6 +79,8 @@ class CourseGoalReminderStatus(TimeStampedModel): Tracks whether we've sent a reminder about a particular goal this week. See the management command goal_reminder_email for more detail about how this is used. + + .. no_pii: """ class Meta: verbose_name_plural = "Course goal reminder statuses" diff --git a/lms/djangoapps/course_home_api/models.py b/lms/djangoapps/course_home_api/models.py index 62d1e0fda3..94c79b8760 100644 --- a/lms/djangoapps/course_home_api/models.py +++ b/lms/djangoapps/course_home_api/models.py @@ -11,6 +11,8 @@ from openedx.core.djangoapps.config_model_utils.models import StackedConfigurati class DisableProgressPageStackedConfig(StackedConfigurationModel): """ Stacked Config Model for disabling the frontend-app-learning progress page + + .. no_pii: """ STACKABLE_FIELDS = ('disabled',) diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index b5cd3839c3..eacf2424de 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -545,6 +545,8 @@ class LastSeenCoursewareTimezone(models.Model): class FinancialAssistanceConfiguration(ConfigurationModel): """ Manages configuration for connecting to Financial Assistance backend service and using its API. + + .. no_pii: """ api_base_url = models.URLField( diff --git a/lms/djangoapps/experiments/models.py b/lms/djangoapps/experiments/models.py index 2e69d185d3..049cd91082 100644 --- a/lms/djangoapps/experiments/models.py +++ b/lms/djangoapps/experiments/models.py @@ -37,6 +37,7 @@ class ExperimentKeyValue(TimeStampedModel): """ ExperimentData stores any generic key-value associated with experiments identified by experiment_id. + .. no_pii: """ experiment_id = models.PositiveSmallIntegerField( diff --git a/lms/djangoapps/support/models.py b/lms/djangoapps/support/models.py index 32df989eed..d8f6e2b9a1 100644 --- a/lms/djangoapps/support/models.py +++ b/lms/djangoapps/support/models.py @@ -21,6 +21,8 @@ register(UserSocialAuth, app=__package__) class CourseResetCourseOptIn(TimeStampedModel): """ Model that represents a course which has opted in to the course reset feature. + + .. no_pii: """ course_id = CourseKeyField(max_length=255, unique=True) active = BooleanField() @@ -40,6 +42,8 @@ class CourseResetCourseOptIn(TimeStampedModel): class CourseResetAudit(TimeStampedModel): """ Model which records the course reset action's status and metadata + + .. no_pii: """ class CourseResetStatus(TextChoices): IN_PROGRESS = "in_progress" diff --git a/lms/djangoapps/user_tours/models.py b/lms/djangoapps/user_tours/models.py index 34bd7b28de..23baacc15e 100644 --- a/lms/djangoapps/user_tours/models.py +++ b/lms/djangoapps/user_tours/models.py @@ -30,6 +30,8 @@ class UserTour(models.Model): class UserDiscussionsTours(models.Model): """ Model to track which discussions tours a user has seen. + + .. no_pii: """ tour_name = models.CharField(max_length=255) show_tour = models.BooleanField(default=True) diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 7d055ffc70..1c10cf7d0a 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -1177,8 +1177,10 @@ class VerificationDeadline(TimeStampedModel): class SSPVerificationRetryConfig(ConfigurationModel): # pylint: disable=model-missing-unicode, useless-suppression """ - SSPVerificationRetryConfig used to inject arguments - to retry_failed_photo_verifications management command + SSPVerificationRetryConfig used to inject arguments + to retry_failed_photo_verifications management command + + .. no_pii: """ class Meta: @@ -1201,6 +1203,10 @@ class VerificationAttempt(StatusModel): Plugins that implement forms of IDV can store information about IDV attempts in this model for use across the platform. + + .. pii: Contains the name of the user + .. pii_types: name + .. pii_retirement: local_api """ user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) name = models.CharField(blank=True, max_length=255) diff --git a/openedx/core/djangoapps/agreements/models.py b/openedx/core/djangoapps/agreements/models.py index 461d7936c4..2672a4f47b 100644 --- a/openedx/core/djangoapps/agreements/models.py +++ b/openedx/core/djangoapps/agreements/models.py @@ -27,6 +27,8 @@ class IntegritySignature(TimeStampedModel): class LTIPIITool(TimeStampedModel): """ This model stores the relationship between a course and the LTI tools in the course that share PII. + + .. no_pii: """ course_key = CourseKeyField(max_length=255, unique=True, db_index=True) lti_tools = models.JSONField() @@ -39,6 +41,8 @@ class LTIPIITool(TimeStampedModel): class LTIPIISignature(TimeStampedModel): """ This model stores a user's acknowledgement to share PII via LTI tools in a particular course. + + .. no_pii: """ user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) course_key = CourseKeyField(max_length=255, db_index=True) @@ -57,6 +61,8 @@ class LTIPIISignature(TimeStampedModel): class ProctoringPIISignature(TimeStampedModel): """ This model stores a user's acknowledgment to share PII via proctoring in a particular course. + + .. no_pii: """ user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) course_key = CourseKeyField(max_length=255, db_index=True) diff --git a/openedx/core/djangoapps/content/learning_sequences/models.py b/openedx/core/djangoapps/content/learning_sequences/models.py index de0881abe5..5d2ee34bbc 100644 --- a/openedx/core/djangoapps/content/learning_sequences/models.py +++ b/openedx/core/djangoapps/content/learning_sequences/models.py @@ -53,6 +53,8 @@ class LearningContext(TimeStampedModel): because this table can contain things that are not courses. It is okay to make a foreign key against this table. + + .. no_pii: """ id = models.BigAutoField(primary_key=True) context_key = LearningContextKeyField( @@ -74,6 +76,8 @@ class LearningContext(TimeStampedModel): class CourseContext(TimeStampedModel): """ A model containing course specific information e.g course_visibility + + .. no_pii: """ learning_context = models.OneToOneField( LearningContext, on_delete=models.CASCADE, primary_key=True, related_name="course_context" @@ -106,6 +110,8 @@ class LearningSequence(TimeStampedModel): CourseSectionSequence. It is okay to make a foreign key against this table. + + .. no_pii: """ id = models.BigAutoField(primary_key=True) learning_context = models.ForeignKey( @@ -131,6 +137,8 @@ class CourseContentVisibilityMixin(models.Model): We keep the XBlock field names here, even if they're somewhat misleading. Please read the comments carefully for each field. + + .. no_pii: """ # This is an obscure, OLX-only flag (there is no UI for it in Studio) that # lets you define a Sequence that is reachable by direct URL but not shown @@ -174,6 +182,8 @@ class UserPartitionGroup(models.Model): UserPartitionGroups are not associated with LearningSequence directly because User Partitions often carry course-level assumptions (e.g. Enrollment Track) that don't make sense outside of a Course. + + .. no_pii: """ id = models.BigAutoField(primary_key=True) partition_id = models.BigIntegerField(null=False) @@ -191,6 +201,8 @@ class UserPartitionGroup(models.Model): class CourseSection(CourseContentVisibilityMixin, TimeStampedModel): """ Course Section data, mapping to the 'chapter' block type. + + .. no_pii: """ id = models.BigAutoField(primary_key=True) course_context = models.ForeignKey( @@ -225,6 +237,8 @@ class SectionPartitionGroup(models.Model): Used for the user_partition_groups ManyToManyField field in the CourseSection model above. Adds a cascading delete which will delete these many-to-many relations whenever a UserPartitionGroup or CourseSection object is deleted. + + .. no_pii: """ class Meta: unique_together = [ @@ -249,6 +263,8 @@ class CourseSectionSequence(CourseContentVisibilityMixin, TimeStampedModel): Do NOT make a foreign key against this table, as the values are deleted and re-created on course publish. + + .. no_pii: """ id = models.BigAutoField(primary_key=True) course_context = models.ForeignKey( @@ -289,6 +305,8 @@ class SectionSequencePartitionGroup(models.Model): Used for the user_partition_groups ManyToManyField field in the CourseSectionSequence model above. Adds a cascading delete which will delete these many-to-many relations whenever a UserPartitionGroup or CourseSectionSequence object is deleted. + + .. no_pii: """ class Meta: unique_together = [ @@ -303,6 +321,8 @@ class CourseSequenceExam(TimeStampedModel): """ This model stores XBlock information that affects outline level information pertaining to special exams + + .. no_pii: """ course_section_sequence = models.OneToOneField(CourseSectionSequence, on_delete=models.CASCADE, related_name='exam') @@ -318,6 +338,8 @@ class PublishReport(models.Model): All these fields could be derived with aggregate SQL functions, but it would be slower and make the admin code more complex. Since we only write at publish time, keeping things in sync is less of a concern. + + .. no_pii: """ learning_context = models.OneToOneField( LearningContext, on_delete=models.CASCADE, related_name='publish_report' @@ -350,6 +372,8 @@ class ContentError(models.Model): freeform messages. It is quite possible that at some point we will come up with a more comprehensive taxonomy of error messages, at which point we could do a backfill to regenerate this data in a more normalized way. + + .. no_pii: """ id = models.BigAutoField(primary_key=True) publish_report = models.ForeignKey( diff --git a/openedx/core/djangoapps/content_libraries/models.py b/openedx/core/djangoapps/content_libraries/models.py index 4a210223cc..61e28b9448 100644 --- a/openedx/core/djangoapps/content_libraries/models.py +++ b/openedx/core/djangoapps/content_libraries/models.py @@ -89,6 +89,8 @@ class ContentLibrary(models.Model): re-imported on another Open edX instance should be kept in Learning Core. This model in Studio should only be used to track settings specific to this Open edX instance, like who has permission to edit this content library. + + .. no_pii: """ objects: ContentLibraryManager[ContentLibrary] = ContentLibraryManager() @@ -183,6 +185,8 @@ class ContentLibrary(models.Model): class ContentLibraryPermission(models.Model): """ Row recording permissions for a content library + + .. no_pii: """ library = models.ForeignKey(ContentLibrary, on_delete=models.CASCADE, related_name="permission_grants") # One of the following must be set (but not both): @@ -226,6 +230,8 @@ class ContentLibraryPermission(models.Model): class ContentLibraryBlockImportTask(models.Model): """ Model of a task to import blocks from an external source (e.g. modulestore). + + .. no_pii: """ library = models.ForeignKey( @@ -331,6 +337,8 @@ class LtiProfile(models.Model): Unless Anonymous, this should be a unique representation of the LTI subject (as per the client token ``sub`` identify claim) that initiated an LTI launch through Content Libraries. + + .. no_pii: """ objects = LtiProfileManager() @@ -453,6 +461,8 @@ class LtiGradedResource(models.Model): launch. This model links the profile that launched the resource with the resource itself, allowing identifcation of the link through its usage key string and user id. + + .. no_pii: """ objects = LtiGradedResourceManager() diff --git a/openedx/core/djangoapps/content_tagging/models/base.py b/openedx/core/djangoapps/content_tagging/models/base.py index 8a232d3a7b..d799d87951 100644 --- a/openedx/core/djangoapps/content_tagging/models/base.py +++ b/openedx/core/djangoapps/content_tagging/models/base.py @@ -16,6 +16,8 @@ class TaxonomyOrg(models.Model): We keep this as a separate class from ContentTaxonomy so that class can remain a proxy for Taxonomy, keeping the data models and usage simple. + + .. no_pii: """ class RelType(models.TextChoices): diff --git a/openedx/core/djangoapps/course_live/models.py b/openedx/core/djangoapps/course_live/models.py index a871ec0244..c311c6fb3f 100644 --- a/openedx/core/djangoapps/course_live/models.py +++ b/openedx/core/djangoapps/course_live/models.py @@ -12,6 +12,8 @@ from simple_history.models import HistoricalRecords class CourseLiveConfiguration(TimeStampedModel): """ Associates a Course with a LTI provider and configuration + + .. no_pii: """ course_key = CourseKeyField(max_length=255, db_index=True, null=False) enabled = models.BooleanField( diff --git a/openedx/core/djangoapps/discussions/models.py b/openedx/core/djangoapps/discussions/models.py index 9d97c90513..f47b661c9a 100644 --- a/openedx/core/djangoapps/discussions/models.py +++ b/openedx/core/djangoapps/discussions/models.py @@ -318,6 +318,8 @@ def get_supported_providers() -> List[str]: class ProviderFilter(StackedConfigurationModel): """ Associate allow/deny-lists of discussions providers with courses/orgs + + .. no_pii: """ allow = ListCharField( @@ -406,6 +408,8 @@ T = TypeVar('T', bound='DiscussionsConfiguration') class DiscussionsConfiguration(TimeStampedModel): """ Associates a learning context with discussion provider and configuration + + .. no_pii: """ context_key = LearningContextKeyField( @@ -554,6 +558,8 @@ class DiscussionsConfiguration(TimeStampedModel): class DiscussionTopicLink(models.Model): """ A model linking discussion topics ids to the part of a course they are linked to. + + ..no_pii: """ context_key = LearningContextKeyField( db_index=True, diff --git a/openedx/core/djangoapps/programs/models.py b/openedx/core/djangoapps/programs/models.py index 302ad553f2..e708066813 100644 --- a/openedx/core/djangoapps/programs/models.py +++ b/openedx/core/djangoapps/programs/models.py @@ -72,8 +72,14 @@ class AbstractProgramLTIConfiguration(TimeStampedModel): class ProgramLiveConfiguration(AbstractProgramLTIConfiguration): + """ + .. no_pii: + """ history = HistoricalRecords() class ProgramDiscussionsConfiguration(AbstractProgramLTIConfiguration): + """ + .. no_pii: + """ history = HistoricalRecords() diff --git a/openedx/core/djangoapps/user_api/models.py b/openedx/core/djangoapps/user_api/models.py index 6c1dd832a3..d776dac8fe 100644 --- a/openedx/core/djangoapps/user_api/models.py +++ b/openedx/core/djangoapps/user_api/models.py @@ -426,6 +426,8 @@ class UserRetirementStatus(TimeStampedModel): class BulkUserRetirementConfig(ConfigurationModel): """ Configuration to store a csv file that will be used in retire_user management command. + + .. no_pii: """ # Timeout set to 0 so that the model does not read from cached config in case the config entry is deleted. cache_timeout = 0 diff --git a/openedx/core/djangoapps/video_config/models.py b/openedx/core/djangoapps/video_config/models.py index 9ea7f8f44d..9a54585475 100644 --- a/openedx/core/djangoapps/video_config/models.py +++ b/openedx/core/djangoapps/video_config/models.py @@ -98,7 +98,8 @@ class CourseYoutubeBlockedFlag(ConfigurationModel): Disables the playback of youtube videos for a given course. If the flag is present for the course, and set to "enabled", then youtube is disabled for that course. - .. no_pii + + .. no_pii: """ KEY_FIELDS = ('course_id',) diff --git a/openedx/features/announcements/models.py b/openedx/features/announcements/models.py index cb202ce521..f58f61165d 100644 --- a/openedx/features/announcements/models.py +++ b/openedx/features/announcements/models.py @@ -7,7 +7,11 @@ from django.db import models class Announcement(models.Model): - """Site-wide announcements to be displayed on the dashboard""" + """ + Site-wide announcements to be displayed on the dashboard + + .. no_pii: + """ class Meta: app_label = 'announcements' diff --git a/openedx/features/discounts/models.py b/openedx/features/discounts/models.py index bca6ef7b37..8c7c5ff53e 100644 --- a/openedx/features/discounts/models.py +++ b/openedx/features/discounts/models.py @@ -15,6 +15,8 @@ from openedx.core.djangoapps.config_model_utils.models import StackedConfigurati class DiscountRestrictionConfig(StackedConfigurationModel): """ A ConfigurationModel used to manage restrictons for lms-controlled discounts + + .. no_pii: """ STACKABLE_FIELDS = ('disabled',) @@ -43,6 +45,8 @@ class DiscountRestrictionConfig(StackedConfigurationModel): class DiscountPercentageConfig(StackedConfigurationModel): """ A ConfigurationModel to configure the discount percentage for the first purchase discount + + .. no_pii: """ STACKABLE_FIELDS = ('percentage',) percentage = models.PositiveIntegerField()