mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 00:57:38 -04:00
* refactor(packaging): adopt src/ layout with split server/viewer packages
Move all Python source under src/ following modern packaging conventions.
Server, viewer, host-agent, and shared common code now live as four
top-level packages with clear excision boundaries — anthias_viewer can
be removed wholesale when the rewrite-out-of-Python lands without
touching the server.
src/anthias_common/ shared: errors, utils, internal_auth, device_helper
src/anthias_server/ Django app, REST API, Celery tasks, manage.py
lib/ server-only: auth, backup_helper, diagnostics, github, telemetry
src/anthias_viewer/ player runtime (was viewer/)
src/anthias_host_agent/ systemd-driven host shim (was host_agent.py)
tools/raspberry_pi_imager/ moved from repo root
tests/conftest.py moved from repo root
pyproject.toml gets [build-system], setuptools src/ discovery, and an
anthias-manage console script. Django AppConfigs keep label='anthias_app'
and label='api' so existing migration dependency tuples don't move.
BASE_DIR computed from parents[3] to keep templates/static at repo root.
mypy_path set to ["src", "stubs"] with explicit_package_bases.
Dockerfile templates set PYTHONPATH=/usr/src/app/src; bin/start_*.sh
and CI workflows use python -m anthias_server.manage / python -m
anthias_viewer instead of bare ./manage.py and python -m viewer.
Ansible host-agent unit invokes python -m anthias_host_agent.
Verified end-to-end in the docker test container:
- 430 unit tests pass (matches baseline)
- 7 integration tests pass, 5 skipped (matches baseline)
- ruff, mypy clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style: ruff format the new src/ tree
The longer post-rename module paths (anthias_common.internal_auth vs
lib.internal_auth, etc.) pushed several import lines past 79 chars, so
ruff format had to wrap them. Apply that formatting and split the one
multi-import in anthias_viewer/__init__.py into per-symbol lines so the
existing # noqa: E402 sits on the `from` line where ruff expects it,
without needing a re-anchor when format wraps the parens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: realign sonar + gitignore comment to src/ layout
sonar-project.properties still pointed at the pre-refactor top-level
packages (anthias_app, anthias_django, api, lib, viewer, ...) and
their old per-file coverage.exclusions paths, which would have
produced empty Sonar runs and stale exclusions. Collapse sources to
`src` and rewrite the exclusions to the new src/anthias_*/ paths.
Also fix the stale path reference in .gitignore's comment for the
test DB (now src/anthias_server/django_project/settings.py).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: gitignore .claude/ and untrack the lock file I just leaked
Previous commit accidentally pulled in .claude/scheduled_tasks.lock
because .claude was in .dockerignore but not .gitignore. Add the
pattern to .gitignore and drop the file from the index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(dockerignore): exclude pytest cache, __pycache__ dirs, and the local test DB
Three entries that were missing relative to the new src/ layout:
- .anthias-test.db (and -journal/-wal/-shm siblings) — created at the
repo root by src/anthias_server/django_project/settings.py when a
developer runs the host pytest suite. Without this exclude, the
next docker build COPY . bakes the file into /usr/src/app/.
- **/__pycache__ — *.py[co] only matched the .pyc/.pyo files, leaving
the empty cache directories to ship.
- .pytest_cache — host-side, regenerable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(urls): preserve 'anthias_app' URL namespace, not just the app label
Copilot caught that the import-rewrite swept up the URL namespace too:
app_name in src/anthias_server/app/urls.py changed from 'anthias_app'
to 'anthias_server.app', which leaves templates/login.html's
{% url 'anthias_app:login' %} pointing at a namespace that no longer
exists — NoReverseMatch at render time when an unauthenticated request
hits the login page.
The namespace is the same kind of stable user-facing identifier as the
AppConfig label (which we already kept as 'anthias_app'). Restore it,
and revert the two reverse() callers in lib/auth.py and app/views.py
that the rewrite changed in lockstep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): update --confcutdir to the new tools/raspberry_pi_imager path
Copilot caught that the earlier sweep missed --confcutdir=raspberry_pi_imager
(no trailing slash) — replace_all of "raspberry_pi_imager/" only matched
path-with-slash forms. Without confcutdir, pytest walks back up looking
for conftests and discovers the repo-root tests/conftest.py, which
applies the Anthias-specific Django/Redis stubs to the rpi-imager test
run on the website-deploy workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
198 lines
5.8 KiB
Python
198 lines
5.8 KiB
Python
import json
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from requests.exceptions import ConnectionError as RequestsConnectionError
|
|
from requests.exceptions import Timeout
|
|
|
|
from anthias_server.lib import telemetry
|
|
|
|
|
|
@pytest.fixture
|
|
def redis_data() -> dict[str, str]:
|
|
return {}
|
|
|
|
|
|
@pytest.fixture
|
|
def settings_data() -> dict[str, Any]:
|
|
return {
|
|
'analytics_opt_out': False,
|
|
'resolution': '1920x1080',
|
|
'audio_output': 'hdmi',
|
|
'use_ssl': False,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_redis(redis_data: dict[str, str]) -> MagicMock:
|
|
fake = MagicMock()
|
|
fake.get.side_effect = redis_data.get
|
|
fake.set.side_effect = lambda key, value: redis_data.__setitem__(
|
|
key, value
|
|
)
|
|
fake.expire.side_effect = lambda key, _ttl: None
|
|
return fake
|
|
|
|
|
|
@pytest.fixture
|
|
def telemetry_env(
|
|
fake_redis: MagicMock,
|
|
settings_data: dict[str, Any],
|
|
) -> Iterator[None]:
|
|
patches = [
|
|
patch.object(telemetry, 'r', fake_redis),
|
|
patch.object(telemetry, 'is_ci', return_value=False),
|
|
patch.object(telemetry, 'is_balena_app', return_value=False),
|
|
patch.object(telemetry, 'get_git_branch', return_value='master'),
|
|
patch.object(telemetry, 'get_git_short_hash', return_value='abc1234'),
|
|
patch.object(
|
|
telemetry, 'parse_cpu_info', return_value={'model': 'Pi 4'}
|
|
),
|
|
]
|
|
settings_patch = patch.object(telemetry, 'settings')
|
|
mock_settings = settings_patch.start()
|
|
mock_settings.__getitem__.side_effect = settings_data.__getitem__
|
|
for p in patches:
|
|
p.start()
|
|
try:
|
|
yield
|
|
finally:
|
|
settings_patch.stop()
|
|
for p in patches:
|
|
p.stop()
|
|
|
|
|
|
@patch.object(telemetry, 'requests_post')
|
|
def test_sends_event_when_no_cooldown(
|
|
mock_post: Any, telemetry_env: None
|
|
) -> None:
|
|
with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4-64'}):
|
|
assert telemetry.send_telemetry() is True
|
|
|
|
mock_post.assert_called_once()
|
|
call_args = mock_post.call_args
|
|
url = call_args.args[0]
|
|
assert 'measurement_id=' in url
|
|
assert 'api_secret=' in url
|
|
|
|
payload = json.loads(call_args.kwargs['data'])
|
|
assert len(payload['client_id']) == 15
|
|
event = payload['events'][0]
|
|
assert event['name'] == 'device_active'
|
|
params = event['params']
|
|
assert params['branch'] == 'master'
|
|
assert params['commit_short'] == 'abc1234'
|
|
assert params['device_type'] == 'pi4-64'
|
|
assert params['hardware_model'] == 'Pi 4'
|
|
# NOOBS is gone.
|
|
assert 'is_noobs' not in params
|
|
assert 'NOOBS' not in params
|
|
# Asset counts always present even when DB is empty.
|
|
assert 'asset_count' in params
|
|
assert 'asset_image_count' in params
|
|
assert 'asset_video_count' in params
|
|
assert 'asset_webpage_count' in params
|
|
# Display + TLS adoption.
|
|
assert params['resolution'] == '1920x1080'
|
|
assert params['audio_output'] == 'hdmi'
|
|
assert params['tls_enabled'] is False
|
|
|
|
|
|
@patch.object(telemetry, 'requests_post')
|
|
def test_passes_timeout(mock_post: Any, telemetry_env: None) -> None:
|
|
telemetry.send_telemetry()
|
|
assert mock_post.call_args.kwargs['timeout'] == telemetry.ANALYTICS_TIMEOUT
|
|
|
|
|
|
@patch.object(telemetry, 'requests_post')
|
|
def test_skips_when_cooldown_set(
|
|
mock_post: Any,
|
|
telemetry_env: None,
|
|
redis_data: dict[str, str],
|
|
) -> None:
|
|
redis_data[telemetry.TELEMETRY_COOLDOWN_KEY] = '1'
|
|
assert telemetry.send_telemetry() is False
|
|
mock_post.assert_not_called()
|
|
|
|
|
|
@patch.object(telemetry, 'requests_post')
|
|
def test_skips_when_opted_out(
|
|
mock_post: Any,
|
|
telemetry_env: None,
|
|
settings_data: dict[str, Any],
|
|
) -> None:
|
|
settings_data['analytics_opt_out'] = True
|
|
assert telemetry.send_telemetry() is False
|
|
mock_post.assert_not_called()
|
|
|
|
|
|
@patch.object(telemetry, 'requests_post')
|
|
def test_skips_in_ci(mock_post: Any, telemetry_env: None) -> None:
|
|
with patch.object(telemetry, 'is_ci', return_value=True):
|
|
assert telemetry.send_telemetry() is False
|
|
mock_post.assert_not_called()
|
|
|
|
|
|
@patch.object(telemetry, 'requests_post', side_effect=RequestsConnectionError)
|
|
def test_swallows_connection_error_and_skips_cooldown(
|
|
_mock_post: Any,
|
|
telemetry_env: None,
|
|
redis_data: dict[str, str],
|
|
) -> None:
|
|
assert telemetry.send_telemetry() is False
|
|
# No cooldown set — next tick retries.
|
|
assert telemetry.TELEMETRY_COOLDOWN_KEY not in redis_data
|
|
|
|
|
|
@patch.object(telemetry, 'requests_post', side_effect=Timeout)
|
|
def test_swallows_timeout(
|
|
_mock_post: Any,
|
|
telemetry_env: None,
|
|
redis_data: dict[str, str],
|
|
) -> None:
|
|
assert telemetry.send_telemetry() is False
|
|
assert telemetry.TELEMETRY_COOLDOWN_KEY not in redis_data
|
|
|
|
|
|
@patch.object(telemetry, 'requests_post')
|
|
def test_sets_cooldown_after_success(
|
|
_mock_post: Any,
|
|
telemetry_env: None,
|
|
redis_data: dict[str, str],
|
|
fake_redis: MagicMock,
|
|
) -> None:
|
|
telemetry.send_telemetry()
|
|
assert telemetry.TELEMETRY_COOLDOWN_KEY in redis_data
|
|
fake_redis.expire.assert_any_call(
|
|
telemetry.TELEMETRY_COOLDOWN_KEY,
|
|
telemetry.TELEMETRY_COOLDOWN_TTL,
|
|
)
|
|
|
|
|
|
@patch.object(telemetry, 'requests_post')
|
|
def test_reuses_persisted_device_id(
|
|
mock_post: Any,
|
|
telemetry_env: None,
|
|
redis_data: dict[str, str],
|
|
) -> None:
|
|
redis_data[telemetry.DEVICE_ID_KEY] = 'persisted-id-123'
|
|
telemetry.send_telemetry()
|
|
payload = json.loads(mock_post.call_args.kwargs['data'])
|
|
assert payload['client_id'] == 'persisted-id-123'
|
|
|
|
|
|
@patch.object(telemetry, 'requests_post')
|
|
def test_generates_and_persists_new_device_id(
|
|
_mock_post: Any,
|
|
telemetry_env: None,
|
|
redis_data: dict[str, str],
|
|
) -> None:
|
|
assert telemetry.DEVICE_ID_KEY not in redis_data
|
|
telemetry.send_telemetry()
|
|
assert telemetry.DEVICE_ID_KEY in redis_data
|
|
assert (
|
|
len(redis_data[telemetry.DEVICE_ID_KEY]) == telemetry.DEVICE_ID_LENGTH
|
|
)
|