agent-tool-openproject-cli 0.2.1__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.
- agent_tool_openproject_cli-0.2.1.dist-info/METADATA +457 -0
- agent_tool_openproject_cli-0.2.1.dist-info/RECORD +40 -0
- agent_tool_openproject_cli-0.2.1.dist-info/WHEEL +5 -0
- agent_tool_openproject_cli-0.2.1.dist-info/entry_points.txt +2 -0
- agent_tool_openproject_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
- agent_tool_openproject_cli-0.2.1.dist-info/top_level.txt +1 -0
- opcli/__init__.py +3 -0
- opcli/__main__.py +6 -0
- opcli/cli.py +166 -0
- opcli/client.py +252 -0
- opcli/commands/__init__.py +1 -0
- opcli/commands/_shared.py +50 -0
- opcli/commands/attachments.py +104 -0
- opcli/commands/auth.py +113 -0
- opcli/commands/comments.py +90 -0
- opcli/commands/costs.py +205 -0
- opcli/commands/custom_fields.py +82 -0
- opcli/commands/filelinks.py +100 -0
- opcli/commands/guide.py +198 -0
- opcli/commands/memberships.py +110 -0
- opcli/commands/notifications.py +130 -0
- opcli/commands/projects.py +149 -0
- opcli/commands/raw.py +83 -0
- opcli/commands/search.py +256 -0
- opcli/commands/settings.py +59 -0
- opcli/commands/time_entries.py +214 -0
- opcli/commands/users.py +109 -0
- opcli/commands/wiki.py +49 -0
- opcli/commands/workpackages.py +338 -0
- opcli/config.py +134 -0
- opcli/context.py +117 -0
- opcli/credentials.py +139 -0
- opcli/duration.py +56 -0
- opcli/errors.py +81 -0
- opcli/hal.py +75 -0
- opcli/output.py +225 -0
- opcli/resolve.py +183 -0
- opcli/searchspec.py +269 -0
- opcli/serialize.py +225 -0
- opcli/wpfilters.py +159 -0
opcli/commands/search.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Powerful, discoverable work-package search.
|
|
2
|
+
|
|
3
|
+
Three ways to search, easiest first:
|
|
4
|
+
|
|
5
|
+
1. **Predefined flags** — ``--mine``, ``--overdue``, ``--status open``,
|
|
6
|
+
``--updated-since 7d``, ``--version v1`` … no JSON needed.
|
|
7
|
+
2. **Presets** — ``search mine``, ``search overdue``, ``search recent`` …
|
|
8
|
+
3. **``--where``** — compact expressions: ``--where "status = open"``,
|
|
9
|
+
``--where "assignee:none"``, ``--where "updated > 7d"``.
|
|
10
|
+
|
|
11
|
+
And to learn what's available:
|
|
12
|
+
|
|
13
|
+
* ``search fields`` — what you can filter on
|
|
14
|
+
* ``search operators`` — what the operator codes mean
|
|
15
|
+
* ``search values X`` — allowed values for a field
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
|
|
24
|
+
from .. import resolve, searchspec, serialize, wpfilters
|
|
25
|
+
from ._shared import ctx_obj, parse_json_option
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(no_args_is_help=True)
|
|
28
|
+
|
|
29
|
+
_COLUMNS = [
|
|
30
|
+
("ID", "id"),
|
|
31
|
+
("Type", "type"),
|
|
32
|
+
("Subject", "subject"),
|
|
33
|
+
("Status", "status"),
|
|
34
|
+
("Assignee", lambda r: (r.get("assignee") or {}).get("name")),
|
|
35
|
+
("Due", "dueDate"),
|
|
36
|
+
("Updated", "updatedAt"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _render(obj, client, filters, *, sort, asc, group_by, limit, count, raw):
|
|
41
|
+
params: dict = {"filters": json.dumps(filters), "sortBy": json.dumps([[sort, "asc" if asc else "desc"]])}
|
|
42
|
+
if group_by:
|
|
43
|
+
params["groupBy"] = group_by
|
|
44
|
+
if count:
|
|
45
|
+
params["pageSize"] = 1
|
|
46
|
+
doc = client.get("work_packages", params=params)
|
|
47
|
+
obj.emitter.emit({"total": doc.get("total", 0), "filters": filters})
|
|
48
|
+
return
|
|
49
|
+
elements = client.collect("work_packages", params=params, limit=limit or None)
|
|
50
|
+
if raw:
|
|
51
|
+
obj.emitter.emit(elements)
|
|
52
|
+
return
|
|
53
|
+
rows = [serialize.work_package(w, include_description=False) for w in elements]
|
|
54
|
+
obj.emitter.emit(rows, columns=_COLUMNS, empty="(no matches)")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command()
|
|
58
|
+
def wp(
|
|
59
|
+
ctx: typer.Context,
|
|
60
|
+
text: str = typer.Argument(None, help="Full-text query (subject/description/comments)."),
|
|
61
|
+
project: str = typer.Option(None, "--project", "-P", help="Restrict to a project."),
|
|
62
|
+
status: str = typer.Option(None, "--status", "-s", help="'open', 'closed', or a status name."),
|
|
63
|
+
open_: bool = typer.Option(False, "--open", help="Only open work packages."),
|
|
64
|
+
closed: bool = typer.Option(False, "--closed", help="Only closed work packages."),
|
|
65
|
+
type_: str = typer.Option(None, "--type", "-t", help="Type name."),
|
|
66
|
+
priority: str = typer.Option(None, "--priority", help="Priority name."),
|
|
67
|
+
assignee: str = typer.Option(None, "--assignee", "-a", help="Assignee (login/name/id or 'me')."),
|
|
68
|
+
mine: bool = typer.Option(False, "--mine", help="Assigned to me."),
|
|
69
|
+
unassigned: bool = typer.Option(False, "--unassigned", help="No assignee."),
|
|
70
|
+
author: str = typer.Option(None, "--author", "--created-by", help="Author (login/name/id or 'me')."),
|
|
71
|
+
responsible: str = typer.Option(None, "--responsible", help="Accountable person."),
|
|
72
|
+
watching: bool = typer.Option(False, "--watching", help="Watched by me."),
|
|
73
|
+
version: str = typer.Option(None, "--version", help="Target version/milestone (name/id)."),
|
|
74
|
+
parent: int = typer.Option(None, "--parent", help="Children of this work-package id."),
|
|
75
|
+
ids: str = typer.Option(None, "--id", help="Specific ids, comma-separated."),
|
|
76
|
+
subject: str = typer.Option(None, "--subject", help="Subject contains (LIKE)."),
|
|
77
|
+
created_since: str = typer.Option(None, "--created-since", help="Created on/after (date or 7d/2w/today)."),
|
|
78
|
+
created_before: str = typer.Option(None, "--created-before", help="Created before (date or spec)."),
|
|
79
|
+
updated_since: str = typer.Option(None, "--updated-since", help="Updated on/after (date or spec)."),
|
|
80
|
+
due_before: str = typer.Option(None, "--due-before", help="Due on/before (date or spec)."),
|
|
81
|
+
due_after: str = typer.Option(None, "--due-after", help="Due on/after (date or spec)."),
|
|
82
|
+
overdue: bool = typer.Option(False, "--overdue", help="Past due and still open."),
|
|
83
|
+
start_after: str = typer.Option(None, "--start-after", help="Starts on/after."),
|
|
84
|
+
start_before: str = typer.Option(None, "--start-before", help="Starts on/before."),
|
|
85
|
+
where: list[str] = typer.Option(None, "--where", "-w", help='Expression e.g. "status = open" (repeatable).'),
|
|
86
|
+
filters_json: str = typer.Option(None, "--filters", help="Raw OpenProject filters JSON (overrides everything)."),
|
|
87
|
+
all_statuses: bool = typer.Option(False, "--all", help="Include closed work packages."),
|
|
88
|
+
sort: str = typer.Option("updatedAt", "--sort", help="Sort field."),
|
|
89
|
+
asc: bool = typer.Option(False, "--asc", help="Ascending (default descending)."),
|
|
90
|
+
group_by: str = typer.Option(None, "--group-by", help="Group column (status/type/assignee/project/priority)."),
|
|
91
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Maximum rows (0 = all)."),
|
|
92
|
+
count: bool = typer.Option(False, "--count", help="Return only the total match count."),
|
|
93
|
+
raw: bool = typer.Option(False, "--raw", "-r", help="Return raw HAL elements."),
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Search work packages with rich, plain-language filters."""
|
|
96
|
+
obj = ctx_obj(ctx)
|
|
97
|
+
client = obj.client()
|
|
98
|
+
|
|
99
|
+
if filters_json:
|
|
100
|
+
filters = parse_json_option(filters_json, what="--filters")
|
|
101
|
+
else:
|
|
102
|
+
if open_:
|
|
103
|
+
status = "open"
|
|
104
|
+
elif closed:
|
|
105
|
+
status = "closed"
|
|
106
|
+
filters = wpfilters.build(
|
|
107
|
+
client, project=project, status=status, type_=type_, priority=priority,
|
|
108
|
+
assignee=assignee, mine=mine, unassigned=unassigned, author=author,
|
|
109
|
+
responsible=responsible, watching=watching, version=version, parent=parent,
|
|
110
|
+
id_list=ids, query=text, subject=subject,
|
|
111
|
+
created_since=created_since, created_before=created_before,
|
|
112
|
+
updated_since=updated_since, due_before=due_before, due_after=due_after,
|
|
113
|
+
overdue=overdue, start_after=start_after, start_before=start_before,
|
|
114
|
+
where=where, all_statuses=all_statuses,
|
|
115
|
+
)
|
|
116
|
+
_render(obj, client, filters, sort=sort, asc=asc, group_by=group_by, limit=limit, count=count, raw=raw)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------- presets
|
|
120
|
+
def _preset(ctx, *, sort="updatedAt", asc=False, limit=50, **build_kwargs):
|
|
121
|
+
obj = ctx_obj(ctx)
|
|
122
|
+
client = obj.client()
|
|
123
|
+
filters = wpfilters.build(client, **build_kwargs)
|
|
124
|
+
_render(obj, client, filters, sort=sort, asc=asc, group_by=None, limit=limit, count=False, raw=False)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command()
|
|
128
|
+
def mine(
|
|
129
|
+
ctx: typer.Context,
|
|
130
|
+
project: str = typer.Option(None, "--project", "-P", help="Restrict to a project."),
|
|
131
|
+
all_statuses: bool = typer.Option(False, "--all", help="Include closed."),
|
|
132
|
+
limit: int = typer.Option(50, "--limit", "-n"),
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Open work packages assigned to me."""
|
|
135
|
+
_preset(ctx, mine=True, project=project, all_statuses=all_statuses, limit=limit)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def reported(
|
|
140
|
+
ctx: typer.Context,
|
|
141
|
+
project: str = typer.Option(None, "--project", "-P"),
|
|
142
|
+
all_statuses: bool = typer.Option(False, "--all"),
|
|
143
|
+
limit: int = typer.Option(50, "--limit", "-n"),
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Work packages I created (authored)."""
|
|
146
|
+
_preset(ctx, author="me", project=project, all_statuses=all_statuses, limit=limit)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command()
|
|
150
|
+
def watching(
|
|
151
|
+
ctx: typer.Context,
|
|
152
|
+
project: str = typer.Option(None, "--project", "-P"),
|
|
153
|
+
all_statuses: bool = typer.Option(False, "--all"),
|
|
154
|
+
limit: int = typer.Option(50, "--limit", "-n"),
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Work packages I watch."""
|
|
157
|
+
_preset(ctx, watching=True, project=project, all_statuses=all_statuses, limit=limit)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command()
|
|
161
|
+
def unassigned(
|
|
162
|
+
ctx: typer.Context,
|
|
163
|
+
project: str = typer.Option(None, "--project", "-P"),
|
|
164
|
+
limit: int = typer.Option(50, "--limit", "-n"),
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Open work packages with no assignee."""
|
|
167
|
+
_preset(ctx, unassigned=True, project=project, limit=limit)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
def overdue(
|
|
172
|
+
ctx: typer.Context,
|
|
173
|
+
project: str = typer.Option(None, "--project", "-P"),
|
|
174
|
+
limit: int = typer.Option(50, "--limit", "-n"),
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Past-due, still-open work packages."""
|
|
177
|
+
_preset(ctx, overdue=True, project=project, sort="dueDate", asc=True, limit=limit)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def recent(
|
|
182
|
+
ctx: typer.Context,
|
|
183
|
+
days: int = typer.Option(7, "--days", "-d", help="Updated within the last N days."),
|
|
184
|
+
project: str = typer.Option(None, "--project", "-P"),
|
|
185
|
+
all_statuses: bool = typer.Option(True, "--all/--open-only", help="Include closed (default yes)."),
|
|
186
|
+
limit: int = typer.Option(50, "--limit", "-n"),
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Recently updated work packages."""
|
|
189
|
+
_preset(ctx, updated_since=f"{days}d", project=project, all_statuses=all_statuses, limit=limit)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------- discoverability
|
|
193
|
+
@app.command()
|
|
194
|
+
def fields(
|
|
195
|
+
ctx: typer.Context,
|
|
196
|
+
project: str = typer.Option(None, "--project", "-P", help="Show project-specific filters (incl. its custom fields)."),
|
|
197
|
+
) -> None:
|
|
198
|
+
"""List what you can filter/search work packages on (live from the instance)."""
|
|
199
|
+
obj = ctx_obj(ctx)
|
|
200
|
+
rows = searchspec.live_fields(obj.client(), project)
|
|
201
|
+
obj.emitter.emit(
|
|
202
|
+
rows,
|
|
203
|
+
columns=[("Field", "field"), ("Kind", "kind"), ("CLI flag", "flag"), ("Description", "description")],
|
|
204
|
+
empty="(no filters)",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command()
|
|
209
|
+
def operators(ctx: typer.Context) -> None:
|
|
210
|
+
"""Explain the filter operator codes."""
|
|
211
|
+
obj = ctx_obj(ctx)
|
|
212
|
+
rows = [{"operator": code, "meaning": meaning} for code, meaning in searchspec.OPERATORS]
|
|
213
|
+
obj.emitter.emit(rows, columns=[("Operator", "operator"), ("Meaning", "meaning")])
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command()
|
|
217
|
+
def values(
|
|
218
|
+
ctx: typer.Context,
|
|
219
|
+
field: str = typer.Argument(..., help="Field to list allowed values for (status/type/priority/version/...)."),
|
|
220
|
+
project: str = typer.Option(None, "--project", "-P", help="Project context (versions, assignees, custom fields)."),
|
|
221
|
+
limit: int = typer.Option(100, "--limit", "-n"),
|
|
222
|
+
) -> None:
|
|
223
|
+
"""List the allowed values for a filterable field."""
|
|
224
|
+
obj = ctx_obj(ctx)
|
|
225
|
+
client = obj.client()
|
|
226
|
+
f = field.strip()
|
|
227
|
+
simple = {"status": "statuses", "type": "types", "priority": "priorities", "project": "projects"}
|
|
228
|
+
rows: list[dict]
|
|
229
|
+
if f in simple:
|
|
230
|
+
rows = [{"id": e.get("id"), "name": e.get("name")} for e in client.collect(simple[f], limit=limit)]
|
|
231
|
+
elif f == "version":
|
|
232
|
+
coll = f"projects/{resolve.project_id(client, project)}/versions" if project else "versions"
|
|
233
|
+
rows = [{"id": e.get("id"), "name": e.get("name")} for e in client.collect(coll, limit=limit)]
|
|
234
|
+
elif f in ("assignee", "author", "responsible", "watcher", "user"):
|
|
235
|
+
if project:
|
|
236
|
+
pid = resolve.project_id(client, project)
|
|
237
|
+
rows = [serialize.principal(p) for p in client.collect(f"projects/{pid}/available_assignees", limit=limit)]
|
|
238
|
+
else:
|
|
239
|
+
rows = [serialize.principal(p) for p in client.collect("principals", limit=limit)]
|
|
240
|
+
elif f.startswith("customField"):
|
|
241
|
+
pid = resolve.project_id(client, project or 1)
|
|
242
|
+
form = client.post(
|
|
243
|
+
f"projects/{pid}/work_packages/form",
|
|
244
|
+
json={"_links": {"project": {"href": f"/api/v3/projects/{pid}"}}},
|
|
245
|
+
)
|
|
246
|
+
spec = ((form.get("_embedded") or {}).get("schema") or {}).get(f) or {}
|
|
247
|
+
allowed = (spec.get("_links") or {}).get("allowedValues") or []
|
|
248
|
+
rows = [{"name": a.get("title"), "href": a.get("href")} for a in allowed]
|
|
249
|
+
else:
|
|
250
|
+
from ..errors import OpError
|
|
251
|
+
|
|
252
|
+
raise OpError(
|
|
253
|
+
f"no value list for '{field}'. Try status, type, priority, project, version, assignee, or a customFieldN. "
|
|
254
|
+
f"Free-text/date/number fields take literal values."
|
|
255
|
+
)
|
|
256
|
+
obj.emitter.emit(rows, columns=[("ID", "id"), ("Name", "name")], empty="(no values)")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Manage CLI settings — most importantly the default output format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .. import credentials
|
|
8
|
+
from ..config import Config, config_path
|
|
9
|
+
from ..errors import OpError
|
|
10
|
+
from ..output import OutputFormat
|
|
11
|
+
from ._shared import ctx_obj
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command()
|
|
17
|
+
def show(ctx: typer.Context) -> None:
|
|
18
|
+
"""Show current settings (default format, active profile, config location)."""
|
|
19
|
+
obj = ctx_obj(ctx)
|
|
20
|
+
cfg = obj.config
|
|
21
|
+
obj.emitter.emit(
|
|
22
|
+
{
|
|
23
|
+
"configPath": str(config_path()),
|
|
24
|
+
"defaultFormat": cfg.default_format or "(not set — defaults to json)",
|
|
25
|
+
"activeProfile": cfg.active_profile_name(),
|
|
26
|
+
"profiles": sorted(cfg.profiles.keys()),
|
|
27
|
+
"credentialBackend": credentials.backend_name(),
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command("set-format")
|
|
33
|
+
def set_format(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
fmt: str = typer.Argument(..., help="json | table | markdown (md)."),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Set the default output format used when no --format/-o is given."""
|
|
38
|
+
obj = ctx_obj(ctx)
|
|
39
|
+
try:
|
|
40
|
+
chosen = OutputFormat.coerce(fmt)
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
raise OpError(str(exc)) from exc
|
|
43
|
+
cfg = Config.load() # reload to avoid clobbering concurrent changes
|
|
44
|
+
cfg.default_format = chosen.value
|
|
45
|
+
cfg.save()
|
|
46
|
+
obj.emitter.emit({"status": "saved", "defaultFormat": chosen.value, "configPath": str(config_path())})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command("get-format")
|
|
50
|
+
def get_format(ctx: typer.Context) -> None:
|
|
51
|
+
"""Print the effective default format."""
|
|
52
|
+
obj = ctx_obj(ctx)
|
|
53
|
+
obj.emitter.emit({"defaultFormat": obj.config.default_format or "json"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def path(ctx: typer.Context) -> None:
|
|
58
|
+
"""Print the config file path."""
|
|
59
|
+
ctx_obj(ctx).emitter.emit({"configPath": str(config_path())})
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Time-entry commands: log, edit, list, delete, activities, and reporting.
|
|
2
|
+
|
|
3
|
+
Time-entry filters use snake_case names (``user_id``, ``project_id``,
|
|
4
|
+
``entity_type``/``entity_id``, ``spent_on``) — different from work-package
|
|
5
|
+
filters. ``hours`` is an ISO-8601 duration; we accept decimals and convert.
|
|
6
|
+
Time entries have no lockVersion.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as _dt
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from .. import hal, resolve, serialize
|
|
17
|
+
from ..duration import iso_to_hours, parse_hours_input
|
|
18
|
+
from ..errors import ApiError, OpError, ValidationError
|
|
19
|
+
from ._shared import ctx_obj, set_link
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(no_args_is_help=True)
|
|
22
|
+
|
|
23
|
+
_COLUMNS = [
|
|
24
|
+
("ID", "id"),
|
|
25
|
+
("Date", "spentOn"),
|
|
26
|
+
("Hours", lambda r: iso_to_hours(r.get("hours"))),
|
|
27
|
+
("User", lambda r: (r.get("user") or {}).get("name")),
|
|
28
|
+
("WorkPkg", lambda r: (r.get("workPackage") or {}).get("id")),
|
|
29
|
+
("Activity", "activity"),
|
|
30
|
+
("Comment", "comment"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _month_range(month: str) -> tuple[str, str]:
|
|
35
|
+
year, mon = (int(x) for x in month.split("-"))
|
|
36
|
+
first = _dt.date(year, mon, 1)
|
|
37
|
+
nxt = _dt.date(year + (mon == 12), (mon % 12) + 1, 1)
|
|
38
|
+
last = nxt - _dt.timedelta(days=1)
|
|
39
|
+
return first.isoformat(), last.isoformat()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_filters(client, *, user, project, work_package, frm, to, month, wp_style="work_package_id") -> list[dict]:
|
|
43
|
+
filters: list[dict] = []
|
|
44
|
+
if month:
|
|
45
|
+
frm, to = _month_range(month)
|
|
46
|
+
if user:
|
|
47
|
+
href = resolve.user(client, user, project_ref=project)
|
|
48
|
+
filters.append({"user_id": {"operator": "=", "values": [str(hal.id_from_href(href))]}})
|
|
49
|
+
if project:
|
|
50
|
+
filters.append({"project_id": {"operator": "=", "values": [str(resolve.project_id(client, project))]}})
|
|
51
|
+
if work_package:
|
|
52
|
+
if wp_style == "entity":
|
|
53
|
+
filters.append({"entity_type": {"operator": "=", "values": ["WorkPackage"]}})
|
|
54
|
+
filters.append({"entity_id": {"operator": "=", "values": [str(work_package)]}})
|
|
55
|
+
else:
|
|
56
|
+
filters.append({"work_package_id": {"operator": "=", "values": [str(work_package)]}})
|
|
57
|
+
if frm and to:
|
|
58
|
+
filters.append({"spent_on": {"operator": "<>d", "values": [frm, to]}})
|
|
59
|
+
elif frm:
|
|
60
|
+
# single-sided: use the between operator with an empty upper bound
|
|
61
|
+
# (>=d/<=d are not valid OpenProject operator codes).
|
|
62
|
+
filters.append({"spent_on": {"operator": "<>d", "values": [frm, ""]}})
|
|
63
|
+
elif to:
|
|
64
|
+
filters.append({"spent_on": {"operator": "<>d", "values": ["", to]}})
|
|
65
|
+
return filters
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def query_time_entries(client, *, user=None, project=None, work_package=None, frm=None, to=None,
|
|
69
|
+
month=None, sort="spent_on", desc=True, limit=None) -> list[dict]:
|
|
70
|
+
"""Fetch time entries, tolerating the work-package-filter name difference
|
|
71
|
+
across OpenProject versions (``work_package_id`` vs ``entity_type``/``entity_id``)."""
|
|
72
|
+
styles = ("work_package_id", "entity") if work_package else ("work_package_id",)
|
|
73
|
+
last: Exception | None = None
|
|
74
|
+
for style in styles:
|
|
75
|
+
filters = _build_filters(client, user=user, project=project, work_package=work_package,
|
|
76
|
+
frm=frm, to=to, month=month, wp_style=style)
|
|
77
|
+
params = {"sortBy": json.dumps([[sort, "desc" if desc else "asc"]])}
|
|
78
|
+
if filters:
|
|
79
|
+
params["filters"] = json.dumps(filters)
|
|
80
|
+
try:
|
|
81
|
+
return client.collect("time_entries", params=params, limit=limit)
|
|
82
|
+
except (ApiError, ValidationError) as exc:
|
|
83
|
+
last = exc
|
|
84
|
+
if work_package and "does not exist" in (exc.message or "").lower():
|
|
85
|
+
continue
|
|
86
|
+
raise
|
|
87
|
+
if last:
|
|
88
|
+
raise last
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("list")
|
|
93
|
+
def list_entries(
|
|
94
|
+
ctx: typer.Context,
|
|
95
|
+
user: str = typer.Option(None, "--user", "-u", help="Filter by user (login/name/id or 'me')."),
|
|
96
|
+
project: str = typer.Option(None, "--project", "-P", help="Filter by project."),
|
|
97
|
+
work_package: int = typer.Option(None, "--work-package", "-w", help="Filter by work package id."),
|
|
98
|
+
frm: str = typer.Option(None, "--from", help="Start date (YYYY-MM-DD)."),
|
|
99
|
+
to: str = typer.Option(None, "--to", help="End date (YYYY-MM-DD)."),
|
|
100
|
+
month: str = typer.Option(None, "--month", help="Convenience month filter YYYY-MM."),
|
|
101
|
+
limit: int = typer.Option(100, "--limit", "-n", help="Maximum rows (0 = all)."),
|
|
102
|
+
) -> None:
|
|
103
|
+
"""List time entries with filters (per person / project / work package)."""
|
|
104
|
+
obj = ctx_obj(ctx)
|
|
105
|
+
client = obj.client()
|
|
106
|
+
entries = query_time_entries(client, user=user, project=project, work_package=work_package,
|
|
107
|
+
frm=frm, to=to, month=month, limit=limit or None)
|
|
108
|
+
rows = [serialize.time_entry(t) for t in entries]
|
|
109
|
+
obj.emitter.emit(rows, columns=_COLUMNS, empty="(no time entries)")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command()
|
|
113
|
+
def get(ctx: typer.Context, entry_id: int = typer.Argument(..., help="Time entry id.")) -> None:
|
|
114
|
+
"""Show a single time entry."""
|
|
115
|
+
obj = ctx_obj(ctx)
|
|
116
|
+
obj.emitter.emit(serialize.time_entry(obj.client().get(f"time_entries/{entry_id}")))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command()
|
|
120
|
+
def add(
|
|
121
|
+
ctx: typer.Context,
|
|
122
|
+
hours: str = typer.Argument(..., help="Hours (decimal like 2.5 or ISO8601 like PT2H30M)."),
|
|
123
|
+
work_package: int = typer.Option(None, "--work-package", "-w", help="Work package id to log against."),
|
|
124
|
+
project: str = typer.Option(None, "--project", "-P", help="Project id/identifier (if not logging on a WP)."),
|
|
125
|
+
spent_on: str = typer.Option(None, "--date", help="Date YYYY-MM-DD (default today)."),
|
|
126
|
+
activity: str = typer.Option(None, "--activity", help="Activity name (e.g. Development)."),
|
|
127
|
+
comment: str = typer.Option(None, "--comment", "-m", help="Comment."),
|
|
128
|
+
user: str = typer.Option(None, "--user", "-u", help="Log on behalf of another user (needs permission)."),
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Log a time entry. Hours accept decimals (2.5) or ISO-8601 (PT2H30M).
|
|
131
|
+
|
|
132
|
+
Example: openproject time add 2.5 --work-package 42 --activity Development --comment "debugging"
|
|
133
|
+
Provide --work-package OR --project. Date defaults to today (`--date 2026-07-10`).
|
|
134
|
+
"""
|
|
135
|
+
obj = ctx_obj(ctx)
|
|
136
|
+
client = obj.client()
|
|
137
|
+
if not work_package and not project:
|
|
138
|
+
raise OpError("provide --work-package or --project")
|
|
139
|
+
|
|
140
|
+
body: dict = {
|
|
141
|
+
"hours": parse_hours_input(hours),
|
|
142
|
+
"spentOn": spent_on or _dt.date.today().isoformat(),
|
|
143
|
+
"_links": {},
|
|
144
|
+
}
|
|
145
|
+
if work_package:
|
|
146
|
+
set_link(body, "workPackage", f"/api/v3/work_packages/{work_package}")
|
|
147
|
+
if project:
|
|
148
|
+
set_link(body, "project", f"/api/v3/projects/{resolve.project_id(client, project)}")
|
|
149
|
+
if activity:
|
|
150
|
+
set_link(body, "activity", resolve.time_activity(client, activity, project_ref=project, wp_id=work_package))
|
|
151
|
+
if comment is not None:
|
|
152
|
+
body["comment"] = {"raw": comment}
|
|
153
|
+
if user:
|
|
154
|
+
set_link(body, "user", resolve.user(client, user, project_ref=project))
|
|
155
|
+
|
|
156
|
+
obj.emitter.emit(serialize.time_entry(client.post("time_entries", json=body)))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command()
|
|
160
|
+
def edit(
|
|
161
|
+
ctx: typer.Context,
|
|
162
|
+
entry_id: int = typer.Argument(..., help="Time entry id."),
|
|
163
|
+
hours: str = typer.Option(None, "--hours", help="New hours (decimal or ISO8601)."),
|
|
164
|
+
spent_on: str = typer.Option(None, "--date", help="New date YYYY-MM-DD."),
|
|
165
|
+
activity: str = typer.Option(None, "--activity", help="New activity name."),
|
|
166
|
+
comment: str = typer.Option(None, "--comment", "-m", help="New comment."),
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Edit a time entry (partial; no lockVersion)."""
|
|
169
|
+
obj = ctx_obj(ctx)
|
|
170
|
+
client = obj.client()
|
|
171
|
+
body: dict = {}
|
|
172
|
+
if hours is not None:
|
|
173
|
+
body["hours"] = parse_hours_input(hours)
|
|
174
|
+
if spent_on is not None:
|
|
175
|
+
body["spentOn"] = spent_on
|
|
176
|
+
if comment is not None:
|
|
177
|
+
body["comment"] = {"raw": comment}
|
|
178
|
+
if activity is not None:
|
|
179
|
+
current = client.get(f"time_entries/{entry_id}")
|
|
180
|
+
pid = hal.link_id(current, "project")
|
|
181
|
+
set_link(body, "activity", resolve.time_activity(client, activity, project_ref=pid))
|
|
182
|
+
if not body:
|
|
183
|
+
raise OpError("nothing to update")
|
|
184
|
+
obj.emitter.emit(serialize.time_entry(client.patch(f"time_entries/{entry_id}", json=body)))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def delete(
|
|
189
|
+
ctx: typer.Context,
|
|
190
|
+
entry_id: int = typer.Argument(..., help="Time entry id."),
|
|
191
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Delete a time entry."""
|
|
194
|
+
obj = ctx_obj(ctx)
|
|
195
|
+
if not yes:
|
|
196
|
+
typer.confirm(f"Delete time entry {entry_id}?", abort=True)
|
|
197
|
+
obj.client().delete(f"time_entries/{entry_id}")
|
|
198
|
+
obj.emitter.emit({"status": "deleted", "timeEntry": entry_id})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.command()
|
|
202
|
+
def activities(
|
|
203
|
+
ctx: typer.Context,
|
|
204
|
+
project: str = typer.Option(None, "--project", "-P", help="Project context (activities can be project-scoped)."),
|
|
205
|
+
) -> None:
|
|
206
|
+
"""List available time-entry activities (Development, Management, ...)."""
|
|
207
|
+
obj = ctx_obj(ctx)
|
|
208
|
+
client = obj.client()
|
|
209
|
+
if project is None:
|
|
210
|
+
# activities live in the form schema; any accessible project gives the global set
|
|
211
|
+
first = client.collect("projects", page_size=1, limit=1)
|
|
212
|
+
project = first[0]["id"] if first else None
|
|
213
|
+
acts = resolve.time_activities(client, project_ref=project)
|
|
214
|
+
obj.emitter.emit([{"id": a["id"], "name": a["name"]} for a in acts], columns=[("ID", "id"), ("Name", "name")])
|
opcli/commands/users.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""User / principal commands: list, get, me, available assignees, groups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .. import resolve, serialize
|
|
10
|
+
from ._shared import ctx_obj
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
_COLUMNS = [
|
|
15
|
+
("ID", "id"),
|
|
16
|
+
("Login", "login"),
|
|
17
|
+
("Name", "name"),
|
|
18
|
+
("Email", "email"),
|
|
19
|
+
("Status", "status"),
|
|
20
|
+
("Admin", "admin"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("list")
|
|
25
|
+
def list_users(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
status: str = typer.Option(None, "--status", help="Filter by status (active, locked, invited...)."),
|
|
28
|
+
name: str = typer.Option(None, "--name", help="Name/login substring filter."),
|
|
29
|
+
limit: int = typer.Option(100, "--limit", "-n", help="Maximum rows (0 = all)."),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""List users (requires admin permission on most instances)."""
|
|
32
|
+
obj = ctx_obj(ctx)
|
|
33
|
+
filters = []
|
|
34
|
+
if status:
|
|
35
|
+
filters.append({"status": {"operator": "=", "values": [status]}})
|
|
36
|
+
if name:
|
|
37
|
+
filters.append({"name": {"operator": "~", "values": [name]}})
|
|
38
|
+
params = {"filters": json.dumps(filters)} if filters else {}
|
|
39
|
+
rows = [serialize.user(u) for u in obj.client().collect("users", params=params, limit=limit or None)]
|
|
40
|
+
obj.emitter.emit(rows, columns=_COLUMNS, empty="(no users)")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def get(
|
|
45
|
+
ctx: typer.Context,
|
|
46
|
+
user: str = typer.Argument(..., help="User id, login, or 'me'."),
|
|
47
|
+
raw: bool = typer.Option(False, "--raw", "-r", help="Return the full HAL document."),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Show a single user."""
|
|
50
|
+
obj = ctx_obj(ctx)
|
|
51
|
+
client = obj.client()
|
|
52
|
+
href = resolve.user(client, user)
|
|
53
|
+
doc = client.get(href)
|
|
54
|
+
obj.emitter.emit(doc if raw else serialize.user(doc))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command()
|
|
58
|
+
def create(
|
|
59
|
+
ctx: typer.Context,
|
|
60
|
+
login: str = typer.Argument(..., help="Login/username."),
|
|
61
|
+
email: str = typer.Option(..., "--email", "-e", help="Email address."),
|
|
62
|
+
first_name: str = typer.Option(..., "--first-name", help="First name."),
|
|
63
|
+
last_name: str = typer.Option(..., "--last-name", help="Last name."),
|
|
64
|
+
password: str = typer.Option(None, "--password", help="Initial password (else invite/status)."),
|
|
65
|
+
status: str = typer.Option("active", "--status", help="active | invited | registered."),
|
|
66
|
+
admin: bool = typer.Option(False, "--admin", help="Grant admin."),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Create a user (requires admin)."""
|
|
69
|
+
obj = ctx_obj(ctx)
|
|
70
|
+
body: dict = {
|
|
71
|
+
"login": login,
|
|
72
|
+
"email": email,
|
|
73
|
+
"firstName": first_name,
|
|
74
|
+
"lastName": last_name,
|
|
75
|
+
"admin": admin,
|
|
76
|
+
"status": status,
|
|
77
|
+
}
|
|
78
|
+
if password:
|
|
79
|
+
body["password"] = password
|
|
80
|
+
body["status"] = "active"
|
|
81
|
+
obj.emitter.emit(serialize.user(obj.client().post("users", json=body)))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command()
|
|
85
|
+
def me(ctx: typer.Context) -> None:
|
|
86
|
+
"""Show the authenticated user."""
|
|
87
|
+
obj = ctx_obj(ctx)
|
|
88
|
+
obj.emitter.emit(serialize.user(obj.client().me()))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def available(
|
|
93
|
+
ctx: typer.Context,
|
|
94
|
+
project: str = typer.Argument(..., help="Project id/identifier."),
|
|
95
|
+
) -> None:
|
|
96
|
+
"""List users assignable within a project (available assignees)."""
|
|
97
|
+
obj = ctx_obj(ctx)
|
|
98
|
+
client = obj.client()
|
|
99
|
+
pid = resolve.project_id(client, project)
|
|
100
|
+
rows = [serialize.principal(p) for p in client.collect(f"projects/{pid}/available_assignees")]
|
|
101
|
+
obj.emitter.emit(rows, columns=[("ID", "id"), ("Name", "name"), ("Login", "login"), ("Type", "type")])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def groups(ctx: typer.Context) -> None:
|
|
106
|
+
"""List groups."""
|
|
107
|
+
obj = ctx_obj(ctx)
|
|
108
|
+
rows = [{"id": g.get("id"), "name": g.get("name")} for g in obj.client().collect("groups")]
|
|
109
|
+
obj.emitter.emit(rows, columns=[("ID", "id"), ("Name", "name")])
|