diff --git a/aleksis/apps/matrix/migrations/0002_drop_onetoone.py b/aleksis/apps/matrix/migrations/0002_drop_onetoone.py new file mode 100644 index 0000000000000000000000000000000000000000..85a6201dcbebb651c6287b879e1b50512dab27b1 --- /dev/null +++ b/aleksis/apps/matrix/migrations/0002_drop_onetoone.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.10 on 2022-01-06 15:06 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0031_auto_20211228_0008'), + ('matrix', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='matrixroom', + name='group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='matrix_room', to='core.group', verbose_name='Group'), + ), + ] diff --git a/aleksis/apps/matrix/model_extensions.py b/aleksis/apps/matrix/model_extensions.py index 2fca9b202dfddae2d01dd7fd589ec3537ea1442c..ab620422e697417717caa69f7d7519761abdfdbb 100644 --- a/aleksis/apps/matrix/model_extensions.py +++ b/aleksis/apps/matrix/model_extensions.py @@ -29,17 +29,15 @@ def _use_in_matrix(self): @Group.property_ def matrix_alias(self) -> Optional[str]: """Return the alias of the group's room in Matrix.""" - if hasattr(self, "matrix_room"): - return self.matrix_room.alias - return None + rooms = [room for room in self.matrix_rooms.all() if type(room) == MatrixRoom] + return rooms[0].alias if rooms else None @Group.property_ def matrix_room_id(self) -> Optional[str]: """Return the ID of the group's room in Matrix.""" - if hasattr(self, "matrix_room"): - return self.matrix_room.room_id - return None + rooms = [room for room in self.matrix_rooms.all() if type(room) == MatrixRoom] + return rooms[0].room_id if rooms else None Group.add_permission("view_matrixroom", _("Can view matrix room of a group")) diff --git a/aleksis/apps/matrix/models.py b/aleksis/apps/matrix/models.py index fff467f7a0ab722c3cd9536cad8ef1da5945e827..6b5858fefe39f4f61fa8a75c61e91bf40a4be108 100644 --- a/aleksis/apps/matrix/models.py +++ b/aleksis/apps/matrix/models.py @@ -55,37 +55,45 @@ class MatrixRoom(ExtensiblePolymorphicModel): room_id = models.CharField(max_length=255, verbose_name=_("Room ID"), unique=True) alias = models.CharField(max_length=255, verbose_name=_("Alias"), unique=True, blank=True) - group = models.OneToOneField( + group = models.ForeignKey( Group, on_delete=models.CASCADE, verbose_name=_("Group"), - related_name="matrix_room", + related_name="matrix_rooms", ) @classmethod - def from_group(self, group: Group): + def get_queryset(cls): + return cls.objects.not_instance_of(MatrixSpace) + + @classmethod + def build_alias(cls, group: Group) -> str: + return slugify(group.short_name or group.name) + + @classmethod + def from_group(cls, group: Group) -> "MatrixRoom": """Create a Matrix room from a group.""" from .matrix import MatrixException, do_matrix_request try: - room = MatrixRoom.objects.get(group=group) - except MatrixRoom.DoesNotExist: - room = MatrixRoom(group=group) + room = cls.get_queryset().get(group=group) + except cls.DoesNotExist: + room = cls(group=group) if room.room_id: # Existing room, check if still accessible r = do_matrix_request("GET", f"directory/list/room/{room.room_id}") else: # Room does not exist, create it - alias = slugify(group.short_name or group.name) + alias = cls.build_alias(group) profiles_to_invite = list( - self.get_profiles_for_group(group).values_list("matrix_id", flat=True) + cls.get_profiles_for_group(group).values_list("matrix_id", flat=True) ) alias_found = False while not alias_found: try: - r = self._create_room(group.name, alias, profiles_to_invite) + r = cls._create_room(group.name, alias, profiles_to_invite) alias_found = True except MatrixException as e: print(e.args, get_site_preferences()["matrix__disambiguate_room_aliases"]) @@ -111,10 +119,23 @@ class MatrixRoom(ExtensiblePolymorphicModel): return room @classmethod - def _create_room(self, name, alias, invite: List[str]): + def _create_room( + self, + name, + alias, + invite: Optional[List[str]] = None, + creation_content: Optional[dict] = None, + ) -> Dict[str, Any]: from .matrix import do_matrix_request - body = {"preset": "private_chat", "name": name, "room_alias_name": alias, "invite": invite} + body = {"preset": "private_chat", "name": name, "room_alias_name": alias} + + if invite: + body["invite"] = invite + + if creation_content: + body["creation_content"] = creation_content + r = do_matrix_request("POST", "createRoom", body=body) return r @@ -210,9 +231,18 @@ class MatrixRoom(ExtensiblePolymorphicModel): user_levels[profile.matrix_id] = 0 self._set_power_levels(user_levels) + def sync_space(self): + if self.group.child_groups.all(): + # Do space stuff + space = MatrixSpace.from_group(self.group) + space.sync() + return None + def sync(self): """Sync this room.""" self.sync_profiles() + if get_site_preferences()["matrix__use_spaces"]: + self.sync_space() class Meta: verbose_name = _("Matrix room") @@ -225,6 +255,77 @@ class MatrixSpace(MatrixRoom): to=MatrixRoom, verbose_name=_("Child rooms/spaces"), blank=True, related_name="parents" ) + @classmethod + def get_queryset(cls): + return cls.objects.instance_of(MatrixSpace) + + @classmethod + def build_alias(cls, group: Group) -> str: + """Build an alias for this space.""" + return slugify(group.short_name or group.name) + "-space" + + @classmethod + def _create_room( + self, + name, + alias, + invite: Optional[List[str]] = None, + creation_content: Optional[dict] = None, + ) -> Dict[str, Any]: + if not creation_content: + creation_content = {} + creation_content["type"] = "m.space" + return super()._create_room(name, alias, invite, creation_content) + + @property + def child_spaces(self) -> List[str]: + """Get all child spaces of this space.""" + from aleksis.apps.matrix.matrix import do_matrix_request + + r = do_matrix_request("GET", f"rooms/{self.room_id}/state") + return [c["state_key"] for c in r if c["type"] == "m.space.child"] + + def _add_child(self, room_id: str): + """Add a child room to this space.""" + r = do_matrix_request( + "PUT", + f"/_matrix/client/v3/rooms/{self.room_id}/state/m.space.child/{room_id}", + body={"via": [get_site_preferences()["matrix__homeserver_ids"]]}, + ) + return r + + def sync_children(self): + """Sync membership of child spaces and rooms.""" + current_children = self.child_spaces + child_spaces = MatrixSpace.get_queryset().filter( + group__in=self.group.child_groups.filter(child_groups__isnull=False) + ) + child_rooms = MatrixRoom.get_queryset().filter( + Q(group__in=self.group.child_groups.filter(child_groups__isnull=True)) + | Q(group=self.group) + ) + + child_ids = [m.room_id for m in list(child_spaces) + list(child_rooms)] + + missing_ids = set(child_ids).difference(set(current_children)) + + for missing_id in missing_ids: + self._add_child(missing_id) + + def ensure_children(self): + """Ensure that all child rooms/spaces exist.""" + for group in self.group.child_groups.all().prefetch_related("child_groups"): + if group.child_groups.all(): + space = MatrixSpace.from_group(group) + space.ensure_children() + else: + group.use_in_matrix(sync=True) + + def sync(self): + """Sync this space.""" + self.ensure_children() + self.sync_children() + class Meta: verbose_name = _("Matrix space") verbose_name_plural = _("Matrix spaces") diff --git a/aleksis/apps/matrix/preferences.py b/aleksis/apps/matrix/preferences.py index 72967e2e5552e04962ede652277b79e8d72d8c89..f1d43e52b2115c2f85952410dfa87e2ec9407520 100644 --- a/aleksis/apps/matrix/preferences.py +++ b/aleksis/apps/matrix/preferences.py @@ -63,3 +63,11 @@ class DisambiguateRoomAliases(BooleanPreference): name = "disambiguate_room_aliases" verbose_name = _("Disambiguate room aliases") default = True + + +@site_preferences_registry.register +class UseSpaces(BooleanPreference): + section = matrix + name = "use_spaces" + verbose_name = _("Use Matrix spaces") + default = True diff --git a/aleksis/apps/matrix/tests/test_matrix.py b/aleksis/apps/matrix/tests/test_matrix.py index f2a049e57fd957952a58ba00e84996bc35dd1e4c..6015b79ad8b8f40c13cdf912ba0c6ca7082856e3 100644 --- a/aleksis/apps/matrix/tests/test_matrix.py +++ b/aleksis/apps/matrix/tests/test_matrix.py @@ -8,7 +8,7 @@ import requests from celery.result import AsyncResult from aleksis.apps.matrix.matrix import do_matrix_request -from aleksis.apps.matrix.models import MatrixProfile, MatrixRoom +from aleksis.apps.matrix.models import MatrixProfile, MatrixRoom, MatrixSpace from aleksis.core.models import Group, Person, SchoolTerm from aleksis.core.util.core_helpers import get_site_preferences @@ -282,3 +282,47 @@ class MatrixCeleryTest(TransactionTestCase): assert MatrixProfile.objects.all().count() == 1 assert p1.matrix_profile assert p1.matrix_profile.matrix_id == "@test1:matrix.aleksis.example.org" + + +def test_space_creation(matrix_bot_user): + parent_group = Group.objects.create(name="Test Group") + child_1 = Group.objects.create(name="Test Group 1") + child_2 = Group.objects.create(name="Test Group 2") + child_3 = Group.objects.create(name="Test Group 3") + parent_group.child_groups.set([child_1, child_2, child_3]) + + parent_group.use_in_matrix(sync=True) + + get_site_preferences()["matrix__use_spaces"] = True + + space = MatrixSpace.from_group(parent_group) + + r = do_matrix_request("GET", f"rooms/{space.room_id}/state") + + events = {x["type"]: x for x in r} + + assert events["m.room.create"]["content"]["type"] == "m.space" + + space.ensure_children() + + rooms = MatrixRoom.get_queryset().values_list("group_id", flat=True) + assert child_1.pk in rooms + assert child_2.pk in rooms + assert child_3.pk in rooms + + space.sync_children() + + r = do_matrix_request("GET", f"rooms/{space.room_id}/state") + interesting_events = [x["state_key"] for x in r if x["type"] == "m.space.child"] + + assert len(interesting_events) == 4 + + rooms = list( + MatrixRoom.get_queryset() + .filter(group__in=[parent_group, child_1, child_2, child_3]) + .values_list("room_id", flat=True) + ) + + assert len(rooms) == 4 + + assert set(interesting_events) == set(rooms)