From 1179946ad631e89c66b868fd4d3c4e9cd5dbbf14 Mon Sep 17 00:00:00 2001 From: Julian B Date: Wed, 7 Jun 2023 22:33:48 +0200 Subject: [PATCH 01/42] add federation plugin, add federation models, add API route for shared events --- ephios/plugins/federation/__init__.py | 0 ephios/plugins/federation/admin.py | 7 + ephios/plugins/federation/apps.py | 17 +++ .../federation/migrations/0001_initial.py | 131 ++++++++++++++++++ .../migrations/0002_federatedeventshare.py | 30 ++++ .../plugins/federation/migrations/__init__.py | 0 ephios/plugins/federation/models.py | 90 ++++++++++++ ephios/plugins/federation/signals.py | 0 ephios/plugins/federation/urls.py | 8 ++ ephios/plugins/federation/views.py | 39 ++++++ ephios/settings.py | 1 + 11 files changed, 323 insertions(+) create mode 100644 ephios/plugins/federation/__init__.py create mode 100644 ephios/plugins/federation/admin.py create mode 100644 ephios/plugins/federation/apps.py create mode 100644 ephios/plugins/federation/migrations/0001_initial.py create mode 100644 ephios/plugins/federation/migrations/0002_federatedeventshare.py create mode 100644 ephios/plugins/federation/migrations/__init__.py create mode 100644 ephios/plugins/federation/models.py create mode 100644 ephios/plugins/federation/signals.py create mode 100644 ephios/plugins/federation/urls.py create mode 100644 ephios/plugins/federation/views.py diff --git a/ephios/plugins/federation/__init__.py b/ephios/plugins/federation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/federation/admin.py b/ephios/plugins/federation/admin.py new file mode 100644 index 000000000..4d58a0c7d --- /dev/null +++ b/ephios/plugins/federation/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from ephios.plugins.federation.models import FederatedEventShare, FederatedGuest, FederatedHost + +admin.site.register(FederatedGuest) +admin.site.register(FederatedHost) +admin.site.register(FederatedEventShare) diff --git a/ephios/plugins/federation/apps.py b/ephios/plugins/federation/apps.py new file mode 100644 index 000000000..4c9cd2e05 --- /dev/null +++ b/ephios/plugins/federation/apps.py @@ -0,0 +1,17 @@ +from django.utils.translation import gettext_lazy as _ + +from ephios.core.plugins import PluginConfig + + +class PluginApp(PluginConfig): + name = "ephios.plugins.federation" + + class EphiosPluginMeta: + name = _("Federation") + author = "Ephios Team" + description = _( + "This plugins provides the possibility to share events with other ephios instances." + ) + + def ready(self): + from . import signals # pylint: disable=unused-import diff --git a/ephios/plugins/federation/migrations/0001_initial.py b/ephios/plugins/federation/migrations/0001_initial.py new file mode 100644 index 000000000..a9350e829 --- /dev/null +++ b/ephios/plugins/federation/migrations/0001_initial.py @@ -0,0 +1,131 @@ +# Generated by Django 4.1.7 on 2023-06-07 19:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("core", "0017_alter_userprofile_date_of_birth"), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FederatedGuest", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=255)), + ("url", models.URLField()), + ("client_id", models.CharField(max_length=255)), + ("client_secret", models.CharField(max_length=255)), + ( + "access_token", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="InviteCode", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("code", models.CharField(max_length=255)), + ("url", models.URLField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name="FederatedUser", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("email", models.EmailField(max_length=254, verbose_name="email address")), + ("first_name", models.CharField(max_length=254, verbose_name="first name")), + ("last_name", models.CharField(max_length=254, verbose_name="last name")), + ("date_of_birth", models.DateField(verbose_name="date of birth")), + ( + "phone", + models.CharField(blank=True, max_length=254, verbose_name="phone number"), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "federated_instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="federation.federatedguest" + ), + ), + ("qualifications", models.ManyToManyField(to="core.qualification")), + ], + ), + migrations.CreateModel( + name="FederatedParticipation", + fields=[ + ( + "abstractparticipation_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.abstractparticipation", + ), + ), + ( + "federated_user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="federation.federateduser", + verbose_name="federated participant", + ), + ), + ], + options={ + "abstract": False, + "base_manager_name": "objects", + }, + bases=("core.abstractparticipation",), + ), + migrations.CreateModel( + name="FederatedHost", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=255)), + ("url", models.URLField()), + ("access_token", models.CharField(max_length=255)), + ( + "oauth_application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, + ), + ), + ], + ), + ] diff --git a/ephios/plugins/federation/migrations/0002_federatedeventshare.py b/ephios/plugins/federation/migrations/0002_federatedeventshare.py new file mode 100644 index 000000000..4a5ecb9bb --- /dev/null +++ b/ephios/plugins/federation/migrations/0002_federatedeventshare.py @@ -0,0 +1,30 @@ +# Generated by Django 4.1.7 on 2023-06-07 20:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0017_alter_userprofile_date_of_birth"), + ("federation", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="FederatedEventShare", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "event", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.event"), + ), + ("shared_with", models.ManyToManyField(to="federation.federatedguest")), + ], + ), + ] diff --git a/ephios/plugins/federation/migrations/__init__.py b/ephios/plugins/federation/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py new file mode 100644 index 000000000..c6bb60bab --- /dev/null +++ b/ephios/plugins/federation/models.py @@ -0,0 +1,90 @@ +import dataclasses + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from ephios.api.models import AccessToken, Application +from ephios.core.models import AbstractParticipation, Event, Qualification +from ephios.core.signup.participants import AbstractParticipant + + +class FederatedGuest(models.Model): + name = models.CharField(max_length=255) + url = models.URLField() + access_token = models.ForeignKey(AccessToken, on_delete=models.CASCADE) + client_id = models.CharField(max_length=255) + client_secret = models.CharField(max_length=255) + + +class FederatedHost(models.Model): + name = models.CharField(max_length=255) + url = models.URLField() + access_token = models.CharField(max_length=255) + oauth_application = models.ForeignKey(Application, on_delete=models.CASCADE) + + +class InviteCode(models.Model): + code = models.CharField(max_length=255) + url = models.URLField() + created_at = models.DateTimeField(auto_now_add=True) + + +class FederatedEventShare(models.Model): + event = models.ForeignKey(Event, on_delete=models.CASCADE) + shared_with = models.ManyToManyField(FederatedGuest) + + +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 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): + pass + + def reverse_event_detail(self, event): + pass + + +class FederatedParticipation(AbstractParticipation): + federated_user = models.ForeignKey( + FederatedUser, on_delete=models.CASCADE, verbose_name=_("federated participant") + ) + + @property + def participant(self) -> "AbstractParticipant": + return self.federated_user.as_participant() diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py new file mode 100644 index 000000000..b7226435b --- /dev/null +++ b/ephios/plugins/federation/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from ephios.plugins.federation.views import SharedEventListView + +app_name = "federation" +urlpatterns = [ + path("api/federation/events", SharedEventListView.as_view()), +] diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py new file mode 100644 index 000000000..c73dea8ed --- /dev/null +++ b/ephios/plugins/federation/views.py @@ -0,0 +1,39 @@ +from urllib.parse import urljoin + +from django.conf import settings +from oauth2_provider.contrib.rest_framework import TokenHasScope +from rest_framework import serializers +from rest_framework.generics import ListAPIView + +from ephios.api.views.events import EventSerializer +from ephios.core.models import Event + + +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(), obj.get_absolute_url()) + + +class SharedEventListView(ListAPIView): + serializer_class = SharedEventSerializer + permission_classes = [TokenHasScope] + required_scopes = ["PUBLIC_READ"] + + def get_queryset(self): + guest = self.request.auth.federatedguest_set.get() + return Event.objects.filter(federatedeventshare__shared_with=guest) diff --git a/ephios/settings.py b/ephios/settings.py index 89bcb079b..43999f0f0 100644 --- a/ephios/settings.py +++ b/ephios/settings.py @@ -92,6 +92,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"): From 6a4310d23e52bec797995f63cc12fa9ae0bbc591 Mon Sep 17 00:00:00 2001 From: Julian B Date: Wed, 7 Jun 2023 22:42:05 +0200 Subject: [PATCH 02/42] handle AccessToken that does not belong to a FederatedGuest --- ephios/plugins/federation/views.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py index c73dea8ed..6f9b60fbb 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views.py @@ -3,10 +3,12 @@ from django.conf import settings from oauth2_provider.contrib.rest_framework import TokenHasScope from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied from rest_framework.generics import ListAPIView from ephios.api.views.events import EventSerializer from ephios.core.models import Event +from ephios.plugins.federation.models import FederatedGuest class SharedEventSerializer(EventSerializer): @@ -35,5 +37,8 @@ class SharedEventListView(ListAPIView): required_scopes = ["PUBLIC_READ"] def get_queryset(self): - guest = self.request.auth.federatedguest_set.get() + try: + guest = self.request.auth.federatedguest_set.get() + except FederatedGuest.DoesNotExist: + raise PermissionDenied return Event.objects.filter(federatedeventshare__shared_with=guest) From 6850f1492d84c1c169283ffd5f96632b1f4467e5 Mon Sep 17 00:00:00 2001 From: Julian B Date: Sun, 11 Jun 2023 16:17:06 +0200 Subject: [PATCH 03/42] add frontend view to display incoming events, add OAuth2 flow, add API route for own user data --- ephios/api/urls.py | 2 + ephios/api/views/users.py | 26 ++++++ .../federation/incoming_shared_events.html | 13 +++ ephios/plugins/federation/urls.py | 24 ++++- ephios/plugins/federation/views.py | 90 ++++++++++++++++++- pyproject.toml | 2 + 6 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 ephios/api/views/users.py create mode 100644 ephios/plugins/federation/templates/federation/incoming_shared_events.html diff --git a/ephios/api/urls.py b/ephios/api/urls.py index 92cefbcf1..2da1e9f9f 100644 --- a/ephios/api/urls.py +++ b/ephios/api/urls.py @@ -13,6 +13,7 @@ ApplicationDelete, ) from ephios.api.views.events import EventViewSet +from ephios.api.views.users import UserProfileMeView from ephios.extra.permissions import staff_required router = routers.DefaultRouter() @@ -20,6 +21,7 @@ app_name = "api" urlpatterns = [ + path("users/me/", UserProfileMeView.as_view(), name="user-profile-me"), path( "settings/", include( diff --git a/ephios/api/views/users.py b/ephios/api/views/users.py new file mode 100644 index 000000000..8bef144c8 --- /dev/null +++ b/ephios/api/views/users.py @@ -0,0 +1,26 @@ +from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope +from rest_framework.generics import RetrieveAPIView +from rest_framework.serializers import ModelSerializer + +from ephios.core.models import UserProfile + + +class UserProfileSerializer(ModelSerializer): + class Meta: + model = UserProfile + fields = [ + "first_name", + "last_name", + "date_of_birth", + "email", + ] + + +class UserProfileMeView(RetrieveAPIView): + serializer_class = UserProfileSerializer + queryset = UserProfile.objects.all() + permission_classes = [IsAuthenticatedOrTokenHasScope] + required_scopes = ["ME_READ"] + + def get_object(self): + return self.request.user diff --git a/ephios/plugins/federation/templates/federation/incoming_shared_events.html b/ephios/plugins/federation/templates/federation/incoming_shared_events.html new file mode 100644 index 000000000..42fb7cd2f --- /dev/null +++ b/ephios/plugins/federation/templates/federation/incoming_shared_events.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %} + {% translate "Shared events" %} +{% endblock %} + +{% block content %} +

{% translate "Shared events" %}

+ {% for event in events %} +

{{ event.title }}


+ {% endfor %} +{% endblock %} diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py index b7226435b..4b5cfa18d 100644 --- a/ephios/plugins/federation/urls.py +++ b/ephios/plugins/federation/urls.py @@ -1,8 +1,28 @@ from django.urls import path -from ephios.plugins.federation.views import SharedEventListView +from ephios.plugins.federation.views import ( + FederatedEventDetailView, + FederationOAuthView, + IncomingSharedEventListView, + SharedEventListView, +) app_name = "federation" urlpatterns = [ - path("api/federation/events", SharedEventListView.as_view()), + path( + "events/shared/", + IncomingSharedEventListView.as_view(), + name="incoming_shared_event_list_view", + ), + path("events/shared//", FederatedEventDetailView.as_view(), name="event_detail"), + path( + "api/federation/events/", + SharedEventListView.as_view(), + name="outgoing_shared_event_list_view", + ), + path( + "api/federation/oauth-callback/", + FederationOAuthView.as_view(), + name="federation_oauth_callback", + ), ] diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py index 6f9b60fbb..d8f7d8ca0 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views.py @@ -1,14 +1,22 @@ from urllib.parse import urljoin +import requests from django.conf import settings +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.views import View +from django.views.generic import DetailView, TemplateView from oauth2_provider.contrib.rest_framework import TokenHasScope +from oauthlib.oauth2 import WebApplicationClient +from requests import HTTPError, JSONDecodeError +from requests_oauthlib import OAuth2Session from rest_framework import serializers from rest_framework.exceptions import PermissionDenied from rest_framework.generics import ListAPIView from ephios.api.views.events import EventSerializer from ephios.core.models import Event -from ephios.plugins.federation.models import FederatedGuest +from ephios.plugins.federation.models import FederatedGuest, FederatedHost, FederatedUser class SharedEventSerializer(EventSerializer): @@ -42,3 +50,83 @@ def get_queryset(self): except FederatedGuest.DoesNotExist: raise PermissionDenied return Event.objects.filter(federatedeventshare__shared_with=guest) + + +class IncomingSharedEventListView(TemplateView): + template_name = "federation/incoming_shared_events.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:outgoing_shared_event_list_view")), + headers={"Authorization": f"Bearer {host.access_token}"}, + ) + r.raise_for_status() + events += r.json()["results"] + except (HTTPError, JSONDecodeError): + continue + context["events"] = events + return context + + +class FederationOAuthView(View): + def get(self, request, *args, **kwargs): + if "code" not in request.GET.keys(): + guest = FederatedGuest.objects.first() + oauth_client = WebApplicationClient(client_id=guest.client_id) + oauth = OAuth2Session( + client=oauth_client, + redirect_uri=urljoin(settings.GET_SITE_URL(), "api/federation/oauth-callback/"), + scope=["ME_READ"], + ) + verifier = oauth_client.create_code_verifier(64) + request.session["code_verifier"] = verifier + request.session["guest"] = guest.pk + challenge = oauth_client.create_code_challenge(verifier, "S256") + authorization_url, state = oauth.authorization_url( + urljoin(guest.url, "api/oauth/authorize/"), + code_challenge=challenge, + code_challenge_method="S256", + ) + return redirect(authorization_url) + else: + guest = get_object_or_404(FederatedGuest, pk=request.session["guest"]) + oauth_client = WebApplicationClient( + client_id=guest.client_id, code_verifier=request.session["code_verifier"] + ) + oauth = OAuth2Session(client=oauth_client) + token = oauth.fetch_token( + urljoin(guest.url, "api/oauth/token/"), + authorization_response=request.get_full_path(), + client_secret=guest.client_secret, + code_verifier=request.session["code_verifier"], + ) + request.session["access_token"] = token["access_token"] + user_data = requests.get( + urljoin(guest.url, "api/users/me/"), + headers={"Authorization": f"Bearer {token['access_token']}"}, + ) + try: + FederatedUser.objects.get(federated_instance=guest, email=user_data.json()["email"]) + except FederatedUser.DoesNotExist: + FederatedUser.objects.create( + federated_instance=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"], + ) + return redirect("federation:event_detail", kwargs={"pk": 1}) + + +class FederatedEventDetailView(DetailView): + model = Event + + def get(self, request, *args, **kwargs): + if "access_token" not in self.request.session.keys(): + return FederationOAuthView.as_view()(self.request) + else: + return super().get(request, *args, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index e99d4bc00..b337c7765 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,8 @@ django-oauth-toolkit = "^2.3.0" mozilla-django-oidc = "^3.0.0" urllib3 = "^1.26.0,<2.0.0" # pinned because of poetry issues with urllib3 2.0.0 pyyaml = "^6.0.1" +requests = "^2.31.0" +requests-oauthlib = "^1.3.1" [tool.poetry.extras] pgsql = ["psycopg2"] From bf2a0e1be78db7d60d9004f54e16879df0f2376a Mon Sep 17 00:00:00 2001 From: Julian B Date: Sun, 11 Jun 2023 16:26:51 +0200 Subject: [PATCH 04/42] add nav link, use correct signup url --- ephios/plugins/federation/signals.py | 16 ++++++++++++++++ ephios/plugins/federation/views.py | 4 +++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py index e69de29bb..e1d1bd82d 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -0,0 +1,16 @@ +from django.dispatch import receiver +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +from ephios.core.signals import nav_link + + +@receiver(nav_link, dispatch_uid="ephios.plugins.federation.signals.nav_link") +def add_nav_link(sender, request, **kwargs): + return [ + { + "label": _("External events"), + "url": reverse_lazy("federation:incoming_shared_event_list_view"), + "active": request.resolver_match and request.resolver_match.app_name == "federation", + } + ] diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py index d8f7d8ca0..86f6d8e35 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views.py @@ -36,7 +36,9 @@ class Meta: ] def get_signup_url(self, obj): - return urljoin(settings.GET_SITE_URL(), obj.get_absolute_url()) + return urljoin( + settings.GET_SITE_URL(), reverse("federation:event_detail", kwargs={"pk": obj.pk}) + ) class SharedEventListView(ListAPIView): From d2316923a6283b2238aeb0945311a0cde6f6ac84 Mon Sep 17 00:00:00 2001 From: Julian B Date: Sun, 11 Jun 2023 17:12:55 +0200 Subject: [PATCH 05/42] check access token validity before serving the event detail view --- ephios/plugins/federation/views.py | 44 +++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py index 86f6d8e35..cdfaab9ec 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views.py @@ -16,7 +16,12 @@ from ephios.api.views.events import EventSerializer from ephios.core.models import Event -from ephios.plugins.federation.models import FederatedGuest, FederatedHost, FederatedUser +from ephios.plugins.federation.models import ( + FederatedEventShare, + FederatedGuest, + FederatedHost, + FederatedUser, +) class SharedEventSerializer(EventSerializer): @@ -124,11 +129,36 @@ def get(self, request, *args, **kwargs): return redirect("federation:event_detail", kwargs={"pk": 1}) -class FederatedEventDetailView(DetailView): +class CheckFederatedAccessTokenMixin: + def dispatch(self, request, *args, **kwargs): + if "access_token" not in request.session.keys(): + return FederationOAuthView.as_view()(request) + else: + try: + guest = FederatedGuest.objects.get(pk=request.session["guest"]) + test_query = requests.get( + urljoin(guest.url, "api/users/me/"), + headers={"Authorization": f"Bearer {request.session['access_token']}"}, + ) + test_query.raise_for_status() + except HTTPError: + request.session.pop("access_token") + return FederationOAuthView.as_view()(request) + except FederatedGuest.DoesNotExist: + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + +class FederatedEventDetailView(CheckFederatedAccessTokenMixin, DetailView): model = Event - def get(self, request, *args, **kwargs): - if "access_token" not in self.request.session.keys(): - return FederationOAuthView.as_view()(self.request) - else: - return super().get(request, *args, **kwargs) + def get_object(self, queryset=None): + obj = super().get_object(queryset) + try: + FederatedEventShare.objects.get( + event=obj, + shared_with__in=[FederatedGuest.objects.get(pk=self.request.session["guest"])], + ) + except FederatedEventShare.DoesNotExist: + raise PermissionDenied + return obj From 4185a7d6391ef550ba604ebfdf498f34ffa87c77 Mon Sep 17 00:00:00 2001 From: Julian B Date: Sun, 11 Jun 2023 23:31:34 +0200 Subject: [PATCH 06/42] add shift signup flow for federated users, add referrer to start correct OAuth2 flow, --- ephios/plugins/federation/admin.py | 10 ++- ephios/plugins/federation/models.py | 12 ++- ephios/plugins/federation/signals.py | 15 +++- .../templates/federation/event_detail.html | 9 +++ .../federation/incoming_shared_events.html | 2 +- ephios/plugins/federation/urls.py | 6 ++ ephios/plugins/federation/views.py | 81 +++++++++++++------ ephios/templates/base.html | 1 + 8 files changed, 107 insertions(+), 29 deletions(-) create mode 100644 ephios/plugins/federation/templates/federation/event_detail.html diff --git a/ephios/plugins/federation/admin.py b/ephios/plugins/federation/admin.py index 4d58a0c7d..7de74f224 100644 --- a/ephios/plugins/federation/admin.py +++ b/ephios/plugins/federation/admin.py @@ -1,7 +1,15 @@ from django.contrib import admin -from ephios.plugins.federation.models import FederatedEventShare, FederatedGuest, FederatedHost +from ephios.plugins.federation.models import ( + FederatedEventShare, + FederatedGuest, + FederatedHost, + FederatedParticipation, + FederatedUser, +) admin.site.register(FederatedGuest) admin.site.register(FederatedHost) admin.site.register(FederatedEventShare) +admin.site.register(FederatedUser) +admin.site.register(FederatedParticipation) diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index c6bb60bab..c55ab047c 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -1,6 +1,8 @@ import dataclasses from django.db import models +from django.urls import reverse +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from ephios.api.models import AccessToken, Application @@ -74,10 +76,16 @@ def all_participations(self): return FederatedParticipation.objects.filter(federated_user=self.federated_user) def reverse_signup_action(self, shift): - pass + return reverse("federation:shift_signup", kwargs=dict(pk=shift.pk)) def reverse_event_detail(self, event): - pass + return reverse("federation:event_detail", kwargs=dict(pk=event.pk)) + + @property + def icon(self): + return mark_safe( + f'' + ) class FederatedParticipation(AbstractParticipation): diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py index e1d1bd82d..5e3f2a3e9 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -2,7 +2,8 @@ from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ -from ephios.core.signals import nav_link +from ephios.core.signals import nav_link, participant_from_request +from ephios.plugins.federation.models import FederatedUser @receiver(nav_link, dispatch_uid="ephios.plugins.federation.signals.nav_link") @@ -14,3 +15,15 @@ def add_nav_link(sender, request, **kwargs): "active": request.resolver_match and request.resolver_match.app_name == "federation", } ] + + +@receiver( + participant_from_request, + dispatch_uid="ephios.plugins.federation.signals.guest_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 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..2e9361ea0 --- /dev/null +++ b/ephios/plugins/federation/templates/federation/event_detail.html @@ -0,0 +1,9 @@ +{% extends "core/event_detail.html" %} +{% load i18n %} + +{% block messages %} + +{% endblock %} diff --git a/ephios/plugins/federation/templates/federation/incoming_shared_events.html b/ephios/plugins/federation/templates/federation/incoming_shared_events.html index 42fb7cd2f..9977f1b0b 100644 --- a/ephios/plugins/federation/templates/federation/incoming_shared_events.html +++ b/ephios/plugins/federation/templates/federation/incoming_shared_events.html @@ -8,6 +8,6 @@ {% block content %}

{% translate "Shared events" %}

{% for event in events %} -

{{ event.title }}


+

{{ event.title }}


{% endfor %} {% endblock %} diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py index 4b5cfa18d..c429c4cc7 100644 --- a/ephios/plugins/federation/urls.py +++ b/ephios/plugins/federation/urls.py @@ -2,6 +2,7 @@ from ephios.plugins.federation.views import ( FederatedEventDetailView, + FederatedUserShiftActionView, FederationOAuthView, IncomingSharedEventListView, SharedEventListView, @@ -15,6 +16,11 @@ name="incoming_shared_event_list_view", ), path("events/shared//", FederatedEventDetailView.as_view(), name="event_detail"), + path( + "shifts/shared//signup/", + FederatedUserShiftActionView.as_view(), + name="shift_signup", + ), path( "api/federation/events/", SharedEventListView.as_view(), diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py index cdfaab9ec..165ec7479 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views.py @@ -6,6 +6,7 @@ from django.urls import reverse from django.views import View from django.views.generic import DetailView, TemplateView +from guardian.mixins import LoginRequiredMixin from oauth2_provider.contrib.rest_framework import TokenHasScope from oauthlib.oauth2 import WebApplicationClient from requests import HTTPError, JSONDecodeError @@ -16,6 +17,7 @@ from ephios.api.views.events import EventSerializer from ephios.core.models import Event +from ephios.core.views.signup import BaseShiftActionView from ephios.plugins.federation.models import ( FederatedEventShare, FederatedGuest, @@ -59,7 +61,7 @@ def get_queryset(self): return Event.objects.filter(federatedeventshare__shared_with=guest) -class IncomingSharedEventListView(TemplateView): +class IncomingSharedEventListView(LoginRequiredMixin, TemplateView): template_name = "federation/incoming_shared_events.html" def get_context_data(self, **kwargs): @@ -82,23 +84,33 @@ def get_context_data(self, **kwargs): class FederationOAuthView(View): def get(self, request, *args, **kwargs): if "code" not in request.GET.keys(): - guest = FederatedGuest.objects.first() - oauth_client = WebApplicationClient(client_id=guest.client_id) - oauth = OAuth2Session( - client=oauth_client, - redirect_uri=urljoin(settings.GET_SITE_URL(), "api/federation/oauth-callback/"), - scope=["ME_READ"], - ) - verifier = oauth_client.create_code_verifier(64) - request.session["code_verifier"] = verifier - request.session["guest"] = guest.pk - challenge = oauth_client.create_code_challenge(verifier, "S256") - authorization_url, state = oauth.authorization_url( - urljoin(guest.url, "api/oauth/authorize/"), - code_challenge=challenge, - code_challenge_method="S256", - ) - return redirect(authorization_url) + try: + guest = ( + FederatedGuest.objects.get(pk=request.session["guest"]) + if "guest" in request.session.keys() + else FederatedGuest.objects.get(url=request.GET["referrer"]) + ) + oauth_client = WebApplicationClient(client_id=guest.client_id) + oauth = OAuth2Session( + client=oauth_client, + redirect_uri=urljoin( + settings.GET_SITE_URL(), f"api/federation/oauth-callback/" + ), + scope=["ME_READ"], + ) + verifier = oauth_client.create_code_verifier(64) + request.session["code_verifier"] = verifier + request.session["event"] = self.kwargs["pk"] + request.session["guest"] = guest.pk + challenge = oauth_client.create_code_challenge(verifier, "S256") + authorization_url, state = oauth.authorization_url( + urljoin(guest.url, "api/oauth/authorize/"), + code_challenge=challenge, + code_challenge_method="S256", + ) + return redirect(authorization_url) + except (KeyError, FederatedGuest.DoesNotExist): + raise PermissionDenied else: guest = get_object_or_404(FederatedGuest, pk=request.session["guest"]) oauth_client = WebApplicationClient( @@ -117,22 +129,26 @@ def get(self, request, *args, **kwargs): headers={"Authorization": f"Bearer {token['access_token']}"}, ) try: - FederatedUser.objects.get(federated_instance=guest, email=user_data.json()["email"]) + user = FederatedUser.objects.get( + federated_instance=guest, email=user_data.json()["email"] + ) except FederatedUser.DoesNotExist: - FederatedUser.objects.create( + user = FederatedUser.objects.create( federated_instance=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"], ) - return redirect("federation:event_detail", kwargs={"pk": 1}) + request.session["federated_user"] = user.pk + requested_event = self.request.session.pop("event") + return redirect("federation:event_detail", pk=requested_event) class CheckFederatedAccessTokenMixin: def dispatch(self, request, *args, **kwargs): if "access_token" not in request.session.keys(): - return FederationOAuthView.as_view()(request) + return FederationOAuthView.as_view()(request, *args, **kwargs) else: try: guest = FederatedGuest.objects.get(pk=request.session["guest"]) @@ -148,17 +164,34 @@ def dispatch(self, request, *args, **kwargs): raise PermissionDenied return super().dispatch(request, *args, **kwargs) + def get_context_data(self, object): + context = super().get_context_data() + context["guest_url"] = FederatedGuest.objects.get(pk=self.request.session["guest"]).url + return context + class FederatedEventDetailView(CheckFederatedAccessTokenMixin, DetailView): model = Event + template_name = "federation/event_detail.html" def get_object(self, queryset=None): obj = super().get_object(queryset) try: + guest = self.request.session["guest"] FederatedEventShare.objects.get( event=obj, - shared_with__in=[FederatedGuest.objects.get(pk=self.request.session["guest"])], + shared_with__in=[FederatedGuest.objects.get(pk=guest)], ) - except FederatedEventShare.DoesNotExist: + except (KeyError, FederatedEventShare.DoesNotExist): raise PermissionDenied return obj + + +class FederatedUserShiftActionView(BaseShiftActionView): + def get_participant(self): + try: + return FederatedUser.objects.get( + pk=self.request.session["federated_user"] + ).as_participant() + except FederatedUser.DoesNotExist as e: + raise PermissionDenied from e diff --git a/ephios/templates/base.html b/ephios/templates/base.html index 20c4e9263..15e184ba1 100644 --- a/ephios/templates/base.html +++ b/ephios/templates/base.html @@ -153,6 +153,7 @@ {{ message }} {% endfor %} + {% block messages %}{% endblock %} {% block content %}{% endblock %} From 87dc5070b8d6211ac10387ca0216d2ca91e5ef3e Mon Sep 17 00:00:00 2001 From: Julian B Date: Sun, 11 Jun 2023 23:47:36 +0200 Subject: [PATCH 07/42] set session expiration --- ephios/plugins/federation/views.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py index 165ec7479..04f1d5e25 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views.py @@ -124,6 +124,7 @@ def get(self, request, *args, **kwargs): code_verifier=request.session["code_verifier"], ) request.session["access_token"] = token["access_token"] + request.session.set_expiry(token["expires_in"]) user_data = requests.get( urljoin(guest.url, "api/users/me/"), headers={"Authorization": f"Bearer {token['access_token']}"}, @@ -149,19 +150,6 @@ class CheckFederatedAccessTokenMixin: def dispatch(self, request, *args, **kwargs): if "access_token" not in request.session.keys(): return FederationOAuthView.as_view()(request, *args, **kwargs) - else: - try: - guest = FederatedGuest.objects.get(pk=request.session["guest"]) - test_query = requests.get( - urljoin(guest.url, "api/users/me/"), - headers={"Authorization": f"Bearer {request.session['access_token']}"}, - ) - test_query.raise_for_status() - except HTTPError: - request.session.pop("access_token") - return FederationOAuthView.as_view()(request) - except FederatedGuest.DoesNotExist: - raise PermissionDenied return super().dispatch(request, *args, **kwargs) def get_context_data(self, object): From bb1f4715724a4880e9037543ad9222aa5291be40 Mon Sep 17 00:00:00 2001 From: Julian B Date: Tue, 13 Jun 2023 23:21:24 +0200 Subject: [PATCH 08/42] add QualificationSerializer --- ephios/api/views/users.py | 41 +++++++++++++++++++++++++++++- ephios/plugins/federation/views.py | 9 ++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/ephios/api/views/users.py b/ephios/api/views/users.py index 8bef144c8..b7c920ae2 100644 --- a/ephios/api/views/users.py +++ b/ephios/api/views/users.py @@ -1,11 +1,37 @@ +import datetime + +from django.db.models import Q from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope +from rest_framework.fields import SerializerMethodField from rest_framework.generics import RetrieveAPIView +from rest_framework.relations import SlugRelatedField from rest_framework.serializers import ModelSerializer -from ephios.core.models import UserProfile +from ephios.core.models import Qualification, UserProfile + + +class QualificationSerializer(ModelSerializer): + category = SlugRelatedField(slug_field="uuid", read_only=True) + includes = SerializerMethodField() + + class Meta: + model = Qualification + fields = [ + "uuid", + "title", + "abbreviation", + "category", + "includes", + ] + + def get_includes(self, obj): + qualifications = Qualification.collect_all_included_qualifications(obj.includes.all()) + return [q.uuid for q in qualifications] class UserProfileSerializer(ModelSerializer): + qualifications = SerializerMethodField() + class Meta: model = UserProfile fields = [ @@ -13,8 +39,21 @@ class Meta: "last_name", "date_of_birth", "email", + "qualifications", ] + def get_qualifications(self, obj): + return QualificationSerializer( + Qualification.objects.filter( + Q(grants__user=obj) + & ( + Q(grants__expires__gte=datetime.datetime.now()) + | Q(grants__expires__isnull=True) + ) + ), + many=True, + ).data + class UserProfileMeView(RetrieveAPIView): serializer_class = UserProfileSerializer diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py index 04f1d5e25..231b6a64d 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views.py @@ -16,7 +16,7 @@ from rest_framework.generics import ListAPIView from ephios.api.views.events import EventSerializer -from ephios.core.models import Event +from ephios.core.models import Event, Qualification from ephios.core.views.signup import BaseShiftActionView from ephios.plugins.federation.models import ( FederatedEventShare, @@ -141,6 +141,13 @@ def get(self, request, *args, **kwargs): last_name=user_data.json()["last_name"], date_of_birth=user_data.json()["date_of_birth"], ) + for qualification in user_data.json()["qualifications"]: + try: + user.qualifications.add( + Qualification.objects.get(uuid=qualification["uuid"]) + ) + except Qualification.DoesNotExist: + continue request.session["federated_user"] = user.pk requested_event = self.request.session.pop("event") return redirect("federation:event_detail", pk=requested_event) From 63f27a740641730bb82c0e95323b128cc35fcaae Mon Sep 17 00:00:00 2001 From: Julian B Date: Tue, 13 Jun 2023 23:26:26 +0200 Subject: [PATCH 09/42] respect included qualifications --- ephios/plugins/federation/views.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py index 231b6a64d..ea74516f3 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views.py @@ -147,7 +147,13 @@ def get(self, request, *args, **kwargs): Qualification.objects.get(uuid=qualification["uuid"]) ) except Qualification.DoesNotExist: - continue + for included_qualification in qualification["includes"]: + try: + user.qualifications.add( + Qualification.objects.get(uuid=included_qualification["uuid"]) + ) + except Qualification.DoesNotExist: + continue request.session["federated_user"] = user.pk requested_event = self.request.session.pop("event") return redirect("federation:event_detail", pk=requested_event) From 1e22bb3d340895a846f464c7333dbd0199f2fc73 Mon Sep 17 00:00:00 2001 From: Julian B Date: Tue, 13 Jun 2023 23:54:08 +0200 Subject: [PATCH 10/42] improve incoming event view --- .../federation/incoming_shared_events.html | 48 ++++++++++++++++++- ephios/plugins/federation/views.py | 10 +++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/ephios/plugins/federation/templates/federation/incoming_shared_events.html b/ephios/plugins/federation/templates/federation/incoming_shared_events.html index 9977f1b0b..04e8c9e65 100644 --- a/ephios/plugins/federation/templates/federation/incoming_shared_events.html +++ b/ephios/plugins/federation/templates/federation/incoming_shared_events.html @@ -8,6 +8,52 @@ {% block content %}

{% translate "Shared events" %}

{% for event in events %} -

{{ event.title }}


+ {% endfor %} {% endblock %} diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py index ea74516f3..ec98f8cdc 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views.py @@ -1,3 +1,4 @@ +from datetime import datetime from urllib.parse import urljoin import requests @@ -74,9 +75,16 @@ def get_context_data(self, **kwargs): headers={"Authorization": f"Bearer {host.access_token}"}, ) r.raise_for_status() - events += r.json()["results"] + 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, JSONDecodeError): continue + events.sort(key=lambda e: e["start_time"]) context["events"] = events return context From 89705520b737b3babc8f54b8980165e565ad590e Mon Sep 17 00:00:00 2001 From: Julian B Date: Wed, 14 Jun 2023 18:39:51 +0200 Subject: [PATCH 11/42] make pylint happy --- ephios/plugins/federation/models.py | 4 +- ephios/plugins/federation/views.py | 151 ++++++++++++++-------------- 2 files changed, 79 insertions(+), 76 deletions(-) diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index c55ab047c..fc7b2260f 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -76,10 +76,10 @@ 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=dict(pk=shift.pk)) + return reverse("federation:shift_signup", kwargs={"pk": shift.pk}) def reverse_event_detail(self, event): - return reverse("federation:event_detail", kwargs=dict(pk=event.pk)) + return reverse("federation:event_detail", kwargs={"pk": event.pk}) @property def icon(self): diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py index ec98f8cdc..ebc28c251 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views.py @@ -57,8 +57,8 @@ class SharedEventListView(ListAPIView): def get_queryset(self): try: guest = self.request.auth.federatedguest_set.get() - except FederatedGuest.DoesNotExist: - raise PermissionDenied + except FederatedGuest.DoesNotExist as exc: + raise PermissionDenied from exc return Event.objects.filter(federatedeventshare__shared_with=guest) @@ -73,6 +73,7 @@ def get_context_data(self, **kwargs): r = requests.get( urljoin(host.url, reverse("federation:outgoing_shared_event_list_view")), headers={"Authorization": f"Bearer {host.access_token}"}, + timeout=5, ) r.raise_for_status() results = r.json()["results"] @@ -93,78 +94,80 @@ class FederationOAuthView(View): def get(self, request, *args, **kwargs): if "code" not in request.GET.keys(): try: - guest = ( - FederatedGuest.objects.get(pk=request.session["guest"]) - if "guest" in request.session.keys() - else FederatedGuest.objects.get(url=request.GET["referrer"]) - ) - oauth_client = WebApplicationClient(client_id=guest.client_id) - oauth = OAuth2Session( - client=oauth_client, - redirect_uri=urljoin( - settings.GET_SITE_URL(), f"api/federation/oauth-callback/" - ), - scope=["ME_READ"], - ) - verifier = oauth_client.create_code_verifier(64) - request.session["code_verifier"] = verifier - request.session["event"] = self.kwargs["pk"] - request.session["guest"] = guest.pk - challenge = oauth_client.create_code_challenge(verifier, "S256") - authorization_url, state = oauth.authorization_url( - urljoin(guest.url, "api/oauth/authorize/"), - code_challenge=challenge, - code_challenge_method="S256", - ) - return redirect(authorization_url) - except (KeyError, FederatedGuest.DoesNotExist): - raise PermissionDenied + return redirect(self._get_authorization_url()) + except (KeyError, FederatedGuest.DoesNotExist) as exc: + raise PermissionDenied from exc else: - guest = get_object_or_404(FederatedGuest, pk=request.session["guest"]) - oauth_client = WebApplicationClient( - client_id=guest.client_id, code_verifier=request.session["code_verifier"] - ) - oauth = OAuth2Session(client=oauth_client) - token = oauth.fetch_token( - urljoin(guest.url, "api/oauth/token/"), - authorization_response=request.get_full_path(), - client_secret=guest.client_secret, - code_verifier=request.session["code_verifier"], + self._oauth_callback() + return redirect("federation:event_detail", pk=self.request.session.pop("event")) + + def _get_authorization_url(self): + guest = ( + FederatedGuest.objects.get(pk=self.request.session["guest"]) + if "guest" in self.request.session.keys() + else FederatedGuest.objects.get(url=self.request.GET["referrer"]) + ) + oauth_client = WebApplicationClient(client_id=guest.client_id) + oauth = OAuth2Session( + client=oauth_client, + redirect_uri=urljoin(settings.GET_SITE_URL(), "api/federation/oauth-callback/"), + scope=["ME_READ"], + ) + verifier = oauth_client.create_code_verifier(64) + self.request.session["code_verifier"] = verifier + self.request.session["event"] = self.kwargs["pk"] + self.request.session["guest"] = guest.pk + challenge = oauth_client.create_code_challenge(verifier, "S256") + authorization_url, _ = oauth.authorization_url( + urljoin(guest.url, "api/oauth/authorize/"), + code_challenge=challenge, + code_challenge_method="S256", + ) + return authorization_url + + def _oauth_callback(self): + guest = get_object_or_404(FederatedGuest, pk=self.request.session["guest"]) + oauth_client = WebApplicationClient( + client_id=guest.client_id, code_verifier=self.request.session["code_verifier"] + ) + oauth = OAuth2Session(client=oauth_client) + token = oauth.fetch_token( + urljoin(guest.url, "api/oauth/token/"), + authorization_response=self.request.get_full_path(), + client_secret=guest.client_secret, + code_verifier=self.request.session["code_verifier"], + ) + self.request.session["access_token"] = token["access_token"] + self.request.session.set_expiry(token["expires_in"]) + user_data = requests.get( + urljoin(guest.url, "api/users/me/"), + headers={"Authorization": f"Bearer {token['access_token']}"}, + timeout=5, + ) + try: + user = FederatedUser.objects.get( + federated_instance=guest, email=user_data.json()["email"] ) - request.session["access_token"] = token["access_token"] - request.session.set_expiry(token["expires_in"]) - user_data = requests.get( - urljoin(guest.url, "api/users/me/"), - headers={"Authorization": f"Bearer {token['access_token']}"}, + except FederatedUser.DoesNotExist: + user = FederatedUser.objects.create( + federated_instance=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"], ) - try: - user = FederatedUser.objects.get( - federated_instance=guest, email=user_data.json()["email"] - ) - except FederatedUser.DoesNotExist: - user = FederatedUser.objects.create( - federated_instance=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: - 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 - request.session["federated_user"] = user.pk - requested_event = self.request.session.pop("event") - return redirect("federation:event_detail", pk=requested_event) + for qualification in user_data.json()["qualifications"]: + try: + 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 + self.request.session["federated_user"] = user.pk class CheckFederatedAccessTokenMixin: @@ -173,7 +176,7 @@ def dispatch(self, request, *args, **kwargs): return FederationOAuthView.as_view()(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs) - def get_context_data(self, object): + def get_context_data(self, obj): context = super().get_context_data() context["guest_url"] = FederatedGuest.objects.get(pk=self.request.session["guest"]).url return context @@ -191,8 +194,8 @@ def get_object(self, queryset=None): event=obj, shared_with__in=[FederatedGuest.objects.get(pk=guest)], ) - except (KeyError, FederatedEventShare.DoesNotExist): - raise PermissionDenied + except (KeyError, FederatedEventShare.DoesNotExist) as exc: + raise PermissionDenied from exc return obj From 4b35cbaf19a238aaeae6600bedf7a2833c34b30c Mon Sep 17 00:00:00 2001 From: Julian B Date: Wed, 14 Jun 2023 18:55:55 +0200 Subject: [PATCH 12/42] add form to select guests to share an event with --- ephios/plugins/federation/forms.py | 33 ++++++++++++++++++++++++++++ ephios/plugins/federation/models.py | 18 +++++++++++++++ ephios/plugins/federation/signals.py | 11 +++++++++- 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 ephios/plugins/federation/forms.py diff --git a/ephios/plugins/federation/forms.py b/ephios/plugins/federation/forms.py new file mode 100644 index 000000000..463699c91 --- /dev/null +++ b/ephios/plugins/federation/forms.py @@ -0,0 +1,33 @@ +from django import forms +from django.utils.translation import gettext as _ +from django_select2.forms import Select2MultipleWidget + +from ephios.core.forms.events import BasePluginFormMixin +from ephios.plugins.federation.models import FederatedEventShare, FederatedGuest + + +class EventAllowFederationForm(BasePluginFormMixin, forms.Form): + shared_with = forms.ModelMultipleChoiceField( + queryset=FederatedGuest.objects.all(), required=False, widget=Select2MultipleWidget + ) + + def __init__(self, *args, **kwargs): + kwargs.setdefault("prefix", "guests") + self.event = kwargs.pop("event") + self.request = kwargs.pop("request") + super().__init__(*args, **kwargs) + try: + self.instance = FederatedEventShare.objects.get(event_id=self.event.id) + except (AttributeError, FederatedEventShare.DoesNotExist): + self.instance = FederatedEventShare(event=self.event) + self.fields["shared_with"].initial = self.instance.shared_with.all() + + def save(self): + self.instance.shared_with.set(self.cleaned_data["shared_with"]) + + @property + def heading(self): + return _("Federation") + + def is_function_active(self): + return self.instance.shared_with.exists() diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index fc7b2260f..c6f160fbb 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -17,6 +17,9 @@ class FederatedGuest(models.Model): client_id = models.CharField(max_length=255) client_secret = models.CharField(max_length=255) + def __str__(self): + return self.name + class FederatedHost(models.Model): name = models.CharField(max_length=255) @@ -24,17 +27,26 @@ class FederatedHost(models.Model): access_token = models.CharField(max_length=255) oauth_application = models.ForeignKey(Application, on_delete=models.CASCADE) + def __str__(self): + return self.name + class InviteCode(models.Model): code = models.CharField(max_length=255) url = models.URLField() created_at = models.DateTimeField(auto_now_add=True) + def __str__(self): + return f"Federation invite code for {self.url}" + class FederatedEventShare(models.Model): event = models.ForeignKey(Event, on_delete=models.CASCADE) shared_with = models.ManyToManyField(FederatedGuest) + def __str__(self): + return f"Federated event share for {self.event}" + class FederatedUser(models.Model): email = models.EmailField(_("email address")) @@ -46,6 +58,9 @@ class FederatedUser(models.Model): federated_instance = models.ForeignKey(FederatedGuest, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) + def __str__(self): + return f"Federated user {self.first_name} {self.last_name}" + def as_participant(self) -> "FederatedParticipant": return FederatedParticipant( first_name=self.first_name, @@ -93,6 +108,9 @@ class FederatedParticipation(AbstractParticipation): FederatedUser, on_delete=models.CASCADE, verbose_name=_("federated participant") ) + def __str__(self): + return f"Federated participation for {self.federated_user} in {self.shift}" + @property def participant(self) -> "AbstractParticipant": return self.federated_user.as_participant() diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py index 5e3f2a3e9..b663b00b8 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -2,7 +2,8 @@ from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ -from ephios.core.signals import nav_link, participant_from_request +from ephios.core.signals import event_forms, nav_link, participant_from_request +from ephios.plugins.federation.forms import EventAllowFederationForm from ephios.plugins.federation.models import FederatedUser @@ -27,3 +28,11 @@ def federated_participant_from_request(sender, request, **kwargs): 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)] From 7321fe5a8715cd9b105582ebb050ee531c945cf6 Mon Sep 17 00:00:00 2001 From: Julian B Date: Wed, 14 Jun 2023 19:56:07 +0200 Subject: [PATCH 13/42] add RedeemFederationInviteCodeView --- ephios/plugins/federation/admin.py | 2 ++ ephios/plugins/federation/models.py | 10 +++++++- ephios/plugins/federation/urls.py | 4 +++ ephios/plugins/federation/views.py | 39 ++++++++++++++++++++++++++++- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/ephios/plugins/federation/admin.py b/ephios/plugins/federation/admin.py index 7de74f224..a2fd23325 100644 --- a/ephios/plugins/federation/admin.py +++ b/ephios/plugins/federation/admin.py @@ -6,6 +6,7 @@ FederatedHost, FederatedParticipation, FederatedUser, + InviteCode, ) admin.site.register(FederatedGuest) @@ -13,3 +14,4 @@ admin.site.register(FederatedEventShare) admin.site.register(FederatedUser) admin.site.register(FederatedParticipation) +admin.site.register(InviteCode) diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index c6f160fbb..026d3da9a 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -1,4 +1,7 @@ +import base64 import dataclasses +import json +from secrets import token_hex from django.db import models from django.urls import reverse @@ -32,13 +35,18 @@ def __str__(self): class InviteCode(models.Model): - code = models.CharField(max_length=255) + code = models.CharField(max_length=255, default=token_hex) url = models.URLField() created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return f"Federation invite code for {self.url}" + def get_share_string(self): + return base64.b64encode( + json.dumps({"url": self.url, "code": self.code}).encode("ascii") + ).decode("ascii") + class FederatedEventShare(models.Model): event = models.ForeignKey(Event, on_delete=models.CASCADE) diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py index c429c4cc7..24d00b8c6 100644 --- a/ephios/plugins/federation/urls.py +++ b/ephios/plugins/federation/urls.py @@ -5,6 +5,7 @@ FederatedUserShiftActionView, FederationOAuthView, IncomingSharedEventListView, + RedeemFederationInviteCodeView, SharedEventListView, ) @@ -31,4 +32,7 @@ FederationOAuthView.as_view(), name="federation_oauth_callback", ), + path( + "api/federation/setup/", RedeemFederationInviteCodeView.as_view(), name="reedem_invite_code" + ), ] diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views.py index ebc28c251..8425ff153 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views.py @@ -1,3 +1,7 @@ +import base64 +import binascii +import json +import secrets from datetime import datetime from urllib.parse import urljoin @@ -14,8 +18,9 @@ from requests_oauthlib import OAuth2Session from rest_framework import serializers from rest_framework.exceptions import PermissionDenied -from rest_framework.generics import ListAPIView +from rest_framework.generics import CreateAPIView, ListAPIView +from ephios.api.models import AccessToken from ephios.api.views.events import EventSerializer from ephios.core.models import Event, Qualification from ephios.core.views.signup import BaseShiftActionView @@ -24,6 +29,7 @@ FederatedGuest, FederatedHost, FederatedUser, + InviteCode, ) @@ -49,6 +55,32 @@ def get_signup_url(self, obj): ) +class FederatedGuestCreateSerializer(serializers.ModelSerializer): + code = serializers.CharField(write_only=True) + access_token = serializers.SlugRelatedField(slug_field="token", read_only=True) + + class Meta: + model = FederatedGuest + fields = ["id", "name", "url", "client_id", "client_secret", "code", "access_token"] + + def validate_code(self, value): + try: + data = json.loads(base64.b64decode(value.encode("ascii")).decode("ascii")) + return InviteCode.objects.get(code=data["code"], url=data["url"]) + except (binascii.Error, JSONDecodeError, KeyError, 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 SharedEventListView(ListAPIView): serializer_class = SharedEventSerializer permission_classes = [TokenHasScope] @@ -207,3 +239,8 @@ def get_participant(self): ).as_participant() except FederatedUser.DoesNotExist as e: raise PermissionDenied from e + + +class RedeemFederationInviteCodeView(CreateAPIView): + serializer_class = FederatedGuestCreateSerializer + queryset = FederatedGuest.objects.all() From 59cacbc4139d29571abde89bbe9f55c47f39f287 Mon Sep 17 00:00:00 2001 From: Julian B Date: Wed, 14 Jun 2023 23:14:24 +0200 Subject: [PATCH 14/42] finish invite flow --- ephios/api/access/auth.py | 2 +- ephios/plugins/federation/forms.py | 67 ++++++++++- ephios/plugins/federation/models.py | 5 +- ephios/plugins/federation/serializer.py | 73 +++++++++++ ephios/plugins/federation/signals.py | 27 ++++- .../federation/federation_settings.html | 74 ++++++++++++ .../templates/federation/invitecode_form.html | 11 ++ .../federation/redeem_invite_code.html | 11 ++ ephios/plugins/federation/urls.py | 36 +++--- ephios/plugins/federation/views/api.py | 30 +++++ .../{views.py => views/frontend.py} | 113 ++++++------------ 11 files changed, 352 insertions(+), 97 deletions(-) create mode 100644 ephios/plugins/federation/serializer.py create mode 100644 ephios/plugins/federation/templates/federation/federation_settings.html create mode 100644 ephios/plugins/federation/templates/federation/invitecode_form.html create mode 100644 ephios/plugins/federation/templates/federation/redeem_invite_code.html create mode 100644 ephios/plugins/federation/views/api.py rename ephios/plugins/federation/{views.py => views/frontend.py} (71%) diff --git a/ephios/api/access/auth.py b/ephios/api/access/auth.py index 574f6bfbe..0054113b0 100644 --- a/ephios/api/access/auth.py +++ b/ephios/api/access/auth.py @@ -14,7 +14,7 @@ def authenticate(self, request): if oauth_result is None: return None user, token = oauth_result - if not user.is_active: + if user is not None and not user.is_active: return None return user, token diff --git a/ephios/plugins/federation/forms.py b/ephios/plugins/federation/forms.py index 463699c91..aaa3f8174 100644 --- a/ephios/plugins/federation/forms.py +++ b/ephios/plugins/federation/forms.py @@ -1,9 +1,27 @@ +import base64 +import binascii +import json +from json import JSONDecodeError +from urllib.parse import urljoin + +import requests from django import forms +from django.conf import settings +from django.core.exceptions import ValidationError +from django.urls import reverse from django.utils.translation import gettext as _ from django_select2.forms import Select2MultipleWidget +from dynamic_preferences.registries import global_preferences_registry +from requests import HTTPError +from ephios.api.models import Application from ephios.core.forms.events import BasePluginFormMixin -from ephios.plugins.federation.models import FederatedEventShare, FederatedGuest +from ephios.plugins.federation.models import ( + FederatedEventShare, + FederatedGuest, + FederatedHost, + InviteCode, +) class EventAllowFederationForm(BasePluginFormMixin, forms.Form): @@ -31,3 +49,50 @@ def heading(self): def is_function_active(self): return self.instance.shared_with.exists() + + +class InviteCodeForm(forms.ModelForm): + class Meta: + model = InviteCode + fields = ["url"] + + +class RedeemInviteCodeForm(forms.Form): + code = forms.CharField(label=_("Invite code")) + + def clean_code(self): + try: + data = json.loads( + base64.b64decode(self.cleaned_data["code"].encode("ascii")).decode("ascii") + ) + if settings.GET_SITE_URL() != data["guest_url"]: + raise ValidationError(_("This invite code is not issued for this instance.")) + oauth_application = Application( + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + redirect_uris=urljoin( + data["host_url"], reverse("federation:federation_oauth_callback") + ), + ) + response = requests.post( + urljoin(data["host_url"], reverse("federation:redeem_invite_code")), + data={ + "name": global_preferences_registry.manager()["general__organization_name"], + "url": data["guest_url"], + "client_id": oauth_application.client_id, + "client_secret": oauth_application.client_secret, + "code": data["code"], + }, + ) + response.raise_for_status() + response_data = response.json() + oauth_application.name = response_data["name"] + oauth_application.save() + FederatedHost.objects.create( + name=response_data["name"], + url=data["host_url"], + access_token=response_data["access_token"], + oauth_application=oauth_application, + ) + except (binascii.Error, JSONDecodeError, KeyError, HTTPError) as exc: + raise ValidationError(_("Invalid code")) from exc diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index 026d3da9a..3fa7f95fe 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -3,6 +3,7 @@ import json from secrets import token_hex +from django.conf import settings from django.db import models from django.urls import reverse from django.utils.safestring import mark_safe @@ -44,7 +45,9 @@ def __str__(self): def get_share_string(self): return base64.b64encode( - json.dumps({"url": self.url, "code": self.code}).encode("ascii") + json.dumps( + {"guest_url": self.url, "code": self.code, "host_url": settings.GET_SITE_URL()} + ).encode("ascii") ).decode("ascii") diff --git a/ephios/plugins/federation/serializer.py b/ephios/plugins/federation/serializer.py new file mode 100644 index 000000000..2041a751a --- /dev/null +++ b/ephios/plugins/federation/serializer.py @@ -0,0 +1,73 @@ +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, data): + try: + data["code"] = InviteCode.objects.get(code=data["code"], url=data["url"]) + return data + 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 index b663b00b8..4a1a9e148 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -1,8 +1,13 @@ from django.dispatch import receiver -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from django.utils.translation import gettext_lazy as _ -from ephios.core.signals import event_forms, nav_link, participant_from_request +from ephios.core.signals import ( + event_forms, + management_settings_sections, + nav_link, + participant_from_request, +) from ephios.plugins.federation.forms import EventAllowFederationForm from ephios.plugins.federation.models import FederatedUser @@ -36,3 +41,21 @@ def federated_participant_from_request(sender, request, **kwargs): ) 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.url_name.startswith("federaion"), + }, + ] + if request.user.is_staff + else [] + ) 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..c9dc68bb9 --- /dev/null +++ b/ephios/plugins/federation/templates/federation/federation_settings.html @@ -0,0 +1,74 @@ +{% extends "core/settings/settings_base.html" %} +{% load i18n %} + +{% block settings_content %} +

Outgoing shares

+ Add outgoing share + + + + + + + + + + {% for guest in guests %} + + + + + + {% endfor %} + +
{% translate "Name" %}{% translate "URL" %}{% translate "Action" %}
{{ guest.name }}{{ guest.url }} + +
+ + {% if invites %} +

{% translate "Pending invites" %}

+ + + + + + + + + + {% for invite in invites %} + + + + + + {% endfor %} + +
{% translate "URL" %}{% translate "Invite code" %}{% translate "Action" %}
{{ invite.url }}{{ invite.get_share_string }} + +
+ {% endif %} + +

Incoming shares

+ Add incoming share + + + + + + + + + + {% for host in hosts %} + + + + + + {% endfor %} + +
{% translate "Name" %}{% translate "URL" %}{% translate "Action" %}
{{ host.name }}{{ host.url }} + +
+{% 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..2ce0523e9 --- /dev/null +++ b/ephios/plugins/federation/templates/federation/invitecode_form.html @@ -0,0 +1,11 @@ +{% extends "core/settings/settings_base.html" %} +{% load crispy_forms_filters %} + +{% block settings_content %} +

Add outgoing share

+
+ {% csrf_token %} + {{ form|crispy }} + +
+{% 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..3376d5d6d --- /dev/null +++ b/ephios/plugins/federation/templates/federation/redeem_invite_code.html @@ -0,0 +1,11 @@ +{% extends "core/settings/settings_base.html" %} +{% load crispy_forms_filters %} + +{% block settings_content %} +

Add incoming share

+
+ {% csrf_token %} + {{ form|crispy }} + +
+{% endblock %} diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py index 24d00b8c6..d538c89dc 100644 --- a/ephios/plugins/federation/urls.py +++ b/ephios/plugins/federation/urls.py @@ -1,38 +1,46 @@ from django.urls import path -from ephios.plugins.federation.views import ( - FederatedEventDetailView, - FederatedUserShiftActionView, - FederationOAuthView, - IncomingSharedEventListView, - RedeemFederationInviteCodeView, - SharedEventListView, -) +from ephios.plugins.federation.views import api, frontend app_name = "federation" urlpatterns = [ path( "events/shared/", - IncomingSharedEventListView.as_view(), + frontend.IncomingSharedEventListView.as_view(), name="incoming_shared_event_list_view", ), - path("events/shared//", FederatedEventDetailView.as_view(), name="event_detail"), + path( + "events/shared//", frontend.FederatedEventDetailView.as_view(), name="event_detail" + ), path( "shifts/shared//signup/", - FederatedUserShiftActionView.as_view(), + 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/guests/add/", + frontend.CreateInviteCodeView.as_view(), + name="create_invite_code", + ), path( "api/federation/events/", - SharedEventListView.as_view(), + api.SharedEventListView.as_view(), name="outgoing_shared_event_list_view", ), path( "api/federation/oauth-callback/", - FederationOAuthView.as_view(), + frontend.FederationOAuthView.as_view(), name="federation_oauth_callback", ), path( - "api/federation/setup/", RedeemFederationInviteCodeView.as_view(), name="reedem_invite_code" + "api/federation/setup/", + api.RedeemFederationInviteCodeView.as_view(), + name="redeem_invite_code", ), ] diff --git a/ephios/plugins/federation/views/api.py b/ephios/plugins/federation/views/api.py new file mode 100644 index 000000000..1b0ceb292 --- /dev/null +++ b/ephios/plugins/federation/views/api.py @@ -0,0 +1,30 @@ +from oauth2_provider.contrib.rest_framework import TokenHasScope +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import CreateAPIView, ListAPIView + +from ephios.core.models import Event +from ephios.plugins.federation.models import FederatedGuest +from ephios.plugins.federation.serializer import ( + FederatedGuestCreateSerializer, + SharedEventSerializer, +) + + +class RedeemFederationInviteCodeView(CreateAPIView): + serializer_class = FederatedGuestCreateSerializer + queryset = FederatedGuest.objects.all() + authentication_classes = [] + permission_classes = [] + + +class SharedEventListView(ListAPIView): + serializer_class = SharedEventSerializer + permission_classes = [TokenHasScope] + required_scopes = [] + + def get_queryset(self): + try: + guest = self.request.auth.federatedguest_set.get() + except FederatedGuest.DoesNotExist as exc: + raise PermissionDenied from exc + return Event.objects.filter(federatedeventshare__shared_with=guest) diff --git a/ephios/plugins/federation/views.py b/ephios/plugins/federation/views/frontend.py similarity index 71% rename from ephios/plugins/federation/views.py rename to ephios/plugins/federation/views/frontend.py index 8425ff153..f4b1750ae 100644 --- a/ephios/plugins/federation/views.py +++ b/ephios/plugins/federation/views/frontend.py @@ -1,29 +1,23 @@ -import base64 -import binascii -import json -import secrets from datetime import datetime from urllib.parse import urljoin import requests from django.conf import settings +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 +from django.urls import reverse, reverse_lazy from django.views import View -from django.views.generic import DetailView, TemplateView +from django.views.generic import CreateView, DetailView, FormView, TemplateView from guardian.mixins import LoginRequiredMixin -from oauth2_provider.contrib.rest_framework import TokenHasScope from oauthlib.oauth2 import WebApplicationClient -from requests import HTTPError, JSONDecodeError +from requests import HTTPError, JSONDecodeError, ReadTimeout from requests_oauthlib import OAuth2Session -from rest_framework import serializers from rest_framework.exceptions import PermissionDenied -from rest_framework.generics import CreateAPIView, ListAPIView -from ephios.api.models import AccessToken -from ephios.api.views.events import EventSerializer from ephios.core.models import Event, Qualification from ephios.core.views.signup import BaseShiftActionView +from ephios.plugins.federation.forms import InviteCodeForm, RedeemInviteCodeForm from ephios.plugins.federation.models import ( FederatedEventShare, FederatedGuest, @@ -33,67 +27,6 @@ ) -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}) - ) - - -class FederatedGuestCreateSerializer(serializers.ModelSerializer): - code = serializers.CharField(write_only=True) - access_token = serializers.SlugRelatedField(slug_field="token", read_only=True) - - class Meta: - model = FederatedGuest - fields = ["id", "name", "url", "client_id", "client_secret", "code", "access_token"] - - def validate_code(self, value): - try: - data = json.loads(base64.b64decode(value.encode("ascii")).decode("ascii")) - return InviteCode.objects.get(code=data["code"], url=data["url"]) - except (binascii.Error, JSONDecodeError, KeyError, 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 SharedEventListView(ListAPIView): - serializer_class = SharedEventSerializer - permission_classes = [TokenHasScope] - required_scopes = ["PUBLIC_READ"] - - def get_queryset(self): - try: - guest = self.request.auth.federatedguest_set.get() - except FederatedGuest.DoesNotExist as exc: - raise PermissionDenied from exc - return Event.objects.filter(federatedeventshare__shared_with=guest) - - class IncomingSharedEventListView(LoginRequiredMixin, TemplateView): template_name = "federation/incoming_shared_events.html" @@ -115,7 +48,7 @@ def get_context_data(self, **kwargs): event["end_time"] = datetime.fromisoformat(event["end_time"]) event["host"] = host.name events += results - except (HTTPError, JSONDecodeError): + except (HTTPError, JSONDecodeError, ReadTimeout): continue events.sort(key=lambda e: e["start_time"]) context["events"] = events @@ -208,7 +141,7 @@ def dispatch(self, request, *args, **kwargs): return FederationOAuthView.as_view()(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs) - def get_context_data(self, obj): + def get_context_data(self, object): context = super().get_context_data() context["guest_url"] = FederatedGuest.objects.get(pk=self.request.session["guest"]).url return context @@ -241,6 +174,30 @@ def get_participant(self): raise PermissionDenied from e -class RedeemFederationInviteCodeView(CreateAPIView): - serializer_class = FederatedGuestCreateSerializer - queryset = FederatedGuest.objects.all() +class FederationSettingsView(TemplateView): + template_name = "federation/federation_settings.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["guests"] = FederatedGuest.objects.all() + context["hosts"] = FederatedHost.objects.all() + context["invites"] = InviteCode.objects.all() + return context + + +class CreateInviteCodeView(SuccessMessageMixin, CreateView): + model = InviteCode + form_class = InviteCodeForm + success_url = reverse_lazy("federation:settings") + + def get_success_message(self, cleaned_data): + return f"Invite code for {cleaned_data['url']} created." + + +class RedeemInviteCodeView(FormView): + form_class = RedeemInviteCodeForm + template_name = "federation/redeem_invite_code.html" + + def form_valid(self, form): + messages.success(self.request, "Incoming share setup was successful.") + return redirect(reverse("federation:settings")) From 79a0d00bc88ba52e74f37130ce5ed45ea8c35e89 Mon Sep 17 00:00:00 2001 From: Julian B Date: Wed, 14 Jun 2023 23:49:08 +0200 Subject: [PATCH 15/42] fix event create --- ephios/plugins/federation/forms.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ephios/plugins/federation/forms.py b/ephios/plugins/federation/forms.py index aaa3f8174..4cd16ae90 100644 --- a/ephios/plugins/federation/forms.py +++ b/ephios/plugins/federation/forms.py @@ -12,7 +12,7 @@ from django.utils.translation import gettext as _ from django_select2.forms import Select2MultipleWidget from dynamic_preferences.registries import global_preferences_registry -from requests import HTTPError +from requests import HTTPError, ReadTimeout from ephios.api.models import Application from ephios.core.forms.events import BasePluginFormMixin @@ -30,7 +30,7 @@ class EventAllowFederationForm(BasePluginFormMixin, forms.Form): ) def __init__(self, *args, **kwargs): - kwargs.setdefault("prefix", "guests") + kwargs.setdefault("prefix", "federation") self.event = kwargs.pop("event") self.request = kwargs.pop("request") super().__init__(*args, **kwargs) @@ -38,7 +38,9 @@ def __init__(self, *args, **kwargs): self.instance = FederatedEventShare.objects.get(event_id=self.event.id) except (AttributeError, FederatedEventShare.DoesNotExist): self.instance = FederatedEventShare(event=self.event) - self.fields["shared_with"].initial = self.instance.shared_with.all() + self.fields["shared_with"].initial = ( + self.instance.shared_with.all() if self.instance.pk else [] + ) def save(self): self.instance.shared_with.set(self.cleaned_data["shared_with"]) @@ -94,5 +96,5 @@ def clean_code(self): access_token=response_data["access_token"], oauth_application=oauth_application, ) - except (binascii.Error, JSONDecodeError, KeyError, HTTPError) as exc: + except (binascii.Error, JSONDecodeError, KeyError, HTTPError, ReadTimeout) as exc: raise ValidationError(_("Invalid code")) from exc From 2e8619329ce9464d0a306d9867729cba476700d8 Mon Sep 17 00:00:00 2001 From: Julian B Date: Wed, 14 Jun 2023 23:58:57 +0200 Subject: [PATCH 16/42] fix event create --- ephios/plugins/federation/forms.py | 6 +++++- ephios/plugins/federation/models.py | 4 ++-- ephios/plugins/federation/serializer.py | 6 +++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/ephios/plugins/federation/forms.py b/ephios/plugins/federation/forms.py index 4cd16ae90..90ee985d1 100644 --- a/ephios/plugins/federation/forms.py +++ b/ephios/plugins/federation/forms.py @@ -43,7 +43,10 @@ def __init__(self, *args, **kwargs): ) def save(self): - self.instance.shared_with.set(self.cleaned_data["shared_with"]) + if self.cleaned_data["shared_with"] and not self.instance.pk: + self.instance.save() + if self.instance.pk: + self.instance.shared_with.set(self.cleaned_data["shared_with"]) @property def heading(self): @@ -85,6 +88,7 @@ def clean_code(self): "client_secret": oauth_application.client_secret, "code": data["code"], }, + timeout=10, ) response.raise_for_status() response_data = response.json() diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index 3fa7f95fe..53cf769d9 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -22,7 +22,7 @@ class FederatedGuest(models.Model): client_secret = models.CharField(max_length=255) def __str__(self): - return self.name + return str(self.name) class FederatedHost(models.Model): @@ -32,7 +32,7 @@ class FederatedHost(models.Model): oauth_application = models.ForeignKey(Application, on_delete=models.CASCADE) def __str__(self): - return self.name + return str(self.name) class InviteCode(models.Model): diff --git a/ephios/plugins/federation/serializer.py b/ephios/plugins/federation/serializer.py index 2041a751a..3511a5078 100644 --- a/ephios/plugins/federation/serializer.py +++ b/ephios/plugins/federation/serializer.py @@ -33,10 +33,10 @@ class Meta: def get_host_name(self, obj): return global_preferences_registry.manager()["general__organization_name"] - def validate(self, data): + def validate(self, attrs): try: - data["code"] = InviteCode.objects.get(code=data["code"], url=data["url"]) - return data + attrs["code"] = InviteCode.objects.get(code=attrs["code"], url=attrs["url"]) + return attrs except InviteCode.DoesNotExist as exc: raise serializers.ValidationError("Invite code is not valid") from exc From 3a07f188c4474c88d78a4cfa7c8882b32d0c81f9 Mon Sep 17 00:00:00 2001 From: Julian B Date: Thu, 15 Jun 2023 00:01:06 +0200 Subject: [PATCH 17/42] fix event create --- ephios/plugins/federation/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ephios/plugins/federation/forms.py b/ephios/plugins/federation/forms.py index 90ee985d1..819a37e97 100644 --- a/ephios/plugins/federation/forms.py +++ b/ephios/plugins/federation/forms.py @@ -53,7 +53,7 @@ def heading(self): return _("Federation") def is_function_active(self): - return self.instance.shared_with.exists() + return self.instance.pk and self.instance.shared_with.exists() class InviteCodeForm(forms.ModelForm): From fb1861d6c3c6342e38ec0e897322c8ec58bdb19f Mon Sep 17 00:00:00 2001 From: Julian B Date: Sun, 18 Jun 2023 19:29:38 +0200 Subject: [PATCH 18/42] fix naming --- ephios/plugins/federation/forms.py | 6 +- ephios/plugins/federation/signals.py | 2 +- ...d_events.html => external_event_list.html} | 4 +- .../federation/federation_settings.html | 8 +- .../templates/federation/invitecode_form.html | 5 +- .../federation/redeem_invite_code.html | 5 +- ephios/plugins/federation/urls.py | 14 +-- ephios/plugins/federation/views/api.py | 100 +++++++++++++++++- ephios/plugins/federation/views/frontend.py | 95 ++--------------- 9 files changed, 126 insertions(+), 113 deletions(-) rename ephios/plugins/federation/templates/federation/{incoming_shared_events.html => external_event_list.html} (97%) diff --git a/ephios/plugins/federation/forms.py b/ephios/plugins/federation/forms.py index 819a37e97..4790021e3 100644 --- a/ephios/plugins/federation/forms.py +++ b/ephios/plugins/federation/forms.py @@ -8,9 +8,9 @@ from django import forms from django.conf import settings from django.core.exceptions import ValidationError +from django.forms import CheckboxSelectMultiple from django.urls import reverse from django.utils.translation import gettext as _ -from django_select2.forms import Select2MultipleWidget from dynamic_preferences.registries import global_preferences_registry from requests import HTTPError, ReadTimeout @@ -26,7 +26,7 @@ class EventAllowFederationForm(BasePluginFormMixin, forms.Form): shared_with = forms.ModelMultipleChoiceField( - queryset=FederatedGuest.objects.all(), required=False, widget=Select2MultipleWidget + queryset=FederatedGuest.objects.all(), required=False, widget=CheckboxSelectMultiple ) def __init__(self, *args, **kwargs): @@ -50,7 +50,7 @@ def save(self): @property def heading(self): - return _("Federation") + return _("Share event with other ephios instances") def is_function_active(self): return self.instance.pk and self.instance.shared_with.exists() diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py index 4a1a9e148..13410c8b1 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -17,7 +17,7 @@ def add_nav_link(sender, request, **kwargs): return [ { "label": _("External events"), - "url": reverse_lazy("federation:incoming_shared_event_list_view"), + "url": reverse_lazy("federation:external_event_list_view"), "active": request.resolver_match and request.resolver_match.app_name == "federation", } ] diff --git a/ephios/plugins/federation/templates/federation/incoming_shared_events.html b/ephios/plugins/federation/templates/federation/external_event_list.html similarity index 97% rename from ephios/plugins/federation/templates/federation/incoming_shared_events.html rename to ephios/plugins/federation/templates/federation/external_event_list.html index 04e8c9e65..51d863527 100644 --- a/ephios/plugins/federation/templates/federation/incoming_shared_events.html +++ b/ephios/plugins/federation/templates/federation/external_event_list.html @@ -2,11 +2,11 @@ {% load i18n %} {% block title %} - {% translate "Shared events" %} + {% translate "External events" %} {% endblock %} {% block content %} -

{% translate "Shared events" %}

+

{% translate "External events" %}

{% for event in events %}
diff --git a/ephios/plugins/federation/templates/federation/federation_settings.html b/ephios/plugins/federation/templates/federation/federation_settings.html index c9dc68bb9..3f96872a2 100644 --- a/ephios/plugins/federation/templates/federation/federation_settings.html +++ b/ephios/plugins/federation/templates/federation/federation_settings.html @@ -2,8 +2,8 @@ {% load i18n %} {% block settings_content %} -

Outgoing shares

- Add outgoing share +

{% translate "ephios instances that you share events with" %}

+ Share events with someone else @@ -49,8 +49,8 @@

{% translate "Pending invites" %}

{% endif %} -

Incoming shares

- Add incoming share +

{% translate "ephios instances that share events with you" %}

+ {% translate "Enter invite code" %} diff --git a/ephios/plugins/federation/templates/federation/invitecode_form.html b/ephios/plugins/federation/templates/federation/invitecode_form.html index 2ce0523e9..db1de38b1 100644 --- a/ephios/plugins/federation/templates/federation/invitecode_form.html +++ b/ephios/plugins/federation/templates/federation/invitecode_form.html @@ -1,11 +1,12 @@ {% extends "core/settings/settings_base.html" %} +{% load i18n %} {% load crispy_forms_filters %} {% block settings_content %} -

Add outgoing share

+

{% translate "Share events with someone else" %}

{% csrf_token %} {{ form|crispy }} - + {% endblock %} diff --git a/ephios/plugins/federation/templates/federation/redeem_invite_code.html b/ephios/plugins/federation/templates/federation/redeem_invite_code.html index 3376d5d6d..497564554 100644 --- a/ephios/plugins/federation/templates/federation/redeem_invite_code.html +++ b/ephios/plugins/federation/templates/federation/redeem_invite_code.html @@ -1,11 +1,12 @@ {% extends "core/settings/settings_base.html" %} +{% load i18n %} {% load crispy_forms_filters %} {% block settings_content %} -

Add incoming share

+

{% translate "Enter invite code" %}

{% csrf_token %} {{ form|crispy }} - + {% endblock %} diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py index d538c89dc..4826ff069 100644 --- a/ephios/plugins/federation/urls.py +++ b/ephios/plugins/federation/urls.py @@ -5,9 +5,9 @@ app_name = "federation" urlpatterns = [ path( - "events/shared/", - frontend.IncomingSharedEventListView.as_view(), - name="incoming_shared_event_list_view", + "events/external/", + frontend.ExternalEventListView.as_view(), + name="external_event_list_view", ), path( "events/shared//", frontend.FederatedEventDetailView.as_view(), name="event_detail" @@ -31,16 +31,16 @@ path( "api/federation/events/", api.SharedEventListView.as_view(), - name="outgoing_shared_event_list_view", + name="shared_event_list_view", ), path( "api/federation/oauth-callback/", - frontend.FederationOAuthView.as_view(), - name="federation_oauth_callback", + api.FederationOAuthView.as_view(), + name="oauth_callback", ), path( "api/federation/setup/", - api.RedeemFederationInviteCodeView.as_view(), + api.RedeemInviteCodeView.as_view(), name="redeem_invite_code", ), ] diff --git a/ephios/plugins/federation/views/api.py b/ephios/plugins/federation/views/api.py index 1b0ceb292..e1e8ed651 100644 --- a/ephios/plugins/federation/views/api.py +++ b/ephios/plugins/federation/views/api.py @@ -1,16 +1,25 @@ +from urllib.parse import urljoin + +import requests +from django.conf import settings +from django.shortcuts import get_object_or_404, 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, ListAPIView -from ephios.core.models import Event -from ephios.plugins.federation.models import FederatedGuest +from ephios.core.models import Event, Qualification +from ephios.plugins.federation.models import FederatedGuest, FederatedUser from ephios.plugins.federation.serializer import ( FederatedGuestCreateSerializer, SharedEventSerializer, ) -class RedeemFederationInviteCodeView(CreateAPIView): +class RedeemInviteCodeView(CreateAPIView): serializer_class = FederatedGuestCreateSerializer queryset = FederatedGuest.objects.all() authentication_classes = [] @@ -28,3 +37,88 @@ def get_queryset(self): except FederatedGuest.DoesNotExist as exc: raise PermissionDenied from exc return Event.objects.filter(federatedeventshare__shared_with=guest) + + +class FederationOAuthView(View): + def get(self, request, *args, **kwargs): + if "code" not in request.GET.keys(): + try: + return redirect(self._get_authorization_url()) + except (KeyError, FederatedGuest.DoesNotExist) as exc: + raise PermissionDenied from exc + else: + self._oauth_callback() + return redirect("federation:event_detail", pk=self.request.session.pop("event")) + + def _get_authorization_url(self): + guest = ( + FederatedGuest.objects.get(pk=self.request.session["guest"]) + if "guest" in self.request.session.keys() + else FederatedGuest.objects.get(url=self.request.GET["referrer"]) + ) + oauth_client = WebApplicationClient(client_id=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["event"] = self.kwargs["pk"] + self.request.session["guest"] = guest.pk + challenge = oauth_client.create_code_challenge(verifier, "S256") + authorization_url, _ = oauth.authorization_url( + urljoin(guest.url, "api/oauth/authorize/"), + code_challenge=challenge, + code_challenge_method="S256", + ) + return authorization_url + + def _oauth_callback(self): + guest = get_object_or_404(FederatedGuest, pk=self.request.session["guest"]) + oauth_client = WebApplicationClient( + client_id=guest.client_id, code_verifier=self.request.session["code_verifier"] + ) + oauth = OAuth2Session(client=oauth_client) + token = oauth.fetch_token( + urljoin(guest.url, "api/oauth/token/"), + authorization_response=self.request.get_full_path(), + client_secret=guest.client_secret, + code_verifier=self.request.session["code_verifier"], + ) + self.request.session["access_token"] = token["access_token"] + self.request.session.set_expiry(token["expires_in"]) + user_data = requests.get( + urljoin(guest.url, "api/users/me/"), + headers={"Authorization": f"Bearer {token['access_token']}"}, + timeout=5, + ) + try: + user = FederatedUser.objects.get( + federated_instance=guest, email=user_data.json()["email"] + ) + except FederatedUser.DoesNotExist: + user = FederatedUser.objects.create( + federated_instance=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 + self.request.session["federated_user"] = user.pk diff --git a/ephios/plugins/federation/views/frontend.py b/ephios/plugins/federation/views/frontend.py index f4b1750ae..e0c0f7bd4 100644 --- a/ephios/plugins/federation/views/frontend.py +++ b/ephios/plugins/federation/views/frontend.py @@ -2,20 +2,16 @@ from urllib.parse import urljoin import requests -from django.conf import settings from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import redirect from django.urls import reverse, reverse_lazy -from django.views import View from django.views.generic import CreateView, DetailView, FormView, TemplateView from guardian.mixins import LoginRequiredMixin -from oauthlib.oauth2 import WebApplicationClient from requests import HTTPError, JSONDecodeError, ReadTimeout -from requests_oauthlib import OAuth2Session from rest_framework.exceptions import PermissionDenied -from ephios.core.models import Event, Qualification +from ephios.core.models import Event from ephios.core.views.signup import BaseShiftActionView from ephios.plugins.federation.forms import InviteCodeForm, RedeemInviteCodeForm from ephios.plugins.federation.models import ( @@ -25,10 +21,11 @@ FederatedUser, InviteCode, ) +from ephios.plugins.federation.views.api import FederationOAuthView -class IncomingSharedEventListView(LoginRequiredMixin, TemplateView): - template_name = "federation/incoming_shared_events.html" +class ExternalEventListView(LoginRequiredMixin, TemplateView): + template_name = "federation/external_event_list.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -36,7 +33,7 @@ def get_context_data(self, **kwargs): for host in FederatedHost.objects.all(): try: r = requests.get( - urljoin(host.url, reverse("federation:outgoing_shared_event_list_view")), + urljoin(host.url, reverse("federation:shared_event_list_view")), headers={"Authorization": f"Bearer {host.access_token}"}, timeout=5, ) @@ -55,86 +52,6 @@ def get_context_data(self, **kwargs): return context -class FederationOAuthView(View): - def get(self, request, *args, **kwargs): - if "code" not in request.GET.keys(): - try: - return redirect(self._get_authorization_url()) - except (KeyError, FederatedGuest.DoesNotExist) as exc: - raise PermissionDenied from exc - else: - self._oauth_callback() - return redirect("federation:event_detail", pk=self.request.session.pop("event")) - - def _get_authorization_url(self): - guest = ( - FederatedGuest.objects.get(pk=self.request.session["guest"]) - if "guest" in self.request.session.keys() - else FederatedGuest.objects.get(url=self.request.GET["referrer"]) - ) - oauth_client = WebApplicationClient(client_id=guest.client_id) - oauth = OAuth2Session( - client=oauth_client, - redirect_uri=urljoin(settings.GET_SITE_URL(), "api/federation/oauth-callback/"), - scope=["ME_READ"], - ) - verifier = oauth_client.create_code_verifier(64) - self.request.session["code_verifier"] = verifier - self.request.session["event"] = self.kwargs["pk"] - self.request.session["guest"] = guest.pk - challenge = oauth_client.create_code_challenge(verifier, "S256") - authorization_url, _ = oauth.authorization_url( - urljoin(guest.url, "api/oauth/authorize/"), - code_challenge=challenge, - code_challenge_method="S256", - ) - return authorization_url - - def _oauth_callback(self): - guest = get_object_or_404(FederatedGuest, pk=self.request.session["guest"]) - oauth_client = WebApplicationClient( - client_id=guest.client_id, code_verifier=self.request.session["code_verifier"] - ) - oauth = OAuth2Session(client=oauth_client) - token = oauth.fetch_token( - urljoin(guest.url, "api/oauth/token/"), - authorization_response=self.request.get_full_path(), - client_secret=guest.client_secret, - code_verifier=self.request.session["code_verifier"], - ) - self.request.session["access_token"] = token["access_token"] - self.request.session.set_expiry(token["expires_in"]) - user_data = requests.get( - urljoin(guest.url, "api/users/me/"), - headers={"Authorization": f"Bearer {token['access_token']}"}, - timeout=5, - ) - try: - user = FederatedUser.objects.get( - federated_instance=guest, email=user_data.json()["email"] - ) - except FederatedUser.DoesNotExist: - user = FederatedUser.objects.create( - federated_instance=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: - 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 - self.request.session["federated_user"] = user.pk - - class CheckFederatedAccessTokenMixin: def dispatch(self, request, *args, **kwargs): if "access_token" not in request.session.keys(): From 68bb977966253f35fa330cbe1e6a3f28325225a7 Mon Sep 17 00:00:00 2001 From: Julian B Date: Mon, 19 Jun 2023 23:05:39 +0200 Subject: [PATCH 19/42] add delete views for host and guest, handle invite code expiration --- .../api/templates/api/access_token_list.html | 3 +++ .../oauth2_provider/application_list.html | 8 ++++++ ephios/plugins/federation/forms.py | 4 +-- ephios/plugins/federation/serializer.py | 4 +++ ephios/plugins/federation/signals.py | 2 +- .../federation/external_event_list.html | 6 +++++ .../federatedguest_confirm_delete.html | 16 +++++++++++ .../federatedhost_confirm_delete.html | 16 +++++++++++ .../federation/federation_settings.html | 10 +++---- .../templates/federation/invitecode_form.html | 2 +- ephios/plugins/federation/urls.py | 10 +++++++ ephios/plugins/federation/views/api.py | 3 ++- ephios/plugins/federation/views/frontend.py | 27 ++++++++++++++++++- 13 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 ephios/plugins/federation/templates/federation/federatedguest_confirm_delete.html create mode 100644 ephios/plugins/federation/templates/federation/federatedhost_confirm_delete.html diff --git a/ephios/api/templates/api/access_token_list.html b/ephios/api/templates/api/access_token_list.html index 2c3833d39..1bc40d392 100644 --- a/ephios/api/templates/api/access_token_list.html +++ b/ephios/api/templates/api/access_token_list.html @@ -25,6 +25,9 @@
{{ token.application.name }}

+ {% if token.application.federatedhost_set.exists %} + {% translate "ephios instance" %} + {% endif %}

diff --git a/ephios/api/templates/oauth2_provider/application_list.html b/ephios/api/templates/oauth2_provider/application_list.html index c9f7caacb..01ec9b674 100644 --- a/ephios/api/templates/oauth2_provider/application_list.html +++ b/ephios/api/templates/oauth2_provider/application_list.html @@ -33,6 +33,13 @@

{% translate "OAuth2 applications" %}

diff --git a/ephios/plugins/federation/forms.py b/ephios/plugins/federation/forms.py index 4790021e3..fd7c604c2 100644 --- a/ephios/plugins/federation/forms.py +++ b/ephios/plugins/federation/forms.py @@ -75,9 +75,7 @@ def clean_code(self): oauth_application = Application( client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, - redirect_uris=urljoin( - data["host_url"], reverse("federation:federation_oauth_callback") - ), + redirect_uris=urljoin(data["host_url"], reverse("federation:oauth_callback")), ) response = requests.post( urljoin(data["host_url"], reverse("federation:redeem_invite_code")), diff --git a/ephios/plugins/federation/serializer.py b/ephios/plugins/federation/serializer.py index 3511a5078..c1921ee52 100644 --- a/ephios/plugins/federation/serializer.py +++ b/ephios/plugins/federation/serializer.py @@ -1,8 +1,10 @@ +import datetime import secrets from urllib.parse import urljoin from django.conf import settings from django.urls import reverse +from django.utils import timezone from dynamic_preferences.registries import global_preferences_registry from rest_framework import serializers @@ -36,6 +38,8 @@ def get_host_name(self, obj): def validate(self, attrs): try: attrs["code"] = InviteCode.objects.get(code=attrs["code"], url=attrs["url"]) + if timezone.now() - attrs["code"].created_at > datetime.timedelta(hours=48): + raise serializers.ValidationError("Invite code is expired") return attrs except InviteCode.DoesNotExist as exc: raise serializers.ValidationError("Invite code is not valid") from exc diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py index 13410c8b1..5eaeee0e8 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -53,7 +53,7 @@ def federation_settings_section(sender, request, **kwargs): { "label": _("Federation"), "url": reverse("federation:settings"), - "active": request.resolver_match.url_name.startswith("federaion"), + "active": request.resolver_match.app_name == "federation", }, ] if request.user.is_staff diff --git a/ephios/plugins/federation/templates/federation/external_event_list.html b/ephios/plugins/federation/templates/federation/external_event_list.html index 51d863527..67304b6c1 100644 --- a/ephios/plugins/federation/templates/federation/external_event_list.html +++ b/ephios/plugins/federation/templates/federation/external_event_list.html @@ -55,5 +55,11 @@
+ {% empty %} +
+
+ {% translate "No results." %} +
+
{% endfor %} {% 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" %}

+ + {% csrf_token %} +

{% blocktranslate trimmed with name=federatedguest.name %} + Are you sure you want to remove the connection to "{{ name }}" and stop sharing all events with them? This will also delete all participations that originated from there. + {% endblocktranslate %}

+ {{ form|crispy }} + {% translate "Back" %} + + +{% 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" %}

+ + {% csrf_token %} +

{% blocktranslate trimmed with name=federatedhost.name %} + Are you sure you want to remove the connection to "{{ name }}" and stop receiving events from them? + {% endblocktranslate %}

+ {{ form|crispy }} + {% translate "Back" %} + + +{% endblock %} diff --git a/ephios/plugins/federation/templates/federation/federation_settings.html b/ephios/plugins/federation/templates/federation/federation_settings.html index 3f96872a2..6dcbe858e 100644 --- a/ephios/plugins/federation/templates/federation/federation_settings.html +++ b/ephios/plugins/federation/templates/federation/federation_settings.html @@ -3,7 +3,7 @@ {% block settings_content %}

{% translate "ephios instances that you share events with" %}

- Share events with someone else + Share events with another instance
{{ application.created }}
@@ -17,9 +17,7 @@

{% translate "ephios instances that you share events with" %}

- + {% endfor %} @@ -64,9 +62,7 @@

{% translate "ephios instances that share events with you" %}

- + {% endfor %} diff --git a/ephios/plugins/federation/templates/federation/invitecode_form.html b/ephios/plugins/federation/templates/federation/invitecode_form.html index db1de38b1..330a39f5a 100644 --- a/ephios/plugins/federation/templates/federation/invitecode_form.html +++ b/ephios/plugins/federation/templates/federation/invitecode_form.html @@ -3,7 +3,7 @@ {% load crispy_forms_filters %} {% block settings_content %} -

{% translate "Share events with someone else" %}

+

{% translate "Share events with another instance" %}

{% csrf_token %} {{ form|crispy }} diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py index 4826ff069..27092a44b 100644 --- a/ephios/plugins/federation/urls.py +++ b/ephios/plugins/federation/urls.py @@ -23,11 +23,21 @@ 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( "api/federation/events/", api.SharedEventListView.as_view(), diff --git a/ephios/plugins/federation/views/api.py b/ephios/plugins/federation/views/api.py index e1e8ed651..f55261983 100644 --- a/ephios/plugins/federation/views/api.py +++ b/ephios/plugins/federation/views/api.py @@ -10,6 +10,7 @@ from requests_oauthlib import OAuth2Session from rest_framework.exceptions import PermissionDenied from rest_framework.generics import CreateAPIView, ListAPIView +from rest_framework.permissions import AllowAny from ephios.core.models import Event, Qualification from ephios.plugins.federation.models import FederatedGuest, FederatedUser @@ -23,7 +24,7 @@ class RedeemInviteCodeView(CreateAPIView): serializer_class = FederatedGuestCreateSerializer queryset = FederatedGuest.objects.all() authentication_classes = [] - permission_classes = [] + permission_classes = [AllowAny] class SharedEventListView(ListAPIView): diff --git a/ephios/plugins/federation/views/frontend.py b/ephios/plugins/federation/views/frontend.py index e0c0f7bd4..61229eb82 100644 --- a/ephios/plugins/federation/views/frontend.py +++ b/ephios/plugins/federation/views/frontend.py @@ -6,7 +6,8 @@ from django.contrib.messages.views import SuccessMessageMixin from django.shortcuts import redirect from django.urls import reverse, reverse_lazy -from django.views.generic import CreateView, DetailView, FormView, TemplateView +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 @@ -118,3 +119,27 @@ class RedeemInviteCodeView(FormView): def form_valid(self, form): messages.success(self.request, "Incoming share setup was successful.") return redirect(reverse("federation:settings")) + + +class FederatedGuestDeleteView(SuccessMessageMixin, DeleteView): + 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(SuccessMessageMixin, DeleteView): + 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 + response = super().form_valid(form) + oauth_app.delete() + return response From 9ded743769260a9b7c7faa2ac6a031806c1253cc Mon Sep 17 00:00:00 2001 From: Julian B Date: Thu, 20 Jul 2023 23:42:47 +0200 Subject: [PATCH 20/42] add InviteCodeRevealView, add explanations and experimental labels --- ephios/extra/templatetags/utils.py | 12 +++++ ephios/plugins/federation/models.py | 6 +++ ephios/plugins/federation/serializer.py | 4 +- ephios/plugins/federation/signals.py | 2 +- .../federation/federation_settings.html | 9 ++-- .../templates/federation/invitecode_form.html | 9 ++++ .../federation/invitecode_reveal.html | 50 +++++++++++++++++++ .../federation/redeem_invite_code.html | 10 ++++ ephios/plugins/federation/urls.py | 5 ++ ephios/plugins/federation/views/frontend.py | 34 +++++++++++-- 10 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 ephios/plugins/federation/templates/federation/invitecode_reveal.html diff --git a/ephios/extra/templatetags/utils.py b/ephios/extra/templatetags/utils.py index 6461b73f4..f1f73bc91 100644 --- a/ephios/extra/templatetags/utils.py +++ b/ephios/extra/templatetags/utils.py @@ -1,7 +1,9 @@ import datetime +from urllib.parse import urljoin from django import template from django.template.defaultfilters import floatformat +from django.urls import reverse register = template.Library() @@ -55,3 +57,13 @@ def timedelta_in_hours(delta): if isinstance(delta, datetime.timedelta) else delta ) + + +@register.filter(name="urljoin") +def _urljoin(url, path): + return urljoin(url, path) + + +@register.filter(name="reverse_url") +def reverse_url(absolute_url, urlname): + return urljoin(absolute_url, reverse(urlname)) diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index 53cf769d9..6c23d9612 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -1,11 +1,13 @@ import base64 import dataclasses +import datetime import json from secrets import token_hex from django.conf import settings from django.db import models from django.urls import reverse +from django.utils import timezone from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -43,6 +45,10 @@ class InviteCode(models.Model): def __str__(self): return f"Federation invite code for {self.url}" + @property + def is_expired(self): + return timezone.now() - self.created_at > datetime.timedelta(hours=48) + def get_share_string(self): return base64.b64encode( json.dumps( diff --git a/ephios/plugins/federation/serializer.py b/ephios/plugins/federation/serializer.py index c1921ee52..482acfb3b 100644 --- a/ephios/plugins/federation/serializer.py +++ b/ephios/plugins/federation/serializer.py @@ -1,10 +1,8 @@ -import datetime import secrets from urllib.parse import urljoin from django.conf import settings from django.urls import reverse -from django.utils import timezone from dynamic_preferences.registries import global_preferences_registry from rest_framework import serializers @@ -38,7 +36,7 @@ def get_host_name(self, obj): def validate(self, attrs): try: attrs["code"] = InviteCode.objects.get(code=attrs["code"], url=attrs["url"]) - if timezone.now() - attrs["code"].created_at > datetime.timedelta(hours=48): + if attrs["code"].is_expired: raise serializers.ValidationError("Invite code is expired") return attrs except InviteCode.DoesNotExist as exc: diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py index 5eaeee0e8..5c1bf4dde 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -51,7 +51,7 @@ def federation_settings_section(sender, request, **kwargs): return ( [ { - "label": _("Federation"), + "label": _("Federation") + " (beta)", "url": reverse("federation:settings"), "active": request.resolver_match.app_name == "federation", }, diff --git a/ephios/plugins/federation/templates/federation/federation_settings.html b/ephios/plugins/federation/templates/federation/federation_settings.html index 6dcbe858e..d43b0d11d 100644 --- a/ephios/plugins/federation/templates/federation/federation_settings.html +++ b/ephios/plugins/federation/templates/federation/federation_settings.html @@ -2,6 +2,9 @@ {% load i18n %} {% block settings_content %} +

{% translate "ephios instances that you share events with" %}

Share events with another instance
{{ guest.name }} {{ guest.url }} - - {% translate "Stop sharing" %}
{{ host.name }} {{ host.url }} - - {% translate "Stop receiving" %}
@@ -29,7 +32,6 @@

{% translate "Pending invites" %}

- @@ -37,10 +39,7 @@

{% translate "Pending invites" %}

{% for invite in invites %} - - + {% endfor %} diff --git a/ephios/plugins/federation/templates/federation/invitecode_form.html b/ephios/plugins/federation/templates/federation/invitecode_form.html index 330a39f5a..667152e51 100644 --- a/ephios/plugins/federation/templates/federation/invitecode_form.html +++ b/ephios/plugins/federation/templates/federation/invitecode_form.html @@ -4,6 +4,15 @@ {% block settings_content %}

{% translate "Share events with another instance" %}

+ + {% csrf_token %} {{ form|crispy }} 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..2b3cbcf19 --- /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" %} +

+ + +
+ +
+ + +
+
+
+ +
+ + +
+
+ + {% 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 index 497564554..9ef96378e 100644 --- a/ephios/plugins/federation/templates/federation/redeem_invite_code.html +++ b/ephios/plugins/federation/templates/federation/redeem_invite_code.html @@ -4,6 +4,16 @@ {% block settings_content %}

{% translate "Enter invite code" %}

+ + {% csrf_token %} {{ form|crispy }} diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py index 27092a44b..3a82e5863 100644 --- a/ephios/plugins/federation/urls.py +++ b/ephios/plugins/federation/urls.py @@ -38,6 +38,11 @@ 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(), diff --git a/ephios/plugins/federation/views/frontend.py b/ephios/plugins/federation/views/frontend.py index 61229eb82..1c1afa950 100644 --- a/ephios/plugins/federation/views/frontend.py +++ b/ephios/plugins/federation/views/frontend.py @@ -4,7 +4,7 @@ import requests from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin -from django.shortcuts import redirect +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 @@ -14,6 +14,7 @@ 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, @@ -103,21 +104,44 @@ def get_context_data(self, **kwargs): return context -class CreateInviteCodeView(SuccessMessageMixin, CreateView): +class CreateInviteCodeView(CreateView): model = InviteCode form_class = InviteCodeForm success_url = reverse_lazy("federation:settings") - def get_success_message(self, cleaned_data): - return f"Invite code for {cleaned_data['url']} created." + def get_success_url(self): + return reverse("federation:reveal_invite_code", kwargs={"pk": self.object.pk}) + + +class InviteCodeRevealView(StaffRequiredMixin, TemplateView): + 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(FormView): 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, "Incoming share setup was successful.") + messages.success( + self.request, + _( + "Invite code redeemded successfully. You are now receiving events from this instance." + ), + ) return redirect(reverse("federation:settings")) From 77fe893b056e80dd6bf545f84978b902612568f7 Mon Sep 17 00:00:00 2001 From: Julian B Date: Fri, 21 Jul 2023 00:00:54 +0200 Subject: [PATCH 21/42] remove expired invite codes --- ephios/plugins/federation/signals.py | 10 ++++++++++ .../templates/federation/invitecode_reveal.html | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py index 5c1bf4dde..86342fe04 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -7,6 +7,7 @@ management_settings_sections, nav_link, participant_from_request, + periodic_signal, ) from ephios.plugins.federation.forms import EventAllowFederationForm from ephios.plugins.federation.models import FederatedUser @@ -59,3 +60,12 @@ def federation_settings_section(sender, request, **kwargs): 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/invitecode_reveal.html b/ephios/plugins/federation/templates/federation/invitecode_reveal.html index 2b3cbcf19..b6729127e 100644 --- a/ephios/plugins/federation/templates/federation/invitecode_reveal.html +++ b/ephios/plugins/federation/templates/federation/invitecode_reveal.html @@ -13,7 +13,7 @@

From 2b442d1f642b276f5947257d2f375cd0b37945af Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Fri, 21 Jul 2023 13:10:43 +0200 Subject: [PATCH 22/42] fix authorize translation --- .../templates/oauth2_provider/authorize.html | 51 ++++++++++--------- ephios/plugins/federation/signals.py | 4 +- ephios/plugins/federation/urls.py | 1 + 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/ephios/api/templates/oauth2_provider/authorize.html b/ephios/api/templates/oauth2_provider/authorize.html index d1f0962b5..b3b757a48 100644 --- a/ephios/api/templates/oauth2_provider/authorize.html +++ b/ephios/api/templates/oauth2_provider/authorize.html @@ -3,34 +3,39 @@ {% block content %} {% if not error %} - -

{% trans "Authorize" %} {{ application.name }}?

- {% csrf_token %} + +

+ {% blocktranslate trimmed with app_name=application.name %} + Authorize {{ app_name }}? + {% endblocktranslate %} +

+ {% csrf_token %} - {# pass query params through the submit process for saving #} - {% for field in form %} - {% if field.is_hidden %} - {{ field }} - {% endif %} - {% endfor %} + {# pass query params through the submit process for saving #} + {% for field in form %} + {% if field.is_hidden %} + {{ field }} + {% endif %} + {% endfor %} -

{% trans "The application requires the following permissions:" %}

-
    - {% for scope in scopes_descriptions %} -
  • {{ scope }}
  • - {% endfor %} -
+

{% trans "The application requires the following permissions:" %}

+
    + {% for scope in scopes_descriptions %} +
  • {{ scope }}
  • + {% endfor %} +
- {{ form.errors }} - {{ form.non_field_errors }} + {{ form.errors }} + {{ form.non_field_errors }} -
-
- - -
+
+
+ +
- +
+ {% else %}

Error: {{ error.error }}

diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py index 86342fe04..e7395af99 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -1,5 +1,5 @@ from django.dispatch import receiver -from django.urls import reverse, reverse_lazy +from django.urls import reverse from django.utils.translation import gettext_lazy as _ from ephios.core.signals import ( @@ -18,7 +18,7 @@ def add_nav_link(sender, request, **kwargs): return [ { "label": _("External events"), - "url": reverse_lazy("federation:external_event_list_view"), + "url": reverse("federation:external_event_list_view"), "active": request.resolver_match and request.resolver_match.app_name == "federation", } ] diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py index 3a82e5863..753fe5ba8 100644 --- a/ephios/plugins/federation/urls.py +++ b/ephios/plugins/federation/urls.py @@ -3,6 +3,7 @@ from ephios.plugins.federation.views import api, frontend app_name = "federation" + urlpatterns = [ path( "events/external/", From 22a6baeea5fe829d043650dc8941a4b8978df4a2 Mon Sep 17 00:00:00 2001 From: Julian B Date: Wed, 2 Aug 2023 23:58:42 +0200 Subject: [PATCH 23/42] address some review comments --- ephios/api/views/users.py | 3 ++ ephios/core/templatetags/settings_extras.py | 7 +++ ephios/plugins/federation/models.py | 4 +- .../{serializer.py => serializers.py} | 0 ephios/plugins/federation/signals.py | 2 +- .../templates/federation/event_detail.html | 6 ++- .../templates/federation/invitecode_form.html | 2 +- ephios/plugins/federation/urls.py | 2 +- ephios/plugins/federation/views/api.py | 47 ++++++++++--------- ephios/plugins/federation/views/frontend.py | 14 +++--- 10 files changed, 51 insertions(+), 36 deletions(-) rename ephios/plugins/federation/{serializer.py => serializers.py} (100%) diff --git a/ephios/api/views/users.py b/ephios/api/views/users.py index b7c920ae2..3fa492eb2 100644 --- a/ephios/api/views/users.py +++ b/ephios/api/views/users.py @@ -2,6 +2,7 @@ from django.db.models import Q from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope +from rest_framework.exceptions import PermissionDenied from rest_framework.fields import SerializerMethodField from rest_framework.generics import RetrieveAPIView from rest_framework.relations import SlugRelatedField @@ -62,4 +63,6 @@ class UserProfileMeView(RetrieveAPIView): required_scopes = ["ME_READ"] def get_object(self): + if self.request.user is None: + raise PermissionDenied() return self.request.user diff --git a/ephios/core/templatetags/settings_extras.py b/ephios/core/templatetags/settings_extras.py index d2f84c06f..9f06b8540 100644 --- a/ephios/core/templatetags/settings_extras.py +++ b/ephios/core/templatetags/settings_extras.py @@ -14,3 +14,10 @@ def available_management_settings_sections(request): @register.simple_tag def oidc_client_enabled(): return settings.ENABLE_OIDC_CLIENT + + +@register.simple_tag +def organization_name(): + from dynamic_preferences.registries import global_preferences_registry + + return global_preferences_registry.manager()["general__organization_name"] diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index 6c23d9612..548bef83c 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -116,7 +116,9 @@ def reverse_event_detail(self, event): @property def icon(self): return mark_safe( - f'' + ''.format( + title=_("Federated user") + ) ) diff --git a/ephios/plugins/federation/serializer.py b/ephios/plugins/federation/serializers.py similarity index 100% rename from ephios/plugins/federation/serializer.py rename to ephios/plugins/federation/serializers.py diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py index e7395af99..597e60ebf 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -18,7 +18,7 @@ def add_nav_link(sender, request, **kwargs): return [ { "label": _("External events"), - "url": reverse("federation:external_event_list_view"), + "url": reverse("federation:external_event_list"), "active": request.resolver_match and request.resolver_match.app_name == "federation", } ] diff --git a/ephios/plugins/federation/templates/federation/event_detail.html b/ephios/plugins/federation/templates/federation/event_detail.html index 2e9361ea0..fe835f436 100644 --- a/ephios/plugins/federation/templates/federation/event_detail.html +++ b/ephios/plugins/federation/templates/federation/event_detail.html @@ -1,9 +1,11 @@ {% extends "core/event_detail.html" %} +{% load settings_extras %} {% load i18n %} {% block messages %} {% endblock %} diff --git a/ephios/plugins/federation/templates/federation/invitecode_form.html b/ephios/plugins/federation/templates/federation/invitecode_form.html index 667152e51..2bab223d3 100644 --- a/ephios/plugins/federation/templates/federation/invitecode_form.html +++ b/ephios/plugins/federation/templates/federation/invitecode_form.html @@ -9,7 +9,7 @@

{% translate "Share events with another instance" %}

diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py index 753fe5ba8..a327dc24e 100644 --- a/ephios/plugins/federation/urls.py +++ b/ephios/plugins/federation/urls.py @@ -8,7 +8,7 @@ path( "events/external/", frontend.ExternalEventListView.as_view(), - name="external_event_list_view", + name="external_event_list", ), path( "events/shared//", frontend.FederatedEventDetailView.as_view(), name="event_detail" diff --git a/ephios/plugins/federation/views/api.py b/ephios/plugins/federation/views/api.py index f55261983..080a6e144 100644 --- a/ephios/plugins/federation/views/api.py +++ b/ephios/plugins/federation/views/api.py @@ -2,7 +2,7 @@ import requests from django.conf import settings -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import redirect from django.urls import reverse from django.views import View from oauth2_provider.contrib.rest_framework import TokenHasScope @@ -14,7 +14,7 @@ from ephios.core.models import Event, Qualification from ephios.plugins.federation.models import FederatedGuest, FederatedUser -from ephios.plugins.federation.serializer import ( +from ephios.plugins.federation.serializers import ( FederatedGuestCreateSerializer, SharedEventSerializer, ) @@ -42,22 +42,24 @@ def get_queryset(self): class FederationOAuthView(View): def get(self, request, *args, **kwargs): - if "code" not in request.GET.keys(): - try: - return redirect(self._get_authorization_url()) - except (KeyError, FederatedGuest.DoesNotExist) as exc: - raise PermissionDenied from exc - else: + try: + self.guest = ( + FederatedGuest.objects.get(pk=self.request.session["guest"]) + if "guest" in self.request.session.keys() + else FederatedGuest.objects.get(url=self.request.GET["referrer"]) + ) + except (KeyError, FederatedGuest.DoesNotExist) 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("event")) + else: + return redirect(self._get_authorization_url()) def _get_authorization_url(self): - guest = ( - FederatedGuest.objects.get(pk=self.request.session["guest"]) - if "guest" in self.request.session.keys() - else FederatedGuest.objects.get(url=self.request.GET["referrer"]) - ) - oauth_client = WebApplicationClient(client_id=guest.client_id) + 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")), @@ -66,41 +68,40 @@ def _get_authorization_url(self): verifier = oauth_client.create_code_verifier(64) self.request.session["code_verifier"] = verifier self.request.session["event"] = self.kwargs["pk"] - self.request.session["guest"] = guest.pk + self.request.session["guest"] = self.guest.pk challenge = oauth_client.create_code_challenge(verifier, "S256") authorization_url, _ = oauth.authorization_url( - urljoin(guest.url, "api/oauth/authorize/"), + urljoin(self.guest.url, "api/oauth/authorize/"), code_challenge=challenge, code_challenge_method="S256", ) return authorization_url def _oauth_callback(self): - guest = get_object_or_404(FederatedGuest, pk=self.request.session["guest"]) oauth_client = WebApplicationClient( - client_id=guest.client_id, code_verifier=self.request.session["code_verifier"] + client_id=self.guest.client_id, code_verifier=self.request.session["code_verifier"] ) oauth = OAuth2Session(client=oauth_client) token = oauth.fetch_token( - urljoin(guest.url, "api/oauth/token/"), + urljoin(self.guest.url, "api/oauth/token/"), authorization_response=self.request.get_full_path(), - client_secret=guest.client_secret, + client_secret=self.guest.client_secret, code_verifier=self.request.session["code_verifier"], ) self.request.session["access_token"] = token["access_token"] self.request.session.set_expiry(token["expires_in"]) user_data = requests.get( - urljoin(guest.url, "api/users/me/"), + urljoin(self.guest.url, "api/users/me/"), headers={"Authorization": f"Bearer {token['access_token']}"}, timeout=5, ) try: user = FederatedUser.objects.get( - federated_instance=guest, email=user_data.json()["email"] + federated_instance=self.guest, email=user_data.json()["email"] ) except FederatedUser.DoesNotExist: user = FederatedUser.objects.create( - federated_instance=guest, + federated_instance=self.guest, email=user_data.json()["email"], first_name=user_data.json()["first_name"], last_name=user_data.json()["last_name"], diff --git a/ephios/plugins/federation/views/frontend.py b/ephios/plugins/federation/views/frontend.py index 1c1afa950..0e874ea2d 100644 --- a/ephios/plugins/federation/views/frontend.py +++ b/ephios/plugins/federation/views/frontend.py @@ -62,7 +62,7 @@ def dispatch(self, request, *args, **kwargs): def get_context_data(self, object): context = super().get_context_data() - context["guest_url"] = FederatedGuest.objects.get(pk=self.request.session["guest"]).url + context["guest"] = FederatedGuest.objects.get(pk=self.request.session["guest"]) return context @@ -83,7 +83,7 @@ def get_object(self, queryset=None): return obj -class FederatedUserShiftActionView(BaseShiftActionView): +class FederatedUserShiftActionView(CheckFederatedAccessTokenMixin, BaseShiftActionView): def get_participant(self): try: return FederatedUser.objects.get( @@ -93,7 +93,7 @@ def get_participant(self): raise PermissionDenied from e -class FederationSettingsView(TemplateView): +class FederationSettingsView(StaffRequiredMixin, TemplateView): template_name = "federation/federation_settings.html" def get_context_data(self, **kwargs): @@ -104,7 +104,7 @@ def get_context_data(self, **kwargs): return context -class CreateInviteCodeView(CreateView): +class CreateInviteCodeView(StaffRequiredMixin, CreateView): model = InviteCode form_class = InviteCodeForm success_url = reverse_lazy("federation:settings") @@ -125,7 +125,7 @@ def get(self, request, *args, **kwargs): return self.render_to_response(context) -class RedeemInviteCodeView(FormView): +class RedeemInviteCodeView(StaffRequiredMixin, FormView): form_class = RedeemInviteCodeForm template_name = "federation/redeem_invite_code.html" @@ -145,7 +145,7 @@ def form_valid(self, form): return redirect(reverse("federation:settings")) -class FederatedGuestDeleteView(SuccessMessageMixin, DeleteView): +class FederatedGuestDeleteView(StaffRequiredMixin, SuccessMessageMixin, DeleteView): model = FederatedGuest success_url = reverse_lazy("federation:settings") success_message = _("You are no longer sharing events with this instance.") @@ -157,7 +157,7 @@ def form_valid(self, form): return response -class FederatedHostDeleteView(SuccessMessageMixin, DeleteView): +class FederatedHostDeleteView(StaffRequiredMixin, SuccessMessageMixin, DeleteView): model = FederatedHost success_url = reverse_lazy("federation:settings") success_message = _("You are no longer receiving events from this instance.") From 04ae30e910f4dc5a39b1ea284afe1062a5c77cc3 Mon Sep 17 00:00:00 2001 From: Julian B Date: Fri, 4 Aug 2023 18:26:51 +0200 Subject: [PATCH 24/42] address more review comments --- .../api/templates/api/access_token_list.html | 3 +- .../oauth2_provider/application_list.html | 2 +- ephios/core/context.py | 2 + ephios/core/templatetags/settings_extras.py | 7 --- ephios/extra/templatetags/utils.py | 12 ----- ephios/plugins/federation/forms.py | 10 +++- ...er_federatedguest_access_token_and_more.py | 54 +++++++++++++++++++ ephios/plugins/federation/models.py | 12 ++--- .../templates/federation/event_detail.html | 3 +- .../federation/invitecode_reveal.html | 4 +- ephios/plugins/federation/views/api.py | 5 +- 11 files changed, 80 insertions(+), 34 deletions(-) create mode 100644 ephios/plugins/federation/migrations/0003_alter_federatedguest_access_token_and_more.py diff --git a/ephios/api/templates/api/access_token_list.html b/ephios/api/templates/api/access_token_list.html index 1bc40d392..28ee8cf0d 100644 --- a/ephios/api/templates/api/access_token_list.html +++ b/ephios/api/templates/api/access_token_list.html @@ -25,7 +25,8 @@
{{ token.application.name }}

- {% if token.application.federatedhost_set.exists %} + {# this belongs to the federation plugin, but we display the information here for better understanding #} + {% if token.application.federatedhost %} {% translate "ephios instance" %} {% endif %}

diff --git a/ephios/api/templates/oauth2_provider/application_list.html b/ephios/api/templates/oauth2_provider/application_list.html index 01ec9b674..47ef90f43 100644 --- a/ephios/api/templates/oauth2_provider/application_list.html +++ b/ephios/api/templates/oauth2_provider/application_list.html @@ -33,7 +33,7 @@

{% translate "OAuth2 applications" %}

- {% for guest in guests %} + {% for guest in federation_guests %} @@ -26,7 +26,7 @@

{% translate "ephios instances that you share events with" %}

{% translate "URL" %}{% translate "Invite code" %} {% translate "Action" %}
{{ invite.url }}{{ invite.get_share_string }} - - {% translate "Show invite code" %}
{{ application.created }}
- {% if application.federatedhost_set.exists %} + {% if application.federatedhost %} diff --git a/ephios/core/context.py b/ephios/core/context.py index 90a518ac0..8f0951653 100644 --- a/ephios/core/context.py +++ b/ephios/core/context.py @@ -1,5 +1,6 @@ from django.conf import settings from django.utils.translation import get_language +from dynamic_preferences.registries import global_preferences_registry from ephios.core.models import AbstractParticipation from ephios.core.signals import footer_link, nav_link @@ -23,4 +24,5 @@ def ephios_base_context(request): "ephios_version": settings.EPHIOS_VERSION, "SITE_URL": settings.GET_SITE_URL(), "PWA_APP_ICONS": settings.PWA_APP_ICONS, + "organization_name": global_preferences_registry.manager()["general__organization_name"], } diff --git a/ephios/core/templatetags/settings_extras.py b/ephios/core/templatetags/settings_extras.py index 9f06b8540..d2f84c06f 100644 --- a/ephios/core/templatetags/settings_extras.py +++ b/ephios/core/templatetags/settings_extras.py @@ -14,10 +14,3 @@ def available_management_settings_sections(request): @register.simple_tag def oidc_client_enabled(): return settings.ENABLE_OIDC_CLIENT - - -@register.simple_tag -def organization_name(): - from dynamic_preferences.registries import global_preferences_registry - - return global_preferences_registry.manager()["general__organization_name"] diff --git a/ephios/extra/templatetags/utils.py b/ephios/extra/templatetags/utils.py index f1f73bc91..6461b73f4 100644 --- a/ephios/extra/templatetags/utils.py +++ b/ephios/extra/templatetags/utils.py @@ -1,9 +1,7 @@ import datetime -from urllib.parse import urljoin from django import template from django.template.defaultfilters import floatformat -from django.urls import reverse register = template.Library() @@ -57,13 +55,3 @@ def timedelta_in_hours(delta): if isinstance(delta, datetime.timedelta) else delta ) - - -@register.filter(name="urljoin") -def _urljoin(url, path): - return urljoin(url, path) - - -@register.filter(name="reverse_url") -def reverse_url(absolute_url, urlname): - return urljoin(absolute_url, reverse(urlname)) diff --git a/ephios/plugins/federation/forms.py b/ephios/plugins/federation/forms.py index fd7c604c2..1b3db64ca 100644 --- a/ephios/plugins/federation/forms.py +++ b/ephios/plugins/federation/forms.py @@ -2,7 +2,7 @@ import binascii import json from json import JSONDecodeError -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse import requests from django import forms @@ -60,6 +60,14 @@ class InviteCodeForm(forms.ModelForm): class Meta: model = InviteCode fields = ["url"] + widgets = { + "url": forms.URLInput(attrs={"placeholder": _("https://other-instance.ephios.de/")}) + } + + def clean_url(self): + result = urlparse(self.cleaned_data["url"]) + cleaned_result = f"{result.scheme}://{result.netloc}{result.path.strip('/')}" + return cleaned_result class RedeemInviteCodeForm(forms.Form): diff --git a/ephios/plugins/federation/migrations/0003_alter_federatedguest_access_token_and_more.py b/ephios/plugins/federation/migrations/0003_alter_federatedguest_access_token_and_more.py new file mode 100644 index 000000000..61e525d2f --- /dev/null +++ b/ephios/plugins/federation/migrations/0003_alter_federatedguest_access_token_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 4.1.7 on 2023-08-04 16:02 + +import secrets + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), + ("federation", "0002_federatedeventshare"), + ] + + operations = [ + migrations.AlterField( + model_name="federatedguest", + name="access_token", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL, + ), + ), + migrations.AlterField( + model_name="federatedguest", + name="url", + field=models.URLField(verbose_name="URL"), + ), + migrations.AlterField( + model_name="federatedhost", + name="oauth_application", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, + ), + ), + migrations.AlterField( + model_name="federatedhost", + name="url", + field=models.URLField(verbose_name="URL"), + ), + migrations.AlterField( + model_name="invitecode", + name="code", + field=models.CharField(default=secrets.token_hex, max_length=255), + ), + migrations.AlterField( + model_name="invitecode", + name="url", + field=models.URLField(verbose_name="URL"), + ), + ] diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index 548bef83c..85fe54293 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -18,8 +18,8 @@ class FederatedGuest(models.Model): name = models.CharField(max_length=255) - url = models.URLField() - access_token = models.ForeignKey(AccessToken, on_delete=models.CASCADE) + url = models.URLField(verbose_name=_("URL")) + access_token = models.OneToOneField(AccessToken, on_delete=models.CASCADE) client_id = models.CharField(max_length=255) client_secret = models.CharField(max_length=255) @@ -29,9 +29,9 @@ def __str__(self): class FederatedHost(models.Model): name = models.CharField(max_length=255) - url = models.URLField() + url = models.URLField(verbose_name=_("URL")) access_token = models.CharField(max_length=255) - oauth_application = models.ForeignKey(Application, on_delete=models.CASCADE) + oauth_application = models.OneToOneField(Application, on_delete=models.CASCADE) def __str__(self): return str(self.name) @@ -39,7 +39,7 @@ def __str__(self): class InviteCode(models.Model): code = models.CharField(max_length=255, default=token_hex) - url = models.URLField() + url = models.URLField(verbose_name=_("URL")) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): @@ -128,7 +128,7 @@ class FederatedParticipation(AbstractParticipation): ) def __str__(self): - return f"Federated participation for {self.federated_user} in {self.shift}" + return f"{self.federated_user.first_name} {self.federated_user.last_name} @ {self.shift}" @property def participant(self) -> "AbstractParticipant": diff --git a/ephios/plugins/federation/templates/federation/event_detail.html b/ephios/plugins/federation/templates/federation/event_detail.html index fe835f436..c8c3d710a 100644 --- a/ephios/plugins/federation/templates/federation/event_detail.html +++ b/ephios/plugins/federation/templates/federation/event_detail.html @@ -4,8 +4,7 @@ {% block messages %} {% endblock %} diff --git a/ephios/plugins/federation/templates/federation/invitecode_reveal.html b/ephios/plugins/federation/templates/federation/invitecode_reveal.html index b6729127e..3c481ac30 100644 --- a/ephios/plugins/federation/templates/federation/invitecode_reveal.html +++ b/ephios/plugins/federation/templates/federation/invitecode_reveal.html @@ -19,9 +19,9 @@

+ {% url "federation:frontend_redeem_invite_code" as redeem_invite_code_url %} + value="{{ invite.url }}{{ redeem_invite_code_url }}?code={{ invite.get_share_string }}"> diff --git a/ephios/plugins/federation/views/api.py b/ephios/plugins/federation/views/api.py index 080a6e144..174122115 100644 --- a/ephios/plugins/federation/views/api.py +++ b/ephios/plugins/federation/views/api.py @@ -34,7 +34,8 @@ class SharedEventListView(ListAPIView): def get_queryset(self): try: - guest = self.request.auth.federatedguest_set.get() + # 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) @@ -84,7 +85,7 @@ def _oauth_callback(self): oauth = OAuth2Session(client=oauth_client) token = oauth.fetch_token( urljoin(self.guest.url, "api/oauth/token/"), - authorization_response=self.request.get_full_path(), + 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"], ) From 562dde948864b4bad3d68c5fe50177b5ba3bd6a4 Mon Sep 17 00:00:00 2001 From: Julian B Date: Mon, 7 Aug 2023 19:09:38 +0200 Subject: [PATCH 25/42] address more reivew comments --- .../templates/federation/event_detail.html | 2 +- .../federation/external_event_list.html | 14 ++-- .../federation/federation_settings.html | 8 +- ephios/plugins/federation/views/api.py | 81 ++++++++++++------- ephios/plugins/federation/views/frontend.py | 53 ++++++++++-- 5 files changed, 110 insertions(+), 48 deletions(-) diff --git a/ephios/plugins/federation/templates/federation/event_detail.html b/ephios/plugins/federation/templates/federation/event_detail.html index c8c3d710a..e3353cd59 100644 --- a/ephios/plugins/federation/templates/federation/event_detail.html +++ b/ephios/plugins/federation/templates/federation/event_detail.html @@ -5,6 +5,6 @@ {% 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 index 67304b6c1..db611313d 100644 --- a/ephios/plugins/federation/templates/federation/external_event_list.html +++ b/ephios/plugins/federation/templates/federation/external_event_list.html @@ -18,10 +18,15 @@

{% translate "External events" %}

href="{{ event.signup_url }}?referrer={{ SITE_URL }}">
-
- {{ event.title }} - -
+
+
+ {{ event.title }} +
+ + + {{ event.host }} + +
{{ event.location }} @@ -30,7 +35,6 @@
class="badge bg-primary">{{ event.type }}
{{ event.start_time|date:"D" }}, diff --git a/ephios/plugins/federation/templates/federation/federation_settings.html b/ephios/plugins/federation/templates/federation/federation_settings.html index d43b0d11d..fb5ddcd0a 100644 --- a/ephios/plugins/federation/templates/federation/federation_settings.html +++ b/ephios/plugins/federation/templates/federation/federation_settings.html @@ -16,7 +16,7 @@

{% translate "ephios instances that you share events with" %}

{{ guest.name }} {{ guest.url }}
- {% if invites %} + {% if federation_invites %}

{% translate "Pending invites" %}

@@ -36,7 +36,7 @@

{% translate "Pending invites" %}

- {% for invite in invites %} + {% for invite in federation_invites %} @@ -57,7 +57,7 @@

{% translate "ephios instances that share events with you" %}

- {% for host in hosts %} + {% for host in federation_hosts %} diff --git a/ephios/plugins/federation/views/api.py b/ephios/plugins/federation/views/api.py index 174122115..13269f941 100644 --- a/ephios/plugins/federation/views/api.py +++ b/ephios/plugins/federation/views/api.py @@ -2,6 +2,7 @@ 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 @@ -21,6 +22,10 @@ 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 = [] @@ -28,6 +33,10 @@ class RedeemInviteCodeView(CreateAPIView): 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 = [] @@ -42,20 +51,26 @@ def get_queryset(self): 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["guest"]) - if "guest" in self.request.session.keys() + 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) as exc: + 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("event")) + return redirect( + "federation:event_detail", pk=self.request.session.pop("federation_event") + ) else: return redirect(self._get_authorization_url()) @@ -68,8 +83,8 @@ def _get_authorization_url(self): ) verifier = oauth_client.create_code_verifier(64) self.request.session["code_verifier"] = verifier - self.request.session["event"] = self.kwargs["pk"] - self.request.session["guest"] = self.guest.pk + 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/"), @@ -89,7 +104,7 @@ def _oauth_callback(self): client_secret=self.guest.client_secret, code_verifier=self.request.session["code_verifier"], ) - self.request.session["access_token"] = token["access_token"] + 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/"), @@ -101,27 +116,31 @@ def _oauth_callback(self): federated_instance=self.guest, email=user_data.json()["email"] ) except FederatedUser.DoesNotExist: - 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 - self.request.session["federated_user"] = user.pk + 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 index 0e874ea2d..75c53c93e 100644 --- a/ephios/plugins/federation/views/frontend.py +++ b/ephios/plugins/federation/views/frontend.py @@ -27,6 +27,10 @@ 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): @@ -56,55 +60,74 @@ def get_context_data(self, **kwargs): class CheckFederatedAccessTokenMixin: def dispatch(self, request, *args, **kwargs): - if "access_token" not in request.session.keys(): + 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["guest"] = FederatedGuest.objects.get(pk=self.request.session["guest"]) + 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["guest"] + guest = self.request.session["federation_guest_pk"] FederatedEventShare.objects.get( event=obj, shared_with__in=[FederatedGuest.objects.get(pk=guest)], ) except (KeyError, FederatedEventShare.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["federated_user"] + 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["guests"] = FederatedGuest.objects.all() - context["hosts"] = FederatedHost.objects.all() - context["invites"] = InviteCode.objects.all() + 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 success_url = reverse_lazy("federation:settings") @@ -114,6 +137,10 @@ def get_success_url(self): 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): @@ -126,6 +153,10 @@ def get(self, request, *args, **kwargs): 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" @@ -146,6 +177,10 @@ def form_valid(self, form): 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.") @@ -158,6 +193,10 @@ def form_valid(self, form): 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.") From 1440dfd93e91960adde9f8ad54b37cd8b4fb8b61 Mon Sep 17 00:00:00 2001 From: Julian B Date: Mon, 7 Aug 2023 19:39:34 +0200 Subject: [PATCH 26/42] add flow to tear down federation connection --- ephios/plugins/federation/urls.py | 5 +++++ ephios/plugins/federation/views/api.py | 19 ++++++++++++++++++- ephios/plugins/federation/views/frontend.py | 18 +++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py index a327dc24e..01cc67a78 100644 --- a/ephios/plugins/federation/urls.py +++ b/ephios/plugins/federation/urls.py @@ -59,4 +59,9 @@ 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 index 13269f941..b577171d0 100644 --- a/ephios/plugins/federation/views/api.py +++ b/ephios/plugins/federation/views/api.py @@ -10,7 +10,7 @@ from oauthlib.oauth2 import WebApplicationClient from requests_oauthlib import OAuth2Session from rest_framework.exceptions import PermissionDenied -from rest_framework.generics import CreateAPIView, ListAPIView +from rest_framework.generics import CreateAPIView, DestroyAPIView, ListAPIView from rest_framework.permissions import AllowAny from ephios.core.models import Event, Qualification @@ -32,6 +32,23 @@ class RedeemInviteCodeView(CreateAPIView): 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. diff --git a/ephios/plugins/federation/views/frontend.py b/ephios/plugins/federation/views/frontend.py index 75c53c93e..40f3ed229 100644 --- a/ephios/plugins/federation/views/frontend.py +++ b/ephios/plugins/federation/views/frontend.py @@ -51,7 +51,12 @@ def get_context_data(self, **kwargs): event["end_time"] = datetime.fromisoformat(event["end_time"]) event["host"] = host.name events += results - except (HTTPError, JSONDecodeError, ReadTimeout): + 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 @@ -203,6 +208,17 @@ class FederatedHostDeleteView(StaffRequiredMixin, SuccessMessageMixin, DeleteVie 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 From de765050756d03259fd9d48824164c9ed80bbc49 Mon Sep 17 00:00:00 2001 From: Julian B Date: Mon, 7 Aug 2023 19:48:54 +0200 Subject: [PATCH 27/42] fix poetry.lock --- poetry.lock | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 50aabf442..cd4154e32 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1897,6 +1897,24 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-oauthlib" +version = "1.3.1" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, + {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + [[package]] name = "rjsmin" version = "1.2.1" @@ -2446,4 +2464,4 @@ redis = ["redis"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "1dace1fde92de85780192d84a97ed25dcd9693f736991dcdf384c978e64c83e6" +content-hash = "fbd685d6caf25cdd40d738cb5e9f7f8621057922afd5f7e6f02fbacd0e96ac3e" From 87f18b333a6d6a852aa37a2d31e1ac41b1a55339 Mon Sep 17 00:00:00 2001 From: Julian B Date: Mon, 7 Aug 2023 20:20:26 +0200 Subject: [PATCH 28/42] fix tests --- ephios/plugins/federation/models.py | 2 +- tests/api/test_oauth_views.py | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index 85fe54293..759b5c3b1 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -116,7 +116,7 @@ def reverse_event_detail(self, event): @property def icon(self): return mark_safe( - ''.format( + ''.format( # noqa title=_("Federated user") ) ) diff --git a/tests/api/test_oauth_views.py b/tests/api/test_oauth_views.py index 0beebbd85..474290527 100644 --- a/tests/api/test_oauth_views.py +++ b/tests/api/test_oauth_views.py @@ -5,8 +5,7 @@ def test_creating_an_oauth_application(django_app, superuser): - response = django_app.get(reverse("api:settings-oauth-app-list"), user=superuser) - response = response.click("New Application") + response = django_app.get(reverse("api:settings-oauth-app-register"), user=superuser) response.form["name"] = "Test Application" response.form["client_type"] = "public" response.form["authorization_grant_type"] = "authorization-code" @@ -28,15 +27,17 @@ def application(superuser): def test_deleting_oauth_application(django_app, superuser, application): - response = django_app.get(reverse("api:settings-oauth-app-list"), user=superuser) - response = response.click("Delete") + response = django_app.get( + reverse("api:settings-oauth-app-delete", kwargs={"pk": application.pk}), user=superuser + ) response = response.form.submit().follow() assert Application.objects.count() == 0 def test_editing_an_application(django_app, superuser, application): - response = django_app.get(reverse("api:settings-oauth-app-list"), user=superuser) - response = response.click("Edit") + response = django_app.get( + reverse("api:settings-oauth-app-update", kwargs={"pk": application.pk}), user=superuser + ) response.form["name"] = "New Name" response = response.form.submit().follow() assert "New Name" in response @@ -44,6 +45,7 @@ def test_editing_an_application(django_app, superuser, application): def test_viewing_an_application(django_app, superuser, application): - response = django_app.get(reverse("api:settings-oauth-app-list"), user=superuser) - response = response.click("View") - assert application.client_id in response + response = django_app.get( + reverse("api:settings-oauth-app-detail", kwargs={"pk": application.pk}), user=superuser + ) + assert application.name in response From 9fef2322de8a23a905c746ec205fd692c24bd708 Mon Sep 17 00:00:00 2001 From: Julian B Date: Mon, 7 Aug 2023 20:23:23 +0200 Subject: [PATCH 29/42] fix linter --- ephios/plugins/federation/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index 759b5c3b1..bea9e7ab8 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -115,8 +115,9 @@ def reverse_event_detail(self, event): @property def icon(self): + # pylint: disable-next=consider-using-f-string return mark_safe( - ''.format( # noqa + ''.format( title=_("Federated user") ) ) From 9485687754e822dfe375898a36b932bfb9071f36 Mon Sep 17 00:00:00 2001 From: Julian B Date: Tue, 8 Aug 2023 15:10:23 +0200 Subject: [PATCH 30/42] address review comments --- ephios/plugins/federation/models.py | 14 ++++++------ ephios/plugins/federation/signals.py | 25 ++++++++++++--------- ephios/plugins/federation/views/frontend.py | 2 +- pyproject.toml | 2 +- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index bea9e7ab8..627939d69 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -43,7 +43,7 @@ class InviteCode(models.Model): created_at = models.DateTimeField(auto_now_add=True) def __str__(self): - return f"Federation invite code for {self.url}" + return _("Federation invite code for {url}").format(url=self.url) @property def is_expired(self): @@ -62,7 +62,7 @@ class FederatedEventShare(models.Model): shared_with = models.ManyToManyField(FederatedGuest) def __str__(self): - return f"Federated event share for {self.event}" + return _("Federated event share for {event}").format(event=self.event) class FederatedUser(models.Model): @@ -76,7 +76,9 @@ class FederatedUser(models.Model): created_at = models.DateTimeField(auto_now_add=True) def __str__(self): - return f"Federated user {self.first_name} {self.last_name}" + 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( @@ -115,11 +117,9 @@ def reverse_event_detail(self, event): @property def icon(self): - # pylint: disable-next=consider-using-f-string + title = _("Federated user") return mark_safe( - ''.format( - title=_("Federated user") - ) + f'' ) diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py index 597e60ebf..7fb4d0eaf 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -10,23 +10,28 @@ periodic_signal, ) from ephios.plugins.federation.forms import EventAllowFederationForm -from ephios.plugins.federation.models import FederatedUser +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", - } - ] + 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.guest_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(): @@ -52,7 +57,7 @@ def federation_settings_section(sender, request, **kwargs): return ( [ { - "label": _("Federation") + " (beta)", + "label": _("Federation"), "url": reverse("federation:settings"), "active": request.resolver_match.app_name == "federation", }, diff --git a/ephios/plugins/federation/views/frontend.py b/ephios/plugins/federation/views/frontend.py index 40f3ed229..9e9fbb9f7 100644 --- a/ephios/plugins/federation/views/frontend.py +++ b/ephios/plugins/federation/views/frontend.py @@ -93,7 +93,7 @@ def get_object(self, queryset=None): event=obj, shared_with__in=[FederatedGuest.objects.get(pk=guest)], ) - except (KeyError, FederatedEventShare.DoesNotExist) as exc: + except (KeyError, FederatedEventShare.DoesNotExist, FederatedGuest.DoesNotExist) as exc: self.request.session.flush() raise PermissionDenied from exc return obj diff --git a/pyproject.toml b/pyproject.toml index b337c7765..1a1de0086 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ disable = """C0301, duplicate-code, attribute-defined-outside-init, missing-module-docstring, missing-class-docstring, no-member, invalid-name, import-outside-toplevel, unused-argument, too-many-ancestors, missing-function-docstring, too-few-public-methods, too-many-arguments, cyclic-import, inconsistent-return-statements, -useless-object-inheritance, logging-fstring-interpolation +useless-object-inheritance, logging-fstring-interpolation, consider-using-f-string """ [tool.pylint.format] From 610a00d8bd139bde5f3618f3e9af951705de48ce Mon Sep 17 00:00:00 2001 From: Julian B Date: Tue, 8 Aug 2023 16:22:12 +0200 Subject: [PATCH 31/42] fix css for external event list --- .../templates/federation/external_event_list.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ephios/plugins/federation/templates/federation/external_event_list.html b/ephios/plugins/federation/templates/federation/external_event_list.html index db611313d..cbfe9aeb4 100644 --- a/ephios/plugins/federation/templates/federation/external_event_list.html +++ b/ephios/plugins/federation/templates/federation/external_event_list.html @@ -7,14 +7,15 @@ {% block content %}

{% translate "External events" %}

+
    {% for event in events %} - + {% empty %}
    @@ -66,4 +67,5 @@
    {% endfor %} +
{% endblock %} From 8160a41fd4f9e7bb47ec2d34ef67eb06c6917deb Mon Sep 17 00:00:00 2001 From: Julian B Date: Tue, 22 Aug 2023 13:07:44 +0200 Subject: [PATCH 32/42] start adding tests --- .../templates/federation/invitecode_form.html | 2 +- ephios/plugins/federation/views/frontend.py | 1 - tests/plugins/federation/conftest.py | 29 +++++++++++++++++++ .../federation/test_federation_setup.py | 29 +++++++++++++++++++ tests/settings.py | 1 + 5 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/plugins/federation/conftest.py create mode 100644 tests/plugins/federation/test_federation_setup.py diff --git a/ephios/plugins/federation/templates/federation/invitecode_form.html b/ephios/plugins/federation/templates/federation/invitecode_form.html index 2bab223d3..264031775 100644 --- a/ephios/plugins/federation/templates/federation/invitecode_form.html +++ b/ephios/plugins/federation/templates/federation/invitecode_form.html @@ -8,7 +8,7 @@

{% translate "Share events with another instance" %}

{% translate "This feature is experimental!" %} - -
-
-
-
- {{ event.title }} -
- - - {{ event.host }} - +
+
+
+
+
+ {{ event.title }} +
+ + + {{ event.host }} + +
+ + {{ event.location }} +
- - {{ event.location }} - -
-
{{ event.type }} -
- -
- {{ event.start_time|date:"D" }}, - {% if event.start_time.date == event.end_time.date %} - {{ event.start_time|date:"SHORT_DATE_FORMAT" }} - , -
- {{ event.start_time|date:"TIME_FORMAT" }} – - {{ event.end_time|date:"TIME_FORMAT" }} - {% else %} - {{ event.start_time|date:"SHORT_DATE_FORMAT" }} -
- {% translate "to" %} - {{ event.end_time|date:"SHORT_DATE_FORMAT" }} - {% endif %} -
-
-
- {% translate "Show" %} +
+ +
+ {{ event.start_time|date:"D" }}, + {% if event.start_time.date == event.end_time.date %} + {{ event.start_time|date:"SHORT_DATE_FORMAT" }} + , +
+ {{ event.start_time|date:"TIME_FORMAT" }} – + {{ event.end_time|date:"TIME_FORMAT" }} + {% else %} + {{ event.start_time|date:"SHORT_DATE_FORMAT" }} +
+ {% translate "to" %} + {{ event.end_time|date:"SHORT_DATE_FORMAT" }} + {% endif %} +
+
+
+ {% translate "Show" %} +
-
- - - {% empty %} -
-
- {% translate "No results." %} -
-
- {% endfor %} + + + {% empty %} +
+
+ {% translate "No results." %} +
+
+ {% endfor %} {% endblock %} diff --git a/ephios/plugins/federation/templates/federation/federation_settings.html b/ephios/plugins/federation/templates/federation/federation_settings.html index fb5ddcd0a..59ae9c531 100644 --- a/ephios/plugins/federation/templates/federation/federation_settings.html +++ b/ephios/plugins/federation/templates/federation/federation_settings.html @@ -9,61 +9,61 @@

{% translate "ephios instances that you share events with" %}

Share events with another instance
{{ invite.url }} {% translate "Show invite code" %}
{{ host.name }} {{ host.url }}
- - - - - - - - {% for guest in federation_guests %} - - - + + + - {% endfor %} + + + {% for guest in federation_guests %} + + + + + + {% endfor %}
{% translate "Name" %}{% translate "URL" %}{% translate "Action" %}
{{ guest.name }}{{ guest.url }}{% translate "Stop sharing" %}{% translate "Name" %}{% translate "URL" %}{% translate "Action" %}
{{ guest.name }}{{ guest.url }}{% translate "Stop sharing" %}
{% if federation_invites %}

{% translate "Pending invites" %}

- - - - - - - - {% for invite in federation_invites %} - - - - - {% endfor %} - -
{% translate "URL" %}{% translate "Action" %}
{{ invite.url }}{% translate "Show invite code" %}
+ + + {% 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" %} - - - - - - - - {% for host in federation_hosts %} - - - + + + - {% endfor %} + + + {% for host in federation_hosts %} + + + + + + {% endfor %}
{% translate "Name" %}{% translate "URL" %}{% translate "Action" %}
{{ host.name }}{{ host.url }}{% translate "Stop receiving" %}{% translate "Name" %}{% translate "URL" %}{% translate "Action" %}
{{ host.name }}{{ host.url }}{% translate "Stop receiving" %}
{% endblock %} diff --git a/ephios/plugins/federation/templates/federation/invitecode_reveal.html b/ephios/plugins/federation/templates/federation/invitecode_reveal.html index 3c481ac30..d9ba97e3b 100644 --- a/ephios/plugins/federation/templates/federation/invitecode_reveal.html +++ b/ephios/plugins/federation/templates/federation/invitecode_reveal.html @@ -18,14 +18,14 @@

-
- {% url "federation:frontend_redeem_invite_code" as redeem_invite_code_url %} - - -
+
+ {% url "federation:frontend_redeem_invite_code" as redeem_invite_code_url %} + + +
diff --git a/ephios/plugins/federation/templates/federation/redeem_invite_code.html b/ephios/plugins/federation/templates/federation/redeem_invite_code.html index 9ef96378e..6fc9a8f75 100644 --- a/ephios/plugins/federation/templates/federation/redeem_invite_code.html +++ b/ephios/plugins/federation/templates/federation/redeem_invite_code.html @@ -9,9 +9,9 @@

{% translate "Enter invite code" %}

diff --git a/tests/settings.py b/tests/settings.py index c2c8695ca..6c7535182 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,4 +1,3 @@ from ephios.settings import * # no-qa LANGUAGE_CODE = "en" -SITE_URL = "http://testserver" From d291fdd2b0a4226220493a818dd818c15e9da1a8 Mon Sep 17 00:00:00 2001 From: Julian B Date: Sun, 24 Sep 2023 22:33:26 +0200 Subject: [PATCH 39/42] run all tests with rollback emulation --- tests/conftest.py | 2 +- tests/plugins/federation/test_federation_setup.py | 4 +++- tests/plugins/federation/test_federation_views.py | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 62f896616..f1f5e79d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,7 +51,7 @@ def csrf_exempt_django_app(django_app_factory): def pytest_collection_modifyitems(items): # mark all tests for use with django_db, as we basically need it everywhere for item in items: - item.add_marker("django_db") + item.add_marker(pytest.mark.django_db(transaction=True, serialized_rollback=True)) @pytest.fixture(autouse=True) diff --git a/tests/plugins/federation/test_federation_setup.py b/tests/plugins/federation/test_federation_setup.py index c623ffee0..7d44c9980 100644 --- a/tests/plugins/federation/test_federation_setup.py +++ b/tests/plugins/federation/test_federation_setup.py @@ -14,7 +14,9 @@ def test_create_invitecode(django_app, superuser): assert InviteCode.objects.get().url == "https://example.com" -def test_redeem_invitecode_frontend(django_app, superuser, invite_code, live_server): +def test_redeem_invitecode_frontend( + django_app, superuser, invite_code, live_server, django_db_serialized_rollback +): global_preferences_registry.manager()["general__organization_name"] = "Test" form = django_app.get(reverse("federation:frontend_redeem_invite_code"), user=superuser).form form["code"] = base64.b64encode( diff --git a/tests/plugins/federation/test_federation_views.py b/tests/plugins/federation/test_federation_views.py index 0ba37d9fc..656c83f62 100644 --- a/tests/plugins/federation/test_federation_views.py +++ b/tests/plugins/federation/test_federation_views.py @@ -3,7 +3,9 @@ from ephios.plugins.federation.models import FederatedEventShare -def test_federation_get_shared_events(django_app, volunteer, live_server, federation, event): +def test_federation_get_shared_events( + django_app, volunteer, live_server, federation, event, django_db_serialized_rollback +): host, guest = federation(live_server.url) share = FederatedEventShare.objects.create(event=event) share.shared_with.add(guest) @@ -16,7 +18,7 @@ def test_federation_get_shared_events(django_app, volunteer, live_server, federa def test_federation_shared_event_detail( - django_app, volunteer, live_server, federation, event, settings + django_app, volunteer, live_server, federation, event, settings, django_db_serialized_rollback ): settings.GET_SITE_URL = lambda: live_server.url host, guest = federation(live_server.url) From f513a08c50f09e3440e474a0a9d19cb6d5d542e5 Mon Sep 17 00:00:00 2001 From: Julian B Date: Mon, 25 Sep 2023 00:22:32 +0200 Subject: [PATCH 40/42] enable OAuth testing --- tests/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/settings.py b/tests/settings.py index 6c7535182..3ef841c87 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,3 +1,4 @@ from ephios.settings import * # no-qa LANGUAGE_CODE = "en" +os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" From 15ab80b97e1d052e023aa000ddc9ebeb90c14a6b Mon Sep 17 00:00:00 2001 From: Julian B Date: Sat, 7 Oct 2023 16:52:56 +0200 Subject: [PATCH 41/42] fix poetry.lock --- poetry.lock | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/poetry.lock b/poetry.lock index 983992ea6..7b701e32b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -1492,16 +1492,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -1852,8 +1842,8 @@ astroid = ">=3.0.0,<=3.1.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, + {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, ] isort = ">=4.2.5,<6" mccabe = ">=0.6,<0.8" @@ -2083,7 +2073,6 @@ optional = false python-versions = ">=3.7,<4" files = [ {file = "reportlab-4.0.5-py3-none-any.whl", hash = "sha256:1344dbe779b9049a1888105503837d0e5b62163bf5c6b33bd1fbe84bad484f50"}, - {file = "reportlab-4.0.5.tar.gz", hash = "sha256:9c68f277736f585c5c9938755b826dd57c877fcaeb203e21cefea12b3b1db4f5"}, ] [package.dependencies] @@ -2115,6 +2104,24 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-oauthlib" +version = "1.3.1" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, + {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + [[package]] name = "rjsmin" version = "1.2.1" @@ -2664,4 +2671,4 @@ redis = ["redis"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "609d596255a0cea33acbaced24cd945db0e33696b26ffc55c741682ffc7a3425" +content-hash = "07e917ddcfa0fac6711d12bfab02bd3eb70828a496fb1a20cd993cce4284ea4b" From d6705e7fc054c059a3347efde03fecaf84c3aed3 Mon Sep 17 00:00:00 2001 From: Julian B Date: Sun, 8 Oct 2023 00:38:44 +0200 Subject: [PATCH 42/42] add translations --- ephios/locale/de/LC_MESSAGES/django.po | 353 +++++++++++++++++++++---- 1 file changed, 301 insertions(+), 52 deletions(-) diff --git a/ephios/locale/de/LC_MESSAGES/django.po b/ephios/locale/de/LC_MESSAGES/django.po index 9c87e43a2..d63f9304b 100644 --- a/ephios/locale/de/LC_MESSAGES/django.po +++ b/ephios/locale/de/LC_MESSAGES/django.po @@ -2,12 +2,12 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-30 17:06+0200\n" +"POT-Creation-Date: 2023-10-07 20:08+0200\n" "PO-Revision-Date: 2023-09-30 17:12+0200\n" "Last-Translator: Felix Rindt \n" "Language-Team: German