Développement
Back-end
Le cœur du back-end est pris en charge par Django, un framework python abordable et complet. Sa documentation est très fournie et est une aide précieuse pour le développement. Le stockage et le traitement des données est quant à lui gérer par PostgreSQL. Django propose une couche d’abstraction qui génère en interne les commandes SQL, il n’y a donc pas – ou très peu – d’interaction directe avec PostgreSQL. En fait, Django s’inspire très largement du principe MVC, il propose ainsi cette couche d’abstraction pour gérer l’aspect modèle ainsi que toute la logique autour des données – le contrôleur. Django propose également un système de gabarit – pour la partie vue. Toutefois, la gestion du rendu côté serveur (et donc par django et son système de gabarit) rend difficile et surtout peu pratique le dynamisme de l’interface de l’utilisateur final (la page web). C’est Vue.js, un framework javascript, qui va se charger de la partie interface – la partie vue. L’interaction entre les deux va se faire au travers d’une API REST avec une sérialisation en JSON. C’est l’excellent Django REST framework qui va permettre la création de l’API et s’occuper de la sérialisation/désérialisation.
Front-end
C’est donc le framework Vue.js qui va se charger de l’interface web, les deux points forts de celui-ci sont, sa facilité de mise en œuvre et la réactivité aux changements qu’il offre. Son principe est de définir une série de données et les transformations sur la partie html lorsque ces données changent. Cette façon de faire permet une séparation claire des différentes parties du code et donc une meilleure vue d’ensemble (sans mauvais jeu de mots). En ce qui concerne le style, le css, c’est le très connu Bootstrap qui est utilisé et qui s’intègre à Vue.js avec le module Bootstrap-vue. Finalement, c’est Webpack, un empaqueteur, qui orchestre toute la partie javascript (interprétation, assemblage, minification, etc).
Création d’une application
Au lieu d’expliquer techniquement point par point le fonctionnement d’Happyschool, expliquons comment créer une application, tant la partie back-end que front-end. Tout le code peut se retrouver sur notre dépôt.
Supposons que nous voulons créer une application simple de présence/d’absence des élèves. La première étape est de créer les fichiers de bases en python. Pour cela, Django propose une commande de création, dans le dossier racine d’Happyschool :
python3 manage.py startapp my_student_absence
Ceci va créer une arborescence dans un nouveau dossier
my_student_absence
. Le fichier manage.py
permet de faire pas mal
de choses
en relation avec Django et le projet, comme un accès à un shell python
pour interagir avec les modèles créés ou encore d’appliquer des
changements de notre modèle.
Tout d’abord, créons notre modèle dans le fichier
my_student_absence/models.py
:
# Notre modèle d'absence.
class StudentAbsenceModel(models.Model):
# L'étudiant qui sera absent.
student = models.ForeignKey(StudentModel, on_delete=models.CASCADE)
date_absence_start = models.DateField("Date de début de l'absence")
date_absence_end = models.DateField("Date de fin de l'absence")
# Indique si l'étudiant est absent le matin.
morning = models.BooleanField("Absence le matin", default=True)
# Indique si l'étudiant est absent l'après-midi.
afternoon = models.BooleanField("Absence l'après-midi", default=True)
user = models.CharField("Utilisateur qui a créé l'absence", max_length=100)
datetime_creation = models.DateTimeField("Date et heure de création de l'absence",
auto_now_add=True)
datetime_update = models.DateTimeField("Date et heure de mise à jour de l'absence",
auto_now=True)
Détaillons le code: - from django.db import models
permet d’importer
la couche d’abstraction de Django concernant la base de donnée. -
from core.models import StudentModel
permet d’utiliser les étudiants
qui sont dans la base de donnée. -
class StudentAbsenceModel(models.Model)
est la déclaration de notre
modèle, elle hérite de models.Model
qui est la couche d’abstraction
de django. Nous devons définir tous les champs de notre modèle (les
colonnes dans la base de donnée). -
student = models.ForeignKey(StudentModel, on_delete=models.CASCADE)
est notre premier champ, il permet de faire une liaison avec une autre
entrée de notre base de donnée, ici, un étudiant (StudentModel
).
L’argument on_delete=models.CASCADE
indique que si un étudiant est
supprimé, l’absence sera également supprimée. - date_absence_start
et date_absence_end
sont de simples champs indiquant la date de
début et de fin de l’absence. - morning
et afternoon
sont des
booléens qui indiquent si l’absence a lieu le matin et/ou l’après-midi.
- user
, datetime_creation
et datetime_update
enregistrent
des données concernant l’enregistrement d’une entrée. auto_now
et
auto_now_add
permet d’automatiquement enregistrer la date et heure
de modification et création.
Une fois notre fichier sauvegardé, nous devons demander à Django de
créer le schéma correspondant dans la base de donnée. Il faut donc
d’abord spécifier à Django que nous voulons utiliser cette nouvelle
application. C’est dans le fichier happyschool/settings.py
et plus
spécifiquement la variable INSTALLED_APPS
auquel nous devons ajouter
notre nouvelle application :
INSTALLED_APPS = [
...,
'appels',
'absence_prof',
'dossier_eleve',
'mail_notification',
'mail_answer',
'my_student_absence,
]
Cela fait, nous pouvons créer le schéma grâce à manage.py
, nous
pouvons exécuter les deux commandes suivantes :
python3 manage.py makemigrations student_absence
qui va générer les commandes SQL nécessaire à la création/modification de la base de donnée. Et finalement :
python3 manage.py migrate student_absence
qui va executer les commandes SQL.
Passons maintenant à la logique autour du modèle et de ce qui sera exposé par l’API REST.
Définissons d’abord ce qui sera exposé dans le modèle, pour cela créons
un fichier serializers.py
:
from rest_framework import serializers
from core.serializers import StudentSerializer
from core.models import StudentModel
from student_absence.models import StudentAbsenceModel
class StudentAbsenceSerializer(serializers.ModelSerializer):
student = StudentSerializer(read_only=True)
student_id = serializers.PrimaryKeyRelatedField(queryset=StudentModel.objects.all(),
source='student', required=False,
allow_null=True)
class Meta:
model = StudentAbsenceModel
exclude = ('user',)
read_only_fields = ('datetime_creation', 'datetime_update',)
Regardons le code, nous commonçons par importer toutes les classes qui
vont nous être nécessaire à notre propre sérialiseur. Ensuite, nous
créons notre sérialiseur, StudentAbsenceSerializer
qui hérite de
serializers.ModelSerializer
un sérialiseur qui se base sur un
modèle.
Remarquez que le nom de notre classe suit une certaine convention de
nommage, l’écriture est de type camel
case ensuite sa fonction est inclue
dans son nom, Serializer
, ainsi que ce à quoi elle se rapporte
StudentAbsence
. Dans un projet collaboratif, il devient vite
nécessaire d’établir certaines conventions, le style d’écriture en fait
parti. HappySchool essaye tant bien que mal de suivre un style conforme
au PEP8 même si par
souci de clarté quelques entorses sont parfaitement autorisées.
Continuons notre analyse du code et passons directement à class Meta
qui permet une génération de notre classe de manière dynamique. Nous
avons donc mis dans cette partie, le modèle auquel nous nous référons,
StudentAbsenceModel
, les champs à exclure de la sérialisation,
('user',)
ainsi que les champs en lecture seul
('datetime_creation', 'datetime_update',)
. Nous aurions pu au
contraire, spécifier les champs à exposer par la variable
fields = ('un_champ', ...)
. Toute la documentation se sur la
sérialisation se trouve
ici.
Finalement, jetons un œil à student
et student_id
. À priori, le
champ student
doit normalement déjà être inclut dans la
sérialisation puisqu’il n’est pas mentionné dans exclude
. Cependant,
nous aimerions avoir un comportement différent pour la création/mise à
jour d’une entrée où nous voulons juste indiquer le matricule de
l’étudiant et pour la lecture d’une entrée où nous voulons avoir des
informations supplémentaires sur l’étudiant comme son nom, sa classe,
son établissement/enseignement. student
sera donc le champ en
lecture seul avec toutes les informations et student_id
sera le
champ du matricule de l’élève nécessaire uniquement pour la
création/modification d’une entrée dans la base de donnée.
Avant d’arriver à la partie vue de notre application, mettons en place un système de configuration pour notre application pour, par exemple, spécifier l’enseignement/établissement qui aura accès aux absences. Afin de profiter des possibilités de django, créons un modèle qui n’aura qu’une seule entrée, les paramètres de StudentAbsence.
from core.models import StudentModel, TeachingModel
# Les paramètres de notre application.
class StudentAbsenceSettingsModel(models.Model):
# Les enseignements/établissements utilisés par l'application.
# Ne pas oublier de mettre une valeur par défaut pour la création automatique.
teachings = models.ManyToManyField(TeachingModel, default=None)
Ceci rajoute simplement un modèle, StudentAbsenceSettingsModel
avec
un seul champ, teachings
, qui peut être relier à plusieurs instances
de TeachingModel
, d’où le ManyToManyField
. Par défaut, aucun
TeachingModel
ne sera sélectioné et aucune entrée ne sera affichée.
Il faudra donc que l’administrateur mette explicitement et manuellement
au moins une entrée.
Comme pour StudentAbsenceModel
, il faut appliquer les changements
sur notre base de donnée avec :
python3 manage.py makemigrations
python3 manage.py migrate
Passons maintenant au cœur de notre application avec la partie vue,
c’est-à-dire exposer notre modèle au travers d’une API REST. La classe
ModelViewSet
du DRF, permet de nous faciliter grandement le travail.
En effet, en lui donnant le sérialiseur ainsi que quelques paramètres,
il nous crée automatiquement une interface http en gérant les requêtes
GET
, POST
, PUT
, DELETE
. Une des particularité
d’Happyschool étant de gérer les permissions d’accès, la classe
BaseMovelViewSet
va hériter de ModelViewSet
et gérer les accès
automatiquement, un éducateur du 2ème niveau ne verra que les élèves de
ce niveau. Il est évidemment possible de passer outre en surchargeant la
méthode get_group_all_access
qui attend comme retour un QuerySet
de Group
ayant accès à tous les niveaux. Les paramètres attendus par
notre class StudentAbsenceViewSet(BaseModelViewSet)
sont, le
sérialiseur serializer_class
, la requête de base à la base de
donnée queryset
(qui servira également de cache), les permissions
avec permission_classes
, les champs qui peuvent être ordonés
ordering_fields
et les filtres que nous pouvons appliquer sur nos
données, filterset_class
, objet que détaillerons par la suite.
En ce qui concerne, permission_classes
, nous pouvons demander que
l’utilisateur soit connecté avec IsAuthenticated
et utilisé le
système de permission de django pour gérer
l’écriture/modification/suppression qui accessible par l’interface
d’admin de django.
Finalement, intéressons-nous aux capacités de filtres. Le système offert
par l’application
`django_filters
<https://django-filter.readthedocs.io/en/master/>`__
permet une grande souplesse dans les types de filtres. Pour cela la
classe fournie par Happyschool, BaseFilters
qui hérite de
django_filters
, permet d’indiquer les champs à filtrer de manière
exacte mais également des filtres personnalisés. Dans notre application
nous avons ajouté un filtre par classe.
Nous obtenons alors le code suivant :
import json
from rest_framework.permissions import IsAuthenticated, DjangoModelPermissions
from django_filters import rest_framework as filters
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView
from core.views import BaseModelViewSet, BaseFilters
from core.models import ResponsibleModel
from core.people import get_classes
from core.utilities import get_menu
from student_absence.models import StudentAbsenceModel, StudentAbsenceSettingsModel
from student_absence.serializers import StudentAbsenceSerializer, StudentAbsenceSettingsSerializer
class StudentAbsenceFilter(BaseFilters):
classe = filters.CharFilter(method='classe_by')
class Meta:
fields_to_filter = ('student_id', 'date_absence_start', 'date_absence_end')
model = StudentAbsenceModel
# Permet de génèrer correctement les filtres avec prises en comptes des accents.
fields = BaseFilters.Meta.generate_filters(fields_to_filter)
filter_overrides = BaseFilters.Meta.filter_overrides
def classe_by(self, queryset, name, value):
if not value[0].isdigit():
return queryset
teachings = ResponsibleModel.objects.get(user=self.request.user).teaching.all()
classes = get_classes(list(map(lambda t: t.name, teachings)), True, self.request.user)
queryset = queryset.filter(student__classe__in=classes)
if len(value) > 0:
queryset = queryset.filter(student__classe__year=value[0])
if len(value) > 1:
queryset = queryset.filter(student__classe__letter=value[1].lower())
return queryset
class StudentAbsenceViewSet(BaseModelViewSet):
queryset = StudentAbsenceModel.objects.filter(student__isnull=False)
serializer_class = StudentAbsenceSerializer
permission_classes = (IsAuthenticated, DjangoModelPermissions,)
filterset_class = StudentAbsenceFilter
ordering_fields = ('datetime_creation',)
Il ne nous reste plus qu’à exposer notre API par un accès http, une URL.
Nous voulons tout d’abord que tout ce qui concerne notre application
soit de la forme http://mon.domaine.org/student_absence/…
, pour cela
il faut ajouter au fichier happyschool/urls.py
, l’application
student_absence
à la liste app
du fichier. Ensuite, créons le
fichier /student_absence/urls.py
et mettons-y :
from rest_framework.routers import DefaultRouter
from . import views
urlpatterns = [
]
router = DefaultRouter()
router.register(r'api/student_absence', views.StudentAbsenceViewSet)
urlpatterns += router.urls
qui va se charger de créer les bonnes urls. Ainsi pour avoir la liste des absences il faudra faire http://localhost:8000/student_absence/api/student_absence/ si vous avez lancé le serveur de développement en local. Pour accéder à une entrée en particulier, qui a comme id 42, nous irons sur http://localhost:8000/student_absence/api/student_absence/42/. DRF crée automatiquement une interface web de notre API accessible depuis un navigateur, il suffit d’aller sur les liens précédents.
Pour tester notre API, django fournit un serveur de développement qui peut être lancer avec :
python3 manage.py runserver
et qui se rechargera à chaque modification de fichiers.
Nous avons maintenant notre partie back-end prête à l’emploi, il nous reste à développer la partie front-end qui sera principalement écrite en javascript avec le framework Vue.js. Pour la suite, il est conseillé d’avoir lu, au moins en partie, sa documentation et sa philosophie.
Pour notre front-end nous avons tout d’abord besoin d’un point
d’entrée, une page html pour servir notre code javascript ainsi que le
contexte de notre application i.e. ses paramètres. Pour cela, ajoutons
à notre fichier views.py
les éléments suivants:
def get_settings():
settings_student_absence = StudentAbsenceSettingsModel.objects.first()
if not settings_student_absence:
# Create default settings.
StudentAbsenceSettingsModel.objects.create().save()
return settings_student_absence
class StudentAbsenceView(LoginRequiredMixin,
TemplateView):
template_name = "student_absence/student_absence.html"
filters = [
{'value': 'student_id', 'text': 'Matricule'},
{'value': 'classe', 'text': 'Classe'},
{'value': 'date_absence_start', 'text': 'Début absence'},
{'value': 'date_absence_end', 'text': 'Fin absence'},
]
def get_context_data(self, **kwargs):
# Add to the current context.
context = super().get_context_data(**kwargs)
context['menu'] = json.dumps(get_menu(self.request, "student_absence"))
context['filters'] = json.dumps(self.filters)
context['settings'] = json.dumps((StudentAbsenceSettingsSerializer(get_settings()).data))
return context
La fonction get_settings()
permet de rapatrier les paramètres de
l’application et de créer le modèle correspondant s’il ne l’est pas
encore. Quant à la classe StudentAbsenceView
va exposer notre page
html. Django utilise un système de
template
(ou gabarit) qui permet de générer dynamiquement une page pour y
introduire quelques variables (paramètres, utilisateur, etc). Notre
template aura la forme suivante
(student_absence/templates/student_absence/student_absence.html
) :
{% extends "core/base_vue.html" %}
{% block header %}
<title>HappySchool : Absence des élèves</title>
{% vite %}
{% vite "appels/js/appels.js" %}
{% endblock %}
{% block content %}
<script>
const current_app = "my_student_absence";
const settings = JSON.parse('{{ settings|safe }}');
const menu = {{ menu|safe }};
const filters = JSON.parse('{{ filters|safe }}');
</script>
<div id="vue-app"></div>
{% endblock %}
Le langage de gabarit utilisé par django permet non seulement d’insérer
des variables avec {{ ma_variable }}
mais également de faire des
opérations logiques {% function/logique %}
. La première ligne hérite
d’un autre gabarit core/base_vue.html
qui s’occupe de charger les
certaines librairies commune à toutes les applications mais également
d’exposer l’utilisateur et les groupes auxquels il appartient.
{% block header %}...{% endblock %}
permet d’insérer du code html
dans la partie header de la page, ici le titre de la page et le javascript
avec l’outil vite.
Quant à {% block content %}...{% endblock %}
il permet d’insérer du code
html dans la balise <body>
de la page. C’est dans la balise
<script>
que le context de la page va être traduit en javascript
({{ settings|safe }}
, …), le filtre
`safe
<https://docs.djangoproject.com/fr/2.1/ref/templates/builtins/#safe>`__
indique à django de ne pas échapper les caractères (accent, guillement,
etc).
<div id="vue-app"></div>
servira à Vue.js comme nous le verons par
la suite.
Revenons maintenant à notre fichier views.py
et notre class
StudentAbsenceView
. Tout d’abord, elle hérite de
LoginRequiredMixin
et de TemplateView
. La première classe
implique qu’il faut être connecté en tant qu’utilisateur pour afficher
la page. La seconde est une classe
générique
fournie par Django pour afficher une page basée sur un gabarit. Elle
demande juste de fournir le chemin vers le template avec la variable
template_name
. La fonction get_context_data()
quant à elle,
permet de passer au gabarit certaines variables, ici les paramètres, les
applications à afficher dans le menu ainsi que les filtres disponibles
pour l’application.
Pour que l’url sur notre classe il rajouter la ligne suivante dans le
fichier urls.py
:
from django.urls import path
urlpatterns = [
path('', views.StudentAbsenceView.as_view(), name='student_absence'),
]
Et c’est tout pour le code python. Passons au javascript !
Afin de structurer le code en différents modules, mutualiser le chargement des librairies externes mais aussi minimiser le code pour le rendre moins lourd à charger, nous utiliserons Vite. Nous allons pour le moment nous contenter de rajouter notre application et en particulier le code javascript que nous allons écrire.
Le code javascript qui va tourner dans le navigateur des utilisateurs,
se range dans le dossier static/app_name/js/
de l’application, pour
notre exemple my_student_absence/static/my_student_absence/js/
. Le
point d’entrée est un fichier my_student_absence.js
.
Créons donc un simple point d’entrée :
import Vue from "vue";
import { BootstrapVue, BootstrapVueIcons } from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
Vue.use(BootstrapVue);
Vue.use(BootstrapVueIcons);
import { createApp } from "vue";
import MyStudentAbsencePage from "./MyStudentAbsencePage.vue";
const studentAbsenceApp = createApp(MyStudentAbsencePage);
studentAbsenceApp.mount("#vue-app");
La quatre premières lignes importe Vue
et une librairie pour utiliser
Bootstrap avec Vue. Finalement, la variable studentAbsenceApp
est une
application Vue.js qui s’attache à l’élément <div id="vue-app">
de notre gabarit et chargera le composant Vue MyStudentAbsencePage
.
Ajoutons donc notre composant
static/my_student_absence/MyStudentAbsencePage.vue
:
<template>
<div>
<div class="loading" v-if="!loaded"></div>
<app-menu :menu-info="menuInfo"></app-menu>
<b-container v-if="loaded">
<b-row>
<h2>Absence des élèves</h2>
</b-row>
<b-row>
<b-col>
<b-form-group>
<div>
<b-btn variant="primary">
<icon name="plus" scale="1" class="align-middle"></icon>
Nouvelle absence
</b-btn>
<b-btn variant="outline-secondary">
<icon name="search" scale="1"></icon>
Ajouter des filtres
</b-btn>
</div>
</b-form-group>
</b-col>
</b-row>
</b-container>
</div>
</template>
<script>
import Vue from 'vue';
import BootstrapVue from 'bootstrap-vue'
Vue.use(BootstrapVue);
import 'vue-awesome/icons'
import Icon from 'vue-awesome/components/Icon.vue'
Vue.component('icon', Icon);
import Menu from '../common/menu.vue'
export default {
data: function () {
return {
menuInfo: {},
loaded: false,
}
},
methods: {
},
mounted: function () {
this.menuInfo = menu;
this.loaded = true;
},
components: {
'app-menu': Menu,
},
}
</script>
<style>
.loading {
content: " ";
display: block;
position: absolute;
width: 80px;
height: 80px;
background-image: url(/static/img/spin.svg);
background-size: cover;
left: 50%;
top: 50%;
}
</style>
Un composant vue possède trois parties : <template>
qui est
également un gabarit mais cette fois-ci pour le code js, <script>
pour toute la partie logique et <style>
pour le style css.
Pour dire à vite de compiler le code, la commande suivante va recharger le ou les composants directement dans le navigateur :
uv run npm run dev
Si l’on pointe maintenant notre navigateur vers
http://localhost:8000/student_absence et si le serveur de développement
a été lancé (python3 manage.py runserver
), notre application
s’affiche enfin ! Pour faciliter le développement, il existe une
extension pour navigateurs
qui permet d’afficher l’état de notre application Vue.js, les composants
ainsi que les différentes variables. Il est fortement conseillé de
l’utiliser !
Dans ce premier jet, c’est une page simple avec un menu, un titre et
deux bouttons. La partie template utilise beaucoup de composants
venant de la librairie
`BootstrapVue
<https://bootstrap-vue.js.org/docs>`__ mais également
le composant Menu
qui est propre à HappySchool. Vous pouvez
remarquer que la page affiche une image de chargement. Celle-ci est liée
à la variable loaded
qui initialement fausse et qui permute lorsque
le composant est chargé (dans la fonction mounted
).
La fonction principale étant d’afficher les absences, rajoutons une
méthode pour rapatrier les données et les assigner à entries
.
Profitons-en pour mettre loaded = true
lorsque les données ont été
rapatriées.
data: function () {
return {
menuInfo: {},
entriesCount: 0,
entries: [],
loaded: false,
}
},
methods: {
loadEntries: function () {
// Get current absences.
axios.get('/student_absence/api/student_absence/')
.then(response => {
this.entries = response.data.results;
// Everything is ready, hide the loading icon and show the content.
this.loaded = true;
});
},
}
Et modifions mounted
:
mounted: function () {
this.menuInfo = menu;
this.loadEntries();
},
Si nous rechargeons la page, visuellement, rien n’a changé mais si nous
regardons dans les requêtes faites à notre serveur de développement,
nous voyons qu’une requête vers notre API a été effectuée. Pour le
moment aucune entrée n’a encore été créée donc rien n’est rapatrié. Pour
changer la donne, allons sur page d’administration de django et créons
une entrée manuellement. Une fois fait, notre page devrait rapatrier
notre première entrée. Vérifiez bien que cela est le cas en utilisant
l’extension vuejs devtools. Et vous verrez dans le composant
StudentAbsence : entries:Array[1]
.
Créons maintenant un composant pour afficher notre absence,
static/my_student_absence/studentAbsenceEntry.vue
:
<template>
<div>
<transition appear name="fade">
<b-card>
<b-row>
<b-col><strong><a href="#" @click="filterStudent">{{ rowData.student.display }}</a> : </strong>
Absent du {{ rowData.date_absence_start }} au {{ rowData.date_absence_end}}.</b-col>
<b-col sm="2"><div class="text-end">
<b-btn variant="light" size="sm" @click="editEntry" class="card-link">
<icon scale="1.3" name="edit" color="green" class="align-text-bottom"></icon>
</b-btn>
<b-btn variant="light" size="sm" @click="deleteEntry"class="card-link">
<icon scale="1.3" name="trash" color="red" class="align-text-bottom"></icon>
</b-btn>
</div></b-col>
</b-row>
</b-card>
</transition>
</div>
</template>
<script>
export default {
props: {
rowData : {type: Object},
},
data: function () {
return {
}
},
methods: {
deleteEntry: function () {
this.$emit('delete');
},
editEntry: function () {
this.$emit('edit');
},
filterStudent: function () {
this.$emit('filterStudent', this.rowData.student_id);
},
},
}
</script>
<style>
.fade-enter-active {
transition: opacity .7s
}
.fade-enter, .fade-leave-to .fade-leave-active {
opacity: 0
}
</style>
Analysons notre composant. Tout d’abord dans la partie template, la
balise <transition>
permet d’ajouter un effet lors de l’apparition
du composant; effet qui est décrit dans la partie style. À
l’intérieur, le reste des balises servent principalement à décrire notre
entrée. À noter toutefois, l’appel des différentes méthodes lorsque les
bouttons sont pressés. Ce qui nous amène à la partie script, qui elle
comporte un props, les données brutes de l’absence fournie par le
composant parent et trois méthodes qui remontent au composant parent
(StudentAbsence), lorsqu’un des boutons est pressé.
Insérons donc notre composant dans notre application. Dans la partie template en dessous de la ligne contenant les boutons :
…
<b-row>
<b-col>
<student-absence-entry
v-for="(entry, index) in entries"
v-bind:key="entry.id"
v-bind:row-data="entry"
@delete="askDelete(entry)"
@edit="editEntry(index)"
@filterStudent="filterStudent($event)"
>
</student-absence-entry>
</b-col>
</b-row>
</b-container>
…
Dans la partie script :
<script>
…
import InfirmerieEntry from './infirmerieEntry.vue'
Vue.component('infirmerie-entry', InfirmerieEntry);
export default {
data: function () {
return {
menuInfo: {},
entriesCount: 0,
entries: [],
loaded: false,
currentEntry: null,
}
},
methods: {
filterStudent: function (matricule) {
},
askDelete: function (entry) {
this.currentEntry = entry;
},
editEntry: function(index) {
this.currentEntry = this.entries[index];
},
deleteEntry: function () {
const token = { xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRFToken'};
axios.delete('/student_absence/api/student_absence/' + this.currentEntry.id + '/', token)
.then(response => {
this.loadEntries();
});
this.currentEntry = null;
},
loadEntries: function () {
// Get current absences.
axios.get('/student_absence/api/student_absence/')
.then(response => {
this.entries = response.data.results;
// Everything is ready, hide the loading icon and show the content.
this.loaded = true;
});
},
mounted: function () {
this.menuInfo = menu;
this.loadEntries();
},
components: {
'app-menu': Menu,
},
}
</script>
Notons que la variable currentEntry
a été ajoutée, elle permet de
retenir l’entrée en cours modification/suppression. Nous savons
maintenant enfin afficher une absence !
Afin de prévenir une suppression inopiné de la part de l’utilisateur, il
serait pertinent d’afficher une fenêtre demandant la confirmation de la
suppression d’où la méthode askDelete
. Une telle fenêtre s’appelle
un modal. Ce
qui se traduit en code dans la partie template par :
…
</b-container>
<b-modal ref="deleteModal" cancel-title="Annuler" hide-header centered
@ok="deleteEntry" @cancel="currentEntry = null">
Êtes-vous sûr de vouloir supprimer définitivement cette entrée ?
</b-modal>
…
Et pour la méthode askDelete
:
askDelete: function (entry) {
this.currentEntry = entry;
this.$refs.deleteModal.show();
},
Après confirmation, le modal va appeler la méthode deleteEntry
qui
supprimera pour de bon l’entrée.
Il est parfois utile de partager les données d’une variable entre tous
les composants, par exemple les paramètres de l’application ou les
filtres appliqués sur les requêtes. C’est l’excellente librairie
Vuex qui va nous offrir ces
possibilités, c’est-à-dire un gestionnaire d’état. Pour qu’il soit
utilisable par tous les composants, c’est dans l’application root
qu’il doit être implémenté (assets/js/students_absence/
) :
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
import StudentAbsence from '../student_absence/student_absence.vue';
const store = new Vuex.Store({
state: {
settings: settings,
filters: [],
},
mutations: {
addFilter: function (state, filter) {
// If filter is a matricule, remove name filter to avoid conflict.
if (filter.filterType === 'matricule_id') {
this.commit('removeFilter', 'name');
}
// Overwrite same filter type.
this.commit('removeFilter', filter.filterType);
state.filters.push(filter);
},
removeFilter: function (state, key) {
for (let f in state.filters) {
if (state.filters[f].filterType === key) {
state.filters.splice(f, 1);
break;
}
}
}
}
});
var studentAbsenceApp = new Vue({
el: '#vue-app',
data: {},
store,
template: '<student-absence/>',
components: { StudentAbsence },
})
C’est la définition de la variable store
(qui est ajouté à notre
application Vue) qui va créer notre gestionnaire d’état. C’est donc tout
naturellement que state
définit les différents états à gérer. Afin
de s’assurer d’une gestion robuste des états, tous les changements
d’état sont décrits explicitement. Dans notre cas, les paramètres,
settings
, ne doivent en aucun être modifiés par l’utilisateur, il
n’y a donc rien à décrire. Par contre, les filtres doivent pouvoir être
dynamiquement ajoutés/retirés. D’où les méthodes addFilter
et
removeFilter
dans les mutations
.
Voyons en pratique ce que cela donne et rajoutons un système de filtre à notre application. HappySchool fournit un composant qui repose justement sur l’utilisation du gestionnaire d’état. Le code dans template donne :
<b-row>
<b-col>
<b-form-group>
<div>
<b-btn variant="primary" @click="">
<icon name="plus" scale="1" class="align-middle"></icon>
Nouvelle absence
</b-btn>
<b-btn variant="outline-secondary" v-b-toggle.filters>
<icon name="search" scale="1"></icon>
Ajouter des filtres
</b-btn>
</div>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col>
<b-collapse id="filters" v-model="showFilters">
<b-card>
<filters app="student_absence" model="student_absence" ref="filters" @update="applyFilter"></filters>
</b-card>
</b-collapse>
</b-col>
</b-row>
<b-pagination class="mt-1" :total-rows="entriesCount" v-model="currentPage" @update:model-value="changePage" :per-page="20">
</b-pagination>
<b-row>
<b-col>
<student-absence-entry
v-for="(entry, index) in entries"
v-bind:key="entry.id"
v-bind:row-data="entry"
@delete="askDelete(entry)"
@edit="editEntry(index)"
@filterStudent="filterStudent($event)"
>
</student-absence-entry>
</b-col>
</b-row>
Et dans la partie scripts :
import Filters from '../common/filters.vue'
import StudentAbsenceEntry from './studentAbsenceEntry.vue'
export default {
data: function () {
return {
menuInfo: {},
currentPage: 1,
entriesCount: 0,
entries: [],
filter: '',
ordering: '&ordering=-datetime_creation',
loaded: false,
showFilters: false,
currentEntry: null,
}
},
methods: {
changePage: function (page) {
this.currentPage = page;
this.loadEntries();
// Move to the top of the page.
scroll(0, 0);
return;
},
applyFilter: function () {
this.filter = "";
let storeFilters = this.$store.state.filters
for (let f in storeFilters) {
if (storeFilters[f].filterType.startsWith("date")
|| storeFilters[f].filterType.startsWith("time")) {
let ranges = storeFilters[f].value.split("_");
this.filter += "&" + storeFilters[f].filterType + "__gt=" + ranges[0];
this.filter += "&" + storeFilters[f].filterType + "__lt=" + ranges[1];
} else {
this.filter += "&" + storeFilters[f].filterType + "=" + storeFilters[f].value;
}
}
this.currentPage = 1;
this.loadEntries();
},
filterStudent: function (matricule) {
this.showFilters = true;
this.$store.commit('addFilter',
{filterType: 'student_id', tag: matricule, value: matricule}
);
this.applyFilter()
},
loadEntries: function () {
// Get current absences.
axios.get('/student_absence/api/student_absence/?page=' + this.currentPage + this.filter + this.ordering)
.then(response => {
this.entriesCount = response.data.count;
this.entries = response.data.results;
// Everything is ready, hide the loading icon and show the content.
this.loaded = true;
});
},
…
En plus du filtre, un système de pagination a été ajouté, d’où la
définition des variables currentPage
et entriesCount
ainsi que
la méthode changePage
. Pour le filtre en lui-même, deux variables
ont été ajoutées, filter
qui représente la chaîne finale à rajouter
à la requête et showFilters
qui indique la visibilité du composant
Filters
. Quant aux méthodes, applyFilter
récupère du
gestionnaire d’état les filtres appliqués, produit la variable
filter
et recharge les entrées à afficher. filterStudent
, lui,
va juste ajouter un filtre manuellement.
Normalement, si l’on clique sur le nom de l’étudiant dans une entrée, le filtre matricule devrait automatiquement être ajouté et le composant filtre affiché.
Passons maintenant à notre dernier composant, l’ajout d’une entrée. Pour
cela, créons un nouveau composant,
assets/student_absence/addStudentModal.vue
, qui proposera à
l’utilisateur d’ajouter/modifier une absence :
<template>
<div>
<b-modal size="lg" title="Nouvelle absence"
ok-title="Soumettre" cancel-title="Annuler"
ref="addStudentModal"
:ok-disabled="!student.matricule || (!form.morning && !form.afternoon)"
@ok="addAbsence" @hidden="resetAbsence"
>
<b-form>
<b-form-row>
<b-col sm="8">
<b-form-group label="Étudiant :" label-for="input-student" :state="inputStates.student">
<multiselect id="input-name"
:internal-search="false"
:options="studentOptions"
@search-change="getStudentOptions"
:loading="studentLoading"
placeholder="Rechercher un étudiant…"
select-label=""
selected-label="Sélectionné"
deselect-label=""
label="display"
track-by="matricule"
v-model="student"
>
<template #noResult>
Aucune personne trouvée.
</template>
<template #noOptions />
</multiselect>
<template #invalid-feedback>{{ errorMsg('student_id') }}</template>
</b-form-group>
</b-col>
<b-col sm="4">
<b-form-group label="Matricule :" label-for="input-matricule">
<b-form-input id="input-matricule" type="text" v-model="student.matricule" readonly></b-form-input>
</b-form-group>
</b-col>
</b-form-row>
<b-form-row class="mt-4">
<b-col>
<b-form-row>
<b-form-group label="À partir du" :state="inputStates.date_absence_start">
<input type="date" v-model="form.date_absence_start" :max="form.date_absence_end"/>
<template #invalid-feedback>{{ errorMsg('date_absence_start') }}</template>
</b-form-group>
</b-form-row>
</b-col>
<b-col>
<b-form-row>
<b-form-group label="Jusqu'au" :state="inputStates.date_absence_end">
<input type="date" v-model="form.date_absence_end" :min="form.date_absence_start"/>
<template #invalid-feedback>{{ errorMsg('date_absence_end') }}</template>
</b-form-group>
</b-form-row>
</b-col>
</b-form-row>
<b-form-row>
<b-form-group label="Matin/Après-midi :">
<b-form-checkbox v-model="form.morning">
Matin
</b-form-checkbox>
<b-form-checkbox v-model="form.afternoon">
Après-midi
</b-form-checkbox>
</b-form-group>
</b-form-row>
</b-form>
</b-modal>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
import 'vue-multiselect/dist/vue-multiselect.min.css'
import axios from 'axios';
window.axios = axios;
window.axios.defaults.baseURL = window.location.origin; // In order to have httpS.
export default {
props: ['entry'],
data: function () {
return {
form: {
student_id: null,
date_absence_start: null,
date_absence_end: null,
morning: true,
afternoon: true,
},
student: {matricule: null},
studentOptions: [],
studentLoading: false,
inputStates: {
student: null,
date_absence_start: null,
date_absence_end: null,
},
errors: {},
searchId: -1,
}
},
watch: {
'form.date_absence_start': function (date) {
if (this.form.date_absence_end === null) this.form.date_absence_end = date;
},
entry: function (entry, oldEntry) {
this.setEntry(entry);
},
errors: function (newErrors, oldErrors) {
let inputs = Object.keys(this.inputStates);
for (let u in inputs) {
if (inputs[u] in newErrors) {
this.inputStates[inputs[u]] = newErrors[inputs[u]].length == 0;
} else {
this.inputStates[inputs[u]] = null;
}
}
},
},
methods: {
show: function () {
this.$refs.addStudentModal.show();
},
hide: function () {
this.$refs.addStudentModal.hide();
},
resetAbsence: function () {
this.$emit('reset');
this.form = {
student_id: null,
date_absence_start: null,
date_absence_end: null,
morning: true,
afternoon: true,
};
this.student = {matricule: null};
},
setEntry: function (entry) {
if (entry) {
this.student = entry.student;
this.form = {
student_id: entry.student.matricule,
date_absence_start: entry.date_absence_start,
date_absence_end: entry.date_absence_end,
morning: entry.morning,
afternoon: entry.afternoon,
id: entry.id,
}
} else {
this.resetAbsence();
}
},
addAbsence: function (evt) {
// Prevent form to be sent.
evt.preventDefault();
this.form.student_id = this.student.matricule;
let modal = this;
const token = { xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRFToken'};
let path = '/student_absence/api/student_absence/';
if (this.entry) path += this.entry.id + '/'
const send = this.entry ? axios.put(path, this.form, token) : axios.post(path, this.form, token);
send.then(response => {
this.hide();
this.errors = {};
this.$emit('update');
}).catch(function (error) {
modal.errors = error.response.data;
});
this.entry = null;
},
getStudentOptions: function (query) {
let app = this;
this.searchId += 1;
let currentSearch = this.searchId;
this.studentLoading = true;
const token = { xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRFToken'};
const data = {
query: query,
teachings: this.$store.state.settings.teachings,
people: 'student',
check_access: false,
};
axios.post('/annuaire/api/people/', data, token)
.then(response => {
// Avoid that a previous search overwrites a faster following search results.
if (this.searchId !== currentSearch)
return;
const options = response.data.map(p => {
// Format entries.
let entry = {display: p.last_name + " " + p.first_name, matricule: p.matricule};
// It's a student.
entry.display += " " + p.classe.year + p.classe.letter.toUpperCase();
entry.display += " – " + p.teaching.display_name;
return entry;
});
this.studentLoading = false;
this.studentOptions = options;
})
.catch(function (error) {
alert(error);
app.studentLoading = false;
});
},
errorMsg(err) {
if (err in this.errors) {
return this.errors[err][0];
} else {
return "";
}
},
},
components: {Multiselect},
mounted: function () {
if (this.entry) this.setEntry(this.entry);
this.show();
},
}
</script>
Il y a pas mal de chose à dire concernant ce composant. De manière
générale, il est composé de composants venant de Bootstrap-vue à
l’exception de `multiselect
<https://vue-multiselect.js.org/>`__ qui
propose un champ de recherche et de sélection modulable. Si nous y
regardons de plus près, la propriété @search-change
indique quelle
méthode est appelée lorsqu’une recherche est effectué, dans notre cas
getStudentOptions
. Celle-ci fait un appel à notre API
/annuaire/api/people/
puis formate la réponse reçue et assigne le
résultat à la variable studentOptions
que le composant multiselect
va utiliser.
Autre particularité, la gestion des erreurs. En effet, s’il manque une
donnée ou que l’une d’entre-elles est mal formaté/incorrecte, le
sérialiseur de notre API va retourner une erreur et indiquer de quelle
type elle est. Nous pouvons voir dans la méthode addAbsence
, qu’en
cas d’erreur le retour est assigné à la variable errors
du composant
:
let modal = this;
send.then(response => {
this.hide();
this.errors = {};
this.$emit('update');
}).catch(function (error) {
modal.errors = error.response.data;
});
Du côté de l’interface utilisateur, chaque input
possède une
propriété state
, indiquant si le champ possède une erreur (trois
valeurs possible, null
, true
, false
). Vue permet de réagir
dès qu’une variable change, dès qu’une erreurs est détectée dans le
watch
du composant, l’état est mis à jour de manière appropriée. La
méthode errorMsg()
quant à elle, permettra d’afficher la bonne
erreur pour l’input correspondant.
De manière similaire, si la props entry
est modifiée, par exemple
lorsque l’utilisateur veut modifier une entrée, le formulaire sera
pré-rempli ou vidé après la création/modification d’une entrée afin de
préparer le prochain ajout/modification.
Il ne nous reste plus qu’à intégrer ce modal à notre instance Vue principale :
<template>
<div>
<div class="loading" v-if="!loaded"></div>
<app-menu :menu-info="menuInfo"></app-menu>
<b-container v-if="loaded">
<b-row>
<h2>Absence des élèves</h2>
</b-row>
<b-row>
<b-col>
<b-form-group>
<div>
<b-btn variant="primary" @click="openDynamicModal('add-student-modal')">
<icon name="plus" scale="1" class="align-middle"></icon>
Nouvelle absence
</b-btn>
<b-btn variant="outline-secondary" v-b-toggle.filters>
<icon name="search" scale="1"></icon>
Ajouter des filtres
</b-btn>
</div>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col>
<b-collapse id="filters" v-model="showFilters">
<b-card>
<filters app="student_absence" model="student_absence" ref="filters" @update="applyFilter"></filters>
</b-card>
</b-collapse>
</b-col>
</b-row>
<b-pagination class="mt-1" :total-rows="entriesCount" v-model="currentPage" @update:model-value="changePage" :per-page="20">
</b-pagination>
<b-row>
<b-col>
<student-absence-entry
v-for="(entry, index) in entries"
v-bind:key="entry.id"
v-bind:row-data="entry"
@delete="askDelete(entry)"
@edit="editEntry(index)"
@filterStudent="filterStudent($event)"
>
</student-absence-entry>
</b-col>
</b-row>
</b-container>
<component
v-bind:is="currentModal" ref="dynamicModal"
:entry="currentEntry"
@update="loadEntries"
@reset="currentEntry = null">
</component>
<b-modal ref="deleteModal" cancel-title="Annuler" hide-header centered
@ok="deleteEntry" @cancel="currentEntry = null">
Êtes-vous sûr de vouloir supprimer définitivement cette entrée ?
</b-modal>
</div>
</template>
<script>
import Vue from 'vue';
import BootstrapVue from 'bootstrap-vue'
Vue.use(BootstrapVue);
import 'vue-awesome/icons'
import Icon from 'vue-awesome/components/Icon.vue'
Vue.component('icon', Icon);
import axios from 'axios';
import Filters from '../common/filters.vue'
import Menu from '../common/menu.vue'
import StudentAbsenceEntry from './studentAbsenceEntry.vue'
import AddStudentModal from './addStudentModal.vue'
export default {
data: function () {
return {
menuInfo: {},
currentPage: 1,
entriesCount: 0,
entries: [],
filter: '',
ordering: '&ordering=-datetime_creation',
loaded: false,
showFilters: false,
currentModal: '',
currentEntry: null,
}
},
methods: {
changePage: function (page) {
this.currentPage = page;
this.loadEntries();
// Move to the top of the page.
scroll(0, 0);
return;
},
openDynamicModal: function (modal) {
this.currentModal = modal;
if ('dynamicModal' in this.$refs) this.$refs.dynamicModal.show();
},
applyFilter: function () {
this.filter = "";
let storeFilters = this.$store.state.filters
for (let f in storeFilters) {
if (storeFilters[f].filterType.startsWith("date")
|| storeFilters[f].filterType.startsWith("time")) {
let ranges = storeFilters[f].value.split("_");
this.filter += "&" + storeFilters[f].filterType + "__gt=" + ranges[0];
this.filter += "&" + storeFilters[f].filterType + "__lt=" + ranges[1];
} else {
this.filter += "&" + storeFilters[f].filterType + "=" + storeFilters[f].value;
}
}
this.currentPage = 1;
this.loadEntries();
},
filterStudent: function (matricule) {
this.showFilters = true;
this.$store.commit('addFilter',
{filterType: 'student_id', tag: matricule, value: matricule}
);
this.applyFilter()
},
loadEntries: function () {
// Get current absences.
axios.get('/student_absence/api/student_absence/?page=' + this.currentPage + this.filter + this.ordering)
.then(response => {
this.entriesCount = response.data.count;
this.entries = response.data.results;
// Everything is ready, hide the loading icon and show the content.
this.loaded = true;
});
},
askDelete: function (entry) {
this.currentEntry = entry;
this.$refs.deleteModal.show();
},
editEntry: function(index) {
this.currentEntry = this.entries[index];
this.openDynamicModal('add-student-modal');
},
deleteEntry: function () {
const token = { xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRFToken'};
axios.delete('/student_absence/api/student_absence/' + this.currentEntry.id + '/', token)
.then(response => {
this.loadEntries();
});
this.currentEntry = null;
},
},
mounted: function () {
this.menuInfo = menu;
this.loadEntries();
},
components: {
'filters': Filters,
'app-menu': Menu,
'student-absence-entry': StudentAbsenceEntry,
'add-student-modal': AddStudentModal,
},
}
</script>
<style>
.loading {
content: " ";
display: block;
position: absolute;
width: 80px;
height: 80px;
background-image: url(/static/img/spin.svg);
background-size: cover;
left: 50%;
top: 50%;
}
</style>
L’ajout est somme toute assez simple. Nous importons le composant et
nous rajoutons une méthode pour ouvrir le modal. Petite particularité,
non nécessaire pour notre application, nous avons laissé la possibilité
d’ouvrir différents modals. En effet, le type de modal, et donc de
composant, est dynamiquement chargé et affiché grâce à la propriété
currentModal
qui retient le composant courant en mémoire. Nous
aurions très bien pu utiliser ce mécanisme pour la demande de
suppression d’une entrée.
Ceci clôture cette première approche de la création d’une application. Pour aller plus loin, un coup d’œil à l’application infirmerie ou, encore plus complexe, dossier_eleve qui donnent une bonne vision de ce que peut être en condition réelle une application dans HappySchool. N’hésitez surtout pas à apporter des corrections à ces explications et à HappySchool de manière générale.