diff --git a/src/membership/admin.py b/src/membership/admin.py
index 465764f..fef4adc 100644
--- a/src/membership/admin.py
+++ b/src/membership/admin.py
@@ -2,6 +2,7 @@ from django.contrib import admin
from .models import Membership
from .models import MembershipType
+from .models import ServiceAccess
from .models import SubscriptionPeriod
@@ -18,3 +19,8 @@ class MembershipTypeAdmin(admin.ModelAdmin):
@admin.register(SubscriptionPeriod)
class SubscriptionPeriodAdmin(admin.ModelAdmin):
pass
+
+
+@admin.register(ServiceAccess)
+class ServiceAccessAdmin(admin.ModelAdmin):
+ pass
diff --git a/src/membership/migrations/0006_serviceaccess_serviceaccess_unique_user_service.py b/src/membership/migrations/0006_serviceaccess_serviceaccess_unique_user_service.py
new file mode 100644
index 0000000..9354287
--- /dev/null
+++ b/src/membership/migrations/0006_serviceaccess_serviceaccess_unique_user_service.py
@@ -0,0 +1,64 @@
+# Generated by Django 5.0.1 on 2024-01-13 19: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", "0005_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=[],
+ registry=services.registry.ServiceRegistry,
+ verbose_name="service",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "service access",
+ "verbose_name_plural": "service accesses",
+ },
+ ),
+ 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 730133f..7cf57cb 100644
--- a/src/membership/models.py
+++ b/src/membership/models.py
@@ -5,6 +5,8 @@ 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 utils.mixins import CreatedModifiedAbstract
@@ -111,3 +113,22 @@ class MembershipType(CreatedModifiedAbstract):
def __str__(self) -> str:
return self.name
+
+
+class ServiceAccess(CreatedModifiedAbstract):
+ 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"))
+
+ def __str__(self):
+ return f"{self.user} - {self.service}"
diff --git a/src/project/settings.py b/src/project/settings.py
index a07226f..eb38225 100644
--- a/src/project/settings.py
+++ b/src/project/settings.py
@@ -41,12 +41,15 @@ THIRD_PARTY_APPS = [
"allauth",
"allauth.account",
"django_view_decorator",
+ "django_registries",
+ "oauth2_provider",
]
LOCAL_APPS = [
"utils",
"accounting",
"membership",
+ "services",
]
INSTALLED_APPS = [
@@ -154,6 +157,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 17f12e2..d71a430 100644
--- a/src/project/static/css/style.css
+++ b/src/project/static/css/style.css
@@ -556,4 +556,4 @@ span.time_remaining {
.pagination .page-item.disabled .page-link {
cursor: default;
-}
\ No newline at end of file
+}
diff --git a/src/project/templates/base.html b/src/project/templates/base.html
index 5f31cd9..4ae7f38 100644
--- a/src/project/templates/base.html
+++ b/src/project/templates/base.html
@@ -78,13 +78,11 @@
- {% comment %}
-
+
Services
- {% endcomment %}
@@ -113,7 +111,7 @@
themeSwitcher.addEventListener('click', function() {
themeSwitcher.classList.toggle('active')
let isDark = document.querySelector('html').classList.toggle('dark');
-
+
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
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 8de19c6..cdd63e3 100644
--- a/src/project/urls.py
+++ b/src/project/urls.py
@@ -7,6 +7,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/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..70b5044
--- /dev/null
+++ b/src/services/registry.py
@@ -0,0 +1,40 @@
+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: list[tuple[str, forms.Field]] = []
+
+ def get_form(self) -> type:
+ """Get the form for the service"""
+ print(self.subscribe_fields)
+ return type(
+ "ServiceForm",
+ (forms.Form,),
+ {
+ field_name: field_type
+ for field_name, field_type in self.subscribe_fields
+ },
+ )()
diff --git a/src/services/services.py b/src/services/services.py
new file mode 100644
index 0000000..9d5135d
--- /dev/null
+++ b/src/services/services.py
@@ -0,0 +1,51 @@
+from django import forms
+
+from .registry import ServiceInterface
+
+
+class MailService(ServiceInterface):
+ slug = "mail"
+ name = "Mail"
+ url = "https://mail.data.coop"
+ description = "Mail service for data.coop"
+
+
+class MatrixService(ServiceInterface):
+ slug = "matrix"
+ name = "Matrix"
+ url = "https://matrix.data.coop"
+ description = "Matrix service for data.coop"
+
+ subscribe_fields = [
+ ("username", forms.CharField()),
+ ]
+
+
+class MastodonService(ServiceInterface):
+ slug = "mastodon"
+ name = "Mastodon"
+ url = "https://social.data.coop"
+ description = "Mastodon service for data.coop"
+
+
+class NextcloudService(ServiceInterface):
+ slug = "nextcloud"
+ name = "Nextcloud"
+ url = "https://cloud.data.coop"
+ description = "Nextcloud service for data.coop"
+
+
+class HedgeDocService(ServiceInterface):
+ slug = "hedgedoc"
+ name = "HedgeDoc"
+ url = "https://pad.data.coop"
+ public = True
+ description = "HedgeDoc service for data.coop"
+
+
+class RalllyService(ServiceInterface):
+ 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..8df66cb
--- /dev/null
+++ b/src/services/templates/services/service_subscribe.html
@@ -0,0 +1,18 @@
+{% 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..2eddf92
--- /dev/null
+++ b/src/services/views.py
@@ -0,0 +1,86 @@
+# Create your views here.
+from django_view_decorator import namespaced_decorator_factory
+
+from membership.models import ServiceAccess
+from services.registry import ServiceRegistry
+from utils.view_utils import render
+
+
+services_view = namespaced_decorator_factory(
+ namespace="services",
+ base_path="services",
+)
+
+
+@services_view(
+ paths="",
+ name="list",
+ login_required=True,
+)
+def services_overview(request):
+ active_services = [
+ access.service_implementation
+ for access in ServiceAccess.objects.filter(
+ 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, service_slug):
+ 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, service_slug):
+ service = ServiceRegistry.get(slug=service_slug)
+
+ # TODO: add a form to subscribe to the service
+ context = {
+ "service": service,
+ "base_path": "services:list",
+ "form": service.get_form(),
+ }
+
+ return render(
+ request=request,
+ template_name="services/service_subscribe.html",
+ context=context,
+ )