planvortex 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {planvortex-0.3.0 → planvortex-0.4.0}/CHANGELOG.md +36 -0
  2. {planvortex-0.3.0 → planvortex-0.4.0}/PKG-INFO +1 -1
  3. {planvortex-0.3.0 → planvortex-0.4.0}/openapi/planvortex.openapi.json +118 -3
  4. {planvortex-0.3.0 → planvortex-0.4.0}/pyproject.toml +1 -1
  5. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_generated/models.py +8 -0
  6. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_version.py +1 -1
  7. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/ai_plans.py +84 -8
  8. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/ai_plans.py +77 -7
  9. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_ai_plans.py +45 -1
  10. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_route_coverage.py +6 -2
  11. {planvortex-0.3.0 → planvortex-0.4.0}/.gitignore +0 -0
  12. {planvortex-0.3.0 → planvortex-0.4.0}/LICENSE +0 -0
  13. {planvortex-0.3.0 → planvortex-0.4.0}/README.md +0 -0
  14. {planvortex-0.3.0 → planvortex-0.4.0}/scripts/build_docs.py +0 -0
  15. {planvortex-0.3.0 → planvortex-0.4.0}/scripts/check_packaging.py +0 -0
  16. {planvortex-0.3.0 → planvortex-0.4.0}/scripts/generate_models.py +0 -0
  17. {planvortex-0.3.0 → planvortex-0.4.0}/scripts/generate_sync.py +0 -0
  18. {planvortex-0.3.0 → planvortex-0.4.0}/scripts/route_coverage.py +0 -0
  19. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/__init__.py +0 -0
  20. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_client.py +0 -0
  21. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_client_sync.py +0 -0
  22. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_core/__init__.py +0 -0
  23. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_core/auth.py +0 -0
  24. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_core/auth_sync.py +0 -0
  25. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_core/errors.py +0 -0
  26. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_core/files.py +0 -0
  27. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_core/http.py +0 -0
  28. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_core/http_sync.py +0 -0
  29. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_core/pagination.py +0 -0
  30. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_core/query.py +0 -0
  31. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_core/transport.py +0 -0
  32. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_generated/__init__.py +0 -0
  33. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/_shapes.py +0 -0
  34. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/py.typed +0 -0
  35. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/__init__.py +0 -0
  36. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/accounts.py +0 -0
  37. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/apps.py +0 -0
  38. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/base.py +0 -0
  39. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/catalog.py +0 -0
  40. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/clients.py +0 -0
  41. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/comments.py +0 -0
  42. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/contacts.py +0 -0
  43. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/dashboard.py +0 -0
  44. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/integrations.py +0 -0
  45. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/messages.py +0 -0
  46. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/organizations.py +0 -0
  47. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/products.py +0 -0
  48. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/publications.py +0 -0
  49. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources/uploads.py +0 -0
  50. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/__init__.py +0 -0
  51. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/accounts.py +0 -0
  52. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/apps.py +0 -0
  53. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/base.py +0 -0
  54. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/catalog.py +0 -0
  55. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/clients.py +0 -0
  56. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/comments.py +0 -0
  57. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/contacts.py +0 -0
  58. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/dashboard.py +0 -0
  59. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/integrations.py +0 -0
  60. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/messages.py +0 -0
  61. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/organizations.py +0 -0
  62. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/products.py +0 -0
  63. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/publications.py +0 -0
  64. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/resources_sync/uploads.py +0 -0
  65. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/types.py +0 -0
  66. {planvortex-0.3.0 → planvortex-0.4.0}/src/planvortex/webhooks.py +0 -0
  67. {planvortex-0.3.0 → planvortex-0.4.0}/tests/__init__.py +0 -0
  68. {planvortex-0.3.0 → planvortex-0.4.0}/tests/conftest.py +0 -0
  69. {planvortex-0.3.0 → planvortex-0.4.0}/tests/contrato.py +0 -0
  70. {planvortex-0.3.0 → planvortex-0.4.0}/tests/live/__init__.py +0 -0
  71. {planvortex-0.3.0 → planvortex-0.4.0}/tests/live/conftest.py +0 -0
  72. {planvortex-0.3.0 → planvortex-0.4.0}/tests/live/test_auth.py +0 -0
  73. {planvortex-0.3.0 → planvortex-0.4.0}/tests/live/test_catalog.py +0 -0
  74. {planvortex-0.3.0 → planvortex-0.4.0}/tests/live/test_connect_flow.py +0 -0
  75. {planvortex-0.3.0 → planvortex-0.4.0}/tests/live/test_errors.py +0 -0
  76. {planvortex-0.3.0 → planvortex-0.4.0}/tests/live/test_publish.py +0 -0
  77. {planvortex-0.3.0 → planvortex-0.4.0}/tests/live/test_read.py +0 -0
  78. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_accounts.py +0 -0
  79. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_apps.py +0 -0
  80. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_catalog.py +0 -0
  81. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_client.py +0 -0
  82. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_clients.py +0 -0
  83. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_comments.py +0 -0
  84. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_contacts.py +0 -0
  85. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_core.py +0 -0
  86. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_dashboard.py +0 -0
  87. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_example_comments.py +0 -0
  88. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_example_connect.py +0 -0
  89. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_example_publish.py +0 -0
  90. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_example_schedule.py +0 -0
  91. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_example_webhooks.py +0 -0
  92. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_files.py +0 -0
  93. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_generate_models.py +0 -0
  94. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_generate_sync.py +0 -0
  95. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_integrations.py +0 -0
  96. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_messages.py +0 -0
  97. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_models.py +0 -0
  98. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_openapi_freshness.py +0 -0
  99. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_organizations.py +0 -0
  100. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_packaging.py +0 -0
  101. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_pagination.py +0 -0
  102. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_products.py +0 -0
  103. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_publications.py +0 -0
  104. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_query_parity.py +0 -0
  105. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_readme.py +0 -0
  106. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_shapes_parity.py +0 -0
  107. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_types.py +0 -0
  108. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_uploads.py +0 -0
  109. {planvortex-0.3.0 → planvortex-0.4.0}/tests/test_webhooks.py +0 -0
@@ -4,6 +4,42 @@ All notable changes to this package are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.4.0] - 2026-08-31
8
+
9
+ An AI plan can now be **archived**, and deleting one finally means what people thought it meant.
10
+
11
+ The two look alike on a screen and have nothing in common underneath: archiving takes the plan out
12
+ of the listing and touches nothing else, while deleting takes the publications that had not gone out
13
+ with it. Almost every time someone reaches for "remove" they wanted the first one.
14
+
15
+ Upgrading from `0.3.0` needs no changes and `MIGRATION.md` gains no entry — everything added here is
16
+ additive. **Read the note on `remove()` anyway**: what it deletes changed in the server, so it has
17
+ been happening to your `0.3.0` calls for a while and the docs that shipped with them say otherwise.
18
+
19
+ ### Added
20
+
21
+ - **`ai_plans.archive()` and `ai_plans.unarchive()`**, in both clients. Archiving is **visibility
22
+ only**: the plan leaves the listing and moves to the archived one, no publication is touched —
23
+ anything scheduled keeps publishing — no credits come back, nothing is cancelled. It therefore
24
+ works in **any state**, `generating` included, and it is undone. On the object it is
25
+ `archived_date`: a field, **not** a value of `state`, so reading the state to find out whether a
26
+ plan is archived will never work. Absent means active, which is how every plan created before the
27
+ field existed comes back.
28
+ - **`archived` on `ai_plans.list()` and `ai_plans.aiterate()`/`iterate()`.** `True` returns the
29
+ archived plans instead of the active ones, never both at once. A `False` is not sent: the server
30
+ switches the filter on with the literal `"true"`, so it would be noise — and it would suggest a
31
+ third mode with both cupboards open that does not exist.
32
+
33
+ ### Changed
34
+
35
+ - **`ai_plans.remove()` deletes the plan's publications that have not gone out yet**, not only the
36
+ drafts: if the plan had already been validated, whatever was still scheduled goes too. This is a
37
+ change **in the server**, so it reached your existing calls the day it shipped — before it, a
38
+ validated plan you deleted kept publishing by itself the following week. Two states are
39
+ deliberately left alone: the already published one, because deleting it here would not take it off
40
+ the network and would only lose its history, and the one being published at that very moment.
41
+ If you wanted the plan out of the way without losing anything, that is `archive()`.
42
+
7
43
  ## [0.3.0] - 2026-08-31
8
44
 
9
45
  The AI planner stops being one thing. A plan can now be generated from **your own photos**, from an
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: planvortex
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Official Python client for the PlanVortex API
5
5
  Project-URL: Homepage, https://planvortex.com/developers
6
6
  Project-URL: Documentation, https://taliasoftworks.github.io/PlanVortexPython/
@@ -1000,7 +1000,7 @@
1000
1000
  "ai_plans"
1001
1001
  ],
1002
1002
  "summary": "List AI publication plans",
1003
- "description": "Return the organization's AI plans ordered by creation date (desc), paginated. Requires the ai_plans:read permission.\n\n**Cancelled plans are not listed.** Deleting a plan sets it to `cancelled` rather than removing it, and this listing filters those out — so a plan you deleted simply stops appearing, while `GET` by id still returns it.\n\nWithout `limit` the whole list comes back.",
1003
+ "description": "Return the organization's AI plans ordered by creation date (desc), paginated. Requires the ai_plans:read permission.\n\n**Active plans by default, archived ones with `archived=true`** — never both at once. Archiving is visibility only (see `POST .../archive`): the plan leaves this listing and keeps every publication it had.\n\n**Cancelled plans are not listed either.** Deleting a plan sets it to `cancelled` rather than removing it, and this listing filters those out — so a plan you deleted simply stops appearing, while `GET` by id still returns it.\n\nWithout `limit` the whole list comes back.",
1004
1004
  "operationId": "getAiPlans",
1005
1005
  "x-planvortex-identity": [
1006
1006
  "current_user",
@@ -1031,6 +1031,16 @@
1031
1031
  "schema": {
1032
1032
  "type": "integer"
1033
1033
  }
1034
+ },
1035
+ {
1036
+ "name": "archived",
1037
+ "in": "query",
1038
+ "description": "`true` returns the archived plans instead of the active ones. Anything other than the literal string `true` is read as false.",
1039
+ "required": false,
1040
+ "schema": {
1041
+ "type": "boolean",
1042
+ "default": false
1043
+ }
1034
1044
  }
1035
1045
  ],
1036
1046
  "responses": {
@@ -1113,8 +1123,8 @@
1113
1123
  "tags": [
1114
1124
  "ai_plans"
1115
1125
  ],
1116
- "summary": "Cancel / discard an AI publication plan",
1117
- "description": "Discard the plan and delete its generated draft publications. AI credits already spent on generation are NOT refunded (same criterion as X: if the provider charged, we charge). Requires the ai_plans:delete permission.",
1126
+ "summary": "Delete an AI publication plan and its pending publications",
1127
+ "description": "Delete the plan **and every publication of it that has not gone out yet**: the generated drafts and, if the plan was already validated, whatever was still scheduled. Two states are deliberately left alone: `sended`, because it is already live on the network and deleting it here would only lose its history while the post stays up, and `publishing`, which the publishing job holds at that very moment.\n\nAI credits already spent on generation are NOT refunded (same criterion as X: if the provider charged, we charge). A plan being generated cannot be deleted (state `generating`, error 2102): wait for the job to finish.\n\nTo keep the plan and its publications and only take it out of the listing, archive it instead (`POST .../archive`). Requires the ai_plans:delete permission.",
1118
1128
  "operationId": "deleteAiPlan",
1119
1129
  "x-planvortex-identity": [
1120
1130
  "current_user",
@@ -1258,6 +1268,106 @@
1258
1268
  }
1259
1269
  }
1260
1270
  },
1271
+ "/clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}/archive": {
1272
+ "post": {
1273
+ "tags": [
1274
+ "ai_plans"
1275
+ ],
1276
+ "summary": "Archive an AI publication plan",
1277
+ "description": "Take the plan out of the default listing and move it to the archived one (`GET .../ai_plans?archived=true`). It is **visibility only**: no publication is touched — anything scheduled keeps publishing — no credits are refunded, and nothing is cancelled. It is reversible with `POST .../unarchive` and valid in **any state**, `generating` included, because it does not interrupt the generation job.\n\nArchiving an already archived plan just refreshes `archived_date`. Requires the ai_plans:update permission.",
1278
+ "operationId": "archiveAiPlan",
1279
+ "x-planvortex-identity": [
1280
+ "current_user",
1281
+ "current_app"
1282
+ ],
1283
+ "parameters": [
1284
+ {
1285
+ "$ref": "#/components/parameters/AiPlansidClient"
1286
+ },
1287
+ {
1288
+ "$ref": "#/components/parameters/AiPlansidOrganization"
1289
+ },
1290
+ {
1291
+ "$ref": "#/components/parameters/AiPlansidAiPlan"
1292
+ }
1293
+ ],
1294
+ "responses": {
1295
+ "200": {
1296
+ "description": "Successful operation. Returns the plan with `archived_date` set.",
1297
+ "content": {
1298
+ "application/json": {
1299
+ "schema": {
1300
+ "$ref": "#/components/schemas/AiPlansAiPlanOne"
1301
+ }
1302
+ }
1303
+ }
1304
+ },
1305
+ "400": {
1306
+ "description": "The request failed. The body carries the PlanVortex error code in `code`:\n\n| Code | Meaning |\n| --- | --- |\n| `1101` | Invalid organization |\n| `2100` | AI plan doesn't exist. The identifier is invalid or doesn't exist. |\n| `521` | Invalid client. The identifier is invalid or doesn't exist |",
1307
+ "content": {
1308
+ "application/json": {
1309
+ "schema": {
1310
+ "$ref": "#/components/schemas/Error"
1311
+ }
1312
+ }
1313
+ }
1314
+ },
1315
+ "500": {
1316
+ "description": "Unhandled error by the server"
1317
+ }
1318
+ }
1319
+ }
1320
+ },
1321
+ "/clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}/unarchive": {
1322
+ "post": {
1323
+ "tags": [
1324
+ "ai_plans"
1325
+ ],
1326
+ "summary": "Unarchive an AI publication plan",
1327
+ "description": "Return the plan to the default listing: `archived_date` is cleared. Unarchiving a plan that was not archived does nothing and answers 200. Requires the ai_plans:update permission.",
1328
+ "operationId": "unarchiveAiPlan",
1329
+ "x-planvortex-identity": [
1330
+ "current_user",
1331
+ "current_app"
1332
+ ],
1333
+ "parameters": [
1334
+ {
1335
+ "$ref": "#/components/parameters/AiPlansidClient"
1336
+ },
1337
+ {
1338
+ "$ref": "#/components/parameters/AiPlansidOrganization"
1339
+ },
1340
+ {
1341
+ "$ref": "#/components/parameters/AiPlansidAiPlan"
1342
+ }
1343
+ ],
1344
+ "responses": {
1345
+ "200": {
1346
+ "description": "Successful operation. Returns the plan without `archived_date`.",
1347
+ "content": {
1348
+ "application/json": {
1349
+ "schema": {
1350
+ "$ref": "#/components/schemas/AiPlansAiPlanOne"
1351
+ }
1352
+ }
1353
+ }
1354
+ },
1355
+ "400": {
1356
+ "description": "The request failed. The body carries the PlanVortex error code in `code`:\n\n| Code | Meaning |\n| --- | --- |\n| `1101` | Invalid organization |\n| `2100` | AI plan doesn't exist. The identifier is invalid or doesn't exist. |\n| `521` | Invalid client. The identifier is invalid or doesn't exist |",
1357
+ "content": {
1358
+ "application/json": {
1359
+ "schema": {
1360
+ "$ref": "#/components/schemas/Error"
1361
+ }
1362
+ }
1363
+ }
1364
+ },
1365
+ "500": {
1366
+ "description": "Unhandled error by the server"
1367
+ }
1368
+ }
1369
+ }
1370
+ },
1261
1371
  "/clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}/publications/{id_publication}/regenerate": {
1262
1372
  "post": {
1263
1373
  "tags": [
@@ -9597,6 +9707,11 @@
9597
9707
  ],
9598
9708
  "description": "State machine: pending -> generating -> generated -> validated | failed | cancelled. Poll the plan while state is pending or generating."
9599
9709
  },
9710
+ "archived_date": {
9711
+ "type": "string",
9712
+ "format": "date-time",
9713
+ "description": "When the plan was archived. Absent means it is active, which is how every plan created before archiving existed comes back.\n\nIt is a field of its own and NOT a value of `state` on purpose: `state` is the generation lifecycle and archiving is orthogonal to it — a `validated` plan that already published gets archived just like a `failed` one. Inside the enum you would have to decide which state it returns to when unarchived, and that question has no answer.\n\nArchiving is visibility only: it takes the plan out of the default listing and touches no publication. Anything it had scheduled keeps publishing."
9714
+ },
9600
9715
  "orchestrator_result": {
9601
9716
  "type": "object",
9602
9717
  "description": "Raw plan returned by the orchestrator, kept for audit (before validation/trimming)."
@@ -9,7 +9,7 @@ name = "planvortex"
9
9
  # entonces `project.version` desapareceria del pyproject y el paso del workflow de release que
10
10
  # compara el tag con la version —que lo lee con `tomllib`— dejaria de encontrarla. Se quedan las dos
11
11
  # y las ata `tests/test_packaging.py`, que falla si se separan.
12
- version = "0.3.0"
12
+ version = "0.4.0"
13
13
  description = "Official Python client for the PlanVortex API"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.10"
@@ -2872,6 +2872,14 @@ class AiPlansAiPlan(TypedDict):
2872
2872
  """
2873
2873
  State machine: pending -> generating -> generated -> validated | failed | cancelled. Poll the plan while state is pending or generating.
2874
2874
  """
2875
+ archived_date: NotRequired[str]
2876
+ """
2877
+ When the plan was archived. Absent means it is active, which is how every plan created before archiving existed comes back.
2878
+
2879
+ It is a field of its own and NOT a value of `state` on purpose: `state` is the generation lifecycle and archiving is orthogonal to it — a `validated` plan that already published gets archived just like a `failed` one. Inside the enum you would have to decide which state it returns to when unarchived, and that question has no answer.
2880
+
2881
+ Archiving is visibility only: it takes the plan out of the default listing and touches no publication. Anything it had scheduled keeps publishing.
2882
+ """
2875
2883
  orchestrator_result: NotRequired[dict[str, Any]]
2876
2884
  """
2877
2885
  Raw plan returned by the orchestrator, kept for audit (before validation/trimming).
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import platform
14
14
 
15
- VERSION = "0.3.0"
15
+ VERSION = "0.4.0"
16
16
 
17
17
  # Sin barra final, y con el `/v1.0.0` dentro: la version de la API es parte de la ruta, no una
18
18
  # cabecera, asi que una `base_url` sin ella da 404 en todo.
@@ -22,7 +22,13 @@ AND WHAT SURPRISES PEOPLE:
22
22
  so Monday/Wednesday/Friday with 3 accounts is 9 publications, not 21.
23
23
  - **These routes hang off the CLIENT**, not only off the organization: they carry both identifiers.
24
24
  - **Deleting is cancelling.** The plan moves to ``cancelled`` and stops appearing in the listing, but
25
- :meth:`AsyncAiPlansResource.get` still returns it.
25
+ :meth:`AsyncAiPlansResource.get` still returns it. What does go for good are its publications that
26
+ had not gone out yet: the drafts and, if the plan was already validated, whatever was still
27
+ scheduled.
28
+ - **Archiving is NOT deleting, and it is the action people are usually after.**
29
+ :meth:`AsyncAiPlansResource.archive` takes the plan out of the listing and touches no publication
30
+ — anything scheduled keeps publishing — works in any state and can be undone. The archived ones
31
+ are read with ``list(..., archived=True)``, never next to the active ones.
26
32
 
27
33
  THE TEMPLATES: WHAT THE PLAN IS GENERATED FROM
28
34
 
@@ -67,7 +73,7 @@ from collections.abc import AsyncIterator
67
73
 
68
74
  from planvortex._core.pagination import Page, PageParams
69
75
  from planvortex._shapes import AiPlanRegenerateResult
70
- from planvortex.resources.base import AsyncResource, require_id
76
+ from planvortex.resources.base import AsyncResource, Query, require_id
71
77
  from planvortex.types import AiPlan, AiPlanCreateRequest, AiPlanCreateResult
72
78
 
73
79
 
@@ -166,17 +172,27 @@ class AsyncAiPlansResource(AsyncResource):
166
172
  *,
167
173
  limit: int | None = None,
168
174
  offset: int | None = None,
175
+ archived: bool | None = None,
169
176
  timeout: float | None = None,
170
177
  ) -> Page[AiPlan]:
171
- """The organization's plans, from the newest to the oldest.
178
+ """The organization's ACTIVE plans, from the newest to the oldest.
172
179
 
173
- **The cancelled ones do not show up**, and here ``publications`` are identifiers, not the
174
- whole publications: for that there is :meth:`get`. With no ``limit`` they all come back.
180
+ **The cancelled ones do not show up** — nor the archived ones, which are asked for with
181
+ ``archived=True`` — and here ``publications`` are identifiers, not the whole publications:
182
+ for that there is :meth:`get`. With no ``limit`` they all come back.
183
+
184
+ ``archived`` opens the other cupboard, never both at once: archiving moves a plan somewhere
185
+ else, it does not put a label on it and leave it where it was.
186
+
187
+ .. code-block:: python
188
+
189
+ activos = await pv.ai_plans.list(client_id, org_id)
190
+ guardados = await pv.ai_plans.list(client_id, org_id, archived=True)
175
191
  """
176
192
  pagina: Page[AiPlan] = await self._list(
177
193
  self._path(id_client, id_organization),
178
194
  "ai_plans",
179
- {"limit": limit, "offset": offset},
195
+ self._list_query(limit, offset, archived),
180
196
  timeout=timeout,
181
197
  )
182
198
  return pagina
@@ -188,6 +204,7 @@ class AsyncAiPlansResource(AsyncResource):
188
204
  *,
189
205
  limit: int | None = None,
190
206
  offset: int | None = None,
207
+ archived: bool | None = None,
191
208
  timeout: float | None = None,
192
209
  ) -> AsyncIterator[AiPlan]:
193
210
  """The organization's plans, chaining pages."""
@@ -198,6 +215,7 @@ class AsyncAiPlansResource(AsyncResource):
198
215
  id_organization,
199
216
  limit=params.limit,
200
217
  offset=params.offset,
218
+ archived=archived,
201
219
  timeout=timeout,
202
220
  )
203
221
 
@@ -274,6 +292,47 @@ class AsyncAiPlansResource(AsyncResource):
274
292
  resultado: AiPlanRegenerateResult = await self._post(ruta, {"target": target}, timeout=timeout)
275
293
  return resultado
276
294
 
295
+ async def archive(
296
+ self,
297
+ id_client: str,
298
+ id_organization: str,
299
+ id_ai_plan: str,
300
+ *,
301
+ timeout: float | None = None,
302
+ ) -> AiPlan:
303
+ """Archive the plan: it leaves the listing and moves to the archived one
304
+ (``list(..., archived=True)``).
305
+
306
+ It is **visibility only**. No publication is touched — anything scheduled keeps publishing —
307
+ no credits are refunded and nothing is cancelled, so it works in ANY state, ``generating``
308
+ included: it does not interrupt the job. Undone with :meth:`unarchive`.
309
+
310
+ It is what people are after almost every time they think of "removing" a plan: :meth:`remove`
311
+ takes the publications that have not gone out with it, and this does not.
312
+ """
313
+ plan: AiPlan = await self._post_one(
314
+ f"{self._one_path(id_client, id_organization, id_ai_plan)}/archive",
315
+ "ai_plan",
316
+ timeout=timeout,
317
+ )
318
+ return plan
319
+
320
+ async def unarchive(
321
+ self,
322
+ id_client: str,
323
+ id_organization: str,
324
+ id_ai_plan: str,
325
+ *,
326
+ timeout: float | None = None,
327
+ ) -> AiPlan:
328
+ """Put the plan back in the active listing. On a plan that was not archived it does nothing."""
329
+ plan: AiPlan = await self._post_one(
330
+ f"{self._one_path(id_client, id_organization, id_ai_plan)}/unarchive",
331
+ "ai_plan",
332
+ timeout=timeout,
333
+ )
334
+ return plan
335
+
277
336
  async def remove(
278
337
  self,
279
338
  id_client: str,
@@ -282,11 +341,28 @@ class AsyncAiPlansResource(AsyncResource):
282
341
  *,
283
342
  timeout: float | None = None,
284
343
  ) -> None:
285
- """Cancel the plan. **It does not delete it**: it moves to ``cancelled``, disappears from
286
- :meth:`list` and :meth:`get` still returns it.
344
+ """Delete the plan **and its publications that have not gone out yet**: the generated drafts
345
+ and, if it had already been validated, whatever was still scheduled. The already published
346
+ ones stay — deleting them here would not take them off the network, it would only lose their
347
+ history — and neither is the one being published at that very moment touched.
348
+
349
+ The plan itself is not wiped: it moves to ``cancelled``, disappears from :meth:`list` and
350
+ :meth:`get` still returns it. Spent credits are not refunded and a ``generating`` plan cannot
351
+ be deleted (2102): wait for the job to finish.
352
+
353
+ To stop seeing it without losing anything, :meth:`archive`.
287
354
  """
288
355
  await self._delete(self._one_path(id_client, id_organization, id_ai_plan), timeout=timeout)
289
356
 
357
+ def _list_query(self, limit: int | None, offset: int | None, archived: bool | None) -> Query:
358
+ return {
359
+ "limit": limit,
360
+ "offset": offset,
361
+ # El servidor enciende el filtro con el literal "true": mandar `archived=false` pediria
362
+ # lo mismo que no mandar nada, asi que un `False` se omite en vez de viajar como ruido.
363
+ "archived": True if archived else None,
364
+ }
365
+
290
366
  def _path(self, id_client: str, id_organization: str) -> str:
291
367
  cliente = require_id(id_client, "id_client")
292
368
  organizacion = require_id(id_organization, "id_organization")
@@ -12,7 +12,7 @@ from collections.abc import Iterator
12
12
 
13
13
  from planvortex._core.pagination import Page, PageParams
14
14
  from planvortex._shapes import AiPlanRegenerateResult
15
- from planvortex.resources_sync.base import Resource, require_id
15
+ from planvortex.resources_sync.base import Query, Resource, require_id
16
16
  from planvortex.types import AiPlan, AiPlanCreateRequest, AiPlanCreateResult
17
17
 
18
18
 
@@ -111,17 +111,27 @@ class AiPlansResource(Resource):
111
111
  *,
112
112
  limit: int | None = None,
113
113
  offset: int | None = None,
114
+ archived: bool | None = None,
114
115
  timeout: float | None = None,
115
116
  ) -> Page[AiPlan]:
116
- """The organization's plans, from the newest to the oldest.
117
+ """The organization's ACTIVE plans, from the newest to the oldest.
117
118
 
118
- **The cancelled ones do not show up**, and here ``publications`` are identifiers, not the
119
- whole publications: for that there is :meth:`get`. With no ``limit`` they all come back.
119
+ **The cancelled ones do not show up** — nor the archived ones, which are asked for with
120
+ ``archived=True`` — and here ``publications`` are identifiers, not the whole publications:
121
+ for that there is :meth:`get`. With no ``limit`` they all come back.
122
+
123
+ ``archived`` opens the other cupboard, never both at once: archiving moves a plan somewhere
124
+ else, it does not put a label on it and leave it where it was.
125
+
126
+ .. code-block:: python
127
+
128
+ activos = pv.ai_plans.list(client_id, org_id)
129
+ guardados = pv.ai_plans.list(client_id, org_id, archived=True)
120
130
  """
121
131
  pagina: Page[AiPlan] = self._list(
122
132
  self._path(id_client, id_organization),
123
133
  "ai_plans",
124
- {"limit": limit, "offset": offset},
134
+ self._list_query(limit, offset, archived),
125
135
  timeout=timeout,
126
136
  )
127
137
  return pagina
@@ -133,6 +143,7 @@ class AiPlansResource(Resource):
133
143
  *,
134
144
  limit: int | None = None,
135
145
  offset: int | None = None,
146
+ archived: bool | None = None,
136
147
  timeout: float | None = None,
137
148
  ) -> Iterator[AiPlan]:
138
149
  """The organization's plans, chaining pages."""
@@ -143,6 +154,7 @@ class AiPlansResource(Resource):
143
154
  id_organization,
144
155
  limit=params.limit,
145
156
  offset=params.offset,
157
+ archived=archived,
146
158
  timeout=timeout,
147
159
  )
148
160
 
@@ -219,6 +231,47 @@ class AiPlansResource(Resource):
219
231
  resultado: AiPlanRegenerateResult = self._post(ruta, {"target": target}, timeout=timeout)
220
232
  return resultado
221
233
 
234
+ def archive(
235
+ self,
236
+ id_client: str,
237
+ id_organization: str,
238
+ id_ai_plan: str,
239
+ *,
240
+ timeout: float | None = None,
241
+ ) -> AiPlan:
242
+ """Archive the plan: it leaves the listing and moves to the archived one
243
+ (``list(..., archived=True)``).
244
+
245
+ It is **visibility only**. No publication is touched — anything scheduled keeps publishing —
246
+ no credits are refunded and nothing is cancelled, so it works in ANY state, ``generating``
247
+ included: it does not interrupt the job. Undone with :meth:`unarchive`.
248
+
249
+ It is what people are after almost every time they think of "removing" a plan: :meth:`remove`
250
+ takes the publications that have not gone out with it, and this does not.
251
+ """
252
+ plan: AiPlan = self._post_one(
253
+ f"{self._one_path(id_client, id_organization, id_ai_plan)}/archive",
254
+ "ai_plan",
255
+ timeout=timeout,
256
+ )
257
+ return plan
258
+
259
+ def unarchive(
260
+ self,
261
+ id_client: str,
262
+ id_organization: str,
263
+ id_ai_plan: str,
264
+ *,
265
+ timeout: float | None = None,
266
+ ) -> AiPlan:
267
+ """Put the plan back in the active listing. On a plan that was not archived it does nothing."""
268
+ plan: AiPlan = self._post_one(
269
+ f"{self._one_path(id_client, id_organization, id_ai_plan)}/unarchive",
270
+ "ai_plan",
271
+ timeout=timeout,
272
+ )
273
+ return plan
274
+
222
275
  def remove(
223
276
  self,
224
277
  id_client: str,
@@ -227,11 +280,28 @@ class AiPlansResource(Resource):
227
280
  *,
228
281
  timeout: float | None = None,
229
282
  ) -> None:
230
- """Cancel the plan. **It does not delete it**: it moves to ``cancelled``, disappears from
231
- :meth:`list` and :meth:`get` still returns it.
283
+ """Delete the plan **and its publications that have not gone out yet**: the generated drafts
284
+ and, if it had already been validated, whatever was still scheduled. The already published
285
+ ones stay — deleting them here would not take them off the network, it would only lose their
286
+ history — and neither is the one being published at that very moment touched.
287
+
288
+ The plan itself is not wiped: it moves to ``cancelled``, disappears from :meth:`list` and
289
+ :meth:`get` still returns it. Spent credits are not refunded and a ``generating`` plan cannot
290
+ be deleted (2102): wait for the job to finish.
291
+
292
+ To stop seeing it without losing anything, :meth:`archive`.
232
293
  """
233
294
  self._delete(self._one_path(id_client, id_organization, id_ai_plan), timeout=timeout)
234
295
 
296
+ def _list_query(self, limit: int | None, offset: int | None, archived: bool | None) -> Query:
297
+ return {
298
+ "limit": limit,
299
+ "offset": offset,
300
+ # El servidor enciende el filtro con el literal "true": mandar `archived=false` pediria
301
+ # lo mismo que no mandar nada, asi que un `False` se omite en vez de viajar como ruido.
302
+ "archived": True if archived else None,
303
+ }
304
+
235
305
  def _path(self, id_client: str, id_organization: str) -> str:
236
306
  cliente = require_id(id_client, "id_client")
237
307
  organizacion = require_id(id_organization, "id_organization")
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
  from pytest_httpx2 import HTTPXMock
23
23
 
24
24
  from tests.conftest import BASE_URL, ClienteDePrueba
25
- from tests.contrato import cuerpo, peticiones, ruta, unica
25
+ from tests.contrato import cuerpo, peticiones, query, ruta, unica
26
26
 
27
27
  PLANES = f"{BASE_URL}/clients/cli1/organizations/org1/ai_plans"
28
28
  PLAN = {
@@ -222,3 +222,47 @@ def test_borrar_es_cancelar(cliente: ClienteDePrueba, httpx_mock: HTTPXMock) ->
222
222
 
223
223
  assert cliente.esperar(cliente.pv.ai_plans.remove("cli1", "org1", "plan1")) is None
224
224
  assert unica(httpx_mock).method == "DELETE"
225
+
226
+
227
+ def test_archivar_y_desarchivar_van_a_dos_rutas(cliente: ClienteDePrueba, httpx_mock: HTTPXMock) -> None:
228
+ """Son DOS rutas y no un cuerpo con un booleano: lo que hay que fijar es que cada metodo vaya a
229
+ la suya, y que el estado del plan no cambie al archivarlo.
230
+ """
231
+ archivado = {**PLAN, "state": "validated", "archived_date": "2026-08-31T09:00:00.000Z"}
232
+ httpx_mock.add_response(url=f"{PLANES}/plan1/archive", method="POST", json={"ai_plan": archivado})
233
+ httpx_mock.add_response(
234
+ url=f"{PLANES}/plan1/unarchive", method="POST", json={"ai_plan": {**PLAN, "state": "validated"}}
235
+ )
236
+
237
+ guardado = cliente.esperar(cliente.pv.ai_plans.archive("cli1", "org1", "plan1"))
238
+ devuelto = cliente.esperar(cliente.pv.ai_plans.unarchive("cli1", "org1", "plan1"))
239
+
240
+ assert guardado["archived_date"] == "2026-08-31T09:00:00.000Z"
241
+ # Ausente, no `None`: es como llega cualquier plan activo, incluidos los anteriores al campo
242
+ assert "archived_date" not in devuelto
243
+ # Archivar no toca el ciclo de vida: un plan validado archivado sigue siendo `validated`
244
+ assert guardado["state"] == "validated"
245
+ archivar, desarchivar = peticiones(httpx_mock)
246
+ assert ruta(archivar) == "/clients/cli1/organizations/org1/ai_plans/plan1/archive"
247
+ assert ruta(desarchivar) == "/clients/cli1/organizations/org1/ai_plans/plan1/unarchive"
248
+ # POST vacios, como validar y reintentar
249
+ assert cuerpo(archivar) is None
250
+ assert cuerpo(desarchivar) is None
251
+
252
+
253
+ def test_el_listado_pide_el_otro_armario_solo_cuando_se_le_pide(
254
+ cliente: ClienteDePrueba, httpx_mock: HTTPXMock
255
+ ) -> None:
256
+ httpx_mock.add_response(url=f"{PLANES}?archived=true", json={"ai_plans": [], "total": 0})
257
+ httpx_mock.add_response(url=PLANES, json={"ai_plans": [PLAN], "total": 1}, is_reusable=True)
258
+
259
+ cliente.esperar(cliente.pv.ai_plans.list("cli1", "org1", archived=True))
260
+ cliente.esperar(cliente.pv.ai_plans.list("cli1", "org1", archived=False))
261
+ cliente.esperar(cliente.pv.ai_plans.list("cli1", "org1"))
262
+
263
+ archivados, negado, sin_decir = peticiones(httpx_mock)
264
+ assert query(archivados) == {"archived": ["true"]}
265
+ # `False` no viaja: el servidor enciende el filtro con el literal "true", asi que mandarlo seria
266
+ # ruido, y ademas invitaria a creer que hay un tercer modo con los dos armarios a la vez
267
+ assert "archived" not in query(negado)
268
+ assert "archived" not in query(sin_decir)
@@ -45,10 +45,14 @@ RECURSOS = (
45
45
 
46
46
 
47
47
  def test_no_queda_ninguna_ruta_del_alcance_sin_metodo() -> None:
48
- """113 de 113. Si esto falla, el mensaje dice exactamente cual falta."""
48
+ """115 de 115. Si esto falla, el mensaje dice exactamente cual falta.
49
+
50
+ Las dos ultimas son `archive` y `unarchive` de un plan de IA: son DOS rutas y no un cuerpo con
51
+ un booleano, asi que el alcance crece de dos en dos cuando aparece una pareja de estas.
52
+ """
49
53
  sin_cubrir, _, total = route_coverage.informe()
50
54
 
51
- assert total == 113, "el alcance ha cambiado de tamano: mira si el spec ha crecido"
55
+ assert total == 115, "el alcance ha cambiado de tamano: mira si el spec ha crecido"
52
56
  assert not sin_cubrir, "rutas del spec que ningun recurso construye:\n" + "\n".join(
53
57
  str(operacion) for operacion in sin_cubrir
54
58
  )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes