Skip to content

Commit

Permalink
patrons: use marshmallow for API control
Browse files Browse the repository at this point in the history
* Use Marshmallow schema to control `Patron` resources through API. The
  `role` field is now controlled to limit changes depending to current
  connected user.
* Renames `User.get_by_id()` to `User.get_record()`
* Improves code coverage

Co-Authored-by: Renaud Michotte <renaud.michotte@gmail.com>
  • Loading branch information
zannkukai committed Jan 20, 2023
1 parent d842bbf commit d607327
Show file tree
Hide file tree
Showing 22 changed files with 402 additions and 106 deletions.
2 changes: 1 addition & 1 deletion rero_ils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,7 @@ def _(x):
},
list_route='/patrons/',
record_loaders={
'application/json': lambda: Patron.load(request.get_json()),
'application/json': 'rero_ils.modules.patrons.loaders:json_v1'
},
record_class='rero_ils.modules.patrons.api:Patron',
item_route='/patrons/<pid(ptrn, record_class="rero_ils.modules.patrons.api:Patron"):pid_value>',
Expand Down
8 changes: 0 additions & 8 deletions rero_ils/modules/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,14 +198,6 @@ def _validate(self, **kwargs):
return self

json = super()._validate(**kwargs)

# Check if some record extensions has a validation method.
# DEV NOTES :: Could be part of `invenio-record.extensions`
for extension in self._extensions:
validate_method = getattr(extension, 'validate', None)
if callable(validate_method):
extension.validate(self, **kwargs)

validation_message = self.extended_validation(**kwargs)
# We only like to run pids_exist_check if validation_message is True
# and not a string with error from extended_validation
Expand Down
26 changes: 26 additions & 0 deletions rero_ils/modules/commons/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
#
# RERO ILS
# Copyright (C) 2019-2023 RERO
# Copyright (C) 2019-2023 UCLouvain
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Common models used into RERO-ILS project."""


class NoteTypes:
"""List of note type."""

PUBLIC_NOTE = 'public_note'
STAFF_NOTE = 'staff_note'
12 changes: 3 additions & 9 deletions rero_ils/modules/patrons/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@
get_patron_from_arguments, get_ref_for_pid, sorted_pids
from rero_ils.utils import create_user_from_data

from .extensions import PatronRoleManagementValidatorExtension, \
UserDataExtension
from .extensions import UserDataExtension
from .models import CommunicationChannel, PatronIdentifier, PatronMetadata
from .utils import get_patron_pid_by_email

Expand Down Expand Up @@ -117,10 +116,10 @@ class Patron(IlsRecord):
fetcher = patron_id_fetcher
provider = PatronProvider
model_cls = PatronMetadata
schema = 'patrons/patron-v0.0.1.json'

_extensions = [
UserDataExtension(),
PatronRoleManagementValidatorExtension()
UserDataExtension()
]

# =========================================================================
Expand Down Expand Up @@ -500,11 +499,6 @@ def set_keep_history(self, keep_history, dbcommit=True, reindex=True):
# =========================================================================
# CLASS METHODS
# =========================================================================
@classmethod
def load(cls, data):
"""Load the data and remove the user data."""
return cls(cls.remove_user_data(data))

@classmethod
def remove_user_data(cls, data):
"""Remove the user data."""
Expand Down
50 changes: 1 addition & 49 deletions rero_ils/modules/patrons/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Patron record extensions."""
from flask import current_app
from flask_login import current_user
from invenio_records.extensions import RecordExtension
from jsonschema.exceptions import ValidationError

from rero_ils.modules.users.api import User

Expand All @@ -36,51 +33,6 @@ def pre_dump(self, record, data, dumper=None):
:param dumper: Dumper to use when dumping the record.
:return the future dumped data.
"""
user = User.get_by_id(record.get('user_id'))
user = User.get_record(record.get('user_id'))
user_info = user.dumps_metadata()
return data.update(user_info)


class PatronRoleManagementValidatorExtension(RecordExtension):
"""Check about patron role management."""

def _validate(self, record, **kwargs):
"""Validate changes on the record.
:param record: the record containing data to validate.
:param **kwargs: any other named arguments.
:raises ValidationError: If an error is detected during the validation
check. This error could be serialized to get the error message.
"""
# First, determine if a user is connected. If not, no check must be
# done about role management (probably it's a console script/user).
if not current_user:
return

# Now determine the roles difference between original record and
# the record that is updated. If no changes are detected, no more
# check will be done
original_record = record.db_record() or {}
record_roles = set(record.get('roles', []))
original_roles = set(original_record.get('roles', []))
role_changes = original_roles.symmetric_difference(record_roles)
if not role_changes:
return

# Depending on the current logged user roles, determine which roles
# this user can manage reading the configuration setting. If any role
# from `role_changes` are not present in manageable role, an error
# should be raised.
key_config = 'RERO_ILS_PATRON_ROLES_MANAGEMENT_RESTRICTIONS'
config_roles = current_app.config.get(key_config, {})
manageable_roles = set()
for role in current_user.roles:
manageable_roles = manageable_roles.union(
config_roles.get(role.name, {}))
# If any difference are found between both sets, disallow the operation
if role_diffs := role_changes.difference(manageable_roles):
error_roles = ', '.join(role_diffs)
raise ValidationError(f'Unable to manage role(s) : {error_roles}')

validate = _validate
pre_delete = _validate
29 changes: 29 additions & 0 deletions rero_ils/modules/patrons/loaders/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
#
# RERO ILS
# Copyright (C) 2019-2023 RERO
# Copyright (C) 2019-2023 UCLouvain
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Loaders for `Patron` resources."""

from invenio_records_rest.loaders.marshmallow import marshmallow_loader

from ..schemas.json import PatronMetadataSchemaV1

json_v1 = marshmallow_loader(PatronMetadataSchemaV1)

__all__ = (
'json_v1',
)
29 changes: 27 additions & 2 deletions rero_ils/modules/patrons/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@

"""Permissions for patrons."""
from flask import g
from invenio_access import action_factory
from flask_login import current_user
from invenio_access import action_factory, any_user
from invenio_records_permissions.generators import Generator

from rero_ils.modules.permissions import AllowedByAction, \
AllowedByActionRestrictByOrganisation, \
Expand All @@ -27,6 +29,7 @@
from rero_ils.modules.users.models import UserRole

from .api import Patron, current_librarian
from .utils import validate_role_changes

# Actions to control patron permission policy
search_action = action_factory('ptrn-search')
Expand Down Expand Up @@ -67,6 +70,27 @@ def needs(self, record=None, *args, **kwargs):
return super().needs(record, *args, **kwargs)


class RestrictDeleteDependOnPatronRolesManagement(Generator):
"""Restrict deletion of patron depending on role management restrictions.
Depending on the current logged user profile, some roles' management
updates could be disallowed. Manageable roles are defined into the
`RERO_ILS_PATRON_ROLES_MANAGEMENT_RESTRICTIONS` configuration attribute.
"""

def excludes(self, record=None, **kwargs):
"""Disallow operation check.
:param record; the record to check.
:param kwargs: extra named arguments.
:returns: a list of Needs to disable access.
"""
roles = set(record.get('roles', []))
if not validate_role_changes(current_user, roles, raise_exc=False):
return [any_user]
return []


class PatronPermissionPolicy(RecordPermissionPolicy):
"""Patron Permission Policy used by the CRUD operations."""

Expand All @@ -82,7 +106,8 @@ class PatronPermissionPolicy(RecordPermissionPolicy):
AllowedByActionRestrictStaffByManageableLibrary(update_action)
]
can_delete = [
AllowedByActionRestrictStaffByManageableLibrary(delete_action)
AllowedByActionRestrictStaffByManageableLibrary(delete_action),
RestrictDeleteDependOnPatronRolesManagement()
]


Expand Down
19 changes: 19 additions & 0 deletions rero_ils/modules/patrons/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
#
# RERO ILS
# Copyright (C) 2019-2023 RERO
# Copyright (C) 2019-2023 UCLouvain
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Patrons schemas."""
136 changes: 136 additions & 0 deletions rero_ils/modules/patrons/schemas/json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
#
# RERO ILS
# Copyright (C) 2019-2023 RERO
# Copyright (C) 2019-2023 UCLouvain
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Schema for JSON representation of `Patron` resources."""
from functools import partial

from flask import abort
from flask_login import current_user
from invenio_records_rest.schemas import StrictKeysMixin
from invenio_records_rest.schemas.fields import GenFunction, SanitizedUnicode
from marshmallow import Schema, fields, pre_load, validates, validates_schema
from marshmallow.validate import OneOf

from rero_ils.modules.commons.models import NoteTypes
from rero_ils.modules.commons.schemas import NoteSchema, RefSchema, \
http_applicable_method
from rero_ils.modules.serializers.base import schema_from_context
from rero_ils.modules.users.api import User
from rero_ils.modules.users.models import UserRole

from ..api import Patron
from ..utils import validate_role_changes

schema_from_template = partial(schema_from_context, schema=Patron.schema)


class PatronAddressSchema(Schema):
"""Marshmallow schema for patron address."""

street = SanitizedUnicode()
postal_code = SanitizedUnicode()
city = SanitizedUnicode()
country = SanitizedUnicode()


class PatronNoteSchema(NoteSchema):
"""Marshmallow schema for `notes` on Patron."""

ttype = SanitizedUnicode(
validate=OneOf([NoteTypes.PUBLIC_NOTE, NoteTypes.STAFF_NOTE])
)


class PatronMetadataSchemaV1(StrictKeysMixin):
"""Marshmallow schema for the `Template` metadata."""

pid = SanitizedUnicode()
schema = GenFunction(
load_only=True,
attribute='$schema',
data_key='$schema',
deserialize=schema_from_template
)
source = SanitizedUnicode()
local_codes = fields.List(SanitizedUnicode())
user_id = fields.Integer()
second_address = fields.Nested(PatronAddressSchema())
patron = fields.Dict() # TODO : stricter implementation
libraries = fields.Nested(RefSchema, many=True)
roles = fields.List(SanitizedUnicode(validate=OneOf(UserRole.ALL_ROLES)))
notes = fields.Nested(PatronNoteSchema, many=True)

@pre_load
def remove_user_data(self, data, many, **kwargs):
"""Removed data concerning User not Patron.
:param data: the data received from request.
:param many: is the `data` represent an array of schema object.
:param kwargs: any additional named arguments.
:return Data cleared from user profile information.
"""
data = data if many else [data]
profile_fields = set(User.profile_fields + ['username', 'email'])
for record in data:
for field in profile_fields:
record.pop(field, None)
return data if many else data[0]

@validates('roles')
@http_applicable_method('POST')
def validate_role(self, data, **kwargs):
"""Validate `roles` attribute through API request.
The `roles` attribute must be controlled to restrict some role
attribution/modification depending on the current logged user.
:param data: the `roles` attribute value.
:param kwargs: any additional named arguments.
:raises ValidationError: if error has detected on `roles` attribute
"""
validate_role_changes(current_user, set(data))

@validates_schema
@http_applicable_method('PUT')
def validate_roles_changes(self, data, **kwargs):
"""Validate `roles` changes through REST API request.
Updates on `roles` attribute is subject to restriction depending
on current connected user. Determine if `roles` field changes and if
these changes are allowed or not.
:param data: the json data to validate.
:param kwargs: additional named arguments.
:raises abort: if corresponding record is not found.
:raises ValidationError: if error has detected on role field
"""
# Load DB record
db_record = Patron.get_record_by_pid(data.get('pid'))
if not db_record:
abort(404)

# Check if `roles` of the patron changed. If not, we can stop
# the validation process.
original_roles = set(db_record.get('roles', []))
data_roles = set(data.get('roles', []))
role_changes = original_roles.symmetric_difference(data_roles)
if not role_changes:
return

# `roles` field changes, we need to validate this change.
validate_role_changes(current_user, role_changes)
Loading

0 comments on commit d607327

Please sign in to comment.