Compare commits

..

2 commits

Author SHA1 Message Date
Martin Pépin
6a6b5a1b97
Vue détaillée des groupes 2021-01-06 20:58:05 +01:00
Martin Pépin
811f5cb53a
Display the list of groups at /_groups/ 2021-01-06 20:54:45 +01:00
36 changed files with 299 additions and 943 deletions

View file

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

1
.envrc
View file

@ -1 +0,0 @@
use nix

1
.gitignore vendored
View file

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

1
WikiENS/.gitignore vendored Normal file
View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
WikiENS/settings/.gitignore vendored Normal file
View file

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

View file

View file

@ -1,34 +1,42 @@
"""
Django settings for the wiki_ens project
"""
import os
from pathlib import Path
from django.urls import reverse_lazy
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
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
try:
from . import secret
except ImportError:
raise ImportError(
"The secret.py file is missing.\n"
"For a development environment, simply copy secret_example.py"
)
###
# List the installed applications
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__))))
INSTALLED_APPS = [
"django.contrib.admin",
@ -58,10 +66,6 @@ INSTALLED_APPS = [
"allauth_ens.providers.clipper",
]
###
# List the installed middlewares
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
@ -70,21 +74,9 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
###
# The main url configuration
ROOT_URLCONF = "app.urls"
###
# Template configuration:
# - Django Templating Language is used
# - Application directories can be used
ROOT_URLCONF = "WikiENS.urls"
TEMPLATES = [
{
@ -98,72 +90,65 @@ 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
# 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 = [
{
"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"
)
},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
###
# Database configuration
# -> https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
DATABASES = credentials.get_json(
"DATABASES",
{
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
},
)
CACHES = credentials.get_json(
"CACHES",
default={
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
},
},
)
###
# 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/
# Internationalization
# https://docs.djangoproject.com/en/1.11/topics/i18n/
LANGUAGE_CODE = "fr-fr"
TIME_ZONE = "Europe/Paris"
USE_I18N = True
USE_L10N = True
USE_TZ = True
LANGUAGES = [
("fr", _("Français")),
]
###
# Authentication configuration
# Authentication
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth
# https://django-allauth.readthedocs.io/en/latest/index.html
AUTHENTICATION_BACKENDS = [
"allauth.account.auth_backends.AuthenticationBackend",
@ -195,18 +180,14 @@ SOCIALACCOUNT_PROVIDERS = {
},
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": f"django.contrib.auth.password_validation.{v}"}
for v in [
"UserAttributeSimilarityValidator",
"MinimumLengthValidator",
"CommonPasswordValidator",
"NumericPasswordValidator",
]
]
###
# Wiki configuration
# Static / media contents
STATIC_URL = "/_static/"
MEDIA_URL = "/_media/"
# WIKI SETTINGS
WIKI_ATTACHMENTS_EXTENSIONS = [
"pdf",
@ -228,6 +209,12 @@ 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
@ -239,10 +226,3 @@ 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"

16
WikiENS/settings/local.py Normal file
View file

@ -0,0 +1,16 @@
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"),
}
}

11
WikiENS/settings/prod.py Normal file
View file

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

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

23
WikiENS/urls.py Normal file
View file

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

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

View file

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

@ -1,45 +0,0 @@
{
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", "app.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "WikiENS.settings.local")
try:
from django.core.management import execute_from_command_line
except ImportError:

View file

@ -1,80 +0,0 @@
# 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`"

View file

@ -1,22 +0,0 @@
{
"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
}

3
requirements.txt Normal file
View file

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

3
requirements_prod.txt Normal file
View file

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

View file

@ -1,5 +1,6 @@
{% extends "wiki/base_site.html" %}
{% load i18n static sekizai_tags %}
{% load i18n staticfiles %}
{% load sekizai_tags %}
{% block wiki_site_title %} - WikiENS{% endblock %}
@ -10,49 +11,49 @@
{% endblock %}
{% block wiki_header_navlinks %}
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link text-primary" href="{% url 'wiki:root' %}">Accueil</a>
</li>
<ul class="nav navbar-nav">
<li class="active"><a href="{% url 'wiki:root' %}">Accueil</a></li>
</ul>
<div class="navbar-right">
<ul class="navbar-nav">
<ul class="nav navbar-nav">
{% if user.is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" class="dropdown-toggle" data-toggle="dropdown">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
{% trans "Paramètres de compte" %}
<b class="caret"></b>
</a>
<div class="dropdown-menu">
<a class="dropdown-item" href="{% url 'account_email' %}">
<i class="fa fa-envelope"></i>
{% trans "Email" %}
</a>
<a class="dropdown-item" href="{% url 'account_change_password' %}">
<i class="fa fa-lock"></i>
{% trans "Mot de passe" %}
</a>
<a class="dropdown-item" href="{% url 'socialaccount_connections' %}" title="Clipper…">
<i class="fa fa-sign-in-alt"></i>
{% trans "Connexions par tiers" %}
</a>
{% 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>
</div>
<ul class="dropdown-menu">
<li>
<a href="{% url "account_email" %}">
<i class="fa fa-envelope"></i>
{% trans "Email" %}
</a>
</li>
<li>
<a 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>
{% trans "Connexions par tiers" %}
</a>
</li>
<li class="divider"></span>
<li>
<a href="{% url "account_logout" %}">
<i class="fa fa-power-off"></i>
{% trans "Déconnexion" %}
</a>
</li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
<li>
<a href="{% url "account_signup" %}">{% trans "Sign Up" %}</a>
</li>
{% endif %}
</ul>
@ -61,5 +62,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>klub-dev [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>cof-geek [chez] ens [point] fr</tt></p>
{% endblock %}

View file

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

View file

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

View file

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

@ -1,20 +0,0 @@
# 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,14 +1,12 @@
from django.contrib.auth import get_user_model
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.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.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.
@ -22,8 +20,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
@ -42,54 +40,22 @@ class WikiGroup(models.Model):
related_name="included_in_groups",
blank=True,
)
users = models.ManyToManyField(User, blank=True)
managers = models.ManyToManyField(User, related_name="managed_groups", blank=True)
users = models.ManyToManyField(get_user_model(), 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:
@ -103,20 +69,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:
@ -137,7 +103,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()
@ -147,8 +113,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()
@ -159,7 +125,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()
@ -170,7 +136,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

@ -1,207 +0,0 @@
{% 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

@ -0,0 +1,41 @@
{% extends "wiki/base.html" %}
{% load staticfiles %}
{% block wiki_site_title %}Groups - WikiENS{% endblock %}
{% block wiki_contents %}
<h2>Informations sur le groupe : {{ group }}</h2>
<hr />
{% if group.users.exists %}
<h3>Membres du groupe</h3>
<hr />
<ul>
{% for user in group.users.all %}
<li>{{ user }}</li>
{% endfor %}
</ul>
{% endif %}
{% if group.includes_groups.exists %}
<h3>{{ group }} contient les groupes suivants</h3>
<hr />
<ul>
{% for g in group.includes_groups.all %}
<li><a href="{% url "wiki_groups:detail" g.id %}">{{ g }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if group.included_in_groups.exists %}
<h3>{{ group }} est contenu dans les groupes suivants</h3>
<hr />
<ul>
{% for g in group.included_in_groups.all %}
<li><a href="{% url "wiki_groups:detail" g.id %}">{{ g }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View file

@ -1,52 +1,47 @@
{% extends "wiki/base.html" %}
{% load static %}
{% load staticfiles %}
{% 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>
<h2>Liste des groupes du wiki</h2>
<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>
<hr />
<h2>Graphe des groupes du wiki</h2>
<hr>
<ul>
{% for id, name in groups %}
<li><a href="{% url "wiki_groups:detail" id %}">{{ name }}</a></li>
{% endfor %}
</ul>
<div id="svg-graph" class="overflow-auto"></div>
<hr>
<h2>Graphe des groupes du wiki</h2>
<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>
<hr />
<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();
<div id="svg-graph"></div>
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
viz.renderSVGElement(this.responseText)
<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>
}
};
xhttp.open("GET", "{% url 'wiki_groups:dot_graph' %}", true);
xhttp.send();
</script>
{% endblock %}

View file

@ -1,18 +0,0 @@
{% 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

@ -6,26 +6,5 @@ app_name = "wiki_groups"
urlpatterns = [
path("", views.GroupList.as_view(), name="list"),
path("dot_graph", views.dot_graph, name="dot_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"),
path("detail/<int:pk>", views.GroupDetail.as_view(), name="detail"),
]

View file

@ -1,16 +1,8 @@
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 django.http import HttpResponse
from django.views.generic import DetailView, ListView
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")
@ -33,211 +25,15 @@ def dot_graph(request):
class GroupList(ListView):
template_name = "wiki_groups/list.html"
model = WikiGroup
context_object_name = "groups"
def get_queryset(self):
return WikiGroup.objects.select_related("django_group").order_by(
return WikiGroup.objects.values_list("id", "django_group__name").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
"""
class GroupDetail(DetailView):
template_name = "wiki_groups/detail.html"
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)
context_object_name = "group"