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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/plugins/pretalx_ref_extensions/README.rst
Original file line number Diff line number Diff line change
@@ -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 <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/<organiser>/plugins/ref-extensions/users/``
Body: ``{"email": "...", "name": "...", "locale": "..."}`` (``name`` and
``locale`` optional). Returns the existing user, or creates one silently.

``GET /api/organisers/<organiser>/plugins/ref-extensions/users/``
Paginated list of users who are members of any of the organiser's teams.

``POST /api/organisers/<organiser>/plugins/ref-extensions/teams/<team_id>/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/<event>/plugins/ref-extensions/submissions/<code>/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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .apps import PretalxPluginMeta # noqa

default_app_config = "pretalx_ref_extensions.apps.PluginApp"
22 changes: 22 additions & 0 deletions src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/apps.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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"]

23 changes: 23 additions & 0 deletions src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from django.urls import path

from .views import AddSpeakerSilentView, TeamAddMemberView, UserCreateView

urlpatterns = [
path(
"api/organisers/<slug:organiser>/plugins/ref-extensions/users/",
UserCreateView.as_view(),
name="ref_extensions.users.create",
),
path(
"api/organisers/<slug:organiser>/plugins/ref-extensions/teams/"
"<int:team_id>/add-member/",
TeamAddMemberView.as_view(),
name="ref_extensions.teams.add_member",
),
path(
"api/events/<slug:event>/plugins/ref-extensions/submissions/"
"<slug:code>/add-speaker-silent/",
AddSpeakerSilentView.as_view(),
name="ref_extensions.submissions.add_speaker_silent",
),
]
113 changes: 113 additions & 0 deletions src/plugins/pretalx_ref_extensions/pretalx_ref_extensions/views.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions src/plugins/pretalx_ref_extensions/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Empty file.
Loading