diff --git a/src/plugins/pretalx_ref_extensions/README.rst b/src/plugins/pretalx_ref_extensions/README.rst new file mode 100644 index 0000000000..135b4df25a --- /dev/null +++ b/src/plugins/pretalx_ref_extensions/README.rst @@ -0,0 +1,61 @@ +pretalx-ref-extensions +======================= + +A pretalx plugin that adds a small set of API endpoints needed for +programmatic, SSO-friendly user provisioning. It does not change pretalx +core; it only orchestrates existing pretalx domain functions in ways the +stock API does not currently expose. + +Why this exists +---------------- + +Our deployment authenticates users via SSO (see the ``pretalx-socialauth`` +plugin), so accounts don't need a password or an "activate your account" +invitation email. This plugin lets an organiser API token: + +* Create user accounts up front from a batch of email addresses (e.g. for + reviewers or speakers who haven't logged in yet), without sending any + email. +* Attach a user as a speaker on a submission by email, without sending the + usual speaker-invitation email. +* Add a user directly to a team (e.g. as a reviewer), without going through + the email invite/accept flow. + +None of the new endpoints send email; they only call pretalx's existing +``create_user``, ``add_speaker`` and ``Team.members.add`` operations +directly, which are the same building blocks pretalx's own invitation flows +use internally, minus the mail step. + +Endpoints +--------- + +All endpoints require a valid ``Authorization: Token `` header for a +pretalx organiser API token whose user is a member of a team with +"Can change teams" permission for the target organiser (for the +organiser-level endpoints), or "Can change submissions" for the +submission-level endpoint. + +``POST /api/organisers//plugins/ref-extensions/users/`` + Body: ``{"email": "...", "name": "...", "locale": "..."}`` (``name`` and + ``locale`` optional). Returns the existing user, or creates one silently. + +``GET /api/organisers//plugins/ref-extensions/users/`` + Paginated list of users who are members of any of the organiser's teams. + +``POST /api/organisers//plugins/ref-extensions/teams//add-member/`` + Body: same as above. Finds-or-creates the user and adds them directly to + the team's members, without an invite/accept step. + +``POST /api/events//plugins/ref-extensions/submissions//add-speaker-silent/`` + Body: same as above. Finds-or-creates the user and attaches them as a + speaker on the submission, without sending an invitation email. + +Installation +------------ + +:: + + pip install -e src/plugins/pretalx_ref_extensions + +Restart pretalx so the plugin and its URLs are detected. No database +migrations are required. diff --git a/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/__init__.py b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/__init__.py new file mode 100644 index 0000000000..4a17e12feb --- /dev/null +++ b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/__init__.py @@ -0,0 +1,3 @@ +from .apps import PretalxPluginMeta # noqa + +default_app_config = "pretalx_ref_extensions.apps.PluginApp" diff --git a/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/apps.py b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/apps.py new file mode 100644 index 0000000000..3732297280 --- /dev/null +++ b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/apps.py @@ -0,0 +1,22 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class PluginApp(AppConfig): + name = "pretalx_ref_extensions" + verbose_name = _("REF Extensions") + + class PretalxPluginMeta: + name = _("REF Extensions") + author = "LCAS" + version = "0.1.0" + visible = True + description = _( + "Adds API endpoints for silent, SSO-friendly batch user " + "provisioning and speaker/reviewer assignment. Does not send " + "invitation emails or change any core pretalx behaviour." + ) + category = "INTEGRATION" + + +PretalxPluginMeta = PluginApp.PretalxPluginMeta diff --git a/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/domain.py b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/domain.py new file mode 100644 index 0000000000..ba8c7ad2e0 --- /dev/null +++ b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/domain.py @@ -0,0 +1,14 @@ +from pretalx.person.domain.user import create_user +from pretalx.person.models import User + + +def get_or_create_user(*, email, name=None, locale=None, event=None): + """Find a user by email, or silently create one (no invitation email sent).""" + try: + return User.objects.get(email__iexact=email), False + except User.DoesNotExist: + kwargs = {} + if locale: + kwargs["locale"] = locale + user = create_user(email=email, name=name or "", event=event, **kwargs) + return user, True diff --git a/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/permissions.py b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/permissions.py new file mode 100644 index 0000000000..8330fc8ffb --- /dev/null +++ b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/permissions.py @@ -0,0 +1,37 @@ +from django.db.models import Count, Q +from rest_framework.permissions import BasePermission + + +class OrganiserTeamManagerPermission(BasePermission): + """Requires a token whose user manages teams for the requested organiser. + + Mirrors the organiser-coverage check pretalx's core API applies to + organiser-level endpoints: a token must either be scoped to all events, + or explicitly cover every event of the organiser. + """ + + def has_permission(self, request, view): + organiser = getattr(request, "organiser", None) + if not organiser or not request.user or not request.user.is_authenticated: + return False + token = request.auth + if token and not token.all_events: + coverage = organiser.events.aggregate( + total=Count("pk"), + uncovered=Count("pk", filter=~Q(pk__in=token.limit_events.all())), + ) + if not coverage["total"] or coverage["uncovered"]: + return False + return request.user.teams.filter( + organiser=organiser, can_change_teams=True + ).exists() + + +class SubmissionUpdatePermission(BasePermission): + """Requires the same permission core pretalx uses for add-speaker.""" + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + submission = view.get_submission() + return request.user.has_perm("submission.update_submission", submission) diff --git a/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/serializers.py b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/serializers.py new file mode 100644 index 0000000000..5185c4aada --- /dev/null +++ b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/serializers.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from pretalx.person.models import User + + +class UserCreateSerializer(serializers.Serializer): + email = serializers.EmailField() + name = serializers.CharField(required=False, allow_blank=True, default="") + locale = serializers.CharField( + required=False, allow_blank=True, allow_null=True, default=None + ) + + +class TeamAddMemberSerializer(UserCreateSerializer): + pass + + +class AddSpeakerSilentSerializer(UserCreateSerializer): + pass + + +class UserListSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["code", "email", "name", "locale", "is_active"] + diff --git a/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/urls.py b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/urls.py new file mode 100644 index 0000000000..6f67197d3e --- /dev/null +++ b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/urls.py @@ -0,0 +1,23 @@ +from django.urls import path + +from .views import AddSpeakerSilentView, TeamAddMemberView, UserCreateView + +urlpatterns = [ + path( + "api/organisers//plugins/ref-extensions/users/", + UserCreateView.as_view(), + name="ref_extensions.users.create", + ), + path( + "api/organisers//plugins/ref-extensions/teams/" + "/add-member/", + TeamAddMemberView.as_view(), + name="ref_extensions.teams.add_member", + ), + path( + "api/events//plugins/ref-extensions/submissions/" + "/add-speaker-silent/", + AddSpeakerSilentView.as_view(), + name="ref_extensions.submissions.add_speaker_silent", + ), +] diff --git a/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/views.py b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/views.py new file mode 100644 index 0000000000..60883deaa7 --- /dev/null +++ b/src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/views.py @@ -0,0 +1,113 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from pretalx.api.auth import UserTokenAuthentication +from pretalx.api.pagination import PageNumberPagination +from pretalx.person.models import User +from pretalx.submission.domain.submission import add_speaker + +from .domain import get_or_create_user +from .permissions import OrganiserTeamManagerPermission, SubmissionUpdatePermission +from .serializers import ( + AddSpeakerSilentSerializer, + TeamAddMemberSerializer, + UserCreateSerializer, + UserListSerializer, +) + + +def _user_response_data(user, created): + return { + "code": user.code, + "email": user.email, + "name": user.name, + "created": created, + } + + +class UserCreateView(APIView): + """List users of an organiser's teams, or get-or-create a user by email + (creation never sends an invitation email).""" + + authentication_classes = [UserTokenAuthentication] + permission_classes = [OrganiserTeamManagerPermission] + pagination_class = PageNumberPagination + + def get(self, request, **kwargs): + users = User.objects.filter(teams__organiser=request.organiser).distinct().order_by("code") + paginator = self.pagination_class() + page = paginator.paginate_queryset(users, request, view=self) + serializer = UserListSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + def post(self, request, **kwargs): + serializer = UserCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + user, created = get_or_create_user( + email=data["email"], name=data.get("name"), locale=data.get("locale") + ) + return Response( + _user_response_data(user, created), + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + +class TeamAddMemberView(APIView): + """Add a user directly to a team's members, bypassing invite/accept.""" + + authentication_classes = [UserTokenAuthentication] + permission_classes = [OrganiserTeamManagerPermission] + + def post(self, request, **kwargs): + team = get_object_or_404(request.organiser.teams, pk=kwargs["team_id"]) + serializer = TeamAddMemberSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + user, created = get_or_create_user( + email=data["email"], name=data.get("name"), locale=data.get("locale") + ) + already_member = team.members.filter(pk=user.pk).exists() + if not already_member: + team.members.add(user) + team.organiser.log_action( + "pretalx.invite.orga.accept", person=user, orga=True + ) + response_data = _user_response_data(user, created) + response_data["added"] = not already_member + return Response(response_data, status=status.HTTP_200_OK) + + +class AddSpeakerSilentView(APIView): + """Attach a speaker to a submission by email. Never sends an invitation email.""" + + authentication_classes = [UserTokenAuthentication] + permission_classes = [SubmissionUpdatePermission] + + def get_submission(self): + # cached so the permission check and the handler share one query + if not hasattr(self, "_submission"): + self._submission = get_object_or_404( + self.request.event.submissions, code__iexact=self.kwargs["code"] + ) + return self._submission + + def post(self, request, **kwargs): + submission = self.get_submission() + serializer = AddSpeakerSilentSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + user, created = get_or_create_user( + email=data["email"], + name=data.get("name"), + locale=data.get("locale"), + event=submission.event, + ) + speaker = add_speaker( + submission, user=user, name=data.get("name"), log_user=request.user + ) + response_data = _user_response_data(user, created) + response_data["speaker_code"] = speaker.code + return Response(response_data, status=status.HTTP_200_OK) diff --git a/src/plugins/pretalx_ref_extensions/pyproject.toml b/src/plugins/pretalx_ref_extensions/pyproject.toml new file mode 100644 index 0000000000..90b1d2719d --- /dev/null +++ b/src/plugins/pretalx_ref_extensions/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "pretalx-ref-extensions" +version = "0.1.0" +description = "Custom API extensions for REF-style pretalx deployments (batch user provisioning, silent speaker/reviewer assignment)." +readme = "README.rst" +requires-python = ">=3.10" + +[project.entry-points."pretalx.plugin"] +pretalx_ref_extensions = "pretalx_ref_extensions:PretalxPluginMeta" diff --git a/src/plugins/pretalx_ref_extensions/tests/__init__.py b/src/plugins/pretalx_ref_extensions/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/plugins/pretalx_ref_extensions/tests/test_views.py b/src/plugins/pretalx_ref_extensions/tests/test_views.py new file mode 100644 index 0000000000..b63e478be0 --- /dev/null +++ b/src/plugins/pretalx_ref_extensions/tests/test_views.py @@ -0,0 +1,214 @@ +import pytest +from django.core import mail +from django_scopes import scopes_disabled + +from pretalx.mail.models import QueuedMail +from pretalx.person.models import User +from tests.factories import ( + EventFactory, + SubmissionFactory, + TeamFactory, + UserApiTokenFactory, + UserFactory, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def event(): + return EventFactory() + + +@pytest.fixture +def team(event): + return TeamFactory( + organiser=event.organiser, + can_change_teams=True, + can_change_submissions=True, + all_events=True, + ) + + +@pytest.fixture +def orga_user(team): + user = UserFactory() + team.members.add(user) + return user + + +@pytest.fixture +def orga_token(orga_user): + return UserApiTokenFactory(user=orga_user, all_events=True) + + +def _users_url(organiser): + return f"/api/organisers/{organiser.slug}/plugins/ref-extensions/users/" + + +def _add_member_url(organiser, team_id): + return ( + f"/api/organisers/{organiser.slug}/plugins/ref-extensions/" + f"teams/{team_id}/add-member/" + ) + + +def _add_speaker_silent_url(event, code): + return ( + f"/api/events/{event.slug}/plugins/ref-extensions/" + f"submissions/{code}/add-speaker-silent/" + ) + + +def test_create_user_creates_new_user_without_email(client, event, orga_token): + queued_before = QueuedMail.objects.count() + + response = client.post( + _users_url(event.organiser), + data={"email": "new.user@example.com", "name": "New User"}, + content_type="application/json", + headers={"Authorization": f"Token {orga_token.token}"}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["email"] == "new.user@example.com" + assert data["created"] is True + with scopes_disabled(): + assert User.objects.filter(email="new.user@example.com").exists() + assert QueuedMail.objects.count() == queued_before + assert len(mail.outbox) == 0 + + +def test_create_user_is_idempotent_for_existing_email(client, event, orga_token): + existing = UserFactory(email="existing@example.com") + + response = client.post( + _users_url(event.organiser), + data={"email": "Existing@example.com"}, + content_type="application/json", + headers={"Authorization": f"Token {orga_token.token}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["created"] is False + assert data["code"] == existing.code + + +def test_create_user_rejected_without_can_change_teams(client, event): + other_user = UserFactory() + token = UserApiTokenFactory(user=other_user, all_events=True) + + response = client.post( + _users_url(event.organiser), + data={"email": "blocked@example.com"}, + content_type="application/json", + headers={"Authorization": f"Token {token.token}"}, + ) + + assert response.status_code == 403 + with scopes_disabled(): + assert not User.objects.filter(email="blocked@example.com").exists() + + +def test_list_users_returns_organiser_team_members(client, event, orga_token, orga_user, team): + other_team_user = UserFactory() + team.members.add(other_team_user) + outsider = UserFactory() + + response = client.get( + _users_url(event.organiser), + headers={"Authorization": f"Token {orga_token.token}"}, + ) + + assert response.status_code == 200 + data = response.json() + codes = {entry["code"] for entry in data["results"]} + assert orga_user.code in codes + assert other_team_user.code in codes + assert outsider.code not in codes + + +def test_list_users_rejected_without_can_change_teams(client, event): + other_user = UserFactory() + token = UserApiTokenFactory(user=other_user, all_events=True) + + response = client.get( + _users_url(event.organiser), + headers={"Authorization": f"Token {token.token}"}, + ) + + assert response.status_code == 403 + + +def test_add_team_member_adds_directly_without_invite(client, event, orga_token, team): + invite_count = team.invites.count() + + response = client.post( + _add_member_url(event.organiser, team.pk), + data={"email": "reviewer@example.com", "name": "New Reviewer"}, + content_type="application/json", + headers={"Authorization": f"Token {orga_token.token}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["added"] is True + with scopes_disabled(): + assert team.members.filter(email="reviewer@example.com").exists() + assert team.invites.count() == invite_count + assert QueuedMail.objects.count() == 0 + assert len(mail.outbox) == 0 + + +def test_add_team_member_is_idempotent_for_existing_member( + client, event, orga_token, team, orga_user +): + response = client.post( + _add_member_url(event.organiser, team.pk), + data={"email": orga_user.email}, + content_type="application/json", + headers={"Authorization": f"Token {orga_token.token}"}, + ) + + assert response.status_code == 200 + assert response.json()["added"] is False + + +def test_add_speaker_silent_attaches_speaker_without_email(client, event, orga_token): + event.plugins = "pretalx_ref_extensions" + event.save() + with scopes_disabled(): + submission = SubmissionFactory(event=event) + + response = client.post( + _add_speaker_silent_url(event, submission.code), + data={"email": "speaker@example.com", "name": "New Speaker"}, + content_type="application/json", + headers={"Authorization": f"Token {orga_token.token}"}, + ) + + assert response.status_code == 200 + with scopes_disabled(): + submission.refresh_from_db() + speaker_emails = [s.user.email for s in submission.speakers.all()] + assert "speaker@example.com" in speaker_emails + assert QueuedMail.objects.count() == 0 + assert len(mail.outbox) == 0 + + +def test_add_speaker_silent_404_when_plugin_not_enabled_for_event( + client, event, orga_token +): + with scopes_disabled(): + submission = SubmissionFactory(event=event) + + response = client.post( + _add_speaker_silent_url(event, submission.code), + data={"email": "speaker2@example.com"}, + content_type="application/json", + headers={"Authorization": f"Token {orga_token.token}"}, + ) + + assert response.status_code == 404 diff --git a/src/pretalx/static/orga/js/forms/reviewsettings.js b/src/pretalx/static/orga/js/forms/reviewsettings.js index 2a49b7f0dd..5fbacdc070 100644 --- a/src/pretalx/static/orga/js/forms/reviewsettings.js +++ b/src/pretalx/static/orga/js/forms/reviewsettings.js @@ -43,7 +43,7 @@ const addNewScores = (ev) => {
- +