From 859f49fece9aacba93af67004b10cab1f7ef9108 Mon Sep 17 00:00:00 2001 From: Lucas Bacciotti Date: Mon, 27 Apr 2026 14:34:06 +0100 Subject: [PATCH] docs: align price lists OpenAPI and architecture with API - List: document filter/order_by bracket params, currency filter, 400s - Add POST /price-lists/{uuid}/duplicate/ operation - PriceListOut: assigned_cost_model_count, assigned_cost_models - Architecture: Settings-style queries, duplicate action, response fields Made-with: Cursor --- .../price-lists/api-and-lifecycle.md | 25 ++- docs/specs/openapi.json | 161 +++++++++++++----- 2 files changed, 142 insertions(+), 44 deletions(-) diff --git a/docs/architecture/price-lists/api-and-lifecycle.md b/docs/architecture/price-lists/api-and-lifecycle.md index d6dec491a9..0c236f4f98 100644 --- a/docs/architecture/price-lists/api-and-lifecycle.md +++ b/docs/architecture/price-lists/api-and-lifecycle.md @@ -25,15 +25,34 @@ Exact prefix depends on deployment (`API_PATH_PREFIX`). | Action | HTTP | Notes | |--------|------|--------| -| List / retrieve | `GET` | Filter: `name`, `uuid`, `enabled` (`PriceListFilter`). | +| List | `GET` | Query contract below; uses [`PriceListFilter`](../../../koku/cost_models/price_list_view.py) + [`SettingsFilter`](../../../koku/api/settings/utils.py) (same bracket style as Settings APIs). | +| Retrieve | `GET` | Single object by `{uuid}`. | | Create | `POST` | Body validated by [`PriceListSerializer`](../../../koku/cost_models/price_list_serializer.py). | | Full update | `PUT` | Delegates to serializer `update` → [`PriceListManager.update`](../../../koku/cost_models/price_list_manager.py). | | Delete | `DELETE` | [`perform_destroy`](../../../koku/cost_models/price_list_view.py) uses manager: **blocked** if any `PriceListCostModelMap` exists. | -| Affected cost models | `GET .../price-lists/{uuid}/affected-cost-models/` | Convenience read for impact analysis. | +| Duplicate | `POST .../price-lists/{uuid}/duplicate/` | Copies rates, dates, currency, description; new name `Copy of …` (max 255 chars); `version=1`, `enabled=true`; **no** cost-model attachments. See [`duplicate`](../../../koku/cost_models/price_list_view.py). | +| Affected cost models | `GET .../price-lists/{uuid}/affected-cost-models/` | Same assignment info as `assigned_cost_models` on the main resource, as a dedicated array endpoint. | + +**List query parameters** (enforced in [`PriceListFilter.filter_queryset`](../../../koku/cost_models/price_list_view.py)): only **`offset`**, **`limit`**, **`filter`**, and **`order_by`** are accepted at the top level. Anything else → **400** (`Unsupported parameter or invalid value`). + +**`filter[field]=value`** — allowed keys: + +| Key | Behavior | +|-----|----------| +| `name` | Case-insensitive **substring**; comma-separated values are **AND**ed (each substring must match). | +| `uuid` | Exact UUID. | +| `enabled` | Boolean. | +| `currency` | Exact match, case-insensitive. | + +Unknown `filter[...]` keys → **400**. + +**`order_by[field]=asc|desc`** — `field` must resolve to a column or annotation on the list queryset (e.g. `name`, `effective_start_date`, `effective_end_date`, `updated_timestamp`, `currency`, **`assigned_cost_model_count`**). Invalid field or direction → **400**. Default ordering is **`name`** ascending (see `PriceListFilter.Meta.default_ordering`). + +**Response shape** ([`PriceListSerializer.to_representation`](../../../koku/cost_models/price_list_serializer.py)): besides persisted fields, each price list includes read-only **`assigned_cost_model_count`** and **`assigned_cost_models`** (`{uuid, name, priority}` per map), so clients do not need a second request to see assignments (the `affected-cost-models` route remains for callers that only need that slice). **Permissions**: [`CostModelsAccessPermission`](../../../koku/api/common/permissions/cost_models_access.py) on the viewset. -**Caching**: list/create/retrieve/update/destroy are wrapped with `@never_cache`. +**Caching**: list/create/retrieve/update/destroy, `affected-cost-models`, and `duplicate` are wrapped with `@never_cache`. --- diff --git a/docs/specs/openapi.json b/docs/specs/openapi.json index 7939dd5327..ee1ff04b96 100644 --- a/docs/specs/openapi.json +++ b/docs/specs/openapi.json @@ -504,6 +504,7 @@ ], "summary": "List the price lists", "operationId": "listPriceLists", + "description": "List endpoint accepts only **offset**, **limit**, **filter**, and **order_by** query parameters. Any other top-level parameter returns 400.", "parameters": [ { "$ref": "#/components/parameters/QueryOffset" @@ -512,49 +513,10 @@ "$ref": "#/components/parameters/QueryLimit" }, { - "name": "name", - "required": false, - "in": "query", - "description": "Filter by price list name (case-insensitive substring match).", - "schema": { - "type": "string" - } - }, - { - "name": "uuid", - "required": false, - "in": "query", - "description": "Filter by exact price list UUID.", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "enabled", - "required": false, - "in": "query", - "description": "Filter by enabled status. Use true to show only active price lists or false to show only disabled ones.", - "schema": { - "type": "boolean" - } + "$ref": "#/components/parameters/PriceListQueryFilter" }, { - "name": "ordering", - "required": false, - "in": "query", - "description": "Order response by allowed fields. Prefix with '-' for descending order. Default is 'name'.", - "schema": { - "type": "string", - "enum": [ - "name", - "-name", - "effective_start_date", - "-effective_start_date", - "updated_timestamp", - "-updated_timestamp" - ] - } + "$ref": "#/components/parameters/PriceListQueryOrderBy" } ], "security": [ @@ -573,6 +535,16 @@ } } }, + "400": { + "description": "Bad Request (unsupported query parameter, invalid filter key, or invalid order_by field/direction).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "401": { "description": "Unauthorized" }, @@ -916,6 +888,78 @@ } } }, + "/price-lists/{price_list_uuid}/duplicate/": { + "post": { + "tags": [ + "Price Lists" + ], + "summary": "Duplicate a price list.", + "description": "Creates a new price list by copying rates, currency, validity dates, and description from the source. The new list is named `Copy of {original name}` (truncated to 255 characters if needed), **version** resets to **1**, **enabled** is **true**, and it is **not** linked to any cost model. Request body is optional and ignored.", + "operationId": "duplicatePriceList", + "parameters": [ + { + "name": "price_list_uuid", + "in": "path", + "description": "UUID of the Price List to duplicate.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "basic_auth": [] + } + ], + "responses": { + "201": { + "description": "The newly created price list (same shape as create response).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PriceListOut" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Unexpected Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/forecasts/aws/costs/": { "summary": "AWS Cost Forecasts", "get": { @@ -6919,6 +6963,28 @@ "maximum": 1000 } }, + "PriceListQueryFilter": { + "name": "filter", + "required": false, + "in": "query", + "description": "Filter price lists with `filter[field]=value`. Allowed keys: **name** (case-insensitive substring; comma-separated values are ANDed), **uuid** (exact UUID), **enabled** (boolean), **currency** (exact, case-insensitive). Unknown filter keys return 400.", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object" + } + }, + "PriceListQueryOrderBy": { + "name": "order_by", + "required": false, + "in": "query", + "description": "Sort with `order_by[field]=asc` or `order_by[field]=desc`. Allowed fields match the `price_list` queryset (including annotation **assigned_cost_model_count**): e.g. **name**, **effective_start_date**, **effective_end_date**, **updated_timestamp**, **currency**, **assigned_cost_model_count**. Unknown fields return 400. Default is ascending **name**.", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object" + } + }, "ReportQueryLimit": { "in": "query", "name": "limit", @@ -7376,6 +7442,19 @@ "type": "string", "format": "date-time", "readOnly": true + }, + "assigned_cost_model_count": { + "type": "integer", + "readOnly": true, + "description": "Number of cost models that reference this price list." + }, + "assigned_cost_models": { + "type": "array", + "readOnly": true, + "description": "Cost models linked to this price list (uuid, name, priority). Same shape as rows from `GET .../affected-cost-models/`, inlined for convenience.", + "items": { + "$ref": "#/components/schemas/PriceListAffectedCostModel" + } } } }