Skip to content

Commit

Permalink
feat: Add content object level publish permissions (django-cms#390)
Browse files Browse the repository at this point in the history
* Add permission check for publish and unpublish - delegate to content model if possible

* Fix linting

* Fix syntax error

* Fix tests - still needs tests for version checking

* Fix linting

* Add docs.

* Docs fixes

* Make explicit that superusers must also be given permissions

* Add change permission for archive and revert

* Fix ruff

* Fix: mess-up created by ide

* Add tests for permissions including low-level permissions

* fix linting issues
  • Loading branch information
fsbraun authored Mar 12, 2024
1 parent 9071ace commit 9c7ad78
Show file tree
Hide file tree
Showing 15 changed files with 476 additions and 56 deletions.
14 changes: 14 additions & 0 deletions djangocms_versioning/conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,17 @@ def inner(version, user):
else:
raise ConditionFailed(message)
return inner

def user_can_publish(message: str) -> callable:
def inner(version, user):
if not version.has_publish_permission(user):
raise ConditionFailed(message)
return inner


def user_can_change(message: str) -> callable:
def inner(version, user):
if not version.has_change_permission(user):
raise ConditionFailed(message)
return inner

2 changes: 1 addition & 1 deletion djangocms_versioning/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@
ON_PUBLISH_REDIRECT = getattr(
settings, "DJANGOCMS_VERISONING_ON_PUBLISH_REDIRECT", "published"
)
# Allowed values: "versions", "published", "preview"
#: Allowed values: "versions", "published", "preview"
58 changes: 55 additions & 3 deletions djangocms_versioning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
draft_is_not_locked,
in_state,
is_not_locked,
user_can_change,
user_can_publish,
)
from .conf import ALLOW_DELETING_VERSIONS, LOCK_VERSIONS
from .operations import send_post_version_operation, send_pre_version_operation
Expand All @@ -29,7 +31,7 @@
not_draft_error = _("Version is not a draft")
lock_error_message = _("Action Denied. The latest version is locked by {user}")
lock_draft_error_message = _("Action Denied. The draft version is locked by {user}")

permission_error_message = _("You do not have permission to perform this action")

def allow_deleting_versions(collector, field, sub_objs, using):
if ALLOW_DELETING_VERSIONS:
Expand Down Expand Up @@ -257,7 +259,7 @@ def copy(self, created_by):
Allows customization of how the content object will be copied
when specified in cms_config.py
This method needs to be ran in a transaction due to the fact that if
This method needs to be run in a transaction due to the fact that if
models are partially created in the copy method a version is not attached.
It needs to be that if anything goes wrong we should roll back the entire task.
We shouldn't leave this to package developers to know to add this feature
Expand All @@ -275,6 +277,7 @@ def copy(self, created_by):

check_archive = Conditions(
[
user_can_change(permission_error_message),
in_state([constants.DRAFT], _("Version is not in draft state")),
is_not_locked(lock_error_message),
]
Expand Down Expand Up @@ -324,7 +327,10 @@ def _set_archive(self, user):
pass

check_publish = Conditions(
[in_state([constants.DRAFT], _("Version is not in draft state"))]
[
user_can_publish(permission_error_message),
in_state([constants.DRAFT], _("Version is not in draft state")),
]
)

def can_be_published(self):
Expand Down Expand Up @@ -387,6 +393,7 @@ def _set_publish(self, user):
pass

check_unpublish = Conditions([
user_can_publish(permission_error_message),
in_state([constants.PUBLISHED], _("Version is not in published state")),
draft_is_not_locked(lock_draft_error_message),
])
Expand Down Expand Up @@ -437,6 +444,50 @@ def _set_unpublish(self, user):
possible to be left with inconsistent data)"""
pass

def has_publish_permission(self, user) -> bool:
"""
Check if the given user has permission to publish.
Args:
user (User): The user to check for permission.
Returns:
bool: True if the user has publish permission, False otherwise.
"""
return self._has_permission("publish", user)

def has_change_permission(self, user) -> bool:
"""
Check whether the given user has permission to change the object.
Parameters:
user (User): The user for which permission needs to be checked.
Returns:
bool: True if the user has permission to change the object, False otherwise.
"""
return self._has_permission("change", user)

def _has_permission(self, perm: str, user) -> bool:
"""
Check if the user has the specified permission for the content by
checking the content's has_publish_permission, has_placeholder_change_permission,
or has_change_permission methods.
Falls back to Djangos change permission for the content object.
"""
if perm == "publish" and hasattr(self.content, "has_publish_permission"):
# First try explicit publish permission
return self.content.has_publish_permission(user)
if hasattr(self.content, "has_change_permission"):
# First fallback: change permissions
return self.content.has_change_permission(user)
if hasattr(self.content, "has_placeholder_change_permission"):
# Second fallback: placeholder change permissions - works for PageContent
return self.content.has_placeholder_change_permission(user)
# final fallback: Django perms
return user.has_perm(f"{self.content_type.app_label}.change_{self.content_type.model}")

check_modify = Conditions(
[
in_state([constants.DRAFT], not_draft_error),
Expand All @@ -445,6 +496,7 @@ def _set_unpublish(self, user):
)
check_revert = Conditions(
[
user_can_change(permission_error_message),
in_state(
[constants.ARCHIVED, constants.UNPUBLISHED],
_("Version is not in archived or unpublished state"),
Expand Down
12 changes: 12 additions & 0 deletions djangocms_versioning/test_utils/blogpost/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ class BlogContent(models.Model):
language = models.TextField()
text = models.TextField()

def has_publish_permission(self, user):
if user.is_superuser:
return True
# Fake a simple object-dependent permission
return user.username in self.text

def has_change_permission(self, user):
if user.is_superuser:
return True
# Fake a simple object-dependent permission
return f"<{user.username}>" in self.text

def __str__(self):
return self.text

Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Welcome to "djangocms-versioning"'s documentation!

basic_concepts
versioning_integration
permissions
version_locking

.. toctree::
Expand Down
94 changes: 94 additions & 0 deletions docs/permissions.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#####################################
Permissions in djangocms-versioning
#####################################

This documentation covers the permissions system introduced for
publishing and unpublishing content in djangocms-versioning. This system
allows for fine-grained control over who can publish and unpublish or otherwise
manage versions of content.

***************************
Understanding Permissions
***************************

Permissions are set at the content object level, allowing for detailed
access control based on the user's roles and permissions. The system
checks for specific methods within the **content object**, e.g.
``PageContent`` to determine if a user has the necessary permissions.

- **Specific publish permission** (only for publish/unpublish action):
To check if a user has the
permission to publish content, the system looks for a method named
``has_publish_permission`` on the content object. If this method is
present, it will be called to determine whether the user is allowed
to publish the content.

Example:

.. code:: python
def has_publish_permission(self, user):
if user.is_superuser:
# Superusers typically have permission to publish
return True
# Custom logic to determine if the user can publish
return user_has_permission
- **Change Permission** (and first fallback for ``has_publish_permission``):
If the content object has a
method named ``has_change_permission``, this method will be called to
assess if a user has the permission to change the content. This is a
general permission check that is not specific to publishing or
unpublishing actions.

Example:

.. code:: python
def has_change_permission(self, user):
if user.is_superuser:
# Superusers typically have permission to publish
return True
# Custom logic to determine if the user can change the content
return user_has_permission
- **First Fallback Placeholder Change Permission**: For content
objects that involve placeholders, such as PageContent objects, a
method named ``has_placeholder_change_permission`` is checked. This
method should determine if the user has the permission to change
placeholders within the content.

Example:

.. code:: python
def has_placeholder_change_permission(self, user):
if user.is_superuser:
# Superusers typically have permission to publish
return True
# Custom logic to determine if the user can change placeholders
return user_has_permission
- **Last resort Django permissions:** If none of the above methods are
present on the content object, the system falls back to checking if
the user has a generic Django permission to change ``Version``
objects. This ensures that there is always a permission check in
place, even if specific methods are not implemented for the content
object. By default, the Django permissions are set on a user or group
level and include all instances of the content object.

.. note::

It is highly recommended to implement the specific permission
methods on your content objects for more granular control over
user actions.

************
Conclusion
************

The permissions system introduced in djangocms-versioning for publishing
and unpublishing content provides a flexible and powerful way to manage
access to content. By defining custom permission logic within your
content objects, you can ensure that only authorized users are able to
perform these actions.
6 changes: 4 additions & 2 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ Settings for djangocms Versioning
Defaults to ``False``

.. versionadded:: 2.0
Before version 2.0 version locking was part of a separate package.

This setting controls if draft versions are locked. If they are, only the user
who created the draft can change the draft. See
:ref:`Locking versions <locking-versions>` for more details.
Expand Down Expand Up @@ -67,8 +70,7 @@ Settings for djangocms Versioning
Defaults to ``"published"``

.. versionadded:: 2.0

Before version 2.0 the behavior was always ``"versions"``.
Before version 2.0 the behavior was always ``"versions"``.

This setting determines what happens after publication/unpublication of a
content object. Three options exist:
Expand Down
Binary file added docs/static/blog-new.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/static/blog-original.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 4 additions & 21 deletions docs/versioning_integration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,13 @@ Change the model structure
----------------------------
Assuming that our `blog` app has one db table:

.. graphviz::

digraph ERD1 {
graph [ rankdir = "LR" ];
ranksep=2;

"Post" [ label="<Post> Post|<PK_GROUPER_ID>id \l |site \l title \l text \l " shape = "record" ];
"Post":"PK_GROUPER_ID" [arrowhead = crow];
}
.. image:: /static/blog-original.jpg
:width: 75px

This would have to change to a db structure like this:

.. graphviz::

digraph ERD2 {
graph [ rankdir = "LR" ];
ranksep=2;

"Post" [ label="<Post> Post|<PK_GROUPER_ID>id \l |site \l " shape = "record" ];
"PostContent" [ label="<PostContent> PostContent|<PK_CONTENT_ID>id \l |<FK_POST>post \l |title \l text \l " shape = "record" ];
"Post":"PK_GROUPER_ID"->"PostContent":"FK_POST" [arrowhead = crow];
}
.. image:: /static/blog-new.jpg
:width: 377px

Or in python code, `models.py` would need to change from:

Expand Down
Loading

0 comments on commit 9c7ad78

Please sign in to comment.