WIP: Services #25

Draft
valberg wants to merge 11 commits from services into main
20 changed files with 366 additions and 67 deletions

View File

@ -2,6 +2,7 @@ from django.contrib import admin
from .models import Membership
from .models import MembershipType
from .models import ServiceAccess
from .models import SubscriptionPeriod
@ -18,3 +19,8 @@ class MembershipTypeAdmin(admin.ModelAdmin):
@admin.register(SubscriptionPeriod)
class SubscriptionPeriodAdmin(admin.ModelAdmin):
pass
@admin.register(ServiceAccess)
class ServiceAccessAdmin(admin.ModelAdmin):
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

@ -5,6 +5,8 @@ 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
@ -111,3 +113,22 @@ class MembershipType(CreatedModifiedAbstract):
def __str__(self) -> str:
return self.name
class ServiceAccess(CreatedModifiedAbstract):
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"))
def __str__(self):
return f"{self.user} - {self.service}"

View File

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

@ -556,4 +556,4 @@ span.time_remaining {
.pagination .page-item.disabled .page-link {
cursor: default;
}
}

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" %}">
@ -113,7 +111,7 @@
themeSwitcher.addEventListener('click', function() {
themeSwitcher.classList.toggle('active')
let isDark = document.querySelector('html').classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
</script>

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

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

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.

40
src/services/registry.py Normal file
View File

@ -0,0 +1,40 @@
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: list[tuple[str, forms.Field]] = []
def get_form(self) -> type:
"""Get the form for the service"""
print(self.subscribe_fields)
return type(
"ServiceForm",
(forms.Form,),
{
field_name: field_type
for field_name, field_type in self.subscribe_fields
},
)()

51
src/services/services.py Normal file
View File

@ -0,0 +1,51 @@
from django import forms
from .registry import ServiceInterface
class MailService(ServiceInterface):
slug = "mail"
name = "Mail"
url = "https://mail.data.coop"
description = "Mail service for data.coop"
class MatrixService(ServiceInterface):
slug = "matrix"
name = "Matrix"
url = "https://matrix.data.coop"
description = "Matrix service for data.coop"
subscribe_fields = [
("username", forms.CharField()),
]
class MastodonService(ServiceInterface):
slug = "mastodon"
name = "Mastodon"
url = "https://social.data.coop"
description = "Mastodon service for data.coop"
class NextcloudService(ServiceInterface):
slug = "nextcloud"
name = "Nextcloud"
url = "https://cloud.data.coop"
description = "Nextcloud service for data.coop"
class HedgeDocService(ServiceInterface):
slug = "hedgedoc"
name = "HedgeDoc"
url = "https://pad.data.coop"
public = True
description = "HedgeDoc service for data.coop"
class RalllyService(ServiceInterface):
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,18 @@
{% extends "base.html" %}
{% block content %}
<div class="content-view">
<h2>Subscribe to {{ service.name }}</h2>
<form>
{{ 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.

86
src/services/views.py Normal file
View File

@ -0,0 +1,86 @@
# Create your views here.
from django_view_decorator import namespaced_decorator_factory
from membership.models import ServiceAccess
from services.registry import ServiceRegistry
from utils.view_utils import render
services_view = namespaced_decorator_factory(
namespace="services",
base_path="services",
)
@services_view(
paths="",
name="list",
login_required=True,
)
def services_overview(request):
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/services_overview.html",
context=context,
)
@services_view(
paths="<str:service_slug>/",
name="detail",
login_required=True,
)
def service_detail(request, service_slug):
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, service_slug):
service = ServiceRegistry.get(slug=service_slug)
# TODO: add a form to subscribe to the service
context = {
"service": service,
"base_path": "services:list",
"form": service.get_form(),
}
return render(
request=request,
template_name="services/service_subscribe.html",
context=context,
)