From 8e1cb055f62e05e4a4d4208e7b67387c3d99c9d2 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 1 Jun 2023 17:58:30 +0200 Subject: [PATCH] =?UTF-8?q?-=20corrige=20saisi=20stage=20sur=20entreprise?= =?UTF-8?q?=20(fix=20#642)=20-=20cl=C3=A9=20=C3=A9trang=C3=A8re=20sur=20Id?= =?UTF-8?q?entite=20dans=20EntrepriseStageApprentissage=20-=20nouveau=20m?= =?UTF-8?q?=C3=A9canisme=20pour=20le=20choix=20d'=C3=A9tudiant=20via=20aut?= =?UTF-8?q?o-completion=20=20=20(ajout=20de=20autoComplete.js-10.2.7)=20-?= =?UTF-8?q?=20nouveau=20point=20d'API:=20/etudiants/name/=20?= =?UTF-8?q?(et=20son=20test=20unitaire)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/etudiants.py | 36 ++++- app/entreprises/forms.py | 83 +++--------- app/entreprises/models.py | 5 +- app/entreprises/routes.py | 35 ++--- app/models/etudiants.py | 4 +- app/static/css/scodoc.css | 13 ++ app/static/js/etud_autocomplete.js | 65 +++++++++ app/static/js/scodoc.js | 4 + .../dist/autoComplete.min.js | 1 + .../dist/css/autoComplete.01.css | 92 +++++++++++++ .../dist/css/autoComplete.02.css | 82 +++++++++++ .../dist/css/autoComplete.css | 128 ++++++++++++++++++ .../dist/css/images/search.svg | 8 ++ app/templates/base.j2 | 10 +- .../form_ajout_stage_apprentissage.j2 | 24 ++-- .../d84bc592584e_extension_unaccent.py | 60 ++++++++ sco_version.py | 2 +- tests/api/test_api_etudiants.py | 26 ++++ 18 files changed, 565 insertions(+), 113 deletions(-) create mode 100644 app/static/js/etud_autocomplete.js create mode 100644 app/static/libjs/autoComplete.js-10.2.7/dist/autoComplete.min.js create mode 100644 app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.01.css create mode 100644 app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.02.css create mode 100644 app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.css create mode 100644 app/static/libjs/autoComplete.js-10.2.7/dist/css/images/search.svg create mode 100644 migrations/versions/d84bc592584e_extension_unaccent.py diff --git a/app/api/etudiants.py b/app/api/etudiants.py index 07df3d5931..e8030b0199 100644 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -8,16 +8,17 @@ API : accès aux étudiants """ from datetime import datetime +from operator import attrgetter from flask import g, request from flask_json import as_json from flask_login import current_user from flask_login import login_required -from sqlalchemy import desc, or_ +from sqlalchemy import desc, func, or_ +from sqlalchemy.dialects.postgresql import VARCHAR import app from app.api import api_bp as bp, api_web_bp -from app.scodoc.sco_utils import json_error from app.api import tools from app.decorators import scodoc, permission_required from app.models import ( @@ -31,6 +32,8 @@ from app.scodoc import sco_bulletins from app.scodoc import sco_groups from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_utils import json_error, suppress_accents + # Un exemple: # @bp.route("/api_function/") @@ -164,12 +167,39 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None): ) if not None in allowed_depts: # restreint aux départements autorisés: - etuds = etuds.join(Departement).filter( + query = query.join(Departement).filter( or_(Departement.acronym == acronym for acronym in allowed_depts) ) return [etud.to_dict_api() for etud in query] +@bp.route("/etudiants/name/") +@api_web_bp.route("/etudiants/name/") +@scodoc +@permission_required(Permission.ScoView) +@as_json +def etudiants_by_name(start: str = "", min_len=3, limit=32): + """Liste des étudiants dont le nom débute par start. + Si start fait moins de min_len=3 caractères, liste vide. + La casse et les accents sont ignorés. + """ + if len(start) < min_len: + return [] + start = suppress_accents(start).lower() + query = Identite.query.filter( + func.lower(func.unaccent(Identite.nom, type_=VARCHAR)).ilike(start + "%") + ) + allowed_depts = current_user.get_depts_with_permission(Permission.ScoView) + if not None in allowed_depts: + # restreint aux départements autorisés: + query = query.join(Departement).filter( + or_(Departement.acronym == acronym for acronym in allowed_depts) + ) + etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit) + # Note: on raffine le tri pour les caractères spéciaux et nom usuel ici: + return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))] + + @bp.route("/etudiant/etudid//formsemestres") @bp.route("/etudiant/nip//formsemestres") @bp.route("/etudiant/ine//formsemestres") diff --git a/app/entreprises/forms.py b/app/entreprises/forms.py index c2b8cdf311..dad9cbaf7b 100644 --- a/app/entreprises/forms.py +++ b/app/entreprises/forms.py @@ -122,7 +122,7 @@ class EntrepriseCreationForm(FlaskForm): origine = _build_string_field("Origine du correspondant", required=False) notes = _build_string_field("Notes sur le correspondant", required=False) - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) def validate(self): @@ -248,7 +248,7 @@ class SiteCreationForm(FlaskForm): codepostal = _build_string_field("Code postal (*)") ville = _build_string_field("Ville (*)") pays = _build_string_field("Pays", required=False) - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) def validate(self): @@ -326,7 +326,7 @@ class OffreCreationForm(FlaskForm): FileAllowed(["pdf", "docx"], "Fichier .pdf ou .docx uniquement"), ], ) - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) def __init__(self, *args, **kwargs): @@ -458,7 +458,7 @@ class CorrespondantCreationForm(FlaskForm): class CorrespondantsCreationForm(FlaskForm): hidden_site_id = HiddenField() correspondants = FieldList(FormField(CorrespondantCreationForm), min_entries=1) - submit = SubmitField("Envoyer") + submit = SubmitField("Enregistrer") cancel = SubmitField("Annuler") def validate(self): @@ -566,7 +566,7 @@ class ContactCreationForm(FlaskForm): render_kw={"placeholder": "Tapez le nom de l'utilisateur"}, ) notes = TextAreaField("Notes (*)", validators=[DataRequired(message=CHAMP_REQUIS)]) - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) def validate_utilisateur(self, utilisateur): @@ -613,8 +613,9 @@ class ContactModificationForm(FlaskForm): class StageApprentissageCreationForm(FlaskForm): etudiant = _build_string_field( "Étudiant (*)", - render_kw={"placeholder": "Tapez le nom de l'étudiant"}, + render_kw={"placeholder": "Tapez le nom de l'étudiant", "autocomplete": "off"}, ) + etudid = HiddenField() type_offre = SelectField( "Type de l'offre (*)", choices=[("Stage"), ("Alternance")], @@ -627,12 +628,12 @@ class StageApprentissageCreationForm(FlaskForm): "Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)] ) notes = TextAreaField("Notes") - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) - def validate(self): + def validate(self, extra_validators=None): validate = True - if not FlaskForm.validate(self): + if not super().validate(extra_validators): validate = False if ( @@ -646,64 +647,12 @@ class StageApprentissageCreationForm(FlaskForm): return validate - def validate_etudiant(self, etudiant): - etudiant_data = etudiant.data.upper().strip() - stm = text( - "SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom" - ) - etudiant = ( - Identite.query.from_statement(stm).params(nom_prenom=etudiant_data).first() - ) + def validate_etudid(self, field): + "L'etudid doit avoit été placé par le JS" + etudid = int(field.data) if field.data else None + etudiant = Identite.query.get(etudid) if etudid is not None else None if etudiant is None: - raise ValidationError("Champ incorrect (selectionnez dans la liste)") - - -class StageApprentissageModificationForm(FlaskForm): - etudiant = _build_string_field( - "Étudiant (*)", - render_kw={"placeholder": "Tapez le nom de l'étudiant"}, - ) - type_offre = SelectField( - "Type de l'offre (*)", - choices=[("Stage"), ("Alternance")], - validators=[DataRequired(message=CHAMP_REQUIS)], - ) - date_debut = DateField( - "Date début (*)", validators=[DataRequired(message=CHAMP_REQUIS)] - ) - date_fin = DateField( - "Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)] - ) - notes = TextAreaField("Notes") - submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE) - cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) - - def validate(self): - validate = True - if not FlaskForm.validate(self): - validate = False - - if ( - self.date_debut.data - and self.date_fin.data - and self.date_debut.data > self.date_fin.data - ): - self.date_debut.errors.append("Les dates sont incompatibles") - self.date_fin.errors.append("Les dates sont incompatibles") - validate = False - - return validate - - def validate_etudiant(self, etudiant): - etudiant_data = etudiant.data.upper().strip() - stm = text( - "SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom" - ) - etudiant = ( - Identite.query.from_statement(stm).params(nom_prenom=etudiant_data).first() - ) - if etudiant is None: - raise ValidationError("Champ incorrect (selectionnez dans la liste)") + raise ValidationError("Étudiant introuvable (sélectionnez dans la liste)") class TaxeApprentissageForm(FlaskForm): @@ -732,7 +681,7 @@ class TaxeApprentissageForm(FlaskForm): default=1, ) notes = TextAreaField("Notes") - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) def validate(self): diff --git a/app/entreprises/models.py b/app/entreprises/models.py index 0ad22f25f0..492cafe96a 100644 --- a/app/entreprises/models.py +++ b/app/entreprises/models.py @@ -164,7 +164,10 @@ class EntrepriseStageApprentissage(db.Model): entreprise_id = db.Column( db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") ) - etudid = db.Column(db.Integer) + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + ) type_offre = db.Column(db.Text) date_debut = db.Column(db.Date) date_fin = db.Column(db.Date) diff --git a/app/entreprises/routes.py b/app/entreprises/routes.py index 7e97737004..f4d3330130 100644 --- a/app/entreprises/routes.py +++ b/app/entreprises/routes.py @@ -28,7 +28,6 @@ from app.entreprises.forms import ( ContactCreationForm, ContactModificationForm, StageApprentissageCreationForm, - StageApprentissageModificationForm, EnvoiOffreForm, AjoutFichierForm, TaxeApprentissageForm, @@ -1473,7 +1472,8 @@ def delete_contact(entreprise_id, contact_id): @permission_required(Permission.RelationsEntreprisesChange) def add_stage_apprentissage(entreprise_id): """ - Permet d'ajouter un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise + Permet d'ajouter un étudiant ayant réalisé un stage ou alternance + sur la fiche de l'entreprise """ entreprise = Entreprise.query.filter_by( id=entreprise_id, visible=True @@ -1484,15 +1484,8 @@ def add_stage_apprentissage(entreprise_id): url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id) ) if form.validate_on_submit(): - etudiant_nomcomplet = form.etudiant.data.upper().strip() - stm = text( - "SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom" - ) - etudiant = ( - Identite.query.from_statement(stm) - .params(nom_prenom=etudiant_nomcomplet) - .first() - ) + etudid = form.etudid.data + etudiant = Identite.query.get_or_404(etudid) formation = etudiant.inscription_courante_date( form.date_debut.data, form.date_fin.data ) @@ -1538,7 +1531,7 @@ def add_stage_apprentissage(entreprise_id): @permission_required(Permission.RelationsEntreprisesChange) def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id): """ - Permet de modifier un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise + Permet de modifier un étudiant ayant réalisé un stage ou alternance sur la fiche de l'entreprise """ stage_apprentissage = EntrepriseStageApprentissage.query.filter_by( id=stage_apprentissage_id, entreprise_id=entreprise_id @@ -1548,21 +1541,14 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id): etudiant = Identite.query.filter_by(id=stage_apprentissage.etudid).first_or_404( description=f"etudiant {stage_apprentissage.etudid} inconnue" ) - form = StageApprentissageModificationForm() + form = StageApprentissageCreationForm() if request.method == "POST" and form.cancel.data: return redirect( url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id) ) if form.validate_on_submit(): - etudiant_nomcomplet = form.etudiant.data.upper().strip() - stm = text( - "SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom" - ) - etudiant = ( - Identite.query.from_statement(stm) - .params(nom_prenom=etudiant_nomcomplet) - .first() - ) + etudid = form.etudid.data + etudiant = Identite.query.get_or_404(etudid) formation = etudiant.inscription_courante_date( form.date_debut.data, form.date_fin.data ) @@ -1577,6 +1563,7 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id): formation.formsemestre.formsemestre_id if formation else None, ) stage_apprentissage.notes = form.notes.data.strip() + db.session.add(stage_apprentissage) log = EntrepriseHistorique( authenticated_user=current_user.user_name, entreprise_id=stage_apprentissage.entreprise_id, @@ -1593,7 +1580,9 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id): ) ) elif request.method == "GET": - form.etudiant.data = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}" + form.etudiant.data = f"""{sco_etud.format_nom(etudiant.nom)} { + sco_etud.format_prenom(etudiant.prenom)}""" + form.etudid.data = etudiant.id form.type_offre.data = stage_apprentissage.type_offre form.date_debut.data = stage_apprentissage.date_debut form.date_fin.data = stage_apprentissage.date_fin diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 78b8db2b05..8a6047097e 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -43,8 +43,8 @@ class Identite(db.Model): "optionnel (si present, affiché à la place du nom)" civilite = db.Column(db.String(1), nullable=False) - # données d'état-civil. Si présent remplace les données d'usage dans les documents officiels (bulletins, PV) - # cf nomprenom_etat_civil() + # données d'état-civil. Si présent remplace les données d'usage dans les documents + # officiels (bulletins, PV): voir nomprenom_etat_civil() civilite_etat_civil = db.Column(db.String(1), nullable=False, server_default="X") prenom_etat_civil = db.Column(db.Text(), nullable=False, server_default="") diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 7c414e1819..4f922c9d94 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -3173,6 +3173,19 @@ li.tf-msg { /* EMO_WARNING, "⚠️" */ } +p.error { + font-weight: bold; + color: red; +} + +p.error::before { + content: "\2049 \fe0f"; + margin-right: 8px; +} + +mark { + padding-right: 0px; +} .infop { font-weight: normal; diff --git a/app/static/js/etud_autocomplete.js b/app/static/js/etud_autocomplete.js new file mode 100644 index 0000000000..217707aaa5 --- /dev/null +++ b/app/static/js/etud_autocomplete.js @@ -0,0 +1,65 @@ + +// Mécanisme d'auto-complétion (choix) d'un étudiant +// Il faut un champs #etudiant (text input) et à coté un champ hidden etudid qui sera rempli. +// utilise autoComplete.js, source https://tarekraafat.github.io/autoComplete.js +// EV 2023-06-01 + +function etud_autocomplete_config(with_dept = false) { + return { + selector: "#etudiant", + placeHolder: "Nom...", + threshold: 3, + data: { + src: async (query) => { + try { + // Fetch Data from external Source + const source = await fetch(`/ScoDoc/api/etudiants/name/${query}`); + // Data should be an array of `Objects` or `Strings` + const data = await source.json(); + return data; + } catch (error) { + return error; + } + }, + // Data source 'Object' key to be searched + keys: ["nom"] + }, + events: { + input: { + selection: (event) => { + const prenom = sco_capitalize(event.detail.selection.value.prenom); + const selection = with_dept ? `${event.detail.selection.value.nom} ${prenom} (${event.detail.selection.value.dept_acronym})` : `${event.detail.selection.value.nom} ${prenom}`; + // store etudid + const etudidField = document.getElementById('etudid'); + etudidField.value = event.detail.selection.value.id; + autoCompleteJS.input.value = selection; + } + } + }, + resultsList: { + element: (list, data) => { + if (!data.results.length) { + // Create "No Results" message element + const message = document.createElement("div"); + // Add class to the created element + message.setAttribute("class", "no_result"); + // Add message text content + message.innerHTML = `Pas de résultat pour "${data.query}"`; + // Append message element to the results list + list.prepend(message); + // Efface l'etudid + const etudidField = document.getElementById('etudid'); + etudidField.value = ""; + } + }, + noResults: true, + }, + resultItem: { + highlight: true, + element: (item, data) => { + const prenom = sco_capitalize(data.value.prenom); + item.innerHTML += with_dept ? ` ${prenom} (${data.value.dept_acronym})` : ` ${prenom}`; + }, + }, + } +} diff --git a/app/static/js/scodoc.js b/app/static/js/scodoc.js index f5b9c995a7..44b0352be3 100644 --- a/app/static/js/scodoc.js +++ b/app/static/js/scodoc.js @@ -67,6 +67,10 @@ $(function () { } }); +function sco_capitalize(string) { + return string[0].toUpperCase() + string.slice(1).toLowerCase(); +} + // Affiche un message transitoire (duration milliseconds, 0 means infinity) function sco_message(msg, className = "message_custom", duration = 0) { var div = document.createElement("div"); diff --git a/app/static/libjs/autoComplete.js-10.2.7/dist/autoComplete.min.js b/app/static/libjs/autoComplete.js-10.2.7/dist/autoComplete.min.js new file mode 100644 index 0000000000..55cd004ad6 --- /dev/null +++ b/app/static/libjs/autoComplete.js-10.2.7/dist/autoComplete.min.js @@ -0,0 +1 @@ +var e,t;e=this,t=function(){"use strict";function e(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function t(t){for(var n=1;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,a=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return u=e.done,e},e:function(e){a=!0,s=e},f:function(){try{u||null==n.return||n.return()}finally{if(a)throw s}}}}(n.keys);try{for(l.s();!(c=l.n()).done;)a(c.value)}catch(e){l.e(e)}finally{l.f()}}else a()})),n.filter&&(i=n.filter(i));var s=i.slice(0,t.resultsList.maxResults);t.feedback={query:e,matches:i,results:s},f("results",t)},m="aria-expanded",b="aria-activedescendant",y="aria-selected",v=function(e,n){e.feedback.selection=t({index:n},e.feedback.results[n])},g=function(e){e.isOpen||((e.wrapper||e.input).setAttribute(m,!0),e.list.removeAttribute("hidden"),e.isOpen=!0,f("open",e))},w=function(e){e.isOpen&&((e.wrapper||e.input).setAttribute(m,!1),e.input.setAttribute(b,""),e.list.setAttribute("hidden",""),e.isOpen=!1,f("close",e))},O=function(e,t){var n=t.resultItem,r=t.list.getElementsByTagName(n.tag),o=!!n.selected&&n.selected.split(" ");if(t.isOpen&&r.length){var s,u,a=t.cursor;e>=r.length&&(e=0),e<0&&(e=r.length-1),t.cursor=e,a>-1&&(r[a].removeAttribute(y),o&&(u=r[a].classList).remove.apply(u,i(o))),r[e].setAttribute(y,!0),o&&(s=r[e].classList).add.apply(s,i(o)),t.input.setAttribute(b,r[t.cursor].id),t.list.scrollTop=r[e].offsetTop-t.list.clientHeight+r[e].clientHeight+5,t.feedback.cursor=t.cursor,v(t,e),f("navigate",t)}},A=function(e){O(e.cursor+1,e)},k=function(e){O(e.cursor-1,e)},L=function(e,t,n){(n=n>=0?n:e.cursor)<0||(e.feedback.event=t,v(e,n),f("selection",e),w(e))};function j(e,n){var r=this;return new Promise((function(i,o){var s,u;return s=n||((u=e.input)instanceof HTMLInputElement||u instanceof HTMLTextAreaElement?u.value:u.innerHTML),function(e,t,n){return t?t(e):e.length>=n}(s=e.query?e.query(s):s,e.trigger,e.threshold)?d(e,s).then((function(n){try{return e.feedback instanceof Error?i():(h(s,e),e.resultsList&&function(e){var n=e.resultsList,r=e.list,i=e.resultItem,o=e.feedback,s=o.matches,u=o.results;if(e.cursor=-1,r.innerHTML="",s.length||n.noResults){var c=new DocumentFragment;u.forEach((function(e,n){var r=a(i.tag,t({id:"".concat(i.id,"_").concat(n),role:"option",innerHTML:e.match,inside:c},i.class&&{class:i.class}));i.element&&i.element(r,e)})),r.append(c),n.element&&n.element(r,o),g(e)}else w(e)}(e),c.call(r))}catch(e){return o(e)}}),o):(w(e),c.call(r));function c(){return i()}}))}var S=function(e,t){for(var n in e)for(var r in e[n])t(n,r)},T=function(e){var n,r,i,o=e.events,s=(n=function(){return j(e)},r=e.debounce,function(){clearTimeout(i),i=setTimeout((function(){return n()}),r)}),u=e.events=t({input:t({},o&&o.input)},e.resultsList&&{list:o?t({},o.list):{}}),a={input:{input:function(){s()},keydown:function(t){!function(e,t){switch(e.keyCode){case 40:case 38:e.preventDefault(),40===e.keyCode?A(t):k(t);break;case 13:t.submit||e.preventDefault(),t.cursor>=0&&L(t,e);break;case 9:t.resultsList.tabSelect&&t.cursor>=0&&L(t,e);break;case 27:t.input.value="",w(t)}}(t,e)},blur:function(){w(e)}},list:{mousedown:function(e){e.preventDefault()},click:function(t){!function(e,t){var n=t.resultItem.tag.toUpperCase(),r=Array.from(t.list.querySelectorAll(n)),i=e.target.closest(n);i&&i.nodeName===n&&L(t,e,r.indexOf(i))}(t,e)}}};S(a,(function(t,n){(e.resultsList||"input"===n)&&(u[t][n]||(u[t][n]=a[t][n]))})),S(u,(function(t,n){e[t].addEventListener(n,u[t][n])}))};function E(e){var n=this;return new Promise((function(r,i){var o,s,u;if(o=e.placeHolder,u={role:"combobox","aria-owns":(s=e.resultsList).id,"aria-haspopup":!0,"aria-expanded":!1},a(e.input,t(t({"aria-controls":s.id,"aria-autocomplete":"both"},o&&{placeholder:o}),!e.wrapper&&t({},u))),e.wrapper&&(e.wrapper=a("div",t({around:e.input,class:e.name+"_wrapper"},u))),s&&(e.list=a(s.tag,t({dest:[s.destination,s.position],id:s.id,role:"listbox",hidden:"hidden"},s.class&&{class:s.class}))),T(e),e.data.cache)return d(e).then((function(e){try{return c.call(n)}catch(e){return i(e)}}),i);function c(){return f("init",e),r()}return c.call(n)}))}function x(e){var t=e.prototype;t.init=function(){E(this)},t.start=function(e){j(this,e)},t.unInit=function(){if(this.wrapper){var e=this.wrapper.parentNode;e.insertBefore(this.input,this.wrapper),e.removeChild(this.wrapper)}var t;S((t=this).events,(function(e,n){t[e].removeEventListener(n,t.events[e][n])}))},t.open=function(){g(this)},t.close=function(){w(this)},t.goTo=function(e){O(e,this)},t.next=function(){A(this)},t.previous=function(){k(this)},t.select=function(e){L(this,null,e)},t.search=function(e,t,n){return p(e,t,n)}}return function e(t){this.options=t,this.id=e.instances=(e.instances||0)+1,this.name="autoComplete",this.wrapper=1,this.threshold=1,this.debounce=0,this.resultsList={position:"afterend",tag:"ul",maxResults:5},this.resultItem={tag:"li"},function(e){var t=e.name,r=e.options,i=e.resultsList,o=e.resultItem;for(var s in r)if("object"===n(r[s]))for(var a in e[s]||(e[s]={}),r[s])e[s][a]=r[s][a];else e[s]=r[s];e.selector=e.selector||"#"+t,i.destination=i.destination||e.selector,i.id=i.id||t+"_list_"+e.id,o.id=o.id||t+"_result",e.input=u(e.selector)}(this),x.call(this,e),E(this)}},"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).autoComplete=t(); diff --git a/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.01.css b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.01.css new file mode 100644 index 0000000000..4df567ee20 --- /dev/null +++ b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.01.css @@ -0,0 +1,92 @@ +.autoComplete_wrapper { + display: inline-block; + position: relative; +} + +.autoComplete_wrapper > input { + width: 370px; + height: 40px; + padding-left: 20px; + font-size: 1rem; + color: rgba(123, 123, 123, 1); + border-radius: 8px; + border: 0; + outline: none; + background-color: #f1f3f4; +} + +.autoComplete_wrapper > input::placeholder { + color: rgba(123, 123, 123, 0.5); + transition: all 0.3s ease; +} + +.autoComplete_wrapper > ul { + position: absolute; + max-height: 226px; + overflow-y: scroll; + top: 100%; + left: 0; + right: 0; + padding: 0; + margin: 0.5rem 0 0 0; + border-radius: 0.6rem; + background-color: #fff; + box-shadow: 0 3px 6px rgba(149, 157, 165, 0.15); + border: 1px solid rgba(33, 33, 33, 0.07); + z-index: 1000; + outline: none; +} + +.autoComplete_wrapper > ul[hidden], +.autoComplete_wrapper > ul:empty { + display: block; + opacity: 0; + transform: scale(0); +} + +.autoComplete_wrapper > ul > li { + margin: 0.3rem; + padding: 0.3rem 0.5rem; + list-style: none; + text-align: left; + font-size: 1rem; + color: #212121; + transition: all 0.1s ease-in-out; + border-radius: 0.35rem; + background-color: rgba(255, 255, 255, 1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: all 0.2s ease; +} + +.autoComplete_wrapper > ul > li::selection { + color: rgba(#ffffff, 0); + background-color: rgba(#ffffff, 0); +} + +.autoComplete_wrapper > ul > li:hover { + cursor: pointer; + background-color: rgba(123, 123, 123, 0.1); +} + +.autoComplete_wrapper > ul > li mark { + background-color: transparent; + color: rgba(255, 122, 122, 1); + font-weight: bold; +} + +.autoComplete_wrapper > ul > li mark::selection { + color: rgba(#ffffff, 0); + background-color: rgba(#ffffff, 0); +} + +.autoComplete_wrapper > ul > li[aria-selected="true"] { + background-color: rgba(123, 123, 123, 0.1); +} + +@media only screen and (max-width: 600px) { + .autoComplete_wrapper > input { + width: 18rem; + } +} diff --git a/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.02.css b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.02.css new file mode 100644 index 0000000000..e9e233cc27 --- /dev/null +++ b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.02.css @@ -0,0 +1,82 @@ +.autoComplete_wrapper { + display: inline-block; + position: relative; +} + +.autoComplete_wrapper > input { + width: 370px; + height: 40px; + padding-left: 10px; + font-size: 1rem; + color: rgb(116, 116, 116); + border-radius: 4px; + border: 1px solid rgba(33, 33, 33, 0.2); + outline: none; +} + +.autoComplete_wrapper > input::placeholder { + color: rgba(123, 123, 123, 0.5); + transition: all 0.3s ease; +} + +.autoComplete_wrapper > ul { + position: absolute; + max-height: 226px; + overflow-y: scroll; + top: 100%; + left: 0; + right: 0; + padding: 0; + margin: 0.5rem 0 0 0; + border-radius: 4px; + background-color: #fff; + border: 1px solid rgba(33, 33, 33, 0.1); + z-index: 1000; + outline: none; +} + +.autoComplete_wrapper > ul > li { + padding: 10px 20px; + list-style: none; + text-align: left; + font-size: 16px; + color: #212121; + transition: all 0.1s ease-in-out; + border-radius: 3px; + background-color: rgba(255, 255, 255, 1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: all 0.2s ease; +} + +.autoComplete_wrapper > ul > li::selection { + color: rgba(#ffffff, 0); + background-color: rgba(#ffffff, 0); +} + +.autoComplete_wrapper > ul > li:hover { + cursor: pointer; + background-color: rgba(123, 123, 123, 0.1); +} + +.autoComplete_wrapper > ul > li mark { + background-color: transparent; + color: rgba(255, 122, 122, 1); + font-weight: bold; +} + +.autoComplete_wrapper > ul > li mark::selection { + color: rgba(#ffffff, 0); + background-color: rgba(#ffffff, 0); +} + +.autoComplete_wrapper > ul > li[aria-selected="true"] { + background-color: rgba(123, 123, 123, 0.1); +} + +@media only screen and (max-width: 600px) { + .autoComplete_wrapper > input { + width: 18rem; + } +} diff --git a/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.css b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.css new file mode 100644 index 0000000000..3bd94d2921 --- /dev/null +++ b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.css @@ -0,0 +1,128 @@ +.autoComplete_wrapper { + display: inline-block; + position: relative; +} + +.autoComplete_wrapper > input { + height: 3rem; + width: 370px; + margin: 0; + padding: 0 2rem 0 3.2rem; + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + font-size: 1rem; + text-overflow: ellipsis; + color: rgba(255, 122, 122, 0.3); + outline: none; + border-radius: 10rem; + border: 0.05rem solid rgba(255, 122, 122, 0.5); + background-image: url(./images/search.svg); + background-size: 1.4rem; + background-position: left 1.05rem top 0.8rem; + background-repeat: no-repeat; + background-origin: border-box; + background-color: #fff; + transition: all 0.4s ease; + -webkit-transition: all -webkit-transform 0.4s ease; +} + +.autoComplete_wrapper > input::placeholder { + color: rgba(255, 122, 122, 0.5); + transition: all 0.3s ease; + -webkit-transition: all -webkit-transform 0.3s ease; +} + +.autoComplete_wrapper > input:hover::placeholder { + color: rgba(255, 122, 122, 0.6); + transition: all 0.3s ease; + -webkit-transition: all -webkit-transform 0.3s ease; +} + +.autoComplete_wrapper > input:focus::placeholder { + padding: 0.1rem 0.6rem; + font-size: 0.95rem; + color: rgba(255, 122, 122, 0.4); +} + +.autoComplete_wrapper > input:focus::selection { + background-color: rgba(255, 122, 122, 0.15); +} + +.autoComplete_wrapper > input::selection { + background-color: rgba(255, 122, 122, 0.15); +} + +.autoComplete_wrapper > input:hover { + color: rgba(255, 122, 122, 0.8); + transition: all 0.3s ease; + -webkit-transition: all -webkit-transform 0.3s ease; +} + +.autoComplete_wrapper > input:focus { + color: rgba(255, 122, 122, 1); + border: 0.06rem solid rgba(255, 122, 122, 0.8); +} + +.autoComplete_wrapper > ul { + position: absolute; + max-height: 226px; + overflow-y: scroll; + box-sizing: border-box; + left: 0; + right: 0; + margin: 0.5rem 0 0 0; + padding: 0; + z-index: 1; + list-style: none; + border-radius: 0.6rem; + background-color: #fff; + border: 1px solid rgba(33, 33, 33, 0.07); + box-shadow: 0 3px 6px rgba(149, 157, 165, 0.15); + outline: none; + transition: opacity 0.15s ease-in-out; + -moz-transition: opacity 0.15s ease-in-out; + -webkit-transition: opacity 0.15s ease-in-out; +} + +.autoComplete_wrapper > ul[hidden], +.autoComplete_wrapper > ul:empty { + display: block; + opacity: 0; + transform: scale(0); +} + +.autoComplete_wrapper > ul > li { + margin: 0.3rem; + padding: 0.3rem 0.5rem; + text-align: left; + font-size: 1rem; + color: #212121; + border-radius: 0.35rem; + background-color: rgba(255, 255, 255, 1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: all 0.2s ease; +} + +.autoComplete_wrapper > ul > li mark { + background-color: transparent; + color: rgba(255, 122, 122, 1); + font-weight: bold; +} + +.autoComplete_wrapper > ul > li:hover { + cursor: pointer; + background-color: rgba(255, 122, 122, 0.15); +} + +.autoComplete_wrapper > ul > li[aria-selected="true"] { + background-color: rgba(255, 122, 122, 0.15); +} + +@media only screen and (max-width: 600px) { + .autoComplete_wrapper > input { + width: 18rem; + } +} diff --git a/app/static/libjs/autoComplete.js-10.2.7/dist/css/images/search.svg b/app/static/libjs/autoComplete.js-10.2.7/dist/css/images/search.svg new file mode 100644 index 0000000000..8063ea1041 --- /dev/null +++ b/app/static/libjs/autoComplete.js-10.2.7/dist/css/images/search.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/templates/base.j2 b/app/templates/base.j2 index 542a161cf8..1be73c172b 100644 --- a/app/templates/base.j2 +++ b/app/templates/base.j2 @@ -85,7 +85,15 @@ {{ super() }} {{ moment.include_moment() }} {{ moment.lang(g.locale) }} + + + + + + + + {% endblock %} \ No newline at end of file diff --git a/app/templates/entreprises/form_ajout_stage_apprentissage.j2 b/app/templates/entreprises/form_ajout_stage_apprentissage.j2 index ba25df2afe..071e4d1fcc 100644 --- a/app/templates/entreprises/form_ajout_stage_apprentissage.j2 +++ b/app/templates/entreprises/form_ajout_stage_apprentissage.j2 @@ -4,33 +4,27 @@ {% block styles %} {{super()}} - - + {% endblock %} {% block app_content %}

{{ title }}


-
+

(*) champs requis

{{ wtf.quick_form(form, novalidate=True) }}
+{% endblock %} +{% block scripts %} +{{super()}} + + -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/migrations/versions/d84bc592584e_extension_unaccent.py b/migrations/versions/d84bc592584e_extension_unaccent.py new file mode 100644 index 0000000000..74d6ded63c --- /dev/null +++ b/migrations/versions/d84bc592584e_extension_unaccent.py @@ -0,0 +1,60 @@ +"""Extension unaccent + +Revision ID: d84bc592584e +Revises: b8df1b913c79 +Create Date: 2023-06-01 13:46:52.927951 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker # added by ev + + +# revision identifiers, used by Alembic. +revision = "d84bc592584e" +down_revision = "b8df1b913c79" +branch_labels = None +depends_on = None + +Session = sessionmaker() + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + bind = op.get_bind() + session = Session(bind=bind) + # Ajout extension pour recherches sans accents: + session.execute(sa.text("""CREATE EXTENSION IF NOT EXISTS "unaccent";""")) + + # Clé étrangère sur identite + session.execute( + sa.text( + """UPDATE are_stages_apprentissages + SET etudid = NULL + WHERE are_stages_apprentissages.etudid NOT IN ( + SELECT id + FROM Identite + ); + """ + ) + ) + with op.batch_alter_table("are_stages_apprentissages", schema=None) as batch_op: + batch_op.create_foreign_key( + "are_stages_apprentissages_etudid_fkey", + "identite", + ["etudid"], + ["id"], + ondelete="CASCADE", + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("are_stages_apprentissages", schema=None) as batch_op: + batch_op.drop_constraint( + "are_stages_apprentissages_etudid_fkey", type_="foreignkey" + ) + + # ### end Alembic commands ### diff --git a/sco_version.py b/sco_version.py index 5043b4d8ea..f0a3ae1d44 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.79" +SCOVERSION = "9.4.80" SCONAME = "ScoDoc" diff --git a/tests/api/test_api_etudiants.py b/tests/api/test_api_etudiants.py index 203c587ad4..56f3193d33 100644 --- a/tests/api/test_api_etudiants.py +++ b/tests/api/test_api_etudiants.py @@ -224,6 +224,32 @@ def test_etudiants(api_headers): assert r.status_code == 404 +def test_etudiants_by_name(api_headers): + """ + Route: /etudiants/name/ + """ + r = requests.get( + API_URL + "/etudiants/name/A", + headers=api_headers, + verify=CHECK_CERTIFICATE, + timeout=scu.SCO_TEST_API_TIMEOUT, + ) + assert r.status_code == 200 + etuds = r.json() + assert etuds == [] + # + r = requests.get( + API_URL + "/etudiants/name/REG", + headers=api_headers, + verify=CHECK_CERTIFICATE, + timeout=scu.SCO_TEST_API_TIMEOUT, + ) + assert r.status_code == 200 + etuds = r.json() + assert len(etuds) == 1 + assert etuds[0]["nom"] == "RÉGNIER" + + def test_etudiant_formsemestres(api_headers): """ Route: /etudiant/etudid//formsemestres