WIP: Services #25

Draft
valberg wants to merge 8 commits from services into main
23 changed files with 447 additions and 81 deletions

0
requirements/base.txt Normal file
View file

View file

@ -4,6 +4,7 @@ from django.contrib import admin
from .models import Membership
from .models import MembershipType
from .models import ServiceAccess
from .models import SubscriptionPeriod
@ -20,3 +21,9 @@ class MembershipTypeAdmin(admin.ModelAdmin):
@admin.register(SubscriptionPeriod)
class SubscriptionPeriodAdmin(admin.ModelAdmin):
"""Admin for SubscriptionPeriod model."""
@admin.register(ServiceAccess)
class ServiceAccessAdmin(admin.ModelAdmin):
"""Admin for ServiceAccess model."""
pass

View file

@ -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"
),
),
]

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."""
from typing import ClassVar
from typing import Self
from django.contrib.auth.models import User
@ -10,6 +9,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 utils.mixins import CreatedModifiedAbstract
@ -56,14 +56,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')}"
@ -139,3 +139,30 @@ class MembershipType(CreatedModifiedAbstract):
def __str__(self) -> str:
return self.name
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)
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"))
subscription_data = models.JSONField(
verbose_name=_("subscription data"),
null=True,
blank=True,
)
def __str__(self) -> str:
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

@ -43,12 +43,15 @@ THIRD_PARTY_APPS = [
"allauth",
"allauth.account",
"django_view_decorator",
"django_registries",
"oauth2_provider",
]
LOCAL_APPS = [
"utils",
"accounting",
"membership",
"services",
]
INSTALLED_APPS = [
@ -156,6 +159,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 = {

View file

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

View file

@ -78,13 +78,11 @@
</a>
</li>
{% comment %}
<li>
<a href="/services" class="{% active_path "services" "current" %}">
<a href="{% url "services:list" %}" class="{% active_path "services:list" "current" %}">
Services
</a>
</li>
{% endcomment %}
<li>
<a href="{% url "account_email" %}" class="{% active_path "account_email" "current" %}">

View file

@ -1,62 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="content-view">
Coming soon!
</div>
{% comment %}
<div class="content-view">
<h2>Services you subscribe to</h2>
<div class="services">
<div>
<div class="description">
<h3>Passit</h3>
<p>Passit is a service that blabla</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Unsubscribe</a>
</div>
</div>
</div>
<div class="content-view">
<h2>Available services</h2>
<div class="services">
<div>
<div class="description">
<h3>Forgejo</h3>
<p>Forgejo is a service that blabla</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Subscribe</a>
</div>
<div>
<div class="description">
<h3>Mastodon</h3>
<p>Mastodon is a service where you can write things to people around the world.</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Subscribe</a>
</div>
<div>
<div class="description">
<h3>Matrix</h3>
<p>Matrix is a service that blabla</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Subscribe</a>
</div>
<div>
<div class="description">
<h3>NextCloud</h3>
<p>NextCloud is a service that blabla</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Subscribe</a>
</div>
</div>
</div>
{% endcomment %}
{% endblock %}

View file

@ -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),
]

View file

@ -1,11 +1,12 @@
"""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 django_view_decorator import view
from utils.view_utils import render
if TYPE_CHECKING:
from django.http import HttpRequest
@ -20,13 +21,3 @@ if TYPE_CHECKING:
def index(request: HttpRequest) -> HttpResponse:
"""View to show the index page."""
return render(request, "index.html")
@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")

0
src/services/__init__.py Normal file
View file

1
src/services/admin.py Normal file
View file

@ -0,0 +1 @@
# Register your models here.

6
src/services/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ServicesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "services"

View file

1
src/services/models.py Normal file
View file

@ -0,0 +1 @@
# Create your models here.

38
src/services/registry.py Normal file
View file

@ -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),
)

67
src/services/services.py Normal file
View file

@ -0,0 +1,67 @@
"""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 RalllyService(ServiceInterface):
"""Rallly service."""
slug = "rallly"
name = "Rallly"
url = "https://when.data.coop"
public = True
description = "Rallly service for data.coop"

View file

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block content %}
<div class="content-view">
<h2>{{ service.name }}</h2>
</div>
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block content %}
<div class="content-view">
<h2>Subscribe to {{ service.name }}</h2>
<form method="POST">
{% csrf_token %}
{{ form }}
<button type="submit">
Subscribe
</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block content %}
<div class="content-view">
<h2>Services you subscribe to</h2>
<div class="services">
{% for service in active_services %}
<div>
<div class="description">
<h3>{{ service.name }}</h3>
<p>...</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Unsubscribe</a>
</div>
{% empty %}
<p>You are not subscribed to any service.</p>
{% endfor %}
</div>
</div>
<div class="content-view">
<h2>Available services</h2>
<div class="services">
{% for service in non_active_services %}
<div>
<div class="description">
<h3>{{ service.name }}</h3>
<p>{{ service.description }}</p>
<a href="{% url "services:detail" service_slug=service.slug %}">
Read more
</a>
|
<a href="{{ service.url }}" target="_blank">
Visit
</a>
</div>
<a href="{% url "services:subscribe" service_slug=service.slug %}">Subscribe</a>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

1
src/services/tests.py Normal file
View file

@ -0,0 +1 @@
# Create your tests here.

114
src/services/views.py Normal file
View file

@ -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="<str:service_slug>/",
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="<str:service_slug>/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,
)
]