Merge ~lloydwaltersj/maas:v3-resourcepool into maas:master

Proposed by Jack Lloyd-Walters
Status: Merged
Approved by: Jacopo Rota
Approved revision: ce7dbdb0c63d707aae1179d0ddb24e9c0e282ed8
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~lloydwaltersj/maas:v3-resourcepool
Merge into: maas:master
Diff against target: 1369 lines (+1133/-21)
22 files modified
src/maasapiserver/common/models/constants.py (+1/-0)
src/maasapiserver/v3/api/handlers/__init__.py (+7/-1)
src/maasapiserver/v3/api/handlers/resource_pools.py (+151/-0)
src/maasapiserver/v3/api/models/requests/base.py (+17/-0)
src/maasapiserver/v3/api/models/requests/resource_pools.py (+25/-0)
src/maasapiserver/v3/api/models/responses/resource_pools.py (+20/-0)
src/maasapiserver/v3/db/base.py (+1/-1)
src/maasapiserver/v3/db/bmc.py (+1/-1)
src/maasapiserver/v3/db/nodes.py (+1/-1)
src/maasapiserver/v3/db/resource_pools.py (+112/-0)
src/maasapiserver/v3/db/users.py (+1/-1)
src/maasapiserver/v3/db/vmcluster.py (+1/-1)
src/maasapiserver/v3/db/zones.py (+1/-1)
src/maasapiserver/v3/models/resource_pools.py (+24/-0)
src/maasapiserver/v3/services/__init__.py (+3/-0)
src/maasapiserver/v3/services/resource_pools.py (+65/-0)
src/tests/fixtures/factories/resource_pools.py (+49/-0)
src/tests/maasapiserver/v3/api/models/requests/test_base.py (+42/-14)
src/tests/maasapiserver/v3/api/test_resource_pools.py (+275/-0)
src/tests/maasapiserver/v3/db/test_resource_pools.py (+149/-0)
src/tests/maasapiserver/v3/models/test_resource_pools.py (+36/-0)
src/tests/maasapiserver/v3/services/test_resource_pools.py (+151/-0)
Reviewer Review Type Date Requested Status
Jacopo Rota Approve
MAAS Lander Approve
Review via email: mp+461550@code.launchpad.net

Commit message

Add Resource Pools v3 Endpoints

To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4762/console
COMMIT: d54f8b6cb5bf09a5099fe215f268cd34b450bfdd

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4763/console
COMMIT: 51ede793710c2fde5262a684000f14a55fc890c8

review: Needs Fixing
Revision history for this message
Jack Lloyd-Walters (lloydwaltersj) wrote :

jenkins: !test

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4764/console
COMMIT: 51ede793710c2fde5262a684000f14a55fc890c8

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
3d9899e... by Jack Lloyd-Walters

linting

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4765/console
COMMIT: 921a2d222087fdc178909dfaab7cc9d5102285f8

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
1326ddf... by Jack Lloyd-Walters

remove f string

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4766/console
COMMIT: 5fb62af3e212cd36af7d6734604066695c1cf61d

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
e1411df... by Jack Lloyd-Walters

move imports

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4767/console
COMMIT: 770baa4f76eac0a0fd6b3ef12e488514c9f5901e

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
9da1baa... by Jack Lloyd-Walters

import flip

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4768/console
COMMIT: 1ff003b893636af2190bb69e0e4577c12b6e389a

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
2a311c3... by Jack Lloyd-Walters

fixture in the wrong place

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: b0db01d80a4359105a3460e550e27837e59338d9

review: Approve
Revision history for this message
Jacopo Rota (r00ta) wrote :

Nice! I have some minor comments/fixes that should be easy to fix. In the meanwhile I'll check what do we need to do for the `delete` as we might have to migrate some resources attached to the resource pool being deleted. I'll get back to you

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
ce6dc56... by Jack Lloyd-Walters

linting and responding to feedback

126af19... by Jack Lloyd-Walters

await

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4787/console
COMMIT: 2af04d35231ecff6fedb425aeacf9cac53fbcddc

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
b2e94c2... by Jack Lloyd-Walters

pass none to optional

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4791/console
COMMIT: 37d136f2b58f7f80ddcd4e5b8056419537dca663

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
5ebffb4... by Jack Lloyd-Walters

Squashed commit of the following:

commit 8b07c3d5c23d179841c8ede1515564d1d418bce0
Author: Christian Grabowski <email address hidden>
Date: Tue Mar 5 14:58:34 2024 +0000

    chore: move maas-temporal-worker to its own package

commit dab4e93190055f198d5f793206d8ca90f1cf6bc6
Author: Peter Makowski <email address hidden>
Date: Tue Mar 5 14:34:46 2024 +0000

    Update maas-ui to 1fd5c1212
    build(dotrun): remove start_django_db (#5331)

commit 85fc8efc48b6b58136c48b7e28ea646be3358836
Author: Alexsander Silva de Souza <email address hidden>
Date: Tue Mar 5 14:25:41 2024 +0000

    restore target used by maas-release-tools

    (cherry picked from commit 53d5ac1f4f782f8f59f399dc3240f8b4a26bc6b4)

commit c6b3d8df01131210abe153db6b48a9bf087a5cca
Author: Javier Fuentes <email address hidden>
Date: Tue Mar 5 12:52:58 2024 +0000

    fix: update name of the Django default engine database

commit e885d22fb7a645490987129fb38aa73067a01ee1
Author: Jacopo Rota <email address hidden>
Date: Tue Mar 5 10:55:16 2024 +0000

    MAASENG-2807: add secrets and configurations services in V3 maasapiserver

3957e09... by Jack Lloyd-Walters

linting

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4792/console
COMMIT: fbec37a509471d2005666760390fd14f0d91d6b1

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
c894cb8... by Jack Lloyd-Walters

Squashed commit of the following:

commit 8b07c3d5c23d179841c8ede1515564d1d418bce0
Author: Christian Grabowski <email address hidden>
Date: Tue Mar 5 14:58:34 2024 +0000

    chore: move maas-temporal-worker to its own package

commit dab4e93190055f198d5f793206d8ca90f1cf6bc6
Author: Peter Makowski <email address hidden>
Date: Tue Mar 5 14:34:46 2024 +0000

    Update maas-ui to 1fd5c1212
    build(dotrun): remove start_django_db (#5331)

commit 85fc8efc48b6b58136c48b7e28ea646be3358836
Author: Alexsander Silva de Souza <email address hidden>
Date: Tue Mar 5 14:25:41 2024 +0000

    restore target used by maas-release-tools

    (cherry picked from commit 53d5ac1f4f782f8f59f399dc3240f8b4a26bc6b4)

commit c6b3d8df01131210abe153db6b48a9bf087a5cca
Author: Javier Fuentes <email address hidden>
Date: Tue Mar 5 12:52:58 2024 +0000

    fix: update name of the Django default engine database

commit e885d22fb7a645490987129fb38aa73067a01ee1
Author: Jacopo Rota <email address hidden>
Date: Tue Mar 5 10:55:16 2024 +0000

    MAASENG-2807: add secrets and configurations services in V3 maasapiserver

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4793/console
COMMIT: 51bd3f16508a4632216425fa3d5b906c458cab19

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: 66199f987ca9a77cd107c03ad9628c902db0dfff

review: Approve
Revision history for this message
Jacopo Rota (r00ta) wrote :

some minor comments

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
3bd403c... by Jack Lloyd-Walters

respond to feedback

Revision history for this message
Jack Lloyd-Walters (lloydwaltersj) :
~lloydwaltersj/maas:v3-resourcepool updated
a890253... by Jack Lloyd-Walters

linting and formatting

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4799/console
COMMIT: c7d5e0681def6fcc8e7fa4dc893acf69f8cfe93f

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
db054aa... by Jack Lloyd-Walters

correct error message

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4800/console
COMMIT: ae562bd642fa6695772d7601c42ca7375d5229a6

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
934047c... by Jack Lloyd-Walters

rebase

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4802/console
COMMIT: 934047c337694edf9a2696f058bdb21350b56467

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
ffad2ec... by Jack Lloyd-Walters

fix tests

Revision history for this message
Jack Lloyd-Walters (lloydwaltersj) wrote :

jenkins: !test

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4811/console
COMMIT: ffad2ec3cc644286cd70beb0bd32d59013ced265

review: Needs Fixing
Revision history for this message
Jack Lloyd-Walters (lloydwaltersj) wrote :

jenkins: !test

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4855/console
COMMIT: ffad2ec3cc644286cd70beb0bd32d59013ced265

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
56e1232... by Jacopo Rota

minor naming fixes, change BaseRepository generics, move update logic to service, add tests for api and service

062fe54... by Jacopo Rota

fix typos

b384579... by Jacopo Rota

refactoring

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4892/console
COMMIT: b3845797f3e59b04fedf6fa2783bd0abf34dae89

review: Needs Fixing
Revision history for this message
Jacopo Rota (r00ta) wrote :

Jenkins: !test

Revision history for this message
Jacopo Rota (r00ta) wrote :

jenkins: !test

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4911/console
COMMIT: b3845797f3e59b04fedf6fa2783bd0abf34dae89

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4916/console
COMMIT: 73dc284893321482b307b75fb25ca3d61ea7ef7e

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
65b5f99... by Jack Lloyd-Walters

submodule fix?

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/4928/console
COMMIT: 65b5f99706b45280337ee5e3375415ede3062df3

review: Needs Fixing
~lloydwaltersj/maas:v3-resourcepool updated
b912594... by Jacopo Rota

update git submodules

2f6eae8... by Jacopo Rota

Merge remote-tracking branch 'upstream/master' into HEAD

ce7dbdb... by Jack Lloyd-Walters

Merge remote-tracking branch 'r00ta/v3-resource-pool-rebase' into v3-resourcepool

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b v3-resourcepool lp:~lloydwaltersj/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: ce7dbdb0c63d707aae1179d0ddb24e9c0e282ed8

review: Approve
Revision history for this message
Jacopo Rota (r00ta) wrote :

Nice!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/maasapiserver/common/models/constants.py b/src/maasapiserver/common/models/constants.py
2index d5554a3..442c566 100644
3--- a/src/maasapiserver/common/models/constants.py
4+++ b/src/maasapiserver/common/models/constants.py
5@@ -8,6 +8,7 @@ UNEXISTING_USER_OR_INVALID_CREDENTIALS_VIOLATION_TYPE = (
6 # Generic
7 UNIQUE_CONSTRAINT_VIOLATION_TYPE = "UniqueConstraintViolation"
8 ETAG_PRECONDITION_VIOLATION_TYPE = "EtagPreconditionViolation"
9+UNEXISTING_RESOURCE_VIOLATION_TYPE = "UnexistingResourceViolation"
10
11 # Zones
12 CANNOT_DELETE_DEFAULT_ZONE_VIOLATION_TYPE = "CannotDeleteDefaultZoneViolation"
13diff --git a/src/maasapiserver/v3/api/handlers/__init__.py b/src/maasapiserver/v3/api/handlers/__init__.py
14index 56c6bd8..e28615e 100644
15--- a/src/maasapiserver/v3/api/handlers/__init__.py
16+++ b/src/maasapiserver/v3/api/handlers/__init__.py
17@@ -1,10 +1,16 @@
18 from maasapiserver.common.api.base import API
19 from maasapiserver.v3.api.handlers.auth import AuthHandler
20+from maasapiserver.v3.api.handlers.resource_pools import ResourcePoolHandler
21 from maasapiserver.v3.api.handlers.root import RootHandler
22 from maasapiserver.v3.api.handlers.zones import ZonesHandler
23 from maasapiserver.v3.constants import V3_API_PREFIX
24
25 APIv3 = API(
26 prefix=V3_API_PREFIX,
27- handlers=[RootHandler(), ZonesHandler(), AuthHandler()],
28+ handlers=[
29+ RootHandler(),
30+ ZonesHandler(),
31+ ResourcePoolHandler(),
32+ AuthHandler(),
33+ ],
34 )
35diff --git a/src/maasapiserver/v3/api/handlers/resource_pools.py b/src/maasapiserver/v3/api/handlers/resource_pools.py
36new file mode 100644
37index 0000000..32b0899
38--- /dev/null
39+++ b/src/maasapiserver/v3/api/handlers/resource_pools.py
40@@ -0,0 +1,151 @@
41+from fastapi import Depends, Response
42+
43+from maasapiserver.common.api.base import Handler, handler
44+from maasapiserver.common.api.models.responses.errors import (
45+ ConflictBodyResponse,
46+ NotFoundBodyResponse,
47+ NotFoundResponse,
48+ ValidationErrorBodyResponse,
49+)
50+from maasapiserver.v3.api import services
51+from maasapiserver.v3.api.models.requests.query import PaginationParams
52+from maasapiserver.v3.api.models.requests.resource_pools import (
53+ ResourcePoolPatchRequest,
54+ ResourcePoolRequest,
55+)
56+from maasapiserver.v3.api.models.responses.resource_pools import (
57+ ResourcePoolResponse,
58+ ResourcePoolsListResponse,
59+)
60+from maasapiserver.v3.constants import EXTERNAL_V3_API_PREFIX
61+from maasapiserver.v3.services import ServiceCollectionV3
62+
63+
64+class ResourcePoolHandler(Handler):
65+ """ResourcePool API handler."""
66+
67+ TAGS = ["ResourcePool"]
68+
69+ @handler(
70+ path="/resource_pools",
71+ methods=["GET"],
72+ tags=TAGS,
73+ responses={
74+ 200: {
75+ "model": ResourcePoolsListResponse,
76+ },
77+ 422: {"model": ValidationErrorBodyResponse},
78+ },
79+ status_code=200,
80+ response_model_exclude_none=True,
81+ )
82+ async def list_resource_pools(
83+ self,
84+ pagination_params: PaginationParams = Depends(),
85+ services: ServiceCollectionV3 = Depends(services),
86+ ) -> Response:
87+ resource_pools = await services.resource_pools.list(pagination_params)
88+ return ResourcePoolsListResponse(
89+ items=[
90+ resource_pools.to_response(
91+ f"{EXTERNAL_V3_API_PREFIX}/resource_pools"
92+ )
93+ for resource_pools in resource_pools.items
94+ ],
95+ total=resource_pools.total,
96+ )
97+
98+ @handler(
99+ path="/resource_pools",
100+ methods=["POST"],
101+ tags=TAGS,
102+ responses={
103+ 201: {
104+ "model": ResourcePoolResponse,
105+ "headers": {
106+ "ETag": {"description": "The ETag for the resource"}
107+ },
108+ },
109+ 409: {"model": ConflictBodyResponse},
110+ 422: {"model": ValidationErrorBodyResponse},
111+ },
112+ status_code=201,
113+ response_model_exclude_none=True,
114+ )
115+ async def create_resource_pool(
116+ self,
117+ response: Response,
118+ resource_pool_request: ResourcePoolRequest,
119+ services: ServiceCollectionV3 = Depends(services),
120+ ) -> Response:
121+ resource_pools = await services.resource_pools.create(
122+ resource_pool_request
123+ )
124+ response.headers["ETag"] = resource_pools.etag()
125+ return resource_pools.to_response(
126+ self_base_hyperlink=f"{EXTERNAL_V3_API_PREFIX}/resource_pools"
127+ )
128+
129+ @handler(
130+ path="/resource_pools/{resource_pool_id}",
131+ methods=["GET"],
132+ tags=TAGS,
133+ responses={
134+ 200: {
135+ "model": ResourcePoolResponse,
136+ "headers": {
137+ "ETag": {"description": "The ETag for the resource"}
138+ },
139+ },
140+ 404: {"model": NotFoundBodyResponse},
141+ 422: {"model": ValidationErrorBodyResponse},
142+ },
143+ status_code=200,
144+ response_model_exclude_none=True,
145+ )
146+ async def get_resource_pool(
147+ self,
148+ resource_pool_id: int,
149+ response: Response,
150+ services: ServiceCollectionV3 = Depends(services),
151+ ) -> Response:
152+ if resource_pool := await services.resource_pools.get_by_id(
153+ resource_pool_id
154+ ):
155+ response.headers["ETag"] = resource_pool.etag()
156+ return resource_pool.to_response(
157+ self_base_hyperlink=f"{EXTERNAL_V3_API_PREFIX}/resource_pools"
158+ )
159+ return NotFoundResponse()
160+
161+ @handler(
162+ path="/resource_pools/{resource_pool_id}",
163+ methods=["PATCH"],
164+ tags=TAGS,
165+ responses={
166+ 200: {
167+ "model": ResourcePoolResponse,
168+ "headers": {
169+ "ETag": {"description": "The ETag for the resource"}
170+ },
171+ },
172+ 404: {"model": NotFoundBodyResponse},
173+ 422: {"model": ValidationErrorBodyResponse},
174+ },
175+ status_code=200,
176+ response_model_exclude_none=True,
177+ )
178+ async def patch_resource_pool(
179+ self,
180+ resource_pool_id: int,
181+ response: Response,
182+ resource_pool_request: ResourcePoolPatchRequest,
183+ services: ServiceCollectionV3 = Depends(services),
184+ ) -> Response:
185+ resource_pool = await services.resource_pools.patch(
186+ resource_pool_id, resource_pool_request
187+ )
188+ response.headers["ETag"] = resource_pool.etag()
189+ return resource_pool.to_response(
190+ self_base_hyperlink=f"{EXTERNAL_V3_API_PREFIX}/resource_pools"
191+ )
192diff --git a/src/maasapiserver/v3/api/models/requests/base.py b/src/maasapiserver/v3/api/models/requests/base.py
193index a674341..df507c2 100644
194--- a/src/maasapiserver/v3/api/models/requests/base.py
195+++ b/src/maasapiserver/v3/api/models/requests/base.py
196@@ -1,4 +1,5 @@
197 from re import compile
198+from typing import Optional
199
200 from pydantic import BaseModel, Field, validator
201
202@@ -15,3 +16,19 @@ class NamedBaseModel(BaseModel):
203 if not MODEL_NAME_VALIDATOR.match(v):
204 raise ValueError("Invalid entity name.")
205 return v
206+
207+
208+class OptionalNamedBaseModel(BaseModel):
209+ name: Optional[str] = Field(
210+ description="The unique name of the entity.", default=None
211+ )
212+
213+ # TODO: move to @field_validator when we migrate to pydantic 2.x
214+ @validator("name")
215+ def check_regex_name(cls, v: str) -> str:
216+ # If the name is set, it must not be None and it must match the regex
217+ if v is None:
218+ raise ValueError("The name for the resource must not be null.")
219+ if not MODEL_NAME_VALIDATOR.match(v):
220+ raise ValueError("Invalid entity name.")
221+ return v
222diff --git a/src/maasapiserver/v3/api/models/requests/resource_pools.py b/src/maasapiserver/v3/api/models/requests/resource_pools.py
223new file mode 100644
224index 0000000..6c3bd8f
225--- /dev/null
226+++ b/src/maasapiserver/v3/api/models/requests/resource_pools.py
227@@ -0,0 +1,25 @@
228+from typing import Optional
229+
230+from pydantic import validator
231+
232+from maasapiserver.v3.api.models.requests.base import (
233+ NamedBaseModel,
234+ OptionalNamedBaseModel,
235+)
236+
237+
238+class ResourcePoolRequest(NamedBaseModel):
239+ description: str
240+
241+
242+class ResourcePoolPatchRequest(OptionalNamedBaseModel):
243+ description: Optional[str]
244+
245+ @validator("description")
246+ def check_description(cls, v: str) -> str:
247+ # If the description is set in the request, it must not be None
248+ if v is None:
249+ raise ValueError(
250+ "The description for the resource must not be null."
251+ )
252+ return v
253diff --git a/src/maasapiserver/v3/api/models/responses/resource_pools.py b/src/maasapiserver/v3/api/models/responses/resource_pools.py
254new file mode 100644
255index 0000000..a90bc87
256--- /dev/null
257+++ b/src/maasapiserver/v3/api/models/responses/resource_pools.py
258@@ -0,0 +1,20 @@
259+from datetime import datetime
260+
261+from maasapiserver.v3.api.models.responses.base import (
262+ BaseHal,
263+ HalResponse,
264+ PaginatedResponse,
265+)
266+
267+
268+class ResourcePoolResponse(HalResponse[BaseHal]):
269+ kind = "ResourcePool"
270+ id: int
271+ name: str
272+ description: str
273+ created: datetime
274+ updated: datetime
275+
276+
277+class ResourcePoolsListResponse(PaginatedResponse[ResourcePoolResponse]):
278+ kind = "ResourcePoolList"
279diff --git a/src/maasapiserver/v3/db/base.py b/src/maasapiserver/v3/db/base.py
280index 36dd883..21ff3cf 100644
281--- a/src/maasapiserver/v3/db/base.py
282+++ b/src/maasapiserver/v3/db/base.py
283@@ -28,7 +28,7 @@ class BaseRepository(ABC, Generic[T, K]):
284 pass
285
286 @abstractmethod
287- async def update(self, id: int, request: K) -> T:
288+ async def update(self, resource: T) -> T:
289 pass
290
291 @abstractmethod
292diff --git a/src/maasapiserver/v3/db/bmc.py b/src/maasapiserver/v3/db/bmc.py
293index b6ac49a..7dee695 100644
294--- a/src/maasapiserver/v3/db/bmc.py
295+++ b/src/maasapiserver/v3/db/bmc.py
296@@ -21,7 +21,7 @@ class BmcRepository(BaseRepository[Bmc, BmcRequest]):
297 ) -> ListResult[Bmc]:
298 raise Exception("Not implemented yet.")
299
300- async def update(self, id: int, request: BmcRequest) -> Bmc:
301+ async def update(self, resource: Bmc) -> Bmc:
302 raise Exception("Not implemented yet.")
303
304 async def delete(self, id: int) -> None:
305diff --git a/src/maasapiserver/v3/db/nodes.py b/src/maasapiserver/v3/db/nodes.py
306index 04599ed..2686a7a 100644
307--- a/src/maasapiserver/v3/db/nodes.py
308+++ b/src/maasapiserver/v3/db/nodes.py
309@@ -21,7 +21,7 @@ class NodesRepository(BaseRepository[Node, NodeRequest]):
310 ) -> ListResult[Node]:
311 raise Exception("Not implemented yet.")
312
313- async def update(self, id: int, request: NodeRequest) -> Node:
314+ async def update(self, resource: Node) -> Node:
315 raise Exception("Not implemented yet.")
316
317 async def delete(self, id: int) -> None:
318diff --git a/src/maasapiserver/v3/db/resource_pools.py b/src/maasapiserver/v3/db/resource_pools.py
319new file mode 100644
320index 0000000..33640ae
321--- /dev/null
322+++ b/src/maasapiserver/v3/db/resource_pools.py
323@@ -0,0 +1,112 @@
324+from datetime import datetime
325+from typing import Any, Optional
326+
327+from sqlalchemy import desc, insert, select, Select, update
328+from sqlalchemy.exc import IntegrityError
329+from sqlalchemy.sql.functions import count
330+from sqlalchemy.sql.operators import eq
331+
332+from maasapiserver.common.db.tables import ResourcePoolTable
333+from maasapiserver.common.models.constants import (
334+ UNIQUE_CONSTRAINT_VIOLATION_TYPE,
335+)
336+from maasapiserver.common.models.exceptions import (
337+ AlreadyExistsException,
338+ BaseExceptionDetail,
339+)
340+from maasapiserver.v3.api.models.requests.query import PaginationParams
341+from maasapiserver.v3.api.models.requests.resource_pools import (
342+ ResourcePoolRequest,
343+)
344+from maasapiserver.v3.db.base import BaseRepository
345+from maasapiserver.v3.models.base import ListResult
346+from maasapiserver.v3.models.resource_pools import ResourcePool
347+
348+RESOURCE_POOLS_FIELDS = (
349+ ResourcePoolTable.c.id,
350+ ResourcePoolTable.c.name,
351+ ResourcePoolTable.c.description,
352+ ResourcePoolTable.c.created,
353+ ResourcePoolTable.c.updated,
354+)
355+
356+
357+class ResourcePoolRepository(
358+ BaseRepository[ResourcePool, ResourcePoolRequest]
359+):
360+ async def find_by_id(self, id: int) -> Optional[ResourcePool]:
361+ stmt = self._select_all_statement().where(
362+ eq(ResourcePoolTable.c.id, id)
363+ )
364+ if result := await self.connection.execute(stmt):
365+ if resource_pools := result.one_or_none():
366+ return ResourcePool(**resource_pools._asdict())
367+ return None
368+
369+ async def create(self, request: ResourcePoolRequest) -> ResourcePool:
370+ now = datetime.utcnow()
371+ stmt = (
372+ insert(ResourcePoolTable)
373+ .returning(*RESOURCE_POOLS_FIELDS)
374+ .values(
375+ name=request.name,
376+ description=request.description,
377+ updated=now,
378+ created=now,
379+ )
380+ )
381+ try:
382+ result = await self.connection.execute(stmt)
383+ except IntegrityError:
384+ self._raise_constraint_violation(request.name)
385+ resource_pools = result.one()
386+ return ResourcePool(**resource_pools._asdict())
387+
388+ async def list(
389+ self, pagination_params: PaginationParams
390+ ) -> ListResult[ResourcePool]:
391+ total_stmt = select(count()).select_from(ResourcePoolTable)
392+ total = (await self.connection.execute(total_stmt)).scalar()
393+
394+ stmt = (
395+ self._select_all_statement()
396+ .order_by(desc(ResourcePoolTable.c.id))
397+ .offset((pagination_params.page - 1) * pagination_params.size)
398+ .limit(pagination_params.size)
399+ )
400+
401+ result = await self.connection.execute(stmt)
402+ return ListResult[ResourcePool](
403+ items=[ResourcePool(**row._asdict()) for row in result.all()],
404+ total=total,
405+ )
406+
407+ async def delete(self, id: int) -> None:
408+ raise Exception("Not implemented yet.")
409+
410+ async def update(self, resource_pool: ResourcePool) -> ResourcePool:
411+ resource_pool.updated = datetime.utcnow()
412+ stmt = (
413+ update(ResourcePoolTable)
414+ .where(eq(ResourcePoolTable.c.id, resource_pool.id))
415+ .returning(*RESOURCE_POOLS_FIELDS)
416+ .values(**resource_pool.dict())
417+ )
418+ try:
419+ new_resource_pool = (await self.connection.execute(stmt)).one()
420+ except IntegrityError:
421+ self._raise_constraint_violation(resource_pool.name)
422+ return ResourcePool(**new_resource_pool._asdict())
423+
424+ def _select_all_statement(self) -> Select[Any]:
425+ return select(*RESOURCE_POOLS_FIELDS).select_from(ResourcePoolTable)
426+
427+ def _raise_constraint_violation(self, name: str):
428+ raise AlreadyExistsException(
429+ details=[
430+ BaseExceptionDetail(
431+ type=UNIQUE_CONSTRAINT_VIOLATION_TYPE,
432+ message=f"An entity named '{name}' already exists.",
433+ )
434+ ]
435+ )
436diff --git a/src/maasapiserver/v3/db/users.py b/src/maasapiserver/v3/db/users.py
437index c058284..573b790 100644
438--- a/src/maasapiserver/v3/db/users.py
439+++ b/src/maasapiserver/v3/db/users.py
440@@ -32,7 +32,7 @@ class UsersRepository(BaseRepository[User, UserRequest]):
441 ) -> ListResult[User]:
442 raise Exception("Not implemented yet.")
443
444- async def update(self, id: int, request: UserRequest) -> User:
445+ async def update(self, resource: User) -> User:
446 raise Exception("Not implemented yet.")
447
448 async def delete(self, id: int) -> None:
449diff --git a/src/maasapiserver/v3/db/vmcluster.py b/src/maasapiserver/v3/db/vmcluster.py
450index 76698f7..b0a2bbf 100644
451--- a/src/maasapiserver/v3/db/vmcluster.py
452+++ b/src/maasapiserver/v3/db/vmcluster.py
453@@ -21,7 +21,7 @@ class VmClustersRepository(BaseRepository[VmCluster, VmClusterRequest]):
454 ) -> ListResult[VmCluster]:
455 raise Exception("Not implemented yet.")
456
457- async def update(self, id: int, request: VmClusterRequest) -> VmCluster:
458+ async def update(self, resource: VmCluster) -> VmCluster:
459 raise Exception("Not implemented yet.")
460
461 async def delete(self, id: int) -> None:
462diff --git a/src/maasapiserver/v3/db/zones.py b/src/maasapiserver/v3/db/zones.py
463index c9a4566..5325896 100644
464--- a/src/maasapiserver/v3/db/zones.py
465+++ b/src/maasapiserver/v3/db/zones.py
466@@ -99,7 +99,7 @@ class ZonesRepository(BaseRepository[Zone, ZoneRequest]):
467 items=[Zone(**row._asdict()) for row in result.all()], total=total
468 )
469
470- async def update(self, id: int, request: ZoneRequest) -> Zone:
471+ async def update(self, resource: Zone) -> Zone:
472 raise Exception("Not implemented yet.")
473
474 async def delete(self, id: int) -> None:
475diff --git a/src/maasapiserver/v3/models/resource_pools.py b/src/maasapiserver/v3/models/resource_pools.py
476new file mode 100644
477index 0000000..722b47e
478--- /dev/null
479+++ b/src/maasapiserver/v3/models/resource_pools.py
480@@ -0,0 +1,24 @@
481+from maasapiserver.v3.api.models.responses.base import BaseHal, BaseHref
482+from maasapiserver.v3.api.models.responses.resource_pools import (
483+ ResourcePoolResponse,
484+)
485+from maasapiserver.v3.models.base import MaasTimestampedBaseModel
486+
487+
488+class ResourcePool(MaasTimestampedBaseModel):
489+ name: str
490+ description: str
491+
492+ def to_response(self, self_base_hyperlink: str) -> ResourcePoolResponse:
493+ return ResourcePoolResponse(
494+ id=self.id,
495+ name=self.name,
496+ description=self.description,
497+ created=self.created,
498+ updated=self.updated,
499+ hal_links=BaseHal(
500+ self=BaseHref(
501+ href=f"{self_base_hyperlink.rstrip('/')}/{self.id}"
502+ )
503+ ),
504+ )
505diff --git a/src/maasapiserver/v3/services/__init__.py b/src/maasapiserver/v3/services/__init__.py
506index 696a3fe..2eac400 100644
507--- a/src/maasapiserver/v3/services/__init__.py
508+++ b/src/maasapiserver/v3/services/__init__.py
509@@ -4,6 +4,7 @@ from maasapiserver.v3.services.auth import AuthService
510 from maasapiserver.v3.services.bmc import BmcService
511 from maasapiserver.v3.services.configurations import ConfigurationsService
512 from maasapiserver.v3.services.nodes import NodesService
513+from maasapiserver.v3.services.resource_pools import ResourcePoolsService
514 from maasapiserver.v3.services.secrets import (
515 SecretsService,
516 SecretsServiceFactory,
517@@ -22,6 +23,7 @@ class ServiceCollectionV3:
518 zones: ZonesService
519 secrets: SecretsService
520 configurations: ConfigurationsService
521+ resource_pools: ResourcePoolsService
522 auth: AuthService
523
524 @classmethod
525@@ -48,4 +50,5 @@ class ServiceCollectionV3:
526 vmcluster_service=services.vmclusters,
527 bmc_service=services.bmc,
528 )
529+ services.resource_pools = ResourcePoolsService(connection=connection)
530 return services
531diff --git a/src/maasapiserver/v3/services/resource_pools.py b/src/maasapiserver/v3/services/resource_pools.py
532new file mode 100644
533index 0000000..6862bdc
534--- /dev/null
535+++ b/src/maasapiserver/v3/services/resource_pools.py
536@@ -0,0 +1,65 @@
537+from typing import Optional
538+
539+from sqlalchemy.ext.asyncio import AsyncConnection
540+
541+from maasapiserver.common.models.constants import (
542+ UNEXISTING_RESOURCE_VIOLATION_TYPE,
543+)
544+from maasapiserver.common.models.exceptions import (
545+ BaseExceptionDetail,
546+ NotFoundException,
547+)
548+from maasapiserver.common.services._base import Service
549+from maasapiserver.v3.api.models.requests.query import PaginationParams
550+from maasapiserver.v3.api.models.requests.resource_pools import (
551+ ResourcePoolPatchRequest,
552+ ResourcePoolRequest,
553+)
554+from maasapiserver.v3.db.resource_pools import ResourcePoolRepository
555+from maasapiserver.v3.models.base import ListResult
556+from maasapiserver.v3.models.resource_pools import ResourcePool
557+
558+
559+class ResourcePoolsService(Service):
560+ def __init__(
561+ self,
562+ connection: AsyncConnection,
563+ resource_pools_repository: Optional[ResourcePoolRepository] = None,
564+ ):
565+ super().__init__(connection)
566+ self.resource_pools_repository = (
567+ resource_pools_repository or ResourcePoolRepository(connection)
568+ )
569+
570+ async def create(
571+ self, resource_pool_request: ResourcePoolRequest
572+ ) -> ResourcePool:
573+ return await self.resource_pools_repository.create(
574+ resource_pool_request
575+ )
576+
577+ async def get_by_id(self, id: int) -> Optional[ResourcePool]:
578+ return await self.resource_pools_repository.find_by_id(id)
579+
580+ async def list(
581+ self, pagination_params: PaginationParams
582+ ) -> ListResult[ResourcePool]:
583+ return await self.resource_pools_repository.list(pagination_params)
584+
585+ async def patch(
586+ self, id: int, patch_request: ResourcePoolPatchRequest
587+ ) -> ResourcePool:
588+ resource_pool = await self.get_by_id(id)
589+ if not resource_pool:
590+ raise NotFoundException(
591+ details=[
592+ BaseExceptionDetail(
593+ type=UNEXISTING_RESOURCE_VIOLATION_TYPE,
594+ message=f"Resource pool with id '{id}' does not exist.",
595+ )
596+ ]
597+ )
598+ resource_pool = resource_pool.copy(
599+ update=patch_request.dict(exclude_none=True)
600+ )
601+ return await self.resource_pools_repository.update(resource_pool)
602diff --git a/src/tests/fixtures/factories/resource_pools.py b/src/tests/fixtures/factories/resource_pools.py
603new file mode 100644
604index 0000000..036f453
605--- /dev/null
606+++ b/src/tests/fixtures/factories/resource_pools.py
607@@ -0,0 +1,49 @@
608+# Factories for the ResourcePool
609+from datetime import datetime
610+from typing import Any
611+
612+from maasapiserver.v3.models.resource_pools import ResourcePool
613+from tests.maasapiserver.fixtures.db import Fixture
614+
615+
616+async def create_test_resource_pool(
617+ fixture: Fixture, **extra_details: Any
618+) -> ResourcePool:
619+ created_at = datetime.utcnow().astimezone()
620+ updated_at = datetime.utcnow().astimezone()
621+ resource_pools = {
622+ "name": "my_resource_pool",
623+ "description": "my_description",
624+ "created": created_at,
625+ "updated": updated_at,
626+ }
627+ resource_pools.update(extra_details)
628+ [created_resource_pools] = await fixture.create(
629+ "maasserver_resourcepool",
630+ [resource_pools],
631+ )
632+ return ResourcePool(**created_resource_pools)
633+
634+
635+async def create_n_test_resource_pools(
636+ fixture: Fixture, size: int
637+) -> list[ResourcePool]:
638+ now = datetime.utcnow().astimezone()
639+ resource_pools = {
640+ "name": "my_resource_pool",
641+ "description": "my_description",
642+ "created": now,
643+ "updated": now,
644+ }
645+
646+ all_pools = [
647+ resource_pools | {"name": str(i), "description": str(i)}
648+ for i in range(size)
649+ ]
650+ created_resource_pools = await fixture.create(
651+ "maasserver_resourcepool",
652+ all_pools,
653+ )
654+ return [
655+ ResourcePool(**created_pool) for created_pool in created_resource_pools
656+ ]
657diff --git a/src/tests/maasapiserver/v3/api/models/requests/test_base.py b/src/tests/maasapiserver/v3/api/models/requests/test_base.py
658index 5c86ec1..d87f29e 100644
659--- a/src/tests/maasapiserver/v3/api/models/requests/test_base.py
660+++ b/src/tests/maasapiserver/v3/api/models/requests/test_base.py
661@@ -1,31 +1,59 @@
662 import pytest
663
664-from maasapiserver.v3.api.models.requests.base import NamedBaseModel
665+from maasapiserver.v3.api.models.requests.base import (
666+ NamedBaseModel,
667+ OptionalNamedBaseModel,
668+)
669+
670+VALID_NAMES = [
671+ "ValidName",
672+ "Name With Spaces",
673+ "Name-With-Hyphens",
674+ "123ValidName",
675+ "name with trailing hyphens-",
676+]
677+
678+INVALID_NAMES = [
679+ "Name_With_Special#Characters",
680+ "",
681+ " ",
682+ "-Name with leading hyphens",
683+]
684
685
686 class TestNamedBaseModel:
687 @pytest.mark.parametrize(
688 "name",
689- [
690- "ValidName",
691- "Name With Spaces",
692- "Name-With-Hyphens",
693- "123ValidName",
694- "name with trailing hyphens-",
695- ],
696+ VALID_NAMES,
697 )
698 def test_valid_names(self, name: str):
699 assert NamedBaseModel(name=name).name == name
700
701 @pytest.mark.parametrize(
702 "name",
703- [
704- "Name_With_Special#Characters",
705- "",
706- " ",
707- "-Name with leading hyphens",
708- ],
709+ INVALID_NAMES,
710 )
711 def test_invalid_names(self, name: str):
712 with pytest.raises(ValueError, match="Invalid entity name."):
713 NamedBaseModel(name=name)
714+
715+
716+class TestOptionalNamedBaseModel:
717+ @pytest.mark.parametrize("name", VALID_NAMES)
718+ def test_valid_names(self, name: str):
719+ assert OptionalNamedBaseModel(name=name).name == name
720+
721+ @pytest.mark.parametrize("name", INVALID_NAMES)
722+ def test_invalid_names(self, name: str):
723+ with pytest.raises(ValueError, match="Invalid entity name."):
724+ OptionalNamedBaseModel(name=name)
725+
726+ def test_none_name(self):
727+ model = OptionalNamedBaseModel()
728+ assert model.name is None
729+
730+ def test_none_name_if_set(self):
731+ with pytest.raises(
732+ ValueError, match="The name for the resource must not be null."
733+ ):
734+ OptionalNamedBaseModel(name=None)
735diff --git a/src/tests/maasapiserver/v3/api/test_resource_pools.py b/src/tests/maasapiserver/v3/api/test_resource_pools.py
736new file mode 100644
737index 0000000..4107c1e
738--- /dev/null
739+++ b/src/tests/maasapiserver/v3/api/test_resource_pools.py
740@@ -0,0 +1,275 @@
741+from datetime import timezone
742+
743+from fastapi.encoders import jsonable_encoder
744+from httpx import AsyncClient
745+import pytest
746+
747+from maasapiserver.common.api.models.responses.errors import ErrorBodyResponse
748+from maasapiserver.v3.api.models.requests.resource_pools import (
749+ ResourcePoolPatchRequest,
750+ ResourcePoolRequest,
751+)
752+from maasapiserver.v3.api.models.responses.resource_pools import (
753+ ResourcePoolResponse,
754+ ResourcePoolsListResponse,
755+)
756+from maasapiserver.v3.constants import EXTERNAL_V3_API_PREFIX
757+from maasapiserver.v3.models.resource_pools import ResourcePool
758+from tests.fixtures.factories.resource_pools import (
759+ create_n_test_resource_pools,
760+ create_test_resource_pool,
761+)
762+from tests.maasapiserver.fixtures.db import Fixture
763+
764+
765+@pytest.mark.usefixtures("ensuremaasdb")
766+@pytest.mark.asyncio
767+class TestResourcePoolApi:
768+ def _assert_resource_pools_in_list(
769+ self,
770+ resource_pools: ResourcePool,
771+ resource_pools_response: ResourcePoolsListResponse,
772+ ) -> None:
773+ resource_pools_response = next(
774+ filter(
775+ lambda resource_pools_response: resource_pools.id
776+ == resource_pools_response.id,
777+ resource_pools_response.items,
778+ )
779+ )
780+ assert resource_pools.id == resource_pools_response.id
781+ assert resource_pools.name == resource_pools_response.name
782+ assert (
783+ resource_pools.description == resource_pools_response.description
784+ )
785+
786+ @pytest.mark.parametrize("resource_pools_size", range(1, 3))
787+ async def test_list(
788+ self,
789+ resource_pools_size: int,
790+ api_client: AsyncClient,
791+ fixture: Fixture,
792+ ) -> None:
793+ created_resource_pools = await create_n_test_resource_pools(
794+ fixture, size=resource_pools_size
795+ )
796+ response = await api_client.get("/api/v3/resource_pools")
797+ assert response.status_code == 200
798+
799+ resource_pools_response = ResourcePoolsListResponse(**response.json())
800+ assert resource_pools_response.kind == "ResourcePoolList"
801+ # Increment as the default resource pool is included in the count.
802+ assert resource_pools_response.total == resource_pools_size + 1
803+ assert len(resource_pools_response.items) == resource_pools_size + 1
804+ for resource_pools in created_resource_pools:
805+ self._assert_resource_pools_in_list(
806+ resource_pools, resource_pools_response
807+ )
808+
809+ async def test_parametrised_list(
810+ self, api_client: AsyncClient, fixture: Fixture
811+ ) -> None:
812+ await create_n_test_resource_pools(fixture, size=9)
813+
814+ for page in range(1, 6):
815+ response = await api_client.get(
816+ f"/api/v3/resource_pools?page={page}&size=2"
817+ )
818+ assert response.status_code == 200
819+ resource_pools_response = ResourcePoolsListResponse(
820+ **response.json()
821+ )
822+ assert resource_pools_response.kind == "ResourcePoolList"
823+ assert resource_pools_response.total == 10
824+ assert len(resource_pools_response.items) == 2
825+
826+ @pytest.mark.parametrize(
827+ "page,size", [(1, 0), (0, 1), (-1, -1), (1, 1001)]
828+ )
829+ async def test_invalid_list(
830+ self, page: int, size: int, api_client: AsyncClient
831+ ) -> None:
832+ response = await api_client.get(
833+ f"/api/v3/resource_pools?page={page}&size={size}"
834+ )
835+ assert response.status_code == 422
836+
837+ error_response = ErrorBodyResponse(**response.json())
838+ assert error_response.kind == "Error"
839+ assert error_response.code == 422
840+
841+ async def test_get(
842+ self, api_client: AsyncClient, fixture: Fixture
843+ ) -> None:
844+ created_resource_pools = await create_test_resource_pool(fixture)
845+ response = await api_client.get(
846+ f"/api/v3/resource_pools/{created_resource_pools.id}"
847+ )
848+ assert response.status_code == 200
849+ assert len(response.headers["ETag"]) > 0
850+ assert response.json() == {
851+ "kind": "ResourcePool",
852+ "id": created_resource_pools.id,
853+ "name": created_resource_pools.name,
854+ "description": created_resource_pools.description,
855+ "created": created_resource_pools.created.isoformat(),
856+ "updated": created_resource_pools.updated.isoformat(),
857+ "_embedded": None,
858+ "_links": {
859+ "self": {
860+ "href": f"{EXTERNAL_V3_API_PREFIX}/resource_pools/{created_resource_pools.id}"
861+ }
862+ },
863+ }
864+
865+ @pytest.mark.parametrize("id,error", [("100", 404), ("xyz", 422)])
866+ async def test_get_invalid(
867+ self, api_client: AsyncClient, id: str, error: int
868+ ) -> None:
869+ response = await api_client.get(f"/api/v3/resource_pools/{id}")
870+ assert response.status_code == error
871+ assert "ETag" not in response.headers
872+
873+ error_response = ErrorBodyResponse(**response.json())
874+ assert error_response.kind == "Error"
875+ assert error_response.code == error
876+
877+ async def test_create(self, api_client: AsyncClient) -> None:
878+ resource_pool_request = ResourcePoolRequest(
879+ name="new_resource pool", description="new_pool_description"
880+ )
881+ response = await api_client.post(
882+ "/api/v3/resource_pools",
883+ json=jsonable_encoder(resource_pool_request),
884+ )
885+ assert response.status_code == 201
886+ assert len(response.headers["ETag"]) > 0
887+ resource_pools_response = ResourcePoolResponse(**response.json())
888+ assert resource_pools_response.id > 1
889+ assert resource_pools_response.name == resource_pool_request.name
890+ assert (
891+ resource_pools_response.description
892+ == resource_pool_request.description
893+ )
894+ assert (
895+ resource_pools_response.hal_links.self.href
896+ == f"{EXTERNAL_V3_API_PREFIX}/resource_pools/{resource_pools_response.id}"
897+ )
898+
899+ @pytest.mark.parametrize(
900+ "error_code,request_data",
901+ [
902+ (422, {"name": None}),
903+ (422, {"description": None}),
904+ (422, {"name": "", "description": "test"}),
905+ (422, {"name": "-my_pool", "description": "test"}),
906+ (422, {"name": "my$pool", "description": "test"}),
907+ ],
908+ )
909+ async def test_create_invalid(
910+ self,
911+ api_client: AsyncClient,
912+ error_code: int,
913+ request_data: dict[str, str],
914+ ) -> None:
915+ response = await api_client.post(
916+ "/api/v3/resource_pools", json=request_data
917+ )
918+ assert response.status_code == error_code
919+
920+ error_response = ErrorBodyResponse(**response.json())
921+ assert error_response.kind == "Error"
922+ assert error_response.code == error_code
923+
924+ async def test_patch(
925+ self,
926+ api_client: AsyncClient,
927+ fixture: Fixture,
928+ ) -> None:
929+ resource_pool = await create_test_resource_pool(fixture=fixture)
930+ patch_resource_pool_request = ResourcePoolPatchRequest(
931+ name="newname", description="new description"
932+ )
933+ response = await api_client.patch(
934+ f"/api/v3/resource_pools/{resource_pool.id}",
935+ json=jsonable_encoder(patch_resource_pool_request),
936+ )
937+ assert response.status_code == 200
938+
939+ patch_resource_pool = ResourcePoolResponse(**response.json())
940+ assert patch_resource_pool.id == resource_pool.id
941+ assert patch_resource_pool.name == patch_resource_pool_request.name
942+ assert (
943+ patch_resource_pool.description
944+ == patch_resource_pool_request.description
945+ )
946+ assert patch_resource_pool.created.astimezone(
947+ timezone.utc
948+ ) == resource_pool.created.astimezone(timezone.utc)
949+ assert patch_resource_pool.updated.astimezone(
950+ timezone.utc
951+ ) >= resource_pool.updated.astimezone(timezone.utc)
952+
953+ patch_resource_pool_request2 = ResourcePoolPatchRequest(
954+ description="new description"
955+ )
956+ response = await api_client.patch(
957+ f"/api/v3/resource_pools/{resource_pool.id}",
958+ json=jsonable_encoder(
959+ patch_resource_pool_request2, exclude_none=True
960+ ),
961+ )
962+ assert response.status_code == 200
963+
964+ patch_resource_pool2 = ResourcePoolResponse(**response.json())
965+ assert patch_resource_pool2.id == resource_pool.id
966+ assert patch_resource_pool2.name == patch_resource_pool_request.name
967+ assert (
968+ patch_resource_pool2.description
969+ == patch_resource_pool_request2.description
970+ )
971+ assert patch_resource_pool2.created.astimezone(
972+ timezone.utc
973+ ) == patch_resource_pool.created.astimezone(timezone.utc)
974+ assert patch_resource_pool2.updated.astimezone(
975+ timezone.utc
976+ ) >= patch_resource_pool.updated.astimezone(timezone.utc)
977+
978+ async def test_patch_unexisting(
979+ self,
980+ api_client: AsyncClient,
981+ fixture: Fixture,
982+ ) -> None:
983+ patch_resource_pool_request = ResourcePoolPatchRequest(
984+ name="newname", description="new description"
985+ )
986+ response = await api_client.patch(
987+ "/api/v3/resource_pools/1000",
988+ json=jsonable_encoder(patch_resource_pool_request),
989+ )
990+ assert response.status_code == 404
991+ error_response = ErrorBodyResponse(**response.json())
992+ assert error_response.code == 404
993+
994+ @pytest.mark.parametrize(
995+ "error_code,request_data",
996+ [
997+ (422, {"name": None}),
998+ (422, {"description": None}),
999+ (422, {"name": "", "description": "test"}),
1000+ (422, {"name": None, "description": "test"}),
1001+ (422, {"name": "-my_pool", "description": "test"}),
1002+ (422, {"name": "my$pool", "description": "test"}),
1003+ ],
1004+ )
1005+ async def test_patch_invalid(
1006+ self,
1007+ api_client: AsyncClient,
1008+ fixture: Fixture,
1009+ error_code: int,
1010+ request_data: dict[str, str],
1011+ ) -> None:
1012+ response = await api_client.patch(
1013+ "/api/v3/resource_pools/0", json=request_data
1014+ )
1015+ assert response.status_code == error_code
1016diff --git a/src/tests/maasapiserver/v3/db/test_resource_pools.py b/src/tests/maasapiserver/v3/db/test_resource_pools.py
1017new file mode 100644
1018index 0000000..6ad5feb
1019--- /dev/null
1020+++ b/src/tests/maasapiserver/v3/db/test_resource_pools.py
1021@@ -0,0 +1,149 @@
1022+from datetime import datetime, timezone
1023+from math import ceil
1024+
1025+import pytest
1026+from sqlalchemy.ext.asyncio import AsyncConnection
1027+from sqlalchemy.orm.exc import NoResultFound
1028+
1029+from maasapiserver.common.models.exceptions import AlreadyExistsException
1030+from maasapiserver.v3.api.models.requests.query import PaginationParams
1031+from maasapiserver.v3.api.models.requests.resource_pools import (
1032+ ResourcePoolRequest,
1033+)
1034+from maasapiserver.v3.db.resource_pools import ResourcePoolRepository
1035+from maasapiserver.v3.models.resource_pools import ResourcePool
1036+from tests.fixtures.factories.resource_pools import (
1037+ create_n_test_resource_pools,
1038+ create_test_resource_pool,
1039+)
1040+from tests.maasapiserver.fixtures.db import Fixture
1041+
1042+
1043+@pytest.mark.usefixtures("ensuremaasdb")
1044+@pytest.mark.asyncio
1045+class TestResourcePoolRepository:
1046+ async def test_create(self, db_connection: AsyncConnection) -> None:
1047+ now = datetime.utcnow()
1048+ resource_pools_repository = ResourcePoolRepository(db_connection)
1049+ created_resource_pools = await resource_pools_repository.create(
1050+ ResourcePoolRequest(
1051+ name="my_resource_pool", description="my description"
1052+ )
1053+ )
1054+ assert created_resource_pools.id
1055+ assert created_resource_pools.name == "my_resource_pool"
1056+ assert created_resource_pools.description == "my description"
1057+ assert created_resource_pools.created.astimezone(
1058+ timezone.utc
1059+ ) >= now.astimezone(timezone.utc)
1060+ assert created_resource_pools.updated.astimezone(
1061+ timezone.utc
1062+ ) >= now.astimezone(timezone.utc)
1063+
1064+ async def test_create_duplicated(
1065+ self, db_connection: AsyncConnection, fixture: Fixture
1066+ ) -> None:
1067+ resource_pools_repository = ResourcePoolRepository(db_connection)
1068+ created_resource_pools = await create_test_resource_pool(fixture)
1069+
1070+ with pytest.raises(AlreadyExistsException):
1071+ await resource_pools_repository.create(
1072+ ResourcePoolRequest(
1073+ name=created_resource_pools.name,
1074+ description=created_resource_pools.description,
1075+ )
1076+ )
1077+
1078+ async def test_find_by_id(
1079+ self, db_connection: AsyncConnection, fixture: Fixture
1080+ ) -> None:
1081+ resource_pools_repository = ResourcePoolRepository(db_connection)
1082+ created_resource_pools = await create_test_resource_pool(fixture)
1083+
1084+ resource_pools = await resource_pools_repository.find_by_id(
1085+ created_resource_pools.id
1086+ )
1087+ assert resource_pools.id == created_resource_pools.id
1088+ assert resource_pools.name == created_resource_pools.name
1089+ assert resource_pools.description == created_resource_pools.description
1090+ assert resource_pools.updated == created_resource_pools.updated
1091+ assert resource_pools.created == created_resource_pools.created
1092+
1093+ resource_pools = await resource_pools_repository.find_by_id(1234)
1094+ assert resource_pools is None
1095+
1096+ @pytest.mark.parametrize("page_size", range(1, 12))
1097+ async def test_list(
1098+ self, page_size: int, db_connection: AsyncConnection, fixture: Fixture
1099+ ) -> None:
1100+ resource_pools_repository = ResourcePoolRepository(db_connection)
1101+ resource_pools_count = 10
1102+ created_resource_pools = (
1103+ await create_n_test_resource_pools(
1104+ fixture, size=resource_pools_count - 1
1105+ )
1106+ )[::-1]
1107+ total_pages = ceil(resource_pools_count / page_size)
1108+ for page in range(1, total_pages + 1):
1109+ resource_pools_result = await resource_pools_repository.list(
1110+ PaginationParams(size=page_size, page=page)
1111+ )
1112+ assert resource_pools_result.total == resource_pools_count
1113+ assert total_pages == ceil(resource_pools_result.total / page_size)
1114+ if page == total_pages: # last page may have fewer elements
1115+ assert len(resource_pools_result.items) == (
1116+ page_size
1117+ - ((total_pages * page_size) % resource_pools_result.total)
1118+ )
1119+ else:
1120+ assert len(resource_pools_result.items) == page_size
1121+ for resource_pools in created_resource_pools[
1122+ ((page - 1) * page_size) : ((page * page_size))
1123+ ]:
1124+ assert resource_pools in resource_pools_result.items
1125+
1126+ async def test_update(
1127+ self, db_connection: AsyncConnection, fixture: Fixture
1128+ ) -> None:
1129+ resource_pools_repository = ResourcePoolRepository(db_connection)
1130+ created_resource_pool = await create_test_resource_pool(fixture)
1131+ updated_request = created_resource_pool.copy()
1132+ updated_request.name = "new name"
1133+ updated_request.description = "new description"
1134+ updated_pools = await resource_pools_repository.update(updated_request)
1135+ # unchanged
1136+ assert updated_pools.id == created_resource_pool.id
1137+ assert updated_pools.created == created_resource_pool.created
1138+ # changed
1139+ assert updated_pools.name == "new name"
1140+ assert updated_pools.description == "new description"
1141+ assert updated_pools.updated > created_resource_pool.updated
1142+
1143+ async def test_update_duplicated_name(
1144+ self, db_connection: AsyncConnection, fixture: Fixture
1145+ ) -> None:
1146+ resource_pools_repository = ResourcePoolRepository(db_connection)
1147+ created_resource_pool = await create_test_resource_pool(
1148+ fixture, name="test1"
1149+ )
1150+ created_resource_pool2 = await create_test_resource_pool(
1151+ fixture, name="test2"
1152+ )
1153+
1154+ updated_resource_pool = created_resource_pool.copy(
1155+ update={"id": created_resource_pool2.id}
1156+ )
1157+ with pytest.raises(AlreadyExistsException):
1158+ await resource_pools_repository.update(updated_resource_pool)
1159+
1160+ async def test_update_nonexistent(
1161+ self, db_connection: AsyncConnection
1162+ ) -> None:
1163+ now = datetime.utcnow()
1164+ resource_pools_repository = ResourcePoolRepository(db_connection)
1165+ resource_pool = ResourcePool(
1166+ id=1000, name="test", description="test", created=now, updated=now
1167+ )
1168+
1169+ with pytest.raises(NoResultFound):
1170+ await resource_pools_repository.update(resource_pool)
1171diff --git a/src/tests/maasapiserver/v3/models/test_resource_pools.py b/src/tests/maasapiserver/v3/models/test_resource_pools.py
1172new file mode 100644
1173index 0000000..398ac93
1174--- /dev/null
1175+++ b/src/tests/maasapiserver/v3/models/test_resource_pools.py
1176@@ -0,0 +1,36 @@
1177+import datetime
1178+
1179+from maasapiserver.v3.models.resource_pools import ResourcePool
1180+
1181+
1182+class TestResourcePoolsModel:
1183+ def test_to_response(self) -> None:
1184+ now = datetime.datetime.utcnow()
1185+ resource_pools = ResourcePool(
1186+ id=1,
1187+ name="my resource_pools",
1188+ description="my description",
1189+ created=now,
1190+ updated=now,
1191+ )
1192+
1193+ response = resource_pools.to_response("/api/v3/")
1194+ assert resource_pools.id == response.id
1195+ assert resource_pools.name == response.name
1196+ assert resource_pools.description == response.description
1197+ assert response.hal_links.self.href == "/api/v3/1"
1198+
1199+ def test_etag(self) -> None:
1200+ now = datetime.datetime.fromtimestamp(1705671128)
1201+ resource_pools = ResourcePool(
1202+ id=1,
1203+ name="my resource pools",
1204+ description="my description",
1205+ created=now,
1206+ updated=now,
1207+ )
1208+
1209+ assert (
1210+ resource_pools.etag()
1211+ == "979626792c99c0860c39341ea26ae63a1ef7ca922d156b969777c05db3bee295"
1212+ )
1213diff --git a/src/tests/maasapiserver/v3/services/test_resource_pools.py b/src/tests/maasapiserver/v3/services/test_resource_pools.py
1214new file mode 100644
1215index 0000000..b3012a3
1216--- /dev/null
1217+++ b/src/tests/maasapiserver/v3/services/test_resource_pools.py
1218@@ -0,0 +1,151 @@
1219+from datetime import datetime
1220+from unittest.mock import AsyncMock, Mock
1221+
1222+import pytest
1223+from sqlalchemy.ext.asyncio import AsyncConnection
1224+
1225+from maasapiserver.common.models.exceptions import NotFoundException
1226+from maasapiserver.v3.api.models.requests.query import PaginationParams
1227+from maasapiserver.v3.api.models.requests.resource_pools import (
1228+ ResourcePoolPatchRequest,
1229+ ResourcePoolRequest,
1230+)
1231+from maasapiserver.v3.db.resource_pools import ResourcePoolRepository
1232+from maasapiserver.v3.models.base import ListResult
1233+from maasapiserver.v3.models.resource_pools import ResourcePool
1234+from maasapiserver.v3.services import ResourcePoolsService
1235+from tests.maasapiserver.fixtures.db import Fixture
1236+
1237+
1238+@pytest.mark.usefixtures("ensuremaasdb")
1239+@pytest.mark.asyncio
1240+class TestResourcePoolsService:
1241+ async def test_create(
1242+ self, db_connection: AsyncConnection, fixture: Fixture
1243+ ) -> None:
1244+ now = datetime.utcnow()
1245+ resource_pool = ResourcePool(
1246+ id=1,
1247+ name="test",
1248+ description="description",
1249+ created=now,
1250+ updated=now,
1251+ )
1252+ resource_pool_repository_mock = Mock(ResourcePoolRepository)
1253+ resource_pool_repository_mock.create = AsyncMock(
1254+ return_value=resource_pool
1255+ )
1256+ resource_pools_service = ResourcePoolsService(
1257+ connection=db_connection,
1258+ resource_pools_repository=resource_pool_repository_mock,
1259+ )
1260+ request = ResourcePoolRequest(
1261+ name=resource_pool.name, description=resource_pool.description
1262+ )
1263+ created_resource_pool = await resource_pools_service.create(request)
1264+ resource_pool_repository_mock.create.assert_called_once_with(request)
1265+ assert created_resource_pool is not None
1266+
1267+ async def test_list(
1268+ self, db_connection: AsyncConnection, fixture: Fixture
1269+ ) -> None:
1270+ resource_pool_repository_mock = Mock(ResourcePoolRepository)
1271+ resource_pool_repository_mock.list = AsyncMock(
1272+ return_value=ListResult[ResourcePool](items=[], total=0)
1273+ )
1274+ resource_pools_service = ResourcePoolsService(
1275+ connection=db_connection,
1276+ resource_pools_repository=resource_pool_repository_mock,
1277+ )
1278+ pagination_params = PaginationParams(page=1, size=1)
1279+ resource_pools_list = await resource_pools_service.list(
1280+ pagination_params
1281+ )
1282+ resource_pool_repository_mock.list.assert_called_once_with(
1283+ pagination_params
1284+ )
1285+ assert resource_pools_list.total == 0
1286+ assert resource_pools_list.items == []
1287+
1288+ async def test_get_by_id(
1289+ self, db_connection: AsyncConnection, fixture: Fixture
1290+ ) -> None:
1291+ now = datetime.utcnow()
1292+ resource_pool = ResourcePool(
1293+ id=1,
1294+ name="test",
1295+ description="description",
1296+ created=now,
1297+ updated=now,
1298+ )
1299+ resource_pool_repository_mock = Mock(ResourcePoolRepository)
1300+ resource_pool_repository_mock.find_by_id = AsyncMock(
1301+ return_value=resource_pool
1302+ )
1303+
1304+ resource_pools_service = ResourcePoolsService(
1305+ connection=db_connection,
1306+ resource_pools_repository=resource_pool_repository_mock,
1307+ )
1308+ retrieved_resource_pool = await resource_pools_service.get_by_id(
1309+ id=resource_pool.id
1310+ )
1311+ resource_pool_repository_mock.find_by_id.assert_called_once_with(
1312+ resource_pool.id
1313+ )
1314+ assert retrieved_resource_pool == resource_pool
1315+
1316+ async def test_patch_not_found(
1317+ self, db_connection: AsyncConnection, fixture: Fixture
1318+ ) -> None:
1319+ resource_pool_repository_mock = Mock(ResourcePoolRepository)
1320+ resource_pool_repository_mock.find_by_id = AsyncMock(return_value=None)
1321+ resource_pools_service = ResourcePoolsService(
1322+ connection=db_connection,
1323+ resource_pools_repository=resource_pool_repository_mock,
1324+ )
1325+ with pytest.raises(NotFoundException):
1326+ await resource_pools_service.patch(
1327+ id=1000,
1328+ patch_request=ResourcePoolPatchRequest(
1329+ name="name", description="description"
1330+ ),
1331+ )
1332+
1333+ async def test_patch(
1334+ self, db_connection: AsyncConnection, fixture: Fixture
1335+ ) -> None:
1336+ now = datetime.utcnow()
1337+ resource_pool = ResourcePool(
1338+ id=1,
1339+ name="test",
1340+ description="description",
1341+ created=now,
1342+ updated=now,
1343+ )
1344+ patch_resource_pool = resource_pool.copy(
1345+ update={"name": "test2", "description": "description2"}
1346+ )
1347+ resource_pool_repository_mock = Mock(ResourcePoolRepository)
1348+ resource_pool_repository_mock.find_by_id = AsyncMock(
1349+ return_value=resource_pool
1350+ )
1351+ resource_pool_repository_mock.update = AsyncMock(
1352+ return_value=patch_resource_pool
1353+ )
1354+
1355+ resource_pools_service = ResourcePoolsService(
1356+ connection=db_connection,
1357+ resource_pools_repository=resource_pool_repository_mock,
1358+ )
1359+ updated_resource_pool = await resource_pools_service.patch(
1360+ id=resource_pool.id,
1361+ patch_request=ResourcePoolPatchRequest(
1362+ name=patch_resource_pool.name,
1363+ description=patch_resource_pool.description,
1364+ ),
1365+ )
1366+ resource_pool_repository_mock.update.assert_called_once_with(
1367+ patch_resource_pool
1368+ )
1369+ assert updated_resource_pool == patch_resource_pool

Subscribers

People subscribed via source and target branches