Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

96 add filing sign endpoint #130

Merged
merged 28 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
54e5307
Added /sign endpoint and SignatureDAO/DTO/alembic
jcadam14 Mar 13, 2024
70e1b20
Added /certify endpoint, updated SubmissionState, alembic, pytests
jcadam14 Mar 13, 2024
c1cd894
Merge branch 'main' into 96-add-filing-sign-endpoint
jcadam14 Mar 14, 2024
2593be9
Merge branch 'main' into 100-add-put-institutionsleifilingsperiod_nam…
jcadam14 Mar 14, 2024
94a60a5
Merge branch '100-add-put-institutionsleifilingsperiod_namesubmission…
jcadam14 Mar 14, 2024
ddf578a
Changed 'sign' to 'certify' in error message
jcadam14 Mar 14, 2024
c25a848
Merge branch '100-add-put-institutionsleifilingsperiod_namesubmission…
jcadam14 Mar 14, 2024
76f5c41
Added certifier to Submission
jcadam14 Mar 14, 2024
ec77581
Merge branch '100-add-put-institutionsleifilingsperiod_namesubmission…
jcadam14 Mar 14, 2024
9292660
Added /sign endpoint, signature table
jcadam14 Mar 14, 2024
19030ea
Have to make enum update postgres centric
jcadam14 Mar 14, 2024
c905ee9
Forgot certifier column on enum rewrite
jcadam14 Mar 14, 2024
48cb342
Trying to keep black from formatting alembic script
jcadam14 Mar 14, 2024
2b13a94
Only turning off/on black formatting for string list
jcadam14 Mar 14, 2024
27424af
Merge branch '100-add-put-institutionsleifilingsperiod_namesubmission…
jcadam14 Mar 14, 2024
6103936
Updated for upsert
jcadam14 Mar 18, 2024
1649f36
Merge branch 'main' into 96-add-filing-sign-endpoint
jcadam14 Mar 18, 2024
a2c2177
Merge branch 'main' into 96-add-filing-sign-endpoint
jcadam14 Mar 26, 2024
4abd730
Changed add_signature to use upsert_helper
jcadam14 Mar 26, 2024
942917a
Removed change that wasn't needed
jcadam14 Mar 26, 2024
f7ac3be
Removed prints
jcadam14 Mar 26, 2024
a7a9a08
Merge branch 'main' into 96-add-filing-sign-endpoint
jcadam14 Mar 26, 2024
3d5b12c
Merge branch 'main' into 96-add-filing-sign-endpoint
jcadam14 Mar 29, 2024
3a7b578
Updated Signature to have the user email
jcadam14 Mar 29, 2024
3573cca
Merge branch 'main' into 96-add-filing-sign-endpoint
jcadam14 Apr 3, 2024
52a10dc
Merge branch 'main' into 96-add-filing-sign-endpoint
jcadam14 Apr 8, 2024
228b847
Merge branch 'main' into 96-add-filing-sign-endpoint
jcadam14 Apr 9, 2024
1256ea8
Needed to update the revises on alembic
jcadam14 Apr 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions db_revisions/versions/fb46d55283d6_add_signature_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""add signature table

Revision ID: fb46d55283d6
Revises: 7a1b7eab0167
Create Date: 2024-03-13 11:41:47.815220

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "fb46d55283d6"
down_revision: Union[str, None] = "7a1b7eab0167"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"signature",
sa.Column("id", sa.INTEGER, autoincrement=True),
sa.Column("filing", sa.Integer),
sa.Column("signer_id", sa.String, nullable=False),
sa.Column("signer_name", sa.String),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably worth adding email address while we're at it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to add the signer email with some small rewrites to support that

sa.Column("signed_date", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id", name="signature_pkey"),
sa.ForeignKeyConstraint(["filing"], ["filing.id"], name="signature_filing_fkey"),
)


def downgrade() -> None:
op.drop_table("signature")
15 changes: 15 additions & 0 deletions src/sbl_filing_api/entities/models/dao.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ def __str__(self):
return f"ContactInfo ID: {self.id}, First Name: {self.first_name}, Last Name: {self.last_name}, Address Street 1: {self.hq_address_street_1}, Address Street 2: {self.hq_address_street_2}, Address City: {self.hq_address_city}, Address State: {self.hq_address_state}, Address Zip: {self.hq_address_zip}"


class SignatureDAO(Base):
__tablename__ = "signature"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
signer_id: Mapped[str]
signer_name: Mapped[str] = mapped_column(
nullable=True
) # Some users may not have populated keycloak first/last name
signed_date: Mapped[datetime] = mapped_column(server_default=func.now())
filing: Mapped[int] = mapped_column(ForeignKey("filing.id"))

def __str__(self):
return f"ID: {self.id}, Filing: {self.filing}, Signer ID: {self.signer_id}, Signer Name: {self.signer_name}, Signing Date: {self.signed_date}"


class FilingDAO(Base):
__tablename__ = "filing"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
Expand All @@ -88,6 +102,7 @@ class FilingDAO(Base):
tasks: Mapped[List[FilingTaskProgressDAO] | None] = relationship(lazy="selectin", cascade="all, delete-orphan")
institution_snapshot_id: Mapped[str]
contact_info: Mapped[ContactInfoDAO] = relationship("ContactInfoDAO", lazy="joined")
signatures: Mapped[List[SignatureDAO] | None] = relationship("SignatureDAO", lazy="selectin")
confirmation_id: Mapped[str] = mapped_column(nullable=True)

def __str__(self):
Expand Down
8 changes: 8 additions & 0 deletions src/sbl_filing_api/entities/models/dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ class ContactInfoDTO(BaseModel):
phone: str


class SignatureDTO(BaseModel):
id: int
signer_id: str
signer_name: str | None = None
signed_date: datetime


class FilingDTO(BaseModel):
model_config = ConfigDict(from_attributes=True)

Expand All @@ -59,6 +66,7 @@ class FilingDTO(BaseModel):
institution_snapshot_id: str
contact_info: ContactInfoDTO | None = None
confirmation_id: str | None = None
signatures: List[SignatureDTO] = []


class FilingPeriodDTO(BaseModel):
Expand Down
6 changes: 6 additions & 0 deletions src/sbl_filing_api/entities/repos/submission_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
FilingTaskProgressDAO,
FilingTaskState,
ContactInfoDAO,
SignatureDAO,
)
from sbl_filing_api.entities.models.dto import (
FilingPeriodDTO,
Expand Down Expand Up @@ -113,6 +114,11 @@ async def update_submission(submission: SubmissionDAO, incoming_session: AsyncSe
raise


async def add_signature(session: AsyncSession, filing_id: int, signer_id: str, signer_name: str = None) -> SignatureDAO:
sig = SignatureDAO(signer_id=signer_id, signer_name=signer_name, filing=filing_id)
return await upsert_helper(session, sig, SignatureDAO)


async def upsert_filing_period(session: AsyncSession, filing_period: FilingPeriodDTO) -> FilingPeriodDAO:
return await upsert_helper(session, filing_period, FilingPeriodDAO)

Expand Down
30 changes: 29 additions & 1 deletion src/sbl_filing_api/routers/filing.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,34 @@ async def post_filing(request: Request, lei: str, period_code: str):
)


@router.put("/institutions/{lei}/filings/{period_code}/sign", response_model=FilingDTO)
@requires("authenticated")
async def sign_filing(request: Request, lei: str, period_code: str):
filing = await repo.get_filing(request.state.db_session, lei, period_code)
if not filing:
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=f"There is no Filing for LEI {lei} in period {period_code}, unable to sign a non-existent Filing.",
)
latest_sub = await repo.get_latest_submission(request.state.db_session, lei, period_code)
if not latest_sub or latest_sub.state != SubmissionState.SUBMISSION_ACCEPTED:
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content=f"Cannot sign filing. Filing for {lei} for period {period_code} does not have a latest submission the SUBMISSION_ACCEPTED state.",
)
if not filing.contact_info:
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content=f"Cannot sign filing. Filing for {lei} for period {period_code} does not have contact info defined.",
)
Comment on lines +87 to +91
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to this Contact Info check, do we need something similar for institution data?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at where the filing is originally created, it looks like we're still mocking that Institution Snapshot data.

async def create_new_filing(session: AsyncSession, lei: str, filing_period: str) -> FilingDAO:
new_filing = FilingDAO(
filing_period=filing_period,
lei=lei,
institution_snapshot_id="v1", # need story to retrieve this from user-fi I believe
)
new_filing = await upsert_helper(session, new_filing, FilingDAO)
new_filing = await populate_missing_tasks(session, [new_filing])
return new_filing[0]

So, it seems like there'll always be a snapshot value set, but it's not currently valid?

Also, I see we have a PUT institution-snapshot-id endpoint...

@router.put("/institutions/{lei}/filings/{period_code}/institution-snapshot-id", response_model=FilingDTO)
@requires("authenticated")
async def put_institution_snapshot(request: Request, lei: str, period_code: str, update_value: SnapshotUpdateDTO):
result = await repo.get_filing(request.state.db_session, lei, period_code)
if result:
result.institution_snapshot_id = update_value.institution_snapshot_id
return await repo.upsert_filing(request.state.db_session, result)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=f"A Filing for the LEI ({lei}) and period ({period_code}) that was attempted to be updated does not exist.",
)

...but I'm not sure where in the current flow we'd call that. Around when we set the SBL institution type? And how would we know to call it if there's newer/different institution data? Lookup the latest one the Sign and submit page, updating it if there is one?

...and do we need to solve any of this for MVP? Is it good enough to just show the latest version of institution data on sign and submit?

Sorry, I know this is now off-topic for this PR. 😆 I'll create a separate issue to work through what (if anything) we want to do for MVP.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to this Contact Info check, do we need something similar for institution data?

I thought about this. Right now we set the institution snapshot ID to a default of "v1". But I think we want to have that start out, like contact info, as None and when they check or update their institution data, the FE sets the snapshot ID and that's how we know they've completed that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created story #143

sig = await repo.add_signature(
request.state.db_session, signer_id=request.user.id, signer_name=request.user.name, filing_id=filing.id
)
filing.confirmation_id = lei + "-" + period_code + "-" + str(latest_sub.id) + "-" + str(sig.signed_date.timestamp())
filing.signatures.append(sig)
Comment on lines +93 to +94
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we know when a filing needs to be re-signed once changes have been made to an already-signed filing?

We could...

  1. Associate signatures to submissions rather than filings. Then on the signing screen if the submission associated to the filing isn't signed, you now you need to sign it? That wouldn't fix needing to re-sign after changing contact info or institution data, though.

  2. Disassociate a signature from a filing if anything changes on a signed filing. Not sure how that'd work in the current one-to-many sigs per filing. Add a invalid/expired type flag?

    Alternatively, don't have it as a one-to-many, and just hang the signature off the filing record similar to other fields. If anything changes on the filing, the signature gets dropped. We lose history of signature, but makes it simple. If we want history, we could implement it the same way we do for institution data at a later time.

Anyway, I don't think we need to solve this for this PR, but I think we will before we go live.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created #146

return await repo.upsert_filing(request.state.db_session, filing)


@router.post("/institutions/{lei}/filings/{period_code}/submissions", response_model=SubmissionDTO)
@requires("authenticated")
async def upload_file(
Expand Down Expand Up @@ -123,7 +151,7 @@ async def accept_submission(request: Request, id: int, lei: str, period_code: st
):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content=f"Submission {id} for LEI {lei} in filing period {period_code} is not in an acceptable state. Submissions must be validated successfully or with only warnings to be signed",
content=f"Submission {id} for LEI {lei} in filing period {period_code} is not in an acceptable state. Submissions must be validated successfully or with only warnings to be accepted.",
)
result.state = SubmissionState.SUBMISSION_ACCEPTED
result.accepter = request.user.id
Expand Down
4 changes: 2 additions & 2 deletions tests/api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def auth_mock(mocker: MockerFixture) -> Mock:
@pytest.fixture
def authed_user_mock(auth_mock: Mock) -> Mock:
claims = {
"name": "test",
"name": "Test User",
"preferred_username": "test_user",
"email": "[email protected]",
"institutions": ["123456ABCDEF", "654321FEDCBA"],
Expand Down Expand Up @@ -72,7 +72,7 @@ def get_filing_mock(mocker: MockerFixture) -> Mock:
mock = mocker.patch("sbl_filing_api.entities.repos.submission_repo.get_filing")
mock.return_value = FilingDAO(
id=1,
lei="1234567890",
lei="123456ABCDEF",
filing_period="2024",
institution_snapshot_id="v1",
contact_info=ContactInfoDAO(
Expand Down
106 changes: 100 additions & 6 deletions tests/api/routers/test_filing_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest

from copy import deepcopy
from datetime import datetime as dt

from unittest.mock import ANY, Mock, AsyncMock

Expand All @@ -18,6 +19,7 @@
FilingTaskState,
ContactInfoDAO,
FilingDAO,
SignatureDAO,
)
from sbl_filing_api.entities.models.dto import ContactInfoDTO

Expand Down Expand Up @@ -50,14 +52,14 @@ def test_unauthed_get_filing(self, app_fixture: FastAPI, get_filing_mock: Mock):

def test_get_filing(self, app_fixture: FastAPI, get_filing_mock: Mock, authed_user_mock: Mock):
client = TestClient(app_fixture)
res = client.get("/v1/filing/institutions/1234567890/filings/2024/")
get_filing_mock.assert_called_with(ANY, "1234567890", "2024")
res = client.get("/v1/filing/institutions/123456ABCDEF/filings/2024/")
get_filing_mock.assert_called_with(ANY, "123456ABCDEF", "2024")
assert res.status_code == 200
assert res.json()["lei"] == "1234567890"
assert res.json()["lei"] == "123456ABCDEF"
assert res.json()["filing_period"] == "2024"

get_filing_mock.return_value = None
res = client.get("/v1/filing/institutions/1234567890/filings/2024/")
res = client.get("/v1/filing/institutions/123456ABCDEF/filings/2024/")
assert res.status_code == 204

def test_unauthed_post_filing(self, app_fixture: FastAPI):
Expand Down Expand Up @@ -211,7 +213,7 @@ def test_authed_upload_file(
files = {"file": ("submission.csv", open(submission_csv, "rb"))}
client = TestClient(app_fixture)

res = client.post("/v1/filing/institutions/1234567890/filings/2024/submissions", files=files)
res = client.post("/v1/filing/institutions/123456ABCDEF/filings/2024/submissions", files=files)
mock_add_submission.assert_called_with(ANY, 1, "123456-7890-ABCDEF-GHIJ", "submission.csv")
assert mock_update_submission.call_args.args[0].state == SubmissionState.SUBMISSION_UPLOADED
assert res.status_code == 200
Expand Down Expand Up @@ -544,7 +546,7 @@ async def test_accept_submission(self, mocker: MockerFixture, app_fixture: FastA
assert res.status_code == 403
assert (
res.json()
== "Submission 1 for LEI 1234567890 in filing period 2024 is not in an acceptable state. Submissions must be validated successfully or with only warnings to be signed"
== "Submission 1 for LEI 1234567890 in filing period 2024 is not in an acceptable state. Submissions must be validated successfully or with only warnings to be accepted."
)

mock.return_value.state = SubmissionState.VALIDATION_SUCCESSFUL
Expand All @@ -558,3 +560,95 @@ async def test_accept_submission(self, mocker: MockerFixture, app_fixture: FastA
res = client.put("/v1/filing/institutions/1234567890/filings/2024/submissions/1/accept")
assert res.status_code == 422
assert res.json() == "Submission ID 1 does not exist, cannot accept a non-existing submission."

async def test_good_sign_filing(
self, mocker: MockerFixture, app_fixture: FastAPI, authed_user_mock: Mock, get_filing_mock: Mock
):
mock = mocker.patch("sbl_filing_api.entities.repos.submission_repo.get_latest_submission")
mock.return_value = SubmissionDAO(
id=1,
submitter="[email protected]",
filing=1,
state=SubmissionState.SUBMISSION_ACCEPTED,
validation_ruleset_version="v1",
submission_time=datetime.datetime.now(),
filename="file1.csv",
)

add_sig_mock = mocker.patch("sbl_filing_api.entities.repos.submission_repo.add_signature")
add_sig_mock.return_value = SignatureDAO(
id=1,
filing=1,
signer_id="1234",
signer_name="Test user",
signed_date=datetime.datetime.now(),
)

upsert_mock = mocker.patch("sbl_filing_api.entities.repos.submission_repo.upsert_filing")
updated_filing_obj = deepcopy(get_filing_mock.return_value)
upsert_mock.return_value = updated_filing_obj

client = TestClient(app_fixture)
res = client.put("/v1/filing/institutions/123456ABCDEF/filings/2024/sign")
add_sig_mock.assert_called_with(ANY, signer_id="123456-7890-ABCDEF-GHIJ", signer_name="Test User", filing_id=1)
assert upsert_mock.call_args.args[1].confirmation_id.startswith("123456ABCDEF-2024-1-")
assert res.status_code == 200
assert float(upsert_mock.call_args.args[1].confirmation_id.split("-")[3]) == pytest.approx(
dt.now().timestamp(), abs=1.5
)

async def test_errors_sign_filing(
self, mocker: MockerFixture, app_fixture: FastAPI, authed_user_mock: Mock, get_filing_mock: Mock
):
sub_mock = mocker.patch("sbl_filing_api.entities.repos.submission_repo.get_latest_submission")
sub_mock.return_value = SubmissionDAO(
id=1,
submitter="[email protected]",
filing=1,
state=SubmissionState.VALIDATION_SUCCESSFUL,
validation_ruleset_version="v1",
submission_time=datetime.datetime.now(),
filename="file1.csv",
)

client = TestClient(app_fixture)
res = client.put("/v1/filing/institutions/123456ABCDEF/filings/2024/sign")
assert res.status_code == 403
assert (
res.json()
== "Cannot sign filing. Filing for 123456ABCDEF for period 2024 does not have a latest submission the SUBMISSION_ACCEPTED state."
)

sub_mock.return_value = None
res = client.put("/v1/filing/institutions/123456ABCDEF/filings/2024/sign")
assert res.status_code == 403
assert (
res.json()
== "Cannot sign filing. Filing for 123456ABCDEF for period 2024 does not have a latest submission the SUBMISSION_ACCEPTED state."
)

sub_mock.return_value = SubmissionDAO(
id=1,
submitter="[email protected]",
filing=1,
state=SubmissionState.SUBMISSION_ACCEPTED,
validation_ruleset_version="v1",
submission_time=datetime.datetime.now(),
filename="file1.csv",
)

get_filing_mock.return_value.contact_info = None
res = client.put("/v1/filing/institutions/123456ABCDEF/filings/2024/sign")
assert res.status_code == 403
assert (
res.json()
== "Cannot sign filing. Filing for 123456ABCDEF for period 2024 does not have contact info defined."
)

get_filing_mock.return_value = None
res = client.put("/v1/filing/institutions/123456ABCDEF/filings/2024/sign")
assert res.status_code == 422
assert (
res.json()
== "There is no Filing for LEI 123456ABCDEF in period 2024, unable to sign a non-existent Filing."
)
12 changes: 12 additions & 0 deletions tests/entities/repos/test_submission_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,18 @@ async def test_mod_filing_task(self, query_session: AsyncSession, transaction_se
seconds_now, abs=1.5
) # allow for possible 1.5 second difference

async def test_add_signature(self, query_session: AsyncSession, transaction_session: AsyncSession):
await repo.add_signature(
transaction_session, filing_id=1, signer_id="1234-5678-ABCD-EFGH", signer_name="Test User"
)
filing = await repo.get_filing(query_session, lei="1234567890", filing_period="2024")

assert filing.signatures[0].id == 1
assert filing.signatures[0].signer_id == "1234-5678-ABCD-EFGH"
assert filing.signatures[0].signer_name == "Test User"
assert filing.signatures[0].filing == 1
assert filing.signatures[0].signed_date.timestamp() == pytest.approx(dt.utcnow().timestamp(), abs=1.0)

async def test_add_filing_task(self, query_session: AsyncSession, transaction_session: AsyncSession):
user = AuthenticatedUser.from_claim({"preferred_username": "testuser"})
await repo.update_task_state(
Expand Down
24 changes: 24 additions & 0 deletions tests/migrations/test_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,30 @@ def test_migration_to_8eaef8ce4c23(alembic_runner: MigrationContext, alembic_eng
assert "contact_info" not in [c["name"] for c in inspector.get_columns("contact_info")]


def test_migrations_to_fb46d55283d6(alembic_runner: MigrationContext, alembic_engine: Engine):
alembic_runner.migrate_up_to("fb46d55283d6")
inspector = sqlalchemy.inspect(alembic_engine)

tables = inspector.get_table_names()

assert "signature" in tables
assert {
"id",
"filing",
"signer_id",
"signer_name",
"signed_date",
} == set([c["name"] for c in inspector.get_columns("signature")])

sig_filing_fk = inspector.get_foreign_keys("signature")[0]
assert sig_filing_fk["name"] == "signature_filing_fkey"
assert (
"filing" in sig_filing_fk["constrained_columns"]
and "filing" == sig_filing_fk["referred_table"]
and "id" in sig_filing_fk["referred_columns"]
)


def test_migrations_to_7a1b7eab0167(alembic_runner: MigrationContext, alembic_engine: Engine):
alembic_runner.migrate_up_to("7a1b7eab0167")
inspector = sqlalchemy.inspect(alembic_engine)
Expand Down
Loading