diff --git a/atst/app.py b/atst/app.py index 13c801f8..06836f10 100644 --- a/atst/app.py +++ b/atst/app.py @@ -14,6 +14,7 @@ from atst.filters import register_filters from atst.routes import bp from atst.routes.portfolios import portfolios_bp as portfolio_routes from atst.routes.task_orders import task_orders_bp +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 @@ -71,6 +72,7 @@ def make_app(config): app.register_blueprint(bp) app.register_blueprint(portfolio_routes) app.register_blueprint(task_orders_bp) + app.register_blueprint(applications_bp) app.register_blueprint(user_routes) if ENV != "prod": diff --git a/atst/domain/environment_roles.py b/atst/domain/environment_roles.py index ab5a082c..99728467 100644 --- a/atst/domain/environment_roles.py +++ b/atst/domain/environment_roles.py @@ -1,9 +1,7 @@ from flask import current_app as app -from sqlalchemy.orm.exc import NoResultFound from atst.database import db -from atst.domain.exceptions import NotFoundError -from atst.models import EnvironmentRole, Environment, Application +from atst.models import EnvironmentRole class EnvironmentRoles(object): @@ -15,23 +13,6 @@ class EnvironmentRoles(object): app.csp.cloud.create_role(env_role) return env_role - @classmethod - def get_for_portfolio(cls, user_id, environment_id, portfolio_id): - try: - return ( - db.session.query(EnvironmentRole) - .join(Environment, EnvironmentRole.environment_id == Environment.id) - .join(Application, Environment.application_id == Application.id) - .filter( - EnvironmentRole.user_id == user_id, - EnvironmentRole.environment_id == environment_id, - Application.portfolio_id == portfolio_id, - ) - .one() - ) - except NoResultFound: - raise NotFoundError("environment_role") - @classmethod def get(cls, user_id, environment_id): existing_env_role = ( diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py index b4543d07..f923517f 100644 --- a/atst/routes/__init__.py +++ b/atst/routes/__init__.py @@ -71,19 +71,19 @@ def home(): ] if is_portfolio_owner: - return redirect( - url_for("portfolios.portfolio_reports", portfolio_id=portfolio_id) - ) + return redirect(url_for("portfolios.reports", portfolio_id=portfolio_id)) else: return redirect( - url_for("portfolios.portfolio_applications", portfolio_id=portfolio_id) + url_for( + "applications.portfolio_applications", portfolio_id=portfolio_id + ) ) else: portfolios = Portfolios.for_user(g.current_user) first_portfolio = sorted(portfolios, key=lambda portfolio: portfolio.name)[0] return redirect( url_for( - "portfolios.portfolio_applications", portfolio_id=first_portfolio.id + "applications.portfolio_applications", portfolio_id=first_portfolio.id ) ) diff --git a/atst/routes/applications/__init__.py b/atst/routes/applications/__init__.py new file mode 100644 index 00000000..60f7f495 --- /dev/null +++ b/atst/routes/applications/__init__.py @@ -0,0 +1,32 @@ +from flask import Blueprint, current_app as app, g, redirect, url_for + +applications_bp = Blueprint("applications", __name__) + +from . import index +from . import new +from . import settings +from . import team +from atst.domain.environment_roles import EnvironmentRoles +from atst.domain.exceptions import UnauthorizedError +from atst.domain.authz.decorator import user_can_access_decorator as user_can +from atst.models.permissions import Permissions +from atst.utils.context_processors import portfolio as portfolio_context_processor + +applications_bp.context_processor(portfolio_context_processor) + + +def wrap_environment_role_lookup(user, environment_id=None, **kwargs): + env_role = EnvironmentRoles.get(user.id, environment_id) + if not env_role: + raise UnauthorizedError(user, "access environment {}".format(environment_id)) + + return True + + +@applications_bp.route("/environments//access") +@user_can(None, override=wrap_environment_role_lookup, message="access environment") +def access_environment(environment_id): + env_role = EnvironmentRoles.get(g.current_user.id, environment_id) + token = app.csp.cloud.get_access_token(env_role) + + return redirect(url_for("atst.csp_environment_access", token=token)) diff --git a/atst/routes/applications/index.py b/atst/routes/applications/index.py new file mode 100644 index 00000000..d61417f4 --- /dev/null +++ b/atst/routes/applications/index.py @@ -0,0 +1,11 @@ +from flask import render_template + +from . import applications_bp +from atst.domain.authz.decorator import user_can_access_decorator as user_can +from atst.models.permissions import Permissions + + +@applications_bp.route("/portfolios//applications") +@user_can(Permissions.VIEW_APPLICATION, message="view portfolio applications") +def portfolio_applications(portfolio_id): + return render_template("portfolios/applications/index.html") diff --git a/atst/routes/applications/new.py b/atst/routes/applications/new.py new file mode 100644 index 00000000..070aa940 --- /dev/null +++ b/atst/routes/applications/new.py @@ -0,0 +1,36 @@ +from flask import redirect, render_template, request as http_request, url_for + +from . import applications_bp +from atst.domain.applications import Applications +from atst.domain.portfolios import Portfolios +from atst.forms.application import NewApplicationForm +from atst.domain.authz.decorator import user_can_access_decorator as user_can +from atst.models.permissions import Permissions + + +@applications_bp.route("/portfolios//applications/new") +@user_can(Permissions.CREATE_APPLICATION, message="view create new application form") +def new(portfolio_id): + form = NewApplicationForm() + return render_template("portfolios/applications/new.html", form=form) + + +@applications_bp.route("/portfolios//applications", methods=["POST"]) +@user_can(Permissions.CREATE_APPLICATION, message="create new application") +def create(portfolio_id): + portfolio = Portfolios.get_for_update(portfolio_id) + form = NewApplicationForm(http_request.form) + + if form.validate(): + application_data = form.data + Applications.create( + portfolio, + application_data["name"], + application_data["description"], + application_data["environment_names"], + ) + return redirect( + url_for("applications.portfolio_applications", portfolio_id=portfolio_id) + ) + else: + return render_template("portfolios/applications/new.html", form=form) diff --git a/atst/routes/applications/settings.py b/atst/routes/applications/settings.py new file mode 100644 index 00000000..8ef290d6 --- /dev/null +++ b/atst/routes/applications/settings.py @@ -0,0 +1,76 @@ +from flask import redirect, render_template, request as http_request, url_for + +from . import applications_bp +from atst.domain.environment_roles import EnvironmentRoles +from atst.domain.applications import Applications +from atst.forms.application import ApplicationForm +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 + + +def get_environments_obj_for_app(application): + environments_obj = {} + + for env in application.environments: + environments_obj[env.name] = [] + for user in env.users: + env_role = EnvironmentRoles.get(user.id, env.id) + environments_obj[env.name].append( + {"name": user.full_name, "role": env_role.displayname} + ) + + return environments_obj + + +@applications_bp.route("/applications//settings") +@user_can(Permissions.VIEW_APPLICATION, message="view application edit form") +def settings(application_id): + application = Applications.get(application_id) + form = ApplicationForm(name=application.name, description=application.description) + + return render_template( + "portfolios/applications/edit.html", + application=application, + form=form, + environments_obj=get_environments_obj_for_app(application=application), + ) + + +@applications_bp.route("/applications//edit", methods=["POST"]) +@user_can(Permissions.EDIT_APPLICATION, message="update application") +def update(application_id): + application = Applications.get(application_id) + form = ApplicationForm(http_request.form) + if form.validate(): + application_data = form.data + Applications.update(application, application_data) + + return redirect( + url_for( + "applications.portfolio_applications", + portfolio_id=application.portfolio_id, + ) + ) + else: + return render_template( + "portfolios/applications/edit.html", + application=application, + form=form, + environments_obj=get_environments_obj_for_app(application=application), + ) + + +@applications_bp.route("/applications//delete", methods=["POST"]) +@user_can(Permissions.DELETE_APPLICATION, message="delete application") +def delete(application_id): + application = Applications.get(application_id) + Applications.delete(application) + + flash("application_deleted", application_name=application.name) + + return redirect( + url_for( + "applications.portfolio_applications", portfolio_id=application.portfolio_id + ) + ) diff --git a/atst/routes/applications/team.py b/atst/routes/applications/team.py new file mode 100644 index 00000000..c9e57296 --- /dev/null +++ b/atst/routes/applications/team.py @@ -0,0 +1,49 @@ +from flask import render_template + + +from . import applications_bp +from atst.domain.environments import Environments +from atst.domain.applications import Applications +from atst.domain.authz.decorator import user_can_access_decorator as user_can +from atst.models.permissions import Permissions +from atst.domain.permission_sets import PermissionSets +from atst.utils.localization import translate + + +def permission_str(member, edit_perm_set): + if member.has_permission_set(edit_perm_set): + return translate("portfolios.members.permissions.edit_access") + else: + return translate("portfolios.members.permissions.view_only") + + +@applications_bp.route("/applications//team") +@user_can(Permissions.VIEW_APPLICATION, message="view portfolio applications") +def team(application_id): + application = Applications.get(resource_id=application_id) + + environment_users = {} + for member in application.members: + user_id = member.user.id + environment_users[user_id] = { + "permissions": { + "delete_access": permission_str( + member, PermissionSets.DELETE_APPLICATION_ENVIRONMENTS + ), + "environment_management": permission_str( + member, PermissionSets.EDIT_APPLICATION_ENVIRONMENTS + ), + "team_management": permission_str( + member, PermissionSets.EDIT_APPLICATION_TEAM + ), + }, + "environments": Environments.for_user( + user=member.user, application=application + ), + } + + return render_template( + "portfolios/applications/team.html", + application=application, + environment_users=environment_users, + ) diff --git a/atst/routes/portfolios/__init__.py b/atst/routes/portfolios/__init__.py index 4bac8b58..979f9ea7 100644 --- a/atst/routes/portfolios/__init__.py +++ b/atst/routes/portfolios/__init__.py @@ -4,49 +4,10 @@ from operator import attrgetter portfolios_bp = Blueprint("portfolios", __name__) from . import index -from . import applications from . import members from . import invitations -from . import task_orders -from atst.domain.exceptions import UnauthorizedError -from atst.domain.portfolios import Portfolios -from atst.domain.authz import Authorization -from atst.models.permissions import Permissions +from . import admin +from atst.utils.context_processors import portfolio as portfolio_context_processor -@portfolios_bp.context_processor -def portfolio(): - portfolio = None - if "portfolio_id" in http_request.view_args: - portfolio = Portfolios.get( - g.current_user, http_request.view_args["portfolio_id"] - ) - - def user_can(permission): - if portfolio: - return Authorization.has_portfolio_permission( - g.current_user, portfolio, permission - ) - return False - - if not portfolio is None: - active_task_orders = [ - task_order for task_order in portfolio.task_orders if task_order.is_active - ] - funding_end_date = ( - sorted(active_task_orders, key=attrgetter("end_date"))[-1].end_date - if active_task_orders - else None - ) - funded = len(active_task_orders) > 1 - else: - funding_end_date = None - funded = None - - return { - "portfolio": portfolio, - "permissions": Permissions, - "user_can": user_can, - "funding_end_date": funding_end_date, - "funded": funded, - } +portfolios_bp.context_processor(portfolio_context_processor) diff --git a/atst/routes/portfolios/admin.py b/atst/routes/portfolios/admin.py new file mode 100644 index 00000000..5ab4c1d3 --- /dev/null +++ b/atst/routes/portfolios/admin.py @@ -0,0 +1,192 @@ +from flask import render_template, request as http_request, g, redirect, url_for + +from . import portfolios_bp +from atst.domain.portfolios import Portfolios +from atst.domain.portfolio_roles import PortfolioRoles +from atst.domain.permission_sets import PermissionSets +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.forms.portfolio import PortfolioForm +import atst.forms.portfolio_member as member_forms +from atst.models.permissions import Permissions +from atst.domain.authz.decorator import user_can_access_decorator as user_can +from atst.utils.flash import formatted_flash as flash +from atst.domain.exceptions import UnauthorizedError + + +def permission_str(member, edit_perm_set, view_perm_set): + if member.has_permission_set(edit_perm_set): + return edit_perm_set + else: + return view_perm_set + + +def serialize_member_form_data(member): + return { + "member": member.user.full_name, + "user_id": member.user_id, + "perms_app_mgmt": permission_str( + member, + PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT, + PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT, + ), + "perms_funding": permission_str( + member, + PermissionSets.EDIT_PORTFOLIO_FUNDING, + PermissionSets.VIEW_PORTFOLIO_FUNDING, + ), + "perms_reporting": permission_str( + member, + PermissionSets.EDIT_PORTFOLIO_REPORTS, + PermissionSets.VIEW_PORTFOLIO_REPORTS, + ), + "perms_portfolio_mgmt": permission_str( + member, + PermissionSets.EDIT_PORTFOLIO_ADMIN, + PermissionSets.VIEW_PORTFOLIO_ADMIN, + ), + } + + +def get_members_data(portfolio): + members = [serialize_member_form_data(member) for member in portfolio.members] + for member in members: + if member["user_id"] == portfolio.owner.id: + ppoc = member + members.remove(member) + members.insert(0, ppoc) + return members + + +def render_admin_page(portfolio, form=None): + pagination_opts = Paginator.get_pagination_opts(http_request) + audit_events = AuditLog.get_portfolio_events(portfolio, pagination_opts) + members_data = get_members_data(portfolio) + portfolio_form = PortfolioForm(data={"name": portfolio.name}) + member_perms_form = member_forms.MembersPermissionsForm( + data={"members_permissions": members_data} + ) + + assign_ppoc_form = member_forms.AssignPPOCForm() + assign_ppoc_form.user_id.choices += [ + (user.id, user.full_name) for user in portfolio.users if user != portfolio.owner + ] + + return render_template( + "portfolios/admin.html", + form=form, + portfolio_form=portfolio_form, + member_perms_form=member_perms_form, + member_form=member_forms.NewForm(), + assign_ppoc_form=assign_ppoc_form, + portfolio=portfolio, + audit_events=audit_events, + user=g.current_user, + members_data=members_data, + ) + + +@portfolios_bp.route("/portfolios//admin") +@user_can(Permissions.VIEW_PORTFOLIO_ADMIN, message="view portfolio admin page") +def admin(portfolio_id): + portfolio = Portfolios.get_for_update(portfolio_id) + return render_admin_page(portfolio) + + +@portfolios_bp.route("/portfolios//admin", methods=["POST"]) +@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="view portfolio admin page") +def edit_members(portfolio_id): + portfolio = Portfolios.get_for_update(portfolio_id) + member_perms_form = member_forms.MembersPermissionsForm(http_request.form) + + if member_perms_form.validate(): + for subform in member_perms_form.members_permissions: + user_id = subform.user_id.data + member = Users.get(user_id=user_id) + if member is not portfolio.owner: + new_perm_set = subform.data["permission_sets"] + portfolio_role = PortfolioRoles.get(portfolio.id, user_id) + PortfolioRoles.update(portfolio_role, new_perm_set) + + flash("update_portfolio_members", portfolio=portfolio) + + return redirect( + url_for( + "portfolios.admin", + portfolio_id=portfolio_id, + fragment="portfolio-members", + _anchor="portfolio-members", + ) + ) + else: + return render_admin_page(portfolio) + + +@portfolios_bp.route("/portfolios//update_ppoc", methods=["POST"]) +@user_can(Permissions.EDIT_PORTFOLIO_POC, message="update portfolio ppoc") +def update_ppoc(portfolio_id): + user_id = http_request.form.get("user_id") + + portfolio = Portfolios.get(g.current_user, portfolio_id) + new_ppoc = Users.get(user_id) + + if new_ppoc not in portfolio.users: + raise NotFoundError("user not in portfolio") + + portfolio_role = PortfolioRoles.get(portfolio_id=portfolio_id, user_id=user_id) + PortfolioRoles.make_ppoc(portfolio_role=portfolio_role) + + flash("primary_point_of_contact_changed", ppoc_name=new_ppoc.full_name) + + return redirect( + url_for( + "portfolios.admin", + portfolio_id=portfolio.id, + fragment="primary-point-of-contact", + _anchor="primary-point-of-contact", + ) + ) + + +@portfolios_bp.route("/portfolios//edit", methods=["POST"]) +@user_can(Permissions.EDIT_PORTFOLIO_NAME, message="edit portfolio") +def edit(portfolio_id): + portfolio = Portfolios.get_for_update(portfolio_id) + form = PortfolioForm(http_request.form) + if form.validate(): + Portfolios.update(portfolio, form.data) + return redirect( + url_for("applications.portfolio_applications", portfolio_id=portfolio.id) + ) + else: + # rerender portfolio admin page + return render_admin_page(portfolio, form) + + +@portfolios_bp.route( + "/portfolios//members//delete", methods=["POST"] +) +@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="update portfolio members") +def remove_member(portfolio_id, user_id): + if str(g.current_user.id) == user_id: + raise UnauthorizedError( + g.current_user, "you cant remove yourself from the portfolio" + ) + + portfolio_role = PortfolioRoles.get(portfolio_id=portfolio_id, user_id=user_id) + # TODO: should this cascade and disable any application and environment + # roles they might have? + PortfolioRoles.disable(portfolio_role=portfolio_role) + + flash("portfolio_member_removed", member_name=portfolio_role.user.full_name) + + return redirect( + url_for( + "portfolios.admin", + portfolio_id=portfolio_id, + _anchor="portfolio-members", + fragment="portfolio-members", + ) + ) diff --git a/atst/routes/portfolios/applications.py b/atst/routes/portfolios/applications.py deleted file mode 100644 index 952ec643..00000000 --- a/atst/routes/portfolios/applications.py +++ /dev/null @@ -1,185 +0,0 @@ -from flask import ( - current_app as app, - g, - redirect, - render_template, - request as http_request, - url_for, -) - -from . import portfolios_bp -from atst.domain.environment_roles import EnvironmentRoles -from atst.domain.environments import Environments -from atst.domain.exceptions import UnauthorizedError -from atst.domain.applications import Applications -from atst.domain.portfolios import Portfolios -from atst.forms.application import NewApplicationForm, ApplicationForm -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 -from atst.domain.permission_sets import PermissionSets -from atst.utils.localization import translate - - -@portfolios_bp.route("/portfolios//applications") -@user_can(Permissions.VIEW_APPLICATION, message="view portfolio applications") -def portfolio_applications(portfolio_id): - return render_template("portfolios/applications/index.html") - - -@portfolios_bp.route("/portfolios//applications/new") -@user_can(Permissions.CREATE_APPLICATION, message="view create new application form") -def new_application(portfolio_id): - form = NewApplicationForm() - return render_template("portfolios/applications/new.html", form=form) - - -@portfolios_bp.route("/portfolios//applications/new", methods=["POST"]) -@user_can(Permissions.CREATE_APPLICATION, message="create new application") -def create_application(portfolio_id): - portfolio = Portfolios.get_for_update(portfolio_id) - form = NewApplicationForm(http_request.form) - - if form.validate(): - application_data = form.data - Applications.create( - portfolio, - application_data["name"], - application_data["description"], - application_data["environment_names"], - ) - return redirect( - url_for("portfolios.portfolio_applications", portfolio_id=portfolio_id) - ) - else: - return render_template("portfolios/applications/new.html", form=form) - - -def get_environments_obj_for_app(application): - environments_obj = {} - - for env in application.environments: - environments_obj[env.name] = [] - for user in env.users: - env_role = EnvironmentRoles.get(user.id, env.id) - environments_obj[env.name].append( - {"name": user.full_name, "role": env_role.displayname} - ) - - return environments_obj - - -@portfolios_bp.route("/portfolios//applications//edit") -@user_can(Permissions.VIEW_APPLICATION, message="view application edit form") -def edit_application(portfolio_id, application_id): - application = Applications.get(application_id, portfolio_id=portfolio_id) - form = ApplicationForm(name=application.name, description=application.description) - - return render_template( - "portfolios/applications/edit.html", - application=application, - form=form, - environments_obj=get_environments_obj_for_app(application=application), - ) - - -@portfolios_bp.route( - "/portfolios//applications//edit", methods=["POST"] -) -@user_can(Permissions.EDIT_APPLICATION, message="update application") -def update_application(portfolio_id, application_id): - application = Applications.get(application_id, portfolio_id=portfolio_id) - form = ApplicationForm(http_request.form) - if form.validate(): - application_data = form.data - Applications.update(application, application_data) - - return redirect( - url_for("portfolios.portfolio_applications", portfolio_id=portfolio_id) - ) - else: - return render_template( - "portfolios/applications/edit.html", - application=application, - form=form, - environments_obj=get_environments_obj_for_app(application=application), - ) - - -def wrap_environment_role_lookup( - user, portfolio_id=None, environment_id=None, **kwargs -): - env_role = EnvironmentRoles.get_for_portfolio( - user.id, environment_id, portfolio_id=portfolio_id - ) - if not env_role: - raise UnauthorizedError(user, "access environment {}".format(environment_id)) - - return True - - -@portfolios_bp.route("/portfolios//environments//access") -@user_can(None, override=wrap_environment_role_lookup, message="access environment") -def access_environment(portfolio_id, environment_id): - env_role = EnvironmentRoles.get_for_portfolio( - g.current_user.id, environment_id, portfolio_id=portfolio_id - ) - token = app.csp.cloud.get_access_token(env_role) - - return redirect(url_for("atst.csp_environment_access", token=token)) - - -@portfolios_bp.route( - "/portfolios//applications//delete", methods=["POST"] -) -@user_can(Permissions.DELETE_APPLICATION, message="delete application") -def delete_application(portfolio_id, application_id): - application = Applications.get(application_id, portfolio_id=portfolio_id) - Applications.delete(application) - - flash("application_deleted", application_name=application.name) - - return redirect( - url_for("portfolios.portfolio_applications", portfolio_id=portfolio_id) - ) - - -def permission_str(member, edit_perm_set): - if member.has_permission_set(edit_perm_set): - return translate("portfolios.members.permissions.edit_access") - else: - return translate("portfolios.members.permissions.view_only") - - -@portfolios_bp.route("/portfolios//applications//team") -@user_can(Permissions.VIEW_APPLICATION, message="view portfolio applications") -def application_team(portfolio_id, application_id): - application = Applications.get( - resource_id=application_id, portfolio_id=portfolio_id - ) - - environment_users = {} - for member in application.members: - user_id = member.user.id - environment_users[user_id] = { - "permissions": { - "delete_access": permission_str( - member, PermissionSets.DELETE_APPLICATION_ENVIRONMENTS - ), - "environment_management": permission_str( - member, PermissionSets.EDIT_APPLICATION_ENVIRONMENTS - ), - "team_management": permission_str( - member, PermissionSets.EDIT_APPLICATION_TEAM - ), - }, - "environments": Environments.for_user( - user=member.user, application=application - ), - } - - return render_template( - "portfolios/applications/team.html", - application=application, - environment_users=environment_users, - ) diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py index dcdee315..28bb079e 100644 --- a/atst/routes/portfolios/index.py +++ b/atst/routes/portfolios/index.py @@ -5,18 +5,8 @@ from flask import render_template, request as http_request, g, redirect, url_for from . import portfolios_bp from atst.domain.reports import Reports from atst.domain.portfolios import Portfolios -from atst.domain.portfolio_roles import PortfolioRoles -from atst.domain.permission_sets import PermissionSets -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.forms.portfolio import PortfolioForm -import atst.forms.portfolio_member as member_forms from atst.models.permissions import Permissions from atst.domain.authz.decorator import user_can_access_decorator as user_can -from atst.utils.flash import formatted_flash as flash -from atst.domain.exceptions import UnauthorizedError @portfolios_bp.route("/portfolios") @@ -29,166 +19,17 @@ def portfolios(): return render_template("portfolios/blank_slate.html") -def permission_str(member, edit_perm_set, view_perm_set): - if member.has_permission_set(edit_perm_set): - return edit_perm_set - else: - return view_perm_set - - -def serialize_member_form_data(member): - return { - "member": member.user.full_name, - "user_id": member.user_id, - "perms_app_mgmt": permission_str( - member, - PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT, - PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT, - ), - "perms_funding": permission_str( - member, - PermissionSets.EDIT_PORTFOLIO_FUNDING, - PermissionSets.VIEW_PORTFOLIO_FUNDING, - ), - "perms_reporting": permission_str( - member, - PermissionSets.EDIT_PORTFOLIO_REPORTS, - PermissionSets.VIEW_PORTFOLIO_REPORTS, - ), - "perms_portfolio_mgmt": permission_str( - member, - PermissionSets.EDIT_PORTFOLIO_ADMIN, - PermissionSets.VIEW_PORTFOLIO_ADMIN, - ), - } - - -def get_members_data(portfolio): - members = [serialize_member_form_data(member) for member in portfolio.members] - for member in members: - if member["user_id"] == portfolio.owner.id: - ppoc = member - members.remove(member) - members.insert(0, ppoc) - return members - - -def render_admin_page(portfolio, form=None): - pagination_opts = Paginator.get_pagination_opts(http_request) - audit_events = AuditLog.get_portfolio_events(portfolio, pagination_opts) - members_data = get_members_data(portfolio) - portfolio_form = PortfolioForm(data={"name": portfolio.name}) - member_perms_form = member_forms.MembersPermissionsForm( - data={"members_permissions": members_data} - ) - - assign_ppoc_form = member_forms.AssignPPOCForm() - assign_ppoc_form.user_id.choices += [ - (user.id, user.full_name) for user in portfolio.users if user != portfolio.owner - ] - - return render_template( - "portfolios/admin.html", - form=form, - portfolio_form=portfolio_form, - member_perms_form=member_perms_form, - member_form=member_forms.NewForm(), - assign_ppoc_form=assign_ppoc_form, - portfolio=portfolio, - audit_events=audit_events, - user=g.current_user, - members_data=members_data, - ) - - -@portfolios_bp.route("/portfolios//admin") -@user_can(Permissions.VIEW_PORTFOLIO_ADMIN, message="view portfolio admin page") -def portfolio_admin(portfolio_id): - portfolio = Portfolios.get_for_update(portfolio_id) - return render_admin_page(portfolio) - - -@portfolios_bp.route("/portfolios//admin", methods=["POST"]) -@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="view portfolio admin page") -def edit_portfolio_members(portfolio_id): - portfolio = Portfolios.get_for_update(portfolio_id) - member_perms_form = member_forms.MembersPermissionsForm(http_request.form) - - if member_perms_form.validate(): - for subform in member_perms_form.members_permissions: - user_id = subform.user_id.data - member = Users.get(user_id=user_id) - if member is not portfolio.owner: - new_perm_set = subform.data["permission_sets"] - portfolio_role = PortfolioRoles.get(portfolio.id, user_id) - PortfolioRoles.update(portfolio_role, new_perm_set) - - flash("update_portfolio_members", portfolio=portfolio) - - return redirect( - url_for( - "portfolios.portfolio_admin", - portfolio_id=portfolio_id, - fragment="portfolio-members", - _anchor="portfolio-members", - ) - ) - else: - return render_admin_page(portfolio) - - -@portfolios_bp.route("/portfolios//update_ppoc", methods=["POST"]) -@user_can(Permissions.EDIT_PORTFOLIO_POC, message="update portfolio ppoc") -def update_ppoc(portfolio_id): - user_id = http_request.form.get("user_id") - - portfolio = Portfolios.get(g.current_user, portfolio_id) - new_ppoc = Users.get(user_id) - - if new_ppoc not in portfolio.users: - raise NotFoundError("user not in portfolio") - - portfolio_role = PortfolioRoles.get(portfolio_id=portfolio_id, user_id=user_id) - PortfolioRoles.make_ppoc(portfolio_role=portfolio_role) - - flash("primary_point_of_contact_changed", ppoc_name=new_ppoc.full_name) - - return redirect( - url_for( - "portfolios.portfolio_admin", - portfolio_id=portfolio.id, - fragment="primary-point-of-contact", - _anchor="primary-point-of-contact", - ) - ) - - -@portfolios_bp.route("/portfolios//edit", methods=["POST"]) -@user_can(Permissions.EDIT_PORTFOLIO_NAME, message="edit portfolio") -def edit_portfolio(portfolio_id): - portfolio = Portfolios.get_for_update(portfolio_id) - form = PortfolioForm(http_request.form) - if form.validate(): - Portfolios.update(portfolio, form.data) - return redirect( - url_for("portfolios.portfolio_applications", portfolio_id=portfolio.id) - ) - else: - # rerender portfolio admin page - return render_admin_page(portfolio, form) - - @portfolios_bp.route("/portfolios/") @user_can(Permissions.VIEW_PORTFOLIO, message="view portfolio") def show_portfolio(portfolio_id): return redirect( - url_for("portfolios.portfolio_applications", portfolio_id=portfolio_id) + url_for("applications.portfolio_applications", portfolio_id=portfolio_id) ) @portfolios_bp.route("/portfolios//reports") @user_can(Permissions.VIEW_PORTFOLIO_REPORTS, message="view portfolio reports") -def portfolio_reports(portfolio_id): +def reports(portfolio_id): portfolio = Portfolios.get(g.current_user, portfolio_id) today = date.today() month = http_request.args.get("month", today.month) @@ -220,30 +61,3 @@ def portfolio_reports(portfolio_id): expiration_date=expiration_date, remaining_days=remaining_days, ) - - -@portfolios_bp.route( - "/portfolios//members//delete", methods=["POST"] -) -@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="update portfolio members") -def remove_member(portfolio_id, user_id): - if str(g.current_user.id) == user_id: - raise UnauthorizedError( - g.current_user, "you cant remove yourself from the portfolio" - ) - - portfolio_role = PortfolioRoles.get(portfolio_id=portfolio_id, user_id=user_id) - # TODO: should this cascade and disable any application and environment - # roles they might have? - PortfolioRoles.disable(portfolio_role=portfolio_role) - - flash("portfolio_member_removed", member_name=portfolio_role.user.full_name) - - return redirect( - url_for( - "portfolios.portfolio_admin", - portfolio_id=portfolio_id, - _anchor="portfolio-members", - fragment="portfolio-members", - ) - ) diff --git a/atst/routes/portfolios/invitations.py b/atst/routes/portfolios/invitations.py index a8870667..e7fd6f92 100644 --- a/atst/routes/portfolios/invitations.py +++ b/atst/routes/portfolios/invitations.py @@ -21,19 +21,10 @@ def send_invite_email(owner_name, token, new_member_email): def accept_invitation(token): invite = Invitations.accept(g.current_user, token) - # TODO: this will eventually redirect to different places depending on - # whether the user is an officer for the TO and what kind of officer they - # are. It will also have to manage cases like: - # - the logged-in user has multiple roles on the TO (e.g., KO and COR) - # - the logged-in user has officer roles on multiple unsigned TOs for task_order in invite.portfolio.task_orders: if g.current_user in task_order.officers: return redirect( - url_for( - "portfolios.view_task_order", - portfolio_id=task_order.portfolio_id, - task_order_id=task_order.id, - ) + url_for("task_orders.view_task_order", task_order_id=task_order.id) ) return redirect( @@ -50,7 +41,7 @@ def revoke_invitation(portfolio_id, token): return redirect( url_for( - "portfolios.portfolio_admin", + "portfolios.admin", portfolio_id=portfolio_id, _anchor="portfolio-members", fragment="portfolio-members", @@ -68,7 +59,7 @@ def resend_invitation(portfolio_id, token): flash("resend_portfolio_invitation", user_name=invite.user_name) return redirect( url_for( - "portfolios.portfolio_admin", + "portfolios.admin", portfolio_id=portfolio_id, fragment="portfolio-members", _anchor="portfolio-members", diff --git a/atst/routes/portfolios/members.py b/atst/routes/portfolios/members.py index 8b6860dd..573781b8 100644 --- a/atst/routes/portfolios/members.py +++ b/atst/routes/portfolios/members.py @@ -52,7 +52,7 @@ def create_member(portfolio_id): return redirect( url_for( - "portfolios.portfolio_admin", + "portfolios.admin", portfolio_id=portfolio_id, fragment="portfolio-members", _anchor="portfolio-members", diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py deleted file mode 100644 index 35b3eb32..00000000 --- a/atst/routes/portfolios/task_orders.py +++ /dev/null @@ -1,321 +0,0 @@ -from collections import defaultdict - -from flask import g, redirect, render_template, url_for, request as http_request - -from . import portfolios_bp -from atst.database import db -from atst.domain.authz import Authorization -from atst.domain.exceptions import NotFoundError, NoAccessError -from atst.domain.invitations import Invitations -from atst.domain.portfolios import Portfolios -from atst.domain.task_orders import TaskOrders, DD254s -from atst.utils.localization import translate -from atst.forms.dd_254 import DD254Form -from atst.forms.ko_review import KOReviewForm -from atst.forms.officers import EditTaskOrderOfficersForm -from atst.models.task_order import Status as TaskOrderStatus -from atst.utils.flash import formatted_flash as flash -from atst.services.invitation import ( - update_officer_invitations, - OFFICER_INVITATIONS, - Invitation as InvitationService, -) -from atst.domain.authz.decorator import user_can_access_decorator as user_can -from atst.models.permissions import Permissions - - -@portfolios_bp.route("/portfolios//task_orders") -@user_can(Permissions.VIEW_PORTFOLIO_FUNDING, message="view portfolio funding") -def portfolio_funding(portfolio_id): - portfolio = Portfolios.get(g.current_user, portfolio_id) - task_orders_by_status = defaultdict(list) - serialize_task_order = lambda task_order: { - key: getattr(task_order, key) - for key in [ - "id", - "budget", - "time_created", - "start_date", - "end_date", - "display_status", - "days_to_expiration", - "balance", - ] - } - - for task_order in portfolio.task_orders: - serialized_task_order = serialize_task_order(task_order) - serialized_task_order["url"] = url_for( - "portfolios.view_task_order", - portfolio_id=portfolio.id, - task_order_id=task_order.id, - ) - task_orders_by_status[task_order.status].append(serialized_task_order) - - active_task_orders = task_orders_by_status.get(TaskOrderStatus.ACTIVE, []) - total_balance = sum([task_order["balance"] for task_order in active_task_orders]) - - return render_template( - "portfolios/task_orders/index.html", - pending_task_orders=( - task_orders_by_status.get(TaskOrderStatus.STARTED, []) - + task_orders_by_status.get(TaskOrderStatus.PENDING, []) - ), - active_task_orders=active_task_orders, - expired_task_orders=task_orders_by_status.get(TaskOrderStatus.EXPIRED, []), - total_balance=total_balance, - ) - - -@portfolios_bp.route("/portfolios//task_order/") -@user_can(Permissions.VIEW_TASK_ORDER_DETAILS, message="view task order details") -def view_task_order(portfolio_id, task_order_id): - task_order = TaskOrders.get(task_order_id, portfolio_id=portfolio_id) - to_form_complete = TaskOrders.all_sections_complete(task_order) - dd_254_complete = DD254s.is_complete(task_order.dd_254) - return render_template( - "portfolios/task_orders/show.html", - dd_254_complete=dd_254_complete, - is_cor=Authorization.is_cor(g.current_user, task_order), - is_ko=Authorization.is_ko(g.current_user, task_order), - is_so=Authorization.is_so(g.current_user, task_order), - is_to_signed=TaskOrders.is_signed_by_ko(task_order), - task_order=task_order, - to_form_complete=to_form_complete, - user=g.current_user, - ) - - -def wrap_check_is_ko_or_cor(user, task_order_id=None, portfolio_id=None, **_kwargs): - task_order = TaskOrders.get(task_order_id, portfolio_id=portfolio_id) - Authorization.check_is_ko_or_cor(user, task_order) - - return True - - -@portfolios_bp.route("/portfolios//task_order//review") -@user_can( - None, - override=wrap_check_is_ko_or_cor, - message="view contracting officer review form", -) -def ko_review(portfolio_id, task_order_id): - task_order = TaskOrders.get(task_order_id, portfolio_id=portfolio_id) - - if TaskOrders.all_sections_complete(task_order): - return render_template( - "/portfolios/task_orders/review.html", - task_order=task_order, - form=KOReviewForm(obj=task_order), - ) - else: - raise NoAccessError("task_order") - - -@portfolios_bp.route( - "/portfolios//task_order//resend_invite", - methods=["POST"], -) -@user_can( - Permissions.EDIT_TASK_ORDER_DETAILS, message="resend task order officer invites" -) -def resend_invite(portfolio_id, task_order_id): - invite_type = http_request.args.get("invite_type") - - if invite_type not in OFFICER_INVITATIONS: - raise NotFoundError("invite_type") - - invite_type_info = OFFICER_INVITATIONS[invite_type] - - task_order = TaskOrders.get(task_order_id, portfolio_id=portfolio_id) - portfolio = Portfolios.get(g.current_user, portfolio_id) - - officer = getattr(task_order, invite_type_info["role"]) - - if not officer: - raise NotFoundError("officer") - - invitation = Invitations.lookup_by_portfolio_and_user(portfolio, officer) - - if not invitation: - raise NotFoundError("invitation") - - if not invitation.can_resend: - raise NoAccessError("invitation") - - Invitations.revoke(token=invitation.token) - - invite_service = InvitationService( - g.current_user, - invitation.portfolio_role, - invitation.email, - subject=invite_type_info["subject"], - email_template=invite_type_info["template"], - ) - - invite_service.invite() - - flash( - "invitation_resent", - officer_type=translate( - "common.officer_helpers.underscore_to_friendly.{}".format( - invite_type_info["role"] - ) - ), - ) - - return redirect( - url_for( - "portfolios.task_order_invitations", - portfolio_id=portfolio_id, - task_order_id=task_order_id, - ) - ) - - -@portfolios_bp.route( - "/portfolios//task_order//review", methods=["POST"] -) -@user_can( - None, override=wrap_check_is_ko_or_cor, message="submit contracting officer review" -) -def submit_ko_review(portfolio_id, task_order_id, form=None): - task_order = TaskOrders.get(task_order_id, portfolio_id=portfolio_id) - form_data = {**http_request.form, **http_request.files} - form = KOReviewForm(form_data) - - if form.validate(): - TaskOrders.update(task_order=task_order, **form.data) - if Authorization.is_ko(g.current_user, task_order) and TaskOrders.can_ko_sign( - task_order - ): - return redirect( - url_for("task_orders.signature_requested", task_order_id=task_order_id) - ) - else: - return redirect( - url_for( - "portfolios.view_task_order", - task_order_id=task_order_id, - portfolio_id=portfolio_id, - ) - ) - else: - return render_template( - "/portfolios/task_orders/review.html", task_order=task_order, form=form - ) - - -@portfolios_bp.route( - "/portfolios//task_order//invitations" -) -@user_can( - Permissions.EDIT_TASK_ORDER_DETAILS, message="view task order invitations page" -) -def task_order_invitations(portfolio_id, task_order_id): - task_order = TaskOrders.get(task_order_id, portfolio_id=portfolio_id) - form = EditTaskOrderOfficersForm(obj=task_order) - - if TaskOrders.all_sections_complete(task_order): - return render_template( - "portfolios/task_orders/invitations.html", - task_order=task_order, - form=form, - user=g.current_user, - ) - else: - raise NotFoundError("task_order") - - -@portfolios_bp.route( - "/portfolios//task_order//invitations", - methods=["POST"], -) -@user_can(Permissions.EDIT_TASK_ORDER_DETAILS, message="edit task order invitations") -def edit_task_order_invitations(portfolio_id, task_order_id): - task_order = TaskOrders.get(task_order_id, portfolio_id=portfolio_id) - form = EditTaskOrderOfficersForm(formdata=http_request.form, obj=task_order) - - if form.validate(): - form.populate_obj(task_order) - db.session.add(task_order) - db.session.commit() - update_officer_invitations(g.current_user, task_order) - - return redirect( - url_for( - "portfolios.task_order_invitations", - portfolio_id=portfolio_id, - task_order_id=task_order.id, - ) - ) - else: - return ( - render_template( - "portfolios/task_orders/invitations.html", - task_order=task_order, - form=form, - ), - 400, - ) - - -def so_review_form(task_order): - if task_order.dd_254: - dd_254 = task_order.dd_254 - form = DD254Form(obj=dd_254) - form.required_distribution.data = dd_254.required_distribution - return form - else: - so = task_order.officer_dictionary("security_officer") - form_data = { - "certifying_official": "{}, {}".format( - so.get("last_name", ""), so.get("first_name", "") - ), - "co_phone": so.get("phone_number", ""), - } - return DD254Form(data=form_data) - - -def wrap_check_is_so(user, task_order_id=None, portfolio_id=None, **_kwargs): - task_order = TaskOrders.get(task_order_id, portfolio_id=portfolio_id) - Authorization.check_is_so(user, task_order) - - return True - - -@portfolios_bp.route("/portfolios//task_order//dd254") -@user_can(None, override=wrap_check_is_so, message="view security officer review form") -def so_review(portfolio_id, task_order_id): - task_order = TaskOrders.get(task_order_id, portfolio_id=portfolio_id) - form = so_review_form(task_order) - - return render_template( - "portfolios/task_orders/so_review.html", form=form, task_order=task_order - ) - - -@portfolios_bp.route( - "/portfolios//task_order//dd254", methods=["POST"] -) -@user_can( - None, override=wrap_check_is_so, message="submit security officer review form" -) -def submit_so_review(portfolio_id, task_order_id): - task_order = TaskOrders.get(task_order_id, portfolio_id=portfolio_id) - form = DD254Form(http_request.form) - - if form.validate(): - TaskOrders.add_dd_254(task_order, form.data) - # TODO: will redirect to download, sign, upload page - return redirect( - url_for( - "portfolios.view_task_order", - portfolio_id=task_order.portfolio.id, - task_order_id=task_order.id, - ) - ) - else: - return render_template( - "portfolios/task_orders/so_review.html", form=form, task_order=task_order - ) diff --git a/atst/routes/task_orders/__init__.py b/atst/routes/task_orders/__init__.py index e09d7c91..ef5a1e74 100644 --- a/atst/routes/task_orders/__init__.py +++ b/atst/routes/task_orders/__init__.py @@ -2,7 +2,12 @@ from flask import Blueprint task_orders_bp = Blueprint("task_orders", __name__) -from . import new from . import index -from . import invite +from . import new +from . import invitations +from . import officer_reviews from . import signing +from . import downloads +from atst.utils.context_processors import portfolio as portfolio_context_processor + +task_orders_bp.context_processor(portfolio_context_processor) diff --git a/atst/routes/task_orders/downloads.py b/atst/routes/task_orders/downloads.py new file mode 100644 index 00000000..c23f5250 --- /dev/null +++ b/atst/routes/task_orders/downloads.py @@ -0,0 +1,56 @@ +from io import BytesIO +from flask import Response, current_app as app + +from . import task_orders_bp +from atst.domain.task_orders import TaskOrders +from atst.domain.exceptions import NotFoundError +from atst.utils.docx import Docx +from atst.domain.authz.decorator import user_can_access_decorator as user_can +from atst.models.permissions import Permissions + + +@task_orders_bp.route("/task_orders//download_summary") +@user_can(Permissions.VIEW_TASK_ORDER_DETAILS, message="download task order summary") +def download_summary(task_order_id): + task_order = TaskOrders.get(task_order_id) + byte_str = BytesIO() + Docx.render(byte_str, data=task_order.to_dictionary()) + filename = "{}.docx".format(task_order.portfolio_name) + return Response( + byte_str, + headers={"Content-Disposition": "attachment; filename={}".format(filename)}, + mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + +def send_file(attachment): + generator = app.csp.files.download(attachment.object_name) + return Response( + generator, + headers={ + "Content-Disposition": "attachment; filename={}".format(attachment.filename) + }, + ) + + +@task_orders_bp.route("/task_orders//csp_estimate") +@user_can( + Permissions.VIEW_TASK_ORDER_DETAILS, + message="download task order cloud service provider estimate", +) +def download_csp_estimate(task_order_id): + task_order = TaskOrders.get(task_order_id) + if task_order.csp_estimate: + return send_file(task_order.csp_estimate) + else: + raise NotFoundError("task_order CSP estimate") + + +@task_orders_bp.route("/task_orders//pdf") +@user_can(Permissions.VIEW_TASK_ORDER_DETAILS, message="download task order PDF") +def download_task_order_pdf(task_order_id): + task_order = TaskOrders.get(task_order_id) + if task_order.pdf: + return send_file(task_order.pdf) + else: + raise NotFoundError("task_order pdf") diff --git a/atst/routes/task_orders/index.py b/atst/routes/task_orders/index.py index 1f775cc8..1f749b24 100644 --- a/atst/routes/task_orders/index.py +++ b/atst/routes/task_orders/index.py @@ -1,56 +1,74 @@ -from io import BytesIO -from flask import Response, current_app as app +from collections import defaultdict + +from flask import g, render_template, url_for from . import task_orders_bp -from atst.domain.task_orders import TaskOrders -from atst.domain.exceptions import NotFoundError -from atst.utils.docx import Docx +from atst.domain.authz import Authorization from atst.domain.authz.decorator import user_can_access_decorator as user_can -from atst.models.permissions import Permissions +from atst.domain.portfolios import Portfolios +from atst.domain.task_orders import TaskOrders, DD254s +from atst.models import Permissions +from atst.models.task_order import Status as TaskOrderStatus -@task_orders_bp.route("/task_orders/download_summary/") -@user_can(Permissions.VIEW_TASK_ORDER_DETAILS, message="download task order summary") -def download_summary(task_order_id): +@task_orders_bp.route("/task_orders/") +@user_can(Permissions.VIEW_TASK_ORDER_DETAILS, message="view task order details") +def view_task_order(task_order_id): task_order = TaskOrders.get(task_order_id) - byte_str = BytesIO() - Docx.render(byte_str, data=task_order.to_dictionary()) - filename = "{}.docx".format(task_order.portfolio_name) - return Response( - byte_str, - headers={"Content-Disposition": "attachment; filename={}".format(filename)}, - mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + to_form_complete = TaskOrders.all_sections_complete(task_order) + dd_254_complete = DD254s.is_complete(task_order.dd_254) + return render_template( + "portfolios/task_orders/show.html", + dd_254_complete=dd_254_complete, + is_cor=Authorization.is_cor(g.current_user, task_order), + is_ko=Authorization.is_ko(g.current_user, task_order), + is_so=Authorization.is_so(g.current_user, task_order), + is_to_signed=TaskOrders.is_signed_by_ko(task_order), + task_order=task_order, + to_form_complete=to_form_complete, + user=g.current_user, ) -def send_file(attachment): - generator = app.csp.files.download(attachment.object_name) - return Response( - generator, - headers={ - "Content-Disposition": "attachment; filename={}".format(attachment.filename) - }, +def serialize_task_order(task_order): + return { + key: getattr(task_order, key) + for key in [ + "id", + "budget", + "time_created", + "start_date", + "end_date", + "display_status", + "days_to_expiration", + "balance", + ] + } + + +@task_orders_bp.route("/portfolios//task_orders") +@user_can(Permissions.VIEW_PORTFOLIO_FUNDING, message="view portfolio funding") +def portfolio_funding(portfolio_id): + portfolio = Portfolios.get(g.current_user, portfolio_id) + task_orders_by_status = defaultdict(list) + + for task_order in portfolio.task_orders: + serialized_task_order = serialize_task_order(task_order) + serialized_task_order["url"] = url_for( + "task_orders.view_task_order", task_order_id=task_order.id + ) + task_orders_by_status[task_order.status].append(serialized_task_order) + + active_task_orders = task_orders_by_status.get(TaskOrderStatus.ACTIVE, []) + total_balance = sum([task_order["balance"] for task_order in active_task_orders]) + + return render_template( + "portfolios/task_orders/index.html", + pending_task_orders=( + task_orders_by_status.get(TaskOrderStatus.STARTED, []) + + task_orders_by_status.get(TaskOrderStatus.PENDING, []) + ), + active_task_orders=active_task_orders, + expired_task_orders=task_orders_by_status.get(TaskOrderStatus.EXPIRED, []), + total_balance=total_balance, ) - - -@task_orders_bp.route("/task_orders/csp_estimate/") -@user_can( - Permissions.VIEW_TASK_ORDER_DETAILS, - message="download task order cloud service provider estimate", -) -def download_csp_estimate(task_order_id): - task_order = TaskOrders.get(task_order_id) - if task_order.csp_estimate: - return send_file(task_order.csp_estimate) - else: - raise NotFoundError("task_order CSP estimate") - - -@task_orders_bp.route("/task_orders/pdf/") -@user_can(Permissions.VIEW_TASK_ORDER_DETAILS, message="download task order PDF") -def download_task_order_pdf(task_order_id): - task_order = TaskOrders.get(task_order_id) - if task_order.pdf: - return send_file(task_order.pdf) - else: - raise NotFoundError("task_order pdf") diff --git a/atst/routes/task_orders/invitations.py b/atst/routes/task_orders/invitations.py new file mode 100644 index 00000000..024c0097 --- /dev/null +++ b/atst/routes/task_orders/invitations.py @@ -0,0 +1,132 @@ +from flask import g, redirect, render_template, url_for, request as http_request + +from . import task_orders_bp +from atst.domain.task_orders import TaskOrders +from atst.utils.flash import formatted_flash as flash +from atst.domain.authz.decorator import user_can_access_decorator as user_can +from atst.models.permissions import Permissions +from atst.database import db +from atst.domain.exceptions import NotFoundError, NoAccessError +from atst.domain.invitations import Invitations +from atst.domain.portfolios import Portfolios +from atst.utils.localization import translate +from atst.forms.officers import EditTaskOrderOfficersForm +from atst.services.invitation import ( + update_officer_invitations, + OFFICER_INVITATIONS, + Invitation as InvitationService, +) + + +@task_orders_bp.route("/task_orders//invite", methods=["POST"]) +@user_can(Permissions.EDIT_TASK_ORDER_DETAILS, message="invite task order officers") +def invite(task_order_id): + task_order = TaskOrders.get(task_order_id) + if TaskOrders.all_sections_complete(task_order): + update_officer_invitations(g.current_user, task_order) + + portfolio = task_order.portfolio + flash("task_order_congrats", portfolio=portfolio) + return redirect( + url_for("task_orders.view_task_order", task_order_id=task_order.id) + ) + else: + flash("task_order_incomplete") + return redirect( + url_for("task_orders.new", screen=4, task_order_id=task_order.id) + ) + + +@task_orders_bp.route("/task_orders//resend_invite", methods=["POST"]) +@user_can( + Permissions.EDIT_TASK_ORDER_DETAILS, message="resend task order officer invites" +) +def resend_invite(task_order_id): + invite_type = http_request.args.get("invite_type") + + if invite_type not in OFFICER_INVITATIONS: + raise NotFoundError("invite_type") + + invite_type_info = OFFICER_INVITATIONS[invite_type] + + task_order = TaskOrders.get(task_order_id) + portfolio = Portfolios.get(g.current_user, task_order.portfolio_id) + + officer = getattr(task_order, invite_type_info["role"]) + + if not officer: + raise NotFoundError("officer") + + invitation = Invitations.lookup_by_portfolio_and_user(portfolio, officer) + + if not invitation: + raise NotFoundError("invitation") + + if not invitation.can_resend: + raise NoAccessError("invitation") + + Invitations.revoke(token=invitation.token) + + invite_service = InvitationService( + g.current_user, + invitation.portfolio_role, + invitation.email, + subject=invite_type_info["subject"], + email_template=invite_type_info["template"], + ) + + invite_service.invite() + + flash( + "invitation_resent", + officer_type=translate( + "common.officer_helpers.underscore_to_friendly.{}".format( + invite_type_info["role"] + ) + ), + ) + + return redirect(url_for("task_orders.invitations", task_order_id=task_order_id)) + + +@task_orders_bp.route("/task_orders//invitations") +@user_can( + Permissions.EDIT_TASK_ORDER_DETAILS, message="view task order invitations page" +) +def invitations(task_order_id): + task_order = TaskOrders.get(task_order_id) + form = EditTaskOrderOfficersForm(obj=task_order) + + if TaskOrders.all_sections_complete(task_order): + return render_template( + "portfolios/task_orders/invitations.html", + task_order=task_order, + form=form, + user=g.current_user, + ) + else: + raise NotFoundError("task_order") + + +@task_orders_bp.route("/task_orders//invitations/edit", methods=["POST"]) +@user_can(Permissions.EDIT_TASK_ORDER_DETAILS, message="edit task order invitations") +def invitations_edit(task_order_id): + task_order = TaskOrders.get(task_order_id) + form = EditTaskOrderOfficersForm(formdata=http_request.form, obj=task_order) + + if form.validate(): + form.populate_obj(task_order) + db.session.add(task_order) + db.session.commit() + update_officer_invitations(g.current_user, task_order) + + return redirect(url_for("task_orders.invitations", task_order_id=task_order.id)) + else: + return ( + render_template( + "portfolios/task_orders/invitations.html", + task_order=task_order, + form=form, + ), + 400, + ) diff --git a/atst/routes/task_orders/invite.py b/atst/routes/task_orders/invite.py deleted file mode 100644 index 4021adda..00000000 --- a/atst/routes/task_orders/invite.py +++ /dev/null @@ -1,31 +0,0 @@ -from flask import redirect, url_for, g - -from . import task_orders_bp -from atst.domain.task_orders import TaskOrders -from atst.utils.flash import formatted_flash as flash -from atst.services.invitation import update_officer_invitations -from atst.domain.authz.decorator import user_can_access_decorator as user_can -from atst.models.permissions import Permissions - - -@task_orders_bp.route("/task_orders/invite/", methods=["POST"]) -@user_can(Permissions.EDIT_TASK_ORDER_DETAILS, message="invite task order officers") -def invite(task_order_id): - task_order = TaskOrders.get(task_order_id) - if TaskOrders.all_sections_complete(task_order): - update_officer_invitations(g.current_user, task_order) - - portfolio = task_order.portfolio - flash("task_order_congrats", portfolio=portfolio) - return redirect( - url_for( - "portfolios.view_task_order", - portfolio_id=task_order.portfolio_id, - task_order_id=task_order.id, - ) - ) - else: - flash("task_order_incomplete") - return redirect( - url_for("task_orders.new", screen=4, task_order_id=task_order.id) - ) diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index 2854e966..9002f8e5 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -305,9 +305,7 @@ def new(screen, task_order_id=None, portfolio_id=None): if http_request.args.get("ko_edit"): template_args["ko_edit"] = True template_args["next"] = url_for( - "portfolios.ko_review", - portfolio_id=workflow.task_order.portfolio.id, - task_order_id=task_order_id, + "task_orders.ko_review", task_order_id=task_order_id ) url_args["next"] = template_args["next"] diff --git a/atst/routes/task_orders/officer_reviews.py b/atst/routes/task_orders/officer_reviews.py new file mode 100644 index 00000000..aaaf8c68 --- /dev/null +++ b/atst/routes/task_orders/officer_reviews.py @@ -0,0 +1,117 @@ +from flask import g, redirect, render_template, url_for, request as http_request + +from . import task_orders_bp +from atst.domain.authz import Authorization +from atst.domain.exceptions import NoAccessError +from atst.domain.task_orders import TaskOrders +from atst.forms.dd_254 import DD254Form +from atst.forms.ko_review import KOReviewForm +from atst.domain.authz.decorator import user_can_access_decorator as user_can + + +def wrap_check_is_ko_or_cor(user, task_order_id=None, **_kwargs): + task_order = TaskOrders.get(task_order_id) + Authorization.check_is_ko_or_cor(user, task_order) + + return True + + +@task_orders_bp.route("/task_orders//review") +@user_can( + None, + override=wrap_check_is_ko_or_cor, + message="view contracting officer review form", +) +def ko_review(task_order_id): + task_order = TaskOrders.get(task_order_id) + + if TaskOrders.all_sections_complete(task_order): + return render_template( + "/portfolios/task_orders/review.html", + task_order=task_order, + form=KOReviewForm(obj=task_order), + ) + else: + raise NoAccessError("task_order") + + +@task_orders_bp.route("/task_orders//review", methods=["POST"]) +@user_can( + None, override=wrap_check_is_ko_or_cor, message="submit contracting officer review" +) +def submit_ko_review(task_order_id, form=None): + task_order = TaskOrders.get(task_order_id) + form_data = {**http_request.form, **http_request.files} + form = KOReviewForm(form_data) + + if form.validate(): + TaskOrders.update(task_order=task_order, **form.data) + if Authorization.is_ko(g.current_user, task_order) and TaskOrders.can_ko_sign( + task_order + ): + return redirect( + url_for("task_orders.signature_requested", task_order_id=task_order_id) + ) + else: + return redirect( + url_for("task_orders.view_task_order", task_order_id=task_order_id) + ) + else: + return render_template( + "/portfolios/task_orders/review.html", task_order=task_order, form=form + ) + + +def so_review_form(task_order): + if task_order.dd_254: + dd_254 = task_order.dd_254 + form = DD254Form(obj=dd_254) + form.required_distribution.data = dd_254.required_distribution + return form + else: + so = task_order.officer_dictionary("security_officer") + form_data = { + "certifying_official": "{}, {}".format( + so.get("last_name", ""), so.get("first_name", "") + ), + "co_phone": so.get("phone_number", ""), + } + return DD254Form(data=form_data) + + +def wrap_check_is_so(user, task_order_id=None, **_kwargs): + task_order = TaskOrders.get(task_order_id) + Authorization.check_is_so(user, task_order) + + return True + + +@task_orders_bp.route("/task_orders//dd254") +@user_can(None, override=wrap_check_is_so, message="view security officer review form") +def so_review(task_order_id): + task_order = TaskOrders.get(task_order_id) + form = so_review_form(task_order) + + return render_template( + "portfolios/task_orders/so_review.html", form=form, task_order=task_order + ) + + +@task_orders_bp.route("/task_orders//dd254", methods=["POST"]) +@user_can( + None, override=wrap_check_is_so, message="submit security officer review form" +) +def submit_so_review(task_order_id): + task_order = TaskOrders.get(task_order_id) + form = DD254Form(http_request.form) + + if form.validate(): + TaskOrders.add_dd_254(task_order, form.data) + # TODO: will redirect to download, sign, upload page + return redirect( + url_for("task_orders.view_task_order", task_order_id=task_order.id) + ) + else: + return render_template( + "portfolios/task_orders/so_review.html", form=form, task_order=task_order + ) diff --git a/atst/routes/task_orders/signing.py b/atst/routes/task_orders/signing.py index 5f40bea8..3b3a92e0 100644 --- a/atst/routes/task_orders/signing.py +++ b/atst/routes/task_orders/signing.py @@ -72,11 +72,7 @@ def record_signature(task_order_id): flash("task_order_signed") return redirect( - url_for( - "portfolios.view_task_order", - portfolio_id=task_order.portfolio_id, - task_order_id=task_order.id, - ) + url_for("task_orders.view_task_order", task_order_id=task_order.id) ) else: return ( diff --git a/atst/utils/context_processors.py b/atst/utils/context_processors.py new file mode 100644 index 00000000..4b82320b --- /dev/null +++ b/atst/utils/context_processors.py @@ -0,0 +1,74 @@ +from operator import attrgetter + +from flask import request as http_request, g +from sqlalchemy.orm.exc import NoResultFound + +from atst.database import db +from atst.domain.authz import Authorization +from atst.models import Application, Portfolio, TaskOrder +from atst.models.permissions import Permissions +from atst.domain.portfolios.scopes import ScopedPortfolio + + +def get_portfolio_from_context(view_args): + query = None + + if "portfolio_id" in view_args: + query = db.session.query(Portfolio).filter( + Portfolio.id == view_args["portfolio_id"] + ) + + elif "application_id" in view_args: + query = ( + db.session.query(Portfolio) + .join(Application, Application.portfolio_id == Portfolio.id) + .filter(Application.id == view_args["application_id"]) + ) + + elif "task_order_id" in view_args: + query = ( + db.session.query(Portfolio) + .join(TaskOrder, TaskOrder.portfolio_id == Portfolio.id) + .filter(TaskOrder.id == view_args["task_order_id"]) + ) + + if query: + try: + portfolio = query.one() + + return ScopedPortfolio(g.current_user, portfolio) + except NoResultFound: + raise NotFoundError("portfolio") + + +def portfolio(): + portfolio = get_portfolio_from_context(http_request.view_args) + + def user_can(permission): + if portfolio: + return Authorization.has_portfolio_permission( + g.current_user, portfolio, permission + ) + return False + + if not portfolio is None: + active_task_orders = [ + task_order for task_order in portfolio.task_orders if task_order.is_active + ] + funding_end_date = ( + sorted(active_task_orders, key=attrgetter("end_date"))[-1].end_date + if active_task_orders + else None + ) + funded = len(active_task_orders) > 1 + else: + funding_end_date = None + funded = None + + return { + "portfolio": portfolio, + "permissions": Permissions, + "user_can": user_can, + "funding_end_date": funding_end_date, + "funded": funded, + } diff --git a/templates/fragments/admin/portfolio_members.html b/templates/fragments/admin/portfolio_members.html index e45f750c..98bd8d13 100644 --- a/templates/fragments/admin/portfolio_members.html +++ b/templates/fragments/admin/portfolio_members.html @@ -9,7 +9,7 @@ {% if g.matchesPath("portfolio-members") %} {% include "fragments/flash.html" %} {% endif %} -
+ {{ member_perms_form.csrf_token }}
diff --git a/templates/portfolios/admin.html b/templates/portfolios/admin.html index 70dfa91e..6ba37cb0 100644 --- a/templates/portfolios/admin.html +++ b/templates/portfolios/admin.html @@ -15,7 +15,7 @@ {% if user_can(permissions.VIEW_PORTFOLIO_NAME) %} - + {{ portfolio_form.csrf_token }}
@@ -52,7 +52,7 @@ {% if user_can(permissions.VIEW_PORTFOLIO_ACTIVITY_LOG) %} {% include "fragments/audit_events_log.html" %} - {{ Pagination(audit_events, 'portfolios.portfolio_admin', portfolio_id=portfolio.id) }} + {{ Pagination(audit_events, 'portfolios.admin', portfolio_id=portfolio.id) }} {% endif %}
{% endblock %} diff --git a/templates/portfolios/applications/edit.html b/templates/portfolios/applications/edit.html index d1e3a8fb..24c8b5fb 100644 --- a/templates/portfolios/applications/edit.html +++ b/templates/portfolios/applications/edit.html @@ -11,7 +11,7 @@
{{ 'portfolios.applications.settings_heading' | translate }}
- +
@@ -112,7 +112,7 @@
- + {{ form.csrf_token }}