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 Membership
from .models import MembershipType from .models import MembershipType
from .models import ServiceAccess
from .models import SubscriptionPeriod from .models import SubscriptionPeriod
@ -18,3 +19,8 @@ class MembershipTypeAdmin(admin.ModelAdmin):
@admin.register(SubscriptionPeriod) @admin.register(SubscriptionPeriod)
class SubscriptionPeriodAdmin(admin.ModelAdmin): class SubscriptionPeriodAdmin(admin.ModelAdmin):
pass 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.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 utils.mixins import CreatedModifiedAbstract from utils.mixins import CreatedModifiedAbstract
@ -111,3 +113,22 @@ class MembershipType(CreatedModifiedAbstract):
def __str__(self) -> str: def __str__(self) -> str:
return self.name 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",
"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 = [
@ -154,6 +157,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

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

View File

@ -78,13 +78,11 @@
</a> </a>
</li> </li>
{% comment %}
<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 %}
<li> <li>
<a href="{% url "account_email" %}" class="{% active_path "account_email" "current" %}"> <a href="{% url "account_email" %}" class="{% active_path "account_email" "current" %}">
@ -113,7 +111,7 @@
themeSwitcher.addEventListener('click', function() { themeSwitcher.addEventListener('click', function() {
themeSwitcher.classList.toggle('active') themeSwitcher.classList.toggle('active')
let isDark = document.querySelector('html').classList.toggle('dark'); let isDark = document.querySelector('html').classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light'); localStorage.setItem('theme', isDark ? 'dark' : 'light');
}); });
</script> </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 = [ 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),
] ]

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