Compare commits

...

26 commits

Author SHA1 Message Date
e6213fd884
fix: Misc 2024-10-22 11:11:13 +02:00
e736c4071e
feat: Update settings to the new format 2024-10-22 10:50:33 +02:00
d369834b3c
feat: Add nix tooling 2024-10-22 10:50:15 +02:00
e4a837ffad
chore: Move WikiENS to app 2024-10-22 08:52:15 +02:00
Basile Clement
25a1b880e5 Merge branch 'thubrecht/django3' into 'master'
Update to Django 3.2

See merge request klub-dev-ens/WikiENS!18
2022-12-05 13:57:57 +01:00
d98d8d013c Update to Django 3.2 2022-01-07 16:47:09 +01:00
Martin Pepin
dbffa735a0 Merge branch 'thubrecht/scroll-groups' into 'master'
On évite le graphe qui prend 3 écrans de largeur

See merge request klub-dev-ens/WikiENS!16
2021-10-30 10:59:57 +02:00
4262f0e7a5 On évite le graphe qui prend 3 écrans de largeur 2021-10-29 17:51:21 +02:00
Martin Pepin
9ce3238000 Merge branch 'kerl/group_list' into 'master'
List des groupes

See merge request klub-dev-ens/WikiENS!11
2021-10-26 20:12:27 +02:00
29c5abbc1a On affiche la liste des managers 2021-10-26 20:01:58 +02:00
Martin Pépin
cc50d540c3 Display the list of groups at /_groups/ 2021-10-26 19:48:33 +02:00
Martin Pepin
ef00b63053 Merge branch 'thubrecht/groupes' into 'master'
Ajoute la gestion des groupes

See merge request klub-dev-ens/WikiENS!15
2021-10-26 19:41:30 +02:00
c771f1ad49 Placeholder 2021-10-26 19:38:00 +02:00
66cb88721f Docstring 2021-10-26 19:29:30 +02:00
e8830540ec Meilleur message d'erreur 2021-10-26 19:20:29 +02:00
93792f5f46 On évite les erreurs si pas la bonne url 2021-10-26 19:17:06 +02:00
d5c1e2225a On remplace les Querysets par ce qu'on veut 2021-10-26 19:06:23 +02:00
3da477729a Better docstring 2021-10-26 18:57:38 +02:00
9c02e1e902 Use already_notified in get_all_groups 2021-10-26 18:53:31 +02:00
f257209f8f JS update 2021-10-11 15:34:24 +02:00
0fa182cb4a Ajoute la gestion des groupes 2021-07-24 04:13:47 +02:00
Basile Clement
20d0f1fba9 Define HOST to "" to use unix domain socket connection 2021-04-10 20:04:01 +02:00
Martin Pepin
7111ce1583 Merge branch 'thubrecht/navbar' into 'master'
Corrige le look de la navbar du wiki

See merge request klub-dev-ens/WikiENS!13
2021-01-28 20:17:30 +01:00
69d34e5a44 Corrige le look de la navbar du wiki 2021-01-28 15:24:41 +01:00
Ludovic Stephan
e8db9f46a6 Merge branch 'dodo/fix_email' into 'master'
Changed email from cof-geek to klub-dev

See merge request klub-dev-ens/WikiENS!10
2021-01-01 21:11:28 +01:00
Dorian Lesbre
ef8bbdfde1 Changed email from cof-geek to klub-dev 2020-12-24 17:38:07 +01:00
36 changed files with 970 additions and 260 deletions

1
.credentials/SECRET_KEY Normal file
View file

@ -0,0 +1 @@
insecure-secret_key

1
.envrc Normal file
View file

@ -0,0 +1 @@
use nix

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ venv
.*.swp
*.pyc
*.sqlite3
.direnv

1
WikiENS/.gitignore vendored
View file

@ -1 +0,0 @@
settings.py

View file

@ -1 +0,0 @@
secret.py

View file

@ -1,16 +0,0 @@
import os
from .common import * # noqa
from .common import BASE_DIR
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DEBUG = True
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
}
}

View file

@ -1,11 +0,0 @@
import os
from .common import * # noqa
from .common import BASE_DIR
DEBUG = False
ALLOWED_HOSTS = ["www.eleves.ens.fr", "wiki.eleves.ens.fr"]
STATIC_ROOT = os.path.join(BASE_DIR, "..", "static")
MEDIA_ROOT = os.path.join(BASE_DIR, "..", "media")

View file

@ -1,7 +0,0 @@
SECRET_KEY = "_u5q4-^1qgkqg=i5o5ha*xkd@82#l$e+%m)$v+4y#t-5!g-%g2"
ADMINS = None
EMAIL_HOST = "localhost"
DBNAME = "wiki"
DBUSER = "wiki"
DBPASSWD = "dummy"

View file

@ -1,23 +0,0 @@
from django.conf.urls import url, include
from django.contrib import admin
from allauth_ens.views import capture_login, capture_logout
from wiki.urls import get_pattern as get_wiki_pattern
from django_nyt.urls import get_pattern as get_nyt_pattern
allauth_urls = [
# Catch login/logout views of admin site.
url(r'^_admin/login/$', capture_login),
url(r'^_admin/logout/$', capture_logout),
# Allauth urls.
url(r'^_profil/', include('allauth.urls')),
]
urlpatterns = allauth_urls + [
url(r'^_admin/', admin.site.urls),
url(r'^notifications/', get_nyt_pattern()),
url(r'^_groups/', include("wiki_groups.urls")),
url(r'', get_wiki_pattern()),
]
# TODO add MEDIA_ROOT

View file

@ -1,42 +1,34 @@
import os
"""
Django settings for the wiki_ens project
"""
from django.urls import reverse_lazy
from pathlib import Path
from django.contrib.messages import constants as messages
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from loadcredential import Credentials
try:
from . import secret
except ImportError:
raise ImportError(
"The secret.py file is missing.\n"
"For a development environment, simply copy secret_example.py"
)
credentials = Credentials(env_prefix="WIKIENS_")
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# WARNING: keep the secret key used in production secret!
SECRET_KEY = credentials["SECRET_KEY"]
# WARNING: don't run with debug turned on in production!
DEBUG = credentials.get_json("DEBUG", False)
ALLOWED_HOSTS = credentials.get_json("ALLOWED_HOSTS", [])
ADMINS = credentials.get_json("ADMINS", [])
SITE_ID = 1
def import_secret(name):
"""
Shorthand for importing a value from the secret module and raising an
informative exception if a secret is missing.
"""
try:
return getattr(secret, name)
except AttributeError:
raise RuntimeError("Secret missing: {}".format(name))
SECRET_KEY = import_secret("SECRET_KEY")
ADMINS = import_secret("ADMINS")
MANAGERS = ADMINS
EMAIL_HOST = import_secret("EMAIL_HOST")
DBNAME = import_secret("DBNAME")
DBUSER = import_secret("DBUSER")
DBPASSWD = import_secret("DBPASSWD")
SERVER_EMAIL = "wiki@www.eleves.ens.fr"
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
###
# List the installed applications
INSTALLED_APPS = [
"django.contrib.admin",
@ -66,6 +58,10 @@ INSTALLED_APPS = [
"allauth_ens.providers.clipper",
]
###
# List the installed middlewares
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
@ -74,9 +70,21 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
ROOT_URLCONF = "WikiENS.urls"
###
# The main url configuration
ROOT_URLCONF = "app.urls"
###
# Template configuration:
# - Django Templating Language is used
# - Application directories can be used
TEMPLATES = [
{
@ -90,65 +98,72 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"sekizai.context_processors.sekizai",
]
],
},
},
}
]
WSGI_APPLICATION = "WikiENS.wsgi.application"
SITE_ID = 1
###
# Database configuration
# -> https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Database
# https://docs.djangoproject.com/en/1.11/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": DBNAME,
"USER": DBUSER,
"PASSWORD": DBPASSWD,
"HOST": "localhost",
}
}
# Password validation
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
DATABASES = credentials.get_json(
"DATABASES",
{
"NAME": (
# XXX. Cette chaîne est très longue… Je la coupe en deux sinon
# black ne me fiche pas la paix (mais c'est vraiment nul)
"django.contrib.auth.password_validation."
"UserAttributeSimilarityValidator"
)
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
)
CACHES = credentials.get_json(
"CACHES",
default={
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
},
},
)
# Internationalization
# https://docs.djangoproject.com/en/1.11/topics/i18n/
###
# WSGI application configuration
WSGI_APPLICATION = "app.wsgi.application"
###
# Staticfiles configuration
STATIC_ROOT = credentials["STATIC_ROOT"]
STATIC_URL = "/static/"
MEDIA_ROOT = credentials.get("MEDIA_ROOT", BASE_DIR / "media")
MEDIA_URL = "/media/"
###
# Internationalization configuration
# -> https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = "fr-fr"
TIME_ZONE = "Europe/Paris"
USE_I18N = True
USE_L10N = True
USE_TZ = True
LANGUAGES = [
("fr", _("Français")),
]
# Authentication
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth
# https://django-allauth.readthedocs.io/en/latest/index.html
###
# Authentication configuration
AUTHENTICATION_BACKENDS = [
"allauth.account.auth_backends.AuthenticationBackend",
@ -180,14 +195,18 @@ SOCIALACCOUNT_PROVIDERS = {
},
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": f"django.contrib.auth.password_validation.{v}"}
for v in [
"UserAttributeSimilarityValidator",
"MinimumLengthValidator",
"CommonPasswordValidator",
"NumericPasswordValidator",
]
]
# Static / media contents
STATIC_URL = "/_static/"
MEDIA_URL = "/_media/"
# WIKI SETTINGS
###
# Wiki configuration
WIKI_ATTACHMENTS_EXTENSIONS = [
"pdf",
@ -209,12 +228,6 @@ WIKI_ATTACHMENTS_EXTENSIONS = [
WIKI_REVISIONS_PER_HOUR = 180
WIKI_REVISIONS_PER_MINUTES = 180
# Dark magic - tell django to use X-Forwarded-*
# This is needed for django-allauth-cas, see
# https://blog.ubuntu.com/2015/08/18/django-behind-a-proxy-fixing-absolute-urls
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Use sign up, login, logout, profile settings views of allauth.
WIKI_ACCOUNT_HANDLING = False
@ -226,3 +239,10 @@ WIKI_ACCOUNT_SIGNUP_ALLOWED = True
# will be treated as the others_write boolean field on models.Article.
WIKI_ANONYMOUS_WRITE = False
WIKI_ANONYMOUS = False
# FIXME: Add correct email settings
# Development settings
if DEBUG:
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"

20
app/urls.py Normal file
View file

@ -0,0 +1,20 @@
from allauth_ens.views import capture_login, capture_logout
from django.contrib import admin
from django.urls import include, path
allauth_urls = [
# Catch login/logout views of admin site.
path("admin/login/", capture_login),
path("admin/logout/", capture_logout),
# Allauth urls.
path("profil/", include("allauth.urls")),
]
urlpatterns = allauth_urls + [
path("_admin/", admin.site.urls),
path("notifications/", include("django_nyt.urls")),
path("_groups/", include("wiki_groups.urls")),
path("", include("wiki.urls")),
]
# TODO add MEDIA_ROOT

View file

@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "WikiENS.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
application = get_wsgi_application()

45
default.nix Normal file
View file

@ -0,0 +1,45 @@
{
sources ? import ./npins,
pkgs ? import sources.nixpkgs { },
}:
let
nix-pkgs = import sources.nix-pkgs { inherit pkgs; };
python3 = pkgs.python3.override {
packageOverrides = _: _: {
inherit (nix-pkgs) django-allauth-ens django-wiki loadcredential;
};
};
in
{
devShell = pkgs.mkShell {
name = "annuaire.dev";
packages = [
(python3.withPackages (ps: [
ps.django
ps.django-allauth-ens
ps.django-wiki
ps.loadcredential
ps.tinycss2
]))
];
env = {
DJANGO_SETTINGS_MODULE = "app.settings";
CREDENTIALS_DIRECTORY = builtins.toString ./.credentials;
WIKIENS_DEBUG = builtins.toJSON true;
WIKIENS_STATIC_ROOT = builtins.toString ./.static;
};
shellHook = ''
if [ ! -d .static ]; then
mkdir .static
fi
'';
};
}

View file

@ -3,7 +3,7 @@ import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "WikiENS.settings.local")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
try:
from django.core.management import execute_from_command_line
except ImportError:

80
npins/default.nix Normal file
View file

@ -0,0 +1,80 @@
# Generated by npins. Do not modify; will be overwritten regularly
let
data = builtins.fromJSON (builtins.readFile ./sources.json);
version = data.version;
mkSource =
spec:
assert spec ? type;
let
path =
if spec.type == "Git" then
mkGitSource spec
else if spec.type == "GitRelease" then
mkGitSource spec
else if spec.type == "PyPi" then
mkPyPiSource spec
else if spec.type == "Channel" then
mkChannelSource spec
else
builtins.throw "Unknown source type ${spec.type}";
in
spec // { outPath = path; };
mkGitSource =
{
repository,
revision,
url ? null,
hash,
branch ? null,
...
}:
assert repository ? type;
# At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository
# In the latter case, there we will always be an url to the tarball
if url != null then
(builtins.fetchTarball {
inherit url;
sha256 = hash; # FIXME: check nix version & use SRI hashes
})
else
assert repository.type == "Git";
let
urlToName =
url: rev:
let
matched = builtins.match "^.*/([^/]*)(\\.git)?$" repository.url;
short = builtins.substring 0 7 rev;
appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else "";
in
"${if matched == null then "source" else builtins.head matched}${appendShort}";
name = urlToName repository.url revision;
in
builtins.fetchGit {
url = repository.url;
rev = revision;
inherit name;
# hash = hash;
};
mkPyPiSource =
{ url, hash, ... }:
builtins.fetchurl {
inherit url;
sha256 = hash;
};
mkChannelSource =
{ url, hash, ... }:
builtins.fetchTarball {
inherit url;
sha256 = hash;
};
in
if version == 3 then
builtins.mapAttrs (_: mkSource) data.pins
else
throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"

22
npins/sources.json Normal file
View file

@ -0,0 +1,22 @@
{
"pins": {
"nix-pkgs": {
"type": "Git",
"repository": {
"type": "Git",
"url": "https://git.hubrecht.ovh/hubrecht/nix-pkgs"
},
"branch": "main",
"revision": "6f56463c0034d4162dabb98ee8e70d6c43214ac0",
"url": null,
"hash": "0dqm2n88f0yl63wacizwpjrcv51arz5z31nhwbjcbyjxrwiwxamq"
},
"nixpkgs": {
"type": "Channel",
"name": "nixpkgs-unstable",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-24.11pre694416.ccc0c2126893/nixexprs.tar.xz",
"hash": "0cn1z4wzps8nfqxzr6l5mbn81adcqy2cy2ic70z13fhzicmxfsbx"
}
},
"version": 3
}

View file

@ -1,3 +0,0 @@
Django==2.2.*
git+https://git.eleves.ens.fr/klub-dev-ens/django-allauth-ens.git@1.1.3
wiki==0.7

View file

@ -1,3 +0,0 @@
-r requirements.txt
psycopg2
gunicorn

View file

@ -1,6 +1,5 @@
{% extends "wiki/base_site.html" %}
{% load i18n staticfiles %}
{% load sekizai_tags %}
{% load i18n static sekizai_tags %}
{% block wiki_site_title %} - WikiENS{% endblock %}
@ -11,49 +10,49 @@
{% endblock %}
{% block wiki_header_navlinks %}
<ul class="nav navbar-nav">
<li class="active"><a href="{% url 'wiki:root' %}">Accueil</a></li>
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link text-primary" href="{% url 'wiki:root' %}">Accueil</a>
</li>
</ul>
<div class="navbar-right">
<ul class="nav navbar-nav">
<ul class="navbar-nav">
{% if user.is_authenticated %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" class="dropdown-toggle" data-toggle="dropdown">
{% trans "Paramètres de compte" %}
<b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li>
<a href="{% url "account_email" %}">
<div class="dropdown-menu">
<a class="dropdown-item" href="{% url 'account_email' %}">
<i class="fa fa-envelope"></i>
{% trans "Email" %}
</a>
</li>
<li>
<a href="{% url "account_change_password" %}">
<a class="dropdown-item" href="{% url 'account_change_password' %}">
<i class="fa fa-lock"></i>
{% trans "Mot de passe" %}
</a>
</li>
<li>
<a href="{% url "socialaccount_connections" %}" title="Clipper…">
<i class="fa fa-sign-in"></i>
<a class="dropdown-item" href="{% url 'socialaccount_connections' %}" title="Clipper…">
<i class="fa fa-sign-in-alt"></i>
{% trans "Connexions par tiers" %}
</a>
</li>
<li class="divider"></span>
<li>
<a href="{% url "account_logout" %}">
{% if request.user.is_staff or request.user.managed_groups.exists %}
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="{% url 'wiki_groups:managed-groups' %}">
<i class="fa fa-cog"></i>
{% trans "Liste des groupes gérés" %}
</a>
{% endif %}
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="{% url 'account_logout' %}">
<i class="fa fa-power-off"></i>
{% trans "Déconnexion" %}
</a>
</li>
</ul>
</div>
</li>
{% else %}
<li>
<a href="{% url "account_signup" %}">{% trans "Sign Up" %}</a>
<li class="nav-item">
<a class="nav-link" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
</li>
{% endif %}
</ul>
@ -62,5 +61,5 @@
{% block wiki_footer_logo %}
<a href="http://www.eleves.ens.fr" class="pull-right"><img height="100px" src="{% static 'img/logoEleves.png' %}" /></a>
<p>Wiki maintenu par les élèves de l'École normale supérieure. <br /> En cas de pépin contacter <tt>cof-geek [chez] ens [point] fr</tt></p>
<p>Wiki maintenu par les élèves de l'École normale supérieure. <br /> En cas de pépin contacter <tt>klub-dev [chez] ens [point] fr</tt></p>
{% endblock %}

1
shell.nix Normal file
View file

@ -0,0 +1 @@
(import ./. { }).devShell

View file

@ -1 +0,0 @@
default_app_config = "wiki_groups.apps.WikiGroupsConfig"

52
wiki_groups/forms.py Normal file
View file

@ -0,0 +1,52 @@
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from wiki_groups.models import WikiGroup
User = get_user_model()
class SelectUserForm(forms.Form):
user = forms.CharField(max_length=150)
def clean_user(self):
user = User.objects.filter(username=self.cleaned_data["user"]).first()
if user is None:
self.add_error(
"user", "Aucune utilisatrice ou utilisateur avec ce login n'existe."
)
return user
class SelectGroupForm(forms.Form):
group = forms.CharField(max_length=150)
def clean_group(self):
group = WikiGroup.objects.filter(
django_group__name=self.cleaned_data["group"]
).first()
if group is None:
self.add_error("group", "Aucun groupe avec ce nom n'existe.")
return group
class CreateGroupForm(forms.Form):
group = forms.CharField(max_length=150)
def clean_group(self):
name = self.cleaned_data["group"]
django_group, created = Group.objects.get_or_create(name=name)
if hasattr(django_group, "wikigroup"):
self.add_error("group", "Un groupe avec ce nom existe déjà.")
return None
group = WikiGroup.objects.create(django_group=django_group)
return group

View file

@ -0,0 +1,20 @@
# Generated by Django 2.2.19 on 2021-07-23 09:01
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('wiki_groups', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='wikigroup',
name='managers',
field=models.ManyToManyField(blank=True, related_name='managed_groups', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -1,12 +1,14 @@
from django.db import models
from django.contrib.auth.models import Group as DjangoGroup
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save, m2m_changed
from django.contrib.auth.models import Group as DjangoGroup
from django.db import models
from django.db.models.signals import m2m_changed, post_save
from django.dispatch import receiver
User = get_user_model()
class WikiGroup(models.Model):
""" A structured user group, used to grant permissions on sub-wikis
"""A structured user group, used to grant permissions on sub-wikis
This model contains a structured group of users, in the sense that a group contains
both users and other groups, allowing a DAG group structure.
@ -20,8 +22,8 @@ class WikiGroup(models.Model):
"""
class CyclicStructureException(Exception):
""" Exception raised when a new edge introduces a cycle in the groups'
structure """
"""Exception raised when a new edge introduces a cycle in the groups'
structure"""
def __init__(self, from_group, to_group):
self.from_group = from_group
@ -40,22 +42,54 @@ class WikiGroup(models.Model):
related_name="included_in_groups",
blank=True,
)
users = models.ManyToManyField(get_user_model(), blank=True)
users = models.ManyToManyField(User, blank=True)
managers = models.ManyToManyField(User, related_name="managed_groups", blank=True)
def __str__(self):
return str(self.django_group)
def get_all_users(self):
""" Get the queryset of all the users in this group, including recursively
included users """
"""Get the queryset of all the users in this group, including recursively
included users"""
users_set = self.users.all()
for subgroup in self.includes_groups.all():
users_set = users_set.union(subgroup.get_all_users())
return users_set
def is_manager(self, user):
"""Checks wether the user is a manager of this group or any subgroup"""
# Base case: the user is an explicit manager
if user in self.managers.all():
return True
for subgroup in self.includes_groups.all():
# If the user is a manager of a subgroup
if subgroup.is_manager(user):
return True
return False
def get_all_groups(self, already_notified=None):
"""Returns the set of metagroups i.e. self plus the list of groups including
this group recursively"""
if already_notified is None:
already_notified = set()
elif self.pk in already_notified:
return set()
already_notified.add(self.pk)
groups = {self}
for metagroup in self.included_in_groups.all():
groups |= metagroup.get_all_groups(already_notified=already_notified)
return groups
def propagate_update(self, already_notified=None):
""" Commits itself to the Django group, and calls this method on every group in
`included_in_groups` """
"""Commits itself to the Django group, and calls this method on every group in
`included_in_groups`"""
# Check that we did not already propagate the update signal to this group
if already_notified is None:
@ -69,20 +103,20 @@ class WikiGroup(models.Model):
metagroup.propagate_update(already_notified=already_notified)
def commit_to_django_group(self):
""" Writes this model's data to the related Django group """
"""Writes this model's data to the related Django group"""
self.django_group.user_set.set(self.get_all_users())
def group_in_cycle(self, with_children):
""" Checks whether this group would be in a group cycle if it had
"""Checks whether this group would be in a group cycle if it had
`with_children` as child nodes. This assumes that the graph currently stored is
acyclic.
Returns `None` if no cycle is found, else retuns a child from `with_children`
causing the cycle to appear. """
causing the cycle to appear."""
def do_dfs(cur_node, visited_nodes):
""" DFS to check whether we find `self` again """
"""DFS to check whether we find `self` again"""
if cur_node.pk in visited_nodes:
return False
if cur_node.pk == self.pk:
@ -103,7 +137,7 @@ class WikiGroup(models.Model):
@receiver(post_save, sender=WikiGroup, dispatch_uid="on_wiki_group_changed")
def on_wiki_group_changed(sender, instance, **kwargs):
""" Commit the related WikiGroups to Django Group upon model change """
"""Commit the related WikiGroups to Django Group upon model change"""
instance.propagate_update()
@ -113,8 +147,8 @@ def on_wiki_group_changed(sender, instance, **kwargs):
dispatch_uid="on_wiki_group_includes_changed",
)
def on_wiki_group_includes_changed(sender, instance, action, **kwargs):
""" Commit the related WikiGroups to Django Group upon change of the set of
included other groups """
"""Commit the related WikiGroups to Django Group upon change of the set of
included other groups"""
if action in ["post_add", "post_remove", "post_clear"]:
instance.propagate_update()
@ -125,7 +159,7 @@ def on_wiki_group_includes_changed(sender, instance, action, **kwargs):
dispatch_uid="on_wiki_group_users_changed",
)
def on_wiki_group_users_changed(sender, instance, action, **kwargs):
""" Commit the related WikiGroups to Django Group upon change of included users """
"""Commit the related WikiGroups to Django Group upon change of included users"""
if action in ["post_add", "post_remove", "post_clear"]:
instance.propagate_update()
@ -136,7 +170,7 @@ def on_wiki_group_users_changed(sender, instance, action, **kwargs):
dispatch_uid="on_wiki_group_includes_check_acyclic",
)
def on_wiki_group_includes_check_acyclic(sender, instance, action, pk_set, **kwargs):
""" Checks the acyclicity of the groups' graph before committing new edges.
"""Checks the acyclicity of the groups' graph before committing new edges.
PLEASE NOTE that this check is only a fallback, and that forms should validate
the acyclicity before committing anything.

View file

@ -0,0 +1,207 @@
{% extends "wiki/base.html" %}
{% load sekizai_tags %}
{% block wiki_site_title %}Groupes administrés - WikiENS{% endblock %}
{% block wiki_contents %}
<h2>Gestion du groupe « {{ wikigroup }} »</h2>
<hr>
<div class="container">
<div class="row">
<div class="col">
<h4>Liste des membres</h4>
<br>
<div class="list-group">
<div class="list-group-item">
<form action="{% url 'wiki_groups:add-user' wikigroup.pk %}" method="post">
{% csrf_token %}
<div class="input-group">
<input type="text" class="form-control {% if errors.user %}is-invalid{% endif %}" name="user" value="{{ errors.user.value }}" placeholder="Ajouter un membre">
<div class="input-group-append">
<button type="submit" class="btn btn-primary rounded-right">Enregistrer</button>
</div>
{% if errors.user %}
{% for msg in errors.user.msg %}
<div class="invalid-feedback">{{ msg }}</div>
{% endfor %}
{% endif %}
</div>
<small class="form-text text-muted">Entrer le login (clipper) de la personne à ajouter</small>
</form>
</div>
{% for user in wikigroup.users.all %}
<div class="list-group-item pb-2">
<span class="font-italic">{{ user }}</span>
<button type="button" class="btn btn-danger btn-sm float-right" data-toggle="modal" data-target="#modal-confirm" data-href="{% url 'wiki_groups:remove-user' wikigroup.pk user.pk %}" data-name="{{ user }}" data-kind="membre">Enlever</a>
</div>
{% endfor %}
</div>
</div>
<div class="col">
<h4>Liste des groupes inclus</h4>
<br>
<div class="list-group">
<div class="list-group-item">
<form action="{% url 'wiki_groups:add-group' wikigroup.pk %}" method="post">
{% csrf_token %}
<div class="input-group">
<input type="text" class="form-control {% if errors.group_add %}is-invalid{% endif %}" name="group" value="{{ errors.group_add.value }}" placeholder="Ajouter un groupe existant">
<div class="input-group-append">
<button type="submit" class="btn btn-primary rounded-right">Enregistrer</button>
</div>
{% if errors.group_add %}
{% for msg in errors.group_add.msg %}
<div class="invalid-feedback">{{ msg }}</div>
{% endfor %}
{% endif %}
</div>
<small class="form-text text-muted">Entrer le nom du groupe à ajouter</small>
</form>
</div>
{% for group in wikigroup.includes_groups.all %}
<div class="list-group-item pb-2">
<span class="font-italic">{{ group }}</span>
<button type="button" class="btn btn-danger btn-sm float-right" data-toggle="modal" data-target="#modal-confirm" data-href="{% url 'wiki_groups:remove-group' wikigroup.pk group.pk %}" data-name="{{ group }}" data-kind="groupe">Enlever</a>
</div>
{% endfor %}
<div class="list-group-item">
<form action="{% url 'wiki_groups:create-group' wikigroup.pk %}" method="post">
{% csrf_token %}
<div class="input-group">
<input type="text" class="form-control {% if errors.group_create %}is-invalid{% endif %}" name="group" value="{{ errors.group_create.value }}" placeholder="Créer et ajouter un groupe">
<div class="input-group-append">
<button type="submit" class="btn btn-primary rounded-right">Enregistrer</button>
</div>
{% if errors.group_create %}
{% for msg in errors.group_create.msg %}
<div class="invalid-feedback">{{ msg }}</div>
{% endfor %}
{% endif %}
</div>
<small class="form-text text-muted">Entrer le nom du groupe à créer</small>
</form>
</div>
</div>
</div>
</div>
<hr>
<div class="row">
<div class="col-6">
<h4>Liste des gestionnaires</h4>
<br>
<div class="list-group">
<div class="list-group-item">
<form action="{% url 'wiki_groups:add-manager' wikigroup.pk %}" method="post">
{% csrf_token %}
<div class="input-group">
<input type="text" class="form-control {% if errors.manager %}is-invalid{% endif %}" name="user" value="{{ errors.manager.value }}" placeholder="Ajouter un·e gestionnaire">
<div class="input-group-append">
<button type="submit" class="btn btn-primary rounded-right">Enregistrer</button>
</div>
{% if errors.manager %}
{% for msg in errors.manager.msg %}
<div class="invalid-feedback">{{ msg }}</div>
{% endfor %}
{% endif %}
</div>
<small class="form-text text-muted">Entrer le login (clipper) de la personne à ajouter</small>
</form>
</div>
{% for user in wikigroup.managers.all %}
<div class="list-group-item pb-2">
<span class="font-italic">{{ user }}</span>
<button type="button" class="btn btn-danger btn-sm float-right" data-toggle="modal" data-target="#modal-confirm" data-href="{% url 'wiki_groups:remove-manager' wikigroup.pk user.pk %}" data-name="{{ user }}" data-kind="gestionnaire">Enlever</a>
</div>
{% endfor %}
</div>
</div>
{% if request.user.is_staff %}
<div class="col">
<h4>Supprimer ce groupe</h4>
<br>
<div class="jumbotron">
<p class="text-danger">Attention, cette action est irréversible.</p>
<button type="button" class="btn btn-danger" data-toggle="modal" data-target="#modal-delete">Supprimer</a>
</div>
</div>
<div class="modal fade" id="modal-delete">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Supprimer le groupe {{ wikigroup }} ?</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
La suppression du groupe « {{ wikigroup }} » est irréversible et tous ses membres seront enlevés des groupes suivants :
<ul>
{% for g in wikigroup.get_all_groups %}
<li>{{ g }}</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Annuler</button>
<a class="btn btn-danger" href="{% url 'wiki_groups:delete-group' wikigroup.pk %}">Supprimer</a>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{# Confirmation modal #}
<div class="modal fade" id="modal-confirm">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"></h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Annuler</button>
<a class="btn btn-danger">Enlever</a>
</div>
</div>
</div>
</div>
{% addtoblock "js" %}
<script>
$('#modal-confirm').on('show.bs.modal', function(event) {
const b = $(event.relatedTarget);
const modal = $(this);
modal.find('.modal-title').text(`Enlever un ${b.data('kind')}`);
modal.find('.modal-body').html(`Enlever <span class="font-weight-bold font-italic">${b.data('name')}</span> en tant que ${b.data('kind')} du groupe {{ wikigroup }} ?`);
modal.find('.modal-footer a').prop('href', b.data('href'));
});
</script>
{% endaddtoblock %}
{% endblock %}

View file

@ -1,37 +0,0 @@
{% extends "wiki/base.html" %}
{% load staticfiles %}
{% block wiki_site_title %}Groupes - WikiENS{% endblock %}
{% block wiki_contents %}
<h2>Graphe des groupes du wiki</h2>
<hr />
<div id="svg-graph"></div>
<hr />
<p>
Les flèches représentent l'inclusion : <code>A -&gt; B</code> signifie que
le groupe <code>A</code> est contenu dans le groupe <code>B</code>.
</p>
<script src="{% static 'wiki_groups/js/vendor/viz.js' %}"></script>
<script src="{% static 'wiki_groups/js/vendor/lite.render.js' %}"></script>
<script>
var viz = new Viz();
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
viz.renderSVGElement(this.responseText)
.then(element => {
document.getElementById("svg-graph").appendChild(element);
})
}
};
xhttp.open("GET", "{% url 'wiki_groups:dot_graph' %}", true);
xhttp.send();
</script>
{% endblock %}

View file

@ -0,0 +1,52 @@
{% extends "wiki/base.html" %}
{% load static %}
{% block wiki_site_title %}Groupes - WikiENS{% endblock %}
{% block wiki_contents %}
<h2>
Liste des groupes du wiki
{% if request.user.is_staff or request.user.managed_groups.exists %}
<a class="btn btn-primary float-right" href="{% url 'wiki_groups:managed-groups' %}">
Liste des groupes gérés
</a>
{% endif %}
</h2>
<hr>
<ul>
{% for group in wikigroup_list %}
<li>{{ group.django_group.name }} {% if group.managers.exists %}(Géré par {% for m in group.managers.all %}{{ m }}{% if not forloop.last %}, {% endif %}{% endfor %}){% endif %}</li>
{% endfor %}
</ul>
<h2>Graphe des groupes du wiki</h2>
<hr>
<div id="svg-graph" class="overflow-auto"></div>
<hr>
<p>
Les flèches représentent l'inclusion : <code>A -&gt; B</code> signifie que
le groupe <code>A</code> est contenu dans le groupe <code>B</code>.
</p>
<script src="{% static 'wiki_groups/js/vendor/viz.js' %}"></script>
<script src="{% static 'wiki_groups/js/vendor/lite.render.js' %}"></script>
<script>
var viz = new Viz();
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
viz.renderSVGElement(this.responseText)
.then(element => {
document.getElementById("svg-graph").appendChild(element);
})
}
};
xhttp.open('GET', '{% url 'wiki_groups:dot_graph' %}', true);
xhttp.send();
</script>
{% endblock %}

View file

@ -0,0 +1,18 @@
{% extends "wiki/base.html" %}
{% block wiki_site_title %}Groupes administrés - WikiENS{% endblock %}
{% block wiki_contents %}
<h2>Liste des groupes administrés</h2>
<hr>
{% if wikigroup_list %}
<div class="list-group">
{% for group in wikigroup_list %}
<a class="list-group-item list-group-item-action" href="{% url 'wiki_groups:admin-group' group.pk %}">{{ group }}</a>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View file

@ -1,9 +1,31 @@
from django.urls import path
from wiki_groups import views
from wiki_groups import views
app_name = "wiki_groups"
urlpatterns = [
path("", views.GroupList.as_view(), name="list"),
path("dot_graph", views.dot_graph, name="dot_graph"),
path("graph", views.graph, name="graph"),
path("managed", views.ManagedGroupsView.as_view(), name="managed-groups"),
path("<int:pk>", views.WikiGroupView.as_view(), name="admin-group"),
path(
"<int:pk>/rm-user/<int:user_pk>",
views.RemoveUserView.as_view(),
name="remove-user",
),
path(
"<int:pk>/rm-manager/<int:user_pk>",
views.RemoveManagerView.as_view(),
name="remove-manager",
),
path(
"<int:pk>/rm-group/<int:group_pk>",
views.RemoveGroupView.as_view(),
name="remove-group",
),
path("<int:pk>/add-user", views.AddUserView.as_view(), name="add-user"),
path("<int:pk>/add-manager", views.AddManagerView.as_view(), name="add-manager"),
path("<int:pk>/add-group", views.AddGroupView.as_view(), name="add-group"),
path("<int:pk>/create-group", views.CreateGroupView.as_view(), name="create-group"),
path("<int:pk>/delete", views.DeleteGroupView.as_view(), name="delete-group"),
]

View file

@ -1,8 +1,16 @@
from django.http import HttpResponse
from django.views.generic import TemplateView
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import HttpResponse, HttpResponseRedirect
from django.urls import reverse, reverse_lazy
from django.views.generic import DetailView, ListView, RedirectView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import BaseFormView
from wiki_groups.forms import CreateGroupForm, SelectGroupForm, SelectUserForm
from wiki_groups.models import WikiGroup
User = get_user_model()
def dot_graph(request):
response = HttpResponse(content_type="text/vnd.graphviz")
@ -22,4 +30,214 @@ def dot_graph(request):
return response
graph = TemplateView.as_view(template_name="wiki_groups/graph.html")
class GroupList(ListView):
template_name = "wiki_groups/list.html"
model = WikiGroup
def get_queryset(self):
return WikiGroup.objects.select_related("django_group").order_by(
"django_group__name"
)
class WikiGroupMixin(SingleObjectMixin, UserPassesTestMixin):
"""
Restricts the view to a manager of the wikigroup, and selects automatically
the required WikiGroup with its Django group
"""
model = WikiGroup
def get_queryset(self):
return super().get_queryset().select_related("django_group")
def test_func(self):
self.object = self.get_object()
return self.request.user.is_staff or self.object.is_manager(self.request.user)
class StaffMixin(UserPassesTestMixin):
"""Restricts the view to a staff member"""
def test_func(self):
return self.request.user.is_staff
class ManagedGroupsView(LoginRequiredMixin, ListView):
model = WikiGroup
template_name = "wiki_groups/managed.html"
def get_queryset(self):
if self.request.user.is_staff:
return WikiGroup.objects.select_related("django_group")
return self.request.user.managed_groups.select_related("django_group")
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
wikigroups = set()
for group in ctx["wikigroup_list"]:
wikigroups |= group.get_all_groups()
ctx["wikigroup_list"] = wikigroups
ctx["object_list"] = wikigroups
return ctx
class WikiGroupView(WikiGroupMixin, DetailView):
template_name = "wiki_groups/admin.html"
def get_context_data(self, **kwargs):
kwargs.update({"errors": self.request.session.pop("wiki_form_errors", None)})
return super().get_context_data(**kwargs)
class RemoveUserView(WikiGroupMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
return reverse("wiki_groups:admin-group", args=[kwargs["pk"]])
def get(self, request, *args, **kwargs):
user = User.objects.filter(pk=kwargs["user_pk"]).first()
if user is not None:
self.object.users.remove(user)
return super().get(request, *args, **kwargs)
class RemoveManagerView(WikiGroupMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
return reverse("wiki_groups:admin-group", args=[kwargs["pk"]])
def get(self, request, *args, **kwargs):
user = User.objects.filter(pk=kwargs["user_pk"]).first()
if user is not None:
self.object.users.remove(user)
self.object.managers.remove(user)
return super().get(request, *args, **kwargs)
class RemoveGroupView(WikiGroupMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
return reverse("wiki_groups:admin-group", args=[kwargs["pk"]])
def get(self, request, *args, **kwargs):
group = WikiGroup.objects.filter(pk=kwargs["group_pk"]).first()
if group is not None:
self.object.includes_groups.remove(group)
return super().get(request, *args, **kwargs)
class AddUserView(WikiGroupMixin, BaseFormView):
form_class = SelectUserForm
def get_success_url(self):
return reverse("wiki_groups:admin-group", args=[self.object.pk])
def form_valid(self, form):
self.object.users.add(form.cleaned_data["user"])
return super().form_valid(form)
def form_invalid(self, form):
self.request.session["wiki_form_errors"] = {
"user": {"value": form["user"].value(), "msg": form.errors["user"]}
}
return HttpResponseRedirect(self.get_success_url())
class AddManagerView(WikiGroupMixin, BaseFormView):
form_class = SelectUserForm
def get_success_url(self):
return reverse("wiki_groups:admin-group", args=[self.object.pk])
def form_valid(self, form):
self.object.managers.add(form.cleaned_data["user"])
return super().form_valid(form)
def form_invalid(self, form):
self.request.session["wiki_form_errors"] = {
"manager": {"value": form["user"].value(), "msg": form.errors["user"]}
}
return HttpResponseRedirect(self.get_success_url())
class AddGroupView(WikiGroupMixin, BaseFormView):
"""Adds an existing metagroup to this group"""
form_class = SelectGroupForm
def get_success_url(self):
return reverse("wiki_groups:admin-group", args=[self.object.pk])
def form_valid(self, form):
subgroup = form.cleaned_data["group"]
group = self.object
if group.group_in_cycle(list(group.includes_groups.all()) + [subgroup]):
form.add_error(
"group",
(
"Ajout impossible sous peine de créer un cycle "
f"({group} est inclus dans {subgroup})"
),
)
return self.form_invalid(form)
group.includes_groups.add(subgroup)
return super().form_valid(form)
def form_invalid(self, form):
self.request.session["wiki_form_errors"] = {
"group_add": {"value": form["group"].value(), "msg": form.errors["group"]}
}
return HttpResponseRedirect(self.get_success_url())
class CreateGroupView(WikiGroupMixin, BaseFormView):
"""Creates a metagroup included in this group"""
form_class = CreateGroupForm
def get_success_url(self):
return reverse("wiki_groups:admin-group", args=[self.object.pk])
def form_valid(self, form):
new_group = form.cleaned_data["group"]
self.object.includes_groups.add(new_group)
new_group.managers.add(self.request.user)
return super().form_valid(form)
def form_invalid(self, form):
self.request.session["wiki_form_errors"] = {
"group_create": {
"value": form["group"].value(),
"msg": form.errors["group"],
}
}
return HttpResponseRedirect(self.get_success_url())
class DeleteGroupView(SingleObjectMixin, StaffMixin, RedirectView):
model = WikiGroup
url = reverse_lazy("wiki_groups:managed-groups")
def get(self, request, *args, **kwargs):
group = self.get_object()
# On enlève les membres pour répercuter les changements
group.users.clear()
# On utilise la propagation de la suppression django_group -> wiki_group
group.django_group.delete()
return super().get(request, *args, **kwargs)