Update atst to atat
This commit is contained in:
21
atat/models/__init__.py
Normal file
21
atat/models/__init__.py
Normal 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
|
68
atat/models/application.py
Normal file
68
atat/models/application.py
Normal 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()
|
50
atat/models/application_invitation.py
Normal file
50
atat/models/application_invitation.py
Normal 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
|
153
atat/models/application_role.py
Normal file
153
atat/models/application_role.py
Normal 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
65
atat/models/attachment.py
Normal 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)
|
56
atat/models/audit_event.py
Normal file
56
atat/models/audit_event.py
Normal 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
3
atat/models/base.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
85
atat/models/clin.py
Normal file
85
atat/models/clin.py
Normal 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
|
79
atat/models/environment.py
Normal file
79
atat/models/environment.py
Normal 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()
|
99
atat/models/environment_role.py
Normal file
99
atat/models/environment_role.py
Normal 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,
|
||||
)
|
21
atat/models/job_failure.py
Normal file
21
atat/models/job_failure.py
Normal 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
|
7
atat/models/mixins/__init__.py
Normal file
7
atat/models/mixins/__init__.py
Normal 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
|
121
atat/models/mixins/auditable.py
Normal file
121
atat/models/mixins/auditable.py
Normal 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,
|
||||
)
|
5
atat/models/mixins/claimable.py
Normal file
5
atat/models/mixins/claimable.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy import Column, TIMESTAMP
|
||||
|
||||
|
||||
class ClaimableMixin(object):
|
||||
claimed_until = Column(TIMESTAMP(timezone=True))
|
6
atat/models/mixins/deletable.py
Normal file
6
atat/models/mixins/deletable.py
Normal 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())
|
139
atat/models/mixins/invites.py
Normal file
139
atat/models/mixins/invites.py
Normal 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
|
6
atat/models/mixins/permissions.py
Normal file
6
atat/models/mixins/permissions.py
Normal 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
|
||||
]
|
158
atat/models/mixins/state_machines.py
Normal file
158
atat/models/mixins/state_machines.py
Normal 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)
|
13
atat/models/mixins/timestamps.py
Normal file
13
atat/models/mixins/timestamps.py
Normal 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(),
|
||||
)
|
12
atat/models/notification_recipient.py
Normal file
12
atat/models/notification_recipient.py
Normal 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)
|
21
atat/models/permission_set.py
Normal file
21
atat/models/permission_set.py
Normal 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
|
||||
)
|
51
atat/models/permissions.py
Normal file
51
atat/models/permissions.py
Normal 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
228
atat/models/portfolio.py
Normal 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
|
||||
)
|
33
atat/models/portfolio_invitation.py
Normal file
33
atat/models/portfolio_invitation.py
Normal 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
|
148
atat/models/portfolio_role.py
Normal file
148
atat/models/portfolio_role.py
Normal 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,
|
||||
)
|
261
atat/models/portfolio_state_machine.py
Normal file
261
atat/models/portfolio_state_machine.py
Normal 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
166
atat/models/task_order.py
Normal 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
11
atat/models/types.py
Normal 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
128
atat/models/user.py
Normal 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
100
atat/models/utils.py
Normal 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()
|
Reference in New Issue
Block a user