mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-09 18:32:44 +02:00
feat: add observer multi-select and collapsible filters to list pages
- Add observer multi-select (<select multiple size=2>) to Advertisements and Messages filter bars, populated from /api/v1/nodes?observer=true - Make all filter sections collapsible via <details> on Nodes, Advertisements, and Messages pages; collapsed by default, auto-expands when active filters exist, preserves open state across auto-refresh ticks - Add backend observer=true|false query param to GET /api/v1/nodes for observer-only or non-observer-only node filtering via subquery - Change observed_by in Advertisements/Messages API from single public_key to list[str] with .in_() for multi-select support - Fix router.js and api.js to handle array query params (duplicate keys promoted to arrays, .append() per element) - Fix createFilterHandler to use FormData.getAll() for multi-value support - Replace DaisyUI form-control/label/label-text classes with Tailwind-native equivalents (flex flex-col gap-1, flex items-center py-1, opacity-80 text-sm) since DaisyUI CSS is tree-shaken from the build output - Thicker collapsible border (border-2 border-base-content/25) visible in both light and dark themes - Bottom-align Filter/Clear buttons via two-row form layout - Move Observer filter to last position on Advertisements page - Add filter_observer_label i18n key - Add tests for observer=true node filtering and multi observer params
This commit is contained in:
@@ -84,22 +84,6 @@ class TestListAdvertisements:
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["node_tag_name"] == "Friendly Search Name"
|
||||
|
||||
def test_list_advertisements_filter_by_public_key(
|
||||
self, client_no_auth, sample_advertisement
|
||||
):
|
||||
"""Test filtering advertisements by public key."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/advertisements?public_key={sample_advertisement.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
response = client_no_auth.get("/api/v1/advertisements?public_key=nonexistent")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
|
||||
class TestGetAdvertisement:
|
||||
"""Tests for GET /advertisements/{id} endpoint."""
|
||||
@@ -199,14 +183,14 @@ class TestListAdvertisementsFilters:
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_observed_by(
|
||||
def test_list_advertisements_filter_by_observed_by_single(
|
||||
self,
|
||||
client_no_auth,
|
||||
sample_advertisement,
|
||||
sample_advertisement_with_receiver,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering advertisements by receiver node."""
|
||||
"""Test filtering advertisements by a single receiver node."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/advertisements?observed_by={receiver_node.public_key}"
|
||||
)
|
||||
@@ -214,6 +198,57 @@ class TestListAdvertisementsFilters:
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_list_advertisements_filter_by_observed_by_multiple(
|
||||
self,
|
||||
client_no_auth,
|
||||
api_db_session,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering advertisements by multiple receiver nodes."""
|
||||
# Create second receiver node
|
||||
second_receiver = receiver_node.__class__(
|
||||
public_key="2nd1232nd1232nd1232nd1232nd1232n",
|
||||
name="SecondObserver",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(second_receiver)
|
||||
api_db_session.commit()
|
||||
|
||||
# Create two advertisements, each observed by a different receiver
|
||||
ad1 = Advertisement(
|
||||
public_key="ad1pubad1pubad1pubad1pubad1pubad",
|
||||
name="AD1",
|
||||
adv_type="CLIENT",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
observer_node_id=receiver_node.id,
|
||||
)
|
||||
ad2 = Advertisement(
|
||||
public_key="ad2pubad2pubad2pubad2pubad2pubad",
|
||||
name="AD2",
|
||||
adv_type="CLIENT",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
observer_node_id=second_receiver.id,
|
||||
)
|
||||
api_db_session.add_all([ad1, ad2])
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter by both receivers
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/advertisements?observed_by={receiver_node.public_key}&observed_by={second_receiver.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
# Filter by just the first receiver
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/advertisements?observed_by={receiver_node.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["name"] == "AD1"
|
||||
|
||||
def test_filter_by_since(self, client_no_auth, api_db_session):
|
||||
"""Test filtering advertisements by since timestamp."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -216,14 +216,14 @@ class TestListMessagesFilters:
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_observed_by(
|
||||
def test_filter_by_observed_by_single(
|
||||
self,
|
||||
client_no_auth,
|
||||
sample_message,
|
||||
sample_message_with_receiver,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering messages by receiver node."""
|
||||
"""Test filtering messages by a single receiver node."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/messages?observed_by={receiver_node.public_key}"
|
||||
)
|
||||
@@ -232,6 +232,57 @@ class TestListMessagesFilters:
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["text"] == sample_message_with_receiver.text
|
||||
|
||||
def test_filter_by_observed_by_multiple(
|
||||
self,
|
||||
client_no_auth,
|
||||
api_db_session,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering messages by multiple receiver nodes."""
|
||||
# Create second receiver node
|
||||
second_receiver = Node(
|
||||
public_key="2ndmsg2ndmsg2ndmsg2ndmsg2ndmsg2n",
|
||||
name="SecondMsgObserver",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(second_receiver)
|
||||
api_db_session.commit()
|
||||
|
||||
# Create two messages, each observed by a different receiver
|
||||
msg1 = Message(
|
||||
message_type="channel",
|
||||
channel_idx=1,
|
||||
text="Msg from receiver A",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
observer_node_id=receiver_node.id,
|
||||
)
|
||||
msg2 = Message(
|
||||
message_type="channel",
|
||||
channel_idx=2,
|
||||
text="Msg from receiver B",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
observer_node_id=second_receiver.id,
|
||||
)
|
||||
api_db_session.add_all([msg1, msg2])
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter by both receivers
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/messages?observed_by={receiver_node.public_key}&observed_by={second_receiver.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
# Filter by just the first receiver
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/messages?observed_by={receiver_node.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["text"] == "Msg from receiver A"
|
||||
|
||||
def test_filter_by_since(self, client_no_auth, api_db_session):
|
||||
"""Test filtering messages by since timestamp."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -168,6 +168,44 @@ class TestListNodesFilters:
|
||||
assert room_node.public_key in room_keys
|
||||
assert name_only_room_node.public_key not in room_keys
|
||||
|
||||
def test_filter_by_observer_true(
|
||||
self, client_no_auth, api_db_session, receiver_node
|
||||
):
|
||||
"""Test filtering nodes that have observed events."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from meshcore_hub.common.models import Advertisement, Message
|
||||
|
||||
# This node has observed an ad and a message
|
||||
advert = Advertisement(
|
||||
public_key="obsflt1obsflt1obsflt1obsflt1ob",
|
||||
name="ObservedAd",
|
||||
adv_type="CLIENT",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
observer_node_id=receiver_node.id,
|
||||
)
|
||||
msg = Message(
|
||||
message_type="channel",
|
||||
channel_idx=1,
|
||||
text="Observed msg",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
observer_node_id=receiver_node.id,
|
||||
)
|
||||
api_db_session.add_all([advert, msg])
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.get("/api/v1/nodes?observer=true")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
observer_keys = {item["public_key"] for item in data["items"]}
|
||||
assert receiver_node.public_key in observer_keys
|
||||
|
||||
response = client_no_auth.get("/api/v1/nodes?observer=false")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
non_observer_keys = {item["public_key"] for item in data["items"]}
|
||||
assert receiver_node.public_key not in non_observer_keys
|
||||
|
||||
|
||||
class TestGetNode:
|
||||
"""Tests for GET /nodes/{public_key} endpoint."""
|
||||
|
||||
Reference in New Issue
Block a user