Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add federation #972

Merged
merged 44 commits into from
Oct 7, 2023
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
1179946
add federation plugin, add federation models, add API route for share…
jeriox Jun 7, 2023
6a4310d
handle AccessToken that does not belong to a FederatedGuest
jeriox Jun 7, 2023
6850f14
add frontend view to display incoming events, add OAuth2 flow, add AP…
jeriox Jun 11, 2023
bf2a0e1
add nav link, use correct signup url
jeriox Jun 11, 2023
d231692
check access token validity before serving the event detail view
jeriox Jun 11, 2023
4185a7d
add shift signup flow for federated users, add referrer to start corr…
jeriox Jun 11, 2023
87dc507
set session expiration
jeriox Jun 11, 2023
bb1f471
add QualificationSerializer
jeriox Jun 13, 2023
63f27a7
respect included qualifications
jeriox Jun 13, 2023
1e22bb3
improve incoming event view
jeriox Jun 13, 2023
8970552
make pylint happy
jeriox Jun 14, 2023
4b35cba
add form to select guests to share an event with
jeriox Jun 14, 2023
7321fe5
add RedeemFederationInviteCodeView
jeriox Jun 14, 2023
59cacbc
finish invite flow
jeriox Jun 14, 2023
79a0d00
fix event create
jeriox Jun 14, 2023
2e86193
fix event create
jeriox Jun 14, 2023
3a07f18
fix event create
jeriox Jun 14, 2023
fb1861d
fix naming
jeriox Jun 18, 2023
68bb977
add delete views for host and guest, handle invite code expiration
jeriox Jun 19, 2023
9ded743
add InviteCodeRevealView, add explanations and experimental labels
jeriox Jul 20, 2023
77fe893
remove expired invite codes
jeriox Jul 20, 2023
2b442d1
fix authorize translation
felixrindt Jul 21, 2023
22a6bae
address some review comments
jeriox Aug 2, 2023
04ae30e
address more review comments
jeriox Aug 4, 2023
562dde9
address more reivew comments
jeriox Aug 7, 2023
1440dfd
add flow to tear down federation connection
jeriox Aug 7, 2023
de76505
fix poetry.lock
jeriox Aug 7, 2023
87f18b3
fix tests
jeriox Aug 7, 2023
9fef232
fix linter
jeriox Aug 7, 2023
9485687
address review comments
jeriox Aug 8, 2023
610a00d
fix css for external event list
jeriox Aug 8, 2023
8160a41
start adding tests
jeriox Aug 22, 2023
611196a
add test_redeem_invitecode_api
jeriox Aug 31, 2023
b47304e
add shared_event_list_test
jeriox Sep 24, 2023
ec1dfb6
add shared_event_detail test
jeriox Sep 24, 2023
fc8a61b
Merge remote-tracking branch 'origin/main' into federation
jeriox Sep 24, 2023
16c233f
fix lockfile
jeriox Sep 24, 2023
5c09069
remove django_db_serialized_rollback
jeriox Sep 24, 2023
409cc65
lint templates
jeriox Sep 24, 2023
d291fdd
run all tests with rollback emulation
jeriox Sep 24, 2023
f513a08
enable OAuth testing
jeriox Sep 24, 2023
1c7976a
Merge remote-tracking branch 'origin/main' into federation
jeriox Oct 7, 2023
15ab80b
fix poetry.lock
jeriox Oct 7, 2023
d6705e7
add translations
jeriox Oct 7, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ephios/api/access/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions ephios/api/templates/api/access_token_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ <h5 class="card-title">
{{ token.application.name }}
</h5>
<p class="text-body-secondary">
{# this belongs to the federation plugin, but we display the information here for better understanding #}
{% if token.application.federatedhost %}
{% translate "ephios instance" %}
{% endif %}
</p>
</div>
<div class="col-12 col-lg">
Expand Down
38 changes: 23 additions & 15 deletions ephios/api/templates/oauth2_provider/application_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,29 @@ <h4>{% translate "OAuth2 applications" %}</h4>
<td>{{ application.created }}</td>
<td class="d-flex">
<div>
<a class="btn btn-secondary"
href="{{ application.get_absolute_url }}">
<span class="fa fa-eye"></span>
<span class="d-none d-lg-inline">{% translate "View" %}</span>
</a>
<a class="btn btn-secondary ms-1"
href="{% url "api:settings-oauth-app-update" application.id %}">
<span class="fa fa-edit"></span>
<span class="d-none d-lg-inline">{% translate "Edit" %}</span>
</a>
<a class="btn btn-secondary ms-1"
href="{% url "api:settings-oauth-app-delete" application.id %}">
<span class="fa fa-trash-alt"></span>
<span class="d-none d-lg-inline">{% translate "Delete" %}</span>
</a>
{% if application.federatedhost %}
<a class="btn btn-secondary"
href="{% url "federation:settings" %}">
<span class="fa fa-cog"></span>
<span class="d-none d-lg-inline">{% translate "Manage federation" %}</span>
</a>
{% else %}
<a class="btn btn-secondary"
href="{{ application.get_absolute_url }}">
<span class="fa fa-eye"></span>
<span class="d-none d-lg-inline">{% translate "View" %}</span>
</a>
<a class="btn btn-secondary ms-1"
href="{% url "api:settings-oauth-app-update" application.id %}">
<span class="fa fa-edit"></span>
<span class="d-none d-lg-inline">{% translate "Edit" %}</span>
</a>
<a class="btn btn-secondary ms-1"
href="{% url "api:settings-oauth-app-delete" application.id %}">
<span class="fa fa-trash-alt"></span>
<span class="d-none d-lg-inline">{% translate "Delete" %}</span>
</a>
{% endif %}
</div>
</td>
</tr>
Expand Down
6 changes: 5 additions & 1 deletion ephios/api/templates/oauth2_provider/authorize.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
{% block content %}
{% if not error %}
<form id="authorizationForm" method="post">
<h3 class="block-center-heading">{% translate "Authorize" %} {{ application.name }}?</h3>
<h3 class="block-center-heading">
{% blocktranslate trimmed with app_name=application.name %}
Authorize {{ app_name }}?
{% endblocktranslate %}
</h3>
{% csrf_token %}

{# pass query params through the submit process for saving #}
Expand Down
2 changes: 2 additions & 0 deletions ephios/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
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()
router.register(r"events", EventViewSet)

app_name = "api"
urlpatterns = [
path("users/me/", UserProfileMeView.as_view(), name="user-profile-me"),
path(
"settings/",
include(
Expand Down
64 changes: 64 additions & 0 deletions ephios/api/views/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from django.db.models import Q
from django.utils import timezone
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
from rest_framework.serializers import ModelSerializer

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 = [
"first_name",
"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=timezone.now()) | Q(grants__expires__isnull=True))
),
many=True,
).data


class UserProfileMeView(RetrieveAPIView):
serializer_class = UserProfileSerializer
queryset = UserProfile.objects.all()
permission_classes = [IsAuthenticatedOrTokenHasScope]
required_scopes = ["ME_READ"]

def get_object(self):
if self.request.user is None:
raise PermissionDenied()
return self.request.user
jeriox marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 2 additions & 0 deletions ephios/core/context.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,4 +23,5 @@ def ephios_base_context(request):
"LANGUAGE_CODE": get_language(),
"ephios_version": settings.EPHIOS_VERSION,
"PWA_APP_ICONS": settings.PWA_APP_ICONS,
"organization_name": global_preferences_registry.manager()["general__organization_name"],
}
Empty file.
17 changes: 17 additions & 0 deletions ephios/plugins/federation/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from django.contrib import admin

from ephios.plugins.federation.models import (
FederatedEventShare,
FederatedGuest,
FederatedHost,
FederatedParticipation,
FederatedUser,
InviteCode,
)

admin.site.register(FederatedGuest)
admin.site.register(FederatedHost)
admin.site.register(FederatedEventShare)
admin.site.register(FederatedUser)
admin.site.register(FederatedParticipation)
admin.site.register(InviteCode)
17 changes: 17 additions & 0 deletions ephios/plugins/federation/apps.py
Original file line number Diff line number Diff line change
@@ -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
110 changes: 110 additions & 0 deletions ephios/plugins/federation/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import base64
import binascii
import json
from json import JSONDecodeError
from urllib.parse import urljoin, urlparse

import requests
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 dynamic_preferences.registries import global_preferences_registry
from requests import HTTPError, ReadTimeout

from ephios.api.models import Application
from ephios.core.forms.events import BasePluginFormMixin
from ephios.plugins.federation.models import (
FederatedEventShare,
FederatedGuest,
FederatedHost,
InviteCode,
)


class EventAllowFederationForm(BasePluginFormMixin, forms.Form):
shared_with = forms.ModelMultipleChoiceField(
queryset=FederatedGuest.objects.all(), required=False, widget=CheckboxSelectMultiple
)

def __init__(self, *args, **kwargs):
kwargs.setdefault("prefix", "federation")
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() if self.instance.pk else []
)

def save(self):
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):
return _("Share event with other ephios instances")

def is_function_active(self):
return self.instance.pk and self.instance.shared_with.exists()


class InviteCodeForm(forms.ModelForm):
class Meta:
model = InviteCode
fields = ["url"]
jeriox marked this conversation as resolved.
Show resolved Hide resolved
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):
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: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"],
},
timeout=10,
)
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, ReadTimeout) as exc:
raise ValidationError(_("Invalid code")) from exc
Loading