Compare commits

..

1 commit

Author SHA1 Message Date
f97b39b87f
feat(dgsi/wifi): Add an IOS profile tentative 2025-01-31 15:29:09 +01:00
38 changed files with 271 additions and 1011 deletions

View file

@ -68,7 +68,6 @@ in
ps.django-bulma-forms
ps.django-compressor
ps.django-debug-toolbar
ps.django-htmx
ps.django-import-export
ps.django-sass-processor
ps.django-sass-processor-dart-sass
@ -78,9 +77,6 @@ in
ps.loadcredential
ps.pykanidm
ps.python-cas
ps.django-extensions
ps.werkzeug
ps.pyopenssl
]
++ ps.django-allauth.optional-dependencies.saml
))
@ -92,7 +88,6 @@ in
DGSI_STATIC_ROOT = builtins.toString ./.static;
DGSI_MEDIA_ROOT = builtins.toString ./.media;
DGSI_KANIDM_CLIENT = "dgsi_test";
DGSI_ARCHIVES_ROOT = builtins.toString ./.archives;
};
shellHook = ''

View file

@ -8,15 +8,15 @@
"repo": "git-hooks.nix"
},
"branch": "master",
"revision": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17",
"url": "https://github.com/cachix/git-hooks.nix/archive/9364dc02281ce2d37a1f55b6e51f7c0f65a75f17.tar.gz",
"hash": "1n2qlj5l8c4g7gm5v6rvc4hff3ka8ljv7y62inybli093bd2ypa7"
"revision": "4e743a6920eab45e8ba0fbe49dc459f1423a4b74",
"url": "https://github.com/cachix/git-hooks.nix/archive/4e743a6920eab45e8ba0fbe49dc459f1423a4b74.tar.gz",
"hash": "0fc69dsn5rhv2zb16c2bfgx84ja8cmn7d7j2mrw3n4m8y611x40g"
},
"nixpkgs": {
"type": "Channel",
"name": "nixpkgs-unstable",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.05pre745387.762a39889257/nixexprs.tar.xz",
"hash": "1pyr54allmvlxd5q4nhflmbdmma41nv2cib3i1qbrhaqm65vf97x"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-24.11pre685691.28b5b8af91ff/nixexprs.tar.xz",
"hash": "14ldh9js6l9nqch7j8z6nhyplxc5d9jw375pg8h4s24m7x37xnvy"
}
},
"version": 3

View file

@ -43,7 +43,6 @@ INSTALLED_APPS = [
"sass_processor",
"bulma",
"import_export",
"django_htmx",
# Authentication
"allauth",
"allauth.account",
@ -68,7 +67,6 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"django_browser_reload.middleware.BrowserReloadMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
@ -237,12 +235,6 @@ ACCOUNT_AUTHENTICATION_METHOD = "username"
AUTH_PASSWORD_VALIDATORS = []
AUTH_USER_MODEL = "dgsi.User"
DGSI_STAFF_GROUP = credentials.get("STAFF_GROUP", "dgnum_bureau@sso.dgnum.eu")
DGSI_SUPERUSER_GROUP = credentials.get("SUPERUSER_GROUP", "dgnum_admins@sso.dgnum.eu")
VLAN_ID_MAX = 4094
VLAN_ID_MIN = (VLAN_ID_MAX - 850) + 1
###
# Internationalization configuration
@ -285,7 +277,6 @@ MEDIA_ROOT = credentials.get("MEDIA_ROOT")
###
# Storages configuration
ARCHIVES_INTERNAL = credentials.get("ARCHIVES_INTERNAL", "_archives")
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
@ -293,13 +284,6 @@ STORAGES = {
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
},
"archives": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
"location": credentials["ARCHIVES_ROOT"],
"base_url": f"/{ARCHIVES_INTERNAL}/",
},
},
}
###
@ -344,7 +328,6 @@ UNFOLD = {
if DEBUG:
INSTALLED_APPS += [
"debug_toolbar",
"django_extensions",
]
MIDDLEWARE += [

View file

@ -33,8 +33,4 @@ if settings.DEBUG:
path("__debug__/", include("debug_toolbar.urls")),
*static(settings.STATIC_URL, document_root=settings.STATIC_ROOT),
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
*static(
settings.STORAGES["archives"]["OPTIONS"]["base_url"],
document_root=settings.STORAGES["archives"]["OPTIONS"]["location"],
),
]

View file

@ -10,7 +10,7 @@ from unfold.contrib.import_export.forms import (
SelectableFieldsExportForm,
)
from dgsi.models import Archive, Bylaws, Service, Statutes, Translation, User
from dgsi.models import Bylaws, Service, Statutes, Translation, User
assert DjangoUserAdmin.fieldsets is not None
@ -44,15 +44,9 @@ class UserAdmin(DjangoUserAdmin, ImportExportMixin, ModelAdmin):
export_form_class = ExportForm
export_form_class = SelectableFieldsExportForm
readonly_fields = ("vlan_id",)
# Add the local fields
fieldsets = (
*DjangoUserAdmin.fieldsets,
(
_("Informations réseau"),
{"fields": ("vlan_id",)},
),
(
_("Documents DGNum"),
{"fields": ("accepted_statutes", "accepted_bylaws")},
@ -60,7 +54,7 @@ class UserAdmin(DjangoUserAdmin, ImportExportMixin, ModelAdmin):
)
@admin.register(Archive, Bylaws, Service, SocialAccount, Statutes, Translation)
@admin.register(Bylaws, Service, SocialAccount, Statutes, Translation)
class AdminClass(ImportExportMixin, ModelAdmin):
compressed_fields = True
import_form_class = ImportForm

View file

@ -1,46 +0,0 @@
# Generated by Django 4.2.16 on 2025-01-25 15:27
from django.db import migrations, models
import dgsi.models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0008_alter_user_accepted_statutes"),
]
operations = [
migrations.CreateModel(
name="Archive",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateField(verbose_name="Date du document")),
(
"name",
models.CharField(max_length=255, verbose_name="Nom du document"),
),
(
"file",
models.FileField(
storage=dgsi.models.get_storage,
upload_to="",
verbose_name="Fichier PDF",
),
),
],
options={
"verbose_name": "Document d'archives",
"verbose_name_plural": "Documents d'archives",
},
),
]

View file

@ -1,30 +0,0 @@
# Generated by Django 4.2.16 on 2025-01-30 08:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0009_archive"),
]
operations = [
migrations.AlterModelOptions(
name="user",
options={},
),
migrations.AddField(
model_name="user",
name="vlan_id",
field=models.PositiveSmallIntegerField(
null=True, verbose_name="VLAN associé au compte"
),
),
migrations.AddConstraint(
model_name="user",
constraint=models.UniqueConstraint(
fields=("vlan_id",), name="unique_vlan_attribution"
),
),
]

View file

@ -1,50 +1,9 @@
from typing import Any
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin, UserPassesTestMixin
from django.http import HttpRequest, HttpResponseBase, HttpResponseRedirect
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import ContextMixin, TemplateResponseMixin
from django.views.generic.detail import SingleObjectMixin
from django.contrib.auth.mixins import UserPassesTestMixin
from django.http import HttpRequest
from dgsi.models import User
class KanidmAccountRequiredMixin(AccessMixin):
"""
Mixin used to require the existence of a kanidm account.
"""
require_radius_secret: bool = False
def dispatch(
self, request: HttpRequest, *args: Any, **kwargs: Any
) -> HttpResponseBase:
if not request.user.is_authenticated:
return self.handle_no_permission()
self._user = User.from_request(request)
if self._user.kanidm is None:
messages.add_message(
request,
messages.WARNING,
_("<b>Veuillez créer un compte DGNum.</b>"),
)
return HttpResponseRedirect(reverse_lazy("dgsi:create_self_account"))
if self.require_radius_secret and self._user.kanidm.radius_secret is None:
messages.add_message(
request,
messages.WARNING,
_("<b>Veuillez générer un mot de passe Wi-Fi.</b>"),
)
return HttpResponseRedirect(reverse_lazy("dgsi:profile"))
return super().dispatch(request, *args, **kwargs) # type: ignore
class StaffRequiredMixin(UserPassesTestMixin):
request: HttpRequest
@ -54,29 +13,8 @@ class StaffRequiredMixin(UserPassesTestMixin):
assert isinstance(self.request.user, User)
return self.request.user.is_staff
return self.request.user.is_admin
def get_context_data(self, **kwargs):
# NOTE: We are only allowed to do this if a class is supplied to the right when constructing the view
return super().get_context_data(admin_view=True, **kwargs) # pyright: ignore
class HtmxPostMixin(TemplateResponseMixin, ContextMixin):
http_method_names = ["post"]
def execute_action(self, *args, **kwargs) -> None:
return None
def post(self, request, *args, **kwargs):
# Execute action
self.execute_action()
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
class HtmxPostObjectMixin(SingleObjectMixin, HtmxPostMixin):
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return super().post(request, *args, **kwargs)

View file

@ -6,10 +6,8 @@ from typing import Optional, Self
from aiohttp.client_exceptions import ClientConnectorError
from allauth.socialaccount.models import SocialAccount
from asgiref.sync import async_to_sync
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.core.files import storage
from django.db import models, transaction
from django.db import models
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
@ -17,9 +15,9 @@ from django.utils.translation import gettext_lazy as _
from kanidm.exceptions import NoMatchingEntries
from kanidm.models.person import Person
from shared.kanidm import klient, sync_call
from shared.kanidm import klient
logger = logging.getLogger(__name__)
ADMIN_GROUP = "dgnum_admins@sso.dgnum.eu"
class Service(models.Model):
@ -39,8 +37,6 @@ class LegalDocument(models.Model):
name = models.CharField(_("Nom du document"), max_length=255)
file = models.FileField(_("Fichier PDF"))
icon = "script"
@classmethod
def latest(cls, **kwargs) -> Self | None:
return cls.objects.filter(**kwargs).latest()
@ -58,7 +54,6 @@ class Statutes(LegalDocument):
"""
kind = "statutes"
color = "primary"
class Meta: # pyright: ignore
get_latest_by = "date"
@ -72,7 +67,6 @@ class Bylaws(LegalDocument):
"""
kind = "bylaws"
color = "success"
class Meta: # pyright: ignore
get_latest_by = "date"
@ -80,30 +74,6 @@ class Bylaws(LegalDocument):
verbose_name_plural = _("Règlements Intérieurs")
def get_storage(*args, **kwargs):
return storage.storages["archives"]
class Archive(models.Model):
"""
Archived documents for the association.
"""
date = models.DateField(_("Date du document"))
name = models.CharField(_("Nom du document"), max_length=255)
file = models.FileField(_("Fichier PDF"), storage=get_storage)
icon = "archive"
color = "warning"
def __str__(self) -> str:
return self.name
class Meta: # pyright: ignore
verbose_name = _("Document d'archives")
verbose_name_plural = _("Documents d'archives")
# class TermsAndConditions(LegalDocument):
# """
# Terms and Conditions of use regarding a service offered by the association.
@ -148,7 +118,7 @@ class Translation(models.Model):
# INFO: We need to use a signal receiver here, as the delete method is not called
# when deleting objects from the admin interface
# when deleteing objects from the admin interface
@receiver(pre_delete, sender=Translation)
def restore_username(**kwargs):
"""
@ -186,8 +156,6 @@ class User(AbstractUser):
)
# accepted_terms = models.ManyToManyField(TermsAndConditions)
vlan_id = models.PositiveSmallIntegerField(_("VLAN associé au compte"), null=True)
@classmethod
def from_request(cls, request: HttpRequest) -> Self:
u = request.user
@ -212,94 +180,8 @@ class User(AbstractUser):
logging.error(f"Erreur lors de la requête à Kanidm: {e}")
return None
def part_of(self, group: str) -> bool:
return (self.kanidm is not None) and (group in self.kanidm.person.memberof)
def can_access_archive(self, archive: Archive) -> bool:
# Prepare a more complex workflow
return True
###
# VLAN attribution machinery
#
# NOTE: It is a bit cumbersome because we need to store the vlan_id
# information both in DG·SI and in Kanidm
# Now the question will be « Which is the source of truth ? »
# For now, I believe it has to be DG·SI, so a sync script will
# have to be run regularly.
@transaction.atomic
def set_unique_vlan_id(self):
if self.vlan_id is not None:
raise ValueError(_("Ce compte a déjà un VLAN associé"))
self.vlan_id = min(
set(range(settings.VLAN_ID_MIN, settings.VLAN_ID_MAX))
- set(
User.objects.exclude(vlan_id__isnull=True).values_list(
"vlan_id", flat=True
)
)
@property
def is_admin(self) -> bool:
return (self.kanidm is not None) and (
ADMIN_GROUP in self.kanidm.person.memberof
)
# Preempt the vlan attribution
self.save(update_fields=["vlan_id"])
@transaction.atomic
def register_unique_vlan(self) -> None:
self.set_unique_vlan_id()
group_name = f"vlan_{self.vlan_id}"
# Add the user to the group requested group
sync_call("group_add_members", group_name, [self.username])
# Check that we succeeded in setting a VLAN that is unique to the current user
group = sync_call("group_get", group_name)
if group.member == []:
# Something went wrong
self.vlan_id = None
self.save(update_fields=["vlan_id"])
raise RuntimeError("VLAN attribution failed")
if group.member != [f"{self.username}@sso.dgnum.eu"]:
# Remove the user from the group
sync_call("group_delete_members", group_name, [self.username])
self.vlan_id = None
self.save(update_fields=["vlan_id"])
raise RuntimeError("Duplicate VLAN attribution detected")
def reclaim_vlan(self):
if self.vlan_id is None:
# Nothing to do, just return
logger.warning(
f"Reclaiming VLAN for {self.username} who does not have one."
)
return
group_name = f"vlan_{self.vlan_id}"
sync_call("group_delete_members", group_name, [self.username])
# Check that the call succeeded
try:
group = sync_call("group_get", group_name)
if "{self.username}@sso.dgnum.eu" in group.member:
raise RuntimeError(
f"Something went wrong in trying to reclaim vlan {self.vlan_id}"
)
except ValueError:
# The group does not exist apparently, keep going
logger.warning(
f"Reclaiming VLAN {self.vlan_id}, but the associated group does not exist."
)
finally:
self.vlan_id = None
self.save(update_fields=["vlan_id"])
class Meta:
constraints = [
models.UniqueConstraint(fields=["vlan_id"], name="unique_vlan_attribution")
]

View file

@ -1,5 +1,4 @@
<a class="button bt-link is-light {{ link.color }}"
href="{% if link.absolute %}{{ link.reverse }}{% else %}{% url link.reverse %}{% endif %}">
<a class="button bt-link is-light {{ link.color }}" href="{% url link.reverse %}">
{% if link.icon %}<span class="icon"><i class="ti ti-{{ link.icon }}"></i></span>{% endif %}
<span>{{ link.text }}</span>
</a>

View file

@ -5,7 +5,7 @@
<span class="tags is-pulled-right">
{% if user_document != document %}
<a class="tag is-warning"
href="{% url "dgsi:accept_legal_document" document.kind %}"
href="{% url "dgsi:dgn-accept_legal_document" document.kind %}"
onclick="return confirm(('{% trans " En acceptant, vous assurez avoir lu ce document et en approuver le contenu." %}'))">
<span>{{ accept_question }}</span>
<span class="icon is-size-6"><i class="ti ti-alert-circle"></i></span>
@ -21,6 +21,6 @@
</h2>
<a class="button bt-link" href="{{ document.file.url }}">
<span class="ellipsis">{{ document }}</span>
<span>{{ document }}</span>
<span class="icon"><i class="ti ti-file-download"></i></span>
</a>

View file

@ -1,12 +0,0 @@
{% load i18n %}
<h2 class="subtitle">
{% trans subtitle %}
<a class="button is-small is-pulled-right is-primary" href="{% url backlink|default:'dgsi:index' %}">
<span class="icon">
<i class="ti ti-arrow-big-left-filled"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</h2>
<hr>

View file

@ -1,20 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% trans "Archives de la DGNum" as subtitle %}
{% include "_subtitle.html" %}
{% for file in document_list %}
<a class="button bt-archive"
{% if file.kind == "statutes" or file.kind == "bylaws" %} href="{{ file.file.url }}" {% else %} href="{% url "dgsi:protected_archive" file.pk %}" {% endif %}>
<span class="tag is-{{ file.color }} is-pulled-left">
<span class="icon"><i class="ti ti-{{ file.icon }}"></i></span>
</span>
<span class="ellipsis mx-2">{{ file }}</span>
<span class="tag is-pulled-right">{{ file.date }}</span>
</a>
{% endfor %}
{% endblock content %}

View file

@ -3,16 +3,16 @@
{% load i18n %}
{% block content %}
{% trans "Création de compte Kanidm" as subtitle %}
{% include "_subtitle.html" %}
<h2 class="subtitle">{% trans "Création de compte Kanidm" %}</h2>
<hr>
<form method="post">
{% csrf_token %}
{% include "bulma/form.html" with form=form %}
<button class="button is-fullwidth mt-6">
<span class="icon"><i class="ti ti-check"></i></span>
<span>{% trans "Enregistrer" %}</span>
<span class="icon"><i class="ti ti-check"></i></span>
</button>
</form>
{% endblock content %}

View file

@ -3,16 +3,16 @@
{% load i18n %}
{% block content %}
{% trans "Création d'un compte DGNum" as subtitle %}
{% include "_subtitle.html" %}
<h2 class="subtitle">{% trans "Création d'un compte DGNum" %}</h2>
<hr>
<form method="post">
{% csrf_token %}
{% include "bulma/form.html" with form=form %}
<button class="button is-fullwidth mt-6">
<span class="icon"><i class="ti ti-check"></i></span>
<span>{% trans "Enregistrer" %}</span>
<span class="icon"><i class="ti ti-check"></i></span>
</button>
</form>
{% endblock content %}

View file

@ -10,7 +10,7 @@
{% endfor %}
{% endif %}
{% if user.is_staff %}
{% if user.is_admin %}
<hr>
{% for link in links.admin %}
{% include "_index_link.html" %}

View file

@ -3,8 +3,8 @@
{% load i18n %}
{% block content %}
{% trans "Documents légaux" as subtitle %}
{% include "_subtitle.html" %}
<h2 class="subtitle">Documents Légaux</h2>
<hr>
{% if user.kanidm is None %}
{% if show_message %}
@ -16,7 +16,7 @@
<b>{% trans "Vous n'avez pas encore de compte DGNum, mais vous pouvez désormais en créer un." %}</b>
<br>
<a class="button mt-5 is-light"
href="{% url "dgsi:create_self_account" %}">{% trans "Poursuivre la création d'un compte DGNum" %}</a>
href="{% url "dgsi:dgn-create_self_account" %}">{% trans "Poursuivre la création d'un compte DGNum" %}</a>
</div>
{% endif %}
{% endif %}

View file

@ -1,42 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% include "_subtitle.html" with subtitle="Mentions Légales" %}
<section class="section content">
<p class="is-size-4">Éditeur</p>
<p>Ce site web est édité par la Délégation Générale Numérique.</p>
<p>
<b>Délégation Générale Numérique (DGNum)</b>
<br>
Association de loi 1901
</p>
<p>
Siège social&nbsp;:
<br>
<i>45 rue d'Ulm, 75005 Paris - France</i>
</p>
<p>Directeur de publication&nbsp;: Jean-Marc Gailis</p>
<p>Contact&nbsp;: contact[at]dgnum.eu</p>
<hr>
<p class="is-size-4">Hébergeur</p>
<p>Ce site web est hébergé par la Délégation Générale Numérique.</p>
<p>
<b>Délégation Générale Numérique (DGNum)</b>
<br>
Association de loi 1901
</p>
<p>
Siège social&nbsp;:
<br>
<i>45 rue d'Ulm, 75005 Paris - France</i>
</p>
<p>Directeur de publication&nbsp;: Jean-Marc Gailis</p>
<p>Contact&nbsp;: contact[at]dgnum.eu</p>
</section>
{% endblock content %}

View file

@ -1,30 +0,0 @@
{% load i18n %}
{% if user.kanidm %}
<h3 class="has-text-weight-bold mb-3">
<span>{% trans "Mot de passe WiFi :" %}</span>
{% if user.kanidm.radius_secret %}
<a class="button is-small is-danger is-pulled-right"
hx-post="{% url "dgsi:generate_wifi_password" %}"
hx-confirm="{% trans "Êtes-vous sûr·e de vouloir réinitialiser votre mot de passe WiFi ?" %}">
<span class="icon"><i class="ti ti-refresh"></i></span>
<span class="has-text-weight-normal">{% trans "Réinitialiser le mot de passe WiFi" %}</span>
</a>
{% endif %}
</h3>
{% if user.kanidm.radius_secret %}
<div class="buttons has-addons is-flex">
<input id="radius-secret"
data-select
class="button is-primary is-size-4 is-flex-grow-2"
value="{{ user.kanidm.radius_secret }}"
type="password"
readonly />
<a id="secret-toggle" class="button is-size-4 is-warning is-light"><span class="icon"><i class="ti ti-eye"></i></span></a>
</div>
{% else %}
<a hx-post="{% url "dgsi:generate_wifi_password" %}"
class="button is-fullwidth is-primary is-light is-size-4 block">{% trans "Générer un mot de passe WiFi" %}</a>
{% endif %}
{% endif %}

View file

@ -1,21 +0,0 @@
{% load i18n %}
<tr id="user-{{ person.pk }}">
<th>{{ person.username }}</th>
<td>{{ person.first_name }}&nbsp;{{ person.last_name }}</td>
<td>{{ person.email }}</td>
<td>{{ person.vlan_id|default:"" }}</td>
<td>
{% if person.vlan_id %}
<a hx-post="{% url "dgsi:user_deassign_vlan" person.pk %}"
hx-target="#user-{{ person.pk }}"
class="button is-fullwidth is-light is-warning">{% trans "Désallouer" %}</a>
{% elif person.kanidm %}
<a hx-post="{% url "dgsi:user_assign_vlan" person.pk %}"
hx-target="#user-{{ person.pk }}"
class="button is-fullwidth is-light is-primary">{% trans "Allouer" %}</a>
{% else %}
<button class="button is-fullwidth is-static">{% trans "Pas de compte" %}</button>
{% endif %}
</td>
</tr>

View file

@ -2,111 +2,69 @@
{% load i18n %}
{% block extra_head %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const secret = document.getElementById("radius-secret");
const toggle = document.getElementById("secret-toggle");
toggle.addEventListener("click", () => {
if (secret.type === "password") {
secret.type = "text";
toggle.innerHTML = `<span class="icon"><i class="ti ti-eye-off"></i></span>`;
} else {
secret.type = "password";
toggle.innerHTML = `<span class="icon"><i class="ti ti-eye"></i></span>`;
}
})
})
</script>
{% endblock extra_head %}
{% block content %}
{% trans "Profil personnel" as subtitle %}
{% include "_subtitle.html" %}
<h3 class="has-text-weight-bold mb-3">{% trans "Nom d'utilisateur :" %}</h3>
<input data-select
class="button is-fullwidth"
value="{{ user.username }}"
readonly />
<br>
{% include "dgsi/partials/profile-radius_secret.html" %}
<h3 class="has-text-weight-bold mb-3">{% trans "Nom d'usage :" %}</h3>
<input data-select
class="button is-fullwidth"
value="{{ user.first_name }}&nbsp;{{ user.last_name|upper }}"
readonly />
<br>
<h3 class="has-text-weight-bold mb-3">{% trans "Adresse e-mail :" %}</h3>
<input data-select
class="button is-fullwidth"
value="{{ user.email }}"
readonly />
<br>
{% if user.kanidm and user.kanidm.radius_secret %}
<div class="buttons">
<a href="{% url "dgsi:apple_profile" %}" class="button is-light">
<span class="icon"><i class="ti ti-brand-apple-filled"></i></span>
<span>{% trans "Télécharger le profil Wi-Fi DGNum pour iOS, iPadOS et macOS" %}</span>
</a>
</div>
{% endif %}
<h2 class="subtitle">
<span>{% blocktrans %}Profil de {{ displayname }}{% endblocktrans %}</span>
<span class="tag is-primary is-medium is-pulled-right">{{ user.username }}</span>
</h2>
<hr>
{% if user.kanidm %}
<h2 class="subtitle mt-4">
{% trans "Informations techniques" %}
<a class="button is-small is-primary is-light is-pulled-right"
data-toggle="on"
data-target="#technical-info"
data-class="is-hidden"
data-on-html="<span>{% trans "Afficher" %}</span>"
data-off-html="<span>{% trans "Cacher" %}</span>">{% trans "Afficher" %}</a>
</h2>
<hr>
<h3 class="has-text-weight-bold mb-3">
<span>{% trans "Mot de passe WiFi :" %}</span>
{% if user.kanidm.radius_secret %}
{% trans "Êtes-vous sûr·e de vouloir réinitialiser votre mot de passe WiFi ?" as confirm_wifi_reset %}
<a href="{% url "dgsi:dgn-generate_wifi_password" %}"
class="tag is-warning is-light is-medium is-pulled-right"
onclick="return confirm('{{ confirm_wifi_reset }}')">{% trans "Réinitialiser le mot de passe WiFi" %}</a>
{% endif %}
</h3>
<div id="technical-info" class="is-hidden">
<h3 class="has-text-weight-bold mb-3">{% trans "Identifiant interne :" %}</h3>
<input data-select
class="button is-fullwidth is-light"
value="{{ user.kanidm.person.uuid }}"
{% if user.kanidm.radius_secret %}
<input id="radius-secret"
onclick="document.querySelector('#radius-secret').select()"
class="button is-fullwidth is-primary is-size-4"
value="{{ user.kanidm.radius_secret }}"
readonly />
<br>
{% else %}
<a href="{% url "dgsi:dgn-generate_wifi_password" %}"
class="button is-fullwidth is-primary is-light is-size-4 block">{% trans "Générer un mot de passe WiFi" %}</a>
{% endif %}
{% endif %}
{% if user.vlan_id %}
<h3 class="has-text-weight-bold mb-3">{% trans "VLAN attribué :" %}</h3>
<input data-select
class="button is-fullwidth"
value="{{ user.vlan_id }}"
readonly />
<br>
{% else %}
<div class="notification is-warning is-light has-text-centered">
<b>{% trans "Pas de VLAN attribué." %}</b>
<h3 class="has-text-weight-bold mb-3">{% trans "Adresse e-mail :" %}</h3>
<span class="button is-fullwidth">{{ user.email }}</span>
<br>
{% if user.kanidm %}
<h2 class="subtitle mt-4">{% trans "Informations techniques" %}</h2>
<hr>
<h3 class="has-text-weight-bold mb-3">{% trans "Identifiant unique :" %}</h3>
<input id="uuid"
onclick="document.querySelector('#uuid').select()"
class="button is-fullwidth"
value="{{ user.kanidm.person.uuid }}"
readonly />
<br>
<h3 class="has-text-weight-bold mb-3">{% trans "Membre des groupes suivants :" %}</h3>
<div class="grid groups">
{% for group in user.kanidm.person.memberof %}
<div class="cell button is-static">
<span>{{ group }}</span>
</div>
{% endif %}
<h3 class="has-text-weight-bold mb-3">{% trans "Membre des groupes suivants :" %}</h3>
<div class="grid groups">
{% for group in user.kanidm.person.memberof %}
<div class="cell button is-static">
<span>{{ group }}</span>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% else %}
<div class="notification is-primary is-light has-text-centered mt-6">
<b>{% trans "Pas de compte DGNum répertorié." %}</b>
<br>
<a class="button mt-5 is-light"
href="{% url "dgsi:create_self_account" %}">{% trans "Créer un compte DGNum" %}</a>
href="{% url "dgsi:dgn-create_self_account" %}">{% trans "Créer un compte DGNum" %}</a>
</div>
{% endif %}
{% endblock content %}

View file

@ -5,12 +5,8 @@
<key>ConsentText</key>
<dict>
<key>default</key>
<string>Souhaitez-vous configurer votre appareil pour utiliser le Wi-Fi DGNum ?</string>
<key>en</key>
<string>Dou you want to configure your device to use the DGNum Wi-Fi ?</string>
<string>Consent Message</string>
</dict>
<key>PayloadUUID</key>
<string>304283f2-b4df-4f54-9fd9-9c8e1fdc778f</string>
<key>PayloadContent</key>
<array>
<dict>
@ -32,17 +28,15 @@
<string>1.2</string>
<key>TLSTrustedServerNames</key>
<array>
<string>radius.dgnum.eu</string>
</array>
<string>radius.dgnum.eu</string>
</array>
<key>UserName</key>
<string>{{ user.username }}</string>
<string>{{ user.username }}</string>
<key>UserPassword</key>
<string>{{ user.kanidm.radius_secret }}</string>
<string>{{ user.kanidm.radius_secret }}</string>
<key>TTLSInnerAuthentication</key>
<string>MSCHAPv2</string>
</dict>
<key>PayloadUUID</key>
<string>a9a6e20c-1d9e-497a-b10c-f93a62e3e7df</string>
<key>EncryptionType</key>
<string>WPA2</string>
<key>HIDDEN_NETWORK</key>
@ -50,11 +44,11 @@
<key>IsHotspot</key>
<false/>
<key>PayloadDescription</key>
<string>DGNum Wi-Fi configuration</string>
<string>DGNum Wi-Fi setup</string>
<key>PayloadDisplayName</key>
<string>Wi-Fi</string>
<key>PayloadIdentifier</key>
<string>com.apple.wifi.managed.a9a6e20c-1d9e-497a-b10c-f93a62e3e7df</string>
<string>com.apple.wifi.managed</string>
<key>PayloadType</key>
<string>com.apple.wifi.managed</string>
<key>PayloadVersion</key>
@ -66,17 +60,19 @@
</dict>
</array>
<key>PayloadDescription</key>
<string>DGNum Wi-Fi configuration</string>
<string>Wi-Fi</string>
<key>PayloadDisplayName</key>
<string>Wi-Fi DGNum</string>
<string>wifi-dgnum</string>
<key>PayloadIdentifier</key>
<string>dgnum-radius.304283f2-b4df-4f54-9fd9-9c8e1fdc778f</string>
<string>kanidm</string>
<key>PayloadOrganization</key>
<string>Délégation Générale Numérique</string>
<string>dgnum.eu</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>E3A2F3F5-88E9-40B9-985C-1B35BD5314B3</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>

View file

@ -3,13 +3,13 @@
{% load i18n %}
{% block content %}
{% trans "Services accessibles via la DGNum" as subtitle %}
{% include "_subtitle.html" %}
<h2 class="subtitle">{% trans "Services accessibles via la DGNum" %}</h2>
<hr>
<div class="buttons bt-links">
{% for service in service_list %}
<a class="button is-medium"
href="{% url "dgsi:service_redirect" service.pk %}">
href="{% url "dgsi:dgn-services_redirect" service.pk %}">
<span class="icon"><i class="ti ti-{{ service.icon }}"></i></span>
<span>{{ service.name }}</span>
</a>

View file

@ -1,27 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% include "_subtitle.html" with subtitle="Comptes DG·SI" %}
<div class="table-container">
<table class="table is-fullwidth is-striped is-narrow">
<thead>
<tr>
<th>{% trans "Nom d'utilisateur" %}</th>
<th>{% trans "Nom d'usage" %}</th>
<th>{% trans "Adresse e-mail" %}</th>
<th>{% trans "VLAN attribué" %}</th>
<th>{% trans "Gestion du VLAN" %}</th>
</tr>
</thead>
<tbody class="is-centered">
{% for person in user_list %}
{% include "dgsi/partials/user_list-user.html" %}
{% endfor %}
</tbody>
</table>
</div>
{% endblock content %}

View file

@ -1,106 +1,50 @@
from django.urls import path
from django.views.generic import TemplateView
from . import views
app_name = "dgsi"
urlpatterns = [
###
# Miscelleanous views
path(
"",
views.IndexView.as_view(),
name="index",
),
path(
"mentions-legales/",
TemplateView.as_view(template_name="dgsi/mentions_legales.html"),
name="mentions_legales",
),
path(
"accounts/forbidden/",
views.TemplateView.as_view(template_name="accounts/forbidden_category.html"),
name="forbidden_account",
),
###
# Archives views
path(
"archives/",
views.ArchiveListView.as_view(),
name="archive_list",
),
path(
"archives/<int:pk>/",
views.ProtectedArchiveView.as_view(),
name="protected_archive",
),
###
# Misc views
path("", views.IndexView.as_view(), name="dgn-index"),
# Legal documents
path(
"legal-documents/",
views.LegalDocumentsView.as_view(),
name="legal_documents",
name="dgn-legal_documents",
),
path(
"legal-documents/accept/<slug:kind>/",
views.AcceptLegalDocumentView.as_view(),
name="accept_legal_document",
),
###
# Services views
path(
"services/",
views.ServiceListView.as_view(),
name="service_list",
),
path(
"services/redirect/<int:pk>/",
views.ServiceRedirectView.as_view(),
name="service_redirect",
),
###
# Profile views
path(
"accounts/profile/",
views.ProfileView.as_view(),
name="profile",
),
path(
"accounts/profile/apple/",
views.AppleProfileView.as_view(),
name="apple_profile",
name="dgn-accept_legal_document",
),
# Account views
path("accounts/profile/", views.ProfileView.as_view(), name="dgn-profile"),
path(
"accounts/generate-wifi-password/",
views.GenerateWiFiPasswordView.as_view(),
name="generate_wifi_password",
name="dgn-generate_wifi_password",
),
path(
"accounts/create/",
views.CreateSelfAccountView.as_view(),
name="create_self_account",
name="dgn-create_self_account",
),
###
# Accounts admin views
path(
"accounts/create-kanidm/",
views.CreateKanidmAccountView.as_view(),
name="create_kanidm_account",
name="dgn-create_kanidm_user",
),
path(
"accounts/list/",
views.UserListView.as_view(),
name="user_list",
"accounts/forbidden/",
views.TemplateView.as_view(template_name="accounts/forbidden_category.html"),
name="dgn-forbidden_account",
),
# Services views
path("services/", views.ServiceListView.as_view(), name="dgn-services"),
path(
"accounts/assign-vlan/<int:pk>",
views.UserAssignVlanView.as_view(),
name="user_assign_vlan",
),
path(
"accounts/deassign-vlan/<int:pk>",
views.UserDeassignVlanView.as_view(),
name="user_deassign_vlan",
"services/redirect/<int:pk>/",
views.ServiceRedirectView.as_view(),
name="dgn-services_redirect",
),
]

View file

@ -1,29 +1,22 @@
from mimetypes import guess_type
from typing import Any, NamedTuple
from asgiref.sync import async_to_sync
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.mail import EmailMessage
from django.http import Http404, HttpRequest, HttpResponseBase, HttpResponseRedirect
from django.http.response import HttpResponse
from django.http import HttpRequest, HttpResponseBase, HttpResponseRedirect
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.utils.functional import Promise
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView, ListView, RedirectView, TemplateView, View
from django.views.generic import FormView, ListView, RedirectView, TemplateView
from django.views.generic.detail import SingleObjectMixin
from dgsi.forms import CreateKanidmAccountForm, CreateSelfAccountForm
from dgsi.mixins import (
HtmxPostObjectMixin,
KanidmAccountRequiredMixin,
StaffRequiredMixin,
)
from dgsi.models import Archive, Bylaws, Service, Statutes, User
from shared.kanidm import klient, sync_call
from dgsi.mixins import StaffRequiredMixin
from dgsi.models import Bylaws, Service, Statutes, User
from shared.kanidm import klient
class Link(NamedTuple):
@ -31,31 +24,21 @@ class Link(NamedTuple):
reverse: str
text: str | Promise
icon: str | None = None
absolute: bool = False
AUTHENTICATED_LINKS = [
Link("is-primary", "dgsi:profile", _("Mon profil"), "user-filled"),
Link(
"is-success",
"https://docs.dgnum.eu/s/doc-publique",
_("Aide et Documention"),
"help",
True,
),
Link("is-primary", "dgsi:legal_documents", _("Documents Légaux"), "script"),
Link("is-info", "dgsi:service_list", _("Services proposés"), "apps-filled"),
Link("is-success", "dgsi:archive_list", _("Archives"), "archive"),
AUTHENTICATED_LINKS: list[Link] = [
Link("is-primary", "dgsi:dgn-profile", _("Mon profil"), "user-filled"),
Link("is-primary", "dgsi:dgn-legal_documents", _("Documents Légaux"), "script"),
Link("is-info", "dgsi:dgn-services", _("Services proposés"), "apps-filled"),
]
ADMIN_LINKS = [
ADMIN_LINKS: list[Link] = [
Link(
"is-danger",
"dgsi:create_kanidm_account",
"dgsi:dgn-create_kanidm_user",
_("Créer un compte Kanidm"),
"user-plus",
),
Link("is-primary", "dgsi:user_list", _("Liste des comptes"), "users"),
Link(
"is-warning", "admin:index", _("Interface d'administration"), "settings-filled"
),
@ -64,49 +47,46 @@ ADMIN_LINKS = [
class IndexView(TemplateView):
template_name = "dgsi/index.html"
extra_context = {
"links": {
"authenticated": AUTHENTICATED_LINKS,
"admin": ADMIN_LINKS,
}
}
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
return super().get_context_data(
links={
"authenticated": AUTHENTICATED_LINKS,
"admin": ADMIN_LINKS,
},
**kwargs,
)
class ProfileView(LoginRequiredMixin, TemplateView):
template_name = "dgsi/profile.html"
def get_context_data(self, **kwargs):
u = User.from_request(self.request)
class AppleProfileView(KanidmAccountRequiredMixin, TemplateView):
content_type = "application/x-apple-aspen-config"
template_name = "dgnum_profile.mobileconfig"
extra_context = {"dgnum_ssid": "DGNum"}
require_radius_secret = True
def render_to_response(
self, context: dict[str, Any], **response_kwargs: Any
) -> HttpResponse:
headers = response_kwargs.pop("headers", {})
headers["Content-Disposition"] = "attachment; filename=wifi_dgnum.mobileconfig"
return super().render_to_response(context, headers=headers, **response_kwargs)
return super().get_context_data(
displayname=f"{u.first_name} {u.last_name}",
**kwargs,
)
class GenerateWiFiPasswordView(KanidmAccountRequiredMixin, View):
url = reverse_lazy("dgsi:profile")
http_method_names = ["post"]
class GenerateWiFiPasswordView(LoginRequiredMixin, RedirectView):
url = reverse_lazy("dgsi:dgn-profile")
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
assert self._user.kanidm is not None
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
user = User.from_request(self.request)
# Give access to the wifi network when the user creates its first password
if not self._user.kanidm.radius_secret:
message = _("Mot de passe Wi-Fi généré avec succès.")
sync_call("group_add_members", "radius_access", [self._user.username])
if user.kanidm is None:
messages.error(self.request, _("Compte DGNum inexistant."))
else:
message = _("Mot de passe Wi-Fi reinitialisé avec succès.")
sync_call("call_post", f"/v1/person/{self._user.username}/_radius")
messages.add_message(request, messages.SUCCESS, message)
# Give access to the wifi network when the user creates its first password
if not user.kanidm.radius_secret:
async_to_sync(klient.group_add_members)(
"radius_access", [user.username]
)
async_to_sync(klient.call_post)(f"/v1/person/{user.username}/_radius")
return HttpResponse(*args, headers={"HX-Redirect": self.url}, **kwargs)
return super().get(request, *args, **kwargs)
# INFO: We subclass AccessMixin and not LoginRequiredMixin because the way we want to
@ -115,8 +95,7 @@ class CreateSelfAccountView(AccessMixin, SuccessMessageMixin, FormView):
template_name = "dgsi/create_self_account.html"
form_class = CreateSelfAccountForm
success_message = _("Compte DGNum créé avec succès")
success_url = reverse_lazy("dgsi:profile")
extra_context = {"backlink": "dgsi:profile"}
success_url = reverse_lazy("dgsi:dgn-profile")
def dispatch(
self, request: HttpRequest, *args: Any, **kwargs: Any
@ -133,7 +112,7 @@ class CreateSelfAccountView(AccessMixin, SuccessMessageMixin, FormView):
messages.WARNING,
_("<b>Vous possédez déjà un compte DGNum !</b>"),
)
return HttpResponseRedirect(reverse_lazy("dgsi:profile"))
return HttpResponseRedirect(reverse_lazy("dgsi:dgn-profile"))
# Check that the Statutes and Bylaws have been accepted
if (
@ -145,7 +124,7 @@ class CreateSelfAccountView(AccessMixin, SuccessMessageMixin, FormView):
messages.WARNING,
_("Vous devez accepter les Statuts et le Règlement Intérieur."),
)
return HttpResponseRedirect(reverse_lazy("dgsi:legal_documents"))
return HttpResponseRedirect(reverse_lazy("dgsi:dgn-legal_documents"))
return super().dispatch(request, *args, **kwargs)
@ -187,58 +166,6 @@ class CreateSelfAccountView(AccessMixin, SuccessMessageMixin, FormView):
return super().form_valid(form)
class ArchiveListView(LoginRequiredMixin, TemplateView):
template_name = "dgsi/archive_list.html"
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
return super().get_context_data(
document_list=sorted(
[
*Archive.objects.all(),
*Bylaws.objects.all(),
*Statutes.objects.all(),
],
key=lambda obj: obj.date,
reverse=True,
),
**kwargs,
)
class ProtectedArchiveView(LoginRequiredMixin, View):
http_method_names = ["get"]
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
u = User.from_request(request)
archive = Archive.objects.get(pk=self.kwargs["pk"])
if u.can_access_archive(archive):
# INFO: When in DEBUG mode, redirect to the "real" file
if settings.DEBUG:
return HttpResponseRedirect(redirect_to=archive.file.url)
content_type, encoding = guess_type(archive.file.name)
if encoding is not None:
content_type = {
"br": "application/x-brotli",
"bzip2": "application/x-bzip",
"compress": "application/x-compress",
"gzip": "application/gzip",
"xz": "application/x-xz",
}.get(encoding, content_type)
return HttpResponse(
headers={
"Content-Type": content_type,
"Content-Disposition": f"inline; filename={archive.file.name}",
"X-Accel-Redirect": f"/{settings.ARCHIVES_INTERNAL}/{archive.file.name}",
}
)
else:
raise Http404
class LegalDocumentsView(LoginRequiredMixin, TemplateView):
template_name = "dgsi/legal_documents.html"
@ -258,7 +185,7 @@ class LegalDocumentsView(LoginRequiredMixin, TemplateView):
class AcceptLegalDocumentView(LoginRequiredMixin, RedirectView):
url = reverse_lazy("dgsi:legal_documents")
url = reverse_lazy("dgsi:dgn-legal_documents")
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
u = User.from_request(self.request)
@ -306,7 +233,7 @@ class CreateKanidmAccountView(StaffRequiredMixin, SuccessMessageMixin, FormView)
template_name = "dgsi/create_kanidm_account.html"
success_message = _("Compte DGNum pour %(displayname)s [%(name)s] créé.")
success_url = reverse_lazy("dgsi:create_kanidm_user")
success_url = reverse_lazy("dgsi:dgn-create_kanidm_user")
@async_to_sync
async def form_valid(self, form):
@ -346,26 +273,3 @@ class CreateKanidmAccountView(StaffRequiredMixin, SuccessMessageMixin, FormView)
).send()
return super().form_valid(form)
class UserListView(StaffRequiredMixin, ListView):
model = User
ordering = ["-date_joined"]
class UserAssignVlanView(StaffRequiredMixin, HtmxPostObjectMixin, View):
model = User
template_name = "dgsi/partials/user_list-user.html"
context_object_name = "person"
def execute_action(self, *args, **kwargs) -> None:
self.object.register_unique_vlan()
class UserDeassignVlanView(StaffRequiredMixin, HtmxPostObjectMixin, View):
model = User
template_name = "dgsi/partials/user_list-user.html"
context_object_name = "person"
def execute_action(self, *args, **kwargs) -> None:
self.object.reclaim_vlan()

View file

@ -5,14 +5,12 @@ from typing import Optional
from allauth.core.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.models import SocialLogin
from django.conf import settings
from django.contrib import messages
from django.http import HttpRequest, HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dgsi.models import Translation, User
from shared.kanidm import sync_call
logger = logging.getLogger(__name__)
@ -41,7 +39,7 @@ class SharedAccountAdapter(DefaultSocialAccountAdapter):
):
messages.error(request, _("Catégorie de compte ENS interdite."))
raise ImmediateHttpResponse(
HttpResponseRedirect(reverse("dgsi:forbidden_account"))
HttpResponseRedirect(reverse("dgsi:dgn-forbidden_account"))
)
# Continue with the login flow
@ -58,7 +56,7 @@ class SharedAccountAdapter(DefaultSocialAccountAdapter):
# INFO: This should never happen
messages.error(request, _("Méthode de connexion invalide."))
raise ImmediateHttpResponse(
HttpResponseRedirect(reverse("dgsi:forbidden_account"))
HttpResponseRedirect(reverse("dgsi:dgn-forbidden_account"))
)
def _get_user(
@ -93,14 +91,8 @@ class SharedAccountAdapter(DefaultSocialAccountAdapter):
u.username = self._get_username(request, sociallogin)
# Update the global permissions
u.is_superuser = u.part_of(settings.DGSI_SUPERUSER_GROUP)
u.is_staff = u.is_superuser or u.part_of(settings.DGSI_STAFF_GROUP)
# Update the e-mail address if possible
if u.kanidm is not None:
emails = sync_call("call_get", f"/v1/person/{u.username}/_attr/mail").data
if emails != []:
u.email = emails[0]
u.is_staff = u.is_admin
u.is_superuser = u.is_admin
# Save the updated user if needed
if sociallogin.is_existing:

View file

@ -1,4 +1,3 @@
from asgiref.sync import async_to_sync
from kanidm import KanidmClient
from loadcredential import Credentials
@ -7,10 +6,3 @@ credentials = Credentials(env_prefix="DGSI_")
klient = KanidmClient(
uri=credentials["KANIDM_URI"], token=credentials["KANIDM_AUTH_TOKEN"]
)
def sync_call(name, *args, **kwargs):
"""
Wraps the required action for use in sync contexts
"""
return async_to_sync(getattr(klient, name))(*args, **kwargs)

View file

@ -1,14 +1,14 @@
# DG·SI english translation
# Copyright (C) 2024 DGNum
# This file is distributed under the same license as the dgsi package.
# Tom Hubrecht <tom.hubrecht@dgnum.eu>, 2024-2025.
# Tom Hubrecht <tom.hubrecht@dgnum.eu>, 2024.
#
msgid ""
msgstr ""
"Project-Id-Version: dgsi.dgnum.eu\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-01 21:40+0100\n"
"PO-Revision-Date: 2025-02-19 12:00+0100\n"
"POT-Creation-Date: 2024-10-12 21:59+0200\n"
"PO-Revision-Date: 2024-10-12 22:04+0200\n"
"Last-Translator: Tom Hubrecht <tom.hubrecht@dgnum.eu>\n"
"Language-Team: French\n"
"Language: fr\n"
@ -16,32 +16,37 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1)\n"
"X-Generator: Gtranslator 47.1\n"
"X-Generator: Gtranslator 46.1\n"
#: app/settings.py:321
msgid "Administration de DGSI"
msgstr "DGSI Administration"
msgid "Informations réseau"
msgstr "Networking informations"
#: dgsi/admin.py:51
msgid "Documents DGNum"
msgstr "DGNum Documents"
#: dgsi/forms.py:16
msgid "Identifiant déjà présent dans la base de données."
msgstr "Username already in the database."
#: dgsi/forms.py:22
msgid "Identifiant"
msgstr "Username"
#: dgsi/forms.py:23
msgid "De préférence identique au login ENS de la personne concernée."
msgstr "Preferably identical to the ENS login of the person concerned."
#: dgsi/forms.py:26 dgsi/forms.py:47
msgid "Nom d'usage"
msgstr "Name in use"
#: dgsi/forms.py:28 dgsi/forms.py:49
msgid "Adresse e-mail"
msgstr "E-mail address"
#: dgsi/forms.py:30
msgid ""
"De préférence :<br>- l'adresse <code>@ens.psl.eu</code> pour les personnes "
"en scolarité ;<br>- l'adresse <code>@normalesup.org</code> pour les "
@ -52,9 +57,11 @@ msgstr ""
"<code>@normalesup.org</code> address for people having finished their "
"studies;<br><b>For outsiders, the board must give its approval.</b>"
#: dgsi/forms.py:37
msgid "Membre actif"
msgstr "Active member"
#: dgsi/forms.py:39
msgid ""
"Si selectionné, la personne sera ajoutée au groupe <code>dgnum_members</"
"code>.<br><b>L'accord préalable du bureau est nécessaire !</b>"
@ -62,90 +69,88 @@ msgstr ""
"If selected, the person will be added to the <code>dgnum_members</code> "
"group.<br><b>Prior approval from the board is required!</b>"
#: dgsi/forms.py:50
msgid "De préférence l'adresse '@ens.psl.eu'"
msgstr "Preferably the @ens.psl.eu address"
#: dgsi/models.py:24
msgid "Nom du service proposé"
msgstr "Name of the proposed service"
#: dgsi/models.py:25
msgid "Adresse du service"
msgstr "Address of the service"
#: dgsi/models.py:26
msgid "Icône du service"
msgstr "Icon of the service"
#: dgsi/models.py:36
msgid "Date du document"
msgstr "Document date"
#: dgsi/models.py:37
msgid "Nom du document"
msgstr "Document name"
#: dgsi/models.py:38
msgid "Fichier PDF"
msgstr "PDF file"
#: dgsi/models.py:60 dgsi/models.py:61
#: dgsi/templates/dgsi/legal_documents.html:26
msgid "Statuts"
msgstr "Statutes"
#: dgsi/models.py:73 dgsi/templates/dgsi/legal_documents.html:30
msgid "Règlement Intérieur"
msgstr "Bylaws"
#: dgsi/models.py:74
msgid "Règlements Intérieurs"
msgstr "Bylaws"
msgid "Document d'archives"
msgstr "Archive document"
msgid "Documents d'archives"
msgstr "Archive documents"
#: dgsi/models.py:116
msgid "Correspondance de login"
msgstr "Login mapping"
#: dgsi/models.py:117
msgid "Correspondances de login"
msgstr "Login mappings"
#: dgsi/models.py:148
msgid "Derniers Statuts acceptés"
msgstr "Latest accepted Statutes"
#: dgsi/models.py:155
msgid "Dernier Règlement Intérieur accepté"
msgstr "Latest accepted Bylaws"
msgid "VLAN associé au compte"
msgstr "VLAN assigned to the account"
msgid "Ce compte a déjà un VLAN associé"
msgstr "This account already has an assigned VLAN"
msgid "Le VLAN {} est déjà attribué."
msgstr "The VLAN {} is already assigned."
#: dgsi/templates/_legal_document.html:9
msgid ""
" En acceptant, vous assurez avoir lu ce document et en approuver le contenu."
msgstr ""
" By accepting, you confirm that you have read this document and agree with "
"its content."
#: dgsi/templates/_legal_document.html:15
msgid "Accepté"
msgstr "Accepted"
msgid "Retour"
msgstr "Go back"
msgid "Archives de la DGNum"
msgstr "Archives of the DGNum"
#: dgsi/templates/dgsi/create_kanidm_account.html:6
msgid "Création de compte Kanidm"
msgstr "Kanidm account creation"
#: dgsi/templates/dgsi/create_kanidm_account.html:14
#: dgsi/templates/dgsi/create_self_account.html:14
msgid "Enregistrer"
msgstr "Save"
#: dgsi/templates/dgsi/create_self_account.html:6
msgid "Création d'un compte DGNum"
msgstr "DGNum account creation"
msgid "Documents légaux"
msgstr "Legal documents"
#: dgsi/templates/dgsi/legal_documents.html:12
msgid ""
"Vous devez accepter les Statuts et le Règlement Intérieur de la DGNum avant "
"de pouvoir créer un compte."
@ -153,180 +158,162 @@ msgstr ""
"You must accept the DGNum Statutes and Bylaws before you can create an "
"account."
#: dgsi/templates/dgsi/legal_documents.html:16
msgid ""
"Vous n'avez pas encore de compte DGNum, mais vous pouvez désormais en créer "
"un."
msgstr "You do not yet have a DGNum account, but you can now create one."
#: dgsi/templates/dgsi/legal_documents.html:19
msgid "Poursuivre la création d'un compte DGNum"
msgstr "Continue the creation of a DGNum account"
#: dgsi/templates/dgsi/legal_documents.html:26
msgid "Accepter les statuts"
msgstr "Accept the statutes"
#: dgsi/templates/dgsi/legal_documents.html:30
msgid "Accepter le règlement intérieur"
msgstr "Accept the bylaws"
msgid "Profil personnel"
msgstr "Personal profile"
msgid "Nom d'utilisateur :"
msgstr "Username :"
#: dgsi/templates/dgsi/profile.html:7
#, python-format
msgid "Profil de %(displayname)s"
msgstr "Profile of %(displayname)s"
#: dgsi/templates/dgsi/profile.html:14
msgid "Mot de passe WiFi :"
msgstr "WiFi password:"
#: dgsi/templates/dgsi/profile.html:16
msgid "Êtes-vous sûr·e de vouloir réinitialiser votre mot de passe WiFi ?"
msgstr "Are you sure that you want to reset your WiFi password?"
#: dgsi/templates/dgsi/profile.html:19
msgid "Réinitialiser le mot de passe WiFi"
msgstr "Reset the WiFi password"
#: dgsi/templates/dgsi/profile.html:32
msgid "Générer un mot de passe WiFi"
msgstr "Generate a WiFi password:"
msgid "Nom d'usage :"
msgstr "Name in use:"
#: dgsi/templates/dgsi/profile.html:36
msgid "Adresse e-mail :"
msgstr "E-mail address:"
msgid "Télécharger le profil Wi-Fi DGNum pour iOS, iPadOS et macOS"
msgstr "Download the DGNum Wi-Fi profile for iOS, iPadOS or macOS"
#: dgsi/templates/dgsi/profile.html:41
msgid "Informations techniques"
msgstr "Technical informations"
msgid "Afficher"
msgstr "Show"
msgid "Cacher"
msgstr "Hide"
msgid "Identifiant interne :"
msgstr "Internal identifier:"
msgid "VLAN attribué :"
msgstr "Assigned VLAN:"
msgid "Pas de VLAN attribué."
msgstr "No assigned VLAN."
#: dgsi/templates/dgsi/profile.html:44
msgid "Identifiant unique :"
msgstr "Unique identifier:"
#: dgsi/templates/dgsi/profile.html:53
msgid "Membre des groupes suivants :"
msgstr "Member of the following groups:"
#: dgsi/templates/dgsi/profile.html:64
msgid "Pas de compte DGNum répertorié."
msgstr "No DGNum account found."
#: dgsi/templates/dgsi/profile.html:67
msgid "Créer un compte DGNum"
msgstr "Create a DGNum account"
#: dgsi/templates/dgsi/service_list.html:6
msgid "Services accessibles via la DGNum"
msgstr "Services accessible via the DGNum"
msgid "Nom d'utilisateur"
msgstr "Username"
msgid "VLAN attribué"
msgstr "Assigned VLAN"
msgid "Gestion du VLAN"
msgstr "VLAN management"
msgid "Désallouer"
msgstr "Deassign"
msgid "Allouer"
msgstr "Assign"
#: dgsi/views.py:30
msgid "Mon profil"
msgstr "My profile"
msgid "Aide et Documention"
msgstr "Help and documentation"
#: dgsi/views.py:31
msgid "Documents Légaux"
msgstr "Legal Documents"
#: dgsi/views.py:32
msgid "Services proposés"
msgstr "Services offered"
msgid "Archives"
msgstr "Archives"
#: dgsi/views.py:39
msgid "Créer un compte Kanidm"
msgstr "Create a Kanidm account"
msgid "Liste des comptes"
msgstr "List of accounts"
#: dgsi/views.py:43 shared/templates/_hero.html:76
msgid "Interface d'administration"
msgstr "Administration interface"
msgid "<b>Veuillez créer un compte DGNum.</b>"
msgstr "<b>Please create a DGNum account.</b>"
msgid "<b>Veuillez générer un mot de passe Wi-Fi.</b>"
msgstr "<b>Please generate a WiFi password.</b>"
#: dgsi/views.py:80
msgid "Compte DGNum inexistant."
msgstr "No existing DGNum account."
msgid "Mot de passe Wi-Fi généré avec succès."
msgstr "Wi-Fi password generated successfully."
msgid "Mot de passe Wi-Fi reinitialisé avec succès."
msgstr "Wi-Fi password reset successfully."
#: dgsi/views.py:97
msgid "Compte DGNum créé avec succès"
msgstr "DGNum account successfully created"
#: dgsi/views.py:113
msgid "<b>Vous possédez déjà un compte DGNum !</b>"
msgstr "<b>You already have a DGNum account!</b>"
#: dgsi/views.py:125
msgid "Vous devez accepter les Statuts et le Règlement Intérieur."
msgstr "You must accept the Statutes and the Bylaws."
#: dgsi/views.py:204
#, python-format
msgid "Type de document invalide : %(kind)s"
msgstr "Invalid document type: %(kind)s"
#: dgsi/views.py:234
#, python-format
msgid "Compte DGNum pour %(displayname)s [%(name)s] créé."
msgstr "DGNum account for %(displayname)s [%(name)s] created."
#: shared/account.py:40
msgid "Catégorie de compte ENS interdite."
msgstr "ENS account category not permitted."
#: shared/account.py:57
msgid "Méthode de connexion invalide."
msgstr "Invalid connection method."
#: shared/templates/_footer.html:4
msgid ""
"Logiciel développé pour et par la <a href=\"https://dgnum.eu\">DGNum</a>."
msgstr ""
"Software developed for and by the <a href=\"https://dgnum.eu\">DGNum</a>."
"Software developed for and by the <a href=https://dgnum.eu>DGNum</a>."
#: shared/templates/_hero.html:18 shared/templates/account/logout.html:6
msgid "Déconnexion"
msgstr "Logout"
#: shared/templates/_hero.html:27 shared/templates/socialaccount/login.html:6
msgid "Connexion"
msgstr "Login"
#: shared/templates/_hero.html:40
msgid "Choix de la langue"
msgstr "Language selection"
#: shared/templates/account/login.html:7
msgid "Connexion via un compte tiers"
msgstr "Connection via a third-party account"
#: shared/templates/account/logout.html:10
msgid "Êtes vous certain·e de vouloir vous déconnecter ?"
msgstr "Are you sure you want to log out?"
#: shared/templates/account/logout.html:16
msgid "Se déconnecter"
msgstr "Log out"
#: shared/templates/accounts/forbidden_category.html:6
msgid "Connexion impossible"
msgstr "Unable to connect"
#: shared/templates/accounts/forbidden_category.html:10
msgid ""
"Vos informations ne permettent pas de vous identifier auprès de la DGNum."
"<br>Si vous pensez qu'il s'agit une erreur, merci de nous contacter à "
@ -336,28 +323,29 @@ msgstr ""
"this is a mistake, please contact us at: <a href=\"mailto:contact@dgnum."
"eu\">contact@dgnum.eu</a>"
#: shared/templates/socialaccount/authentication_error.html:7
msgid "Erreur lors de la connexion"
msgstr "Error during login"
#: shared/templates/socialaccount/authentication_error.html:11
msgid ""
"Une erreur est survenue lors de votre tentative de connexion avec un compte "
"tiers."
msgstr ""
"An error has occurred while trying to login with a third-party account."
#: shared/templates/socialaccount/login.html:11
#, python-format
msgid "Se connecter via un compte <b>%(provider)s</b>"
msgstr "Log in with a <b>%(provider)s</b> account"
#: shared/templates/socialaccount/login.html:16
#, python-format
msgid ""
"Vous vous apprêtez à vous connecter à l'aide d'un compte tiers provenant de "
"%(provider)s."
msgstr "You are about to log in using a third-party account from %(provider)s."
#: shared/templates/socialaccount/login.html:21
msgid "Continuer"
msgstr "Continue"
#, python-format
#~ msgid "Profil de %(displayname)s"
#~ msgstr "Profile of %(displayname)s"

View file

@ -13,15 +13,6 @@ $dark: rgb(46, 46, 46);
@use "./sass/utilities/mixins" as mx;
@use "./sass/utilities/initial-variables.scss" as iv;
tbody {
&.is-centered {
th,
td {
vertical-align: middle !important;
}
}
}
body {
min-height: 100vh;
display: flex;
@ -29,19 +20,6 @@ body {
justify-content: space-between;
}
.ellipsis {
overflow-x: hidden;
text-overflow: ellipsis;
display: block;
}
.bt-archive {
display: flex;
width: 100%;
margin-bottom: calc(0.5 * var(--bulma-block-spacing));
justify-content: space-between;
}
.bt-link {
display: flex;
width: 100%;

View file

@ -1,39 +1,11 @@
const init = ($node) => {
const q = (query, f) => ($node.querySelectorAll(query) || []).forEach(f);
q(".notification .delete", ($delete) => {
const $notification = $delete.parentNode;
const dismiss = () => $notification.remove();
$delete.addEventListener("click", dismiss);
setTimeout(dismiss, 15000);
});
q("[data-toggle]", ($toggle) => {
const target = $node.querySelector($toggle.dataset.target);
$toggle.addEventListener("click", () => {
if ($toggle.dataset.toggle === "on") {
$toggle.dataset.toggle = "off";
$toggle.innerHTML = $toggle.dataset.offHtml;
target.classList.remove($toggle.dataset.class);
} else {
$toggle.dataset.toggle = "on";
$toggle.innerHTML = $toggle.dataset.onHtml;
target.classList.add($toggle.dataset.class);
}
});
});
q("[data-select]", ($input) => {
$input.addEventListener("focus", () => {
$input.select();
});
});
};
document.addEventListener("DOMContentLoaded", () => {
document.body.addEventListener("htmx:load", (e) => {
init(e.detail.elt);
});
(document.querySelectorAll(".notification .delete") || []).forEach(
($delete) => {
const $notification = $delete.parentNode;
const dismiss = () => $notification.parentNode.removeChild($notification);
$delete.addEventListener("click", dismiss);
setTimeout(dismiss, 15000);
},
);
});

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,5 @@
<footer class="footer has-text-centered">
<b>{% blocktrans %}Logiciel développé pour et par la <a href="https://dgnum.eu">DGNum</a>.{% endblocktrans %}</b>
<hr class="my-2">
<a class="tag is-medium" href="{% url "dgsi:mentions_legales" %}">Mentions Légales</a>
{% django_browser_reload_script %}
</footer>

View file

@ -5,7 +5,7 @@
<div class="columns mx-6">
<div class="column is-three-quarters">
<h1 class="title">
<a href="{% url 'dgsi:index' %}" class="has-text-dark">Dossier Général des Services Informagiques</a>
<a href="{% url 'dgsi:dgn-index' %}" class="has-text-dark">Dossier Général des Services Informagiques</a>
</h1>
<h2 class="subtitle mt-2">Système d'information de la DGNum</h2>
</div>
@ -15,19 +15,19 @@
<a href="{% url 'account_logout' %}"
class="button is-light is-fullwidth">
<span>
<span>{% trans "Déconnexion" %}</span>
<span class="icon">
<i class="ti ti-door-exit"></i>
</span>
<span>{% trans "Déconnexion" %}</span>
</span>
</a>
{% else %}
<a href="{% url 'account_login' %}" class="button is-fullwidth is-light">
<span>
<span>{% trans "Connexion" %}</span>
<span class="icon">
<i class="ti ti-door-enter"></i>
</span>
<span>{% trans "Connexion" %}</span>
</span>
</a>
{% endif %}

View file

@ -1,33 +1,16 @@
{% load django_htmx sass_tags static %}
{% load sass_tags static %}
<!-- Icons -->
<link href="{% static 'favicon.ico' %}" rel="icon" />
<link href="{% static 'apple-touch-icon.png' %}" rel="apple-touch-icon" />
<link rel="icon"
type="image/png"
href="{% static 'favicon-16x16.png' %}"
sizes="16x16" />
<link rel="icon"
type="image/png"
href="{% static 'favicon-32x32.png' %}"
sizes="32x32" />
<link rel="icon"
type="image/png"
href="{% static 'android-chrome-192x192.png' %}"
sizes="192x192" />
<link rel="icon" type="image/png" href="{% static 'favicon-16x16.png' %}" sizes="16x16" />
<link rel="icon" type="image/png" href="{% static 'favicon-32x32.png' %}" sizes="32x32" />
<link rel="icon" type="image/png" href="{% static 'android-chrome-192x192.png' %}" sizes="192x192" />
<!-- CSS -->
<link href="{% sass_src 'bulma/bulma.scss' %}"
rel="stylesheet"
type="text/css" />
<link href="{% static 'tabler-icons/tabler-icons.min.css' %}"
rel="stylesheet"
type="text/css" />
<link href="{% sass_src 'bulma/bulma.scss' %}" rel="stylesheet" type="text/css" />
<link href="{% static 'tabler-icons/tabler-icons.min.css' %}" rel="stylesheet" type="text/css" />
<!-- JS -->
<script src="{% static 'js/dgsi.js' %}"></script>
<script defer src="{% static 'js/htmx.min.js' %}"></script>
<script defer
data-domain="profil.dgnum.eu"
src="https://analytics.dgnum.eu/js/script.js"></script>
{% django_htmx_script %}
<script defer data-domain="profil.dgnum.eu" src="https://analytics.dgnum.eu/js/script.js"></script>

View file

@ -5,8 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="keywords" content="dgnum,dgsi,ens" />
<meta name="description" content="Système d'information de la DGNum" />
<meta name="htmx-config"
content='{"defaultSwapStyle":"outerHTML","requestClass":"is-loading"}' />
<title>DGNum</title>
{% block extra_head %}
@ -15,13 +13,16 @@
{% include "_links.html" %}
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<body>
{% include "_hero.html" %}
<section class="container is-max-widescreen py-6 px-4">
<section class="container is-max-widescreen py-6">
<div id="notifications">
{% for message in messages %}
{% include "partials/notification.html" %}
<article class="notification is-light has-text-centered {{ message.tags }}">
<button class="delete"></button>
{{ message|safe }}
</article>
{% endfor %}
</div>

View file

@ -1,4 +0,0 @@
<article class="notification is-light has-text-centered {{ message.tags }}">
<button class="delete"></button>
{{ message|safe }}
</article>