Skip to content

Commit

Permalink
docs: Add PII annotations (#274)
Browse files Browse the repository at this point in the history
Per OEP-30 these annotations should live with the project that defines
the models. I'm moving them here so we can remove them from
edx-platform.
  • Loading branch information
bmtcril authored Nov 27, 2024
1 parent 0796ba1 commit 0eac172
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 98 deletions.
43 changes: 26 additions & 17 deletions django_notify/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,43 +11,51 @@
class NotificationType(models.Model):
"""
Notification types are added on-the-fly by the
applications adding new notifications"""
applications adding new notifications
.. no_pii:
"""
key = models.CharField(max_length=128, primary_key=True, verbose_name=_('unique key'),
unique=True)
label = models.CharField(max_length=128, verbose_name=_('verbose name'),
blank=True, null=True)
content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE)

def __unicode__(self):
return self.key

class Meta:
app_label = 'django_notify'
db_table = settings.DB_TABLE_PREFIX + '_notificationtype'
verbose_name = _('type')
verbose_name_plural = _('types')

class Settings(models.Model):

"""
.. no_pii:
"""

user = models.ForeignKey(User, on_delete=models.CASCADE)
interval = models.SmallIntegerField(choices=settings.INTERVALS, verbose_name=_('interval'),
default=settings.INTERVALS_DEFAULT)

def __unicode__(self):
return _("Settings for %s") % self.user.username

class Meta:
app_label = 'django_notify'
db_table = settings.DB_TABLE_PREFIX + '_settings'
verbose_name = _('settings')
verbose_name_plural = _('settings')

class Subscription(models.Model):

"""
.. no_pii:
"""
subscription_id = models.AutoField(primary_key=True)
settings = models.ForeignKey(Settings, on_delete=models.CASCADE)
notification_type = models.ForeignKey(NotificationType, on_delete=models.CASCADE)
object_id = models.CharField(max_length=64, null=True, blank=True,
object_id = models.CharField(max_length=64, null=True, blank=True,
help_text=_('Leave this blank to subscribe to any kind of object'))
send_emails = models.BooleanField(default=True)

Expand All @@ -61,23 +69,25 @@ class Meta:
verbose_name_plural = _('subscriptions')

class Notification(models.Model):

"""
.. no_pii:
"""
subscription = models.ForeignKey(Subscription, null=True, blank=True, on_delete=models.SET_NULL)
message = models.TextField()
url = models.URLField(blank=True, null=True, verbose_name=_('link for notification'))
is_viewed = models.BooleanField(default=False)
is_emailed = models.BooleanField(default=False)
created = models.DateTimeField(auto_now_add=True)

@classmethod
def create_notifications(cls, key, **kwargs):
if not key or not isinstance(key, str):
raise KeyError('No notification key (string) specified.')

object_id = kwargs.pop('object_id', None)

objects_created = []
subscriptions = Subscription.objects.filter(Q(notification_type__key=key) |
subscriptions = Subscription.objects.filter(Q(notification_type__key=key) |
Q(notification_type__key=None),)
if object_id:
subscriptions = subscriptions.filter(Q(object_id=object_id) |
Expand All @@ -94,9 +104,9 @@ def create_notifications(cls, key, **kwargs):
cls.objects.create(subscription=subscription, **kwargs)
)
prev_user = subscription.settings.user

return objects_created

def __unicode__(self):
return "%s: %s" % (str(self.subscription.settings.user), self.message)

Expand All @@ -122,7 +132,6 @@ def notify(message, key, target_object=None, url=None):
with the message "New comment posted".
notify("New comment posted", "new_comments")
"""

if _disable_notifications:
Expand Down
30 changes: 24 additions & 6 deletions wiki/models/article.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@


class Article(models.Model):

"""
.. no_pii:
"""
objects = managers.ArticleManager()

current_revision = models.OneToOneField('ArticleRevision',
Expand Down Expand Up @@ -192,7 +194,9 @@ def render(self, preview_content=None):


class ArticleForObject(models.Model):

"""
.. no_pii:
"""
objects = managers.ArticleFkManager()

article = models.ForeignKey('Article', on_delete=models.CASCADE)
Expand All @@ -213,8 +217,16 @@ class Meta:


class BaseRevisionMixin(models.Model):
"""This is an abstract model used as a mixin: Do not override any of the
core model methods but respect the inheritor's freedom to do so itself."""
"""
This is an abstract model used as a mixin: Do not override any of the
core model methods but respect the inheritor's freedom to do so itself.
Marking this as no PII here, it says it's abstract but is handled as an
actual model instead of an AbstractModel, probably because this code
predates that Django functionality.
.. no_pii: Though this model has an IP address field, it is abstract.
"""

revision_number = models.IntegerField(editable=False, verbose_name=_('revision number'))

Expand Down Expand Up @@ -258,8 +270,14 @@ class Meta:


class ArticleRevision(BaseRevisionMixin, models.Model):
"""This is where main revision data is stored. To make it easier to
copy, do NEVER create m2m relationships."""
"""
This is where main revision data is stored. To make it easier to
copy, do NEVER create m2m relationships.
.. pii: This model stores the IP addresses of users who have edited the article
.. pii_types: choice_ip
.. pii_retirement: local_api
"""

article = models.ForeignKey('Article', on_delete=models.CASCADE,
verbose_name=_('article'))
Expand Down
87 changes: 50 additions & 37 deletions wiki/models/pluginbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,37 @@
1) SimplePlugin - an object purely associated with an article. Will bump the
article's revision history upon creation, and rolling back an article will
make it go away (not from the database, you can roll forwards again).
2) RevisionPlugin - an object with its own revisions. The object will have its
own history independent of the article. The strategy is that you will provide
different code for the article text while including it, so it will indirectly
affect the article history, but you have the force of rolling back this
object independently.
3) ReusablePlugin - a plugin that can be used on many articles. Please note
that the logics for keeping revisions on such plugins are complicated, so you
have to implement that on your own. Furthermore, you need to be aware of
the permission system!
"""


class ArticlePlugin(models.Model):
"""This is the mother of all plugins. Extending from it means a deletion
"""
This is the mother of all plugins. Extending from it means a deletion
of an article will CASCADE to your plugin, and the database will be kept
clean. Furthermore, it's possible to list all plugins and maintain generic
properties in the future..."""

article = models.ForeignKey(Article, on_delete=models.CASCADE,
properties in the future...
.. no_pii:
"""

article = models.ForeignKey(Article, on_delete=models.CASCADE,
verbose_name=_("article"))

deleted = models.BooleanField(default=False)

created = models.DateTimeField(auto_now_add=True)

# Permission methods - you should override these, if they don't fit your logic.
def can_read(self, **kwargs):
return self.article.can_read(**kwargs)
Expand All @@ -57,16 +59,19 @@ def purge(self):


class ReusablePlugin(ArticlePlugin):
"""Extend from this model if you have a plugin that may be related to many
"""
Extend from this model if you have a plugin that may be related to many
articles. Please note that the ArticlePlugin.article ForeignKey STAYS! This
is in order to maintain an explicit set of permissions.
In general, it's quite complicated to maintain plugin content that's shared
between different articles. The best way to go is to avoid this. For inspiration,
look at wiki.plugins.attachments
You might have to override the permission methods (can_read, can_write etc.)
if you have certain needs for logic in your reusable plugin.
.. no_pii:
"""
# The article on which the plugin was originally created.
# Used to apply permissions.
Expand All @@ -75,9 +80,9 @@ class ReusablePlugin(ArticlePlugin):
ArticlePlugin.article.help_text=_('Permissions are inherited from this article')
ArticlePlugin.article.null = True
ArticlePlugin.article.blank = True

articles = models.ManyToManyField(Article, related_name='shared_plugins_set')

# Since the article relation may be None, we have to check for this
# before handling permissions....
def can_read(self, **kwargs):
Expand All @@ -90,13 +95,13 @@ def can_moderate(self, user):
return self.article.can_moderate(user) if self.article else False

def save(self, *args, **kwargs):

# Automatically make the original article the first one in the added set
if not self.article:
articles = self.articles.all()
if articles.count() == 0:
self.article = articles[0]

super().save(*args, **kwargs)


Expand All @@ -108,30 +113,32 @@ class SimplePlugin(ArticlePlugin):
saving a new instance. This way, a new revision will be created, and
users are able to roll back to the a previous revision (in which your
plugin wasn't related to the article).
Furthermore, your plugin relation is kept when new revisions are created.
Usage:
class YourPlugin(SimplePlugin):
...
Creating new plugins instances:
YourPlugin(article=article_instance, ...) or
YourPlugin.objects.create(article=article_instance, ...)
.. no_pii:
"""
# The article revision that this plugin is attached to
article_revision = models.ForeignKey(ArticleRevision, on_delete=models.CASCADE)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.id and not 'article' in kwargs:
raise SimplePluginCreateError("Keyword argument 'article' expected.")
self.article = kwargs['article']

def get_logmessage(self):
return _("A plugin was changed")

def save(self, *args, **kwargs):
if not self.id:
if not self.article.current_revision:
Expand All @@ -140,7 +147,7 @@ def save(self, *args, **kwargs):
new_revision.inherit_predecessor(self.article)
new_revision.automatic_log = self.get_logmessage()
new_revision.save()

self.article_revision = new_revision
super().save(*args, **kwargs)

Expand All @@ -149,25 +156,27 @@ class RevisionPlugin(ArticlePlugin):
"""
If you want your plugin to maintain revisions, extend from this one,
not SimplePlugin.
This kind of plugin is not attached to article plugins so rolling articles
back and forth does not affect it.
.. no_pii:
"""
# The current revision of this plugin, if any!
current_revision = models.OneToOneField('RevisionPluginRevision',
current_revision = models.OneToOneField('RevisionPluginRevision',
verbose_name=_('current revision'),
blank=True, null=True, related_name='plugin_set',
help_text=_('The revision being displayed for this plugin.'
'If you need to do a roll-back, simply change the value of this field.'),
on_delete=models.CASCADE
)

def add_revision(self, new_revision, save=True):
"""
Sets the properties of a revision and ensures its the current
revision.
"""
assert self.id or save, ('RevisionPluginRevision.add_revision: Sorry, you cannot add a'
assert self.id or save, ('RevisionPluginRevision.add_revision: Sorry, you cannot add a'
'revision to a plugin that has not been saved '
'without using save=True')
if not self.id: self.save()
Expand All @@ -188,17 +197,21 @@ class RevisionPluginRevision(BaseRevisionMixin, models.Model):
"""
If you want your plugin to maintain revisions, make an extra model
that extends from this one.
(this class is very much copied from wiki.models.article.ArticleRevision
(this class is very much copied from wiki.models.article.ArticleRevision)
.. pii: This model stores the IP addresses of users who have edited the object
.. pii_types: choice_ip
.. pii_retirement: local_api
"""

plugin = models.ForeignKey(RevisionPlugin, related_name='revision_set', on_delete=models.CASCADE)

def save(self, *args, **kwargs):
if (not self.id and
not self.previous_revision and
not self.previous_revision and
self.plugin and
self.plugin.current_revision and
self.plugin.current_revision and
self.plugin.current_revision != self):
self.previous_revision = self.plugin.current_revision

Expand All @@ -210,7 +223,7 @@ def save(self, *args, **kwargs):
self.revision_number = 1

super().save(*args, **kwargs)

if not self.plugin.current_revision:
# If I'm saved from Django admin, then plugin.current_revision is me!
self.plugin.current_revision = self
Expand Down Expand Up @@ -240,7 +253,7 @@ class Meta:


def update_simple_plugins(instance, *args, **kwargs):
"""Every time a new article revision is created, we update all active
"""Every time a new article revision is created, we update all active
plugins to match this article revision"""
if kwargs.get('created', False):
p_revisions = SimplePlugin.objects.filter(article=instance.article, deleted=False)
Expand Down
Loading

0 comments on commit 0eac172

Please sign in to comment.