Merge pull request #263 from dod-ccpo/manually-approve-request-#159038361
Manually approve request #159038361
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
"""request_reviews comment column name
|
||||||
|
|
||||||
|
Revision ID: 53ab3edd334b
|
||||||
|
Revises: 777ded5c57a0
|
||||||
|
Create Date: 2018-09-10 13:29:02.648359
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '53ab3edd334b'
|
||||||
|
down_revision = '777ded5c57a0'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('request_reviews', sa.Column('comment', sa.String(), nullable=True))
|
||||||
|
op.drop_column('request_reviews', 'comments')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('request_reviews', sa.Column('comments', sa.VARCHAR(), autoincrement=False, nullable=True))
|
||||||
|
op.drop_column('request_reviews', 'comment')
|
||||||
|
# ### end Alembic commands ###
|
@@ -0,0 +1,36 @@
|
|||||||
|
"""bigint for request review id
|
||||||
|
|
||||||
|
Revision ID: 777ded5c57a0
|
||||||
|
Revises: 7bdb2055d7c7
|
||||||
|
Create Date: 2018-09-10 13:24:36.328610
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '777ded5c57a0'
|
||||||
|
down_revision = '7bdb2055d7c7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('request_reviews', 'id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
type_=sa.BigInteger(),
|
||||||
|
nullable=False)
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('request_reviews', 'id',
|
||||||
|
existing_type=sa.BigInteger(),
|
||||||
|
type_=sa.Integer(),
|
||||||
|
nullable=False)
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
46
alembic/versions/7bdb2055d7c7_add_request_review_table.py
Normal file
46
alembic/versions/7bdb2055d7c7_add_request_review_table.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""add request review table
|
||||||
|
|
||||||
|
Revision ID: 7bdb2055d7c7
|
||||||
|
Revises: ad30159ef19b
|
||||||
|
Create Date: 2018-09-06 15:15:40.666840
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '7bdb2055d7c7'
|
||||||
|
down_revision = 'ad30159ef19b'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('request_reviews',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('comments', sa.String(), nullable=True),
|
||||||
|
sa.Column('fname_mao', sa.String(), nullable=True),
|
||||||
|
sa.Column('lname_mao', sa.String(), nullable=True),
|
||||||
|
sa.Column('email_mao', sa.String(), nullable=True),
|
||||||
|
sa.Column('phone_mao', sa.String(), nullable=True),
|
||||||
|
sa.Column('fname_ccpo', sa.String(), nullable=True),
|
||||||
|
sa.Column('lname_ccpo', sa.String(), nullable=True),
|
||||||
|
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.add_column('request_status_events', sa.Column('request_review_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key(None, 'request_status_events', 'request_reviews', ['request_review_id'], ['id'])
|
||||||
|
op.create_foreign_key(None, 'request_reviews', 'users', ['user_id'], ['id'])
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint(None, 'request_status_events', type_='foreignkey')
|
||||||
|
op.drop_constraint(None, 'request_reviews', type_='foreignkey')
|
||||||
|
op.drop_column('request_status_events', 'request_review_id')
|
||||||
|
op.drop_table('request_reviews')
|
||||||
|
# ### end Alembic commands ###
|
@@ -12,6 +12,7 @@ from atst.domain.workspaces import Workspaces
|
|||||||
from atst.models.request import Request
|
from atst.models.request import Request
|
||||||
from atst.models.request_revision import RequestRevision
|
from atst.models.request_revision import RequestRevision
|
||||||
from atst.models.request_status_event import RequestStatusEvent, RequestStatus
|
from atst.models.request_status_event import RequestStatusEvent, RequestStatus
|
||||||
|
from atst.models.request_review import RequestReview
|
||||||
from atst.utils import deep_merge
|
from atst.utils import deep_merge
|
||||||
|
|
||||||
from .exceptions import NotFoundError, UnauthorizedError
|
from .exceptions import NotFoundError, UnauthorizedError
|
||||||
@@ -90,7 +91,7 @@ class Requests(object):
|
|||||||
if Requests.should_auto_approve(request):
|
if Requests.should_auto_approve(request):
|
||||||
new_status = RequestStatus.PENDING_FINANCIAL_VERIFICATION
|
new_status = RequestStatus.PENDING_FINANCIAL_VERIFICATION
|
||||||
else:
|
else:
|
||||||
new_status = RequestStatus.PENDING_CCPO_APPROVAL
|
new_status = RequestStatus.PENDING_CCPO_ACCEPTANCE
|
||||||
|
|
||||||
request = Requests.set_status(request, new_status)
|
request = Requests.set_status(request, new_status)
|
||||||
|
|
||||||
@@ -175,6 +176,10 @@ class Requests(object):
|
|||||||
def is_pending_financial_verification(cls, request):
|
def is_pending_financial_verification(cls, request):
|
||||||
return request.status == RequestStatus.PENDING_FINANCIAL_VERIFICATION
|
return request.status == RequestStatus.PENDING_FINANCIAL_VERIFICATION
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_pending_ccpo_acceptance(cls, request):
|
||||||
|
return request.status == RequestStatus.PENDING_CCPO_ACCEPTANCE
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_pending_ccpo_approval(cls, request):
|
def is_pending_ccpo_approval(cls, request):
|
||||||
return request.status == RequestStatus.PENDING_CCPO_APPROVAL
|
return request.status == RequestStatus.PENDING_CCPO_APPROVAL
|
||||||
@@ -214,7 +219,12 @@ WHERE requests_with_status.status = :status
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def pending_ccpo_count(cls):
|
def pending_ccpo_count(cls):
|
||||||
return Requests.status_count(RequestStatus.PENDING_CCPO_APPROVAL)
|
return sum(
|
||||||
|
[
|
||||||
|
Requests.status_count(RequestStatus.PENDING_CCPO_ACCEPTANCE),
|
||||||
|
Requests.status_count(RequestStatus.PENDING_CCPO_APPROVAL),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def completed_count(cls):
|
def completed_count(cls):
|
||||||
@@ -260,3 +270,24 @@ WHERE requests_with_status.status = :status
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _add_review(cls, user, request, review_data):
|
||||||
|
request.latest_status.review = RequestReview(reviewer=user, **review_data)
|
||||||
|
|
||||||
|
db.session.add(request)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return request
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def accept_for_financial_verification(cls, user, request, review_data):
|
||||||
|
Requests.set_status(request, RequestStatus.PENDING_FINANCIAL_VERIFICATION)
|
||||||
|
|
||||||
|
return Requests._add_review(user, request, review_data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def request_changes(cls, user, request, review_data):
|
||||||
|
Requests.set_status(request, RequestStatus.CHANGES_REQUESTED)
|
||||||
|
|
||||||
|
return Requests._add_review(user, request, review_data)
|
||||||
|
26
atst/forms/ccpo_review.py
Normal file
26
atst/forms/ccpo_review.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from wtforms.fields.html5 import EmailField, TelField
|
||||||
|
from wtforms.fields import StringField, TextAreaField
|
||||||
|
from wtforms.validators import Email, Optional
|
||||||
|
|
||||||
|
from .forms import ValidatedForm
|
||||||
|
from .validators import Alphabet, PhoneNumber
|
||||||
|
|
||||||
|
|
||||||
|
class CCPOReviewForm(ValidatedForm):
|
||||||
|
comment = TextAreaField("Comments (optional)")
|
||||||
|
fname_mao = StringField(
|
||||||
|
"First Name (optional)", validators=[Optional(), Alphabet()]
|
||||||
|
)
|
||||||
|
lname_mao = StringField("Last Name (optional)", validators=[Optional(), Alphabet()])
|
||||||
|
email_mao = EmailField(
|
||||||
|
"Mission Owner e-mail (optional)", validators=[Optional(), Email()]
|
||||||
|
)
|
||||||
|
phone_mao = TelField(
|
||||||
|
"Mission Owner phone number (optional)", validators=[Optional(), PhoneNumber()]
|
||||||
|
)
|
||||||
|
fname_ccpo = StringField(
|
||||||
|
"First Name (optional)", validators=[Optional(), Alphabet()]
|
||||||
|
)
|
||||||
|
lname_ccpo = StringField(
|
||||||
|
"Last Name (optional)", validators=[Optional(), Alphabet()]
|
||||||
|
)
|
@@ -15,3 +15,4 @@ from .project import Project
|
|||||||
from .environment import Environment
|
from .environment import Environment
|
||||||
from .attachment import Attachment
|
from .attachment import Attachment
|
||||||
from .request_revision import RequestRevision
|
from .request_revision import RequestRevision
|
||||||
|
from .request_review import RequestReview
|
||||||
|
@@ -115,13 +115,17 @@ class Request(Base):
|
|||||||
|
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_status(self):
|
||||||
|
return self.status_events[-1]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self):
|
def status(self):
|
||||||
return self.status_events[-1].new_status
|
return self.latest_status.new_status
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status_displayname(self):
|
def status_displayname(self):
|
||||||
return self.status_events[-1].displayname
|
return self.latest_status.displayname
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def annual_spend(self):
|
def annual_spend(self):
|
||||||
@@ -152,5 +156,11 @@ class Request(Base):
|
|||||||
def action_required_by(self):
|
def action_required_by(self):
|
||||||
return {
|
return {
|
||||||
RequestStatus.PENDING_FINANCIAL_VERIFICATION: "mission_owner",
|
RequestStatus.PENDING_FINANCIAL_VERIFICATION: "mission_owner",
|
||||||
|
RequestStatus.CHANGES_REQUESTED: "mission_owner",
|
||||||
RequestStatus.PENDING_CCPO_APPROVAL: "ccpo",
|
RequestStatus.PENDING_CCPO_APPROVAL: "ccpo",
|
||||||
|
RequestStatus.PENDING_CCPO_ACCEPTANCE: "ccpo",
|
||||||
}.get(self.status)
|
}.get(self.status)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reviews(self):
|
||||||
|
return [status.review for status in self.status_events if status.review]
|
||||||
|
22
atst/models/request_review.py
Normal file
22
atst/models/request_review.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from sqlalchemy import Column, BigInteger, String, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from atst.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RequestReview(Base):
|
||||||
|
__tablename__ = "request_reviews"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True)
|
||||||
|
status = relationship("RequestStatusEvent", back_populates="review")
|
||||||
|
|
||||||
|
user_id = Column(ForeignKey("users.id"), nullable=False)
|
||||||
|
reviewer = relationship("User")
|
||||||
|
|
||||||
|
comment = Column(String)
|
||||||
|
fname_mao = Column(String)
|
||||||
|
lname_mao = Column(String)
|
||||||
|
email_mao = Column(String)
|
||||||
|
phone_mao = Column(String)
|
||||||
|
fname_ccpo = Column(String)
|
||||||
|
lname_ccpo = Column(String)
|
@@ -13,6 +13,7 @@ class RequestStatus(Enum):
|
|||||||
STARTED = "Started"
|
STARTED = "Started"
|
||||||
SUBMITTED = "Submitted"
|
SUBMITTED = "Submitted"
|
||||||
PENDING_FINANCIAL_VERIFICATION = "Pending Financial Verification"
|
PENDING_FINANCIAL_VERIFICATION = "Pending Financial Verification"
|
||||||
|
PENDING_CCPO_ACCEPTANCE = "Pending CCPO Acceptance"
|
||||||
PENDING_CCPO_APPROVAL = "Pending CCPO Approval"
|
PENDING_CCPO_APPROVAL = "Pending CCPO Approval"
|
||||||
CHANGES_REQUESTED = "Changes Requested"
|
CHANGES_REQUESTED = "Changes Requested"
|
||||||
APPROVED = "Approved"
|
APPROVED = "Approved"
|
||||||
@@ -35,6 +36,9 @@ class RequestStatusEvent(Base):
|
|||||||
request_revision_id = Column(ForeignKey("request_revisions.id"), nullable=False)
|
request_revision_id = Column(ForeignKey("request_revisions.id"), nullable=False)
|
||||||
revision = relationship("RequestRevision")
|
revision = relationship("RequestRevision")
|
||||||
|
|
||||||
|
request_review_id = Column(ForeignKey("request_reviews.id"), nullable=True)
|
||||||
|
review = relationship("RequestReview", back_populates="status")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def displayname(self):
|
def displayname(self):
|
||||||
return self.new_status.value
|
return self.new_status.value
|
||||||
|
@@ -1,10 +1,18 @@
|
|||||||
from flask import render_template, g, Response
|
from flask import (
|
||||||
|
render_template,
|
||||||
|
g,
|
||||||
|
Response,
|
||||||
|
request as http_request,
|
||||||
|
redirect,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
|
|
||||||
from . import requests_bp
|
from . import requests_bp
|
||||||
from atst.domain.requests import Requests
|
from atst.domain.requests import Requests
|
||||||
from atst.domain.exceptions import NotFoundError
|
from atst.domain.exceptions import NotFoundError
|
||||||
from atst.domain.authz import Authorization
|
from atst.domain.authz import Authorization
|
||||||
|
from atst.forms.ccpo_review import CCPOReviewForm
|
||||||
|
|
||||||
|
|
||||||
def task_order_dictionary(task_order):
|
def task_order_dictionary(task_order):
|
||||||
@@ -15,25 +23,54 @@ def task_order_dictionary(task_order):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@requests_bp.route("/requests/approval/<string:request_id>", methods=["GET"])
|
def render_approval(request, form=None):
|
||||||
def approval(request_id):
|
|
||||||
request = Requests.get(g.current_user, request_id)
|
|
||||||
Authorization.check_can_approve_request(g.current_user)
|
|
||||||
|
|
||||||
data = request.body
|
data = request.body
|
||||||
if request.task_order:
|
pending_final_approval = Requests.is_pending_ccpo_approval(request)
|
||||||
|
pending_review = (
|
||||||
|
Requests.is_pending_ccpo_acceptance(request) or pending_final_approval
|
||||||
|
)
|
||||||
|
if pending_final_approval and request.task_order:
|
||||||
data["task_order"] = task_order_dictionary(request.task_order)
|
data["task_order"] = task_order_dictionary(request.task_order)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"requests/approval.html",
|
"requests/approval.html",
|
||||||
data=data,
|
data=data,
|
||||||
request_id=request_id,
|
request_id=request.id,
|
||||||
status=request.status.value,
|
status=request.status.value,
|
||||||
financial_review=True,
|
pending_review=pending_review,
|
||||||
|
financial_review=pending_final_approval,
|
||||||
pdf_available=request.task_order and request.task_order.pdf,
|
pdf_available=request.task_order and request.task_order.pdf,
|
||||||
|
f=form or CCPOReviewForm(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@requests_bp.route("/requests/approval/<string:request_id>", methods=["GET"])
|
||||||
|
def approval(request_id):
|
||||||
|
request = Requests.get(g.current_user, request_id)
|
||||||
|
Authorization.check_can_approve_request(g.current_user)
|
||||||
|
|
||||||
|
return render_approval(request)
|
||||||
|
|
||||||
|
|
||||||
|
@requests_bp.route("/requests/submit_approval/<string:request_id>", methods=["POST"])
|
||||||
|
def submit_approval(request_id):
|
||||||
|
request = Requests.get(g.current_user, request_id)
|
||||||
|
Authorization.check_can_approve_request(g.current_user)
|
||||||
|
|
||||||
|
form = CCPOReviewForm(http_request.form)
|
||||||
|
if form.validate():
|
||||||
|
if http_request.form.get("approved"):
|
||||||
|
Requests.accept_for_financial_verification(
|
||||||
|
g.current_user, request, form.data
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
Requests.request_changes(g.current_user, request, form.data)
|
||||||
|
|
||||||
|
return redirect(url_for("requests.requests_index"))
|
||||||
|
else:
|
||||||
|
return render_approval(request, form)
|
||||||
|
|
||||||
|
|
||||||
@requests_bp.route("/requests/task_order_download/<string:request_id>", methods=["GET"])
|
@requests_bp.route("/requests/task_order_download/<string:request_id>", methods=["GET"])
|
||||||
def task_order_pdf_download(request_id):
|
def task_order_pdf_download(request_id):
|
||||||
request = Requests.get(g.current_user, request_id)
|
request = Requests.get(g.current_user, request_id)
|
||||||
|
@@ -30,7 +30,7 @@ class RequestsIndex(object):
|
|||||||
return {
|
return {
|
||||||
"requests": mapped_requests,
|
"requests": mapped_requests,
|
||||||
"pending_financial_verification": False,
|
"pending_financial_verification": False,
|
||||||
"pending_ccpo_approval": False,
|
"pending_ccpo_acceptance": False,
|
||||||
"extended_view": True,
|
"extended_view": True,
|
||||||
"kpi_inprogress": Requests.in_progress_count(),
|
"kpi_inprogress": Requests.in_progress_count(),
|
||||||
"kpi_pending": Requests.pending_ccpo_count(),
|
"kpi_pending": Requests.pending_ccpo_count(),
|
||||||
@@ -47,12 +47,12 @@ class RequestsIndex(object):
|
|||||||
pending_fv = any(
|
pending_fv = any(
|
||||||
Requests.is_pending_financial_verification(r) for r in requests
|
Requests.is_pending_financial_verification(r) for r in requests
|
||||||
)
|
)
|
||||||
pending_ccpo = any(Requests.is_pending_ccpo_approval(r) for r in requests)
|
pending_ccpo = any(Requests.is_pending_ccpo_acceptance(r) for r in requests)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"requests": mapped_requests,
|
"requests": mapped_requests,
|
||||||
"pending_financial_verification": pending_fv,
|
"pending_financial_verification": pending_fv,
|
||||||
"pending_ccpo_approval": pending_ccpo,
|
"pending_ccpo_acceptance": pending_ccpo,
|
||||||
"num_action_required": num_action_required,
|
"num_action_required": num_action_required,
|
||||||
"extended_view": False,
|
"extended_view": False,
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,9 @@ class RequestsIndex(object):
|
|||||||
edit_link = url_for(
|
edit_link = url_for(
|
||||||
"requests.financial_verification", request_id=request.id
|
"requests.financial_verification", request_id=request.id
|
||||||
)
|
)
|
||||||
elif Requests.is_pending_ccpo_approval(request):
|
elif Requests.is_pending_ccpo_acceptance(
|
||||||
|
request
|
||||||
|
) or Requests.is_pending_ccpo_approval(request):
|
||||||
edit_link = url_for("requests.view_pending_request", request_id=request.id)
|
edit_link = url_for("requests.view_pending_request", request_id=request.id)
|
||||||
else:
|
else:
|
||||||
edit_link = url_for(
|
edit_link = url_for(
|
||||||
|
@@ -2,12 +2,21 @@
|
|||||||
|
|
||||||
{% from "components/icon.html" import Icon %}
|
{% from "components/icon.html" import Icon %}
|
||||||
{% from "components/alert.html" import Alert %}
|
{% from "components/alert.html" import Alert %}
|
||||||
|
{% from "components/text_input.html" import TextInput %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<article class='col col--grow request-approval'>
|
<article class='col col--grow request-approval'>
|
||||||
|
|
||||||
<form>
|
{% if f.errors %}
|
||||||
|
{{ Alert('There were some errors',
|
||||||
|
message="<p>Please see below.</p>",
|
||||||
|
level='error'
|
||||||
|
) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for("requests.submit_approval", request_id=request_id) }}" autocomplete="off">
|
||||||
|
{{ f.csrf_token }}
|
||||||
<section class='panel'>
|
<section class='panel'>
|
||||||
<header class='panel__heading request-approval__heading'>
|
<header class='panel__heading request-approval__heading'>
|
||||||
<h1 class='h2'>Request #{{ request_id }}</h1>
|
<h1 class='h2'>Request #{{ request_id }}</h1>
|
||||||
@@ -24,139 +33,107 @@
|
|||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class='panel'>
|
{% if pending_review %}
|
||||||
<header class='panel__heading'>
|
<section class='panel'>
|
||||||
<h2 class='h3'>Approval Notes</h2>
|
<header class='panel__heading'>
|
||||||
</header>
|
<h2 class='h3'>Approval Notes</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class='panel__content'>
|
<div class='panel__content'>
|
||||||
|
|
||||||
<div class="form__sub-fields">
|
<div class="form__sub-fields">
|
||||||
|
|
||||||
<h3>Instructions for the Requestor</h3>
|
<h3>Instructions for the Requestor</h3>
|
||||||
|
|
||||||
Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. <b>These notes will be visible to the person making the JEDI request</b>.
|
Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. <b>These notes will be visible to the person making the JEDI request</b>.
|
||||||
|
|
||||||
<div class='usa-input'>
|
{{ TextInput(f.comment, paragraph=True, placeholder="Add notes or comments explaining what changes are being requested or why further discussion is needed about this request.") }}
|
||||||
<label for='notes'>Comments <em>(optional)</em></label>
|
|
||||||
<textarea id='notes' placeholder='Add notes or comments explaining what changes are being requested or why further discussion is needed about this request.'/></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-col">
|
|
||||||
<h3>Authorizing Officials</h3>
|
|
||||||
<p>This section is not visible to the person making the request. It is only viewable by CCPO staff.</p>
|
|
||||||
<p>Provide the name of the key officials for both parties that have authorized this request to move forward.</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<h4 class="h3">Mission Authorizing Official</h4>
|
|
||||||
|
|
||||||
<div class='form-row'>
|
<div class="form-row">
|
||||||
|
<div class="form-col">
|
||||||
|
<h3>Authorizing Officials</h3>
|
||||||
|
<p>This section is not visible to the person making the request. It is only viewable by CCPO staff.</p>
|
||||||
|
<p>Provide the name of the key officials for both parties that have authorized this request to move forward.</p>
|
||||||
|
|
||||||
<div class='form-col form-col--half'>
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4 class="h3">Mission Authorizing Official</h4>
|
||||||
|
|
||||||
<div class='usa-input'>
|
|
||||||
<label for='mo-behalf-fname'>First Name</label>
|
<div class='form-row'>
|
||||||
<input id='mo-behalf-fname' type='text' placeholder='First name of mission authorizing official' />
|
|
||||||
|
<div class='form-col form-col--half'>
|
||||||
|
{{ TextInput(f.fname_mao, placeholder="First name of mission authorizing official") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='form-col form-col--half'>
|
||||||
|
{{ TextInput(f.lname_mao, placeholder="Last name of mission authorizing official") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class='form-col form-col--half'>
|
<div class='form-row'>
|
||||||
|
|
||||||
|
<div class='form-col form-col--half'>
|
||||||
|
{{ TextInput(f.email_mao, placeholder="name@mail.mil") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class='form-col form-col--half'>
|
||||||
|
{{ TextInput(f.phone_mao, placeholder="(123) 456-7890", validation='usPhone') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<h4 class="h3">CCPO Authorizing Official</h4>
|
||||||
|
|
||||||
|
<div class='form-row'>
|
||||||
|
|
||||||
|
<div class='form-col form-col--half'>
|
||||||
|
{{ TextInput(f.fname_ccpo, placeholder="First name of CCPO authorizing official") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='form-col form-col--half'>
|
||||||
|
{{ TextInput(f.lname_ccpo, placeholder="Last name of CCPO authorizing official") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<h4 class="h3">CCPO Internal Notes</h4>
|
||||||
|
|
||||||
|
<p>You may add additional comments and notes for internal CCPO reference and follow-up here.</p>
|
||||||
|
|
||||||
|
<div class='form-row'>
|
||||||
|
|
||||||
|
<div class='form-col'>
|
||||||
|
|
||||||
|
<div class='usa-input'>
|
||||||
|
<label for='notes'>Internal Comments <em>(optional)</em></label>
|
||||||
|
<textarea id='notes' placeholder='Add notes or comments for internal CCPO reference.'/></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class='usa-input'>
|
|
||||||
<label for='mo-behalf-lname'>Last Name</label>
|
|
||||||
<input id='mo-behalf-lname' type='text' placeholder='Last name of mission authorizing official'/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class='form-row'>
|
</section>
|
||||||
|
|
||||||
<div class='form-col form-col--half'>
|
<section class='action-group'>
|
||||||
|
<input type="submit" name="approved" class='usa-button usa-button-big' value='Approve Request'>
|
||||||
|
<input type="submit" name="denied" class='usa-button usa-button-big usa-button-secondary' value='Mark as Changes Requested'>
|
||||||
<div class='usa-input'>
|
<a href='{{ url_for("requests.requests_index") }}' class='icon-link'>
|
||||||
<label for='mo-behalf-email'>Mission Owner e-mail (optional)</label>
|
{{ Icon('x') }}
|
||||||
<input id='mo-behalf-email' type='email' placeholder='name@mail.mil'/>
|
<span>Cancel</span>
|
||||||
</div>
|
</a>
|
||||||
|
</section>
|
||||||
</div>
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<div class='form-col form-col--half'>
|
|
||||||
|
|
||||||
<div class='usa-input'>
|
|
||||||
<label for='mo-behalf-phone'>Mission Owner phone number (optional)</label>
|
|
||||||
<input id='mo-behalf-phone' type='tel' placeholder='(123) 456-7890'/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<h4 class="h3">CCPO Authorizing Official</h4>
|
|
||||||
|
|
||||||
<div class='form-row'>
|
|
||||||
|
|
||||||
<div class='form-col form-col--half'>
|
|
||||||
|
|
||||||
<div class='usa-input'>
|
|
||||||
<label for='ccpo-behalf-fname'>First Name</label>
|
|
||||||
<input id='ccpo-behalf-fname' type='text' placeholder='First name of CCPO authorizing official' />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class='form-col form-col--half'>
|
|
||||||
|
|
||||||
<div class='usa-input'>
|
|
||||||
<label for='ccpo-behalf-lname'>Last Name</label>
|
|
||||||
<input id='ccpo-behalf-lname' type='text' placeholder='Last name of CCPO authorizing official'/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<h4 class="h3">CCPO Internal Notes</h4>
|
|
||||||
|
|
||||||
<p>You may add additional comments and notes for internal CCPO reference and follow-up here.</p>
|
|
||||||
|
|
||||||
<div class='form-row'>
|
|
||||||
|
|
||||||
<div class='form-col'>
|
|
||||||
|
|
||||||
<div class='usa-input'>
|
|
||||||
<label for='notes'>Internal Comments <em>(optional)</em></label>
|
|
||||||
<textarea id='notes' placeholder='Add notes or comments for internal CCPO reference.'/></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class='action-group'>
|
|
||||||
<a href='#' class='usa-button usa-button-big'>Approve Request</a>
|
|
||||||
<a href='#' class='usa-button usa-button-big usa-button-secondary'>Mark as Changes Requested</a>
|
|
||||||
<a href='#' class='icon-link'>
|
|
||||||
{{ Icon('x') }}
|
|
||||||
<span>Cancel</span>
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class='panel'>
|
<section class='panel'>
|
||||||
<header class='panel__heading'>
|
<header class='panel__heading'>
|
||||||
|
@@ -13,6 +13,7 @@ from tests.factories import (
|
|||||||
RequestStatusEventFactory,
|
RequestStatusEventFactory,
|
||||||
TaskOrderFactory,
|
TaskOrderFactory,
|
||||||
RequestRevisionFactory,
|
RequestRevisionFactory,
|
||||||
|
RequestReviewFactory,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -50,14 +51,14 @@ def test_dont_auto_approve_if_dollar_value_is_1m_or_above():
|
|||||||
new_request = RequestFactory.create(initial_revision={"dollar_value": 1000000})
|
new_request = RequestFactory.create(initial_revision={"dollar_value": 1000000})
|
||||||
request = Requests.submit(new_request)
|
request = Requests.submit(new_request)
|
||||||
|
|
||||||
assert request.status == RequestStatus.PENDING_CCPO_APPROVAL
|
assert request.status == RequestStatus.PENDING_CCPO_ACCEPTANCE
|
||||||
|
|
||||||
|
|
||||||
def test_dont_auto_approve_if_no_dollar_value_specified():
|
def test_dont_auto_approve_if_no_dollar_value_specified():
|
||||||
new_request = RequestFactory.create(initial_revision={})
|
new_request = RequestFactory.create(initial_revision={})
|
||||||
request = Requests.submit(new_request)
|
request = Requests.submit(new_request)
|
||||||
|
|
||||||
assert request.status == RequestStatus.PENDING_CCPO_APPROVAL
|
assert request.status == RequestStatus.PENDING_CCPO_ACCEPTANCE
|
||||||
|
|
||||||
|
|
||||||
def test_should_allow_submission():
|
def test_should_allow_submission():
|
||||||
@@ -178,3 +179,14 @@ def test_set_status_sets_revision():
|
|||||||
request = RequestFactory.create()
|
request = RequestFactory.create()
|
||||||
Requests.set_status(request, RequestStatus.APPROVED)
|
Requests.set_status(request, RequestStatus.APPROVED)
|
||||||
assert request.latest_revision == request.status_events[-1].revision
|
assert request.latest_revision == request.status_events[-1].revision
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_for_financial_verification():
|
||||||
|
request = RequestFactory.create()
|
||||||
|
review_data = RequestReviewFactory.dictionary()
|
||||||
|
Requests.accept_for_financial_verification(
|
||||||
|
UserFactory.create(), request, review_data
|
||||||
|
)
|
||||||
|
assert request.status == RequestStatus.PENDING_FINANCIAL_VERIFICATION
|
||||||
|
current_review = request.latest_status.review
|
||||||
|
assert current_review.fname_mao == review_data["fname_mao"]
|
||||||
|
@@ -7,6 +7,7 @@ import datetime
|
|||||||
from atst.forms.data import SERVICE_BRANCHES
|
from atst.forms.data import SERVICE_BRANCHES
|
||||||
from atst.models.request import Request
|
from atst.models.request import Request
|
||||||
from atst.models.request_revision import RequestRevision
|
from atst.models.request_revision import RequestRevision
|
||||||
|
from atst.models.request_review import RequestReview
|
||||||
from atst.models.request_status_event import RequestStatusEvent, RequestStatus
|
from atst.models.request_status_event import RequestStatusEvent, RequestStatus
|
||||||
from atst.models.pe_number import PENumber
|
from atst.models.pe_number import PENumber
|
||||||
from atst.models.task_order import TaskOrder
|
from atst.models.task_order import TaskOrder
|
||||||
@@ -16,14 +17,20 @@ from atst.models.workspace import Workspace
|
|||||||
from atst.domain.roles import Roles
|
from atst.domain.roles import Roles
|
||||||
|
|
||||||
|
|
||||||
class RoleFactory(factory.alchemy.SQLAlchemyModelFactory):
|
class Base(factory.alchemy.SQLAlchemyModelFactory):
|
||||||
|
@classmethod
|
||||||
|
def dictionary(cls, **attrs):
|
||||||
|
return factory.build(dict, FACTORY_CLASS=cls, **attrs)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleFactory(Base):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Role
|
model = Role
|
||||||
|
|
||||||
permissions = []
|
permissions = []
|
||||||
|
|
||||||
|
|
||||||
class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
|
class UserFactory(Base):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
|
|
||||||
@@ -40,7 +47,7 @@ class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
|
|||||||
return cls.create(atat_role=role, **kwargs)
|
return cls.create(atat_role=role, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class RequestStatusEventFactory(factory.alchemy.SQLAlchemyModelFactory):
|
class RequestStatusEventFactory(Base):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RequestStatusEvent
|
model = RequestStatusEvent
|
||||||
|
|
||||||
@@ -48,14 +55,29 @@ class RequestStatusEventFactory(factory.alchemy.SQLAlchemyModelFactory):
|
|||||||
sequence = 1
|
sequence = 1
|
||||||
|
|
||||||
|
|
||||||
class RequestRevisionFactory(factory.alchemy.SQLAlchemyModelFactory):
|
class RequestRevisionFactory(Base):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RequestRevision
|
model = RequestRevision
|
||||||
|
|
||||||
id = factory.Sequence(lambda x: uuid4())
|
id = factory.Sequence(lambda x: uuid4())
|
||||||
|
|
||||||
|
|
||||||
class RequestFactory(factory.alchemy.SQLAlchemyModelFactory):
|
class RequestReviewFactory(Base):
|
||||||
|
class Meta:
|
||||||
|
model = RequestReview
|
||||||
|
|
||||||
|
comment = factory.Faker("sentence")
|
||||||
|
fname_mao = factory.Faker("first_name")
|
||||||
|
lname_mao = factory.Faker("last_name")
|
||||||
|
email_mao = factory.Faker("email")
|
||||||
|
phone_mao = factory.LazyFunction(
|
||||||
|
lambda: "".join(random.choices(string.digits, k=10))
|
||||||
|
)
|
||||||
|
fname_ccpo = factory.Faker("first_name")
|
||||||
|
lname_ccpo = factory.Faker("last_name")
|
||||||
|
|
||||||
|
|
||||||
|
class RequestFactory(Base):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Request
|
model = Request
|
||||||
|
|
||||||
@@ -127,17 +149,17 @@ class RequestFactory(factory.alchemy.SQLAlchemyModelFactory):
|
|||||||
return RequestRevisionFactory.build(**data)
|
return RequestRevisionFactory.build(**data)
|
||||||
|
|
||||||
|
|
||||||
class PENumberFactory(factory.alchemy.SQLAlchemyModelFactory):
|
class PENumberFactory(Base):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PENumber
|
model = PENumber
|
||||||
|
|
||||||
|
|
||||||
class TaskOrderFactory(factory.alchemy.SQLAlchemyModelFactory):
|
class TaskOrderFactory(Base):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TaskOrder
|
model = TaskOrder
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceFactory(factory.alchemy.SQLAlchemyModelFactory):
|
class WorkspaceFactory(Base):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Workspace
|
model = Workspace
|
||||||
|
|
||||||
|
@@ -1,4 +1,9 @@
|
|||||||
from tests.factories import RequestFactory, UserFactory
|
from tests.factories import (
|
||||||
|
RequestFactory,
|
||||||
|
UserFactory,
|
||||||
|
RequestStatusEventFactory,
|
||||||
|
RequestReviewFactory,
|
||||||
|
)
|
||||||
from atst.domain.requests import Requests, RequestStatus
|
from atst.domain.requests import Requests, RequestStatus
|
||||||
|
|
||||||
|
|
||||||
@@ -69,3 +74,20 @@ def test_annual_spend():
|
|||||||
request = RequestFactory.create()
|
request = RequestFactory.create()
|
||||||
monthly = request.body.get("details_of_use").get("estimated_monthly_spend")
|
monthly = request.body.get("details_of_use").get("estimated_monthly_spend")
|
||||||
assert request.annual_spend == monthly * 12
|
assert request.annual_spend == monthly * 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviews():
|
||||||
|
request = RequestFactory.create()
|
||||||
|
ccpo = UserFactory.from_atat_role("ccpo")
|
||||||
|
request.status_events = [
|
||||||
|
RequestStatusEventFactory.create(
|
||||||
|
revision=request.latest_revision,
|
||||||
|
review=RequestReviewFactory.create(reviewer=ccpo),
|
||||||
|
),
|
||||||
|
RequestStatusEventFactory.create(
|
||||||
|
revision=request.latest_revision,
|
||||||
|
review=RequestReviewFactory.create(reviewer=ccpo),
|
||||||
|
),
|
||||||
|
RequestStatusEventFactory.create(revision=request.latest_revision),
|
||||||
|
]
|
||||||
|
assert len(request.reviews) == 2
|
||||||
|
@@ -2,9 +2,15 @@ import os
|
|||||||
from flask import url_for
|
from flask import url_for
|
||||||
|
|
||||||
from atst.models.attachment import Attachment
|
from atst.models.attachment import Attachment
|
||||||
|
from atst.models.request_status_event import RequestStatus
|
||||||
from atst.domain.roles import Roles
|
from atst.domain.roles import Roles
|
||||||
|
|
||||||
from tests.factories import RequestFactory, TaskOrderFactory, UserFactory
|
from tests.factories import (
|
||||||
|
RequestFactory,
|
||||||
|
TaskOrderFactory,
|
||||||
|
UserFactory,
|
||||||
|
RequestReviewFactory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_ccpo_can_view_approval(user_session, client):
|
def test_ccpo_can_view_approval(user_session, client):
|
||||||
@@ -59,3 +65,29 @@ def test_task_order_download_does_not_exist(client, user_session):
|
|||||||
url_for("requests.task_order_pdf_download", request_id=request.id)
|
url_for("requests.task_order_pdf_download", request_id=request.id)
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_submit_request_approval(client, user_session):
|
||||||
|
user = UserFactory.from_atat_role("ccpo")
|
||||||
|
user_session(user)
|
||||||
|
request = RequestFactory.create()
|
||||||
|
review_data = RequestReviewFactory.dictionary()
|
||||||
|
review_data["approved"] = True
|
||||||
|
response = client.post(
|
||||||
|
url_for("requests.submit_approval", request_id=request.id), data=review_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert request.status == RequestStatus.PENDING_FINANCIAL_VERIFICATION
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_submit_request_denial(client, user_session):
|
||||||
|
user = UserFactory.from_atat_role("ccpo")
|
||||||
|
user_session(user)
|
||||||
|
request = RequestFactory.create()
|
||||||
|
review_data = RequestReviewFactory.dictionary()
|
||||||
|
review_data["denied"] = True
|
||||||
|
response = client.post(
|
||||||
|
url_for("requests.submit_approval", request_id=request.id), data=review_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert request.status == RequestStatus.CHANGES_REQUESTED
|
||||||
|
@@ -81,4 +81,4 @@ def test_stepthrough_request_form(user_session, screens, client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
finished_request = Requests.get(user, req_id)
|
finished_request = Requests.get(user, req_id)
|
||||||
assert finished_request.status == RequestStatus.PENDING_CCPO_APPROVAL
|
assert finished_request.status == RequestStatus.PENDING_CCPO_ACCEPTANCE
|
||||||
|
Reference in New Issue
Block a user