diff --git a/.stylelintrc.json b/.stylelintrc.json index c263ada2b..1033e4a9b 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -7,6 +7,9 @@ "color-hex-case": "upper", "color-hex-length": "long", "indentation": [4], - "no-descending-specificity": null + "no-descending-specificity": null, + "property-no-vendor-prefix": null, + "shorthand-property-no-redundant-values": null, + "value-no-vendor-prefix": null } } diff --git a/CHANGES.rst b/CHANGES.rst index f2544ae21..c63a3f6e1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,7 +7,21 @@ Changes `Unreleased `_ (latest) ------------------------------------------------------------------------------------ -* Nothing new for the moment. +Features / Changes +~~~~~~~~~~~~~~~~~~~~~ +* Add new `Terms and conditions` field for ``Group`` creation. When a request is made to assign a ``User`` to a + ``Group`` with terms and conditions, an email is now sent to the ``User`` with the terms and conditions. The ``User`` + is assigned to the ``Group`` when receiving the ``User``'s approval of terms and conditions, and another email is + then sent to notify the ``User`` of the successful operation. +* Changed ``/groups/{group_name}/users``, ``/users/current/groups`` and ``/users/{user_name}/groups`` endpoints with + new query parameter `status` to either get active, pending or all ``Users`` or ``Groups``. This new parameter is + useful to display any pending ``Users``/``Groups`` on the UI. +* Added new field `has_pending_group` in the user info returned by ``/users/{user_name}`` endpoint, indicating if + the user has any pending group. + +Bug Fixes +~~~~~~~~~~~~~~~~~~~~~ +* Fix HTTP ``Internal Server Error [500]`` on the page to edit a ``Group`` when deleting the last ``User`` of a ``Group``. `3.16.1 `_ (2021-10-18) ------------------------------------------------------------------------------------ diff --git a/config/magpie.ini b/config/magpie.ini index d89c17d9c..831ff3010 100644 --- a/config/magpie.ini +++ b/config/magpie.ini @@ -61,6 +61,10 @@ magpie.user_registration_notify_enabled = false magpie.user_registration_notify_email_recipient = magpie.user_registration_notify_email_template = +# --- user assignment to groups with t&c --- +magpie.group_terms_submission_email_template = +magpie.group_terms_approved_email_template = + # smtp server configuration magpie.smtp_user = Magpie magpie.smtp_from = diff --git a/docs/_static/custom.css b/docs/_static/custom.css index e971853bb..c55de2d0b 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -20,7 +20,7 @@ code.docutils.literal:not(.xref) { /* overrides and additions */ background-color: #DBDBDD; /* slightly darker than original to make more obvious */ - padding: 0.1em 0.1em; /* pad so that background color encapsulate letters going 'lower' (eg: g, j, y) */ + padding: 0.1em; /* pad so that background color encapsulate letters going 'lower' (eg: g, j, y) */ white-space: nowrap; /* don't allow breaking on non-word items such as dot in value */ } diff --git a/docs/configuration.rst b/docs/configuration.rst index 343671c74..14bbdab8c 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1259,6 +1259,44 @@ approval procedures. The default template provides details about available template arguments. +.. _config_user_group_assignment: + +User-Group Assignment Configuration +----------------------------------- + +.. versionadded:: 3.17 + +Following are the full description of all configuration parameters employed by the :term:`User`-:term:`Group` assignment +procedures, in the case of a :term:`Group` that requires terms and conditions validation by the :term:`User`. + +.. envvar:: MAGPIE_GROUP_TERMS_SUBMISSION_EMAIL_TEMPLATE + + (Default: |email_uga_submission_mako|_) + + .. versionadded:: 3.17 + + Path to a `Mako Template`_ file providing custom email format to send notification email to the :term:`User` + following submission of the :term:`User` assignment to a :term:`Group` that requires accepting terms and conditions. + + When overridden with a custom email format, the contents should provide sufficient details indicating to the + :term:`User` that they must accept the :term:`Group`'s terms and conditions to join it, and that confirmation is + accomplished by visiting the link contained in that email. The confirmation URL would validate that the :term:`User` + accepts the terms and conditions, and would proceed with the assignment of the :term:`User` to the :term:`Group`. + The contents of the email should also include the terms and conditions of the :term:`Group`. + + The default template provides details about available template arguments. + +.. envvar:: MAGPIE_GROUP_TERMS_APPROVED_EMAIL_TEMPLATE + + (Default: |email_uga_approved_mako|_) + + .. versionadded:: 3.17 + + Path to a `Mako Template`_ file providing custom email format to send an email to the :term:`User` related to a + :term:`User`-:term:`Group` assignment to notify them that the terms and conditions were accepted, and that their + account is now a member of the requested :term:`Group`. + + The default template provides details about available template arguments. .. _config_webhook: diff --git a/docs/glossary.rst b/docs/glossary.rst index f4a6bdf0f..bf255f99d 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -92,7 +92,10 @@ Glossary Group Entity on which :term:`Permission` over a :term:`Service` or :term:`Resource` can be applied. Any :term:`User` can be set as a member of any number of :term:`Group`, making it inherit all applicable set of - :term:`Permission`. + :term:`Permission`. A :term:`Group` can optionally have terms and conditions, which the :term:`User` has to + accept before being assigned to the :term:`Group`. In this case, an email is sent to the :term:`User` upon + request to ask for confirmation. The terms and conditions can only be defined upon the :term:`Group` creation + and can never be modified afterwards. Immediate Permissions Describes a :term:`Permission` that originates directly and only from a :term:`Service`. diff --git a/docs/references.rst b/docs/references.rst index c255ac732..6095aa020 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -44,16 +44,20 @@ .. _constants.py: https://github.com/Ouranosinc/Magpie/tree/master/magpie/constants.py .. _Dockerfile: https://github.com/Ouranosinc/Magpie/tree/master/Dockerfile .. _docker-compose.yml.example: https://github.com/Ouranosinc/Magpie/tree/master/docker-compose.yml.example -.. |email_ur_submission_mako| replace:: ``magpie/api/template/email_user_registration_submission.mako`` -.. _email_ur_submission_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/template/email_user_registration_submission.mako -.. |email_ur_approval_mako| replace:: ``magpie/api/template/email_user_registration_approval.mako`` -.. _email_ur_approval_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/template/email_user_registration_approval.mako -.. |email_ur_approved_mako| replace:: ``magpie/api/template/email_user_registration_approved.mako`` -.. _email_ur_approved_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/template/email_user_registration_approved.mako -.. |email_ur_declined_mako| replace:: ``magpie/api/template/email_user_registration_declined.mako`` -.. _email_ur_declined_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/template/email_user_registration_declined.mako -.. |email_ur_notify_mako| replace:: ``magpie/api/template/email_user_registration_notify.mako`` -.. _email_ur_notify_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/template/email_user_registration_notify.mako +.. |email_ur_submission_mako| replace:: ``magpie/api/templates/email_user_registration_submission.mako`` +.. _email_ur_submission_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/templates/email_user_registration_submission.mako +.. |email_ur_approval_mako| replace:: ``magpie/api/templates/email_user_registration_approval.mako`` +.. _email_ur_approval_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/templates/email_user_registration_approval.mako +.. |email_ur_approved_mako| replace:: ``magpie/api/templates/email_user_registration_approved.mako`` +.. _email_ur_approved_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/templates/email_user_registration_approved.mako +.. |email_ur_declined_mako| replace:: ``magpie/api/templates/email_user_registration_declined.mako`` +.. _email_ur_declined_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/templates/email_user_registration_declined.mako +.. |email_ur_notify_mako| replace:: ``magpie/api/templates/email_user_registration_notify.mako`` +.. _email_ur_notify_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/templates/email_user_registration_notify.mako +.. |email_uga_submission_mako| replace:: ``magpie/api/templates/email_group_terms_submission.mako`` +.. _email_uga_submission_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/templates/email_group_terms_submission.mako +.. |email_uga_approved_mako| replace:: ``magpie/api/templates/email_group_terms_approved.mako`` +.. _email_uga_approved_mako: https://github.com/Ouranosinc/Magpie/blob/master/magpie/api/templates/email_group_terms_approved.mako .. _magpie-cron: https://github.com/Ouranosinc/Magpie/tree/master/magpie-cron .. _magpie.env.example: https://github.com/Ouranosinc/Magpie/tree/master/env/magpie.env.example .. _magpie.ini: https://github.com/Ouranosinc/Magpie/tree/master/config/magpie.ini diff --git a/magpie/alembic/versions/2021-06-09_cb92ff1f81bb_add_group_terms.py b/magpie/alembic/versions/2021-06-09_cb92ff1f81bb_add_group_terms.py new file mode 100644 index 000000000..ff89a7c82 --- /dev/null +++ b/magpie/alembic/versions/2021-06-09_cb92ff1f81bb_add_group_terms.py @@ -0,0 +1,24 @@ +""" +add group terms + +Revision ID: cb92ff1f81bb +Revises: 35e98bdc8aed +Create Date: 2021-06-09 14:18:32.777082 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "cb92ff1f81bb" +down_revision = "35e98bdc8aed" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("groups", sa.Column("terms", sa.UnicodeText(), nullable=True)) + + +def downgrade(): + op.drop_column("groups", "terms") diff --git a/magpie/api/management/group/group_formats.py b/magpie/api/management/group/group_formats.py index 84b17d1a5..7ead71fc0 100644 --- a/magpie/api/management/group/group_formats.py +++ b/magpie/api/management/group/group_formats.py @@ -43,6 +43,7 @@ def fmt_grp(): if public_info: return info info["discoverable"] = group.discoverable + info["terms"] = group.terms info["priority"] = "max" if group.priority == math.inf else int(group.priority) info["member_count"] = group.get_member_count(db_session) info["user_names"] = [usr.user_name for usr in group.users] diff --git a/magpie/api/management/group/group_utils.py b/magpie/api/management/group/group_utils.py index aa92d5a0f..317d62281 100644 --- a/magpie/api/management/group/group_utils.py +++ b/magpie/api/management/group/group_utils.py @@ -75,8 +75,8 @@ def get_group_resources(group, db_session, service_types=None): return json_response -def create_group(group_name, description, discoverable, db_session): - # type: (Str, Str, bool, Session) -> HTTPException +def create_group(group_name, description, discoverable, terms, db_session): + # type: (Str, Str, bool, Str, Session) -> HTTPException """ Creates a group if it is permitted and not conflicting. @@ -85,6 +85,7 @@ def create_group(group_name, description, discoverable, db_session): """ description = str(description) if description else None discoverable = asbool(discoverable) + terms = str(terms) if terms else None group_content_error = { "group_name": str(group_name), "description": description, @@ -103,7 +104,10 @@ def create_group(group_name, description, discoverable, db_session): http_error=HTTPConflict, content=group_content_error, msg_on_fail=s.Groups_POST_ConflictResponseSchema.description) new_group = ax.evaluate_call( - lambda: models.Group(group_name=group_name, description=description, discoverable=discoverable), # noqa + lambda: models.Group(group_name=group_name, + description=description, + discoverable=discoverable, + terms=terms), # noqa fallback=lambda: db_session.rollback(), http_error=HTTPForbidden, content=group_content_error, msg_on_fail=s.Groups_POST_ForbiddenCreateResponseSchema.description) ax.evaluate_call(lambda: db_session.add(new_group), fallback=lambda: db_session.rollback(), diff --git a/magpie/api/management/group/group_views.py b/magpie/api/management/group/group_views.py index 6d3c3bb06..02dec84c6 100644 --- a/magpie/api/management/group/group_views.py +++ b/magpie/api/management/group/group_views.py @@ -10,6 +10,7 @@ from magpie.api.management.group import group_utils as gu from magpie.api.management.service import service_utils as su from magpie.constants import get_constant +from magpie.models import TemporaryToken, TokenOperation, UserGroupStatus @s.GroupsAPI.get(tags=[s.GroupsTag], response_schemas=s.Groups_GET_responses) @@ -30,9 +31,10 @@ def create_group_view(request): Create a group. """ group_name = ar.get_value_multiformat_body_checked(request, "group_name") - group_desc = ar.get_multiformat_body(request, "description", default="") + group_desc = ar.get_multiformat_body(request, "description", default=None) group_disc = asbool(ar.get_multiformat_body(request, "discoverable", default=False)) - return gu.create_group(group_name, group_desc, group_disc, request.db) + group_terms = ar.get_multiformat_body(request, "terms", default=None) + return gu.create_group(group_name, group_desc, group_disc, group_terms, request.db) @s.GroupAPI.get(schema=s.Group_GET_RequestSchema, tags=[s.GroupsTag], response_schemas=s.Group_GET_responses) @@ -116,12 +118,34 @@ def delete_group_view(request): @view_config(route_name=s.GroupUsersAPI.name, request_method="GET") def get_group_users_view(request): """ - List all user from a group. + List all users from a group. + Users can be filtered by status depending of input arguments. """ group = ar.get_group_matchdict_checked(request) - user_names = ax.evaluate_call(lambda: [user.user_name for user in group.users], - http_error=HTTPForbidden, - msg_on_fail=s.GroupUsers_GET_ForbiddenResponseSchema.description) + status = ar.get_query_param(request, "status", default=UserGroupStatus.ACTIVE.value) + ax.verify_param(status, is_in=True, param_compare=UserGroupStatus.values(), param_name="status", + msg_on_fail=s.UserGroup_Check_Status_BadRequestResponseSchema.description, + http_error=HTTPBadRequest) + status = UserGroupStatus.get(status) + + user_names = set() + member_user_names = ax.evaluate_call(lambda: set(user.user_name for user in group.users), + http_error=HTTPForbidden, + msg_on_fail=s.GroupUsers_GET_ForbiddenResponseSchema.description) + if status in [UserGroupStatus.ACTIVE, UserGroupStatus.ALL]: + user_names = user_names.union(member_user_names) + if status in [UserGroupStatus.PENDING, UserGroupStatus.ALL]: + # Find all temporary tokens with requested group id that have a pending accept terms request + tmp_tokens = request.db.query(TemporaryToken).filter(TemporaryToken.group_id == group.id) + tmp_tokens = tmp_tokens.filter(TemporaryToken.operation == TokenOperation.GROUP_ACCEPT_TERMS) + + # Find and return all user names associated with the discovered tokens + pending_user_names = set(tmp_token.user.user_name for tmp_token in tmp_tokens) + + # Remove any user already belonging to the group, in case any tokens are irrelevant. + # Should not happen since related tokens are deleted upon T&C acceptation. + pending_user_names = pending_user_names - member_user_names + user_names = user_names.union(pending_user_names) return ax.valid_http(http_success=HTTPOk, detail=s.GroupUsers_GET_OkResponseSchema.description, content={"user_names": sorted(user_names)}) diff --git a/magpie/api/management/register/register_utils.py b/magpie/api/management/register/register_utils.py index 3aaf63435..e3b50e5cf 100644 --- a/magpie/api/management/register/register_utils.py +++ b/magpie/api/management/register/register_utils.py @@ -65,7 +65,7 @@ def handle_temporary_token(tmp_token, request): http_error=HTTPInternalServerError, msg_on_fail="Invalid token.") ax.verify_param(tmp_token.user, not_none=True, http_error=HTTPInternalServerError, msg_on_fail="Invalid token.") - uu.assign_user_group(tmp_token.user, tmp_token.group, request.db) + response = uu.handle_user_group_terms_confirmation(tmp_token, request) elif tmp_token.operation == TokenOperation.USER_PASSWORD_RESET: ax.verify_param(tmp_token.user, not_none=True, diff --git a/magpie/api/management/register/register_views.py b/magpie/api/management/register/register_views.py index 17ad1e295..f692c819d 100644 --- a/magpie/api/management/register/register_views.py +++ b/magpie/api/management/register/register_views.py @@ -122,6 +122,10 @@ def join_discoverable_group_view(request): user = ar.get_logged_user(request) group = ru.get_discoverable_group_by_name(group.group_name, db_session=request.db) + if group.terms: + # If group requires terms acceptation, send T&C email and await confirmation before adding the user to the group + return uu.send_group_terms_email(user, group, request.db) + ax.verify_param(user.id, param_compare=[usr.id for usr in group.users], not_in=True, with_param=False, http_error=HTTPConflict, content={"user_name": user.user_name, "group_name": group.group_name}, msg_on_fail=s.RegisterGroup_POST_ConflictResponseSchema.description) diff --git a/magpie/api/management/user/user_formats.py b/magpie/api/management/user/user_formats.py index db7325333..08fdaed3b 100644 --- a/magpie/api/management/user/user_formats.py +++ b/magpie/api/management/user/user_formats.py @@ -4,7 +4,7 @@ from magpie.api.exception import evaluate_call from magpie.constants import get_constant -from magpie.models import UserStatuses +from magpie.models import UserGroupStatus, UserStatuses if TYPE_CHECKING: from typing import List @@ -42,6 +42,10 @@ def fmt_usr(): if not basic_info: grp_names = group_names if group_names else [grp.group_name for grp in user.groups] user_info["group_names"] = list(sorted(grp_names)) + + # indicate if user has any pending T&C groups + user_info["has_pending_group"] = bool(user.get_groups_by_status(UserGroupStatus.PENDING)) + # special users not meant to be used as valid "accounts" marked as without an ID if user.user_name != get_constant("MAGPIE_ANONYMOUS_USER") and status != UserStatuses.Pending: user_info["user{}id".format(sep)] = int(user.id) diff --git a/magpie/api/management/user/user_utils.py b/magpie/api/management/user/user_utils.py index b0c0ed89e..9435ffe7e 100644 --- a/magpie/api/management/user/user_utils.py +++ b/magpie/api/management/user/user_utils.py @@ -1,9 +1,11 @@ +from inspect import cleandoc from secrets import compare_digest from typing import TYPE_CHECKING import six import transaction from pyramid.httpexceptions import ( + HTTPAccepted, HTTPBadRequest, HTTPConflict, HTTPCreated, @@ -24,6 +26,7 @@ from magpie.api.management.resource import resource_utils as ru from magpie.api.management.service.service_formats import format_service from magpie.api.management.user import user_formats as uf +from magpie.api.notifications import get_email_template, send_email from magpie.api.webhooks import ( WebhookAction, generate_callback_url, @@ -31,9 +34,11 @@ process_webhook_requests ) from magpie.constants import get_constant +from magpie.models import TemporaryToken, TokenOperation from magpie.permissions import PermissionSet, PermissionType, format_permissions from magpie.services import SERVICE_TYPE_DICT, service_factory -from magpie.utils import get_logger +from magpie.ui.utils import BaseViews +from magpie.utils import get_logger, get_settings_from_config_ini LOGGER = get_logger(__name__) @@ -43,6 +48,7 @@ from pyramid.httpexceptions import HTTPException from pyramid.request import Request + from pyramid.response import Response from sqlalchemy.orm.session import Session from ziggurat_foundations.permissions import PermissionTuple # noqa @@ -139,19 +145,13 @@ def _get_group(grp_name): http_error=HTTPForbidden, msg_on_fail=s.UserNew_POST_ForbiddenResponseSchema.description) - def _add_to_group(usr, grp): - # type: (models.User, models.Group) -> None - group_entry = models.UserGroup(group_id=grp.id, user_id=usr.id) # noqa - ax.evaluate_call(lambda: db_session.add(group_entry), fallback=lambda: db_session.rollback(), - http_error=HTTPForbidden, msg_on_fail=s.UserGroup_GET_ForbiddenResponseSchema.description) - # Assign user to group new_user_groups = [group_name] - _add_to_group(new_user, group_checked) + create_pending_or_assign_user_group(new_user, group_checked, db_session) # Also add user to anonymous group if not already done anonym_grp_name = get_constant("MAGPIE_ANONYMOUS_GROUP") if group_checked.group_name != anonym_grp_name: - _add_to_group(new_user, _get_group(anonym_grp_name)) + create_pending_or_assign_user_group(new_user, _get_group(anonym_grp_name), db_session) new_user_groups.append(anonym_grp_name) user_content = uf.format_user(new_user, new_user_groups) @@ -311,6 +311,89 @@ def assign_user_group(user, group, db_session): content={"user_name": user.user_name, "group_name": group.group_name}) +def send_group_terms_email(user, group, db_session): + # type: (models.User, models.Group, Session) -> None + """ + Sends an email for terms and conditions confirmation, in the case of a request for the creation of + a user-group relationship where the group requires a terms and conditions confirmation. + + :returns: valid HTTP response on successful operations. + :raises HTTPError: corresponding error matching problem encountered. + """ + confirmation_url = generate_callback_url(models.TokenOperation.GROUP_ACCEPT_TERMS, + db_session, + user=user, + group=group) + params = { + "user": user, + "group_name": group.group_name, + "group_terms": group.terms, + "confirm_url": confirmation_url + } + settings = get_settings_from_config_ini(get_constant("MAGPIE_INI_FILE_PATH")) + template = get_email_template("MAGPIE_GROUP_TERMS_SUBMISSION_EMAIL_TEMPLATE", settings) + ax.evaluate_call(lambda: send_email(user.email, settings, template, params), + fallback=lambda: db_session.rollback(), http_error=HTTPInternalServerError, + msg_on_fail="Error occurred while adding user to a group when trying to send " + "email to user for requesting agreement of the group's terms and conditions.") + + return ax.valid_http(http_success=HTTPAccepted, detail=s.UserGroups_POST_AcceptedResponseSchema.description, + content={"user_name": user.user_name, "group_name": group.group_name}) + + +def create_pending_or_assign_user_group(user, group, db_session): + # type: (models.User, models.Group, Session) -> None + """ + Creates either a new user-group relationship (user membership to a group) or a pending terms and conditions + confirmation. If the group requires a T&C confirmation, sends an email for T&C confirmation, + else, the user is assigned directly to the group. + + :returns: valid HTTP response on successful operations. + :raises HTTPError: corresponding error matching problem encountered. + """ + if group.terms: + return send_group_terms_email(user, group, db_session) + + assign_user_group(user, group, db_session=db_session) + return ax.valid_http(http_success=HTTPCreated, detail=s.UserGroups_POST_CreatedResponseSchema.description, + content={"user_name": user.user_name, "group_name": group.group_name}) + + +def handle_user_group_terms_confirmation(tmp_token, request): + # type: (models.TemporaryToken, Request) -> Response + """ + Handles the confirmation of a user to accept the terms and conditions of a group. + + Generates the appropriate response that will be displayed to the user. + """ + LOGGER.info("User [%s:%s] approved terms and conditions of group [%s:%s].", + tmp_token.user.id, tmp_token.user.user_name, tmp_token.group.id, tmp_token.group.group_name) + assign_user_group(tmp_token.user, tmp_token.group, request.db) + + # notify the user of its successful T&C acceptation, and confirm the user has been added to the requested group + params = {"user": tmp_token.user, "group_name": tmp_token.group.group_name} + template = get_email_template("MAGPIE_GROUP_TERMS_APPROVED_EMAIL_TEMPLATE", request) + ax.evaluate_call(lambda: send_email(tmp_token.user.email, request, template, params), + fallback=lambda: request.db.rollback(), http_error=HTTPInternalServerError, + msg_on_fail="Error occurred during group terms confirmation when trying to send " + "email to user for confirmation of the terms and conditions acceptation.") + + # Remove all group_accept_terms temporary tokens associated with the same user and group + tmp_tokens = TemporaryToken.by_user(tmp_token.user) + tmp_tokens = tmp_tokens.filter(TemporaryToken.operation == TokenOperation.GROUP_ACCEPT_TERMS) + tmp_tokens = tmp_tokens.filter(TemporaryToken.group == tmp_token.group) + for token in tmp_tokens: + request.db.delete(token) + + msg = cleandoc(""" + You have accepted the terms and conditions of the '{grp_name}' group. + + User '{user_name}' has now been successfully added to the '{grp_name}' group. + """.format(grp_name=tmp_token.group.group_name, user_name=tmp_token.user.user_name)) + + return BaseViews(request).render("magpie.ui.home:templates/message.mako", {"message": msg}) + + def delete_user_group(user, group, db_session): # type: (models.User, models.Group, Session) -> None """ diff --git a/magpie/api/management/user/user_views.py b/magpie/api/management/user/user_views.py index 39b544861..605a80255 100644 --- a/magpie/api/management/user/user_views.py +++ b/magpie/api/management/user/user_views.py @@ -1,7 +1,7 @@ """ User Views, both for specific user-name provided as request path variable and special keyword for logged session user. """ -from pyramid.httpexceptions import HTTPBadRequest, HTTPCreated, HTTPForbidden, HTTPNotFound, HTTPOk +from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPNotFound, HTTPOk from pyramid.settings import asbool from pyramid.view import view_config from ziggurat_foundations.models.services.group import GroupService @@ -17,6 +17,7 @@ from magpie.api.management.user import user_utils as uu from magpie.api.webhooks import WebhookAction, process_webhook_requests from magpie.constants import MAGPIE_CONTEXT_PERMISSION, MAGPIE_LOGGED_PERMISSION, get_constant +from magpie.models import UserGroupStatus from magpie.permissions import PermissionType, format_permissions from magpie.utils import get_logger @@ -124,10 +125,17 @@ def delete_user_view(request): def get_user_groups_view(request): """ List all groups a user belongs to. + Groups can be filtered by status depending of input arguments. """ user = ar.get_user_matchdict_checked_or_logged(request) - group_names = uu.get_user_groups_checked(user, request.db) - return ax.valid_http(http_success=HTTPOk, content={"group_names": group_names}, + status = ar.get_query_param(request, "status", default=UserGroupStatus.ACTIVE.value) + ax.verify_param(status, is_in=True, param_compare=UserGroupStatus.values(), param_name="status", + msg_on_fail=s.UserGroup_Check_Status_BadRequestResponseSchema.description, + http_error=HTTPBadRequest) + status = UserGroupStatus.get(status) + group_names = user.get_groups_by_status(status, request.db) + + return ax.valid_http(http_success=HTTPOk, content={"group_names": sorted(group_names)}, detail=s.UserGroups_GET_OkResponseSchema.description) @@ -148,9 +156,7 @@ def assign_user_group_view(request): msg_on_fail=s.UserGroups_POST_ForbiddenResponseSchema.description) ax.verify_param(group, not_none=True, http_error=HTTPNotFound, msg_on_fail=s.UserGroups_POST_GroupNotFoundResponseSchema.description) - uu.assign_user_group(user, group, db_session=request.db) - return ax.valid_http(http_success=HTTPCreated, detail=s.UserGroups_POST_CreatedResponseSchema.description, - content={"user_name": user.user_name, "group_name": group.group_name}) + return uu.create_pending_or_assign_user_group(user, group, db_session=request.db) @s.UserGroupAPI.delete(schema=s.UserGroup_DELETE_RequestSchema, tags=[s.UsersTag], diff --git a/magpie/api/notifications.py b/magpie/api/notifications.py index 3328b7684..246eb1e4a 100644 --- a/magpie/api/notifications.py +++ b/magpie/api/notifications.py @@ -23,6 +23,10 @@ TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") DEFAULT_TEMPLATE_MAPPING = { + "MAGPIE_GROUP_TERMS_APPROVED_EMAIL_TEMPLATE": + os.path.join(TEMPLATE_DIR, "email_group_terms_approved.mako"), + "MAGPIE_GROUP_TERMS_SUBMISSION_EMAIL_TEMPLATE": + os.path.join(TEMPLATE_DIR, "email_group_terms_submission.mako"), "MAGPIE_USER_REGISTRATION_SUBMISSION_EMAIL_TEMPLATE": os.path.join(TEMPLATE_DIR, "email_user_registration_submission.mako"), "MAGPIE_USER_REGISTRATION_APPROVAL_EMAIL_TEMPLATE": @@ -43,6 +47,8 @@ def get_email_template(template_constant, container=None): Allowed values of :paramref:`template_constant` are: + - :envvar:`MAGPIE_GROUP_TERMS_APPROVED_EMAIL_TEMPLATE` + - :envvar:`MAGPIE_GROUP_TERMS_SUBMISSION_EMAIL_TEMPLATE` - :envvar:`MAGPIE_USER_REGISTRATION_SUBMISSION_EMAIL_TEMPLATE` - :envvar:`MAGPIE_USER_REGISTRATION_APPROVAL_EMAIL_TEMPLATE` - :envvar:`MAGPIE_USER_REGISTRATION_APPROVED_EMAIL_TEMPLATE` diff --git a/magpie/api/schemas.py b/magpie/api/schemas.py index a622eba47..3ac19e8a1 100644 --- a/magpie/api/schemas.py +++ b/magpie/api/schemas.py @@ -7,6 +7,7 @@ from cornice.service import get_services from cornice_swagger.swagger import CorniceSwagger from pyramid.httpexceptions import ( + HTTPAccepted, HTTPBadRequest, HTTPConflict, HTTPCreated, @@ -25,7 +26,7 @@ from magpie import __meta__ from magpie.constants import get_constant -from magpie.models import UserStatuses +from magpie.models import UserGroupStatus, UserStatuses from magpie.permissions import Access, Permission, PermissionType, Scope from magpie.security import get_provider_names from magpie.utils import ( @@ -816,6 +817,11 @@ class UserBodySchema(RegisteredUserInfoSchema): group_names = GroupNamesListSchema( example=["administrators", "users"] ) + has_pending_group = colander.SchemaNode( + colander.Bool(), + description="Indicates if the user has any pending group requiring terms and conditions validation.", + example=False + ) class UserDetailListSchema(colander.SequenceSchema): @@ -850,6 +856,12 @@ class GroupInfoBodySchema(GroupBaseBodySchema): class GroupDetailBodySchema(GroupPublicBodySchema, GroupInfoBodySchema): description = "Detailed information of the group obtained by specifically requesting it." + terms = colander.SchemaNode( + colander.String(), + name="terms", + description="Terms and conditions associated to the group.", + example="", + missing=colander.null) member_count = colander.SchemaNode( colander.Integer(), description="Number of users member of the group.", @@ -1901,8 +1913,25 @@ class User_DELETE_ForbiddenResponseSchema(BaseResponseSchemaAPI): body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) +class UserGroup_Check_Status_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Invalid 'status' value specified." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class UserGroupsQuery(QueryRequestSchemaAPI): + status = colander.SchemaNode( + colander.String(), + default=UserGroupStatus.ACTIVE.value, + missing=colander.drop, + description="Obtain the user-groups filtered by statuses [all, active, pending]. " + "Returns active user-groups if not provided. ", + validator=colander.OneOf(UserGroupStatus.allowed()) + ) + + class UserGroups_GET_RequestSchema(BaseRequestSchemaAPI): path = User_RequestPathSchema() + querystring = UserGroupsQuery() class UserGroups_GET_ResponseBodySchema(BaseResponseBodySchema): @@ -1945,6 +1974,14 @@ class UserGroups_POST_CreatedResponseSchema(BaseResponseSchemaAPI): body = UserGroups_POST_ResponseBodySchema(code=HTTPCreated.code, description=description) +class UserGroups_POST_AcceptedResponseSchema(BaseResponseSchemaAPI): + description = ( + "Accepted request to add user to the group. Group requires accepting terms and conditions. " + "Pending confirmation by the user." + ) + body = UserGroups_POST_ResponseBodySchema(code=HTTPAccepted.code, description=description) + + class UserGroups_POST_GroupNotFoundResponseSchema(BaseResponseSchemaAPI): description = "Cannot find the group to assign to." body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) @@ -2369,9 +2406,11 @@ class Groups_GET_ForbiddenResponseSchema(BaseResponseSchemaAPI): class Groups_POST_RequestBodySchema(colander.MappingSchema): group_name = colander.SchemaNode(colander.String(), description="Name of the group to create.") description = colander.SchemaNode(colander.String(), default="", - description="Description to apply to the created group.") + description="Description to apply to the group to create.") discoverable = colander.SchemaNode(colander.Boolean(), default=False, - description="Discoverability status of the created group.") + description="Discoverability status of the group to create.") + terms = colander.SchemaNode(colander.String(), default="", + description="Terms and conditions of the group to create.") class Groups_POST_RequestSchema(BaseRequestSchemaAPI): @@ -2492,6 +2531,7 @@ class Group_DELETE_ReservedKeyword_ForbiddenResponseSchema(BaseResponseSchemaAPI class GroupUsers_GET_RequestSchema(BaseRequestSchemaAPI): path = Group_RequestPathSchema() + querystring = UserGroupsQuery() class GroupUsers_GET_ResponseBodySchema(BaseResponseBodySchema): @@ -3411,6 +3451,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): } UserGroups_POST_responses = { "201": UserGroups_POST_CreatedResponseSchema(), + "202": UserGroups_POST_AcceptedResponseSchema(), "400": User_Check_BadRequestResponseSchema(), # FIXME: https://github.com/Ouranosinc/Magpie/issues/359 "401": UnauthorizedResponseSchema(), "403": User_CheckAnonymous_ForbiddenResponseSchema(), @@ -3558,6 +3599,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): } LoggedUserGroups_POST_responses = { "201": UserGroups_POST_CreatedResponseSchema(), + "202": UserGroups_POST_AcceptedResponseSchema(), "401": UnauthorizedResponseSchema(), "403": User_CheckAnonymous_ForbiddenResponseSchema(), "404": User_CheckAnonymous_NotFoundResponseSchema(), @@ -3798,6 +3840,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): } RegisterGroup_POST_responses = { "201": RegisterGroup_POST_CreatedResponseSchema(), + "202": UserGroups_POST_AcceptedResponseSchema(), "401": UnauthorizedResponseSchema(), "403": RegisterGroup_POST_ForbiddenResponseSchema(), # FIXME: https://github.com/Ouranosinc/Magpie/issues/359 "404": RegisterGroup_NotFoundResponseSchema(), diff --git a/magpie/api/templates/email_group_terms_approved.mako b/magpie/api/templates/email_group_terms_approved.mako new file mode 100644 index 000000000..da1abadeb --- /dev/null +++ b/magpie/api/templates/email_group_terms_approved.mako @@ -0,0 +1,34 @@ +<%doc> + This is the default notification message sent by email to the user to notify of accepted terms and conditions and + to confirm the user has been added to the requested group. + + It is formatted using the Mako template library (https://www.makotemplates.org/). + The email header MUST be provided (from, to, subject, content-type). + + Additional variables available to build the content are: + + user: User requested to join a group, with associated details. + group_name: Name of the group the user has been requested to join. + email_user: Value defined by MAGPIE_SMTP_USER to identify the sender of this email. + email_from: Value defined by MAGPIE_SMTP_FROM to identify the sender of this email. + email_sender: Resolved value between MAGPIE_SMTP_FROM or MAGPIE_SMTP_USER sending this email. + email_recipient: Resolved email of the identity where to send the notification email. + email_datetime: Date and time (ISO-8601 UTC) when that email was generated. + magpie_url: Application endpoint defined by MAGPIE_URL or derived configuration settings. + + + +From: ${email_sender} +To: ${email_recipient} +Subject: Magpie - User ${user.user_name} added to '${group_name}' group +Content-Type: text/plain; charset=UTF-8 + +<%doc> === end of header === + +Dear ${user.user_name}, + +The request to join the '${group_name}' group at ${magpie_url} has been completed, following your agreement of the terms and conditions. +Your account has been successfully added to the '${group_name}' group. + +Regards, +${email_user} diff --git a/magpie/api/templates/email_group_terms_submission.mako b/magpie/api/templates/email_group_terms_submission.mako new file mode 100644 index 000000000..dd0828ad9 --- /dev/null +++ b/magpie/api/templates/email_group_terms_submission.mako @@ -0,0 +1,56 @@ +<%doc> + This is the default notification message sent by email for group terms and conditions validation. + + It is formatted using the Mako template library (https://www.makotemplates.org/). + The email header MUST be provided (from, to, subject, content-type). + + Additional variables available to build the content are: + + user: User requested to join a group, with associated details. + group_name: Name of the group the user has been requested to join. + group_terms: Text containing the group's terms and conditions. + email_user: Value defined by MAGPIE_SMTP_USER to identify the sender of this email. + email_from: Value defined by MAGPIE_SMTP_FROM to identify the sender of this email. + email_sender: Resolved value between MAGPIE_SMTP_FROM or MAGPIE_SMTP_USER sending this email. + email_recipient: Resolved email of the identity where to send the notification email. + email_datetime: Date and time (ISO-8601 UTC) when that email was generated. + magpie_url: Application endpoint defined by MAGPIE_URL or derived configuration. + confirm_url: Endpoint where terms and conditions confirmation can be performed to join the group. + + + + +From: ${email_sender} +To: ${email_recipient} +Subject: Magpie - '${group_name}' group terms and conditions +Content-Type: text/html; charset=UTF-8 + +<%doc> === end of header === + + + ${group_name} group terms and conditions + +

+ Dear ${user.user_name}, +

+ +

+ A request to ${magpie_url} has been submitted to join the '${group_name}' group. + Before you can be assigned to the group, we need your consent to the group's terms and conditions. +

+ +

+ Terms and conditions: +

+

+ ${group_terms} +

+ +

+ Please confirm agreeing to the group's terms and conditions by clicking this link. +

+ + Regards,
+ ${email_user} + + diff --git a/magpie/models.py b/magpie/models.py index 27507f370..410d8f5e8 100644 --- a/magpie/models.py +++ b/magpie/models.py @@ -35,7 +35,7 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from typing import Dict, Iterable, List, Optional, Type, Union + from typing import Dict, Iterable, List, Optional, Set, Type, Union from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session @@ -73,6 +73,13 @@ def discoverable(self): """ return sa.Column(sa.Boolean(), default=False) + @declared_attr + def terms(self): + """ + Text containing the terms and conditions. + """ + return sa.Column(sa.UnicodeText(), nullable=True) + @property def priority(self): # type: () -> GroupPriority @@ -94,6 +101,26 @@ class GroupPermission(GroupPermissionMixin, Base): pass +class UserGroupStatus(FlexibleNameEnum): + """ + Supported statuses of user-group relationships. + """ + ALL = "all" + ACTIVE = "active" + PENDING = "pending" + + @classmethod + def allowed(cls): + # type: () -> List[Str] + """ + Returns all supported representation values that can be mapped to a valid status. + """ + names = cls.names() + allowed = names + allowed.extend([name.lower() for name in names]) + return allowed + + class UserGroup(UserGroupMixin, Base): pass @@ -147,6 +174,30 @@ class User(UserMixin, Base): def __str__(self): return "".format(self.user_name, self.id) + def get_groups_by_status(self, status, db_session=None): + # type: (UserGroupStatus, Session) -> Set[Str] + """ + List all groups a user belongs to, filtered by UserGroup status type. + """ + from magpie.api.management.user.user_utils import get_user_groups_checked + + cur_session = get_db_session(session=db_session) if db_session else get_db_session(obj=self) + + group_names = set() + member_group_names = set(get_user_groups_checked(self, cur_session)) + if status in [UserGroupStatus.ACTIVE, UserGroupStatus.ALL]: + group_names = group_names.union(member_group_names) + if status in [UserGroupStatus.PENDING, UserGroupStatus.ALL]: + tmp_tokens = TemporaryToken.by_user(self).filter( + TemporaryToken.operation == TokenOperation.GROUP_ACCEPT_TERMS) + pending_group_names = set(tmp_token.group.group_name for tmp_token in tmp_tokens) + + # Remove any group a user already belongs to, in case any tokens are irrelevant. + # Should not happen since related tokens are deleted upon T&C acceptation. + pending_group_names = pending_group_names - member_group_names + group_names = group_names.union(pending_group_names) + return group_names + class UserPending(Base): """ @@ -210,6 +261,15 @@ def groups(self): """ return [] + def get_groups_by_status(self, status, db_session=None): + """ + Pending user is not a member of any group. + + Avoid error in case this method gets accessed when simultaneously + handling :class:`User` and :class`UserPending`. + """ + return [] + def upgrade(self, db_session=None): # type: (Optional[Session]) -> User """ diff --git a/magpie/register.py b/magpie/register.py index 522b0d275..c69c86ac2 100644 --- a/magpie/register.py +++ b/magpie/register.py @@ -877,7 +877,8 @@ def _apply_profile(_usr_name=None, _grp_name=None): grp_data = { "group_name": _grp_name, "description": groups.get(_grp_name, {}).get("description", ""), - "discoverable": groups.get(_grp_name, {}).get("discoverable", False) + "discoverable": groups.get(_grp_name, {}).get("discoverable", False), + "terms": groups.get(_grp_name, {}).get("terms", "") } if _use_request(cookies_or_session): if _usr_name: diff --git a/magpie/ui/home/static/style.css b/magpie/ui/home/static/style.css index 43824815f..33046036e 100644 --- a/magpie/ui/home/static/style.css +++ b/magpie/ui/home/static/style.css @@ -12,6 +12,40 @@ body { font-family: "Open Sans", sans-serif; } +table, +textarea, +input[type="text"], +.panel-line { + font-family: arial, sans-serif; +} + +textarea, +input[type="text"] { + font-size: 0.8333em; +} + +textarea { + resize: both; + min-width: 21em; + min-height: 5em; +} + +table { + border-collapse: collapse; + width: auto; +} + +td, +th { + border: 1px solid #DDDDDD; + padding: 0.5em; +} + +table thead { + color: white; + font-weight: bold; +} + .content { margin: 5em; } @@ -38,7 +72,7 @@ body { .img-button { padding: 0.5em; - margin: 1em 0 1em; + margin: 1em 0; display: inline-flex; /* align-items: flex-end; */ @@ -186,23 +220,6 @@ input[type="submit"] { height: 1.5em; } -table { - font-family: arial, sans-serif; - border-collapse: collapse; - width: auto; -} - -td, -th { - border: 1px solid #DDDDDD; - padding: 0.5em; -} - -table thead { - color: white; - font-weight: bold; -} - .checkbox-align label { display: block; @@ -279,7 +296,6 @@ table thead { .panel-line { margin: 0.25em 0.5em; - font-family: arial, sans-serif; /* same as table to ensure they match when table is not actually used */ } table.panel-line { @@ -289,7 +305,7 @@ table.panel-line { table.panel-line th, table.panel-line td { border-width: 0; - margin: 0.25em 0.25em; + margin: 0.25em; white-space: nowrap; padding: 0; /* override global th/td */ } @@ -393,6 +409,10 @@ table.panel-line td { margin-left: 0.5em; } +.italic-text { + font-style: italic; +} + /* --- Labels --- */ .label { @@ -430,7 +450,7 @@ table.panel-line td { .alert { padding: 20px; color: white; - opacity: 1; + opacity: 100%; transition: opacity 0.6s; margin-bottom: 15px; display: none; /* hidden default */ @@ -613,9 +633,9 @@ table.panel-line td { margin-left: 0.5em; } -.icon-color-invert { - filter: saturate(0) invert(100%); - -webkit-filter: saturate(0) invert(100%); +.icon-color-white { + filter: brightness(0) invert(1); + -webkit-filter: brightness(0) invert(1); } .status-container { @@ -1074,7 +1094,7 @@ ul.breadcrumb li a:hover { .admin-button { display: inline-block; box-sizing: border-box; - box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); + box-shadow: 0 4px 8px 0 rgba(0 0 0 / 20%), 0 6px 20px 0 rgba(0 0 0 / 19%); width: 10em; height: 10em; margin: 3em; @@ -1253,6 +1273,14 @@ table.fields-table td.centered { text-align: center; } +table.fields-table td.top-align { + vertical-align: top; +} + +table.fields-table td.add-group-fixed-width { + width: 11.1875em; +} + table.fields-table input[type="radio"] { margin-left: 2em; } diff --git a/magpie/ui/management/templates/add_group.mako b/magpie/ui/management/templates/add_group.mako index 379098743..279dd320d 100644 --- a/magpie/ui/management/templates/add_group.mako +++ b/magpie/ui/management/templates/add_group.mako @@ -34,7 +34,8 @@ Description: - + + @@ -71,6 +72,31 @@ + + Terms and conditions: + + + + + %if not invalid_terms: + (optional) + %else: + + %endif + + diff --git a/magpie/ui/management/templates/edit_group.mako b/magpie/ui/management/templates/edit_group.mako index a0ade12b1..5aeb2f45d 100644 --- a/magpie/ui/management/templates/edit_group.mako +++ b/magpie/ui/management/templates/edit_group.mako @@ -1,6 +1,7 @@ <%inherit file="magpie.ui.management:templates/tree_scripts.mako"/> <%namespace name="panel" file="magpie.ui.management:templates/panel_scripts.mako"/> <%namespace name="tree" file="magpie.ui.management:templates/tree_scripts.mako"/> +<%namespace name="membership_alerts" file="magpie.ui.management:templates/membership_alerts.mako"/> <%block name="breadcrumb">
  • Home
  • @@ -144,34 +145,54 @@ +
    +
    +
    Terms and conditions
    +
    +
    + %if terms: + ${terms} + %else: + No terms and conditions for this group. + %endif +
    +

    Members

    +${membership_alerts.edit_membership_alerts()}
    - -%for user in users: - - + + %endfor +
    - +

    Permissions

    diff --git a/magpie/ui/management/templates/edit_service.mako b/magpie/ui/management/templates/edit_service.mako index 6c5bd0c39..23aeb987f 100644 --- a/magpie/ui/management/templates/edit_service.mako +++ b/magpie/ui/management/templates/edit_service.mako @@ -21,7 +21,7 @@

    Danger!

    + alt="" class="icon-error icon-color-white" />
    Delete: [${service_name}]
    diff --git a/magpie/ui/management/templates/edit_user.mako b/magpie/ui/management/templates/edit_user.mako index 0bca76eaa..62e49a490 100644 --- a/magpie/ui/management/templates/edit_user.mako +++ b/magpie/ui/management/templates/edit_user.mako @@ -1,6 +1,7 @@ <%inherit file="magpie.ui.home:templates/template.mako"/> <%namespace name="panel" file="magpie.ui.management:templates/panel_scripts.mako"/> <%namespace name="tree" file="magpie.ui.management:templates/tree_scripts.mako"/> +<%namespace name="membership_alerts" file="magpie.ui.management:templates/membership_alerts.mako"/> <%block name="breadcrumb">
  • Home
  • @@ -212,6 +213,7 @@

    Groups Membership

    +${membership_alerts.edit_membership_alerts()}
    @@ -230,7 +232,13 @@ onchange="document.getElementById('edit_membership').submit()" %endif > - ${group} + %if group in pending_groups: + + ${group} [pending] + %else: + ${group} + %endif diff --git a/magpie/ui/management/templates/membership_alerts.mako b/magpie/ui/management/templates/membership_alerts.mako new file mode 100644 index 000000000..502845242 --- /dev/null +++ b/magpie/ui/management/templates/membership_alerts.mako @@ -0,0 +1,41 @@ +<%def name="edit_membership_alerts()"> + %if edit_new_membership_error: +
    +

    Warning

    +
    + +
    + Edit Membership Failed +
    +
    +

    + Failed to add the user to the group. + Refer to the Magpie logs for more details. +

    + + + +
    + %endif + %if edit_membership_pending_success: +
    +

    Success

    +
    +
    + Edit Membership Successful +
    +
    +

    + Successfully requested to add the user to the group. + The terms and conditions of the group have been sent by email. + The request will stay as 'pending' until confirmation of the terms and conditions is received. +

    +
    + +
    +
    + %endif + diff --git a/magpie/ui/management/templates/view_services.mako b/magpie/ui/management/templates/view_services.mako index 8a45ec7da..070a07e24 100644 --- a/magpie/ui/management/templates/view_services.mako +++ b/magpie/ui/management/templates/view_services.mako @@ -11,7 +11,7 @@

    Danger!

    + alt="" class="icon-error icon-color-white" />
    Delete: [${service}]
    diff --git a/magpie/ui/management/views.py b/magpie/ui/management/views.py index 49f3920e1..046558856 100644 --- a/magpie/ui/management/views.py +++ b/magpie/ui/management/views.py @@ -9,6 +9,7 @@ from pyramid.httpexceptions import ( HTTPBadRequest, HTTPConflict, + HTTPException, HTTPFound, HTTPMovedPermanently, HTTPNotFound, @@ -23,7 +24,7 @@ from magpie.cli.sync_resources import OUT_OF_SYNC from magpie.constants import get_constant # TODO: remove (REMOTE_RESOURCE_TREE_SERVICE, RESOURCE_TYPE_DICT), implement getters via API -from magpie.models import REMOTE_RESOURCE_TREE_SERVICE, RESOURCE_TYPE_DICT, UserStatuses +from magpie.models import REMOTE_RESOURCE_TREE_SERVICE, RESOURCE_TYPE_DICT, UserGroupStatus, UserStatuses from magpie.permissions import PermissionSet from magpie.ui.utils import AdminRequests, BaseViews, check_response, handle_errors, request_api from magpie.utils import CONTENT_TYPE_JSON, get_json, get_logger @@ -122,6 +123,7 @@ def edit_user(self): inherit_grp_perms = self.request.matchdict.get("inherit_groups_permissions", False) own_groups = self.get_user_groups(user_name) + pending_groups = self.get_user_groups(user_name, user_group_status=UserGroupStatus.PENDING) all_groups = self.get_all_groups(first_default_group=get_constant("MAGPIE_USERS_GROUP", self.request)) # TODO: @@ -141,6 +143,7 @@ def edit_user(self): user_info["user_with_error"] = UserStatuses.get(user_info["status"]) != UserStatuses.OK user_info["edit_mode"] = "no_edit" user_info["own_groups"] = own_groups + user_info["pending_groups"] = pending_groups user_info["groups"] = all_groups user_info["cur_svc_type"] = cur_svc_type user_info["svc_types"] = svc_types @@ -252,12 +255,25 @@ def edit_user(self): path = schemas.UserGroupAPI.path.format(user_name=user_name, group_name=group) resp = request_api(self.request, path, "DELETE") check_response(resp) + + user_info["edit_new_membership_error"] = set() + successful_new_groups = set() for group in new_groups: - path = schemas.UserGroupsAPI.path.format(user_name=user_name) - data = {"group_name": group} - resp = request_api(self.request, path, "POST", data=data) - check_response(resp) + try: + path = schemas.UserGroupsAPI.path.format(user_name=user_name) + data = {"group_name": group} + resp = request_api(self.request, path, "POST", data=data) + check_response(resp) + except HTTPException as exc: + detail = "{} ({}), {!s}".format(type(exc).__name__, exc.code, exc) + LOGGER.error("Unexpected API error under UI operation. [%s]", detail) + user_info["edit_new_membership_error"].add(group) + else: + successful_new_groups.add(group) user_info["own_groups"] = self.get_user_groups(user_name) + user_info["pending_groups"] = self.get_user_groups(user_name, user_group_status=UserGroupStatus.PENDING) + + user_info["edit_membership_pending_success"] = successful_new_groups & set(user_info["pending_groups"]) # display resources permissions per service type tab try: @@ -337,17 +353,19 @@ def view_groups(self): @view_config(route_name="add_group", renderer="templates/add_group.mako") def add_group(self): - return_data = {"invalid_group_name": False, "invalid_description": False, - "reason_group_name": "Invalid", "reason_description": "Invalid", - "form_group_name": "", "form_discoverable": False, "form_description": ""} + return_data = {"invalid_group_name": False, "invalid_description": False, "invalid_terms": False, + "reason_group_name": "Invalid", "reason_description": "Invalid", "reason_terms": "Invalid", + "form_group_name": "", "form_discoverable": False, "form_description": "", "form_terms": ""} if "create" in self.request.POST: group_name = self.request.POST.get("group_name") description = self.request.POST.get("description") discoverable = asbool(self.request.POST.get("discoverable")) + terms = self.request.POST.get("terms") return_data["form_group_name"] = group_name return_data["form_description"] = description return_data["form_discoverable"] = discoverable + return_data["form_terms"] = terms if not group_name: return_data["invalid_group_name"] = True return self.add_template_data(return_data) @@ -356,6 +374,7 @@ def add_group(self): "group_name": group_name, "description": return_data["form_description"], "discoverable": return_data["form_discoverable"], + "terms": return_data["form_terms"], } resp = request_api(self.request, schemas.GroupsAPI.path, "POST", data=data) if resp.status_code == HTTPConflict.code: @@ -375,6 +394,10 @@ def add_group(self): return_data["invalid_description"] = True return_data["reason_description"] = reason return self.add_template_data(return_data) + if param_name == "terms": + return_data["invalid_terms"] = True + return_data["reason_terms"] = reason + return self.add_template_data(return_data) check_response(resp) # check for any other exception than checked use-cases return HTTPFound(self.request.route_url("view_groups")) @@ -411,11 +434,22 @@ def edit_group_users(self, group_name): path = schemas.UserGroupAPI.path.format(user_name=user_name, group_name=group_name) resp = request_api(self.request, path, "DELETE") check_response(resp) + + report_info = {"edit_new_membership_success": set(), + "edit_new_membership_error": set()} for user_name in new_members: - path = schemas.UserGroupsAPI.path.format(user_name=user_name) - data = {"group_name": group_name} - resp = request_api(self.request, path, "POST", data=data) - check_response(resp) + try: + path = schemas.UserGroupsAPI.path.format(user_name=user_name) + data = {"group_name": group_name} + resp = request_api(self.request, path, "POST", data=data) + check_response(resp) + except HTTPException as exc: + detail = "{} ({}), {!s}".format(type(exc).__name__, exc.code, exc) + LOGGER.error("Unexpected API error under UI operation. [%s]", detail) + report_info["edit_new_membership_error"].add(user_name) + else: + report_info["edit_new_membership_success"].add(user_name) + return report_info def edit_user_or_group_resource_permissions(self, user_or_group_name, is_user=False): posted = self.request.POST.dict_of_lists().items() @@ -535,6 +569,7 @@ def edit_group(self): cur_svc_type = self.request.matchdict["cur_svc_type"] group_info = {"edit_mode": "no_edit", "group_name": group_name, "cur_svc_type": cur_svc_type} error_message = "" + edit_grp_usrs_info = {} # TODO: # Until the api is modified to make it possible to request from the RemoteResource table, @@ -546,6 +581,7 @@ def edit_group(self): # move to service or edit requested group/permission changes if self.request.method == "POST": + is_edit_group_members = False res_id = self.request.POST.get("resource_id") if "delete" in self.request.POST: @@ -585,8 +621,8 @@ def edit_group(self): # res_id = self.add_remote_resource(cur_svc_type, services_names, group_name, # remote_id, is_user=False) self.edit_user_or_group_resource_permissions(group_name, is_user=False) - elif "member" in self.request.POST: - self.edit_group_users(group_name) + elif "edit_group_members" in self.request.POST: + is_edit_group_members = True elif "force_sync" in self.request.POST: _, errmsg = self.sync_services(services) error_message += errmsg or "" @@ -597,6 +633,10 @@ def edit_group(self): elif "no_edit" not in self.request.POST: raise HTTPBadRequest(detail="Invalid POST request for group edit.") + # edits to group members checkboxes + if is_edit_group_members: + edit_grp_usrs_info = self.edit_group_users(group_name) + # display resources permissions per service type tab try: res_perm_names, res_perms = self.get_user_or_group_resources_permissions_dict( @@ -616,6 +656,7 @@ def edit_group(self): group_info.update(self.get_group_info(group_name)) group_info["members"] = group_info.pop("user_names") + group_info["pending_users"] = self.get_group_users(group_name, user_group_status=UserGroupStatus.PENDING) group_info["error_message"] = error_message group_info["ids_to_clean"] = ";".join(ids_to_clean) group_info["last_sync"] = last_sync_humanized @@ -626,6 +667,11 @@ def edit_group(self): group_info["cur_svc_type"] = cur_svc_type group_info["resources"] = res_perms group_info["permissions"] = res_perm_names + + if edit_grp_usrs_info: + new_usrs_from_pending = edit_grp_usrs_info["edit_new_membership_success"] & set(group_info["pending_users"]) + group_info["edit_membership_pending_success"] = new_usrs_from_pending + group_info["edit_new_membership_error"] = edit_grp_usrs_info["edit_new_membership_error"] return self.add_template_data(data=group_info) @staticmethod diff --git a/magpie/ui/user/templates/edit_current_user.mako b/magpie/ui/user/templates/edit_current_user.mako index 6b82bbb96..5349952dc 100644 --- a/magpie/ui/user/templates/edit_current_user.mako +++ b/magpie/ui/user/templates/edit_current_user.mako @@ -1,4 +1,5 @@ <%inherit file="magpie.ui.home:templates/template.mako"/> +<%namespace name="membership_alerts" file="magpie.ui.management:templates/membership_alerts.mako"/> <%block name="breadcrumb">
  • Home
  • @@ -10,7 +11,7 @@

    Warning

    + alt="" class="icon-warning icon-color-white" />
    Update User Information Failed
    @@ -35,7 +36,7 @@
    + alt="" class="icon-error icon-color-white"/>  Delete your account?
    @@ -224,6 +225,7 @@

    Public Groups Membership

    +${membership_alerts.edit_membership_alerts()}
    @@ -242,7 +244,13 @@ onchange="document.getElementById('edit_membership').submit()" %endif > - ${group} + %if group in pending_groups: + + ${group} [pending] + %else: + ${group} + %endif diff --git a/magpie/ui/user/views.py b/magpie/ui/user/views.py index c10f78c3a..45cb9cac1 100644 --- a/magpie/ui/user/views.py +++ b/magpie/ui/user/views.py @@ -1,18 +1,21 @@ from typing import TYPE_CHECKING from pyramid.authentication import Authenticated -from pyramid.httpexceptions import HTTPBadRequest, HTTPFound, HTTPUnprocessableEntity +from pyramid.httpexceptions import HTTPBadRequest, HTTPException, HTTPFound, HTTPUnprocessableEntity from pyramid.settings import asbool from pyramid.view import view_config from magpie.api import schemas from magpie.constants import get_constant +from magpie.models import UserGroupStatus from magpie.ui.utils import BaseViews, check_response, handle_errors, request_api -from magpie.utils import get_json +from magpie.utils import get_json, get_logger if TYPE_CHECKING: from magpie.typedefs import JSON, List, Str +LOGGER = get_logger(__name__) + class UserViews(BaseViews): def add_template_data(self, data=None): @@ -21,9 +24,10 @@ def add_template_data(self, data=None): return super(UserViews, self).add_template_data(data) @handle_errors - def get_current_user_groups(self): - # type: () -> List[str] - resp = request_api(self.request, schemas.LoggedUserGroupsAPI.path, "GET") + def get_current_user_groups(self, user_group_status=UserGroupStatus.ACTIVE): + # type: (UserGroupStatus) -> List[str] + path = schemas.LoggedUserGroupsAPI.path + "?status={}".format(user_group_status.value) + resp = request_api(self.request, path, "GET") check_response(resp) return get_json(resp)["group_names"] @@ -41,7 +45,6 @@ def get_discoverable_groups(self): check_response(resp) return get_json(resp)["group_names"] - @handle_errors def join_discoverable_group(self, group_name): """ Registers the current user to the discoverable group. @@ -73,10 +76,12 @@ def edit_current_user(self): - :meth:`magpie.ui.management.views.ManagementViews.edit_user` for corresponding operation by administrator """ joined_groups = self.get_current_user_groups() + pending_groups = self.get_current_user_groups(user_group_status=UserGroupStatus.PENDING) public_groups = self.get_discoverable_groups() user_info = self.get_current_user_info() user_info["edit_mode"] = "no_edit" user_info["joined_groups"] = joined_groups + user_info["pending_groups"] = pending_groups user_info["groups"] = public_groups # FIXME: disable email edit when self-registration is enabled to avoid not having any confirmation of new email # (see https://github.com/Ouranosinc/Magpie/issues/436) @@ -148,9 +153,25 @@ def edit_current_user(self): new_groups = list(set(selected_groups) - set(joined_groups)) for group in removed_groups: self.leave_discoverable_group(group) + + user_info["edit_new_membership_error"] = set() + successful_new_groups = set() for group in new_groups: - self.join_discoverable_group(group) + try: + self.join_discoverable_group(group) + except HTTPException as exc: + detail = "{} ({}), {!s}".format(type(exc).__name__, exc.code, exc) + LOGGER.error("Unexpected API error under UI operation. [%s]", detail) + user_info["edit_new_membership_error"].add(group) + else: + successful_new_groups.add(group) + user_info["joined_groups"] = self.get_current_user_groups() + user_info["pending_groups"] = self.get_current_user_groups( + user_group_status=UserGroupStatus.PENDING) + + user_info["edit_membership_pending_success"] = \ + successful_new_groups & set(user_info["pending_groups"]) user_info.pop("password", None) # always remove password from output return self.add_template_data(data=user_info) diff --git a/magpie/ui/utils.py b/magpie/ui/utils.py index 653ada1e3..4eddab589 100644 --- a/magpie/ui/utils.py +++ b/magpie/ui/utils.py @@ -21,6 +21,7 @@ from magpie.api.generic import get_exception_info, get_request_info from magpie.api.requests import get_logged_user from magpie.constants import get_constant +from magpie.models import UserGroupStatus from magpie.security import mask_credentials from magpie.utils import CONTENT_TYPE_JSON, get_header, get_json, get_logger, get_magpie_url @@ -319,9 +320,10 @@ def get_group_info(self, group_name): return get_json(resp)["group"] @handle_errors - def get_group_users(self, group_name): + def get_group_users(self, group_name, user_group_status=UserGroupStatus.ACTIVE): + # type: (UserGroupStatus) -> List[str] path = schemas.GroupUsersAPI.path.format(group_name=group_name) - resp = request_api(self.request, path, "GET") + resp = request_api(self.request, path + "?status={}".format(user_group_status.value), "GET") check_response(resp) return get_json(resp)["user_names"] @@ -340,9 +342,10 @@ def delete_group(self, group_name): return get_json(resp) @handle_errors - def get_user_groups(self, user_name): + def get_user_groups(self, user_name, user_group_status=UserGroupStatus.ACTIVE): + # type: (UserGroupStatus) -> List[str] path = schemas.UserGroupsAPI.path.format(user_name=user_name) - resp = request_api(self.request, path, "GET") + resp = request_api(self.request, path + "?status={}".format(user_group_status.value), "GET") check_response(resp) return get_json(resp)["group_names"] diff --git a/tests/interfaces.py b/tests/interfaces.py index 15373b1e2..4f15e247b 100644 --- a/tests/interfaces.py +++ b/tests/interfaces.py @@ -4527,24 +4527,6 @@ def test_PostUserGroup_AllowAdmin_SelfAssignMembership(self): utils.TestSetup.assign_TestUserGroup(self, override_user_name=self.test_user_name, override_headers=self.json_headers, override_cookies=self.cookies) - @runner.MAGPIE_TEST_USERS - def test_GetUserGroups(self): - users_group = get_constant("MAGPIE_USERS_GROUP") - utils.TestSetup.create_TestUser(self, override_group_name=users_group) - utils.TestSetup.create_TestGroup(self) - utils.TestSetup.assign_TestUserGroup(self) - - path = "/users/{usr}/groups".format(usr=self.test_user_name) - resp = utils.test_request(self, "GET", path, headers=self.json_headers, - cookies=self.cookies, expect_errors=True) - body = utils.check_response_basic_info(resp, 200, expected_method="GET") - utils.check_val_is_in("group_names", body) - utils.check_val_type(body["group_names"], list) - expected_groups = {self.test_group_name, users_group} - if TestVersion(self.version) >= TestVersion("1.4.0"): - expected_groups.add(get_constant("MAGPIE_ANONYMOUS_GROUP")) - utils.check_all_equal(body["group_names"], expected_groups, any_order=True) - @runner.MAGPIE_TEST_USERS def test_DeleteUser_DeleteSelf(self): """ @@ -4840,6 +4822,22 @@ def test_UpdateGroup_Discoverable(self): body = utils.check_response_basic_info(resp, 200) utils.check_val_equal(body["group"]["discoverable"], True) + @runner.MAGPIE_TEST_GROUPS + def test_UpdateGroup_Terms_BadRequest(self): + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + + path = "/groups/{}".format(self.test_group_name) + data = {"terms": "Bad request, trying to change the terms & conditions."} + resp = utils.test_request(self, self.update_method, path, json=data, expect_errors=True, + headers=self.json_headers, cookies=self.cookies) + # T&C should be immutable + utils.check_response_basic_info(resp, 400, expected_method=self.update_method) + + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200) + utils.check_val_equal(body["group"]["terms"], terms) + @runner.MAGPIE_TEST_GROUPS def test_UpdateGroup_MultipleFields(self): data = {"group_name": self.test_group_name, "discoverable": True, "description": "test-group"} @@ -4893,16 +4891,6 @@ def test_DeleteGroup_forbidden_ReservedKeyword_Admin(self): groups = utils.TestSetup.get_RegisteredGroupsList(self) utils.check_val_is_in(admins, groups, msg="Admin special group should still exist.") - @runner.MAGPIE_TEST_GROUPS - def test_GetGroupUsers(self): - path = "/groups/{grp}/users".format(grp=get_constant("MAGPIE_ADMIN_GROUP")) - resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) - body = utils.check_response_basic_info(resp, 200, expected_method="GET") - utils.check_val_is_in("user_names", body) - utils.check_val_type(body["user_names"], list) - utils.check_val_is_in(get_constant("MAGPIE_ADMIN_USER"), body["user_names"]) - utils.check_val_is_in(self.usr, body["user_names"]) - @runner.MAGPIE_TEST_GROUPS def test_GetGroupUsers_not_found(self): fake_group = "magpie-unittest-random-group" diff --git a/tests/test_magpie_api.py b/tests/test_magpie_api.py index 6921f7162..34c783b07 100644 --- a/tests/test_magpie_api.py +++ b/tests/test_magpie_api.py @@ -14,9 +14,10 @@ # NOTE: must be imported without 'from', otherwise the interface's test cases are also executed import tests.interfaces as ti from magpie.constants import get_constant -from magpie.models import UserStatuses +from magpie.models import UserGroupStatus, UserStatuses from magpie.utils import CONTENT_TYPE_JSON from tests import runner, utils +from tests.utils import TestVersion @runner.MAGPIE_TEST_API @@ -80,6 +81,44 @@ def setUpClass(cls): cls.test_group_name = "unittest-user-auth-local_test-group" cls.test_user_name = "unittest-user-auth-local_test-user-username" + @runner.MAGPIE_TEST_USERS + @runner.MAGPIE_TEST_GROUPS + @runner.MAGPIE_TEST_REGISTRATION + @utils.mocked_send_email + def test_RegisterDiscoverableGroupWithTerms(self): + """ + Non-admin logged user is allowed to request to join a group requiring terms and conditions acceptation. + """ + terms = "Test terms and conditions." + utils.TestSetup.delete_TestGroup(self) + utils.TestSetup.create_TestGroup(self, override_discoverable=True, override_terms=terms) + self.login_test_user() + + path = "/register/groups/{}".format(self.test_group_name) + resp = utils.test_request(self, "POST", path, data={}, headers=self.test_headers, cookies=self.test_cookies) + body = utils.check_response_basic_info(resp, 202, expected_method="POST") + utils.check_val_is_in("group_name", body) + utils.check_val_is_in("user_name", body) + utils.check_val_is_in(body["group_name"], self.test_group_name) + utils.check_val_is_in(body["user_name"], self.test_user_name) + + # validate as admin that user was not registered yet to the group, + # since it requires terms and condition acceptation + utils.check_or_try_logout_user(self) + utils.check_or_try_login_user(self, username=self.usr, password=self.pwd) + utils.TestSetup.check_UserGroupMembership(self, member=False, + override_headers=self.json_headers, override_cookies=self.cookies) + + # Check if the user's membership is pending + path = "/users/{user_name}/groups?status={status}".format(user_name=self.test_user_name, + status=UserGroupStatus.PENDING.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + + utils.check_val_is_in("group_names", body) + utils.check_val_type(body["group_names"], list) + utils.check_val_is_in(self.test_group_name, body["group_names"]) + @runner.MAGPIE_TEST_API @runner.MAGPIE_TEST_LOCAL @@ -170,6 +209,248 @@ def setUpClass(cls): cls.login_admin() cls.setup_test_values() + @runner.MAGPIE_TEST_GROUPS + def test_GetGroupUsers_Pending(self): + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + # Create test user and request adding the user to test group, but leave him as 'pending' + utils.TestSetup.create_TestUser(self, accept_terms=False) + # Add admin user as an active member of test group + utils.TestSetup.assign_TestUserGroup(self, override_user_name=self.usr) + + path = "/groups/{grp}/users?status={status}".format(grp=self.test_group_name, + status=UserGroupStatus.PENDING.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("user_names", body) + utils.check_val_type(body["user_names"], list) + utils.check_all_equal(body["user_names"], {self.test_user_name}, any_order=True) + + @runner.MAGPIE_TEST_GROUPS + def test_GetGroupUsers_Active(self): + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + # Create test user and request adding the user to test group, but leave him as 'pending' + utils.TestSetup.create_TestUser(self, accept_terms=False) + # Add admin user as an active member of test group + utils.TestSetup.assign_TestUserGroup(self, override_user_name=self.usr) + + path = "/groups/{grp}/users?status={status}".format(grp=self.test_group_name, + status=UserGroupStatus.ACTIVE.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("user_names", body) + utils.check_val_type(body["user_names"], list) + utils.check_all_equal(body["user_names"], {self.usr}, any_order=True) + + @runner.MAGPIE_TEST_GROUPS + def test_GetGroupUsers_Unspecified(self): + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + # Create test user and request adding the user to test group, but leave him as 'pending' + utils.TestSetup.create_TestUser(self, accept_terms=False) + # Add admin user as an active member of test group + utils.TestSetup.assign_TestUserGroup(self, override_user_name=self.usr) + + path = "/groups/{grp}/users".format(grp=self.test_group_name) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("user_names", body) + utils.check_val_type(body["user_names"], list) + utils.check_all_equal(body["user_names"], {self.usr}, any_order=True) + + @runner.MAGPIE_TEST_GROUPS + def test_GetGroupUsers_All(self): + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + # Create test user and request adding the user to test group, but leave him as 'pending' + utils.TestSetup.create_TestUser(self, accept_terms=False) + # Add admin user as an active member of test group + utils.TestSetup.assign_TestUserGroup(self, override_user_name=self.usr) + + path = "/groups/{grp}/users?status={status}".format(grp=self.test_group_name, + status=UserGroupStatus.ALL.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("user_names", body) + utils.check_val_type(body["user_names"], list) + utils.check_all_equal(body["user_names"], {self.usr, self.test_user_name}, any_order=True) + + @runner.MAGPIE_TEST_GROUPS + def test_GetUserInfo_PendingGroups(self): + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + # Add user to users group + users_group = get_constant("MAGPIE_USERS_GROUP") + utils.TestSetup.create_TestUser(self, override_group_name=users_group) + + # Check if user info displays no current pending group + path = "/users/{usr}".format(usr=self.test_user_name) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("user", body) + utils.check_val_is_in("has_pending_group", body["user"]) + utils.check_val_false(body["user"]["has_pending_group"]) + + # add user to test group and leave him as pending + utils.TestSetup.assign_TestUserGroup(self, accept_terms=False) + + # Check if user info displays having a pending group + path = "/users/{usr}".format(usr=self.test_user_name) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("user", body) + utils.check_val_is_in("has_pending_group", body["user"]) + utils.check_val_true(body["user"]["has_pending_group"]) + + @runner.MAGPIE_TEST_GROUPS + def test_GetUserGroups_Pending(self): + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + # Add user to users group and leave user pending on test group + users_group = get_constant("MAGPIE_USERS_GROUP") + utils.TestSetup.create_TestUser(self, override_group_name=users_group) + utils.TestSetup.assign_TestUserGroup(self, accept_terms=False) + + path = "/users/{usr}/groups?status={status}".format(usr=self.test_user_name, + status=UserGroupStatus.PENDING.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("group_names", body) + utils.check_val_type(body["group_names"], list) + utils.check_all_equal(body["group_names"], {self.test_group_name}, any_order=True) + + @runner.MAGPIE_TEST_GROUPS + def test_GetUserGroups_Active(self): + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + # Add user to users group and leave user pending on test group + users_group = get_constant("MAGPIE_USERS_GROUP") + utils.TestSetup.create_TestUser(self, override_group_name=users_group) + utils.TestSetup.assign_TestUserGroup(self, accept_terms=False) + + path = "/users/{usr}/groups?status={status}".format(usr=self.test_user_name, + status=UserGroupStatus.ACTIVE.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("group_names", body) + utils.check_val_type(body["group_names"], list) + + expected_active_groups = {users_group} + if TestVersion(self.version) >= TestVersion("1.4.0"): + expected_active_groups.add(get_constant("MAGPIE_ANONYMOUS_GROUP")) + utils.check_all_equal(body["group_names"], expected_active_groups, any_order=True) + + @runner.MAGPIE_TEST_GROUPS + def test_GetUserGroups_Unspecified(self): + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + # Add user to users group and leave user pending on test group + users_group = get_constant("MAGPIE_USERS_GROUP") + utils.TestSetup.create_TestUser(self, override_group_name=users_group) + utils.TestSetup.assign_TestUserGroup(self, accept_terms=False) + + path = "/users/{usr}/groups".format(usr=self.test_user_name) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("group_names", body) + utils.check_val_type(body["group_names"], list) + + expected_active_groups = {users_group} + if TestVersion(self.version) >= TestVersion("1.4.0"): + expected_active_groups.add(get_constant("MAGPIE_ANONYMOUS_GROUP")) + utils.check_all_equal(body["group_names"], expected_active_groups, any_order=True) + + @runner.MAGPIE_TEST_GROUPS + def test_GetUserGroups_All(self): + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + # Add user to users group and leave user pending on test group + users_group = get_constant("MAGPIE_USERS_GROUP") + utils.TestSetup.create_TestUser(self, override_group_name=users_group) + utils.TestSetup.assign_TestUserGroup(self, accept_terms=False) + + path = "/users/{usr}/groups?status={status}".format(usr=self.test_user_name, + status=UserGroupStatus.ALL.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, + cookies=self.cookies, expect_errors=True) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("group_names", body) + utils.check_val_type(body["group_names"], list) + + expected_groups = {users_group, self.test_group_name} + if TestVersion(self.version) >= TestVersion("1.4.0"): + expected_groups.add(get_constant("MAGPIE_ANONYMOUS_GROUP")) + utils.check_all_equal(body["group_names"], expected_groups, any_order=True) + + @runner.MAGPIE_TEST_GROUPS + @utils.mocked_send_email + def test_PostUserGroupWithTerms(self): + # First test adding an existing user to a group with terms + utils.TestSetup.create_TestUser(self, override_group_name=None) + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + + # Request adding the user to test group + path = "/users/{usr}/groups".format(usr=self.test_user_name) + data = {"group_name": self.test_group_name} + resp = utils.test_request(self, "POST", path, json=data, + headers=self.json_headers, cookies=self.cookies) + utils.check_response_basic_info(resp, 202, expected_method="POST") + + # User should not be added to group until terms are accepted + utils.TestSetup.check_UserGroupMembership(self, member=False) + + # Now test adding a new user to a group with terms upon user creation + new_user_name = "new_usr_in_group_with_terms" + self.extra_user_names.add(new_user_name) + data = { + "user_name": new_user_name, + "password": new_user_name, + "group_name": self.test_group_name, + "email": "{}@mail.com".format(new_user_name) + } + resp = utils.test_request(self, "POST", "/users", json=data, headers=self.json_headers, cookies=self.cookies) + utils.check_response_basic_info(resp, 201, expected_method="POST") + utils.TestSetup.check_UserGroupMembership(self, override_user_name=new_user_name, member=False) + + # Check if both user memberships are pending + path = "/groups/{grp}/users?status={status}".format(grp=self.test_group_name, + status=UserGroupStatus.PENDING.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + + utils.check_val_is_in("user_names", body) + utils.check_val_type(body["user_names"], list) + utils.check_val_is_in(self.test_user_name, body["user_names"]) + utils.check_val_is_in(new_user_name, body["user_names"]) + + @runner.MAGPIE_TEST_GROUPS + def test_PostUserGroupWithTerms_Fail(self): + utils.TestSetup.create_TestUser(self, override_group_name=None) + terms = "Test terms and conditions." + utils.TestSetup.create_TestGroup(self, override_terms=terms) + + # Use empty settings dictionary, not assigning the MAGPIE_SMTP_HOST variable in the settings will + # trigger a fail when assigning the user to the group with terms + with utils.mocked_get_settings(settings={}): + with utils.mock_send_email("magpie.api.management.user.user_utils.send_email"): + path = "/users/{usr}/groups".format(usr=self.test_user_name) + data = {"group_name": self.test_group_name} + resp = utils.test_request(self, "POST", path, json=data, expect_errors=True, + headers=self.json_headers, cookies=self.cookies) + utils.check_response_basic_info(resp, 500, expected_method="POST") + + # Check that the user membership has not been updated as pending or as active + path = "/groups/{grp}/users?status={status}".format(grp=self.test_group_name, + status=UserGroupStatus.ALL.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + + utils.check_val_is_in("user_names", body) + utils.check_val_type(body["user_names"], list) + utils.check_val_not_in(self.test_user_name, body["user_names"]) + @runner.MAGPIE_TEST_API @runner.MAGPIE_TEST_LOCAL diff --git a/tests/test_magpie_ui.py b/tests/test_magpie_ui.py index c9504232e..2d49393d2 100644 --- a/tests/test_magpie_ui.py +++ b/tests/test_magpie_ui.py @@ -17,7 +17,7 @@ # NOTE: must be imported without 'from', otherwise the interface's test cases are also executed import tests.interfaces as ti from magpie.constants import get_constant -from magpie.models import Route +from magpie.models import Route, UserGroupStatus from magpie.permissions import Access, Permission, PermissionSet, PermissionType, Scope from magpie.services import ServiceAPI, ServiceWPS from tests import runner, utils @@ -297,6 +297,154 @@ def check_api_resource_permissions(resource_permissions): check_ui_resource_permissions(res_perm_form, sub_id, [to_ui_permission(perm) for perm in sub_perms_mod]) check_api_resource_permissions([(svc_id, svc_perms_mod), (res_id, res_perms_mod), (sub_id, sub_perms_mod)]) + @runner.MAGPIE_TEST_USERS + @runner.MAGPIE_TEST_FUNCTIONAL + @runner.MAGPIE_TEST_GROUPS + def test_end2end_user_join_group_with_terms_confirmation(self): + utils.TestSetup.create_TestGroup(self) + utils.TestSetup.create_TestUser(self) + + terms = "Test terms and conditions." + group_with_terms_name = "unittest-admin-auth_ui-group-with-terms-local" + utils.TestSetup.create_TestGroup(self, override_group_name=group_with_terms_name, override_discoverable=True, + override_terms=terms) + + # custom app settings, smtp_host must exist when getting configs, but not used because email mocked + settings = {"magpie.smtp_host": "example.com", + # for testing, ignore any 'from' and 'password' arguments that could be found in the .ini file + "magpie.smtp_from": "", + "magpie.smtp_password": ""} + + from magpie.api.notifications import make_email_contents as real_contents # test contents with real generation + with utils.mocked_get_settings(settings=settings): + with utils.mock_send_email("magpie.api.management.user.user_utils.send_email") as email_contexts: + _, wrapped_contents, mocked_send = email_contexts + + # Get current group's active members, for later checks + path = "/users/{user_name}/groups?status={status}".format(user_name=self.test_user_name, + status=UserGroupStatus.ACTIVE.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("group_names", body) + active_members = body["group_names"] + + # Request adding the user to test group + path = "/users/{usr}/groups".format(usr=self.test_user_name) + data = {"group_name": group_with_terms_name} + resp = utils.test_request(self, "POST", path, json=data, + headers=self.json_headers, cookies=self.cookies) + utils.check_response_basic_info(resp, 202, expected_method="POST") + + # Send a second request, to check later if both tmp_tokens are removed upon T&C acceptation + resp = utils.test_request(self, "POST", path, json=data, + headers=self.json_headers, cookies=self.cookies) + utils.check_response_basic_info(resp, 202, expected_method="POST") + + # User should not be added to group until terms are accepted + utils.TestSetup.check_UserGroupMembership(self, member=False, + override_group_name=group_with_terms_name) + + utils.check_val_equal(mocked_send.call_count, 2, + msg="Expected sent notifications to user for an email confirmation " + "of terms and conditions.") + + # Check if the user's membership is pending + path = "/users/{user_name}/groups?status={status}".format(user_name=self.test_user_name, + status=UserGroupStatus.PENDING.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + + utils.check_val_is_in("group_names", body) + utils.check_val_type(body["group_names"], list) + utils.check_val_is_in(group_with_terms_name, body["group_names"]) + pending_members = body["group_names"] + + # Check if getting all group's members finds both pending and active members + path = "/users/{user_name}/groups?status={status}".format(user_name=self.test_user_name, + status=UserGroupStatus.ALL.value) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_is_in("group_names", body) + self.assertCountEqual(body["group_names"], pending_members + active_members) + + # validate that pending user can be viewed in the edit group page + path = "/ui/groups/{}/default".format(group_with_terms_name) + resp = utils.test_request(self, "GET", path) + body = utils.check_ui_response_basic_info(resp) + utils.check_val_is_in("{} [pending]".format(self.test_user_name), body) + + # validate that pending group membership can be viewed in the edit user page + path = "/ui/users/{}/default".format(self.test_user_name) + resp = utils.test_request(self, "GET", path) + body = utils.check_ui_response_basic_info(resp) + utils.check_val_is_in("{} [pending]".format(group_with_terms_name), body) + + # validate that pending group membership can be viewed in the user's account page + utils.check_or_try_logout_user(self) + utils.check_or_try_login_user(self, username=self.test_user_name, password=self.test_user_name, + use_ui_form_submit=True) + resp = utils.test_request(self, "GET", "/ui/users/current") + body = utils.check_ui_response_basic_info(resp, expected_title="Magpie") + utils.check_val_is_in("{} [pending]".format(group_with_terms_name), body) + + # Validate the content of the email that would have been sent if not mocked + message = real_contents(*wrapped_contents.call_args.args, **wrapped_contents.call_args.kwargs) + msg_str = message.decode() + + confirm_url = wrapped_contents.call_args.args[-1].get("confirm_url") + + test_user_email = "{}@mail.com".format(self.test_user_name) + utils.check_val_is_in("To: {}".format(test_user_email), msg_str) + utils.check_val_is_in("From: Magpie", msg_str) + utils.check_val_is_in(confirm_url, msg_str) + utils.check_val_true(confirm_url.startswith("http://localhost") and "/tmp/" in confirm_url, + msg="Expected confirmation URL in email to be a temporary token URL.") + + # Simulate user clicking the confirmation link in 'sent' email (external operation from Magpie) + resp = utils.test_request(self, "GET", urlparse(confirm_url).path) + body = utils.check_ui_response_basic_info(resp, 200) + utils.check_val_is_in("accepted the terms and conditions", body) + + utils.check_val_equal(mocked_send.call_count, 3, + msg="Expected sent notification to user for an email confirmation of user added " + "to requested group, following terms and conditions acceptation.") + + # Log back to admin user to apply admin-only checks + utils.check_or_try_logout_user(self) + self.login_admin() + + # Check if user has been added to group successfully + utils.TestSetup.check_UserGroupMembership(self, override_group_name=group_with_terms_name) + path = "/groups/{grp}".format(grp=group_with_terms_name) + resp = utils.test_request(self, "GET", path, headers=self.json_headers, cookies=self.cookies) + body = utils.check_response_basic_info(resp, 200, expected_method="GET") + utils.check_val_equal(body["group"]["member_count"], 1) + utils.check_val_is_in(self.test_user_name, body["group"]["user_names"]) + + # UI checks: validates that both test tmp_tokens were deleted if '[pending]' is not displayed anymore + # validate that user is no longer pending in the edit group page + path = "/ui/groups/{}/default".format(group_with_terms_name) + resp = utils.test_request(self, "GET", path) + body = utils.check_ui_response_basic_info(resp) + utils.check_val_not_in("{} [pending]".format(self.test_user_name), body) + utils.check_val_is_in(self.test_user_name, body) + + # validate that group membership is no longer pending in the edit user page + path = "/ui/users/{}/default".format(self.test_user_name) + resp = utils.test_request(self, "GET", path) + body = utils.check_ui_response_basic_info(resp) + utils.check_val_not_in("{} [pending]".format(group_with_terms_name), body) + utils.check_val_is_in(group_with_terms_name, body) + + # validate that group membership is no longer pending in the user's account page + utils.check_or_try_logout_user(self) + utils.check_or_try_login_user(self, username=self.test_user_name, password=self.test_user_name, + use_ui_form_submit=True) + resp = utils.test_request(self, "GET", "/ui/users/current") + body = utils.check_ui_response_basic_info(resp, expected_title="Magpie") + utils.check_val_not_in("{} [pending]".format(group_with_terms_name), body) + utils.check_val_is_in(group_with_terms_name, body) + @runner.MAGPIE_TEST_UI @runner.MAGPIE_TEST_LOCAL @@ -380,7 +528,7 @@ def test_end2end_user_registration_procedure_email_confirmed_admin_approved(self from magpie.api.notifications import make_email_contents as real_contents # test contents with real generation with utils.mocked_get_settings(settings=settings): - with utils.mock_send_email() as email_contexts: + with utils.mock_send_email("magpie.api.management.register.register_utils.send_email") as email_contexts: _, wrapped_contents, mocked_send = email_contexts # submit the registration form to trigger the confirmation email @@ -525,7 +673,7 @@ def test_end2end_user_registration_procedure_email_confirmed_admin_declined(self from magpie.api.notifications import make_email_contents as real_contents # test contents with real generation with utils.mocked_get_settings(settings=settings): - with utils.mock_send_email() as email_contexts: + with utils.mock_send_email("magpie.api.management.register.register_utils.send_email") as email_contexts: _, wrapped_contents, mocked_send = email_contexts # submit the registration form to trigger the confirmation email @@ -588,7 +736,7 @@ def test_user_pending_status(self): utils.TestSetup.delete_TestUser(self) with utils.mocked_get_settings(): - with utils.mock_send_email(): + with utils.mock_send_email("magpie.api.management.register.register_utils.send_email"): data = { "user_name": test_register_user, "password": test_register_user, diff --git a/tests/utils.py b/tests/utils.py index 63ffae320..1aedd2dce 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -698,14 +698,15 @@ def no_email(*_, **__): def wrapped(*_, **__): # mock both direct reference if imported and places that use it to globally mock email notifications with wrapped_call("magpie.api.management.register.register_utils.send_email", side_effect=no_email): - with wrapped_call("magpie.api.notifications.send_email", side_effect=no_email): - return func(*_, **__) + with wrapped_call("magpie.api.management.user.user_utils.send_email", side_effect=no_email): + with wrapped_call("magpie.api.notifications.send_email", side_effect=no_email): + return func(*_, **__) return wrapped @contextlib.contextmanager -def mock_send_email(): +def mock_send_email(target): """ Context that mocks :func:`magpie.api.notifications.send_email` steps and returns email contents and call parameters. @@ -713,7 +714,7 @@ def mock_send_email(): .. code-block:: python - with mock_send_email() as email_mocks: + with mock_send_email("magpie.api.management.register.register_utils.send_email") as email_mocks: mocked_connect, mocked_contents, mocked_send = email_mocks # run tests with mock contexts # ex: mocked_contents.call_args == ... @@ -729,6 +730,8 @@ def mock_send_email(): The context references returned can be used to test each part of the email process, once during connection to the SMTP server, another for the email contents generation and finally, the simulated expedition of the generated email. Using those, it is possible to retrieve all calls and arguments that were passed to individual steps. + + :param target: Target function which will be replaced by a mocked send_email function. """ # Employ the function that builds the SMTP connection to raise an error midway to skip sending the email. @@ -771,8 +774,7 @@ class MockUser(object): # Run the test - full user registration procedure! with wrapped_call("magpie.api.notifications.get_smtp_server_connection", side_effect=fake_connect) as wrapped_conn: with wrapped_call("magpie.api.notifications.make_email_contents") as wrapped_contents: - with wrapped_call("magpie.api.management.register.register_utils.send_email", - side_effect=fake_email) as mocked_email: + with wrapped_call(target, side_effect=fake_email) as mocked_email: yield wrapped_conn, wrapped_contents, mocked_email @@ -1122,6 +1124,52 @@ def _is_logged_out(): raise Exception("logout did not succeed" + msg) +def create_or_assign_user_group_with_terms(test_case, # type: AnyMagpieTestCaseType + path, # type: Str + data, # type: Union[JSON, Str] + headers, # type: HeadersType + cookies, # type: CookiesType + accept_terms, # type: bool + expect_errors=False # type: bool + ): # type: (...) -> AnyResponseType + """ + Executes a request to create or assign a user to a group with terms and conditions, and accepts the terms and + conditions automatically if enabled. + Returns the input query's response. + """ + # custom app settings, smtp_host must exist when getting configs, but not used because email mocked + settings = {"magpie.smtp_host": "example.com", + # for testing, ignore any 'from' and 'password' arguments + # that could be found in the .ini file + "magpie.smtp_from": "", + "magpie.smtp_password": ""} + + with mocked_get_settings(settings=settings): + with mock_send_email("magpie.api.management.user.user_utils.send_email") as email_contexts: + _, wrapped_contents, mocked_send = email_contexts + query_resp = test_request(test_case, "POST", path, json=data, expect_errors=expect_errors, + headers=headers, cookies=cookies) + + if query_resp.status_code == 409 and expect_errors: + return query_resp + check_val_equal(mocked_send.call_count, 1, + msg="Expected sent notifications to user for an email confirmation " + "of terms and conditions.") + if accept_terms: + # Simulate user clicking the confirmation link in 'sent' email + # (external operation from Magpie) + confirm_url = wrapped_contents.call_args.args[-1].get("confirm_url") + resp = test_request(test_case, "GET", urlparse(confirm_url).path) + body = check_ui_response_basic_info(resp, 200) + check_val_is_in("accepted the terms and conditions", body) + + check_val_equal(mocked_send.call_count, 2, + msg="Expected sent notification to user for an email confirmation of " + "user added to requested group, following terms and conditions " + "acceptation.") + return query_resp + + def visual_repr(item): # type: (Any) -> Str try: @@ -2573,6 +2621,7 @@ def create_TestUser(test_case, # type: AnyMagpieTestCaseType override_cookies=null, # type: Optional[CookiesType] override_exist=False, # type: bool pending=False, # type: bool + accept_terms=True, # type: bool ): # type: (...) -> JSON """ Creates the test user. @@ -2591,18 +2640,38 @@ def create_TestUser(test_case, # type: AnyMagpieTestCaseType } data["email"] = override_email if override_email is not null else "{}@mail.com".format(data["user_name"]) usr_name = (data or {}).get("user_name") + grp_name = (data or {}).get("group_name") + headers = override_headers if override_headers is not null else test_case.json_headers + cookies = override_cookies if override_cookies is not null else test_case.cookies if usr_name: test_case.extra_user_names.add(usr_name) # indicate potential removal at a later point - override_headers = override_headers if override_headers is not null else test_case.json_headers - override_cookies = override_cookies if override_cookies is not null else test_case.cookies + + # Prepare request to create user path = "/register/users" if pending else "/users" - resp = test_request(app_or_url, "POST", path, json=data, expect_errors=override_exist, - headers=override_headers, cookies=override_cookies) - if resp.status_code == 409 and override_exist and usr_name: + + grp_terms = "" + if grp_name: + # Get group info + grp_info_path = "/groups/{grp}".format(grp=grp_name) + resp = test_request(app_or_url, "GET", grp_info_path, headers=headers, cookies=cookies) + body = check_response_basic_info(resp, 200, expected_method="GET") + check_val_is_in("group", body) + grp_terms = body["group"].get("terms") + + if grp_terms: + create_user_resp = create_or_assign_user_group_with_terms(test_case=test_case, path=path, data=data, + expect_errors=override_exist, + headers=headers, cookies=cookies, + accept_terms=accept_terms) + else: + create_user_resp = test_request(app_or_url, "POST", path, json=data, expect_errors=override_exist, + headers=headers, cookies=cookies) + + if create_user_resp.status_code == 409 and override_exist and usr_name: TestSetup.delete_TestUser(test_case, override_user_name=usr_name, - override_headers=override_headers, - override_cookies=override_cookies, + override_headers=headers, + override_cookies=cookies, pending=pending) return TestSetup.create_TestUser(test_case, override_data=override_data, @@ -2610,11 +2679,12 @@ def create_TestUser(test_case, # type: AnyMagpieTestCaseType override_email=override_email, override_password=override_password, override_group_name=override_group_name, - override_headers=override_headers, - override_cookies=override_cookies, + override_headers=headers, + override_cookies=cookies, override_exist=False, - pending=pending) - return check_response_basic_info(resp, 201, expected_method="POST") + pending=pending, + accept_terms=accept_terms) + return check_response_basic_info(create_user_resp, 201, expected_method="POST") @staticmethod def delete_TestUser(test_case, # type: AnyMagpieTestCaseType @@ -2760,29 +2830,52 @@ def assign_TestUserGroup(test_case, # type: AnyMagpieTestCaseTyp override_group_name=null, # type: Optional[Str] override_headers=null, # type: Optional[HeadersType] override_cookies=null, # type: Optional[CookiesType] + accept_terms=True, # type: bool ): # type: (...) -> None """ Ensures that the test user is a member of the test group, adding him to the group as needed. + Also works for a group that has terms and conditions, either completing the T&C confirmation if the + :paramref:`accept_terms` parameter is enabled, or leaving the user as pending. :raises AssertionError: if any request response does not match successful validation or assignation to group. """ app_or_url = get_app_or_url(test_case) usr_name = override_user_name if override_user_name is not null else test_case.test_user_name grp_name = override_group_name if override_group_name is not null else test_case.test_group_name + headers = override_headers if override_headers is not null else test_case.json_headers + cookies = override_cookies if override_cookies is not null else test_case.cookies + + # Check if the user is already in the group path = "/groups/{grp}/users".format(grp=grp_name) - resp = test_request(app_or_url, "GET", path, - headers=override_headers if override_headers is not null else test_case.json_headers, - cookies=override_cookies if override_cookies is not null else test_case.cookies) + resp = test_request(app_or_url, "GET", path, headers=headers, cookies=cookies) body = check_response_basic_info(resp, 200, expected_method="GET") + + is_member = True # Default expected test result + if usr_name not in body["user_names"]: + # Get group info + path = "/groups/{grp}".format(grp=grp_name) + resp = test_request(app_or_url, "GET", path, headers=headers, cookies=cookies) + body = check_response_basic_info(resp, 200, expected_method="GET") + check_val_is_in("group", body) + group_info = body["group"] + + # Prepare request to add user to group with terms path = "/users/{usr}/groups".format(usr=usr_name) data = {"group_name": grp_name} - resp = test_request(app_or_url, "POST", path, json=data, - headers=override_headers if override_headers is not null else test_case.json_headers, - cookies=override_cookies if override_cookies is not null else test_case.cookies) - check_response_basic_info(resp, 201, expected_method="POST") + + if group_info.get("terms"): + is_member = accept_terms # Expected test result, should be false if terms are not accepted + assign_user_resp = create_or_assign_user_group_with_terms(test_case=test_case, path=path, + data=data, headers=headers, cookies=cookies, + accept_terms=accept_terms) + check_response_basic_info(assign_user_resp, 202, expected_method="POST") + else: + resp = test_request(app_or_url, "POST", path, json=data, headers=headers, cookies=cookies) + # User should have been assigned to the group directly if no terms and conditions were found. + check_response_basic_info(resp, 201, expected_method="POST") TestSetup.check_UserGroupMembership(test_case, override_user_name=usr_name, override_group_name=grp_name, - override_headers=override_headers, override_cookies=override_cookies) + override_headers=headers, override_cookies=cookies, member=is_member) @staticmethod def get_RegisteredGroupsList(test_case, only_discoverable=False, override_headers=null, override_cookies=null): @@ -2817,6 +2910,7 @@ def check_NonExistingTestGroup(test_case, override_group_name=null, override_hea @staticmethod def create_TestGroup(test_case, # type: AnyMagpieTestCaseType override_group_name=null, # type: Optional[Str] + override_terms=null, # type: Optional[Str] override_discoverable=null, # type: Optional[bool] override_data=null, # type: Optional[JSON] override_headers=null, # type: Optional[HeadersType] @@ -2835,6 +2929,8 @@ def create_TestGroup(test_case, # type: AnyMagpieTestCaseTyp # only add 'discoverable' if explicitly provided here to preserve original behaviour of 'no value provided' if override_discoverable is not null: data["discoverable"] = override_discoverable + if override_terms is not null: + data["terms"] = override_terms grp_name = (data or {}).get("group_name") if grp_name: test_case.extra_group_names.add(grp_name) # indicate potential removal at a later point