diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py index bc6dca945..da8cce5d7 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -1072,6 +1072,29 @@ class BonusTours(BonusDirect): ) +class BonusIUTvannes(BonusSportAdditif): + """Calcul bonus modules optionels (sport, culture), règle IUT Vannes + +

Ne concerne actuellement que les DUT et LP

+

Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'U.B.S. (sports, musique, deuxième langue, culture, etc) non + rattachés à une unité d'enseignement. +

+ Les points au-dessus de 10 sur 20 obtenus dans chacune des matières + optionnelles sont cumulés. +

+ 3% de ces points cumulés s'ajoutent à la moyenne générale du semestre + déjà obtenue par l'étudiant. +

+ """ + + name = "bonus_iutvannes" + displayed_name = "IUT de Vannes" + seuil_moy_gen = 10.0 + proportion_point = 0.03 # 3% + classic_use_bonus_ues = False # seulement sur moy gen. + + class BonusVilleAvray(BonusSport): """Bonus modules optionnels (sport, culture), règle IUT Ville d'Avray. diff --git a/app/comp/inscr_mod.py b/app/comp/inscr_mod.py index 56567f80d..a76f24305 100644 --- a/app/comp/inscr_mod.py +++ b/app/comp/inscr_mod.py @@ -34,7 +34,7 @@ def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame: ) df = df.merge(ins_df, how="left", left_index=True, right_index=True) # Force columns names to integers (moduleimpl ids) - df.columns = pd.Int64Index([int(x) for x in df.columns], dtype="int") + df.columns = pd.Index([int(x) for x in df.columns], dtype=int) # les colonnes de df sont en float (Nan) quand il n'y a # aucun inscrit au module. df.fillna(0, inplace=True) # les non-inscrits diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 13829a392..f5efbeb25 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -169,9 +169,7 @@ class ModuleImplResults: self.en_attente = True # Force columns names to integers (evaluation ids) - evals_notes.columns = pd.Int64Index( - [int(x) for x in evals_notes.columns], dtype="int" - ) + evals_notes.columns = pd.Index([int(x) for x in evals_notes.columns], dtype=int) self.evals_notes = evals_notes def _load_evaluation_notes(self, evaluation: Evaluation) -> pd.DataFrame: diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index 61b5fd15c..6fa3cbaca 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -100,8 +100,9 @@ def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series): if (notes is None) or (len(notes) == 0): return (pd.Series([], dtype=object), pd.Series([], dtype=int)) notes = notes.sort_values(ascending=False) # Serie, tri par ordre décroissant - rangs_str = pd.Series(index=notes.index, dtype=str) # le rang est une chaîne - rangs_int = pd.Series(index=notes.index, dtype=int) # le rang numérique pour tris + rangs_str = pd.Series("", index=notes.index, dtype=str) # le rang est une chaîne + # le rang numérique pour tris: + rangs_int = pd.Series(0, index=notes.index, dtype=int) N = len(notes) nb_ex = 0 # nb d'ex-aequo consécutifs en cours notes_i = notes.iat @@ -128,4 +129,5 @@ def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series): rangs_int[etudid] = i + 1 srang = "%d" % (i + 1) rangs_str[etudid] = srang + assert rangs_int.dtype == int return rangs_str, rangs_int diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 6d80f0b7b..2d337a3d0 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -271,7 +271,7 @@ def compute_ue_moys_apc( ) # Annule les coefs des modules NaN modimpl_coefs_etuds_no_nan = np.where(np.isnan(sem_cube), 0.0, modimpl_coefs_etuds) - if modimpl_coefs_etuds_no_nan.dtype == np.object: # arrive sur des tableaux vides + if modimpl_coefs_etuds_no_nan.dtype == object: # arrive sur des tableaux vides modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float) # # Version vectorisée @@ -356,7 +356,7 @@ def compute_ue_moys_classic( modimpl_coefs_etuds_no_nan = np.where( np.isnan(sem_matrix), 0.0, modimpl_coefs_etuds ) - if modimpl_coefs_etuds_no_nan.dtype == np.object: # arrive sur des tableaux vides + if modimpl_coefs_etuds_no_nan.dtype == object: # arrive sur des tableaux vides modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float) # --------------------- Calcul des moyennes d'UE ue_modules = np.array( @@ -367,7 +367,7 @@ def compute_ue_moys_classic( ) # nb_ue x nb_etuds x nb_mods : coefs prenant en compte NaN et inscriptions: coefs = (modimpl_coefs_etuds_no_nan_stacked * ue_modules).swapaxes(1, 2) - if coefs.dtype == np.object: # arrive sur des tableaux vides + if coefs.dtype == object: # arrive sur des tableaux vides coefs = coefs.astype(np.float) with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) etud_moy_ue = ( @@ -462,7 +462,7 @@ def compute_mat_moys_classic( modimpl_coefs_etuds_no_nan = np.where( np.isnan(sem_matrix), 0.0, modimpl_coefs_etuds ) - if modimpl_coefs_etuds_no_nan.dtype == np.object: # arrive sur des tableaux vides + if modimpl_coefs_etuds_no_nan.dtype == object: # arrive sur des tableaux vides modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float) etud_moy_mat = (modimpl_coefs_etuds_no_nan * sem_matrix_inscrits).sum( diff --git a/app/entreprises/app_relations_entreprises.py b/app/entreprises/app_relations_entreprises.py index 68ff99865..e3c0d1da4 100644 --- a/app/entreprises/app_relations_entreprises.py +++ b/app/entreprises/app_relations_entreprises.py @@ -34,7 +34,7 @@ from flask_login import current_user from app.entreprises.models import ( Entreprise, - EntrepriseContact, + EntrepriseCorrespondant, EntrepriseOffre, EntrepriseOffreDepartement, EntreprisePreferences, @@ -85,6 +85,9 @@ def get_offre_files_and_depts(offre: EntrepriseOffre, depts: list): Retourne l'offre, les fichiers attachés a l'offre et les département liés """ offre_depts = EntrepriseOffreDepartement.query.filter_by(offre_id=offre.id).all() + correspondant = EntrepriseCorrespondant.query.filter_by( + id=offre.correspondant_id + ).first() if not offre_depts or check_offre_depts(depts, offre_depts): files = [] path = os.path.join( @@ -100,13 +103,11 @@ def get_offre_files_and_depts(offre: EntrepriseOffre, depts: list): for _file in glob.glob(f"{dir}/*"): file = [os.path.basename(dir), os.path.basename(_file)] files.append(file) - return [offre, files, offre_depts] + return [offre, files, offre_depts, correspondant] return None -def send_email_notifications_entreprise( - subject, entreprise: Entreprise, contact: EntrepriseContact -): +def send_email_notifications_entreprise(subject, entreprise: Entreprise): txt = [ "Une entreprise est en attente de validation", "Entreprise:", @@ -116,14 +117,6 @@ def send_email_notifications_entreprise( f"\tcode postal: {entreprise.codepostal}", f"\tville: {entreprise.ville}", f"\tpays: {entreprise.pays}", - "", - "Contact:", - f"nom: {contact.nom}", - f"prenom: {contact.prenom}", - f"telephone: {contact.telephone}", - f"mail: {contact.mail}", - f"poste: {contact.poste}", - f"service: {contact.service}", ] txt = "\n".join(txt) email.send_email( @@ -135,34 +128,42 @@ def send_email_notifications_entreprise( return txt -def verif_contact_data(contact_data): +def verif_correspondant_data(correspondant_data): """ - Verifie les données d'une ligne Excel (contact) - contact_data[0]: nom - contact_data[1]: prenom - contact_data[2]: telephone - contact_data[3]: mail - contact_data[4]: poste - contact_data[5]: service - contact_data[6]: entreprise_id + Verifie les données d'une ligne Excel (correspondant) + correspondant_data[0]: nom + correspondant_data[1]: prenom + correspondant_data[2]: telephone + correspondant_data[3]: mail + correspondant_data[4]: poste + correspondant_data[5]: service + correspondant_data[6]: entreprise_id """ # champs obligatoires - if contact_data[0] == "" or contact_data[1] == "" or contact_data[6] == "": + if ( + correspondant_data[0].strip() == "" + or correspondant_data[1].strip() == "" + or correspondant_data[6].strip() == "" + ): return False # entreprise_id existant - entreprise = Entreprise.query.filter_by(siret=contact_data[6]).first() + entreprise = Entreprise.query.filter_by(siret=correspondant_data[6].strip()).first() if entreprise is None: return False - # contact possède le meme nom et prénom dans la meme entreprise - contact = EntrepriseContact.query.filter_by( - nom=contact_data[0], prenom=contact_data[1], entreprise_id=entreprise.id + # correspondant possède le meme nom et prénom dans la meme entreprise + correspondant = EntrepriseCorrespondant.query.filter_by( + nom=correspondant_data[0].strip(), + prenom=correspondant_data[1].strip(), + entreprise_id=entreprise.id, ).first() - if contact is not None: + if correspondant is not None: return False - if contact_data[2] == "" and contact_data[3] == "": # 1 moyen de contact + if ( + correspondant_data[2].strip() == "" and correspondant_data[3].strip() == "" + ): # 1 moyen de contact return False return True @@ -174,23 +175,23 @@ def verif_entreprise_data(entreprise_data): """ if EntreprisePreferences.get_check_siret(): for data in entreprise_data: # champs obligatoires - if data == "": + if data.strip() == "": return False else: for data in entreprise_data[1:]: # champs obligatoires - if data == "": + if data.strip() == "": return False if EntreprisePreferences.get_check_siret(): - siret = entreprise_data[0].strip() # vérification sur le siret + siret = entreprise_data[0].replace(" ", "") # vérification sur le siret if re.match("^\d{14}$", siret) is None: return False try: req = requests.get( f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}" ) + if req.status_code != 200: + return False except requests.ConnectionError: - print("no internet") - if req.status_code != 200: return False entreprise = Entreprise.query.filter_by(siret=siret).first() if entreprise is not None: diff --git a/app/entreprises/forms.py b/app/entreprises/forms.py index 0540c0802..654b26186 100644 --- a/app/entreprises/forms.py +++ b/app/entreprises/forms.py @@ -40,11 +40,17 @@ from wtforms import ( SelectMultipleField, DateField, BooleanField, + FieldList, + FormField, ) from wtforms.validators import ValidationError, DataRequired, Email, Optional from wtforms.widgets import ListWidget, CheckboxInput -from app.entreprises.models import Entreprise, EntrepriseContact, EntreprisePreferences +from app.entreprises.models import ( + Entreprise, + EntrepriseCorrespondant, + EntreprisePreferences, +) from app.models import Identite, Departement from app.auth.models import User @@ -66,7 +72,7 @@ def _build_string_field(label, required=True, render_kw=None): class EntrepriseCreationForm(FlaskForm): siret = _build_string_field( "SIRET (*)", - render_kw={"placeholder": "Numéro composé de 14 chiffres", "maxlength": "14"}, + render_kw={"placeholder": "Numéro composé de 14 chiffres"}, ) nom_entreprise = _build_string_field("Nom de l'entreprise (*)") adresse = _build_string_field("Adresse de l'entreprise (*)") @@ -74,15 +80,18 @@ class EntrepriseCreationForm(FlaskForm): ville = _build_string_field("Ville de l'entreprise (*)") pays = _build_string_field("Pays de l'entreprise", required=False) - nom_contact = _build_string_field("Nom du contact (*)") - prenom_contact = _build_string_field("Prénom du contact (*)") - telephone = _build_string_field("Téléphone du contact (*)", required=False) + nom_correspondant = _build_string_field("Nom du correspondant", required=False) + prenom_correspondant = _build_string_field( + "Prénom du correspondant", required=False + ) + telephone = _build_string_field("Téléphone du correspondant", required=False) mail = StringField( - "Mail du contact (*)", + "Mail du correspondant", validators=[Optional(), Email(message="Adresse e-mail invalide")], ) - poste = _build_string_field("Poste du contact", required=False) - service = _build_string_field("Service du contact", required=False) + poste = _build_string_field("Poste du correspondant", required=False) + service = _build_string_field("Service du correspondant", required=False) + submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) def validate(self): @@ -90,29 +99,46 @@ class EntrepriseCreationForm(FlaskForm): if not FlaskForm.validate(self): validate = False - if not self.telephone.data and not self.mail.data: - self.telephone.errors.append( - "Saisir un moyen de contact (mail ou téléphone)" - ) - self.mail.errors.append("Saisir un moyen de contact (mail ou téléphone)") - validate = False + if ( + self.nom_correspondant.data.strip() + or self.prenom_correspondant.data.strip() + or self.telephone.data.strip() + or self.mail.data.strip() + or self.poste.data.strip() + or self.service.data.strip() + ): + if not self.nom_correspondant.data.strip(): + self.nom_correspondant.errors.append("Ce champ est requis") + validate = False + if not self.prenom_correspondant.data.strip(): + self.prenom_correspondant.errors.append("Ce champ est requis") + validate = False + if not self.telephone.data.strip() and not self.mail.data.strip(): + self.telephone.errors.append( + "Saisir un moyen de contact (mail ou téléphone)" + ) + self.mail.errors.append( + "Saisir un moyen de contact (mail ou téléphone)" + ) + validate = False return validate def validate_siret(self, siret): if EntreprisePreferences.get_check_siret(): - siret = siret.data.strip() - if re.match("^\d{14}$", siret) is None: + siret_data = siret.data.replace(" ", "") + self.siret.data = siret_data + if re.match("^\d{14}$", siret_data) is None: raise ValidationError("Format incorrect") try: req = requests.get( - f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}" + f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret_data}" ) + if req.status_code != 200: + raise ValidationError("SIRET inexistant") except requests.ConnectionError: - print("no internet") - if req.status_code != 200: - raise ValidationError("SIRET inexistant") - entreprise = Entreprise.query.filter_by(siret=siret).first() + raise ValidationError("Impossible de vérifier l'existance du SIRET") + entreprise = Entreprise.query.filter_by(siret=siret_data).first() if entreprise is not None: lien = f'ici' raise ValidationError( @@ -144,6 +170,7 @@ class MultiCheckboxField(SelectMultipleField): class OffreCreationForm(FlaskForm): + hidden_entreprise_id = HiddenField() intitule = _build_string_field("Intitulé (*)") description = TextAreaField( "Description (*)", validators=[DataRequired(message=CHAMP_REQUIS)] @@ -159,17 +186,44 @@ class OffreCreationForm(FlaskForm): duree = _build_string_field("Durée (*)") depts = MultiCheckboxField("Départements", validators=[Optional()], coerce=int) expiration_date = DateField("Date expiration", validators=[Optional()]) + correspondant = SelectField("Correspondant à contacté", validators=[Optional()]) + fichier = FileField( + "Fichier (*)", + validators=[ + Optional(), + FileAllowed(["pdf", "docx"], "Fichier .pdf ou .docx uniquement"), + ], + ) submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.correspondant.choices = [ + (correspondant.id, f"{correspondant.nom} {correspondant.prenom}") + for correspondant in EntrepriseCorrespondant.query.filter_by( + entreprise_id=self.hidden_entreprise_id.data + ) + ] + self.depts.choices = [ (dept.id, dept.acronym) for dept in Departement.query.all() ] + def validate(self): + validate = True + if not FlaskForm.validate(self): + validate = False + + if len(self.depts.data) < 1: + self.depts.errors.append("Choisir au moins un département") + validate = False + + return validate + class OffreModificationForm(FlaskForm): + hidden_entreprise_id = HiddenField() intitule = _build_string_field("Intitulé (*)") description = TextAreaField( "Description (*)", validators=[DataRequired(message=CHAMP_REQUIS)] @@ -185,27 +239,79 @@ class OffreModificationForm(FlaskForm): duree = _build_string_field("Durée (*)") depts = MultiCheckboxField("Départements", validators=[Optional()], coerce=int) expiration_date = DateField("Date expiration", validators=[Optional()]) + correspondant = SelectField("Correspondant à contacté", validators=[Optional()]) submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.correspondant.choices = [ + (correspondant.id, f"{correspondant.nom} {correspondant.prenom}") + for correspondant in EntrepriseCorrespondant.query.filter_by( + entreprise_id=self.hidden_entreprise_id.data + ) + ] + self.depts.choices = [ (dept.id, dept.acronym) for dept in Departement.query.all() ] + def validate(self): + validate = True + if not FlaskForm.validate(self): + validate = False -class ContactCreationForm(FlaskForm): - hidden_entreprise_id = HiddenField() - nom = _build_string_field("Nom (*)") - prenom = _build_string_field("Prénom (*)") - telephone = _build_string_field("Téléphone (*)", required=False) + if len(self.depts.data) < 1: + self.depts.errors.append("Choisir au moins un département") + validate = False + + return validate + + +class CorrespondantCreationForm(FlaskForm): + nom = _build_string_field("Nom (*)", render_kw={"class": "form-control"}) + prenom = _build_string_field("Prénom (*)", render_kw={"class": "form-control"}) + telephone = _build_string_field( + "Téléphone (*)", required=False, render_kw={"class": "form-control"} + ) mail = StringField( "Mail (*)", validators=[Optional(), Email(message="Adresse e-mail invalide")], + render_kw={"class": "form-control"}, ) - poste = _build_string_field("Poste", required=False) - service = _build_string_field("Service", required=False) + poste = _build_string_field( + "Poste", required=False, render_kw={"class": "form-control"} + ) + service = _build_string_field( + "Service", required=False, render_kw={"class": "form-control"} + ) + # depts = MultiCheckboxField("Départements", validators=[Optional()], coerce=int) + + # def __init__(self, *args, **kwargs): + # super().__init__(*args, **kwargs) + + # self.depts.choices = [ + # (dept.id, dept.acronym) for dept in Departement.query.all() + # ] + + def validate(self): + validate = True + if not FlaskForm.validate(self): + validate = False + + if not self.telephone.data and not self.mail.data: + self.telephone.errors.append( + "Saisir un moyen de contact (mail ou téléphone)" + ) + self.mail.errors.append("Saisir un moyen de contact (mail ou téléphone)") + validate = False + + return validate + + +class CorrespondantsCreationForm(FlaskForm): + hidden_entreprise_id = HiddenField() + correspondants = FieldList(FormField(CorrespondantCreationForm), min_entries=1) submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) def validate(self): @@ -213,28 +319,37 @@ class ContactCreationForm(FlaskForm): if not FlaskForm.validate(self): validate = False - contact = EntrepriseContact.query.filter_by( - entreprise_id=self.hidden_entreprise_id.data, - nom=self.nom.data, - prenom=self.prenom.data, - ).first() - if contact is not None: - self.nom.errors.append("Ce contact existe déjà (même nom et prénom)") - self.prenom.errors.append("") - validate = False - - if not self.telephone.data and not self.mail.data: - self.telephone.errors.append( - "Saisir un moyen de contact (mail ou téléphone)" - ) - self.mail.errors.append("Saisir un moyen de contact (mail ou téléphone)") - validate = False - + correspondant_list = [] + for entry in self.correspondants.entries: + if entry.nom.data.strip() and entry.prenom.data.strip(): + if ( + entry.nom.data.strip(), + entry.prenom.data.strip(), + ) in correspondant_list: + entry.nom.errors.append( + "Vous avez saisi 2 fois le même nom et prenom" + ) + entry.prenom.errors.append("") + validate = False + correspondant_list.append( + (entry.nom.data.strip(), entry.prenom.data.strip()) + ) + correspondant = EntrepriseCorrespondant.query.filter_by( + entreprise_id=self.hidden_entreprise_id.data, + nom=entry.nom.data, + prenom=entry.prenom.data, + ).first() + if correspondant is not None: + entry.nom.errors.append( + "Ce correspondant existe déjà (même nom et prénom)" + ) + entry.prenom.errors.append("") + validate = False return validate -class ContactModificationForm(FlaskForm): - hidden_contact_id = HiddenField() +class CorrespondantModificationForm(FlaskForm): + hidden_correspondant_id = HiddenField() hidden_entreprise_id = HiddenField() nom = _build_string_field("Nom (*)") prenom = _build_string_field("Prénom (*)") @@ -245,21 +360,29 @@ class ContactModificationForm(FlaskForm): ) poste = _build_string_field("Poste", required=False) service = _build_string_field("Service", required=False) + # depts = MultiCheckboxField("Départements", validators=[Optional()], coerce=int) submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE) + # def __init__(self, *args, **kwargs): + # super().__init__(*args, **kwargs) + + # self.depts.choices = [ + # (dept.id, dept.acronym) for dept in Departement.query.all() + # ] + def validate(self): validate = True if not FlaskForm.validate(self): validate = False - contact = EntrepriseContact.query.filter( - EntrepriseContact.id != self.hidden_contact_id.data, - EntrepriseContact.entreprise_id == self.hidden_entreprise_id.data, - EntrepriseContact.nom == self.nom.data, - EntrepriseContact.prenom == self.prenom.data, + correspondant = EntrepriseCorrespondant.query.filter( + EntrepriseCorrespondant.id != self.hidden_correspondant_id.data, + EntrepriseCorrespondant.entreprise_id == self.hidden_entreprise_id.data, + EntrepriseCorrespondant.nom == self.nom.data, + EntrepriseCorrespondant.prenom == self.prenom.data, ).first() - if contact is not None: - self.nom.errors.append("Ce contact existe déjà (même nom et prénom)") + if correspondant is not None: + self.nom.errors.append("Ce correspondant existe déjà (même nom et prénom)") self.prenom.errors.append("") validate = False @@ -273,7 +396,59 @@ class ContactModificationForm(FlaskForm): return validate -class HistoriqueCreationForm(FlaskForm): +class ContactCreationForm(FlaskForm): + date = _build_string_field( + "Date (*)", + render_kw={"type": "datetime-local"}, + ) + utilisateur = _build_string_field( + "Utilisateur (*)", + render_kw={"placeholder": "Tapez le nom de l'utilisateur"}, + ) + notes = TextAreaField("Notes (*)", validators=[DataRequired(message=CHAMP_REQUIS)]) + submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + + def validate_utilisateur(self, utilisateur): + utilisateur_data = self.utilisateur.data.upper().strip() + stm = text( + "SELECT id, UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')')) FROM \"user\" WHERE UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')'))=:utilisateur_data" + ) + utilisateur = ( + User.query.from_statement(stm) + .params(utilisateur_data=utilisateur_data) + .first() + ) + if utilisateur is None: + raise ValidationError("Champ incorrect (selectionnez dans la liste)") + + +class ContactModificationForm(FlaskForm): + date = _build_string_field( + "Date (*)", + render_kw={"type": "datetime-local"}, + ) + utilisateur = _build_string_field( + "Utilisateur (*)", + render_kw={"placeholder": "Tapez le nom de l'utilisateur"}, + ) + notes = TextAreaField("Notes (*)", validators=[DataRequired(message=CHAMP_REQUIS)]) + submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE) + + def validate_utilisateur(self, utilisateur): + utilisateur_data = self.utilisateur.data.upper().strip() + stm = text( + "SELECT id, UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')')) FROM \"user\" WHERE UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')'))=:utilisateur_data" + ) + utilisateur = ( + User.query.from_statement(stm) + .params(utilisateur_data=utilisateur_data) + .first() + ) + if utilisateur is None: + raise ValidationError("Champ incorrect (selectionnez dans la liste)") + + +class StageApprentissageCreationForm(FlaskForm): etudiant = _build_string_field( "Étudiant (*)", render_kw={"placeholder": "Tapez le nom de l'étudiant"}, @@ -289,6 +464,7 @@ class HistoriqueCreationForm(FlaskForm): date_fin = DateField( "Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)] ) + notes = TextAreaField("Notes") submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) def validate(self): @@ -319,25 +495,87 @@ class HistoriqueCreationForm(FlaskForm): 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) + + 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)") + + class EnvoiOffreForm(FlaskForm): - responsable = _build_string_field( - "Responsable de formation (*)", - render_kw={"placeholder": "Tapez le nom du responsable de formation"}, + responsables = FieldList( + _build_string_field( + "Responsable (*)", + render_kw={ + "placeholder": "Tapez le nom du responsable de formation", + "class": "form-control", + }, + ), + min_entries=1, ) submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) - def validate_responsable(self, responsable): - responsable_data = responsable.data.upper().strip() - stm = text( - "SELECT id, UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')')) FROM \"user\" WHERE UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')'))=:responsable_data" - ) - responsable = ( - User.query.from_statement(stm) - .params(responsable_data=responsable_data) - .first() - ) - if responsable is None: - raise ValidationError("Champ incorrect (selectionnez dans la liste)") + def validate(self): + validate = True + if not FlaskForm.validate(self): + validate = False + + for entry in self.responsables.entries: + if entry.data: + responsable_data = entry.data.upper().strip() + stm = text( + "SELECT id, UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')')) FROM \"user\" WHERE UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')'))=:responsable_data" + ) + responsable = ( + User.query.from_statement(stm) + .params(responsable_data=responsable_data) + .first() + ) + if responsable is None: + entry.errors.append("Champ incorrect (selectionnez dans la liste)") + validate = False + + return validate class AjoutFichierForm(FlaskForm): diff --git a/app/entreprises/models.py b/app/entreprises/models.py index dd0b4ba44..90fcf3357 100644 --- a/app/entreprises/models.py +++ b/app/entreprises/models.py @@ -11,8 +11,8 @@ class Entreprise(db.Model): ville = db.Column(db.Text) pays = db.Column(db.Text, default="FRANCE") visible = db.Column(db.Boolean, default=False) - contacts = db.relationship( - "EntrepriseContact", + correspondants = db.relationship( + "EntrepriseCorrespondant", backref="entreprise", lazy="dynamic", cascade="all, delete-orphan", @@ -35,12 +35,22 @@ class Entreprise(db.Model): } -class EntrepriseContact(db.Model): - __tablename__ = "are_contacts" +# class EntrepriseSite(db.Model): +# __tablename__ = "are_sites" +# id = db.Column(db.Integer, primary_key=True) +# entreprise_id = db.Column( +# db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") +# ) +# nom = db.Column(db.Text) + + +class EntrepriseCorrespondant(db.Model): + __tablename__ = "are_correspondants" id = db.Column(db.Integer, primary_key=True) entreprise_id = db.Column( db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") ) + # site_id = db.Column(db.Integer, db.ForeignKey("are_sites.id", ondelete="cascade")) nom = db.Column(db.Text) prenom = db.Column(db.Text) telephone = db.Column(db.Text) @@ -61,6 +71,17 @@ class EntrepriseContact(db.Model): } +class EntrepriseContact(db.Model): + __tablename__ = "are_contacts" + id = db.Column(db.Integer, primary_key=True) + date = db.Column(db.DateTime(timezone=True)) + user = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="cascade")) + entreprise = db.Column( + db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") + ) + notes = db.Column(db.Text) + + class EntrepriseOffre(db.Model): __tablename__ = "are_offres" id = db.Column(db.Integer, primary_key=True) @@ -75,6 +96,9 @@ class EntrepriseOffre(db.Model): duree = db.Column(db.Text) expiration_date = db.Column(db.Date) expired = db.Column(db.Boolean, default=False) + correspondant_id = db.Column( + db.Integer, db.ForeignKey("are_correspondants.id", ondelete="cascade") + ) def to_dict(self): return { @@ -95,8 +119,8 @@ class EntrepriseLog(db.Model): text = db.Column(db.Text) -class EntrepriseEtudiant(db.Model): - __tablename__ = "are_etudiants" +class EntrepriseStageApprentissage(db.Model): + __tablename__ = "are_stages_apprentissages" id = db.Column(db.Integer, primary_key=True) entreprise_id = db.Column( db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") @@ -107,6 +131,7 @@ class EntrepriseEtudiant(db.Model): date_fin = db.Column(db.Date) formation_text = db.Column(db.Text) formation_scodoc = db.Column(db.Integer) + notes = db.Column(db.Text) class EntrepriseEnvoiOffre(db.Model): @@ -136,6 +161,15 @@ class EntrepriseOffreDepartement(db.Model): dept_id = db.Column(db.Integer, db.ForeignKey("departement.id", ondelete="cascade")) +# class EntrepriseCorrespondantDepartement(db.Model): +# __tablename__ = "are_correspondant_departement" +# id = db.Column(db.Integer, primary_key=True) +# correspondant_id = db.Column( +# db.Integer, db.ForeignKey("are_correspondants.id", ondelete="cascade") +# ) +# dept_id = db.Column(db.Integer, db.ForeignKey("departement.id", ondelete="cascade")) + + class EntreprisePreferences(db.Model): __tablename__ = "are_preferences" id = db.Column(db.Integer, primary_key=True) diff --git a/app/entreprises/routes.py b/app/entreprises/routes.py index d1633e71d..7fe2e7274 100644 --- a/app/entreprises/routes.py +++ b/app/entreprises/routes.py @@ -12,14 +12,17 @@ from app.decorators import permission_required from app.entreprises import LOGS_LEN from app.entreprises.forms import ( + CorrespondantsCreationForm, EntrepriseCreationForm, EntrepriseModificationForm, SuppressionConfirmationForm, OffreCreationForm, OffreModificationForm, + CorrespondantModificationForm, ContactCreationForm, ContactModificationForm, - HistoriqueCreationForm, + StageApprentissageCreationForm, + StageApprentissageModificationForm, EnvoiOffreForm, AjoutFichierForm, ValidationConfirmationForm, @@ -30,9 +33,10 @@ from app.entreprises import bp from app.entreprises.models import ( Entreprise, EntrepriseOffre, - EntrepriseContact, + EntrepriseCorrespondant, EntrepriseLog, - EntrepriseEtudiant, + EntrepriseContact, + EntrepriseStageApprentissage, EntrepriseEnvoiOffre, EntrepriseOffreDepartement, EntreprisePreferences, @@ -96,22 +100,22 @@ def validation(): ) -@bp.route("/contacts", methods=["GET"]) +@bp.route("/correspondants", methods=["GET"]) @permission_required(Permission.RelationsEntreprisesView) -def contacts(): +def correspondants(): """ - Permet d'afficher une page avec la liste des contacts des entreprises visibles et une liste des dernières opérations + Permet d'afficher une page avec la liste des correspondants des entreprises visibles et une liste des dernières opérations """ - contacts = ( - db.session.query(EntrepriseContact, Entreprise) - .join(Entreprise, EntrepriseContact.entreprise_id == Entreprise.id) + correspondants = ( + db.session.query(EntrepriseCorrespondant, Entreprise) + .join(Entreprise, EntrepriseCorrespondant.entreprise_id == Entreprise.id) .filter_by(visible=True) ) logs = EntrepriseLog.query.order_by(EntrepriseLog.date.desc()).limit(LOGS_LEN).all() return render_template( - "entreprises/contacts.html", - title="Contacts", - contacts=contacts, + "entreprises/correspondants.html", + title="Correspondants", + correspondants=correspondants, logs=logs, ) @@ -122,7 +126,7 @@ def fiche_entreprise(id): """ Permet d'afficher la fiche entreprise d'une entreprise avec une liste des dernières opérations et l'historique des étudiants ayant réaliser un stage ou une alternance dans cette entreprise. - La fiche entreprise comporte les informations de l'entreprise, les contacts de l'entreprise et + La fiche entreprise comporte les informations de l'entreprise, les correspondants de l'entreprise et les offres de l'entreprise. """ entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( @@ -141,32 +145,32 @@ def fiche_entreprise(id): offre_with_files = are.get_offre_files_and_depts(offre, depts) if offre_with_files is not None: offres_with_files.append(offre_with_files) - contacts = entreprise.contacts[:] + correspondants = entreprise.correspondants[:] logs = ( EntrepriseLog.query.order_by(EntrepriseLog.date.desc()) .filter_by(object=id) .limit(LOGS_LEN) .all() ) - historique = ( - db.session.query(EntrepriseEtudiant, Identite) - .order_by(EntrepriseEtudiant.date_debut.desc()) - .filter(EntrepriseEtudiant.entreprise_id == id) - .join(Identite, Identite.id == EntrepriseEtudiant.etudid) + stages_apprentissages = ( + db.session.query(EntrepriseStageApprentissage, Identite) + .order_by(EntrepriseStageApprentissage.date_debut.desc()) + .filter(EntrepriseStageApprentissage.entreprise_id == id) + .join(Identite, Identite.id == EntrepriseStageApprentissage.etudid) .all() ) return render_template( "entreprises/fiche_entreprise.html", title="Fiche entreprise", entreprise=entreprise, - contacts=contacts, + correspondants=correspondants, offres=offres_with_files, logs=logs, - historique=historique, + stages_apprentissages=stages_apprentissages, ) -@bp.route("/logs/", methods=["GET"]) +@bp.route("/fiche_entreprise//logs", methods=["GET"]) @permission_required(Permission.RelationsEntreprisesView) def logs_entreprise(id): """ @@ -198,12 +202,12 @@ def fiche_entreprise_validation(id): entreprise = Entreprise.query.filter_by(id=id, visible=False).first_or_404( description=f"fiche entreprise (validation) {id} inconnue" ) - contacts = entreprise.contacts + correspondants = entreprise.correspondants return render_template( "entreprises/fiche_entreprise_validation.html", title="Validation fiche entreprise", entreprise=entreprise, - contacts=contacts, + correspondants=correspondants, ) @@ -221,6 +225,9 @@ def offres_recues(): ) offres_recues_with_files = [] for offre in offres_recues: + correspondant = EntrepriseCorrespondant.query.filter_by( + id=offre[1].correspondant_id + ).first() files = [] path = os.path.join( Config.SCODOC_VAR_DIR, @@ -235,7 +242,7 @@ def offres_recues(): for file in glob.glob(f"{dir}/*"): file = [os.path.basename(dir), os.path.basename(file)] files.append(file) - offres_recues_with_files.append([offre[0], offre[1], files]) + offres_recues_with_files.append([offre[0], offre[1], files, correspondant]) return render_template( "entreprises/offres_recues.html", title="Offres reçues", @@ -287,23 +294,24 @@ def add_entreprise(): ) db.session.add(entreprise) db.session.commit() - db.session.refresh(entreprise) - contact = EntrepriseContact( - entreprise_id=entreprise.id, - nom=form.nom_contact.data.strip(), - prenom=form.prenom_contact.data.strip(), - telephone=form.telephone.data.strip(), - mail=form.mail.data.strip(), - poste=form.poste.data.strip(), - service=form.service.data.strip(), - ) - db.session.add(contact) + if form.nom_correspondant.data.strip(): + db.session.refresh(entreprise) + correspondant = EntrepriseCorrespondant( + entreprise_id=entreprise.id, + nom=form.nom_correspondant.data.strip(), + prenom=form.prenom_correspondant.data.strip(), + telephone=form.telephone.data.strip(), + mail=form.mail.data.strip(), + poste=form.poste.data.strip(), + service=form.service.data.strip(), + ) + db.session.add(correspondant) if current_user.has_permission(Permission.RelationsEntreprisesValidate, None): entreprise.visible = True nom_entreprise = f"{entreprise.nom}" log = EntrepriseLog( authenticated_user=current_user.user_name, - text=f"{nom_entreprise} - Création de la fiche entreprise ({entreprise.nom}) avec un contact", + text=f"{nom_entreprise} - Création de la fiche entreprise ({entreprise.nom})", ) db.session.add(log) db.session.commit() @@ -314,18 +322,18 @@ def add_entreprise(): db.session.commit() if EntreprisePreferences.get_email_notifications(): are.send_email_notifications_entreprise( - "entreprise en attente de validation", entreprise, contact + "entreprise en attente de validation", entreprise ) flash("L'entreprise a été ajouté à la liste pour la validation.") return redirect(url_for("entreprises.index")) return render_template( "entreprises/ajout_entreprise.html", - title="Ajout entreprise avec contact", + title="Ajout entreprise avec correspondant", form=form, ) -@bp.route("/edit_entreprise/", methods=["GET", "POST"]) +@bp.route("/fiche_entreprise/edit_entreprise/", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesChange) def edit_entreprise(id): """ @@ -396,7 +404,7 @@ def edit_entreprise(id): ) -@bp.route("/delete_entreprise/", methods=["GET", "POST"]) +@bp.route("/fiche_entreprise/delete_entreprise/", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesChange) def delete_entreprise(id): """ @@ -432,7 +440,9 @@ def delete_entreprise(id): ) -@bp.route("/validate_entreprise/", methods=["GET", "POST"]) +@bp.route( + "/fiche_entreprise_validation//validate_entreprise", methods=["GET", "POST"] +) @permission_required(Permission.RelationsEntreprisesValidate) def validate_entreprise(id): """ @@ -447,7 +457,7 @@ def validate_entreprise(id): nom_entreprise = f"{entreprise.nom}" log = EntrepriseLog( authenticated_user=current_user.user_name, - text=f"{nom_entreprise} - Validation de la fiche entreprise ({entreprise.nom}) avec un contact", + text=f"{nom_entreprise} - Validation de la fiche entreprise ({entreprise.nom}) avec un correspondant", ) db.session.add(log) db.session.commit() @@ -460,7 +470,10 @@ def validate_entreprise(id): ) -@bp.route("/delete_validation_entreprise/", methods=["GET", "POST"]) +@bp.route( + "/fiche_entreprise_validation//delete_validation_entreprise", + methods=["GET", "POST"], +) @permission_required(Permission.RelationsEntreprisesValidate) def delete_validation_entreprise(id): """ @@ -482,7 +495,7 @@ def delete_validation_entreprise(id): ) -@bp.route("/add_offre/", methods=["GET", "POST"]) +@bp.route("/fiche_entreprise//add_offre", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesChange) def add_offre(id): """ @@ -491,7 +504,7 @@ def add_offre(id): entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( description=f"entreprise {id} inconnue" ) - form = OffreCreationForm() + form = OffreCreationForm(hidden_entreprise_id=id) if form.validate_on_submit(): offre = EntrepriseOffre( entreprise_id=entreprise.id, @@ -501,6 +514,7 @@ def add_offre(id): missions=form.missions.data.strip(), duree=form.duree.data.strip(), expiration_date=form.expiration_date.data, + correspondant_id=form.correspondant.data, ) db.session.add(offre) db.session.commit() @@ -511,6 +525,19 @@ def add_offre(id): dept_id=dept, ) db.session.add(offre_dept) + if form.fichier.data: + date = f"{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}" + path = os.path.join( + Config.SCODOC_VAR_DIR, + "entreprises", + f"{offre.entreprise_id}", + f"{offre.id}", + f"{date}", + ) + os.makedirs(path) + file = form.fichier.data + filename = secure_filename(file.filename) + file.save(os.path.join(path, filename)) log = EntrepriseLog( authenticated_user=current_user.user_name, object=entreprise.id, @@ -527,7 +554,7 @@ def add_offre(id): ) -@bp.route("/edit_offre/", methods=["GET", "POST"]) +@bp.route("/fiche_entreprise/edit_offre/", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesChange) def edit_offre(id): """ @@ -537,7 +564,9 @@ def edit_offre(id): description=f"offre {id} inconnue" ) offre_depts = EntrepriseOffreDepartement.query.filter_by(offre_id=offre.id).all() - form = OffreModificationForm() + form = OffreModificationForm( + hidden_entreprise_id=offre.entreprise_id, correspondant=offre.correspondant_id + ) offre_depts_list = [(offre_dept.dept_id) for offre_dept in offre_depts] if form.validate_on_submit(): offre.intitule = form.intitule.data.strip() @@ -546,6 +575,7 @@ def edit_offre(id): offre.missions = form.missions.data.strip() offre.duree = form.duree.data.strip() offre.expiration_date = form.expiration_date.data + offre.correspondant_id = form.correspondant.data if offre_depts_list != form.depts.data: for dept in form.depts.data: if dept not in offre_depts_list: @@ -584,7 +614,7 @@ def edit_offre(id): ) -@bp.route("/delete_offre/", methods=["GET", "POST"]) +@bp.route("/fiche_entreprise/delete_offre/", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesChange) def delete_offre(id): """ @@ -621,7 +651,7 @@ def delete_offre(id): ) -@bp.route("/delete_offre_recue/", methods=["GET", "POST"]) +@bp.route("/offres_recues/delete_offre_recue/", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesView) def delete_offre_recue(id): """ @@ -635,7 +665,7 @@ def delete_offre_recue(id): return redirect(url_for("entreprises.offres_recues")) -@bp.route("/expired/", methods=["GET", "POST"]) +@bp.route("/fiche_entreprise/expired/", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesChange) def expired(id): """ @@ -653,36 +683,153 @@ def expired(id): return redirect(url_for("entreprises.fiche_entreprise", id=offre.entreprise_id)) -@bp.route("/add_contact/", methods=["GET", "POST"]) +@bp.route("/fiche_entreprise//add_correspondant", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesChange) -def add_contact(id): +def add_correspondant(id): """ - Permet d'ajouter un contact a une entreprise + Permet d'ajouter un correspondant a une entreprise """ entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( description=f"entreprise {id} inconnue" ) - form = ContactCreationForm(hidden_entreprise_id=entreprise.id) + form = CorrespondantsCreationForm(hidden_entreprise_id=entreprise.id) if form.validate_on_submit(): - contact = EntrepriseContact( - entreprise_id=entreprise.id, - nom=form.nom.data.strip(), - prenom=form.prenom.data.strip(), - telephone=form.telephone.data.strip(), - mail=form.mail.data.strip(), - poste=form.poste.data.strip(), - service=form.service.data.strip(), - ) + for correspondant_entry in form.correspondants.entries: + correspondant = EntrepriseCorrespondant( + entreprise_id=entreprise.id, + nom=correspondant_entry.nom.data.strip(), + prenom=correspondant_entry.prenom.data.strip(), + telephone=correspondant_entry.telephone.data.strip(), + mail=correspondant_entry.mail.data.strip(), + poste=correspondant_entry.poste.data.strip(), + service=correspondant_entry.service.data.strip(), + ) + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=entreprise.id, + text="Création d'un correspondant", + ) + db.session.add(log) + db.session.add(correspondant) + db.session.commit() + flash("Le correspondant a été ajouté à la fiche entreprise.") + return redirect(url_for("entreprises.fiche_entreprise", id=entreprise.id)) + return render_template( + "entreprises/ajout_correspondants.html", + title="Ajout correspondant", + form=form, + ) + + +@bp.route("/fiche_entreprise/edit_correspondant/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) +def edit_correspondant(id): + """ + Permet de modifier un correspondant + """ + correspondant = EntrepriseCorrespondant.query.filter_by(id=id).first_or_404( + description=f"correspondant {id} inconnu" + ) + form = CorrespondantModificationForm( + hidden_entreprise_id=correspondant.entreprise_id, + hidden_correspondant_id=correspondant.id, + ) + if form.validate_on_submit(): + correspondant.nom = form.nom.data.strip() + correspondant.prenom = form.prenom.data.strip() + correspondant.telephone = form.telephone.data.strip() + correspondant.mail = form.mail.data.strip() + correspondant.poste = form.poste.data.strip() + correspondant.service = form.service.data.strip() log = EntrepriseLog( authenticated_user=current_user.user_name, - object=entreprise.id, - text="Création d'un contact", + object=correspondant.entreprise_id, + text="Modification d'un correspondant", ) db.session.add(log) + db.session.commit() + flash("Le correspondant a été modifié.") + return redirect( + url_for("entreprises.fiche_entreprise", id=correspondant.entreprise.id) + ) + elif request.method == "GET": + form.nom.data = correspondant.nom + form.prenom.data = correspondant.prenom + form.telephone.data = correspondant.telephone + form.mail.data = correspondant.mail + form.poste.data = correspondant.poste + form.service.data = correspondant.service + return render_template( + "entreprises/form.html", + title="Modification correspondant", + form=form, + ) + + +@bp.route("/fiche_entreprise/delete_correspondant/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) +def delete_correspondant(id): + """ + Permet de supprimer un correspondant + """ + correspondant = EntrepriseCorrespondant.query.filter_by(id=id).first_or_404( + description=f"correspondant {id} inconnu" + ) + form = SuppressionConfirmationForm() + if form.validate_on_submit(): + db.session.delete(correspondant) + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=correspondant.entreprise_id, + text="Suppression d'un correspondant", + ) + db.session.add(log) + db.session.commit() + flash("Le correspondant a été supprimé de la fiche entreprise.") + return redirect( + url_for("entreprises.fiche_entreprise", id=correspondant.entreprise_id) + ) + return render_template( + "entreprises/delete_confirmation.html", + title="Supression correspondant", + form=form, + ) + + +@bp.route("/fiche_entreprise//add_contact", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) +def add_contact(id): + """ + Permet d'ajouter un contact avec une entreprise + """ + entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( + description=f"entreprise {id} inconnue" + ) + form = ContactCreationForm( + date=f"{datetime.now().strftime('%Y-%m-%dT%H:%M')}", + utilisateur=f"{current_user.nom} {current_user.prenom} ({current_user.user_name})" + if current_user.nom and current_user.prenom + else "", + ) + if form.validate_on_submit(): + utilisateur_data = form.utilisateur.data.upper().strip() + stm = text( + "SELECT id, UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')')) FROM \"user\" WHERE UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')'))=:utilisateur_data" + ) + utilisateur = ( + User.query.from_statement(stm) + .params(utilisateur_data=utilisateur_data) + .first() + ) + contact = EntrepriseContact( + date=form.date.data, + user=utilisateur.id, + entreprise=entreprise.id, + notes=form.notes.data.strip(), + ) db.session.add(contact) db.session.commit() - flash("Le contact a été ajouté à la fiche entreprise.") - return redirect(url_for("entreprises.fiche_entreprise", id=entreprise.id)) + return redirect(url_for("entreprises.contacts", id=entreprise.id)) return render_template( "entreprises/form.html", title="Ajout contact", @@ -690,44 +837,38 @@ def add_contact(id): ) -@bp.route("/edit_contact/", methods=["GET", "POST"]) +@bp.route("/fiche_entreprise/edit_contact/", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesChange) def edit_contact(id): """ - Permet de modifier un contact + Permet d'editer un contact avec une entreprise """ contact = EntrepriseContact.query.filter_by(id=id).first_or_404( description=f"contact {id} inconnu" ) - form = ContactModificationForm( - hidden_entreprise_id=contact.entreprise_id, - hidden_contact_id=contact.id, - ) + form = ContactModificationForm() if form.validate_on_submit(): - contact.nom = form.nom.data.strip() - contact.prenom = form.prenom.data.strip() - contact.telephone = form.telephone.data.strip() - contact.mail = form.mail.data.strip() - contact.poste = form.poste.data.strip() - contact.service = form.service.data.strip() - log = EntrepriseLog( - authenticated_user=current_user.user_name, - object=contact.entreprise_id, - text="Modification d'un contact", + utilisateur_data = form.utilisateur.data.upper().strip() + stm = text( + "SELECT id, UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')')) FROM \"user\" WHERE UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')'))=:utilisateur_data" ) - db.session.add(log) + utilisateur = ( + User.query.from_statement(stm) + .params(utilisateur_data=utilisateur_data) + .first() + ) + contact.date = form.date.data + contact.user = utilisateur.id + contact.notes = form.notes.data db.session.commit() - flash("Le contact a été modifié.") - return redirect( - url_for("entreprises.fiche_entreprise", id=contact.entreprise.id) - ) + return redirect(url_for("entreprises.contacts", id=contact.entreprise)) elif request.method == "GET": - form.nom.data = contact.nom - form.prenom.data = contact.prenom - form.telephone.data = contact.telephone - form.mail.data = contact.mail - form.poste.data = contact.poste - form.service.data = contact.service + utilisateur = User.query.filter_by(id=contact.user).first() + form.date.data = contact.date.strftime("%Y-%m-%dT%H:%M") + form.utilisateur.data = ( + f"{utilisateur.nom} {utilisateur.prenom} ({utilisateur.user_name})" + ) + form.notes.data = contact.notes return render_template( "entreprises/form.html", title="Modification contact", @@ -735,57 +876,31 @@ def edit_contact(id): ) -@bp.route("/delete_contact/", methods=["GET", "POST"]) -@permission_required(Permission.RelationsEntreprisesChange) -def delete_contact(id): +@bp.route("/fiche_entreprise//contacts") +@permission_required(Permission.RelationsEntreprisesView) +def contacts(id): """ - Permet de supprimer un contact + Permet d'afficher une page avec la liste des contacts d'une entreprise """ - contact = EntrepriseContact.query.filter_by(id=id).first_or_404( - description=f"contact {id} inconnu" - ) - form = SuppressionConfirmationForm() - if form.validate_on_submit(): - contact_count = EntrepriseContact.query.filter_by( - entreprise_id=contact.entreprise_id - ).count() - if contact_count == 1: - flash( - "Le contact n'a pas été supprimé de la fiche entreprise. (1 contact minimum)" - ) - return redirect( - url_for("entreprises.fiche_entreprise", id=contact.entreprise_id) - ) - else: - db.session.delete(contact) - log = EntrepriseLog( - authenticated_user=current_user.user_name, - object=contact.entreprise_id, - text="Suppression d'un contact", - ) - db.session.add(log) - db.session.commit() - flash("Le contact a été supprimé de la fiche entreprise.") - return redirect( - url_for("entreprises.fiche_entreprise", id=contact.entreprise_id) - ) + contacts = EntrepriseContact.query.filter_by(entreprise=id).all() return render_template( - "entreprises/delete_confirmation.html", - title="Supression contact", - form=form, + "entreprises/contacts.html", + title="Liste des contacts", + contacts=contacts, + entreprise_id=id, ) -@bp.route("/add_historique/", methods=["GET", "POST"]) +@bp.route("/fiche_entreprise//add_stage_apprentissage", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesChange) -def add_historique(id): +def add_stage_apprentissage(id): """ Permet d'ajouter un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise """ entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( description=f"entreprise {id} inconnue" ) - form = HistoriqueCreationForm() + form = StageApprentissageCreationForm() if form.validate_on_submit(): etudiant_nomcomplet = form.etudiant.data.upper().strip() stm = text( @@ -799,7 +914,7 @@ def add_historique(id): formation = etudiant.inscription_courante_date( form.date_debut.data, form.date_fin.data ) - historique = EntrepriseEtudiant( + stage_apprentissage = EntrepriseStageApprentissage( entreprise_id=entreprise.id, etudid=etudiant.id, type_offre=form.type_offre.data.strip(), @@ -809,19 +924,105 @@ def add_historique(id): formation_scodoc=formation.formsemestre.formsemestre_id if formation else None, + notes=form.notes.data.strip(), ) - db.session.add(historique) + db.session.add(stage_apprentissage) db.session.commit() flash("L'étudiant a été ajouté sur la fiche entreprise.") return redirect(url_for("entreprises.fiche_entreprise", id=entreprise.id)) return render_template( - "entreprises/ajout_historique.html", - title="Ajout historique", + "entreprises/ajout_stage_apprentissage.html", + title="Ajout stage / apprentissage", form=form, ) -@bp.route("/envoyer_offre/", methods=["GET", "POST"]) +@bp.route( + "/fiche_entreprise/edit_stage_apprentissage/", methods=["GET", "POST"] +) +@permission_required(Permission.RelationsEntreprisesChange) +def edit_stage_apprentissage(id): + """ + Permet de modifier un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise + """ + stage_apprentissage = EntrepriseStageApprentissage.query.filter_by( + id=id + ).first_or_404(description=f"stage_apprentissage {id} inconnue") + etudiant = Identite.query.filter_by(id=stage_apprentissage.etudid).first_or_404( + description=f"etudiant {id} inconnue" + ) + form = StageApprentissageModificationForm() + 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() + ) + formation = etudiant.inscription_courante_date( + form.date_debut.data, form.date_fin.data + ) + stage_apprentissage.etudid = etudiant.id + stage_apprentissage.type_offre = form.type_offre.data.strip() + stage_apprentissage.date_debut = form.date_debut.data + stage_apprentissage.date_fin = form.date_fin.data + stage_apprentissage.formation_text = ( + formation.formsemestre.titre if formation else None, + ) + stage_apprentissage.formation_scodoc = ( + formation.formsemestre.formsemestre_id if formation else None, + ) + stage_apprentissage.notes = form.notes.data.strip() + db.session.commit() + return redirect( + url_for( + "entreprises.fiche_entreprise", id=stage_apprentissage.entreprise_id + ) + ) + elif request.method == "GET": + form.etudiant.data = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}" + form.type_offre.data = stage_apprentissage.type_offre + form.date_debut.data = stage_apprentissage.date_debut + form.date_fin.data = stage_apprentissage.date_fin + form.notes.data = stage_apprentissage.notes + return render_template( + "entreprises/ajout_stage_apprentissage.html", + title="Modification stage / apprentissage", + form=form, + ) + + +@bp.route( + "/fiche_entreprise/delete_stage_apprentissage/", methods=["GET", "POST"] +) +@permission_required(Permission.RelationsEntreprisesChange) +def delete_stage_apprentissage(id): + """ + Permet de supprimer un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise + """ + stage_apprentissage = EntrepriseStageApprentissage.query.filter_by( + id=id + ).first_or_404(description=f"stage_apprentissage {id} inconnu") + form = SuppressionConfirmationForm() + if form.validate_on_submit(): + db.session.delete(stage_apprentissage) + db.session.commit() + return redirect( + url_for( + "entreprises.fiche_entreprise", id=stage_apprentissage.entreprise_id + ) + ) + return render_template( + "entreprises/delete_confirmation.html", + title="Supression stage/apprentissage", + form=form, + ) + + +@bp.route("/fiche_entreprise/envoyer_offre/", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesSend) def envoyer_offre(id): """ @@ -832,23 +1033,25 @@ def envoyer_offre(id): ) form = EnvoiOffreForm() if form.validate_on_submit(): - responsable_data = form.responsable.data.upper().strip() - stm = text( - "SELECT id, UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')')) FROM \"user\" WHERE UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')'))=:responsable_data" - ) - responsable = ( - User.query.from_statement(stm) - .params(responsable_data=responsable_data) - .first() - ) - envoi_offre = EntrepriseEnvoiOffre( - sender_id=current_user.id, - receiver_id=responsable.id, - offre_id=offre.id, - ) - db.session.add(envoi_offre) - db.session.commit() - flash(f"L'offre a été envoyé à {responsable.get_nomplogin()}.") + for responsable in form.responsables.entries: + if responsable.data.strip(): + responsable_data = responsable.data.upper().strip() + stm = text( + "SELECT id, UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')')) FROM \"user\" WHERE UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')'))=:responsable_data" + ) + responsable = ( + User.query.from_statement(stm) + .params(responsable_data=responsable_data) + .first() + ) + envoi_offre = EntrepriseEnvoiOffre( + sender_id=current_user.id, + receiver_id=responsable.id, + offre_id=offre.id, + ) + db.session.add(envoi_offre) + db.session.commit() + flash(f"L'offre a été envoyé à {responsable.get_nomplogin()}.") return redirect(url_for("entreprises.fiche_entreprise", id=offre.entreprise_id)) return render_template( "entreprises/envoi_offre_form.html", @@ -924,10 +1127,11 @@ def export_entreprises(): filename = title return scu.send_file(xlsx, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) else: - abort(404) + flash("Aucune entreprise dans la base.") + return redirect(url_for("entreprises.index")) -@bp.route("/get_import_entreprises_file_sample") +@bp.route("/import_entreprises/get_import_entreprises_file_sample") @permission_required(Permission.RelationsEntreprisesExport) def get_import_entreprises_file_sample(): """ @@ -978,20 +1182,19 @@ def import_entreprises(): ligne += 1 if ( are.verif_entreprise_data(entreprise_data) - and entreprise_data[0] not in siret_list + and entreprise_data[0].replace(" ", "") not in siret_list ): - siret_list.append(entreprise_data[0]) + siret_list.append(entreprise_data[0].replace(" ", "")) entreprise = Entreprise( - siret=entreprise_data[0], - nom=entreprise_data[1], - adresse=entreprise_data[2], - ville=entreprise_data[3], - codepostal=entreprise_data[4], - pays=entreprise_data[5], + siret=entreprise_data[0].replace(" ", ""), + nom=entreprise_data[1].strip(), + adresse=entreprise_data[2].strip(), + ville=entreprise_data[3].strip(), + codepostal=entreprise_data[4].strip(), + pays=entreprise_data[5].strip(), visible=True, ) entreprises_import.append(entreprise) - else: flash(f"Erreur lors de l'importation (ligne {ligne})") return render_template( @@ -1026,19 +1229,19 @@ def import_entreprises(): ) -@bp.route("/export_contacts") +@bp.route("/export_correspondants") @permission_required(Permission.RelationsEntreprisesExport) -def export_contacts(): +def export_correspondants(): """ - Permet d'exporter la liste des contacts sous format excel (.xlsx) + Permet d'exporter la liste des correspondants sous format excel (.xlsx) """ - contacts = ( - db.session.query(EntrepriseContact) - .join(Entreprise, EntrepriseContact.entreprise_id == Entreprise.id) + correspondants = ( + db.session.query(EntrepriseCorrespondant) + .join(Entreprise, EntrepriseCorrespondant.entreprise_id == Entreprise.id) .filter_by(visible=True) .all() ) - if contacts: + if correspondants: keys = [ "nom", "prenom", @@ -1049,20 +1252,24 @@ def export_contacts(): "entreprise_siret", ] titles = keys[:] - L = [[contact.to_dict().get(k, "") for k in keys] for contact in contacts] - title = "Contacts" + L = [ + [correspondant.to_dict().get(k, "") for k in keys] + for correspondant in correspondants + ] + title = "Correspondants" xlsx = sco_excel.excel_simple_table(titles=titles, lines=L, sheet_name=title) filename = title return scu.send_file(xlsx, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) else: - abort(404) + flash("Aucun correspondant dans la base.") + return redirect(url_for("entreprises.correspondants")) -@bp.route("/get_import_contacts_file_sample") +@bp.route("/import_correspondants/get_import_correspondants_file_sample") @permission_required(Permission.RelationsEntreprisesExport) -def get_import_contacts_file_sample(): +def get_import_correspondants_file_sample(): """ - Permet de récupérer un fichier exemple vide pour pouvoir importer des contacts + Permet de récupérer un fichier exemple vide pour pouvoir importer des correspondants """ keys = [ "nom", @@ -1074,17 +1281,17 @@ def get_import_contacts_file_sample(): "entreprise_siret", ] titles = keys[:] - title = "ImportContacts" - xlsx = sco_excel.excel_simple_table(titles=titles, sheet_name="Contacts") + title = "ImportCorrespondants" + xlsx = sco_excel.excel_simple_table(titles=titles, sheet_name="Correspondants") filename = title return scu.send_file(xlsx, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) -@bp.route("/import_contacts", methods=["GET", "POST"]) +@bp.route("/import_correspondants", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesExport) -def import_contacts(): +def import_correspondants(): """ - Permet d'importer des contacts a l'aide d'un fichier excel (.xlsx) + Permet d'importer des correspondants a l'aide d'un fichier excel (.xlsx) """ form = ImportForm() if form.validate_on_submit(): @@ -1095,8 +1302,8 @@ def import_contacts(): file.save(file_path) data = sco_excel.excel_file_to_list(file_path) os.remove(file_path) - contacts_import = [] - contact_list = [] + correspondants_import = [] + correspondant_list = [] ligne = 0 titles = [ "nom", @@ -1110,57 +1317,72 @@ def import_contacts(): if data[1][0] != titles: flash("Veuillez utilisez la feuille excel à remplir") return render_template( - "entreprises/import_contacts.html", - title="Importation contacts", + "entreprises/import_correspondants.html", + title="Importation correspondants", form=form, ) - for contact_data in data[1][1:]: + for correspondant_data in data[1][1:]: ligne += 1 if ( - are.verif_contact_data(contact_data) - and (contact_data[0], contact_data[1], contact_data[6]) - not in contact_list - ): - contact_list.append((contact_data[0], contact_data[1], contact_data[6])) - contact = EntrepriseContact( - nom=contact_data[0], - prenom=contact_data[1], - telephone=contact_data[2], - mail=contact_data[3], - poste=contact_data[4], - service=contact_data[5], - entreprise_id=contact_data[6], + are.verif_correspondant_data(correspondant_data) + and ( + correspondant_data[0].strip(), + correspondant_data[1].strip(), + correspondant_data[6].strip(), ) - contacts_import.append(contact) + not in correspondant_list + ): + correspondant_list.append( + ( + correspondant_data[0].strip(), + correspondant_data[1].strip(), + correspondant_data[6].strip(), + ) + ) + entreprise = Entreprise.query.filter_by( + siret=correspondant_data[6].strip() + ).first() + correspondant = EntrepriseCorrespondant( + nom=correspondant_data[0].strip(), + prenom=correspondant_data[1].strip(), + telephone=correspondant_data[2].strip(), + mail=correspondant_data[3].strip(), + poste=correspondant_data[4].strip(), + service=correspondant_data[5].strip(), + entreprise_id=entreprise.id, + ) + correspondants_import.append(correspondant) else: flash(f"Erreur lors de l'importation (ligne {ligne})") return render_template( - "entreprises/import_contacts.html", - title="Importation contacts", + "entreprises/import_correspondants.html", + title="Importation correspondants", form=form, ) - if len(contacts_import) > 0: - for contact in contacts_import: - db.session.add(contact) + if len(correspondants_import) > 0: + for correspondant in correspondants_import: + db.session.add(correspondant) log = EntrepriseLog( authenticated_user=current_user.user_name, - text=f"Importation de {len(contacts_import)} contact(s)", + text=f"Importation de {len(correspondants_import)} correspondant(s)", ) db.session.add(log) db.session.commit() - flash(f"Importation réussie de {len(contacts_import)} contact(s)") + flash( + f"Importation réussie de {len(correspondants_import)} correspondant(s)" + ) return render_template( - "entreprises/import_contacts.html", - title="Importation Contacts", + "entreprises/import_correspondants.html", + title="Importation correspondants", form=form, - contacts_import=contacts_import, + correspondants_import=correspondants_import, ) else: - flash('Feuille "Contacts" vide') + flash('Feuille "Correspondants" vide') return render_template( - "entreprises/import_contacts.html", - title="Importation contacts", + "entreprises/import_correspondants.html", + title="Importation correspondants", form=form, ) @@ -1198,7 +1420,7 @@ def get_offre_file(entreprise_id, offre_id, filedir, filename): abort(404, description=f"fichier {filename} inconnu") -@bp.route("/add_offre_file/", methods=["GET", "POST"]) +@bp.route("/fiche_entreprise/add_offre_file/", methods=["GET", "POST"]) @permission_required(Permission.RelationsEntreprisesChange) def add_offre_file(offre_id): """ @@ -1230,7 +1452,10 @@ def add_offre_file(offre_id): ) -@bp.route("/delete_offre_file//", methods=["GET", "POST"]) +@bp.route( + "/fiche_entreprise/delete_offre_file//", + methods=["GET", "POST"], +) @permission_required(Permission.RelationsEntreprisesChange) def delete_offre_file(offre_id, filedir): """ diff --git a/app/forms/main/config_logos.py b/app/forms/main/config_logos.py index db69ae35b..0d0ac0d5a 100644 --- a/app/forms/main/config_logos.py +++ b/app/forms/main/config_logos.py @@ -41,11 +41,8 @@ from wtforms.fields.simple import StringField, HiddenField from app.models import Departement from app.scodoc import sco_logos, html_sco_header from app.scodoc import sco_utils as scu -from app.scodoc.sco_config_actions import ( - LogoDelete, - LogoUpdate, - LogoInsert, -) + +from app.scodoc.sco_config_actions import LogoInsert from app.scodoc.sco_logos import find_logo @@ -120,6 +117,8 @@ def logo_name_validator(message=None): class AddLogoForm(FlaskForm): """Formulaire permettant l'ajout d'un logo (dans un département)""" + from app.scodoc.sco_config_actions import LogoInsert + dept_key = HiddenField() name = StringField( label="Nom", @@ -151,7 +150,7 @@ class AddLogoForm(FlaskForm): dept_id = dept_key_to_id(self.dept_key.data) if dept_id == GLOBAL: dept_id = None - if find_logo(logoname=name.data, dept_id=dept_id) is not None: + if find_logo(logoname=name.data, dept_id=dept_id, strict=True) is not None: raise validators.ValidationError("Un logo de même nom existe déjà") def select_action(self): @@ -160,6 +159,14 @@ class AddLogoForm(FlaskForm): return LogoInsert.build_action(self.data) return None + def opened(self): + if self.do_insert.data: + if self.name.errors: + return "open" + if self.upload.errors: + return "open" + return "" + class LogoForm(FlaskForm): """Embed both presentation of a logo (cf. template file configuration.html) @@ -176,7 +183,18 @@ class LogoForm(FlaskForm): ) ], ) - do_delete = SubmitField("Supprimer l'image") + do_delete = SubmitField("Supprimer") + do_rename = SubmitField("Renommer") + new_name = StringField( + label="Nom", + validators=[ + logo_name_validator("Nom de logo invalide (alphanumérique, _)"), + validators.Length( + max=20, message="Un nom ne doit pas dépasser 20 caractères" + ), + validators.DataRequired("Nom de logo requis (alphanumériques ou '-')"), + ], + ) def __init__(self, *args, **kwargs): kwargs["meta"] = {"csrf": False} @@ -205,12 +223,25 @@ class LogoForm(FlaskForm): self.titre = "Logo pied de page" def select_action(self): + from app.scodoc.sco_config_actions import LogoRename + from app.scodoc.sco_config_actions import LogoUpdate + from app.scodoc.sco_config_actions import LogoDelete + if self.do_delete.data and self.can_delete: return LogoDelete.build_action(self.data) if self.upload.data and self.validate(): return LogoUpdate.build_action(self.data) + if self.do_rename.data and self.validate(): + return LogoRename.build_action(self.data) return None + def opened(self): + if self.upload.data and self.upload.errors: + return "open" + if self.new_name.data and self.new_name.errors: + return "open" + return "" + class DeptForm(FlaskForm): dept_key = HiddenField() @@ -244,6 +275,23 @@ class DeptForm(FlaskForm): return self return self.index.get(logoname, None) + def opened(self): + if self.add_logo.opened(): + return "open" + for logo_form in self.logos: + if logo_form.opened(): + return "open" + return "" + + def count(self): + compte = len(self.logos.entries) + if compte == 0: + return "vide" + elif compte == 1: + return "1 élément" + else: + return f"{compte} éléments" + def _make_dept_id_name(): """Cette section assure que tous les départements sont traités (y compris ceux qu'ont pas de logo au départ) diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 3aacb66a9..b1fec6092 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -302,22 +302,46 @@ class Identite(db.Model): else: date_ins = events[0].event_date situation += date_ins.strftime(" le %d/%m/%Y") + elif inscr.etat == scu.DEF: + situation = f"défaillant en {inscr.formsemestre.titre_mois()}" + event = ( + models.ScolarEvent.query.filter_by( + etudid=self.id, + formsemestre_id=inscr.formsemestre.id, + event_type="DEFAILLANCE", + ) + .order_by(models.ScolarEvent.event_date) + .first() + ) + if not event: + log( + f"*** situation inconsistante pour {self} (def mais pas d'event)" + ) + situation += "???" # ??? + else: + date_def = event.event_date + situation += date_def.strftime(" le %d/%m/%Y") + else: situation = f"démission de {inscr.formsemestre.titre_mois()}" # Cherche la date de demission dans scolar_events: - events = models.ScolarEvent.query.filter_by( - etudid=self.id, - formsemestre_id=inscr.formsemestre.id, - event_type="DEMISSION", - ).all() - if not events: + event = ( + models.ScolarEvent.query.filter_by( + etudid=self.id, + formsemestre_id=inscr.formsemestre.id, + event_type="DEMISSION", + ) + .order_by(models.ScolarEvent.event_date) + .first() + ) + if not event: log( f"*** situation inconsistante pour {self} (demission mais pas d'event)" ) - date_dem = "???" # ??? + situation += "???" # ??? else: - date_dem = events[0].event_date - situation += date_dem.strftime(" le %d/%m/%Y") + date_dem = event.event_date + situation += date_dem.strftime(" le %d/%m/%Y") else: situation = "non inscrit" + self.e diff --git a/app/models/events.py b/app/models/events.py index 9725f3c8a..b94549e76 100644 --- a/app/models/events.py +++ b/app/models/events.py @@ -36,18 +36,21 @@ class Scolog(db.Model): class ScolarNews(db.Model): """Nouvelles pour page d'accueil""" - NEWS_INSCR = "INSCR" # inscription d'étudiants (object=None ou formsemestre_id) - NEWS_NOTE = "NOTES" # saisie note (object=moduleimpl_id) - NEWS_FORM = "FORM" # modification formation (object=formation_id) - NEWS_SEM = "SEM" # creation semestre (object=None) NEWS_ABS = "ABS" # saisie absence + NEWS_APO = "APO" # changements de codes APO + NEWS_FORM = "FORM" # modification formation (object=formation_id) + NEWS_INSCR = "INSCR" # inscription d'étudiants (object=None ou formsemestre_id) NEWS_MISC = "MISC" # unused + NEWS_NOTE = "NOTES" # saisie note (object=moduleimpl_id) + NEWS_SEM = "SEM" # creation semestre (object=None) NEWS_MAP = { - NEWS_INSCR: "inscription d'étudiants", - NEWS_NOTE: "saisie note", + NEWS_ABS: "saisie absence", + NEWS_APO: "modif. code Apogée", NEWS_FORM: "modification formation", - NEWS_SEM: "création semestre", + NEWS_INSCR: "inscription d'étudiants", NEWS_MISC: "opération", # unused + NEWS_NOTE: "saisie note", + NEWS_SEM: "création semestre", } NEWS_TYPES = list(NEWS_MAP.keys()) diff --git a/app/models/formations.py b/app/models/formations.py index c0f375ddc..f76607d3c 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -146,7 +146,8 @@ class Formation(db.Model): db.session.add(ue) db.session.commit() - app.clear_scodoc_cache() + if change: + app.clear_scodoc_cache() class Matiere(db.Model): diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index edf5fa68d..fda723839 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -286,7 +286,7 @@ class FormSemestre(db.Model): """ if not self.etapes: return "" - return ", ".join([str(x.etape_apo) for x in self.etapes]) + return ", ".join(sorted([str(x.etape_apo) for x in self.etapes])) def responsables_str(self, abbrev_prenom=True) -> str: """chaîne "J. Dupond, X. Martin" @@ -375,7 +375,7 @@ class FormSemestre(db.Model): return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}" def sem_modalite(self) -> str: - """Le semestre et la modialité, ex "S2 FI" ou "S3 APP" """ + """Le semestre et la modalité, ex "S2 FI" ou "S3 APP" """ if self.semestre_id > 0: descr_sem = f"S{self.semestre_id}" else: @@ -433,7 +433,7 @@ notes_formsemestre_responsables = db.Table( class FormSemestreEtape(db.Model): - """Étape Apogée associées au semestre""" + """Étape Apogée associée au semestre""" __tablename__ = "notes_formsemestre_etapes" id = db.Column(db.Integer, primary_key=True) diff --git a/app/pe/pe_jurype.py b/app/pe/pe_jurype.py index 0943649cf..48795edfc 100644 --- a/app/pe/pe_jurype.py +++ b/app/pe/pe_jurype.py @@ -486,7 +486,10 @@ class JuryPE(object): sesdates = [ pe_tagtable.conversionDate_StrToDate(sem["date_fin"]) for sem in sessems ] # association 1 date -> 1 semestrePE pour les semestres de l'étudiant - lastdate = max(sesdates) # date de fin de l'inscription la plus récente + if sesdates: + lastdate = max(sesdates) # date de fin de l'inscription la plus récente + else: + return False # if PETable.AFFICHAGE_DEBUG_PE == True : pe_tools.pe_print(" derniere inscription = ", lastDateSem) @@ -585,7 +588,7 @@ class JuryPE(object): for (i, fid) in enumerate(lesFids): if pe_tools.PE_DEBUG: pe_tools.pe_print( - u"%d) Semestre taggué %s (avec classement dans groupe)" + "%d) Semestre taggué %s (avec classement dans groupe)" % (i + 1, fid) ) self.add_semtags_in_jury(fid) @@ -620,7 +623,7 @@ class JuryPE(object): nbinscrit = self.semTagDict[fid].get_nbinscrits() if pe_tools.PE_DEBUG: pe_tools.pe_print( - u" - %d étudiants classés " % (nbinscrit) + " - %d étudiants classés " % (nbinscrit) + ": " + ",".join( [etudid for etudid in self.semTagDict[fid].get_etudids()] @@ -628,12 +631,12 @@ class JuryPE(object): ) if lesEtudidsManquants: pe_tools.pe_print( - u" - dont %d étudiants manquants ajoutés aux données du jury" + " - dont %d étudiants manquants ajoutés aux données du jury" % (len(lesEtudidsManquants)) + ": " + ", ".join(lesEtudidsManquants) ) - pe_tools.pe_print(u" - Export csv") + pe_tools.pe_print(" - Export csv") filename = self.NOM_EXPORT_ZIP + self.semTagDict[fid].nom + ".csv" self.zipfile.writestr(filename, self.semTagDict[fid].str_tagtable()) @@ -742,7 +745,7 @@ class JuryPE(object): for fid in fids_finaux: if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 1: - pe_tools.pe_print(u" - semestre final %s" % (fid)) + pe_tools.pe_print(" - semestre final %s" % (fid)) settag = pe_settag.SetTag( nom, parcours=parcours ) # Le set tag fusionnant les données @@ -762,7 +765,7 @@ class JuryPE(object): for ffid in settag.get_Fids_in_settag(): if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 1: pe_tools.pe_print( - u" -> ajout du semestre tagué %s" % (ffid) + " -> ajout du semestre tagué %s" % (ffid) ) self.add_semtags_in_jury(ffid) settag.set_SemTagDict( @@ -791,7 +794,7 @@ class JuryPE(object): if nbreEtudInscrits > 0: if pe_tools.PE_DEBUG: pe_tools.pe_print( - u"%d) %s avec interclassement sur la promo" % (i + 1, nom) + "%d) %s avec interclassement sur la promo" % (i + 1, nom) ) if nom in ["S1", "S2", "S3", "S4"]: settag.set_SetTagDict(self.semTagDict) @@ -802,7 +805,7 @@ class JuryPE(object): else: if pe_tools.PE_DEBUG: pe_tools.pe_print( - u"%d) Pas d'interclassement %s sur la promo faute de notes" + "%d) Pas d'interclassement %s sur la promo faute de notes" % (i + 1, nom) ) @@ -1152,11 +1155,14 @@ class JuryPE(object): return sesSems # ********************************************** - def calcul_anneePromoDUT_d_un_etudiant(self, etudid): + def calcul_anneePromoDUT_d_un_etudiant(self, etudid) -> int: """Calcule et renvoie la date de diplome prévue pour un étudiant fourni avec son etudid - en fonction de sesSemestres de scolarisation""" - sesSemestres = self.get_semestresDUT_d_un_etudiant(etudid) - return max([get_annee_diplome_semestre(sem) for sem in sesSemestres]) + en fonction de ses semestres de scolarisation""" + semestres = self.get_semestresDUT_d_un_etudiant(etudid) + if semestres: + return max([get_annee_diplome_semestre(sem) for sem in semestres]) + else: + return None # ********************************************* # Fonctions d'affichage pour debug @@ -1184,18 +1190,21 @@ class JuryPE(object): chaine += "\n" return chaine - def get_date_entree_etudiant(self, etudid): - """Renvoie la date d'entree d'un étudiant""" - return str( - min([int(sem["annee_debut"]) for sem in self.ETUDINFO_DICT[etudid]["sems"]]) - ) + def get_date_entree_etudiant(self, etudid) -> str: + """Renvoie la date d'entree d'un étudiant: "1996" """ + annees_debut = [ + int(sem["annee_debut"]) for sem in self.ETUDINFO_DICT[etudid]["sems"] + ] + if annees_debut: + return str(min(annees_debut)) + return "" # ---------------------------------------------------------------------------------------- # Fonctions # ---------------------------------------------------------------------------------------- -def get_annee_diplome_semestre(sem): +def get_annee_diplome_semestre(sem) -> int: """Pour un semestre donne, décrit par le biais du dictionnaire sem usuel : sem = {'formestre_id': ..., 'semestre_id': ..., 'annee_debut': ...}, à condition qu'il soit un semestre de formation DUT, diff --git a/app/scodoc/gen_tables.py b/app/scodoc/gen_tables.py index 2136ee841..1c708ca5c 100644 --- a/app/scodoc/gen_tables.py +++ b/app/scodoc/gen_tables.py @@ -121,6 +121,7 @@ class GenTable(object): html_with_td_classes=False, # put class=column_id in each html_before_table="", # html snippet to put before the in the page html_empty_element="", # replace table when empty + html_table_attrs="", # for html base_url=None, origin=None, # string added to excel and xml versions filename="table", # filename, without extension @@ -146,6 +147,7 @@ class GenTable(object): self.html_header = html_header self.html_before_table = html_before_table self.html_empty_element = html_empty_element + self.html_table_attrs = html_table_attrs self.page_title = page_title self.pdf_link = pdf_link self.xls_link = xls_link @@ -383,12 +385,16 @@ class GenTable(object): colspan_count = colspan else: colspan_txt = "" + attrs = row.get("_%s_td_attrs" % cid, "") + order = row.get(f"_{cid}_order") + if order: + attrs += f' data-order="{order}"' r.append( "<%s%s %s%s%s>%s" % ( elem, std, - row.get("_%s_td_attrs" % cid, ""), + attrs, klass, colspan_txt, content, @@ -413,8 +419,7 @@ class GenTable(object): cls = ' class="%s"' % " ".join(tablclasses) else: cls = "" - - H = [self.html_before_table, "" % (hid, cls)] + H = [self.html_before_table, f""] line_num = 0 # thead diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index c700c1b80..5ab8eacf3 100644 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -57,7 +57,6 @@ def sidebar_common(): Absences
""" ] - if current_user.has_permission( Permission.ScoUsersAdmin ) or current_user.has_permission(Permission.ScoUsersView): diff --git a/app/scodoc/sco_apogee_compare.py b/app/scodoc/sco_apogee_compare.py index 86e2f334b..b6cb042b2 100644 --- a/app/scodoc/sco_apogee_compare.py +++ b/app/scodoc/sco_apogee_compare.py @@ -272,9 +272,15 @@ def _build_etud_res(e, apo_data): r = {} for elt_code in apo_data.apo_elts: elt = apo_data.apo_elts[elt_code] - col_ids_type = [ - (ec["apoL_a01_code"], ec["Type R\xc3\xa9s."]) for ec in elt.cols - ] # les colonnes de cet élément + try: + # les colonnes de cet élément + col_ids_type = [ + (ec["apoL_a01_code"], ec["Type R\xc3\xa9s."]) for ec in elt.cols + ] + except KeyError as exc: + raise ScoValueError( + "Erreur: un élément sans 'Type R\xc3\xa9s.'. Vérifiez l'encodage de vos fichiers." + ) from exc r[elt_code] = {} for (col_id, type_res) in col_ids_type: r[elt_code][type_res] = e.cols[col_id] diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index d9c176eb3..7fe9501df 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -396,7 +396,7 @@ class ApoEtud(dict): # Element etape (annuel ou non): if sco_formsemestre.sem_has_etape(sem, code) or ( - code in sem["elt_annee_apo"].split(",") + code in {x.strip() for x in sem["elt_annee_apo"].split(",")} ): export_res_etape = self.export_res_etape if (not export_res_etape) and cur_sem: @@ -412,7 +412,7 @@ class ApoEtud(dict): return VOID_APO_RES # Element semestre: - if code in sem["elt_sem_apo"].split(","): + if code in {x.strip() for x in sem["elt_sem_apo"].split(",")}: if self.export_res_sem: return self.comp_elt_semestre(nt, decision, etudid) else: @@ -421,7 +421,9 @@ class ApoEtud(dict): # Elements UE decisions_ue = nt.get_etud_decision_ues(etudid) for ue in nt.get_ues_stat_dict(): - if ue["code_apogee"] and code in ue["code_apogee"].split(","): + if ue["code_apogee"] and code in { + x.strip() for x in ue["code_apogee"].split(",") + }: if self.export_res_ues: if decisions_ue and ue["ue_id"] in decisions_ue: ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) @@ -442,9 +444,10 @@ class ApoEtud(dict): modimpls = nt.get_modimpls_dict() module_code_found = False for modimpl in modimpls: - if modimpl["module"]["code_apogee"] and code in modimpl["module"][ - "code_apogee" - ].split(","): + module = modimpl["module"] + if module["code_apogee"] and code in { + x.strip() for x in module["code_apogee"].split(",") + }: n = nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) if n != "NI" and self.export_res_modules: return dict(N=self.fmt_note(n), B=20, J="", R="") @@ -949,8 +952,9 @@ class ApoData(object): return maq_elems, sem_elems def get_codes_by_sem(self): - """Pour chaque semestre associé, donne l'ensemble des codes Apogée qui s'y trouvent - (dans le semestre, les UE et les modules) + """Pour chaque semestre associé, donne l'ensemble des codes de cette maquette Apogée + qui s'y trouvent (dans le semestre, les UE ou les modules). + Return: { formsemestre_id : { 'code1', 'code2', ... }} """ codes_by_sem = {} for sem in self.sems_etape: @@ -961,8 +965,8 @@ class ApoData(object): # associé à l'étape, l'année ou les semestre: if ( sco_formsemestre.sem_has_etape(sem, code) - or (code in sem["elt_sem_apo"].split(",")) - or (code in sem["elt_annee_apo"].split(",")) + or (code in {x.strip() for x in sem["elt_sem_apo"].split(",")}) + or (code in {x.strip() for x in sem["elt_annee_apo"].split(",")}) ): s.add(code) continue @@ -970,17 +974,20 @@ class ApoData(object): formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) for ue in nt.get_ues_stat_dict(): - if ue["code_apogee"] and code in ue["code_apogee"].split(","): - s.add(code) - continue + if ue["code_apogee"]: + codes = {x.strip() for x in ue["code_apogee"].split(",")} + if code in codes: + s.add(code) + continue # associé à un module: modimpls = nt.get_modimpls_dict() for modimpl in modimpls: - if modimpl["module"]["code_apogee"] and code in modimpl["module"][ - "code_apogee" - ].split(","): - s.add(code) - continue + module = modimpl["module"] + if module["code_apogee"]: + codes = {x.strip() for x in module["code_apogee"].split(",")} + if code in codes: + s.add(code) + continue # log('codes_by_sem=%s' % pprint.pformat(codes_by_sem)) return codes_by_sem diff --git a/app/scodoc/sco_config_actions.py b/app/scodoc/sco_config_actions.py index f6ea96371..887b66084 100644 --- a/app/scodoc/sco_config_actions.py +++ b/app/scodoc/sco_config_actions.py @@ -28,11 +28,10 @@ """ """ -from app.models import ScoDocSiteConfig -from app.scodoc.sco_logos import write_logo, find_logo, delete_logo -import app from flask import current_app +from app.scodoc.sco_logos import find_logo + class Action: """Base class for all classes describing an action from from config form.""" @@ -42,9 +41,9 @@ class Action: self.parameters = parameters @staticmethod - def build_action(parameters, stream=None): + def build_action(parameters): """Check (from parameters) if some action has to be done and - then return list of action (or else return empty list).""" + then return list of action (or else return None).""" raise NotImplementedError def display(self): @@ -59,6 +58,45 @@ class Action: GLOBAL = "_" +class LogoRename(Action): + """Action: rename a logo + dept_id: dept_id or '-' + logo_id: logo_id (old name) + new_name: new_name + """ + + def __init__(self, parameters): + super().__init__( + f"Renommage du logo {parameters['logo_id']} en {parameters['new_name']}", + parameters, + ) + + @staticmethod + def build_action(parameters): + dept_id = parameters["dept_key"] + if dept_id == GLOBAL: + dept_id = None + parameters["dept_id"] = dept_id + if parameters["new_name"]: + logo = find_logo( + logoname=parameters["new_name"], + dept_id=parameters["dept_key"], + strict=True, + ) + if logo is None: + return LogoRename(parameters) + + def execute(self): + from app.scodoc.sco_logos import rename_logo + + current_app.logger.info(self.message) + rename_logo( + old_name=self.parameters["logo_id"], + new_name=self.parameters["new_name"], + dept_id=self.parameters["dept_id"], + ) + + class LogoUpdate(Action): """Action: change a logo dept_id: dept_id or '_', @@ -83,6 +121,8 @@ class LogoUpdate(Action): return None def execute(self): + from app.scodoc.sco_logos import write_logo + current_app.logger.info(self.message) write_logo( stream=self.parameters["upload"], @@ -113,6 +153,8 @@ class LogoDelete(Action): return None def execute(self): + from app.scodoc.sco_logos import delete_logo + current_app.logger.info(self.message) delete_logo(name=self.parameters["logo_id"], dept_id=self.parameters["dept_id"]) @@ -136,13 +178,15 @@ class LogoInsert(Action): parameters["dept_id"] = None if parameters["upload"] and parameters["name"]: logo = find_logo( - logoname=parameters["name"], dept_id=parameters["dept_key"] + logoname=parameters["name"], dept_id=parameters["dept_key"], strict=True ) if logo is None: return LogoInsert(parameters) return None def execute(self): + from app.scodoc.sco_logos import write_logo + dept_id = self.parameters["dept_key"] if dept_id == GLOBAL: dept_id = None diff --git a/app/scodoc/sco_dept.py b/app/scodoc/sco_dept.py index c19f93608..14c476513 100644 --- a/app/scodoc/sco_dept.py +++ b/app/scodoc/sco_dept.py @@ -29,6 +29,7 @@ """ from flask import g, request +from flask import url_for from flask_login import current_user import app @@ -79,7 +80,7 @@ def index_html(showcodes=0, showsemtable=0): sco_formsemestre.sem_set_responsable_name(sem) if showcodes: - sem["tmpcode"] = "
" % sem["formsemestre_id"] + sem["tmpcode"] = f"" else: sem["tmpcode"] = "" # Nombre d'inscrits: @@ -121,26 +122,27 @@ def index_html(showcodes=0, showsemtable=0): if showsemtable: H.append( - """
-

Semestres de %s

+ f"""
+

Semestres de {sco_preferences.get_preference("DeptName")}

""" - % sco_preferences.get_preference("DeptName") ) H.append(_sem_table_gt(sems, showcodes=showcodes).html()) H.append("
%s{sem['formsemestre_id']}
") if not showsemtable: H.append( - '

Voir tous les semestres

' - % request.base_url + f"""
+

Voir tous les semestres ({len(othersems)} verrouillés) +

""" ) H.append( - """

-Chercher étape courante: -
- """ - % scu.NotesURL() + f"""

+

+ Chercher étape courante: + +
+

""" ) # if current_user.has_permission(Permission.ScoEtudInscrit): @@ -148,23 +150,26 @@ Chercher étape courante:

Gestion des étudiants

""" ) # if current_user.has_permission(Permission.ScoEditApo): H.append( - """
+ f"""

Exports Apogée

""" - % scu.NotesURL() ) # H.append( @@ -176,7 +181,13 @@ Chercher étape courante: 0 -# ---- Table recap formation -def formation_table_recap(formation_id, format="html"): - """Table recapitulant formation.""" - from app.scodoc import sco_formations - - F = sco_formations.formation_list(args={"formation_id": formation_id}) - if not F: - raise ScoValueError("invalid formation_id") - F = F[0] - T = [] - ues = ue_list(args={"formation_id": formation_id}) - for ue in ues: - Matlist = sco_edit_matiere.matiere_list(args={"ue_id": ue["ue_id"]}) - for Mat in Matlist: - Modlist = sco_edit_module.module_list( - args={"matiere_id": Mat["matiere_id"]} - ) - for Mod in Modlist: - Mod["nb_moduleimpls"] = sco_edit_module.module_count_moduleimpls( - Mod["module_id"] - ) - # - T.append( - { - "UE_acro": ue["acronyme"], - "Mat_tit": Mat["titre"], - "Mod_tit": Mod["abbrev"] or Mod["titre"], - "Mod_code": Mod["code"], - "Mod_coef": Mod["coefficient"], - "Mod_sem": Mod["semestre_id"], - "nb_moduleimpls": Mod["nb_moduleimpls"], - "heures_cours": Mod["heures_cours"], - "heures_td": Mod["heures_td"], - "heures_tp": Mod["heures_tp"], - "ects": Mod["ects"], - } - ) - columns_ids = [ - "UE_acro", - "Mat_tit", - "Mod_tit", - "Mod_code", - "Mod_coef", - "Mod_sem", - "nb_moduleimpls", - "heures_cours", - "heures_td", - "heures_tp", - "ects", - ] - titles = { - "UE_acro": "UE", - "Mat_tit": "Matière", - "Mod_tit": "Module", - "Mod_code": "Code", - "Mod_coef": "Coef.", - "Mod_sem": "Sem.", - "nb_moduleimpls": "Nb utilisé", - "heures_cours": "Cours (h)", - "heures_td": "TD (h)", - "heures_tp": "TP (h)", - "ects": "ECTS", - } - - title = ( - """Formation %(titre)s (%(acronyme)s) [version %(version)s] code %(formation_code)s""" - % F - ) - tab = GenTable( - columns_ids=columns_ids, - rows=T, - titles=titles, - origin="Généré par %s le " % scu.sco_version.SCONAME - + scu.timedate_human_repr() - + "", - caption=title, - html_caption=title, - html_class="table_leftalign", - base_url="%s?formation_id=%s" % (request.base_url, formation_id), - page_title=title, - html_title="

" + title + "

", - pdf_title=title, - preferences=sco_preferences.SemPreferences(), - ) - return tab.make_page(format=format) - - def ue_list_semestre_ids(ue: dict): """Liste triée des numeros de semestres des modules dans cette UE Il est recommandable que tous les modules d'une UE aient le même indice de semestre. diff --git a/app/scodoc/sco_etape_apogee.py b/app/scodoc/sco_etape_apogee.py index 9c0e004cb..8a14f5611 100644 --- a/app/scodoc/sco_etape_apogee.py +++ b/app/scodoc/sco_etape_apogee.py @@ -32,11 +32,8 @@ Voir sco_apogee_csv.py pour la structure du fichier Apogée. Stockage: utilise sco_archive.py - => /opt/scodoc/var/scodoc/archives/apo_csv//2016-1/2016-07-03-16-12-19/V3ASR.csv - pour une maquette de l'année scolaire 2016, semestre 1, etape V3ASR - - ou bien (à partir de ScoDoc 1678) : - /opt/scodoc/var/scodoc/archives/apo_csv//2016-1/2016-07-03-16-12-19/V3ASR!111.csv + exemple: + /opt/scodoc-data/archives/apo_csv//2016-1/2016-07-03-16-12-19/V3ASR!111.csv pour une maquette de l'étape V3ASR version VDI 111. La version VDI sera ignorée sauf si elle est indiquée dans l'étape du semestre. diff --git a/app/scodoc/sco_etape_apogee_view.py b/app/scodoc/sco_etape_apogee_view.py index 21fc2410f..f0ceecc92 100644 --- a/app/scodoc/sco_etape_apogee_view.py +++ b/app/scodoc/sco_etape_apogee_view.py @@ -34,8 +34,6 @@ from zipfile import ZipFile import flask from flask import url_for, g, send_file, request -# from werkzeug.utils import send_file - import app.scodoc.sco_utils as scu from app import log from app.scodoc import html_sco_header diff --git a/app/scodoc/sco_formation_recap.py b/app/scodoc/sco_formation_recap.py new file mode 100644 index 000000000..fedf7a489 --- /dev/null +++ b/app/scodoc/sco_formation_recap.py @@ -0,0 +1,193 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Table recap formation (avec champs éditables) +""" +import io +from zipfile import ZipFile, BadZipfile + +from flask import Response +from flask import send_file, url_for +from flask import g, request +from flask_login import current_user + +from app.models import Formation, FormSemestre, UniteEns, Module +from app.models.formations import Matiere + +from app.scodoc.gen_tables import GenTable +from app.scodoc.sco_permissions import Permission +from app.scodoc import sco_preferences +import app.scodoc.sco_utils as scu + + +# ---- Table recap formation +def formation_table_recap(formation_id, format="html") -> Response: + """Table recapitulant formation.""" + T = [] + formation = Formation.query.get_or_404(formation_id) + ues = formation.ues.order_by(UniteEns.semestre_idx, UniteEns.numero) + can_edit = current_user.has_permission(Permission.ScoChangeFormation) + li = 0 + for ue in ues: + # L'UE + T.append( + { + "sem": f"S{ue.semestre_idx}" if ue.semestre_idx is not None else "-", + "_sem_order": f"{li:04d}", + "code": ue.acronyme, + "titre": ue.titre or "", + "_titre_target": url_for( + "notes.ue_edit", + scodoc_dept=g.scodoc_dept, + ue_id=ue.id, + ) + if can_edit + else None, + "apo": ue.code_apogee or "", + "_apo_td_attrs": f""" data-oid="{ue.id}" data-value="{ue.code_apogee or ''}" """, + "coef": ue.coefficient or "", + "ects": ue.ects, + "_css_row_class": "ue", + } + ) + li += 1 + matieres = ue.matieres.order_by(Matiere.numero) + for mat in matieres: + modules = mat.modules.order_by(Module.numero) + for mod in modules: + nb_moduleimpls = mod.modimpls.count() + # le module (ou ressource ou sae) + T.append( + { + "sem": f"S{mod.semestre_id}" + if mod.semestre_id is not None + else "-", + "_sem_order": f"{li:04d}", + "code": mod.code, + "titre": mod.abbrev or mod.titre, + "_titre_target": url_for( + "notes.module_edit", + scodoc_dept=g.scodoc_dept, + module_id=mod.id, + ) + if can_edit + else None, + "apo": mod.code_apogee, + "_apo_td_attrs": f""" data-oid="{mod.id}" data-value="{mod.code_apogee or ''}" """, + "coef": mod.coefficient, + "nb_moduleimpls": nb_moduleimpls, + "heures_cours": mod.heures_cours, + "heures_td": mod.heures_td, + "heures_tp": mod.heures_tp, + "_css_row_class": f"mod {mod.type_abbrv()}", + } + ) + columns_ids = [ + "sem", + "code", + "apo", + # "mat", inutile d'afficher la matière + "titre", + "coef", + "ects", + "nb_moduleimpls", + "heures_cours", + "heures_td", + "heures_tp", + ] + titles = { + "ue": "UE", + "mat": "Matière", + "titre": "Titre", + "code": "Code", + "apo": "Apo", + "coef": "Coef.", + "sem": "Sem.", + "nb_moduleimpls": "Nb utilisé", + "heures_cours": "Cours (h)", + "heures_td": "TD (h)", + "heures_tp": "TP (h)", + "ects": "ECTS", + } + + title = f"""Formation {formation.titre} ({formation.acronyme}) + [version {formation.version}] code {formation.formation_code}""" + html_class = "stripe cell-border compact hover order-column formation_table_recap" + if current_user.has_permission(Permission.ScoEditApo): + html_class += " apo_editable" + + tab = GenTable( + columns_ids=columns_ids, + rows=T, + titles=titles, + origin=f"Généré par {scu.sco_version.SCONAME} le {scu.timedate_human_repr()}", + caption=title, + html_caption=title, + html_class=html_class, + html_class_ignore_default=True, + html_table_attrs=f""" + data-apo_ue_save_url="{url_for('notes.ue_set_apo', scodoc_dept=g.scodoc_dept)}" + data-apo_mod_save_url="{url_for('notes.module_set_apo', scodoc_dept=g.scodoc_dept)}" + """, + html_with_td_classes=True, + base_url=f"{request.base_url}?formation_id={formation_id}", + page_title=title, + html_title=f"

{title}

", + pdf_title=title, + preferences=sco_preferences.SemPreferences(), + table_id="formation_table_recap", + ) + return tab.make_page(format=format, javascripts=["js/formation_recap.js"]) + + +def export_recap_formations_annee_scolaire(annee_scolaire): + """Exporte un zip des recap (excel) des formatons de tous les semestres + de l'année scolaire indiquée. + """ + annee_scolaire = int(annee_scolaire) + data = io.BytesIO() + zip_file = ZipFile(data, "w") + formsemestres = FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id).filter( + FormSemestre.date_debut >= scu.date_debut_anne_scolaire(annee_scolaire), + FormSemestre.date_debut <= scu.date_fin_anne_scolaire(annee_scolaire), + ) + formation_ids = {formsemestre.formation.id for formsemestre in formsemestres} + for formation_id in formation_ids: + formation = Formation.query.get(formation_id) + xls = formation_table_recap(formation_id, format="xlsx").data + filename = ( + scu.sanitize_filename(formation.get_titre_version()) + scu.XLSX_SUFFIX + ) + zip_file.writestr(filename, xls) + zip_file.close() + data.seek(0) + return send_file( + data, + mimetype="application/zip", + download_name=f"formations-{g.scodoc_dept}-{annee_scolaire}-{annee_scolaire+1}.zip", + as_attachment=True, + ) diff --git a/app/scodoc/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py index 5d8510ac2..11792057c 100644 --- a/app/scodoc/sco_formsemestre.py +++ b/app/scodoc/sco_formsemestre.py @@ -95,9 +95,12 @@ _formsemestreEditor = ndb.EditableTable( def get_formsemestre(formsemestre_id, raise_soft_exc=False): "list ONE formsemestre" + if formsemestre_id is None: + raise ValueError(f"get_formsemestre: id manquant") if formsemestre_id in g.stored_get_formsemestre: return g.stored_get_formsemestre[formsemestre_id] if not isinstance(formsemestre_id, int): + log(f"get_formsemestre: invalid id '{formsemestre_id}'") raise ScoInvalidIdType("formsemestre_id must be an integer !") sems = do_formsemestre_list(args={"formsemestre_id": formsemestre_id}) if not sems: @@ -141,7 +144,6 @@ def _formsemestre_enrich(sem): """Ajoute champs souvent utiles: titre + annee et dateord (pour tris)""" # imports ici pour eviter refs circulaires from app.scodoc import sco_formsemestre_edit - from app.scodoc import sco_etud F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0] parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"]) @@ -350,6 +352,7 @@ def read_formsemestre_etapes(formsemestre_id): # OBSOLETE """SELECT etape_apo FROM notes_formsemestre_etapes WHERE formsemestre_id = %(formsemestre_id)s + ORDER BY etape_apo """, {"formsemestre_id": formsemestre_id}, ) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 83314cf85..299318ed7 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -28,6 +28,7 @@ """Tableau de bord semestre """ +import datetime from flask import current_app from flask import g from flask import request @@ -760,8 +761,7 @@ def _make_listes_sem(sem, with_absences=True): ) formsemestre_id = sem["formsemestre_id"] - - # calcule dates 1er jour semaine pour absences + weekday = datetime.datetime.today().weekday() try: if with_absences: first_monday = sco_abs.ddmmyyyy(sem["date_debut"]).prev_monday() @@ -780,8 +780,8 @@ def _make_listes_sem(sem, with_absences=True): @@ -966,6 +966,7 @@ Il y a des notes en attente ! Le classement des étudiants n'a qu'une valeur ind def formsemestre_status(formsemestre_id=None): """Tableau de bord semestre HTML""" # porté du DTML + sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True) modimpls = sco_moduleimpl.moduleimpl_withmodule_list( formsemestre_id=formsemestre_id @@ -987,7 +988,9 @@ def formsemestre_status(formsemestre_id=None): use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id) H = [ - html_sco_header.sco_header(page_title="Semestre %s" % sem["titreannee"]), + html_sco_header.sco_header( + page_title=f"{formsemestre.sem_modalite()} {formsemestre.titre_annee()}" + ), '
', formsemestre_status_head( formsemestre_id=formsemestre_id, page_title="Tableau de bord" diff --git a/app/scodoc/sco_logos.py b/app/scodoc/sco_logos.py index 02272ce87..27ce7573d 100644 --- a/app/scodoc/sco_logos.py +++ b/app/scodoc/sco_logos.py @@ -89,6 +89,11 @@ def write_logo(stream, name, dept_id=None): Logo(logoname=name, dept_id=dept_id).create(stream) +def rename_logo(old_name, new_name, dept_id): + logo = find_logo(old_name, dept_id, True) + logo.rename(new_name) + + def list_logos(): """Crée l'inventaire de tous les logos existants. L'inventaire se présente comme un dictionnaire de dictionnaire de Logo: @@ -285,6 +290,20 @@ class Logo: dt = path.stat().st_mtime return path.stat().st_mtime + def rename(self, new_name): + """Change le nom (pas le département) + Les éléments non utiles ne sont pas recalculés (car rechargés lors des accès ultérieurs) + """ + old_path = Path(self.filepath) + self.logoname = secure_filename(new_name) + if not self.logoname: + self.logoname = "*** *** nom de logo invalide *** à changer ! *** ***" + else: + new_path = os.path.sep.join( + [self.dirpath, self.prefix + self.logoname + "." + self.suffix] + ) + old_path.rename(new_path) + def guess_image_type(stream) -> str: "guess image type from header in stream" diff --git a/app/scodoc/sco_portal_apogee.py b/app/scodoc/sco_portal_apogee.py index 836be2ed9..8c77b91cf 100644 --- a/app/scodoc/sco_portal_apogee.py +++ b/app/scodoc/sco_portal_apogee.py @@ -37,12 +37,20 @@ import xml.dom.minidom import app.scodoc.sco_utils as scu from app import log +from app.scodoc import sco_cache from app.scodoc.sco_exceptions import ScoValueError from app.scodoc import sco_preferences SCO_CACHE_ETAPE_FILENAME = os.path.join(scu.SCO_TMP_DIR, "last_etapes.xml") +class ApoInscritsEtapeCache(sco_cache.ScoDocCache): + """Cache liste des inscrits à une étape Apogée""" + + timeout = 10 * 60 # 10 minutes + prefix = "APOINSCRETAP" + + def has_portal(): "True if we are connected to a portal" return get_portal_url() @@ -139,14 +147,20 @@ get_maquette_url = _PI.get_maquette_url get_portal_api_version = _PI.get_portal_api_version -def get_inscrits_etape(code_etape, anneeapogee=None, ntrials=2): +def get_inscrits_etape(code_etape, anneeapogee=None, ntrials=4, use_cache=True): """Liste des inscrits à une étape Apogée Result = list of dicts ntrials: try several time the same request, useful for some bad web services + use_cache: use (redis) cache """ log("get_inscrits_etape: code=%s anneeapogee=%s" % (code_etape, anneeapogee)) if anneeapogee is None: anneeapogee = str(time.localtime()[0]) + if use_cache: + obj = ApoInscritsEtapeCache.get((code_etape, anneeapogee)) + if obj: + log("get_inscrits_etape: using cached data") + return obj etud_url = get_etud_url() api_ver = get_portal_api_version() @@ -189,6 +203,8 @@ def get_inscrits_etape(code_etape, anneeapogee=None, ntrials=2): return False # ??? pas d'annee d'inscription dans la réponse etuds = [e for e in etuds if check_inscription(e)] + if use_cache and etuds: + ApoInscritsEtapeCache.set((code_etape, anneeapogee), etuds) return etuds diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index 83dd79d66..179014cfa 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -103,7 +103,7 @@ def formsemestre_recapcomplet( return data H = [ html_sco_header.sco_header( - page_title="Récapitulatif", + page_title=f"{formsemestre.sem_modalite()}: moyennes", no_side_bar=True, init_qtip=True, javascripts=["js/etud_info.js", "js/table_recap.js"], diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py index 6483ffcff..67ac5a6c7 100644 --- a/app/scodoc/sco_synchro_etuds.py +++ b/app/scodoc/sco_synchro_etuds.py @@ -704,6 +704,7 @@ def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident): typ=ScolarNews.NEWS_INSCR, text="Import Apogée de %d étudiants en " % len(created_etudids), obj=sem["formsemestre_id"], + max_frequency=10 * 60, # 10' ) diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index c7422ecbc..ff91c1495 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -728,15 +728,13 @@ def sendResult( def send_file(data, filename="", suffix="", mime=None, attached=None): """Build Flask Response for file download of given type - By default (attached is None), json and xml are inlined and otrher types are attached. + By default (attached is None), json and xml are inlined and other types are attached. """ if attached is None: if mime == XML_MIMETYPE or mime == JSON_MIMETYPE: attached = False else: attached = True - # if attached and not filename: - # raise ValueError("send_file: missing attachement filename") if filename: if suffix: filename += suffix @@ -755,7 +753,7 @@ def send_docx(document, filename): buffer.seek(0) return flask.send_file( buffer, - attachment_filename=sanitize_filename(filename), + download_name=sanitize_filename(filename), mimetype=DOCX_MIMETYPE, ) @@ -873,6 +871,20 @@ def annee_scolaire_debut(year, month): return int(year) - 1 +def date_debut_anne_scolaire(annee_scolaire: int) -> datetime: + """La date de début de l'année scolaire + = 1er aout + """ + return datetime.datetime(year=annee_scolaire, month=8, day=1) + + +def date_fin_anne_scolaire(annee_scolaire: int) -> datetime: + """La date de fin de l'année scolaire + = 31 juillet de l'année suivante + """ + return datetime.datetime(year=annee_scolaire + 1, month=7, day=31) + + def sem_decale_str(sem): """'D' si semestre decalé, ou ''""" # considère "décalé" les semestre impairs commençant entre janvier et juin diff --git a/app/static/css/entreprises.css b/app/static/css/entreprises.css index fac9d11b8..1f4e5f233 100644 --- a/app/static/css/entreprises.css +++ b/app/static/css/entreprises.css @@ -15,6 +15,10 @@ } +.form-error { + color: #a94442; +} + .nav-entreprise>ul>li>a:hover { color: red; } @@ -50,23 +54,23 @@ margin-bottom: -5px; } -.entreprise, .contact, .offre { +.entreprise, .correspondant, .offre { border: solid 2px; border-radius: 10px; padding: 10px; margin-bottom: 10px; } -.contacts-et-offres { +.correspondants-et-offres { display: flex; justify-content: space-between; } -.contacts-et-offres > div { +.correspondants-et-offres > div { flex: 1 0 0; } -.contacts-et-offres > div:nth-child(2) { +.correspondants-et-offres > div:nth-child(2) { margin-left: 20px; } diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 5a244863d..6a0d9e73d 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -427,8 +427,8 @@ table.semlist tr td { border: none; } -table.semlist tr a.stdlink, -table.semlist tr a.stdlink:visited { +table.semlist tbody tr a.stdlink, +table.semlist tbody tr a.stdlink:visited { color: navy; text-decoration: none; } @@ -442,32 +442,86 @@ table.semlist tr td.semestre_id { text-align: right; } -table.semlist tr td.modalite { +table.semlist tbody tr td.modalite { text-align: left; padding-right: 1em; } -div#gtrcontent table.semlist tr.css_S-1 { +/***************************/ +/* Statut des cellules */ +/***************************/ +.sco_selected { + outline: 1px solid #c09; +} + +.sco_modifying { + outline: 2px dashed #c09; + background-color: white !important; +} + +.sco_wait { + outline: 2px solid #c90; +} + +.sco_good { + outline: 2px solid #9c0; +} + +.sco_modified { + font-weight: bold; + color: indigo +} + +/***************************/ +/* Message */ +/***************************/ +.message { + position: fixed; + bottom: 100%; + left: 50%; + z-index: 10; + padding: 20px; + border-radius: 0 0 10px 10px; + background: #ec7068; + background: #90c; + color: #FFF; + font-size: 24px; + animation: message 3s; + transform: translate(-50%, 0); +} + +@keyframes message { + 20% { + transform: translate(-50%, 100%) + } + + 80% { + transform: translate(-50%, 100%) + } +} + + +div#gtrcontent table.semlist tbody tr.css_S-1 td { background-color: rgb(251, 250, 216); } -div#gtrcontent table.semlist tr.css_S1 { +div#gtrcontent table.semlist tbody tr.css_S1 td { background-color: rgb(92%, 95%, 94%); } -div#gtrcontent table.semlist tr.css_S2 { +div#gtrcontent table.semlist tbody tr.css_S2 td { background-color: rgb(214, 223, 236); } -div#gtrcontent table.semlist tr.css_S3 { +div#gtrcontent table.semlist tbody tr.css_S3 td { background-color: rgb(167, 216, 201); } -div#gtrcontent table.semlist tr.css_S4 { +div#gtrcontent table.semlist tbody tr.css_S4 td { background-color: rgb(131, 225, 140); } -div#gtrcontent table.semlist tr.css_MEXT { +div#gtrcontent table.semlist tbody tr.css_MEXT td { color: #0b6e08; } @@ -1001,6 +1055,14 @@ span.wtf-field ul.errors li { display: list-item !important; } +.configuration_logo entete_dept { + display: inline-block; +} + +.configuration_logo .effectifs { + float: right; +} + .configuration_logo h1 { display: inline-block; } @@ -3909,4 +3971,18 @@ table.evaluations_recap td.nb_abs, table.evaluations_recap td.nb_att, table.evaluations_recap td.nb_exc { text-align: center; +} + +/* ------------- Tableau récap formation ------------ */ +table.formation_table_recap tr.ue td { + font-weight: bold; +} + +table.formation_table_recap td.coef, +table.formation_table_recap td.ects, +table.formation_table_recap td.nb_moduleimpls, +table.formation_table_recap td.heures_cours, +table.formation_table_recap td.heures_td, +table.formation_table_recap td.heures_tp { + text-align: right; } \ No newline at end of file diff --git a/app/static/css/table_editor.css b/app/static/css/table_editor.css index e3192d882..25d192c55 100644 --- a/app/static/css/table_editor.css +++ b/app/static/css/table_editor.css @@ -52,6 +52,9 @@ div.title_STANDARD, .champs_STANDARD { div.title_MALUS { background-color: #ff4700; } +.sums { + background: #ddd; +} /***************************/ /* Statut des cellules */ /***************************/ diff --git a/app/static/js/formation_recap.js b/app/static/js/formation_recap.js new file mode 100644 index 000000000..0bcb009f4 --- /dev/null +++ b/app/static/js/formation_recap.js @@ -0,0 +1,28 @@ +/* Page accueil département */ +var apo_ue_editor = null; +var apo_mod_editor = null; + +$(document).ready(function () { + var table_options = { + "paging": false, + "searching": false, + "info": false, + /* "autoWidth" : false, */ + "fixedHeader": { + "header": true, + "footer": true + }, + "orderCellsTop": true, // cellules ligne 1 pour tri + "aaSorting": [], // Prevent initial sorting + }; + $('table#formation_table_recap').DataTable(table_options); + let table_editable = document.querySelector("table#formation_table_recap.apo_editable"); + if (table_editable) { + let apo_ue_save_url = document.querySelector("table#formation_table_recap.apo_editable").dataset.apo_ue_save_url; + apo_ue_editor = new ScoFieldEditor("table#formation_table_recap tr.ue td.apo", apo_ue_save_url, false); + let apo_mod_save_url = document.querySelector("table#formation_table_recap.apo_editable").dataset.apo_mod_save_url; + apo_mod_editor = new ScoFieldEditor("table#formation_table_recap tr.mod td.apo", apo_mod_save_url, false); + } +}); + + diff --git a/app/static/js/scodoc.js b/app/static/js/scodoc.js index eae42d0a4..23ec7fda6 100644 --- a/app/static/js/scodoc.js +++ b/app/static/js/scodoc.js @@ -133,3 +133,134 @@ function readOnlyTags(nodes) { node.after('' + tags.join('') + ''); } } + +/* Editeur pour champs + * Usage: créer un élément avec data-oid (object id) + * La méthode d'URL save sera appelée en POST avec deux arguments: oid et value, + * value contenant la valeur du champs. + * Inspiré par les codes et conseils de Seb. L. + */ +class ScoFieldEditor { + constructor(selector, save_url, read_only) { + this.save_url = save_url; + this.read_only = read_only; + this.selector = selector; + this.installListeners(); + } + // Enregistre l'élément obj + save(obj) { + var value = obj.innerText.trim(); + if (value.length == 0) { + value = ""; + } + if (value == obj.dataset.value) { + return true; // Aucune modification, pas d'enregistrement mais on continue normalement + } + obj.classList.add("sco_wait"); + // DEBUG + // console.log(` + // data : ${value}, + // id: ${obj.dataset.oid} + // `); + + $.post(this.save_url, + { + oid: obj.dataset.oid, + value: value, + }, + function (result) { + obj.classList.remove("sco_wait"); + obj.classList.add("sco_modified"); + } + ); + return true; + } + /*****************************/ + /* Gestion des évènements */ + /*****************************/ + installListeners() { + if (this.read_only) { + return; + } + document.body.addEventListener("keydown", this.key); + let editor = this; + this.handleSelectCell = (event) => { editor.selectCell(event) }; + this.handleModifCell = (event) => { editor.modifCell(event) }; + this.handleBlur = (event) => { editor.blurCell(event) }; + this.handleKeyCell = (event) => { editor.keyCell(event) }; + document.querySelectorAll(this.selector).forEach(cellule => { + cellule.addEventListener("click", this.handleSelectCell); + cellule.addEventListener("dblclick", this.handleModifCell); + cellule.addEventListener("blur", this.handleBlur); + }); + } + /*********************************/ + /* Interaction avec les cellules */ + /*********************************/ + blurCell(event) { + let currentModif = document.querySelector(".sco_modifying"); + if (currentModif) { + if (!this.save(currentModif)) { + return; + } + } + } + selectCell(event) { + let obj = event.currentTarget; + if (obj) { + if (obj.classList.contains("sco_modifying")) { + return; // Cellule en cours de modification, ne pas sélectionner. + } + let currentModif = document.querySelector(".sco_modifying"); + if (currentModif) { + if (!this.save(currentModif)) { + return; + } + } + + this.unselectCell(); + obj.classList.add("sco_selected"); + } + } + unselectCell() { + document.querySelectorAll(".sco_selected, .sco_modifying").forEach(cellule => { + cellule.classList.remove("sco_selected", "sco_modifying"); + cellule.removeAttribute("contentEditable"); + cellule.removeEventListener("keydown", this.handleKeyCell); + }); + } + modifCell(event) { + let obj = event.currentTarget; + if (obj) { + obj.classList.add("sco_modifying"); + obj.contentEditable = true; + obj.addEventListener("keydown", this.handleKeyCell); + obj.focus(); + } + } + key(event) { + switch (event.key) { + case "Enter": + this.modifCell(document.querySelector(".sco_selected")); + event.preventDefault(); + break; + } + } + keyCell(event) { + let obj = event.currentTarget; + if (obj) { + if (event.key == "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (!this.save(obj)) { + return + } + obj.classList.remove("sco_modifying"); + // ArrowMove(0, 1); + // modifCell(document.querySelector(".sco_selected")); + this.unselectCell(); + } + } + } +} + diff --git a/app/static/js/scolar_index.js b/app/static/js/scolar_index.js new file mode 100644 index 000000000..90c20b45f --- /dev/null +++ b/app/static/js/scolar_index.js @@ -0,0 +1,33 @@ +/* Page accueil département */ +var apo_editor = null; +var elt_annee_apo_editor = null; +var elt_sem_apo_editor = null; + +$(document).ready(function () { + var table_options = { + "paging": false, + "searching": false, + "info": false, + /* "autoWidth" : false, */ + "fixedHeader": { + "header": true, + "footer": true + }, + "orderCellsTop": true, // cellules ligne 1 pour tri + "aaSorting": [], // Prevent initial sorting + }; + $('table.semlist').DataTable(table_options); + let table_editable = document.querySelector("table#semlist.apo_editable"); + if (table_editable) { + let save_url = document.querySelector("table#semlist.apo_editable").dataset.apo_save_url; + apo_editor = new ScoFieldEditor(".etapes_apo_str", save_url, false); + + save_url = document.querySelector("table#semlist.apo_editable").dataset.elt_annee_apo_save_url; + elt_annee_apo_editor = new ScoFieldEditor(".elt_annee_apo", save_url, false); + + save_url = document.querySelector("table#semlist.apo_editable").dataset.elt_sem_apo_save_url; + elt_sem_apo_editor = new ScoFieldEditor(".elt_sem_apo", save_url, false); + } +}); + + diff --git a/app/static/js/table_editor.js b/app/static/js/table_editor.js index 2af700a41..cfd60d1e1 100644 --- a/app/static/js/table_editor.js +++ b/app/static/js/table_editor.js @@ -4,8 +4,13 @@ /*****************************/ /* Mise en place des données */ /*****************************/ +let lastX; +let lastY; + function build_table(data) { let output = ""; + let sumsUE = {}; + let sumsRessources = {}; data.forEach((cellule) => { output += ` @@ -29,11 +34,61 @@ function build_table(data) { "> ${cellule.data}
`; + + if (cellule.editable) { + sumsRessources[cellule.y] = (sumsRessources[cellule.y] ?? 0) + (parseFloat(cellule.data) || 0); + sumsUE[cellule.x] = (sumsUE[cellule.x] ?? 0) + (parseFloat(cellule.data) || 0); + } }) + + output += showSums(sumsRessources, sumsUE); document.querySelector(".tableau").innerHTML = output; installListeners(); } +function showSums(sumsRessources, sumsUE) { + lastX = Object.keys(sumsUE).length + 2; + lastY = Object.keys(sumsRessources).length + 2; + + let output = ""; + + Object.entries(sumsUE).forEach(([num, value]) => { + output += ` +
+ ${value} +
`; + }) + + Object.entries(sumsRessources).forEach(([num, value]) => { + output += ` +
+ ${value} +
`; + }) + + return output; +} + /*****************************/ /* Gestion des évènements */ /*****************************/ @@ -54,6 +109,7 @@ function installListeners() { } } }); + cellule.addEventListener("input", processSums); }); } @@ -120,11 +176,28 @@ function keyCell(event) { return } this.classList.remove("modifying"); + let selected = document.querySelector(".selected"); ArrowMove(0, 1); - modifCell(document.querySelector(".selected")); + if (selected != document.querySelector(".selected")) { + modifCell(document.querySelector(".selected")); + } } } +function processSums() { + let sum = 0; + document.querySelectorAll(`[data-editable="true"][data-x="${this.dataset.x}"]`).forEach(e => { + sum += parseFloat(e.innerText) || 0; + }) + document.querySelector(`.sums[data-x="${this.dataset.x}"][data-y="${lastY}"]`).innerText = sum; + + sum = 0; + document.querySelectorAll(`[data-editable="true"][data-y="${this.dataset.y}"]`).forEach(e => { + sum += parseFloat(e.innerText) || 0; + }) + document.querySelector(`.sums[data-x="${lastX}"][data-y="${this.dataset.y}"]`).innerText = sum; +} + /******************************/ /* Affichage d'un message */ /******************************/ diff --git a/app/templates/but/bulletin.html b/app/templates/but/bulletin.html index d394f255b..826356ff4 100644 --- a/app/templates/but/bulletin.html +++ b/app/templates/but/bulletin.html @@ -31,6 +31,18 @@ display: none; }`; releve.shadowRoot.appendChild(style); + }) + .catch(error => { + let div = document.createElement("div"); + div.innerText = "Une erreur s'est produite lors du transfère des données."; + div.style.fontSize = "24px"; + div.style.color = "#d93030"; + + let releve = document.querySelector("releve-but"); + releve.after(div); + releve.remove(); + + throw 'Fin du script - données invalides'; }); document.querySelector("html").style.scrollBehavior = "smooth"; diff --git a/app/templates/config_logos.html b/app/templates/config_logos.html index a4974ca78..69d18f7bd 100644 --- a/app/templates/config_logos.html +++ b/app/templates/config_logos.html @@ -20,7 +20,7 @@ {% endmacro %} {% macro render_add_logo(add_logo_form) %} -
+

Ajouter un logo

@@ -33,7 +33,7 @@ {% endmacro %} {% macro render_logo(dept_form, logo_form) %} -
+
{{ logo_form.hidden_tag() }} {% if logo_form.titre %} @@ -65,6 +65,11 @@ {{ render_field(logo_form.upload, False, onchange="submit_form()") }} {% if logo_form.can_delete %} +
Renommer
+
+ {{ render_field(logo_form.new_name, False) }} + {{ render_field(logo_form.do_rename, False, onSubmit="submit_form()") }} +
Supprimer l'image
{{ render_field(logo_form.do_delete, False, onSubmit="submit_form()") }} @@ -97,20 +102,24 @@