Services (#25)

Dette PR handler om at få en måde hvorpå medlemmer kan få adgang til de forskellige services vi udbyder.

Co-authored-by: Halfdan Mouritzen <halfdan@robothangarskib.dk>
Reviewed-on: data.coop/membersystem#25
Reviewed-by: benjaoming <benjaoming@data.coop>
Co-authored-by: Víðir Valberg Guðmundsson <valberg@orn.li>
Co-committed-by: Víðir Valberg Guðmundsson <valberg@orn.li>
This commit is contained in:
Víðir Valberg Guðmundsson 2024-12-22 23:46:13 +00:00 committed by valberg
parent 43d5dcbd52
commit 3dd7352d90
22 changed files with 418 additions and 80 deletions

0
requirements/base.txt Normal file
View file

View file

@ -20,6 +20,7 @@ from .emails import InviteEmail
from .models import Member from .models import Member
from .models import Membership from .models import Membership
from .models import MembershipType from .models import MembershipType
from .models import ServiceAccess
from .models import SubscriptionPeriod from .models import SubscriptionPeriod
from .models import WaitingListEntry from .models import WaitingListEntry
@ -46,6 +47,12 @@ class SubscriptionPeriodAdmin(admin.ModelAdmin):
"""Admin for SubscriptionPeriod model.""" """Admin for SubscriptionPeriod model."""
@admin.register(ServiceAccess)
class ServiceAccessAdmin(admin.ModelAdmin):
"""Admin for ServiceAccess model."""
pass
class MembershipInlineAdmin(admin.TabularInline): class MembershipInlineAdmin(admin.TabularInline):
"""Inline admin.""" """Inline admin."""

View file

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

View file

@ -12,6 +12,7 @@ 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 djmoney.money import Money from djmoney.money import Money
from utils.mixins import CreatedModifiedAbstract from utils.mixins import CreatedModifiedAbstract
@ -91,14 +92,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')}"
@ -245,3 +246,30 @@ class WaitingListEntry(CreatedModifiedAbstract):
class Meta: class Meta:
verbose_name = _("waiting list entry") verbose_name = _("waiting list entry")
verbose_name_plural = _("waiting list entries") 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}"

View file

@ -46,12 +46,15 @@ THIRD_PARTY_APPS = [
"allauth", "allauth",
"allauth.account", "allauth.account",
"django_view_decorator", "django_view_decorator",
"django_registries",
"oauth2_provider",
] ]
LOCAL_APPS = [ LOCAL_APPS = [
"utils", "utils",
"accounting", "accounting",
"membership", "membership",
"services",
] ]
INSTALLED_APPS = [ INSTALLED_APPS = [
@ -160,6 +163,16 @@ ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
ACCOUNT_USERNAME_REQUIRED = 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 # Logging
# We want to log everything to stdout in docker # We want to log everything to stdout in docker
LOGGING = { LOGGING = {

View file

@ -51,6 +51,7 @@ h6 {
--light-dust: #fefef9; --light-dust: #fefef9;
--dust: #f4f1ef; --dust: #f4f1ef;
--medium-dust: #dadada; --medium-dust: #dadada;
--medium-dust : #dadada;
--dark-dust: #bfbfbf; --dark-dust: #bfbfbf;
--fade: #878787; --fade: #878787;
--twilight: #4a4a4a; --twilight: #4a4a4a;
@ -256,7 +257,7 @@ div.content-view>h2 {
div.services { div.services {
display: flex; display: flex;
justify-content: space-between; justify-content: start;
gap: var(--double-space); gap: var(--double-space);
flex-wrap: wrap; flex-wrap: wrap;
} }

View file

@ -78,13 +78,13 @@
</a> </a>
</li> </li>
{% comment %} {% if user.is_superuser %}
<li> <li>
<a href="/services" class="{% active_path "services" "current" %}"> <a href="{% url "services:list" %}" class="{% active_path "services:list" "current" %}">
Services Services
</a> </a>
</li> </li>
{% endcomment %} {% endif %}
<li> <li>
<a href="{% url "account_email" %}" class="{% active_path "account_email" "current" %}"> <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 = [ urlpatterns = [
path("", include_view_urls(extra_modules=["project.views"])), path("", include_view_urls(extra_modules=["project.views"])),
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
path("_admin/", admin.site.urls), path("_admin/", admin.site.urls),
] ]

View file

@ -1,12 +1,13 @@
"""Project views.""" """Project views."""
from __future__ import annotations 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 typing import TYPE_CHECKING
from accounting.models import Order from accounting.models import Order
from django_view_decorator import view from django_view_decorator import view
from utils.view_utils import render
if TYPE_CHECKING: if TYPE_CHECKING:
from django.http import HttpRequest from django.http import HttpRequest
@ -25,13 +26,3 @@ def index(request: HttpRequest) -> HttpResponse:
context = {"unpaid_orders": list(unpaid_orders)} context = {"unpaid_orders": list(unpaid_orders)}
return render(request, "index.html", context=context) 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")

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

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

@ -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"

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