Update atst to atat

This commit is contained in:
leigh-mil
2020-02-28 16:01:45 -05:00
parent 6eb48239cf
commit c2814416fb
215 changed files with 735 additions and 746 deletions

21
atat/models/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
from .base import Base
from .application import Application
from .application_invitation import ApplicationInvitation
from .application_role import ApplicationRole, Status as ApplicationRoleStatus
from .attachment import Attachment
from .audit_event import AuditEvent
from .clin import CLIN, JEDICLINType
from .environment import Environment
from .environment_role import EnvironmentRole, CSPRole
from .job_failure import JobFailure
from .notification_recipient import NotificationRecipient
from .permissions import Permissions
from .permission_set import PermissionSet
from .portfolio import Portfolio
from .portfolio_state_machine import PortfolioStateMachine, FSMStates
from .portfolio_invitation import PortfolioInvitation
from .portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
from .task_order import TaskOrder
from .user import User
from .mixins.invites import Status as InvitationStatus

View File

@@ -0,0 +1,68 @@
from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import relationship, synonym
from atat.models.base import Base
from atat.models.application_role import ApplicationRole
from atat.models.environment import Environment
from atat.models import mixins
from atat.models.types import Id
class Application(
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.ClaimableMixin,
):
__tablename__ = "applications"
id = Id()
name = Column(String, nullable=False)
description = Column(String)
portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False)
portfolio = relationship("Portfolio")
environments = relationship(
"Environment",
back_populates="application",
primaryjoin=and_(
Environment.application_id == id, Environment.deleted == False
),
order_by="Environment.name",
)
roles = relationship(
"ApplicationRole",
primaryjoin=and_(
ApplicationRole.application_id == id, ApplicationRole.deleted == False
),
)
members = synonym("roles")
__table_args__ = (
UniqueConstraint(
"name", "portfolio_id", name="applications_name_portfolio_id_key"
),
)
cloud_id = Column(String)
@property
def users(self):
return set(role.user for role in self.members)
@property
def displayname(self):
return self.name
@property
def application_id(self):
return self.id
def __repr__(self): # pragma: no cover
return "<Application(name='{}', description='{}', portfolio='{}', id='{}')>".format(
self.name, self.description, self.portfolio.name, self.id
)
@property
def history(self):
return self.get_changes()

View File

@@ -0,0 +1,50 @@
from sqlalchemy import Column, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, backref
from atat.models.base import Base
import atat.models.mixins as mixins
class ApplicationInvitation(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.InvitesMixin
):
__tablename__ = "application_invitations"
application_role_id = Column(
UUID(as_uuid=True),
ForeignKey("application_roles.id"),
index=True,
nullable=False,
)
role = relationship(
"ApplicationRole",
backref=backref("invitations", order_by="ApplicationInvitation.time_created"),
)
@property
def application(self):
if self.role: # pragma: no branch
return self.role.application
@property
def application_id(self):
return self.role.application_id
@property
def portfolio_id(self):
return self.role.portfolio_id
@property
def event_details(self):
return {"email": self.email, "dod_id": self.user_dod_id}
@property
def history(self):
changes = self.get_changes()
change_set = {}
if "status" in changes:
change_set["status"] = [s.name for s in changes["status"]]
return change_set

View File

@@ -0,0 +1,153 @@
from enum import Enum
from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum, Table, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.event import listen
from atat.utils import first_or_none
from atat.models.base import Base
import atat.models.mixins as mixins
import atat.models.types as types
from atat.models.mixins.auditable import record_permission_sets_updates
class Status(Enum):
ACTIVE = "active"
DISABLED = "disabled"
PENDING = "pending"
application_roles_permission_sets = Table(
"application_roles_permission_sets",
Base.metadata,
Column(
"application_role_id", UUID(as_uuid=True), ForeignKey("application_roles.id")
),
Column("permission_set_id", UUID(as_uuid=True), ForeignKey("permission_sets.id")),
)
class ApplicationRole(
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.PermissionsMixin,
mixins.DeletableMixin,
mixins.ClaimableMixin,
):
__tablename__ = "application_roles"
id = types.Id()
application_id = Column(
UUID(as_uuid=True), ForeignKey("applications.id"), index=True, nullable=False
)
application = relationship("Application", back_populates="roles")
user_id = Column(
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=True
)
status = Column(
SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False
)
permission_sets = relationship(
"PermissionSet", secondary=application_roles_permission_sets
)
environment_roles = relationship(
"EnvironmentRole",
primaryjoin="and_(EnvironmentRole.application_role_id == ApplicationRole.id, EnvironmentRole.deleted == False)",
)
cloud_id = Column(String)
@property
def latest_invitation(self):
if self.invitations:
return self.invitations[-1]
@property
def user_name(self):
if self.user:
return self.user.full_name
elif self.latest_invitation:
return self.latest_invitation.user_name
def __repr__(self):
return "<ApplicationRole(application='{}', user_id='{}', id='{}', permissions={})>".format(
self.application.name, self.user_id, self.id, self.permissions
)
@property
def history(self):
previous_state = self.get_changes()
change_set = {}
if "status" in previous_state:
from_status = previous_state["status"][0].value
to_status = self.status.value
change_set["status"] = [from_status, to_status]
return change_set
def has_permission_set(self, perm_set_name):
return first_or_none(
lambda prms: prms.name == perm_set_name, self.permission_sets
)
@property
def portfolio_id(self):
return self.application.portfolio_id
@property
def event_details(self):
return {
"updated_user_name": self.user_name,
"updated_user_id": str(self.user_id),
"application": self.application.name,
"portfolio": self.application.portfolio.name,
}
@property
def is_pending(self):
return self.status == Status.PENDING
@property
def is_active(self):
return self.status == Status.ACTIVE
@property
def display_status(self):
if (
self.is_pending
and self.latest_invitation
and self.latest_invitation.is_expired
):
return "invite_expired"
elif (
self.is_pending
and self.latest_invitation
and self.latest_invitation.is_pending
):
return "invite_pending"
elif self.is_active and any(
env_role.is_pending for env_role in self.environment_roles
):
return "changes_pending"
return None
Index(
"application_role_user_application",
ApplicationRole.user_id,
ApplicationRole.application_id,
unique=True,
)
listen(
ApplicationRole.permission_sets,
"bulk_replace",
record_permission_sets_updates,
raw=True,
)

65
atat/models/attachment.py Normal file
View File

@@ -0,0 +1,65 @@
from sqlalchemy import Column, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm.exc import NoResultFound
from atat.models.base import Base
import atat.models.mixins as mixins
import atat.models.types as types
from atat.database import db
from atat.domain.exceptions import NotFoundError
class AttachmentError(Exception):
pass
class Attachment(Base, mixins.TimestampsMixin):
__tablename__ = "attachments"
id = types.Id()
filename = Column(String, nullable=False)
object_name = Column(String, unique=True, nullable=False)
resource = Column(String)
resource_id = Column(UUID(as_uuid=True), index=True)
@classmethod
def get_or_create(cls, object_name, params):
try:
return db.session.query(Attachment).filter_by(object_name=object_name).one()
except NoResultFound:
new_attachment = cls(**params)
db.session.add(new_attachment)
db.session.commit()
return new_attachment
@classmethod
def get(cls, id_):
try:
return db.session.query(Attachment).filter_by(id=id_).one()
except NoResultFound:
raise NotFoundError("attachment")
@classmethod
def get_for_resource(cls, resource, resource_id):
try:
return (
db.session.query(Attachment)
.filter_by(resource=resource, resource_id=resource_id)
.one()
)
except NoResultFound:
raise NotFoundError("attachment")
@classmethod
def delete_for_resource(cls, resource, resource_id):
try:
return (
db.session.query(Attachment)
.filter_by(resource=resource, resource_id=resource_id)
.update({"resource_id": None})
)
except NoResultFound:
raise NotFoundError("attachment")
def __repr__(self):
return "<Attachment(name='{}', id='{}')>".format(self.filename, self.id)

View File

@@ -0,0 +1,56 @@
from sqlalchemy import String, Column, ForeignKey, inspect
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from atat.models.base import Base
import atat.models.types as types
from atat.models.mixins.timestamps import TimestampsMixin
class AuditEvent(Base, TimestampsMixin):
__tablename__ = "audit_events"
id = types.Id()
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
user = relationship("User", backref="audit_events")
portfolio_id = Column(UUID(as_uuid=True), ForeignKey("portfolios.id"), index=True)
portfolio = relationship("Portfolio", backref="audit_events")
application_id = Column(
UUID(as_uuid=True), ForeignKey("applications.id"), index=True
)
application = relationship("Application", backref="audit_events")
changed_state = Column(JSONB())
event_details = Column(JSONB())
resource_type = Column(String(), nullable=False)
resource_id = Column(UUID(as_uuid=True), index=True, nullable=False)
display_name = Column(String())
action = Column(String(), nullable=False)
@property
def log(self):
return {
"portfolio_id": str(self.portfolio_id),
"application_id": str(self.application_id),
"changed_state": self.changed_state,
"event_details": self.event_details,
"resource_type": self.resource_type,
"resource_id": str(self.resource_id),
"display_name": self.display_name,
"action": self.action,
}
def save(self, connection):
attrs = inspect(self).dict
connection.execute(self.__table__.insert(), **attrs)
def __repr__(self): # pragma: no cover
return "<AuditEvent(name='{}', action='{}', id='{}')>".format(
self.display_name, self.action, self.id
)

3
atat/models/base.py Normal file
View File

@@ -0,0 +1,3 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

85
atat/models/clin.py Normal file
View File

@@ -0,0 +1,85 @@
from enum import Enum
from sqlalchemy import (
Column,
Date,
DateTime,
Enum as SQLAEnum,
ForeignKey,
Numeric,
String,
)
from sqlalchemy.orm import relationship
import pendulum
from atat.models.base import Base
import atat.models.mixins as mixins
import atat.models.types as types
class JEDICLINType(Enum):
JEDI_CLIN_1 = "JEDI_CLIN_1"
JEDI_CLIN_2 = "JEDI_CLIN_2"
JEDI_CLIN_3 = "JEDI_CLIN_3"
JEDI_CLIN_4 = "JEDI_CLIN_4"
class CLIN(Base, mixins.TimestampsMixin):
__tablename__ = "clins"
id = types.Id()
task_order_id = Column(ForeignKey("task_orders.id"), nullable=False)
task_order = relationship("TaskOrder")
number = Column(String, nullable=False)
start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=False)
total_amount = Column(Numeric(scale=2), nullable=False)
obligated_amount = Column(Numeric(scale=2), nullable=False)
jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=False)
last_sent_at = Column(DateTime)
#
# NOTE: For now obligated CLINS are CLIN 1 + CLIN 3
#
def is_obligated(self):
return self.jedi_clin_type in [
JEDICLINType.JEDI_CLIN_1,
JEDICLINType.JEDI_CLIN_3,
]
@property
def type(self):
return "Base" if self.number[0] == "0" else "Option"
@property
def is_completed(self):
return all(
[
self.number,
self.start_date,
self.end_date,
self.total_amount,
self.obligated_amount,
self.jedi_clin_type,
]
)
@property
def jedi_clin_number(self):
return self.jedi_clin_type.value[-1]
def to_dictionary(self):
data = {
c.name: getattr(self, c.name)
for c in self.__table__.columns
if c.name not in ["id"]
}
return data
@property
def is_active(self):
return (
self.start_date <= pendulum.today().date() <= self.end_date
) and self.task_order.signed_at

View File

@@ -0,0 +1,79 @@
from sqlalchemy import Column, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import relationship
import atat.models.mixins as mixins
import atat.models.types as types
from atat.models.base import Base
class Environment(
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.ClaimableMixin,
):
__tablename__ = "environments"
id = types.Id()
name = Column(String, nullable=False)
application_id = Column(ForeignKey("applications.id"), nullable=False)
application = relationship("Application")
# User user.id as the foreign key here beacuse the Environment creator may
# not have an application role. We may need to revisit this if we receive any
# requirements around tracking an environment's custodian.
creator_id = Column(ForeignKey("users.id"), nullable=False)
creator = relationship("User")
cloud_id = Column(String)
roles = relationship(
"EnvironmentRole",
back_populates="environment",
primaryjoin="and_(EnvironmentRole.environment_id == Environment.id, EnvironmentRole.deleted == False)",
)
__table_args__ = (
UniqueConstraint(
"name", "application_id", name="environments_name_application_id_key"
),
)
@property
def users(self):
return {r.application_role.user for r in self.roles}
@property
def num_users(self):
return len(self.users)
@property
def displayname(self):
return self.name
@property
def portfolio(self):
return self.application.portfolio
@property
def portfolio_id(self):
return self.application.portfolio_id
@property
def is_pending(self):
return self.cloud_id is None
def __repr__(self):
return "<Environment(name='{}', num_users='{}', application='{}', portfolio='{}', id='{}')>".format(
self.name,
self.num_users,
self.application.name,
self.portfolio.name,
self.id,
)
@property
def history(self):
return self.get_changes()

View File

@@ -0,0 +1,99 @@
from enum import Enum
from sqlalchemy import Index, ForeignKey, Column, String, Enum as SQLAEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from atat.models.base import Base
import atat.models.mixins as mixins
import atat.models.types as types
class CSPRole(Enum):
ADMIN = "Admin"
BILLING_READ = "Billing Read-only"
CONTRIBUTOR = "Contributor"
class EnvironmentRole(
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.ClaimableMixin,
):
__tablename__ = "environment_roles"
id = types.Id()
environment_id = Column(
UUID(as_uuid=True), ForeignKey("environments.id"), nullable=False
)
environment = relationship("Environment")
role = Column(SQLAEnum(CSPRole, native_enum=False), nullable=True)
application_role_id = Column(
UUID(as_uuid=True), ForeignKey("application_roles.id"), nullable=False
)
application_role = relationship("ApplicationRole")
cloud_id = Column(String())
class Status(Enum):
PENDING = "pending"
COMPLETED = "completed"
DISABLED = "disabled"
status = Column(
SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False
)
def __repr__(self):
return "<EnvironmentRole(role='{}', user='{}', environment='{}', id='{}')>".format(
self.role, self.application_role.user_name, self.environment.name, self.id
)
@property
def history(self):
return self.get_changes()
@property
def portfolio_id(self):
return self.environment.portfolio_id
@property
def application_id(self):
return self.environment.application_id
@property
def displayname(self):
return self.role
@property
def disabled(self):
return self.status == EnvironmentRole.Status.DISABLED
@property
def is_pending(self):
return self.status == EnvironmentRole.Status.PENDING
@property
def event_details(self):
return {
"updated_user_name": self.application_role.user_name,
"updated_application_role_id": str(self.application_role_id),
"role": self.role,
"environment": self.environment.displayname,
"environment_id": str(self.environment_id),
"application": self.environment.application.name,
"application_id": str(self.environment.application_id),
"portfolio": self.environment.portfolio.name,
"portfolio_id": str(self.environment.portfolio.id),
}
Index(
"environments_role_user_environment",
EnvironmentRole.application_role_id,
EnvironmentRole.environment_id,
unique=True,
)

View File

@@ -0,0 +1,21 @@
from celery.result import AsyncResult
from sqlalchemy import Column, String, Integer
from atat.models.base import Base
import atat.models.mixins as mixins
class JobFailure(Base, mixins.TimestampsMixin):
__tablename__ = "job_failures"
id = Column(Integer(), primary_key=True)
task_id = Column(String(), nullable=False)
entity = Column(String(), nullable=False)
entity_id = Column(String(), nullable=False)
@property
def task(self):
if not hasattr(self, "_task"):
self._task = AsyncResult(self.task_id)
return self._task

View File

@@ -0,0 +1,7 @@
from .timestamps import TimestampsMixin
from .auditable import AuditableMixin
from .permissions import PermissionsMixin
from .deletable import DeletableMixin
from .invites import InvitesMixin
from .state_machines import FSMMixin
from .claimable import ClaimableMixin

View File

@@ -0,0 +1,121 @@
from sqlalchemy import event, inspect
from flask import g, current_app as app
from atat.models.audit_event import AuditEvent
from atat.utils import camel_to_snake, getattr_path
ACTION_CREATE = "create"
ACTION_UPDATE = "update"
ACTION_DELETE = "delete"
class AuditableMixin(object):
@staticmethod
def create_audit_event(connection, resource, action, changed_state=None):
user_id = getattr_path(g, "current_user.id")
if changed_state is None:
changed_state = resource.history if action == ACTION_UPDATE else None
log_data = {
"user_id": user_id,
"portfolio_id": resource.portfolio_id,
"application_id": resource.application_id,
"resource_type": resource.resource_type,
"resource_id": resource.id,
"display_name": resource.displayname,
"action": action,
"changed_state": changed_state,
"event_details": resource.event_details,
}
app.logger.info(
"Audit Event {}".format(action),
extra={
"audit_event": {key: str(value) for key, value in log_data.items()},
"tags": ["audit_event", action],
},
)
if app.config.get("USE_AUDIT_LOG", False):
audit_event = AuditEvent(**log_data)
audit_event.save(connection)
@classmethod
def __declare_last__(cls):
event.listen(cls, "after_insert", cls.audit_insert)
event.listen(cls, "after_delete", cls.audit_delete)
event.listen(cls, "after_update", cls.audit_update)
@staticmethod
def audit_insert(mapper, connection, target):
"""Listen for the `after_insert` event and create an AuditLog entry"""
target.create_audit_event(connection, target, ACTION_CREATE)
@staticmethod
def audit_delete(mapper, connection, target):
"""Listen for the `after_delete` event and create an AuditLog entry"""
target.create_audit_event(connection, target, ACTION_DELETE)
@staticmethod
def audit_update(mapper, connection, target):
if AuditableMixin.get_changes(target):
target.create_audit_event(connection, target, ACTION_UPDATE)
def get_changes(self):
"""
This function returns a dictionary of the form {item: [from_value, to_value]},
where 'item' is the attribute on the target that has been updated,
'from_value' is the value of the attribute before it was updated,
and 'to_value' is the current value of the attribute.
There may be more than one item in the dictionary, but that is not expected.
"""
previous_state = {}
attrs = inspect(self).mapper.column_attrs
for attr in attrs:
history = getattr(inspect(self).attrs, attr.key).history
if history.has_changes():
deleted = history.deleted.pop() if history.deleted else None
added = history.added.pop() if history.added else None
previous_state[attr.key] = [deleted, added]
return previous_state
@property
def history(self):
return None
@property
def event_details(self):
return None
@property
def resource_type(self):
return camel_to_snake(type(self).__name__)
@property
def portfolio_id(self):
raise NotImplementedError()
@property
def application_id(self):
raise NotImplementedError()
@property
def displayname(self):
return None
def record_permission_sets_updates(instance_state, permission_sets, initiator):
old_perm_sets = instance_state.attrs.get("permission_sets").value
if instance_state.persistent and old_perm_sets != permission_sets:
connection = instance_state.session.connection()
old_state = [p.name for p in old_perm_sets]
new_state = [p.name for p in permission_sets]
changed_state = {"permission_sets": (old_state, new_state)}
instance_state.object.create_audit_event(
connection,
instance_state.object,
ACTION_UPDATE,
changed_state=changed_state,
)

View File

@@ -0,0 +1,5 @@
from sqlalchemy import Column, TIMESTAMP
class ClaimableMixin(object):
claimed_until = Column(TIMESTAMP(timezone=True))

View File

@@ -0,0 +1,6 @@
from sqlalchemy import Column, Boolean
from sqlalchemy.sql import expression
class DeletableMixin(object):
deleted = Column(Boolean, nullable=False, server_default=expression.false())

View File

@@ -0,0 +1,139 @@
import pendulum
from enum import Enum
import secrets
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum, TIMESTAMP, String
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from atat.models import types
class Status(Enum):
ACCEPTED = "accepted"
REVOKED = "revoked"
PENDING = "pending"
REJECTED_WRONG_USER = "rejected_wrong_user"
REJECTED_EXPIRED = "rejected_expired"
class InvitesMixin(object):
id = types.Id()
@declared_attr
def user_id(cls):
return Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
@declared_attr
def user(cls):
return relationship("User", foreign_keys=[cls.user_id])
@declared_attr
def inviter_id(cls):
return Column(
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=False
)
@declared_attr
def inviter(cls):
return relationship("User", foreign_keys=[cls.inviter_id])
status = Column(
SQLAEnum(Status, native_enum=False, default=Status.PENDING, nullable=False)
)
expiration_time = Column(TIMESTAMP(timezone=True), nullable=False)
token = Column(
String, index=True, default=lambda: secrets.token_urlsafe(), nullable=False
)
email = Column(String, nullable=False)
dod_id = Column(String, nullable=False)
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
phone_number = Column(String)
phone_ext = Column(String)
def __repr__(self):
role_id = self.role.id if self.role else None
return "<{}(user='{}', role='{}', id='{}', email='{}')>".format(
self.__class__.__name__, self.user_id, role_id, self.id, self.email
)
@property
def is_accepted(self):
return self.status == Status.ACCEPTED
@property
def is_revoked(self):
return self.status == Status.REVOKED
@property
def is_pending(self):
return self.status == Status.PENDING
@property
def is_rejected(self):
return self.status in [Status.REJECTED_WRONG_USER, Status.REJECTED_EXPIRED]
@property
def is_rejected_expired(self):
return self.status == Status.REJECTED_EXPIRED
@property
def is_rejected_wrong_user(self):
return self.status == Status.REJECTED_WRONG_USER
@property
def is_expired(self):
return (
pendulum.now(tz=self.expiration_time.tzinfo) > self.expiration_time
and not self.status == Status.ACCEPTED
)
@property
def is_inactive(self):
return self.is_expired or self.status in [
Status.REJECTED_WRONG_USER,
Status.REJECTED_EXPIRED,
Status.REVOKED,
]
@property
def user_name(self):
return "{} {}".format(self.first_name, self.last_name)
@property
def is_revokable(self):
return self.is_pending and not self.is_expired
@property
def can_resend(self):
return self.is_pending or self.is_expired
@property
def user_dod_id(self):
return self.user.dod_id if self.user is not None else None
@property
def event_details(self):
"""Overrides the same property in AuditableMixin.
Provides the event details for an invite that are required for the audit log
"""
return {"email": self.email, "dod_id": self.user_dod_id}
@property
def history(self):
"""Overrides the same property in AuditableMixin
Determines whether or not invite status has been updated
"""
changes = self.get_changes()
change_set = {}
if "status" in changes:
change_set["status"] = [s.name for s in changes["status"]]
return change_set

View File

@@ -0,0 +1,6 @@
class PermissionsMixin(object):
@property
def permissions(self):
return [
perm for permset in self.permission_sets for perm in permset.permissions
]

View File

@@ -0,0 +1,158 @@
from enum import Enum
from flask import current_app as app
class StageStates(Enum):
CREATED = "created"
IN_PROGRESS = "in progress"
FAILED = "failed"
class AzureStages(Enum):
TENANT = "tenant"
BILLING_PROFILE_CREATION = "billing profile creation"
BILLING_PROFILE_VERIFICATION = "billing profile verification"
BILLING_PROFILE_TENANT_ACCESS = "billing profile tenant access"
TASK_ORDER_BILLING_CREATION = "task order billing creation"
TASK_ORDER_BILLING_VERIFICATION = "task order billing verification"
BILLING_INSTRUCTION = "billing instruction"
PRODUCT_PURCHASE = "purchase aad premium product"
PRODUCT_PURCHASE_VERIFICATION = "purchase aad premium product verification"
TENANT_PRINCIPAL_APP = "tenant principal application"
TENANT_PRINCIPAL = "tenant principal"
TENANT_PRINCIPAL_CREDENTIAL = "tenant principal credential"
ADMIN_ROLE_DEFINITION = "admin role definition"
PRINCIPAL_ADMIN_ROLE = "tenant principal admin"
INITIAL_MGMT_GROUP = "initial management group"
INITIAL_MGMT_GROUP_VERIFICATION = "initial management group verification"
TENANT_ADMIN_OWNERSHIP = "tenant admin ownership"
TENANT_PRINCIPAL_OWNERSHIP = "tenant principial ownership"
BILLING_OWNER = "billing owner"
def _build_csp_states(csp_stages):
states = {
"UNSTARTED": "unstarted",
"STARTING": "starting",
"STARTED": "started",
"COMPLETED": "completed",
"FAILED": "failed",
}
for csp_stage in csp_stages:
for state in StageStates:
states[csp_stage.name + "_" + state.name] = (
csp_stage.value + " " + state.value
)
return states
FSMStates = Enum("FSMStates", _build_csp_states(AzureStages))
compose_state = lambda csp_stage, state: getattr(
FSMStates, "_".join([csp_stage.name, state.name])
)
def _build_transitions(csp_stages):
transitions = []
states = []
for stage_i, csp_stage in enumerate(csp_stages):
# the last CREATED stage has a transition to COMPLETED
if stage_i == len(csp_stages) - 1:
transitions.append(
dict(
trigger="complete",
source=compose_state(
list(csp_stages)[stage_i], StageStates.CREATED
),
dest=FSMStates.COMPLETED,
)
)
for state in StageStates:
states.append(
dict(
name=compose_state(csp_stage, state),
tags=[csp_stage.name, state.name],
)
)
if state == StageStates.CREATED:
if stage_i > 0:
src = compose_state(
list(csp_stages)[stage_i - 1], StageStates.CREATED
)
else:
src = FSMStates.STARTED
transitions.append(
dict(
trigger="create_" + csp_stage.name.lower(),
source=src,
dest=compose_state(csp_stage, StageStates.IN_PROGRESS),
after="after_in_progress_callback",
)
)
if state == StageStates.IN_PROGRESS:
transitions.append(
dict(
trigger="finish_" + csp_stage.name.lower(),
source=compose_state(csp_stage, state),
dest=compose_state(csp_stage, StageStates.CREATED),
conditions=["is_csp_data_valid"],
)
)
if state == StageStates.FAILED:
transitions.append(
dict(
trigger="fail_" + csp_stage.name.lower(),
source=compose_state(csp_stage, StageStates.IN_PROGRESS),
dest=compose_state(csp_stage, StageStates.FAILED),
)
)
transitions.append(
dict(
trigger="resume_progress_" + csp_stage.name.lower(),
source=compose_state(csp_stage, StageStates.FAILED),
dest=compose_state(csp_stage, StageStates.IN_PROGRESS),
conditions=["is_ready_resume_progress"],
)
)
return states, transitions
class FSMMixin:
system_states = [
{"name": FSMStates.UNSTARTED.name, "tags": ["system"]},
{"name": FSMStates.STARTING.name, "tags": ["system"]},
{"name": FSMStates.STARTED.name, "tags": ["system"]},
{"name": FSMStates.FAILED.name, "tags": ["system"]},
{"name": FSMStates.COMPLETED.name, "tags": ["system"]},
]
system_transitions = [
{"trigger": "init", "source": FSMStates.UNSTARTED, "dest": FSMStates.STARTING},
{"trigger": "start", "source": FSMStates.STARTING, "dest": FSMStates.STARTED},
{"trigger": "reset", "source": "*", "dest": FSMStates.UNSTARTED},
{"trigger": "fail", "source": "*", "dest": FSMStates.FAILED,},
]
def fail_stage(self, stage):
fail_trigger = f"fail_{stage}"
if fail_trigger in self.machine.get_triggers(self.current_state.name):
self.trigger(fail_trigger)
app.logger.info(
f"calling fail trigger '{fail_trigger}' for '{self.__repr__()}'"
)
else:
app.logger.info(
f"could not locate fail trigger '{fail_trigger}' for '{self.__repr__()}'"
)
def finish_stage(self, stage):
finish_trigger = f"finish_{stage}"
if finish_trigger in self.machine.get_triggers(self.current_state.name):
app.logger.info(
f"calling finish trigger '{finish_trigger}' for '{self.__repr__()}'"
)
self.trigger(finish_trigger)

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, func, TIMESTAMP
class TimestampsMixin(object):
time_created = Column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
time_updated = Column(
TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.current_timestamp(),
)

View File

@@ -0,0 +1,12 @@
from sqlalchemy import String, Column
from atat.models.base import Base
import atat.models.types as types
import atat.models.mixins as mixins
class NotificationRecipient(Base, mixins.TimestampsMixin):
__tablename__ = "notification_recipients"
id = types.Id()
email = Column(String, nullable=False)

View File

@@ -0,0 +1,21 @@
from sqlalchemy import String, Column
from sqlalchemy.dialects.postgresql import ARRAY
from atat.models.base import Base
import atat.models.mixins as mixins
import atat.models.types as types
class PermissionSet(Base, mixins.TimestampsMixin):
__tablename__ = "permission_sets"
id = types.Id()
name = Column(String, index=True, unique=True, nullable=False)
display_name = Column(String, nullable=False)
description = Column(String, nullable=False)
permissions = Column(ARRAY(String), index=True, server_default="{}", nullable=False)
def __repr__(self):
return "<PermissionSet(name='{}', description='{}', permissions='{}', id='{}')>".format(
self.name, self.description, self.permissions, self.id
)

View File

@@ -0,0 +1,51 @@
class Permissions(object):
# ccpo permissions
VIEW_AUDIT_LOG = "view_audit_log"
VIEW_CCPO_USER = "view_ccpo_user"
CREATE_CCPO_USER = "create_ccpo_user"
EDIT_CCPO_USER = "edit_ccpo_user"
DELETE_CCPO_USER = "delete_ccpo_user"
# base portfolio perms
VIEW_PORTFOLIO = "view_portfolio"
# application management
VIEW_APPLICATION = "view_application"
EDIT_APPLICATION = "edit_application"
CREATE_APPLICATION = "create_application"
DELETE_APPLICATION = "delete_application"
VIEW_APPLICATION_MEMBER = "view_application_member"
EDIT_APPLICATION_MEMBER = "edit_application_member"
DELETE_APPLICATION_MEMBER = "delete_application_member"
CREATE_APPLICATION_MEMBER = "create_application_member"
VIEW_ENVIRONMENT = "view_environment"
EDIT_ENVIRONMENT = "edit_environment"
CREATE_ENVIRONMENT = "create_environment"
DELETE_ENVIRONMENT = "delete_environment"
ASSIGN_ENVIRONMENT_MEMBER = "assign_environment_member"
VIEW_APPLICATION_ACTIVITY_LOG = "view_application_activity_log"
# funding
VIEW_PORTFOLIO_FUNDING = "view_portfolio_funding" # TO summary page
CREATE_TASK_ORDER = "create_task_order" # create a new TO
VIEW_TASK_ORDER_DETAILS = "view_task_order_details" # individual TO page
EDIT_TASK_ORDER_DETAILS = (
"edit_task_order_details" # edit TO that has not been finalized
)
# reporting
VIEW_PORTFOLIO_REPORTS = "view_portfolio_reports"
# portfolio admin
VIEW_PORTFOLIO_ADMIN = "view_portfolio_admin"
VIEW_PORTFOLIO_NAME = "view_portfolio_name"
EDIT_PORTFOLIO_NAME = "edit_portfolio_name"
VIEW_PORTFOLIO_USERS = "view_portfolio_users"
EDIT_PORTFOLIO_USERS = "edit_portfolio_users"
CREATE_PORTFOLIO_USERS = "create_portfolio_users"
VIEW_PORTFOLIO_ACTIVITY_LOG = "view_portfolio_activity_log"
VIEW_PORTFOLIO_POC = "view_portfolio_poc"
# portfolio POC
EDIT_PORTFOLIO_POC = "edit_portfolio_poc"
ARCHIVE_PORTFOLIO = "archive_portfolio"

228
atat/models/portfolio.py Normal file
View File

@@ -0,0 +1,228 @@
import re
from itertools import chain
from typing import Dict
from sqlalchemy import Column, String
from sqlalchemy.orm import relationship
from sqlalchemy.types import ARRAY
from sqlalchemy_json import NestedMutableJson
from atat.database import db
import atat.models.mixins as mixins
import atat.models.types as types
from atat.domain.csp.cloud.utils import generate_mail_nickname
from atat.domain.permission_sets import PermissionSets
from atat.models.base import Base
from atat.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
from atat.utils import first_or_none
class Portfolio(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
):
__tablename__ = "portfolios"
id = types.Id()
name = Column(String, nullable=False)
defense_component = Column(
ARRAY(String), nullable=False
) # Department of Defense Component
app_migration = Column(String) # App Migration
complexity = Column(ARRAY(String)) # Application Complexity
complexity_other = Column(String)
description = Column(String)
dev_team = Column(ARRAY(String)) # Development Team
dev_team_other = Column(String)
native_apps = Column(String) # Native Apps
team_experience = Column(String) # Team Experience
csp_data = Column(NestedMutableJson, nullable=True)
applications = relationship(
"Application",
back_populates="portfolio",
primaryjoin="and_(Application.portfolio_id == Portfolio.id, Application.deleted == False)",
)
state_machine = relationship(
"PortfolioStateMachine", uselist=False, back_populates="portfolio"
)
roles = relationship("PortfolioRole")
task_orders = relationship("TaskOrder")
clins = relationship("CLIN", secondary="task_orders")
@property
def owner_role(self):
def _is_portfolio_owner(portfolio_role):
return PermissionSets.PORTFOLIO_POC in [
perms_set.name for perms_set in portfolio_role.permission_sets
]
return first_or_none(_is_portfolio_owner, self.roles)
@property
def owner(self):
owner_role = self.owner_role
return owner_role.user if owner_role else None
@property
def users(self):
return set(role.user for role in self.roles)
@property
def user_count(self):
return len(self.members)
@property
def num_task_orders(self):
return len(self.task_orders)
@property
def initial_clin_dict(self) -> Dict:
initial_clin = min(
(
clin
for clin in self.clins
if (clin.is_active and clin.task_order.is_signed)
),
key=lambda clin: clin.start_date,
default=None,
)
if initial_clin:
return {
"initial_task_order_id": initial_clin.task_order.number,
"initial_clin_number": initial_clin.number,
"initial_clin_type": initial_clin.jedi_clin_number,
"initial_clin_amount": initial_clin.obligated_amount,
"initial_clin_start_date": initial_clin.start_date.strftime("%Y/%m/%d"),
"initial_clin_end_date": initial_clin.end_date.strftime("%Y/%m/%d"),
}
else:
return {}
@property
def active_task_orders(self):
return [task_order for task_order in self.task_orders if task_order.is_active]
@property
def total_obligated_funds(self):
return sum(
(task_order.total_obligated_funds for task_order in self.active_task_orders)
)
@property
def upcoming_obligated_funds(self):
return sum(
(
task_order.total_obligated_funds
for task_order in self.task_orders
if task_order.is_upcoming
)
)
@property
def funding_duration(self):
"""
Return the earliest period of performance start date and latest period
of performance end date for all active task orders in a portfolio.
@return: (datetime.date or None, datetime.date or None)
"""
start_dates = (
task_order.start_date
for task_order in self.task_orders
if task_order.is_active
)
end_dates = (
task_order.end_date
for task_order in self.task_orders
if task_order.is_active
)
earliest_pop_start_date = min(start_dates, default=None)
latest_pop_end_date = max(end_dates, default=None)
return (earliest_pop_start_date, latest_pop_end_date)
@property
def days_to_funding_expiration(self):
"""
Returns the number of days between today and the lastest period performance
end date of all active Task Orders
"""
return max(
(
task_order.days_to_expiration
for task_order in self.task_orders
if task_order.is_active
),
default=0,
)
@property
def members(self):
return (
db.session.query(PortfolioRole)
.filter(PortfolioRole.portfolio_id == self.id)
.filter(PortfolioRole.status != PortfolioRoleStatus.DISABLED)
.all()
)
@property
def displayname(self):
return self.name
@property
def all_environments(self):
return list(chain.from_iterable(p.environments for p in self.applications))
@property
def portfolio_id(self):
return self.id
@property
def domain_name(self):
"""
CSP domain name associated with portfolio.
If a domain name is not set, generate one.
"""
domain_name = re.sub("[^0-9a-zA-Z]+", "", self.name).lower()
if self.csp_data:
return self.csp_data.get("domain_name", domain_name)
else:
return domain_name
@property
def application_id(self):
return None
def to_dictionary(self):
return {
"user_id": generate_mail_nickname(
f"{self.owner.first_name[0]}{self.owner.last_name}"
),
"password": "",
"domain_name": self.domain_name,
"first_name": self.owner.first_name,
"last_name": self.owner.last_name,
"country_code": "US",
"password_recovery_email_address": self.owner.email,
"address": { # TODO: TBD if we're sourcing this from data or config
"company_name": "",
"address_line_1": "",
"city": "",
"region": "",
"country": "",
"postal_code": "",
},
"billing_profile_display_name": "ATAT Billing Profile",
**self.initial_clin_dict,
}
def __repr__(self):
return "<Portfolio(name='{}', user_count='{}', id='{}')>".format(
self.name, self.user_count, self.id
)

View File

@@ -0,0 +1,33 @@
from sqlalchemy import Column, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, backref
from atat.models.base import Base
import atat.models.mixins as mixins
class PortfolioInvitation(
Base, mixins.TimestampsMixin, mixins.InvitesMixin, mixins.AuditableMixin
):
__tablename__ = "portfolio_invitations"
portfolio_role_id = Column(
UUID(as_uuid=True), ForeignKey("portfolio_roles.id"), index=True, nullable=False
)
role = relationship(
"PortfolioRole",
backref=backref("invitations", order_by="PortfolioInvitation.time_created"),
)
@property
def portfolio(self):
if self.role: # pragma: no branch
return self.role.portfolio
@property
def portfolio_id(self):
return self.role.portfolio_id
@property
def application_id(self):
return None

View File

@@ -0,0 +1,148 @@
from enum import Enum
from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum, Table
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.event import listen
from atat.models.base import Base
import atat.models.types as types
import atat.models.mixins as mixins
from atat.utils import first_or_none
from atat.models.mixins.auditable import record_permission_sets_updates
class Status(Enum):
ACTIVE = "active"
DISABLED = "disabled"
PENDING = "pending"
portfolio_roles_permission_sets = Table(
"portfolio_roles_permission_sets",
Base.metadata,
Column("portfolio_role_id", UUID(as_uuid=True), ForeignKey("portfolio_roles.id")),
Column("permission_set_id", UUID(as_uuid=True), ForeignKey("permission_sets.id")),
)
class PortfolioRole(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.PermissionsMixin
):
__tablename__ = "portfolio_roles"
id = types.Id()
portfolio_id = Column(
UUID(as_uuid=True), ForeignKey("portfolios.id"), index=True, nullable=False
)
portfolio = relationship("Portfolio", back_populates="roles")
user_id = Column(
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=True
)
status = Column(
SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False
)
permission_sets = relationship(
"PermissionSet", secondary=portfolio_roles_permission_sets
)
def __repr__(self):
return "<PortfolioRole(portfolio='{}', user_id='{}', id='{}', permissions={})>".format(
self.portfolio.name, self.user_id, self.id, self.permissions
)
@property
def history(self):
previous_state = self.get_changes()
change_set = {}
if "status" in previous_state:
from_status = previous_state["status"][0].value
to_status = self.status.value
change_set["status"] = [from_status, to_status]
return change_set
@property
def event_details(self):
return {
"updated_user_name": self.user_name,
"updated_user_id": str(self.user_id),
}
@property
def latest_invitation(self):
if self.invitations:
return self.invitations[-1]
@property
def display_status(self):
if self.status == Status.ACTIVE:
return "active"
elif self.status == Status.DISABLED:
return "disabled"
elif self.latest_invitation:
if self.latest_invitation.is_revoked:
return "invite_revoked"
elif self.latest_invitation.is_rejected_wrong_user:
return "invite_error"
elif (
self.latest_invitation.is_rejected_expired
or self.latest_invitation.is_expired
):
return "invite_expired"
else:
return "invite_pending"
else:
return "unknown"
def has_permission_set(self, perm_set_name):
return first_or_none(
lambda prms: prms.name == perm_set_name, self.permission_sets
)
@property
def has_dod_id_error(self):
return self.latest_invitation and self.latest_invitation.is_rejected_wrong_user
@property
def user_name(self):
if self.user:
return self.user.full_name
else:
return self.latest_invitation.user_name
@property
def full_name(self):
return self.user_name
@property
def is_active(self):
return self.status == Status.ACTIVE
@property
def can_resend_invitation(self):
return not self.is_active and (
self.latest_invitation and self.latest_invitation.is_inactive
)
@property
def application_id(self):
return None
Index(
"portfolio_role_user_portfolio",
PortfolioRole.user_id,
PortfolioRole.portfolio_id,
unique=True,
)
listen(
PortfolioRole.permission_sets,
"bulk_replace",
record_permission_sets_updates,
raw=True,
)

View File

@@ -0,0 +1,261 @@
import importlib
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum
from sqlalchemy.orm import relationship, reconstructor
from sqlalchemy.dialects.postgresql import UUID
from pydantic import ValidationError as PydanticValidationError
from transitions import Machine
from transitions.extensions.states import add_state_features, Tags
from flask import current_app as app
from atat.domain.csp.cloud.exceptions import ConnectionException, UnknownServerException
from atat.database import db
from atat.models.types import Id
from atat.models.base import Base
import atat.models.mixins as mixins
from atat.models.mixins.state_machines import (
FSMStates,
AzureStages,
StageStates,
_build_transitions,
)
class StateMachineMisconfiguredError(Exception):
def __init__(self, class_details):
self.class_details = class_details
@property
def message(self):
return self.class_details
def _stage_to_classname(stage):
return "".join(map(lambda word: word.capitalize(), stage.split("_")))
def _stage_state_to_stage_name(state, stage_state):
return state.name.split(f"_{stage_state.name}")[0].lower()
def get_stage_csp_class(stage, class_type):
"""
given a stage name and class_type return the class
class_type is either 'payload' or 'result'
"""
cls_name = f"{_stage_to_classname(stage)}CSP{class_type.capitalize()}"
try:
return getattr(
importlib.import_module("atat.domain.csp.cloud.models"), cls_name
)
except AttributeError:
raise StateMachineMisconfiguredError(
f"could not import CSP Payload/Result class {cls_name}"
)
@add_state_features(Tags)
class StateMachineWithTags(Machine):
pass
class PortfolioStateMachine(
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.FSMMixin,
):
__tablename__ = "portfolio_state_machines"
id = Id()
portfolio_id = Column(UUID(as_uuid=True), ForeignKey("portfolios.id"),)
portfolio = relationship("Portfolio", back_populates="state_machine")
state = Column(
SQLAEnum(FSMStates, native_enum=False, create_constraint=False),
default=FSMStates.UNSTARTED,
nullable=False,
)
def __init__(self, portfolio, csp=None, **kwargs):
self.portfolio = portfolio
self.attach_machine()
def after_state_change(self, event):
db.session.add(self)
db.session.commit()
def __repr__(self):
return f"<PortfolioStateMachine(state='{self.current_state.name}', portfolio='{self.portfolio.name}'"
@reconstructor
def attach_machine(self, stages=AzureStages):
"""
This is called as a result of a sqlalchemy query.
Attach a machine depending on the current state.
"""
self.machine = StateMachineWithTags(
model=self,
send_event=True,
initial=self.current_state if self.state else FSMStates.UNSTARTED,
auto_transitions=False,
after_state_change="after_state_change",
)
states, transitions = _build_transitions(stages)
self.machine.add_states(self.system_states + states)
self.machine.add_transitions(self.system_transitions + transitions)
@property
def current_state(self):
if isinstance(self.state, str):
return getattr(FSMStates, self.state)
return self.state
def trigger_next_transition(self, **kwargs):
state_obj = self.machine.get_state(self.state)
kwargs["csp_data"] = kwargs.get("csp_data", {})
if state_obj.is_system:
if self.current_state in (FSMStates.UNSTARTED, FSMStates.STARTING):
# call the first trigger availabe for these two system states
trigger_name = self.machine.get_triggers(self.current_state.name)[0]
self.trigger(trigger_name, **kwargs)
elif self.current_state == FSMStates.STARTED:
# get the first trigger that starts with 'create_'
create_trigger = next(
filter(
lambda trigger: trigger.startswith("create_"),
self.machine.get_triggers(FSMStates.STARTED.name),
),
None,
)
if create_trigger:
self.trigger(create_trigger, **kwargs)
else:
app.logger.info(
f"could not locate 'create trigger' for {self.__repr__()}"
)
self.trigger("fail")
elif self.current_state == FSMStates.FAILED:
# get the first trigger that starts with 'resume_progress_'
resume_progress_trigger = next(
filter(
lambda trigger: trigger.startswith("resume_progress_"),
self.machine.get_triggers(FSMStates.FAILED.name),
),
None,
)
if resume_progress_trigger:
self.trigger(resume_progress_trigger, **kwargs)
else:
app.logger.info(
f"could not locate 'resume progress trigger' for {self.__repr__()}"
)
elif state_obj.is_CREATED:
# if last CREATED state then transition to COMPLETED
if list(AzureStages)[-1].name == state_obj.name.split("_CREATED")[
0
] and "complete" in self.machine.get_triggers(state_obj.name):
app.logger.info(
"last stage completed. transitioning to COMPLETED state"
)
self.trigger("complete", **kwargs)
# the create trigger for the next stage should be in the available
# triggers for the current state
create_trigger = next(
filter(
lambda trigger: trigger.startswith("create_"),
self.machine.get_triggers(self.state.name),
),
None,
)
if create_trigger is not None:
self.trigger(create_trigger, **kwargs)
def after_in_progress_callback(self, event):
# Accumulate payload w/ creds
payload = event.kwargs.get("csp_data")
current_stage = _stage_state_to_stage_name(
self.current_state, StageStates.IN_PROGRESS
)
payload_data_cls = get_stage_csp_class(current_stage, "payload")
if not payload_data_cls:
app.logger.info(
f"could not resolve payload data class for stage {current_stage}"
)
self.fail_stage(current_stage)
try:
payload_data = payload_data_cls(**payload)
except PydanticValidationError as exc:
app.logger.error(
f"Payload Validation Error in {self.__repr__()}:", exc_info=1
)
app.logger.info(exc.json())
print(exc.json())
app.logger.info(payload)
self.fail_stage(current_stage)
# TODO: Determine best place to do this, maybe @reconstructor
self.csp = app.csp.cloud
try:
func_name = f"create_{current_stage}"
response = getattr(self.csp, func_name)(payload_data)
if self.portfolio.csp_data is None:
self.portfolio.csp_data = {}
self.portfolio.csp_data.update(response.dict())
db.session.add(self.portfolio)
db.session.commit()
except PydanticValidationError as exc:
app.logger.error(
f"Failed to cast response to valid result class {self.__repr__()}:",
exc_info=1,
)
app.logger.info(exc.json())
print(exc.json())
app.logger.info(payload_data)
# TODO: Ensure that failing the stage does not preclude a Celery retry
self.fail_stage(current_stage)
# TODO: catch and handle general CSP exception here
except (ConnectionException, UnknownServerException) as exc:
app.logger.error(
f"CSP api call. Caught exception for {self.__repr__()}.", exc_info=1,
)
# TODO: Ensure that failing the stage does not preclude a Celery retry
self.fail_stage(current_stage)
self.finish_stage(current_stage)
def is_csp_data_valid(self, event):
"""
This function guards advancing states from *_IN_PROGRESS to *_COMPLETED.
"""
if self.portfolio.csp_data is None or not isinstance(
self.portfolio.csp_data, dict
):
print("no csp data")
return False
return True
def is_ready_resume_progress(self, event):
"""
This function guards advancing states from FAILED to *_IN_PROGRESS.
"""
return True
@property
def application_id(self):
return None

166
atat/models/task_order.py Normal file
View File

@@ -0,0 +1,166 @@
from enum import Enum
from sqlalchemy import Column, DateTime, ForeignKey, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
from atat.models.clin import CLIN
from atat.models.base import Base
import atat.models.types as types
import atat.models.mixins as mixins
from atat.models.attachment import Attachment
from pendulum import today
from sqlalchemy import func
class Status(Enum):
DRAFT = "Draft"
ACTIVE = "Active"
UPCOMING = "Upcoming"
EXPIRED = "Expired"
UNSIGNED = "Unsigned"
SORT_ORDERING = [
Status.ACTIVE,
Status.DRAFT,
Status.UPCOMING,
Status.EXPIRED,
]
class TaskOrder(Base, mixins.TimestampsMixin):
__tablename__ = "task_orders"
id = types.Id()
portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False)
portfolio = relationship("Portfolio")
pdf_attachment_id = Column(ForeignKey("attachments.id"))
_pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id])
pdf_last_sent_at = Column(DateTime)
number = Column(String, unique=True,) # Task Order Number
signer_dod_id = Column(String)
signed_at = Column(DateTime)
clins = relationship(
"CLIN",
back_populates="task_order",
cascade="all, delete-orphan",
order_by=lambda: [func.substr(CLIN.number, 2), func.substr(CLIN.number, 1, 2)],
)
@hybrid_property
def pdf(self):
return self._pdf
@pdf.setter
def pdf(self, new_pdf):
self._pdf = self._set_attachment(new_pdf, "_pdf")
def _set_attachment(self, new_attachment, attribute):
if isinstance(new_attachment, Attachment):
return new_attachment
elif isinstance(new_attachment, dict):
if new_attachment["filename"] and new_attachment["object_name"]:
attachment = Attachment.get_or_create(
new_attachment["object_name"], new_attachment
)
return attachment
else:
return None
elif not new_attachment and hasattr(self, attribute):
return None
else:
raise TypeError("Could not set attachment with invalid type")
@property
def is_draft(self):
return self.status == Status.DRAFT or self.status == Status.UNSIGNED
@property
def is_active(self):
return self.status == Status.ACTIVE
@property
def is_expired(self):
return self.status == Status.EXPIRED
@property
def is_upcoming(self):
return self.status == Status.UPCOMING
@property
def clins_are_completed(self):
return all([len(self.clins), (clin.is_completed for clin in self.clins)])
@property
def is_completed(self):
return all([self.pdf, self.number, self.clins_are_completed])
@property
def is_signed(self):
return self.signed_at is not None
@property
def status(self):
todays_date = today(tz="UTC").date()
if not self.is_completed and not self.is_signed:
return Status.DRAFT
elif self.is_completed and not self.is_signed:
return Status.UNSIGNED
elif todays_date < self.start_date:
return Status.UPCOMING
elif todays_date > self.end_date:
return Status.EXPIRED
elif self.start_date <= todays_date <= self.end_date:
return Status.ACTIVE
@property
def start_date(self):
return min((c.start_date for c in self.clins), default=None)
@property
def end_date(self):
return max((c.end_date for c in self.clins), default=None)
@property
def days_to_expiration(self):
if self.end_date:
return (self.end_date - today(tz="UTC").date()).days
@property
def total_obligated_funds(self):
return sum(
(clin.obligated_amount for clin in self.clins if clin.obligated_amount)
)
@property
def total_contract_amount(self):
return sum((clin.total_amount for clin in self.clins if clin.total_amount))
@property
def display_status(self):
if self.status == Status.UNSIGNED:
return Status.DRAFT.value
else:
return self.status.value
@property
def portfolio_name(self):
return self.portfolio.name
def to_dictionary(self):
return {
"portfolio_name": self.portfolio_name,
"pdf": self.pdf,
"clins": [clin.to_dictionary() for clin in self.clins],
**{
c.name: getattr(self, c.name)
for c in self.__table__.columns
if c.name not in ["id"]
},
}
def __repr__(self):
return "<TaskOrder(number='{}', id='{}')>".format(self.number, self.id)

11
atat/models/types.py Normal file
View File

@@ -0,0 +1,11 @@
import sqlalchemy
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import UUID
def Id():
return Column(
UUID(as_uuid=True),
primary_key=True,
server_default=sqlalchemy.text("uuid_generate_v4()"),
)

128
atat/models/user.py Normal file
View File

@@ -0,0 +1,128 @@
from sqlalchemy import String, ForeignKey, Column, Date, Table, TIMESTAMP
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.event import listen
from atat.models.base import Base
import atat.models.types as types
import atat.models.mixins as mixins
from atat.models.portfolio_invitation import PortfolioInvitation
from atat.models.application_invitation import ApplicationInvitation
from atat.models.mixins.auditable import (
AuditableMixin,
ACTION_UPDATE,
record_permission_sets_updates,
)
users_permission_sets = Table(
"users_permission_sets",
Base.metadata,
Column("user_id", UUID(as_uuid=True), ForeignKey("users.id")),
Column("permission_set_id", UUID(as_uuid=True), ForeignKey("permission_sets.id")),
)
class User(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.PermissionsMixin
):
__tablename__ = "users"
id = types.Id()
username = Column(String)
permission_sets = relationship("PermissionSet", secondary=users_permission_sets)
portfolio_roles = relationship("PortfolioRole", backref="user")
application_roles = relationship(
"ApplicationRole",
backref="user",
primaryjoin="and_(ApplicationRole.user_id == User.id, ApplicationRole.deleted == False)",
)
portfolio_invitations = relationship(
"PortfolioInvitation", foreign_keys=PortfolioInvitation.user_id
)
sent_portfolio_invitations = relationship(
"PortfolioInvitation", foreign_keys=PortfolioInvitation.inviter_id
)
application_invitations = relationship(
"ApplicationInvitation", foreign_keys=ApplicationInvitation.user_id
)
sent_application_invitations = relationship(
"ApplicationInvitation", foreign_keys=ApplicationInvitation.inviter_id
)
email = Column(String)
dod_id = Column(String, unique=True, nullable=False)
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
phone_number = Column(String)
phone_ext = Column(String)
service_branch = Column(String)
citizenship = Column(String)
designation = Column(String)
date_latest_training = Column(Date)
last_login = Column(TIMESTAMP(timezone=True), nullable=True)
last_session_id = Column(UUID(as_uuid=True), nullable=True)
cloud_id = Column(String)
REQUIRED_FIELDS = [
"email",
"dod_id",
"first_name",
"last_name",
"phone_number",
"service_branch",
"citizenship",
"designation",
"date_latest_training",
]
@property
def profile_complete(self):
return all(
[
getattr(self, field_name) is not None
for field_name in self.REQUIRED_FIELDS
]
)
@property
def full_name(self):
return "{} {}".format(self.first_name, self.last_name)
@property
def displayname(self):
return self.full_name
@property
def portfolio_id(self):
return None
@property
def application_id(self):
return None
def __repr__(self):
return "<User(name='{}', dod_id='{}', email='{}', id='{}')>".format(
self.full_name, self.dod_id, self.email, self.id
)
def to_dictionary(self):
return {
c.name: getattr(self, c.name)
for c in self.__table__.columns
if c.name not in ["id"]
}
@staticmethod
def audit_update(mapper, connection, target):
changes = AuditableMixin.get_changes(target)
if changes and not "last_login" in changes:
target.create_audit_event(connection, target, ACTION_UPDATE)
listen(User.permission_sets, "bulk_replace", record_permission_sets_updates, raw=True)

100
atat/models/utils.py Normal file
View File

@@ -0,0 +1,100 @@
from typing import List
from sqlalchemy import func, sql, Interval, and_, or_
from contextlib import contextmanager
from atat.database import db
from atat.domain.exceptions import ClaimFailedException
@contextmanager
def claim_for_update(resource, minutes=30):
"""
Claim a mutually exclusive expiring hold on a resource.
Uses the database as a central source of time in case the server clocks have drifted.
Args:
resource: A SQLAlchemy model instance with a `claimed_until` attribute.
minutes: The maximum amount of time, in minutes, to hold the claim.
"""
Model = resource.__class__
claim_until = func.now() + func.cast(
sql.functions.concat(minutes, " MINUTES"), Interval
)
# Optimistically query for and update the resource in question. If it's
# already claimed, `rows_updated` will be 0 and we can give up.
rows_updated = (
db.session.query(Model)
.filter(
and_(
Model.id == resource.id,
or_(Model.claimed_until.is_(None), Model.claimed_until <= func.now()),
)
)
.update({"claimed_until": claim_until}, synchronize_session="fetch")
)
if rows_updated < 1:
raise ClaimFailedException(resource)
# Fetch the claimed resource
claimed = db.session.query(Model).filter_by(id=resource.id).one()
try:
# Give the resource to the caller.
yield claimed
finally:
# Release the claim.
db.session.query(Model).filter(Model.id == resource.id).filter(
Model.claimed_until != None
).update({"claimed_until": None}, synchronize_session="fetch")
db.session.commit()
@contextmanager
def claim_many_for_update(resources: List, minutes=30):
"""
Claim a mutually exclusive expiring hold on a group of resources.
Uses the database as a central source of time in case the server clocks have drifted.
Args:
resources: A list of SQLAlchemy model instances with a `claimed_until` attribute.
minutes: The maximum amount of time, in minutes, to hold the claim.
"""
Model = resources[0].__class__
claim_until = func.now() + func.cast(
sql.functions.concat(minutes, " MINUTES"), Interval
)
ids = tuple(r.id for r in resources)
# Optimistically query for and update the resources in question. If they're
# already claimed, `rows_updated` will be 0 and we can give up.
rows_updated = (
db.session.query(Model)
.filter(
and_(
Model.id.in_(ids),
or_(Model.claimed_until.is_(None), Model.claimed_until <= func.now()),
)
)
.update({"claimed_until": claim_until}, synchronize_session="fetch")
)
if rows_updated < 1:
# TODO: Generalize this exception class so it can take multiple resources
raise ClaimFailedException(resources[0])
# Fetch the claimed resources
claimed = db.session.query(Model).filter(Model.id.in_(ids)).all()
try:
# Give the resource to the caller.
yield claimed
finally:
# Release the claim.
db.session.query(Model).filter(Model.id.in_(ids)).filter(
Model.claimed_until != None
).update({"claimed_until": None}, synchronize_session="fetch")
db.session.commit()