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 %}
+
+{% 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 }}
+
+ {% 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
-
-
-
-
- Name |
- Email |
- DoD ID |
-
-
-
- {% for user in users %}
+
+
+ {{ "ccpo.users_title" | translate }}
+
+
+ {% include "fragments/flash.html" %}
+
+
+
- {{ user.full_name }} |
- {{ user.email }} |
- {{ user.dod_id }} |
+ {{ "common.name" | translate }} |
+ {{ "common.email" | translate }} |
+ {{ "common.dod_id" | translate }} |
- {% endfor %}
-
-
-
+
+
+ {% 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