diff --git a/atst/app.py b/atst/app.py index 163a7bae..4f7bdbff 100644 --- a/atst/app.py +++ b/atst/app.py @@ -18,6 +18,7 @@ from atst.routes.applications import applications_bp from atst.routes.dev import bp as dev_routes from atst.routes.users import bp as user_routes from atst.routes.errors import make_error_pages +from atst.routes.ccpo import bp as ccpo_routes from atst.domain.authnid.crl import CRLCache, NoOpCRLCache from atst.domain.auth import apply_authentication from atst.domain.authz import Authorization @@ -78,6 +79,7 @@ def make_app(config): app.register_blueprint(task_orders_bp) app.register_blueprint(applications_bp) app.register_blueprint(user_routes) + app.register_blueprint(ccpo_routes) if ENV != "prod": app.register_blueprint(dev_routes) diff --git a/atst/domain/permission_sets.py b/atst/domain/permission_sets.py index ab55feac..727b34df 100644 --- a/atst/domain/permission_sets.py +++ b/atst/domain/permission_sets.py @@ -64,6 +64,7 @@ ATAT_PERMISSION_SETS = [ "description": "", "permissions": [ Permissions.VIEW_CCPO_USER, + Permissions.CREATE_CCPO_USER, Permissions.EDIT_CCPO_USER, Permissions.DELETE_CCPO_USER, ], diff --git a/atst/domain/users.py b/atst/domain/users.py index a75a1f28..03a8044e 100644 --- a/atst/domain/users.py +++ b/atst/domain/users.py @@ -87,6 +87,20 @@ class Users(object): return user + @classmethod + def give_ccpo_perms(cls, user): + user.permission_sets = PermissionSets.get_all() + db.session.add(user) + db.session.commit() + return user + + @classmethod + def revoke_ccpo_perms(cls, user): + user.permission_sets = [] + db.session.add(user) + db.session.commit() + return user + @classmethod def update_last_login(cls, user): user.last_login = datetime.now() diff --git a/atst/forms/ccpo_user.py b/atst/forms/ccpo_user.py new file mode 100644 index 00000000..e9e07ec2 --- /dev/null +++ b/atst/forms/ccpo_user.py @@ -0,0 +1,13 @@ +from flask_wtf import FlaskForm +from wtforms.validators import Required, Length +from wtforms.fields import StringField + +from atst.forms.validators import IsNumber +from atst.utils.localization import translate + + +class CCPOUserForm(FlaskForm): + dod_id = StringField( + translate("forms.new_member.dod_id_label"), + validators=[Required(), Length(min=10, max=10), IsNumber()], + ) diff --git a/atst/models/permissions.py b/atst/models/permissions.py index a7d735b8..f9e73046 100644 --- a/atst/models/permissions.py +++ b/atst/models/permissions.py @@ -2,6 +2,7 @@ class Permissions(object): # ccpo permissions VIEW_AUDIT_LOG = "view_audit_log" VIEW_CCPO_USER = "view_ccpo_user" + CREATE_CCPO_USER = "create_ccpo_user" EDIT_CCPO_USER = "edit_ccpo_user" DELETE_CCPO_USER = "delete_ccpo_user" diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py index 0729d878..eaa447be 100644 --- a/atst/routes/__init__.py +++ b/atst/routes/__init__.py @@ -18,12 +18,7 @@ from werkzeug.exceptions import NotFound from atst.domain.users import Users from atst.domain.authnid import AuthenticationContext -from atst.domain.audit_log import AuditLog from atst.domain.auth import logout as _logout -from atst.domain.common import Paginator -from atst.domain.portfolios import Portfolios -from atst.domain.authz.decorator import user_can_access_decorator as user_can -from atst.models.permissions import Permissions from atst.utils.flash import formatted_flash as flash @@ -124,21 +119,6 @@ def logout(): return response -@bp.route("/activity-history") -@user_can(Permissions.VIEW_AUDIT_LOG, message="view activity log") -def activity_history(): - pagination_opts = Paginator.get_pagination_opts(request) - audit_events = AuditLog.get_all_events(pagination_opts) - return render_template("audit_log/audit_log.html", audit_events=audit_events) - - -@bp.route("/ccpo-users") -@user_can(Permissions.VIEW_CCPO_USER, message="view ccpo users") -def ccpo_users(): - users = Users.get_ccpo_users() - return render_template("ccpo/users.html", users=users) - - @bp.route("/about") def about(): return render_template("about.html") diff --git a/atst/routes/ccpo.py b/atst/routes/ccpo.py new file mode 100644 index 00000000..c2846aa1 --- /dev/null +++ b/atst/routes/ccpo.py @@ -0,0 +1,58 @@ +from flask import Blueprint, render_template, redirect, url_for, request +from atst.domain.users import Users +from atst.domain.audit_log import AuditLog +from atst.domain.common import Paginator +from atst.domain.exceptions import NotFoundError +from atst.domain.authz.decorator import user_can_access_decorator as user_can +from atst.forms.ccpo_user import CCPOUserForm +from atst.models.permissions import Permissions +from atst.utils.context_processors import atat as atat_context_processor +from atst.utils.flash import formatted_flash as flash + + +bp = Blueprint("ccpo", __name__) +bp.context_processor(atat_context_processor) + + +@bp.route("/activity-history") +@user_can(Permissions.VIEW_AUDIT_LOG, message="view activity log") +def activity_history(): + pagination_opts = Paginator.get_pagination_opts(request) + audit_events = AuditLog.get_all_events(pagination_opts) + return render_template("audit_log/audit_log.html", audit_events=audit_events) + + +@bp.route("/ccpo-users") +@user_can(Permissions.VIEW_CCPO_USER, message="view ccpo users") +def users(): + users = Users.get_ccpo_users() + return render_template("ccpo/users.html", users=users) + + +@bp.route("/ccpo-users/new") +@user_can(Permissions.CREATE_CCPO_USER, message="create ccpo user") +def add_new_user(): + form = CCPOUserForm() + return render_template("ccpo/add_user.html", form=form) + + +@bp.route("/ccpo-users/new", methods=["POST"]) +@user_can(Permissions.CREATE_CCPO_USER, message="create ccpo user") +def submit_new_user(): + try: + new_user = Users.get_by_dod_id(request.form["dod_id"]) + form = CCPOUserForm(obj=new_user) + except NotFoundError: + flash("ccpo_user_not_found") + return redirect(url_for("ccpo.users")) + + return render_template("ccpo/confirm_user.html", new_user=new_user, form=form) + + +@bp.route("/ccpo-users/confirm-new", methods=["POST"]) +@user_can(Permissions.CREATE_CCPO_USER, message="create ccpo user") +def confirm_new_user(): + user = Users.get_by_dod_id(request.form["dod_id"]) + Users.give_ccpo_perms(user) + flash("ccpo_user_added", user_name=user.full_name) + return redirect(url_for("ccpo.users")) diff --git a/atst/utils/context_processors.py b/atst/utils/context_processors.py index 1e8d619a..7d39b367 100644 --- a/atst/utils/context_processors.py +++ b/atst/utils/context_processors.py @@ -119,3 +119,7 @@ def portfolio(): "funding_end_date": funding_end_date, "funded": funded, } + + +def atat(): + return {"permissions": Permissions, "user_can": user_can_view} diff --git a/atst/utils/flash.py b/atst/utils/flash.py index 0ea62028..e4f852ea 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -30,6 +30,16 @@ MESSAGES = { "message_template": "You have successfully deleted {{ user_name }} from {{ application_name }}", "category": "success", }, + "ccpo_user_added": { + "title_template": translate("flash.success"), + "message_template": "You have successfully given {{ user_name }} CCPO permissions.", + "category": "success", + }, + "ccpo_user_not_found": { + "title_template": translate("ccpo.form.user_not_found_title"), + "message_template": translate("ccpo.form.user_not_found_text"), + "category": "info", + }, "environment_added": { "title_template": translate("flash.success"), "message_template": """ diff --git a/templates/audit_log/audit_log.html b/templates/audit_log/audit_log.html index 803d70ad..57a6656c 100644 --- a/templates/audit_log/audit_log.html +++ b/templates/audit_log/audit_log.html @@ -4,6 +4,6 @@ {% block content %}
{% include "fragments/audit_events_log.html" %} - {{ Pagination(audit_events, url_for('atst.activity_history'))}} + {{ Pagination(audit_events, url_for('ccpo.activity_history'))}}
{% endblock %} diff --git a/templates/ccpo/add_user.html b/templates/ccpo/add_user.html new file mode 100644 index 00000000..b3b9b0f5 --- /dev/null +++ b/templates/ccpo/add_user.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% from "components/text_input.html" import TextInput %} + +{% block content %} +
+ {{ form.csrf_token }} +

{{ "ccpo.form.add_user_title" | translate }}

+
+
+ {{ TextInput(form.dod_id, validation='dodId') }} +
+ +
+
+{% endblock %} diff --git a/templates/ccpo/confirm_user.html b/templates/ccpo/confirm_user.html new file mode 100644 index 00000000..4d054240 --- /dev/null +++ b/templates/ccpo/confirm_user.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% from "components/text_input.html" import TextInput %} + +{% block content %} + {% if new_user %} +

{{ 'ccpo.form.confirm_user_title' | translate }}

+
+ {{ form.csrf_token }} + +
+

+ {{ "ccpo.form.confirm_user_text" | translate }} +

+

+ {{ new_user.full_name }} +

+

+ {{ new_user.email }} +

+
+
+ + + {{ "common.cancel" | translate }} + +
+
+ {% endif %} +{% endblock %} diff --git a/templates/ccpo/users.html b/templates/ccpo/users.html index f3d545de..bc77a83f 100644 --- a/templates/ccpo/users.html +++ b/templates/ccpo/users.html @@ -1,27 +1,39 @@ {% extends "base.html" %} +{% from "components/icon.html" import Icon %} + {% block content %} -
-
- CCPO Users -
- - - - - - - - - - {% for user in users %} +
+
+ {{ "ccpo.users_title" | translate }} +
+ + {% include "fragments/flash.html" %} + +
NameEmailDoD ID
+ - - - + + + - {% endfor %} - -
{{ user.full_name }}{{ user.email }}{{ user.dod_id }}{{ "common.name" | translate }}{{ "common.email" | translate }}{{ "common.dod_id" | translate }}
-
+ + + {% for user in users %} + + {{ user.full_name }} + {{ user.email }} + {{ user.dod_id }} + + {% endfor %} + + + + + {% if user_can(permissions.CREATE_CCPO_USER) %} + + {{ "ccpo.add_user" | translate }} {{ Icon("plus") }} + + {% endif %} + {% endblock %} diff --git a/tests/domain/test_users.py b/tests/domain/test_users.py index fda69a12..b5a24058 100644 --- a/tests/domain/test_users.py +++ b/tests/domain/test_users.py @@ -85,3 +85,17 @@ def test_get_ccpo_users(): assert ccpo_1 in ccpo_users assert ccpo_2 in ccpo_users assert rando not in ccpo_users + + +def test_give_ccpo_perms(): + rando = UserFactory.create() + Users.give_ccpo_perms(rando) + ccpo_users = Users.get_ccpo_users() + assert rando in ccpo_users + + +def test_revoke_ccpo_perms(): + ccpo = UserFactory.create_ccpo() + Users.revoke_ccpo_perms(ccpo) + ccpo_users = Users.get_ccpo_users() + assert ccpo not in ccpo_users diff --git a/tests/routes/test_ccpo.py b/tests/routes/test_ccpo.py new file mode 100644 index 00000000..618eb9b9 --- /dev/null +++ b/tests/routes/test_ccpo.py @@ -0,0 +1,54 @@ +from flask import url_for + +from atst.utils.localization import translate + +from tests.factories import UserFactory + + +def test_ccpo_users(user_session, client): + ccpo = UserFactory.create_ccpo() + user_session(ccpo) + response = client.get(url_for("ccpo.users")) + assert ccpo.email in response.data.decode() + + +def test_submit_new_user(user_session, client): + ccpo = UserFactory.create_ccpo() + new_user = UserFactory.create() + random_dod_id = "1234567890" + user_session(ccpo) + + # give new_user CCPO permissions + response = client.post( + url_for("ccpo.submit_new_user"), data={"dod_id": new_user.dod_id} + ) + assert new_user.email in response.data.decode() + + # give person without ATAT account CCPO permissions + response = client.post( + url_for("ccpo.submit_new_user"), data={"dod_id": random_dod_id} + ) + assert url_for("ccpo.users") in response.location + + +def test_confirm_new_user(user_session, client): + ccpo = UserFactory.create_ccpo() + new_user = UserFactory.create() + random_dod_id = "1234567890" + user_session(ccpo) + + # give new_user CCPO permissions + response = client.post( + url_for("ccpo.confirm_new_user"), + data={"dod_id": new_user.dod_id}, + follow_redirects=True, + ) + assert new_user.dod_id in response.data.decode() + + # give person with out ATAT account CCPO permissions + response = client.post( + url_for("ccpo.confirm_new_user"), + data={"dod_id": random_dod_id}, + follow_redirects=True, + ) + assert random_dod_id not in response.data.decode() diff --git a/tests/test_access.py b/tests/test_access.py index 141fed18..f459ea06 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -110,26 +110,57 @@ def post_url_assert_status(client, user_session): return _get_url_assert_status -# atst.activity_history +# ccpo.activity_history def test_atst_activity_history_access(get_url_assert_status): ccpo = user_with(PermissionSets.VIEW_AUDIT_LOG) rando = user_with() - url = url_for("atst.activity_history") + url = url_for("ccpo.activity_history") get_url_assert_status(ccpo, url, 200) get_url_assert_status(rando, url, 404) -# atst.ccpo_users -def test_atst_ccpo_users_access(get_url_assert_status): +# ccpo.users +def test_ccpo_users_access(get_url_assert_status): ccpo = user_with(PermissionSets.MANAGE_CCPO_USERS) rando = user_with() - url = url_for("atst.ccpo_users") + url = url_for("ccpo.users") get_url_assert_status(ccpo, url, 200) get_url_assert_status(rando, url, 404) +# ccpo.add_new_user +def test_ccpo_add_new_user_access(get_url_assert_status): + ccpo = user_with(PermissionSets.MANAGE_CCPO_USERS) + rando = user_with() + + url = url_for("ccpo.add_new_user") + get_url_assert_status(ccpo, url, 200) + get_url_assert_status(rando, url, 404) + + +# ccpo.submit_new_user +def test_ccpo_submit_new_user_access(post_url_assert_status): + ccpo = user_with(PermissionSets.MANAGE_CCPO_USERS) + rando = user_with() + + url = url_for("ccpo.submit_new_user") + post_url_assert_status(ccpo, url, 302, data={"dod_id": "1234567890"}) + post_url_assert_status(rando, url, 404, data={"dod_id": "1234567890"}) + + +# ccpo.confirm_new_user +def test_ccpo_confirm_new_user_access(post_url_assert_status): + ccpo = user_with(PermissionSets.MANAGE_CCPO_USERS) + rando = user_with() + user = UserFactory.create() + + url = url_for("ccpo.confirm_new_user") + post_url_assert_status(ccpo, url, 302, data={"dod_id": user.dod_id}) + post_url_assert_status(rando, url, 404, data={"dod_id": user.dod_id}) + + # applications.access_environment def test_applications_access_environment_access(get_url_assert_status): dev = UserFactory.create() diff --git a/translations.yaml b/translations.yaml index fd5563c1..fd97ae8f 100644 --- a/translations.yaml +++ b/translations.yaml @@ -26,6 +26,17 @@ home: applications_descrip: ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod reports_descrip: enim ad minim veniam, quis nostrud exercitation ullamco admin_descrip: aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat +ccpo: + users_title: CCPO Users + add_user: Add new CCPO user + form: + add_user_title: Add new CCPO user + confirm_user_title: Confirm new CCPO user + confirm_user_text: Please confirm that the user details below match the user being given CCPO permissions. + confirm_button: Confirm and Add User + return_link: Return to list of CCPO users + user_not_found_title: User not found + user_not_found_text: To add someone as a CCPO user, they must already have an ATAT account. common: cancel: Cancel close: Close @@ -34,8 +45,12 @@ common: delete: Delete deactivate: Deactivate delete_confirm: 'Please type the word {word} to confirm:' + dod_id: DoD ID edit: Edit + email: Email members: Members + name: Name + next: Next 'yes': 'Yes' 'no': 'No' response_label: Response required @@ -45,7 +60,6 @@ common: resource_names: environments: Environments choose_role: Choose a role - name: Name components: date_selector: day: Day