Skip to content

Commit 5c70074

Browse files
authored
MPT-20438: Added endpoints and e2e tests for program enrollment attachments (#320)
This pull request adds support for program enrollment attachments in the API client, including synchronous and asynchronous service classes, a reusable attachment mixin, and comprehensive end-to-end and unit test coverage. **Enrollment Attachments Service Implementation:** - Added `EnrollmentAttachment` model and `EnrollmentAttachmentsService` / `AsyncEnrollmentAttachmentsService` classes in `mpt_api_client/resources/program/enrollments_attachments.py`, supporting collection listing, get, create (file upload), update, delete, and download operations. - Introduced `AttachmentMixin` and `AsyncAttachmentMixin` in `mpt_api_client/resources/program/mixins/attachment_mixin.py` to compose file attachment operations for both sync and async services. **Integration with Enrollment Service:** - Added `.attachments(enrollment_id)` method to `EnrollmentService` and `AsyncEnrollmentService` in `mpt_api_client/resources/program/enrollments.py` to expose the attachments sub-service. **Testing Enhancements:** - Added end-to-end tests for both sync and async attachment flows in `tests/e2e/program/enrollment/attachment/test_sync_attachment.py` and `test_async_attachment.py`, covering upload, retrieval, listing, update, download, and deletion. - Created test fixtures for attachment data in `tests/e2e/program/enrollment/attachment/conftest.py`. - Added unit tests for `EnrollmentAttachmentsService`, `AsyncEnrollmentAttachmentsService`, and the attachment mixin in `tests/unit/resources/program/test_enrollments_attachments.py` and `tests/unit/resources/program/mixin/test_attachment_mixin.py`. **Configuration and Linting Updates:** - Updated `e2e_config.test.json` with `program.enrollment.attachment.id` for E2E testing. - Extended flake8 linting configuration in `pyproject.toml` to ignore `WPS235` for program resource files. Closes [MPT-20438](https://softwareone.atlassian.net/browse/MPT-20438) [MPT-20438]: https://softwareone.atlassian.net/browse/MPT-20438?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2 parents 28ccd6f + ef7e6db commit 5c70074

11 files changed

Lines changed: 514 additions & 1 deletion

File tree

e2e_config.test.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"integration.term.id": "ETC-6587-4477-0062",
7373
"program.document.file.id": "PDM-9643-3741-0001",
7474
"program.enrollment.assignee.id": "USR-6337-1324",
75+
"program.enrollment.attachment.id": "ENA-3965-5056-7966-0001",
7576
"program.enrollment.id": "ENR-3965-5056-7966",
7677
"program.enrollment.process.template.id": "PTM-9643-3741-0001",
7778
"program.enrollment.query.template.id": "PTM-9643-3741-0002",

mpt_api_client/resources/program/enrollments.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
)
88
from mpt_api_client.models import Model
99
from mpt_api_client.models.model import BaseModel, ResourceData
10+
from mpt_api_client.resources.program.enrollments_attachments import (
11+
AsyncEnrollmentAttachmentsService,
12+
EnrollmentAttachmentsService,
13+
)
1014
from mpt_api_client.resources.program.mixins.render_mixin import AsyncRenderMixin, RenderMixin
1115

1216

@@ -59,6 +63,19 @@ class EnrollmentService(
5963
):
6064
"""Program enrollment service."""
6165

66+
def attachments(self, enrollment_id: str) -> EnrollmentAttachmentsService:
67+
"""Get enrollment attachments service.
68+
69+
Args:
70+
enrollment_id: Enrollment ID
71+
72+
Returns:
73+
Enrollment attachments service.
74+
"""
75+
return EnrollmentAttachmentsService(
76+
http_client=self.http_client, endpoint_params={"enrollment_id": enrollment_id}
77+
)
78+
6279
def validate(self, resource_id: str, resource_data: ResourceData | None = None) -> Enrollment:
6380
"""Validate enrollment.
6481
@@ -141,6 +158,19 @@ class AsyncEnrollmentService(
141158
):
142159
"""Async program enrollment service."""
143160

161+
def attachments(self, enrollment_id: str) -> AsyncEnrollmentAttachmentsService:
162+
"""Get enrollment attachments service.
163+
164+
Args:
165+
enrollment_id: Enrollment ID
166+
167+
Returns:
168+
Enrollment attachments service.
169+
"""
170+
return AsyncEnrollmentAttachmentsService(
171+
http_client=self.http_client, endpoint_params={"enrollment_id": enrollment_id}
172+
)
173+
144174
async def validate(
145175
self, resource_id: str, resource_data: ResourceData | None = None
146176
) -> Enrollment:
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from mpt_api_client.http import AsyncService, Service
2+
from mpt_api_client.http.mixins import (
3+
AsyncCollectionMixin,
4+
CollectionMixin,
5+
)
6+
from mpt_api_client.models import Model
7+
from mpt_api_client.models.model import BaseModel
8+
from mpt_api_client.resources.program.mixins.attachment_mixin import (
9+
AsyncAttachmentMixin,
10+
AttachmentMixin,
11+
)
12+
13+
14+
class EnrollmentAttachment(Model):
15+
"""Enrollment Attachment resource.
16+
17+
Attributes:
18+
name: The name of the attachment.
19+
description: The description of the attachment.
20+
type: The type of the attachment.
21+
filename: The filename of the attachment.
22+
size: The size of the attachment in bytes.
23+
content_type: The content type of the attachment.
24+
enrollment: The enrollment associated with the attachment.
25+
audit: The audit information for the attachment.
26+
"""
27+
28+
name: str | None = None
29+
description: str | None = None
30+
type: str | None = None
31+
filename: str | None = None
32+
size: int | None = None
33+
content_type: str | None = None
34+
enrollment: BaseModel | None = None
35+
audit: BaseModel | None = None
36+
37+
38+
class EnrollmentAttachmentsServiceConfig:
39+
"""Enrollment Attachments service configuration."""
40+
41+
_endpoint = "/public/v1/program/enrollments/{enrollment_id}/attachments"
42+
_model_class = EnrollmentAttachment
43+
_collection_key = "data"
44+
_upload_file_key = "file"
45+
_upload_data_key = "attachment"
46+
47+
48+
class EnrollmentAttachmentsService(
49+
AttachmentMixin[EnrollmentAttachment],
50+
CollectionMixin[EnrollmentAttachment],
51+
Service[EnrollmentAttachment],
52+
EnrollmentAttachmentsServiceConfig,
53+
):
54+
"""Enrollment Attachments service."""
55+
56+
57+
class AsyncEnrollmentAttachmentsService(
58+
AsyncAttachmentMixin[EnrollmentAttachment],
59+
AsyncCollectionMixin[EnrollmentAttachment],
60+
AsyncService[EnrollmentAttachment],
61+
EnrollmentAttachmentsServiceConfig,
62+
):
63+
"""Enrollment Attachments service."""
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from mpt_api_client.http.mixins import (
2+
AsyncCreateFileMixin,
3+
AsyncDeleteMixin,
4+
AsyncDownloadFileMixin,
5+
AsyncGetMixin,
6+
AsyncUpdateMixin,
7+
CreateFileMixin,
8+
DeleteMixin,
9+
DownloadFileMixin,
10+
GetMixin,
11+
UpdateMixin,
12+
)
13+
14+
15+
class AttachmentMixin[Model](
16+
CreateFileMixin[Model],
17+
UpdateMixin[Model],
18+
DeleteMixin,
19+
DownloadFileMixin[Model],
20+
GetMixin[Model],
21+
):
22+
"""Attachment mixin."""
23+
24+
25+
class AsyncAttachmentMixin[Model](
26+
AsyncCreateFileMixin[Model],
27+
AsyncUpdateMixin[Model],
28+
AsyncDeleteMixin,
29+
AsyncDownloadFileMixin[Model],
30+
AsyncGetMixin[Model],
31+
):
32+
"""Async Attachment mixin."""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ per-file-ignores = [
126126
"mpt_api_client/resources/exchange/*.py: WPS235 WPS215",
127127
"mpt_api_client/resources/integration/*.py: WPS214 WPS215 WPS235",
128128
"mpt_api_client/resources/helpdesk/*.py: WPS204 WPS215 WPS214",
129-
"mpt_api_client/resources/program/*.py: WPS204 WPS215",
129+
"mpt_api_client/resources/program/*.py: WPS204 WPS215 WPS235",
130130
"mpt_api_client/rql/query_builder.py: WPS110 WPS115 WPS210 WPS214",
131131
"tests/e2e/accounts/*.py: WPS430 WPS202",
132132
"tests/e2e/billing/*.py: WPS202 WPS421 WPS118",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import pytest
2+
3+
4+
@pytest.fixture
5+
def attachment_id(e2e_config):
6+
return e2e_config["program.enrollment.attachment.id"]
7+
8+
9+
@pytest.fixture
10+
def invalid_attachment_id():
11+
return "ENA-0000-0000-0000-0000"
12+
13+
14+
@pytest.fixture
15+
def enrollment_attachment_factory():
16+
def factory(name: str = "E2E Created Program Enrollment Attachment"):
17+
return {
18+
"name": name,
19+
"description": name,
20+
}
21+
22+
return factory
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import pytest
2+
3+
from mpt_api_client.exceptions import MPTAPIError
4+
from mpt_api_client.rql.query_builder import RQLQuery
5+
6+
pytestmark = [pytest.mark.flaky]
7+
8+
9+
@pytest.fixture
10+
async def created_enrollment_attachment(
11+
async_mpt_vendor, enrollment_attachment_factory, enrollment_id, pdf_fd
12+
):
13+
new_enrollment_attachment_request_data = enrollment_attachment_factory(
14+
name="E2E Created Program Enrollment Attachment",
15+
)
16+
enrollment_attachments = async_mpt_vendor.program.enrollments.attachments(enrollment_id)
17+
18+
created_enrollment_attachment = await enrollment_attachments.create(
19+
new_enrollment_attachment_request_data, file=pdf_fd
20+
)
21+
22+
yield created_enrollment_attachment
23+
24+
try:
25+
await enrollment_attachments.delete(created_enrollment_attachment.id)
26+
except MPTAPIError as error:
27+
print(f"TEARDOWN - Unable to delete enrollment attachment: {error.title}") # noqa: WPS421
28+
29+
30+
@pytest.fixture
31+
def enrollment_attachments(async_mpt_vendor, enrollment_id):
32+
return async_mpt_vendor.program.enrollments.attachments(enrollment_id)
33+
34+
35+
async def test_get_enrollment_attachment_by_id(enrollment_attachments, attachment_id):
36+
result = await enrollment_attachments.get(attachment_id)
37+
38+
assert result is not None
39+
40+
41+
async def test_get_enrollment_attachment_not_found(enrollment_attachments, invalid_attachment_id):
42+
with pytest.raises(MPTAPIError, match=r"404 Not Found"):
43+
await enrollment_attachments.get(invalid_attachment_id)
44+
45+
46+
async def test_list_enrollment_attachments(enrollment_attachments):
47+
limit = 10
48+
49+
result = await enrollment_attachments.fetch_page(limit=limit)
50+
51+
assert len(result) > 0
52+
53+
54+
async def test_filter_enrollment_attachments(enrollment_attachments, attachment_id):
55+
select_fields = ["-description"]
56+
filtered_attachments = (
57+
enrollment_attachments
58+
.filter(RQLQuery(id=attachment_id))
59+
.filter(RQLQuery(name="E2E Seeded Program Enrollment Attachment"))
60+
.select(*select_fields)
61+
)
62+
63+
result = [attachment async for attachment in filtered_attachments.iterate()]
64+
65+
assert len(result) == 1
66+
67+
68+
def test_create_enrollment_attachment(created_enrollment_attachment):
69+
result = created_enrollment_attachment
70+
71+
assert result is not None
72+
73+
74+
async def test_update_enrollment_attachment(enrollment_attachments, created_enrollment_attachment):
75+
updated_data = {
76+
"name": "E2E Updated Program Enrollment Attachment",
77+
"description": "E2E Updated Program Enrollment Attachment",
78+
}
79+
80+
result = await enrollment_attachments.update(created_enrollment_attachment.id, updated_data)
81+
82+
assert result is not None
83+
84+
85+
async def test_delete_enrollment_attachment(enrollment_attachments, created_enrollment_attachment):
86+
result = created_enrollment_attachment
87+
88+
await enrollment_attachments.delete(result.id)
89+
90+
91+
async def test_download_enrollment_attachment(enrollment_attachments, attachment_id):
92+
result = await enrollment_attachments.download(attachment_id)
93+
94+
assert result.file_contents is not None
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import pytest
2+
3+
from mpt_api_client.exceptions import MPTAPIError
4+
from mpt_api_client.rql.query_builder import RQLQuery
5+
6+
pytestmark = [pytest.mark.flaky]
7+
8+
9+
@pytest.fixture
10+
def created_enrollment_attachment(mpt_vendor, enrollment_attachment_factory, enrollment_id, pdf_fd):
11+
new_enrollment_attachment_request_data = enrollment_attachment_factory(
12+
name="E2E Created Program Enrollment Attachment",
13+
)
14+
enrollment_attachments = mpt_vendor.program.enrollments.attachments(enrollment_id)
15+
16+
created_enrollment_attachment = enrollment_attachments.create(
17+
new_enrollment_attachment_request_data, file=pdf_fd
18+
)
19+
20+
yield created_enrollment_attachment
21+
22+
try:
23+
enrollment_attachments.delete(created_enrollment_attachment.id)
24+
except MPTAPIError as error:
25+
print(f"TEARDOWN - Unable to delete enrollment attachment: {error.title}") # noqa: WPS421
26+
27+
28+
@pytest.fixture
29+
def enrollment_attachments(mpt_vendor, enrollment_id):
30+
return mpt_vendor.program.enrollments.attachments(enrollment_id)
31+
32+
33+
def test_get_enrollment_attachment_by_id(enrollment_attachments, attachment_id):
34+
result = enrollment_attachments.get(attachment_id)
35+
36+
assert result is not None
37+
38+
39+
def test_get_enrollment_attachment_not_found(enrollment_attachments, invalid_attachment_id):
40+
with pytest.raises(MPTAPIError, match=r"404 Not Found"):
41+
enrollment_attachments.get(invalid_attachment_id)
42+
43+
44+
def test_list_enrollment_attachments(enrollment_attachments):
45+
limit = 10
46+
47+
result = enrollment_attachments.fetch_page(limit=limit)
48+
49+
assert len(result) > 0
50+
51+
52+
def test_filter_enrollment_attachments(enrollment_attachments, attachment_id):
53+
select_fields = ["-description"]
54+
filtered_attachments = (
55+
enrollment_attachments
56+
.filter(RQLQuery(id=attachment_id))
57+
.filter(RQLQuery(name="E2E Seeded Program Enrollment Attachment"))
58+
.select(*select_fields)
59+
)
60+
61+
result = list(filtered_attachments.iterate())
62+
63+
assert len(result) == 1
64+
65+
66+
def test_create_enrollment_attachment(created_enrollment_attachment):
67+
result = created_enrollment_attachment
68+
69+
assert result is not None
70+
71+
72+
def test_update_enrollment_attachment(enrollment_attachments, created_enrollment_attachment):
73+
updated_data = {
74+
"name": "E2E Updated Program Enrollment Attachment",
75+
"description": "E2E Updated Program Enrollment Attachment",
76+
}
77+
78+
result = enrollment_attachments.update(created_enrollment_attachment.id, updated_data)
79+
80+
assert result is not None
81+
82+
83+
def test_delete_enrollment_attachment(enrollment_attachments, created_enrollment_attachment):
84+
result = created_enrollment_attachment
85+
86+
enrollment_attachments.delete(result.id)
87+
88+
89+
def test_download_enrollment_attachment(enrollment_attachments, attachment_id):
90+
result = enrollment_attachments.download(attachment_id)
91+
92+
assert result.file_contents is not None

0 commit comments

Comments
 (0)