diff --git a/README.md b/README.md index 886a301..87aa58c 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,45 @@ The [Umami API is extensive](https://umami.is/docs/api) and much of that is inte ## Core Features * ➕ **Add a custom event** to your Umami analytics dashboard. +* 📄 **Add a page view** to your Umami analytics dashboard. * 🌐 List all websites with details that you have registered at Umami. +* 📊 **Get website statistics** including page views, visitors, bounce rate, and more. +* 👥 **Get active users** count for real-time monitoring. +* 💓 **Heartbeat check** to verify Umami server connectivity. * 🔀 Both **sync** and **async** programming models. * ⚒️ **Structured data with Pydantic** models for API responses. * 👩‍💻 **Login / authenticate** for either a self-hosted or SaaS hosted instance of Umami. * 🥇Set a **default website** for a **simplified API** going forward. +* 🔧 **Enable/disable tracking** for development and testing environments. + +## Development and Testing Support + +🔧 **Disable tracking in development**: Use `umami.disable()` to disable all event and page view tracking without changing your code. Perfect for development and testing environments where you don't want to pollute your analytics with test data. + +```python +import umami + +# Configure as usual +umami.set_url_base("https://umami.hostedbyyouorthem.com") +umami.set_website_id('cc726914-8e68-4d1a-4be0-af4ca8933456') +umami.set_hostname('somedomain.com') + +# Disable tracking for development/testing +umami.disable() + +# These calls will return immediately without sending data to Umami +umami.new_event('test-event') # No HTTP request made +umami.new_page_view('Test Page', '/test') # No HTTP request made + +# Re-enable when needed (default state is enabled) +umami.enable() +``` + +When tracking is disabled: +- ✅ **No HTTP requests** are made to your Umami server +- ✅ **API calls still validate** parameters (helps catch configuration issues) +- ✅ **All other functions work normally** (login, websites, stats, etc.) +- ✅ **Functions return appropriate values** for compatibility See the usage example below for the Python API around these features. @@ -47,6 +81,9 @@ login = umami.login(username, password) umami.set_website_id('cc726914-8e68-4d1a-4be0-af4ca8933456') umami.set_hostname('somedomain.com') +# Optional: Disable tracking for development/testing +# umami.disable() # Uncomment to disable tracking + # List your websites websites = umami.websites() @@ -68,6 +105,31 @@ page_view_resp = umami.new_page_view( url='/users/actions', referrer='https://some_url') +# Get website statistics for a date range +from datetime import datetime, timedelta + +end_date = datetime.now() +start_date = end_date - timedelta(days=7) # Last 7 days + +stats = umami.website_stats( + start_at=start_date, + end_at=end_date, + website_id='a7cd-5d1a-2b33' # Only send if overriding default above +) +print(f"Page views: {stats.pageviews}") +print(f"Unique visitors: {stats.visitors}") +print(f"Bounce rate: {stats.bounces}") + +# Get current active users count +active_count = umami.active_users( + website_id='a7cd-5d1a-2b33' # Only send if overriding default above +) +print(f"Currently active users: {active_count}") + +# Check if Umami server is accessible +server_ok = umami.heartbeat() +print(f"Umami server is {'accessible' if server_ok else 'not accessible'}") + # Call after logging in to make sure the auth token is still valid. umami.verify_token() ``` @@ -78,4 +140,4 @@ This code listing is very-very high fidelity psuedo code. If you want an actuall See the [API documentation](https://umami.is/docs/api) for the remaining endpoints to be added. PRs are welcome. But please open an issue first to see if the proposed feature fits with the direction of this library. -Enjoy. +Enjoy. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6b68903..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -httpx -pydantic diff --git a/ruff.toml b/ruff.toml index 1d61af5..a59fa20 100644 --- a/ruff.toml +++ b/ruff.toml @@ -3,8 +3,8 @@ line-length = 120 format.quote-style = "single" # Enable Pyflakes `E` and `F` codes by default. -select = ["E", "F"] -ignore = [ +lint.select = ["E", "F"] +lint.ignore = [ "E501" # Line length ] @@ -32,7 +32,7 @@ exclude = [ ".venv", "venv", ] -per-file-ignores = {} +lint.per-file-ignores = {} # Allow unused variables when underscore-prefixed. # dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" @@ -42,4 +42,4 @@ target-version = "py38" #[tool.ruff.mccabe] ## Unlike Flake8, default to a complexity level of 10. -mccabe.max-complexity = 10 +lint.mccabe.max-complexity = 10 diff --git a/umami/README.md b/umami/README.md new file mode 100644 index 0000000..87aa58c --- /dev/null +++ b/umami/README.md @@ -0,0 +1,143 @@ +# Umami Analytics Client for Python + +Client for privacy-preserving, open source [Umami analytics platform](https://umami.is) based on +`httpx` and `pydantic`. + +`umami-analytics` is intended for adding custom data to your Umami instance (self-hosted or SaaS). Many umami events can supplied directly from HTML via their `data-*` attributes. However, some cannot. For example, if you have an event that is triggered in your app but doesn't have a clear HTML action you can add custom events. These will appear at the bottom of your Umami analtytics page for a website. + +One example is a **purchase-course** event that happens deep inside the Python code rather than in HTML at [Talk Python Training](https://training.talkpython.fm). This is what our events section looks like for a typical weekend day (US Pacific Time): + +![](https://raw.githubusercontent.com/mikeckennedy/umami-python/main/readme_resources/events-example.jpg) + +## Focused on what you need, not what is offered + +The [Umami API is extensive](https://umami.is/docs/api) and much of that is intended for their frontend code to be able to function. You probably don't want or need that. `umami-analytics` only covers the subset that most developers will need for common SaaS actions such as adding [custom events](https://umami.is/docs/event-data). That said, PRs are weclome. + +## Core Features + +* ➕ **Add a custom event** to your Umami analytics dashboard. +* 📄 **Add a page view** to your Umami analytics dashboard. +* 🌐 List all websites with details that you have registered at Umami. +* 📊 **Get website statistics** including page views, visitors, bounce rate, and more. +* 👥 **Get active users** count for real-time monitoring. +* 💓 **Heartbeat check** to verify Umami server connectivity. +* 🔀 Both **sync** and **async** programming models. +* ⚒️ **Structured data with Pydantic** models for API responses. +* 👩‍💻 **Login / authenticate** for either a self-hosted or SaaS hosted instance of Umami. +* 🥇Set a **default website** for a **simplified API** going forward. +* 🔧 **Enable/disable tracking** for development and testing environments. + +## Development and Testing Support + +🔧 **Disable tracking in development**: Use `umami.disable()` to disable all event and page view tracking without changing your code. Perfect for development and testing environments where you don't want to pollute your analytics with test data. + +```python +import umami + +# Configure as usual +umami.set_url_base("https://umami.hostedbyyouorthem.com") +umami.set_website_id('cc726914-8e68-4d1a-4be0-af4ca8933456') +umami.set_hostname('somedomain.com') + +# Disable tracking for development/testing +umami.disable() + +# These calls will return immediately without sending data to Umami +umami.new_event('test-event') # No HTTP request made +umami.new_page_view('Test Page', '/test') # No HTTP request made + +# Re-enable when needed (default state is enabled) +umami.enable() +``` + +When tracking is disabled: +- ✅ **No HTTP requests** are made to your Umami server +- ✅ **API calls still validate** parameters (helps catch configuration issues) +- ✅ **All other functions work normally** (login, websites, stats, etc.) +- ✅ **Functions return appropriate values** for compatibility + +See the usage example below for the Python API around these features. + +## Async or sync API? You choose + +🔀 **Async is supported but not required** for your Python code. For functions that access the network, there is a `func()` and `func_async()` variant that works with Python's `async` and `await`. + +## Installation + +Just `pip install umami-analytics` + +## Usage + +```python + +import umami + +umami.set_url_base("https://umami.hostedbyyouorthem.com") + +# Auth is NOT required to send events, but is for other features. +login = umami.login(username, password) + +# Skip the need to pass the target website in subsequent calls. +umami.set_website_id('cc726914-8e68-4d1a-4be0-af4ca8933456') +umami.set_hostname('somedomain.com') + +# Optional: Disable tracking for development/testing +# umami.disable() # Uncomment to disable tracking + +# List your websites +websites = umami.websites() + +# Create a new event in the events section of the dashboards. +event_resp = umami.new_event( + website_id='a7cd-5d1a-2b33', # Only send if overriding default above + event_name='Umami-Test', + title='Umami-Test', # Defaults to event_name if omitted. + hostname='somedomain.com', # Only send if overriding default above. + url='/users/actions', + custom_data={'client': 'umami-tester-v1'}, + referrer='https://some_url') + +# Create a new page view in the pages section of the dashboards. +page_view_resp = umami.new_page_view( + website_id='a7cd-5d1a-2b33', # Only send if overriding default above + page_title='Umami-Test', # Defaults to event_name if omitted. + hostname='somedomain.com', # Only send if overriding default above. + url='/users/actions', + referrer='https://some_url') + +# Get website statistics for a date range +from datetime import datetime, timedelta + +end_date = datetime.now() +start_date = end_date - timedelta(days=7) # Last 7 days + +stats = umami.website_stats( + start_at=start_date, + end_at=end_date, + website_id='a7cd-5d1a-2b33' # Only send if overriding default above +) +print(f"Page views: {stats.pageviews}") +print(f"Unique visitors: {stats.visitors}") +print(f"Bounce rate: {stats.bounces}") + +# Get current active users count +active_count = umami.active_users( + website_id='a7cd-5d1a-2b33' # Only send if overriding default above +) +print(f"Currently active users: {active_count}") + +# Check if Umami server is accessible +server_ok = umami.heartbeat() +print(f"Umami server is {'accessible' if server_ok else 'not accessible'}") + +# Call after logging in to make sure the auth token is still valid. +umami.verify_token() +``` + +This code listing is very-very high fidelity psuedo code. If you want an actually executable example, see the [example client](./umami/example_client) in the repo. + +## Want to contribute? + +See the [API documentation](https://umami.is/docs/api) for the remaining endpoints to be added. PRs are welcome. But please open an issue first to see if the proposed feature fits with the direction of this library. + +Enjoy. \ No newline at end of file diff --git a/umami/example_client/client.py b/umami/example_client/client.py index 05b2a74..3bc806d 100644 --- a/umami/example_client/client.py +++ b/umami/example_client/client.py @@ -1,57 +1,105 @@ import json from pathlib import Path +from typing import Any import umami file = Path(__file__).parent / 'settings.json' -settings = {} +settings: dict[str, Any] = {} if file.exists(): settings = json.loads(file.read_text()) print(umami.user_agent) -url = settings.get('base_url') or input("Enter the base URL for your instance: ") -user = settings.get('username') or input("Enter the username for Umami: ") -password = settings.get('password') or input("Enter the password for ") +url = settings.get('base_url') or input('Enter the base URL for your instance: ') +user = settings.get('username') or input('Enter the username for Umami: ') +password = settings.get('password') or input('Enter the password for ') umami.set_url_base(url) +print(f'Not currently logged in? {not umami.is_logged_in()}') login = umami.login(user, password) -print(f"Logged in successfully as {login.user.username} : admin? {login.user.isAdmin}") +print(f'Logged in successfully as {login.user.username} : admin? {login.user.isAdmin}') +print(f'Currently logged in? {umami.is_logged_in()}') print() -print("Verify token:") +print('Verify token:') print(umami.verify_token(check_server=False)) print(umami.verify_token()) print() +print('Checking heartbeat') +print(umami.heartbeat()) +print() + websites = umami.websites() -print(f"Found {len(websites):,} websites.") -print("First website in list:") +print(f'Found {len(websites):,} websites.') +print('First website in list:') print(websites[0]) print() if test_domain := settings.get('test_domain'): - test_site = [w for w in websites if w.domain == test_domain][0] - print(f"Using {test_domain} for testing events.") + print(f'Using {test_domain} for testing events.') # Set these once umami.set_hostname(test_site.domain) umami.set_website_id(test_site.id) - event_resp = umami.new_event( + # Demonstrate the new enable/disable functionality + print('\n=== Demonstrating tracking enable/disable functionality ===') + + # Test with tracking enabled (default) + print('1. Sending event with tracking enabled...') + umami.new_event( event_name='Umami-Test-Event3', title='Umami-Test-Event3', url='/users/actions', custom_data={'client': 'umami-tester-v1'}, - referrer='https://talkpython.fm') + referrer='https://talkpython.fm', + ) + print(' ✓ Event sent to Umami') + + # Test with tracking disabled + print('2. Disabling tracking...') + umami.disable() + print(' Tracking disabled. Now sending event (should not reach Umami)...') - print(f"Created new event: {event_resp}") + umami.new_event( + event_name='This-Should-Not-Be-Sent', + title='This event should not appear in Umami', + url='/disabled-test', + custom_data={'should_not_appear': True}, + ) + print(' ✓ Event call completed but no data sent to Umami') - print('Sending event as if we are a browser user:', end=' ') - page_resp = umami.new_page_view("Account Details - Your App", "/account/details") - print(page_resp) + # Test page view with tracking disabled + print(' Sending page view with tracking disabled...') + umami.new_page_view('Disabled Test Page', '/disabled-page-view', ip_address='127.100.200.1') + print(' ✓ Page view call completed but no data sent to Umami') + + # Re-enable tracking + print('3. Re-enabling tracking...') + umami.enable() + print(' Tracking re-enabled. Sending final test event...') + + umami.new_event( + event_name='Tracking-Re-Enabled', + title='This event should appear in Umami', + url='/re-enabled-test', + custom_data={'tracking_restored': True}, + ) + print(' ✓ Event sent to Umami') + + print('\nSending event as if we are a browser user') + umami.new_page_view('Account Details - Your App', '/account/details', ip_address='127.100.200.1') else: - print("No test domain, skipping event creation.") + print('No test domain, skipping event creation.') + +print('\n=== Summary ===') +print('The new tracking control functions allow you to:') +print('• umami.disable() - Disable tracking for dev/test environments') +print('• umami.enable() - Re-enable tracking (default state)') +print('• All API calls still work and validate parameters when disabled') +print('• No HTTP requests are made to Umami when tracking is disabled') diff --git a/umami/pyproject.toml b/umami/pyproject.toml index 46501c0..f153eb2 100644 --- a/umami/pyproject.toml +++ b/umami/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "umami-analytics" description = "Umami Analytics Client for Python" -readme = "../README.md" +readme = "README.md" license = "MIT" -requires-python = ">=3.8" +requires-python = ">=3.9" keywords = [ "analytics", "website", @@ -13,7 +13,7 @@ authors = [ { name = "Michael Kennedy", email = "michael@talkpython.fm" }, ] classifiers = [ - 'Development Status :: 2 - Pre-Alpha', + 'Development Status :: 4 - Beta', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python', 'Programming Language :: Python :: 3', @@ -22,12 +22,14 @@ classifiers = [ 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', ] dependencies = [ "httpx", "pydantic", ] -version = "0.1.13" +version = "0.2.20" [project.urls] @@ -47,8 +49,7 @@ exclude = [ "/example_client", "setup.py", "settings.json", - "tox.ini", - "/example_client", + "/tox.ini", ] [tool.hatch.build.targets.wheel] @@ -59,6 +60,5 @@ exclude = [ "/example_client", "setup.py", "settings.json", - "tox.ini", - "/example_client", -] \ No newline at end of file + "/tox.ini", +] \ No newline at end of file diff --git a/umami/requirements.txt b/umami/requirements.txt new file mode 100644 index 0000000..2c3b43c --- /dev/null +++ b/umami/requirements.txt @@ -0,0 +1,2 @@ +httpx +pydantic \ No newline at end of file diff --git a/umami/setup.py b/umami/setup.py deleted file mode 100644 index 30575d5..0000000 --- a/umami/setup.py +++ /dev/null @@ -1,43 +0,0 @@ -import io -import os -import re - -from setuptools import find_packages -from setuptools import setup - - -def read(filename): - filename = os.path.join(os.path.dirname(__file__), filename) - text_type = type(u"") - with io.open(filename, mode="r", encoding='utf-8') as fd: - return re.sub(text_type(r':[a-z]+:`~?(.*?)`'), text_type(r'``\1``'), fd.read()) - - -setup( - name="umami", - version="0.1.0", - url="https://github.com/mikeckennedy/umami-python", - license='MIT', - - author="Michael Kennedy", - author_email="michael@talkpython.fm", - - description="Umami Analytics Client for Python", - long_description=read("README.md"), - - packages=find_packages(exclude=('tests', 'example_client',)), - - install_requires=[], - - classifiers=[ - 'Development Status :: 2 - Pre-Alpha', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - ], -) diff --git a/umami/tests/test_sample.py b/umami/tests/test_sample.py index 2b3a488..8b2d48f 100644 --- a/umami/tests/test_sample.py +++ b/umami/tests/test_sample.py @@ -1,4 +1,5 @@ # Sample Test passing with nose and pytest + def test_pass(): - assert True, "dummy sample test" + assert True, 'dummy sample test' diff --git a/umami/umami/__init__.py b/umami/umami/__init__.py index 6d83713..856f9e1 100644 --- a/umami/umami/__init__.py +++ b/umami/umami/__init__.py @@ -1,24 +1,56 @@ from umami import impl from . import errors # noqa: F401, E402 from . import models # noqa: F401, E402 -from .impl import login_async, login # noqa: F401, E402 +from .impl import active_users, active_users_async # noqa: F401, E402 +from .impl import heartbeat_async, heartbeat # noqa: F401, E402 +from .impl import login_async, login, is_logged_in # noqa: F401, E402 from .impl import new_event_async, new_event # noqa: F401, E402 from .impl import new_page_view, new_page_view_async # noqa: F401, E402 from .impl import set_url_base, set_website_id, set_hostname # noqa: F401, E402 from .impl import verify_token_async, verify_token # noqa: F401, E402 +from .impl import website_stats, website_stats_async # noqa: F401, E402 from .impl import websites_async, websites # noqa: F401, E402 +from .impl import enable, disable # noqa: F401, E402 __author__ = 'Michael Kennedy ' __version__ = impl.__version__ user_agent = impl.user_agent +# fmt: off +# ruff: noqa __all__ = [ - models, - errors, - set_url_base, set_website_id, set_hostname, - login_async, login, - websites_async, websites, - new_event_async, new_event, - new_page_view, new_page_view_async, - verify_token_async, verify_token, + # Core modules + 'models', + 'errors', + + # Configuration/Setup + 'set_url_base', + 'set_website_id', + 'set_hostname', + 'enable', + 'disable', + + # Authentication + 'login', + 'login_async', + 'is_logged_in', + 'verify_token', + 'verify_token_async', + + # Basic operations + 'websites', + 'websites_async', + 'heartbeat', + 'heartbeat_async', + + # Main features - Events and Analytics + 'new_event', + 'new_event_async', + 'new_page_view', + 'new_page_view_async', + 'website_stats', + 'website_stats_async', + 'active_users', + 'active_users_async', ] +# fmt: on diff --git a/umami/umami/impl/__init__.py b/umami/umami/impl/__init__.py index a7912e6..defd6cd 100644 --- a/umami/umami/impl/__init__.py +++ b/umami/umami/impl/__init__.py @@ -1,25 +1,31 @@ +import base64 +import json import sys -from typing import Optional +from typing import Optional, Any, Dict import httpx from umami import models, urls -__version__ = '0.1.13' +__version__ = '0.2.20' from umami.errors import ValidationError, OperationNotAllowedError +from datetime import datetime url_base: Optional[str] = None auth_token: Optional[str] = None default_website_id: Optional[str] = None default_hostname: Optional[str] = None +tracking_enabled: bool = True # An actual browser UA is needed to get around the bot detection in Umami # You can also set DISABLE_BOT_CHECK=true in your Umami environment to disable the bot check entirely: # https://github.com/umami-software/umami/blob/7a3443cd06772f3cde37bdbb0bf38eabf4515561/pages/api/collect.js#L13 event_user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0' -user_agent = (f'Umami-Client v{__version__} / ' - f'Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} / ' - f'{sys.platform.capitalize()}') +user_agent = ( + f'Umami-Client v{__version__} / ' + f'Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} / ' + f'{sys.platform.capitalize()}' +) def set_url_base(url: str) -> None: @@ -30,12 +36,12 @@ def set_url_base(url: str) -> None: url: The base URL of your instance without /api. """ if not url or not url.strip(): - raise ValidationError("URL must not be empty.") + raise ValidationError('URL must not be empty.') # noinspection HttpUrlsUsage if not url.startswith('http://') and not url.startswith('https://'): # noinspection HttpUrlsUsage - raise ValidationError("The url must start with the HTTP scheme (http:// or https://).") + raise ValidationError('The url must start with the HTTP scheme (http:// or https://).') if url.endswith('/'): url = url.rstrip('/') @@ -65,16 +71,20 @@ def set_hostname(hostname: str) -> None: default_hostname = hostname +def is_logged_in() -> bool: + return auth_token is not None + + async def login_async(username: str, password: str) -> models.LoginResponse: """ - Logs into Umami and retrieves a temporary auth token. If the token is expired, - you'll need to log in again. This can be checked with verify_token(). - Args: - username: Your Umami username - password: Your Umami password + Logs into Umami and retrieves a temporary auth token. If the token is expired, + you'll need to log in again. This can be checked with verify_token(). + Args: + username: Your Umami username + password: Your Umami password - Returns: LoginResponse object which your token and user details (no need to save this). - """ + Returns: LoginResponse object which your token and user details (no need to save this). + """ global auth_token validate_state(url=True) validate_login(username, password) @@ -82,11 +92,11 @@ async def login_async(username: str, password: str) -> models.LoginResponse: url = f'{url_base}{urls.login}' headers = {'User-Agent': user_agent} api_data = { - "username": username, - "password": password, + 'username': username, + 'password': password, } async with httpx.AsyncClient() as client: - resp = await client.post(url, data=api_data, headers=headers, follow_redirects=True) + resp = await client.post(url, json=api_data, headers=headers, follow_redirects=True) resp.raise_for_status() model = models.LoginResponse(**resp.json()) @@ -112,10 +122,10 @@ def login(username: str, password: str) -> models.LoginResponse: url = f'{url_base}{urls.login}' headers = {'User-Agent': user_agent} api_data = { - "username": username, - "password": password, + 'username': username, + 'password': password, } - resp = httpx.post(url, data=api_data, headers=headers, follow_redirects=True) + resp = httpx.post(url, json=api_data, headers=headers, follow_redirects=True) resp.raise_for_status() model = models.LoginResponse(**resp.json()) @@ -125,8 +135,8 @@ def login(username: str, password: str) -> models.LoginResponse: async def websites_async() -> list[models.Website]: """ - All the websites that are registered in your Umami instance. - Returns: A list of Website Pydantic models. + All the websites that are registered in your Umami instance. + Returns: A list of Website Pydantic models. """ global auth_token validate_state(url=True, user=True) @@ -136,7 +146,8 @@ async def websites_async() -> list[models.Website]: 'User-Agent': user_agent, 'Authorization': f'Bearer {auth_token}', } - async with httpx.AsyncClient() as client: + + async with httpx.AsyncClient() as client: # type: ignore resp = await client.get(url, headers=headers, follow_redirects=True) resp.raise_for_status() @@ -146,8 +157,8 @@ async def websites_async() -> list[models.Website]: def websites() -> list[models.Website]: """ - All the websites that are registered in your Umami instance. - Returns: A list of Website Pydantic models. + All the websites that are registered in your Umami instance. + Returns: A list of Website Pydantic models. """ global auth_token validate_state(url=True, user=True) @@ -165,10 +176,41 @@ def websites() -> list[models.Website]: return model.websites -async def new_event_async(event_name: str, hostname: Optional[str] = None, url: str = '/', - website_id: Optional[str] = None, title: Optional[str] = None, - custom_data=None, referrer: str = '', language: str = 'en-US', - screen: str = "1920x1080", ip_address: Optional[str] = None) -> str: +def enable() -> None: + """ + Enable event and page view tracking. + + When enabled, new_event() and new_page_view() functions will send + data to Umami normally. This is the default state. + """ + global tracking_enabled + tracking_enabled = True + + +def disable() -> None: + """ + Disable event and page view tracking. + + When disabled, new_event() and new_page_view() functions will return + immediately without sending data to Umami. This is useful for + development and testing environments. + """ + global tracking_enabled + tracking_enabled = False + + +async def new_event_async( + event_name: str, + hostname: Optional[str] = None, + url: str = '/', + website_id: Optional[str] = None, + title: Optional[str] = None, + custom_data: Optional[Dict[str, Any]] = None, + referrer: str = '', + language: str = 'en-US', + screen: str = '1920x1080', + ip_address: Optional[str] = None, +) -> str: """ Creates a new custom event in Umami for the given website_id and hostname (both use the default if you have set them with the other functions such as set_hostname()). These events will both @@ -187,7 +229,7 @@ async def new_event_async(event_name: str, hostname: Optional[str] = None, url: screen: The screen resolution of the client. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. - Returns: The text returned from the Umami API. + Returns: The data returned from the Umami API. """ validate_state(url=True, user=False) website_id = website_id or default_website_id @@ -197,6 +239,10 @@ async def new_event_async(event_name: str, hostname: Optional[str] = None, url: validate_event_data(event_name, hostname, website_id) + # Early return if tracking is disabled + if not tracking_enabled: + return '' + api_url = f'{url_base}{urls.events}' headers = { 'User-Agent': event_user_agent, @@ -204,36 +250,42 @@ async def new_event_async(event_name: str, hostname: Optional[str] = None, url: } payload = { - "hostname": hostname, - "language": language, - "referrer": referrer, - "screen": screen, - "title": title, - "url": url, - "website": website_id, - "name": event_name, - "data": custom_data + 'hostname': hostname, + 'language': language, + 'referrer': referrer, + 'screen': screen, + 'title': title, + 'url': url, + 'website': website_id, + 'name': event_name, + 'data': custom_data, } if ip_address and ip_address.strip(): payload['ip'] = ip_address - event_data = { - 'payload': payload, - 'type': 'event' - } + event_data = {'payload': payload, 'type': 'event'} async with httpx.AsyncClient() as client: resp = await client.post(api_url, json=event_data, headers=headers, follow_redirects=True) resp.raise_for_status() - return resp.text - - -def new_event(event_name: str, hostname: Optional[str] = None, url: str = '/event-api-endpoint', - website_id: Optional[str] = None, title: Optional[str] = None, - custom_data=None, referrer: str = '', language: str = 'en-US', - screen: str = "1920x1080", ip_address: Optional[str] = None) -> str: + data_str = base64.b64decode(resp.text) + return json.loads(data_str) + + +def new_event( + event_name: str, + hostname: Optional[str] = None, + url: str = '/event-api-endpoint', + website_id: Optional[str] = None, + title: Optional[str] = None, + custom_data: Optional[Dict[str, Any]] = None, + referrer: str = '', + language: str = 'en-US', + screen: str = '1920x1080', + ip_address: Optional[str] = None, +): """ Creates a new custom event in Umami for the given website_id and hostname (both use the default if you have set them with the other functions such as set_hostname()). These events will both @@ -251,8 +303,6 @@ def new_event(event_name: str, hostname: Optional[str] = None, url: str = '/even language: The language of the event / client. screen: The screen resolution of the client. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. - - Returns: The text returned from the Umami API. """ validate_state(url=True, user=False) website_id = website_id or default_website_id @@ -262,6 +312,10 @@ def new_event(event_name: str, hostname: Optional[str] = None, url: str = '/even validate_event_data(event_name, hostname, website_id) + # Early return if tracking is disabled + if not tracking_enabled: + return + api_url = f'{url_base}{urls.events}' headers = { 'User-Agent': event_user_agent, @@ -269,35 +323,37 @@ def new_event(event_name: str, hostname: Optional[str] = None, url: str = '/even } payload = { - "hostname": hostname, - "language": language, - "referrer": referrer, - "screen": screen, - "title": title, - "url": url, - "website": website_id, - "name": event_name, - "data": custom_data + 'hostname': hostname, + 'language': language, + 'referrer': referrer, + 'screen': screen, + 'title': title, + 'url': url, + 'website': website_id, + 'name': event_name, + 'data': custom_data, } if ip_address and ip_address.strip(): payload['ip'] = ip_address - event_data = { - 'payload': payload, - 'type': 'event' - } + event_data = {'payload': payload, 'type': 'event'} resp = httpx.post(api_url, json=event_data, headers=headers, follow_redirects=True) resp.raise_for_status() - return resp.text - -async def new_page_view_async(page_title: str, url: str, hostname: Optional[str] = None, - website_id: Optional[str] = None, referrer: str = '', - language: str = 'en-US', screen: str = "1920x1080", ua: str = event_user_agent, - ip_address: Optional[str] = None) -> str: +async def new_page_view_async( + page_title: str, + url: str, + hostname: Optional[str] = None, + website_id: Optional[str] = None, + referrer: str = '', + language: str = 'en-US', + screen: str = '1920x1080', + ua: str = event_user_agent, + ip_address: Optional[str] = None, +): """ Creates a new page view event in Umami for the given website_id and hostname (both use the default if you have set them with the other functions such as set_hostname()). This is equivalent to what @@ -313,14 +369,16 @@ async def new_page_view_async(page_title: str, url: str, hostname: Optional[str] screen: OPTIONAL: The screen resolution of the client. ua: OPTIONAL: The UserAgent resolution of the client. Note umami blocks non browsers by default. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. - - Returns: The text returned from the Umami API. """ validate_state(url=True, user=False) website_id = website_id or default_website_id hostname = hostname or default_hostname - validate_event_data(event_name="NOT NEEDED", hostname=hostname, website_id=website_id) + validate_event_data(event_name='NOT NEEDED', hostname=hostname, website_id=website_id) + + # Early return if tracking is disabled + if not tracking_enabled: + return api_url = f'{url_base}{urls.events}' headers = { @@ -329,34 +387,36 @@ async def new_page_view_async(page_title: str, url: str, hostname: Optional[str] } payload = { - "hostname": hostname, - "language": language, - "referrer": referrer, - "screen": screen, - "title": page_title, - "url": url, - "website": website_id, + 'hostname': hostname, + 'language': language, + 'referrer': referrer, + 'screen': screen, + 'title': page_title, + 'url': url, + 'website': website_id, } if ip_address and ip_address.strip(): payload['ip'] = ip_address - event_data = { - 'payload': payload, - 'type': 'event' - } + event_data = {'payload': payload, 'type': 'event'} async with httpx.AsyncClient() as client: resp = await client.post(api_url, json=event_data, headers=headers, follow_redirects=True) resp.raise_for_status() - return resp.text - -def new_page_view(page_title: str, url: str, hostname: Optional[str] = None, - website_id: Optional[str] = None, referrer: str = '', - language: str = 'en-US', screen: str = "1920x1080", ua: str = event_user_agent, - ip_address: Optional[str] = None) -> str: +def new_page_view( + page_title: str, + url: str, + hostname: Optional[str] = None, + website_id: Optional[str] = None, + referrer: str = '', + language: str = 'en-US', + screen: str = '1920x1080', + ua: str = event_user_agent, + ip_address: Optional[str] = None, +): """ Creates a new page view event in Umami for the given website_id and hostname (both use the default if you have set them with the other functions such as set_hostname()). This is equivalent to what @@ -372,14 +432,16 @@ def new_page_view(page_title: str, url: str, hostname: Optional[str] = None, screen: OPTIONAL: The screen resolution of the client. ua: OPTIONAL: The UserAgent resolution of the client. Note umami blocks non browsers by default. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. - - Returns: The text returned from the Umami API. """ validate_state(url=True, user=False) website_id = website_id or default_website_id hostname = hostname or default_hostname - validate_event_data(event_name="NOT NEEDED", hostname=hostname, website_id=website_id) + validate_event_data(event_name='NOT NEEDED', hostname=hostname, website_id=website_id) + + # Early return if tracking is disabled + if not tracking_enabled: + return api_url = f'{url_base}{urls.events}' headers = { @@ -388,45 +450,40 @@ def new_page_view(page_title: str, url: str, hostname: Optional[str] = None, } payload = { - "hostname": hostname, - "language": language, - "referrer": referrer, - "screen": screen, - "title": page_title, - "url": url, - "website": website_id, + 'hostname': hostname, + 'language': language, + 'referrer': referrer, + 'screen': screen, + 'title': page_title, + 'url': url, + 'website': website_id, } if ip_address and ip_address.strip(): payload['ip'] = ip_address - event_data = { - 'payload': payload, - 'type': 'event' - } + event_data = {'payload': payload, 'type': 'event'} resp = httpx.post(api_url, json=event_data, headers=headers, follow_redirects=True) resp.raise_for_status() - return resp.text - -def validate_event_data(event_name, hostname, website_id): +def validate_event_data(event_name: str, hostname: Optional[str], website_id: Optional[str]): """ Internal use only. """ if not hostname: - raise Exception("The hostname must be set, either as a parameter here or via set_hostname().") + raise Exception('The hostname must be set, either as a parameter here or via set_hostname().') if not website_id: - raise Exception("The website_id must be set, either as a parameter here or via set_website_id().") + raise Exception('The website_id must be set, either as a parameter here or via set_website_id().') if not event_name and not event_name.strip(): - raise Exception("The event_name is required.") + raise Exception('The event_name is required.') async def verify_token_async(check_server: bool = True) -> bool: """ Verifies that the token set when you called login() is still valid. Umami says this token will expire, - but I'm not sure if that's minutes, hours, or years. + but I'm not sure if that's minutes, hours, or years. Args: check_server: If true, we will contact the server and verify that the token is valid. @@ -488,14 +545,265 @@ def verify_token(check_server: bool = True) -> bool: return False +async def heartbeat_async() -> bool: + """ + Verifies that the server is reachable via the internet and is healthy. + + Returns: True if the server is healthy and accessible. + """ + # noinspection PyBroadException + try: + global auth_token + validate_state(url=True, user=False) + + url = f'{url_base}{urls.heartbeat}' + headers = { + 'User-Agent': user_agent, + } + async with httpx.AsyncClient() as client: + resp = await client.post(url, headers=headers, follow_redirects=True) + resp.raise_for_status() + + return True + except Exception: + return False + + +def heartbeat() -> bool: + """ + Verifies that the server is reachable via the internet and is healthy. + + Returns: True if the server is healthy and accessible. + """ + # noinspection PyBroadException + try: + global auth_token + validate_state(url=True, user=False) + + url = f'{url_base}{urls.heartbeat}' + headers = { + 'User-Agent': user_agent, + } + resp = httpx.post(url, headers=headers, follow_redirects=True) + resp.raise_for_status() + + return True + except Exception: + return False + + def validate_login(email: str, password: str) -> None: """ Internal helper function, not need to use this. """ if not email: - raise ValidationError("Email cannot be empty") + raise ValidationError('Email cannot be empty') if not password: - raise ValidationError("Password cannot be empty") + raise ValidationError('Password cannot be empty') + + +async def active_users_async(website_id: Optional[str] = None) -> int: + """ + Retrieves the active users for a specific website. + + Args: + website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). + + + Returns: The number of active users. + """ + validate_state(url=True, user=True) + + website_id = website_id or default_website_id + + url = f'{url_base}{urls.websites}/{website_id}/active' + headers = { + 'User-Agent': user_agent, + 'Authorization': f'Bearer {auth_token}', + } + + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers=headers, follow_redirects=True) + resp.raise_for_status() + + return int(resp.json().get('x', 0)) + + +def active_users(website_id: Optional[str] = None) -> int: + """ + Retrieves the active users for a specific website. + + Args: + website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). + + + Returns: The number of active users. + """ + validate_state(url=True, user=True) + + website_id = website_id or default_website_id + + url = f'{url_base}{urls.websites}/{website_id}/active' + headers = { + 'User-Agent': user_agent, + 'Authorization': f'Bearer {auth_token}', + } + + resp = httpx.get(url, headers=headers, follow_redirects=True) + resp.raise_for_status() + + return int(resp.json().get('x', 0)) + + +async def website_stats_async( + start_at: datetime, + end_at: datetime, + website_id: Optional[str] = None, + url: Optional[str] = None, + referrer: Optional[str] = None, + title: Optional[str] = None, + query: Optional[str] = None, + event: Optional[str] = None, + host: Optional[str] = None, + os: Optional[str] = None, + browser: Optional[str] = None, + device: Optional[str] = None, + country: Optional[str] = None, + region: Optional[str] = None, + city: Optional[str] = None, +) -> models.WebsiteStats: + """ + Retrieves the statistics for a specific website. + + Args: + start_at: Starting date as a datetime object. + end_at: End date as a datetime object. + website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). + url: OPTIONAL: Name of URL. + referrer: OPTIONAL: Name of referrer. + title: OPTIONAL: Name of page title. + query: OPTIONAL: Name of query. + event: OPTIONAL: Name of event. + host: OPTIONAL: Name of hostname. + os: OPTIONAL: Name of operating system. + browser: OPTIONAL: Name of browser. + device: OPTIONAL: Name of device (ex. Mobile) + country: OPTIONAL: Name of country. + region: OPTIONAL: Name of region/state/province. + city: OPTIONAL: Name of city. + + Returns: A WebsiteStatsResponse model containing the website statistics data. + """ + validate_state(url=True, user=True) + + website_id = website_id or default_website_id + + api_url = f'{url_base}{urls.websites}/{website_id}/stats' + + headers = { + 'User-Agent': user_agent, + 'Authorization': f'Bearer {auth_token}', + } + params = { + 'start_at': int(start_at.timestamp() * 1000), + 'end_at': int(end_at.timestamp() * 1000), + } + optional_params: dict[str, Any] = { + 'url': url, + 'referrer': referrer, + 'title': title, + 'query': query, + 'event': event, + 'host': host, + 'os': os, + 'browser': browser, + 'device': device, + 'country': country, + 'region': region, + 'city': city, + } + params.update({k: v for k, v in optional_params.items() if v is not None}) + + async with httpx.AsyncClient() as client: + resp = await client.get(api_url, headers=headers, params=params, follow_redirects=True) + resp.raise_for_status() + + return models.WebsiteStats(**resp.json()) + + +def website_stats( + start_at: datetime, + end_at: datetime, + website_id: Optional[str] = None, + url: Optional[str] = None, + referrer: Optional[str] = None, + title: Optional[str] = None, + query: Optional[str] = None, + event: Optional[str] = None, + host: Optional[str] = None, + os: Optional[str] = None, + browser: Optional[str] = None, + device: Optional[str] = None, + country: Optional[str] = None, + region: Optional[str] = None, + city: Optional[str] = None, +) -> models.WebsiteStats: + """ + Retrieves the statistics for a specific website. + + Args: + start_at: Starting date as a datetime object. + end_at: End date as a datetime object. + url: OPTIONAL: Name of URL. + website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). + referrer: OPTIONAL: Name of referrer. + title: (OPTIONAL: Name of page title. + query: OPTIONAL: Name of query. + event: OPTIONAL: Name of event. + host: OPTIONAL: Name of hostname. + os: OPTIONAL: Name of operating system. + browser: OPTIONAL: Name of browser. + device: OPTIONAL: Name of device (ex. Mobile) + country: OPTIONAL: Name of country. + region: OPTIONAL: Name of region/state/province. + city: OPTIONAL: Name of city. + + Returns: A WebsiteStatsResponse model containing the website statistics data. + """ + validate_state(url=True, user=True) + + website_id = website_id or default_website_id + + api_url = f'{url_base}{urls.websites}/{website_id}/stats' + + headers = { + 'User-Agent': user_agent, + 'Authorization': f'Bearer {auth_token}', + } + params = { + 'startAt': int(start_at.timestamp() * 1000), + 'endAt': int(end_at.timestamp() * 1000), + } + optional_params: dict[str, Any] = { + 'url': url, + 'referrer': referrer, + 'title': title, + 'query': query, + 'event': event, + 'host': host, + 'os': os, + 'browser': browser, + 'device': device, + 'country': country, + 'region': region, + 'city': city, + } + params.update({k: v for k, v in optional_params.items() if v is not None}) + + resp = httpx.get(api_url, headers=headers, params=params, follow_redirects=True) + resp.raise_for_status() + + return models.WebsiteStats(**resp.json()) def validate_state(url: bool = False, user: bool = False): @@ -503,7 +811,7 @@ def validate_state(url: bool = False, user: bool = False): Internal helper function, not need to use this. """ if url and not url_base: - raise OperationNotAllowedError("URL Base must be set to proceed.") + raise OperationNotAllowedError('URL Base must be set to proceed.') if user and not auth_token: - raise OperationNotAllowedError("You must login before proceeding.") + raise OperationNotAllowedError('You must login before proceeding.') diff --git a/umami/umami/models/__init__.py b/umami/umami/models/__init__.py index d0fc6ff..5f56d19 100644 --- a/umami/umami/models/__init__.py +++ b/umami/umami/models/__init__.py @@ -16,45 +16,38 @@ class LoginResponse(pydantic.BaseModel): user: User -class TokenVerification(pydantic.BaseModel): - id: str - username: str - role: str - createdAt: str - isAdmin: bool - - -class WebsiteTeam(pydantic.BaseModel): - name: str - - class WebsiteUser(pydantic.BaseModel): username: str id: str -class TeamSiteDetails(pydantic.BaseModel): - id: str - teamId: str - websiteId: str - createdAt: str - team: WebsiteTeam - - class Website(pydantic.BaseModel): id: str name: typing.Optional[str] = None domain: str shareId: typing.Any resetAt: typing.Any - userId: str + userId: typing.Optional[str] = None createdAt: str updatedAt: str deletedAt: typing.Any - teamWebsite: list[TeamSiteDetails] + teamId: typing.Optional[str] = None user: WebsiteUser +class Metric(pydantic.BaseModel): + value: int + prev: int + + +class WebsiteStats(pydantic.BaseModel): + pageviews: Metric + visitors: Metric + visits: Metric + bounces: Metric + totaltime: Metric + + class WebsitesResponse(pydantic.BaseModel): websites: list[Website] = pydantic.Field(alias='data') count: int diff --git a/umami/umami/urls.py b/umami/umami/urls.py index 1df8139..ea10055 100644 --- a/umami/umami/urls.py +++ b/umami/umami/urls.py @@ -2,3 +2,4 @@ websites = '/api/websites' events = '/api/send' verify = '/api/auth/verify' +heartbeat = '/api/heartbeat'