diff --git a/requirements/base.txt b/requirements/base.txt
new file mode 100644
index 0000000..e69de29
diff --git a/src/membership/admin.py b/src/membership/admin.py
index 69e2c22..f1ad815 100644
--- a/src/membership/admin.py
+++ b/src/membership/admin.py
@@ -20,6 +20,7 @@ from .emails import InviteEmail
from .models import Member
from .models import Membership
from .models import MembershipType
+from .models import ServiceAccess
from .models import SubscriptionPeriod
from .models import WaitingListEntry
@@ -46,6 +47,12 @@ class SubscriptionPeriodAdmin(admin.ModelAdmin):
"""Admin for SubscriptionPeriod model."""
+@admin.register(ServiceAccess)
+class ServiceAccessAdmin(admin.ModelAdmin):
+ """Admin for ServiceAccess model."""
+ pass
+
+
class MembershipInlineAdmin(admin.TabularInline):
"""Inline admin."""
diff --git a/src/membership/migrations/0011_serviceaccess_alter_membership_options_and_more.py b/src/membership/migrations/0011_serviceaccess_alter_membership_options_and_more.py
new file mode 100644
index 0000000..0a8879d
--- /dev/null
+++ b/src/membership/migrations/0011_serviceaccess_alter_membership_options_and_more.py
@@ -0,0 +1,49 @@
+# Generated by Django 5.1rc1 on 2024-12-22 23:20
+
+import django.db.models.deletion
+import django_registries.registry
+import services.registry
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('membership', '0010_waitinglistentry_member'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ServiceAccess',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
+ ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
+ ('service', django_registries.registry.ChoicesField(choices=[('hedgedoc', 'hedgedoc'), ('mail', 'mail'), ('mastodon', 'mastodon'), ('matrix', 'matrix'), ('nextcloud', 'nextcloud'), ('rallly', 'rallly')], registry=services.registry.ServiceRegistry, verbose_name='service')),
+ ('subscription_data', models.JSONField(blank=True, null=True, verbose_name='subscription data')),
+ ],
+ options={
+ 'verbose_name': 'service access',
+ 'verbose_name_plural': 'service accesses',
+ },
+ ),
+ migrations.AlterModelOptions(
+ name='membership',
+ options={'verbose_name': 'membership', 'verbose_name_plural': 'memberships'},
+ ),
+ migrations.RemoveConstraint(
+ model_name='subscriptionperiod',
+ name='exclude_overlapping_periods',
+ ),
+ migrations.AddField(
+ model_name='serviceaccess',
+ name='user',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddConstraint(
+ model_name='serviceaccess',
+ constraint=models.UniqueConstraint(fields=('user', 'service'), name='unique_user_service'),
+ ),
+ ]
diff --git a/src/membership/models.py b/src/membership/models.py
index f9e5ddd..9352a46 100644
--- a/src/membership/models.py
+++ b/src/membership/models.py
@@ -12,6 +12,7 @@ from django.contrib.postgres.fields import RangeOperators
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext as _
+from services.registry import ServiceRegistry
from djmoney.money import Money
from utils.mixins import CreatedModifiedAbstract
@@ -91,14 +92,14 @@ class SubscriptionPeriod(CreatedModifiedAbstract):
period = DateRangeField(verbose_name=_("period"))
class Meta:
- constraints: ClassVar = [
+ constraints: (
ExclusionConstraint(
name="exclude_overlapping_periods",
expressions=[
("period", RangeOperators.OVERLAPS),
],
),
- ]
+ )
def __str__(self) -> str:
return f"{self.period.lower} - {self.period.upper or _('next general assembly')}"
@@ -245,3 +246,30 @@ class WaitingListEntry(CreatedModifiedAbstract):
class Meta:
verbose_name = _("waiting list entry")
verbose_name_plural = _("waiting list entries")
+
+
+class ServiceAccess(CreatedModifiedAbstract):
+ """Access to a service for a user."""
+
+ class Meta:
+ verbose_name = _("service access")
+ verbose_name_plural = _("service accesses")
+ constraints = (
+ models.UniqueConstraint(
+ fields=["user", "service"],
+ name="unique_user_service",
+ ),
+ )
+
+ user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
+
+ service = ServiceRegistry.choices_field(verbose_name=_("service"))
+
+ subscription_data = models.JSONField(
+ verbose_name=_("subscription data"),
+ null=True,
+ blank=True,
+ )
+
+ def __str__(self) -> str:
+ return f"{self.user} - {self.service}"
diff --git a/src/project/settings.py b/src/project/settings.py
index d6a800c..8c039fa 100644
--- a/src/project/settings.py
+++ b/src/project/settings.py
@@ -46,12 +46,15 @@ THIRD_PARTY_APPS = [
"allauth",
"allauth.account",
"django_view_decorator",
+ "django_registries",
+ "oauth2_provider",
]
LOCAL_APPS = [
"utils",
"accounting",
"membership",
+ "services",
]
INSTALLED_APPS = [
@@ -160,6 +163,16 @@ ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
ACCOUNT_USERNAME_REQUIRED = False
+# OAuth2 configuration
+OAUTH2_PROVIDER = {
+ "OIDC_ENABLED": True,
+ "SCOPES": {
+ "openid": "OpenID Connect scope",
+ "profile": "Profile Information",
+ },
+ "PKCE_REQUIRED": False, # this can be a callable - https://github.com/jazzband/django-oauth-toolkit/issues/711#issuecomment-497073038
+}
+
# Logging
# We want to log everything to stdout in docker
LOGGING = {
diff --git a/src/project/static/css/style.css b/src/project/static/css/style.css
index 1d1bf4d..d845d2c 100644
--- a/src/project/static/css/style.css
+++ b/src/project/static/css/style.css
@@ -51,6 +51,7 @@ h6 {
--light-dust: #fefef9;
--dust: #f4f1ef;
--medium-dust: #dadada;
+ --medium-dust : #dadada;
--dark-dust: #bfbfbf;
--fade: #878787;
--twilight: #4a4a4a;
@@ -256,7 +257,7 @@ div.content-view>h2 {
div.services {
display: flex;
- justify-content: space-between;
+ justify-content: start;
gap: var(--double-space);
flex-wrap: wrap;
}
diff --git a/src/project/templates/base.html b/src/project/templates/base.html
index 1bcdab5..fadae0f 100644
--- a/src/project/templates/base.html
+++ b/src/project/templates/base.html
@@ -78,13 +78,13 @@
- {% comment %}
+ {% if user.is_superuser %}
-
+
Services
- {% endcomment %}
+ {% endif %}
diff --git a/src/project/templates/services_overview.html b/src/project/templates/services_overview.html
deleted file mode 100644
index 139165e..0000000
--- a/src/project/templates/services_overview.html
+++ /dev/null
@@ -1,62 +0,0 @@
-{% extends "base.html" %}
-
-{% block content %}
-
-
- Coming soon!
-
-
- {% comment %}
-
-
Services you subscribe to
-
-
-
-
-
Available services
-
-
-
-
-
Mastodon
-
Mastodon is a service where you can write things to people around the world.
-
Read more …
-
-
Subscribe
-
-
-
-
-
- {% endcomment %}
-{% endblock %}
diff --git a/src/project/urls.py b/src/project/urls.py
index c68d1f5..db57757 100644
--- a/src/project/urls.py
+++ b/src/project/urls.py
@@ -8,6 +8,7 @@ from django_view_decorator import include_view_urls
urlpatterns = [
path("", include_view_urls(extra_modules=["project.views"])),
+ path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
path("accounts/", include("allauth.urls")),
path("_admin/", admin.site.urls),
]
diff --git a/src/project/views.py b/src/project/views.py
index affd1e7..6dad305 100644
--- a/src/project/views.py
+++ b/src/project/views.py
@@ -1,12 +1,13 @@
"""Project views."""
-
from __future__ import annotations
+from membership.models import ServiceAccess
+from services.registry import ServiceRegistry
+from utils.view_utils import render
from typing import TYPE_CHECKING
from accounting.models import Order
from django_view_decorator import view
-from utils.view_utils import render
if TYPE_CHECKING:
from django.http import HttpRequest
@@ -25,13 +26,3 @@ def index(request: HttpRequest) -> HttpResponse:
context = {"unpaid_orders": list(unpaid_orders)}
return render(request, "index.html", context=context)
-
-
-@view(
- paths="services/",
- name="services",
- login_required=True,
-)
-def services_overview(request: HttpRequest) -> HttpResponse:
- """View to show the services overview."""
- return render(request, "services_overview.html")
diff --git a/src/services/__init__.py b/src/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/services/admin.py b/src/services/admin.py
new file mode 100644
index 0000000..846f6b4
--- /dev/null
+++ b/src/services/admin.py
@@ -0,0 +1 @@
+# Register your models here.
diff --git a/src/services/apps.py b/src/services/apps.py
new file mode 100644
index 0000000..11e745d
--- /dev/null
+++ b/src/services/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ServicesConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "services"
diff --git a/src/services/migrations/__init__.py b/src/services/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/services/models.py b/src/services/models.py
new file mode 100644
index 0000000..6b20219
--- /dev/null
+++ b/src/services/models.py
@@ -0,0 +1 @@
+# Create your models here.
diff --git a/src/services/registry.py b/src/services/registry.py
new file mode 100644
index 0000000..6e9e303
--- /dev/null
+++ b/src/services/registry.py
@@ -0,0 +1,38 @@
+"""Registry for services."""
+
+from django import forms
+from django_registries.registry import Interface
+from django_registries.registry import Registry
+
+
+class ServiceRegistry(Registry):
+ """Registry for services."""
+
+ implementations_module = "services"
+
+
+class ServiceInterface(Interface):
+ """Interface for services."""
+
+ registry = ServiceRegistry
+
+ name: str
+ description: str
+ url: str
+
+ public: bool = False
+
+ # TODO: add a way to add a something which defines the required fields for a service
+ # - maybe a list of tuples with the field name and the type of the field
+ # this could be used to generate a form for the service, and also to validate
+ # the data saved in a JSONField on the ServiceAccess model
+
+ subscribe_fields: tuple[tuple[str, forms.Field]] = []
+
+ def get_form_class(self) -> type:
+ """Get the form class for the service."""
+ return type(
+ "ServiceForm",
+ (forms.Form,),
+ dict(self.subscribe_fields),
+ )
diff --git a/src/services/services.py b/src/services/services.py
new file mode 100644
index 0000000..7a8e99b
--- /dev/null
+++ b/src/services/services.py
@@ -0,0 +1,76 @@
+"""Service classes for data.coop."""
+
+from django import forms
+
+from .registry import ServiceInterface
+
+
+class MailService(ServiceInterface):
+ """Mail service."""
+
+ slug = "mail"
+ name = "Mail"
+ url = "https://mail.data.coop"
+ description = "Mail service for data.coop"
+
+ subscribe_fields = (("username", forms.CharField()),)
+
+
+class MatrixService(ServiceInterface):
+ """Matrix service."""
+
+ slug = "matrix"
+ name = "Matrix"
+ url = "https://matrix.data.coop"
+ description = "Matrix service for data.coop"
+
+ subscribe_fields = (("username", forms.CharField()),)
+
+
+class MastodonService(ServiceInterface):
+ """Mastodon service."""
+
+ slug = "mastodon"
+ name = "Mastodon"
+ url = "https://social.data.coop"
+ description = "Mastodon service for data.coop"
+
+ subscribe_fields = (("username", forms.CharField()),)
+
+
+class NextcloudService(ServiceInterface):
+ """Nextcloud service."""
+
+ slug = "nextcloud"
+ name = "Nextcloud"
+ url = "https://cloud.data.coop"
+ description = "Nextcloud service for data.coop"
+
+
+class HedgeDocService(ServiceInterface):
+ """HedgeDoc service."""
+
+ slug = "hedgedoc"
+ name = "HedgeDoc"
+ url = "https://pad.data.coop"
+ public = True
+ description = "HedgeDoc service for data.coop"
+
+
+class ForgejoService(ServiceInterface):
+ """Forgejo service."""
+
+ slug = "forgejo"
+ name = "Forgejo"
+ url = "https://git.data.coop"
+ description = "Git service for data.coop"
+
+
+class RalllyService(ServiceInterface):
+ """Rallly service."""
+
+ slug = "rallly"
+ name = "Rallly"
+ url = "https://when.data.coop"
+ public = True
+ description = "Rallly service for data.coop"
diff --git a/src/services/templates/services/service_detail.html b/src/services/templates/services/service_detail.html
new file mode 100644
index 0000000..3f67084
--- /dev/null
+++ b/src/services/templates/services/service_detail.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
{{ service.name }}
+
+
+{% endblock %}
diff --git a/src/services/templates/services/service_subscribe.html b/src/services/templates/services/service_subscribe.html
new file mode 100644
index 0000000..8fe86bd
--- /dev/null
+++ b/src/services/templates/services/service_subscribe.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
Subscribe to {{ service.name }}
+
+
+
+
+
+{% endblock %}
diff --git a/src/services/templates/services/services_overview.html b/src/services/templates/services/services_overview.html
new file mode 100644
index 0000000..ba13a55
--- /dev/null
+++ b/src/services/templates/services/services_overview.html
@@ -0,0 +1,45 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
Services you subscribe to
+
+ {% for service in active_services %}
+
+ {% empty %}
+
You are not subscribed to any service.
+ {% endfor %}
+
+
+
+
+
Available services
+
+ {% for service in non_active_services %}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/src/services/tests.py b/src/services/tests.py
new file mode 100644
index 0000000..a39b155
--- /dev/null
+++ b/src/services/tests.py
@@ -0,0 +1 @@
+# Create your tests here.
diff --git a/src/services/views.py b/src/services/views.py
new file mode 100644
index 0000000..5902c95
--- /dev/null
+++ b/src/services/views.py
@@ -0,0 +1,114 @@
+"""Views for the services app."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from django.shortcuts import redirect
+from django_view_decorator import namespaced_decorator_factory
+from membership.models import ServiceAccess
+from utils.view_utils import render
+
+from services.registry import ServiceInterface
+from services.registry import ServiceRegistry
+
+if TYPE_CHECKING:
+ from django.contrib.auth.models import User
+ from django.http import HttpRequest
+ from django.http import HttpResponse
+
+services_view = namespaced_decorator_factory(
+ namespace="services",
+ base_path="services",
+)
+
+
+@services_view(
+ paths="",
+ name="list",
+ login_required=True,
+)
+def services_overview(request: HttpRequest) -> HttpResponse:
+ """View all services."""
+ active_services = get_services(user=request.user)
+
+ active_service_classes = [service.__class__ for service in active_services]
+
+ services = [service for _, service in ServiceRegistry.get_items() if service not in active_service_classes]
+
+ context = {
+ "non_active_services": services,
+ "active_services": active_services,
+ }
+
+ return render(
+ request=request,
+ template_name="services/services_overview.html",
+ context=context,
+ )
+
+
+@services_view(
+ paths="/",
+ name="detail",
+ login_required=True,
+)
+def service_detail(request: HttpRequest, service_slug: str) -> HttpResponse:
+ """View a service."""
+ service = ServiceRegistry.get(slug=service_slug)
+
+ context = {
+ "service": service,
+ }
+
+ return render(
+ request=request,
+ template_name="services/service_detail.html",
+ context=context,
+ )
+
+
+@services_view(
+ paths="/subscribe/",
+ name="subscribe",
+ login_required=True,
+)
+def service_subscribe(request: HttpRequest, service_slug: str) -> HttpResponse:
+ """Subscribe to a service."""
+ service = ServiceRegistry.get(slug=service_slug)
+
+ form_class = service.get_form_class()
+
+ if request.method == "POST":
+ form = form_class(request.POST)
+ if form.is_valid():
+ data = form.cleaned_data
+ service_access = ServiceAccess(
+ user=request.user,
+ service=service.slug,
+ subscription_data=data,
+ )
+ service_access.save()
+ return redirect("services:list")
+
+ context = {
+ "service": service,
+ "base_path": "services:list",
+ "form": form_class(),
+ }
+
+ return render(
+ request=request,
+ template_name="services/service_subscribe.html",
+ context=context,
+ )
+
+
+def get_services(*, user: User) -> list[ServiceInterface]:
+ """Get services for the user."""
+ return [
+ access.service_implementation
+ for access in ServiceAccess.objects.filter(
+ user=user,
+ )
+ ]