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

    -
    -
    -
    -

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

    + +
    + {% csrf_token %} + {{ 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..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, + ) + ]