diff --git a/aleksis/apps/matrix/example_data.yaml b/aleksis/apps/matrix/example_data.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9fa7bee01d298979955fc7285411528ed630746b --- /dev/null +++ b/aleksis/apps/matrix/example_data.yaml @@ -0,0 +1,254 @@ +# All passwords are "admin" +- model: auth.user + pk: 2 + fields: + password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE= + username: test1 + is_active: true +- model: auth.user + pk: 3 + fields: + password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE= + username: test2 + is_active: true +- model: auth.user + pk: 4 + fields: + password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE= + username: test3 + is_active: true +- model: auth.user + pk: 5 + fields: + password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE= + username: test4 + is_active: true +- model: auth.user + pk: 6 + fields: + password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE= + username: test5 + is_active: true +- model: auth.user + pk: 7 + fields: + password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE= + username: test6 + is_active: true +- model: auth.user + pk: 8 + fields: + password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE= + username: test7 + is_active: true +- model: sites.site + pk: 1 + fields: + domain: example.org + name: example.org +- model: core.person + pk: 2 + fields: + site: 1 + user: 2 + is_active: true + first_name: Jane + last_name: Doe + short_name: DOE + email: jane.doe@example.com +- model: core.person + pk: 3 + fields: + site: 1 + user: 3 + is_active: true + first_name: Aunt + last_name: Dawn + short_name: DAW + email: aunt.dawn@example.com +- model: core.person + pk: 4 + fields: + site: 1 + user: 4 + is_active: true + first_name: Mad + last_name: Wilson + short_name: WIL + email: mad.wilson@example.com +- model: core.person + pk: 5 + fields: + site: 1 + user: 5 + is_active: true + first_name: Lara + last_name: God + short_name: GOD + email: lara.god@example.com +- model: core.person + pk: 6 + fields: + site: 1 + user: 6 + is_active: true + first_name: Student + last_name: A + email: student.a@example.com +- model: core.person + pk: 7 + fields: + site: 1 + user: 7 + is_active: true + first_name: Student + last_name: B + email: student.b@example.com +- model: core.person + pk: 8 + fields: + site: 1 + user: 8 + is_active: true + first_name: Student + last_name: C + email: student.C@example.com + +# Classes: PK 100 and above + +- model: core.group + pk: 101 + fields: + site: 1 + name: "8c" + short_name: "8c" + members: [ 6, 7, 8 ] +- model: core.group + pk: 102 + fields: + site: 1 + name: "5c" + short_name: "5c" + members: [ 6, 7, 8 ] +- model: core.group + pk: 103 + fields: + site: 1 + name: "6c" + short_name: "6c" + members: [ 6, 7, 8 ] +- model: core.group + pk: 104 + fields: + site: 1 + name: "5a" + short_name: "5a" + members: [ 6, 7, 8 ] +- model: core.group + pk: 105 + fields: + site: 1 + name: "7a" + short_name: "7a" + members: [ 6, 7, 8 ] +- model: core.group + pk: 106 + fields: + site: 1 + name: "6a" + short_name: "6a" + members: [ 6, 7, 8 ] +- model: core.group + pk: 107 + fields: + site: 1 + name: "9d" + short_name: "9d" + members: [ 6, 7, 8 ] +- model: core.group + pk: 1 + fields: + site: 1 + name: "8c:Mu" + short_name: "8c:Mu" + members: [ 6, 7, 8 ] + owners: [ 2 ] + parent_groups: [ 101 ] +- model: core.group + pk: 2 + fields: + site: 1 + name: "5c:Mu" + short_name: "5c:Mu" + members: [ 6, 7, 8 ] + owners: [ 2 ] + parent_groups: [ 102 ] +- model: core.group + pk: 3 + fields: + site: 1 + name: "6c:Mu" + short_name: "6c:Mu" + members: [ 6, 7, 8 ] + owners: [ 3 ] + parent_groups: [ 103 ] +- model: core.group + pk: 4 + fields: + site: 1 + name: "5a:De" + short_name: "5a:De" + + members: [ 6, 7, 8 ] + owners: [ 4 ] + parent_groups: [ 104 ] +- model: core.group + pk: 5 + fields: + site: 1 + name: "7a:Mu" + short_name: "7a:Mu" + members: [ 6, 7, 8 ] + owners: [ 4 ] + parent_groups: [ 105 ] +- model: core.group + pk: 6 + fields: + site: 1 + name: "6a:Mu" + short_name: "6a:Mu" + members: [ 6, 7, 8 ] + owners: [ 5 ] + parent_groups: [ 106 ] +- model: core.group + pk: 7 + fields: + site: 1 + name: "9d:Mu" + short_name: "9d:Mu" + members: [ 6, 7, 8 ] + owners: [ 5 ] + parent_groups: [ 107 ] +- model: core.group + pk: 8 + fields: + site: 1 + name: "6a:De" + short_name: "6a:De" + members: [ 6, 7, 8 ] + owners: [ 5 ] + parent_groups: [ 106 ] +- model: core.group + pk: 9 + fields: + site: 1 + name: "Teachers" + short_name: "Teachers" + members: [ 2, 3, 4, 5 ] +- model: core.group + pk: 10 + fields: + site: 1 + name: "Students" + short_name: "Students" + members: [ 6, 7, 8 ] diff --git a/aleksis/apps/matrix/filters.py b/aleksis/apps/matrix/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..ac7c9184431babd64be2c969e8737a35ca2df0c9 --- /dev/null +++ b/aleksis/apps/matrix/filters.py @@ -0,0 +1,5 @@ +from aleksis.core.filters import GroupFilter + + +class GroupMatrixRoomFilter(GroupFilter): + pass diff --git a/aleksis/apps/matrix/forms.py b/aleksis/apps/matrix/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..e194a7433b3c1674dcb72e140b0708c953f4ba21 --- /dev/null +++ b/aleksis/apps/matrix/forms.py @@ -0,0 +1,17 @@ +from django.utils.translation import gettext as _ + +from aleksis.apps.matrix.tasks import use_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))) + + +use_in_matrix_action.short_description = _("Use in Matrix") + + +class GroupMatrixRoomActionForm(ActionForm): + def get_actions(self): + return [use_in_matrix_action] diff --git a/aleksis/apps/matrix/matrix.py b/aleksis/apps/matrix/matrix.py index 5184286315a91ff4e79fe34be442b65831e78c13..41a7b04cc13b2b1b47063f2b897be7689b4d564a 100644 --- a/aleksis/apps/matrix/matrix.py +++ b/aleksis/apps/matrix/matrix.py @@ -1,5 +1,10 @@ +import time +from json import JSONDecodeError +from typing import Any, Dict, Optional from urllib.parse import urljoin +import requests + from aleksis.core.util.core_helpers import get_site_preferences @@ -17,3 +22,24 @@ def get_headers(): return { "Authorization": "Bearer " + get_site_preferences()["matrix__access_token"], } + + +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: + break + + return res.json() diff --git a/aleksis/apps/matrix/menus.py b/aleksis/apps/matrix/menus.py new file mode 100644 index 0000000000000000000000000000000000000000..9eac098aa10389647c89f5c5d0f369522e7d3937 --- /dev/null +++ b/aleksis/apps/matrix/menus.py @@ -0,0 +1,31 @@ +from django.utils.translation import gettext_lazy as _ + +MENUS = { + "NAV_MENU_CORE": [ + { + "name": _("Matrix"), + "url": "#", + "icon": "chat", + "root": True, + "validators": [ + ( + "aleksis.core.util.predicates.permission_validator", + "matrix.show_menu_rule", + ), + ], + "submenu": [ + { + "name": _("Groups and Rooms"), + "url": "matrix_rooms", + "icon": "group_work", + "validators": [ + ( + "aleksis.core.util.predicates.permission_validator", + "matrix.view_matrixrooms_rule", + ), + ], + }, + ], + } + ] +} diff --git a/aleksis/apps/matrix/migrations/0001_initial.py b/aleksis/apps/matrix/migrations/0001_initial.py index 60d62d51ba52a4b9b668301b851c911e62adf170..c5aad5550dfa1e9f6c4131e442c94da9ba57c5f6 100644 --- a/aleksis/apps/matrix/migrations/0001_initial.py +++ b/aleksis/apps/matrix/migrations/0001_initial.py @@ -24,13 +24,14 @@ class Migration(migrations.Migration): ('extended_data', models.JSONField(default=dict, editable=False)), ('room_id', models.CharField(max_length=255, unique=True, verbose_name='Room ID')), ('alias', models.CharField(blank=True, max_length=255, unique=True, verbose_name='Alias')), - ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matrix_spaces', to='core.group', verbose_name='Group')), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='matrix_room', to='core.group', verbose_name='Group')), ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_matrix.matrixroom_set+', to='contenttypes.contenttype')), ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')), ], options={ 'verbose_name': 'Matrix room', 'verbose_name_plural': 'Matrix rooms', + 'permissions': (('use_group_in_matrix', 'Can use 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 new file mode 100644 index 0000000000000000000000000000000000000000..2fca9b202dfddae2d01dd7fd589ec3537ea1442c --- /dev/null +++ b/aleksis/apps/matrix/model_extensions.py @@ -0,0 +1,45 @@ +from typing import Optional, Union + +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.core.models import Group + + +@Group.method +def use_in_matrix(self, sync=False) -> Union[MatrixRoom, AsyncResult]: + """Create and sync a room for this group in Matrix.""" + if sync: + return self._use_in_matrix() + else: + return use_group_in_matrix.delay(self.pk) + + +@Group.method +def _use_in_matrix(self): + """Create and sync a room for this group in Matrix.""" + room = MatrixRoom.from_group(self) + room.sync() + return room + + +@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 + + +@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 + + +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 78c9fd99c609c86961688ce879fd33fc99d34f4d..fff467f7a0ab722c3cd9536cad8ef1da5945e827 100644 --- a/aleksis/apps/matrix/models.py +++ b/aleksis/apps/matrix/models.py @@ -1,13 +1,12 @@ import re -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union from django.db import models from django.db.models import Q from django.template.defaultfilters import slugify from django.utils.translation import gettext_lazy as _ -import requests - +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 @@ -56,19 +55,17 @@ 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.ForeignKey( + group = models.OneToOneField( Group, on_delete=models.CASCADE, verbose_name=_("Group"), - null=True, - blank=True, - related_name="matrix_spaces", + related_name="matrix_room", ) @classmethod def from_group(self, group: Group): """Create a Matrix room from a group.""" - from .matrix import MatrixException, build_url, get_headers + from .matrix import MatrixException, do_matrix_request try: room = MatrixRoom.objects.get(group=group) @@ -77,16 +74,27 @@ class MatrixRoom(ExtensiblePolymorphicModel): if room.room_id: # Existing room, check if still accessible - r = requests.get( - build_url(f"directory/list/room/{room.room_id}"), headers=get_headers() - ) - if not r.status_code == requests.codes.ok: - raise MatrixException() + 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) - r = self._create_group(group.name, alias) - while r.json().get("errcode") == "M_ROOM_IN_USE": + profiles_to_invite = list( + self.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) + 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) + match = re.match(r"^(.*)-(\d+)$", alias) if match: # Counter found, increase @@ -96,75 +104,72 @@ class MatrixRoom(ExtensiblePolymorphicModel): else: # Counter not found, add one alias = f"{alias}-2" - r = self._create_group(group.name, alias) - - if r.status_code == requests.codes.ok: - room.room_id = r.json()["room_id"] - room.alias = r.json()["room_alias"] - room.save() - else: - raise MatrixException(r.text) + + room.room_id = r["room_id"] + room.alias = r["room_alias"] + room.save() return room @classmethod - def _create_group(self, name, alias): - from .matrix import build_url, get_headers + def _create_room(self, name, alias, invite: List[str]): + from .matrix import do_matrix_request - body = {"preset": "private_chat", "name": name, "room_alias_name": alias} - r = requests.post(build_url("createRoom"), headers=get_headers(), json=body) + body = {"preset": "private_chat", "name": name, "room_alias_name": alias, "invite": invite} + r = do_matrix_request("POST", "createRoom", body=body) return r @property def power_levels(self) -> Dict[str, int]: """Return the power levels for this room.""" - from aleksis.apps.matrix.matrix import MatrixException, build_url, get_headers + from aleksis.apps.matrix.matrix import do_matrix_request - r = requests.get(build_url(f"rooms/{self.room_id}/state"), headers=get_headers()) - if r.status_code != requests.codes.ok: - raise MatrixException(r.text) + r = do_matrix_request("GET", f"rooms/{self.room_id}/state") - event = list(filter(lambda x: x["type"] == "m.room.power_levels", r.json())) + event = list(filter(lambda x: x["type"] == "m.room.power_levels", r)) user_levels = event[0]["content"]["users"] return user_levels + @property + def members(self) -> List[str]: + from aleksis.apps.matrix.matrix import do_matrix_request + + 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]: """Invite a user to this room.""" - from aleksis.apps.matrix.matrix import MatrixException, build_url, get_headers + from aleksis.apps.matrix.matrix import do_matrix_request - r = requests.post( - build_url(f"rooms/{self.room_id}/invite"), - headers=get_headers(), - json={"user_id": profile.matrix_id}, + r = do_matrix_request( + "POST", + f"rooms/{self.room_id}/invite", + body={"user_id": profile.matrix_id}, ) - if not r.status_code == requests.codes.ok: - raise MatrixException(r.text) - return r.json() + return r def _set_power_levels(self, power_levels: Dict[str, int]) -> Dict[str, Any]: """Set the power levels for this room.""" - from aleksis.apps.matrix.matrix import MatrixException, build_url, get_headers - - r = requests.put( - build_url(f"rooms/{self.room_id}/state/m.room.power_levels/"), - headers=get_headers(), - json={"users": power_levels}, + r = do_matrix_request( + "PUT", + f"rooms/{self.room_id}/state/m.room.power_levels/", + body={"users": power_levels}, ) - print(r.text, r.status_code) - if not r.status_code == requests.codes.ok: - raise MatrixException(r.text) - return r.json() + return r - def sync_profiles(self): - """Sync profiles for this room.""" + @classmethod + def get_profiles_for_group(cls, group: Group): + """Get all profile objects for the members/owners of a group.""" existing_profiles = MatrixProfile.objects.filter( - Q(person__member_of=self.group) | Q(person__owner_of=self.group) + Q(person__member_of=group) | Q(person__owner_of=group) ) profiles_to_create = [] for person in ( Person.objects.filter(user__isnull=False) - .filter(Q(member_of=self.group) | Q(owner_of=self.group)) + .filter(Q(member_of=group) | Q(owner_of=group)) .exclude(matrix_profile__in=existing_profiles) .distinct() ): @@ -174,13 +179,23 @@ class MatrixRoom(ExtensiblePolymorphicModel): MatrixProfile.objects.bulk_create(profiles_to_create) all_profiles = MatrixProfile.objects.filter( - Q(person__in=self.group.members.all()) | Q(person__in=self.group.owners.all()) + Q(person__in=group.members.all()) | Q(person__in=group.owners.all()) ) - user_levels = self.power_levels + + return all_profiles + + def get_profiles(self): + """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 # Invite all users who are not in the room yet for profile in all_profiles: - if profile.matrix_id not in user_levels: + if profile.matrix_id not in members: # Now invite self._invite(profile) @@ -195,9 +210,14 @@ class MatrixRoom(ExtensiblePolymorphicModel): user_levels[profile.matrix_id] = 0 self._set_power_levels(user_levels) + def sync(self): + """Sync this room.""" + self.sync_profiles() + class Meta: verbose_name = _("Matrix room") verbose_name_plural = _("Matrix rooms") + permissions = (("use_group_in_matrix", "Can use group in Matrix"),) class MatrixSpace(MatrixRoom): diff --git a/aleksis/apps/matrix/preferences.py b/aleksis/apps/matrix/preferences.py index 124c42f7ecd97d6e919d4d0b6e213726c49819da..72967e2e5552e04962ede652277b79e8d72d8c89 100644 --- a/aleksis/apps/matrix/preferences.py +++ b/aleksis/apps/matrix/preferences.py @@ -47,7 +47,6 @@ class DeviceID(StringPreference): name = "device_id" verbose_name = _("Device ID") default = "" - field_kwargs = {"editable": False} @site_preferences_registry.register diff --git a/aleksis/apps/matrix/rules.py b/aleksis/apps/matrix/rules.py new file mode 100644 index 0000000000000000000000000000000000000000..4628e35ad5a32880211163731d14459960336c81 --- /dev/null +++ b/aleksis/apps/matrix/rules.py @@ -0,0 +1,24 @@ +# View groups +import rules + +from aleksis.core.models import Group +from aleksis.core.rules import view_group_predicate, view_groups_predicate +from aleksis.core.util.predicates import has_any_object, has_global_perm, has_object_perm + +view_matrix_rooms_predicate = view_groups_predicate & ( + has_global_perm("matrix.view_matrixroom") | has_any_object("core.view_matrixroom", Group) +) +rules.add_perm("matrix.view_matrixrooms_rule", view_matrix_rooms_predicate) + +view_matrix_room_predicate = view_group_predicate & ( + has_global_perm("matrix.view_matrixroom") | has_object_perm("core.view_matrixroom") +) +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") +) +rules.add_perm("matrix.use_group_in_matrix_rule", use_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/tables.py b/aleksis/apps/matrix/tables.py new file mode 100644 index 0000000000000000000000000000000000000000..b8bb54b4a13c987581c93e3ffa42dc01fca1e03b --- /dev/null +++ b/aleksis/apps/matrix/tables.py @@ -0,0 +1,16 @@ +from django_tables2 import Column, Table + +from aleksis.core.util.tables import SelectColumn + + +class GroupsMatrixRoomsTable(Table): + """Table to list groups together with their Matrix rooms.""" + + class Meta: + attrs = {"class": "highlight"} + + selected = SelectColumn() + name = Column() + short_name = Column() + matrix_alias = Column() + matrix_room_id = Column() diff --git a/aleksis/apps/matrix/tasks.py b/aleksis/apps/matrix/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..20f8d5750efd2bb1fdba97b32afd0ec26345b4f6 --- /dev/null +++ b/aleksis/apps/matrix/tasks.py @@ -0,0 +1,24 @@ +from typing import Sequence + +from aleksis.apps.matrix.models import MatrixRoom +from aleksis.core.celery import app +from aleksis.core.models import Group + + +@app.task +def sync_room(pk: int): + room = MatrixRoom.objects.get(pk=pk) + room.sync() + + +@app.task +def use_groups_in_matrix(pks: Sequence[int]): + groups = Group.objects.filter(pk__in=pks) + for group in groups: + group._use_in_matrix() + + +@app.task +def use_group_in_matrix(pk: int): + group = Group.objects.get(pk=pk) + group._use_in_matrix() diff --git a/aleksis/apps/matrix/templates/matrix/room/list.html b/aleksis/apps/matrix/templates/matrix/room/list.html new file mode 100644 index 0000000000000000000000000000000000000000..b1b326c876bc14e771ffd11f778b1c94688e638c --- /dev/null +++ b/aleksis/apps/matrix/templates/matrix/room/list.html @@ -0,0 +1,61 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n material_form static %} +{% load render_table from django_tables2 %} + +{% block browser_title %}{% blocktrans %}Groups and Matrix Rooms{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Groups and Matrix Rooms{% endblocktrans %}{% endblock %} + +{% block content %} +<!-- <a class="btn green waves-effect waves-light" href="{% url 'create_group' %}">--> +<!-- <i class="material-icons left">add</i>--> +<!-- {% trans "Create group" %}--> +<!-- </a>--> + +<div class="card"> + <div class="card-content"> + <div class="card-title">{% trans "Filter groups" %}</div> + <form method="get"> + {% form form=filter.form %}{% endform %} + {% trans "Search" as caption %} + {% include "core/partials/save_button.html" with caption=caption icon="search" %} + <button type="reset" class="btn red waves-effect waves-light"> + <i class="material-icons left">clear</i> + {% trans "Clear" %} + </button> + </form> + </div> +</div> + + +<div class="card"> + <div class="card-content"> + <form action="" method="post"> + {% csrf_token %} + <div class="row"> + <div class="col s12 {% if action_form %}m4 l4 xl6{% endif %}"> + <div class="card-title">{% trans "Selected groups" %}</div> + </div> + {% if action_form %} + <div class="col s12 m8 l8 xl6"> + <div class="col s12 m8"> + {% form form=action_form %}{% endform %} + </div> + <div class="col s12 m4"> + <button type="submit" class="btn waves-effect waves-primary"> + {% trans "Execute" %} + <i class="material-icons right">send</i> + </button> + </div> + </div> + {% endif %} + </div> + {% render_table table %} + + </form> + </div> +</div> +<script src="{% static "js/multi_select.js" %}"></script> +{% endblock %} diff --git a/aleksis/apps/matrix/tests/test_matrix.py b/aleksis/apps/matrix/tests/test_matrix.py index 00a8efdf75c084c0a1ec993a43335917bd9d1056..f2a049e57fd957952a58ba00e84996bc35dd1e4c 100644 --- a/aleksis/apps/matrix/tests/test_matrix.py +++ b/aleksis/apps/matrix/tests/test_matrix.py @@ -1,10 +1,13 @@ +import time from datetime import date from django.contrib.auth.models import User 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 from aleksis.core.models import Group, Person, SchoolTerm from aleksis.core.util.core_helpers import get_site_preferences @@ -40,7 +43,12 @@ def matrix_bot_user(synapse): get_site_preferences()["matrix__device_id"] = user["device_id"] get_site_preferences()["matrix__access_token"] = user["access_token"] - yield r.json() + yield user + + +def test_matrix_bot_user(matrix_bot_user): + print(matrix_bot_user) + assert True def test_create_room_for_group(matrix_bot_user): @@ -56,8 +64,8 @@ def test_create_room_for_group(matrix_bot_user): # On second get, it should be the same matrix room assert MatrixRoom.from_group(g) == room - r = requests.get(build_url(f"rooms/{room.room_id}/aliases"), headers=get_headers()) - aliases = r.json()["aliases"] + r = do_matrix_request("GET", f"rooms/{room.room_id}/aliases") + aliases = r["aliases"] assert "#test-room:matrix.aleksis.example.org" in aliases @@ -77,6 +85,9 @@ def test_room_alias_collision_same_name(matrix_bot_user): g2 = Group.objects.create(name="test-room") g3 = Group.objects.create(name="Test-Room") g4 = Group.objects.create(name="test room") + + get_site_preferences()["matrix__disambiguate_room_aliases"] = True + room = MatrixRoom.from_group(g1) assert room.alias == "#test-room:matrix.aleksis.example.org" @@ -93,6 +104,8 @@ def test_room_alias_collision_same_name(matrix_bot_user): def test_room_alias_collision_school_term(matrix_bot_user): + get_site_preferences()["matrix__disambiguate_room_aliases"] = True + school_term_a = SchoolTerm.objects.create( name="Test Term A", date_start=date(2020, 1, 1), date_end=date(2020, 12, 31) ) @@ -146,14 +159,13 @@ def test_sync_room_members(matrix_bot_user): assert p5.matrix_profile.matrix_id == "@test5:matrix.aleksis.example.org" # Check members - r = requests.get( - build_url(f"rooms/{room.room_id}/members"), - headers=get_headers(), - json={"membership": ["join", "invite"]}, + r = do_matrix_request( + "GET", + f"rooms/{room.room_id}/members", + body={"membership": ["join", "invite"]}, ) - assert r.status_code == requests.codes.ok - matrix_ids = [x["state_key"] for x in r.json()["chunk"]] + matrix_ids = [x["state_key"] for x in r["chunk"]] assert p1.matrix_profile.matrix_id in matrix_ids assert p2.matrix_profile.matrix_id in matrix_ids assert p3.matrix_profile.matrix_id in matrix_ids @@ -161,9 +173,8 @@ def test_sync_room_members(matrix_bot_user): assert p5.matrix_profile.matrix_id in matrix_ids # Get power levels - r = requests.get(build_url(f"rooms/{room.room_id}/state"), headers=get_headers()) - assert r.status_code == requests.codes.ok - for event in r.json(): + 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"] @@ -221,3 +232,53 @@ def test_sync_room_members_without_homeserver(matrix_bot_user): assert not hasattr(p1, "matrix_profile") assert p2.matrix_profile assert p2.matrix_profile.matrix_id == "@test2:matrix.aleksis.example.org" + + +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") + u1 = User.objects.create_user("test1", "test1@example.org", "test1") + + p1 = Person.objects.create(first_name="Test", last_name="Person", user=u1) + + g.members.add(p1) + + r = g.use_in_matrix(sync=True) + + assert isinstance(r, MatrixRoom) + + assert MatrixProfile.objects.all().count() == 1 + assert p1.matrix_profile + assert p1.matrix_profile.matrix_id == "@test1:matrix.aleksis.example.org" + + +from django.test import TransactionTestCase, override_settings + + +@pytest.mark.usefixtures("celery_worker", "matrix_bot_user") +@override_settings(CELERY_BROKER_URL="memory://localhost//") +@override_settings(HAYSTACK_SIGNAL_PROCESSOR="") +class MatrixCeleryTest(TransactionTestCase): + serialized_rollback = True + + def test_use_room_async(self): + get_site_preferences()["matrix__homeserver_ids"] = "matrix.aleksis.example.org" + + g = Group.objects.create(name="Test Room") + u1 = User.objects.create_user("test1", "test1@example.org", "test1") + + p1 = Person.objects.create(first_name="Test", last_name="Person", user=u1) + + g.members.add(p1) + + r = g.use_in_matrix(sync=False) + assert isinstance(r, AsyncResult) + + time.sleep(3) + + assert MatrixProfile.objects.all().count() == 1 + assert p1.matrix_profile + assert p1.matrix_profile.matrix_id == "@test1:matrix.aleksis.example.org" diff --git a/aleksis/apps/matrix/urls.py b/aleksis/apps/matrix/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..e3619969d993c03eec24aaa94f3ed71d2ec4f938 --- /dev/null +++ b/aleksis/apps/matrix/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("rooms/", views.MatrixRoomListView.as_view(), name="matrix_rooms"), +] diff --git a/aleksis/apps/matrix/views.py b/aleksis/apps/matrix/views.py new file mode 100644 index 0000000000000000000000000000000000000000..92c4aafa2655773646a0e2ae0634e0112edc70fb --- /dev/null +++ b/aleksis/apps/matrix/views.py @@ -0,0 +1,37 @@ +from django_filters.views import FilterView +from django_tables2 import SingleTableMixin +from guardian.shortcuts import get_objects_for_user +from rules.contrib.views import PermissionRequiredMixin + +from aleksis.apps.matrix.filters import GroupMatrixRoomFilter +from aleksis.apps.matrix.forms import GroupMatrixRoomActionForm +from aleksis.apps.matrix.tables import GroupsMatrixRoomsTable +from aleksis.core.models import Group + + +class MatrixRoomListView(PermissionRequiredMixin, SingleTableMixin, FilterView): + model = Group + template_name = "matrix/room/list.html" + permission_required = "matrix.view_matrixrooms_rule" + table_class = GroupsMatrixRoomsTable + filterset_class = GroupMatrixRoomFilter + + def get_queryset(self): + return get_objects_for_user( + self.request.user, ["core.view_group", "core.view_matrixroom"], Group + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + self.action_form = GroupMatrixRoomActionForm( + self.request, self.request.POST or None, queryset=self.get_queryset() + ) + context["action_form"] = self.action_form + return context + + 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"): + self.action_form.execute() + return r diff --git a/conftest.py b/conftest.py index eadfed813a9ec1b1bfe9b5e4b21374d70cd8992b..bdd05deeafd0174838d11958e7a028cdb73d80eb 100644 --- a/conftest.py +++ b/conftest.py @@ -4,6 +4,8 @@ import pytest import yaml from xprocess import ProcessStarter +pytest_plugins = ("celery.contrib.pytest",) + @pytest.fixture def synapse(xprocess, tmp_path):