WIP: Services #25

Draft
valberg wants to merge 8 commits from services into main
6 changed files with 115 additions and 41 deletions
Showing only changes of commit ce8d4bd228 - Show all commits

View file

@ -0,0 +1,25 @@
# Generated by Django 5.0.6 on 2024-07-15 07:30
import django_registries.registry
import services.registry
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('membership', '0006_serviceaccess_serviceaccess_unique_user_service'),
]
operations = [
migrations.AddField(
model_name='serviceaccess',
name='subscription_data',
field=models.JSONField(blank=True, null=True, verbose_name='subscription data'),
),
migrations.AlterField(
model_name='serviceaccess',
name='service',
field=django_registries.registry.ChoicesField(choices=[('hedgedoc', 'hedgedoc'), ('mail', 'mail'), ('mastodon', 'mastodon'), ('matrix', 'matrix'), ('nextcloud', 'nextcloud'), ('rallly', 'rallly')], registry=services.registry.ServiceRegistry, verbose_name='service'),
),
]

View file

@ -1,6 +1,5 @@
"""Models for the membership app.""" """Models for the membership app."""
from typing import ClassVar
from typing import Self from typing import Self
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -10,7 +9,6 @@ from django.contrib.postgres.fields import RangeOperators
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from services.registry import ServiceRegistry from services.registry import ServiceRegistry
from utils.mixins import CreatedModifiedAbstract from utils.mixins import CreatedModifiedAbstract
@ -58,14 +56,14 @@ class SubscriptionPeriod(CreatedModifiedAbstract):
period = DateRangeField(verbose_name=_("period")) period = DateRangeField(verbose_name=_("period"))
class Meta: class Meta:
constraints: ClassVar = [ constraints: (
ExclusionConstraint( ExclusionConstraint(
name="exclude_overlapping_periods", name="exclude_overlapping_periods",
expressions=[ expressions=[
("period", RangeOperators.OVERLAPS), ("period", RangeOperators.OVERLAPS),
], ],
), ),
] )
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.period.lower} - {self.period.upper or _('next general assembly')}" return f"{self.period.lower} - {self.period.upper or _('next general assembly')}"
@ -144,19 +142,27 @@ class MembershipType(CreatedModifiedAbstract):
class ServiceAccess(CreatedModifiedAbstract): class ServiceAccess(CreatedModifiedAbstract):
"""Access to a service for a user."""
class Meta: class Meta:
verbose_name = _("service access") verbose_name = _("service access")
verbose_name_plural = _("service accesses") verbose_name_plural = _("service accesses")
constraints = [ constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=["user", "service"], fields=["user", "service"],
name="unique_user_service", name="unique_user_service",
), ),
] )
user = models.ForeignKey("auth.User", on_delete=models.PROTECT) user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
Review

This is correct! 💯

Notable because there is also a Membership model.. however, memberships are renewed every year, and a user should be able to use the same services across different memberships.

How we handle "eligibility to a service" can happen later..

This is correct! 💯 Notable because there is also a Membership model.. however, memberships are renewed every year, and a user should be able to use the same services across different memberships. How we handle "eligibility to a service" can happen later..
service = ServiceRegistry.choices_field(verbose_name=_("service")) service = ServiceRegistry.choices_field(verbose_name=_("service"))
def __str__(self): subscription_data = models.JSONField(
verbose_name=_("subscription data"),
null=True,
blank=True,
)
def __str__(self) -> str:
return f"{self.user} - {self.service}" return f"{self.user} - {self.service}"
Review

Just wanna say that I've seen this WRT the Membership and MembershipType definitions. I think this will work fine in combination. I have some ideas etc.. but fundamentally, this doesn't need to change 👍

Just wanna say that I've seen this WRT the Membership and MembershipType definitions. I think this will work fine in combination. I have some ideas etc.. but fundamentally, this doesn't need to change 👍
Review

There will be a new model Product and Order that are relevant for charging services, but we can probably figure out how they are associated with services... I like the idea of matching a service with some well-known string.

There will be a new model Product and Order that are relevant for charging services, but we can probably figure out how they are associated with services... I like the idea of matching a service with some well-known string.

View file

@ -1,16 +1,18 @@
"""Registry for services."""
from django import forms from django import forms
from django_registries.registry import Interface from django_registries.registry import Interface
from django_registries.registry import Registry from django_registries.registry import Registry
class ServiceRegistry(Registry): class ServiceRegistry(Registry):
"""Registry for services""" """Registry for services."""
implementations_module = "services" implementations_module = "services"
class ServiceInterface(Interface): class ServiceInterface(Interface):
"""Interface for services""" """Interface for services."""
registry = ServiceRegistry registry = ServiceRegistry
@ -25,16 +27,12 @@ class ServiceInterface(Interface):
# this could be used to generate a form for the service, and also to validate # 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 # the data saved in a JSONField on the ServiceAccess model
subscribe_fields: list[tuple[str, forms.Field]] = [] subscribe_fields: tuple[tuple[str, forms.Field]] = []
def get_form(self) -> type: def get_form_class(self) -> type:
"""Get the form for the service""" """Get the form class for the service."""
print(self.subscribe_fields)
return type( return type(
"ServiceForm", "ServiceForm",
(forms.Form,), (forms.Form,),
{ dict(self.subscribe_fields),
field_name: field_type )
for field_name, field_type in self.subscribe_fields
},
)()

View file

@ -1,34 +1,46 @@
"""Service classes for data.coop."""
from django import forms from django import forms
from .registry import ServiceInterface from .registry import ServiceInterface
class MailService(ServiceInterface): class MailService(ServiceInterface):
"""Mail service."""
slug = "mail" slug = "mail"
name = "Mail" name = "Mail"
url = "https://mail.data.coop" url = "https://mail.data.coop"
description = "Mail service for data.coop" description = "Mail service for data.coop"
subscribe_fields = (("username", forms.CharField()),)
class MatrixService(ServiceInterface): class MatrixService(ServiceInterface):
"""Matrix service."""
slug = "matrix" slug = "matrix"
name = "Matrix" name = "Matrix"
url = "https://matrix.data.coop" url = "https://matrix.data.coop"
description = "Matrix service for data.coop" description = "Matrix service for data.coop"
subscribe_fields = [ subscribe_fields = (("username", forms.CharField()),)
("username", forms.CharField()),
]
class MastodonService(ServiceInterface): class MastodonService(ServiceInterface):
"""Mastodon service."""
slug = "mastodon" slug = "mastodon"
name = "Mastodon" name = "Mastodon"
url = "https://social.data.coop" url = "https://social.data.coop"
description = "Mastodon service for data.coop" description = "Mastodon service for data.coop"
subscribe_fields = (("username", forms.CharField()),)
class NextcloudService(ServiceInterface): class NextcloudService(ServiceInterface):
"""Nextcloud service."""
slug = "nextcloud" slug = "nextcloud"
name = "Nextcloud" name = "Nextcloud"
url = "https://cloud.data.coop" url = "https://cloud.data.coop"
@ -36,6 +48,8 @@ class NextcloudService(ServiceInterface):
class HedgeDocService(ServiceInterface): class HedgeDocService(ServiceInterface):
"""HedgeDoc service."""
slug = "hedgedoc" slug = "hedgedoc"
name = "HedgeDoc" name = "HedgeDoc"
url = "https://pad.data.coop" url = "https://pad.data.coop"
@ -44,6 +58,8 @@ class HedgeDocService(ServiceInterface):
class RalllyService(ServiceInterface): class RalllyService(ServiceInterface):
"""Rallly service."""
slug = "rallly" slug = "rallly"
name = "Rallly" name = "Rallly"
url = "https://when.data.coop" url = "https://when.data.coop"

View file

@ -5,7 +5,8 @@
<div class="content-view"> <div class="content-view">
<h2>Subscribe to {{ service.name }}</h2> <h2>Subscribe to {{ service.name }}</h2>
<form> <form method="POST">
{% csrf_token %}
{{ form }} {{ form }}
<button type="submit"> <button type="submit">

View file

@ -1,10 +1,21 @@
# Create your views here. """Views for the services app."""
from django_view_decorator import namespaced_decorator_factory
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 membership.models import ServiceAccess
from services.registry import ServiceRegistry
from utils.view_utils import render 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( services_view = namespaced_decorator_factory(
namespace="services", namespace="services",
@ -17,21 +28,13 @@ services_view = namespaced_decorator_factory(
name="list", name="list",
login_required=True, login_required=True,
) )
def services_overview(request): def services_overview(request: HttpRequest) -> HttpResponse:
active_services = [ """View all services."""
access.service_implementation active_services = get_services(user=request.user)
for access in ServiceAccess.objects.filter(
user=request.user,
)
]
active_service_classes = [service.__class__ for service in active_services] active_service_classes = [service.__class__ for service in active_services]
services = [ services = [service for _, service in ServiceRegistry.get_items() if service not in active_service_classes]
service
for _, service in ServiceRegistry.get_items()
if service not in active_service_classes
]
context = { context = {
"non_active_services": services, "non_active_services": services,
@ -50,7 +53,8 @@ def services_overview(request):
name="detail", name="detail",
login_required=True, login_required=True,
) )
def service_detail(request, service_slug): def service_detail(request: HttpRequest, service_slug: str) -> HttpResponse:
"""View a service."""
service = ServiceRegistry.get(slug=service_slug) service = ServiceRegistry.get(slug=service_slug)
context = { context = {
@ -69,14 +73,28 @@ def service_detail(request, service_slug):
name="subscribe", name="subscribe",
login_required=True, login_required=True,
) )
def service_subscribe(request, service_slug): def service_subscribe(request: HttpRequest, service_slug: str) -> HttpResponse:
"""Subscribe to a service."""
service = ServiceRegistry.get(slug=service_slug) service = ServiceRegistry.get(slug=service_slug)
# TODO: add a form to subscribe to the service 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 = { context = {
"service": service, "service": service,
"base_path": "services:list", "base_path": "services:list",
"form": service.get_form(), "form": form_class(),
} }
return render( return render(
@ -84,3 +102,13 @@ def service_subscribe(request, service_slug):
template_name="services/service_subscribe.html", template_name="services/service_subscribe.html",
context=context, 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,
)
]