This commit is contained in:
Víðir Valberg Guðmundsson 2024-01-14 12:49:33 +01:00
parent e603b5e559
commit d82a91d710
13 changed files with 260 additions and 109 deletions

View file

@ -1,64 +0,0 @@
# 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,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,7 +12,6 @@ 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
@ -93,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')}"
@ -250,19 +249,27 @@ class WaitingListEntry(CreatedModifiedAbstract):
class ServiceAccess(CreatedModifiedAbstract):
"""Access to a service for a user."""
class Meta:
verbose_name = _("service access")
verbose_name_plural = _("service accesses")
constraints = [
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"))
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}"

View file

@ -47,6 +47,7 @@ THIRD_PARTY_APPS = [
"allauth.account",
"django_view_decorator",
"django_registries",
"oauth2_provider",
]
LOCAL_APPS = [
@ -162,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

@ -79,7 +79,7 @@
</li>
<li>
<a href="/services" class="{% active_path "services" "current" %}">
<a href="{% url "services:list" %}" class="{% active_path "services:list" "current" %}">
Services
</a>
</li>

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

@ -26,37 +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."""
active_services = [
access.service_implementation
for access in ServiceAccess.objects.filter(
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_overview.html",
context=context,
)

View file

@ -1,15 +1,18 @@
"""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"""
"""Registry for services."""
implementations_module = "services"
class ServiceInterface(Interface):
"""Interface for services"""
"""Interface for services."""
registry = ServiceRegistry
@ -23,3 +26,13 @@ class ServiceInterface(Interface):
# - 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),
)

View file

@ -1,28 +1,46 @@
"""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"
@ -30,6 +48,8 @@ class NextcloudService(ServiceInterface):
class HedgeDocService(ServiceInterface):
"""HedgeDoc service."""
slug = "hedgedoc"
name = "HedgeDoc"
url = "https://pad.data.coop"
@ -38,6 +58,8 @@ class HedgeDocService(ServiceInterface):
class RalllyService(ServiceInterface):
"""Rallly service."""
slug = "rallly"
name = "Rallly"
url = "https://when.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

@ -28,11 +28,16 @@
<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>Subscribe</a>
<a href="{% url "services:subscribe" service_slug=service.slug %}">Subscribe</a>
</div>
{% endfor %}
</div>

View file

@ -1 +1,114 @@
# Create your views here.
"""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,
)
]