vikunja-python 0.1.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.
- vikunja_python-0.1.0/PKG-INFO +16 -0
- vikunja_python-0.1.0/README.md +85 -0
- vikunja_python-0.1.0/pyproject.toml +40 -0
- vikunja_python-0.1.0/setup.cfg +4 -0
- vikunja_python-0.1.0/tests/test_auth_integration.py +40 -0
- vikunja_python-0.1.0/tests/test_cli_core.py +60 -0
- vikunja_python-0.1.0/tests/test_container.py +26 -0
- vikunja_python-0.1.0/tests/test_phase6_integration.py +122 -0
- vikunja_python-0.1.0/tests/test_task_hierarchy_integration.py +165 -0
- vikunja_python-0.1.0/tests/test_task_models_unit.py +73 -0
- vikunja_python-0.1.0/vikunja_python/__init__.py +0 -0
- vikunja_python-0.1.0/vikunja_python/cli/__init__.py +0 -0
- vikunja_python-0.1.0/vikunja_python/cli/main.py +264 -0
- vikunja_python-0.1.0/vikunja_python/core/__init__.py +0 -0
- vikunja_python-0.1.0/vikunja_python/core/client.py +106 -0
- vikunja_python-0.1.0/vikunja_python/core/models/__init__.py +377 -0
- vikunja_python-0.1.0/vikunja_python/core/models/api_token.py +131 -0
- vikunja_python-0.1.0/vikunja_python/core/models/auth.py +34 -0
- vikunja_python-0.1.0/vikunja_python/core/models/base.py +193 -0
- vikunja_python-0.1.0/vikunja_python/core/models/bulk_assignees.py +98 -0
- vikunja_python-0.1.0/vikunja_python/core/models/filter.py +134 -0
- vikunja_python-0.1.0/vikunja_python/core/models/label.py +230 -0
- vikunja_python-0.1.0/vikunja_python/core/models/link_sharing.py +138 -0
- vikunja_python-0.1.0/vikunja_python/core/models/migration.py +404 -0
- vikunja_python-0.1.0/vikunja_python/core/models/phase6_medium.py +74 -0
- vikunja_python-0.1.0/vikunja_python/core/models/project.py +217 -0
- vikunja_python-0.1.0/vikunja_python/core/models/relation.py +199 -0
- vikunja_python-0.1.0/vikunja_python/core/models/task.py +261 -0
- vikunja_python-0.1.0/vikunja_python/core/models/task_expansion.py +252 -0
- vikunja_python-0.1.0/vikunja_python/core/models/user.py +838 -0
- vikunja_python-0.1.0/vikunja_python/core/models/webhook.py +270 -0
- vikunja_python-0.1.0/vikunja_python/mcp/__init__.py +0 -0
- vikunja_python-0.1.0/vikunja_python/mcp/server.py +678 -0
- vikunja_python-0.1.0/vikunja_python.egg-info/PKG-INFO +16 -0
- vikunja_python-0.1.0/vikunja_python.egg-info/SOURCES.txt +37 -0
- vikunja_python-0.1.0/vikunja_python.egg-info/dependency_links.txt +1 -0
- vikunja_python-0.1.0/vikunja_python.egg-info/entry_points.txt +3 -0
- vikunja_python-0.1.0/vikunja_python.egg-info/requires.txt +10 -0
- vikunja_python-0.1.0/vikunja_python.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vikunja-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: API wrapper, CLI tool, and MCP server for Vikunja
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
License-Expression: WTFPL
|
|
7
|
+
Requires-Python: >=3.13.12
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0.0
|
|
10
|
+
Requires-Dist: dateparser>=1.2.0
|
|
11
|
+
Requires-Dist: typer>=0.12.0
|
|
12
|
+
Requires-Dist: fastmcp>=0.1.0
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=8.0.0; extra == "test"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "test"
|
|
16
|
+
Requires-Dist: testcontainers[mysql]>=4.0.0; extra == "test"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Vikunja Python
|
|
2
|
+
|
|
3
|
+
An open-source (WTFPL) API wrapper, rich CLI tool, and Model Context Protocol (MCP) server for the [Vikunja](https://vikunja.io/) task management system.
|
|
4
|
+
|
|
5
|
+
Built for **Python 3.13** using `httpx`, `pydantic v2`, and `fastmcp`.
|
|
6
|
+
|
|
7
|
+
## 🏗 Architecture
|
|
8
|
+
|
|
9
|
+
The project is structured into three distinct layers to ensure modularity and clean dependency management:
|
|
10
|
+
|
|
11
|
+
- **`/core`**: The engine. Handles Pydantic models, HTTP client logic, and error handling. (Minimal dependencies).
|
|
12
|
+
- **`/cli`**: A high-visual-impact command-line interface built with `typer` and `rich`.
|
|
13
|
+
- **`/mcp`**: A Model Context Protocol server designed for agentic workflows, optimized for smaller LLMs (like Gemma).
|
|
14
|
+
|
|
15
|
+
## 🚀 Getting Started
|
|
16
|
+
|
|
17
|
+
### Installation
|
|
18
|
+
This project uses `uv` for modern Python package management.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv sync --all-extras
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Environment Variables
|
|
25
|
+
Configure your Vikunja instance in a `.env` file or your shell:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
VIKUNJA_URL="https://your-vikunja-instance.com/api/v1"
|
|
29
|
+
VIKUNJA_API_TOKEN="your_api_token_here" # Recommended for MCP
|
|
30
|
+
VIKUNJA_JWT_TOKEN="your_jwt_here" # Required for Buckets/Reactions in CLI
|
|
31
|
+
VIKUNJA_DEBUG="true" # Set to true or 1 to enable verbose stderr logging
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 🛠 Features & Tooling
|
|
35
|
+
|
|
36
|
+
### CLI (`vikunja`)
|
|
37
|
+
The CLI provides a rich, human-friendly interface for managing your workspace.
|
|
38
|
+
- **Tasks & Projects**: Full CRUD support with formatted tables.
|
|
39
|
+
- **Buckets (Kanban)**: List and manage columns (Requires JWT).
|
|
40
|
+
- **Reactions**: Add emojis to tasks (Requires JWT).
|
|
41
|
+
- **Labels**: List and categorize tasks.
|
|
42
|
+
- **Auth**: Built-in `login` command to generate JWTs.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv run vikunja list-tasks
|
|
46
|
+
uv run vikunja login
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### MCP Server (`vikunja-mcp`)
|
|
50
|
+
A powerful server that gives LLMs "hands" to manage your Vikunja instance. Optimized for reliability and low turn-count.
|
|
51
|
+
- **Global Search**: Find tasks across all projects in one turn.
|
|
52
|
+
- **Bulk Scaffolding**: Create projects and multiple tasks in a single operation.
|
|
53
|
+
- **Smart Dates**: Natural language date parsing (e.g., "remind me next Friday").
|
|
54
|
+
- **Task Memory**: Full support for reading and writing task comments.
|
|
55
|
+
- **Explicit Hierarchy**: Clear directional tools for subtasks and dependencies.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Start via STDIO (for Claude Desktop)
|
|
59
|
+
uv run vikunja-mcp
|
|
60
|
+
|
|
61
|
+
# Start via SSE (HTTP)
|
|
62
|
+
uv run vikunja-mcp dev --transport sse
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 🔒 Security & Auth Policy
|
|
66
|
+
|
|
67
|
+
- **MCP Protocol**: The MCP server is strictly limited to **API Key Authentication**. We do not support user/password login via MCP to ensure secure, scoped agentic access.
|
|
68
|
+
- **UI-Only Features**: Features like Buckets (Kanban columns) and Reactions currently require a JWT (UI Session). These are supported in the CLI but are excluded from the MCP server to maintain protocol security.
|
|
69
|
+
|
|
70
|
+
## 🧪 Testing
|
|
71
|
+
|
|
72
|
+
We use `testcontainers-python` to run integration tests against a live, ephemeral Vikunja instance. No mocks, just real API verification.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv run pytest
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 🗺 Future Roadmap
|
|
79
|
+
- [ ] **Backgrounds**: Support for Unsplash project backgrounds.
|
|
80
|
+
- [ ] **Attachments**: File upload/download support.
|
|
81
|
+
- [ ] **Webhooks**: Core models for receiving Vikunja events.
|
|
82
|
+
- [ ] **Bulk Labels**: Tools for mass-tagging tasks.
|
|
83
|
+
|
|
84
|
+
## 📜 License
|
|
85
|
+
WTFPL - Do What the Fuck You Want to Public License.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vikunja-python"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "API wrapper, CLI tool, and MCP server for Vikunja"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Your Name", email = "your.email@example.com" }
|
|
7
|
+
]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"httpx>=0.27.0",
|
|
10
|
+
"pydantic>=2.0.0",
|
|
11
|
+
"dateparser>=1.2.0",
|
|
12
|
+
"typer>=0.12.0",
|
|
13
|
+
"fastmcp>=0.1.0",
|
|
14
|
+
]
|
|
15
|
+
requires-python = ">=3.13.12"
|
|
16
|
+
license = "WTFPL"
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
test = [
|
|
20
|
+
"pytest>=8.0.0",
|
|
21
|
+
"pytest-asyncio>=0.23.0",
|
|
22
|
+
"testcontainers[mysql]>=4.0.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["setuptools>=61.0"]
|
|
27
|
+
build-backend = "setuptools.build_meta"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
include = ["vikunja_python*"]
|
|
31
|
+
exclude = ["custom_components*", "tests*"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
testpaths = ["tests"]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
vikunja = "vikunja_python.cli.main:app"
|
|
39
|
+
vikunja-mcp = "vikunja_python.mcp.server:main"
|
|
40
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
from vikunja_python.core.models.base import Token
|
|
4
|
+
|
|
5
|
+
@pytest.mark.asyncio
|
|
6
|
+
async def test_full_auth_lifecycle(vikunja_auth):
|
|
7
|
+
"""Test full authentication lifecycle using the ephemeral test container."""
|
|
8
|
+
VIKUNJA_URL = vikunja_auth["base_url"]
|
|
9
|
+
USERNAME = vikunja_auth["username"]
|
|
10
|
+
PASSWORD = vikunja_auth["password"]
|
|
11
|
+
|
|
12
|
+
async with httpx.AsyncClient(follow_redirects=True) as client:
|
|
13
|
+
print("\n[1] Attempting Login...")
|
|
14
|
+
login_payload = {
|
|
15
|
+
"long_token": True,
|
|
16
|
+
"password": PASSWORD,
|
|
17
|
+
"username": USERNAME
|
|
18
|
+
}
|
|
19
|
+
login_resp = await client.post(f"{VIKUNJA_URL}/login", json=login_payload)
|
|
20
|
+
|
|
21
|
+
assert login_resp.status_code == 200, f"Login failed: {login_resp.text}"
|
|
22
|
+
|
|
23
|
+
# Validate Token model
|
|
24
|
+
token_data = login_resp.json()
|
|
25
|
+
token_obj = Token(**token_data)
|
|
26
|
+
assert token_obj.token is not None
|
|
27
|
+
print("✅ Login successful & Token validated")
|
|
28
|
+
|
|
29
|
+
print("\n[2] Attempting Token Refresh...")
|
|
30
|
+
refresh_resp = await client.get(f"{VIKUNJA_URL}/user/token/refresh", headers={"Authorization": f"Bearer {token_obj.token}"})
|
|
31
|
+
if refresh_resp.status_code == 200:
|
|
32
|
+
print("✅ Token refreshed successfully via GET.")
|
|
33
|
+
new_token_data = refresh_resp.json()
|
|
34
|
+
assert "token" in new_token_data
|
|
35
|
+
elif refresh_resp.status_code == 404:
|
|
36
|
+
print("⚠️ Refresh endpoint not found or incorrect method.")
|
|
37
|
+
|
|
38
|
+
print("\n[3] Attempting Logout...")
|
|
39
|
+
logout_resp = await client.post(f"{VIKUNJA_URL}/logout", headers={"Authorization": f"Bearer {token_obj.token}"})
|
|
40
|
+
assert logout_resp.status_code in [200, 204, 404]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import pytest
|
|
4
|
+
from vikunja_python.core.client import VikunjaClient
|
|
5
|
+
from vikunja_python.core.models.task import Task
|
|
6
|
+
from vikunja_python.core.models.project import Project
|
|
7
|
+
|
|
8
|
+
@pytest.mark.asyncio
|
|
9
|
+
async def test_cli_integration_basic(vikunja_auth):
|
|
10
|
+
"""Verify that the CLI-facing core client works with the testcontainer."""
|
|
11
|
+
base_url = vikunja_auth["base_url"]
|
|
12
|
+
token = vikunja_auth["token"]
|
|
13
|
+
|
|
14
|
+
async with VikunjaClient(base_url, token) as client:
|
|
15
|
+
# 1. Create a project
|
|
16
|
+
proj_data = await client.request("PUT", "/projects", json={"title": "CLI Project"})
|
|
17
|
+
project = Project(**proj_data)
|
|
18
|
+
assert project.title == "CLI Project"
|
|
19
|
+
|
|
20
|
+
# 2. Create a task via client (simulating CLI action)
|
|
21
|
+
task_data = await client.request("PUT", f"/projects/{project.id}/tasks", json={"title": "CLI Task"})
|
|
22
|
+
task = Task(**task_data)
|
|
23
|
+
assert task.title == "CLI Task"
|
|
24
|
+
assert task.project_id == project.id
|
|
25
|
+
|
|
26
|
+
# 4. Update task
|
|
27
|
+
update_data = await client.request("POST", f"/tasks/{task.id}", json={"done": True})
|
|
28
|
+
assert update_data["done"] is True
|
|
29
|
+
|
|
30
|
+
# 5. Create another task for relationship
|
|
31
|
+
task2_data = await client.request("PUT", f"/projects/{project.id}/tasks", json={"title": "Task 2"})
|
|
32
|
+
task2 = Task(**task2_data)
|
|
33
|
+
|
|
34
|
+
# 6. Create relationship
|
|
35
|
+
rel_data = await client.request("PUT", f"/tasks/{task.id}/relations", json={
|
|
36
|
+
"other_task_id": task2.id,
|
|
37
|
+
"relation_kind": "subtask"
|
|
38
|
+
})
|
|
39
|
+
assert rel_data["other_task_id"] == task2.id
|
|
40
|
+
|
|
41
|
+
# 7. Delete tasks
|
|
42
|
+
await client.request("DELETE", f"/tasks/{task.id}")
|
|
43
|
+
await client.request("DELETE", f"/tasks/{task2.id}")
|
|
44
|
+
|
|
45
|
+
# Verify deletion
|
|
46
|
+
check = await client.request("GET", f"/tasks/{task.id}")
|
|
47
|
+
assert check["status_code"] == 404
|
|
48
|
+
|
|
49
|
+
@pytest.mark.asyncio
|
|
50
|
+
async def test_error_handling_structured(vikunja_auth):
|
|
51
|
+
"""Verify that the client returns structured error data instead of crashing."""
|
|
52
|
+
base_url = vikunja_auth["base_url"]
|
|
53
|
+
token = vikunja_auth["token"]
|
|
54
|
+
|
|
55
|
+
async with VikunjaClient(base_url, token) as client:
|
|
56
|
+
# Request non-existent project
|
|
57
|
+
data = await client.request("GET", "/projects/999999")
|
|
58
|
+
assert "error" in data
|
|
59
|
+
assert data["status_code"] == 404
|
|
60
|
+
assert "details" in data
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import httpx
|
|
3
|
+
from vikunja_python.core.models.user import User
|
|
4
|
+
|
|
5
|
+
@pytest.mark.asyncio
|
|
6
|
+
async def test_container_running(vikunja_server):
|
|
7
|
+
"""Test that the container is reachable via HTTP."""
|
|
8
|
+
async with httpx.AsyncClient() as client:
|
|
9
|
+
resp = await client.get(f"{vikunja_server}/info")
|
|
10
|
+
assert resp.status_code == 200
|
|
11
|
+
data = resp.json()
|
|
12
|
+
assert "version" in data
|
|
13
|
+
|
|
14
|
+
@pytest.mark.asyncio
|
|
15
|
+
async def test_auth_works(vikunja_auth):
|
|
16
|
+
"""Test that authentication provides a valid token."""
|
|
17
|
+
assert vikunja_auth["token"] is not None
|
|
18
|
+
assert vikunja_auth["username"] is not None
|
|
19
|
+
|
|
20
|
+
@pytest.mark.asyncio
|
|
21
|
+
async def test_authenticated_client(async_client):
|
|
22
|
+
"""Test that the authenticated client can fetch the current user."""
|
|
23
|
+
resp = await async_client.get("/user")
|
|
24
|
+
assert resp.status_code == 200
|
|
25
|
+
user = User(**resp.json())
|
|
26
|
+
assert user.id > 0
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from vikunja_python.core.models.phase6_medium import (
|
|
3
|
+
Bucket, BucketCreateRequest, BucketUpdateRequest, BucketMoveRequest,
|
|
4
|
+
ReactionKind, Reaction, ReactionMapEntry, ReactionCreateRequest,
|
|
5
|
+
SubscriptionType, Subscription, SubscriptionCreateRequest
|
|
6
|
+
)
|
|
7
|
+
from vikunja_python.core.models.project import Project
|
|
8
|
+
|
|
9
|
+
@pytest.mark.asyncio
|
|
10
|
+
async def test_project_lifecycle(async_client):
|
|
11
|
+
"""CRUD lifecycle for Project to validate model"""
|
|
12
|
+
# 1. Create
|
|
13
|
+
project_data = {"title": "Hermes Test Project"}
|
|
14
|
+
resp = await async_client.put("/projects", json=project_data)
|
|
15
|
+
assert resp.status_code in [200, 201]
|
|
16
|
+
|
|
17
|
+
new_project_json = resp.json()
|
|
18
|
+
project_id = new_project_json['id']
|
|
19
|
+
|
|
20
|
+
project_obj = Project(**new_project_json)
|
|
21
|
+
assert project_obj.id == project_id
|
|
22
|
+
|
|
23
|
+
# 2. Get
|
|
24
|
+
resp = await async_client.get(f"/projects/{project_id}")
|
|
25
|
+
assert resp.status_code == 200
|
|
26
|
+
Project(**resp.json())
|
|
27
|
+
|
|
28
|
+
# 3. Delete
|
|
29
|
+
resp = await async_client.delete(f"/projects/{project_id}")
|
|
30
|
+
assert resp.status_code in [200, 204]
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_bucket_lifecycle(async_client):
|
|
34
|
+
"""CRUD lifecycle for Bucket"""
|
|
35
|
+
# 1. Create Project for bucket
|
|
36
|
+
project_data = {"title": "Project for Bucket Test"}
|
|
37
|
+
resp = await async_client.put("/projects", json=project_data)
|
|
38
|
+
assert resp.status_code in [200, 201]
|
|
39
|
+
project_id = resp.json()['id']
|
|
40
|
+
|
|
41
|
+
# Vikunja buckets need a view
|
|
42
|
+
# A view is automatically created for a project. Let's get the views.
|
|
43
|
+
resp = await async_client.get(f"/projects/{project_id}/views")
|
|
44
|
+
assert resp.status_code == 200
|
|
45
|
+
views = resp.json()
|
|
46
|
+
assert len(views) > 0
|
|
47
|
+
view_id = views[0]['id']
|
|
48
|
+
|
|
49
|
+
# 2. Create Bucket
|
|
50
|
+
bucket_payload = {"title": "Hermes Test Column"}
|
|
51
|
+
resp = await async_client.put(f"/projects/{project_id}/views/{view_id}/buckets", json=bucket_payload)
|
|
52
|
+
assert resp.status_code in [200, 201]
|
|
53
|
+
|
|
54
|
+
new_bucket_json = resp.json()
|
|
55
|
+
bucket_id = new_bucket_json['id']
|
|
56
|
+
Bucket(**new_bucket_json)
|
|
57
|
+
|
|
58
|
+
# Create a second bucket so we can delete the first one without a 412 precondition error
|
|
59
|
+
await async_client.put(f"/projects/{project_id}/views/{view_id}/buckets", json={"title": "Keep Column"})
|
|
60
|
+
|
|
61
|
+
# 3. Update
|
|
62
|
+
update_payload = {"title": "Hermes Updated Column"}
|
|
63
|
+
resp = await async_client.post(f"/projects/{project_id}/views/{view_id}/buckets/{bucket_id}", json=update_payload)
|
|
64
|
+
assert resp.status_code == 200
|
|
65
|
+
Bucket(**resp.json())
|
|
66
|
+
|
|
67
|
+
# 4. Delete
|
|
68
|
+
resp = await async_client.delete(f"/projects/{project_id}/views/{view_id}/buckets/{bucket_id}")
|
|
69
|
+
assert resp.status_code in [200, 204]
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_reaction_lifecycle(async_client):
|
|
73
|
+
"""CRUD lifecycle for Reaction"""
|
|
74
|
+
# 1. Create Project and Task
|
|
75
|
+
project_data = {"title": "Project for Reaction Test"}
|
|
76
|
+
resp = await async_client.put("/projects", json=project_data)
|
|
77
|
+
project_id = resp.json()['id']
|
|
78
|
+
|
|
79
|
+
task_payload = {"title": "Task for Reaction", "project_id": project_id}
|
|
80
|
+
resp = await async_client.put(f"/projects/{project_id}/tasks", json=task_payload)
|
|
81
|
+
assert resp.status_code in [200, 201], f"Failed to create task: {resp.text}"
|
|
82
|
+
task_id = resp.json()['id']
|
|
83
|
+
|
|
84
|
+
# 2. Create Reaction
|
|
85
|
+
reaction_payload = {"value": "❤️"}
|
|
86
|
+
# Note: Vikunja spec says PUT /tasks/{id}/reactions
|
|
87
|
+
resp = await async_client.put(f"/tasks/{task_id}/reactions", json=reaction_payload)
|
|
88
|
+
|
|
89
|
+
assert resp.status_code in [200, 201], f"Reaction failed: {resp.text}"
|
|
90
|
+
new_reaction_json = resp.json()
|
|
91
|
+
Reaction(**new_reaction_json)
|
|
92
|
+
|
|
93
|
+
# 3. Delete Reaction
|
|
94
|
+
resp = await async_client.post(f"/tasks/{task_id}/reactions/delete", json={"value": "❤️"})
|
|
95
|
+
assert resp.status_code in [200, 204]
|
|
96
|
+
|
|
97
|
+
@pytest.mark.asyncio
|
|
98
|
+
async def test_subscription_lifecycle(async_client):
|
|
99
|
+
"""CRUD lifecycle for Subscription"""
|
|
100
|
+
# 1. Create Project and Task
|
|
101
|
+
project_data = {"title": "Project for Sub Test"}
|
|
102
|
+
resp = await async_client.put("/projects", json=project_data)
|
|
103
|
+
project_id = resp.json()['id']
|
|
104
|
+
|
|
105
|
+
task_payload = {"title": "Task for Sub Test", "project_id": project_id}
|
|
106
|
+
resp = await async_client.put(f"/projects/{project_id}/tasks", json=task_payload)
|
|
107
|
+
task_id = resp.json()['id']
|
|
108
|
+
|
|
109
|
+
# 2. Create Subscription
|
|
110
|
+
# The spec actually says PUT /subscriptions/task/{entityID}
|
|
111
|
+
resp = await async_client.put(f"/subscriptions/task/{task_id}")
|
|
112
|
+
|
|
113
|
+
if resp.status_code == 200:
|
|
114
|
+
new_sub_json = resp.json()
|
|
115
|
+
Subscription(**new_sub_json)
|
|
116
|
+
else:
|
|
117
|
+
# Some versions just return 204 on PUT
|
|
118
|
+
assert resp.status_code in [200, 201, 204], f"Subscription failed: {resp.text}"
|
|
119
|
+
|
|
120
|
+
# 3. Delete Subscription
|
|
121
|
+
resp = await async_client.delete(f"/subscriptions/task/{task_id}")
|
|
122
|
+
assert resp.status_code in [200, 204]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from vikunja_python.core.models.task import Task
|
|
3
|
+
from vikunja_python.core.models.label import Label, LabelCreateRequest
|
|
4
|
+
|
|
5
|
+
@pytest.mark.asyncio
|
|
6
|
+
async def test_task_hierarchy_and_labels_integration(async_client):
|
|
7
|
+
"""
|
|
8
|
+
Mandatory integration test for Phase 6 enhancements:
|
|
9
|
+
1. Label creation
|
|
10
|
+
2. Hierarchical task creation (subtasks)
|
|
11
|
+
3. Detailed listing with expansion
|
|
12
|
+
"""
|
|
13
|
+
# 1. Create a Label
|
|
14
|
+
label_payload = {"title": "IntegrationTest", "hex_color": "#ff00ff"}
|
|
15
|
+
resp = await async_client.put("/labels", json=label_payload)
|
|
16
|
+
assert resp.status_code in [200, 201]
|
|
17
|
+
label_id = resp.json()["id"]
|
|
18
|
+
|
|
19
|
+
# 2. Create a Project
|
|
20
|
+
proj_resp = await async_client.put("/projects", json={"title": "Hierarchy Project"})
|
|
21
|
+
assert proj_resp.status_code in [200, 201]
|
|
22
|
+
project_id = proj_resp.json()["id"]
|
|
23
|
+
|
|
24
|
+
# 3. Create Parent Task
|
|
25
|
+
parent_resp = await async_client.put(f"/projects/{project_id}/tasks", json={"title": "Parent Task"})
|
|
26
|
+
assert parent_resp.status_code in [200, 201]
|
|
27
|
+
parent_id = parent_resp.json()["id"]
|
|
28
|
+
|
|
29
|
+
# 4. Create Subtask using the relation endpoint
|
|
30
|
+
sub_resp = await async_client.put(f"/projects/{project_id}/tasks", json={"title": "Child Task"})
|
|
31
|
+
assert sub_resp.status_code in [200, 201]
|
|
32
|
+
child_id = sub_resp.json()["id"]
|
|
33
|
+
|
|
34
|
+
# Create the subtask relationship
|
|
35
|
+
rel_payload = {"other_task_id": child_id, "relation_kind": "subtask"}
|
|
36
|
+
rel_resp = await async_client.put(f"/tasks/{parent_id}/relations", json=rel_payload)
|
|
37
|
+
assert rel_resp.status_code in [200, 201]
|
|
38
|
+
|
|
39
|
+
# 5. Add label to Parent
|
|
40
|
+
await async_client.put(f"/tasks/{parent_id}/labels", json={"label_id": label_id})
|
|
41
|
+
|
|
42
|
+
# 6. Test list_tasks with expansion (The core of the enhancement)
|
|
43
|
+
# GET /projects/{id}/tasks?expand=subtasks
|
|
44
|
+
list_resp = await async_client.get(f"/projects/{project_id}/tasks", params={"expand": ["subtasks"]})
|
|
45
|
+
assert list_resp.status_code == 200
|
|
46
|
+
tasks_data = list_resp.json()
|
|
47
|
+
|
|
48
|
+
# Use the Task model to parse the response
|
|
49
|
+
tasks = [Task(**item) for item in tasks_data]
|
|
50
|
+
parent_task = next((t for t in tasks if t.id == parent_id), None)
|
|
51
|
+
|
|
52
|
+
assert parent_task is not None
|
|
53
|
+
assert len(parent_task.subtasks) == 1
|
|
54
|
+
assert parent_task.subtasks[0].id == child_id
|
|
55
|
+
assert parent_task.subtasks[0].title == "Child Task"
|
|
56
|
+
|
|
57
|
+
# 7. Verify Labels are present (v2.3.0 returns them by default)
|
|
58
|
+
assert len(parent_task.labels) == 1
|
|
59
|
+
assert parent_task.labels[0].id == label_id
|
|
60
|
+
assert parent_task.labels[0].hex_color == "#ff00ff"
|
|
61
|
+
|
|
62
|
+
@pytest.mark.asyncio
|
|
63
|
+
async def test_pagination_integration(async_client):
|
|
64
|
+
"""Verify that pagination parameters are respected."""
|
|
65
|
+
# Create a project and 3 tasks
|
|
66
|
+
proj_resp = await async_client.put("/projects", json={"title": "Pagination Project"})
|
|
67
|
+
project_id = proj_resp.json()["id"]
|
|
68
|
+
|
|
69
|
+
for i in range(3):
|
|
70
|
+
await async_client.put(f"/projects/{project_id}/tasks", json={"title": f"Task {i}"})
|
|
71
|
+
|
|
72
|
+
# List with per_page=2
|
|
73
|
+
list_resp = await async_client.get("/tasks", params={"per_page": 2, "filter": f"project_id = {project_id}"})
|
|
74
|
+
assert list_resp.status_code == 200
|
|
75
|
+
assert len(list_resp.json()) == 2
|
|
76
|
+
|
|
77
|
+
# Check headers for pagination info (as defined in base.py/task.py)
|
|
78
|
+
assert "x-pagination-total-pages" in list_resp.headers
|
|
79
|
+
assert int(list_resp.headers["x-pagination-total-pages"]) >= 2
|
|
80
|
+
|
|
81
|
+
@pytest.mark.asyncio
|
|
82
|
+
async def test_expand_parameter_sanitization(async_client, vikunja_auth):
|
|
83
|
+
"""
|
|
84
|
+
Verify that the MCP list_tasks tool correctly sanitizes the expand parameter
|
|
85
|
+
to prevent 412 Precondition Failed errors from the Vikunja API.
|
|
86
|
+
"""
|
|
87
|
+
# 1. Create a Project
|
|
88
|
+
proj_resp = await async_client.put("/projects", json={"title": "Expand Test Project"})
|
|
89
|
+
assert proj_resp.status_code in [200, 201]
|
|
90
|
+
project_id = proj_resp.json()["id"]
|
|
91
|
+
|
|
92
|
+
# 2. Call the raw API with an invalid expand parameter to prove it fails (412)
|
|
93
|
+
raw_resp = await async_client.get("/tasks", params={"expand": ["labels", "invalid_field"]})
|
|
94
|
+
assert raw_resp.status_code == 412
|
|
95
|
+
|
|
96
|
+
# 3. Import and call the MCP tool function directly to test its sanitization logic
|
|
97
|
+
from vikunja_python.mcp.server import list_tasks
|
|
98
|
+
import os
|
|
99
|
+
|
|
100
|
+
# Set env vars temporarily for the tool's get_client() call
|
|
101
|
+
os.environ["VIKUNJA_URL"] = vikunja_auth["base_url"]
|
|
102
|
+
os.environ["VIKUNJA_API_TOKEN"] = vikunja_auth["token"]
|
|
103
|
+
|
|
104
|
+
# Call tool with invalid 'labels' and valid 'subtasks'
|
|
105
|
+
# The tool should filter out 'labels' and send only 'subtasks', returning 200 OK.
|
|
106
|
+
result = await list_tasks(project_id=project_id, expand=["labels", "subtasks"])
|
|
107
|
+
|
|
108
|
+
# If it failed with 412, result would start with "Error fetching tasks: 412" or similar
|
|
109
|
+
assert "Error fetching tasks" not in result
|
|
110
|
+
assert "No tasks found" in result or "ID:" in result # Should succeed (return empty string or tasks)
|
|
111
|
+
|
|
112
|
+
# 4. Call tool with ONLY invalid params
|
|
113
|
+
# The tool should filter all out and send no expand param at all, returning 200 OK.
|
|
114
|
+
result2 = await list_tasks(project_id=project_id, expand=["labels", "invalid_field"])
|
|
115
|
+
assert "Error fetching tasks" not in result2
|
|
116
|
+
assert "No tasks found" in result2 or "ID:" in result2
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
async def test_invalid_expand_sanitization_v2(async_client, vikunja_auth):
|
|
120
|
+
"""
|
|
121
|
+
Explicitly verify that 'attachments', 'reminders', and 'assignees'
|
|
122
|
+
are sanitized out to prevent 412 errors.
|
|
123
|
+
"""
|
|
124
|
+
from vikunja_python.mcp.server import list_tasks
|
|
125
|
+
import os
|
|
126
|
+
|
|
127
|
+
os.environ["VIKUNJA_URL"] = vikunja_auth["base_url"]
|
|
128
|
+
os.environ["VIKUNJA_API_TOKEN"] = vikunja_auth["token"]
|
|
129
|
+
|
|
130
|
+
# These three specifically caused 412 errors in the wild
|
|
131
|
+
invalid_expands = ["attachments", "reminders", "assignees"]
|
|
132
|
+
|
|
133
|
+
# Call tool with these invalid expands
|
|
134
|
+
# If sanitization fails, the API will return 412 and the tool will return an error string.
|
|
135
|
+
result = await list_tasks(expand=invalid_expands)
|
|
136
|
+
|
|
137
|
+
assert "Error fetching tasks: 412" not in result
|
|
138
|
+
assert "Error fetching tasks" not in result
|
|
139
|
+
|
|
140
|
+
@pytest.mark.asyncio
|
|
141
|
+
async def test_get_task_expansion_integration(async_client, vikunja_auth):
|
|
142
|
+
"""Verify that get_task supports full expansion including attachments/reminders."""
|
|
143
|
+
from vikunja_python.mcp.server import get_task
|
|
144
|
+
import os
|
|
145
|
+
|
|
146
|
+
os.environ["VIKUNJA_URL"] = vikunja_auth["base_url"]
|
|
147
|
+
os.environ["VIKUNJA_API_TOKEN"] = vikunja_auth["token"]
|
|
148
|
+
|
|
149
|
+
# 1. Create a task with a description
|
|
150
|
+
proj_resp = await async_client.put("/projects", json={"title": "GetTask Project"})
|
|
151
|
+
project_id = proj_resp.json()["id"]
|
|
152
|
+
task_resp = await async_client.put(f"/projects/{project_id}/tasks", json={"title": "Detailed Task", "description": "Full details here"})
|
|
153
|
+
task_id = task_resp.json()["id"]
|
|
154
|
+
|
|
155
|
+
# 2. Test get_task with expansion
|
|
156
|
+
# Note: We can't easily upload attachments in this test without more setup,
|
|
157
|
+
# but we can verify the API call doesn't 412.
|
|
158
|
+
result = await get_task(task_id=task_id, expand=["attachments", "reminders", "assignees", "comments"])
|
|
159
|
+
|
|
160
|
+
assert "Error fetching task" not in result
|
|
161
|
+
assert "ID: " in result
|
|
162
|
+
assert "Full details here" in result
|
|
163
|
+
assert "Detailed Task" in result
|
|
164
|
+
|
|
165
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from vikunja_python.core.models.task import Task
|
|
3
|
+
from vikunja_python.core.models.base import Label
|
|
4
|
+
|
|
5
|
+
def test_task_model_with_subtasks():
|
|
6
|
+
"""Verify that the Task model can handle nested subtasks."""
|
|
7
|
+
subtask_data = {
|
|
8
|
+
"id": 2,
|
|
9
|
+
"title": "Subtask 1",
|
|
10
|
+
"identifier": "#2",
|
|
11
|
+
"project_id": 1,
|
|
12
|
+
"done": False,
|
|
13
|
+
"created": "2026-05-05T12:00:00Z",
|
|
14
|
+
"updated": "2026-05-05T12:00:00Z"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
task_data = {
|
|
18
|
+
"id": 1,
|
|
19
|
+
"title": "Parent Task",
|
|
20
|
+
"identifier": "#1",
|
|
21
|
+
"project_id": 1,
|
|
22
|
+
"done": False,
|
|
23
|
+
"created": "2026-05-05T12:00:00Z",
|
|
24
|
+
"updated": "2026-05-05T12:00:00Z",
|
|
25
|
+
"subtasks": [subtask_data]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
task = Task(**task_data)
|
|
29
|
+
assert task.id == 1
|
|
30
|
+
assert len(task.subtasks) == 1
|
|
31
|
+
assert task.subtasks[0].id == 2
|
|
32
|
+
assert task.subtasks[0].title == "Subtask 1"
|
|
33
|
+
|
|
34
|
+
def test_task_model_with_labels():
|
|
35
|
+
"""Verify that the Task model handles expanded labels."""
|
|
36
|
+
label_data = {
|
|
37
|
+
"id": 10,
|
|
38
|
+
"title": "Urgent",
|
|
39
|
+
"hex_color": "ff0000",
|
|
40
|
+
"created": "2026-05-05T12:00:00Z",
|
|
41
|
+
"updated": "2026-05-05T12:00:00Z"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
task_data = {
|
|
45
|
+
"id": 1,
|
|
46
|
+
"title": "Task with labels",
|
|
47
|
+
"identifier": "#1",
|
|
48
|
+
"project_id": 1,
|
|
49
|
+
"done": False,
|
|
50
|
+
"created": "2026-05-05T12:00:00Z",
|
|
51
|
+
"updated": "2026-05-05T12:00:00Z",
|
|
52
|
+
"labels": [label_data]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
task = Task(**task_data)
|
|
56
|
+
assert len(task.labels) == 1
|
|
57
|
+
assert task.labels[0].title == "Urgent"
|
|
58
|
+
assert task.labels[0].hex_color == "#ff0000"
|
|
59
|
+
|
|
60
|
+
def test_label_model_validation():
|
|
61
|
+
"""Test label model validation (hex color format)."""
|
|
62
|
+
# Both models now normalize to include # prefix
|
|
63
|
+
|
|
64
|
+
from vikunja_python.core.models.base import Label as BaseLabel
|
|
65
|
+
l1 = BaseLabel(id=1, title="test", hex_color="ff0000", created="2026-05-05T12:00:00Z", updated="2026-05-05T12:00:00Z")
|
|
66
|
+
assert l1.hex_color == "#ff0000"
|
|
67
|
+
|
|
68
|
+
from vikunja_python.core.models.label import Label as FullLabel
|
|
69
|
+
l2 = FullLabel(id=1, title="test", hex_color="#ff0000")
|
|
70
|
+
assert l2.hex_color == "#ff0000"
|
|
71
|
+
|
|
72
|
+
with pytest.raises(ValueError):
|
|
73
|
+
FullLabel(id=1, title="test", hex_color="invalid")
|
|
File without changes
|
|
File without changes
|