vulnotes 0.1.0__py3-none-any.whl
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.
- vulnotes/__init__.py +42 -0
- vulnotes/_utils.py +65 -0
- vulnotes/_version.py +1 -0
- vulnotes/client.py +228 -0
- vulnotes/exceptions.py +122 -0
- vulnotes/resources/__init__.py +31 -0
- vulnotes/resources/_base.py +67 -0
- vulnotes/resources/ai.py +110 -0
- vulnotes/resources/api_keys.py +17 -0
- vulnotes/resources/attachments.py +75 -0
- vulnotes/resources/comments.py +98 -0
- vulnotes/resources/companies.py +77 -0
- vulnotes/resources/findings.py +62 -0
- vulnotes/resources/images.py +85 -0
- vulnotes/resources/notes.py +71 -0
- vulnotes/resources/planning.py +210 -0
- vulnotes/resources/report_templates.py +52 -0
- vulnotes/resources/reports.py +293 -0
- vulnotes/resources/snapshots.py +35 -0
- vulnotes/resources/vulnerabilities.py +82 -0
- vulnotes/resources/vulnerability_templates.py +132 -0
- vulnotes-0.1.0.dist-info/METADATA +166 -0
- vulnotes-0.1.0.dist-info/RECORD +25 -0
- vulnotes-0.1.0.dist-info/WHEEL +4 -0
- vulnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""File attachments. Permissions: ``ro:reports`` / ``rw:reports``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .._utils import FileTypes, omit_none, prepare_file
|
|
9
|
+
from ._base import JSON, Resource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Attachments(Resource):
|
|
13
|
+
def list(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
template_id: str | None = None,
|
|
17
|
+
company_id: str | None = None,
|
|
18
|
+
report_id: str | None = None,
|
|
19
|
+
vulnerability_id: str | None = None,
|
|
20
|
+
note_id: str | None = None,
|
|
21
|
+
) -> builtins.list[dict[str, Any]]:
|
|
22
|
+
"""List attachments, optionally filtered by associated entity."""
|
|
23
|
+
params = omit_none(
|
|
24
|
+
{
|
|
25
|
+
"templateId": template_id,
|
|
26
|
+
"companyId": company_id,
|
|
27
|
+
"reportId": report_id,
|
|
28
|
+
"vulnerabilityId": vulnerability_id,
|
|
29
|
+
"noteId": note_id,
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
return self._client.get("/attachments", params=params)
|
|
33
|
+
|
|
34
|
+
def upload(
|
|
35
|
+
self,
|
|
36
|
+
file: FileTypes,
|
|
37
|
+
*,
|
|
38
|
+
template_id: str | None = None,
|
|
39
|
+
company_id: str | None = None,
|
|
40
|
+
report_id: str | None = None,
|
|
41
|
+
vulnerability_id: str | None = None,
|
|
42
|
+
note_id: str | None = None,
|
|
43
|
+
user_id: str | None = None,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Upload a file attachment. Requires ``rw:reports``.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
file: A filesystem path, raw bytes, an open binary file object,
|
|
49
|
+
or a ``(filename, fileobj_or_bytes)`` tuple.
|
|
50
|
+
"""
|
|
51
|
+
data = omit_none(
|
|
52
|
+
{
|
|
53
|
+
"templateId": template_id,
|
|
54
|
+
"companyId": company_id,
|
|
55
|
+
"reportId": report_id,
|
|
56
|
+
"vulnerabilityId": vulnerability_id,
|
|
57
|
+
"noteId": note_id,
|
|
58
|
+
"userId": user_id,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
prepared = prepare_file(file, default_name="attachment.bin")
|
|
62
|
+
try:
|
|
63
|
+
return self._client.post(
|
|
64
|
+
"/attachments/upload", data=data, files={"file": prepared}
|
|
65
|
+
)
|
|
66
|
+
finally:
|
|
67
|
+
fh = prepared[1]
|
|
68
|
+
if hasattr(fh, "close"):
|
|
69
|
+
fh.close()
|
|
70
|
+
|
|
71
|
+
def list_for_report(self, report_id: str) -> builtins.list[dict[str, Any]]:
|
|
72
|
+
return self._client.get(f"/attachments/report/{report_id}")
|
|
73
|
+
|
|
74
|
+
def delete(self, attachment_id: str) -> JSON:
|
|
75
|
+
return self._client.delete(f"/attachments/{attachment_id}")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Review comment threads on report snapshots.
|
|
2
|
+
|
|
3
|
+
Permissions: ``ro:reports`` / ``rw:reports``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import builtins
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .._utils import omit_none
|
|
12
|
+
from ._base import JSON, Resource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Comments(Resource):
|
|
16
|
+
def list(
|
|
17
|
+
self,
|
|
18
|
+
report_id: str,
|
|
19
|
+
snapshot_id: str,
|
|
20
|
+
*,
|
|
21
|
+
resolved: bool | None = None,
|
|
22
|
+
type: str | None = None,
|
|
23
|
+
ref: str | None = None,
|
|
24
|
+
) -> builtins.list[dict[str, Any]]:
|
|
25
|
+
"""List comments on a snapshot, optionally filtered by resolved state,
|
|
26
|
+
attachment type, or attachment ref.
|
|
27
|
+
|
|
28
|
+
Returns the comment list; use :meth:`counts` for the aggregate counts.
|
|
29
|
+
"""
|
|
30
|
+
params = omit_none(
|
|
31
|
+
{
|
|
32
|
+
"resolved": str(resolved).lower() if resolved is not None else None,
|
|
33
|
+
"type": type,
|
|
34
|
+
"ref": ref,
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
resp = self._client.get(
|
|
38
|
+
f"/reports/{report_id}/snapshots/{snapshot_id}/comments", params=params
|
|
39
|
+
)
|
|
40
|
+
if isinstance(resp, dict):
|
|
41
|
+
return resp.get("comments", [])
|
|
42
|
+
return resp
|
|
43
|
+
|
|
44
|
+
def create(
|
|
45
|
+
self,
|
|
46
|
+
report_id: str,
|
|
47
|
+
snapshot_id: str,
|
|
48
|
+
content: str,
|
|
49
|
+
*,
|
|
50
|
+
attachment_type: str | None = None,
|
|
51
|
+
attachment_ref: str | None = None,
|
|
52
|
+
finding_field: str | None = None,
|
|
53
|
+
parent_comment: str | None = None,
|
|
54
|
+
page_number: int | None = None,
|
|
55
|
+
bounding_box: dict[str, Any] | None = None,
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""Create a review comment (or a reply, via ``parent_comment``)."""
|
|
58
|
+
body = omit_none(
|
|
59
|
+
{
|
|
60
|
+
"content": content,
|
|
61
|
+
"attachmentType": attachment_type,
|
|
62
|
+
"attachmentRef": attachment_ref,
|
|
63
|
+
"findingField": finding_field,
|
|
64
|
+
"parentComment": parent_comment,
|
|
65
|
+
"pageNumber": page_number,
|
|
66
|
+
"boundingBox": bounding_box,
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
return self._client.post(
|
|
70
|
+
f"/reports/{report_id}/snapshots/{snapshot_id}/comments", json=body
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def counts(self, report_id: str, snapshot_id: str) -> dict[str, Any]:
|
|
74
|
+
"""Get comment counts for a snapshot
|
|
75
|
+
(``total`` / ``unresolved`` / ``resolved`` / ``byType``)."""
|
|
76
|
+
return self._client.get(
|
|
77
|
+
f"/reports/{report_id}/snapshots/{snapshot_id}/comments/counts"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def annotations(self, report_id: str, snapshot_id: str) -> JSON:
|
|
81
|
+
"""Get PDF annotations for a snapshot, as ``{"annotations": [...],
|
|
82
|
+
"byPage": {...}}``."""
|
|
83
|
+
return self._client.get(
|
|
84
|
+
f"/reports/{report_id}/snapshots/{snapshot_id}/annotations"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def update(self, report_id: str, comment_id: str, content: str) -> dict[str, Any]:
|
|
88
|
+
"""Edit a comment's content."""
|
|
89
|
+
return self._client.put(
|
|
90
|
+
f"/reports/{report_id}/comments/{comment_id}", json={"content": content}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def delete(self, report_id: str, comment_id: str) -> JSON:
|
|
94
|
+
return self._client.delete(f"/reports/{report_id}/comments/{comment_id}")
|
|
95
|
+
|
|
96
|
+
def toggle_resolved(self, report_id: str, comment_id: str) -> dict[str, Any]:
|
|
97
|
+
"""Toggle a comment's resolved status."""
|
|
98
|
+
return self._client.patch(f"/reports/{report_id}/comments/{comment_id}/resolve")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Client companies. Permissions: ``ro:clients`` / ``rw:clients``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .._utils import omit_none
|
|
10
|
+
from ._base import JSON, Resource, page_params, paginate
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Companies(Resource):
|
|
14
|
+
def list(self, *, page: int | None = None, limit: int | None = None) -> JSON:
|
|
15
|
+
"""List companies, sorted by creation date descending.
|
|
16
|
+
|
|
17
|
+
Returns a plain list when called without arguments, or a paginated
|
|
18
|
+
envelope (``data`` plus a ``pagination`` object with ``page``,
|
|
19
|
+
``limit``, ``total``, ``totalPages``, ``hasNextPage``) when ``page``
|
|
20
|
+
or ``limit`` is given.
|
|
21
|
+
"""
|
|
22
|
+
return self._client.get("/companies", params=page_params(page, limit))
|
|
23
|
+
|
|
24
|
+
def iter(self, *, limit: int = 100) -> Iterator[dict[str, Any]]:
|
|
25
|
+
"""Iterate over every company, fetching pages lazily."""
|
|
26
|
+
return paginate(lambda page, limit: self.list(page=page, limit=limit), limit=limit)
|
|
27
|
+
|
|
28
|
+
def search(self, query: str) -> builtins.list[dict[str, Any]]:
|
|
29
|
+
"""Search companies by name (accent-insensitive)."""
|
|
30
|
+
return self._client.get("/companies/search", params={"query": query})
|
|
31
|
+
|
|
32
|
+
def get(self, company_id: str) -> dict[str, Any]:
|
|
33
|
+
return self._client.get(f"/companies/{company_id}")
|
|
34
|
+
|
|
35
|
+
def create(
|
|
36
|
+
self,
|
|
37
|
+
name: str,
|
|
38
|
+
*,
|
|
39
|
+
logo: str | None = None,
|
|
40
|
+
contacts: builtins.list[dict[str, Any]] | None = None,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""Create a company. Requires ``rw:clients``."""
|
|
43
|
+
body = omit_none({"name": name, "logo": logo, "contacts": contacts})
|
|
44
|
+
return self._client.post("/companies", json=body)
|
|
45
|
+
|
|
46
|
+
def update(
|
|
47
|
+
self,
|
|
48
|
+
company_id: str,
|
|
49
|
+
*,
|
|
50
|
+
name: str | None = None,
|
|
51
|
+
logo: str | None = None,
|
|
52
|
+
contacts: builtins.list[dict[str, Any]] | None = None,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
"""Update a company. Only the supplied fields are changed."""
|
|
55
|
+
body = omit_none({"name": name, "logo": logo, "contacts": contacts})
|
|
56
|
+
return self._client.put(f"/companies/{company_id}", json=body)
|
|
57
|
+
|
|
58
|
+
def delete(self, company_id: str) -> JSON:
|
|
59
|
+
return self._client.delete(f"/companies/{company_id}")
|
|
60
|
+
|
|
61
|
+
def portal_access(self, company_id: str) -> dict[str, Any]:
|
|
62
|
+
"""Get the company's client-portal access configuration and users."""
|
|
63
|
+
return self._client.get(f"/companies/{company_id}/portal-access")
|
|
64
|
+
|
|
65
|
+
def update_client_user(
|
|
66
|
+
self,
|
|
67
|
+
company_id: str,
|
|
68
|
+
user_id: str,
|
|
69
|
+
*,
|
|
70
|
+
client_role: str | None = None,
|
|
71
|
+
active: bool | None = None,
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""Update a company's client-portal user (role and/or active status)."""
|
|
74
|
+
body = omit_none({"clientRole": client_role, "active": active})
|
|
75
|
+
return self._client.patch(
|
|
76
|
+
f"/companies/{company_id}/client-users/{user_id}", json=body
|
|
77
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Findings embedded in a report. Permissions: ``ro:reports`` / ``rw:reports``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ._base import JSON, Resource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Findings(Resource):
|
|
12
|
+
def list(self, report_id: str) -> builtins.list[dict[str, Any]]:
|
|
13
|
+
"""Get all findings for a report."""
|
|
14
|
+
return self._client.get(f"/reports/{report_id}/findings")
|
|
15
|
+
|
|
16
|
+
def add(self, report_id: str, finding: dict[str, Any]) -> dict[str, Any]:
|
|
17
|
+
"""Append a finding to a report. Requires ``rw:reports``.
|
|
18
|
+
|
|
19
|
+
A unique ``id`` is generated automatically. If the report has a
|
|
20
|
+
vulnerability template and ``fields`` is not provided, the field
|
|
21
|
+
definitions are copied from it, and multilingual ``data`` entries are
|
|
22
|
+
initialized for all supported languages.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> client.findings.add(report_id, {
|
|
26
|
+
... "title": "SQL Injection in /login",
|
|
27
|
+
... "severity": "Critical",
|
|
28
|
+
... "cvss": {"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"},
|
|
29
|
+
... "data": {"EN": {"title": "SQL Injection in /login",
|
|
30
|
+
... "description": "..."}},
|
|
31
|
+
... })
|
|
32
|
+
"""
|
|
33
|
+
return self._client.post(f"/reports/{report_id}/findings", json=finding)
|
|
34
|
+
|
|
35
|
+
def replace_all(
|
|
36
|
+
self, report_id: str, findings: builtins.list[dict[str, Any]]
|
|
37
|
+
) -> builtins.list[dict[str, Any]]:
|
|
38
|
+
"""Replace the report's entire findings array (also used for reordering)."""
|
|
39
|
+
return self._client.put(f"/reports/{report_id}/findings", json=findings)
|
|
40
|
+
|
|
41
|
+
def update(
|
|
42
|
+
self, report_id: str, finding_id: str, changes: dict[str, Any]
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
"""Partially update a finding.
|
|
45
|
+
|
|
46
|
+
Accepted fields: ``title``, ``severity``, ``status``, ``description``,
|
|
47
|
+
``impact``, ``remediation``, ``references``, ``cvss``, ``cwe``,
|
|
48
|
+
``affectedSystems``, ``evidence``, ``data``, ``order``, ``fields``,
|
|
49
|
+
``isComplete``. Other fields are ignored.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
report_id: The report's ObjectId.
|
|
53
|
+
finding_id: The finding's ``id`` field (a generated string, not an ObjectId).
|
|
54
|
+
changes: The fields to change.
|
|
55
|
+
"""
|
|
56
|
+
return self._client.put(
|
|
57
|
+
f"/reports/{report_id}/findings/{finding_id}", json=changes
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def delete(self, report_id: str, finding_id: str) -> JSON:
|
|
61
|
+
"""Remove a finding from the report."""
|
|
62
|
+
return self._client.delete(f"/reports/{report_id}/findings/{finding_id}")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Image upload and management. Permissions: ``ro:reports`` / ``rw:reports``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .._utils import FileTypes, omit_none, prepare_file
|
|
9
|
+
from ._base import JSON, Resource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Images(Resource):
|
|
13
|
+
def list(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
template_id: str | None = None,
|
|
17
|
+
client_id: str | None = None,
|
|
18
|
+
report_id: str | None = None,
|
|
19
|
+
) -> builtins.list[dict[str, Any]]:
|
|
20
|
+
"""List images, optionally filtered by template, client, or report."""
|
|
21
|
+
params = omit_none(
|
|
22
|
+
{"templateId": template_id, "clientId": client_id, "reportId": report_id}
|
|
23
|
+
)
|
|
24
|
+
return self._client.get("/images", params=params)
|
|
25
|
+
|
|
26
|
+
def upload(
|
|
27
|
+
self,
|
|
28
|
+
image: FileTypes,
|
|
29
|
+
*,
|
|
30
|
+
template_id: str | None = None,
|
|
31
|
+
client_id: str | None = None,
|
|
32
|
+
report_id: str | None = None,
|
|
33
|
+
note_id: str | None = None,
|
|
34
|
+
vulnerability_id: str | None = None,
|
|
35
|
+
company_id: str | None = None,
|
|
36
|
+
user_id: str | None = None,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
"""Upload an image and associate it with an entity. Requires ``rw:reports``.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
image: A filesystem path, raw bytes, an open binary file object,
|
|
42
|
+
or a ``(filename, fileobj_or_bytes)`` tuple.
|
|
43
|
+
template_id / client_id / report_id / note_id / vulnerability_id /
|
|
44
|
+
company_id / user_id: Entity to associate the image with.
|
|
45
|
+
"""
|
|
46
|
+
data = omit_none(
|
|
47
|
+
{
|
|
48
|
+
"templateId": template_id,
|
|
49
|
+
"clientId": client_id,
|
|
50
|
+
"reportId": report_id,
|
|
51
|
+
"noteId": note_id,
|
|
52
|
+
"vulnerabilityId": vulnerability_id,
|
|
53
|
+
"companyId": company_id,
|
|
54
|
+
"userId": user_id,
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
prepared = prepare_file(image, default_name="image.png")
|
|
58
|
+
try:
|
|
59
|
+
return self._client.post(
|
|
60
|
+
"/images/upload", data=data, files={"image": prepared}
|
|
61
|
+
)
|
|
62
|
+
finally:
|
|
63
|
+
fh = prepared[1]
|
|
64
|
+
if hasattr(fh, "close"):
|
|
65
|
+
fh.close()
|
|
66
|
+
|
|
67
|
+
def list_for_note(self, note_id: str) -> builtins.list[dict[str, Any]]:
|
|
68
|
+
return self._client.get(f"/images/note/{note_id}")
|
|
69
|
+
|
|
70
|
+
def list_for_template(self, template_id: str) -> builtins.list[dict[str, Any]]:
|
|
71
|
+
return self._client.get(f"/images/template/{template_id}")
|
|
72
|
+
|
|
73
|
+
def list_for_user(self, user_id: str) -> builtins.list[dict[str, Any]]:
|
|
74
|
+
return self._client.get(f"/images/user/{user_id}")
|
|
75
|
+
|
|
76
|
+
def associate_with_template(
|
|
77
|
+
self, template_id: str, image_ids: builtins.list[str]
|
|
78
|
+
) -> JSON:
|
|
79
|
+
"""Associate previously uploaded images with a template."""
|
|
80
|
+
return self._client.post(
|
|
81
|
+
f"/images/template/{template_id}/associate", json={"imageIds": image_ids}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def delete(self, image_id: str) -> JSON:
|
|
85
|
+
return self._client.delete(f"/images/{image_id}")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Notes attached to reports. Permissions: ``ro:reports`` / ``rw:reports``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .._utils import omit_none
|
|
8
|
+
from ._base import JSON, Resource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Notes(Resource):
|
|
12
|
+
def list_for_report(self, report_id: str) -> list[dict[str, Any]]:
|
|
13
|
+
"""Get all notes attached to a report."""
|
|
14
|
+
return self._client.get(f"/notes/report/{report_id}")
|
|
15
|
+
|
|
16
|
+
def create(
|
|
17
|
+
self,
|
|
18
|
+
report_id: str,
|
|
19
|
+
title: str,
|
|
20
|
+
*,
|
|
21
|
+
content: str | None = None,
|
|
22
|
+
is_pinned: bool | None = None,
|
|
23
|
+
tags: list[str] | None = None,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
"""Create a note on a report. Requires ``rw:reports``."""
|
|
26
|
+
body = omit_none(
|
|
27
|
+
{"title": title, "content": content, "isPinned": is_pinned, "tags": tags}
|
|
28
|
+
)
|
|
29
|
+
return self._client.post(f"/notes/report/{report_id}", json=body)
|
|
30
|
+
|
|
31
|
+
def get(self, note_id: str) -> dict[str, Any]:
|
|
32
|
+
return self._client.get(f"/notes/{note_id}")
|
|
33
|
+
|
|
34
|
+
def update(
|
|
35
|
+
self,
|
|
36
|
+
note_id: str,
|
|
37
|
+
*,
|
|
38
|
+
title: str | None = None,
|
|
39
|
+
content: str | None = None,
|
|
40
|
+
is_pinned: bool | None = None,
|
|
41
|
+
tags: list[str] | None = None,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
"""Update a note. Only the supplied fields are changed."""
|
|
44
|
+
body = omit_none(
|
|
45
|
+
{"title": title, "content": content, "isPinned": is_pinned, "tags": tags}
|
|
46
|
+
)
|
|
47
|
+
return self._client.put(f"/notes/{note_id}", json=body)
|
|
48
|
+
|
|
49
|
+
def delete(self, note_id: str) -> JSON:
|
|
50
|
+
return self._client.delete(f"/notes/{note_id}")
|
|
51
|
+
|
|
52
|
+
def save(
|
|
53
|
+
self,
|
|
54
|
+
note_id: str,
|
|
55
|
+
*,
|
|
56
|
+
content: str | None = None,
|
|
57
|
+
title: str | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Autosave-style update of a note's content and title.
|
|
60
|
+
|
|
61
|
+
Warning: this endpoint always rewrites the title. When ``title`` is
|
|
62
|
+
omitted it is re-extracted from the first ``# heading`` line of the
|
|
63
|
+
content, falling back to ``"Untitled Note"``. Pass ``title``
|
|
64
|
+
explicitly (or use :meth:`update`) to keep the existing one.
|
|
65
|
+
"""
|
|
66
|
+
body = omit_none({"content": content, "title": title})
|
|
67
|
+
return self._client.patch(f"/notes/{note_id}/save", json=body)
|
|
68
|
+
|
|
69
|
+
def toggle_pin(self, note_id: str) -> dict[str, Any]:
|
|
70
|
+
"""Toggle the note's pinned status."""
|
|
71
|
+
return self._client.patch(f"/notes/{note_id}/toggle-pin")
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""The resource-planning calendar: engagements and availability.
|
|
2
|
+
|
|
3
|
+
Permissions: ``ro:planning`` to read, ``rw:planning`` to write,
|
|
4
|
+
``manage:planning`` to delete events or assign users outside your own team.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as _dt
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from typing import Any, Union
|
|
12
|
+
|
|
13
|
+
from .._utils import iso, omit_none
|
|
14
|
+
from ._base import JSON, Resource, page_params, paginate
|
|
15
|
+
|
|
16
|
+
DateLike = Union[str, _dt.date, _dt.datetime]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Planning(Resource):
|
|
20
|
+
# ── calendar ─────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def calendar(self, start: DateLike, end: DateLike) -> dict[str, Any]:
|
|
23
|
+
"""Get the combined planning calendar (events + availability) for a date range."""
|
|
24
|
+
return self._client.get(
|
|
25
|
+
"/planning/calendar", params={"start": iso(start), "end": iso(end)}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def users(self, *, skills: str | None = None) -> list[dict[str, Any]]:
|
|
29
|
+
"""List calendar users, optionally filtered by skills."""
|
|
30
|
+
return self._client.get("/planning/users", params=omit_none({"skills": skills}))
|
|
31
|
+
|
|
32
|
+
# ── events ───────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def list_events(
|
|
35
|
+
self, *, page: int | None = None, limit: int | None = None
|
|
36
|
+
) -> JSON:
|
|
37
|
+
"""List planning events (plain list, or paginated envelope with ``page``/``limit``)."""
|
|
38
|
+
return self._client.get("/planning/events", params=page_params(page, limit))
|
|
39
|
+
|
|
40
|
+
def iter_events(self, *, limit: int = 100) -> Iterator[dict[str, Any]]:
|
|
41
|
+
"""Iterate over every visible planning event, fetching pages lazily."""
|
|
42
|
+
return paginate(
|
|
43
|
+
lambda page, limit: self.list_events(page=page, limit=limit), limit=limit
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def events_in_range(self, start: DateLike, end: DateLike) -> list[dict[str, Any]]:
|
|
47
|
+
"""List planning events overlapping a date range."""
|
|
48
|
+
return self._client.get(
|
|
49
|
+
"/planning/events/range", params={"start": iso(start), "end": iso(end)}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def check_conflicts(
|
|
53
|
+
self,
|
|
54
|
+
user_id: str,
|
|
55
|
+
start: DateLike,
|
|
56
|
+
end: DateLike,
|
|
57
|
+
*,
|
|
58
|
+
exclude_event_id: str | None = None,
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
"""Check whether a user is already booked during a date range."""
|
|
61
|
+
params = omit_none(
|
|
62
|
+
{"start": iso(start), "end": iso(end), "excludeEventId": exclude_event_id}
|
|
63
|
+
)
|
|
64
|
+
return self._client.get(f"/planning/events/conflicts/{user_id}", params=params)
|
|
65
|
+
|
|
66
|
+
def get_event(self, event_id: str) -> dict[str, Any]:
|
|
67
|
+
return self._client.get(f"/planning/events/{event_id}")
|
|
68
|
+
|
|
69
|
+
def create_event(
|
|
70
|
+
self,
|
|
71
|
+
title: str,
|
|
72
|
+
start_date: DateLike,
|
|
73
|
+
end_date: DateLike,
|
|
74
|
+
assignees: list[str],
|
|
75
|
+
event_type: str,
|
|
76
|
+
*,
|
|
77
|
+
description: str | None = None,
|
|
78
|
+
client: str | None = None,
|
|
79
|
+
status: str | None = None,
|
|
80
|
+
color: str | None = None,
|
|
81
|
+
notes: str | None = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
"""Book an engagement on the calendar. Requires ``rw:planning``.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
title: Event title (max 200 chars).
|
|
87
|
+
start_date / end_date: ``end_date`` must be strictly after ``start_date``.
|
|
88
|
+
assignees: User ObjectIds (at least one). Non-admin callers may only
|
|
89
|
+
assign members of their own team.
|
|
90
|
+
event_type: One of ``pentest``, ``training``, ``conference``,
|
|
91
|
+
``research``, ``other``.
|
|
92
|
+
client: Company ObjectId.
|
|
93
|
+
status: One of ``planned``, ``requirements``, ``in-progress``,
|
|
94
|
+
``completed``, ``cancelled``.
|
|
95
|
+
color: Hex color, e.g. ``"#3B82F6"``.
|
|
96
|
+
"""
|
|
97
|
+
body = omit_none(
|
|
98
|
+
{
|
|
99
|
+
"title": title,
|
|
100
|
+
"startDate": iso(start_date),
|
|
101
|
+
"endDate": iso(end_date),
|
|
102
|
+
"assignees": assignees,
|
|
103
|
+
"eventType": event_type,
|
|
104
|
+
"description": description,
|
|
105
|
+
"client": client,
|
|
106
|
+
"status": status,
|
|
107
|
+
"color": color,
|
|
108
|
+
"notes": notes,
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
return self._client.post("/planning/events", json=body)
|
|
112
|
+
|
|
113
|
+
def update_event(
|
|
114
|
+
self,
|
|
115
|
+
event_id: str,
|
|
116
|
+
*,
|
|
117
|
+
title: str | None = None,
|
|
118
|
+
start_date: DateLike | None = None,
|
|
119
|
+
end_date: DateLike | None = None,
|
|
120
|
+
assignees: list[str] | None = None,
|
|
121
|
+
event_type: str | None = None,
|
|
122
|
+
description: str | None = None,
|
|
123
|
+
client: str | None = None,
|
|
124
|
+
status: str | None = None,
|
|
125
|
+
color: str | None = None,
|
|
126
|
+
notes: str | None = None,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
"""Update a planning event. Only the supplied fields are changed."""
|
|
129
|
+
body = omit_none(
|
|
130
|
+
{
|
|
131
|
+
"title": title,
|
|
132
|
+
"startDate": iso(start_date),
|
|
133
|
+
"endDate": iso(end_date),
|
|
134
|
+
"assignees": assignees,
|
|
135
|
+
"eventType": event_type,
|
|
136
|
+
"description": description,
|
|
137
|
+
"client": client,
|
|
138
|
+
"status": status,
|
|
139
|
+
"color": color,
|
|
140
|
+
"notes": notes,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
return self._client.put(f"/planning/events/{event_id}", json=body)
|
|
144
|
+
|
|
145
|
+
def delete_event(self, event_id: str) -> JSON:
|
|
146
|
+
"""Delete a planning event. Requires ``manage:planning``."""
|
|
147
|
+
return self._client.delete(f"/planning/events/{event_id}")
|
|
148
|
+
|
|
149
|
+
# ── availability ─────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def list_availability(
|
|
152
|
+
self, *, page: int | None = None, limit: int | None = None
|
|
153
|
+
) -> JSON:
|
|
154
|
+
"""List availability entries (plain list, or paginated envelope)."""
|
|
155
|
+
return self._client.get("/planning/availability", params=page_params(page, limit))
|
|
156
|
+
|
|
157
|
+
def availability_in_range(
|
|
158
|
+
self, start: DateLike, end: DateLike
|
|
159
|
+
) -> list[dict[str, Any]]:
|
|
160
|
+
"""List availability entries overlapping a date range."""
|
|
161
|
+
return self._client.get(
|
|
162
|
+
"/planning/availability/range", params={"start": iso(start), "end": iso(end)}
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def create_availability(
|
|
166
|
+
self,
|
|
167
|
+
type: str,
|
|
168
|
+
start_date: DateLike,
|
|
169
|
+
end_date: DateLike,
|
|
170
|
+
*,
|
|
171
|
+
description: str | None = None,
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
"""Mark availability for the API key's owner. Requires ``rw:planning``.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
type: One of ``vacation``, ``conference``, ``sick``, ``training``, ``other``.
|
|
177
|
+
start_date / end_date: ``end_date`` must be strictly after ``start_date``.
|
|
178
|
+
"""
|
|
179
|
+
body = omit_none(
|
|
180
|
+
{
|
|
181
|
+
"type": type,
|
|
182
|
+
"startDate": iso(start_date),
|
|
183
|
+
"endDate": iso(end_date),
|
|
184
|
+
"description": description,
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
return self._client.post("/planning/availability", json=body)
|
|
188
|
+
|
|
189
|
+
def update_availability(
|
|
190
|
+
self,
|
|
191
|
+
availability_id: str,
|
|
192
|
+
*,
|
|
193
|
+
type: str | None = None,
|
|
194
|
+
start_date: DateLike | None = None,
|
|
195
|
+
end_date: DateLike | None = None,
|
|
196
|
+
description: str | None = None,
|
|
197
|
+
) -> dict[str, Any]:
|
|
198
|
+
"""Update an availability entry. Only the supplied fields are changed."""
|
|
199
|
+
body = omit_none(
|
|
200
|
+
{
|
|
201
|
+
"type": type,
|
|
202
|
+
"startDate": iso(start_date),
|
|
203
|
+
"endDate": iso(end_date),
|
|
204
|
+
"description": description,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
return self._client.put(f"/planning/availability/{availability_id}", json=body)
|
|
208
|
+
|
|
209
|
+
def delete_availability(self, availability_id: str) -> JSON:
|
|
210
|
+
return self._client.delete(f"/planning/availability/{availability_id}")
|