Compare commits

...
Sign in to create a new pull request.

15 commits

Author SHA1 Message Date
Ludovic Stephan
317077951b Markdown fix 2020-11-16 19:12:22 +01:00
Ludovic Stephan
5ee66b453a README 2020-11-16 19:10:02 +01:00
Ludovic Stephan
3fee57061d Fix : side effects in local.py 2020-11-16 19:07:10 +01:00
Ludovic Stephan
d3ec952de8 Debug toolbar on vagrant 2020-11-16 19:03:24 +01:00
Ludovic Stephan
f45812a076 Fix les permissions des fichiers statiques sous vagrant 2020-11-16 18:50:45 +01:00
Ludovic Stephan
700272ac22 VAGRANT 2020-11-16 18:41:23 +01:00
Ludovic Stephan
764865c9d8 Ajoute la debug_toolbar 2020-11-16 18:41:06 +01:00
Ludovic Stephan
43026cecd2 Split settings between local and prod 2020-11-16 18:40:46 +01:00
Ludovic Stephan
d7263fc9e0 Split requirements 2020-11-16 18:39:29 +01:00
Ludovic Stephan
1d24478c1d Split in 2 commands 2020-11-16 18:33:41 +01:00
Ludovic Stephan
df10e754a5 requirements 2020-11-16 18:33:41 +01:00
Ludovic Stephan
bf05b0a7d4 Commande pour ajouter les conscrit·e·s 2020-11-16 18:33:41 +01:00
Ludovic Stephan
ef0076781e Rajoute les serveurs ldap aux settings 2020-11-16 18:33:41 +01:00
Ludovic Stephan
d01d4d4d51 Blackify settings (oups) 2020-11-16 18:33:41 +01:00
Ludovic Stephan
4289eed6d5 Small model tweaks 2020-11-16 18:33:41 +01:00
24 changed files with 902 additions and 149 deletions

5
.gitignore vendored
View file

@ -3,11 +3,16 @@
*.swo
*~
*#
*.log
/media/picture
/venv/
.vagrant/
static/
*.sqlite3
fiches/templates/fiches/base_old.html
fiches/static/fiches/css_old/
.vscode

View file

@ -1,6 +1,8 @@
# Annuaire des élèves de l'ENS
## Installation
## Environnement de développement
### Méthode facile : environnement virtuel python
Il est fortement conseillé d'utiliser un environnement virtuel pour Python.
@ -33,6 +35,29 @@ Vous êtes prêts à développer ! Lancez l'annuaire avec :
python manage.py runserver
### Plus compliqué : machine virtuelle Vagrant
Pour avoir une situation plus proche de la situation en production, il est possible de faire tourner le site depuis une
machine virtuelle Windows. Pour cela, il faut d'abord installer `vagrant` et `virtualbox` :
sudo apt install vagrant virtualbox
Ensuite, la commande `vagrant up` devrait créer et configurer la machine virtuelle ; un peu de patience, cela peut prendre
du temps ! Une fois fini, il y a deux possibilités :
- `vagrant ssh` permet de se connecter à la machine, et d'effectuer des opérations à l'aide de `manage.py`. On peut effectuer
à peu près les mêmes opérations que pour un virtualenv classique, à une différence près : il faut utiliser
```
python manage.py runserver 0.0.0.0:8000
```
pour lancer le serveur, afin d'y avoir accès depuis son navigateur.
- un serveur normalement très proche de celui de production (avec `gunicorn` + `nginx`) tourne en permanence sur la machine ;
il suffit de visiter `127.0.0.1:8080` pour y avoir accès !
## Développement
En manque d'inspiration ? N'hésitez pas à aller lire les issues ouvertes actuellement, il y en a pour tous les niveaux !

47
Vagrantfile vendored Normal file
View file

@ -0,0 +1,47 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure(2) do |config|
# The most common configuration options are documented and commented below.
# For a complete reference, please see the online documentation at
# https://docs.vagrantup.com.
config.vm.box = "ubuntu/focal64"
# On associe le port 80 dans la machine virtuelle avec le port 8080 de notre
# ordinateur, et le port 8000 avec le port 8000.
config.vm.network :forwarded_port, guest: 80, host: 8080
config.vm.network :forwarded_port, guest: 8000, host: 8000
# Create a private network, which allows host-only access to the machine
# using a specific IP.
# config.vm.network "private_network", ip: "192.168.33.10"
# Provider-specific configuration so you can fine-tune various
# backing providers for Vagrant. These expose provider-specific options.
# Example for VirtualBox:
#
# config.vm.provider "virtualbox" do |vb|
# # Display the VirtualBox GUI when booting the machine
# vb.gui = true
#
# # Customize the amount of memory on the VM:
# vb.memory = "1024"
# end
#
# View the documentation for the provider you are using for more
# information on available options.
# Enable provisioning with a shell script. Additional provisioners such as
# Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the
# documentation for more information about their specific syntax and use.
# config.vm.provision "shell", inline: <<-SHELL
# sudo apt-get update
# sudo apt-get install -y apache2
# SHELL
config.vm.provision :shell, path: "provisioning/bootstrap.sh", args: ENV['PWD']
end

View file

@ -1,137 +0,0 @@
"""
Django settings for annuaire project.
Generated by 'django-admin startproject' using Django 2.2b1.
For more information on this file, see
https://docs.djangoproject.com/en/dev/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/dev/ref/settings/
"""
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '84=n(@@wl(04oc$(-+3surgrlf&uq3=m)=(hpg$immi1h69s)p'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_cas_ng',
'fiches'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'annuaire.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
'django_cas_ng.backends.CASBackend',
)
WSGI_APPLICATION = 'annuaire.wsgi.application'
# Database
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
# Password validation
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME':
'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',
},
]
# Internationalization
# https://docs.djangoproject.com/en/dev/topics/i18n/
LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/dev/howto/static-files/
STATIC_URL = '/static/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
CAS_SERVER_URL = 'https://cas.eleves.ens.fr/'
CAS_VERSION = "2"
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

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

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

View file

130
annuaire/settings/common.py Normal file
View file

@ -0,0 +1,130 @@
"""
Settings communs entre setups de dev et de production.
"""
import os
import sys
# ---
# Secrets
# ---
try:
from . import secret
except ImportError:
raise ImportError(
"The secret.py file is missing.\n"
"For a development environment, simply copy secret_example.py"
)
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")
SERVER_EMAIL = import_secret("SERVER_EMAIL")
EMAIL_HOST = import_secret("EMAIL_HOST")
# ---
# Défauts Django
# ---
DEBUG = False # False by default feels safer
TESTING = len(sys.argv) > 1 and sys.argv[1] == "test"
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_cas_ng",
"fiches",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "annuaire.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.ModelBackend",
"django_cas_ng.backends.CASBackend",
)
WSGI_APPLICATION = "annuaire.wsgi.application"
# Internationalization
# https://docs.djangoproject.com/en/dev/topics/i18n/
LANGUAGE_CODE = "fr-fr"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# ---
# Settings CAS
# ---
CAS_SERVER_URL = "https://cas.eleves.ens.fr/"
CAS_VERSION = "2"
# ---
# LDAP et annuaire ENS
# ---
# Est-ce vraiment nécessaire de les garder secrets ?
LDAP = {
"SPI": {
"PROTOCOL": "ldaps",
"URL": "ldap.spi.ens.fr",
"PORT": 636,
},
"CRI": {
"PROTOCOL": "ldap",
"URL": "annuaire.ens.fr",
"PORT": 389,
},
}
ANNUAIRE = {
"PROTOCOL": "http",
"URL": "annuaireweb.ens.fr",
"PORT": 80,
}

View file

@ -0,0 +1,62 @@
"""
Settings pour le dev local de l'annuaire (hors vagrant).
"""
import os
from .common import * # NOQA
from .common import BASE_DIR, INSTALLED_APPS, MIDDLEWARE, TESTING
# ---
# Tweaks for debug/local development
# ---
ALLOWED_HOSTS = []
DEBUG = True
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
STATIC_URL = "/static/"
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
}
}
# Use the default cache backend for local development
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
# Pas besoin de sécurité en local
AUTH_PASSWORD_VALIDATORS = []
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
# ---
# Debug tool bar
# ---
def show_toolbar(request):
"""
On active la debug-toolbar en mode développement local sauf :
- dans l'admin où ça ne sert pas à grand chose;
- si la variable d'environnement DJANGO_NO_DDT est à 1 → ça permet de la désactiver
sans modifier ce fichier en exécutant `export DJANGO_NO_DDT=1` dans le terminal
qui lance `./manage.py runserver`.
Autre side effect de cette fonction : on ne fait pas la vérification de INTERNAL_IPS
que ferait la debug-toolbar par défaut, ce qui la fait fonctionner aussi à
l'intérieur de Vagrant (comportement non testé depuis un moment…)
"""
env_no_ddt = bool(os.environ.get("DJANGO_NO_DDT", None))
return DEBUG and not env_no_ddt and not request.path.startswith("/admin/")
if not TESTING:
INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar"]
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": show_toolbar}

81
annuaire/settings/prod.py Normal file
View file

@ -0,0 +1,81 @@
"""
Settings pour la mise en production de l'annuaire.
"""
import os
from .common import * # NOQA
from .common import (
BASE_DIR,
import_secret,
)
# ---
# Prod-specific secrets
# ---
REDIS_PASSWD = import_secret("REDIS_PASSWD")
REDIS_DB = import_secret("REDIS_DB")
REDIS_HOST = import_secret("REDIS_HOST")
REDIS_PORT = import_secret("REDIS_PORT")
DBNAME = import_secret("DBNAME")
DBUSER = import_secret("DBUSER")
DBPASSWD = import_secret("DBPASSWD")
# ---
# À modifier possiblement lors de la mise en production
# ---
ALLOWED_HOSTS = ["annuaire.eleves.ens.fr", "www.annuaire.eleves.ens.fr"]
STATIC_ROOT = os.path.join(
os.path.dirname(os.path.dirname(BASE_DIR)), "public", "annuaire", "static"
)
STATIC_URL = "/static/"
MEDIA_ROOT = os.path.join(os.path.dirname(BASE_DIR), "media")
MEDIA_URL = "/media/"
# ---
# Cache settings
# ---
CACHES = {
"default": {
"BACKEND": "redis_cache.RedisCache",
"LOCATION": "redis://:{passwd}@{host}:{port}/{db}".format(
passwd=REDIS_PASSWD, host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB
),
}
}
# ---
# Prod database settings
# ---
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": DBNAME,
"USER": DBUSER,
"PASSWORD": DBPASSWD,
"HOST": os.environ.get("DBHOST", "localhost"),
}
}
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # noqa
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]

View file

@ -0,0 +1,15 @@
SECRET_KEY = "$=kp$3e=xh)*4h8(_g#lprlmve_vs9_xv9hlgse%+uk9nhc==x"
ADMINS = None
SERVER_EMAIL = "root@localhost"
EMAIL_HOST = None
# Ne pas modifier si on utilise vagrant !
DBUSER = "annuaire"
DBNAME = "annuaire"
DBPASSWD = "O1LxCADDA6Px5SiKvifjvdp3DSjfbp"
REDIS_PASSWD = "dummy"
REDIS_PORT = 6379
REDIS_DB = 0
REDIS_HOST = "127.0.0.1"

View file

@ -0,0 +1,24 @@
"""
Settings pour le développement de l'annuaire avec vagrant.
Essaie de rester le plus fidèle possible aux settings de production,
avec des différences les plus minimes possibles.
"""
from .prod import * # noqa
from .prod import TESTING, INSTALLED_APPS, MIDDLEWARE
from .local import show_toolbar
DEBUG = True
MEDIA_ROOT = "/srv/annuaire/media"
STATIC_ROOT = "/srv/annuaire/static"
EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
EMAIL_FILE_PATH = "/var/mail/django"
ALLOWED_HOSTS = ["127.0.0.1", "localhost", "0.0.0.0"]
if not TESTING:
INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": show_toolbar}

View file

@ -21,13 +21,16 @@ from fiches.views import BirthdayView, HomeView
import django_cas_ng.views as cas_views
urlpatterns = [
path('admin/', admin.site.urls),
path('fiche/', include('fiches.urls')),
path('', HomeView.as_view(), name='home'),
path('birthday', BirthdayView.as_view(), name='birthday'),
path('accounts/login/', cas_views.LoginView.as_view(), name='cas_ng_login'),
path('logout', cas_views.LogoutView.as_view(), name='cas_ng_logout'),
path("admin/", admin.site.urls),
path("fiche/", include("fiches.urls")),
path("", HomeView.as_view(), name="home"),
path("birthday", BirthdayView.as_view(), name="birthday"),
path("accounts/login/", cas_views.LoginView.as_view(), name="cas_ng_login"),
path("logout", cas_views.LogoutView.as_view(), name="cas_ng_logout"),
]
if settings.DEBUG:
if settings.DEBUG and "debug_toolbar" in settings.INSTALLED_APPS:
import debug_toolbar
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += [path("__debug__/", include(debug_toolbar.urls))]

View file

@ -0,0 +1,148 @@
from collections import namedtuple
import ldap
import ldap.filter
from django.conf import settings
ClipperAccount = namedtuple("ClipperAccount", ["uid", "name", "email", "year", "dept"])
class LDAP:
"""Classe pour se connecter à un LDAP ENS (CRI ou SPI).
Inspirée de `merle_scripts`.
"""
# À renseigner
LDAP_SERVER = {
"PROTOCOL": "ldaps",
"URL": "ldap.example.com",
"PORT": 636,
}
search_base = ""
attr_list = []
def __init__(self):
""" Initialize the LDAP object """
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
self.ldap_obj = ldap.initialize(
"{PROTOCOL}://{URL}:{PORT}".format(**self.LDAP_SERVER)
)
self.ldap_obj.set_option(ldap.OPT_REFERRALS, 0)
self.ldap_obj.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
self.ldap_obj.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND)
self.ldap_obj.set_option(ldap.OPT_X_TLS_DEMAND, True)
self.ldap_obj.set_option(ldap.OPT_NETWORK_TIMEOUT, 10)
self.ldap_obj.set_option(ldap.OPT_TIMEOUT, 10)
def search(self, filters="(objectClass=*)"):
"""Do a ldap.search_s with the given filters, as specified for
ldap.search_s"""
return self.ldap_obj.search_s(
self.search_base, ldap.SCOPE_SUBTREE, filters, self.attr_list
)
@staticmethod
def extract_ldap_info(entry, field):
""" Extract the given field from an LDAP entry as an UTF-8 string """
return entry[1].get(field, [b""])[0].decode("utf-8")
class ClipperLDAP(LDAP):
LDAP_SERVER = settings.LDAP["SPI"]
search_base = "dc=spi,dc=ens,dc=fr"
attr_list = ["cn", "uid", "mail", "homeDirectory"]
verbose_depts = {
"bio": "Biologie",
"chimie": "Chimie",
"dec": "Études Cognitives",
"geol": "Géosciences",
"guests": "Invité·e",
"info": "Informatique",
"litt": "Lettres",
"maths": "Mathématiques",
"pei": "PEI",
"phy": "Physique",
}
def parse_dept(self, home_dir):
"""Extrait le département d'entrée d'un·e élève à partir de son dossier.
Le dossier a le format `/users/<promo>/<dept>/<login>`.
"""
users, promo, dept, login = home_dir.split("/")[1:]
if users != "users":
raise ValueError("Invalid home directory")
# Ça casse en 2100, mais le système de naming de sas aussi...
promo = "20" + promo
return promo, self.verbose_depts.get(dept, None)
def get_clipper_list(self, promo_filter=None):
"""Extrait la liste des comptes clipper présents dans le LDAP.
Renvoie une liste de `namedTuple` contenant leur nom, prénom, adresse mail et
département/année d'entrée.
Si `promo_filter != None`, ne renvoie que les clippers d'une promotion donnée.
"""
search_res = self.search()
clipper_list = []
for entry in search_res:
uid = self.extract_ldap_info(entry, "uid")
# Il y a des comptes "bizarre" (e.g. `root`) avec uid vide
if len(uid) > 0:
try:
name = self.extract_ldap_info(entry, "cn")
email = self.extract_ldap_info(entry, "mail")
promo, dept = self.parse_dept(
self.extract_ldap_info(entry, "homeDirectory")
)
if promo_filter is None or promo == promo_filter:
clipper_list.append(
ClipperAccount(uid, name, email, promo, dept)
)
except ValueError:
pass
return clipper_list
class AnnuaireLDAP(LDAP):
LDAP_SERVER = settings.LDAP["CRI"]
search_base = "ou=people,dc=ens,dc=fr"
attr_list = ["uid", "sn", "givenName", "ou"]
def try_match(self, profile):
"""Essaie de trouver une entrée correspondant au profile donné dans
l'annuaire de l'ENS. L'heuristique est la suivante : il est très probable
que le prénom de la personne commence par le premier mot de `profile.full_name`,
et que son nom de famille finisse par le dernier mot.
"""
given_name = profile.full_name.split(" ")[0]
last_name = profile.full_name.split(" ")[-1]
search_name = self.search(
"(&(givenName={}*)(sn=*{}))".format(given_name, last_name)
)
if len(search_name) > 0:
if len(search_name) > 2:
print(
"Erreur : deux résultats trouvés pour {}".format(
profile.user.username
)
)
return None
return self.extract_ldap_info(search_name[0], "uid")
return None

View file

@ -0,0 +1,82 @@
from datetime import date
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand
from fiches.models import Department, Profile
from ._ldap import ClipperLDAP
class Command(BaseCommand):
help = "Crée les fiches annuaire des conscrit·e·s automatiquement"
def add_arguments(self, parser):
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--all", action="store_true", help="Importe l'intégralité des promotions"
)
group.add_argument("--promo", help="Spécifie la promotion à importer")
def get_current_promo(self):
today = date.today()
year = today.year
if today.month < 9:
year -= 1
return str(year)
def handle(self, *args, **options):
if options["all"]:
promo = None
elif options["promo"] is not None:
promo = options["promo"]
if len(promo) == 2:
promo = "20" + promo
else:
promo = self.get_current_promo()
# On récupère la liste des élèves à créer
ldap = ClipperLDAP()
clipper_list = ldap.get_clipper_list(promo_filter=promo)
# On vire les élèves déjà existants
existing_users = set(User.objects.values_list("username", flat=True))
clippers = [
clipper for clipper in clipper_list if clipper.uid not in existing_users
]
# Les départements sont créés à la main ; il y en a peu.
depts = {
dept: Department.objects.get_or_create(name=dept)[0].id
for dept in ldap.verbose_depts.values()
}
users_to_create = []
profiles_to_create = []
dept_m2m_to_create = []
for clipper in clippers:
user = User(username=clipper.uid, email=clipper.email)
profile = Profile(user=user, full_name=clipper.name, promotion=clipper.year)
users_to_create.append(user)
profiles_to_create.append(profile)
dept_m2m_to_create.append(
Profile.department.through(
profile=profile, department_id=depts[clipper.dept]
)
)
User.objects.bulk_create(users_to_create)
for profile in profiles_to_create:
profile.user_id = profile.user.id
Profile.objects.bulk_create(profiles_to_create)
for dept_m2m in dept_m2m_to_create:
dept_m2m.profile_id = dept_m2m.profile.id
Profile.department.through.objects.bulk_create(dept_m2m_to_create)
print(
"Création de {} utilisateur·ices et de {} profils effectuée avec succès".format(
len(users_to_create), len(profiles_to_create)
)
)

View file

@ -0,0 +1,73 @@
from datetime import date
from io import BytesIO
from urllib.error import HTTPError
from urllib.request import urlopen
from django.conf import settings
from django.core.files import File
from django.core.management.base import BaseCommand
from fiches.models import Profile
from ._ldap import AnnuaireLDAP
class Command(BaseCommand):
help = "Si possible, import les photos des conscrit·e·s dans l'annuaire."
def add_arguments(self, parser):
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--all", action="store_true", help="Importe l'intégralité des promotions"
)
group.add_argument("--promo", help="Spécifie la promotion à importer")
def get_current_promo(self):
today = date.today()
year = today.year
if today.month < 9:
year -= 1
return year
def handle(self, *args, **options):
if options["all"]:
promo = None
elif options["promo"] is not None:
promo = options["promo"]
if len(promo) == 2:
promo = "20" + promo
else:
promo = self.get_current_promo()
no_images = Profile.objects.select_related("user").filter(picture="")
if promo is not None:
no_images = no_images.filter(promotion=promo)
cri_ldap = AnnuaireLDAP()
base_annuaire = "{PROTOCOL}://{URL}:{PORT}".format(**settings.ANNUAIRE)
success = 0
for profile in no_images:
cri_login = cri_ldap.try_match(profile)
if cri_login is not None:
img_url = "{}/photos/{}.jpg".format(base_annuaire, cri_login)
try:
istream = urlopen(img_url)
profile.picture.save(
name="{}.jpg".format(profile.user.username),
content=File(BytesIO(istream.read())),
)
success += 1
except HTTPError:
# Parfois, même si on trouve un login CRI, il y a une erreur 404.
# Dans ce cas, pas de photo : on échoue gracieusement.
pass
print(
"{} profils traités ; {} images importées.".format(
no_images.count(), success
)
)

View file

@ -0,0 +1,23 @@
# Generated by Django 2.2.17 on 2020-11-13 10:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fiches', '0007_auto_20200917_1421'),
]
operations = [
migrations.AlterField(
model_name='department',
name='name',
field=models.CharField(max_length=255, unique=True, verbose_name='nom du département'),
),
migrations.AlterField(
model_name='profile',
name='birth_date',
field=models.DateField(blank=True, null=True, verbose_name='date de naissance'),
),
]

View file

@ -23,7 +23,9 @@ class Profile(models.Model):
promotion = models.IntegerField(
validators=[MinValueValidator(1980)], verbose_name=_("promotion")
)
birth_date = models.DateField(blank=True, verbose_name=_("date de naissance"))
birth_date = models.DateField(
blank=True, null=True, verbose_name=_("date de naissance")
)
thurne = models.CharField(blank=True, max_length=100, verbose_name=_("thurne"))
text_field = models.TextField(blank=True, verbose_name=_("champ libre"))
printing = models.BooleanField(
@ -41,7 +43,9 @@ class Profile(models.Model):
class Department(models.Model):
name = models.CharField(max_length=255, verbose_name=_("nom du département"))
name = models.CharField(
max_length=255, verbose_name=_("nom du département"), unique=True
)
def __str__(self):
return self.name

View file

@ -5,7 +5,7 @@ import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'annuaire.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "annuaire.settings.local")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@ -17,5 +17,5 @@ def main():
execute_from_command_line(sys.argv)
if __name__ == '__main__':
if __name__ == "__main__":
main()

86
provisioning/bootstrap.sh Normal file
View file

@ -0,0 +1,86 @@
#!/bin/sh
# Stop if an error is encountered
set -e
PROJECTNAME=$(basename $1)
SETTINGS_MODULE="$PROJECTNAME.settings.vagrant"
# Configuration de la base de données. Le mot de passe est constant car c'est
# pour une installation de dév locale qui ne sera accessible que depuis la
# machine virtuelle.
DBUSER=$PROJECTNAME
DBNAME=$PROJECTNAME
DBPASSWD="O1LxCADDA6Px5SiKvifjvdp3DSjfbp"
# Installation de paquets utiles.
# Installe les paquets mentionnés dans `package.list`, en excluant les lignes
# commençant par #.
apt-get update && apt-get upgrade -y
apt-get install -y $(awk '! /^ *#/' /vagrant/provisioning/package.list)
# Postgresql
# On teste si la db existe déjà pour ne pas essayer de la recréer
DB_EXISTS=$(sudo -u postgres psql -lqt | cut -d \| -f 1 | grep -cw $DBNAME || true)
if [ $DB_EXISTS -eq 0 ]
then
sudo -u postgres createdb $DBNAME
sudo -u postgres createuser -SdR $DBUSER
sudo -u postgres psql -c "ALTER USER $DBUSER WITH PASSWORD '$DBPASSWD';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DBNAME TO $DBUSER;"
fi
# Redis
REDIS_PASSWD="dummy"
redis-cli CONFIG SET requirepass $REDIS_PASSWD
redis-cli -a $REDIS_PASSWD CONFIG REWRITE
# Contenu statique
mkdir -p /srv/$PROJECTNAME/static
ln -sf /vagrant/media /srv/$PROJECTNAME/media
chown -R vagrant:www-data /srv/$PROJECTNAME
# Nginx
rm -f /etc/nginx/sites-enabled/default
sed "s/\_\_PROJECTNAME__/$PROJECTNAME/g" /vagrant/provisioning/nginx.conf > /etc/nginx/sites-enabled/$PROJECTNAME.conf
systemctl reload nginx
# Environnement virtuel python
sudo -H -u vagrant python3 -m venv ~vagrant/venv
sudo -H -u vagrant ~vagrant/venv/bin/pip install -U pip
sudo -H -u vagrant ~vagrant/venv/bin/pip install -r /vagrant/requirements-prod.txt -r /vagrant/requirements-dev.txt
# Préparation de Django
cd /vagrant
sudo -H -u vagrant \
DJANGO_SETTINGS_MODULE=$SETTINGS_MODULE \
bash -c ". ~/venv/bin/activate && bash provisioning/prepare_django.sh"
sudo -H -u vagrant /home/vagrant/venv/bin/python manage.py collectstatic --noinput --settings $SETTINGS_MODULE
# Mails
mkdir -p /var/mail/django
chown -R vagrant:www-data /var/mail/django
# Service files
for file in /vagrant/provisioning/*.service
do
# failsafe si aucun fichier .service n'existe
[ -f $file ] || break
SERVICE=$(basename $file)
# On copie en remplaçant si nécessaire le template
sed "s/\_\_PROJECTNAME__/$PROJECTNAME/g" $file > /etc/systemd/system/$SERVICE
systemctl enable $SERVICE
systemctl start $SERVICE
done
# Mise en place du .bash_profile pour tout configurer lors du `vagrant ssh`
cat >> ~vagrant/.bashrc <<EOF
export DJANGO_SETTINGS_MODULE=$SETTINGS_MODULE
# Charge le virtualenv
source ~/venv/bin/activate
cd /vagrant
EOF

View file

@ -0,0 +1,15 @@
Description="Gunicorn"
After=syslog.target
After=network.target
[Service]
Type=simple
User=vagrant
Group=vagrant
TimeoutSec=300
WorkingDirectory=/vagrant
Environment="DJANGO_SETTINGS_MODULE=__PROJECTNAME__.settings.vagrant"
ExecStart=/home/vagrant/venv/bin/gunicorn --bind=unix:/tmp/gunicorn.sock __PROJECTNAME__.wsgi:application
[Install]
WantedBy=multi-user.target

49
provisioning/nginx.conf Normal file
View file

@ -0,0 +1,49 @@
upstream app_server {
# fail_timeout=0 means we always retry an upstream even if it failed
# to return a good HTTP response
# for UNIX domain socket setups
server unix:/tmp/gunicorn.sock fail_timeout=0;
# for a TCP configuration
# server 192.168.0.7:8000 fail_timeout=0;
}
server {
# use 'listen 80 deferred;' for Linux
# use 'listen 80 accept_filter=httpready;' for FreeBSD
listen 80 deferred;
client_max_body_size 4G;
# set the correct host(s) for your site
server_name localhost;
keepalive_timeout 5;
# path for static files
root /srv/__PROJECTNAME__;
# Static files
location /static/ {
access_log off;
}
# Uploaded media
location /media/ {
access_log off;
}
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-S-DN $ssl_client_s_dn;
# we don't want nginx trying to do something clever with
# redirects, we set the Host: header above already.
proxy_redirect off;
proxy_pass http://app_server;
}
}

View file

@ -0,0 +1,6 @@
python3-pip python3-dev python3-venv
libpq-dev postgresql postgresql-contrib libjpeg-dev
build-essential nginx git redis-server
# Needed for python-ldap
libldap2-dev libsasl2-dev ldap-utils lcov

View file

@ -0,0 +1,6 @@
#!/bin/bash
# Stop if an error is encountered.
set -e
python manage.py migrate

5
requirements-prod.txt Normal file
View file

@ -0,0 +1,5 @@
-r requirements.txt
psycopg2
python-ldap
gunicorn