datetime.timedelta(hours=48)
+
+ def get_share_string(self):
+ return base64.b64encode(
+ json.dumps(
+ {"guest_url": self.url, "code": self.code, "host_url": settings.GET_SITE_URL()}
+ ).encode("ascii")
+ ).decode("ascii")
+
+
+class FederatedEventShare(models.Model):
+ event = models.ForeignKey(Event, on_delete=models.CASCADE)
+ shared_with = models.ManyToManyField(FederatedGuest)
+
+ def __str__(self):
+ return _("Federated event share for {event}").format(event=self.event)
+
+
+class FederatedUser(models.Model):
+ email = models.EmailField(_("email address"))
+ first_name = models.CharField(_("first name"), max_length=254)
+ last_name = models.CharField(_("last name"), max_length=254)
+ date_of_birth = models.DateField(_("date of birth"))
+ phone = models.CharField(_("phone number"), max_length=254, blank=True)
+ qualifications = models.ManyToManyField(Qualification)
+ federated_instance = models.ForeignKey(FederatedGuest, on_delete=models.CASCADE)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ def __str__(self):
+ return _("Federated user {first_name} {last_name}").format(
+ first_name=self.first_name, last_name=self.last_name
+ )
+
+ def as_participant(self) -> "FederatedParticipant":
+ return FederatedParticipant(
+ first_name=self.first_name,
+ last_name=self.last_name,
+ qualifications=self.qualifications.all(),
+ date_of_birth=self.date_of_birth,
+ email=self.email,
+ federated_user=self,
+ )
+
+
+@dataclasses.dataclass(frozen=True)
+class FederatedParticipant(AbstractParticipant):
+ federated_user: FederatedUser
+
+ def new_participation(self, shift):
+ return FederatedParticipation(shift=shift, federated_user=self.federated_user)
+
+ def participation_for(self, shift):
+ try:
+ return FederatedParticipation.objects.get(
+ shift=shift, federated_user=self.federated_user
+ )
+ except FederatedParticipation.DoesNotExist:
+ return None
+
+ def all_participations(self):
+ return FederatedParticipation.objects.filter(federated_user=self.federated_user)
+
+ def reverse_signup_action(self, shift):
+ return reverse("federation:shift_signup", kwargs={"pk": shift.pk})
+
+ def reverse_event_detail(self, event):
+ return reverse("federation:event_detail", kwargs={"pk": event.pk})
+
+ @property
+ def icon(self):
+ title = _("Federated user")
+ return mark_safe(
+ f' '
+ )
+
+
+class FederatedParticipation(AbstractParticipation):
+ federated_user = models.ForeignKey(
+ FederatedUser, on_delete=models.CASCADE, verbose_name=_("federated participant")
+ )
+
+ def __str__(self):
+ return f"{self.federated_user.first_name} {self.federated_user.last_name} @ {self.shift}"
+
+ @property
+ def participant(self) -> "AbstractParticipant":
+ return self.federated_user.as_participant()
diff --git a/ephios/plugins/federation/serializers.py b/ephios/plugins/federation/serializers.py
new file mode 100644
index 000000000..482acfb3b
--- /dev/null
+++ b/ephios/plugins/federation/serializers.py
@@ -0,0 +1,75 @@
+import secrets
+from urllib.parse import urljoin
+
+from django.conf import settings
+from django.urls import reverse
+from dynamic_preferences.registries import global_preferences_registry
+from rest_framework import serializers
+
+from ephios.api.models import AccessToken
+from ephios.api.views.events import EventSerializer
+from ephios.core.models import Event
+from ephios.plugins.federation.models import FederatedGuest, InviteCode
+
+
+class FederatedGuestCreateSerializer(serializers.ModelSerializer):
+ code = serializers.CharField(write_only=True)
+ access_token = serializers.SlugRelatedField(slug_field="token", read_only=True)
+ host_name = serializers.SerializerMethodField(method_name="get_host_name", read_only=True)
+
+ class Meta:
+ model = FederatedGuest
+ fields = [
+ "id",
+ "name",
+ "url",
+ "client_id",
+ "client_secret",
+ "code",
+ "access_token",
+ "host_name",
+ ]
+
+ def get_host_name(self, obj):
+ return global_preferences_registry.manager()["general__organization_name"]
+
+ def validate(self, attrs):
+ try:
+ attrs["code"] = InviteCode.objects.get(code=attrs["code"], url=attrs["url"])
+ if attrs["code"].is_expired:
+ raise serializers.ValidationError("Invite code is expired")
+ return attrs
+ except InviteCode.DoesNotExist as exc:
+ raise serializers.ValidationError("Invite code is not valid") from exc
+
+ def create(self, validated_data):
+ code = validated_data.pop("code")
+ validated_data["access_token"] = AccessToken.objects.create(
+ token=secrets.token_hex(),
+ description=f"Access token for federated guest {validated_data['name']}",
+ )
+ obj = super().create(validated_data)
+ code.delete()
+ return obj
+
+
+class SharedEventSerializer(EventSerializer):
+ signup_url = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Event
+ fields = [
+ "id",
+ "title",
+ "description",
+ "location",
+ "type",
+ "start_time",
+ "end_time",
+ "signup_url",
+ ]
+
+ def get_signup_url(self, obj):
+ return urljoin(
+ settings.GET_SITE_URL(), reverse("federation:event_detail", kwargs={"pk": obj.pk})
+ )
diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py
new file mode 100644
index 000000000..7fb4d0eaf
--- /dev/null
+++ b/ephios/plugins/federation/signals.py
@@ -0,0 +1,76 @@
+from django.dispatch import receiver
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from ephios.core.signals import (
+ event_forms,
+ management_settings_sections,
+ nav_link,
+ participant_from_request,
+ periodic_signal,
+)
+from ephios.plugins.federation.forms import EventAllowFederationForm
+from ephios.plugins.federation.models import FederatedHost, FederatedUser
+
+
+@receiver(nav_link, dispatch_uid="ephios.plugins.federation.signals.nav_link")
+def add_nav_link(sender, request, **kwargs):
+ return (
+ [
+ {
+ "label": _("External events"),
+ "url": reverse("federation:external_event_list"),
+ "active": request.resolver_match
+ and request.resolver_match.app_name == "federation",
+ }
+ ]
+ if FederatedHost.objects.exists()
+ else []
+ )
+
+
+@receiver(
+ participant_from_request,
+ dispatch_uid="ephios.plugins.federation.signals.federated_participant_from_request",
+)
+def federated_participant_from_request(sender, request, **kwargs):
+ if "federated_user" in request.session.keys():
+ try:
+ return FederatedUser.objects.get(pk=request.session["federated_user"]).as_participant()
+ except FederatedUser.DoesNotExist:
+ pass
+
+
+@receiver(
+ event_forms,
+ dispatch_uid="ephios.plugins.federation.signals.federation_event_forms",
+)
+def guests_event_forms(sender, event, request, **kwargs):
+ return [EventAllowFederationForm(request.POST or None, event=event, request=request)]
+
+
+@receiver(
+ management_settings_sections,
+ dispatch_uid="ephios.plugins.federation.signals.federation_settings_section",
+)
+def federation_settings_section(sender, request, **kwargs):
+ return (
+ [
+ {
+ "label": _("Federation"),
+ "url": reverse("federation:settings"),
+ "active": request.resolver_match.app_name == "federation",
+ },
+ ]
+ if request.user.is_staff
+ else []
+ )
+
+
+@receiver(periodic_signal, dispatch_uid="ephios.plugins.federation.signals.periodic_signal")
+def delete_expired_invites(sender, **kwargs):
+ from ephios.plugins.federation.models import InviteCode
+
+ for invite in InviteCode.objects.all():
+ if invite.is_expired:
+ invite.delete()
diff --git a/ephios/plugins/federation/templates/federation/event_detail.html b/ephios/plugins/federation/templates/federation/event_detail.html
new file mode 100644
index 000000000..e3353cd59
--- /dev/null
+++ b/ephios/plugins/federation/templates/federation/event_detail.html
@@ -0,0 +1,10 @@
+{% extends "core/event_detail.html" %}
+{% load settings_extras %}
+{% load i18n %}
+
+{% block messages %}
+
+{% endblock %}
diff --git a/ephios/plugins/federation/templates/federation/external_event_list.html b/ephios/plugins/federation/templates/federation/external_event_list.html
new file mode 100644
index 000000000..ceb0bbccc
--- /dev/null
+++ b/ephios/plugins/federation/templates/federation/external_event_list.html
@@ -0,0 +1,71 @@
+{% extends "base.html" %}
+{% load i18n %}
+
+{% block title %}
+ {% translate "External events" %}
+{% endblock %}
+
+{% block content %}
+ {% translate "External events" %}
+
+{% endblock %}
diff --git a/ephios/plugins/federation/templates/federation/federatedguest_confirm_delete.html b/ephios/plugins/federation/templates/federation/federatedguest_confirm_delete.html
new file mode 100644
index 000000000..4645eec79
--- /dev/null
+++ b/ephios/plugins/federation/templates/federation/federatedguest_confirm_delete.html
@@ -0,0 +1,16 @@
+{% extends "core/settings/settings_base.html" %}
+{% load crispy_forms_filters %}
+{% load i18n %}
+
+{% block settings_content %}
+ {% translate "Stop sharing events" %}
+
+
+{% endblock %}
diff --git a/ephios/plugins/federation/templates/federation/federatedhost_confirm_delete.html b/ephios/plugins/federation/templates/federation/federatedhost_confirm_delete.html
new file mode 100644
index 000000000..2ba45bf00
--- /dev/null
+++ b/ephios/plugins/federation/templates/federation/federatedhost_confirm_delete.html
@@ -0,0 +1,16 @@
+{% extends "core/settings/settings_base.html" %}
+{% load crispy_forms_filters %}
+{% load i18n %}
+
+{% block settings_content %}
+ {% translate "Stop receiving events" %}
+
+
+{% endblock %}
diff --git a/ephios/plugins/federation/templates/federation/federation_settings.html b/ephios/plugins/federation/templates/federation/federation_settings.html
new file mode 100644
index 000000000..59ae9c531
--- /dev/null
+++ b/ephios/plugins/federation/templates/federation/federation_settings.html
@@ -0,0 +1,69 @@
+{% extends "core/settings/settings_base.html" %}
+{% load i18n %}
+
+{% block settings_content %}
+
+ {% translate "This feature is experimental!" %}
+
+ {% translate "ephios instances that you share events with" %}
+ Share events with another instance
+
+
+
+ {% translate "Name" %}
+ {% translate "URL" %}
+ {% translate "Action" %}
+
+
+
+ {% for guest in federation_guests %}
+
+ {{ guest.name }}
+ {{ guest.url }}
+ {% translate "Stop sharing" %}
+
+ {% endfor %}
+
+
+
+ {% if federation_invites %}
+ {% translate "Pending invites" %}
+
+
+
+ {% translate "URL" %}
+ {% translate "Action" %}
+
+
+
+ {% for invite in federation_invites %}
+
+ {{ invite.url }}
+ {% translate "Show invite code" %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% translate "ephios instances that share events with you" %}
+ {% translate "Enter invite code" %}
+
+
+
+ {% translate "Name" %}
+ {% translate "URL" %}
+ {% translate "Action" %}
+
+
+
+ {% for host in federation_hosts %}
+
+ {{ host.name }}
+ {{ host.url }}
+ {% translate "Stop receiving" %}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/ephios/plugins/federation/templates/federation/invitecode_form.html b/ephios/plugins/federation/templates/federation/invitecode_form.html
new file mode 100644
index 000000000..264031775
--- /dev/null
+++ b/ephios/plugins/federation/templates/federation/invitecode_form.html
@@ -0,0 +1,21 @@
+{% extends "core/settings/settings_base.html" %}
+{% load i18n %}
+{% load crispy_forms_filters %}
+
+{% block settings_content %}
+ {% translate "Share events with another instance" %}
+
+ {% translate "This feature is experimental!" %}
+
+
+ {% blocktranslate trimmed %}
+ You can share events with another ephios instance. When sharing is enabled for an event, it will show up for users on the other instance as well. Their users can then sign up for your event.
+ To set up sharing, enter the URL of the instance that you want to share events with below.
+ {% endblocktranslate %}
+
+
+{% endblock %}
diff --git a/ephios/plugins/federation/templates/federation/invitecode_reveal.html b/ephios/plugins/federation/templates/federation/invitecode_reveal.html
new file mode 100644
index 000000000..d9ba97e3b
--- /dev/null
+++ b/ephios/plugins/federation/templates/federation/invitecode_reveal.html
@@ -0,0 +1,50 @@
+{% extends "core/settings/settings_base.html" %}
+{% load utils %}
+{% load static %}
+{% load crispy_forms_filters %}
+{% load i18n %}
+
+{% block settings_content %}
+
+
+ {% translate "Invite code" %}
+
+
+
+ {% blocktranslate trimmed with url=invite.url %}
+ We created an invite code that allows the ephios instance at {{ url }} to accept your invitation to share events with them.
+ Please copy the URL or the code below and send it to the administrator of the other instance. They can then visit the URL or enter the code in their ephios instance to accept your invitation. It will expire in 48 hours.
+ {% endblocktranslate %}
+
+
+
Invite URL
+
+ {% url "federation:frontend_redeem_invite_code" as redeem_invite_code_url %}
+
+ {% translate "Copy" %}
+
+
+
+
Invite code
+
+
+ {% translate "Copy" %}
+
+
+
+ {% translate "Done" %}
+{% endblock %}
+
+
+{% block javascript %}
+
+
+{% endblock %}
diff --git a/ephios/plugins/federation/templates/federation/redeem_invite_code.html b/ephios/plugins/federation/templates/federation/redeem_invite_code.html
new file mode 100644
index 000000000..6fc9a8f75
--- /dev/null
+++ b/ephios/plugins/federation/templates/federation/redeem_invite_code.html
@@ -0,0 +1,22 @@
+{% extends "core/settings/settings_base.html" %}
+{% load i18n %}
+{% load crispy_forms_filters %}
+
+{% block settings_content %}
+ {% translate "Enter invite code" %}
+
+ {% translate "This feature is experimental!" %}
+
+
+ {% blocktranslate trimmed %}
+ You can accept shared events from other ephios instances. You need an invite code from the instance that wants to share events with you. If you obtained such a code, enter it here.
+ By accepting the invitation, their events will appear in the "External events" section for everyone in your organisation. Your user will be able to view those events and sign up for them.
+ To enable this, the other instance will also be able to access data from your users, such as their names, email addresses and qualifications.
+ {% endblocktranslate %}
+
+
+{% endblock %}
diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py
new file mode 100644
index 000000000..01cc67a78
--- /dev/null
+++ b/ephios/plugins/federation/urls.py
@@ -0,0 +1,67 @@
+from django.urls import path
+
+from ephios.plugins.federation.views import api, frontend
+
+app_name = "federation"
+
+urlpatterns = [
+ path(
+ "events/external/",
+ frontend.ExternalEventListView.as_view(),
+ name="external_event_list",
+ ),
+ path(
+ "events/shared//", frontend.FederatedEventDetailView.as_view(), name="event_detail"
+ ),
+ path(
+ "shifts/shared//signup/",
+ frontend.FederatedUserShiftActionView.as_view(),
+ name="shift_signup",
+ ),
+ path("settings/federation/", frontend.FederationSettingsView.as_view(), name="settings"),
+ path(
+ "settings/federation/hosts/add/",
+ frontend.RedeemInviteCodeView.as_view(),
+ name="frontend_redeem_invite_code",
+ ),
+ path(
+ "settings/federation/hosts//delete/",
+ frontend.FederatedHostDeleteView.as_view(),
+ name="delete_host",
+ ),
+ path(
+ "settings/federation/guests/add/",
+ frontend.CreateInviteCodeView.as_view(),
+ name="create_invite_code",
+ ),
+ path(
+ "settings/federation/guests//delete/",
+ frontend.FederatedGuestDeleteView.as_view(),
+ name="delete_guest",
+ ),
+ path(
+ "settings/federation/invite//",
+ frontend.InviteCodeRevealView.as_view(),
+ name="reveal_invite_code",
+ ),
+ path(
+ "api/federation/events/",
+ api.SharedEventListView.as_view(),
+ name="shared_event_list_view",
+ ),
+ path(
+ "api/federation/oauth-callback/",
+ api.FederationOAuthView.as_view(),
+ name="oauth_callback",
+ ),
+ path(
+ "api/federation/setup/",
+ api.RedeemInviteCodeView.as_view(),
+ name="redeem_invite_code",
+ ),
+ path(
+ "api/federation/guest/delete/",
+ api.FederatedGuestDeleteView.as_view(),
+ name="api_delete_guest",
+ ),
+]
diff --git a/ephios/plugins/federation/views/api.py b/ephios/plugins/federation/views/api.py
new file mode 100644
index 000000000..b577171d0
--- /dev/null
+++ b/ephios/plugins/federation/views/api.py
@@ -0,0 +1,163 @@
+from urllib.parse import urljoin
+
+import requests
+from django.conf import settings
+from django.core.exceptions import MultipleObjectsReturned
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.views import View
+from oauth2_provider.contrib.rest_framework import TokenHasScope
+from oauthlib.oauth2 import WebApplicationClient
+from requests_oauthlib import OAuth2Session
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.generics import CreateAPIView, DestroyAPIView, ListAPIView
+from rest_framework.permissions import AllowAny
+
+from ephios.core.models import Event, Qualification
+from ephios.plugins.federation.models import FederatedGuest, FederatedUser
+from ephios.plugins.federation.serializers import (
+ FederatedGuestCreateSerializer,
+ SharedEventSerializer,
+)
+
+
+class RedeemInviteCodeView(CreateAPIView):
+ """
+ API view that accepts an InviteCode and creates a FederatedGuest (to start sharing events with that instance).
+ """
+
+ serializer_class = FederatedGuestCreateSerializer
+ queryset = FederatedGuest.objects.all()
+ authentication_classes = []
+ permission_classes = [AllowAny]
+
+
+class FederatedGuestDeleteView(DestroyAPIView):
+ """
+ API view that deletes a FederatedGuest (to stop sharing events with that instance).
+ """
+
+ queryset = FederatedGuest.objects.all()
+ permission_classes = [TokenHasScope]
+ required_scopes = []
+
+ def get_object(self):
+ try:
+ # request.auth is an auth token, federatedguest is the reverse relation
+ return self.request.auth.federatedguest
+ except FederatedGuest.DoesNotExist as exc:
+ raise PermissionDenied from exc
+
+
+class SharedEventListView(ListAPIView):
+ """
+ API view that lists all events that are shared with the instance corresponding to the access token.
+ """
+
+ serializer_class = SharedEventSerializer
+ permission_classes = [TokenHasScope]
+ required_scopes = []
+
+ def get_queryset(self):
+ try:
+ # request.auth is an auth token, federatedguest is the reverse relation
+ guest = self.request.auth.federatedguest
+ except FederatedGuest.DoesNotExist as exc:
+ raise PermissionDenied from exc
+ return Event.objects.filter(federatedeventshare__shared_with=guest)
+
+
+class FederationOAuthView(View):
+ """
+ View that handles the OAuth2 flow for federated users from another instance.
+ """
+
+ def get(self, request, *args, **kwargs):
+ try:
+ self.guest = (
+ FederatedGuest.objects.get(pk=self.request.session["federation_guest_pk"])
+ if "federation_guest_pk" in self.request.session.keys()
+ else FederatedGuest.objects.get(url=self.request.GET["referrer"])
+ )
+ except (KeyError, FederatedGuest.DoesNotExist, MultipleObjectsReturned) as exc:
+ raise PermissionDenied from exc
+ if "error" in request.GET.keys():
+ return redirect(urljoin(self.guest.url, reverse("federation:external_event_list")))
+ elif "code" in request.GET.keys():
+ self._oauth_callback()
+ return redirect(
+ "federation:event_detail", pk=self.request.session.pop("federation_event")
+ )
+ else:
+ return redirect(self._get_authorization_url())
+
+ def _get_authorization_url(self):
+ oauth_client = WebApplicationClient(client_id=self.guest.client_id)
+ oauth = OAuth2Session(
+ client=oauth_client,
+ redirect_uri=urljoin(settings.GET_SITE_URL(), reverse("federation:oauth_callback")),
+ scope=["ME_READ"],
+ )
+ verifier = oauth_client.create_code_verifier(64)
+ self.request.session["code_verifier"] = verifier
+ self.request.session["federation_event"] = self.kwargs["pk"]
+ self.request.session["federation_guest_pk"] = self.guest.pk
+ challenge = oauth_client.create_code_challenge(verifier, "S256")
+ authorization_url, _ = oauth.authorization_url(
+ urljoin(self.guest.url, "api/oauth/authorize/"),
+ code_challenge=challenge,
+ code_challenge_method="S256",
+ )
+ return authorization_url
+
+ def _oauth_callback(self):
+ oauth_client = WebApplicationClient(
+ client_id=self.guest.client_id, code_verifier=self.request.session["code_verifier"]
+ )
+ oauth = OAuth2Session(client=oauth_client)
+ token = oauth.fetch_token(
+ urljoin(self.guest.url, "api/oauth/token/"),
+ authorization_response=urljoin(settings.GET_SITE_URL(), self.request.get_full_path()),
+ client_secret=self.guest.client_secret,
+ code_verifier=self.request.session["code_verifier"],
+ )
+ self.request.session["federation_access_token"] = token["access_token"]
+ self.request.session.set_expiry(token["expires_in"])
+ user_data = requests.get(
+ urljoin(self.guest.url, "api/users/me/"),
+ headers={"Authorization": f"Bearer {token['access_token']}"},
+ timeout=5,
+ )
+ try:
+ user = FederatedUser.objects.get(
+ federated_instance=self.guest, email=user_data.json()["email"]
+ )
+ except FederatedUser.DoesNotExist:
+ user = self._create_user(user_data)
+ self.request.session["federation_user"] = user.pk
+
+ def _create_user(self, user_data):
+ user = FederatedUser.objects.create(
+ federated_instance=self.guest,
+ email=user_data.json()["email"],
+ first_name=user_data.json()["first_name"],
+ last_name=user_data.json()["last_name"],
+ date_of_birth=user_data.json()["date_of_birth"],
+ )
+ for qualification in user_data.json()["qualifications"]:
+ try:
+ # Note that we assign the qualification on the host instance without further checks.
+ # This may lead to incorrect qualifications if the inclusions for the qualification
+ # are defined differently on the guest instance. We are accepting this as it should
+ # not happen with the pre-defined qualifications as we are displaying a warning if
+ # the user adapt these and custom qualifications will have different uuids anyway.
+ user.qualifications.add(Qualification.objects.get(uuid=qualification["uuid"]))
+ except Qualification.DoesNotExist:
+ for included_qualification in qualification["includes"]:
+ try:
+ user.qualifications.add(
+ Qualification.objects.get(uuid=included_qualification["uuid"])
+ )
+ except Qualification.DoesNotExist:
+ continue
+ return user
diff --git a/ephios/plugins/federation/views/frontend.py b/ephios/plugins/federation/views/frontend.py
new file mode 100644
index 000000000..b75318c41
--- /dev/null
+++ b/ephios/plugins/federation/views/frontend.py
@@ -0,0 +1,223 @@
+from datetime import datetime
+from urllib.parse import urljoin
+
+import requests
+from django.contrib import messages
+from django.contrib.messages.views import SuccessMessageMixin
+from django.shortcuts import get_object_or_404, redirect
+from django.urls import reverse, reverse_lazy
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import CreateView, DeleteView, DetailView, FormView, TemplateView
+from guardian.mixins import LoginRequiredMixin
+from requests import HTTPError, JSONDecodeError, ReadTimeout
+from rest_framework.exceptions import PermissionDenied
+
+from ephios.core.models import Event
+from ephios.core.views.signup import BaseShiftActionView
+from ephios.extra.mixins import StaffRequiredMixin
+from ephios.plugins.federation.forms import InviteCodeForm, RedeemInviteCodeForm
+from ephios.plugins.federation.models import (
+ FederatedEventShare,
+ FederatedGuest,
+ FederatedHost,
+ FederatedUser,
+ InviteCode,
+)
+from ephios.plugins.federation.views.api import FederationOAuthView
+
+
+class ExternalEventListView(LoginRequiredMixin, TemplateView):
+ """
+ View that lists all events that are shared with this instance.
+ """
+
+ template_name = "federation/external_event_list.html"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ events = []
+ for host in FederatedHost.objects.all():
+ try:
+ r = requests.get(
+ urljoin(host.url, reverse("federation:shared_event_list_view")),
+ headers={"Authorization": f"Bearer {host.access_token}"},
+ timeout=5,
+ )
+ r.raise_for_status()
+ results = r.json()["results"]
+ for event in results:
+ event["type"] = event["type"]["title"]
+ event["start_time"] = datetime.fromisoformat(event["start_time"])
+ event["end_time"] = datetime.fromisoformat(event["end_time"])
+ event["host"] = host.name
+ events += results
+ except HTTPError as exc:
+ if exc.response.status_code == 403:
+ # the host does not accept our token anymore, so the share needs to be set up again
+ host.oauth_application.delete()
+ host.delete()
+ except (JSONDecodeError, ReadTimeout):
+ continue
+ events.sort(key=lambda e: e["start_time"])
+ context["events"] = events
+ return context
+
+
+class CheckFederatedAccessTokenMixin:
+ def dispatch(self, request, *args, **kwargs):
+ if "federation_access_token" not in request.session.keys():
+ return FederationOAuthView.as_view()(request, *args, **kwargs)
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, object):
+ context = super().get_context_data()
+ context["federation_guest"] = FederatedGuest.objects.get(
+ pk=self.request.session["federation_guest_pk"]
+ )
+ return context
+
+
+class FederatedEventDetailView(CheckFederatedAccessTokenMixin, DetailView):
+ """
+ View that displays a shared event to a federated user from another instance
+ """
+
+ model = Event
+ template_name = "federation/event_detail.html"
+
+ def get_object(self, queryset=None):
+ obj = super().get_object(queryset)
+ try:
+ guest = self.request.session["federation_guest_pk"]
+ FederatedEventShare.objects.get(
+ event=obj,
+ shared_with__in=[FederatedGuest.objects.get(pk=guest)],
+ )
+ except (KeyError, FederatedEventShare.DoesNotExist, FederatedGuest.DoesNotExist) as exc:
+ self.request.session.flush()
+ raise PermissionDenied from exc
+ return obj
+
+
+class FederatedUserShiftActionView(CheckFederatedAccessTokenMixin, BaseShiftActionView):
+ """
+ View that allows a federated user from another instanceto sign up for a shift
+ """
+
+ def get_participant(self):
+ try:
+ return FederatedUser.objects.get(
+ pk=self.request.session["federation_user"]
+ ).as_participant()
+ except FederatedUser.DoesNotExist as e:
+ raise PermissionDenied from e
+
+
+class FederationSettingsView(StaffRequiredMixin, TemplateView):
+ """
+ View that displays the federation settings page where new instances can be connected
+ """
+
+ template_name = "federation/federation_settings.html"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["federation_guests"] = FederatedGuest.objects.all()
+ context["federation_hosts"] = FederatedHost.objects.all()
+ context["federation_invites"] = InviteCode.objects.all()
+ return context
+
+
+class CreateInviteCodeView(StaffRequiredMixin, CreateView):
+ """
+ View that allows staff users to create new invite codes (to share events with another instance)
+ """
+
+ model = InviteCode
+ form_class = InviteCodeForm
+
+ def get_success_url(self):
+ return reverse("federation:reveal_invite_code", kwargs={"pk": self.object.pk})
+
+
+class InviteCodeRevealView(StaffRequiredMixin, TemplateView):
+ """
+ View that displays an invite code to a staff user
+ """
+
+ template_name = "federation/invitecode_reveal.html"
+
+ def get(self, request, *args, **kwargs):
+ invite = get_object_or_404(InviteCode, pk=kwargs["pk"])
+ if invite.is_expired:
+ messages.error(request, _("Invite code has expired."))
+ return redirect("federation:settings")
+ context = self.get_context_data(invite=invite, **kwargs)
+ return self.render_to_response(context)
+
+
+class RedeemInviteCodeView(StaffRequiredMixin, FormView):
+ """
+ View that allows staff users to redeem an invite code (to receive events from another instance)
+ """
+
+ form_class = RedeemInviteCodeForm
+ template_name = "federation/redeem_invite_code.html"
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ if "code" in self.request.GET:
+ kwargs["initial"] = {"code": self.request.GET["code"]}
+ return kwargs
+
+ def form_valid(self, form):
+ messages.success(
+ self.request,
+ _(
+ "Invite code redeemded successfully. You are now receiving events from this instance."
+ ),
+ )
+ return redirect(reverse("federation:settings"))
+
+
+class FederatedGuestDeleteView(StaffRequiredMixin, SuccessMessageMixin, DeleteView):
+ """
+ View that allows staff users to remove a guest instance (to stop sharing events with another instance)
+ """
+
+ model = FederatedGuest
+ success_url = reverse_lazy("federation:settings")
+ success_message = _("You are no longer sharing events with this instance.")
+
+ def form_valid(self, form):
+ access_token = self.object.access_token
+ response = super().form_valid(form)
+ access_token.delete()
+ return response
+
+
+class FederatedHostDeleteView(StaffRequiredMixin, SuccessMessageMixin, DeleteView):
+ """
+ View that allows staff users to remove a host instance (to stop receiving events from another instance)
+ """
+
+ model = FederatedHost
+ success_url = reverse_lazy("federation:settings")
+ success_message = _("You are no longer receiving events from this instance.")
+
+ def form_valid(self, form):
+ oauth_app = self.object.oauth_application
+ guest_response = requests.delete(
+ urljoin(self.object.url, reverse("federation:api_delete_guest")),
+ headers={"Authorization": f"Bearer {self.object.access_token}"},
+ timeout=5,
+ )
+ if guest_response.status_code != 204:
+ messages.error(
+ self.request,
+ _("Failed to remove this instance. Please try again later."),
+ )
+ return redirect("federation:settings")
+ response = super().form_valid(form)
+ oauth_app.delete()
+ return response
diff --git a/ephios/settings.py b/ephios/settings.py
index 0bf7e6d23..b54b4f135 100644
--- a/ephios/settings.py
+++ b/ephios/settings.py
@@ -108,6 +108,7 @@
"ephios.plugins.guests.apps.PluginApp",
"ephios.plugins.eventautoqualification.apps.PluginApp",
"ephios.plugins.simpleresource.apps.PluginApp",
+ "ephios.plugins.federation.apps.PluginApp",
]
PLUGINS = copy.copy(CORE_PLUGINS)
for ep in importlib_metadata.entry_points(group="ephios.plugins"):
diff --git a/ephios/templates/base.html b/ephios/templates/base.html
index 8dc515ce4..d37a6e7e9 100644
--- a/ephios/templates/base.html
+++ b/ephios/templates/base.html
@@ -153,6 +153,7 @@
{{ message }}