diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..22fc3a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Lint with ruff + run: | + ruff check src/ tests/ + ruff format --check src/ tests/ + + - name: Type check with mypy + run: mypy src/ + + - name: Run tests + run: pytest --cov=lansweeper_helpdesk --cov-report=xml -v + + - name: Upload coverage + if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v4 + with: + file: coverage.xml + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..c41001a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,47 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +permissions: + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tools + run: pip install build + + - name: Build package + run: python -m build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1271ce1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg + +# Virtual environments +.venv/ +venv/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Type checking / linting caches +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ + +# Coverage +htmlcov/ +.coverage +.coverage.* +coverage.xml + +# Environment / secrets +.env +config/config.json + +# OS +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1e54cd2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +## [0.1.0] - Unreleased + +Initial release as a proper Python package. + +### Added + +- Installable package via `pip install lansweeper-helpdesk`. +- Full type annotations on all public methods and parameters. +- Custom exception hierarchy: `HelpdeskError`, `APIError`, `ConfigurationError`, `TicketNotFoundError`. +- `TicketState` and `NoteType` enums for common API values. +- `py.typed` marker for PEP 561 type checker support. +- Comprehensive test suite using pytest and responses. +- CI pipeline (GitHub Actions) with testing across Python 3.10–3.13, ruff linting, and mypy type checking. +- PyPI publishing workflow via GitHub Releases using trusted publishers (OIDC). + +### Changed + +- **BREAKING**: Renamed `type` parameter to `note_type` in `add_note()` and `ticket_type` in `edit_ticket()` to avoid shadowing the Python builtin. +- **BREAKING**: Renamed `search_ticket()` to `search_tickets()` with snake_case parameters (`from_user_id`, `agent_id`, `ticket_type`, `max_results`, `min_date`, `max_date`). All parameters are now keyword-only. +- **BREAKING**: API errors now raise `APIError` instead of returning `None`. +- **BREAKING**: `get_ticket_history()` now returns a `list[dict]` of parsed notes instead of a formatted JSON string. +- Replaced all `print()` calls with `logging` — the SDK no longer writes to stdout. +- `cert_path` is now optional (defaults to standard certificate verification). +- Internal request method renamed from `make_request` to `_request`. + +### Removed + +- `pretty_print_response()` — libraries should not print to stdout; use `json.dumps(response, indent=2)` if needed. +- `usage_decorator` — replaced by proper type annotations and exception handling. +- Hard-coded `time.sleep(1)` in `get_ticket_history()`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1301caa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing + +Contributions are welcome! Here's how to get started. + +## Setup + +1. Fork and clone the repository: + + ```bash + git clone https://github.com//Lansweeper.Helpdesk-Python.git + cd Lansweeper.Helpdesk-Python + ``` + +2. Create a virtual environment and install dev dependencies: + + ```bash + python -m venv .venv + source .venv/bin/activate # or .venv\Scripts\activate on Windows + pip install -e ".[dev]" + ``` + +## Development Workflow + +### Running Tests + +```bash +pytest --cov +``` + +All new features and bug fixes should include tests. + +### Linting & Formatting + +```bash +ruff check src/ tests/ +ruff format src/ tests/ +``` + +### Type Checking + +```bash +mypy src/ +``` + +## Submitting Changes + +1. Create a feature branch from `main`. +2. Make your changes with clear, descriptive commits. +3. Ensure all tests pass, linting is clean, and type checking succeeds. +4. Open a pull request against `main`. + +## Code Style + +- Follow existing patterns in the codebase. +- Use type annotations on all public functions and methods. +- Use `logging` (never `print()`) for any debug or status output. +- Keep docstrings up to date when modifying public API methods. diff --git a/README.md b/README.md new file mode 100644 index 0000000..83d996f --- /dev/null +++ b/README.md @@ -0,0 +1,220 @@ +# lansweeper-helpdesk + +[![CI](https://github.com/ds-brandao/Lansweeper.Helpdesk-Python/actions/workflows/ci.yml/badge.svg)](https://github.com/ds-brandao/Lansweeper.Helpdesk-Python/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/lansweeper-helpdesk)](https://pypi.org/project/lansweeper-helpdesk/) +[![Python](https://img.shields.io/pypi/pyversions/lansweeper-helpdesk)](https://pypi.org/project/lansweeper-helpdesk/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +A Python SDK for the [Lansweeper Helpdesk API](https://www.lansweeper.com/). Create, retrieve, search, and manage helpdesk tickets programmatically. + +> **Note:** The Lansweeper Helpdesk API appears to be unmaintained and may be deprecated in the future. This SDK wraps the existing API as-is. + +## Installation + +```bash +pip install lansweeper-helpdesk +``` + +## Quick Start + +```python +from lansweeper_helpdesk import HelpdeskAPI + +api = HelpdeskAPI( + base_url="https://your-helpdesk-url:443/api.aspx", + api_key="your-api-key", + cert_path="/path/to/cert.pem", # optional +) + +# Create a ticket +ticket = api.create_ticket( + subject="Network Issue", + description="Cannot reach internal network", + email="user@example.com", +) + +# Get ticket details (HTML is auto-stripped) +ticket = api.get_ticket("12345") +print(ticket["Description"]) +``` + +## API Reference + +### `HelpdeskAPI(base_url, api_key, cert_path=None)` + +Initialize the client. + +| Parameter | Type | Description | +|-------------|-----------------|----------------------------------------------------| +| `base_url` | `str` | Base URL of the Lansweeper Helpdesk API | +| `api_key` | `str` | API key for authentication | +| `cert_path` | `str` or `None` | Path to SSL certificate file (optional) | + +### Ticket Operations + +#### `create_ticket(subject, description, email) -> dict` + +Create a new helpdesk ticket. + +```python +api.create_ticket( + subject="Printer not working", + description="Office printer on floor 3 is offline", + email="user@company.com", +) +``` + +#### `get_ticket(ticket_id) -> dict` + +Retrieve ticket details. HTML in the `Description` field is automatically converted to plain text. + +```python +ticket = api.get_ticket("12345") +``` + +#### `get_ticket_history(ticket_id) -> list[dict]` + +Get all notes for a ticket. HTML content in note text fields is auto-stripped. + +```python +notes = api.get_ticket_history("12345") +for note in notes: + print(note["Text"]) +``` + +#### `edit_ticket(ticket_id, state, ticket_type, email) -> dict` + +Update a ticket's state and type. + +```python +api.edit_ticket( + ticket_id="12345", + state="Closed", + ticket_type="Hardware Repair", + email="agent@company.com", +) +``` + +### Notes + +#### `add_note(ticket_id, text, email, note_type="Public") -> dict` + +Add a note to a ticket. Use `note_type="Internal"` for agent-only notes. + +```python +api.add_note( + ticket_id="12345", + text="Router restart resolved the issue", + email="agent@company.com", + note_type="Internal", +) +``` + +### Search + +#### `search_tickets(*, state, from_user_id, agent_id, description, subject, ticket_type, max_results, min_date, max_date) -> dict` + +Search tickets with optional filters. All parameters are keyword-only and optional. + +```python +results = api.search_tickets( + state="Open", + ticket_type="Hardware Repair", + max_results=50, + min_date="2024-01-01", + max_date="2024-12-31", +) +``` + +#### `get_user(email) -> dict` + +Look up a user by email address. + +```python +user = api.get_user("user@company.com") +``` + +### Enums + +The SDK provides convenience enums for common values: + +```python +from lansweeper_helpdesk import TicketState, NoteType + +api.search_tickets(state=TicketState.OPEN) +api.add_note("12345", "note text", "a@b.com", note_type=NoteType.INTERNAL) +``` + +### Error Handling + +All API methods raise typed exceptions instead of returning `None`: + +```python +from lansweeper_helpdesk import HelpdeskAPI, APIError, ConfigurationError + +try: + api = HelpdeskAPI(base_url="", api_key="key") +except ConfigurationError as e: + print(f"Bad config: {e}") + +try: + ticket = api.get_ticket("99999") +except APIError as e: + print(f"API error (HTTP {e.status_code}): {e}") +``` + +| Exception | When | +|------------------------|----------------------------------------------------| +| `HelpdeskError` | Base class for all SDK exceptions | +| `ConfigurationError` | Invalid client configuration | +| `APIError` | HTTP or API-level error (has `.status_code`) | +| `TicketNotFoundError` | Requested ticket does not exist | + +## Configuration + +You can load credentials from a JSON config file: + +```python +import json +from lansweeper_helpdesk import HelpdeskAPI + +with open("config/config.json") as f: + config = json.load(f) + +api = HelpdeskAPI(**config) +``` + +Example `config/config.json`: + +```json +{ + "base_url": "https://your-helpdesk-url:443/api.aspx", + "api_key": "your-api-key-here", + "cert_path": "path/to/your/certificate.pem" +} +``` + +## Development + +```bash +# Clone and install in dev mode +git clone https://github.com/ds-brandao/Lansweeper.Helpdesk-Python.git +cd Lansweeper.Helpdesk-Python +pip install -e ".[dev]" + +# Run tests +pytest --cov + +# Lint & format +ruff check src/ tests/ +ruff format src/ tests/ + +# Type check +mypy src/ +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines. + +## License + +MIT License — see [LICENSE](LICENSE) for details. diff --git a/config/config.json b/config/config.example.json similarity index 100% rename from config/config.json rename to config/config.example.json diff --git a/helpdesk.py b/helpdesk.py deleted file mode 100644 index fec85dc..0000000 --- a/helpdesk.py +++ /dev/null @@ -1,388 +0,0 @@ -import requests -import os -import functools -import json -import time -from bs4 import BeautifulSoup -import json -import logging - - -class HelpdeskAPI: - """A wrapper class for the Lansweeper Helpdesk API. - - This class provides methods to interact with the Lansweeper Helpdesk API, - including creating, retrieving, and managing tickets, adding notes, and searching users. - - Attributes: - base_url (str): The base URL of the Lansweeper Helpdesk API. - api_key (str): The API key for authentication. - cert_path (str): Path to the SSL certificate file. - session (requests.Session): A session object for making HTTP requests. - """ - - def __init__(self, - base_url=None, - api_key=None, - cert_path=None): - """Initialize the HelpdeskAPI client. - - Args: - base_url (str): The base URL of the Lansweeper Helpdesk API. - api_key (str): The API key for authentication. - cert_path (str): Path to the SSL certificate file. - - Raises: - ValueError: If base_url or api_key is not provided. - FileNotFoundError: If the certificate file is not found. - """ - - self.base_url = base_url - self.api_key = api_key - self.cert_path = fr"{cert_path}" - - if not self.base_url or not self.api_key: - raise ValueError("Base URL and API key must be provided.") - - if not os.path.isfile(self.cert_path): - raise FileNotFoundError(f"Certificate file not found: {self.cert_path}") - - self.session = requests.Session() - self.session.verify = self.cert_path - - # Function to pretty-print JSON response - def pretty_print_response(self, response): - """Pretty print the API response in a formatted JSON structure. - - Args: - response (dict): The API response to format and print. - """ - if response: - print(json.dumps(response, indent=4, sort_keys=True)) - else: - print("No response or an error occurred.") - - def make_request(self, action, method='GET', params=None, data=None, files=None): - """Make an HTTP request to the Lansweeper Helpdesk API. - - Args: - action (str): The API action to perform. - method (str, optional): HTTP method to use ('GET' or 'POST'). Defaults to 'GET'. - params (dict, optional): Query parameters to include. Defaults to None. - data (dict, optional): Data to send in the request body. Defaults to None. - files (dict, optional): Files to upload. Defaults to None. - - Returns: - dict or str: The API response, parsed as JSON if possible, otherwise as raw text. - None: If the request fails or returns empty response. - """ - params = params or {} - data = data or {} - - params['Action'] = action - params['Key'] = self.api_key - - url = self.base_url - - #print(f"Making {method} request to {url} with params: {params} and data: {data}") - - try: - if method == 'GET': - response = self.session.get(url, params=params) - elif method == 'POST': - response = self.session.post(url, data=params, files=files) - - response.raise_for_status() - print(f"Response Status Code: {response.status_code}") - - # Check if the response is empty - if not response.text: - print("Empty response received.") - return None - - # Log the raw response text for debugging - print(f"Raw Response Text: {response.text}") - - # Attempt to parse JSON response - try: - return response.json() - except json.JSONDecodeError: - print("Response is not in JSON format.") - return response.text # Return raw text if not JSON - except requests.RequestException as e: - print(f"An error occurred: {e}") - return None - - def usage_decorator(func): - @functools.wraps(func) - def wrapper(self, *args, **kwargs): - try: - return func(self, *args, **kwargs) - except TypeError as e: - print(f"Incorrect usage of {func.__name__}: {e}") - print(f"Usage: {func.__doc__}") - return None - return wrapper - - @usage_decorator - def create_ticket(self, subject, description, email): - """Create a new ticket in the helpdesk system. - - Args: - subject (str): The subject line of the ticket. - description (str): Detailed description of the issue or request. - email (str): Email address of the ticket requester. - - Returns: - dict: The API response containing the created ticket information. - None: If the ticket creation fails. - - Example: - >>> api.create_ticket( - subject="Network Issue", - description="Unable to connect to internal network", - email="user@example.com" - ) - """ - params = { - 'Subject': subject, - 'Description': description, - 'Email': email, - } - response = self.make_request('AddTicket', method='POST', params=params) - self.pretty_print_response(response) - return response - - @usage_decorator - def get_ticket(self, ticket_id): - """Retrieve details of a specific ticket. - - Args: - ticket_id (str): The unique identifier of the ticket. - - Returns: - dict: Ticket information including status, description, and metadata. - HTML in description field is automatically converted to plain text. - None: If the ticket retrieval fails or ticket doesn't exist. - - Example: - >>> api.get_ticket("12345") - """ - params = { - 'TicketID': ticket_id, - } - response = self.make_request('GetTicket', method='GET', params=params) - - if response: - #print("Raw response:", response) - if 'Description' in response: - soup = BeautifulSoup(response['Description'], 'html.parser') - response['Description'] = soup.get_text() - self.pretty_print_response(response) - else: - print("No response received or response is empty") - - return response - - @usage_decorator - def get_ticket_history(self, ticket_id): - """Retrieve the complete history of a ticket including all notes and updates. - - Args: - ticket_id (str): The unique identifier of the ticket. - - Returns: - str: Formatted JSON string containing the ticket's history. - Includes all notes with HTML content converted to plain text. - None: If the history retrieval fails. - - Note: - This method includes a 1-second delay between requests to prevent API rate limiting. - """ - params = { - 'TicketID': ticket_id, - } - - ticket_info = [] - - response = self.make_request('GetNotes', method='GET', params=params) - - if not response: - print("No response received or response is empty") - return None - - # Parse HTML in 'Notes' field if it is not None - if response.get('Notes') is not None: - notes = [] - for note in response['Notes']: - note_str = json.dumps(note) - soup = BeautifulSoup(note_str, 'html.parser') - notes.append(soup.get_text()) - response['Notes'] = '\n\n'.join(notes) - - ticket_info.append(response) - - # pause for 1 second - time.sleep(1) - - # Parse and pretty-print the ticket history - for ticket in ticket_info: - if 'Notes' in ticket: - # Parse the nested JSON string in 'Notes' - notes = ticket['Notes'].split('\n\n') - parsed_notes = [] - for note in notes: - if note.strip(): - try: - parsed_notes.append(json.loads(note)) - except json.JSONDecodeError: - print(f"Invalid JSON format in note: {note}") - parsed_notes.append(note) # Append raw note if JSON parsing fail - ticket['Notes'] = parsed_notes - - # Pretty-print the JSON response - formatted_ticket_info = json.dumps(ticket_info, indent=4) - return formatted_ticket_info - - @usage_decorator - def add_note(self, ticket_id, text, email, type) -> dict | None: - """Add a note to an existing ticket. - - Args: - ticket_id (str): The unique identifier of the ticket. - text (str): The content of the note to add. - email (str): Email address of the note author. - type (str): Type of note - either 'Public' or 'Internal'. - - Returns: - dict: The API response confirming note addition. - None: If adding the note fails. - - Example: - >>> api.add_note( - ticket_id="12345", - text="Updated status with customer", - email="agent@example.com", - type="Public" - ) - """ - try: - logging.info(f"Adding note to ticket {ticket_id}") - logging.info(f"Note text length: {len(text)}") - logging.info(f"Email: {email}") - logging.info(f"Note type: {type}") - - params = { - 'TicketID': ticket_id, - 'Text': text, - 'Email': email, - 'Type': type - } - - response = self.make_request('AddNote', method='POST', params=params) - logging.info(f"Raw API response: {response}") - return response - except Exception as e: - logging.error(f"Error in add_note: {str(e)}", exc_info=True) - return None - - @usage_decorator - def search_ticket(self, state=None, FromUserId=None, AgentId=None, Description=None, Subject=None, Type=None, MaxResults=None, MinDate=None, MaxDate=None): - """Search for tickets based on various criteria. - - Args: - state (str, optional): Ticket state ('Open', 'Closed', etc.). - FromUserId (str, optional): ID of the ticket creator. - AgentId (str, optional): ID of the assigned agent. - Description (str, optional): Search text in ticket description. - Subject (str, optional): Search text in ticket subject. - Type (str, optional): Ticket type (e.g., 'Hardware Repair', 'Network', etc.). - MaxResults (int, optional): Maximum number of results to return (default 100). - MinDate (str, optional): Start date for ticket search (format: YYYY-MM-DD). - MaxDate (str, optional): End date for ticket search (format: YYYY-MM-DD). - - Returns: - dict: List of matching ticket IDs and their information. - None: If the search fails. - - Note: - If MaxResults is set lower than the actual number of matching tickets, - the API will return an empty list. - - Example: - >>> api.search_ticket( - state="Open", - Type="Hardware Repair", - MaxResults=50 - ) - """ - params = { - 'State': state, - 'FromUserId': FromUserId, - 'AgentId': AgentId, - 'Description': Description, - 'Subject': Subject, - 'Type': Type, - 'MaxResults': MaxResults, # if lower than the tickets returned, the api will bitch about it and return an empty list. - 'MinDate': MinDate, - 'MaxDate': MaxDate - } - # Remove keys with None values - params = {k: v for k, v in params.items() if v is not None} - - response = self.make_request('SearchTickets', method='GET', params=params) - self.pretty_print_response(response) - return response # returns a list of ticketIDs - - @usage_decorator - def get_user(self, email): - """Retrieve user information by email address. - - Args: - email (str): Email address of the user to look up. - - Returns: - dict: User information including ID and profile details. - None: If the user is not found or lookup fails. - - Example: - >>> api.get_user("user@example.com") - """ - params = { - 'Email': email - } - response = self.make_request('SearchUsers', method='GET', params=params) - self.pretty_print_response(response) - return response - - @usage_decorator - def edit_ticket(self, ticket_id, state, type, email): - """Update an existing ticket's properties. - - Args: - ticket_id (str): The unique identifier of the ticket to edit. - state (str): New state for the ticket ('Open', 'Closed', etc.). - type (str): New type for the ticket (e.g., 'Hardware Repair', 'Network'). - email (str): Email address of the person making the edit. - - Returns: - dict: The API response confirming ticket updates. - None: If the ticket update fails. - - Example: - >>> api.edit_ticket( - ticket_id="12345", - state="Closed", - type="Hardware Repair", - email="agent@example.com" - ) - """ - params = { - 'TicketID': ticket_id, - 'State': state, - 'Type': type, - 'Email': email - } - response = self.make_request('EditTicket', method='POST', params=params) - self.pretty_print_response(response) - return response diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..247acff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "lansweeper-helpdesk" +dynamic = ["version"] +description = "Python SDK for the Lansweeper Helpdesk API" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +authors = [ + { name = "Daniel Brandao" }, +] +keywords = ["lansweeper", "helpdesk", "api", "sdk", "itsm"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "requests>=2.28", + "beautifulsoup4>=4.12", +] + +[project.urls] +Homepage = "https://github.com/ds-brandao/Lansweeper.Helpdesk-Python" +Repository = "https://github.com/ds-brandao/Lansweeper.Helpdesk-Python" +Issues = "https://github.com/ds-brandao/Lansweeper.Helpdesk-Python/issues" +Changelog = "https://github.com/ds-brandao/Lansweeper.Helpdesk-Python/blob/main/CHANGELOG.md" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "responses>=0.23", + "ruff>=0.4", + "mypy>=1.10", + "types-requests>=2.31", + "types-beautifulsoup4>=4.12", +] + +[tool.hatch.version] +path = "src/lansweeper_helpdesk/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/lansweeper_helpdesk"] + +[tool.ruff] +target-version = "py310" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_configs = true + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/readme.md b/readme.md deleted file mode 100644 index ab14118..0000000 --- a/readme.md +++ /dev/null @@ -1,194 +0,0 @@ -# Helpdesk API Python Wrapper - -I created this wrapper to make it easier to interact with the Lansweeper Helpdesk API and integrate it with other projects that I am working on that require helpdesk integration. This is a work in progress and I will be adding more features as I need them, there are many requests that are available in the original Lansweeper Helpdesk API that are not available in this wrapper. - -It seems like the Lansweeper Helpdesk API is a bit of a mess and I have no idea what the future holds for it, looks like it is not being maintained and may be deprecated in the future or it is already deprecated. - -If you find this useful, please feel free to contribute to the project and make it better!!! - -## Table of Contents - -- [Features](#features) -- [Requirements](#requirements) -- [Installation](#installation) -- [Initialization](#initialization) -- [Usage](#usage) - - [Create Ticket](#create-ticket) - - [Get Ticket](#get-ticket) - - [Get Ticket History](#get-ticket-history) - - [Add Note](#add-note) - - [Search Tickets](#search-tickets) - - [Get User](#get-user) - - [Edit Ticket](#edit-ticket) -- [Contributing](#contributing) -- [License](#license) - -## Features - -- **Ticket Management**: Create, retrieve, and edit helpdesk tickets. -- **Notes Handling**: Add notes to existing tickets and retrieve full ticket history. -- **Searching**: Search for tickets based on various parameters (state, type, dates, etc.). -- **User Lookup**: Retrieve user information from the helpdesk system by email. - -## Requirements - -- `requests` -- `bs4` -- A valid base URL, API key, and a corresponding certificate file for SSL verification. - -## Installation - -1. Clone this repository - -2. Install required dependencies: - ```bash - pip install -r requirements.txt - ``` - -## Initialization - -To use the API wrapper, you'll need to initialize it with your credentials: - -```python -from helpdesk import HelpdeskAPI - -api = HelpdeskAPI( - base_url="https://your-helpdesk-url/api", - api_key="your-api-key", - cert_path="path/to/your/certificate.pem" -) -``` - -## Usage - -### Create Ticket - -Create a new ticket in the helpdesk system: - -```python -response = api.create_ticket( - subject="Network Connection Issue", - description="User unable to connect to internal network", - email="user@company.com" # The API takes in either an email or an username. Email is easier when tagging a svc_account. -) -``` - -### Get Ticket - -Retrieve details of a specific ticket: - -```python -ticket = api.get_ticket("12345") -# HTML in description is automatically converted to plain text -print(ticket['Description']) -``` - -### Get Ticket History - -Retrieve the complete history of a ticket including all notes: - -```python -history = api.get_ticket_history("12345") -# Returns formatted JSON string with HTML content converted to plain text -``` - -### Add Note - -Add a note to an existing ticket: - -```python -response = api.add_note( - ticket_id="12345", - text="Investigated the issue - router restart required", - email="technician@company.com", - type="Internal" # or "Public" (Public is visible to the user) -) -``` - -### Search Tickets - -Search for tickets using various filters: - -```python -# Search for open hardware repair tickets -tickets = api.search_ticket( - state="Open", - Type="Hardware Repair", - MaxResults=50, - MinDate="2024-01-01", - MaxDate="2024-12-31" -) - -# Available search parameters: -# - state: Ticket state (Open, Closed, etc.) -# - FromUserId: ID of ticket creator -# - AgentId: ID of assigned agent -# - Description: Search in ticket description -# - Subject: Search in ticket subject -# - Type: Ticket type -# - MaxResults: Maximum number of results (default 100) - If the number of tickets returned is greater than the MaxResults, the API will bitch about it. -# - MinDate: Start date (YYYY-MM-DD) -# - MaxDate: End date (YYYY-MM-DD) -``` - -### Get User - -Look up user information by email: - -```python -user_info = api.get_user("user@company.com") -``` - -### Edit Ticket - -Update an existing ticket's properties: - -```python -response = api.edit_ticket( - ticket_id="12345", - state="Closed", - type="Hardware Repair", - email="technician@company.com" -) -``` - -## Configuration - -Use a `config.json` file in the config directory: - -```json -{ - "base_url": "https://your-helpdesk-url/api", - "api_key": "your-api-key", - "cert_path": "path/to/your/certificate.pem" -} -``` - -Then load it: - -```python -import json - -with open('config/config.json') as f: - config = json.load(f) - -api = HelpdeskAPI(**config) -``` - -## Contributing - -Not sure if there are many using the helpdesk but if you are interested in contributing, please do so. - -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## Support - -What is this? - -## Notes - -- Rate limiting is built into certain methods (e.g., `get_ticket_history`) -- HTML content in descriptions and notes is automatically converted to plain text -- The `MaxResults` parameter in search_ticket must be greater than or equal to the number of matching tickets \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a151126..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -beautifulsoup4 -requests \ No newline at end of file diff --git a/src/lansweeper_helpdesk/__init__.py b/src/lansweeper_helpdesk/__init__.py new file mode 100644 index 0000000..f9aa23e --- /dev/null +++ b/src/lansweeper_helpdesk/__init__.py @@ -0,0 +1,27 @@ +"""Lansweeper Helpdesk API SDK. + +A Python client for creating, retrieving, searching, and managing +tickets in the Lansweeper Helpdesk system. +""" + +from lansweeper_helpdesk.client import HelpdeskAPI +from lansweeper_helpdesk.exceptions import ( + APIError, + ConfigurationError, + HelpdeskError, + TicketNotFoundError, +) +from lansweeper_helpdesk.types import NoteType, TicketState + +__version__ = "0.1.0" + +__all__ = [ + "HelpdeskAPI", + "APIError", + "ConfigurationError", + "HelpdeskError", + "TicketNotFoundError", + "NoteType", + "TicketState", + "__version__", +] diff --git a/src/lansweeper_helpdesk/client.py b/src/lansweeper_helpdesk/client.py new file mode 100644 index 0000000..bebf595 --- /dev/null +++ b/src/lansweeper_helpdesk/client.py @@ -0,0 +1,383 @@ +"""Lansweeper Helpdesk API client.""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any + +import requests +from bs4 import BeautifulSoup + +from lansweeper_helpdesk.exceptions import APIError, ConfigurationError +from lansweeper_helpdesk.types import APIResponse + +logger = logging.getLogger(__name__) + + +class HelpdeskAPI: + """Client for the Lansweeper Helpdesk API. + + Provides methods to create, retrieve, search, and manage helpdesk tickets, + add notes, and look up users. + + Args: + base_url: The base URL of the Lansweeper Helpdesk API + (e.g. ``"https://helpdesk.example.com:443/api.aspx"``). + api_key: API key for authentication. + cert_path: Optional path to an SSL certificate file for verification. + When ``None``, standard certificate verification is used. + + Raises: + ConfigurationError: If ``base_url`` or ``api_key`` is not provided. + FileNotFoundError: If ``cert_path`` is given but the file does not exist. + + Example:: + + from lansweeper_helpdesk import HelpdeskAPI + + api = HelpdeskAPI( + base_url="https://helpdesk.example.com:443/api.aspx", + api_key="your-api-key", + cert_path="/path/to/cert.pem", + ) + ticket = api.get_ticket("12345") + """ + + def __init__( + self, + base_url: str, + api_key: str, + cert_path: str | None = None, + ) -> None: + if not base_url: + raise ConfigurationError("base_url must be provided.") + if not api_key: + raise ConfigurationError("api_key must be provided.") + + self.base_url = base_url + self.api_key = api_key + + self.session = requests.Session() + + if cert_path is not None: + if not os.path.isfile(cert_path): + raise FileNotFoundError(f"Certificate file not found: {cert_path}") + self.session.verify = cert_path + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _request( + self, + action: str, + method: str = "GET", + params: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, + ) -> APIResponse | str: + """Make an HTTP request to the Lansweeper Helpdesk API. + + Args: + action: The API action to perform (e.g. ``"AddTicket"``). + method: HTTP method — ``"GET"`` or ``"POST"``. + params: Extra query/form parameters. + files: Files to upload with the request. + + Returns: + Parsed JSON response as a dict, or raw text if the response is not JSON. + + Raises: + APIError: If the request fails or the server returns an error status. + """ + request_params: dict[str, Any] = { + "Action": action, + "Key": self.api_key, + **(params or {}), + } + + logger.debug("Making %s request for action=%s", method, action) + + try: + if method == "POST": + response = self.session.post(self.base_url, data=request_params, files=files) + else: + response = self.session.get(self.base_url, params=request_params) + + response.raise_for_status() + except requests.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else None + body = exc.response.text if exc.response is not None else None + raise APIError(f"HTTP {status} for action {action}", status_code=status, response_body=body) from exc + except requests.RequestException as exc: + raise APIError(f"Request failed for action {action}: {exc}") from exc + + logger.debug("Response status: %s", response.status_code) + + if not response.text: + raise APIError(f"Empty response for action {action}", status_code=response.status_code) + + try: + return response.json() # type: ignore[no-any-return] + except json.JSONDecodeError: + return response.text + + @staticmethod + def _strip_html(html: str) -> str: + """Convert an HTML string to plain text.""" + return BeautifulSoup(html, "html.parser").get_text() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def create_ticket(self, subject: str, description: str, email: str) -> APIResponse: + """Create a new helpdesk ticket. + + Args: + subject: The subject line of the ticket. + description: Detailed description of the issue. + email: Email address of the ticket requester. + + Returns: + API response containing the created ticket data. + + Raises: + APIError: If ticket creation fails. + + Example:: + + api.create_ticket( + subject="Network Issue", + description="Cannot reach internal network", + email="user@example.com", + ) + """ + params = { + "Subject": subject, + "Description": description, + "Email": email, + } + response = self._request("AddTicket", method="POST", params=params) + if isinstance(response, str): + raise APIError(f"Unexpected non-JSON response: {response}") + return response + + def get_ticket(self, ticket_id: str) -> APIResponse: + """Retrieve details of a specific ticket. + + HTML in the ``Description`` field is automatically converted to plain text. + + Args: + ticket_id: The unique identifier of the ticket. + + Returns: + Ticket information dict. + + Raises: + APIError: If the request fails. + + Example:: + + ticket = api.get_ticket("12345") + print(ticket["Description"]) + """ + response = self._request("GetTicket", params={"TicketID": ticket_id}) + if isinstance(response, str): + raise APIError(f"Unexpected non-JSON response: {response}") + + if "Description" in response: + response["Description"] = self._strip_html(response["Description"]) + return response + + def get_ticket_history(self, ticket_id: str) -> list[APIResponse]: + """Retrieve the complete history of a ticket including all notes. + + HTML content inside individual note fields is converted to plain text. + + Args: + ticket_id: The unique identifier of the ticket. + + Returns: + List of note dicts for the ticket. + + Raises: + APIError: If the request fails. + """ + response = self._request("GetNotes", params={"TicketID": ticket_id}) + if isinstance(response, str): + raise APIError(f"Unexpected non-JSON response: {response}") + + notes: list[APIResponse] = response.get("Notes") or [] + + # Strip HTML from text fields inside each note + for note in notes: + for key in ("Text", "Description"): + if key in note and isinstance(note[key], str): + note[key] = self._strip_html(note[key]) + + return notes + + def add_note( + self, + ticket_id: str, + text: str, + email: str, + note_type: str = "Public", + ) -> APIResponse: + """Add a note to an existing ticket. + + Args: + ticket_id: The unique identifier of the ticket. + text: The note content. + email: Email address of the note author. + note_type: ``"Public"`` (visible to requester) or ``"Internal"``. + + Returns: + API response confirming note addition. + + Raises: + APIError: If the request fails. + + Example:: + + api.add_note( + ticket_id="12345", + text="Investigated — router restart required", + email="agent@example.com", + note_type="Internal", + ) + """ + params = { + "TicketID": ticket_id, + "Text": text, + "Email": email, + "Type": note_type, + } + response = self._request("AddNote", method="POST", params=params) + if isinstance(response, str): + raise APIError(f"Unexpected non-JSON response: {response}") + return response + + def search_tickets( + self, + *, + state: str | None = None, + from_user_id: str | None = None, + agent_id: str | None = None, + description: str | None = None, + subject: str | None = None, + ticket_type: str | None = None, + max_results: int | None = None, + min_date: str | None = None, + max_date: str | None = None, + ) -> APIResponse: + """Search for tickets matching the given criteria. + + All parameters are optional filters. Only non-``None`` values are sent + to the API. + + Args: + state: Ticket state (e.g. ``"Open"``, ``"Closed"``). + from_user_id: ID of the ticket creator. + agent_id: ID of the assigned agent. + description: Text to search in ticket descriptions. + subject: Text to search in ticket subjects. + ticket_type: Ticket type (e.g. ``"Hardware Repair"``). + max_results: Maximum number of results to return. + min_date: Start date filter (``YYYY-MM-DD``). + max_date: End date filter (``YYYY-MM-DD``). + + Returns: + API response containing matching tickets. + + Raises: + APIError: If the request fails. + + Example:: + + results = api.search_tickets( + state="Open", + ticket_type="Hardware Repair", + max_results=50, + ) + """ + param_map: dict[str, Any] = { + "State": state, + "FromUserId": from_user_id, + "AgentId": agent_id, + "Description": description, + "Subject": subject, + "Type": ticket_type, + "MaxResults": max_results, + "MinDate": min_date, + "MaxDate": max_date, + } + params = {k: v for k, v in param_map.items() if v is not None} + response = self._request("SearchTickets", params=params) + if isinstance(response, str): + raise APIError(f"Unexpected non-JSON response: {response}") + return response + + def get_user(self, email: str) -> APIResponse: + """Look up a user by email address. + + Args: + email: Email address of the user. + + Returns: + User information dict. + + Raises: + APIError: If the request fails. + + Example:: + + user = api.get_user("user@example.com") + """ + response = self._request("SearchUsers", params={"Email": email}) + if isinstance(response, str): + raise APIError(f"Unexpected non-JSON response: {response}") + return response + + def edit_ticket( + self, + ticket_id: str, + state: str, + ticket_type: str, + email: str, + ) -> APIResponse: + """Update an existing ticket's properties. + + Args: + ticket_id: The unique identifier of the ticket. + state: New state (e.g. ``"Open"``, ``"Closed"``). + ticket_type: New ticket type (e.g. ``"Hardware Repair"``). + email: Email of the person making the edit. + + Returns: + API response confirming the update. + + Raises: + APIError: If the request fails. + + Example:: + + api.edit_ticket( + ticket_id="12345", + state="Closed", + ticket_type="Hardware Repair", + email="agent@example.com", + ) + """ + params = { + "TicketID": ticket_id, + "State": state, + "Type": ticket_type, + "Email": email, + } + response = self._request("EditTicket", method="POST", params=params) + if isinstance(response, str): + raise APIError(f"Unexpected non-JSON response: {response}") + return response diff --git a/src/lansweeper_helpdesk/exceptions.py b/src/lansweeper_helpdesk/exceptions.py new file mode 100644 index 0000000..bafdcae --- /dev/null +++ b/src/lansweeper_helpdesk/exceptions.py @@ -0,0 +1,34 @@ +"""Custom exceptions for the Lansweeper Helpdesk SDK.""" + +from __future__ import annotations + + +class HelpdeskError(Exception): + """Base exception for all lansweeper-helpdesk errors.""" + + +class ConfigurationError(HelpdeskError): + """Raised for invalid client configuration (missing URL, bad cert path, etc.).""" + + +class APIError(HelpdeskError): + """Raised when the API returns an error response. + + Attributes: + status_code: HTTP status code from the response, if available. + response_body: Raw response body text, if available. + """ + + def __init__( + self, + message: str, + status_code: int | None = None, + response_body: str | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.response_body = response_body + + +class TicketNotFoundError(APIError): + """Raised when a requested ticket does not exist.""" diff --git a/src/lansweeper_helpdesk/py.typed b/src/lansweeper_helpdesk/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/lansweeper_helpdesk/types.py b/src/lansweeper_helpdesk/types.py new file mode 100644 index 0000000..9f4e841 --- /dev/null +++ b/src/lansweeper_helpdesk/types.py @@ -0,0 +1,32 @@ +"""Type definitions for the Lansweeper Helpdesk SDK.""" + +from __future__ import annotations + +import sys +from enum import Enum +from typing import Any + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + + class StrEnum(str, Enum): + """String enum backport for Python 3.10.""" + + +class TicketState(StrEnum): + """Possible states for a helpdesk ticket.""" + + OPEN = "Open" + CLOSED = "Closed" + + +class NoteType(StrEnum): + """Types of notes that can be added to a ticket.""" + + PUBLIC = "Public" + INTERNAL = "Internal" + + +# Type alias for API responses +APIResponse = dict[str, Any] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..15668c9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +"""Shared test fixtures.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from lansweeper_helpdesk import HelpdeskAPI + + +@pytest.fixture() +def cert_file(tmp_path: Path) -> str: + """Create a temporary dummy certificate file and return its path.""" + cert = tmp_path / "cert.pem" + cert.write_text("dummy-cert-content") + return str(cert) + + +@pytest.fixture() +def api(cert_file: str) -> HelpdeskAPI: + """Create a HelpdeskAPI instance configured for testing.""" + return HelpdeskAPI( + base_url="https://helpdesk.example.com/api.aspx", + api_key="test-api-key", + cert_path=cert_file, + ) + + +@pytest.fixture() +def api_no_cert() -> HelpdeskAPI: + """Create a HelpdeskAPI instance without a custom certificate.""" + return HelpdeskAPI( + base_url="https://helpdesk.example.com/api.aspx", + api_key="test-api-key", + ) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..059069d --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,233 @@ +"""Tests for the HelpdeskAPI client.""" + +from __future__ import annotations + +import pytest +import responses + +from lansweeper_helpdesk import HelpdeskAPI +from lansweeper_helpdesk.exceptions import APIError, ConfigurationError + +BASE_URL = "https://helpdesk.example.com/api.aspx" + + +# ------------------------------------------------------------------ +# Constructor tests +# ------------------------------------------------------------------ + + +class TestInit: + def test_missing_base_url(self) -> None: + with pytest.raises(ConfigurationError, match="base_url"): + HelpdeskAPI(base_url="", api_key="key") + + def test_missing_api_key(self) -> None: + with pytest.raises(ConfigurationError, match="api_key"): + HelpdeskAPI(base_url="https://example.com", api_key="") + + def test_bad_cert_path(self) -> None: + with pytest.raises(FileNotFoundError, match="does-not-exist"): + HelpdeskAPI(base_url="https://example.com", api_key="key", cert_path="/does-not-exist.pem") + + def test_no_cert_uses_default_verification(self, api_no_cert: HelpdeskAPI) -> None: + # session.verify should remain True (requests default) + assert api_no_cert.session.verify is True + + def test_cert_path_set(self, api: HelpdeskAPI, cert_file: str) -> None: + assert api.session.verify == cert_file + + +# ------------------------------------------------------------------ +# create_ticket +# ------------------------------------------------------------------ + + +class TestCreateTicket: + @responses.activate + def test_success(self, api: HelpdeskAPI) -> None: + responses.add( + responses.POST, + BASE_URL, + json={"TicketID": "100", "Result": "Success"}, + status=200, + ) + result = api.create_ticket(subject="Test", description="A test ticket", email="u@example.com") + assert result["TicketID"] == "100" + + @responses.activate + def test_server_error(self, api: HelpdeskAPI) -> None: + responses.add(responses.POST, BASE_URL, body="Internal Server Error", status=500) + with pytest.raises(APIError, match="500"): + api.create_ticket(subject="Test", description="Desc", email="u@example.com") + + +# ------------------------------------------------------------------ +# get_ticket +# ------------------------------------------------------------------ + + +class TestGetTicket: + @responses.activate + def test_success_strips_html(self, api: HelpdeskAPI) -> None: + responses.add( + responses.GET, + BASE_URL, + json={"TicketID": "100", "Description": "

Hello world

"}, + status=200, + ) + result = api.get_ticket("100") + assert result["Description"] == "Hello world" + + @responses.activate + def test_no_description_key(self, api: HelpdeskAPI) -> None: + responses.add( + responses.GET, + BASE_URL, + json={"TicketID": "100", "Subject": "Test"}, + status=200, + ) + result = api.get_ticket("100") + assert "Description" not in result + + +# ------------------------------------------------------------------ +# get_ticket_history +# ------------------------------------------------------------------ + + +class TestGetTicketHistory: + @responses.activate + def test_returns_notes_with_html_stripped(self, api: HelpdeskAPI) -> None: + responses.add( + responses.GET, + BASE_URL, + json={ + "Notes": [ + {"Text": "

Note one

", "Author": "agent"}, + {"Text": "Note two", "Author": "user"}, + ] + }, + status=200, + ) + notes = api.get_ticket_history("100") + assert len(notes) == 2 + assert notes[0]["Text"] == "Note one" + assert notes[1]["Text"] == "Note two" + + @responses.activate + def test_empty_notes(self, api: HelpdeskAPI) -> None: + responses.add(responses.GET, BASE_URL, json={"Notes": []}, status=200) + notes = api.get_ticket_history("100") + assert notes == [] + + @responses.activate + def test_null_notes(self, api: HelpdeskAPI) -> None: + responses.add(responses.GET, BASE_URL, json={"Notes": None}, status=200) + notes = api.get_ticket_history("100") + assert notes == [] + + +# ------------------------------------------------------------------ +# add_note +# ------------------------------------------------------------------ + + +class TestAddNote: + @responses.activate + def test_success(self, api: HelpdeskAPI) -> None: + responses.add(responses.POST, BASE_URL, json={"Result": "Success"}, status=200) + result = api.add_note(ticket_id="100", text="A note", email="a@b.com", note_type="Internal") + assert result["Result"] == "Success" + + @responses.activate + def test_default_note_type(self, api: HelpdeskAPI) -> None: + responses.add(responses.POST, BASE_URL, json={"Result": "Success"}, status=200) + api.add_note(ticket_id="100", text="A note", email="a@b.com") + assert responses.calls[0].request.body is not None + assert "Type=Public" in responses.calls[0].request.body + + +# ------------------------------------------------------------------ +# search_tickets +# ------------------------------------------------------------------ + + +class TestSearchTickets: + @responses.activate + def test_with_filters(self, api: HelpdeskAPI) -> None: + responses.add( + responses.GET, + BASE_URL, + json={"Tickets": [{"TicketID": "1"}, {"TicketID": "2"}]}, + status=200, + ) + result = api.search_tickets(state="Open", max_results=10) + assert "Tickets" in result + # Verify query params + request_url = responses.calls[0].request.url or "" + assert "State=Open" in request_url + assert "MaxResults=10" in request_url + + @responses.activate + def test_no_filters(self, api: HelpdeskAPI) -> None: + responses.add(responses.GET, BASE_URL, json={"Tickets": []}, status=200) + result = api.search_tickets() + assert result == {"Tickets": []} + + +# ------------------------------------------------------------------ +# get_user +# ------------------------------------------------------------------ + + +class TestGetUser: + @responses.activate + def test_success(self, api: HelpdeskAPI) -> None: + responses.add( + responses.GET, + BASE_URL, + json={"UserID": "42", "Email": "u@example.com"}, + status=200, + ) + result = api.get_user("u@example.com") + assert result["UserID"] == "42" + + +# ------------------------------------------------------------------ +# edit_ticket +# ------------------------------------------------------------------ + + +class TestEditTicket: + @responses.activate + def test_success(self, api: HelpdeskAPI) -> None: + responses.add(responses.POST, BASE_URL, json={"Result": "Success"}, status=200) + result = api.edit_ticket(ticket_id="100", state="Closed", ticket_type="Network", email="a@b.com") + assert result["Result"] == "Success" + + +# ------------------------------------------------------------------ +# _request edge cases +# ------------------------------------------------------------------ + + +class TestRequest: + @responses.activate + def test_empty_response_raises(self, api: HelpdeskAPI) -> None: + responses.add(responses.GET, BASE_URL, body="", status=200) + with pytest.raises(APIError, match="Empty response"): + api.get_ticket("100") + + @responses.activate + def test_non_json_response(self, api: HelpdeskAPI) -> None: + responses.add(responses.GET, BASE_URL, body="plain text response", status=200) + # get_ticket expects JSON — non-JSON raises APIError + with pytest.raises(APIError, match="non-JSON"): + api.get_ticket("100") + + @responses.activate + def test_http_error_includes_status(self, api: HelpdeskAPI) -> None: + responses.add(responses.GET, BASE_URL, body="Not Found", status=404) + with pytest.raises(APIError) as exc_info: + api.get_ticket("999") + assert exc_info.value.status_code == 404 diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..dcba1c7 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,23 @@ +"""Tests for type definitions.""" + +from __future__ import annotations + +from lansweeper_helpdesk.types import NoteType, TicketState + + +class TestTicketState: + def test_values(self) -> None: + assert TicketState.OPEN == "Open" + assert TicketState.CLOSED == "Closed" + + def test_is_string(self) -> None: + assert isinstance(TicketState.OPEN, str) + + +class TestNoteType: + def test_values(self) -> None: + assert NoteType.PUBLIC == "Public" + assert NoteType.INTERNAL == "Internal" + + def test_is_string(self) -> None: + assert isinstance(NoteType.PUBLIC, str)