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: #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 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."""

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

View file

@ -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 = {

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,13 @@
</a>
</li>
{% comment %}
{% if user.is_superuser %}
<li>
<a href="/services" class="{% active_path "services" "current" %}">
<a href="{% url "services:list" %}" class="{% active_path "services:list" "current" %}">
Services
</a>
</li>
{% endcomment %}
{% endif %}
<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,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")

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