diff --git a/README.rst b/README.rst index fddf3e7ceaece907b6e3fb9876db570db31c4450..c2099c66cf1e5948769e12a2cc125effc9734c89 100644 --- a/README.rst +++ b/README.rst @@ -16,7 +16,7 @@ Licence :: - Copyright © 2021 Dominik George <dominik.george@teckids.org> + Copyright © 2021, 2022 Jonathan Weth <dev@jonathanweth.de> Licenced under the EUPL, version 1.2 or later diff --git a/SETUP.rst b/SETUP.rst deleted file mode 100644 index a54bc5be6fa8ca91b693d706b830bed4c617020f..0000000000000000000000000000000000000000 --- a/SETUP.rst +++ /dev/null @@ -1,6 +0,0 @@ -Setup Matrix sync ------------------ - -* Register a bot user with a Matrix ID and note down returned access token - and Matrix ID. -* Go to `Admin → Configuration → Matrix` and fill in all values. \ No newline at end of file diff --git a/aleksis/apps/matrix/__init__.py b/aleksis/apps/matrix/__init__.py index 7da74f18ff0c0c063970d5f77d81f41841b0e07a..47838dca46c861480633d45fb511523ce8198bfe 100644 --- a/aleksis/apps/matrix/__init__.py +++ b/aleksis/apps/matrix/__init__.py @@ -4,5 +4,3 @@ try: __version__ = pkg_resources.get_distribution("AlekSIS-App-Matrix").version except Exception: __version__ = "unknown" - -default_app_config = "aleksis.apps.matrix.apps.DefaultConfig" diff --git a/aleksis/apps/matrix/apps.py b/aleksis/apps/matrix/apps.py index 64749ecbac295255e4b89599dfad37ae1dc0a84b..1c60d9e509f7e8e9af819f274130b8001a473be1 100644 --- a/aleksis/apps/matrix/apps.py +++ b/aleksis/apps/matrix/apps.py @@ -10,4 +10,4 @@ class DefaultConfig(AppConfig): "Repository": "https://edugit.org/AlekSIS/onboarding//AlekSIS-App-Matrix", } licence = "EUPL-1.2+" - copyright_info = (([2021], "Dominik George", "dominik.george@teckids.org"),) + copyright_info = (([2021, 2022], "Jonathan Weth", "dev@jonathanweth.de"),) diff --git a/aleksis/apps/matrix/filters.py b/aleksis/apps/matrix/filters.py index ac7c9184431babd64be2c969e8737a35ca2df0c9..fe145bec6db2c98d03120f959eb8c10de3f19ae4 100644 --- a/aleksis/apps/matrix/filters.py +++ b/aleksis/apps/matrix/filters.py @@ -2,4 +2,4 @@ from aleksis.core.filters import GroupFilter class GroupMatrixRoomFilter(GroupFilter): - pass + """Custom filter for groups on Matrix room overview.""" diff --git a/aleksis/apps/matrix/forms.py b/aleksis/apps/matrix/forms.py index e194a7433b3c1674dcb72e140b0708c953f4ba21..4cf2f12a27bcd559efb6af7f8dc44f1f35c78009 100644 --- a/aleksis/apps/matrix/forms.py +++ b/aleksis/apps/matrix/forms.py @@ -1,17 +1,17 @@ from django.utils.translation import gettext as _ -from aleksis.apps.matrix.tasks import use_groups_in_matrix +from aleksis.apps.matrix.tasks import provision_groups_in_matrix from aleksis.core.forms import ActionForm -def use_in_matrix_action(modeladmin, request, queryset): - """Use selected groups in Matrix.""" - use_groups_in_matrix.delay(list(queryset.values_list("pk", flat=True))) +def provision_in_matrix_action(modeladmin, request, queryset): + """Provision selected groups in Matrix.""" + provision_groups_in_matrix.delay(list(queryset.values_list("pk", flat=True))) -use_in_matrix_action.short_description = _("Use in Matrix") +provision_in_matrix_action.short_description = _("Provision in Matrix") class GroupMatrixRoomActionForm(ActionForm): def get_actions(self): - return [use_in_matrix_action] + return [provision_in_matrix_action] diff --git a/aleksis/apps/matrix/migrations/0001_initial.py b/aleksis/apps/matrix/migrations/0001_initial.py index ba8190a05649f9e62ac6bccad1f268be6eac83d1..8d4865efc32d897c22c9d78ba1c1ed5c2fef43b1 100644 --- a/aleksis/apps/matrix/migrations/0001_initial.py +++ b/aleksis/apps/matrix/migrations/0001_initial.py @@ -31,7 +31,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'Matrix room', 'verbose_name_plural': 'Matrix rooms', - 'permissions': (('use_group_in_matrix', 'Can use group in Matrix'),), + 'permissions': (('provision_group_in_matrix', 'Can provision group in Matrix'),), }, managers=[ ('objects', aleksis.core.managers.PolymorphicCurrentSiteManager()), diff --git a/aleksis/apps/matrix/model_extensions.py b/aleksis/apps/matrix/model_extensions.py index ab620422e697417717caa69f7d7519761abdfdbb..4e0472b2a75acaea8486a95e10982815ef61619f 100644 --- a/aleksis/apps/matrix/model_extensions.py +++ b/aleksis/apps/matrix/model_extensions.py @@ -5,21 +5,21 @@ from django.utils.translation import gettext_lazy as _ from celery.result import AsyncResult from aleksis.apps.matrix.models import MatrixRoom -from aleksis.apps.matrix.tasks import use_group_in_matrix +from aleksis.apps.matrix.tasks import provision_group_in_matrix from aleksis.core.models import Group @Group.method -def use_in_matrix(self, sync=False) -> Union[MatrixRoom, AsyncResult]: +def provision_in_matrix(self, sync: bool = False) -> Union[MatrixRoom, AsyncResult]: """Create and sync a room for this group in Matrix.""" if sync: - return self._use_in_matrix() + return self._provision_in_matrix() else: - return use_group_in_matrix.delay(self.pk) + return provision_group_in_matrix.delay(self.pk) @Group.method -def _use_in_matrix(self): +def _provision_in_matrix(self) -> MatrixRoom: """Create and sync a room for this group in Matrix.""" room = MatrixRoom.from_group(self) room.sync() diff --git a/aleksis/apps/matrix/models.py b/aleksis/apps/matrix/models.py index 9270c6151c570d8345828a470c1f941fe7b25a2e..4ab8df110397c418be57e9ff5bb602b4ceb7fa98 100644 --- a/aleksis/apps/matrix/models.py +++ b/aleksis/apps/matrix/models.py @@ -1,16 +1,17 @@ import re -from typing import Any, Dict, List, Optional, Union +from typing import Any, Optional, Union from django.db import models -from django.db.models import Q +from django.db.models import Q, QuerySet from django.template.defaultfilters import slugify from django.utils.translation import gettext_lazy as _ -from aleksis.apps.matrix.matrix import do_matrix_request from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel from aleksis.core.models import Group, Person from aleksis.core.util.core_helpers import get_site_preferences +from .util.matrix import MatrixException, do_matrix_request + class MatrixProfile(ExtensibleModel): """Model for a Matrix profile.""" @@ -26,12 +27,14 @@ class MatrixProfile(ExtensibleModel): ) @classmethod - def build_matrix_id(cls, username, homeserver: Optional[str] = None): + def build_matrix_id(cls, username: str, homeserver: Optional[str] = None) -> str: + """Build a Matrix ID from a username.""" homeserver = homeserver or get_site_preferences()["matrix__homeserver_ids"] return f"@{username}:{homeserver}" @classmethod def from_person(cls, person: Person, commit: bool = False) -> Union["MatrixProfile", None]: + """Get or create a Matrix profile from a person.""" if hasattr(person, "matrix_profile"): return person.matrix_profile if not person.user: @@ -64,17 +67,17 @@ class MatrixRoom(ExtensiblePolymorphicModel): @classmethod def get_queryset(cls): + """Get a queryset for only Matrix rooms.""" return cls.objects.not_instance_of(MatrixSpace) @classmethod def build_alias(cls, group: Group) -> str: + """Build a room alias from a group.""" 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 - + """Get or create a Matrix room from a group.""" try: room = cls.get_queryset().get(group=group) except cls.DoesNotExist: @@ -96,12 +99,11 @@ class MatrixRoom(ExtensiblePolymorphicModel): 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"]) if ( not get_site_preferences()["matrix__disambiguate_room_aliases"] or e.args[0].get("errcode") != "M_ROOM_IN_USE" ): - raise MatrixException(*e.args) + raise match = re.match(r"^(.*)-(\d+)$", alias) if match: @@ -121,13 +123,12 @@ class MatrixRoom(ExtensiblePolymorphicModel): @classmethod def _create_room( self, - name, - alias, - invite: Optional[List[str]] = None, + name: str, + alias: str, + invite: Optional[list[str]] = None, creation_content: Optional[dict] = None, - ) -> Dict[str, Any]: - from .matrix import do_matrix_request - + ) -> dict[str, Any]: + """Create a Matrix room.""" body = {"preset": "private_chat", "name": name, "room_alias_name": alias} if invite: @@ -140,11 +141,8 @@ class MatrixRoom(ExtensiblePolymorphicModel): return r - @property - def power_levels(self) -> Dict[str, int]: + def get_power_levels(self) -> dict[str, int]: """Return the power levels for this room.""" - from aleksis.apps.matrix.matrix import do_matrix_request - r = do_matrix_request("GET", f"rooms/{self.room_id}/state") event = list(filter(lambda x: x["type"] == "m.room.power_levels", r)) @@ -152,19 +150,16 @@ class MatrixRoom(ExtensiblePolymorphicModel): return user_levels - @property - def members(self) -> List[str]: - from aleksis.apps.matrix.matrix import do_matrix_request + def get_members(self) -> list[str]: + """Get all members of this room.""" + r = do_matrix_request("GET", f"rooms/{self.room_id}/members") + return [ + m["state_key"] + for m in filter(lambda c: c["content"]["membership"] in ("join", "invite"), r["chunk"]) + ] - r = do_matrix_request( - "GET", f"rooms/{self.room_id}/members", body={"membership": ["join", "invite"]} - ) - return [m["state_key"] for m in r["chunk"]] - - def _invite(self, profile: MatrixProfile) -> Dict[str, Any]: + def _invite(self, profile: MatrixProfile) -> dict[str, Any]: """Invite a user to this room.""" - from aleksis.apps.matrix.matrix import do_matrix_request - r = do_matrix_request( "POST", f"rooms/{self.room_id}/invite", @@ -172,7 +167,7 @@ class MatrixRoom(ExtensiblePolymorphicModel): ) return r - def _set_power_levels(self, power_levels: Dict[str, int]) -> Dict[str, Any]: + def _set_power_levels(self, power_levels: dict[str, int]) -> dict[str, Any]: """Set the power levels for this room.""" r = do_matrix_request( "PUT", @@ -182,7 +177,7 @@ class MatrixRoom(ExtensiblePolymorphicModel): return r @classmethod - def get_profiles_for_group(cls, group: Group): + def get_profiles_for_group(cls, group: Group) -> QuerySet: """Get all profile objects for the members/owners of a group.""" existing_profiles = MatrixProfile.objects.filter( Q(person__member_of=group) | Q(person__owner_of=group) @@ -201,18 +196,18 @@ class MatrixRoom(ExtensiblePolymorphicModel): all_profiles = MatrixProfile.objects.filter( Q(person__in=group.members.all()) | Q(person__in=group.owners.all()) - ) + ).distinct() return all_profiles - def get_profiles(self): + def get_profiles(self) -> QuerySet: """Get all profile objects for the members/owners of this group.""" return self.get_profiles_for_group(self.group) def sync_profiles(self): """Sync profiles for this room.""" all_profiles = self.get_profiles() - members = self.members + members = self.get_members() # Invite all users who are not in the room yet for profile in all_profiles: @@ -221,17 +216,23 @@ class MatrixRoom(ExtensiblePolymorphicModel): self._invite(profile) # Set power levels for all users - # Mod = 50 = Owners - # User = 0 = Members - user_levels = self.power_levels + user_levels = self.get_power_levels() for profile in all_profiles: if profile.person in self.group.owners.all(): - user_levels[profile.matrix_id] = 50 - elif profile.person in self.group.members.all(): - user_levels[profile.matrix_id] = 0 + power_level = get_site_preferences()["matrix__power_level_for_owners"] + else: + power_level = get_site_preferences()["matrix__power_level_for_members"] + + if ( + profile.matrix_id not in user_levels + or user_levels[profile.matrix_id] < power_level + or get_site_preferences()["matrix__reduce_power_levels"] + ): + user_levels[profile.matrix_id] = power_level self._set_power_levels(user_levels) def sync_space(self): + """Sync the space for this room.""" if self.group.child_groups.all(): # Do space stuff space = MatrixSpace.from_group(self.group) @@ -247,7 +248,7 @@ class MatrixRoom(ExtensiblePolymorphicModel): class Meta: verbose_name = _("Matrix room") verbose_name_plural = _("Matrix rooms") - permissions = (("use_group_in_matrix", "Can use group in Matrix"),) + permissions = (("provision_group_in_matrix", "Can provision group in Matrix"),) class MatrixSpace(MatrixRoom): @@ -257,6 +258,7 @@ class MatrixSpace(MatrixRoom): @classmethod def get_queryset(cls): + """Get a queryset with only Matrix spaces.""" return cls.objects.instance_of(MatrixSpace) @classmethod @@ -267,25 +269,23 @@ class MatrixSpace(MatrixRoom): @classmethod def _create_room( self, - name, - alias, - invite: Optional[List[str]] = None, + name: str, + alias: str, + invite: Optional[list[str]] = None, creation_content: Optional[dict] = None, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: + """Create a Matrix space.""" 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 - + def get_children(self) -> list[str]: + """Get all children (rooms/spaces) of this space.""" 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): + def _add_child(self, room_id: str) -> dict[str, Any]: """Add a child room to this space.""" r = do_matrix_request( "PUT", @@ -296,16 +296,22 @@ class MatrixSpace(MatrixRoom): 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) + current_children = self.get_children() + child_spaces = ( + MatrixSpace.get_queryset() + .filter(group__in=self.group.child_groups.filter(child_groups__isnull=False)) + .values_list("room_id", flat=True) ) - child_rooms = MatrixRoom.get_queryset().filter( - Q(group__in=self.group.child_groups.filter(child_groups__isnull=True)) - | Q(group=self.group) + child_rooms = ( + MatrixRoom.get_queryset() + .filter( + Q(group__in=self.group.child_groups.filter(child_groups__isnull=True)) + | Q(group=self.group) + ) + .values_list("room_id", flat=True) ) - child_ids = [m.room_id for m in list(child_spaces) + list(child_rooms)] + child_ids = list(child_spaces) + list(child_rooms) missing_ids = set(child_ids).difference(set(current_children)) @@ -315,7 +321,7 @@ class MatrixSpace(MatrixRoom): def ensure_children(self): """Ensure that all child rooms/spaces exist.""" for group in self.group.child_groups.all().prefetch_related("child_groups"): - group.use_in_matrix(sync=True) + group.provision_in_matrix(sync=True) if group.child_groups.all(): space = MatrixSpace.from_group(group) space.ensure_children() diff --git a/aleksis/apps/matrix/preferences.py b/aleksis/apps/matrix/preferences.py index f1d43e52b2115c2f85952410dfa87e2ec9407520..dce980e06509c98ec9d0f2d7f36769fc73509129 100644 --- a/aleksis/apps/matrix/preferences.py +++ b/aleksis/apps/matrix/preferences.py @@ -1,7 +1,7 @@ from django.utils.translation import gettext as _ from dynamic_preferences.preferences import Section -from dynamic_preferences.types import BooleanPreference, StringPreference +from dynamic_preferences.types import BooleanPreference, IntegerPreference, StringPreference from aleksis.core.registries import site_preferences_registry @@ -14,6 +14,10 @@ class Homeserver(StringPreference): name = "homeserver" verbose_name = _("URL of Matrix homeserver") default = "" + help_text = _( + "URL of the Matrix homeserver on which groups and " + "spaces should be created (e. g. https://matrix.org)" + ) @site_preferences_registry.register @@ -31,43 +35,53 @@ class AccessToken(StringPreference): name = "access_token" verbose_name = _("Access token to access homeserver") default = "" + help_text = _( + "This has to be the access token of a suitable bot user. It is used for all actions." + ) @site_preferences_registry.register -class User(StringPreference): +class DisambiguateRoomAliases(BooleanPreference): section = matrix - name = "user" - verbose_name = _("User to access homeserver") - default = "" + name = "disambiguate_room_aliases" + verbose_name = _("Disambiguate room aliases") + default = True + help_text = _("Suffix room aliases with ascending numbers to avoid name clashes") @site_preferences_registry.register -class DeviceID(StringPreference): +class UseSpaces(BooleanPreference): section = matrix - name = "device_id" - verbose_name = _("Device ID") - default = "" + name = "use_spaces" + verbose_name = _("Use Matrix spaces") + default = True + help_text = _("This activates the creation and management of Matrix spaces.") @site_preferences_registry.register -class DeviceName(StringPreference): +class ReducePowerLevels(BooleanPreference): section = matrix - name = "device_name" - verbose_name = _("Device name") - default = "AlekSIS" + name = "reduce_power_levels" + verbose_name = _("Reduce existing power levels") + default = False + help_text = _("Reduce power levels of existing members to the level suggested by AlekSIS.") @site_preferences_registry.register -class DisambiguateRoomAliases(BooleanPreference): +class PowerLevelForOwners(IntegerPreference): section = matrix - name = "disambiguate_room_aliases" - verbose_name = _("Disambiguate room aliases") - default = True + name = "power_level_for_owners" + verbose_name = _("Power level for owners") + default = 50 + required = True + help_text = _("This power level will be set for all owners of a group.") @site_preferences_registry.register -class UseSpaces(BooleanPreference): +class PowerLevelForMembers(IntegerPreference): section = matrix - name = "use_spaces" - verbose_name = _("Use Matrix spaces") - default = True + name = "power_level_for_members" + verbose_name = _("Power level for members") + default = 0 + required = True + help_text = _("This power level will be set for all members of a group.") diff --git a/aleksis/apps/matrix/rules.py b/aleksis/apps/matrix/rules.py index 4628e35ad5a32880211163731d14459960336c81..7617732cf5af37f924c493b5f902ca2c572eb679 100644 --- a/aleksis/apps/matrix/rules.py +++ b/aleksis/apps/matrix/rules.py @@ -15,10 +15,10 @@ view_matrix_room_predicate = view_group_predicate & ( ) rules.add_perm("matrix.view_matrixroom_rule", view_matrix_room_predicate) -use_room_for_matrix_predicate = view_matrix_room_predicate & ( - has_global_perm("matrix.use_group_in_matrix") +provision_room_for_matrix_predicate = view_matrix_room_predicate & ( + has_global_perm("matrix.provision_group_in_matrix") ) -rules.add_perm("matrix.use_group_in_matrix_rule", use_room_for_matrix_predicate) +rules.add_perm("matrix.provision_group_in_matrix_rule", provision_room_for_matrix_predicate) show_menu_predicate = view_matrix_rooms_predicate rules.add_perm("matrix.show_menu_rule", show_menu_predicate) diff --git a/aleksis/apps/matrix/tasks.py b/aleksis/apps/matrix/tasks.py index 20f8d5750efd2bb1fdba97b32afd0ec26345b4f6..c7346d8c5eb632fd7255e678cd5ca6aeb4c18075 100644 --- a/aleksis/apps/matrix/tasks.py +++ b/aleksis/apps/matrix/tasks.py @@ -7,18 +7,21 @@ from aleksis.core.models import Group @app.task def sync_room(pk: int): + """Synchronise a Matrix room.""" room = MatrixRoom.objects.get(pk=pk) room.sync() @app.task -def use_groups_in_matrix(pks: Sequence[int]): +def provision_groups_in_matrix(pks: Sequence[int]): + """Provision provided groups in Matrix.""" groups = Group.objects.filter(pk__in=pks) for group in groups: - group._use_in_matrix() + group._provision_in_matrix() @app.task -def use_group_in_matrix(pk: int): +def provision_group_in_matrix(pk: int): + """Provision provided group in Matrix.""" group = Group.objects.get(pk=pk) - group._use_in_matrix() + group._provision_in_matrix() diff --git a/aleksis/apps/matrix/tests/conftest.py b/aleksis/apps/matrix/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..eadfed813a9ec1b1bfe9b5e4b21374d70cd8992b --- /dev/null +++ b/aleksis/apps/matrix/tests/conftest.py @@ -0,0 +1,55 @@ +import os + +import pytest +import yaml +from xprocess import ProcessStarter + + +@pytest.fixture +def synapse(xprocess, tmp_path): + path = os.path.dirname(__file__) + new_config_filename = os.path.join(tmp_path, "homeserver.yaml") + + files_to_replace = [ + "homeserver.yaml", + "matrix.aleksis.example.org.log.config", + ] + for filename in files_to_replace: + new_filename = os.path.join(tmp_path, filename) + + with open(os.path.join(path, "synapse", filename), "r") as read_file: + content = read_file.read() + + content = content.replace("%path%", path) + content = content.replace("%tmp_path%", str(tmp_path)) + + with open(new_filename, "w") as write_file: + write_file.write(content) + + with open(new_config_filename, "r") as f: + config = yaml.safe_load(f) + + config["server_url"] = "http://127.0.0.1:8008" + + class SynapseStarter(ProcessStarter): + # startup pattern + pattern = "SynapseSite starting on 8008" + + # command to start process + args = [ + "python", + "-m", + "synapse.app.homeserver", + "--enable-registration", + "-c", + new_config_filename, + ] + + max_read_lines = 400 + timeout = 10 + + xprocess.ensure("synapse", SynapseStarter) + + yield config + + xprocess.getinfo("synapse").terminate() diff --git a/synapse/homeserver.yaml b/aleksis/apps/matrix/tests/synapse/homeserver.yaml similarity index 100% rename from synapse/homeserver.yaml rename to aleksis/apps/matrix/tests/synapse/homeserver.yaml diff --git a/synapse/matrix.aleksis.example.org.log.config b/aleksis/apps/matrix/tests/synapse/matrix.aleksis.example.org.log.config similarity index 100% rename from synapse/matrix.aleksis.example.org.log.config rename to aleksis/apps/matrix/tests/synapse/matrix.aleksis.example.org.log.config diff --git a/synapse/matrix.aleksis.example.org.signing.key b/aleksis/apps/matrix/tests/synapse/matrix.aleksis.example.org.signing.key similarity index 100% rename from synapse/matrix.aleksis.example.org.signing.key rename to aleksis/apps/matrix/tests/synapse/matrix.aleksis.example.org.signing.key diff --git a/aleksis/apps/matrix/tests/test_matrix.py b/aleksis/apps/matrix/tests/test_matrix.py index 5efb3a1f04be829a4fc86a775d42b4caa987ca56..52f9a885760f99e26cad373039fee8bde2acc1a2 100644 --- a/aleksis/apps/matrix/tests/test_matrix.py +++ b/aleksis/apps/matrix/tests/test_matrix.py @@ -7,8 +7,13 @@ import pytest import requests from celery.result import AsyncResult -from aleksis.apps.matrix.matrix import do_matrix_request from aleksis.apps.matrix.models import MatrixProfile, MatrixRoom, MatrixSpace +from aleksis.apps.matrix.util.matrix import ( + MatrixException, + build_url, + do_matrix_request, + get_headers, +) from aleksis.core.models import Group, Person, SchoolTerm from aleksis.core.util.core_helpers import get_site_preferences @@ -27,8 +32,6 @@ def test_connection(synapse): @pytest.fixture def matrix_bot_user(synapse): - from aleksis.apps.matrix.matrix import build_url - body = {"username": "aleksis-bot", "password": "test", "auth": {"type": "m.login.dummy"}} get_site_preferences()["matrix__homeserver"] = SERVER_URL @@ -39,8 +42,6 @@ def matrix_bot_user(synapse): user = r.json() - get_site_preferences()["matrix__user"] = user["user_id"] - get_site_preferences()["matrix__device_id"] = user["device_id"] get_site_preferences()["matrix__access_token"] = user["access_token"] yield user @@ -52,8 +53,6 @@ def test_matrix_bot_user(matrix_bot_user): def test_create_room_for_group(matrix_bot_user): - from aleksis.apps.matrix.matrix import build_url, get_headers - g = Group.objects.create(name="Test Room") assert not MatrixRoom.objects.all().exists() room = MatrixRoom.from_group(g) @@ -79,8 +78,6 @@ def test_create_room_for_group_short_name(matrix_bot_user): def test_room_alias_collision_same_name(matrix_bot_user): - from aleksis.apps.matrix.matrix import MatrixException - g1 = Group.objects.create(name="Test Room") g2 = Group.objects.create(name="test-room") g3 = Group.objects.create(name="Test-Room") @@ -123,9 +120,10 @@ def test_room_alias_collision_school_term(matrix_bot_user): def test_sync_room_members(matrix_bot_user): - from aleksis.apps.matrix.matrix import build_url, get_headers - get_site_preferences()["matrix__homeserver_ids"] = "matrix.aleksis.example.org" + get_site_preferences()["matrix__reduce_power_levels"] = False + get_site_preferences()["matrix__power_level_for_owners"] = 50 + get_site_preferences()["matrix__power_level_for_members"] = 0 g = Group.objects.create(name="Test Room") u1 = User.objects.create_user("test1", "test1@example.org", "test1") @@ -188,6 +186,71 @@ def test_sync_room_members(matrix_bot_user): break +def test_power_levels(matrix_bot_user): + get_site_preferences()["matrix__homeserver_ids"] = "matrix.aleksis.example.org" + get_site_preferences()["matrix__power_level_for_owners"] = 55 + get_site_preferences()["matrix__power_level_for_members"] = 11 + get_site_preferences()["matrix__reduce_power_levels"] = False + + g = Group.objects.create(name="Test Room") + u1 = User.objects.create_user("test1", "test1@example.org", "test1") + u2 = User.objects.create_user("test2", "test2@example.org", "test2") + + p1 = Person.objects.create(first_name="Test", last_name="Person", user=u1) + p2 = Person.objects.create(first_name="Test 2", last_name="Person", user=u2) + + g.members.set([p1]) + g.owners.set([p2]) + + room = MatrixRoom.from_group(g) + room.sync_profiles() + + # Get power levels + r = do_matrix_request("GET", f"rooms/{room.room_id}/state") + for event in r: + if not event["type"] == "m.room.power_levels": + continue + current_power_levels = event["content"]["users"] + + assert current_power_levels[p1.matrix_profile.matrix_id] == 11 + assert current_power_levels[p2.matrix_profile.matrix_id] == 55 + + break + + # Test reducing of power levels + g.owners.set([]) + g.members.set([p1, p2]) + + room.sync_profiles() + + # Not reduced here + r = do_matrix_request("GET", f"rooms/{room.room_id}/state") + for event in r: + if not event["type"] == "m.room.power_levels": + continue + current_power_levels = event["content"]["users"] + + assert current_power_levels[p1.matrix_profile.matrix_id] == 11 + assert current_power_levels[p2.matrix_profile.matrix_id] == 55 + + break + + get_site_preferences()["matrix__reduce_power_levels"] = True + room.sync_profiles() + + # Reduced here + r = do_matrix_request("GET", f"rooms/{room.room_id}/state") + for event in r: + if not event["type"] == "m.room.power_levels": + continue + current_power_levels = event["content"]["users"] + + assert current_power_levels[p1.matrix_profile.matrix_id] == 11 + assert current_power_levels[p2.matrix_profile.matrix_id] == 11 + + break + + def test_sync_room_members_without_user(matrix_bot_user): get_site_preferences()["matrix__homeserver_ids"] = "matrix.aleksis.example.org" @@ -235,8 +298,6 @@ def test_sync_room_members_without_homeserver(matrix_bot_user): def test_use_room_sync(matrix_bot_user): - from aleksis.apps.matrix.matrix import build_url, get_headers - get_site_preferences()["matrix__homeserver_ids"] = "matrix.aleksis.example.org" g = Group.objects.create(name="Test Room") @@ -246,7 +307,7 @@ def test_use_room_sync(matrix_bot_user): g.members.add(p1) - r = g.use_in_matrix(sync=True) + r = g.provision_in_matrix(sync=True) assert isinstance(r, MatrixRoom) @@ -274,7 +335,7 @@ class MatrixCeleryTest(TransactionTestCase): g.members.add(p1) - r = g.use_in_matrix(sync=False) + r = g.provision_in_matrix(sync=False) assert isinstance(r, AsyncResult) time.sleep(3) @@ -291,7 +352,7 @@ def test_space_creation(matrix_bot_user): 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) + parent_group.provision_in_matrix(sync=True) get_site_preferences()["matrix__use_spaces"] = True @@ -338,7 +399,7 @@ def test_space_creation_with_child_spaces(matrix_bot_user): 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) + parent_group.provision_in_matrix(sync=True) get_site_preferences()["matrix__use_spaces"] = True diff --git a/aleksis/apps/matrix/matrix.py b/aleksis/apps/matrix/util/matrix.py similarity index 51% rename from aleksis/apps/matrix/matrix.py rename to aleksis/apps/matrix/util/matrix.py index 41a7b04cc13b2b1b47063f2b897be7689b4d564a..864a3f6ebbbefc34219dad31b94b0ccdfc8459ae 100644 --- a/aleksis/apps/matrix/matrix.py +++ b/aleksis/apps/matrix/util/matrix.py @@ -1,6 +1,6 @@ import time from json import JSONDecodeError -from typing import Any, Dict, Optional +from typing import Any, Optional from urllib.parse import urljoin import requests @@ -12,34 +12,37 @@ class MatrixException(Exception): pass -def build_url(path): +def build_url(path: str) -> str: + """Build a URL to the Matrix Client Server API.""" return urljoin( urljoin(get_site_preferences()["matrix__homeserver"], "_matrix/client/v3/"), path ) -def get_headers(): +def get_headers() -> dict[str, str]: + """Get the headers for a Matrix Client Server API request.""" return { "Authorization": "Bearer " + get_site_preferences()["matrix__access_token"], } -def do_matrix_request(method: str, url: str, body: Optional[dict] = None) -> Dict[str, Any]: +def do_matrix_request(method: str, url: str, body: Optional[dict] = None) -> dict[str, Any]: """Do a HTTP request to the Matrix Client Server API.""" while True: res = requests.request(method=method, url=build_url(url), headers=get_headers(), json=body) - if res.status_code != requests.codes.ok: - try: - data = res.json() - except JSONDecodeError: - raise MatrixException(res.text) - - # If rate limit exceeded, wait and retry - if data.get("errcode", "") == "M_LIMIT_EXCEEDED": - time.sleep(data["retry_after_ms"] / 1000) - else: - raise MatrixException(data) - else: + + try: + data = res.json() + except JSONDecodeError: + raise MatrixException(res.text) from JSONDecodeError + + if res.status_code == requests.codes.ok: break - return res.json() + # If rate limit exceeded, wait and retry + if data.get("errcode", "") == "M_LIMIT_EXCEEDED": + time.sleep(data["retry_after_ms"] / 1000) + else: + raise MatrixException(data) + + return data diff --git a/aleksis/apps/matrix/views.py b/aleksis/apps/matrix/views.py index 92c4aafa2655773646a0e2ae0634e0112edc70fb..f3f9dd9beb7a00337874b19b11606c487890ab7c 100644 --- a/aleksis/apps/matrix/views.py +++ b/aleksis/apps/matrix/views.py @@ -10,6 +10,8 @@ from aleksis.core.models import Group class MatrixRoomListView(PermissionRequiredMixin, SingleTableMixin, FilterView): + """Overview about groups and their Matrix rooms.""" + model = Group template_name = "matrix/room/list.html" permission_required = "matrix.view_matrixrooms_rule" @@ -32,6 +34,8 @@ class MatrixRoomListView(PermissionRequiredMixin, SingleTableMixin, FilterView): def post(self, request, *args, **kwargs): r = super().get(request, *args, **kwargs) - if self.action_form.is_valid() and request.user.has_perm("matrix.use_group_in_matrix_rule"): + if self.action_form.is_valid() and request.user.has_perm( + "matrix.provision_group_in_matrix_rule" + ): self.action_form.execute() return r diff --git a/conftest.py b/conftest.py index bdd05deeafd0174838d11958e7a028cdb73d80eb..8dc44cfe41f00b97d89a12e90a35372b941aac95 100644 --- a/conftest.py +++ b/conftest.py @@ -5,53 +5,3 @@ import yaml from xprocess import ProcessStarter pytest_plugins = ("celery.contrib.pytest",) - - -@pytest.fixture -def synapse(xprocess, tmp_path): - path = os.path.dirname(__file__) - new_config_filename = os.path.join(tmp_path, "homeserver.yaml") - - files_to_replace = [ - "homeserver.yaml", - "matrix.aleksis.example.org.log.config", - ] - for filename in files_to_replace: - new_filename = os.path.join(tmp_path, filename) - - with open(os.path.join(path, "synapse", filename), "r") as read_file: - content = read_file.read() - - content = content.replace("%path%", path) - content = content.replace("%tmp_path%", str(tmp_path)) - - with open(new_filename, "w") as write_file: - write_file.write(content) - - with open(new_config_filename, "r") as f: - config = yaml.safe_load(f) - - config["server_url"] = "http://127.0.0.1:8008" - - class SynapseStarter(ProcessStarter): - # startup pattern - pattern = "SynapseSite starting on 8008" - - # command to start process - args = [ - "python", - "-m", - "synapse.app.homeserver", - "--enable-registration", - "-c", - new_config_filename, - ] - - max_read_lines = 400 - timeout = 10 - - xprocess.ensure("synapse", SynapseStarter) - - yield config - - xprocess.getinfo("synapse").terminate() diff --git a/aleksis/apps/matrix/example_data.yaml b/fixtures/example_data.yaml similarity index 100% rename from aleksis/apps/matrix/example_data.yaml rename to fixtures/example_data.yaml diff --git a/pyproject.toml b/pyproject.toml index 9ff420af8e5c743e7ca25f02cb1f6feba6a79e42..1c9b49dd1860150aad1093105313f4c89052c64e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.rst" include = ["CHANGELOG.rst", "LICENCE.rst", "aleksis/**/*.mo"] description = "AlekSIS (School Information System) — App Matrix (Integration with Matrix/Element)" -authors = ["Dominik George <dominik.george@teckids.org>"] +authors = ["Jonathan Weth <dev@jonathanweth.de>"] maintainers = [ "Jonathan Weth <wethjo@katharineum.de>", "Dominik George <dominik.george@teckids.org>" @@ -32,7 +32,7 @@ secondary = true [tool.poetry.dependencies] python = "^3.9" -aleksis-core = "^2.1.dev0" +aleksis-core = "^2.7" [tool.poetry.dev-dependencies] aleksis-builddeps = "^6"