diff --git a/db_revisions/versions/fb46d55283d6_add_signature_table.py b/db_revisions/versions/fb46d55283d6_add_signature_table.py new file mode 100644 index 00000000..0b17fc31 --- /dev/null +++ b/db_revisions/versions/fb46d55283d6_add_signature_table.py @@ -0,0 +1,36 @@ +"""add signature table + +Revision ID: fb46d55283d6 +Revises: ffd779216f6d +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] = "2e81179924b5" +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), + sa.Column("signer_email", sa.String, nullable=False), + 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") diff --git a/src/sbl_filing_api/entities/models/dao.py b/src/sbl_filing_api/entities/models/dao.py index a70fbafd..51d818ba 100644 --- a/src/sbl_filing_api/entities/models/dao.py +++ b/src/sbl_filing_api/entities/models/dao.py @@ -104,6 +104,21 @@ 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 + signer_email: Mapped[str] + 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) @@ -112,6 +127,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): diff --git a/src/sbl_filing_api/entities/models/dto.py b/src/sbl_filing_api/entities/models/dto.py index e8ab3df3..d03bed7c 100644 --- a/src/sbl_filing_api/entities/models/dto.py +++ b/src/sbl_filing_api/entities/models/dto.py @@ -70,6 +70,14 @@ class ContactInfoDTO(BaseModel): phone: str +class SignatureDTO(BaseModel): + id: int + signer_id: str + signer_name: str | None = None + signer_email: str + signed_date: datetime + + class FilingDTO(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -80,6 +88,7 @@ class FilingDTO(BaseModel): institution_snapshot_id: str contact_info: ContactInfoDTO | None = None confirmation_id: str | None = None + signatures: List[SignatureDTO] = [] class FilingPeriodDTO(BaseModel): diff --git a/src/sbl_filing_api/entities/repos/submission_repo.py b/src/sbl_filing_api/entities/repos/submission_repo.py index 828f7959..fcf0bea7 100644 --- a/src/sbl_filing_api/entities/repos/submission_repo.py +++ b/src/sbl_filing_api/entities/repos/submission_repo.py @@ -20,6 +20,7 @@ FilingTaskProgressDAO, FilingTaskState, ContactInfoDAO, + SignatureDAO, AccepterDAO, SubmitterDAO, ) @@ -113,6 +114,11 @@ async def update_submission(submission: SubmissionDAO, incoming_session: AsyncSe return await upsert_helper(session, submission, SubmissionDAO) +async def add_signature(session: AsyncSession, filing_id: int, user: AuthenticatedUser) -> SignatureDAO: + sig = SignatureDAO(signer_id=user.id, signer_name=user.name, signer_email=user.email, 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) diff --git a/src/sbl_filing_api/routers/filing.py b/src/sbl_filing_api/routers/filing.py index 52c3a8c8..21f7143d 100644 --- a/src/sbl_filing_api/routers/filing.py +++ b/src/sbl_filing_api/routers/filing.py @@ -69,6 +69,32 @@ 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.", + ) + sig = await repo.add_signature(request.state.db_session, filing_id=filing.id, user=request.user) + filing.confirmation_id = lei + "-" + period_code + "-" + str(latest_sub.id) + "-" + str(sig.signed_date.timestamp()) + filing.signatures.append(sig) + 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( @@ -144,7 +170,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.", ) updated_accepter = await repo.add_accepter( diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 3c2906d6..8a71aff9 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -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": "test@local.host", "institutions": ["123456ABCDEF", "654321FEDCBA"], @@ -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( diff --git a/tests/api/routers/test_filing_api.py b/tests/api/routers/test_filing_api.py index b46b6ab0..5e90e2e7 100644 --- a/tests/api/routers/test_filing_api.py +++ b/tests/api/routers/test_filing_api.py @@ -5,6 +5,7 @@ import pytest from copy import deepcopy +from datetime import datetime as dt from unittest.mock import ANY, Mock, AsyncMock @@ -19,6 +20,7 @@ FilingTaskState, ContactInfoDAO, FilingDAO, + SignatureDAO, AccepterDAO, SubmitterDAO, ) @@ -53,14 +55,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): @@ -583,7 +585,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 @@ -593,7 +595,7 @@ async def test_accept_submission(self, mocker: MockerFixture, app_fixture: FastA ANY, submission_id=1, accepter="123456-7890-ABCDEF-GHIJ", - accepter_name="test", + accepter_name="Test User", accepter_email="test@local.host", ) @@ -610,6 +612,117 @@ async def test_accept_submission(self, mocker: MockerFixture, app_fixture: FastA 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=SubmitterDAO( + id=1, + submission=1, + submitter="1234-5678-ABCD-EFGH", + submitter_name="Test User", + submitter_email="test1@cfpb.gov", + ), + 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", + signer_email="test@local.host", + 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, filing_id=1, user=authed_user_mock.return_value[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=SubmitterDAO( + id=1, + submission=1, + submitter="1234-5678-ABCD-EFGH", + submitter_name="Test User", + submitter_email="test1@cfpb.gov", + ), + 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=SubmitterDAO( + id=1, + submission=1, + submitter="1234-5678-ABCD-EFGH", + submitter_name="Test User", + submitter_email="test1@cfpb.gov", + ), + 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." + ) + async def test_get_latest_sub_report(self, mocker: MockerFixture, app_fixture: FastAPI, authed_user_mock: Mock): sub_mock = mocker.patch("sbl_filing_api.entities.repos.submission_repo.get_latest_submission") sub_mock.return_value = SubmissionDAO( diff --git a/tests/entities/conftest.py b/tests/entities/conftest.py index 5e5dee35..af935639 100644 --- a/tests/entities/conftest.py +++ b/tests/entities/conftest.py @@ -8,7 +8,9 @@ async_scoped_session, async_sessionmaker, ) +from unittest.mock import Mock from sbl_filing_api.entities.models.dao import Base +from regtech_api_commons.models.auth import AuthenticatedUser @pytest.fixture(scope="session") @@ -59,3 +61,15 @@ async def query_session(session_generator: async_scoped_session): @pytest.fixture(scope="function") def session_generator(engine: AsyncEngine): return async_scoped_session(async_sessionmaker(engine, expire_on_commit=False), current_task) + + +@pytest.fixture +def authed_user_mock() -> Mock: + claims = { + "name": "Test User", + "preferred_username": "test_user", + "email": "test@local.host", + "institutions": ["123456ABCDEF", "654321FEDCBA"], + "sub": "123456-7890-ABCDEF-GHIJ", + } + return AuthenticatedUser.from_claim(claims) diff --git a/tests/entities/repos/test_submission_repo.py b/tests/entities/repos/test_submission_repo.py index 8c2a75e7..13c89ced 100644 --- a/tests/entities/repos/test_submission_repo.py +++ b/tests/entities/repos/test_submission_repo.py @@ -235,6 +235,19 @@ 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, authed_user_mock: AuthenticatedUser + ): + await repo.add_signature(transaction_session, filing_id=1, user=authed_user_mock) + filing = await repo.get_filing(query_session, lei="1234567890", filing_period="2024") + + assert filing.signatures[0].id == 1 + assert filing.signatures[0].signer_id == "123456-7890-ABCDEF-GHIJ" + assert filing.signatures[0].signer_name == "Test User" + assert filing.signatures[0].signer_email == "test@local.host" + 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( diff --git a/tests/migrations/test_migrations.py b/tests/migrations/test_migrations.py index 4369611d..89af625c 100644 --- a/tests/migrations/test_migrations.py +++ b/tests/migrations/test_migrations.py @@ -176,6 +176,31 @@ 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", + "signer_email", + "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)