class SoftwareProductPageSerializer(ProductSerializer): handles_recordings_how = serializers.CharField(required=False, max_length=5000) recording_alert = ExtendedYesNoField(default='CD') recording_alert_helptext = serializers.CharField(required=False, max_length=5000) medical_privacy_compliant = serializers.BooleanField(default=False) medical_privacy_compliant_helptext = serializers.CharField(required=False, max_length=5000) host_controls = serializers.CharField(required=False, max_length=5000) easy_to_learn_and_use = serializers.BooleanField(default=False) easy_to_learn_and_use_helptext = serializers.CharField(required=False, max_length=5000)
class Product(ClusterableModel): """ A product that may not have privacy included. """ @property def specific(self): for Type in registered_product_types: try: return Type.objects.get(slug=self.slug) except ObjectDoesNotExist: pass return self draft = models.BooleanField( help_text= 'When checked, this product will only show for CMS-authenticated users', default=True, ) privacy_ding = models.BooleanField( help_text='Tick this box if privacy is not included for this product', default=False, ) adult_content = models.BooleanField( help_text= 'When checked, product thumbnail will appear blurred as well as have an 18+ badge on it', default=False, ) uses_wifi = models.BooleanField( help_text='Does this product rely on WiFi connectivity?', default=False, ) uses_bluetooth = models.BooleanField( help_text='Does this product rely on Bluetooth connectivity?', default=False, ) review_date = models.DateField(help_text='Review date of this product', ) name = models.CharField( max_length=100, help_text='Name of Product', blank=True, ) slug = models.CharField(max_length=256, help_text='slug used in urls', blank=True, default=None, editable=False) company = models.CharField( max_length=100, help_text='Name of Company', blank=True, ) product_category = models.ManyToManyField( 'buyersguide.BuyersGuideProductCategory', related_name='pniproduct', blank=True) blurb = models.TextField(max_length=5000, help_text='Description of the product', blank=True) url = models.URLField( max_length=2048, help_text='Link to this product page', blank=True, ) price = models.CharField( max_length=100, help_text='Price', blank=True, ) image = models.FileField( max_length=2048, help_text='Image representing this product', upload_to=get_product_image_upload_path, blank=True, ) cloudinary_image = CloudinaryField( help_text='Image representing this product - hosted on Cloudinary', blank=True, verbose_name='image', folder='foundationsite/buyersguide', use_filename=True) worst_case = models.CharField( max_length=5000, help_text= "What's the worst thing that could happen by using this product?", blank=True, ) # What is required to sign up? signup_requires_email = ExtendedYesNoField( help_text= 'Does this product requires providing an email address in order to sign up?' ) signup_requires_phone = ExtendedYesNoField( help_text= 'Does this product requires providing a phone number in order to sign up?' ) signup_requires_third_party_account = ExtendedYesNoField( help_text= 'Does this product require a third party account in order to sign up?') signup_requirement_explanation = models.TextField( max_length=5000, blank=True, help_text='Describe the particulars around sign-up requirements here.') # How does it use this data? how_does_it_use_data_collected = models.TextField( max_length=5000, blank=True, help_text='How does this product use the data collected?') data_collection_policy_is_bad = models.BooleanField( default=False, verbose_name='Privacy ding') # Privacy policy user_friendly_privacy_policy = ExtendedYesNoField( help_text='Does this product have a user-friendly privacy policy?') # Minimum security standards show_ding_for_minimum_security_standards = models.BooleanField( default=False, verbose_name="Privacy ding") meets_minimum_security_standards = models.BooleanField( null=True, help_text='Does this product meet our minimum security standards?', ) uses_encryption = ExtendedYesNoField( help_text='Does the product use encryption?', ) uses_encryption_helptext = models.TextField(max_length=5000, blank=True) security_updates = ExtendedYesNoField(help_text='Security updates?', ) security_updates_helptext = models.TextField(max_length=5000, blank=True) strong_password = ExtendedYesNoField() strong_password_helptext = models.TextField(max_length=5000, blank=True) manage_vulnerabilities = ExtendedYesNoField( help_text='Manages security vulnerabilities?', ) manage_vulnerabilities_helptext = models.TextField(max_length=5000, blank=True) privacy_policy = ExtendedYesNoField( help_text='Does this product have a privacy policy?') privacy_policy_helptext = models.TextField( # REPURPOSED: WILL REQUIRE A 'clear' MIGRATION max_length=5000, blank=True) """ privacy_policy_links = one to many, defined in PrivacyPolicyLink """ # How to contact the company phone_number = models.CharField( max_length=100, help_text='Phone Number', blank=True, ) live_chat = models.CharField( max_length=100, help_text='Live Chat', blank=True, ) email = models.CharField( max_length=100, help_text='Email', blank=True, ) twitter = models.CharField( max_length=100, help_text='Twitter username', blank=True, ) updates = models.ManyToManyField('buyersguide.Update', related_name='pniproduct', blank=True) def get_product_updates(self): """ This function is used by our custom ProductUpdatesFieldPanel, to make sure updates are alphabetically listed. Eventually we want to replace this with "that, but also nothing older than 2 years". We can't do that yet, though, as product updates currently do not record their creation_date. """ return ProductUpdate.objects.all().order_by('title') # comments are not a model field, but are "injected" on the product page instead related_products = models.ManyToManyField( 'self', related_name='related_pniproduct', blank=True, symmetrical=False) def get_related_products(self): """ This function is used by our custom RelatedProductFieldPanel, to make sure we don't list every single PNI product ever entered into the system, but only products added in recent iterations of PNI. For PNI v4 this has been set to "any product entered after 2019". """ return Product.objects.filter( review_date__gte=date(2020, 1, 1)).order_by('name') # List of fields to show in admin to hide the image/cloudinary_image field. There's probably a better way to do # this using `_meta.get_fields()`. To be refactored in the future. panels = product_panels @property def is_current(self): d = self.review_date review = datetime(d.year, d.month, d.day) cutoff = datetime(2019, 6, 1) return cutoff < review @property def votes(self): return get_product_vote_information(self) def to_dict(self): """ Rather than rendering products based on the instance object, we serialize the product to a dictionary, and instead render the template based on that. NOTE: if you add indirect fields like Foreign/ParentalKey or @property definitions, those needs to be added! """ model_dict = model_to_dict(self) model_dict['votes'] = self.votes model_dict['slug'] = self.slug # model_to_dict does NOT capture related fields or @properties! model_dict['privacy_policy_links'] = list( self.privacy_policy_links.all()) model_dict['is_current'] = self.is_current return model_dict def save(self, *args, **kwargs): self.slug = slugify(self.name) models.Model.save(self, *args, **kwargs) def __str__(self): return f'{self.name} ({self.company})' class Meta: # use oldest-first ordering ordering = ['id']
class Product(ClusterableModel): """ A thing you can buy in stores and our review of it """ draft = models.BooleanField( help_text= 'When checked, this product will only show for CMS-authenticated users', default=True, ) adult_content = models.BooleanField( help_text= 'When checked, product thumbnail will appear blurred as well as have an 18+ badge on it', default=False, ) review_date = models.DateField(help_text='Review date of this product', ) @property def is_current(self): d = self.review_date review = datetime(d.year, d.month, d.day) cutoff = datetime(2019, 6, 1) return cutoff < review name = models.CharField( max_length=100, help_text='Name of Product', blank=True, ) slug = models.CharField(max_length=256, help_text='slug used in urls', blank=True, default=None, editable=False) company = models.CharField( max_length=100, help_text='Name of Company', blank=True, ) product_category = models.ManyToManyField(BuyersGuideProductCategory, related_name='product', blank=True) blurb = models.TextField(max_length=5000, help_text='Description of the product', blank=True) url = models.URLField( max_length=2048, help_text='Link to this product page', blank=True, ) price = models.CharField( max_length=100, help_text='Price', blank=True, ) image = models.FileField( max_length=2048, help_text='Image representing this product', upload_to=get_product_image_upload_path, blank=True, ) cloudinary_image = CloudinaryImageField( help_text='Image representing this product - hosted on Cloudinary', blank=True, verbose_name='image', ) meets_minimum_security_standards = models.BooleanField( null=True, help_text='Does this product meet minimum security standards?', ) # Minimum security standards (stars) uses_encryption = ExtendedYesNoField( help_text='Does the product use encryption?', ) uses_encryption_helptext = models.TextField(max_length=5000, blank=True) security_updates = ExtendedYesNoField(help_text='Security updates?', ) security_updates_helptext = models.TextField(max_length=5000, blank=True) strong_password = ExtendedYesNoField() strong_password_helptext = models.TextField(max_length=5000, blank=True) manage_vulnerabilities = ExtendedYesNoField( help_text='Manages security vulnerabilities?', ) manage_vulnerabilities_helptext = models.TextField(max_length=5000, blank=True) privacy_policy = ExtendedYesNoField( help_text='Does this product have a privacy policy?') privacy_policy_helptext = models.TextField( # REPURPOSED: WILL REQUIRE A 'clear' MIGRATION max_length=5000, blank=True) share_data = models.BooleanField( # TO BE REMOVED null=True, help_text='Does the maker share data with other companies?', ) share_data_helptext = models.TextField( # TO BE REMOVED max_length=5000, blank=True) # It uses your... camera_device = ExtendedYesNoField( help_text='Does this device have or access a camera?', ) camera_app = ExtendedYesNoField( help_text='Does the app have or access a camera?', ) microphone_device = ExtendedYesNoField( help_text='Does this Device have or access a microphone?', ) microphone_app = ExtendedYesNoField( help_text='Does this app have or access a microphone?', ) location_device = ExtendedYesNoField( help_text='Does this product access your location?', ) location_app = ExtendedYesNoField( help_text='Does this app access your location?', ) # How it handles privacy how_does_it_share = models.CharField( max_length=5000, help_text='How does this product handle data?', blank=True) delete_data = models.BooleanField( # TO BE REMOVED null=True, help_text='Can you request data be deleted?', ) delete_data_helptext = models.TextField( # TO BE REMOVED max_length=5000, blank=True) parental_controls = ExtendedYesNoField( null=True, help_text='Are there rules for children?', ) child_rules_helptext = models.TextField( # TO BE REMOVED max_length=5000, blank=True) collects_biometrics = ExtendedYesNoField( help_text='Does this product collect biometric data?', ) collects_biometrics_helptext = models.TextField(max_length=5000, blank=True) user_friendly_privacy_policy = ExtendedYesNoField( help_text='Does this product have a user-friendly privacy policy?') user_friendly_privacy_policy_helptext = models.TextField(max_length=5000, blank=True) """ privacy_policy_links = one to many, defined in PrivacyPolicyLink """ worst_case = models.CharField( max_length=5000, help_text= "What's the worst thing that could happen by using this product?", blank=True, ) PP_CHOICES = ( # TO BE REMOVED ('0', 'Can\'t Determine'), ('7', 'Grade 7'), ('8', 'Grade 8'), ('9', 'Grade 9'), ('10', 'Grade 10'), ('11', 'Grade 11'), ('12', 'Grade 12'), ('13', 'Grade 13'), ('14', 'Grade 14'), ('15', 'Grade 15'), ('16', 'Grade 16'), ('17', 'Grade 17'), ('18', 'Grade 18'), ('19', 'Grade 19'), ) privacy_policy_reading_level = models.CharField( # TO BE REMOVED IN FAVOUR OF USER_FRIENDLY_PRIVACY_POLICY choices=PP_CHOICES, default='0', max_length=2, ) privacy_policy_url = models.URLField( # TO BE REMOVED IN FAVOUR OF PRIVACY_POLICY_LINKS max_length=2048, help_text='Link to privacy policy', blank=True) privacy_policy_reading_level_url = models.URLField( # TO BE REMOVED max_length=2048, help_text='Link to privacy policy reading level', blank=True) # How to contact the company phone_number = models.CharField( max_length=100, help_text='Phone Number', blank=True, ) live_chat = models.CharField( max_length=100, help_text='Live Chat', blank=True, ) email = models.CharField( max_length=100, help_text='Email', blank=True, ) twitter = models.CharField( max_length=100, help_text='Twitter username', blank=True, ) updates = models.ManyToManyField(Update, related_name='products', blank=True) # comments are not a model field, but are "injected" on the product page instead related_products = models.ManyToManyField('self', related_name='rps', blank=True, symmetrical=False) # --- if settings.USE_CLOUDINARY: image_field = FieldPanel('cloudinary_image') else: image_field = FieldPanel('image') # List of fields to show in admin to hide the image/cloudinary_image field. There's probably a better way to do # this using `_meta.get_fields()`. To be refactored in the future. panels = [ MultiFieldPanel([ FieldPanel('draft'), ], heading="Publication status", classname="collapsible"), MultiFieldPanel([ FieldPanel('adult_content'), FieldPanel('review_date'), FieldPanel('name'), FieldPanel('company'), FieldPanel('product_category'), FieldPanel('blurb'), FieldPanel('url'), FieldPanel('price'), image_field, FieldPanel('meets_minimum_security_standards') ], heading="General Product Details", classname="collapsible"), MultiFieldPanel( [ FieldPanel('uses_encryption'), FieldPanel('uses_encryption_helptext'), FieldPanel('security_updates'), FieldPanel('security_updates_helptext'), FieldPanel('strong_password'), FieldPanel('strong_password_helptext'), FieldPanel('manage_vulnerabilities'), FieldPanel('manage_vulnerabilities_helptext'), FieldPanel('privacy_policy'), FieldPanel( 'privacy_policy_helptext'), # NEED A "clear" MIGRATION FieldPanel('share_data'), FieldPanel('share_data_helptext'), # DEPRECATED AND WILL BE REMOVED FieldPanel('privacy_policy_url'), FieldPanel('privacy_policy_reading_level'), FieldPanel('privacy_policy_reading_level_url'), ], heading="Minimum Security Standards", classname="collapsible"), MultiFieldPanel([ FieldPanel('camera_device'), FieldPanel('camera_app'), FieldPanel('microphone_device'), FieldPanel('microphone_app'), FieldPanel('location_device'), FieldPanel('location_app'), ], heading="Can it snoop?", classname="collapsible"), MultiFieldPanel([ FieldPanel('how_does_it_share'), FieldPanel('delete_data'), FieldPanel('delete_data_helptext'), FieldPanel('parental_controls'), FieldPanel('collects_biometrics'), FieldPanel('collects_biometrics_helptext'), FieldPanel('user_friendly_privacy_policy'), FieldPanel('user_friendly_privacy_policy_helptext'), FieldPanel('worst_case'), ], heading="How does it handle privacy", classname="collapsible"), MultiFieldPanel([ InlinePanel( 'privacy_policy_links', label='link', min_num=1, max_num=3, ), ], heading="Privacy policy links", classname="collapsible"), MultiFieldPanel([ FieldPanel('phone_number'), FieldPanel('live_chat'), FieldPanel('email'), FieldPanel('twitter'), ], heading="Ways to contact the company", classname="collapsible"), FieldPanel('updates'), FieldPanel('related_products'), ] @property def votes(self): votes = {} confidence_vote_breakdown = {} creepiness = {'vote_breakdown': {}} try: # Get vote QuerySets creepiness_votes = self.range_product_votes.get( attribute='creepiness') confidence_votes = self.boolean_product_votes.get( attribute='confidence') # Aggregate the Creepiness votes creepiness['average'] = creepiness_votes.average for vote_breakdown in creepiness_votes.rangevotebreakdown_set.all( ): creepiness['vote_breakdown'][str( vote_breakdown.bucket)] = vote_breakdown.count # Aggregate the confidence votes for boolean_vote_breakdown in confidence_votes.booleanvotebreakdown_set.all( ): confidence_vote_breakdown[str(boolean_vote_breakdown.bucket )] = boolean_vote_breakdown.count # Build + return the votes dict votes['creepiness'] = creepiness votes['confidence'] = confidence_vote_breakdown votes['total'] = BooleanVote.objects.filter(product=self).count() return votes except ObjectDoesNotExist: # There's no aggregate data available yet, return None return None @property def numeric_reading_grade(self): try: return int(self.privacy_policy_reading_level) except ValueError: return 0 @property def reading_grade(self): val = self.numeric_reading_grade if val == 0: return 0 if val <= 8: return 'Middle school' if val <= 12: return 'High school' if val <= 16: return 'College' return 'Grad school' def to_dict(self): """ Rather than rendering products based on the instance object, we serialize the product to a dictionary, and instead render the template based on that. NOTE: if you add indirect fields like Foreign/ParentalKey or @property definitions, those needs to be added! """ model_dict = model_to_dict(self) model_dict['votes'] = self.votes model_dict['slug'] = self.slug model_dict['delete_data'] = tri_to_quad(self.delete_data) # TODO: remove these two entries model_dict['numeric_reading_grade'] = self.numeric_reading_grade model_dict['reading_grade'] = self.reading_grade # model_to_dict does NOT capture related fields or @properties! model_dict['privacy_policy_links'] = list( self.privacy_policy_links.all()) model_dict['is_current'] = self.is_current return model_dict def save(self, *args, **kwargs): self.slug = slugify(self.name) models.Model.save(self, *args, **kwargs) def __str__(self): return str(self.name)
class ProductPage(FoundationMetadataPageMixin, Page): """ ProductPage is the superclass that SoftwareProductPage and GeneralProductPage inherit from. This should not be an abstract model as we need it to connect the two page types together. """ privacy_ding = models.BooleanField( help_text='Tick this box if privacy is not included for this product', default=False, ) adult_content = models.BooleanField( help_text='When checked, product thumbnail will appear blurred as well as have an 18+ badge on it', default=False, ) uses_wifi = models.BooleanField( help_text='Does this product rely on WiFi connectivity?', default=False, ) uses_bluetooth = models.BooleanField( help_text='Does this product rely on Bluetooth connectivity?', default=False, ) review_date = models.DateField( help_text='Review date of this product', auto_now_add=True, ) company = models.CharField( max_length=100, help_text='Name of Company', blank=True, ) blurb = models.TextField( max_length=5000, help_text='Description of the product', blank=True ) # TODO: We'll need to update this URL in the template product_url = models.URLField( max_length=2048, help_text='Link to this product page', blank=True, ) price = models.CharField( max_length=100, help_text='Price', blank=True, ) image = models.ForeignKey( 'wagtailimages.Image', null=True, blank=True, on_delete=models.SET_NULL, related_name='+', help_text='Image representing this product', ) cloudinary_image = CloudinaryField( help_text='Image representing this product - hosted on Cloudinary', blank=True, verbose_name='image', folder='foundationsite/buyersguide', use_filename=True ) worst_case = models.CharField( max_length=5000, help_text="What's the worst thing that could happen by using this product?", blank=True, ) """ product_privacy_policy_links = Orderable, defined in ProductPagePrivacyPolicyLink Other "magic" relations that use InlinePanels will follow the same pattern of using Wagtail Orderables. """ # What is required to sign up? signup_requires_email = ExtendedYesNoField( help_text='Does this product requires providing an email address in order to sign up?' ) signup_requires_phone = ExtendedYesNoField( help_text='Does this product requires providing a phone number in order to sign up?' ) signup_requires_third_party_account = ExtendedYesNoField( help_text='Does this product require a third party account in order to sign up?' ) signup_requirement_explanation = models.TextField( max_length=5000, blank=True, help_text='Describe the particulars around sign-up requirements here.' ) # How does it use this data? how_does_it_use_data_collected = models.TextField( max_length=5000, blank=True, help_text='How does this product use the data collected?' ) data_collection_policy_is_bad = models.BooleanField( default=False, verbose_name='Privacy ding' ) # Privacy policy user_friendly_privacy_policy = ExtendedYesNoField( help_text='Does this product have a user-friendly privacy policy?' ) # Minimum security standards show_ding_for_minimum_security_standards = models.BooleanField( default=False, verbose_name="Privacy ding" ) meets_minimum_security_standards = models.BooleanField( null=True, blank=True, help_text='Does this product meet our minimum security standards?', ) uses_encryption = ExtendedYesNoField( help_text='Does the product use encryption?', ) uses_encryption_helptext = models.TextField( max_length=5000, blank=True ) security_updates = ExtendedYesNoField( help_text='Security updates?', ) security_updates_helptext = models.TextField( max_length=5000, blank=True ) strong_password = ExtendedYesNoField() strong_password_helptext = models.TextField( max_length=5000, blank=True ) manage_vulnerabilities = ExtendedYesNoField( help_text='Manages security vulnerabilities?', ) manage_vulnerabilities_helptext = models.TextField( max_length=5000, blank=True ) privacy_policy = ExtendedYesNoField( help_text='Does this product have a privacy policy?' ) privacy_policy_helptext = models.TextField( # REPURPOSED: WILL REQUIRE A 'clear' MIGRATION max_length=5000, blank=True ) # How to contact the company phone_number = models.CharField( max_length=100, help_text='Phone Number', blank=True, ) live_chat = models.CharField( max_length=100, help_text='Live Chat', blank=True, ) email = models.CharField( max_length=100, help_text='Email', blank=True, ) twitter = models.CharField( max_length=100, help_text='Twitter username', blank=True, ) content_panels = Page.content_panels + [ MultiFieldPanel( [ FieldPanel('privacy_ding'), FieldPanel('adult_content'), FieldPanel('company'), FieldPanel('product_url'), FieldPanel('price'), FieldPanel('uses_wifi'), FieldPanel('uses_bluetooth'), FieldPanel('blurb'), image_field, FieldPanel('worst_case'), ], heading='General Product Details', classname='collapsible' ), MultiFieldPanel( [ InlinePanel('product_categories', label='Category'), ], heading='Product Categories', classname='collapsible', ), MultiFieldPanel( [ FieldPanel('signup_requires_email'), FieldPanel('signup_requires_phone'), FieldPanel('signup_requires_third_party_account'), FieldPanel('signup_requirement_explanation'), ], heading='What is required to sign up', classname='collapsible' ), MultiFieldPanel( [ FieldPanel('how_does_it_use_data_collected'), FieldPanel('data_collection_policy_is_bad'), ], heading='How does it use this data', classname='collapsible', ), MultiFieldPanel( [ FieldPanel('user_friendly_privacy_policy'), ], heading='Privacy policy', classname='collapsible' ), MultiFieldPanel( [ InlinePanel( 'product_privacy_policy_links', label='link', min_num=1, max_num=3, ), ], heading='Privacy policy links', classname='collapsible' ), MultiFieldPanel( [ FieldPanel('show_ding_for_minimum_security_standards'), FieldPanel('meets_minimum_security_standards'), FieldPanel('uses_encryption'), FieldPanel('uses_encryption_helptext'), FieldPanel('security_updates'), FieldPanel('security_updates_helptext'), FieldPanel('strong_password'), FieldPanel('strong_password_helptext'), FieldPanel('manage_vulnerabilities'), FieldPanel('manage_vulnerabilities_helptext'), FieldPanel('privacy_policy'), FieldPanel('privacy_policy_helptext'), ], heading='Security', classname='collapsible' ), MultiFieldPanel( [ FieldPanel('phone_number'), FieldPanel('live_chat'), FieldPanel('email'), FieldPanel('twitter'), ], heading='Ways to contact the company', classname='collapsible' ), MultiFieldPanel( [ InlinePanel('product_updates', label='Update') ], heading='Product Updates', ), MultiFieldPanel( [ InlinePanel('related_product_pages', label='Product') ], heading='Related Products', ), ] class Meta: verbose_name = "Product Page"
class SoftwareProduct(Product): """ A thing you can install on your computer and our review of it """ # How does it handle privacy? handles_recordings_how = models.TextField( max_length=5000, blank=True, help_text='How does this software handle your recordings') recording_alert = ExtendedYesNoField( null=True, help_text='Alerts when calls are being recorded?', ) recording_alert_helptext = models.TextField(max_length=5000, blank=True) signup_with_email = models.BooleanField( null=True, help_text='Email required to sign up?', ) signup_with_phone = models.BooleanField( null=True, help_text='Phone number required to sign up?', ) signup_with_third_party = models.BooleanField( null=True, help_text='Third Party account required to sign up?', ) signup_methods_helptext = models.TextField( max_length=5000, blank=True, help_text= 'Describe the kind of contact information requirements for signing up for this product' ) medical_privacy_compliant = models.BooleanField( null=True, help_text='Compliant with US medical privacy laws?') medical_privacy_compliant_helptext = models.TextField(max_length=5000, blank=True) # Can I control it? host_controls = models.TextField(max_length=5000, blank=True) easy_to_learn_and_use = models.BooleanField( null=True, help_text='Is it easy to learn & use the features?', ) easy_to_learn_and_use_helptext = models.TextField(max_length=5000, blank=True) # administrative panels panels = Product.panels.copy() panels = insert_panels_after( panels, 'Minimum Security Standards for general products', [ MultiFieldPanel([ FieldPanel('signup_with_email'), FieldPanel('signup_with_phone'), FieldPanel('signup_with_third_party'), FieldPanel('signup_methods_helptext'), ], heading='How does it handle signup?', classname='collapsible'), ], ) panels = insert_panels_after( panels, 'How does it handle data sharing', [ MultiFieldPanel([ FieldPanel('handles_recordings_how'), FieldPanel('recording_alert'), FieldPanel('recording_alert_helptext'), FieldPanel('medical_privacy_compliant'), FieldPanel('medical_privacy_compliant_helptext'), ], heading='How does it handle privacy?', classname='collapsible'), ], ) panels = insert_panels_after( panels, 'How does it handle privacy?', [ MultiFieldPanel([ FieldPanel('host_controls'), FieldPanel('easy_to_learn_and_use'), FieldPanel('easy_to_learn_and_use_helptext'), ], heading='Can I control it', classname='collapsible'), ], ) def to_dict(self): model_dict = super().to_dict() model_dict['product_type'] = 'software' return model_dict
class GeneralProductPage(ProductPage): template = 'buyersguide/product_page.html' camera_device = ExtendedYesNoField( help_text='Does this device have or access a camera?', ) camera_app = ExtendedYesNoField( help_text='Does the app have or access a camera?', ) microphone_device = ExtendedYesNoField( help_text='Does this Device have or access a microphone?', ) microphone_app = ExtendedYesNoField( help_text='Does this app have or access a microphone?', ) location_device = ExtendedYesNoField( help_text='Does this product access your location?', ) location_app = ExtendedYesNoField( help_text='Does this app access your location?', ) # What data does it collect? personal_data_collected = models.TextField( max_length=5000, blank=True, help_text='What kind of personal data does this product collect?') biometric_data_collected = models.TextField( max_length=5000, blank=True, help_text='What kind of biometric data does this product collect?') social_data_collected = models.TextField( max_length=5000, blank=True, help_text='What kind of social data does this product collect?') # How can you control your data how_can_you_control_your_data = models.TextField( max_length=5000, blank=True, help_text='How does this product let you control your data?') data_control_policy_is_bad = models.BooleanField( default=False, verbose_name='Privacy ding') # Company track record company_track_record = models.CharField( choices=TRACK_RECORD_CHOICES, default='Average', help_text='This company has a ... track record', max_length=20) track_record_is_bad = models.BooleanField(default=False, verbose_name='Privacy ding') track_record_details = models.TextField( max_length=5000, blank=True, help_text='Describe the track record of this company here.') # Offline use offline_capable = ExtendedYesNoField( help_text='Can this product be used offline?', ) offline_use_description = models.TextField( max_length=5000, blank=True, help_text='Describe how this product can be used offline.') # Artificial Intelligence uses_ai = ExtendedYesNoField(help_text='Does the product use AI?') ai_uses_personal_data = ExtendedYesNoField( help_text= 'Does the AI use your personal data to make decisions about you?') ai_is_transparent = ExtendedYesNoField( help_text='Does the company allow users to see how the AI works?') ai_helptext = models.TextField( max_length=5000, blank=True, help_text='Helpful text around AI to show on the product page', ) @classmethod def map_import_fields(cls): generic_product_import_fields = super().map_import_fields() general_product_mappings = { "Has camera device": "camera_device", "Has camera app": "camera_app", "Has microphone device": "microphone_device", "Has microphone app": "microphone_app", "Has location device": "location_device", "Has location app": "location_app", "Personal data collected": "personal_data_collected", "Biometric data collected": "biometric_data_collected", "Social data collected": "social_data_collected", "How you can control your data": "how_can_you_control_your_data", "Company track record": "company_track_record", "Show company track record privacy ding": "track_record_is_bad", "Offline capable": "offline_capable", "Offline use": "offline_use_description", "Uses AI": "uses_ai", "AI uses personal data": "ai_uses_personal_data", "AI help text": "ai_helptext", "AI is transparent": "ai_is_transparent", } # Return the merged fields return {**generic_product_import_fields, **general_product_mappings} def get_export_fields(self): """ This should be a dictionary of the fields to send to Airtable. Keys are the Column Names in Airtable. Values are the Wagtail values we want to send. """ generic_product_data = super().get_export_fields() general_product_data = { "Has camera device": self.camera_device, "Has camera app": self.camera_app, "Has microphone device": self.microphone_device, "Has microphone app": self.microphone_app, "Has location device": self.location_device, "Has location app": self.location_app, "Personal data collected": self.personal_data_collected, "Biometric data collected": self.biometric_data_collected, "Social data collected": self.social_data_collected, "How you can control your data": self.how_can_you_control_your_data, "Company track record": self.company_track_record, "Show company track record privacy ding": self.track_record_is_bad, "Offline capable": self.offline_capable, "Offline use": self.offline_use_description, "Uses AI": self.uses_ai, "AI uses personal data": self.ai_uses_personal_data, "AI is transparent": self.ai_uses_personal_data, "AI help text": self.ai_helptext, } # Merge the two dicts together. data = {**generic_product_data, **general_product_data} return data # administrative panels content_panels = ProductPage.content_panels.copy() content_panels = insert_panels_after( content_panels, 'Product Categories', [ MultiFieldPanel([ FieldPanel('camera_device'), FieldPanel('camera_app'), FieldPanel('microphone_device'), FieldPanel('microphone_app'), FieldPanel('location_device'), FieldPanel('location_app'), ], heading='Can it snoop?', classname='collapsible'), ], ) content_panels = insert_panels_after( content_panels, 'What is required to sign up', [ MultiFieldPanel( [ FieldPanel('personal_data_collected'), FieldPanel('biometric_data_collected'), FieldPanel('social_data_collected'), ], heading='What data does it collect', classname='collapsible', ), ]) content_panels = insert_panels_after( content_panels, 'How does it use this data', [ MultiFieldPanel( [ FieldPanel('how_can_you_control_your_data'), FieldPanel('data_control_policy_is_bad'), ], heading='How can you control your data', classname='collapsible', ), MultiFieldPanel([ FieldPanel('company_track_record'), FieldPanel('track_record_is_bad'), FieldPanel('track_record_details'), ], heading='Company track record', classname='collapsible'), MultiFieldPanel([ FieldPanel('offline_capable'), FieldPanel('offline_use_description'), ], heading='Offline use', classname='collapsible'), ], ) content_panels = insert_panels_after( content_panels, 'Security', [ MultiFieldPanel([ FieldPanel('uses_ai'), FieldPanel('ai_uses_personal_data'), FieldPanel('ai_is_transparent'), FieldPanel('ai_helptext'), ], heading='Artificial Intelligence', classname='collapsible'), ], ) @property def product_type(self): return "general" class Meta: verbose_name = "General Product Page"
class SoftwareProductPage(ProductPage): template = 'buyersguide/product_page.html' handles_recordings_how = models.TextField( max_length=5000, blank=True, help_text='How does this software handle your recordings') recording_alert = ExtendedYesNoField( null=True, help_text='Alerts when calls are being recorded?', ) recording_alert_helptext = models.TextField(max_length=5000, blank=True) # NullBooleanField is deprecated as of Django 3.1. # We're using it here primarily for a data migration, but we should # move to BooleanField as soon as it's safe to do so with the content we have medical_privacy_compliant = models.NullBooleanField( null=True, help_text='Compliant with US medical privacy laws?') medical_privacy_compliant_helptext = models.TextField(max_length=5000, blank=True) # Can I control it? host_controls = models.TextField(max_length=5000, blank=True) # NullBooleanField is deprecated as of Django 3.1. # We're using it here primarily for a data migration, but we should # move to BooleanField as soon as it's safe to do so with the content we have easy_to_learn_and_use = models.NullBooleanField( null=True, help_text='Is it easy to learn & use the features?', ) easy_to_learn_and_use_helptext = models.TextField(max_length=5000, blank=True) @classmethod def map_import_fields(cls): generic_product_import_fields = super().map_import_fields() software_product_mappings = { "How it handles recording": "handles_recordings_how", "Recording alert": "recording_alert", "Recording alert help text": "recording_alert_helptext", "Medical privacy compliant": "medical_privacy_compliant", "Medical privacy compliant help text": "medical_privacy_compliant_helptext", "Host controls": "host_controls", "Easy to learn and use": "easy_to_learn_and_use", "Easy to learn and use help text": "easy_to_learn_and_use_helptext", } # Return the merged fields return {**generic_product_import_fields, **software_product_mappings} def get_export_fields(self): """ This should be a dictionary of the fields to send to Airtable. Keys are the Column Names in Airtable. Values are the Wagtail values we want to send. """ generic_product_data = super().get_export_fields() software_product_data = { "How it handles recording": self.handles_recordings_how, "Recording alert": self.recording_alert, "Recording alert help text": self.recording_alert_helptext, "Medical privacy compliant": True if self.medical_privacy_compliant else False, "Medical privacy compliant help text": self.medical_privacy_compliant_helptext, "Host controls": self.host_controls, "Easy to learn and use": True if self.easy_to_learn_and_use else False, "Easy to learn and use help text": self.easy_to_learn_and_use_helptext, } data = {**generic_product_data, **software_product_data} return data content_panels = ProductPage.content_panels.copy() content_panels = insert_panels_after( content_panels, 'Product Categories', [ MultiFieldPanel([ FieldPanel('handles_recordings_how'), FieldPanel('recording_alert'), FieldPanel('recording_alert_helptext'), FieldPanel('medical_privacy_compliant'), FieldPanel('medical_privacy_compliant_helptext'), ], heading='How does it handle privacy?', classname='collapsible'), MultiFieldPanel([ FieldPanel('host_controls'), FieldPanel('easy_to_learn_and_use'), FieldPanel('easy_to_learn_and_use_helptext'), ], heading='Can I control it', classname='collapsible'), ], ) @property def product_type(self): return "software" class Meta: verbose_name = "Software Product Page"
class ProductPage(AirtableMixin, FoundationMetadataPageMixin, Page): """ ProductPage is the superclass that SoftwareProductPage and GeneralProductPage inherit from. This should not be an abstract model as we need it to connect the two page types together. """ template = 'buyersguide/product_page.html' privacy_ding = models.BooleanField( help_text='Tick this box if privacy is not included for this product', default=False, ) adult_content = models.BooleanField( help_text= 'When checked, product thumbnail will appear blurred as well as have an 18+ badge on it', default=False, ) uses_wifi = models.BooleanField( help_text='Does this product rely on WiFi connectivity?', default=False, ) uses_bluetooth = models.BooleanField( help_text='Does this product rely on Bluetooth connectivity?', default=False, ) review_date = models.DateField( help_text='Review date of this product', default=timezone.now, ) company = models.CharField( max_length=100, help_text='Name of Company', blank=True, ) blurb = models.TextField(max_length=5000, help_text='Description of the product', blank=True) product_url = models.URLField( max_length=2048, help_text='Link to this product page', blank=True, ) price = models.CharField( max_length=100, help_text='Price', blank=True, ) image = models.ForeignKey( 'wagtailimages.Image', null=True, blank=True, on_delete=models.SET_NULL, related_name='+', help_text='Image representing this product', ) cloudinary_image = CloudinaryField( help_text='Image representing this product - hosted on Cloudinary', blank=True, verbose_name='image', folder='foundationsite/buyersguide', use_filename=True) worst_case = models.TextField( max_length=5000, help_text= "What's the worst thing that could happen by using this product?", blank=True, ) """ privacy_policy_links = Orderable, defined in ProductPagePrivacyPolicyLink Other "magic" relations that use InlinePanels will follow the same pattern of using Wagtail Orderables. """ # What is required to sign up? signup_requires_email = ExtendedYesNoField( help_text= 'Does this product requires providing an email address in order to sign up?' ) signup_requires_phone = ExtendedYesNoField( help_text= 'Does this product requires providing a phone number in order to sign up?' ) signup_requires_third_party_account = ExtendedYesNoField( help_text= 'Does this product require a third party account in order to sign up?') signup_requirement_explanation = models.TextField( max_length=5000, blank=True, help_text='Describe the particulars around sign-up requirements here.') # How does it use this data? how_does_it_use_data_collected = models.TextField( max_length=5000, blank=True, help_text='How does this product use the data collected?') data_collection_policy_is_bad = models.BooleanField( default=False, verbose_name='Privacy ding') # Privacy policy user_friendly_privacy_policy = ExtendedYesNoField( help_text='Does this product have a user-friendly privacy policy?') user_friendly_privacy_policy_helptext = models.TextField(max_length=5000, blank=True) # Minimum security standards show_ding_for_minimum_security_standards = models.BooleanField( default=False, verbose_name="Privacy ding") meets_minimum_security_standards = models.BooleanField( null=True, blank=True, help_text='Does this product meet our minimum security standards?', ) uses_encryption = ExtendedYesNoField( help_text='Does the product use encryption?', ) uses_encryption_helptext = models.TextField(max_length=5000, blank=True) security_updates = ExtendedYesNoField(help_text='Security updates?', ) security_updates_helptext = models.TextField(max_length=5000, blank=True) strong_password = ExtendedYesNoField() strong_password_helptext = models.TextField(max_length=5000, blank=True) manage_vulnerabilities = ExtendedYesNoField( help_text='Manages security vulnerabilities?', ) manage_vulnerabilities_helptext = models.TextField(max_length=5000, blank=True) privacy_policy = ExtendedYesNoField( help_text='Does this product have a privacy policy?') privacy_policy_helptext = models.TextField( # REPURPOSED: WILL REQUIRE A 'clear' MIGRATION max_length=5000, blank=True) # How to contact the company phone_number = models.CharField( max_length=100, help_text='Phone Number', blank=True, ) live_chat = models.CharField( max_length=100, help_text='Live Chat', blank=True, ) email = models.CharField( max_length=100, help_text='Email', blank=True, ) twitter = models.CharField( max_length=100, help_text='Twitter username', blank=True, ) # Un-editable voting fields. Don't add these to the content_panels. creepiness_value = models.IntegerField( default=0) # The total points for creepiness votes = models.ForeignKey( ProductPageVotes, on_delete=models.SET_NULL, null=True, blank=True, related_name='votes', ) @classmethod def map_import_fields(cls): mappings = { "Title": "title", "Wagtail Page ID": "pk", "Slug": "slug", "Show privacy ding": "privacy_ding", "Has adult content": "adult_content", "Uses wifi": "uses_wifi", "Uses Bluetooth": "uses_bluetooth", "Review date": "review_date", "Company": "company", "Blurb": "blurb", "Product link": "product_url", "Price": "price", "Worst case": "worst_case", "Signup requires email": "signup_requires_email", "Signup requires phone number": "signup_requires_phone", "Signup requires 3rd party account": "signup_requires_third_party_account", "Signup explanation": "signup_requirement_explanation", "How it collects data": "how_does_it_use_data_collected", "Data collection privacy ding": "data_collection_policy_is_bad", "User friendly privacy policy": "user_friendly_privacy_policy", "User friendly privacy policy help text": "user_friendly_privacy_policy_helptext", "Meets MSS": "meets_minimum_security_standards", "Meets MSS privacy policy ding": "show_ding_for_minimum_security_standards", "Uses encryption": "uses_encryption", "Encryption help text": "uses_encryption_helptext", "Has security updates": "security_updates", "Security updates help text": "security_updates_helptext", "Strong password": "******", "Strong password help text": "strong_password_helptext", "Manages security vulnerabilities": "manage_vulnerabilities", "Manages security help text": "manage_vulnerabilities_helptext", "Has privacy policy": "privacy_policy", "Privacy policy help text": "privacy_policy_helptext", "Phone number": "phone_number", "Live chat": "live_chat", "Email address": "email", "Twitter": "twitter", } return mappings def get_export_fields(self): """ This should be a dictionary of the fields to send to Airtable. Keys are the Column Names in Airtable. Values are the Wagtail values we want to send. """ return { "Title": self.title, "Slug": self.slug, "Wagtail Page ID": self.pk if hasattr(self, 'pk') else 0, "Last Updated": str(self.last_published_at) if self.last_published_at else str(timezone.now().isoformat()), "Status": self.get_status_for_airtable(), "Show privacy ding": self.privacy_ding, "Has adult content": self.adult_content, "Uses wifi": self.uses_wifi, "Uses Bluetooth": self.uses_bluetooth, "Review date": str(self.review_date), "Company": self.company, "Blurb": self.blurb, "Product link": self.product_url if self.product_url else '', "Price": self.price, "Worst case": self.worst_case, "Signup requires email": self.signup_requires_email, "Signup requires phone number": self.signup_requires_phone, "Signup requires 3rd party account": self.signup_requires_third_party_account, "Signup explanation": self.signup_requirement_explanation, "How it collects data": self.how_does_it_use_data_collected, "Data collection privacy ding": self.data_collection_policy_is_bad, "User friendly privacy policy": self.user_friendly_privacy_policy, "User friendly privacy policy help text": self.user_friendly_privacy_policy_helptext, "Meets MSS": self.meets_minimum_security_standards, "Meets MSS privacy policy ding": self.show_ding_for_minimum_security_standards, "Uses encryption": self.uses_encryption, "Encryption help text": self.uses_encryption_helptext, "Has security updates": self.security_updates, "Security updates help text": self.security_updates_helptext, "Strong password": self.strong_password, "Strong password help text": self.strong_password_helptext, "Manages security vulnerabilities": self.manage_vulnerabilities, "Manages security help text": self.manage_vulnerabilities_helptext, "Has privacy policy": self.privacy_policy, "Privacy policy help text": self.privacy_policy_helptext, "Phone number": self.phone_number, "Live chat": self.live_chat, "Email address": self.email, "Twitter": self.twitter if self.twitter else '' } def get_status_for_airtable(self): if self.live: if self.has_unpublished_changes: return "Live + Draft" return "Live" return "Draft" @property def total_vote_count(self): return sum(self.get_or_create_votes()) @property def creepiness(self): try: average = self.creepiness_value / self.total_vote_count except ZeroDivisionError: average = 50 return average @property def get_voting_json(self): """ Return a dictionary as a string with the relevant data needed for the frontend: """ votes = self.votes.get_votes() data = { 'creepiness': { 'vote_breakdown': {k: v for (k, v) in enumerate(votes)}, 'average': self.creepiness }, 'total': self.total_vote_count } return json.dumps(data) content_panels = Page.content_panels + [ MultiFieldPanel([ FieldPanel('review_date'), FieldPanel('privacy_ding'), FieldPanel('adult_content'), FieldPanel('company'), FieldPanel('product_url'), FieldPanel('price'), FieldPanel('uses_wifi'), FieldPanel('uses_bluetooth'), FieldPanel('blurb'), image_field, FieldPanel('worst_case'), ], heading='General Product Details', classname='collapsible'), MultiFieldPanel( [ InlinePanel('product_categories', label='Category'), ], heading='Product Categories', classname='collapsible', ), MultiFieldPanel([ FieldPanel('signup_requires_email'), FieldPanel('signup_requires_phone'), FieldPanel('signup_requires_third_party_account'), FieldPanel('signup_requirement_explanation'), ], heading='What is required to sign up', classname='collapsible'), MultiFieldPanel( [ FieldPanel('how_does_it_use_data_collected'), FieldPanel('data_collection_policy_is_bad'), ], heading='How does it use this data', classname='collapsible', ), MultiFieldPanel([ FieldPanel('user_friendly_privacy_policy'), FieldPanel('user_friendly_privacy_policy_helptext'), ], heading='Privacy policy', classname='collapsible'), MultiFieldPanel([ InlinePanel( 'privacy_policy_links', label='link', min_num=1, max_num=3, ), ], heading='Privacy policy links', classname='collapsible'), MultiFieldPanel([ FieldPanel('show_ding_for_minimum_security_standards'), FieldPanel('meets_minimum_security_standards'), FieldPanel('uses_encryption'), FieldPanel('uses_encryption_helptext'), FieldPanel('security_updates'), FieldPanel('security_updates_helptext'), FieldPanel('strong_password'), FieldPanel('strong_password_helptext'), FieldPanel('manage_vulnerabilities'), FieldPanel('manage_vulnerabilities_helptext'), FieldPanel('privacy_policy'), FieldPanel('privacy_policy_helptext'), ], heading='Security', classname='collapsible'), MultiFieldPanel([ FieldPanel('phone_number'), FieldPanel('live_chat'), FieldPanel('email'), FieldPanel('twitter'), ], heading='Ways to contact the company', classname='collapsible'), MultiFieldPanel( [InlinePanel('updates', label='Update')], heading='Product Updates', ), MultiFieldPanel( [InlinePanel('related_product_pages', label='Product')], heading='Related Products', ), ] @property def product_type(self): return "unknown" def get_or_create_votes(self): """ If a page doesn't have a ProductPageVotes objects, create it. Regardless of whether or not its created, return the parsed votes. """ if not self.votes: votes = ProductPageVotes() votes.save() self.votes = votes self.save() return self.votes.get_votes() def get_context(self, request, *args, **kwargs): context = super().get_context(request, *args, **kwargs) context['product'] = self context['categories'] = BuyersGuideProductCategory.objects.filter( hidden=False) context[ 'mediaUrl'] = settings.CLOUDINARY_URL if settings.USE_CLOUDINARY else settings.MEDIA_URL context['use_commento'] = settings.USE_COMMENTO context['pageTitle'] = f'{self.title} | ' + gettext( "Privacy & security guide") + ' | Mozilla Foundation' context['about_page'] = BuyersGuidePage.objects.first() return context def serve(self, request, *args, **kwargs): # In Wagtail we use the serve() method to detect POST submissions. # Alternatively, this could be a routable view. # For more on this, see the docs here: # https://docs.wagtail.io/en/stable/reference/pages/model_recipes.html#overriding-the-serve-method if request.body and request.method == "POST": # If the request is POST. Parse the body. data = json.loads(request.body) # If the POST body has a productID and value, it's someone voting on the product if data.get("value"): # Product ID and Value can both be zero. It's impossible to get a Page with ID of zero. try: value = int(data["value"]) # ie. 0 to 100 except ValueError: return HttpResponseNotAllowed( 'Product ID or value is invalid') if value < 0 or value > 100: return HttpResponseNotAllowed('Cannot save vote') try: product = ProductPage.objects.get(pk=self.id) # If the product exists but isn't live and the user isn't logged in.. if (not product.live and not request.user.is_authenticated) or not product: return HttpResponseNotFound("Product does not exist") # Save the new voting totals product.creepiness_value = product.creepiness_value + value # Add the vote to the vote bin if not product.votes: # If there is no vote bin attached to this product yet, create one now. votes = ProductPageVotes() votes.save() product.votes = votes # Add the vote to the proper "vote bin" votes = product.votes.get_votes() index = int((value - 1) / 20) votes[index] += 1 product.votes.set_votes(votes) # Don't save this as a revision with .save_revision() as to not spam the Audit log # And don't make this live with .publish(). The Page model will have the proper # data stored on it already, and the revision history won't be spammed by votes. product.save() return HttpResponse('Vote recorded', content_type='text/plain') except ProductPage.DoesNotExist: return HttpResponseNotFound('Missing page') except ValidationError as ex: return HttpResponseNotAllowed( f'Payload validation failed: {ex}') except Error as ex: print( f'Internal Server Error (500) for ProductPage: {ex.message} ({type(ex)})' ) return HttpResponseServerError() self.get_or_create_votes() return super().serve(request, *args, **kwargs) def save(self, *args, **kwargs): # When a new ProductPage is created, ensure a vote bin always exists. # We can use save() or a post-save Wagtail hook. save = super().save(*args, **kwargs) self.get_or_create_votes() return save class Meta: verbose_name = "Product Page"
class Product(ClusterableModel): """ A product that may not have privacy included. """ @property def specific(self): for Type in registered_product_types: try: return Type.objects.get(slug=self.slug) except ObjectDoesNotExist: pass return self draft = models.BooleanField( help_text= 'When checked, this product will only show for CMS-authenticated users', default=True, ) adult_content = models.BooleanField( help_text= 'When checked, product thumbnail will appear blurred as well as have an 18+ badge on it', default=False, ) review_date = models.DateField(help_text='Review date of this product', ) name = models.CharField( max_length=100, help_text='Name of Product', blank=True, ) slug = models.CharField(max_length=256, help_text='slug used in urls', blank=True, default=None, editable=False) company = models.CharField( max_length=100, help_text='Name of Company', blank=True, ) product_category = models.ManyToManyField( 'buyersguide.BuyersGuideProductCategory', related_name='pniproduct', blank=True) blurb = models.TextField(max_length=5000, help_text='Description of the product', blank=True) url = models.URLField( max_length=2048, help_text='Link to this product page', blank=True, ) price = models.CharField( max_length=100, help_text='Price', blank=True, ) image = models.FileField( max_length=2048, help_text='Image representing this product', upload_to=get_product_image_upload_path, blank=True, ) cloudinary_image = CloudinaryField( help_text='Image representing this product - hosted on Cloudinary', blank=True, verbose_name='image', folder='foundationsite/buyersguide', use_filename=True) meets_minimum_security_standards = models.BooleanField( null=True, help_text='Does this product meet minimum security standards?', ) # Minimum security standards (stars) uses_encryption = ExtendedYesNoField( help_text='Does the product use encryption?', ) uses_encryption_helptext = models.TextField(max_length=5000, blank=True) security_updates = ExtendedYesNoField(help_text='Security updates?', ) security_updates_helptext = models.TextField(max_length=5000, blank=True) strong_password = ExtendedYesNoField() strong_password_helptext = models.TextField(max_length=5000, blank=True) manage_vulnerabilities = ExtendedYesNoField( help_text='Manages security vulnerabilities?', ) manage_vulnerabilities_helptext = models.TextField(max_length=5000, blank=True) privacy_policy = ExtendedYesNoField( help_text='Does this product have a privacy policy?') privacy_policy_helptext = models.TextField( # REPURPOSED: WILL REQUIRE A 'clear' MIGRATION max_length=5000, blank=True) # How it handles privacy share_data = models.BooleanField( # TO BE REMOVED? null=True, help_text='Does the maker share data with other companies?', ) share_data_helptext = models.TextField( # TO BE REMOVED? max_length=5000, blank=True) how_does_it_share = models.CharField( max_length=5000, help_text='How does this product handle data?', blank=True) user_friendly_privacy_policy = ExtendedYesNoField( help_text='Does this product have a user-friendly privacy policy?') user_friendly_privacy_policy_helptext = models.TextField(max_length=5000, blank=True) """ privacy_policy_links = one to many, defined in PrivacyPolicyLink """ worst_case = models.CharField( max_length=5000, help_text= "What's the worst thing that could happen by using this product?", blank=True, ) # How to contact the company phone_number = models.CharField( max_length=100, help_text='Phone Number', blank=True, ) live_chat = models.CharField( max_length=100, help_text='Live Chat', blank=True, ) email = models.CharField( max_length=100, help_text='Email', blank=True, ) twitter = models.CharField( max_length=100, help_text='Twitter username', blank=True, ) updates = models.ManyToManyField('buyersguide.Update', related_name='pniproduct', blank=True) # comments are not a model field, but are "injected" on the product page instead related_products = models.ManyToManyField( 'self', related_name='related_pniproduct', blank=True, symmetrical=False) # List of fields to show in admin to hide the image/cloudinary_image field. There's probably a better way to do # this using `_meta.get_fields()`. To be refactored in the future. panels = product_panels @property def is_current(self): d = self.review_date review = datetime(d.year, d.month, d.day) cutoff = datetime(2019, 6, 1) return cutoff < review @property def votes(self): return get_product_vote_information(self) def to_dict(self): """ Rather than rendering products based on the instance object, we serialize the product to a dictionary, and instead render the template based on that. NOTE: if you add indirect fields like Foreign/ParentalKey or @property definitions, those needs to be added! """ model_dict = model_to_dict(self) model_dict['votes'] = self.votes model_dict['slug'] = self.slug # model_to_dict does NOT capture related fields or @properties! model_dict['privacy_policy_links'] = list( self.privacy_policy_links.all()) model_dict['is_current'] = self.is_current return model_dict def save(self, *args, **kwargs): self.slug = slugify(self.name) models.Model.save(self, *args, **kwargs) def __str__(self): return f'{self.name} ({self.company})' class Meta: # use oldest-first ordering ordering = ['id']
class GeneralProduct(Product): """ A thing you can buy in stores and our review of it """ # Can it snoop on me camera_device = ExtendedYesNoField( help_text='Does this device have or access a camera?', ) camera_app = ExtendedYesNoField( help_text='Does the app have or access a camera?', ) microphone_device = ExtendedYesNoField( help_text='Does this Device have or access a microphone?', ) microphone_app = ExtendedYesNoField( help_text='Does this app have or access a microphone?', ) location_device = ExtendedYesNoField( help_text='Does this product access your location?', ) location_app = ExtendedYesNoField( help_text='Does this app access your location?', ) # What data does it collect? personal_data_collected = models.TextField( max_length=5000, blank=True, help_text='What kind of personal data does this product collect?') biometric_data_collected = models.TextField( max_length=5000, blank=True, help_text='What kind of biometric data does this product collect?') social_data_collected = models.TextField( max_length=5000, blank=True, help_text='What kind of social data does this product collect?') # How can you control your data how_can_you_control_your_data = models.TextField( max_length=5000, blank=True, help_text='How does this product let you control your data?') data_control_policy_is_bad = models.BooleanField( default=False, verbose_name='Privacy ding') # Company track record track_record_choices = [('Great', 'Great'), ('Average', 'Average'), ('Needs Improvement', 'Needs Improvement'), ('Bad', 'Bad')] company_track_record = models.CharField( choices=track_record_choices, default='Average', help_text='This company has a ... track record', max_length=20) track_record_is_bad = models.BooleanField(default=False, verbose_name='Privacy ding') track_record_details = models.TextField( max_length=5000, blank=True, help_text='Describe the track record of this company here.') # Offline use offline_capable = ExtendedYesNoField( help_text='Can this product be used offline?', ) offline_use_description = models.TextField( max_length=5000, blank=True, help_text='Describe how this product can be used offline.') # Artificial Intelligence uses_ai = ExtendedYesNoField(help_text='Does the product use AI?') ai_uses_personal_data = ExtendedYesNoField( help_text= 'Does the AI use your personal data to make decisions about you?') ai_is_transparent = ExtendedYesNoField( help_text='Does the company allow users to see how the AI works?') ai_helptext = models.TextField( max_length=5000, blank=True, help_text='Helpful text around AI to show on the product page', ) # administrative panels panels = Product.panels.copy() panels = insert_panels_after( panels, 'General Product Details', [ MultiFieldPanel([ FieldPanel('camera_device'), FieldPanel('camera_app'), FieldPanel('microphone_device'), FieldPanel('microphone_app'), FieldPanel('location_device'), FieldPanel('location_app'), ], heading='Can it snoop?', classname='collapsible'), ], ) panels = insert_panels_after(panels, 'What is required to sign up', [ MultiFieldPanel( [ FieldPanel('personal_data_collected'), FieldPanel('biometric_data_collected'), FieldPanel('social_data_collected'), ], heading='What data does it collect', classname='collapsible', ), ]) panels = insert_panels_after( panels, 'How does it use this data', [ MultiFieldPanel( [ FieldPanel('how_can_you_control_your_data'), FieldPanel('data_control_policy_is_bad'), ], heading='How can you control your data', classname='collapsible', ), MultiFieldPanel([ FieldPanel('company_track_record'), FieldPanel('track_record_is_bad'), FieldPanel('track_record_details'), ], heading='Company track record', classname='collapsible'), MultiFieldPanel([ FieldPanel('offline_capable'), FieldPanel('offline_use_description'), ], heading='Offline use', classname='collapsible'), ], ) panels = insert_panels_after( panels, 'Security', [ MultiFieldPanel([ FieldPanel('uses_ai'), FieldPanel('ai_uses_personal_data'), FieldPanel('ai_is_transparent'), FieldPanel('ai_helptext'), ], heading='Artificial Intelligence', classname='collapsible'), ], ) def to_dict(self): model_dict = super().to_dict() model_dict['product_type'] = 'general' return model_dict
class GeneralProduct(Product): """ A thing you can buy in stores and our review of it """ # It uses your... camera_device = ExtendedYesNoField( help_text='Does this device have or access a camera?', ) camera_app = ExtendedYesNoField( help_text='Does the app have or access a camera?', ) microphone_device = ExtendedYesNoField( help_text='Does this Device have or access a microphone?', ) microphone_app = ExtendedYesNoField( help_text='Does this app have or access a microphone?', ) location_device = ExtendedYesNoField( help_text='Does this product access your location?', ) location_app = ExtendedYesNoField( help_text='Does this app access your location?', ) # how it handles privacy delete_data = models.BooleanField( # TO BE REMOVED? null=True, help_text='Can you request data be deleted?', ) delete_data_helptext = models.TextField( # TO BE REMOVED? max_length=5000, blank=True) parental_controls = ExtendedYesNoField( null=True, help_text='Are there rules for children?', ) child_rules_helptext = models.TextField( # TO BE REMOVED? max_length=5000, blank=True) collects_biometrics = ExtendedYesNoField( help_text='Does this product collect biometric data?', ) collects_biometrics_helptext = models.TextField(max_length=5000, blank=True) # administrative panels panels = Product.panels.copy() panels = insert_panels_after( panels, 'Minimum Security Standards for general products', [ MultiFieldPanel([ FieldPanel('camera_device'), FieldPanel('camera_app'), FieldPanel('microphone_device'), FieldPanel('microphone_app'), FieldPanel('location_device'), FieldPanel('location_app'), ], heading='Can it snoop?', classname='collapsible'), ], ) panels = insert_panels_after( panels, 'How does it handle data sharing', [ MultiFieldPanel([ FieldPanel('delete_data'), FieldPanel('delete_data_helptext'), FieldPanel('parental_controls'), FieldPanel('collects_biometrics'), FieldPanel('collects_biometrics_helptext'), FieldPanel('user_friendly_privacy_policy'), FieldPanel('user_friendly_privacy_policy_helptext'), ], heading='How does it handle privacy', classname='collapsible'), ], ) def to_dict(self): model_dict = super().to_dict() model_dict['delete_data'] = tri_to_quad(self.delete_data) model_dict['product_type'] = 'general' return model_dict
class SoftwareProduct(Product): """ A thing you can install on your computer and our review of it """ # How does it handle privacy? handles_recordings_how = models.TextField( max_length=5000, blank=True, help_text='How does this software handle your recordings') recording_alert = ExtendedYesNoField( null=True, help_text='Alerts when calls are being recorded?', ) recording_alert_helptext = models.TextField(max_length=5000, blank=True) medical_privacy_compliant = models.BooleanField( null=True, help_text='Compliant with US medical privacy laws?') medical_privacy_compliant_helptext = models.TextField(max_length=5000, blank=True) # Can I control it? host_controls = models.TextField(max_length=5000, blank=True) easy_to_learn_and_use = models.BooleanField( null=True, help_text='Is it easy to learn & use the features?', ) easy_to_learn_and_use_helptext = models.TextField(max_length=5000, blank=True) # administrative panels panels = Product.panels.copy() panels = insert_panels_after( panels, 'General Product Details', [ MultiFieldPanel([ FieldPanel('handles_recordings_how'), FieldPanel('recording_alert'), FieldPanel('recording_alert_helptext'), FieldPanel('medical_privacy_compliant'), FieldPanel('medical_privacy_compliant_helptext'), ], heading='How does it handle privacy?', classname='collapsible'), MultiFieldPanel([ FieldPanel('host_controls'), FieldPanel('easy_to_learn_and_use'), FieldPanel('easy_to_learn_and_use_helptext'), ], heading='Can I control it', classname='collapsible'), ], ) def to_dict(self): model_dict = super().to_dict() model_dict['product_type'] = 'software' return model_dict