diff --git a/atst/app.py b/atst/app.py index f2e100b2..9bb7df3f 100644 --- a/atst/app.py +++ b/atst/app.py @@ -12,7 +12,7 @@ from atst.database import db from atst.assets import environment as assets_environment from atst.filters import register_filters from atst.routes import bp -from atst.routes.workspaces import workspaces_bp as workspace_routes +from atst.routes.portfolios import portfolios_bp as portfolio_routes from atst.routes.requests import requests_bp from atst.routes.task_orders import task_orders_bp from atst.routes.dev import bp as dev_routes @@ -63,7 +63,7 @@ def make_app(config): make_error_pages(app) app.register_blueprint(bp) - app.register_blueprint(workspace_routes) + app.register_blueprint(portfolio_routes) app.register_blueprint(task_orders_bp) app.register_blueprint(user_routes) app.register_blueprint(requests_bp) diff --git a/atst/domain/applications.py b/atst/domain/applications.py index c96b4206..55320bf8 100644 --- a/atst/domain/applications.py +++ b/atst/domain/applications.py @@ -10,9 +10,9 @@ from atst.models.environment_role import EnvironmentRole class Applications(object): @classmethod - def create(cls, user, workspace, name, description, environment_names): + def create(cls, user, portfolio, name, description, environment_names): application = Application( - workspace=workspace, name=name, description=description + portfolio=portfolio, name=name, description=description ) db.session.add(application) @@ -22,13 +22,13 @@ class Applications(object): return application @classmethod - def get(cls, user, workspace, application_id): + def get(cls, user, portfolio, application_id): # TODO: this should check permission for this particular application - Authorization.check_workspace_permission( + Authorization.check_portfolio_permission( user, - workspace, + portfolio, Permissions.VIEW_APPLICATION_IN_WORKSPACE, - "view application in workspace", + "view application in portfolio", ) try: @@ -41,28 +41,28 @@ class Applications(object): return application @classmethod - def for_user(self, user, workspace): + def for_user(self, user, portfolio): return ( db.session.query(Application) .join(Environment) .join(EnvironmentRole) - .filter(Application.workspace_id == workspace.id) + .filter(Application.workspace_id == portfolio.id) .filter(EnvironmentRole.user_id == user.id) .all() ) @classmethod - def get_all(cls, user, workspace_role, workspace): - Authorization.check_workspace_permission( + def get_all(cls, user, portfolio_role, portfolio): + Authorization.check_portfolio_permission( user, - workspace, + portfolio, Permissions.VIEW_APPLICATION_IN_WORKSPACE, - "view application in workspace", + "view application in portfolio", ) try: applications = ( - db.session.query(Application).filter_by(workspace_id=workspace.id).all() + db.session.query(Application).filter_by(portfolio_id=portfolio.id).all() ) except NoResultFound: raise NotFoundError("applications") @@ -70,7 +70,7 @@ class Applications(object): return applications @classmethod - def update(cls, user, workspace, application, new_data): + def update(cls, user, portfolio, application, new_data): if "name" in new_data: application.name = new_data["name"] if "description" in new_data: diff --git a/atst/domain/audit_log.py b/atst/domain/audit_log.py index 00bbba17..36f1d2a1 100644 --- a/atst/domain/audit_log.py +++ b/atst/domain/audit_log.py @@ -15,13 +15,13 @@ class AuditEventQuery(Query): return cls.paginate(query, pagination_opts) @classmethod - def get_ws_events(cls, workspace_id, pagination_opts): + def get_ws_events(cls, portfolio_id, pagination_opts): query = ( db.session.query(cls.model) .filter( or_( - cls.model.workspace_id == workspace_id, - cls.model.resource_id == workspace_id, + cls.model.portfolio_id == portfolio_id, + cls.model.resource_id == portfolio_id, ) ) .order_by(cls.model.time_created.desc()) @@ -31,8 +31,8 @@ class AuditEventQuery(Query): class AuditLog(object): @classmethod - def log_system_event(cls, resource, action, workspace=None): - return cls._log(resource=resource, action=action, workspace=workspace) + def log_system_event(cls, resource, action, portfolio=None): + return cls._log(resource=resource, action=action, portfolio=portfolio) @classmethod def get_all_events(cls, user, pagination_opts=None): @@ -42,14 +42,14 @@ class AuditLog(object): return AuditEventQuery.get_all(pagination_opts) @classmethod - def get_workspace_events(cls, user, workspace, pagination_opts=None): - Authorization.check_workspace_permission( + def get_portfolio_events(cls, user, portfolio, pagination_opts=None): + Authorization.check_portfolio_permission( user, - workspace, + portfolio, Permissions.VIEW_WORKSPACE_AUDIT_LOG, - "view workspace audit log", + "view portfolio audit log", ) - return AuditEventQuery.get_ws_events(workspace.id, pagination_opts) + return AuditEventQuery.get_ws_events(portfolio.id, pagination_opts) @classmethod def get_by_resource(cls, resource_id): @@ -65,14 +65,14 @@ class AuditLog(object): return type(resource).__name__.lower() @classmethod - def _log(cls, user=None, workspace=None, resource=None, action=None): + def _log(cls, user=None, portfolio=None, resource=None, action=None): resource_id = resource.id if resource else None resource_type = cls._resource_type(resource) if resource else None - workspace_id = workspace.id if workspace else None + portfolio_id = portfolio.id if portfolio else None audit_event = AuditEventQuery.create( user=user, - workspace_id=workspace_id, + portfolio_id=portfolio_id, resource_id=resource_id, resource_type=resource_type, action=action, diff --git a/atst/domain/authz.py b/atst/domain/authz.py index ab596154..a893e0b1 100644 --- a/atst/domain/authz.py +++ b/atst/domain/authz.py @@ -1,24 +1,24 @@ -from atst.domain.workspace_roles import WorkspaceRoles +from atst.domain.portfolio_roles import PortfolioRoles from atst.models.permissions import Permissions from atst.domain.exceptions import UnauthorizedError class Authorization(object): @classmethod - def has_workspace_permission(cls, user, workspace, permission): - return permission in WorkspaceRoles.workspace_role_permissions(workspace, user) + def has_portfolio_permission(cls, user, portfolio, permission): + return permission in PortfolioRoles.portfolio_role_permissions(portfolio, user) @classmethod def has_atat_permission(cls, user, permission): return permission in user.atat_role.permissions @classmethod - def is_in_workspace(cls, user, workspace): - return user in workspace.users + def is_in_portfolio(cls, user, portfolio): + return user in portfolio.users @classmethod - def check_workspace_permission(cls, user, workspace, permission, message): - if not Authorization.has_workspace_permission(user, workspace, permission): + def check_portfolio_permission(cls, user, portfolio, permission, message): + if not Authorization.has_portfolio_permission(user, portfolio, permission): raise UnauthorizedError(user, message) @classmethod @@ -39,8 +39,8 @@ class Authorization(object): if Authorization._check_is_task_order_officer(task_order, user): return True - Authorization.check_workspace_permission( - user, task_order.workspace, permission, message + Authorization.check_portfolio_permission( + user, task_order.portfolio, permission, message ) @classmethod diff --git a/atst/domain/csp/reports.py b/atst/domain/csp/reports.py index e7a012e5..cd87d46c 100644 --- a/atst/domain/csp/reports.py +++ b/atst/domain/csp/reports.py @@ -200,17 +200,17 @@ class MockReportingProvider(ReportingInterface): ] ) - def get_budget(self, workspace): - if workspace.name in self.REPORT_FIXTURE_MAP: - return self.REPORT_FIXTURE_MAP[workspace.name]["budget"] - elif workspace.request and workspace.legacy_task_order: - return workspace.legacy_task_order.budget + def get_budget(self, portfolio): + if portfolio.name in self.REPORT_FIXTURE_MAP: + return self.REPORT_FIXTURE_MAP[portfolio.name]["budget"] + elif portfolio.request and portfolio.legacy_task_order: + return portfolio.legacy_task_order.budget return 0 - def get_total_spending(self, workspace): - if workspace.name in self.REPORT_FIXTURE_MAP: + def get_total_spending(self, portfolio): + if portfolio.name in self.REPORT_FIXTURE_MAP: return self._sum_monthly_spend( - self.REPORT_FIXTURE_MAP[workspace.name]["applications"] + self.REPORT_FIXTURE_MAP[portfolio.name]["applications"] ) return 0 @@ -230,17 +230,17 @@ class MockReportingProvider(ReportingInterface): return application_totals - def _rollup_workspace_totals(self, application_totals): + def _rollup_portfolio_totals(self, application_totals): monthly_spend = [ (month, spend) for application in application_totals.values() for month, spend in application.items() ] - workspace_totals = {} + portfolio_totals = {} for month, spends in groupby(sorted(monthly_spend), lambda m: m[0]): - workspace_totals[month] = sum([spend[1] for spend in spends]) + portfolio_totals[month] = sum([spend[1] for spend in spends]) - return workspace_totals + return portfolio_totals def monthly_totals_for_environment(self, environment_id): """Return the monthly totals for the specified environment. @@ -253,26 +253,26 @@ class MockReportingProvider(ReportingInterface): """ return self.MONTHLY_SPEND_BY_ENVIRONMENT.get(environment_id, {}) - def monthly_totals(self, workspace): - """Return month totals rolled up by environment, application, and workspace. + def monthly_totals(self, portfolio): + """Return month totals rolled up by environment, application, and portfolio. - Data should returned with three top level keys, "workspace", "applications", + Data should returned with three top level keys, "portfolio", "applications", and "environments". The "applications" key will have budget data per month for each application, The "environments" key will have budget data for each environment. - The "workspace" key will be total monthly spending for the workspace. + The "portfolio" key will be total monthly spending for the portfolio. For example: { "environments": { "X-Wing": { "Prod": { "01/2018": 75.42 } } }, "applications": { "X-Wing": { "01/2018": 75.42 } }, - "workspace": { "01/2018": 75.42 }, + "portfolio": { "01/2018": 75.42 }, } """ - applications = workspace.applications - if workspace.name in self.REPORT_FIXTURE_MAP: - applications = self.REPORT_FIXTURE_MAP[workspace.name]["applications"] + applications = portfolio.applications + if portfolio.name in self.REPORT_FIXTURE_MAP: + applications = self.REPORT_FIXTURE_MAP[portfolio.name]["applications"] environments = { application.name: { env.name: self.monthly_totals_for_environment(env.id) @@ -282,17 +282,17 @@ class MockReportingProvider(ReportingInterface): } application_totals = self._rollup_application_totals(environments) - workspace_totals = self._rollup_workspace_totals(application_totals) + portfolio_totals = self._rollup_portfolio_totals(application_totals) return { "environments": environments, "applications": application_totals, - "workspace": workspace_totals, + "portfolio": portfolio_totals, } - def cumulative_budget(self, workspace): - if workspace.name in self.REPORT_FIXTURE_MAP: - budget_months = self.REPORT_FIXTURE_MAP[workspace.name]["cumulative"] + def cumulative_budget(self, portfolio): + if portfolio.name in self.REPORT_FIXTURE_MAP: + budget_months = self.REPORT_FIXTURE_MAP[portfolio.name]["cumulative"] else: budget_months = {} diff --git a/atst/domain/environments.py b/atst/domain/environments.py index 7502266b..7978cdc4 100644 --- a/atst/domain/environments.py +++ b/atst/domain/environments.py @@ -60,10 +60,10 @@ class Environments(object): return env @classmethod - def update_environment_roles(cls, user, workspace, workspace_role, ids_and_roles): - Authorization.check_workspace_permission( + def update_environment_roles(cls, user, portfolio, portfolio_role, ids_and_roles): + Authorization.check_portfolio_permission( user, - workspace, + portfolio, Permissions.ADD_AND_ASSIGN_CSP_ROLES, "assign environment roles", ) @@ -75,13 +75,13 @@ class Environments(object): if new_role is None: role_deleted = EnvironmentRoles.delete( - workspace_role.user.id, environment.id + portfolio_role.user.id, environment.id ) if role_deleted: updated = True else: env_role = EnvironmentRoles.get( - workspace_role.user.id, id_and_role["id"] + portfolio_role.user.id, id_and_role["id"] ) if env_role and env_role.role != new_role: env_role.role = new_role @@ -89,7 +89,7 @@ class Environments(object): db.session.add(env_role) elif not env_role: env_role = EnvironmentRoles.create( - user=workspace_role.user, environment=environment, role=new_role + user=portfolio_role.user, environment=environment, role=new_role ) updated = True db.session.add(env_role) @@ -101,9 +101,9 @@ class Environments(object): @classmethod def revoke_access(cls, user, environment, target_user): - Authorization.check_workspace_permission( + Authorization.check_portfolio_permission( user, - environment.workspace, + environment.portfolio, Permissions.REMOVE_CSP_ROLES, "revoke environment access", ) diff --git a/atst/domain/invitations.py b/atst/domain/invitations.py index f4edd757..24c11da3 100644 --- a/atst/domain/invitations.py +++ b/atst/domain/invitations.py @@ -3,9 +3,9 @@ from sqlalchemy.orm.exc import NoResultFound from atst.database import db from atst.models.invitation import Invitation, Status as InvitationStatus -from atst.domain.workspace_roles import WorkspaceRoles +from atst.domain.portfolio_roles import PortfolioRoles from atst.domain.authz import Authorization, Permissions -from atst.domain.workspaces import Workspaces +from atst.domain.portfolios import Portfolios from .exceptions import NotFoundError @@ -54,11 +54,11 @@ class Invitations(object): return invite @classmethod - def create(cls, inviter, workspace_role, email): + def create(cls, inviter, portfolio_role, email): invite = Invitation( - workspace_role=workspace_role, + portfolio_role=portfolio_role, inviter=inviter, - user=workspace_role.user, + user=portfolio_role.user, status=InvitationStatus.PENDING, expiration_time=Invitations.current_expiration_time(), email=email, @@ -86,7 +86,7 @@ class Invitations(object): elif invite.is_pending: # pragma: no branch Invitations._update_status(invite, InvitationStatus.ACCEPTED) - WorkspaceRoles.enable(invite.workspace_role) + PortfolioRoles.enable(invite.portfolio_role) return invite @classmethod @@ -109,18 +109,18 @@ class Invitations(object): return Invitations._update_status(invite, InvitationStatus.REVOKED) @classmethod - def resend(cls, user, workspace_id, token): - workspace = Workspaces.get(user, workspace_id) - Authorization.check_workspace_permission( + def resend(cls, user, portfolio_id, token): + portfolio = Portfolios.get(user, portfolio_id) + Authorization.check_portfolio_permission( user, - workspace, + portfolio, Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, - "resend a workspace invitation", + "resend a portfolio invitation", ) previous_invitation = Invitations._get(token) Invitations._update_status(previous_invitation, InvitationStatus.REVOKED) return Invitations.create( - user, previous_invitation.workspace_role, previous_invitation.email + user, previous_invitation.portfolio_role, previous_invitation.email ) diff --git a/atst/domain/portfolio_roles.py b/atst/domain/portfolio_roles.py new file mode 100644 index 00000000..2d35b9d2 --- /dev/null +++ b/atst/domain/portfolio_roles.py @@ -0,0 +1,167 @@ +from sqlalchemy.orm.exc import NoResultFound + +from atst.database import db +from atst.models.portfolio_role import ( + PortfolioRole, + Status as PortfolioRoleStatus, + MEMBER_STATUSES, +) +from atst.models.user import User + +from .roles import Roles +from .users import Users +from .exceptions import NotFoundError + + +MEMBER_STATUS_CHOICES = [ + dict(name=key, display_name=value) for key, value in MEMBER_STATUSES.items() +] + + +class PortfolioRoles(object): + @classmethod + def get(cls, portfolio_id, user_id): + try: + portfolio_role = ( + db.session.query(PortfolioRole) + .join(User) + .filter(User.id == user_id, PortfolioRole.portfolio_id == portfolio_id) + .one() + ) + except NoResultFound: + raise NotFoundError("portfolio_role") + + return portfolio_role + + @classmethod + def get_by_id(cls, id_): + try: + return db.session.query(PortfolioRole).filter(PortfolioRole.id == id_).one() + except NoResultFound: + raise NotFoundError("portfolio_role") + + @classmethod + def _get_active_portfolio_role(cls, portfolio_id, user_id): + try: + return ( + db.session.query(PortfolioRole) + .join(User) + .filter(User.id == user_id, PortfolioRole.workspace_id == portfolio_id) + .filter(PortfolioRole.status == PortfolioRoleStatus.ACTIVE) + .one() + ) + except NoResultFound: + return None + + @classmethod + def portfolio_role_permissions(cls, portfolio, user): + portfolio_role = PortfolioRoles._get_active_portfolio_role( + portfolio.id, user.id + ) + atat_permissions = set(user.atat_role.permissions) + portfolio_permissions = ( + [] if portfolio_role is None else portfolio_role.role.permissions + ) + return set(portfolio_permissions).union(atat_permissions) + + @classmethod + def _get_portfolio_role(cls, user, portfolio_id): + try: + existing_portfolio_role = ( + db.session.query(PortfolioRole) + .filter( + PortfolioRole.user == user, + PortfolioRole.portfolio_id == portfolio_id, + ) + .one() + ) + return existing_portfolio_role + except NoResultFound: + raise NotFoundError("portfolio role") + + @classmethod + def add(cls, user, portfolio_id, role_name): + role = Roles.get(role_name) + + new_portfolio_role = None + try: + existing_portfolio_role = ( + db.session.query(PortfolioRole) + .filter( + PortfolioRole.user == user, + PortfolioRole.portfolio_id == portfolio_id, + ) + .one() + ) + new_portfolio_role = existing_portfolio_role + new_portfolio_role.role = role + except NoResultFound: + new_portfolio_role = PortfolioRole( + user=user, + role_id=role.id, + portfolio_id=portfolio_id, + status=PortfolioRoleStatus.PENDING, + ) + + user.portfolio_roles.append(new_portfolio_role) + db.session.add(user) + db.session.commit() + + return new_portfolio_role + + @classmethod + def update_role(cls, portfolio_role, role_name): + new_role = Roles.get(role_name) + portfolio_role.role = new_role + + db.session.add(portfolio_role) + db.session.commit() + return portfolio_role + + @classmethod + def add_many(cls, portfolio_id, portfolio_role_dicts): + portfolio_roles = [] + + for user_dict in portfolio_role_dicts: + try: + user = Users.get(user_dict["id"]) + except NoResultFound: + default_role = Roles.get("developer") + user = User(id=user_dict["id"], atat_role=default_role) + + try: + role = Roles.get(user_dict["portfolio_role"]) + except NoResultFound: + raise NotFoundError("role") + + try: + existing_portfolio_role = ( + db.session.query(PortfolioRole) + .filter( + PortfolioRole.user == user, + PortfolioRole.portfolio_id == portfolio_id, + ) + .one() + ) + new_portfolio_role = existing_portfolio_role + new_portfolio_role.role = role + except NoResultFound: + new_portfolio_role = PortfolioRole( + user=user, role_id=role.id, portfolio_id=portfolio_id + ) + + user.portfolio_roles.append(new_portfolio_role) + portfolio_roles.append(new_portfolio_role) + + db.session.add(user) + + db.session.commit() + + return portfolio_roles + + @classmethod + def enable(cls, portfolio_role): + portfolio_role.status = PortfolioRoleStatus.ACTIVE + + db.session.add(portfolio_role) + db.session.commit() diff --git a/atst/domain/portfolios/__init__.py b/atst/domain/portfolios/__init__.py new file mode 100644 index 00000000..cc5177ad --- /dev/null +++ b/atst/domain/portfolios/__init__.py @@ -0,0 +1 @@ +from .portfolios import Portfolios, PortfolioError diff --git a/atst/domain/portfolios/portfolios.py b/atst/domain/portfolios/portfolios.py new file mode 100644 index 00000000..d3b5269f --- /dev/null +++ b/atst/domain/portfolios/portfolios.py @@ -0,0 +1,182 @@ +from atst.domain.roles import Roles +from atst.domain.authz import Authorization +from atst.models.permissions import Permissions +from atst.domain.users import Users +from atst.domain.portfolio_roles import PortfolioRoles +from atst.domain.environments import Environments +from atst.models.portfolio_role import Status as PortfolioRoleStatus + +from .query import PortfoliosQuery +from .scopes import ScopedPortfolio + + +class PortfolioError(Exception): + pass + + +class Portfolios(object): + @classmethod + def create(cls, user, name): + portfolio = PortfoliosQuery.create(name=name) + Portfolios._create_portfolio_role( + user, portfolio, "owner", status=PortfolioRoleStatus.ACTIVE + ) + PortfoliosQuery.add_and_commit(portfolio) + return portfolio + + @classmethod + def create_from_request(cls, request, name=None): + name = name or request.displayname + portfolio = PortfoliosQuery.create(request=request, name=name) + Portfolios._create_portfolio_role( + request.creator, portfolio, "owner", status=PortfolioRoleStatus.ACTIVE + ) + PortfoliosQuery.add_and_commit(portfolio) + return portfolio + + @classmethod + def get(cls, user, portfolio_id): + portfolio = PortfoliosQuery.get(portfolio_id) + Authorization.check_portfolio_permission( + user, portfolio, Permissions.VIEW_WORKSPACE, "get portfolio" + ) + + return ScopedPortfolio(user, portfolio) + + @classmethod + def get_for_update_applications(cls, user, portfolio_id): + portfolio = PortfoliosQuery.get(portfolio_id) + Authorization.check_portfolio_permission( + user, portfolio, Permissions.ADD_APPLICATION_IN_WORKSPACE, "add application" + ) + + return portfolio + + @classmethod + def get_for_update_information(cls, user, portfolio_id): + portfolio = PortfoliosQuery.get(portfolio_id) + Authorization.check_portfolio_permission( + user, + portfolio, + Permissions.EDIT_WORKSPACE_INFORMATION, + "update portfolio information", + ) + + return portfolio + + @classmethod + def get_for_update_member(cls, user, portfolio_id): + portfolio = PortfoliosQuery.get(portfolio_id) + Authorization.check_portfolio_permission( + user, + portfolio, + Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, + "update a portfolio member", + ) + + return portfolio + + @classmethod + def get_by_request(cls, request): + return PortfoliosQuery.get_by_request(request) + + @classmethod + def get_with_members(cls, user, portfolio_id): + portfolio = PortfoliosQuery.get(portfolio_id) + Authorization.check_portfolio_permission( + user, + portfolio, + Permissions.VIEW_WORKSPACE_MEMBERS, + "view portfolio members", + ) + + return portfolio + + @classmethod + def for_user(cls, user): + if Authorization.has_atat_permission(user, Permissions.VIEW_WORKSPACE): + portfolios = PortfoliosQuery.get_all() + else: + portfolios = PortfoliosQuery.get_for_user(user) + return portfolios + + @classmethod + def create_member(cls, user, portfolio, data): + Authorization.check_portfolio_permission( + user, + portfolio, + Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, + "create portfolio member", + ) + + new_user = Users.get_or_create_by_dod_id( + data["dod_id"], + first_name=data["first_name"], + last_name=data["last_name"], + email=data["email"], + atat_role_name="default", + provisional=True, + ) + return Portfolios.add_member(portfolio, new_user, data["portfolio_role"]) + + @classmethod + def add_member(cls, portfolio, member, role_name): + portfolio_role = PortfolioRoles.add(member, portfolio.id, role_name) + return portfolio_role + + @classmethod + def update_member(cls, user, portfolio, member, role_name): + Authorization.check_portfolio_permission( + user, + portfolio, + Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, + "edit portfolio member", + ) + + return PortfolioRoles.update_role(member, role_name) + + @classmethod + def _create_portfolio_role( + cls, user, portfolio, role_name, status=PortfolioRoleStatus.PENDING + ): + role = Roles.get(role_name) + portfolio_role = PortfoliosQuery.create_portfolio_role( + user, role, portfolio, status=status + ) + PortfoliosQuery.add_and_commit(portfolio_role) + return portfolio_role + + @classmethod + def update(cls, portfolio, new_data): + if "name" in new_data: + portfolio.name = new_data["name"] + + PortfoliosQuery.add_and_commit(portfolio) + + @classmethod + def can_revoke_access_for(cls, portfolio, portfolio_role): + return ( + portfolio_role.user != portfolio.owner + and portfolio_role.status == PortfolioRoleStatus.ACTIVE + ) + + @classmethod + def revoke_access(cls, user, portfolio_id, portfolio_role_id): + portfolio = PortfoliosQuery.get(portfolio_id) + Authorization.check_portfolio_permission( + user, + portfolio, + Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, + "revoke portfolio access", + ) + portfolio_role = PortfolioRoles.get_by_id(portfolio_role_id) + + if not Portfolios.can_revoke_access_for(portfolio, portfolio_role): + raise PortfolioError("cannot revoke portfolio access for this user") + + portfolio_role.status = PortfolioRoleStatus.DISABLED + for environment in portfolio.all_environments: + Environments.revoke_access(user, environment, portfolio_role.user) + PortfoliosQuery.add_and_commit(portfolio_role) + + return portfolio_role diff --git a/atst/domain/portfolios/query.py b/atst/domain/portfolios/query.py new file mode 100644 index 00000000..486009eb --- /dev/null +++ b/atst/domain/portfolios/query.py @@ -0,0 +1,34 @@ +from sqlalchemy.orm.exc import NoResultFound + +from atst.database import db +from atst.domain.common import Query +from atst.domain.exceptions import NotFoundError +from atst.models.portfolio import Portfolio +from atst.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus + + +class PortfoliosQuery(Query): + model = Portfolio + + @classmethod + def get_by_request(cls, request): + try: + portfolio = db.session.query(Portfolio).filter_by(request=request).one() + except NoResultFound: + raise NotFoundError("portfolio") + + return portfolio + + @classmethod + def get_for_user(cls, user): + return ( + db.session.query(Portfolio) + .join(PortfolioRole) + .filter(PortfolioRole.user == user) + .filter(PortfolioRole.status == PortfolioRoleStatus.ACTIVE) + .all() + ) + + @classmethod + def create_portfolio_role(cls, user, role, portfolio, **kwargs): + return PortfolioRole(user=user, role=role, portfolio=portfolio, **kwargs) diff --git a/atst/domain/workspaces/scopes.py b/atst/domain/portfolios/scopes.py similarity index 84% rename from atst/domain/workspaces/scopes.py rename to atst/domain/portfolios/scopes.py index 69c64642..c74a94d4 100644 --- a/atst/domain/workspaces/scopes.py +++ b/atst/domain/portfolios/scopes.py @@ -21,16 +21,16 @@ class ScopedResource(object): return self.resource == other -class ScopedWorkspace(ScopedResource): +class ScopedPortfolio(ScopedResource): """ - An object that obeys the same API as a Workspace, but with the added + An object that obeys the same API as a Portfolio, but with the added functionality that it only returns sub-resources (applications and environments) that the given user is allowed to see. """ @property def applications(self): - can_view_all_applications = Authorization.has_workspace_permission( + can_view_all_applications = Authorization.has_portfolio_permission( self.user, self.resource, Permissions.VIEW_APPLICATION_IN_WORKSPACE ) @@ -46,16 +46,16 @@ class ScopedWorkspace(ScopedResource): class ScopedApplication(ScopedResource): """ - An object that obeys the same API as a Workspace, but with the added + An object that obeys the same API as a Portfolio, but with the added functionality that it only returns sub-resources (environments) that the given user is allowed to see. """ @property def environments(self): - can_view_all_environments = Authorization.has_workspace_permission( + can_view_all_environments = Authorization.has_portfolio_permission( self.user, - self.resource.workspace, + self.resource.portfolio, Permissions.VIEW_ENVIRONMENT_IN_APPLICATION, ) diff --git a/atst/domain/reports.py b/atst/domain/reports.py index 47f558fb..96085afb 100644 --- a/atst/domain/reports.py +++ b/atst/domain/reports.py @@ -3,15 +3,15 @@ from flask import current_app class Reports: @classmethod - def workspace_totals(cls, workspace): - budget = current_app.csp.reports.get_budget(workspace) - spent = current_app.csp.reports.get_total_spending(workspace) + def portfolio_totals(cls, portfolio): + budget = current_app.csp.reports.get_budget(portfolio) + spent = current_app.csp.reports.get_total_spending(portfolio) return {"budget": budget, "spent": spent} @classmethod - def monthly_totals(cls, workspace): - return current_app.csp.reports.monthly_totals(workspace) + def monthly_totals(cls, portfolio): + return current_app.csp.reports.monthly_totals(portfolio) @classmethod - def cumulative_budget(cls, workspace): - return current_app.csp.reports.cumulative_budget(workspace) + def cumulative_budget(cls, portfolio): + return current_app.csp.reports.cumulative_budget(portfolio) diff --git a/atst/domain/requests/requests.py b/atst/domain/requests/requests.py index 6fc1e62d..6b30c366 100644 --- a/atst/domain/requests/requests.py +++ b/atst/domain/requests/requests.py @@ -1,6 +1,6 @@ import dateutil -from atst.domain.workspaces import Workspaces +from atst.domain.portfolios import Portfolios from atst.models.request_revision import RequestRevision from atst.models.request_status_event import RequestStatusEvent, RequestStatus from atst.models.request_review import RequestReview @@ -99,25 +99,25 @@ class Requests(object): return RequestsQuery.add_and_commit(request) @classmethod - def approve_and_create_workspace(cls, request): + def approve_and_create_portfolio(cls, request): approved_request = Requests.set_status(request, RequestStatus.APPROVED) - workspace = Workspaces.create_from_request(approved_request) + portfolio = Portfolios.create_from_request(approved_request) RequestsQuery.add_and_commit(approved_request) - return workspace + return portfolio @classmethod - def auto_approve_and_create_workspace( + def auto_approve_and_create_portfolio( cls, request, reason="Financial verification information found in Electronic Document Access API", ): - workspace = Requests.approve_and_create_workspace(request) + portfolio = Requests.approve_and_create_portfolio(request) Requests._add_review( user=None, request=request, review_data={"comment": reason} ) - return workspace + return portfolio @classmethod def set_status(cls, request, status: RequestStatus): @@ -214,7 +214,7 @@ class Requests(object): if request.status == RequestStatus.PENDING_CCPO_ACCEPTANCE: Requests.set_status(request, RequestStatus.PENDING_FINANCIAL_VERIFICATION) elif request.status == RequestStatus.PENDING_CCPO_APPROVAL: - Requests.approve_and_create_workspace(request) + Requests.approve_and_create_portfolio(request) return Requests._add_review(user=user, request=request, review_data=review_data) diff --git a/atst/domain/roles.py b/atst/domain/roles.py index d0b41aa3..329bb8e9 100644 --- a/atst/domain/roles.py +++ b/atst/domain/roles.py @@ -57,7 +57,7 @@ ATAT_ROLES = [ WORKSPACE_ROLES = [ { "name": "owner", - "display_name": "Workspace Owner", + "display_name": "Portfolio Owner", "description": "Adds, edits, deactivates access to all applications, environments, and members. Views budget reports. Initiates and edits JEDI Cloud requests.", "permissions": [ Permissions.REQUEST_JEDI_WORKSPACE, @@ -131,7 +131,7 @@ WORKSPACE_ROLES = [ { "name": "billing_auditor", "display_name": "Billing Auditor", - "description": "Views only the applications and environments they are granted access to. Can also view budgets and reports associated with the workspace.", + "description": "Views only the applications and environments they are granted access to. Can also view budgets and reports associated with the portfolio.", "permissions": [ Permissions.VIEW_USAGE_REPORT, Permissions.VIEW_USAGE_DOLLARS, diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index e0e76573..95a20d62 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -3,7 +3,7 @@ from sqlalchemy.orm.exc import NoResultFound from atst.database import db from atst.models.task_order import TaskOrder from atst.models.permissions import Permissions -from atst.domain.workspaces import Workspaces +from atst.domain.portfolios import Portfolios from atst.domain.authz import Authorization from .exceptions import NotFoundError @@ -63,11 +63,11 @@ class TaskOrders(object): raise NotFoundError("task_order") @classmethod - def create(cls, creator, workspace): - Authorization.check_workspace_permission( - creator, workspace, Permissions.UPDATE_TASK_ORDER, "add task order" + def create(cls, creator, portfolio): + Authorization.check_portfolio_permission( + creator, portfolio, Permissions.UPDATE_TASK_ORDER, "add task order" ) - task_order = TaskOrder(workspace=workspace, creator=creator) + task_order = TaskOrder(portfolio=portfolio, creator=creator) db.session.add(task_order) db.session.commit() @@ -116,39 +116,39 @@ class TaskOrders(object): @classmethod def add_officer(cls, user, task_order, officer_type, officer_data): - Authorization.check_workspace_permission( + Authorization.check_portfolio_permission( user, - task_order.workspace, + task_order.portfolio, Permissions.ADD_TASK_ORDER_OFFICER, "add task order officer", ) if officer_type in TaskOrders.OFFICERS: - workspace = task_order.workspace + portfolio = task_order.portfolio existing_member = next( ( member - for member in workspace.members + for member in portfolio.members if member.user.dod_id == officer_data["dod_id"] ), None, ) if existing_member: - workspace_user = existing_member.user + portfolio_user = existing_member.user else: - member = Workspaces.create_member( - user, workspace, {**officer_data, "workspace_role": "officer"} + member = Portfolios.create_member( + user, portfolio, {**officer_data, "portfolio_role": "officer"} ) - workspace_user = member.user + portfolio_user = member.user - setattr(task_order, officer_type, workspace_user) + setattr(task_order, officer_type, portfolio_user) db.session.add(task_order) db.session.commit() - return workspace_user + return portfolio_user else: raise TaskOrderError( "{} is not an officer role on task orders".format(officer_type) diff --git a/atst/domain/workspace_roles.py b/atst/domain/workspace_roles.py deleted file mode 100644 index 5996a483..00000000 --- a/atst/domain/workspace_roles.py +++ /dev/null @@ -1,167 +0,0 @@ -from sqlalchemy.orm.exc import NoResultFound - -from atst.database import db -from atst.models.workspace_role import ( - WorkspaceRole, - Status as WorkspaceRoleStatus, - MEMBER_STATUSES, -) -from atst.models.user import User - -from .roles import Roles -from .users import Users -from .exceptions import NotFoundError - - -MEMBER_STATUS_CHOICES = [ - dict(name=key, display_name=value) for key, value in MEMBER_STATUSES.items() -] - - -class WorkspaceRoles(object): - @classmethod - def get(cls, workspace_id, user_id): - try: - workspace_role = ( - db.session.query(WorkspaceRole) - .join(User) - .filter(User.id == user_id, WorkspaceRole.workspace_id == workspace_id) - .one() - ) - except NoResultFound: - raise NotFoundError("workspace_role") - - return workspace_role - - @classmethod - def get_by_id(cls, id_): - try: - return db.session.query(WorkspaceRole).filter(WorkspaceRole.id == id_).one() - except NoResultFound: - raise NotFoundError("workspace_role") - - @classmethod - def _get_active_workspace_role(cls, workspace_id, user_id): - try: - return ( - db.session.query(WorkspaceRole) - .join(User) - .filter(User.id == user_id, WorkspaceRole.workspace_id == workspace_id) - .filter(WorkspaceRole.status == WorkspaceRoleStatus.ACTIVE) - .one() - ) - except NoResultFound: - return None - - @classmethod - def workspace_role_permissions(cls, workspace, user): - workspace_role = WorkspaceRoles._get_active_workspace_role( - workspace.id, user.id - ) - atat_permissions = set(user.atat_role.permissions) - workspace_permissions = ( - [] if workspace_role is None else workspace_role.role.permissions - ) - return set(workspace_permissions).union(atat_permissions) - - @classmethod - def _get_workspace_role(cls, user, workspace_id): - try: - existing_workspace_role = ( - db.session.query(WorkspaceRole) - .filter( - WorkspaceRole.user == user, - WorkspaceRole.workspace_id == workspace_id, - ) - .one() - ) - return existing_workspace_role - except NoResultFound: - raise NotFoundError("workspace role") - - @classmethod - def add(cls, user, workspace_id, role_name): - role = Roles.get(role_name) - - new_workspace_role = None - try: - existing_workspace_role = ( - db.session.query(WorkspaceRole) - .filter( - WorkspaceRole.user == user, - WorkspaceRole.workspace_id == workspace_id, - ) - .one() - ) - new_workspace_role = existing_workspace_role - new_workspace_role.role = role - except NoResultFound: - new_workspace_role = WorkspaceRole( - user=user, - role_id=role.id, - workspace_id=workspace_id, - status=WorkspaceRoleStatus.PENDING, - ) - - user.workspace_roles.append(new_workspace_role) - db.session.add(user) - db.session.commit() - - return new_workspace_role - - @classmethod - def update_role(cls, workspace_role, role_name): - new_role = Roles.get(role_name) - workspace_role.role = new_role - - db.session.add(workspace_role) - db.session.commit() - return workspace_role - - @classmethod - def add_many(cls, workspace_id, workspace_role_dicts): - workspace_roles = [] - - for user_dict in workspace_role_dicts: - try: - user = Users.get(user_dict["id"]) - except NoResultFound: - default_role = Roles.get("developer") - user = User(id=user_dict["id"], atat_role=default_role) - - try: - role = Roles.get(user_dict["workspace_role"]) - except NoResultFound: - raise NotFoundError("role") - - try: - existing_workspace_role = ( - db.session.query(WorkspaceRole) - .filter( - WorkspaceRole.user == user, - WorkspaceRole.workspace_id == workspace_id, - ) - .one() - ) - new_workspace_role = existing_workspace_role - new_workspace_role.role = role - except NoResultFound: - new_workspace_role = WorkspaceRole( - user=user, role_id=role.id, workspace_id=workspace_id - ) - - user.workspace_roles.append(new_workspace_role) - workspace_roles.append(new_workspace_role) - - db.session.add(user) - - db.session.commit() - - return workspace_roles - - @classmethod - def enable(cls, workspace_role): - workspace_role.status = WorkspaceRoleStatus.ACTIVE - - db.session.add(workspace_role) - db.session.commit() diff --git a/atst/domain/workspaces/__init__.py b/atst/domain/workspaces/__init__.py deleted file mode 100644 index b2d10ac7..00000000 --- a/atst/domain/workspaces/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .workspaces import Workspaces, WorkspaceError diff --git a/atst/domain/workspaces/query.py b/atst/domain/workspaces/query.py deleted file mode 100644 index 6ac00d8a..00000000 --- a/atst/domain/workspaces/query.py +++ /dev/null @@ -1,34 +0,0 @@ -from sqlalchemy.orm.exc import NoResultFound - -from atst.database import db -from atst.domain.common import Query -from atst.domain.exceptions import NotFoundError -from atst.models.workspace import Workspace -from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus - - -class WorkspacesQuery(Query): - model = Workspace - - @classmethod - def get_by_request(cls, request): - try: - workspace = db.session.query(Workspace).filter_by(request=request).one() - except NoResultFound: - raise NotFoundError("workspace") - - return workspace - - @classmethod - def get_for_user(cls, user): - return ( - db.session.query(Workspace) - .join(WorkspaceRole) - .filter(WorkspaceRole.user == user) - .filter(WorkspaceRole.status == WorkspaceRoleStatus.ACTIVE) - .all() - ) - - @classmethod - def create_workspace_role(cls, user, role, workspace, **kwargs): - return WorkspaceRole(user=user, role=role, workspace=workspace, **kwargs) diff --git a/atst/domain/workspaces/workspaces.py b/atst/domain/workspaces/workspaces.py deleted file mode 100644 index e8be09de..00000000 --- a/atst/domain/workspaces/workspaces.py +++ /dev/null @@ -1,182 +0,0 @@ -from atst.domain.roles import Roles -from atst.domain.authz import Authorization -from atst.models.permissions import Permissions -from atst.domain.users import Users -from atst.domain.workspace_roles import WorkspaceRoles -from atst.domain.environments import Environments -from atst.models.workspace_role import Status as WorkspaceRoleStatus - -from .query import WorkspacesQuery -from .scopes import ScopedWorkspace - - -class WorkspaceError(Exception): - pass - - -class Workspaces(object): - @classmethod - def create(cls, user, name): - workspace = WorkspacesQuery.create(name=name) - Workspaces._create_workspace_role( - user, workspace, "owner", status=WorkspaceRoleStatus.ACTIVE - ) - WorkspacesQuery.add_and_commit(workspace) - return workspace - - @classmethod - def create_from_request(cls, request, name=None): - name = name or request.displayname - workspace = WorkspacesQuery.create(request=request, name=name) - Workspaces._create_workspace_role( - request.creator, workspace, "owner", status=WorkspaceRoleStatus.ACTIVE - ) - WorkspacesQuery.add_and_commit(workspace) - return workspace - - @classmethod - def get(cls, user, workspace_id): - workspace = WorkspacesQuery.get(workspace_id) - Authorization.check_workspace_permission( - user, workspace, Permissions.VIEW_WORKSPACE, "get workspace" - ) - - return ScopedWorkspace(user, workspace) - - @classmethod - def get_for_update_applications(cls, user, workspace_id): - workspace = WorkspacesQuery.get(workspace_id) - Authorization.check_workspace_permission( - user, workspace, Permissions.ADD_APPLICATION_IN_WORKSPACE, "add application" - ) - - return workspace - - @classmethod - def get_for_update_information(cls, user, workspace_id): - workspace = WorkspacesQuery.get(workspace_id) - Authorization.check_workspace_permission( - user, - workspace, - Permissions.EDIT_WORKSPACE_INFORMATION, - "update workspace information", - ) - - return workspace - - @classmethod - def get_for_update_member(cls, user, workspace_id): - workspace = WorkspacesQuery.get(workspace_id) - Authorization.check_workspace_permission( - user, - workspace, - Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, - "update a workspace member", - ) - - return workspace - - @classmethod - def get_by_request(cls, request): - return WorkspacesQuery.get_by_request(request) - - @classmethod - def get_with_members(cls, user, workspace_id): - workspace = WorkspacesQuery.get(workspace_id) - Authorization.check_workspace_permission( - user, - workspace, - Permissions.VIEW_WORKSPACE_MEMBERS, - "view workspace members", - ) - - return workspace - - @classmethod - def for_user(cls, user): - if Authorization.has_atat_permission(user, Permissions.VIEW_WORKSPACE): - workspaces = WorkspacesQuery.get_all() - else: - workspaces = WorkspacesQuery.get_for_user(user) - return workspaces - - @classmethod - def create_member(cls, user, workspace, data): - Authorization.check_workspace_permission( - user, - workspace, - Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, - "create workspace member", - ) - - new_user = Users.get_or_create_by_dod_id( - data["dod_id"], - first_name=data["first_name"], - last_name=data["last_name"], - email=data["email"], - atat_role_name="default", - provisional=True, - ) - return Workspaces.add_member(workspace, new_user, data["workspace_role"]) - - @classmethod - def add_member(cls, workspace, member, role_name): - workspace_role = WorkspaceRoles.add(member, workspace.id, role_name) - return workspace_role - - @classmethod - def update_member(cls, user, workspace, member, role_name): - Authorization.check_workspace_permission( - user, - workspace, - Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, - "edit workspace member", - ) - - return WorkspaceRoles.update_role(member, role_name) - - @classmethod - def _create_workspace_role( - cls, user, workspace, role_name, status=WorkspaceRoleStatus.PENDING - ): - role = Roles.get(role_name) - workspace_role = WorkspacesQuery.create_workspace_role( - user, role, workspace, status=status - ) - WorkspacesQuery.add_and_commit(workspace_role) - return workspace_role - - @classmethod - def update(cls, workspace, new_data): - if "name" in new_data: - workspace.name = new_data["name"] - - WorkspacesQuery.add_and_commit(workspace) - - @classmethod - def can_revoke_access_for(cls, workspace, workspace_role): - return ( - workspace_role.user != workspace.owner - and workspace_role.status == WorkspaceRoleStatus.ACTIVE - ) - - @classmethod - def revoke_access(cls, user, workspace_id, workspace_role_id): - workspace = WorkspacesQuery.get(workspace_id) - Authorization.check_workspace_permission( - user, - workspace, - Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, - "revoke workspace access", - ) - workspace_role = WorkspaceRoles.get_by_id(workspace_role_id) - - if not Workspaces.can_revoke_access_for(workspace, workspace_role): - raise WorkspaceError("cannot revoke workspace access for this user") - - workspace_role.status = WorkspaceRoleStatus.DISABLED - for environment in workspace.all_environments: - Environments.revoke_access(user, environment, workspace_role.user) - WorkspacesQuery.add_and_commit(workspace_role) - - return workspace_role diff --git a/atst/forms/data.py b/atst/forms/data.py index cce774c6..6e21aaba 100644 --- a/atst/forms/data.py +++ b/atst/forms/data.py @@ -162,7 +162,7 @@ ENVIRONMENT_ROLES = [ ENV_ROLE_MODAL_DESCRIPTION = { "header": "Assign Environment Role", - "body": "An environment role determines the permissions a member of the workspace assumes when using the JEDI Cloud.

A member may have different environment roles across different applications. A member can only have one assigned environment role in a given environment.", + "body": "An environment role determines the permissions a member of the portfolio assumes when using the JEDI Cloud.

A member may have different environment roles across different applications. A member can only have one assigned environment role in a given environment.", } FUNDING_TYPES = [ diff --git a/atst/forms/edit_member.py b/atst/forms/edit_member.py index e2683d95..45563608 100644 --- a/atst/forms/edit_member.py +++ b/atst/forms/edit_member.py @@ -11,8 +11,8 @@ class EditMemberForm(FlaskForm): # This form also accepts a field for each environment in each application # that the user is a member of - workspace_role = SelectField( - translate("forms.edit_member.workspace_role_label"), + portfolio_role = SelectField( + translate("forms.edit_member.portfolio_role_label"), choices=WORKSPACE_ROLES, validators=[Required()], ) diff --git a/atst/forms/new_member.py b/atst/forms/new_member.py index 0fae4728..15db93a9 100644 --- a/atst/forms/new_member.py +++ b/atst/forms/new_member.py @@ -25,10 +25,10 @@ class NewMemberForm(FlaskForm): translate("forms.new_member.dod_id_label"), validators=[Required(), Length(min=10), IsNumber()], ) - workspace_role = SelectField( - translate("forms.new_member.workspace_role_label"), + portfolio_role = SelectField( + translate("forms.new_member.portfolio_role_label"), choices=WORKSPACE_ROLES, validators=[Required()], default="", - description=translate("forms.new_member.workspace_role_description"), + description=translate("forms.new_member.portfolio_role_description"), ) diff --git a/atst/forms/new_request.py b/atst/forms/new_request.py index ff89d58d..e160070c 100644 --- a/atst/forms/new_request.py +++ b/atst/forms/new_request.py @@ -181,7 +181,7 @@ class InformationAboutYouForm(CacheableForm): date_latest_training = inherit_field(USER_FIELDS["date_latest_training"]) -class WorkspaceOwnerForm(CacheableForm): +class PortfolioOwnerForm(CacheableForm): def validate(self, *args, **kwargs): if self.am_poc.data: # Prepend Optional validators so that the validation chain diff --git a/atst/forms/workspace.py b/atst/forms/portfolio.py similarity index 68% rename from atst/forms/workspace.py rename to atst/forms/portfolio.py index 972af45f..ec26072d 100644 --- a/atst/forms/workspace.py +++ b/atst/forms/portfolio.py @@ -5,14 +5,14 @@ from .forms import CacheableForm from atst.utils.localization import translate -class WorkspaceForm(CacheableForm): +class PortfolioForm(CacheableForm): name = StringField( - translate("forms.workspace.name_label"), + translate("forms.portfolio.name_label"), validators=[ Length( min=4, max=100, - message=translate("forms.workspace.name_length_validation_message"), + message=translate("forms.portfolio.name_length_validation_message"), ) ], ) diff --git a/atst/models/__init__.py b/atst/models/__init__.py index 6705589c..b62161bf 100644 --- a/atst/models/__init__.py +++ b/atst/models/__init__.py @@ -7,10 +7,10 @@ from .request_status_event import RequestStatusEvent from .permissions import Permissions from .role import Role from .user import User -from .workspace_role import WorkspaceRole +from .portfolio_role import PortfolioRole from .pe_number import PENumber from .legacy_task_order import LegacyTaskOrder -from .workspace import Workspace +from .portfolio import Portfolio from .application import Application from .environment import Environment from .attachment import Attachment diff --git a/atst/models/application.py b/atst/models/application.py index c3e0a05c..78bc0b9c 100644 --- a/atst/models/application.py +++ b/atst/models/application.py @@ -14,7 +14,7 @@ class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin): description = Column(String, nullable=False) workspace_id = Column(ForeignKey("workspaces.id"), nullable=False) - workspace = relationship("Workspace") + portfolio = relationship("Portfolio") environments = relationship("Environment", back_populates="application") @property @@ -22,6 +22,6 @@ class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin): return self.name def __repr__(self): # pragma: no cover - return "".format( - self.name, self.description, self.workspace.name, self.id + return "".format( + self.name, self.description, self.portfolio.name, self.id ) diff --git a/atst/models/audit_event.py b/atst/models/audit_event.py index 42f01433..dac66a84 100644 --- a/atst/models/audit_event.py +++ b/atst/models/audit_event.py @@ -15,7 +15,7 @@ class AuditEvent(Base, TimestampsMixin): user = relationship("User", backref="audit_events") workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), index=True) - workspace = relationship("Workspace", backref="audit_events") + portfolio = relationship("Portfolio", backref="audit_events") request_id = Column(UUID(as_uuid=True), ForeignKey("requests.id"), index=True) request = relationship("Request", backref="audit_events") diff --git a/atst/models/environment.py b/atst/models/environment.py index 2c4cb43c..cbffedf6 100644 --- a/atst/models/environment.py +++ b/atst/models/environment.py @@ -30,17 +30,17 @@ class Environment(Base, mixins.TimestampsMixin, mixins.AuditableMixin): return self.name @property - def workspace(self): - return self.application.workspace + def portfolio(self): + return self.application.portfolio - def auditable_workspace_id(self): + def auditable_portfolio_id(self): return self.application.workspace_id def __repr__(self): - return "".format( + return "".format( self.name, self.num_users, self.application.name, - self.application.workspace.name, + self.application.portfolio.name, self.id, ) diff --git a/atst/models/environment_role.py b/atst/models/environment_role.py index 158b5cfe..2846f23f 100644 --- a/atst/models/environment_role.py +++ b/atst/models/environment_role.py @@ -47,8 +47,8 @@ class EnvironmentRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): "environment_id": str(self.environment_id), "application": self.environment.application.name, "application_id": str(self.environment.project_id), - "workspace": self.environment.application.workspace.name, - "workspace_id": str(self.environment.application.workspace.id), + "portfolio": self.environment.application.portfolio.name, + "portfolio_id": str(self.environment.application.portfolio.id), } diff --git a/atst/models/invitation.py b/atst/models/invitation.py index cc5f97c1..95704913 100644 --- a/atst/models/invitation.py +++ b/atst/models/invitation.py @@ -30,8 +30,8 @@ class Invitation(Base, TimestampsMixin, AuditableMixin): workspace_role_id = Column( UUID(as_uuid=True), ForeignKey("workspace_roles.id"), index=True ) - workspace_role = relationship( - "WorkspaceRole", + portfolio_role = relationship( + "PortfolioRole", backref=backref("invitations", order_by="Invitation.time_created"), ) @@ -47,8 +47,8 @@ class Invitation(Base, TimestampsMixin, AuditableMixin): email = Column(String, nullable=False) def __repr__(self): - return "".format( - self.user_id, self.workspace_role_id, self.id, self.email + return "".format( + self.user_id, self.portfolio_role_id, self.id, self.email ) @property @@ -91,13 +91,13 @@ class Invitation(Base, TimestampsMixin, AuditableMixin): ] @property - def workspace(self): - if self.workspace_role: # pragma: no branch - return self.workspace_role.workspace + def portfolio(self): + if self.portfolio_role: # pragma: no branch + return self.portfolio_role.portfolio @property def user_name(self): - return self.workspace_role.user.full_name + return self.portfolio_role.user.full_name @property def is_revokable(self): @@ -122,5 +122,5 @@ class Invitation(Base, TimestampsMixin, AuditableMixin): return change_set @property - def workspace_id(self): - return self.workspace_role.workspace_id + def portfolio_id(self): + return self.portfolio_role.workspace_id diff --git a/atst/models/mixins/auditable.py b/atst/models/mixins/auditable.py index c765a36f..a37f2ec6 100644 --- a/atst/models/mixins/auditable.py +++ b/atst/models/mixins/auditable.py @@ -13,7 +13,7 @@ class AuditableMixin(object): @staticmethod def create_audit_event(connection, resource, action): user_id = getattr_path(g, "current_user.id") - workspace_id = resource.workspace_id + portfolio_id = resource.workspace_id request_id = resource.request_id resource_type = resource.resource_type display_name = resource.displayname @@ -23,7 +23,7 @@ class AuditableMixin(object): audit_event = AuditEvent( user_id=user_id, - workspace_id=workspace_id, + workspace_id=portfolio_id, request_id=request_id, resource_type=resource_type, resource_id=resource.id, diff --git a/atst/models/permissions.py b/atst/models/permissions.py index 15d103c5..f293fdec 100644 --- a/atst/models/permissions.py +++ b/atst/models/permissions.py @@ -1,10 +1,10 @@ class Permissions(object): VIEW_AUDIT_LOG = "view_audit_log" - VIEW_WORKSPACE_AUDIT_LOG = "view_workspace_audit_log" - REQUEST_JEDI_WORKSPACE = "request_jedi_workspace" + VIEW_WORKSPACE_AUDIT_LOG = "view_portfolio_audit_log" + REQUEST_JEDI_WORKSPACE = "request_jedi_portfolio" VIEW_ORIGINAL_JEDI_REQEUST = "view_original_jedi_request" REVIEW_AND_APPROVE_JEDI_WORKSPACE_REQUEST = ( - "review_and_approve_jedi_workspace_request" + "review_and_approve_jedi_portfolio_request" ) MODIFY_ATAT_ROLE_PERMISSIONS = "modify_atat_role_permissions" CREATE_CSP_ROLE = "create_csp_role" @@ -22,18 +22,18 @@ class Permissions(object): VIEW_ASSIGNED_ATAT_ROLE_CONFIGURATIONS = "view_assigned_atat_role_configurations" VIEW_ASSIGNED_CSP_ROLE_CONFIGURATIONS = "view_assigned_csp_role_configurations" - EDIT_WORKSPACE_INFORMATION = "edit_workspace_information" - DEACTIVATE_WORKSPACE = "deactivate_workspace" + EDIT_WORKSPACE_INFORMATION = "edit_portfolio_information" + DEACTIVATE_WORKSPACE = "deactivate_portfolio" VIEW_ATAT_PERMISSIONS = "view_atat_permissions" - TRANSFER_OWNERSHIP_OF_WORKSPACE = "transfer_ownership_of_workspace" - VIEW_WORKSPACE_MEMBERS = "view_workspace_members" - VIEW_WORKSPACE = "view_workspace" + TRANSFER_OWNERSHIP_OF_WORKSPACE = "transfer_ownership_of_portfolio" + VIEW_WORKSPACE_MEMBERS = "view_portfolio_members" + VIEW_WORKSPACE = "view_portfolio" - ADD_APPLICATION_IN_WORKSPACE = "add_application_in_workspace" - DELETE_APPLICATION_IN_WORKSPACE = "delete_application_in_workspace" - DEACTIVATE_APPLICATION_IN_WORKSPACE = "deactivate_application_in_workspace" - VIEW_APPLICATION_IN_WORKSPACE = "view_application_in_workspace" - RENAME_APPLICATION_IN_WORKSPACE = "rename_application_in_workspace" + ADD_APPLICATION_IN_WORKSPACE = "add_application_in_portfolio" + DELETE_APPLICATION_IN_WORKSPACE = "delete_application_in_portfolio" + DEACTIVATE_APPLICATION_IN_WORKSPACE = "deactivate_application_in_portfolio" + VIEW_APPLICATION_IN_WORKSPACE = "view_application_in_portfolio" + RENAME_APPLICATION_IN_WORKSPACE = "rename_application_in_portfolio" ADD_ENVIRONMENT_IN_APPLICATION = "add_environment_in_application" DELETE_ENVIRONMENT_IN_APPLICATION = "delete_environment_in_application" @@ -41,8 +41,8 @@ class Permissions(object): VIEW_ENVIRONMENT_IN_APPLICATION = "view_environment_in_application" RENAME_ENVIRONMENT_IN_APPLICATION = "rename_environment_in_application" - ADD_TAG_TO_WORKSPACE = "add_tag_to_workspace" - REMOVE_TAG_FROM_WORKSPACE = "remove_tag_from_workspace" + ADD_TAG_TO_WORKSPACE = "add_tag_to_portfolio" + REMOVE_TAG_FROM_WORKSPACE = "remove_tag_from_portfolio" VIEW_TASK_ORDER = "view_task_order" UPDATE_TASK_ORDER = "update_task_order" diff --git a/atst/models/workspace.py b/atst/models/portfolio.py similarity index 63% rename from atst/models/workspace.py rename to atst/models/portfolio.py index fccc6ee4..a40d9e95 100644 --- a/atst/models/workspace.py +++ b/atst/models/portfolio.py @@ -3,28 +3,28 @@ from sqlalchemy.orm import relationship from itertools import chain from atst.models import Base, mixins, types -from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus +from atst.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus from atst.utils import first_or_none from atst.database import db -class Workspace(Base, mixins.TimestampsMixin, mixins.AuditableMixin): +class Portfolio(Base, mixins.TimestampsMixin, mixins.AuditableMixin): __tablename__ = "workspaces" id = types.Id() name = Column(String) request_id = Column(ForeignKey("requests.id"), nullable=True) - applications = relationship("Application", back_populates="workspace") - roles = relationship("WorkspaceRole") + applications = relationship("Application", back_populates="portfolio") + roles = relationship("PortfolioRole") task_orders = relationship("TaskOrder") @property def owner(self): - def _is_workspace_owner(workspace_role): - return workspace_role.role.name == "owner" + def _is_portfolio_owner(portfolio_role): + return portfolio_role.role.name == "owner" - owner = first_or_none(_is_workspace_owner, self.roles) + owner = first_or_none(_is_portfolio_owner, self.roles) return owner.user if owner else None @property @@ -42,9 +42,9 @@ class Workspace(Base, mixins.TimestampsMixin, mixins.AuditableMixin): @property def members(self): return ( - db.session.query(WorkspaceRole) - .filter(WorkspaceRole.workspace_id == self.id) - .filter(WorkspaceRole.status != WorkspaceRoleStatus.DISABLED) + db.session.query(PortfolioRole) + .filter(PortfolioRole.portfolio_id == self.id) + .filter(PortfolioRole.status != PortfolioRoleStatus.DISABLED) .all() ) @@ -56,10 +56,10 @@ class Workspace(Base, mixins.TimestampsMixin, mixins.AuditableMixin): def all_environments(self): return list(chain.from_iterable(p.environments for p in self.applications)) - def auditable_workspace_id(self): + def auditable_portfolio_id(self): return self.id def __repr__(self): - return "".format( + return "".format( self.name, self.request_id, self.user_count, self.id ) diff --git a/atst/models/workspace_role.py b/atst/models/portfolio_role.py similarity index 88% rename from atst/models/workspace_role.py rename to atst/models/portfolio_role.py index 84c64a8f..ba38d9f6 100644 --- a/atst/models/workspace_role.py +++ b/atst/models/portfolio_role.py @@ -30,14 +30,14 @@ class Status(Enum): PENDING = "pending" -class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): +class PortfolioRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): __tablename__ = "workspace_roles" id = Id() workspace_id = Column( UUID(as_uuid=True), ForeignKey("workspaces.id"), index=True, nullable=False ) - workspace = relationship("Workspace", back_populates="roles") + portfolio = relationship("Portfolio", back_populates="roles") role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=False) role = relationship("Role") @@ -49,8 +49,8 @@ class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): status = Column(SQLAEnum(Status, native_enum=False), default=Status.PENDING) def __repr__(self): - return "".format( - self.role.name, self.workspace.name, self.user_id, self.id + return "".format( + self.role.name, self.portfolio.name, self.user_id, self.id ) @property @@ -127,8 +127,8 @@ class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): db.session.query(EnvironmentRole) .join(EnvironmentRole.environment) .join(Environment.application) - .join(Application.workspace) - .filter(Application.workspace_id == self.workspace_id) + .join(Application.portfolio) + .filter(Application.portfolio_id == self.portfolio_id) .filter(EnvironmentRole.user_id == self.user_id) .count() ) @@ -139,8 +139,8 @@ class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): db.session.query(EnvironmentRole) .join(EnvironmentRole.environment) .join(Environment.application) - .join(Application.workspace) - .filter(Application.workspace_id == self.workspace_id) + .join(Application.portfolio) + .filter(Application.portfolio_id == self.portfolio_id) .filter(EnvironmentRole.user_id == self.user_id) .all() ) @@ -157,8 +157,8 @@ class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): Index( - "workspace_role_user_workspace", - WorkspaceRole.user_id, - WorkspaceRole.workspace_id, + "portfolio_role_user_portfolio", + PortfolioRole.user_id, + PortfolioRole.workspace_id, unique=True, ) diff --git a/atst/models/request.py b/atst/models/request.py index ae065647..68243468 100644 --- a/atst/models/request.py +++ b/atst/models/request.py @@ -34,7 +34,7 @@ class Request(Base, mixins.TimestampsMixin, mixins.AuditableMixin): "RequestStatusEvent", backref="request", order_by="RequestStatusEvent.sequence" ) - workspace = relationship("Workspace", uselist=False, backref="request") + portfolio = relationship("Portfolio", uselist=False, backref="request") user_id = Column(ForeignKey("users.id"), nullable=False) creator = relationship("User", backref="owned_requests") diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 19d8a0ab..6a8f7cad 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -25,7 +25,7 @@ class TaskOrder(Base, mixins.TimestampsMixin): id = types.Id() workspace_id = Column(ForeignKey("workspaces.id")) - workspace = relationship("Workspace") + portfolio = relationship("Portfolio") user_id = Column(ForeignKey("users.id")) creator = relationship("User", foreign_keys="TaskOrder.user_id") @@ -92,7 +92,7 @@ class TaskOrder(Base, mixins.TimestampsMixin): @property def portfolio_name(self): - return self.workspace.name + return self.portfolio.name @property def is_pending(self): diff --git a/atst/models/user.py b/atst/models/user.py index f6d8de62..0c272638 100644 --- a/atst/models/user.py +++ b/atst/models/user.py @@ -14,7 +14,7 @@ class User(Base, mixins.TimestampsMixin, mixins.AuditableMixin): atat_role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id")) atat_role = relationship("Role") - workspace_roles = relationship("WorkspaceRole", backref="user") + portfolio_roles = relationship("PortfolioRole", backref="user") email = Column(String, unique=True) dod_id = Column(String, unique=True, nullable=False) @@ -65,22 +65,22 @@ class User(Base, mixins.TimestampsMixin, mixins.AuditableMixin): return "{} {}".format(self.first_name, self.last_name) @property - def has_workspaces(self): + def has_portfolios(self): return ( Permissions.VIEW_WORKSPACE in self.atat_role.permissions - ) or self.workspace_roles + ) or self.portfolio_roles @property def displayname(self): return self.full_name def __repr__(self): - return "".format( + return "".format( self.full_name, self.dod_id, self.email, self.atat_role_name, - self.has_workspaces, + self.has_portfolios, self.id, ) diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py index af63d5dd..a3f50306 100644 --- a/atst/routes/__init__.py +++ b/atst/routes/__init__.py @@ -52,25 +52,25 @@ def home(): if user.atat_role_name == "ccpo": return redirect(url_for("requests.requests_index")) - num_workspaces = len(user.workspace_roles) + num_portfolios = len(user.portfolio_roles) - if num_workspaces == 0: + if num_portfolios == 0: return redirect(url_for("requests.requests_index")) - elif num_workspaces == 1: - workspace_role = user.workspace_roles[0] - workspace_id = workspace_role.workspace.id - is_request_owner = workspace_role.role.name == "owner" + elif num_portfolios == 1: + portfolio_role = user.portfolio_roles[0] + portfolio_id = portfolio_role.portfolio.id + is_request_owner = portfolio_role.role.name == "owner" if is_request_owner: return redirect( - url_for("workspaces.workspace_reports", workspace_id=workspace_id) + url_for("portfolios.portfolio_reports", portfolio_id=portfolio_id) ) else: return redirect( - url_for("workspaces.workspace_applications", workspace_id=workspace_id) + url_for("portfolios.portfolio_applications", portfolio_id=portfolio_id) ) else: - return redirect(url_for("workspaces.workspaces")) + return redirect(url_for("portfolios.portfolios")) @bp.route("/styleguide") diff --git a/atst/routes/errors.py b/atst/routes/errors.py index 1b62a117..6ab65f2e 100644 --- a/atst/routes/errors.py +++ b/atst/routes/errors.py @@ -8,7 +8,7 @@ from atst.domain.invitations import ( ExpiredError as InvitationExpiredError, WrongUserError as InvitationWrongUserError, ) -from atst.domain.workspaces import WorkspaceError +from atst.domain.portfolios import PortfolioError from atst.utils.flash import formatted_flash as flash @@ -26,7 +26,7 @@ def make_error_pages(app): @app.errorhandler(werkzeug_exceptions.NotFound) @app.errorhandler(exceptions.NotFoundError) @app.errorhandler(exceptions.UnauthorizedError) - @app.errorhandler(WorkspaceError) + @app.errorhandler(PortfolioError) # pylint: disable=unused-variable def not_found(e): return handle_error(e) diff --git a/atst/routes/portfolios/__init__.py b/atst/routes/portfolios/__init__.py new file mode 100644 index 00000000..990c7c2e --- /dev/null +++ b/atst/routes/portfolios/__init__.py @@ -0,0 +1,41 @@ +from flask import Blueprint, request as http_request, g, render_template + +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 + + +@portfolios_bp.context_processor +def portfolio(): + portfolios = Portfolios.for_user(g.current_user) + portfolio = None + if "portfolio_id" in http_request.view_args: + try: + portfolio = Portfolios.get( + g.current_user, http_request.view_args["portfolio_id"] + ) + portfolios = [ws for ws in portfolios if not ws.id == portfolio.id] + except UnauthorizedError: + pass + + def user_can(permission): + if portfolio: + return Authorization.has_portfolio_permission( + g.current_user, portfolio, permission + ) + return False + + return { + "portfolio": portfolio, + "portfolios": portfolios, + "permissions": Permissions, + "user_can": user_can, + } diff --git a/atst/routes/portfolios/applications.py b/atst/routes/portfolios/applications.py new file mode 100644 index 00000000..f9cad70d --- /dev/null +++ b/atst/routes/portfolios/applications.py @@ -0,0 +1,102 @@ +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.exceptions import UnauthorizedError +from atst.domain.applications import Applications +from atst.domain.portfolios import Portfolios +from atst.forms.application import NewApplicationForm, ApplicationForm + + +@portfolios_bp.route("/portfolios//applications") +def portfolio_applications(portfolio_id): + portfolio = Portfolios.get(g.current_user, portfolio_id) + return render_template("portfolios/applications/index.html", portfolio=portfolio) + + +@portfolios_bp.route("/portfolios//applications/new") +def new_application(portfolio_id): + portfolio = Portfolios.get_for_update_applications(g.current_user, portfolio_id) + form = NewApplicationForm() + return render_template( + "portfolios/applications/new.html", portfolio=portfolio, form=form + ) + + +@portfolios_bp.route("/portfolios//applications/new", methods=["POST"]) +def create_application(portfolio_id): + portfolio = Portfolios.get_for_update_applications(g.current_user, portfolio_id) + form = NewApplicationForm(http_request.form) + + if form.validate(): + application_data = form.data + Applications.create( + g.current_user, + 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", portfolio=portfolio, form=form + ) + + +@portfolios_bp.route("/portfolios//applications//edit") +def edit_application(portfolio_id, application_id): + portfolio = Portfolios.get_for_update_applications(g.current_user, portfolio_id) + application = Applications.get(g.current_user, portfolio, application_id) + form = ApplicationForm(name=application.name, description=application.description) + + return render_template( + "portfolios/applications/edit.html", + portfolio=portfolio, + application=application, + form=form, + ) + + +@portfolios_bp.route( + "/portfolios//applications//edit", methods=["POST"] +) +def update_application(portfolio_id, application_id): + portfolio = Portfolios.get_for_update_applications(g.current_user, portfolio_id) + application = Applications.get(g.current_user, portfolio, application_id) + form = ApplicationForm(http_request.form) + if form.validate(): + application_data = form.data + Applications.update(g.current_user, portfolio, application, application_data) + + return redirect( + url_for("portfolios.portfolio_applications", portfolio_id=portfolio.id) + ) + else: + return render_template( + "portfolios/applications/edit.html", + portfolio=portfolio, + application=application, + form=form, + ) + + +@portfolios_bp.route("/portfolios//environments//access") +def access_environment(portfolio_id, environment_id): + env_role = EnvironmentRoles.get(g.current_user.id, environment_id) + if not env_role: + raise UnauthorizedError( + g.current_user, "access environment {}".format(environment_id) + ) + else: + token = app.csp.cloud.get_access_token(env_role) + return redirect(url_for("atst.csp_environment_access", token=token)) diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py new file mode 100644 index 00000000..647d68be --- /dev/null +++ b/atst/routes/portfolios/index.py @@ -0,0 +1,102 @@ +from datetime import date, timedelta + +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.audit_log import AuditLog +from atst.domain.authz import Authorization +from atst.domain.common import Paginator +from atst.forms.portfolio import PortfolioForm +from atst.models.permissions import Permissions + + +@portfolios_bp.route("/portfolios") +def portfolios(): + portfolios = Portfolios.for_user(g.current_user) + return render_template("portfolios/index.html", page=5, portfolios=portfolios) + + +@portfolios_bp.route("/portfolios//edit") +def portfolio(portfolio_id): + portfolio = Portfolios.get_for_update_information(g.current_user, portfolio_id) + form = PortfolioForm(data={"name": portfolio.name}) + return render_template("portfolios/edit.html", form=form, portfolio=portfolio) + + +@portfolios_bp.route("/portfolios//edit", methods=["POST"]) +def edit_portfolio(portfolio_id): + portfolio = Portfolios.get_for_update_information(g.current_user, 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: + return render_template("portfolios/edit.html", form=form, portfolio=portfolio) + + +@portfolios_bp.route("/portfolios/") +def show_portfolio(portfolio_id): + return redirect( + url_for("portfolios.portfolio_applications", portfolio_id=portfolio_id) + ) + + +@portfolios_bp.route("/portfolios//reports") +def portfolio_reports(portfolio_id): + portfolio = Portfolios.get(g.current_user, portfolio_id) + Authorization.check_portfolio_permission( + g.current_user, + portfolio, + Permissions.VIEW_USAGE_DOLLARS, + "view portfolio reports", + ) + + today = date.today() + month = http_request.args.get("month", today.month) + year = http_request.args.get("year", today.year) + current_month = date(int(year), int(month), 15) + prev_month = current_month - timedelta(days=28) + two_months_ago = prev_month - timedelta(days=28) + + expiration_date = ( + portfolio.legacy_task_order and portfolio.legacy_task_order.expiration_date + ) + if expiration_date: + remaining_difference = expiration_date - today + remaining_days = remaining_difference.days + else: + remaining_days = None + + return render_template( + "portfolios/reports/index.html", + cumulative_budget=Reports.cumulative_budget(portfolio), + portfolio_totals=Reports.portfolio_totals(portfolio), + monthly_totals=Reports.monthly_totals(portfolio), + jedi_request=portfolio.request, + legacy_task_order=portfolio.legacy_task_order, + current_month=current_month, + prev_month=prev_month, + two_months_ago=two_months_ago, + expiration_date=expiration_date, + remaining_days=remaining_days, + ) + + +@portfolios_bp.route("/portfolios//activity") +def portfolio_activity(portfolio_id): + portfolio = Portfolios.get(g.current_user, portfolio_id) + pagination_opts = Paginator.get_pagination_opts(http_request) + audit_events = AuditLog.get_portfolio_events( + g.current_user, portfolio, pagination_opts + ) + + return render_template( + "portfolios/activity/index.html", + portfolio_name=portfolio.name, + portfolio_id=portfolio_id, + audit_events=audit_events, + ) diff --git a/atst/routes/workspaces/invitations.py b/atst/routes/portfolios/invitations.py similarity index 62% rename from atst/routes/workspaces/invitations.py rename to atst/routes/portfolios/invitations.py index af1d423e..3e015eb2 100644 --- a/atst/routes/workspaces/invitations.py +++ b/atst/routes/portfolios/invitations.py @@ -1,7 +1,7 @@ from flask import g, redirect, url_for, render_template -from . import workspaces_bp -from atst.domain.workspaces import Workspaces +from . import portfolios_bp +from atst.domain.portfolios import Portfolios from atst.domain.invitations import Invitations from atst.queue import queue from atst.utils.flash import formatted_flash as flash @@ -11,12 +11,12 @@ def send_invite_email(owner_name, token, new_member_email): body = render_template("emails/invitation.txt", owner=owner_name, token=token) queue.send_mail( [new_member_email], - "{} has invited you to a JEDI Cloud Workspace".format(owner_name), + "{} has invited you to a JEDI Cloud Portfolio".format(owner_name), body, ) -@workspaces_bp.route("/workspaces/invitations/", methods=["GET"]) +@portfolios_bp.route("/portfolios/invitations/", methods=["GET"]) def accept_invitation(token): invite = Invitations.accept(g.current_user, token) @@ -25,7 +25,7 @@ def accept_invitation(token): # 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.workspace.task_orders: + for task_order in invite.portfolio.task_orders: if g.current_user == task_order.contracting_officer: return redirect( url_for("task_orders.new", screen=4, task_order_id=task_order.id) @@ -40,25 +40,25 @@ def accept_invitation(token): ) return redirect( - url_for("workspaces.show_workspace", workspace_id=invite.workspace.id) + url_for("portfolios.show_portfolio", portfolio_id=invite.portfolio.id) ) -@workspaces_bp.route( - "/workspaces//invitations//revoke", methods=["POST"] +@portfolios_bp.route( + "/portfolios//invitations//revoke", methods=["POST"] ) -def revoke_invitation(workspace_id, token): - workspace = Workspaces.get_for_update_member(g.current_user, workspace_id) +def revoke_invitation(portfolio_id, token): + portfolio = Portfolios.get_for_update_member(g.current_user, portfolio_id) Invitations.revoke(token) - return redirect(url_for("workspaces.workspace_members", workspace_id=workspace.id)) + return redirect(url_for("portfolios.portfolio_members", portfolio_id=portfolio.id)) -@workspaces_bp.route( - "/workspaces//invitations//resend", methods=["POST"] +@portfolios_bp.route( + "/portfolios//invitations//resend", methods=["POST"] ) -def resend_invitation(workspace_id, token): - invite = Invitations.resend(g.current_user, workspace_id, token) +def resend_invitation(portfolio_id, token): + invite = Invitations.resend(g.current_user, portfolio_id, token) send_invite_email(g.current_user.full_name, invite.token, invite.email) - flash("resend_workspace_invitation", user_name=invite.user_name) - return redirect(url_for("workspaces.workspace_members", workspace_id=workspace_id)) + flash("resend_portfolio_invitation", user_name=invite.user_name) + return redirect(url_for("portfolios.portfolio_members", portfolio_id=portfolio_id)) diff --git a/atst/routes/workspaces/members.py b/atst/routes/portfolios/members.py similarity index 53% rename from atst/routes/workspaces/members.py rename to atst/routes/portfolios/members.py index b9a53452..df0c0113 100644 --- a/atst/routes/workspaces/members.py +++ b/atst/routes/portfolios/members.py @@ -2,11 +2,11 @@ import re from flask import render_template, request as http_request, g, redirect, url_for -from . import workspaces_bp +from . import portfolios_bp from atst.domain.exceptions import AlreadyExistsError from atst.domain.applications import Applications -from atst.domain.workspaces import Workspaces -from atst.domain.workspace_roles import WorkspaceRoles, MEMBER_STATUS_CHOICES +from atst.domain.portfolios import Portfolios +from atst.domain.portfolio_roles import PortfolioRoles, MEMBER_STATUS_CHOICES from atst.domain.environments import Environments from atst.domain.environment_roles import EnvironmentRoles from atst.services.invitation import Invitation as InvitationService @@ -23,12 +23,12 @@ from atst.models.permissions import Permissions from atst.utils.flash import formatted_flash as flash -@workspaces_bp.route("/workspaces//members") -def workspace_members(workspace_id): - workspace = Workspaces.get_with_members(g.current_user, workspace_id) +@portfolios_bp.route("/portfolios//members") +def portfolio_members(portfolio_id): + portfolio = Portfolios.get_with_members(g.current_user, portfolio_id) new_member_name = http_request.args.get("newMemberName") new_member = next( - filter(lambda m: m.user_name == new_member_name, workspace.members), None + filter(lambda m: m.user_name == new_member_name, portfolio.members), None ) members_list = [ { @@ -38,15 +38,15 @@ def workspace_members(workspace_id): "role": k.role_displayname, "num_env": k.num_environment_roles, "edit_link": url_for( - "workspaces.view_member", workspace_id=workspace.id, member_id=k.user_id + "portfolios.view_member", portfolio_id=portfolio.id, member_id=k.user_id ), } - for k in workspace.members + for k in portfolio.members ] return render_template( - "workspaces/members/index.html", - workspace=workspace, + "portfolios/members/index.html", + portfolio=portfolio, role_choices=WORKSPACE_ROLE_DEFINITIONS, status_choices=MEMBER_STATUS_CHOICES, members=members_list, @@ -54,32 +54,32 @@ def workspace_members(workspace_id): ) -@workspaces_bp.route("/workspaces//members/new") -def new_member(workspace_id): - workspace = Workspaces.get(g.current_user, workspace_id) +@portfolios_bp.route("/portfolios//members/new") +def new_member(portfolio_id): + portfolio = Portfolios.get(g.current_user, portfolio_id) form = NewMemberForm() return render_template( - "workspaces/members/new.html", workspace=workspace, form=form + "portfolios/members/new.html", portfolio=portfolio, form=form ) -@workspaces_bp.route("/workspaces//members/new", methods=["POST"]) -def create_member(workspace_id): - workspace = Workspaces.get(g.current_user, workspace_id) +@portfolios_bp.route("/portfolios//members/new", methods=["POST"]) +def create_member(portfolio_id): + portfolio = Portfolios.get(g.current_user, portfolio_id) form = NewMemberForm(http_request.form) if form.validate(): try: - member = Workspaces.create_member(g.current_user, workspace, form.data) + member = Portfolios.create_member(g.current_user, portfolio, form.data) invite_service = InvitationService( g.current_user, member, form.data.get("email") ) invite_service.invite() - flash("new_workspace_member", new_member=new_member, workspace=workspace) + flash("new_portfolio_member", new_member=new_member, portfolio=portfolio) return redirect( - url_for("workspaces.workspace_members", workspace_id=workspace.id) + url_for("portfolios.portfolio_members", portfolio_id=portfolio.id) ) except AlreadyExistsError: return render_template( @@ -87,31 +87,31 @@ def create_member(workspace_id): ) else: return render_template( - "workspaces/members/new.html", workspace=workspace, form=form + "portfolios/members/new.html", portfolio=portfolio, form=form ) -@workspaces_bp.route("/workspaces//members//member_edit") -def view_member(workspace_id, member_id): - workspace = Workspaces.get(g.current_user, workspace_id) - Authorization.check_workspace_permission( +@portfolios_bp.route("/portfolios//members//member_edit") +def view_member(portfolio_id, member_id): + portfolio = Portfolios.get(g.current_user, portfolio_id) + Authorization.check_portfolio_permission( g.current_user, - workspace, + portfolio, Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, - "edit this workspace user", + "edit this portfolio user", ) - member = WorkspaceRoles.get(workspace_id, member_id) - applications = Applications.get_all(g.current_user, member, workspace) - form = EditMemberForm(workspace_role=member.role_name) + member = PortfolioRoles.get(portfolio_id, member_id) + applications = Applications.get_all(g.current_user, member, portfolio) + form = EditMemberForm(portfolio_role=member.role_name) editable = g.current_user == member.user - can_revoke_access = Workspaces.can_revoke_access_for(workspace, member) + can_revoke_access = Portfolios.can_revoke_access_for(portfolio, member) if member.has_dod_id_error: - flash("workspace_member_dod_id_error") + flash("portfolio_member_dod_id_error") return render_template( - "workspaces/members/edit.html", - workspace=workspace, + "portfolios/members/edit.html", + portfolio=portfolio, member=member, applications=applications, form=form, @@ -123,18 +123,18 @@ def view_member(workspace_id, member_id): ) -@workspaces_bp.route( - "/workspaces//members//member_edit", methods=["POST"] +@portfolios_bp.route( + "/portfolios//members//member_edit", methods=["POST"] ) -def update_member(workspace_id, member_id): - workspace = Workspaces.get(g.current_user, workspace_id) - Authorization.check_workspace_permission( +def update_member(portfolio_id, member_id): + portfolio = Portfolios.get(g.current_user, portfolio_id) + Authorization.check_portfolio_permission( g.current_user, - workspace, + portfolio, Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, - "edit this workspace user", + "edit this portfolio user", ) - member = WorkspaceRoles.get(workspace_id, member_id) + member = PortfolioRoles.get(portfolio_id, member_id) ids_and_roles = [] form_dict = http_request.form.to_dict() @@ -147,39 +147,39 @@ def update_member(workspace_id, member_id): form = EditMemberForm(http_request.form) if form.validate(): new_role_name = None - if form.data["workspace_role"] != member.role.name: - member = Workspaces.update_member( - g.current_user, workspace, member, form.data["workspace_role"] + if form.data["portfolio_role"] != member.role.name: + member = Portfolios.update_member( + g.current_user, portfolio, member, form.data["portfolio_role"] ) new_role_name = member.role_displayname flash( - "workspace_role_updated", + "portfolio_role_updated", member_name=member.user_name, updated_role=new_role_name, ) updated_roles = Environments.update_environment_roles( - g.current_user, workspace, member, ids_and_roles + g.current_user, portfolio, member, ids_and_roles ) if updated_roles: flash("environment_access_changed") return redirect( - url_for("workspaces.workspace_members", workspace_id=workspace.id) + url_for("portfolios.portfolio_members", portfolio_id=portfolio.id) ) else: return render_template( - "workspaces/members/edit.html", + "portfolios/members/edit.html", form=form, - workspace=workspace, + portfolio=portfolio, member=member, ) -@workspaces_bp.route( - "/workspaces//members//revoke_access", methods=["POST"] +@portfolios_bp.route( + "/portfolios//members//revoke_access", methods=["POST"] ) -def revoke_access(workspace_id, member_id): - revoked_role = Workspaces.revoke_access(g.current_user, workspace_id, member_id) - flash("revoked_workspace_access", member_name=revoked_role.user.full_name) - return redirect(url_for("workspaces.workspace_members", workspace_id=workspace_id)) +def revoke_access(portfolio_id, member_id): + revoked_role = Portfolios.revoke_access(g.current_user, portfolio_id, member_id) + flash("revoked_portfolio_access", member_name=revoked_role.user.full_name) + return redirect(url_for("portfolios.portfolio_members", portfolio_id=portfolio_id)) diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py new file mode 100644 index 00000000..e069cbd9 --- /dev/null +++ b/atst/routes/portfolios/task_orders.py @@ -0,0 +1,20 @@ +from flask import g, render_template + +from . import portfolios_bp +from atst.domain.task_orders import TaskOrders +from atst.domain.portfolios import Portfolios + + +@portfolios_bp.route("/portfolios//task_orders") +def portfolio_task_orders(portfolio_id): + portfolio = Portfolios.get(g.current_user, portfolio_id) + return render_template("portfolios/task_orders/index.html", portfolio=portfolio) + + +@portfolios_bp.route("/portfolios//task_order/") +def view_task_order(portfolio_id, task_order_id): + portfolio = Portfolios.get(g.current_user, portfolio_id) + task_order = TaskOrders.get(task_order_id) + return render_template( + "portfolios/task_orders/show.html", portfolio=portfolio, task_order=task_order + ) diff --git a/atst/routes/requests/financial_verification.py b/atst/routes/requests/financial_verification.py index 52d8311e..970d85a4 100644 --- a/atst/routes/requests/financial_verification.py +++ b/atst/routes/requests/financial_verification.py @@ -248,10 +248,10 @@ def update_financial_verification(request_id): ) if updated_request.legacy_task_order.verified: - workspace = Requests.auto_approve_and_create_workspace(updated_request) - flash("new_workspace") + portfolio = Requests.auto_approve_and_create_portfolio(updated_request) + flash("new_portfolio") return redirect( - url_for("workspaces.new_application", workspace_id=workspace.id) + url_for("portfolios.new_application", portfolio_id=portfolio.id) ) else: return redirect(url_for("requests.requests_index", modal="pendingCCPOApproval")) diff --git a/atst/routes/requests/index.py b/atst/routes/requests/index.py index 69f433ba..46b0e7ba 100644 --- a/atst/routes/requests/index.py +++ b/atst/routes/requests/index.py @@ -63,10 +63,10 @@ class RequestsIndex(object): "extended_view": False, } - def _workspace_link_for_request(self, request): + def _portfolio_link_for_request(self, request): if request.is_approved: return url_for( - "workspaces.workspace_applications", workspace_id=request.workspace.id + "portfolios.portfolio_applications", portfolio_id=request.portfolio.id ) else: return None @@ -80,7 +80,7 @@ class RequestsIndex(object): annual_usage = request.annual_spend return { - "workspace_id": request.workspace.id if request.workspace else None, + "portfolio_id": request.portfolio.id if request.portfolio else None, "name": request.displayname, "is_new": is_new, "is_approved": request.is_approved, @@ -93,7 +93,7 @@ class RequestsIndex(object): "edit_link": url_for("requests.edit", request_id=request.id), "action_required": request.action_required_by == viewing_role, "dod_component": request.latest_revision.dod_component, - "workspace_link": self._workspace_link_for_request(request), + "portfolio_link": self._portfolio_link_for_request(request), } diff --git a/atst/routes/requests/jedi_request_flow.py b/atst/routes/requests/jedi_request_flow.py index 0872d8ca..269ff632 100644 --- a/atst/routes/requests/jedi_request_flow.py +++ b/atst/routes/requests/jedi_request_flow.py @@ -113,9 +113,9 @@ class JEDIRequestFlow(object): "form": request_forms.InformationAboutYouForm, }, { - "title": "Workspace Owner", + "title": "Portfolio Owner", "section": "primary_poc", - "form": request_forms.WorkspaceOwnerForm, + "form": request_forms.PortfolioOwnerForm, }, { "title": "Review & Submit", diff --git a/atst/routes/task_orders/invite.py b/atst/routes/task_orders/invite.py index 78e2daf5..1e8e40ac 100644 --- a/atst/routes/task_orders/invite.py +++ b/atst/routes/task_orders/invite.py @@ -10,5 +10,5 @@ def invite(task_order_id): task_order = TaskOrders.get(g.current_user, task_order_id) flash("task_order_submitted", task_order=task_order) return redirect( - url_for("workspaces.workspace_members", workspace_id=task_order.workspace.id) + url_for("portfolios.portfolio_members", portfolio_id=task_order.portfolio.id) ) diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index 532ed13c..4e777e41 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -11,8 +11,8 @@ from flask import ( from . import task_orders_bp from atst.domain.task_orders import TaskOrders -from atst.domain.workspaces import Workspaces -from atst.domain.workspace_roles import WorkspaceRoles +from atst.domain.portfolios import Portfolios +from atst.domain.portfolio_roles import PortfolioRoles import atst.forms.task_order as task_order_form from atst.services.invitation import Invitation as InvitationService @@ -114,9 +114,9 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): return self._form @property - def workspace(self): + def portfolio(self): if self.task_order: - return self.task_order.workspace + return self.task_order.portfolio def validate(self): return self.form.validate() @@ -125,7 +125,7 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): if self.task_order: TaskOrders.update(self.user, self.task_order, **self.form.data) else: - ws = Workspaces.create(self.user, self.form.portfolio_name.data) + ws = Portfolios.create(self.user, self.form.portfolio_name.data) to_data = self.form.data.copy() to_data.pop("portfolio_name") self._task_order = TaskOrders.create(self.user, ws) @@ -177,7 +177,7 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): officer = TaskOrders.add_officer( self.user, self.task_order, officer_type["role"], officer_data ) - ws_officer_member = WorkspaceRoles.get(self.workspace.id, officer.id) + ws_officer_member = PortfolioRoles.get(self.portfolio.id, officer.id) invite_service = InvitationService( self.user, ws_officer_member, diff --git a/atst/routes/workspaces/__init__.py b/atst/routes/workspaces/__init__.py deleted file mode 100644 index 9d822771..00000000 --- a/atst/routes/workspaces/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -from flask import Blueprint, request as http_request, g, render_template - -workspaces_bp = Blueprint("workspaces", __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.workspaces import Workspaces -from atst.domain.authz import Authorization -from atst.models.permissions import Permissions - - -@workspaces_bp.context_processor -def workspace(): - workspaces = Workspaces.for_user(g.current_user) - workspace = None - if "workspace_id" in http_request.view_args: - try: - workspace = Workspaces.get( - g.current_user, http_request.view_args["workspace_id"] - ) - workspaces = [ws for ws in workspaces if not ws.id == workspace.id] - except UnauthorizedError: - pass - - def user_can(permission): - if workspace: - return Authorization.has_workspace_permission( - g.current_user, workspace, permission - ) - return False - - return { - "workspace": workspace, - "workspaces": workspaces, - "permissions": Permissions, - "user_can": user_can, - } diff --git a/atst/routes/workspaces/applications.py b/atst/routes/workspaces/applications.py deleted file mode 100644 index c80374c0..00000000 --- a/atst/routes/workspaces/applications.py +++ /dev/null @@ -1,102 +0,0 @@ -from flask import ( - current_app as app, - g, - redirect, - render_template, - request as http_request, - url_for, -) - -from . import workspaces_bp -from atst.domain.environment_roles import EnvironmentRoles -from atst.domain.exceptions import UnauthorizedError -from atst.domain.applications import Applications -from atst.domain.workspaces import Workspaces -from atst.forms.application import NewApplicationForm, ApplicationForm - - -@workspaces_bp.route("/workspaces//applications") -def workspace_applications(workspace_id): - workspace = Workspaces.get(g.current_user, workspace_id) - return render_template("workspaces/applications/index.html", workspace=workspace) - - -@workspaces_bp.route("/workspaces//applications/new") -def new_application(workspace_id): - workspace = Workspaces.get_for_update_applications(g.current_user, workspace_id) - form = NewApplicationForm() - return render_template( - "workspaces/applications/new.html", workspace=workspace, form=form - ) - - -@workspaces_bp.route("/workspaces//applications/new", methods=["POST"]) -def create_application(workspace_id): - workspace = Workspaces.get_for_update_applications(g.current_user, workspace_id) - form = NewApplicationForm(http_request.form) - - if form.validate(): - application_data = form.data - Applications.create( - g.current_user, - workspace, - application_data["name"], - application_data["description"], - application_data["environment_names"], - ) - return redirect( - url_for("workspaces.workspace_applications", workspace_id=workspace.id) - ) - else: - return render_template( - "workspaces/applications/new.html", workspace=workspace, form=form - ) - - -@workspaces_bp.route("/workspaces//applications//edit") -def edit_application(workspace_id, application_id): - workspace = Workspaces.get_for_update_applications(g.current_user, workspace_id) - application = Applications.get(g.current_user, workspace, application_id) - form = ApplicationForm(name=application.name, description=application.description) - - return render_template( - "workspaces/applications/edit.html", - workspace=workspace, - application=application, - form=form, - ) - - -@workspaces_bp.route( - "/workspaces//applications//edit", methods=["POST"] -) -def update_application(workspace_id, application_id): - workspace = Workspaces.get_for_update_applications(g.current_user, workspace_id) - application = Applications.get(g.current_user, workspace, application_id) - form = ApplicationForm(http_request.form) - if form.validate(): - application_data = form.data - Applications.update(g.current_user, workspace, application, application_data) - - return redirect( - url_for("workspaces.workspace_applications", workspace_id=workspace.id) - ) - else: - return render_template( - "workspaces/applications/edit.html", - workspace=workspace, - application=application, - form=form, - ) - - -@workspaces_bp.route("/workspaces//environments//access") -def access_environment(workspace_id, environment_id): - env_role = EnvironmentRoles.get(g.current_user.id, environment_id) - if not env_role: - raise UnauthorizedError( - g.current_user, "access environment {}".format(environment_id) - ) - else: - token = app.csp.cloud.get_access_token(env_role) - return redirect(url_for("atst.csp_environment_access", token=token)) diff --git a/atst/routes/workspaces/index.py b/atst/routes/workspaces/index.py deleted file mode 100644 index c9462bbf..00000000 --- a/atst/routes/workspaces/index.py +++ /dev/null @@ -1,102 +0,0 @@ -from datetime import date, timedelta - -from flask import render_template, request as http_request, g, redirect, url_for - -from . import workspaces_bp -from atst.domain.reports import Reports -from atst.domain.workspaces import Workspaces -from atst.domain.audit_log import AuditLog -from atst.domain.authz import Authorization -from atst.domain.common import Paginator -from atst.forms.workspace import WorkspaceForm -from atst.models.permissions import Permissions - - -@workspaces_bp.route("/workspaces") -def workspaces(): - workspaces = Workspaces.for_user(g.current_user) - return render_template("workspaces/index.html", page=5, workspaces=workspaces) - - -@workspaces_bp.route("/workspaces//edit") -def workspace(workspace_id): - workspace = Workspaces.get_for_update_information(g.current_user, workspace_id) - form = WorkspaceForm(data={"name": workspace.name}) - return render_template("workspaces/edit.html", form=form, workspace=workspace) - - -@workspaces_bp.route("/workspaces//edit", methods=["POST"]) -def edit_workspace(workspace_id): - workspace = Workspaces.get_for_update_information(g.current_user, workspace_id) - form = WorkspaceForm(http_request.form) - if form.validate(): - Workspaces.update(workspace, form.data) - return redirect( - url_for("workspaces.workspace_applications", workspace_id=workspace.id) - ) - else: - return render_template("workspaces/edit.html", form=form, workspace=workspace) - - -@workspaces_bp.route("/workspaces/") -def show_workspace(workspace_id): - return redirect( - url_for("workspaces.workspace_applications", workspace_id=workspace_id) - ) - - -@workspaces_bp.route("/workspaces//reports") -def workspace_reports(workspace_id): - workspace = Workspaces.get(g.current_user, workspace_id) - Authorization.check_workspace_permission( - g.current_user, - workspace, - Permissions.VIEW_USAGE_DOLLARS, - "view workspace reports", - ) - - today = date.today() - month = http_request.args.get("month", today.month) - year = http_request.args.get("year", today.year) - current_month = date(int(year), int(month), 15) - prev_month = current_month - timedelta(days=28) - two_months_ago = prev_month - timedelta(days=28) - - expiration_date = ( - workspace.legacy_task_order and workspace.legacy_task_order.expiration_date - ) - if expiration_date: - remaining_difference = expiration_date - today - remaining_days = remaining_difference.days - else: - remaining_days = None - - return render_template( - "workspaces/reports/index.html", - cumulative_budget=Reports.cumulative_budget(workspace), - workspace_totals=Reports.workspace_totals(workspace), - monthly_totals=Reports.monthly_totals(workspace), - jedi_request=workspace.request, - legacy_task_order=workspace.legacy_task_order, - current_month=current_month, - prev_month=prev_month, - two_months_ago=two_months_ago, - expiration_date=expiration_date, - remaining_days=remaining_days, - ) - - -@workspaces_bp.route("/workspaces//activity") -def workspace_activity(workspace_id): - workspace = Workspaces.get(g.current_user, workspace_id) - pagination_opts = Paginator.get_pagination_opts(http_request) - audit_events = AuditLog.get_workspace_events( - g.current_user, workspace, pagination_opts - ) - - return render_template( - "workspaces/activity/index.html", - workspace_name=workspace.name, - workspace_id=workspace_id, - audit_events=audit_events, - ) diff --git a/atst/services/invitation.py b/atst/services/invitation.py index f1badf2b..259dc2d0 100644 --- a/atst/services/invitation.py +++ b/atst/services/invitation.py @@ -10,7 +10,7 @@ class Invitation: inviter, member, email, - subject="{} has invited you to a JEDI Cloud Workspace", + subject="{} has invited you to a JEDI Cloud Portfolio", email_template="emails/invitation.txt", ): self.inviter = inviter diff --git a/atst/utils/flash.py b/atst/utils/flash.py index e857cc87..4ba9ac42 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -1,29 +1,29 @@ from flask import flash, render_template_string MESSAGES = { - "new_workspace_member": { + "new_portfolio_member": { "title_template": "Member added successfully", "message_template": """ -

{{ new_member.user_name }} was successfully invited via email to this workspace. They do not yet have access to any environments.

-

Add environment access.

+

{{ new_member.user_name }} was successfully invited via email to this portfolio. They do not yet have access to any environments.

+

Add environment access.

""", "category": "success", }, - "revoked_workspace_access": { - "title_template": "Removed workspace access", + "revoked_portfolio_access": { + "title_template": "Removed portfolio access", "message_template": """

Portfolio access successfully removed from {{ member_name }}.

""", "category": "success", }, - "resend_workspace_invitation": { + "resend_portfolio_invitation": { "title_template": "Invitation resent", "message_template": """

Successfully sent a new invitation to {{ user_name }}.

""", "category": "success", }, - "workspace_role_updated": { + "portfolio_role_updated": { "title_template": "Portfolio role updated successfully", "message_template": """

{{ member_name }}'s role was successfully updated to {{ updated_role }}

@@ -44,14 +44,14 @@ MESSAGES = { """, "category": "warning", }, - "new_workspace": { + "new_portfolio": { "title_template": "Portfolio created!", "message_template": """

You are now ready to create applications and environments within the JEDI Cloud.

""", "category": "success", }, - "workspace_member_dod_id_error": { + "portfolio_member_dod_id_error": { "title_template": "CAC ID Error", "message_template": """ The member attempted to accept this invite, but their CAC ID did not match the CAC ID you specified on the invite. Please confirm that the DOD ID is accurate. diff --git a/js/components/members_list.js b/js/components/members_list.js index 71db3796..57695ad2 100644 --- a/js/components/members_list.js +++ b/js/components/members_list.js @@ -84,7 +84,7 @@ export default { sortFunc: alphabeticalSort }, { - displayName: 'Workspace Role', + displayName: 'Portfolio Role', attr: 'role', sortFunc: alphabeticalSort, }, diff --git a/js/components/tables/spend_table.js b/js/components/tables/spend_table.js index c87028ab..56f68b70 100644 --- a/js/components/tables/spend_table.js +++ b/js/components/tables/spend_table.js @@ -6,7 +6,7 @@ export default { props: { applications: Object, - workspace: Object, + portfolio: Object, environments: Object, currentMonthIndex: String, prevMonthIndex: String, diff --git a/js/lib/input_validations.js b/js/lib/input_validations.js index 1838848e..81071d2a 100644 --- a/js/lib/input_validations.js +++ b/js/lib/input_validations.js @@ -83,10 +83,10 @@ export default { unmask: [], validationError: 'Please enter a valid BA Code. Note that it should be two digits, followed by an optional letter.' }, - workspaceName: { + portfolioName: { mask: false, match: /^.{4,100}$/, unmask: [], - validationError: 'Workspace and request names must be at least 4 and not more than 100 characters' + validationError: 'Portfolio and request names must be at least 4 and not more than 100 characters' }, } diff --git a/styles/atat.scss b/styles/atat.scss index ce01d0e9..63889846 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -25,7 +25,7 @@ @import 'components/topbar'; @import 'components/global_layout'; @import 'components/global_navigation'; -@import 'components/workspace_layout'; +@import 'components/portfolio_layout'; @import 'components/site_action'; @import 'components/empty_state'; @import 'components/alerts'; diff --git a/styles/components/_global_navigation.scss b/styles/components/_global_navigation.scss index b975270a..bdc3f47e 100644 --- a/styles/components/_global_navigation.scss +++ b/styles/components/_global_navigation.scss @@ -18,7 +18,7 @@ } } - &.global-navigation__context--workspace { + &.global-navigation__context--portfolio { .sidenav__link { padding-right: $gap; } diff --git a/styles/components/_workspace_layout.scss b/styles/components/_portfolio_layout.scss similarity index 88% rename from styles/components/_workspace_layout.scss rename to styles/components/_portfolio_layout.scss index cf6e79bd..62938d35 100644 --- a/styles/components/_workspace_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -1,10 +1,10 @@ -.workspace-panel-container { +.portfolio-panel-container { @include media($large-screen) { @include grid-row; } } -.workspace-navigation { +.portfolio-navigation { @include panel-margin; margin-bottom: $gap * 4; diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index 4af39785..54424924 100644 --- a/styles/components/_topbar.scss +++ b/styles/components/_topbar.scss @@ -59,11 +59,11 @@ align-items: stretch; justify-content: flex-end; - .topbar__workspace-menu { + .topbar__portfolio-menu { margin-right: auto; position: relative; - .topbar__workspace-menu__toggle { + .topbar__portfolio-menu__toggle { margin: 0; border-radius: 0; @@ -89,12 +89,12 @@ } } - .topbar__workspace-menu__panel { + .topbar__portfolio-menu__panel { position: absolute; } } - &.topbar__context--workspace { + &.topbar__context--portfolio { background-color: $color-primary; -ms-flex-pack: start; diff --git a/styles/elements/_inputs.scss b/styles/elements/_inputs.scss index 418fde41..d8170780 100644 --- a/styles/elements/_inputs.scss +++ b/styles/elements/_inputs.scss @@ -207,7 +207,7 @@ &--validation { &--anything, - &--workspaceName, + &--portfolioName, &--requiredField, &--email { input { diff --git a/styles/sections/_reports.scss b/styles/sections/_reports.scss index ccd5a5f8..8f2d5fd5 100644 --- a/styles/sections/_reports.scss +++ b/styles/sections/_reports.scss @@ -283,7 +283,7 @@ } } - .spend-table__workspace { + .spend-table__portfolio { th, td { font-weight: bold; } diff --git a/templates/audit_log/events/environment.html b/templates/audit_log/events/environment.html index 0a6b1747..4284a968 100644 --- a/templates/audit_log/events/environment.html +++ b/templates/audit_log/events/environment.html @@ -1,5 +1,5 @@ {% extends "audit_log/events/_base.html" %} {% block content %} - in Portfolio {{ event.workspace_id }} ({{ event.workspace.name }}) + in Portfolio {{ event.portfolio_id }} ({{ event.portfolio.name }}) {% endblock %} diff --git a/templates/audit_log/events/environment_role.html b/templates/audit_log/events/environment_role.html index f59b8fe0..e692b1e6 100644 --- a/templates/audit_log/events/environment_role.html +++ b/templates/audit_log/events/environment_role.html @@ -14,6 +14,6 @@
in Application {{ event.event_details["application_id"] }} ({{ event.event_details["application"] }})
- in Portfolio {{ event.event_details["workspace_id"] }} ({{ event.event_details["workspace"] }}) + in Portfolio {{ event.event_details["portfolio_id"] }} ({{ event.event_details["portfolio"] }}) {% endif %} {% endblock %} diff --git a/templates/audit_log/events/invitation.html b/templates/audit_log/events/invitation.html index 572d3195..2b0b0fd9 100644 --- a/templates/audit_log/events/invitation.html +++ b/templates/audit_log/events/invitation.html @@ -10,5 +10,5 @@ invited {{ event.event_details.email }} (DOD {{ event.event_details.dod_id }})
{% endif %} - in Portfolio {{ event.workspace_id }} ({{ event.workspace.name }}) + in Portfolio {{ event.portfolio_id }} ({{ event.portfolio.name }}) {% endblock %} diff --git a/templates/audit_log/events/workspace.html b/templates/audit_log/events/portfolio.html similarity index 100% rename from templates/audit_log/events/workspace.html rename to templates/audit_log/events/portfolio.html diff --git a/templates/audit_log/events/workspace_role.html b/templates/audit_log/events/portfolio_role.html similarity index 85% rename from templates/audit_log/events/workspace_role.html rename to templates/audit_log/events/portfolio_role.html index 3a8df341..11bdad10 100644 --- a/templates/audit_log/events/workspace_role.html +++ b/templates/audit_log/events/portfolio_role.html @@ -2,7 +2,7 @@ {% block content %} for User {{ event.event_details.updated_user_id }} ({{ event.event_details.updated_user_name }}) - in Portfolio {{ event.workspace_id }} ({{ event.workspace.name }}) + in Portfolio {{ event.portfolio_id }} ({{ event.portfolio.name }}) {% if event.changed_state.status %} from status "{{ event.changed_state.status[0] }}" to "{{ event.changed_state.status[1] }}" diff --git a/templates/components/pagination.html b/templates/components/pagination.html index d7674d66..a44dcb77 100644 --- a/templates/components/pagination.html +++ b/templates/components/pagination.html @@ -1,4 +1,4 @@ -{% macro Page(pagination, route, i, label=None, disabled=False, workspace_id=None) -%} +{% macro Page(pagination, route, i, label=None, disabled=False, portfolio_id=None) -%} {% set label = label or i %} {% set button_class = "page usa-button " %} @@ -11,42 +11,42 @@ {% set button_class = button_class + "usa-button-secondary" %} {% endif %} - {{ label }} + {{ label }} {%- endmacro %} -{% macro Pagination(pagination, route, workspace_id=None) -%} +{% macro Pagination(pagination, route, portfolio_id=None) -%} diff --git a/templates/emails/invitation.txt b/templates/emails/invitation.txt index 131d0393..f63e4db2 100644 --- a/templates/emails/invitation.txt +++ b/templates/emails/invitation.txt @@ -1,7 +1,7 @@ Join this JEDI Cloud Portfolio {{ owner }} has invited you to join a JEDI Cloud Portfolio. Login now to view or use your JEDI Cloud resources. -{{ url_for("workspaces.accept_invitation", token=token, _external=True) }} +{{ url_for("portfolios.accept_invitation", token=token, _external=True) }} What is JEDI Cloud? JEDI Cloud is a DoD enterprise-wide solution for commercial cloud services. diff --git a/templates/navigation/global_navigation.html b/templates/navigation/global_navigation.html index d17b2188..90f9bac5 100644 --- a/templates/navigation/global_navigation.html +++ b/templates/navigation/global_navigation.html @@ -1,6 +1,6 @@ {% from "components/sidenav_item.html" import SidenavItem %} -