diff --git a/django_notify/models.py b/django_notify/models.py index d40c419bb..c559aad11 100644 --- a/django_notify/models.py +++ b/django_notify/models.py @@ -11,31 +11,37 @@ 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' @@ -43,11 +49,13 @@ class Meta: 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) @@ -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) | @@ -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) @@ -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: diff --git a/wiki/models/article.py b/wiki/models/article.py index 8259820c9..550e852d3 100644 --- a/wiki/models/article.py +++ b/wiki/models/article.py @@ -16,7 +16,9 @@ class Article(models.Model): - + """ + .. no_pii: + """ objects = managers.ArticleManager() current_revision = models.OneToOneField('ArticleRevision', @@ -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) @@ -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')) @@ -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')) diff --git a/wiki/models/pluginbase.py b/wiki/models/pluginbase.py index f906ce975..9e8c1406b 100644 --- a/wiki/models/pluginbase.py +++ b/wiki/models/pluginbase.py @@ -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) @@ -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. @@ -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): @@ -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) @@ -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: @@ -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) @@ -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() @@ -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 @@ -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 @@ -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) diff --git a/wiki/models/urlpath.py b/wiki/models/urlpath.py index c85f1b292..cfa3a6faf 100644 --- a/wiki/models/urlpath.py +++ b/wiki/models/urlpath.py @@ -25,35 +25,37 @@ class URLPath(MPTTModel): """ Strategy: Very few fields go here, as most has to be managed through an article's revision. As a side-effect, the URL resolution remains slim and swift. + + .. no_pii: """ # Tells django-wiki that permissions from a this object's article # should be inherited to children's articles. In this case, it's a static # property.. but you can also use a BooleanField. INHERIT_PERMISSIONS = True - + objects = managers.URLPathManager() - + articles = fields.GenericRelation(ArticleForObject) - + # Do NOT modify this field - it is updated with signals whenever ArticleForObject is changed. article = models.ForeignKey(Article, on_delete=models.CASCADE, editable=False, verbose_name=_('Cache lookup value for articles')) - + # The slug is constructed from course key and will in practice be much shorter then 255 characters # since course keys are capped at 65 characters in the Studio (https://openedx.atlassian.net/browse/TNL-889). SLUG_MAX_LENGTH = 255 - + slug = models.SlugField(verbose_name=_('slug'), null=True, blank=True, max_length=SLUG_MAX_LENGTH) site = models.ForeignKey(Site, on_delete=models.CASCADE) parent = TreeForeignKey('self', null=True, blank=True, related_name='children', on_delete=models.CASCADE) - + def __init__(self, *args, **kwargs): pass # Fixed in django-mptt 0.5.3 #self._tree_manager = URLPath.objects return super().__init__(*args, **kwargs) - + @property def cached_ancestors(self): """ @@ -61,43 +63,43 @@ def cached_ancestors(self): cached from the article path lookup. Accessing a foreign key included in add_selecte_related on one of these ancestors will not occur an additional sql query, as they were retrieved with a select_related. - + If the cached ancestors were not set explicitly, they will be retrieved from the database. """ if not hasattr(self, "_cached_ancestors"): self._cached_ancestors = list(self.get_ancestors().select_related_common() ) - + return self._cached_ancestors - + @cached_ancestors.setter def cached_ancestors(self, ancestors): self._cached_ancestors = ancestors - + def set_cached_ancestors_from_parent(self, parent): self.cached_ancestors = parent.cached_ancestors + [parent] - + @property def path(self): if not self.parent: return "" - + ancestors = [ancestor for ancestor in self.cached_ancestors if ancestor.parent is not None] slugs = [obj.slug if obj.slug else "" for obj in ancestors + [self] ] - + return "/".join(slugs) + "/" - + def is_deleted(self): """ Returns True if this article or any of its ancestors have been deleted """ return self.first_deleted_ancestor() is not None - + def first_deleted_ancestor(self): for ancestor in self.cached_ancestors + [self]: if ancestor.article.current_revision.deleted == True: return ancestor return None - + def delete_subtree(self): """ NB! This deletes this urlpath, its children, and ALL of the related @@ -110,15 +112,15 @@ def delete_subtree(self): descendant.article.delete() except: log.exception("Exception deleting article subtree.") - - - + + + @classmethod def root(cls): site = get_current_site(get_current_request()) root_nodes = list( cls.objects.root_nodes().filter(site=site).select_related_common() - ) + ) # We fetch the nodes as a list and use len(), not count() because we need # to get the result out anyway. This only takes one sql query no_paths = len(root_nodes) @@ -130,23 +132,23 @@ def root(cls): class MPTTMeta: pass - + def __unicode__(self): path = self.path return path if path else gettext("(root)") - + def save(self, *args, **kwargs): super().save(*args, **kwargs) - + def delete(self, *args, **kwargs): assert not (self.parent and self.get_children()), "You cannot delete a root article with children." super().delete(*args, **kwargs) - + class Meta: verbose_name = _('URL path') verbose_name_plural = _('URL paths') unique_together = ('site', 'parent', 'slug') - + def clean(self, *args, **kwargs): if self.slug and not self.parent: raise ValidationError(_('Sorry but you cannot have a root article with a slug.')) @@ -156,25 +158,25 @@ def clean(self, *args, **kwargs): if URLPath.objects.root_nodes().filter(site=self.site).exclude(id=self.id): raise ValidationError(_('There is already a root node on %s') % self.site) super().clean(*args, **kwargs) - + @classmethod def get_by_path(cls, path, select_related=False): """ Strategy: Don't handle all kinds of weird cases. Be strict. Accepts paths both starting with and without '/' """ - + # TODO: Save paths directly in the model for constant time lookups? - + # Or: Save the parents in a lazy property because the parents are # always fetched anyways so it's fine to fetch them here. path = path.lstrip("/") path = path.rstrip("/") - + # Root page requested if not path: return cls.root() - + slugs = path.split('/') level = 1 parent = cls.root() @@ -188,12 +190,12 @@ def get_by_path(cls, path, select_related=False): child.cached_ancestors = parent.cached_ancestors + [parent] parent = child level += 1 - + return parent - + def get_absolute_url(self): return reverse('wiki:get', kwargs={'path': self.path}) - + @classmethod def create_root(cls, site=None, title="Root", request=None, **kwargs): if not site: site = get_current_site(get_current_request()) @@ -210,7 +212,7 @@ def create_root(cls, site=None, title="Root", request=None, **kwargs): else: root = root_nodes[0] return root - + @classmethod def create_article(cls, parent, slug, site=None, title="Root", article_kwargs={}, **kwargs): """Utility function: @@ -223,7 +225,7 @@ def create_article(cls, parent, slug, site=None, title="Root", article_kwargs={} newpath = cls.objects.create(site=site, parent=parent, slug=slug, article=article) article.add_object_relation(newpath) return newpath - + ###################################################### # SIGNAL HANDLERS @@ -269,7 +271,7 @@ def on_article_delete(instance, *args, **kwargs): article=article) article.add_object_relation(lost_and_found) - + for urlpath in URLPath.objects.filter(articles__article=instance, site=site): # Delete the children for child in urlpath.get_children(): @@ -277,5 +279,5 @@ def on_article_delete(instance, *args, **kwargs): # ...and finally delete the path itself # TODO: This should be unnecessary because of URLPath.article(...ondelete=models.CASCADE) urlpath.delete() - + # pre_delete.connect(on_article_delete, Article)