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

    -
    -
    -
    -

    Forgejo

    -

    Forgejo is a service that blabla

    - Read more … -
    - Subscribe -
    -
    -
    -

    Mastodon

    -

    Mastodon is a service where you can write things to people around the world.

    - Read more … -
    - Subscribe -
    -
    -
    -

    Matrix

    -

    Matrix is a service that blabla

    - Read more … -
    - Subscribe -
    -
    -
    -

    NextCloud

    -

    NextCloud is a service that blabla

    - 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 }}

    + +
    + {{ form }} + + +
    +
    + + +{% 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 %} +
    +
    +

    {{ service.name }}

    +

    ...

    + Read more … +
    + Unsubscribe +
    + {% empty %} +

    You are not subscribed to any service.

    + {% endfor %} +
    +
    + +
    +

    Available services

    +
    + {% for service in non_active_services %} +
    +
    +

    {{ service.name }}

    +

    {{ service.description }}

    + + + Read more + + | + + Visit + +
    + Subscribe +
    + {% 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, + )