Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions Meshflow/common/mc_channel_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,22 @@
from nodes.models import ManagedNode, ManagedNodeMcChannelLink


def _scope_suffix(channel: MessageChannel) -> str:
scope = (channel.region_scope or "").strip()
if scope:
return f" · {scope}"
return ""


def mc_channel_admin_label(channel: MessageChannel) -> str:
"""Human label for admin lists: #hashtag for HASHTAG, plain name for PUBLIC."""
if channel.mc_channel_type == MeshCoreChannelType.HASHTAG:
tag = (channel.mc_hashtag or channel.name or "").strip().lstrip("#")
tag = (channel.name or "").strip().lstrip("#")
if tag:
return f"#{tag}"
return f"#{tag}{_scope_suffix(channel)}"
name = (channel.name or "").strip()
if name:
return name
return f"{name}{_scope_suffix(channel)}"
return str(channel.pk)


Expand Down Expand Up @@ -53,10 +60,10 @@ def message_channel_to_apply_entry(
"mc_channel_idx": mc_channel_idx,
"name": channel.name,
"mc_channel_type": ch_type,
"region_scope": channel.region_scope,
}
if channel.mc_channel_type == MeshCoreChannelType.HASHTAG:
tag = (channel.mc_hashtag or channel.name or "").strip().lstrip("#")
entry["mc_hashtag"] = tag[:64] if tag else None
tag = (channel.name or "").strip().lstrip("#")
if tag:
entry["name"] = tag[:100]
return entry
Expand Down
31 changes: 31 additions & 0 deletions Meshflow/common/mc_region_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Normalize MeshCore region scope names for storage and API validation."""

from __future__ import annotations

import re

REGION_SCOPE_MAX_BYTES = 29

# Lowercase alphanumeric and hyphen per MeshCore region naming rules.
_REGION_SCOPE_RE = re.compile(r"^[a-z0-9-]+$")

_NULL_SCOPE_VALUES = frozenset({"", "*", "none", "null"})


def normalize_region_scope(value: str | None) -> str | None:
"""
Return a canonical region name for DB storage, or None for null/legacy scope.

Strips whitespace, lowercases, rejects invalid characters. Maps *, empty, and
"none" to None (null region — legacy flood everywhere on mesh).
"""
if value is None:
return None
raw = str(value).strip().lower().lstrip("#")
if not raw or raw in _NULL_SCOPE_VALUES:
return None
if len(raw.encode("utf-8")) > REGION_SCOPE_MAX_BYTES:
raise ValueError(f"region_scope exceeds {REGION_SCOPE_MAX_BYTES} UTF-8 bytes.")
if not _REGION_SCOPE_RE.match(raw):
raise ValueError("region_scope must be lowercase alphanumeric with hyphens only.")
return raw
18 changes: 15 additions & 3 deletions Meshflow/common/tests/test_mc_channel_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,23 @@ def test_mc_channel_admin_label_hashtag_prefix(create_constellation):
constellation=constellation,
protocol=Protocol.MESHCORE,
mc_channel_type=MeshCoreChannelType.HASHTAG,
mc_hashtag="galloway",
)
assert mc_channel_admin_label(ch) == "#galloway"


@pytest.mark.django_db
def test_mc_channel_admin_label_hashtag_with_scope(create_constellation):
constellation = create_constellation()
ch = MessageChannel.objects.create(
name="galloway",
constellation=constellation,
protocol=Protocol.MESHCORE,
mc_channel_type=MeshCoreChannelType.HASHTAG,
region_scope="sample-west",
)
assert mc_channel_admin_label(ch) == "#galloway · sample-west"


@pytest.mark.django_db
def test_message_channel_to_apply_entry_hashtag(create_constellation):
constellation = create_constellation()
Expand All @@ -43,9 +55,9 @@ def test_message_channel_to_apply_entry_hashtag(create_constellation):
constellation=constellation,
protocol=Protocol.MESHCORE,
mc_channel_type=MeshCoreChannelType.HASHTAG,
mc_hashtag="galloway",
region_scope="sample-west",
)
entry = message_channel_to_apply_entry(ch, mc_channel_idx=1)
assert entry["mc_channel_type"] == "HASHTAG"
assert entry["mc_hashtag"] == "galloway"
assert entry["name"] == "galloway"
assert entry["region_scope"] == "sample-west"
25 changes: 25 additions & 0 deletions Meshflow/common/tests/test_mc_region_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Tests for MeshCore region_scope normalization."""

import pytest

from common.mc_region_scope import normalize_region_scope


def test_normalize_region_scope_null_values():
assert normalize_region_scope(None) is None
assert normalize_region_scope("") is None
assert normalize_region_scope("*") is None
assert normalize_region_scope("none") is None
assert normalize_region_scope(" * ") is None


def test_normalize_region_scope_valid():
assert normalize_region_scope("Sample-West") == "sample-west"
assert normalize_region_scope("#galloway") == "galloway"


def test_normalize_region_scope_invalid():
with pytest.raises(ValueError, match="alphanumeric"):
normalize_region_scope("bad scope")
with pytest.raises(ValueError, match="UTF-8"):
normalize_region_scope("a" * 30)
8 changes: 4 additions & 4 deletions Meshflow/constellations/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,18 +116,18 @@ class MeshCoreMessageChannelAdmin(admin.ModelAdmin):
("mc_channel_type", admin.ChoicesFieldListFilter),
"constellation",
)
search_fields = ("name", "mc_hashtag", "constellation__name")
search_fields = ("name", "region_scope", "constellation__name")
ordering = ("constellation__name", "name")
list_select_related = ("constellation",)
fieldsets = (
(None, {"fields": ("constellation",)}),
(
_("Channel"),
{
"fields": ("name", "mc_channel_type", "mc_hashtag"),
"fields": ("name", "mc_channel_type", "region_scope"),
"description": _(
"PUBLIC channels use a plain name. HASHTAG channels use mc_hashtag "
"(no leading #); lists show #prefix for hashtags."
"PUBLIC channels use a plain name. HASHTAG channels store the tag in name "
"(no leading #); lists show #prefix. region_scope is optional (null = legacy scope)."
),
},
),
Expand Down
124 changes: 124 additions & 0 deletions Meshflow/constellations/migrations/0015_messagechannel_region_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Add region_scope, backfill from mc_hashtag, merge duplicates, drop mc_hashtag

from django.db import migrations, models

MC_PROTOCOL = 2


def _normalize_tag(value):
if not value:
return ""
return str(value).strip().lstrip("#").lower()[:100]


def _logical_key(channel):
name = str(channel.name or "").strip().lower()
if channel.mc_channel_type == 2 and channel.mc_hashtag:
tag = _normalize_tag(channel.mc_hashtag)
if tag:
name = tag
return (channel.mc_channel_type, name, channel.region_scope)


def _repoint_channel_fks(apps, old_id, new_id):
if old_id == new_id:
return
TextMessage = apps.get_model("text_messages", "TextMessage")
TextMessage.objects.filter(channel_id=old_id).update(channel_id=new_id)

try:
MeshCoreTextPacket = apps.get_model("meshcore_packets", "MeshCoreTextPacket")
MeshCoreTextPacket.objects.filter(channel_id=old_id).update(channel_id=new_id)
except LookupError:
pass

try:
MeshCorePacketObservation = apps.get_model("meshcore_packets", "MeshCorePacketObservation")
MeshCorePacketObservation.objects.filter(channel_id=old_id).update(channel_id=new_id)
except LookupError:
pass

Link = apps.get_model("nodes", "ManagedNodeMcChannelLink")
Link.objects.filter(message_channel_id=old_id).update(message_channel_id=new_id)


def backfill_hashtag_names(apps, schema_editor):
MessageChannel = apps.get_model("constellations", "MessageChannel")
for ch in MessageChannel.objects.filter(protocol=MC_PROTOCOL, mc_channel_type=2):
tag = _normalize_tag(ch.mc_hashtag or ch.name)
if tag and ch.name != tag:
ch.name = tag
ch.save(update_fields=["name"])


def merge_duplicate_mc_channels(apps, schema_editor):
MessageChannel = apps.get_model("constellations", "MessageChannel")
channels = list(MessageChannel.objects.filter(protocol=MC_PROTOCOL))
groups = {}
for ch in channels:
key = (ch.constellation_id, _logical_key(ch))
groups.setdefault(key, []).append(ch)

for _key, group in groups.items():
if len(group) < 2:
continue
group.sort(key=lambda c: c.id)
survivor = group[0]
for dup in group[1:]:
_repoint_channel_fks(apps, dup.id, survivor.id)
MessageChannel.objects.filter(id=dup.id).delete()


class Migration(migrations.Migration):

dependencies = [
("constellations", "0014_mc_canonical_channels_finalize"),
("text_messages", "0008_textmessage_mc_provenance"),
("meshcore_packets", "0004_observation_path_hash_size_mode"),
]

operations = [
migrations.AddField(
model_name="messagechannel",
name="region_scope",
field=models.CharField(
blank=True,
help_text=(
"MeshCore region scope when protocol is MeshCore (lowercase alphanumeric + "
"hyphen; null = legacy / no scope)."
),
max_length=29,
null=True,
),
),
migrations.RunPython(backfill_hashtag_names, migrations.RunPython.noop),
migrations.RunPython(merge_duplicate_mc_channels, migrations.RunPython.noop),
migrations.RemoveConstraint(
model_name="messagechannel",
name="messagechannel_mc_hashtag_constellation_unique",
),
migrations.RemoveConstraint(
model_name="messagechannel",
name="messagechannel_mc_public_name_constellation_unique",
),
migrations.RemoveField(
model_name="messagechannel",
name="mc_hashtag",
),
migrations.AddConstraint(
model_name="messagechannel",
constraint=models.UniqueConstraint(
condition=models.Q(("protocol", MC_PROTOCOL), ("region_scope__isnull", True)),
fields=("constellation", "protocol", "name", "mc_channel_type"),
name="messagechannel_mc_null_scope_unique",
),
),
migrations.AddConstraint(
model_name="messagechannel",
constraint=models.UniqueConstraint(
condition=models.Q(("protocol", MC_PROTOCOL), ("region_scope__isnull", False)),
fields=("constellation", "protocol", "name", "mc_channel_type", "region_scope"),
name="messagechannel_mc_region_scope_unique",
),
),
]
22 changes: 12 additions & 10 deletions Meshflow/constellations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,33 +53,35 @@ class MessageChannel(models.Model):
blank=True,
help_text=_("MeshCore channel type when protocol is MeshCore."),
)
mc_hashtag = models.CharField(
max_length=64,
region_scope = models.CharField(
max_length=29,
null=True,
blank=True,
help_text=_("Hashtag string when mc_channel_type is HASHTAG (no leading #)."),
help_text=_(
"MeshCore region scope when protocol is MeshCore (lowercase alphanumeric + hyphen; "
"null = legacy / no scope)."
),
)

class Meta:
verbose_name = _("Message channel")
verbose_name_plural = _("Message channels")
constraints = [
models.UniqueConstraint(
fields=["constellation", "protocol", "mc_hashtag"],
fields=["constellation", "protocol", "name", "mc_channel_type"],
condition=models.Q(
protocol=Protocol.MESHCORE,
mc_channel_type=MeshCoreChannelType.HASHTAG,
mc_hashtag__isnull=False,
region_scope__isnull=True,
),
name="messagechannel_mc_hashtag_constellation_unique",
name="messagechannel_mc_null_scope_unique",
),
models.UniqueConstraint(
fields=["constellation", "protocol", "name"],
fields=["constellation", "protocol", "name", "mc_channel_type", "region_scope"],
condition=models.Q(
protocol=Protocol.MESHCORE,
mc_channel_type=MeshCoreChannelType.PUBLIC,
region_scope__isnull=False,
),
name="messagechannel_mc_public_name_constellation_unique",
name="messagechannel_mc_region_scope_unique",
),
]

Expand Down
2 changes: 1 addition & 1 deletion Meshflow/constellations/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def message_channel_payload(channel: MessageChannel) -> dict:
"name": channel.name,
"protocol": channel.protocol,
"mc_channel_type": mc_type,
"mc_hashtag": channel.mc_hashtag,
"region_scope": channel.region_scope,
"constellation": channel.constellation_id,
}
if channel.protocol == Protocol.MESHCORE:
Expand Down
Loading