Compare commits

...

108 commits

Author SHA1 Message Date
24f825c1dd
feat(templates): Tweak base template
This allows content to stay at a reasonable size
2024-10-12 22:24:16 +02:00
304fe1f249
fix(forms): Reword what JM wrote 2024-10-12 22:05:43 +02:00
41396ccae2
fix(dgsi/models): Import from the original source 2024-10-12 22:05:43 +02:00
37ddb5f075
chore: Don't commit the auth token 2024-10-12 22:05:43 +02:00
868c406af9
feat(translations): Update en 2024-10-12 22:05:43 +02:00
615eb6041f feat: precising the conditions of some operations when creating account as admin
Signed-off-by: jemagius <jmg@dgnum.eu>
2024-10-12 10:52:15 +02:00
5a84ea6e0f
chore(app): Disable SAML auth for now
This does not work, and quite a bit of time will be necessary to fix it
2024-10-12 08:05:01 +02:00
faa8ea051a
fix(bulma): Only use dark text when the button is light
This makes the documents readable when using dark mode
2024-10-12 08:03:25 +02:00
6921373d6c
feat(templates): Add a visual indication that some pages give more power 2024-10-12 07:58:19 +02:00
f6fcd90622
feat(account): WIP 2024-10-09 10:41:15 +02:00
6caf3dbf61
feat(project/djlint): Declare custom blocks used by allauth 2024-10-07 23:34:00 +02:00
833c855b5c
feat(templates): Update 2024-10-07 23:33:22 +02:00
c291112350
feat(commit-hooks): Add ruff 2024-10-06 16:52:49 +02:00
61bb59d244
chore(app/urls): Cleaner file 2024-10-06 16:52:15 +02:00
9c4413faa1
feat(settings): Add SAML auth 2024-10-06 14:43:41 +02:00
3fa4591665
feat(shell): Add SAML dependencies 2024-10-04 14:48:00 +02:00
119867a91e
fix(index): Shorten names to fit on small screens 2024-10-04 14:43:55 +02:00
61e3d4ed19
feat(bulma): Tweak bt-link width 2024-09-30 10:14:45 +02:00
a88d31541c
feat(radius): Add the user to the correct group
When generating the first WiFi password, the user is added to the
`radius_access` group, which will allow them to connect. This also fix a
previous incorrect logic where users without wifi password could not
generate one.
2024-09-29 20:05:16 +02:00
f4428ace59
feat(kanidm): Log some errors 2024-09-29 19:58:46 +02:00
fb70bf13f8
feat(profile): Add a way to reset/generate a wifi password
TODO: Maybe switch to a post request, as it modifies the internal state
2024-09-29 19:52:02 +02:00
6a581fcec4
feat(scss): More idiomatic writing 2024-09-27 14:41:41 +02:00
fa8f8a214f
feat(templates): Add analytics 2024-09-27 12:42:47 +02:00
c7e13b76f2
feat(admin): Add fields to the user admin panel 2024-09-27 12:41:13 +02:00
dc8f89be86
feat(admin): Unregister most socialaccount models, and re-register SocialAccount
This allows to use the django-unfold ModelAdmin class
2024-09-27 11:08:35 +02:00
2f5482a091
fix(isort): Make it compatible with black 2024-09-27 10:56:37 +02:00
97dc77fd5d
feat(apps): Add django-import-export 2024-09-27 10:56:37 +02:00
224f9df858
chore(npins): Update 2024-09-27 10:38:32 +02:00
45bd436ea7
feat(i18n): Add an english translation 2024-09-26 23:01:09 +02:00
997fd254ca
chore(accounts): Remove unused login view 2024-09-26 16:11:06 +02:00
bf5bce5fc5
chore(templates): Add checkmarcks on buttons 2024-09-26 16:00:35 +02:00
2978c1facb
feat(settings): Allow giving the admin list 2024-09-26 15:25:26 +02:00
7885252b4a
fix(views): Use default from email 2024-09-26 14:50:45 +02:00
97e3ae9702
fix(settings): Allow specifying the EMAIL_PORT 2024-09-26 14:40:58 +02:00
5019b89ef2
feat(translations): Factorize the user renaming scheme, and update the remote data 2024-09-26 14:23:35 +02:00
be0cf4c0f5
chore(kanidm): Rename client to klient 2024-09-26 13:31:40 +02:00
b33f13db30
feat(accounts): Add a mechanism to automatically change the local username when creating or deleting translations 2024-09-26 13:29:36 +02:00
941f2b031e
fix(translations): Disable updates to an existing translation 2024-09-26 12:12:58 +02:00
3b3f2dd34d
fix(accounts): This was, in fact, necessary 2024-09-26 12:12:26 +02:00
9119ad38b0
feat(accounts): Allow divergence between cas_login and username
- Adds a Translation table between cas_login and the effective username
- Show more descriptive errors when the connection cannot happen

TODO: The translation mechanism is currently fragile, we need to update
usernames when a translation is created/deleted and also disable updates
to a translation
2024-09-26 12:04:38 +02:00
5381b0379b
feat(index): Add a link to the services 2024-09-25 13:57:25 +02:00
88e2c25ce4
feat(shell): Add media root 2024-09-25 13:57:01 +02:00
48005ae251
chore(migrations): Reflect the change in models.Meta 2024-09-25 13:56:34 +02:00
91d5d68da3
feat(settings): Add console logging 2024-09-25 09:39:35 +02:00
59fa740950
fix(css): Don't overflow on group names 2024-09-24 23:29:01 +02:00
088d6d613a
fix: Always include django-browser-reload 2024-09-24 23:06:09 +02:00
1080ec0c44
feat(pkgs): Update loadcredential 2024-09-24 22:45:56 +02:00
7064a3aa4b
feat(settings): Add e-mail configuration 2024-09-24 22:45:42 +02:00
49f1133fed
fix(settings): We use wsgi 2024-09-24 22:40:53 +02:00
fee28c598c
chore(wsgi): Don't set an invalid settings module 2024-09-24 22:35:50 +02:00
76c07e3086
feat(settings): Make MEDIA_ROOT configurable 2024-09-24 22:08:09 +02:00
7537b26fbe
feat(settings): Make databases configurable 2024-09-24 22:06:50 +02:00
8a46e4ddb5
feat(pkgs/pykanidm): Update to current version 2024-09-24 20:37:12 +02:00
5f0dfff4ae
feat(accout): Add a view to self-create an account
Various checks are in place to make sure that the user does not already
have an account, and has accepted the rules of the association
2024-09-24 20:37:12 +02:00
6c18ec3855
chore(legal-documents): Add an icon 2024-09-24 20:37:12 +02:00
e370977aac
feat(user): Add a type guard to extract the user from a request 2024-09-24 20:37:12 +02:00
a9d369d55d
feat(kanidm): Use an async function to get the data 2024-09-24 14:32:22 +02:00
b5cedebda1
feat(legal-documents): Init list view and acceptance flow 2024-09-24 14:32:22 +02:00
abdcb2c8ad
feat: Setup media 2024-09-24 14:28:24 +02:00
1dc8305027
chore(index): Move the links outside of the class 2024-09-24 14:28:05 +02:00
2490d83459
feat(index): Add parametrization to links 2024-09-24 11:31:54 +02:00
b9f165c1e6
chore(index): Move the view inside dgsi 2024-09-24 11:31:05 +02:00
e10d33176b
chore(templates): Rename templates 2024-09-24 11:29:47 +02:00
e8ce6f343b
feat(unfold): Change the name 2024-09-24 11:28:44 +02:00
87e13b357e
chore(services): Add comments 2024-09-24 10:06:51 +02:00
cab8369558
feat(services): Add an icon, and a transitive redirect view 2024-09-23 23:59:48 +02:00
8c3cba0af8
feat(kanidm): Update the required group to be an admin 2024-09-23 23:59:48 +02:00
9b7f4f17e8
feat(dgsi): Add a Service model
This will allow to list our services, whith links directly in the app
and perform ToS verification
2024-09-23 23:59:48 +02:00
cfa14a6aae
fix(shell): Run linters at pre-push 2024-09-23 18:16:21 +02:00
39623a8802
feat(admin): Update the registration of the User model 2024-09-23 18:16:02 +02:00
0c54fd29ab
fix(profile): Tweak CSS so that the group names don't overflow
We do not really lose any information as all groups end with
`@sso.dgnum.eu`
2024-09-23 16:53:40 +02:00
26fdcbe8ef
feat(home):feat(templates): Move style to the sass bundle 2024-09-22 20:13:55 +02:00
ef9877ea60
chore(nix): Remove eslint hook, as it does not really work 2024-09-22 20:13:15 +02:00
ef80f9389b
feat(messages): Add support for dismissable notifications 2024-09-22 20:12:52 +02:00
01597ffb7b
feat(home): Add a link to the admin panel 2024-09-21 23:01:59 +02:00
5ec6a482cc
feat(accounts): Copy distant attributes 2024-09-21 23:01:42 +02:00
e31731f8e6
feat(apps): Install django-unfold 2024-09-21 23:01:21 +02:00
7c11b0de4b
feat(create_user): Send an email with the credentials reset link 2024-09-19 13:03:09 +02:00
283815d555
fix(create_user): Kanidm expects a list of email addresses 2024-09-19 11:59:24 +02:00
ea3c5cf6fd
fix(mixins): Use the correct test function 2024-09-19 11:40:16 +02:00
36ccc6be24
feat(account): Add a view to create accounts
TODO: include the reset mechanism for the password
2024-09-19 11:37:45 +02:00
cd8859f610
feat(account): Add a template with a message when a CAS account is forbidden 2024-09-19 09:14:42 +02:00
5af8e2fd24
feat(account): Prevent connections from some categories of users 2024-09-18 23:00:36 +02:00
c3f0e70384
feat(profile): Use the local name, add a notification when no dgn account exists 2024-09-18 22:32:44 +02:00
35d6a7fa8c
feat(templates): Tweak 2024-09-18 22:20:17 +02:00
97535a6bc0
fix(models): Don't crash when no kanidm profile exists 2024-09-18 22:20:05 +02:00
1d2f4a5866
feat(account): Use a custom adpater 2024-09-18 22:19:33 +02:00
1732249a2d
feat(pkgs): Add django-allauth and django-allauth-cas 2024-09-18 22:18:42 +02:00
763c2dfcbb
feat(profile): Add translations and tweak template 2024-09-17 17:12:48 +02:00
590ac25b8c
feat(home): Add profile link 2024-09-17 17:12:20 +02:00
0272edc266
feat(profile): Make the password selectable on click 2024-09-17 10:36:37 +02:00
fd5e036f49
feat(profile): Add more informations 2024-09-16 19:07:19 +02:00
82123717cc
feat(app): Remove daphne 2024-09-16 19:07:02 +02:00
99764928c6
feat(nix): Rework the python environment 2024-09-16 17:57:26 +02:00
8baad602c6
feat(pkgs): Add django-allauth-cas 2024-09-16 17:56:53 +02:00
abc71eb52d
feat(accounts): Simplify the template and disable signup 2024-09-14 16:35:57 +02:00
2817054e7e
feat(dgsi.views): Add a view to create users 2024-09-14 15:53:10 +02:00
2642a64116
feat(shell): Add ipython 2024-09-14 15:52:15 +02:00
06351ca22e
chore(.gitignore): Don't consider backups of the local db 2024-09-14 15:52:15 +02:00
3b2937b6d1
feat(dgsi.mixins): Introduce StaffRequiredMixin 2024-09-14 15:52:15 +02:00
f56961c7bb
feat(User): Switch to a custom model, simplifying the logic 2024-09-14 15:52:15 +02:00
c02b29a6f5
feat(shell): Use python312 2024-09-14 14:44:47 +02:00
855664e410
feat(templates): Tweak _hero.html
This makes the title fit on one line
2024-09-13 21:25:26 +02:00
aed32e0725
feat(profile): Change the url so that the redirection is automatic after connexion 2024-09-13 17:32:45 +02:00
7491fdb376
chore(shell): Switch back to django-stubs
It is more up to date, and no longer requires mypy
2024-09-13 17:09:21 +02:00
7581bf59df
feat(models): Add a Profile model
This simplifies the management of the views data
2024-09-13 16:55:44 +02:00
8599992dd7
feat(dgsi): Add a basic profile view 2024-09-11 21:08:08 +02:00
772ca45292
feat(pkgs): Update pykanidm 2024-09-11 21:07:11 +02:00
67 changed files with 2577 additions and 176 deletions

1
.credentials/EMAIL_HOST Normal file
View file

@ -0,0 +1 @@
localhost

1
.credentials/FROM_EMAIL Normal file
View file

@ -0,0 +1 @@
Délégation Générale Numérique <dgsi@localhost>

1
.credentials/KANIDM_URI Normal file
View file

@ -0,0 +1 @@
https://sso.dgnum.eu

View file

@ -0,0 +1 @@
dgsi@localhost

2
.gitignore vendored
View file

@ -2,9 +2,11 @@
.pre-commit-config.yaml
db.sqlite3
db.sqlite3.*
__pycache__/
.static/*
!.static/.gitkeep
src/shared/static/bulma/bulma.css
src/shared/static/bulma/bulma.css.map
.credentials/KANIDM_AUTH_TOKEN

View file

@ -8,22 +8,44 @@ let
src = ./.;
hooks = {
# JS hooks
eslint.enable = true;
# Python hooks
ruff.enable = true;
black.enable = true;
isort.enable = true;
black = {
enable = true;
stages = [ "pre-push" ];
};
isort = {
enable = true;
stages = [ "pre-push" ];
};
ruff = {
enable = true;
stages = [ "pre-push" ];
};
# Nix Hooks
statix.enable = true;
deadnix.enable = true;
statix = {
enable = true;
stages = [ "pre-push" ];
};
deadnix = {
enable = true;
stages = [ "pre-push" ];
};
# Misc Hooks
commitizen.enable = true;
};
};
python = pkgs.python312.override {
packageOverrides =
self: _:
pkgs.lib.genAttrs (builtins.attrNames (builtins.readDir ./pkgs)) (
p: self.callPackage ./pkgs/${p} { }
);
};
in
{
@ -31,28 +53,32 @@ in
name = "dgsi.dev";
packages = [
pkgs.jq
pkgs.dart-sass
pkgs.gettext
pkgs.jq
# Python dependencies
(pkgs.python3.withPackages (
(python.withPackages (
ps:
[
ps.daphne
ps.django
ps.django-allauth
ps.django-allauth-cas
ps.django-browser-reload
ps.django-bulma-forms
ps.django-compressor
ps.django-debug-toolbar
ps.django-types
ps.django-import-export
ps.django-sass-processor
ps.django-sass-processor-dart-sass
ps.django-stubs
ps.django-unfold
ps.ipython
ps.loadcredential
ps.pykanidm
ps.python-cas
]
++ (builtins.map (p: ps.callPackage ./pkgs/${p} { }) [
"django-browser-reload"
"django-bulma-forms"
"django-sass-processor"
"django-sass-processor-dart-sass"
"pykanidm"
])
++ ps.django-allauth.optional-dependencies.saml
))
] ++ check.enabledPackages;
@ -60,6 +86,7 @@ in
CREDENTIALS_DIRECTORY = builtins.toString ./.credentials;
DGSI_DEBUG = "true";
DGSI_STATIC_ROOT = builtins.toString ./.static;
DGSI_MEDIA_ROOT = builtins.toString ./.media;
DGSI_KANIDM_CLIENT = "dgsi_test";
};

View file

@ -8,15 +8,15 @@
"repo": "git-hooks.nix"
},
"branch": "master",
"revision": "e35aed5fda3cc79f88ed7f1795021e559582093a",
"url": "https://github.com/cachix/pre-commit-hooks.nix/archive/e35aed5fda3cc79f88ed7f1795021e559582093a.tar.gz",
"hash": "1bq0yrjmkddj964s2q6393nwp4mqrlmc2i5wsy992r034awyywp1"
"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-24.11pre630522.3305b2b25e4a/nixexprs.tar.xz",
"hash": "1bg240s2jbyvdixpy14rc4fcn9zrjf36mcd2xv59rcxx508gwhi2"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-24.11pre685691.28b5b8af91ff/nixexprs.tar.xz",
"hash": "14ldh9js6l9nqch7j8z6nhyplxc5d9jw375pg8h4s24m7x37xnvy"
}
},
"version": 3

View file

@ -0,0 +1,12 @@
diff --git a/setup.py b/setup.py
index fb06ec0..506677f 100644
--- a/setup.py
+++ b/setup.py
@@ -52,7 +52,6 @@ setup(
install_requires=[
"django-allauth",
"python-cas",
- "six",
],
extras_require={
"docs": ["sphinx"],

View file

@ -0,0 +1,39 @@
diff --git a/allauth_cas/signals.py b/allauth_cas/signals.py
index 36c9b24..530c26e 100644
--- a/allauth_cas/signals.py
+++ b/allauth_cas/signals.py
@@ -1,4 +1,4 @@
-from allauth.account.adapter import get_adapter
+from allauth.socialaccount.adapter import get_adapter
from allauth.account.utils import get_next_redirect_url
from allauth.socialaccount import providers
from django.contrib.auth.signals import user_logged_out
@@ -14,7 +14,7 @@ def cas_account_logout(sender, request, **kwargs):
if not provider_id:
return
- provider = providers.registry.by_id(provider_id, request)
+ provider = get_adapter(request).get_provider(request, provider_id)
if not provider.message_suggest_caslogout_on_logout(request):
return
diff --git a/allauth_cas/views.py b/allauth_cas/views.py
index d08e354..9e81e53 100644
--- a/allauth_cas/views.py
+++ b/allauth_cas/views.py
@@ -1,5 +1,5 @@
import cas
-from allauth.account.adapter import get_adapter
+from allauth.socialaccount.adapter import get_adapter
from allauth.account.utils import get_next_redirect_url
from allauth.socialaccount import providers
from allauth.socialaccount.helpers import (
@@ -56,7 +56,7 @@ class CASAdapter:
"""
Returns a provider instance for the current request.
"""
- return providers.registry.by_id(self.provider_id, self.request)
+ return get_adapter(self.request).get_provider(self.request, self.provider_id)
def complete_login(self, request, response):
"""

View file

@ -0,0 +1,49 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
django-allauth,
python-cas,
}:
buildPythonPackage rec {
pname = "django-allauth-cas";
version = "unstable-2024-01-25";
pyproject = true;
src = fetchFromGitHub {
owner = "jlucasp25";
repo = "django-allauth-cas";
rev = "77e02f3796cd564a9a0c48b5b568b14d4d4c5687";
hash = "sha256-y/IquXl/4+9MJmsgbWtPun3tBbRJ4kJFzWo5c+5WeHk=";
};
patches = [
./01-setup.patch
./02-registry.patch
];
build-system = [
setuptools
wheel
];
dependencies = [
django-allauth
python-cas
];
pythonImportsCheck = [
"allauth_cas"
];
meta = {
description = "CAS support for django-allauth";
homepage = "https://github.com/jlucasp25/django-allauth-cas";
changelog = "https://github.com/jlucasp25/django-allauth-cas/blob/${src.rev}/CHANGELOG.rst";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ ];
};
}

View file

@ -0,0 +1,96 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
pythonOlder,
# build-system
setuptools,
# dependencies
django,
python3-openid,
requests,
requests-oauthlib,
pyjwt,
# optional-dependencies
python3-saml,
qrcode,
fido2,
# tests
pillow,
pytestCheckHook,
pytest-django,
# passthru tests
dj-rest-auth,
}:
buildPythonPackage rec {
pname = "django-allauth";
version = "64.2.1";
pyproject = true;
disabled = pythonOlder "3.7";
src = fetchFromGitHub {
owner = "pennersr";
repo = "django-allauth";
rev = "refs/tags/${version}";
hash = "sha256-JKjM+zqrXidxpbi+fo6wbvdXlw2oDYH51EsvQ5yp3R8=";
};
nativeBuildInputs = [
setuptools
];
propagatedBuildInputs = [
django
];
passthru.optional-dependencies = {
mfa = [
qrcode
fido2
];
openid = [
python3-openid
];
saml = [
python3-saml
];
socialaccount = [
pyjwt
requests
requests-oauthlib
] ++ pyjwt.optional-dependencies.crypto;
steam = [
python3-openid
];
};
pythonImportsCheck = [
"allauth"
];
nativeCheckInputs = [
pillow
pytestCheckHook
pytest-django
] ++ lib.flatten (builtins.attrValues passthru.optional-dependencies);
disabledTests = [
# Tests require network access
"test_login"
];
passthru.tests = {
inherit dj-rest-auth;
};
meta = with lib; {
changelog = "https://github.com/pennersr/django-allauth/blob/${version}/ChangeLog.rst";
description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication";
downloadPage = "https://github.com/pennersr/django-allauth";
homepage = "https://www.intenct.nl/projects/django-allauth";
license = licenses.mit;
maintainers = with maintainers; [ derdennisop ];
};
}

View file

@ -0,0 +1,40 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
poetry-core,
django,
}:
buildPythonPackage rec {
pname = "django-unfold";
version = "0.39.0";
pyproject = true;
src = fetchFromGitHub {
owner = "unfoldadmin";
repo = "django-unfold";
rev = version;
hash = "sha256-CmmlTx2eLcANc6ANy25ii1KVebkmUEJmDCe+/RwakAg=";
};
build-system = [
poetry-core
];
dependencies = [
django
];
pythonImportsCheck = [
"unfold"
];
meta = {
description = "Modern Django admin theme for seamless interface development";
homepage = "https://github.com/unfoldadmin/django-unfold";
changelog = "https://github.com/unfoldadmin/django-unfold/blob/${src.rev}/CHANGELOG.md";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ ];
};
}

View file

@ -0,0 +1,34 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
}:
buildPythonPackage rec {
pname = "loadcredential";
version = "1.2";
pyproject = true;
src = fetchFromGitHub {
owner = "Tom-Hubrecht";
repo = "loadcredential";
rev = "v${version}";
hash = "sha256-rNWFD89h1p1jYWLcfzsa/w8nK3bR4aVJsUPx0UtZnIw=";
};
build-system = [
setuptools
wheel
];
pythonImportsCheck = [ "loadcredential" ];
meta = {
description = "A simple python package to read credentials passed through systemd's LoadCredential, with a fallback on env variables ";
homepage = "https://github.com/Tom-Hubrecht/loadcredential";
license = lib.licenses.mit;
maintainers = [ ]; # with lib.maintainers; [ thubrecht ];
};
}

View file

@ -11,14 +11,14 @@
buildPythonPackage rec {
pname = "kanidm";
version = "1.1.0-rc.16";
version = "1.3.3";
pyproject = true;
src = fetchFromGitHub {
owner = "kanidm";
repo = "kanidm";
rev = "v${version}";
hash = "sha256-NH9V5KKI9LAtJ2/WuWtUJUzkjVMfO7Q5NQkK7Ys2olU=";
hash = "sha256-W5G7osV4du6w/BfyY9YrDzorcLNizRsoz70RMfO2AbY=";
};
sourceRoot = "source/pykanidm";

View file

@ -0,0 +1,43 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
lxml,
requests,
six,
}:
buildPythonPackage rec {
pname = "python-cas";
version = "1.6.0";
pyproject = true;
src = fetchFromGitHub {
owner = "python-cas";
repo = "python-cas";
rev = "v${version}";
hash = "sha256-0lpjG/Sma0tJGtahiFE1CjvTyswrBUp+F6f1S65b+lk=";
};
nativeBuildInputs = [
setuptools
wheel
];
propagatedBuildInputs = [
lxml
requests
six
];
pythonImportsCheck = [ "cas" ];
meta = with lib; {
description = "Python CAS (Central Authentication Service) client library support CAS 1.0/2.0/3.0";
homepage = "https://github.com/python-cas/python-cas";
license = licenses.mit;
maintainers = with maintainers; [ ];
};
}

View file

@ -21,6 +21,7 @@ Repository = "https://git.dgnum.eu/DGNum/dgsi"
[tool.djlint]
blank_line_after_tag = "load,extends"
custom_blocks = "slot,element"
format_js = true
indent = 2
max_blank_lines = 1
@ -28,3 +29,6 @@ profile = "django"
[tool.djlint.js]
indent_size = 4
[tool.isort]
profile = "black"

View file

@ -4,6 +4,8 @@ Django settings for the DGSI project.
from pathlib import Path
from django.contrib.messages import constants as messages
from django.utils.translation import gettext_lazy as _
from loadcredential import Credentials
credentials = Credentials(env_prefix="DGSI_")
@ -20,24 +22,35 @@ DEBUG = credentials.get_json("DEBUG", False)
ALLOWED_HOSTS = credentials.get_json("ALLOWED_HOSTS", [])
ADMINS = credentials.get_json("ADMINS", [])
###
# List the installed applications
INSTALLED_APPS = [
"daphne",
# Unfold apps
"unfold",
"unfold.contrib.import_export",
# Django standard apps
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
# Custom apps
"shared.staticfiles.StaticFilesApp", # Overrides the default staticfiles app to filter out the sccs sources
"django_browser_reload",
"sass_processor",
"bulma",
"import_export",
# Authentication
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.openid_connect",
# "allauth.socialaccount.providers.saml",
"allauth_cas",
"shared.cas",
# Main app
"dgsi",
]
@ -48,14 +61,33 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_browser_reload.middleware.BrowserReloadMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
###
# Logging configuration
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": credentials.get("LOG_LEVEL", "WARNING"),
},
}
###
# The main url configuration
@ -86,20 +118,34 @@ TEMPLATES = [
]
###
# ASGI application configuration
# WSGI application configuration
ASGI_APPLICATION = "app.asgi.application"
WSGI_APPLICATION = "app.wsgi.application"
###
# E-Mail configuration
DEFAULT_FROM_EMAIL = credentials["FROM_EMAIL"]
EMAIL_HOST = credentials.get("EMAIL_HOST", "localhost")
EMAIL_HOST_PASSWORD = credentials.get("EMAIL_HOST_PASSWORD", "")
EMAIL_HOST_USER = credentials.get("EMAIL_HOST_USER", "")
EMAIL_PORT = credentials.get_json("EMAIL_PORT", 465)
EMAIL_USE_SSL = credentials.get("EMAIL_USE_SSL", False)
SERVER_EMAIL = credentials["SERVER_EMAIL"]
###
# Database configuration
# -> https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
DATABASES = credentials.get_json(
"DATABASES",
{
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
},
)
###
@ -110,7 +156,6 @@ AUTHENTICATION_BACKENDS = [
"allauth.account.auth_backends.AuthenticationBackend",
]
SOCIALACCOUNT_ONLY = True
SOCIALACCOUNT_PROVIDERS = {
"openid_connect": {
"OAUTH_PKCE_ENABLED": True,
@ -122,22 +167,91 @@ SOCIALACCOUNT_PROVIDERS = {
"secret": credentials["KANIDM_SECRET"],
"settings": {
"server_url": f"https://sso.dgnum.eu/oauth2/openid/{credentials['KANIDM_CLIENT']}",
"color": "primary",
},
}
],
},
"cas": {
"APP": {
"provider_id": "ens_cas",
"name": "CAS ENS",
"settings": {"color": "danger"},
},
},
# "saml": {
# "APPS": [
# {
# "provider_id": "ens_saml",
# "name": "SSO ENS",
# "client_id": "ens",
# "settings": {
# "color": "info",
# "idp": {
# "entity_id": "https://federation-test.ens.psl.eu/idp/shibboleth",
# "metadata_url": "https://federation-test.ens.psl.eu/idp/shibboleth",
# },
# # Our configuration
# "sp": {
# "entity_id": "https://profil.dgnum.eu/accounts/saml/ens/metadata",
# },
# "advanced": {
# "authn_request_signed": True,
# "metadata_signed": True,
# "private_key": credentials["X509_KEY"],
# "x509cert": credentials["X509_CERT"],
# "want_assertion_encrypted": False,
# "want_attribute_statement": True,
# "want_name_id": True,
# },
# "organization": {
# "en": {
# "name": "Délégation Générale Numérique",
# "displayname": "Délégation Générale Numérique",
# "url": "https://dgnum.eu",
# },
# },
# "contact_person": {
# "technical": {
# "givenName": "Tom Hubrecht",
# "emailAddress": "admins@dgnum.eu",
# },
# "administrative": {
# "givenName": "Jean-Marc Gailis",
# "emailAddress": "bureau@dgnum.eu",
# },
# },
# },
# }
# ],
# },
}
SOCIALACCOUNT_ONLY = True
SOCIALACCOUNT_ADAPTER = "shared.account.SharedAccountAdapter"
ACCOUNT_EMAIL_VERIFICATION = "none"
ACCOUNT_AUTHENTICATION_METHOD = "username"
AUTH_PASSWORD_VALIDATORS = []
AUTH_USER_MODEL = "dgsi.User"
###
# Internationalization configuration
# -> https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = "fr-fr"
LANGUAGE_CODE = "fr"
TIME_ZONE = "UTC"
LANGUAGES = [
("en", "English"),
("fr", "Français"),
]
LOCALE_PATHS = [
(BASE_DIR / "shared" / "locale"),
]
TIME_ZONE = "Europe/Paris"
USE_I18N = True
USE_TZ = True
@ -148,6 +262,7 @@ USE_TZ = True
# -> https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = "static/"
MEDIA_URL = "media/"
STATICFILES_DIRS = [BASE_DIR / "shared" / "static"]
STATICFILES_FINDERS = [
@ -156,7 +271,8 @@ STATICFILES_FINDERS = [
"sass_processor.finders.CssFinder",
]
STATIC_ROOT = credentials["STATIC_ROOT"]
STATIC_ROOT = credentials.get("STATIC_ROOT")
MEDIA_ROOT = credentials.get("MEDIA_ROOT")
###
# Storages configuration
@ -186,20 +302,40 @@ SASS_PROCESSOR_ENABLED = True
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
##
# Messages configuration
MESSAGE_TAGS = {
messages.DEBUG: "is-dark",
messages.INFO: "is-primary",
messages.SUCCESS: "is-success",
messages.WARNING: "is-warning",
messages.ERROR: "is-danger",
}
###
# Unfold Interface configuration
UNFOLD = {
"SITE_HEADER": _("Administration de DGSI"),
}
###
# Extend settings when running in dev mode
if DEBUG:
INSTALLED_APPS += [
"debug_toolbar",
"django_browser_reload",
]
MIDDLEWARE += [
"debug_toolbar.middleware.DebugToolbarMiddleware",
"django_browser_reload.middleware.BrowserReloadMiddleware",
]
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
INTERNAL_IPS = ["127.0.0.1"]
DEBUG_TOOLBAR_CONFIG = {"INSERT_BEFORE": "</footer>"}

View file

@ -14,22 +14,23 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from django.views.generic import TemplateView
urlpatterns = [
path("", TemplateView.as_view(template_name="home.html"), name="index"),
path("login", TemplateView.as_view(template_name="login.html"), name="login"),
path("", include("dgsi.urls")),
path("accounts/", include("allauth.urls")),
path("admin/", admin.site.urls),
path("i18n/", include("django.conf.urls.i18n")),
path("__reload__/", include("django_browser_reload.urls")),
]
if settings.DEBUG:
urlpatterns += [
path("admin/", admin.site.urls),
path("__reload__/", include("django_browser_reload.urls")),
path("__debug__/", include("debug_toolbar.urls")),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
*static(settings.STATIC_URL, document_root=settings.STATIC_ROOT),
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
]

View file

@ -7,10 +7,6 @@ For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
application = get_wsgi_application()

View file

@ -1 +1,62 @@
# Register your models here.
from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from django.utils.translation import gettext_lazy as _
from import_export.admin import ImportExportMixin
from unfold.admin import ModelAdmin
from unfold.contrib.import_export.forms import (
ExportForm,
ImportForm,
SelectableFieldsExportForm,
)
from dgsi.models import Bylaws, Service, Statutes, Translation, User
assert DjangoUserAdmin.fieldsets is not None
# Unregister allauth models
def unregister(*models, site=None):
"""
Unregister the given model(s) classes.
unregister(Author)
The `site` kwarg is an admin site to use instead of the default admin site.
"""
from django.contrib.admin.sites import site as default_site
admin_site = site or default_site
if not isinstance(admin_site, admin.AdminSite):
raise ValueError("site must subclass AdminSite")
admin_site.unregister(models)
unregister(SocialAccount, SocialApp, SocialToken)
@admin.register(User)
class UserAdmin(DjangoUserAdmin, ImportExportMixin, ModelAdmin):
import_form_class = ImportForm
export_form_class = ExportForm
export_form_class = SelectableFieldsExportForm
# Add the local fields
fieldsets = (
*DjangoUserAdmin.fieldsets,
(
_("Documents DGNum"),
{"fields": ("accepted_statutes", "accepted_bylaws")},
),
)
@admin.register(Bylaws, Service, SocialAccount, Statutes, Translation)
class AdminClass(ImportExportMixin, ModelAdmin):
compressed_fields = True
import_form_class = ImportForm
export_form_class = ExportForm
export_form_class = SelectableFieldsExportForm

51
src/dgsi/forms.py Normal file
View file

@ -0,0 +1,51 @@
from asgiref.sync import async_to_sync
from django.core.exceptions import ValidationError
from django.forms import BooleanField, CharField, EmailField, forms
from django.utils.translation import gettext_lazy as _
from shared.kanidm import klient
@async_to_sync
async def name_validator(value: str) -> None:
try:
await klient.person_account_get(value)
except ValueError:
return
raise ValidationError(_("Identifiant déjà présent dans la base de données."))
class CreateKanidmAccountForm(forms.Form):
# TODO: Add a field for the clipper login information for the local mapping
name = CharField(
label=_("Identifiant"),
help_text=_("De préférence identique au login ENS de la personne concernée."),
validators=[name_validator],
)
displayname = CharField(label=_("Nom d'usage"))
mail = EmailField(
label=_("Adresse e-mail"),
help_text=_(
"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 personnes ayant fini leur scolarité ;<br>"
"<b>Pour les personnes extérieures, le bureau doit donner son accord.</b>"
),
)
active = BooleanField(
label=_("Membre actif"),
help_text=_(
"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>"
),
required=False,
)
class CreateSelfAccountForm(forms.Form):
displayname = CharField(label=_("Nom d'usage"))
mail = EmailField(
label=_("Adresse e-mail"),
help_text=_("De préférence l'adresse '@ens.psl.eu'"),
)

View file

@ -0,0 +1,131 @@
# Generated by Django 4.2.12 on 2024-09-14 13:35
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
]

View file

@ -0,0 +1,33 @@
# Generated by Django 4.2.12 on 2024-09-23 15:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Service",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(
max_length=255, verbose_name="Nom du service proposé"
),
),
("url", models.URLField(verbose_name="Adresse du service")),
],
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 4.2.12 on 2024-09-23 21:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0002_service"),
]
operations = [
migrations.AddField(
model_name="service",
name="icon",
field=models.CharField(
blank=True, max_length=255, verbose_name="Icône du service"
),
),
]

View file

@ -0,0 +1,81 @@
# Generated by Django 4.2.12 on 2024-09-24 08:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0003_service_icon"),
]
operations = [
migrations.CreateModel(
name="Bylaws",
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(upload_to="", verbose_name="Fichier PDF")),
],
options={
"get_latest_by": "date",
"abstract": False,
},
),
migrations.CreateModel(
name="Statutes",
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(upload_to="", verbose_name="Fichier PDF")),
],
options={
"get_latest_by": "date",
"abstract": False,
},
),
migrations.AddField(
model_name="user",
name="accepted_bylaws",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="dgsi.bylaws",
),
),
migrations.AddField(
model_name="user",
name="accepted_statutes",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="dgsi.statutes",
),
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 4.2.12 on 2024-09-24 20:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0004_bylaws_statutes_user_accepted_bylaws_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="bylaws",
options={
"get_latest_by": "date",
"verbose_name": "Règlement Intérieur",
"verbose_name_plural": "Règlements Intérieurs",
},
),
migrations.AlterModelOptions(
name="statutes",
options={
"get_latest_by": "date",
"verbose_name": "Statuts",
"verbose_name_plural": "Statuts",
},
),
]

View file

@ -0,0 +1,32 @@
# Generated by Django 4.2.12 on 2024-09-26 09:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0005_alter_bylaws_options_alter_statutes_options"),
]
operations = [
migrations.CreateModel(
name="Translation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("cas_login", models.CharField(max_length=255, unique=True)),
("username", models.CharField(max_length=255, unique=True)),
],
options={
"verbose_name": "Correspondance de login",
"verbose_name_plural": "Correspondances de login",
},
),
]

View file

@ -0,0 +1,36 @@
# Generated by Django 4.2.16 on 2024-09-27 10:40
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0006_translation"),
]
operations = [
migrations.AlterField(
model_name="user",
name="accepted_bylaws",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="dgsi.bylaws",
verbose_name="Dernier Règlement Intérieur accepté",
),
),
migrations.AlterField(
model_name="user",
name="accepted_statutes",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="dgsi.statutes",
verbose_name="Derniers statuts acceptés",
),
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 4.2.16 on 2024-10-12 20:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0007_alter_user_accepted_bylaws_and_more"),
]
operations = [
migrations.AlterField(
model_name="user",
name="accepted_statutes",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="dgsi.statutes",
verbose_name="Derniers Statuts acceptés",
),
),
]

20
src/dgsi/mixins.py Normal file
View file

@ -0,0 +1,20 @@
from django.contrib.auth.mixins import UserPassesTestMixin
from django.http import HttpRequest
from dgsi.models import User
class StaffRequiredMixin(UserPassesTestMixin):
request: HttpRequest
def test_func(self) -> bool:
if not self.request.user.is_authenticated:
return False
assert isinstance(self.request.user, User)
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

View file

@ -1 +1,187 @@
# Create your models here.
import logging
from dataclasses import dataclass
from functools import cached_property
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.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from kanidm.exceptions import NoMatchingEntries
from kanidm.models.person import Person
from shared.kanidm import klient
ADMIN_GROUP = "dgnum_admins@sso.dgnum.eu"
class Service(models.Model):
name = models.CharField(_("Nom du service proposé"), max_length=255)
url = models.URLField(_("Adresse du service"))
icon = models.CharField(_("Icône du service"), max_length=255, blank=True)
# TODO: Add a group field, to show only if the user really has access,
# when this field is null, then it is open bar
def __str__(self) -> str:
return f"{self.name} [{self.url}]"
class LegalDocument(models.Model):
date = models.DateField(_("Date du document"))
name = models.CharField(_("Nom du document"), max_length=255)
file = models.FileField(_("Fichier PDF"))
@classmethod
def latest(cls, **kwargs) -> Self | None:
return cls.objects.filter(**kwargs).latest()
def __str__(self) -> str:
return self.name
class Meta: # pyright: ignore
abstract = True
class Statutes(LegalDocument):
"""
Statutes of the association.
"""
kind = "statutes"
class Meta: # pyright: ignore
get_latest_by = "date"
verbose_name = _("Statuts")
verbose_name_plural = _("Statuts")
class Bylaws(LegalDocument):
"""
Bylaws of the association.
"""
kind = "bylaws"
class Meta: # pyright: ignore
get_latest_by = "date"
verbose_name = _("Règlement Intérieur")
verbose_name_plural = _("Règlements Intérieurs")
# class TermsAndConditions(LegalDocument):
# """
# Terms and Conditions of use regarding a service offered by the association.
# """
#
# service = models.ForeignKey(Service, on_delete=models.CASCADE)
class Translation(models.Model):
cas_login = models.CharField(max_length=255, unique=True)
username = models.CharField(max_length=255, unique=True)
def __str__(self) -> str:
return f"{self.cas_login}{self.username}"
def update_user(self, username: str):
# Update the username of the person with the required cas_login
try:
# Find out if a user exists with the cas_login to update
account = SocialAccount.objects.get(provider="ens_cas", uid=self.cas_login)
# WARNING: This updates the remote data, we need to be careful with what we do
async_to_sync(klient.person_account_update)(account.user.username, username)
account.user.username = username
account.user.save()
except SocialAccount.DoesNotExist:
# No user has registered with this cas_login yet
pass
def save(self, *args, **kwargs) -> None:
# INFO: Only update the model if it does not already exist
# This will prevent a lot of pain
if self.pk is None:
self.update_user(self.username)
return super().save(*args, **kwargs)
class Meta: # pyright: ignore
verbose_name = _("Correspondance de login")
verbose_name_plural = _("Correspondances de login")
# INFO: We need to use a signal receiver here, as the delete method is not called
# when deleteing objects from the admin interface
@receiver(pre_delete, sender=Translation)
def restore_username(**kwargs):
"""
Restore the username to the cas_login
"""
self = kwargs["instance"]
self.update_user(self.cas_login)
@dataclass
class KanidmProfile:
person: Person
radius_secret: Optional[str]
class User(AbstractUser):
"""
Custom User class, to have a direct link to the Kanidm data.
"""
accepted_statutes = models.ForeignKey(
Statutes,
on_delete=models.SET_NULL,
null=True,
default=None,
verbose_name=_("Derniers Statuts acceptés"),
)
accepted_bylaws = models.ForeignKey(
Bylaws,
on_delete=models.SET_NULL,
null=True,
default=None,
verbose_name=_("Dernier Règlement Intérieur accepté"),
)
# accepted_terms = models.ManyToManyField(TermsAndConditions)
@classmethod
def from_request(cls, request: HttpRequest) -> Self:
u = request.user
assert isinstance(u, cls)
return u
@cached_property
@async_to_sync
async def kanidm(self) -> Optional[KanidmProfile]:
try:
radius_data = (await klient.get_radius_token(self.username)).data
return KanidmProfile(
person=(await klient.person_account_get(self.username)),
radius_secret=radius_data and radius_data.get("secret"),
)
except NoMatchingEntries:
return None
except (TimeoutError, ClientConnectorError) as e:
logging.error(f"Erreur lors de la requête à Kanidm: {e}")
return None
@property
def is_admin(self) -> bool:
return (self.kanidm is not None) and (
ADMIN_GROUP in self.kanidm.person.memberof
)

View file

@ -0,0 +1,4 @@
<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

@ -0,0 +1,26 @@
{% load i18n %}
<h2 class="subtitle">
{{ title }}
<span class="tags is-pulled-right">
{% if user_document != document %}
<a class="tag is-warning"
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>
</a>
{% else %}
<span class="tag is-success">
<span>{% trans "Accepté" %}</span>
<span class="icon is-size-6"><i class="ti ti-checkbox"></i></span>
</span>
{% endif %}
<span class="tag is-dark">{{ document.date }}</span>
</span>
</h2>
<a class="button bt-link" href="{{ document.file.url }}">
<span>{{ document }}</span>
<span class="icon"><i class="ti ti-file-download"></i></span>
</a>

View file

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<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>{% trans "Enregistrer" %}</span>
<span class="icon"><i class="ti ti-check"></i></span>
</button>
</form>
{% endblock content %}

View file

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<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>{% trans "Enregistrer" %}</span>
<span class="icon"><i class="ti ti-check"></i></span>
</button>
</form>
{% endblock content %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<section class="section">
{% if user.is_authenticated %}
{% for link in links.authenticated %}
{% include "_index_link.html" %}
{% endfor %}
{% endif %}
{% if user.is_admin %}
<hr>
{% for link in links.admin %}
{% include "_index_link.html" %}
{% endfor %}
{% endif %}
</section>
{% endblock content %}

View file

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<h2 class="subtitle">Documents Légaux</h2>
<hr>
{% if user.kanidm is None %}
{% if show_message %}
<div class="notification is-warning is-light has-text-centered">
<b>{% trans "Vous devez accepter les Statuts et le Règlement Intérieur de la DGNum avant de pouvoir créer un compte." %}</b>
</div>
{% else %}
<div class="notification is-primary is-light has-text-centered">
<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:dgn-create_self_account" %}">{% trans "Poursuivre la création d'un compte DGNum" %}</a>
</div>
{% endif %}
{% endif %}
<br class="my-5">
{% include "_legal_document.html" with document=statutes user_document=user.accepted_statutes title=_("Statuts") accept_question=_("Accepter les statuts") %}
<br class="my-4">
{% include "_legal_document.html" with document=bylaws user_document=user.accepted_bylaws title=_("Règlement Intérieur") accept_question=_("Accepter le règlement intérieur") %}
{% endblock content %}

View file

@ -0,0 +1,70 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<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 %}
<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>
{% 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 %}
<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>
{% 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:dgn-create_self_account" %}">{% trans "Créer un compte DGNum" %}</a>
</div>
{% endif %}
{% endblock content %}

View file

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<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:dgn-services_redirect" service.pk %}">
<span class="icon"><i class="ti ti-{{ service.icon }}"></i></span>
<span>{{ service.name }}</span>
</a>
{% endfor %}
</div>
{% endblock content %}

View file

@ -1 +1,50 @@
urlpatterns = []
from django.urls import path
from . import views
app_name = "dgsi"
urlpatterns = [
# Misc views
path("", views.IndexView.as_view(), name="dgn-index"),
# Legal documents
path(
"legal-documents/",
views.LegalDocumentsView.as_view(),
name="dgn-legal_documents",
),
path(
"legal-documents/accept/<slug:kind>/",
views.AcceptLegalDocumentView.as_view(),
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="dgn-generate_wifi_password",
),
path(
"accounts/create/",
views.CreateSelfAccountView.as_view(),
name="dgn-create_self_account",
),
path(
"accounts/create-kanidm/",
views.CreateKanidmAccountView.as_view(),
name="dgn-create_kanidm_user",
),
path(
"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(
"services/redirect/<int:pk>/",
views.ServiceRedirectView.as_view(),
name="dgn-services_redirect",
),
]

View file

@ -1 +1,275 @@
# Create your views here.
from typing import Any, NamedTuple
from asgiref.sync import async_to_sync
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 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
from django.views.generic.detail import SingleObjectMixin
from dgsi.forms import CreateKanidmAccountForm, CreateSelfAccountForm
from dgsi.mixins import StaffRequiredMixin
from dgsi.models import Bylaws, Service, Statutes, User
from shared.kanidm import klient
class Link(NamedTuple):
color: str
reverse: str
text: str | Promise
icon: str | None = None
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: list[Link] = [
Link(
"is-danger",
"dgsi:dgn-create_kanidm_user",
_("Créer un compte Kanidm"),
"user-plus",
),
Link(
"is-warning", "admin:index", _("Interface d'administration"), "settings-filled"
),
]
class IndexView(TemplateView):
template_name = "dgsi/index.html"
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)
return super().get_context_data(
displayname=f"{u.first_name} {u.last_name}",
**kwargs,
)
class GenerateWiFiPasswordView(LoginRequiredMixin, RedirectView):
url = reverse_lazy("dgsi:dgn-profile")
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
user = User.from_request(self.request)
if user.kanidm is None:
messages.error(self.request, _("Compte DGNum inexistant."))
else:
# 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 super().get(request, *args, **kwargs)
# INFO: We subclass AccessMixin and not LoginRequiredMixin because the way we want to
# use dispatch means that we need to execute the login check anyways.
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:dgn-profile")
def dispatch(
self, request: HttpRequest, *args: Any, **kwargs: Any
) -> HttpResponseBase:
if not request.user.is_authenticated:
return self.handle_no_permission()
u = User.from_request(request)
# Check that the user does not already exist
if u.kanidm is not None:
messages.add_message(
request,
messages.WARNING,
_("<b>Vous possédez déjà un compte DGNum !</b>"),
)
return HttpResponseRedirect(reverse_lazy("dgsi:dgn-profile"))
# Check that the Statutes and Bylaws have been accepted
if (
u.accepted_statutes != Statutes.latest()
or u.accepted_bylaws != Bylaws.latest()
):
messages.add_message(
request,
messages.WARNING,
_("Vous devez accepter les Statuts et le Règlement Intérieur."),
)
return HttpResponseRedirect(reverse_lazy("dgsi:dgn-legal_documents"))
return super().dispatch(request, *args, **kwargs)
@async_to_sync
async def form_valid(self, form):
ttl = 86400 # 24h
d = form.cleaned_data
u = User.from_request(self.request)
# Create the base account
await klient.person_account_create(u.username, d["displayname"])
# Update the information
await klient.person_account_update(u.username, mail=[d["mail"]])
# FIXME: Will maybe change when kanidm gets its shit together and switches to POST
r = await klient.call_get(
f"/v1/person/{u.username}/_credential/_update_intent/{ttl}"
)
assert r.data is not None
token: str = r.data["token"]
link = f"https://sso.dgnum.eu/ui/reset?token={token}"
# Send an email to the new user with the given email address
EmailMessage(
subject="Réinitialisation de mot de passe DGNum -- DGNum password reset",
body=render_to_string(
"mail/credentials_reset.txt",
context={"link": link},
),
from_email="To Be Determined <dgsi@infra.dgnum.eu>",
to=[d["mail"]],
headers={"Reply-To": "contact@dgnum.eu"},
).send()
return super().form_valid(form)
class LegalDocumentsView(LoginRequiredMixin, TemplateView):
template_name = "dgsi/legal_documents.html"
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
u = User.from_request(self.request)
statutes = Statutes.latest()
bylaws = Bylaws.latest()
return super().get_context_data(
statutes=statutes,
bylaws=bylaws,
show_message=(
(u.accepted_bylaws != bylaws) or (u.accepted_statutes != statutes)
),
**kwargs,
)
class AcceptLegalDocumentView(LoginRequiredMixin, RedirectView):
url = reverse_lazy("dgsi:dgn-legal_documents")
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
u = User.from_request(self.request)
match kwargs.get("kind"):
case "statutes":
u.accepted_statutes = Statutes.latest()
u.save()
case "bylaws":
u.accepted_bylaws = Bylaws.latest()
u.save()
case k:
messages.add_message(
request,
messages.WARNING,
_("Type de document invalide : %(kind)s") % {"kind": k},
)
return super().get(request, *args, **kwargs)
##
# INFO: Below are classes related to services offered by the DGNum
class ServiceListView(LoginRequiredMixin, ListView):
model = Service
# TODO: Only show available websites
class ServiceRedirectView(LoginRequiredMixin, SingleObjectMixin, RedirectView):
model = Service
def get_redirect_url(self, *args: Any, **kwargs: Any) -> str:
return self.get_object().url
##
# INFO: Below are views related to the administration of DGSI
class CreateKanidmAccountView(StaffRequiredMixin, SuccessMessageMixin, FormView):
form_class = CreateKanidmAccountForm
template_name = "dgsi/create_kanidm_account.html"
success_message = _("Compte DGNum pour %(displayname)s [%(name)s] créé.")
success_url = reverse_lazy("dgsi:dgn-create_kanidm_user")
@async_to_sync
async def form_valid(self, form):
ttl = 86400 # 24h
d = form.cleaned_data
# Create the base account
await klient.person_account_create(d["name"], d["displayname"])
# Update the information
await klient.person_account_update(d["name"], mail=[d["mail"]])
# If necessary, add the user to the active members group
if d["active"]:
await klient.group_add_members("dgnum_members", [d["name"]])
# FIXME: Will maybe change when kanidm gets its shit together and switches to POST
r = await klient.call_get(
f"/v1/person/{d['name']}/_credential/_update_intent/{ttl}"
)
assert r.data is not None
token: str = r.data["token"]
link = f"https://sso.dgnum.eu/ui/reset?token={token}"
# Send an email to the new user with the given email address
EmailMessage(
subject="Réinitialisation de mot de passe DGNum -- DGNum password reset",
body=render_to_string(
"mail/credentials_reset.txt",
context={"link": link},
),
to=[d["mail"]],
headers={"Reply-To": "contact@dgnum.eu"},
).send()
return super().form_valid(form)

123
src/shared/account.py Normal file
View file

@ -0,0 +1,123 @@
import logging
from functools import lru_cache
from typing import Optional
from allauth.core.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.models import SocialLogin
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
logger = logging.getLogger(__name__)
class SharedAccountAdapter(DefaultSocialAccountAdapter):
"""
Overrides the Account Adapter, to allow a simpler connection via CAS.
"""
@lru_cache
def _get_username(self, request: HttpRequest, sociallogin: SocialLogin) -> str:
"""
Returns the required username
"""
match sociallogin.account.provider:
case "ens_cas":
cas_login = sociallogin.account.extra_data["uid"]
# Verify that this user can indeed connect to the website
home = sociallogin.account.extra_data["homeDirectory"].split("/")
if (home[1] != "users") or (
home[2]
in ["absint", "algo", "grecc", "guests", "spi", "spi1", "staffs"]
):
messages.error(request, _("Catégorie de compte ENS interdite."))
raise ImmediateHttpResponse(
HttpResponseRedirect(reverse("dgsi:dgn-forbidden_account"))
)
# Continue with the login flow
try:
return Translation.objects.get(cas_login=cas_login).username
except Translation.DoesNotExist:
return cas_login
case "kanidm":
return sociallogin.account.extra_data["preferred_username"]
case _:
logger.warning(sociallogin.user)
# INFO: This should never happen
messages.error(request, _("Méthode de connexion invalide."))
raise ImmediateHttpResponse(
HttpResponseRedirect(reverse("dgsi:dgn-forbidden_account"))
)
def _get_user(
self, request: HttpRequest, sociallogin: SocialLogin
) -> Optional[User]:
"""
Returns the required user for completing the login
"""
# The user is already linked to the social login, no reason to change it
if sociallogin.is_existing:
return sociallogin.user
# No user is currently linked to this social login, either the user has already
# logged in with another method, or it truly does not exist
return User.objects.filter(
username=self._get_username(request, sociallogin)
).first()
def _update_user(self, request: HttpRequest, sociallogin: SocialLogin):
"""
Updates the required attributes of the user:
- username
- permissions
"""
u = sociallogin.user
assert isinstance(u, User)
# Update the username first, so that calls to kanidm return the correct information
u.username = self._get_username(request, sociallogin)
# Update the global permissions
u.is_staff = u.is_admin
u.is_superuser = u.is_admin
# Save the updated user if needed
if sociallogin.is_existing:
u.save()
def pre_social_login(self, request, sociallogin: SocialLogin):
###
# The flow is the following:
# - Get the correct user
# - Do the connection if possible
# - Update the required attributes
user = self._get_user(request, sociallogin)
if user is not None:
sociallogin.user = user
# If the user exists, connect to it
if sociallogin.is_existing:
sociallogin.connect(request, sociallogin.user)
self._update_user(request, sociallogin)
def populate_user(self, request, sociallogin, data):
return super().populate_user(request, sociallogin, data)
def save_user(self, request, sociallogin: SocialLogin, form=None):
return super().save_user(request, sociallogin, form)

View file

View file

@ -0,0 +1,11 @@
from allauth.socialaccount.providers.base import ProviderAccount
from allauth_cas.providers import CASProvider as Provider
class CASProvider(Provider):
id = "cas" # Choose an identifier for your provider
name = "CAS ENS" # Verbose name of your provider
account_class = ProviderAccount
provider_classes = [CASProvider]

5
src/shared/cas/urls.py Normal file
View file

@ -0,0 +1,5 @@
from allauth_cas.urls import default_urlpatterns
from .provider import CASProvider
urlpatterns = default_urlpatterns(CASProvider)

14
src/shared/cas/views.py Normal file
View file

@ -0,0 +1,14 @@
from allauth_cas.views import CASAdapter as Adapter
from allauth_cas.views import CASCallbackView, CASLoginView
from .provider import CASProvider
class CASAdapter(Adapter):
provider_id = CASProvider.id
url = "https://cas.eleves.ens.fr"
version = 3
login = CASLoginView.adapter_view(CASAdapter)
callback = CASCallbackView.adapter_view(CASAdapter)

8
src/shared/kanidm.py Normal file
View file

@ -0,0 +1,8 @@
from kanidm import KanidmClient
from loadcredential import Credentials
credentials = Credentials(env_prefix="DGSI_")
klient = KanidmClient(
uri=credentials["KANIDM_URI"], token=credentials["KANIDM_AUTH_TOKEN"]
)

Binary file not shown.

View file

@ -0,0 +1,351 @@
# 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.
#
msgid ""
msgstr ""
"Project-Id-Version: dgsi.dgnum.eu\n"
"Report-Msgid-Bugs-To: \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"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1)\n"
"X-Generator: Gtranslator 46.1\n"
#: app/settings.py:321
msgid "Administration de DGSI"
msgstr "DGSI Administration"
#: 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 "
"personnes ayant fini leur scolarité ;<br><b>Pour les personnes extérieures, "
"le bureau doit donner son accord.</b>"
msgstr ""
"Preferably:<br>- the <code>@ens.psl.eu</code> address for students;<br>- the "
"<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>"
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"
#: 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"
#: 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"
#: 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"
#: 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."
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"
#: 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:"
#: dgsi/templates/dgsi/profile.html:36
msgid "Adresse e-mail :"
msgstr "E-mail address:"
#: dgsi/templates/dgsi/profile.html:41
msgid "Informations techniques"
msgstr "Technical informations"
#: 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"
#: dgsi/views.py:30
msgid "Mon profil"
msgstr "My profile"
#: dgsi/views.py:31
msgid "Documents Légaux"
msgstr "Legal Documents"
#: dgsi/views.py:32
msgid "Services proposés"
msgstr "Services offered"
#: dgsi/views.py:39
msgid "Créer un compte Kanidm"
msgstr "Create a Kanidm account"
#: dgsi/views.py:43 shared/templates/_hero.html:76
msgid "Interface d'administration"
msgstr "Administration interface"
#: dgsi/views.py:80
msgid "Compte DGNum inexistant."
msgstr "No existing DGNum account."
#: 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>."
#: 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 à "
"l'adresse : <a href=\"mailto:contact@dgnum.eu\">contact@dgnum.eu</a>"
msgstr ""
"Your details do not allow the DGNum to authenticate you.<br>If you think "
"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"

View file

@ -7,12 +7,78 @@ $dark: rgb(46, 46, 46);
@use "sass" with (
$primary: $blue,
$link: rgb(72, 95, 199),
$dark: $dark,
$dark: $dark
);
@use "./sass/utilities/mixins" as mx;
@use "./sass/utilities/initial-variables.scss" as iv;
body {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.bt-link {
display: flex;
width: 100%;
margin-bottom: calc(0.5 * var(--bulma-block-spacing));
font-size: 1.25rem;
&.is-light {
// Dark color for text
--bulma-color-l: var(--bulma-dark-l);
--bulma-color-l-delta: 0%;
color: hsl(
var(--bulma-dark-h),
var(--bulma-dark-s),
calc(var(--bulma-color-l) + var(--bulma-color-l-delta))
);
}
}
.bt-links {
justify-content: space-evenly;
.button {
@include mx.from(iv.$desktop) {
width: 47.5%;
}
@include mx.until(iv.$desktop) {
width: 100%;
}
}
}
.grid.groups {
--bulma-grid-column-min: min(24rem, 100%);
.button > span {
overflow-x: hidden;
text-overflow: ellipsis;
}
}
#notifications {
margin-left: -0.75rem;
margin-right: -0.75rem;
position: sticky;
display: block;
inset: 1.5rem;
z-index: 500;
}
.notification {
margin-bottom: var(--bulma-block-spacing);
}
.dropdown.is-fullwidth {
width: 100%;
.dropdown-trigger,
.dropdown-menu {
width: 100%;
}
}

View file

@ -0,0 +1,11 @@
document.addEventListener("DOMContentLoaded", () => {
(document.querySelectorAll(".notification .delete") || []).forEach(
($delete) => {
const $notification = $delete.parentNode;
const dismiss = () => $notification.parentNode.removeChild($notification);
$delete.addEventListener("click", dismiss);
setTimeout(dismiss, 15000);
},
);
});

View file

@ -1,6 +1,6 @@
{% load django_browser_reload %}
{% load i18n django_browser_reload %}
<footer class="footer has-text-centered">
<b>Logiciel développé pour et par la <a href="https://dgnum.eu">DGNum</a>.</b>
<b>{% blocktrans %}Logiciel développé pour et par la <a href="https://dgnum.eu">DGNum</a>.{% endblocktrans %}</b>
{% django_browser_reload_script %}
</footer>

View file

@ -1,37 +1,80 @@
{% load i18n %}
<section class="hero is-dark is-primary">
<div class="hero-body">
<div class="container">
<div class="grid">
<div class="cell">
<h1 class="title">
<a href="{% url 'index' %}" class="has-text-dark">Dossier Général des Services Informagiques</a>
</h1>
<h2 class="subtitle">Système d'information de la DGNum</h2>
</div>
<div class="cell">
<section class="hero {% if admin_view %}is-danger{% else %}is-primary{% endif %}">
<div class="hero-body px-0">
<div class="columns mx-6">
<div class="column is-three-quarters">
<h1 class="title">
<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>
<div class="column">
<div class="buttons mt-5">
{% if user.is_authenticated %}
<a href="{% url 'account_logout' %}" class="button is-light is-pulled-right">
<span>
<span>{% trans "Déconnexion" %}</span>
<span class="icon">
<i class="ti ti-door-exit"></i>
<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>
</span>
</a>
</a>
{% else %}
<a href="{% url 'login' %}" class="button is-light is-pulled-right">
<span>
<span>{% trans "Connexion" %}</span>
<span class="icon">
<i class="ti ti-door-enter"></i>
<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>
</span>
</a>
</a>
{% endif %}
<div class="dropdown is-hoverable is-fullwidth">
<div class="dropdown-trigger"
aria-haspopup="true"
aria-controls="lang-menu">
<button class="button is-primary is-light is-fullwidth has-text-dark">
<span class="icon"><i class="ti ti-language"></i></span>
<span>{% trans "Choix de la langue" %}</span>
<span class="icon is-pulled-right">
<i class="ti ti-chevron-down" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="lang-menu" role="menu">
<div class="dropdown-content px-2">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<input name="next" type="hidden" value="{{ redirect }}">
<input type="hidden" name="language" value="{{ language.code }}">
<button class="dropdown-item {% if language.code == LANGUAGE_CODE %}is-active{% endif %}">
{{ language.name_local|capfirst }}
</button>
</form>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% if admin_view %}
<div class="hero is-dark">
<div class="hero-body py-2 has-text-centered">
<span class="icon">
<i class="ti ti-lock-open-2"></i>
</span>
<span>{% trans "Interface d'administration" %}</span>
</div>
</div>
{% endif %}
</section>

View file

@ -10,3 +10,7 @@
<!-- 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 data-domain="profil.dgnum.eu" src="https://analytics.dgnum.eu/js/script.js"></script>

View file

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% load i18n socialaccount %}
{% load allauth account %}
{% block content %}
<h2 class="subtitle">{% trans "Connexion via un compte tiers" %}</h2>
<hr>
{% get_providers as providers %}
<div class="grid mt-5">
{% for provider in providers %}
{% if provider.id == "openid" %}
{% for brand in provider.get_brands %}
<a class="cell button is-{{ provider.app.settings.color }} is-light py-5 is-size-4"
title="{{ brand.name }}"
href="{% provider_login_url provider openid=brand.openid_url process="login" %}"><b>{{ brand.name }}</b></a>
{% endfor %}
{% endif %}
<a class="cell button is-{{ provider.app.settings.color }} is-light py-5 is-size-5"
title="{{ provider.id }}"
href="{% provider_login_url provider process="login" scope=scope auth_params=auth_params %}">{{ provider.name }}</a>
{% endfor %}
</div>
<!-- TODO: Write a text explaining how the different methods work -->
{% endblock content %}
{% block extra_body %}
{{ block.super }}
{% if PASSKEY_LOGIN_ENABLED %}
{% include "mfa/webauthn/snippets/login_script.html" with button_id="passkey_login" %}
{% endif %}
{% endblock extra_body %}

View file

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% load allauth i18n %}
{% block content %}
<h2 class="subtitle">{% trans "Déconnexion" %}</h2>
<hr>
<p class="notification is-warning is-light has-text-centered">
{% trans "Êtes vous certain·e de vouloir vous déconnecter ?" %}
</p>
<form method="post" action="{% url 'account_logout' %}">
{% csrf_token %}
{{ redirect_field }}
<button class="button is-fullwidth" type="submit">{% trans "Se déconnecter" %}</button>
</form>
{% endblock content %}

View file

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<h2 class="subtitle">{% trans "Connexion impossible" %}</h2>
<hr>
<div class="notification is-warning is-light px-5 py-5 has-text-centered is-size-5">
{% blocktrans %}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 à l'adresse : <a href="mailto:contact@dgnum.eu">contact@dgnum.eu</a>{% endblocktrans %}
</div>
{% endblock content %}

View file

@ -1,4 +1,5 @@
{% load allauth %}
{% comment %} djlint:off {% endcomment %}
<{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %}
{% if attrs.form %}form="{{ attrs.form }}"{% endif %}

View file

@ -1 +0,0 @@
<a class="cell button is-primary is-light" title="{{ attrs.name }}" href="{{ attrs.href }}">{{ attrs.name }}</a>

View file

@ -1,6 +0,0 @@
{% load allauth %}
<div class="grid mt-5">
{% slot default %}
{% endslot %}
</div>

View file

@ -1,5 +1,3 @@
{% load django_browser_reload i18n sass_tags static %}
<!DOCTYPE html>
<html lang="fr">
<head>
@ -15,58 +13,17 @@
<body>
{% include "_hero.html" %}
<section class="section container">
<nav class="level">
{% if user.is_authenticated %}
{% url 'account_email' as email_url %}
{% if email_url %}
<li class="level-item button is-light">
<a href="{{ email_url }}">{% trans "Change Email" %}</a>
</li>
{% endif %}
{% url 'account_change_password' as change_password_url %}
{% if change_password_url %}
<li class="level-item button is-light">
<a href="{{ change_password_url }}">{% trans "Change Password" %}</a>
</li>
{% endif %}
{% url 'mfa_index' as mfa_url %}
{% if mfa_url %}
<li class="level-item button is-light">
<a href="{{ mfa_url }}">{% trans "Two-Factor Authentication" %}</a>
</li>
{% endif %}
{% url 'usersessions_list' as usersessions_list_url %}
{% if usersessions_list_url %}
<li class="level-item button is-light">
<a href="{{ usersessions_list_url }}">{% trans "Sessions" %}</a>
</li>
{% endif %}
{% url 'account_logout' as logout_url %}
{% if logout_url %}
<li class="level-item button is-light">
<a href="{{ logout_url }}">{% trans "Sign Out" %}</a>
</li>
{% endif %}
{% else %}
{% url 'account_login' as login_url %}
{% if login_url %}
<li class="level-item button is-light has-text-weight-bold">
<a href="{{ login_url }}">{% trans "Sign In" %}</a>
</li>
{% endif %}
{% url 'account_signup' as signup_url %}
{% if signup_url %}
<li class="level-item button is-light has-text-weight-bold">
<a href="{{ signup_url }}">{% trans "Sign Up" %}</a>
</li>
{% endif %}
{% endif %}
</nav>
<section class="section">
<div id="notifications">
{% for message in messages %}
<article class="notification is-light has-text-centered {{ message.tags }}">
<button class="delete"></button>
{{ message|safe }}
</article>
{% endfor %}
</div>
<hr>
<div class="content">
<div class="content container">
{% block content %}
{% endblock content %}
</div>

View file

@ -7,13 +7,25 @@
<meta name="description" content="Système d'information de la DGNum" />
<title>DGNum</title>
{% block extra_head %}
{% endblock extra_head %}
{% include "_links.html" %}
</head>
<body>
{% include "_hero.html" %}
<section class="section">
<section class="container is-max-widescreen py-6">
<div id="notifications">
{% for message in messages %}
<article class="notification is-light has-text-centered {{ message.tags }}">
<button class="delete"></button>
{{ message|safe }}
</article>
{% endfor %}
</div>
{% block content %}
{% endblock content %}
</section>

View file

@ -1,3 +0,0 @@
{% extends "base.html" %}
{% block content %}{% endblock %}

View file

@ -1,29 +0,0 @@
{% extends "base.html" %}
{% load i18n socialaccount %}
{% block content %}
<div class="fixed-grid">
<div class="grid">
<a href="{% provider_login_url 'kanidm' %}"
class="cell has-background-primary-dark p-6 has-radius-normal has-text-centered has-text-white">
<span class="icon-text">
<span class="icon">
<i class="ti ti-login"></i>
</span>
<span><b>{% trans "Connexion via la DGNum" %}</b></span>
</span>
</a>
<a href="{% provider_login_url 'kanidm' %}"
class="cell has-background-primary p-6 has-radius-normal has-text-centered has-text-white">
<span class="icon-text">
<span class="icon">
<i class="ti ti-login"></i>
</span>
<span><b>{% trans "Connexion via l'ENS" %}</b></span>
</span>
</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,17 @@
Bonjour,
Une demande de réinitialisation de votre mot de passe DGNum a été effectuée.
Pour mettre à jour vos moyens de connexion, merci de vous rendre à l'adresse : {{ link }}
--
Hello,
A request to reset your DGNum password has been made.
To update your login details, please go to: {{ link }}
Bien cordialement,
La Délégation Générale Numérique

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% load i18n %}
{% load allauth %}
{% block content %}
<h2 class="subtitle">{% trans "Erreur lors de la connexion" %}</h2>
<hr>
<p class="notification is-danger is-light has-text-centered">
{% trans "Une erreur est survenue lors de votre tentative de connexion avec un compte tiers." %}
</p>
{% endblock content %}

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% load allauth i18n %}
{% block title %}
{% trans "Connexion" %}
{% endblock title %}
{% block content %}
<h2 class="subtitle">
{% blocktrans with provider.name as provider %}Se connecter via un compte <b>{{ provider }}</b>{% endblocktrans %}
</h2>
<hr>
<p class="notification is-warning is-light has-text-centered">
{% blocktrans with provider.name as provider %}Vous vous apprêtez à vous connecter à l'aide d'un compte tiers provenant de {{ provider }}.{% endblocktrans %}
</p>
<form method="post">
{% csrf_token %}
<button class="button is-fullwidth" type="submit">{% trans "Continuer" %}</button>
</form>
{% endblock content %}