diff --git a/README.md b/README.md index 209a2a0176..65c369b3ea 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes). ### État actuel (26 jan 22) - - 9.1 (master) reproduit l'ensemble des fonctions de ScoDoc 7 (donc pas de BUT), sauf: + - 9.1.5x (master) reproduit l'ensemble des fonctions de ScoDoc 7 (donc pas de BUT), sauf: - ancien module "Entreprises" (obsolète) et ajoute la gestion du BUT. - - 9.2 (branche refactor_nt) est la version de développement. + - 9.2 (branche dev92) est la version de développement. ### Lignes de commandes @@ -69,7 +69,12 @@ Puis remplacer `/opt/scodoc` par un clone du git. cd /opt git clone https://scodoc.org/git/viennet/ScoDoc.git # (ou bien utiliser votre clone gitea si vous l'avez déjà créé !) - mv ScoDoc scodoc # important ! + + # Renommer le répertoire: + mv ScoDoc scodoc + + # Et donner ce répertoire à l'utilisateur scodoc: + chown -R scodoc.scodoc /opt/scodoc Il faut ensuite installer l'environnement et le fichier de configuration: diff --git a/app/__init__.py b/app/__init__.py index 76f9471bb4..a1862aaa70 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,10 +10,11 @@ import traceback import logging from logging.handlers import SMTPHandler, WatchedFileHandler +from threading import Thread from flask import current_app, g, request from flask import Flask -from flask import abort, has_request_context, jsonify +from flask import abort, flash, has_request_context, jsonify from flask import render_template from flask.logging import default_handler from flask_sqlalchemy import SQLAlchemy @@ -27,6 +28,7 @@ import sqlalchemy from app.scodoc.sco_exceptions import ( AccessDenied, + ScoBugCatcher, ScoGenError, ScoValueError, APIInvalidParams, @@ -43,11 +45,13 @@ mail = Mail() bootstrap = Bootstrap() moment = Moment() -cache = Cache( # XXX TODO: configuration file +CACHE_TYPE = os.environ.get("CACHE_TYPE") +cache = Cache( config={ # see https://flask-caching.readthedocs.io/en/latest/index.html#configuring-flask-caching - "CACHE_TYPE": "RedisCache", - "CACHE_DEFAULT_TIMEOUT": 0, # by default, never expire + "CACHE_TYPE": CACHE_TYPE or "RedisCache", + # by default, never expire: + "CACHE_DEFAULT_TIMEOUT": os.environ.get("CACHE_DEFAULT_TIMEOUT") or 0, } ) @@ -60,7 +64,7 @@ def handle_access_denied(exc): return render_template("error_access_denied.html", exc=exc), 403 -def internal_server_error(e): +def internal_server_error(exc): """Bugs scodoc, erreurs 500""" # note that we set the 500 status explicitly return ( @@ -68,11 +72,35 @@ def internal_server_error(e): "error_500.html", SCOVERSION=sco_version.SCOVERSION, date=datetime.datetime.now().isoformat(), + exc=exc, + request_url=request.url, ), 500, ) +def handle_sco_bug(exc): + """Un bug, en général rare, sur lequel les dev cherchent des + informations pour le corriger. + """ + Thread( + target=_async_dump, args=(current_app._get_current_object(), request.url) + ).start() + + return internal_server_error(exc) + + +def _async_dump(app, request_url: str): + from app.scodoc.sco_dump_db import sco_dump_and_send_db + + with app.app_context(): + ndb.open_db_connection() + try: + sco_dump_and_send_db("ScoBugCatcher", request_url=request_url) + except ScoValueError: + pass + + def handle_invalid_usage(error): response = jsonify(error.to_dict()) response.status_code = error.status_code @@ -187,10 +215,12 @@ def create_app(config_class=DevConfig): moment.init_app(app) cache.init_app(app) sco_cache.CACHE = cache + if CACHE_TYPE: # non default + app.logger.info(f"CACHE_TYPE={CACHE_TYPE}") app.register_error_handler(ScoGenError, handle_sco_value_error) app.register_error_handler(ScoValueError, handle_sco_value_error) - + app.register_error_handler(ScoBugCatcher, handle_sco_bug) app.register_error_handler(AccessDenied, handle_access_denied) app.register_error_handler(500, internal_server_error) app.register_error_handler(503, postgresql_server_error) @@ -201,7 +231,7 @@ def create_app(config_class=DevConfig): app.register_blueprint(auth_bp, url_prefix="/auth") from app.entreprises import bp as entreprises_bp - + app.register_blueprint(entreprises_bp, url_prefix="/ScoDoc/entreprises") from app.views import scodoc_bp @@ -295,10 +325,12 @@ def create_app(config_class=DevConfig): from app.scodoc.sco_bulletins_legacy import BulletinGeneratorLegacy from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard + from app.but.bulletin_but_pdf import BulletinGeneratorStandardBUT from app.scodoc.sco_bulletins_ucac import BulletinGeneratorUCAC - # l'ordre est important, le premeir sera le "défaut" pour les nouveaux départements. + # l'ordre est important, le premier sera le "défaut" pour les nouveaux départements. sco_bulletins_generator.register_bulletin_class(BulletinGeneratorStandard) + sco_bulletins_generator.register_bulletin_class(BulletinGeneratorStandardBUT) sco_bulletins_generator.register_bulletin_class(BulletinGeneratorLegacy) sco_bulletins_generator.register_bulletin_class(BulletinGeneratorUCAC) if app.testing or app.debug: @@ -334,7 +366,7 @@ def user_db_init(): current_app.logger.info("Init User's db") # Create roles: - Role.insert_roles() + Role.reset_standard_roles_permissions() current_app.logger.info("created initial roles") # Ensure that admin exists admin_mail = current_app.config.get("SCODOC_ADMIN_MAIL") @@ -457,15 +489,12 @@ from app.models import Departement from app.scodoc import notesdb as ndb, sco_preferences from app.scodoc import sco_cache -# admin_role = Role.query.filter_by(name="SuperAdmin").first() -# if admin_role: -# admin = ( -# User.query.join(UserRole) -# .filter((UserRole.user_id == User.id) & (UserRole.role_id == admin_role.id)) -# .first() -# ) -# else: -# click.echo( -# "Warning: user database not initialized !\n (use: flask user-db-init)" -# ) -# admin = None + +def scodoc_flash_status_messages(): + """Should be called on each page: flash messages indicating specific ScoDoc status""" + email_test_mode_address = sco_preferences.get_preference("email_test_mode_address") + if email_test_mode_address: + flash( + f"Mode test: mails redirigés vers {email_test_mode_address}", + category="warning", + ) diff --git a/app/auth/models.py b/app/auth/models.py index 32768df03d..cfab21a9ca 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -76,7 +76,9 @@ class User(UserMixin, db.Model): "Departement", foreign_keys=[Departement.acronym], primaryjoin=(dept == Departement.acronym), - lazy="dynamic", + lazy="select", + passive_deletes="all", + uselist=False, ) def __init__(self, **kwargs): @@ -171,7 +173,7 @@ class User(UserMixin, db.Model): "id": self.id, "active": self.active, "status_txt": "actif" if self.active else "fermé", - "last_seen": self.last_seen.isoformat() + "Z", + "last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else "", "nom": (self.nom or ""), # sco8 "prenom": (self.prenom or ""), # sco8 "roles_string": self.get_roles_string(), # eg "Ens_RT, Ens_Info" @@ -236,7 +238,7 @@ class User(UserMixin, db.Model): def get_dept_id(self) -> int: "returns user's department id, or None" if self.dept: - return self._departement.first().id + return self._departement.id return None # Permissions management: @@ -268,6 +270,8 @@ class User(UserMixin, db.Model): """Add a role to this user. :param role: Role to add. """ + if not isinstance(role, Role): + raise ScoValueError("add_role: rôle invalide") self.user_roles.append(UserRole(user=self, role=role, dept=dept)) def add_roles(self, roles, dept): @@ -279,7 +283,9 @@ class User(UserMixin, db.Model): def set_roles(self, roles, dept): "set roles in the given dept" - self.user_roles = [UserRole(user=self, role=r, dept=dept) for r in roles] + self.user_roles = [ + UserRole(user=self, role=r, dept=dept) for r in roles if isinstance(r, Role) + ] def get_roles(self): "iterator on my roles" @@ -290,7 +296,11 @@ class User(UserMixin, db.Model): """string repr. of user's roles (with depts) e.g. "Ens_RT, Ens_Info, Secr_CJ" """ - return ",".join(f"{r.role.name or ''}_{r.dept or ''}" for r in self.user_roles) + return ",".join( + f"{r.role.name or ''}_{r.dept or ''}" + for r in self.user_roles + if r is not None + ) def is_administrator(self): "True if i'm an active SuperAdmin" @@ -400,20 +410,30 @@ class Role(db.Model): return self.permissions & perm == perm @staticmethod - def insert_roles(): - """Create default roles""" + def reset_standard_roles_permissions(reset_permissions=True): + """Create default roles if missing, then, if reset_permissions, + reset their permissions to default values. + """ default_role = "Observateur" for role_name, permissions in SCO_ROLES_DEFAULTS.items(): role = Role.query.filter_by(name=role_name).first() if role is None: role = Role(name=role_name) - role.reset_permissions() - for perm in permissions: - role.add_permission(perm) - role.default = role.name == default_role - db.session.add(role) + role.default = role.name == default_role + db.session.add(role) + if reset_permissions: + role.reset_permissions() + for perm in permissions: + role.add_permission(perm) + db.session.add(role) + db.session.commit() + @staticmethod + def ensure_standard_roles(): + """Create default roles if missing""" + Role.reset_standard_roles_permissions(reset_permissions=False) + @staticmethod def get_named_role(name): """Returns existing role with given name, or None.""" diff --git a/app/auth/routes.py b/app/auth/routes.py index df34015155..24daa8ca03 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -19,7 +19,7 @@ from app.auth.forms import ( ResetPasswordForm, DeactivateUserForm, ) -from app.auth.models import Permission +from app.auth.models import Role from app.auth.models import User from app.auth.email import send_password_reset_email from app.decorators import admin_required @@ -121,3 +121,11 @@ def reset_password(token): flash(_("Votre mot de passe a été changé.")) return redirect(url_for("auth.login")) return render_template("auth/reset_password.html", form=form, user=user) + + +@bp.route("/reset_standard_roles_permissions", methods=["GET", "POST"]) +@admin_required +def reset_standard_roles_permissions(): + Role.reset_standard_roles_permissions() + flash("rôles standard réinitialisés !") + return redirect(url_for("scodoc.configuration")) diff --git a/app/but/__init__.py b/app/but/__init__.py new file mode 100644 index 0000000000..f850a58c35 --- /dev/null +++ b/app/but/__init__.py @@ -0,0 +1 @@ +# empty but required for pylint diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 5b17c814ad..689ea9ed97 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -7,16 +7,20 @@ """Génération bulletin BUT """ +import collections import datetime +import numpy as np from flask import url_for, g -from app.models.formsemestre import FormSemestre -from app.scodoc import sco_utils as scu -from app.scodoc import sco_bulletins_json -from app.scodoc import sco_preferences -from app.scodoc.sco_codes_parcours import UE_SPORT -from app.scodoc.sco_utils import fmt_note from app.comp.res_but import ResultatsSemestreBUT +from app.models import FormSemestre, Identite +from app.models.ues import UniteEns +from app.scodoc import sco_bulletins, sco_utils as scu +from app.scodoc import sco_bulletins_json +from app.scodoc import sco_bulletins_pdf +from app.scodoc import sco_preferences +from app.scodoc.sco_codes_parcours import UE_SPORT, DEF +from app.scodoc.sco_utils import fmt_note class BulletinBUT: @@ -28,6 +32,7 @@ class BulletinBUT: def __init__(self, formsemestre: FormSemestre): """ """ self.res = ResultatsSemestreBUT(formsemestre) + self.prefs = sco_preferences.SemPreferences(formsemestre.id) def etud_ue_mod_results(self, etud, ue, modimpls) -> dict: "dict synthèse résultats dans l'UE pour les modules indiqués" @@ -59,18 +64,15 @@ class BulletinBUT: # } return d - def etud_ue_results(self, etud, ue): + def etud_ue_results(self, etud: Identite, ue: UniteEns, decision_ue: dict) -> dict: "dict synthèse résultats UE" res = self.res + d = { "id": ue.id, "titre": ue.titre, "numero": ue.numero, "type": ue.type, - "ECTS": { - "acquis": 0, # XXX TODO voir jury #sco92 - "total": ue.ects, - }, "color": ue.color, "competence": None, # XXX TODO lien avec référentiel "moyenne": None, @@ -78,13 +80,18 @@ class BulletinBUT: "bonus": fmt_note(res.bonus_ues[ue.id][etud.id]) if res.bonus_ues is not None and ue.id in res.bonus_ues else fmt_note(0.0), - "malus": res.malus[ue.id][etud.id], + "malus": fmt_note(res.malus[ue.id][etud.id]), "capitalise": None, # "AAAA-MM-JJ" TODO #sco92 "ressources": self.etud_ue_mod_results(etud, ue, res.ressources), "saes": self.etud_ue_mod_results(etud, ue, res.saes), } + if self.prefs["bul_show_ects"]: + d["ECTS"] = { + "acquis": decision_ue.get("ects", 0.0), + "total": ue.ects or 0.0, # float même si non renseigné + } if ue.type != UE_SPORT: - if sco_preferences.get_preference("bul_show_ue_rangs", res.formsemestre.id): + if self.prefs["bul_show_ue_rangs"]: rangs, effectif = res.ue_rangs[ue.id] rang = rangs[etud.id] else: @@ -109,9 +116,10 @@ class BulletinBUT: d["modules"] = self.etud_mods_results(etud, modimpls_spo) return d - def etud_mods_results(self, etud, modimpls) -> dict: + def etud_mods_results(self, etud, modimpls, version="long") -> dict: """dict synthèse résultats des modules indiqués, - avec évaluations de chacun.""" + avec évaluations de chacun (sauf si version == "short") + """ res = self.res d = {} # etud_idx = self.etud_index[etud.id] @@ -152,14 +160,14 @@ class BulletinBUT: "evaluations": [ self.etud_eval_results(etud, e) for e in modimpl.evaluations - if e.visibulletin + if (e.visibulletin or version == "long") and ( modimpl_results.evaluations_etat[e.id].is_complete - or sco_preferences.get_preference( - "bul_show_all_evals", res.formsemestre.id - ) + or self.prefs["bul_show_all_evals"] ) - ], + ] + if version != "short" + else [], } return d @@ -168,14 +176,23 @@ class BulletinBUT: # eval_notes est une pd.Series avec toutes les notes des étudiants inscrits eval_notes = self.res.modimpls_results[e.moduleimpl_id].evals_notes[e.id] notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna() + modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id] + try: + poids = { + ue.acronyme: modimpls_evals_poids[ue.id][e.id] + for ue in self.res.ues + if ue.type != UE_SPORT + } + except KeyError: + poids = collections.defaultdict(lambda: 0.0) d = { "id": e.id, "description": e.description, "date": e.jour.isoformat() if e.jour else None, "heure_debut": e.heure_debut.strftime("%H:%M") if e.heure_debut else None, "heure_fin": e.heure_fin.strftime("%H:%M") if e.heure_debut else None, - "coef": e.coefficient, - "poids": {p.ue.acronyme: p.poids for p in e.ue_poids}, + "coef": fmt_note(e.coefficient), + "poids": poids, "note": { "value": fmt_note( eval_notes[etud.id], @@ -205,7 +222,8 @@ class BulletinBUT: details = [ f"{fmt_note(bonus_vect[ue.id])} sur {ue.acronyme}" for ue in res.ues - if res.modimpls_in_ue(ue.id, etudid) + if ue.type != UE_SPORT + and res.modimpls_in_ue(ue.id, etudid) and ue.id in res.bonus_ues and bonus_vect[ue.id] > 0.0 ] @@ -216,13 +234,23 @@ class BulletinBUT: else: return f"Bonus de {fmt_note(bonus_vect.iloc[0])}" - def bulletin_etud(self, etud, formsemestre, force_publishing=False) -> dict: - """Le bulletin de l'étudiant dans ce semestre. - Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai + def bulletin_etud( + self, + etud: Identite, + formsemestre: FormSemestre, + force_publishing=False, + version="long", + ) -> dict: + """Le bulletin de l'étudiant dans ce semestre: dict pour la version JSON / HTML. + - version: + "long", "selectedevals": toutes les infos (notes des évaluations) + "short" : ne descend pas plus bas que les modules. + + - Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai (bulletins non publiés). """ res = self.res - etat_inscription = etud.etat_inscription(formsemestre.id) + etat_inscription = etud.inscription_etat(formsemestre.id) nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT] published = (not formsemestre.bul_hide_xml) or force_publishing d = { @@ -239,7 +267,9 @@ class BulletinBUT: }, "formsemestre_id": formsemestre.id, "etat_inscription": etat_inscription, - "options": sco_preferences.bulletin_option_affichage(formsemestre.id), + "options": sco_preferences.bulletin_option_affichage( + formsemestre.id, self.prefs + ), } if not published: return d @@ -253,39 +283,55 @@ class BulletinBUT: "numero": formsemestre.semestre_id, "inscription": "", # inutilisé mais nécessaire pour le js de Seb. "groupes": [], # XXX TODO - "absences": { - "injustifie": nbabsjust, - "total": nbabs, - }, } + if self.prefs["bul_show_abs"]: + semestre_infos["absences"] = { + "injustifie": nbabs - nbabsjust, + "total": nbabs, + } + decisions_ues = self.res.get_etud_decision_ues(etud.id) or {} + if self.prefs["bul_show_ects"]: + ects_tot = sum([ue.ects or 0 for ue in res.ues]) if res.ues else 0.0 + ects_acquis = sum([d.get("ects", 0) for d in decisions_ues.values()]) + semestre_infos["ECTS"] = {"acquis": ects_acquis, "total": ects_tot} semestre_infos.update( sco_bulletins_json.dict_decision_jury(etud.id, formsemestre.id) ) if etat_inscription == scu.INSCRIT: - semestre_infos.update( - { - "notes": { # moyenne des moyennes générales du semestre - "value": fmt_note(res.etud_moy_gen[etud.id]), - "min": fmt_note(res.etud_moy_gen.min()), - "moy": fmt_note(res.etud_moy_gen.mean()), - "max": fmt_note(res.etud_moy_gen.max()), - }, - "rang": { # classement wrt moyenne général, indicatif - "value": res.etud_moy_gen_ranks[etud.id], - "total": nb_inscrits, - }, - }, - ) + # moyenne des moyennes générales du semestre + semestre_infos["notes"] = { + "value": fmt_note(res.etud_moy_gen[etud.id]), + "min": fmt_note(res.etud_moy_gen.min()), + "moy": fmt_note(res.etud_moy_gen.mean()), + "max": fmt_note(res.etud_moy_gen.max()), + } + if self.prefs["bul_show_rangs"] and not np.isnan(res.etud_moy_gen[etud.id]): + # classement wrt moyenne général, indicatif + semestre_infos["rang"] = { + "value": res.etud_moy_gen_ranks[etud.id], + "total": nb_inscrits, + } + else: + semestre_infos["rang"] = { + "value": "-", + "total": nb_inscrits, + } d.update( { - "ressources": self.etud_mods_results(etud, res.ressources), - "saes": self.etud_mods_results(etud, res.saes), + "ressources": self.etud_mods_results( + etud, res.ressources, version=version + ), + "saes": self.etud_mods_results(etud, res.saes, version=version), "ues": { - ue.acronyme: self.etud_ue_results(etud, ue) + ue.acronyme: self.etud_ue_results( + etud, ue, decision_ue=decisions_ues.get(ue.id, {}) + ) for ue in res.ues - if self.res.modimpls_in_ue( - ue.id, etud.id - ) # si l'UE comporte des modules auxquels on est inscrit + # si l'UE comporte des modules auxquels on est inscrit: + if ( + (ue.type == UE_SPORT) + or self.res.modimpls_in_ue(ue.id, etud.id) + ) }, "semestre": semestre_infos, }, @@ -312,3 +358,56 @@ class BulletinBUT: ) return d + + def bulletin_etud_complet(self, etud: Identite, version="long") -> dict: + """Bulletin dict complet avec toutes les infos pour les bulletins BUT pdf + Résultat compatible avec celui de sco_bulletins.formsemestre_bulletinetud_dict + """ + d = self.bulletin_etud( + etud, self.res.formsemestre, version=version, force_publishing=True + ) + d["etudid"] = etud.id + d["etud"] = d["etudiant"] + d["etud"]["nomprenom"] = etud.nomprenom + d.update(self.res.sem) + etud_etat = self.res.get_etud_etat(etud.id) + d["filigranne"] = sco_bulletins_pdf.get_filigranne( + etud_etat, + self.prefs, + decision_sem=d["semestre"].get("decision"), + ) + if etud_etat == scu.DEMISSION: + d["demission"] = "(Démission)" + elif etud_etat == DEF: + d["demission"] = "(Défaillant)" + else: + d["demission"] = "" + + # --- Absences + d["nbabs"], d["nbabsjust"] = self.res.formsemestre.get_abs_count(etud.id) + + # --- Decision Jury + infos, dpv = sco_bulletins.etud_descr_situation_semestre( + etud.id, + self.res.formsemestre.id, + format="html", + show_date_inscr=self.prefs["bul_show_date_inscr"], + show_decisions=self.prefs["bul_show_decision"], + show_uevalid=self.prefs["bul_show_uevalid"], + show_mention=self.prefs["bul_show_mention"], + ) + + d.update(infos) + # --- Rangs + d[ + "rang_nt" + ] = f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}" + d["rang_txt"] = "Rang " + d["rang_nt"] + + # --- Appréciations + d.update( + sco_bulletins.get_appreciations_list(self.res.formsemestre.id, etud.id) + ) + d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"])) + + return d diff --git a/app/but/bulletin_but_pdf.py b/app/but/bulletin_but_pdf.py new file mode 100644 index 0000000000..36d11d1e4a --- /dev/null +++ b/app/but/bulletin_but_pdf.py @@ -0,0 +1,351 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Génération bulletin BUT au format PDF standard +""" +from reportlab.lib.colors import blue +from reportlab.lib.units import cm, mm +from reportlab.platypus import Paragraph, Spacer + +from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard +from app.scodoc import gen_tables +from app.scodoc.sco_codes_parcours import UE_SPORT + + +class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): + """Génération du bulletin de BUT au format PDF. + + self.infos est le dict issu de BulletinBUT.bulletin_etud_complet() + """ + + # spécialisation du BulletinGeneratorStandard, ne pas présenter à l'utilisateur: + list_in_menu = False + scale_table_in_page = False # pas de mise à l'échelle pleine page auto + multi_pages = True # plusieurs pages par bulletins + small_fontsize = "8" + + def bul_table(self, format="html"): + """Génère la table centrale du bulletin de notes + Renvoie: + - en HTML: une chaine + - en PDF: une liste d'objets PLATYPUS (eg instance de Table). + """ + tables_infos = [ + # ---- TABLE SYNTHESE UES + self.but_table_synthese_ues(), + ] + if self.version != "short": + tables_infos += [ + # ---- TABLE RESSOURCES + self.but_table_ressources(), + # ---- TABLE SAE + self.but_table_saes(), + ] + objects = [] + for i, (col_keys, rows, pdf_style, col_widths) in enumerate(tables_infos): + table = gen_tables.GenTable( + rows=rows, + columns_ids=col_keys, + pdf_table_style=pdf_style, + pdf_col_widths=[col_widths[k] for k in col_keys], + preferences=self.preferences, + html_class="notes_bulletin", + html_class_ignore_default=True, + html_with_td_classes=True, + ) + table_objects = table.gen(format=format) + objects += table_objects + # objects += [KeepInFrame(0, 0, table_objects, mode="shrink")] + if i != 2: + objects.append(Spacer(1, 6 * mm)) + + return objects + + def but_table_synthese_ues(self, title_bg=(182, 235, 255)): + """La table de synthèse; pour chaque UE, liste des ressources et SAÉs avec leurs notes + et leurs coefs. + Renvoie: colkeys, P, pdf_style, colWidths + - colkeys: nom des colonnes de la table (clés) + - P : table (liste de dicts de chaines de caracteres) + - pdf_style : commandes table Platypus + - largeurs de colonnes pour PDF + """ + col_widths = { + "titre": None, + "moyenne": 2 * cm, + "coef": 2 * cm, + } + title_bg = tuple(x / 255.0 for x in title_bg) + nota_bene = """La moyenne des ressources et SAÉs dans une UE + dépend des poids donnés aux évaluations.""" + # elems pour générer table avec gen_table (liste de dicts) + rows = [ + # Ligne de titres + { + "titre": "Unités d'enseignement", + "moyenne": Paragraph("Note/20"), + "coef": "Coef.", + "_coef_pdf": Paragraph("Coef."), + "_css_row_class": "note_bold", + "_pdf_row_markup": ["b"], + "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + # ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ], + }, + { + "titre": nota_bene, + "_titre_pdf": Paragraph( + f"{nota_bene}" + ), + "_titre_colspan": 3, + "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ( + "LINEBELOW", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + blue, + ), + ], + }, + ] + col_keys = ["titre", "coef", "moyenne"] # noms des colonnes à afficher + for ue_acronym, ue in self.infos["ues"].items(): + self.ue_rows(rows, ue_acronym, ue, title_bg) + # Global pdf style commands: + pdf_style = [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("BOX", (0, 0), (-1, -1), 0.4, blue), # ajoute cadre extérieur bleu: + ] + return col_keys, rows, pdf_style, col_widths + + def ue_rows(self, rows: list, ue_acronym: str, ue: dict, title_bg: tuple): + "Décrit une UE dans la table synthèse: titre, sous-titre et liste modules" + # 1er ligne titre UE + moy_ue = ue.get("moyenne") + t = { + "titre": f"{ue_acronym} - {ue['titre']}", + "moyenne": Paragraph( + f"""{moy_ue.get("value", "-") + if moy_ue is not None else "-" + }""" + ), + "_css_row_class": "note_bold", + "_pdf_row_markup": ["b"], + "_pdf_style": [ + ( + "LINEABOVE", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + self.PDF_LINECOLOR, + ), + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ], + } + rows.append(t) + if ue["type"] == UE_SPORT: + self.ue_sport_rows(rows, ue, title_bg) + else: + self.ue_std_rows(rows, ue, title_bg) + + def ue_std_rows(self, rows: list, ue: dict, title_bg: tuple): + "Lignes décrivant une UE standard dans la table de synthèse" + # 2eme ligne titre UE (bonus/malus/ects) + if "ECTS" in ue: + ects_txt = f'ECTS: {ue["ECTS"]["acquis"]:.3g} / {ue["ECTS"]["total"]:.3g}' + else: + ects_txt = "" + t = { + "titre": f"""Bonus: {ue['bonus']} - Malus: { + ue["malus"]}""", + "coef": ects_txt, + "_coef_pdf": Paragraph(f"""{ects_txt}"""), + "_coef_colspan": 2, + "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("LINEBELOW", (0, 0), (-1, 0), self.PDF_LINEWIDTH, self.PDF_LINECOLOR), + # cadre autour du bonus/malus, gris clair + ("BOX", (0, 0), (0, 0), self.PDF_LINEWIDTH, (0.7, 0.7, 0.7)), + ], + } + rows.append(t) + + # Liste chaque ressource puis chaque SAE + for mod_type in ("ressources", "saes"): + for mod_code, mod in ue[mod_type].items(): + t = { + "titre": f"{mod_code} {self.infos[mod_type][mod_code]['titre']}", + "moyenne": Paragraph(f'{mod["moyenne"]}'), + "coef": mod["coef"], + "_coef_pdf": Paragraph( + f"{mod['coef']}" + ), + "_pdf_style": [ + ( + "LINEBELOW", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + (0.7, 0.7, 0.7), # gris clair + ) + ], + } + rows.append(t) + + def ue_sport_rows(self, rows: list, ue: dict, title_bg: tuple): + "Lignes décrivant l'UE bonus dans la table de synthèse" + # UE BONUS + for mod_code, mod in ue["modules"].items(): + rows.append( + { + "titre": f"{mod_code} {mod['titre']}", + } + ) + self.evaluations_rows(rows, mod["evaluations"]) + + def but_table_ressources(self): + """La table de synthèse; pour chaque ressources, note et liste d'évaluations + Renvoie: colkeys, P, pdf_style, colWidths + """ + return self.bul_table_modules( + mod_type="ressources", title="Ressources", title_bg=(248, 200, 68) + ) + + def but_table_saes(self): + "table des SAEs" + return self.bul_table_modules( + mod_type="saes", + title="Situations d'apprentissage et d'évaluation", + title_bg=(198, 255, 171), + ) + + def bul_table_modules(self, mod_type=None, title="", title_bg=(248, 200, 68)): + """Table ressources ou SAEs + - colkeys: nom des colonnes de la table (clés) + - P : table (liste de dicts de chaines de caracteres) + - pdf_style : commandes table Platypus + - largeurs de colonnes pour PDF + """ + # UE à utiliser pour les poids (# colonne/UE) + ue_infos = self.infos["ues"] + ue_acros = list( + [k for k in ue_infos if ue_infos[k]["type"] != UE_SPORT] + ) # ['RT1.1', 'RT2.1', 'RT3.1'] + # Colonnes à afficher: + col_keys = ["titre"] + ue_acros + ["coef", "moyenne"] + # Largeurs des colonnes: + col_widths = { + "titre": None, + # "poids": None, + "moyenne": 2 * cm, + "coef": 2 * cm, + } + for ue_acro in ue_acros: + col_widths[ue_acro] = 12 * mm # largeur col. poids + + title_bg = tuple(x / 255.0 for x in title_bg) + # elems pour générer table avec gen_table (liste de dicts) + # Ligne de titres + t = { + "titre": title, + # "_titre_colspan": 1 + len(ue_acros), + "moyenne": "Note/20", + "coef": "Coef.", + "_coef_pdf": Paragraph("Coef."), + "_css_row_class": "note_bold", + "_pdf_row_markup": ["b"], + "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ( + "LINEBELOW", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + blue, + ), + ], + } + for ue_acro in ue_acros: + t[ue_acro] = Paragraph( + f"{ue_acro}" + ) + rows = [t] + for mod_code, mod in self.infos[mod_type].items(): + # 1er ligne titre module + t = { + "titre": f"{mod_code} - {mod['titre']}", + "_titre_colspan": 2 + len(ue_acros), + "_css_row_class": "note_bold", + "_pdf_row_markup": ["b"], + "_pdf_style": [ + ( + "LINEABOVE", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + self.PDF_LINECOLOR, + ), + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ], + } + rows.append(t) + # Evaluations: + self.evaluations_rows(rows, mod["evaluations"], ue_acros) + + # Global pdf style commands: + pdf_style = [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("BOX", (0, 0), (-1, -1), 0.4, blue), # ajoute cadre extérieur bleu: + ] + return col_keys, rows, pdf_style, col_widths + + def evaluations_rows(self, rows, evaluations, ue_acros=()): + "lignes des évaluations" + for e in evaluations: + t = { + "titre": f"{e['description']}", + "moyenne": e["note"]["value"], + "_moyenne_pdf": Paragraph( + f"""{e["note"]["value"]}""" + ), + "coef": e["coef"], + "_coef_pdf": Paragraph( + f"{e['coef']}" + ), + "_pdf_style": [ + ( + "LINEBELOW", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + (0.7, 0.7, 0.7), # gris clair + ) + ], + } + col_idx = 1 # 1ere col. poids + for ue_acro in ue_acros: + t[ue_acro] = Paragraph( + f"""{ + e["poids"].get(ue_acro, "") or ""}""" + ) + t["_pdf_style"].append( + ( + "BOX", + (col_idx, 0), + (col_idx, 0), + self.PDF_LINEWIDTH, + (0.7, 0.7, 0.7), # gris clair + ), + ) + col_idx += 1 + rows.append(t) diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index 7cb5c5877d..bab7b72870 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -72,7 +72,7 @@ def bulletin_but_xml_compat( etud: Identite = Identite.query.get_or_404(etudid) results = bulletin_but.ResultatsSemestreBUT(formsemestre) nb_inscrits = results.get_inscriptions_counts()[scu.INSCRIT] - # etat_inscription = etud.etat_inscription(formsemestre.id) + # etat_inscription = etud.inscription_etat(formsemestre.id) etat_inscription = results.formsemestre.etuds_inscriptions[etudid].etat if (not formsemestre.bul_hide_xml) or force_publishing: published = 1 @@ -145,7 +145,7 @@ def bulletin_but_xml_compat( doc.append(Element("note_max", value="20")) # notes toujours sur 20 doc.append(Element("bonus_sport_culture", value=str(bonus))) # Liste les UE / modules /evals - for ue in results.ues: + for ue in results.ues: # avec bonus rang_ue = 0 # XXX TODO rang de l'étudiant dans cette UE nb_inscrits_ue = ( nb_inscrits # approx: compliqué de définir le "nb d'inscrit à une UE" @@ -161,14 +161,17 @@ def bulletin_but_xml_compat( doc.append(x_ue) if ue.type != sco_codes_parcours.UE_SPORT: v = results.etud_moy_ue[ue.id][etud.id] + vmin = results.etud_moy_ue[ue.id].min() + vmax = results.etud_moy_ue[ue.id].max() else: - v = 0 # XXX TODO valeur bonus sport pour cet étudiant + v = results.bonus or 0.0 + vmin = vmax = 0.0 x_ue.append( Element( "note", value=scu.fmt_note(v), - min=scu.fmt_note(results.etud_moy_ue[ue.id].min()), - max=scu.fmt_note(results.etud_moy_ue[ue.id].max()), + min=scu.fmt_note(vmin), + max=scu.fmt_note(vmax), ) ) x_ue.append(Element("ects", value=str(ue.ects if ue.ects else 0))) @@ -179,7 +182,10 @@ def bulletin_but_xml_compat( # Liste ici uniquement les modules rattachés à cette UE if modimpl.module.ue.id == ue.id: # mod_moy = scu.fmt_note(results.etud_moy_ue[ue.id][etud.id]) - coef = results.modimpl_coefs_df[modimpl.id][ue.id] + try: + coef = results.modimpl_coefs_df[modimpl.id][ue.id] + except KeyError: + coef = 0.0 x_mod = Element( "module", id=str(modimpl.id), @@ -214,17 +220,23 @@ def bulletin_but_xml_compat( note_max_origin=str(e.note_max), ) x_mod.append(x_eval) - x_eval.append( - Element( - "note", - value=scu.fmt_note( - results.modimpls_results[ - e.moduleimpl_id - ].evals_notes[e.id][etud.id], - note_max=e.note_max, - ), + try: + x_eval.append( + Element( + "note", + value=scu.fmt_note( + results.modimpls_results[ + e.moduleimpl_id + ].evals_notes[e.id][etud.id], + note_max=e.note_max, + ), + ) ) - ) + except KeyError: + x_eval.append( + Element("note", value="", note_max="") + ) + # XXX TODO: Evaluations incomplètes ou futures: XXX # XXX TODO UE capitalisee (listee seulement si meilleure que l'UE courante) diff --git a/app/but/forms/refcomp_forms.py b/app/but/forms/refcomp_forms.py index 6fcd59500b..ce16292dfd 100644 --- a/app/but/forms/refcomp_forms.py +++ b/app/but/forms/refcomp_forms.py @@ -19,10 +19,12 @@ class FormationRefCompForm(FlaskForm): class RefCompLoadForm(FlaskForm): + referentiel_standard = SelectField( + "Choisir un référentiel de compétences officiel BUT" + ) upload = FileField( - label="Sélectionner un fichier XML Orébut", + label="Ou bien sélectionner un fichier XML au format Orébut", validators=[ - FileRequired(), FileAllowed( [ "xml", @@ -33,3 +35,13 @@ class RefCompLoadForm(FlaskForm): ) submit = SubmitField("Valider") cancel = SubmitField("Annuler") + + def validate(self): + if not super().validate(): + return False + if (self.referentiel_standard.data == "0") == (not self.upload.data): + self.referentiel_standard.errors.append( + "Choisir soit un référentiel, soit un fichier xml" + ) + return False + return True diff --git a/app/but/import_refcomp.py b/app/but/import_refcomp.py index 4f055147bc..0f97cd958c 100644 --- a/app/but/import_refcomp.py +++ b/app/but/import_refcomp.py @@ -6,6 +6,8 @@ from xml.etree import ElementTree from typing import TextIO +import sqlalchemy + from app import db from app.models.but_refcomp import ( @@ -19,7 +21,7 @@ from app.models.but_refcomp import ( ApcAnneeParcours, ApcParcoursNiveauCompetence, ) -from app.scodoc.sco_exceptions import ScoFormatError +from app.scodoc.sco_exceptions import ScoFormatError, ScoValueError def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None): @@ -27,6 +29,16 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None): peut lever TypeError ou ScoFormatError Résultat: instance de ApcReferentielCompetences """ + # Vérifie que le même fichier n'a pas déjà été chargé: + if ApcReferentielCompetences.query.filter_by( + scodoc_orig_filename=orig_filename, dept_id=dept_id + ).count(): + raise ScoValueError( + f"""Un référentiel a déjà été chargé d'un fichier de même nom. + ({orig_filename}) + Supprimez-le ou changer le nom du fichier.""" + ) + try: root = ElementTree.XML(xml_data) except ElementTree.ParseError as exc: @@ -42,7 +54,16 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None): if not competences: raise ScoFormatError("élément 'competences' manquant") for competence in competences.findall("competence"): - c = ApcCompetence(**ApcCompetence.attr_from_xml(competence.attrib)) + try: + c = ApcCompetence(**ApcCompetence.attr_from_xml(competence.attrib)) + db.session.flush() + except sqlalchemy.exc.IntegrityError: + # ne devrait plus se produire car pas d'unicité de l'id: donc inutile + db.session.rollback() + raise ScoValueError( + f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({competence.attrib["id"]}) + """ + ) ref.competences.append(c) # --- SITUATIONS situations = competence.find("situations") diff --git a/app/comp/__init__.py b/app/comp/__init__.py new file mode 100644 index 0000000000..f850a58c35 --- /dev/null +++ b/app/comp/__init__.py @@ -0,0 +1 @@ +# empty but required for pylint diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py index 1c7fc5437e..f57e225435 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -20,6 +20,7 @@ from flask import g from app.models.formsemestre import FormSemestre from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_codes_parcours import ParcoursDUT, ParcoursDUTMono from app.scodoc.sco_utils import ModuleType @@ -52,7 +53,7 @@ class BonusSport: etud_moy_gen et etud_moy_ue ne sont PAS modifiés (mais utilisés par certains bonus non additifs). """ - # En classique, active un bonus sur les UEs: (dans ce cas bonus_moy_gen reste None) + # En classique, active un bonus sur les UEs: (dans ce cas bonus_moy_gen est ajusté pour le prendre en compte) classic_use_bonus_ues = False # Attributs virtuels: @@ -87,7 +88,7 @@ class BonusSport: for m in formsemestre.modimpls_sorted ] ) - if not len(modimpl_mask): + if len(modimpl_mask) == 0: modimpl_mask = np.s_[:] # il n'y a rien, on prend tout donc rien self.modimpls_spo = [ modimpl @@ -106,6 +107,8 @@ class BonusSport: # sem_modimpl_moys_spo est (nb_etuds, nb_mod_sport) # ou (nb_etuds, nb_mod_sport, nb_ues_non_bonus) nb_etuds, nb_mod_sport = sem_modimpl_moys_spo.shape[:2] + if nb_etuds == 0 or nb_mod_sport == 0: + return # no bonus at all # Enlève les NaN du numérateur: sem_modimpl_moys_no_nan = np.nan_to_num(sem_modimpl_moys_spo, nan=0.0) @@ -157,7 +160,8 @@ class BonusSport: """Calcul des bonus: méthode virtuelle à écraser. Arguments: - sem_modimpl_moys_inscrits: - ndarray (nb_etuds, mod_sport) ou en APC (nb_etuds, mods_sport, nb_ue_non_bonus) + ndarray (nb_etuds, mod_sport) + ou en APC (nb_etuds, mods_sport, nb_ue_non_bonus) les notes aux modules sports auxquel l'étudiant est inscrit, 0 sinon. Pas de nans. - modimpl_coefs_etuds_no_nan: les coefficients: float ndarray @@ -194,52 +198,66 @@ class BonusSportAdditif(BonusSport): à la moyenne générale du semestre déjà obtenue par l'étudiant. """ - seuil_moy_gen = 10.0 # seuls les points au dessus du seuil sont comptés + seuil_moy_gen = 10.0 # seuls les bonus au dessus du seuil sont pris en compte + # les points au dessus du seuil sont comptés (defaut: seuil_moy_gen): + seuil_comptage = None proportion_point = 0.05 # multiplie les points au dessus du seuil + bonux_max = 20.0 # le bonus ne peut dépasser 20 points + bonus_min = 0.0 # et ne peut pas être négatif def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): """calcul du bonus sem_modimpl_moys_inscrits: les notes de sport En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus) - modimpl_coefs_etuds_no_nan: + En classic: ndarray (nb_etuds, nb_mod_sport) + modimpl_coefs_etuds_no_nan: même shape, les coefs. """ if 0 in sem_modimpl_moys_inscrits.shape: # pas d'étudiants ou pas d'UE ou pas de module... return + seuil_comptage = ( + self.seuil_moy_gen if self.seuil_comptage is None else self.seuil_comptage + ) bonus_moy_arr = np.sum( np.where( - sem_modimpl_moys_inscrits > self.seuil_moy_gen, - (sem_modimpl_moys_inscrits - self.seuil_moy_gen) - * self.proportion_point, + (sem_modimpl_moys_inscrits >= self.seuil_moy_gen) + & (modimpl_coefs_etuds_no_nan > 0), + (sem_modimpl_moys_inscrits - seuil_comptage) * self.proportion_point, 0.0, ), axis=1, ) - if self.bonus_max is not None: - # Seuil: bonus limité à bonus_max points (et >= 0) - bonus_moy_arr = np.clip( - bonus_moy_arr, 0.0, self.bonus_max, out=bonus_moy_arr - ) - else: # necessaire pour éviter bonus négatifs ! - bonus_moy_arr = np.clip(bonus_moy_arr, 0.0, 20.0, out=bonus_moy_arr) + # Seuil: bonus dans [min, max] (défaut [0,20]) + bonus_max = self.bonus_max or 0.0 + np.clip(bonus_moy_arr, self.bonus_min, bonus_max, out=bonus_moy_arr) + self.bonus_additif(bonus_moy_arr) + + def bonus_additif(self, bonus_moy_arr: np.array): + "Set bonus_ues et bonus_moy_gen" # en APC, bonus_moy_arr est (nb_etuds, nb_ues_non_bonus) - if self.formsemestre.formation.is_apc() or self.classic_use_bonus_ues: + if self.formsemestre.formation.is_apc(): # Bonus sur les UE et None sur moyenne générale ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] self.bonus_ues = pd.DataFrame( bonus_moy_arr, index=self.etuds_idx, columns=ues_idx, dtype=float ) + elif self.classic_use_bonus_ues: + # Formations classiques apppliquant le bonus sur les UEs + # ici bonus_moy_arr = ndarray 1d nb_etuds + ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] + self.bonus_ues = pd.DataFrame( + np.stack([bonus_moy_arr] * len(ues_idx)).T, + index=self.etuds_idx, + columns=ues_idx, + dtype=float, + ) else: # Bonus sur la moyenne générale seulement self.bonus_moy_gen = pd.Series( bonus_moy_arr, index=self.etuds_idx, dtype=float ) - # if len(bonus_moy_arr.shape) > 1: - # bonus_moy_arr = bonus_moy_arr.sum(axis=1) - # Laisse bonus_moy_gen à None, en APC le bonus moy. gen. sera réparti sur les UEs. - class BonusSportMultiplicatif(BonusSport): """Bonus sport qui multiplie les moyennes d'UE par un facteur""" @@ -284,6 +302,7 @@ class BonusSportMultiplicatif(BonusSport): class BonusDirect(BonusSportAdditif): """Bonus direct: les points sont directement ajoutés à la moyenne générale. + Les coefficients sont ignorés: tous les points de bonus sont sommés. (rappel: la note est ramenée sur 20 avant application). """ @@ -294,8 +313,112 @@ class BonusDirect(BonusSportAdditif): proportion_point = 1.0 +class BonusAisneStQuentin(BonusSportAdditif): + """Calcul bonus modules optionels (sport, culture), règle IUT Aisne St Quentin + +

Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'Université de St Quentin non rattachés à une unité d'enseignement. +

+ +

+ Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par + l'étudiant (en BUT, s'ajoute à la moyenne de chaque UE). +

+ """ + + name = "bonus_iutstq" + displayed_name = "IUT de Saint-Quentin" + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + if 0 in sem_modimpl_moys_inscrits.shape: + # pas d'étudiants ou pas d'UE ou pas de module... + return + # Calcule moyenne pondérée des notes de sport: + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + bonus_moy_arr = np.sum( + sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + np.nan_to_num(bonus_moy_arr, nan=0.0, copy=False) + + bonus_moy_arr[bonus_moy_arr < 10.0] = 0.0 + bonus_moy_arr[bonus_moy_arr >= 18.1] = 0.5 + bonus_moy_arr[bonus_moy_arr >= 16.1] = 0.4 + bonus_moy_arr[bonus_moy_arr >= 14.1] = 0.3 + bonus_moy_arr[bonus_moy_arr >= 12.1] = 0.2 + bonus_moy_arr[bonus_moy_arr >= 10] = 0.1 + + self.bonus_additif(bonus_moy_arr) + + +class BonusAmiens(BonusSportAdditif): + """Bonus IUT Amiens pour les modules optionnels (sport, culture, ...). + + Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,1 point + sur toutes les moyennes d'UE. + """ + + name = "bonus_amiens" + displayed_name = "IUT d'Amiens" + seuil_moy_gen = 0.0 # tous les points sont comptés + proportion_point = 1e10 + bonus_max = 0.1 + classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP + + +# Finalement ils n'en veulent pas. +# class BonusAnnecy(BonusSport): +# """Calcul bonus modules optionnels (sport), règle IUT d'Annecy. + +# Il peut y avoir plusieurs modules de bonus. +# Prend pour chaque étudiant la meilleure de ses notes bonus et +# ajoute à chaque UE :
+# 0.05 point si >=10,
+# 0.1 point si >=12,
+# 0.15 point si >=14,
+# 0.2 point si >=16,
+# 0.25 point si >=18. +# """ + +# name = "bonus_iut_annecy" +# displayed_name = "IUT d'Annecy" + +# def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): +# """calcul du bonus""" +# # if math.prod(sem_modimpl_moys_inscrits.shape) == 0: +# # return # no etuds or no mod sport +# # Prend la note de chaque modimpl, sans considération d'UE +# if len(sem_modimpl_moys_inscrits.shape) > 2: # apc +# sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] +# # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic +# note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds +# bonus = np.zeros(note_bonus_max.shape) +# bonus[note_bonus_max >= 10.0] = 0.05 +# bonus[note_bonus_max >= 12.0] = 0.10 +# bonus[note_bonus_max >= 14.0] = 0.15 +# bonus[note_bonus_max >= 16.0] = 0.20 +# bonus[note_bonus_max >= 18.0] = 0.25 + +# # Bonus moyenne générale et sur les UE +# self.bonus_moy_gen = pd.Series(bonus, index=self.etuds_idx, dtype=float) +# ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] +# nb_ues_no_bonus = len(ues_idx) +# self.bonus_ues = pd.DataFrame( +# np.stack([bonus] * nb_ues_no_bonus, axis=1), +# columns=ues_idx, +# index=self.etuds_idx, +# dtype=float, +# ) + + class BonusBethune(BonusSportMultiplicatif): - """Calcul bonus modules optionels (sport), règle IUT de Béthune. + """Calcul bonus modules optionnels (sport), règle IUT de Béthune. Les points au dessus de la moyenne de 10 apportent un bonus pour le semestre. Ce bonus est égal au nombre de points divisé par 200 et multiplié par la @@ -309,7 +432,7 @@ class BonusBethune(BonusSportMultiplicatif): class BonusBezier(BonusSportAdditif): - """Calcul bonus modules optionels (sport, culture), règle IUT de Bézier. + """Calcul bonus modules optionnels (sport, culture), règle IUT de Bézier. Les étudiants de l'IUT peuvent suivre des enseignements optionnels sport , etc) non rattachés à une unité d'enseignement. Les points @@ -330,28 +453,152 @@ class BonusBezier(BonusSportAdditif): class BonusBordeaux1(BonusSportMultiplicatif): - """Calcul bonus modules optionels (sport, culture), règle IUT Bordeaux 1, sur moyenne générale - et UE. - + """Calcul bonus modules optionnels (sport, culture), règle IUT Bordeaux 1, + sur moyenne générale et UEs. +

Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Bordeaux 1 (sport, théâtre) non rattachés à une unité d'enseignement. - +

Chaque point au-dessus de 10 sur 20 obtenus dans cet enseignement correspond à un % - qui augmente la moyenne de chaque UE et la moyenne générale. - Formule : le % = points>moyenne / 2 + qui augmente la moyenne de chaque UE et la moyenne générale.
+ Formule : pourcentage = (points au dessus de 10) / 2 +

Par exemple : sport 13/20 : chaque UE sera multipliée par 1+0,015, ainsi que la moyenne générale. - +

""" name = "bonus_iutBordeaux1" - displayed_name = "IUT de Bordeaux 1" + displayed_name = "IUT de Bordeaux" classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP seuil_moy_gen = 10.0 amplitude = 0.005 +# Exactement le même que Bordeaux: +class BonusBrest(BonusSportMultiplicatif): + """Calcul bonus modules optionnels (sport, culture), règle IUT de Brest, + sur moyenne générale et UEs. +

+ Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'Université (sport, théâtre) non rattachés à une unité d'enseignement. +

+ Chaque point au-dessus de 10 sur 20 obtenus dans cet enseignement correspond à un % + qui augmente la moyenne de chaque UE et la moyenne générale.
+ Formule : pourcentage = (points au dessus de 10) / 2 +

+ Par exemple : sport 13/20 : chaque UE sera multipliée par 1+0,015, ainsi que la moyenne générale. +

+ """ + + name = "bonus_iut_brest" + displayed_name = "IUT de Brest" + classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP + seuil_moy_gen = 10.0 + amplitude = 0.005 + + +class BonusCachan1(BonusSportAdditif): + """Calcul bonus optionnels (sport, culture), règle IUT de Cachan 1. + + + """ + + name = "bonus_cachan1" + displayed_name = "IUT de Cachan 1" + seuil_moy_gen = 10.0 # tous les points sont comptés + proportion_point = 0.03 + classic_use_bonus_ues = True + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus, avec réglage différent suivant le type de formation""" + # Prend la note de chaque modimpl, sans considération d'UE + if len(sem_modimpl_moys_inscrits.shape) > 2: # apc + sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] + # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic + note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds + ues = self.formsemestre.query_ues(with_sport=False).all() + ues_idx = [ue.id for ue in ues] + + if self.formsemestre.formation.is_apc(): # --- BUT + bonus_moy_arr = np.where( + note_bonus_max > self.seuil_moy_gen, + (note_bonus_max - self.seuil_moy_gen) * self.proportion_point, + 0.0, + ) + self.bonus_ues = pd.DataFrame( + np.stack([bonus_moy_arr] * len(ues)).T, + index=self.etuds_idx, + columns=ues_idx, + dtype=float, + ) + else: # --- DUT + # pareil mais proportion différente et exclusion d'une UE + proportion_point = 0.1 + bonus_moy_arr = np.where( + note_bonus_max > self.seuil_moy_gen, + (note_bonus_max - self.seuil_moy_gen) * proportion_point, + 0.0, + ) + self.bonus_ues = pd.DataFrame( + np.stack([bonus_moy_arr] * len(ues)).T, + index=self.etuds_idx, + columns=ues_idx, + dtype=float, + ) + # Pas de bonus sur la ou les ue de code "UE41_E" + ue_exclues = [ue for ue in ues if ue.ue_code == "UE41_E"] + for ue in ue_exclues: + self.bonus_ues[ue.id] = 0.0 + + +class BonusCalais(BonusSportAdditif): + """Calcul bonus modules optionnels (sport, culture), règle IUT LCO. + + Les étudiants de l'IUT LCO peuvent suivre des enseignements optionnels 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 + dans la limite de 10 points. 6% de ces points cumulés s'ajoutent : + + """ + + name = "bonus_calais" + displayed_name = "IUT du Littoral" + bonus_max = 0.6 + seuil_moy_gen = 10.0 # au dessus de 10 + proportion_point = 0.06 # 6% + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + parcours = self.formsemestre.formation.get_parcours() + # Variantes de DUT ? + if ( + isinstance(parcours, ParcoursDUT) + or parcours.TYPE_PARCOURS == ParcoursDUTMono.TYPE_PARCOURS + ): # DUT + super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + else: + self.classic_use_bonus_ues = True # pour les LP + super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + ues = self.formsemestre.query_ues(with_sport=False).all() + ues_sans_bs = [ + ue for ue in ues if ue.acronyme[-2:].upper() != "BS" + ] # les 2 derniers cars forcés en majus + for ue in ues_sans_bs: + self.bonus_ues[ue.id] = 0.0 + + class BonusColmar(BonusSportAdditif): - """Calcul bonus modules optionels (sport, culture), règle IUT Colmar. + """Calcul bonus modules optionnels (sport, culture), règle IUT Colmar. Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'U.H.A. (sports, musique, deuxième langue, culture, etc) non @@ -374,19 +621,21 @@ class BonusColmar(BonusSportAdditif): class BonusGrenobleIUT1(BonusSportMultiplicatif): """Bonus IUT1 de Grenoble +

À compter de sept. 2021: La note de sport est sur 20, et on calcule une bonification (en %) qui va s'appliquer à la moyenne de chaque UE du semestre en appliquant la formule : bonification (en %) = (note-10)*0,5. - - Bonification qui ne s'applique que si la note est >10. - - (Une note de 10 donne donc 0% de bonif ; note de 20 : 5% de bonif) - +

+ La bonification ne s'applique que si la note est supérieure à 10. +

+ (Une note de 10 donne donc 0% de bonif, et une note de 20 : 5% de bonif) +

Avant sept. 2021, la note de sport allait de 0 à 5 points (sur 20). Chaque point correspondait à 0.25% d'augmentation de la moyenne générale. Par exemple : note de sport 2/5 : la moyenne générale était augmentée de 0.5%. +

""" name = "bonus_iut1grenoble_2017" @@ -411,45 +660,72 @@ class BonusGrenobleIUT1(BonusSportMultiplicatif): class BonusLaRochelle(BonusSportAdditif): - """Calcul bonus modules optionels (sport, culture), règle IUT de La Rochelle. + """Calcul bonus modules optionnels (sport, culture), règle IUT de La Rochelle. - Si la note de sport est comprise entre 0 et 10 : pas d'ajout de point. - Si la note de sport est comprise entre 10 et 20 : ajout de 1% de cette - note sur la moyenne générale du semestre (ou sur les UE en BUT). + """ name = "bonus_iutlr" displayed_name = "IUT de La Rochelle" - seuil_moy_gen = 10.0 # tous les points sont comptés - proportion_point = 0.01 + seuil_moy_gen = 10.0 # si bonus > 10, + seuil_comptage = 0.0 # tous les points sont comptés + proportion_point = 0.01 # 1% -class BonusLeHavre(BonusSportMultiplicatif): - """Bonus sport IUT du Havre sur moyenne générale et UE +class BonusLeHavre(BonusSportAdditif): + """Bonus sport IUT du Havre sur les moyennes d'UE - Les points des modules bonus au dessus de 10/20 sont ajoutés, - et les moyennes d'UE augmentées de 5% de ces points. +

+ Les enseignements optionnels de langue, préprofessionnalisation, + PIX (compétences numériques), l'entrepreneuriat étudiant, l'engagement + bénévole au sein d’association dès lors qu’une grille d’évaluation des + compétences existe ainsi que les activités sportives et culturelles + seront traités au niveau semestriel. +

+ Le maximum de bonification qu’un étudiant peut obtenir sur sa moyenne + est plafonné à 0.5 point. +

+ Lorsqu’un étudiant suit plus de deux matières qui donnent droit à + bonification, l’étudiant choisit les deux notes à retenir. +

+ Les points bonus ne sont acquis que pour une note supérieure à 10/20. +

+ La bonification est calculée de la manière suivante :
+ + Pour chaque matière (max. 2) donnant lieu à bonification :
+ + Bonification = (N-10) x 0,05, + N étant la note de l’activité sur 20. +

""" + # note: ScoDoc ne vérifie pas que le nombre de modules avec inscription n'excède pas 2 name = "bonus_iutlh" displayed_name = "IUT du Havre" + classic_use_bonus_ues = True # sur les UE, même en DUT et LP seuil_moy_gen = 10.0 # seuls les points au dessus du seuil sont comptés - amplitude = 0.005 # multiplie les points au dessus du seuil + proportion_point = 0.05 + bonus_max = 0.5 # class BonusLeMans(BonusSportAdditif): """Calcul bonus modules optionnels (sport, culture), règle IUT Le Mans. - Les points au-dessus de 10 sur 20 obtenus dans chacune des matières +

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

+ +

Dans tous les cas, le bonus est dans la limite de 0,5 point.

""" name = "bonus_iutlemans" @@ -471,14 +747,15 @@ class BonusLeMans(BonusSportAdditif): # Bonus simple, mais avec changement de paramètres en 2010 ! class BonusLille(BonusSportAdditif): - """Calcul bonus modules optionels (sport, culture), règle IUT Villeneuve d'Ascq + """Calcul bonus modules optionnels (sport, culture), règle IUT Villeneuve d'Ascq - Les étudiants de l'IUT peuvent suivre des enseignements optionnels +

Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Lille (sports, 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 et 4% (2% avant août 2010) de ces points cumulés s'ajoutent à la moyenne générale du semestre déjà obtenue par l'étudiant. +

""" name = "bonus_lille" @@ -497,8 +774,25 @@ class BonusLille(BonusSportAdditif): ) +class BonusLimousin(BonusSportAdditif): + """Calcul bonus modules optionnels (sport, culture) à l'IUT du Limousin + + Les points au-dessus de 10 sur 20 obtenus dans chacune des matières optionnelles + sont cumulés. + + La moyenne de chacune des UE du semestre pair est augmentée de 5% du + cumul des points de bonus. + Le maximum de points bonus est de 0,5. + """ + + name = "bonus_limousin" + displayed_name = "IUT du Limousin" + proportion_point = 0.05 + bonus_max = 0.5 + + class BonusLyonProvisoire(BonusSportAdditif): - """Calcul bonus modules optionnels (sport, culture), règle IUT de Lyon (provisoire) + """Calcul bonus modules optionnels (sport, culture) à l'IUT de Lyon (provisoire) Les points au-dessus de 10 sur 20 obtenus dans chacune des matières optionnelles sont cumulés et 1,8% de ces points cumulés @@ -512,8 +806,36 @@ class BonusLyonProvisoire(BonusSportAdditif): bonus_max = 0.5 +class BonusMantes(BonusSportAdditif): + """Calcul bonus modules optionnels (investissement, ...), IUT de Mantes en Yvelines. + +

+ Soit N la note attribuée, le bonus (ou malus) correspond à : + (N-10) x 0,05 + appliqué sur chaque UE du semestre sélectionné pour le BUT + ou appliqué sur la moyenne générale du semestre sélectionné pour le DUT. +

+

Exemples :

+ + """ + + name = "bonus_mantes" + displayed_name = "IUT de Mantes en Yvelines" + bonus_min = -0.5 # peut être NEGATIF ! + bonus_max = 0.5 + seuil_moy_gen = 0.0 # tous les points comptent + seuil_comptage = 10.0 # pivot à 10. + proportion_point = 0.05 + + class BonusMulhouse(BonusSportAdditif): - """Calcul bonus modules optionnels (sport, culture), règle IUT de Mulhouse + """Calcul bonus modules optionnels (sport, culture) à l'IUT de Mulhouse La moyenne de chacune des UE du semestre sera majorée à hauteur de 5% du cumul des points supérieurs à 10 obtenus en matières optionnelles, @@ -530,17 +852,19 @@ class BonusMulhouse(BonusSportAdditif): class BonusNantes(BonusSportAdditif): """IUT de Nantes (Septembre 2018) - Nous avons différents types de bonification +

Nous avons différents types de bonification (sport, culture, engagement citoyen). - +

Nous ajoutons aux moyennes une bonification de 0,2 pour chaque item la bonification totale ne doit pas excéder les 0,5 point. Sur le bulletin nous ne mettons pas une note sur 20 mais directement les bonifications. - - Dans ScoDoc: on a déclarera une UE "sport&culture" dans laquelle on aura des modules - pour chaque activité (Sport, Associations, ...) - avec à chaque fois une note (ScoDoc l'affichera comme une note sur 20, mais en fait ce sera la - valeur de la bonification: entrer 0,1/20 signifiera un bonus de 0,1 point la moyenne générale) +

+ Dans ScoDoc: on a déclarera une UE "sport&culture" dans laquelle on aura + des modules pour chaque activité (Sport, Associations, ...) + avec à chaque fois une note (ScoDoc l'affichera comme une note sur 20, + mais en fait ce sera la valeur de la bonification: entrer 0,1/20 signifiera + un bonus de 0,1 point la moyenne générale). +

""" name = "bonus_nantes" @@ -550,6 +874,19 @@ class BonusNantes(BonusSportAdditif): bonus_max = 0.5 # plafonnement à 0.5 points +class BonusPoitiers(BonusSportAdditif): + """Calcul bonus optionnels (sport, culture), règle IUT de Poitiers. + + Les deux notes d'option supérieure à 10, bonifies les moyennes de chaque UE. + + bonus = (option1 - 10)*5% + (option2 - 10)*5% + """ + + name = "bonus_poitiers" + displayed_name = "IUT de Poitiers" + proportion_point = 0.05 + + class BonusRoanne(BonusSportAdditif): """IUT de Roanne. @@ -561,11 +898,46 @@ class BonusRoanne(BonusSportAdditif): displayed_name = "IUT de Roanne" seuil_moy_gen = 0.0 bonus_max = 0.6 # plafonnement à 0.6 points - apply_bonus_mg_to_ues = True # sur les UE, même en DUT et LP + classic_use_bonus_ues = True # sur les UE, même en DUT et LP + proportion_point = 1 + + +class BonusStBrieuc(BonusSportAdditif): + """IUT de Saint Brieuc + + Ne s'applique qu'aux semestres pairs (S2, S4, S6), et bonifie les moyennes d'UE: + + """ + + # Utilisé aussi par St Malo, voir plus bas + name = "bonus_iut_stbrieuc" + displayed_name = "IUT de Saint-Brieuc" + proportion_point = 1 / 20.0 + classic_use_bonus_ues = True + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + if self.formsemestre.semestre_id % 2 == 0: + super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + + +class BonusStEtienne(BonusSportAdditif): + """IUT de Saint-Etienne. + + Le bonus est compris entre 0 et 0.6 points. + """ + + name = "bonus_iutse" + displayed_name = "IUT de Saint-Etienne" + seuil_moy_gen = 0.0 + bonus_max = 0.6 # plafonnement à 0.6 points + proportion_point = 1 class BonusStDenis(BonusSportAdditif): - """Calcul bonus modules optionels (sport, culture), règle IUT Saint-Denis + """Calcul bonus modules optionnels (sport, culture), règle IUT Saint-Denis Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Paris 13 (sports, musique, deuxième langue, @@ -581,16 +953,75 @@ class BonusStDenis(BonusSportAdditif): bonus_max = 0.5 +class BonusStMalo(BonusStBrieuc): + # identique à St Brieux, sauf la doc + """IUT de Saint Malo + + Ne s'applique qu'aux semestres pairs (S2, S4, S6), et bonifie les moyennes d'UE: + + """ + name = "bonus_iut_stmalo" + displayed_name = "IUT de Saint-Malo" + + +class BonusTarbes(BonusSportAdditif): + """Calcul bonus optionnels (sport, culture), règle IUT de Tarbes. + + + """ + + name = "bonus_tarbes" + displayed_name = "IUT de Tazrbes" + seuil_moy_gen = 10.0 + proportion_point = 1 / 30.0 + classic_use_bonus_ues = True + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + # Prend la note de chaque modimpl, sans considération d'UE + if len(sem_modimpl_moys_inscrits.shape) > 2: # apc + sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] + # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic + note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds + ues = self.formsemestre.query_ues(with_sport=False).all() + ues_idx = [ue.id for ue in ues] + + if self.formsemestre.formation.is_apc(): # --- BUT + bonus_moy_arr = np.where( + note_bonus_max > self.seuil_moy_gen, + (note_bonus_max - self.seuil_moy_gen) * self.proportion_point, + 0.0, + ) + self.bonus_ues = pd.DataFrame( + np.stack([bonus_moy_arr] * len(ues)).T, + index=self.etuds_idx, + columns=ues_idx, + dtype=float, + ) + + class BonusTours(BonusDirect): """Calcul bonus sport & culture IUT Tours. - Les notes des UE bonus (ramenées sur 20) sont sommées +

Les notes des UE bonus (ramenées sur 20) sont sommées et 1/40 (2,5%) est ajouté aux moyennes: soit à la moyenne générale, soit pour le BUT à chaque moyenne d'UE. - - Attention: en GEII, facteur 1/40, ailleurs facteur 1. - +

+ Attention: en GEII, facteur 1/40, ailleurs facteur 1. +

Le bonus total est limité à 1 point. +

""" name = "bonus_tours" @@ -611,15 +1042,17 @@ class BonusTours(BonusDirect): class BonusVilleAvray(BonusSport): - """Bonus modules optionels (sport, culture), règle IUT Ville d'Avray. + """Bonus modules optionnels (sport, culture), règle IUT Ville d'Avray. Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Paris 10 (C2I) non rattachés à une unité d'enseignement. - Si la note est >= 10 et < 12, bonus de 0.1 point - Si la note est >= 12 et < 16, bonus de 0.2 point - Si la note est >= 16, bonus de 0.3 point - Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par - l'étudiant. + +

Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par + l'étudiant.

""" name = "bonus_iutva" @@ -627,25 +1060,25 @@ class BonusVilleAvray(BonusSport): def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): """calcul du bonus""" + if 0 in sem_modimpl_moys_inscrits.shape: + # pas d'étudiants ou pas d'UE ou pas de module... + return # Calcule moyenne pondérée des notes de sport: - bonus_moy_arr = np.sum( - sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 - ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) - bonus_moy_arr[bonus_moy_arr >= 10.0] = 0.1 - bonus_moy_arr[bonus_moy_arr >= 12.0] = 0.2 + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + bonus_moy_arr = np.sum( + sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + np.nan_to_num(bonus_moy_arr, nan=0.0, copy=False) + bonus_moy_arr[bonus_moy_arr < 10.0] = 0.0 bonus_moy_arr[bonus_moy_arr >= 16.0] = 0.3 + bonus_moy_arr[bonus_moy_arr >= 12.0] = 0.2 + bonus_moy_arr[bonus_moy_arr >= 10.0] = 0.1 - # Bonus moyenne générale, et 0 sur les UE - self.bonus_moy_gen = pd.Series(bonus_moy_arr, index=self.etuds_idx, dtype=float) - if self.bonus_max is not None: - # Seuil: bonus (sur moy. gen.) limité à bonus_max points - self.bonus_moy_gen = self.bonus_moy_gen.clip(upper=self.bonus_max) - - # Laisse bonus_ues à None, en APC le bonus moy. gen. sera réparti sur les UEs. + self.bonus_additif(bonus_moy_arr) class BonusIUTV(BonusSportAdditif): - """Calcul bonus modules optionels (sport, culture), règle IUT Villetaneuse + """Calcul bonus modules optionnels (sport, culture), règle IUT Villetaneuse Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Paris 13 (sports, musique, deuxième langue, @@ -657,7 +1090,7 @@ class BonusIUTV(BonusSportAdditif): name = "bonus_iutv" displayed_name = "IUT de Villetaneuse" - pass # oui, c'ets le bonus par défaut + pass # oui, c'est le bonus par défaut def get_bonus_class_dict(start=BonusSport, d=None): diff --git a/app/comp/inscr_mod.py b/app/comp/inscr_mod.py index 667eacbd04..56567f80d8 100644 --- a/app/comp/inscr_mod.py +++ b/app/comp/inscr_mod.py @@ -3,11 +3,9 @@ """Matrices d'inscription aux modules d'un semestre """ -import numpy as np import pandas as pd from app import db -from app import models # # Le chargement des inscriptions est long: matrice nb_module x nb_etuds @@ -17,7 +15,7 @@ from app import models def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame: """Charge la matrice des inscriptions aux modules du semestre rows: etudid (inscrits au semestre, avec DEM et DEF) - columns: moduleimpl_id (en chaîne) + columns: moduleimpl_id value: bool (0/1 inscrit ou pas) """ # méthode la moins lente: une requete par module, merge les dataframes diff --git a/app/comp/jury.py b/app/comp/jury.py index 6581a2fb03..d9be05b6d6 100644 --- a/app/comp/jury.py +++ b/app/comp/jury.py @@ -16,7 +16,7 @@ from app.scodoc import sco_codes_parcours class ValidationsSemestre(ResultatsCache): - """ """ + """Les décisions de jury pour un semestre""" _cached_attrs = ( "decisions_jury", diff --git a/app/comp/moy_mat.py b/app/comp/moy_mat.py new file mode 100644 index 0000000000..0a7522637c --- /dev/null +++ b/app/comp/moy_mat.py @@ -0,0 +1,50 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Calcul des moyennes de matières +""" + +# C'est un recalcul (optionnel) effectué _après_ le calcul standard. + +import numpy as np +import pandas as pd +from app.comp import moy_ue +from app.models.formsemestre import FormSemestre + +from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_utils import ModuleType + + +def compute_mat_moys_classic( + formsemestre: FormSemestre, + sem_matrix: np.array, + ues: list, + modimpl_inscr_df: pd.DataFrame, + modimpl_coefs: np.array, +) -> dict: + """Calcul des moyennes par matières. + Result: dict, { matiere_id : Series, index etudid } + """ + modimpls_std = [ + m + for m in formsemestre.modimpls_sorted + if (m.module.module_type == ModuleType.STANDARD) + and (m.module.ue.type != UE_SPORT) + ] + matiere_ids = {m.module.matiere.id for m in modimpls_std} + matiere_moy = {} # { matiere_id : moy pd.Series, index etudid } + for matiere_id in matiere_ids: + modimpl_mask = np.array( + [m.module.matiere.id == matiere_id for m in formsemestre.modimpls_sorted] + ) + etud_moy_mat = moy_ue.compute_mat_moys_classic( + sem_matrix=sem_matrix, + modimpl_inscr_df=modimpl_inscr_df, + modimpl_coefs=modimpl_coefs, + modimpl_mask=modimpl_mask, + ) + matiere_moy[matiere_id] = etud_moy_mat + return matiere_moy diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index f30f049123..eea357e8f8 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -335,15 +335,17 @@ class ModuleImplResultsAPC(ModuleImplResults): notes_rat / (eval_rat.note_max / 20.0), np.nan, ) + # "Étend" le rattrapage sur les UE: la note de rattrapage est la même + # pour toutes les UE mais ne remplace que là où elle est supérieure + notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1) # prend le max - etuds_use_rattrapage = notes_rat > etuds_moy_module + etuds_use_rattrapage = notes_rat_ues > etuds_moy_module etuds_moy_module = np.where( - etuds_use_rattrapage[:, np.newaxis], - np.tile(notes_rat[:, np.newaxis], nb_ues), - etuds_moy_module, + etuds_use_rattrapage, notes_rat_ues, etuds_moy_module ) + # Serie indiquant que l'étudiant utilise une note de rattarage sur l'une des UE: self.etuds_use_rattrapage = pd.Series( - etuds_use_rattrapage, index=self.evals_notes.index + etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index ) self.etuds_moy_module = pd.DataFrame( etuds_moy_module, @@ -359,6 +361,10 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: Les valeurs manquantes (évaluations sans coef vers des UE) sont remplies: 1 si le coef de ce module dans l'UE est non nul, zéro sinon (sauf pour module bonus, defaut à 1) + + Si le module n'est pas une ressource ou une SAE, ne charge pas de poids + et renvoie toujours les poids par défaut. + Résultat: (evals_poids, liste de UEs du semestre sauf le sport) """ modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) @@ -367,13 +373,17 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: ue_ids = [ue.id for ue in ues] evaluation_ids = [evaluation.id for evaluation in evaluations] evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float) - for ue_poids in EvaluationUEPoids.query.join( - EvaluationUEPoids.evaluation - ).filter_by(moduleimpl_id=moduleimpl_id): - try: - evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids - except KeyError as exc: - pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre... + if ( + modimpl.module.module_type == ModuleType.RESSOURCE + or modimpl.module.module_type == ModuleType.SAE + ): + for ue_poids in EvaluationUEPoids.query.join( + EvaluationUEPoids.evaluation + ).filter_by(moduleimpl_id=moduleimpl_id): + try: + evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids + except KeyError as exc: + pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre... # Initialise poids non enregistrés: default_poids = ( diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index ae167d4e77..61b5fd15cc 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -30,8 +30,11 @@ import numpy as np import pandas as pd +from flask import flash, g, Markup, url_for +from app.models.formations import Formation -def compute_sem_moys_apc( + +def compute_sem_moys_apc_using_coefs( etud_moy_ue_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame ) -> pd.Series: """Calcule les moyennes générales indicatives de tous les étudiants @@ -48,14 +51,57 @@ def compute_sem_moys_apc( return moy_gen -def comp_ranks_series(notes: pd.Series) -> dict[int, str]: +def compute_sem_moys_apc_using_ects( + etud_moy_ue_df: pd.DataFrame, ects: list, formation_id=None, skip_empty_ues=False +) -> pd.Series: + """Calcule les moyennes générales indicatives de tous les étudiants + = moyenne des moyennes d'UE, pondérée par leurs ECTS. + + etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid + ects: liste de floats ou None, 1 par UE + + Si skip_empty_ues: ne compte pas les UE non notées. + Sinon (par défaut), une UE non notée compte comme zéro. + + Result: panda Series, index etudid, valeur float (moyenne générale) + """ + try: + if skip_empty_ues: + # annule les coefs des UE sans notes (NaN) + ects = np.where(etud_moy_ue_df.isna(), 0.0, np.array(ects, dtype=float)) + # ects est devenu nb_etuds x nb_ues + moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1) + else: + moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / sum(ects) + except TypeError: + if None in ects: + formation = Formation.query.get(formation_id) + flash( + Markup( + f"""Calcul moyenne générale impossible: ECTS des UE manquants !
+ (formation: {formation.get_titre_version()})""" + ) + ) + moy_gen = pd.Series(np.NaN, index=etud_moy_ue_df.index) + else: + raise + return moy_gen + + +def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series): """Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur numérique) en tenant compte des ex-aequos. - Result: { etudid : rang:str } où rang est une chaine decrivant le rang. + Result: couple (tuple) + Series { etudid : rang:str } où rang est une chaine decrivant le rang, + Series { etudid : rang:int } le rang comme un nombre """ + 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 = pd.Series(index=notes.index, dtype=str) # le rang est une chaîne + 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 N = len(notes) nb_ex = 0 # nb d'ex-aequo consécutifs en cours notes_i = notes.iat @@ -67,6 +113,7 @@ def comp_ranks_series(notes: pd.Series) -> dict[int, str]: next = None val = notes_i[i] if nb_ex: + rangs_int[etudid] = i + 1 - nb_ex srang = "%d ex" % (i + 1 - nb_ex) if val == next: nb_ex += 1 @@ -74,9 +121,11 @@ def comp_ranks_series(notes: pd.Series) -> dict[int, str]: nb_ex = 0 else: if val == next: + rangs_int[etudid] = i + 1 - nb_ex srang = "%d ex" % (i + 1 - nb_ex) nb_ex = 1 else: + rangs_int[etudid] = i + 1 srang = "%d" % (i + 1) - rangs[etudid] = srang - return rangs + rangs_str[etudid] = srang + return rangs_str, rangs_int diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 1d3e17f6f7..6d80f0b7b4 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -27,7 +27,6 @@ """Fonctions de calcul des moyennes d'UE (classiques ou BUT) """ -from re import X import numpy as np import pandas as pd @@ -136,8 +135,13 @@ def df_load_modimpl_coefs( ) for mod_coef in mod_coefs: - modimpl_coefs_df[mod2impl[mod_coef.module_id]][mod_coef.ue_id] = mod_coef.coef - + try: + modimpl_coefs_df[mod2impl[mod_coef.module_id]][ + mod_coef.ue_id + ] = mod_coef.coef + except IndexError: + # il peut y avoir en base des coefs sur des modules ou UE qui ont depuis été retirés de la formation + pass # Initialisation des poids non fixés: # 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse # sur toutes les UE) @@ -193,6 +197,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id) etuds_moy_module = mod_results.compute_module_moy(evals_poids) modimpls_results[modimpl.id] = mod_results + modimpls_evals_poids[modimpl.id] = evals_poids modimpls_notes.append(etuds_moy_module) if len(modimpls_notes): cube = notes_sem_assemble_cube(modimpls_notes) @@ -213,28 +218,32 @@ def compute_ue_moys_apc( ues: list, modimpl_inscr_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame, + modimpl_mask: np.array, ) -> pd.DataFrame: """Calcul de la moyenne d'UE en mode APC (BUT). La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR NI non inscrit à (au moins un) module de cette UE NA pas de notes disponibles - ERR erreur dans une formule utilisateur. [XXX pas encore gérées ici] + ERR erreur dans une formule utilisateurs (pas gérées ici). sem_cube: notes moyennes aux modules ndarray (etuds x modimpls x UEs) (floats avec des NaN) etuds : liste des étudiants (dim. 0 du cube) - modimpls : liste des modules à considérer (dim. 1 du cube) + modimpls : liste des module_impl (dim. 1 du cube) ues : liste des UE (dim. 2 du cube) modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport + modimpl_mask: liste de booléens, indiquants le module doit être pris ou pas. + (utilisé pour éliminer les bonus, et pourra servir à cacluler + sur des sous-ensembles de modules) - Résultat: DataFrame columns UE (sans sport), rows etudid + Résultat: DataFrame columns UE (sans bonus), rows etudid """ nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape nb_ues_tot = len(ues) assert len(modimpls) == nb_modules - if nb_modules == 0 or nb_etuds == 0: + if nb_modules == 0 or nb_etuds == 0 or nb_ues_no_bonus == 0: return pd.DataFrame( index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index ) @@ -244,7 +253,8 @@ def compute_ue_moys_apc( assert modimpl_coefs_df.shape[0] == nb_ues_no_bonus assert modimpl_coefs_df.shape[1] == nb_modules modimpl_inscr = modimpl_inscr_df.values - modimpl_coefs = modimpl_coefs_df.values + # Met à zéro tous les coefs des modules non sélectionnés dans le masque: + modimpl_coefs = np.where(modimpl_mask, modimpl_coefs_df.values, 0.0) # Duplique les inscriptions sur les UEs non bonus: modimpl_inscr_stacked = np.stack([modimpl_inscr] * nb_ues_no_bonus, axis=2) @@ -261,6 +271,8 @@ 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 + modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float) # # Version vectorisée # @@ -283,7 +295,8 @@ def compute_ue_moys_classic( modimpl_coefs: np.array, modimpl_mask: np.array, ) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame]: - """Calcul de la moyenne d'UE en mode classique. + """Calcul de la moyenne d'UE et de la moy. générale en mode classique (DUT, LMD, ...). + La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR NI non inscrit à (au moins un) module de cette UE NA pas de notes disponibles @@ -343,7 +356,8 @@ 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 + modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float) # --------------------- Calcul des moyennes d'UE ue_modules = np.array( [[m.module.ue == ue for m in formsemestre.modimpls_sorted] for ue in ues] @@ -351,8 +365,10 @@ def compute_ue_moys_classic( modimpl_coefs_etuds_no_nan_stacked = np.stack( [modimpl_coefs_etuds_no_nan.T] * nb_ues ) - # nb_ue x nb_etuds x nb_mods : coefs prenant en compte NaN et inscriptions + # 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 + coefs = coefs.astype(np.float) with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) etud_moy_ue = ( np.sum(coefs * sem_matrix_inscrits, axis=2) / np.sum(coefs, axis=2) @@ -394,6 +410,68 @@ def compute_ue_moys_classic( return etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df +def compute_mat_moys_classic( + sem_matrix: np.array, + modimpl_inscr_df: pd.DataFrame, + modimpl_coefs: np.array, + modimpl_mask: np.array, +) -> pd.Series: + """Calcul de la moyenne sur un sous-enemble de modules en formation CLASSIQUE + + La moyenne est un nombre (note/20 ou NaN. + + Le masque modimpl_mask est un tableau de booléens (un par modimpl) qui + permet de sélectionner un sous-ensemble de modules (ceux de la matière d'intérêt). + + sem_matrix: notes moyennes aux modules (tous les étuds x tous les modimpls) + ndarray (etuds x modimpls) + (floats avec des NaN) + etuds : listes des étudiants (dim. 0 de la matrice) + modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) + modimpl_coefs: vecteur des coefficients de modules + modimpl_mask: masque des modimpls à prendre en compte + + Résultat: + - moyennes: pd.Series, index etudid + """ + if (not len(modimpl_mask)) or ( + sem_matrix.shape[0] == 0 + ): # aucun module ou aucun étudiant + # etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df + return pd.Series( + [0.0] * len(modimpl_inscr_df.index), index=modimpl_inscr_df.index + ) + # Restreint aux modules sélectionnés: + sem_matrix = sem_matrix[:, modimpl_mask] + modimpl_inscr = modimpl_inscr_df.values[:, modimpl_mask] + modimpl_coefs = modimpl_coefs[modimpl_mask] + + nb_etuds, nb_modules = sem_matrix.shape + assert len(modimpl_coefs) == nb_modules + + # Enlève les NaN du numérateur: + sem_matrix_no_nan = np.nan_to_num(sem_matrix, nan=0.0) + # Ne prend pas en compte les notes des étudiants non inscrits au module: + # Annule les notes: + sem_matrix_inscrits = np.where(modimpl_inscr, sem_matrix_no_nan, 0.0) + # Annule les coefs des modules où l'étudiant n'est pas inscrit: + modimpl_coefs_etuds = np.where( + modimpl_inscr, np.stack([modimpl_coefs.T] * nb_etuds), 0.0 + ) + # Annule les coefs des modules NaN (nb_etuds x nb_mods) + 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 + 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( + axis=1 + ) / modimpl_coefs_etuds_no_nan.sum(axis=1) + + return pd.Series(etud_moy_mat, index=modimpl_inscr_df.index) + + def compute_malus( formsemestre: FormSemestre, sem_modimpl_moys: np.array, diff --git a/app/comp/res_but.py b/app/comp/res_but.py index b74efb105f..d7fec78639 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -12,11 +12,13 @@ import pandas as pd from app import log from app.comp import moy_ue, moy_sem, inscr_mod -from app.comp.res_common import NotesTableCompat +from app.comp.res_compat import NotesTableCompat from app.comp.bonus_spo import BonusSport from app.models import ScoDocSiteConfig +from app.models.moduleimpls import ModuleImpl from app.models.ues import UniteEns from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc import sco_preferences class ResultatsSemestreBUT(NotesTableCompat): @@ -31,6 +33,9 @@ class ResultatsSemestreBUT(NotesTableCompat): def __init__(self, formsemestre): super().__init__(formsemestre) + self.sem_cube = None + """ndarray (etuds x modimpl x ue)""" + if not self.load_cached(): t0 = time.time() self.compute() @@ -38,7 +43,8 @@ class ResultatsSemestreBUT(NotesTableCompat): self.store() t2 = time.time() log( - f"ResultatsSemestreBUT: cached formsemestre_id={formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s)" + f"""ResultatsSemestreBUT: cached formsemestre_id={formsemestre.id + } ({(t1-t0):g}s +{(t2-t1):g}s)""" ) def compute(self): @@ -56,14 +62,11 @@ class ResultatsSemestreBUT(NotesTableCompat): # modimpl_coefs_df.columns.get_loc(modimpl.id) # idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id) - # Elimine les coefs des modimpl bonus sports: - modimpls_sport = [ - modimpl + # Masque de tous les modules _sauf_ les bonus (sport) + modimpls_mask = [ + modimpl.module.ue.type != UE_SPORT for modimpl in self.formsemestre.modimpls_sorted - if modimpl.module.ue.type == UE_SPORT ] - for modimpl in modimpls_sport: - self.modimpl_coefs_df[modimpl.id] = 0 self.etud_moy_ue = moy_ue.compute_ue_moys_apc( self.sem_cube, @@ -72,10 +75,11 @@ class ResultatsSemestreBUT(NotesTableCompat): self.ues, self.modimpl_inscr_df, self.modimpl_coefs_df, + modimpls_mask, ) # Les coefficients d'UE ne sont pas utilisés en APC self.etud_coef_ue_df = pd.DataFrame( - 1.0, index=self.etud_moy_ue.index, columns=self.etud_moy_ue.columns + 0.0, index=self.etud_moy_ue.index, columns=self.etud_moy_ue.columns ) # --- Modules de MALUS sur les UEs @@ -85,7 +89,7 @@ class ResultatsSemestreBUT(NotesTableCompat): self.etud_moy_ue -= self.malus # --- Bonus Sport & Culture - if len(modimpls_sport) > 0: + if not all(modimpls_mask): # au moins un module bonus bonus_class = ScoDocSiteConfig.get_bonus_sport_class() if bonus_class is not None: bonus: BonusSport = bonus_class( @@ -100,13 +104,23 @@ class ResultatsSemestreBUT(NotesTableCompat): self.bonus_ues = bonus.get_bonus_ues() if self.bonus_ues is not None: self.etud_moy_ue += self.bonus_ues # somme les dataframes - self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) + + # Clippe toutes les moyennes d'UE dans [0,20] + self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) # Moyenne générale indicative: # (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte # donc la moyenne indicative) - self.etud_moy_gen = moy_sem.compute_sem_moys_apc( - self.etud_moy_ue, self.modimpl_coefs_df + # self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_coefs( + # self.etud_moy_ue, self.modimpl_coefs_df + # ) + self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_ects( + self.etud_moy_ue, + [ue.ects for ue in self.ues if ue.type != UE_SPORT], + formation_id=self.formsemestre.formation_id, + skip_empty_ues=sco_preferences.get_preference( + "but_moy_skip_empty_ues", self.formsemestre.id + ), ) # --- UE capitalisées self.apply_capitalisation() @@ -134,3 +148,30 @@ class ResultatsSemestreBUT(NotesTableCompat): (ne dépend pas des modules auxquels est inscrit l'étudiant, ). """ return self.modimpl_coefs_df.loc[ue.id].sum() + + def modimpls_in_ue(self, ue_id, etudid, with_bonus=True) -> list[ModuleImpl]: + """Liste des modimpl ayant des coefs non nuls vers cette UE + et auxquels l'étudiant est inscrit. Inclus modules bonus le cas échéant. + """ + # sert pour l'affichage ou non de l'UE sur le bulletin et la table recap + coefs = self.modimpl_coefs_df # row UE, cols modimpl + modimpls = [ + modimpl + for modimpl in self.formsemestre.modimpls_sorted + if (coefs[modimpl.id][ue_id] != 0) + and self.modimpl_inscr_df[modimpl.id][etudid] + ] + if not with_bonus: + return [ + modimpl for modimpl in modimpls if modimpl.module.ue.type != UE_SPORT + ] + return modimpls + + def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray: + """Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue. + Utile pour stats bottom tableau recap. + Résultat: 1d array of float + """ + i = self.modimpl_coefs_df.columns.get_loc(modimpl_id) + j = self.modimpl_coefs_df.index.get_loc(ue_id) + return self.sem_cube[:, i, j] diff --git a/app/comp/res_cache.py b/app/comp/res_cache.py index 47c40b7e42..890526b9d9 100644 --- a/app/comp/res_cache.py +++ b/app/comp/res_cache.py @@ -11,6 +11,14 @@ from app.models import FormSemestre class ResultatsCache: + """Résultats cachés (via redis) + L'attribut _cached_attrs donne la liste des noms des attributs à cacher + (doivent être sérialisables facilement, se limiter à des types simples) + + store() enregistre les attributs dans le cache, et + load_cached() les recharge. + """ + _cached_attrs = () # virtual def __init__(self, formsemestre: FormSemestre, cache_class=None): diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 2152997261..5577546b77 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -15,8 +15,8 @@ from flask import g, url_for from app import db from app import log -from app.comp import moy_mod, moy_ue, inscr_mod -from app.comp.res_common import NotesTableCompat +from app.comp import moy_mat, moy_mod, moy_sem, moy_ue, inscr_mod +from app.comp.res_compat import NotesTableCompat from app.comp.bonus_spo import BonusSport from app.models import ScoDocSiteConfig from app.models.etudiants import Identite @@ -24,6 +24,7 @@ from app.models.formsemestre import FormSemestre from app.models.ues import UniteEns from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc import sco_preferences from app.scodoc.sco_utils import ModuleType @@ -34,10 +35,13 @@ class ResultatsSemestreClassic(NotesTableCompat): "modimpl_coefs", "modimpl_idx", "sem_matrix", + "mod_rangs", ) def __init__(self, formsemestre): super().__init__(formsemestre) + self.sem_matrix: np.ndarray = None + "sem_matrix : 2d-array (etuds x modimpls)" if not self.load_cached(): t0 = time.time() @@ -46,7 +50,8 @@ class ResultatsSemestreClassic(NotesTableCompat): self.store() t2 = time.time() log( - f"ResultatsSemestreClassic: cached formsemestre_id={formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s)" + f"""ResultatsSemestreClassic: cached formsemestre_id={ + formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s)""" ) # recalculé (aussi rapide que de les cacher) self.moy_min = self.etud_moy_gen.min() @@ -60,7 +65,7 @@ class ResultatsSemestreClassic(NotesTableCompat): ) self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre) self.modimpl_coefs = np.array( - [m.module.coefficient for m in self.formsemestre.modimpls_sorted] + [m.module.coefficient or 0.0 for m in self.formsemestre.modimpls_sorted] ) self.modimpl_idx = { m.id: i for i, m in enumerate(self.formsemestre.modimpls_sorted) @@ -113,23 +118,60 @@ class ResultatsSemestreClassic(NotesTableCompat): self.etud_moy_ue += self.bonus_ues # somme les dataframes self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) bonus_mg = bonus.get_bonus_moy_gen() - if bonus_mg is not None: + if bonus_mg is None and self.bonus_ues is not None: + # pas de bonus explicite sur la moyenne générale + # on l'ajuste pour refléter les modifs d'UE, à l'aide des coefs d'UE. + bonus_mg = (self.etud_coef_ue_df * self.bonus_ues).sum( + axis=1 + ) / self.etud_coef_ue_df.sum(axis=1) self.etud_moy_gen += bonus_mg - self.etud_moy_gen.clip(lower=0.0, upper=20.0, inplace=True) - self.bonus = ( - bonus_mg # compat nt, utilisé pour l'afficher sur les bulletins - ) + elif bonus_mg is not None: + # Applique le bonus moyenne générale renvoyé + self.etud_moy_gen += bonus_mg + + # compat nt, utilisé pour l'afficher sur les bulletins: + self.bonus = bonus_mg + # --- UE capitalisées self.apply_capitalisation() + # Clippe toutes les moyennes dans [0,20] + self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) + self.etud_moy_gen.clip(lower=0.0, upper=20.0, inplace=True) + # --- Classements: self.compute_rangs() + # --- En option, moyennes par matières + if sco_preferences.get_preference("bul_show_matieres", self.formsemestre.id): + self.compute_moyennes_matieres() + + def compute_rangs(self): + """Calcul des rangs (classements) dans le semestre (moy. gen.), les UE + et les modules. + """ + # rangs moy gen et UEs sont calculées par la méthode commune à toutes les formations: + super().compute_rangs() + # les rangs des modules n'existent que dans les formations classiques: + self.mod_rangs = {} + for modimpl_result in self.modimpls_results.values(): + # ne prend que les rangs sous forme de chaines: + rangs = moy_sem.comp_ranks_series(modimpl_result.etuds_moy_module)[0] + self.mod_rangs[modimpl_result.moduleimpl_id] = ( + rangs, + modimpl_result.nb_inscrits_module, + ) + def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: """La moyenne de l'étudiant dans le moduleimpl Result: valeur float (peut être NaN) ou chaîne "NI" (non inscrit ou DEM) """ - return self.modimpls_results[moduleimpl_id].etuds_moy_module.get(etudid, "NI") + try: + if self.modimpl_inscr_df[moduleimpl_id][etudid]: + return self.modimpls_results[moduleimpl_id].etuds_moy_module[etudid] + except KeyError: + pass + return "NI" def get_mod_stats(self, moduleimpl_id: int) -> dict: """Stats sur les notes obtenues dans un modimpl""" @@ -150,42 +192,58 @@ class ResultatsSemestreClassic(NotesTableCompat): ), } + def modimpl_notes( + self, + modimpl_id: int, + ue_id: int = None, + ) -> np.ndarray: + """Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue. + Utile pour stats bottom tableau recap. + ue_id n'est pas utilisé ici (formations classiques) + Résultat: 1d array of float + """ + i = self.modimpl_idx[modimpl_id] + return self.sem_matrix[:, i] + + def compute_moyennes_matieres(self): + """Calcul les moyennes par matière. Doit être appelée au besoin, en fin de compute.""" + self.moyennes_matieres = moy_mat.compute_mat_moys_classic( + self.formsemestre, + self.sem_matrix, + self.ues, + self.modimpl_inscr_df, + self.modimpl_coefs, + ) + def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float: """Détermine le coefficient de l'UE pour cet étudiant. N'est utilisé que pour l'injection des UE capitalisées dans la moyenne générale. Coef = somme des coefs des modules de l'UE auxquels il est inscrit """ - c = comp_etud_sum_coef_modules_ue(self.formsemestre.id, etudid, ue["ue_id"]) - if c is not None: # inscrit à au moins un module de cette UE - return c + coef = comp_etud_sum_coef_modules_ue(self.formsemestre.id, etudid, ue["ue_id"]) + if coef is not None: # inscrit à au moins un module de cette UE + return coef # arfff: aucun moyen de déterminer le coefficient de façon sûre log( - "* oups: calcul coef UE impossible\nformsemestre_id='%s'\netudid='%s'\nue=%s" - % (self.formsemestre.id, etudid, ue) + f"""* oups: calcul coef UE impossible\nformsemestre_id='{self.formsemestre.id + }'\netudid='{etudid}'\nue={ue}""" ) etud: Identite = Identite.query.get(etudid) raise ScoValueError( - """

Coefficient de l'UE capitalisée %s impossible à déterminer - pour l'étudiant %s

-

Il faut saisir le coefficient de cette UE avant de continuer

+ f"""

Coefficient de l'UE capitalisée {ue.acronyme} + impossible à déterminer pour l'étudiant {etud.nom_disp()}

+

Il faut saisir le coefficient de cette UE avant de continuer

""" - % ( - ue.acronyme, - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - etud.nom_disp(), - url_for( - "notes.formsemestre_edit_uecoefs", - scodoc_dept=g.scodoc_dept, - formsemestre_id=self.formsemestre.id, - err_ue_id=ue["ue_id"], - ), - ) ) - return 0.0 # ? - def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple[np.ndarray, dict]: """Calcule la matrice des notes du semestre @@ -215,7 +273,7 @@ def notes_sem_assemble_matrix(modimpls_notes: list[pd.Series]) -> np.ndarray: (Series rendus par compute_module_moy, index: etud) Resultat: ndarray (etud x module) """ - if not len(modimpls_notes): + if not modimpls_notes: return np.zeros((0, 0), dtype=float) modimpls_notes_arr = [s.values for s in modimpls_notes] modimpls_notes = np.stack(modimpls_notes_arr) diff --git a/app/comp/res_common.py b/app/comp/res_common.py index b53c942fc7..1f19fbfd91 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -4,23 +4,29 @@ # See LICENSE ############################################################################## -from collections import defaultdict, Counter +"""Résultats semestre: méthodes communes aux formations classiques et APC +""" + +from collections import Counter from functools import cached_property import numpy as np import pandas as pd -from app.comp.aux_stats import StatsMoyenne -from app.comp import moy_sem +from flask import g, url_for + from app.comp.res_cache import ResultatsCache from app.comp import res_sem from app.comp.moy_mod import ModuleImplResults -from app.models import FormSemestre, Identite, ModuleImpl -from app.models import FormSemestreUECoef +from app.models import FormSemestre, FormSemestreUECoef +from app.models import Identite +from app.models import ModuleImpl, ModuleImplInscription from app.models.ues import UniteEns -from app.scodoc import sco_utils as scu from app.scodoc.sco_cache import ResultatsSemestreCache -from app.scodoc.sco_codes_parcours import UE_SPORT, ATT, DEF +from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc import sco_groups +from app.scodoc import sco_users +from app.scodoc import sco_utils as scu # Il faut bien distinguer # - ce qui est caché de façon persistente (via redis): @@ -32,13 +38,20 @@ from app.scodoc.sco_exceptions import ScoValueError # qui sont notamment les attributs décorés par `@cached_property`` # class ResultatsSemestre(ResultatsCache): + """Les résultats (notes, ...) d'un formsemestre + Classe commune à toutes les formations (classiques, BUT) + """ + _cached_attrs = ( + "bonus", + "bonus_ues", "etud_moy_gen_ranks", "etud_moy_gen", "etud_moy_ue", "modimpl_inscr_df", "modimpls_results", "etud_coef_ue_df", + "moyennes_matieres", ) def __init__(self, formsemestre: FormSemestre): @@ -46,16 +59,31 @@ class ResultatsSemestre(ResultatsCache): # BUT ou standard ? (apc == "approche par compétences") self.is_apc = formsemestre.formation.is_apc() # Attributs "virtuels", définis dans les sous-classes + self.bonus: pd.Series = None # virtuel + "Bonus sur moy. gen. Series de float, index etudid" + self.bonus_ues: pd.DataFrame = None # virtuel + "DataFrame de float, index etudid, columns: ue.id" # ResultatsSemestreBUT ou ResultatsSemestreClassic self.etud_moy_ue = {} "etud_moy_ue: DataFrame columns UE, rows etudid" - self.etud_moy_gen = {} + self.etud_moy_gen: pd.Series = None self.etud_moy_gen_ranks = {} + self.etud_moy_gen_ranks_int = {} + self.modimpl_inscr_df: pd.DataFrame = None + "Inscriptions: row etudid, col modimlpl_id" self.modimpls_results: ModuleImplResults = None "Résultats de chaque modimpl: dict { modimpl.id : ModuleImplResults(Classique ou BUT) }" self.etud_coef_ue_df = None """coefs d'UE effectifs pour chaque étudiant (pour form. classiques)""" + self.modimpl_coefs_df: pd.DataFrame = None + """Coefs APC: rows = UEs (sans bonus), columns = modimpl, value = coef.""" + self.validations = None + self.moyennes_matieres = {} + """Moyennes de matières, si calculées. { matiere_id : Series, index etudid }""" + + def __repr__(self): + return f"<{self.__class__.__name__}(formsemestre='{self.formsemestre}')>" def compute(self): "Charge les notes et inscriptions et calcule toutes les moyennes" @@ -82,6 +110,14 @@ class ResultatsSemestre(ResultatsCache): "dict { etudid : indice dans les inscrits }" return {e.id: idx for idx, e in enumerate(self.etuds)} + def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray: + """Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue. + Utile pour stats bottom tableau recap. + Résultat: 1d array of float + """ + # différent en BUT et classique: virtuelle + raise NotImplementedError + @cached_property def etuds_dict(self) -> dict[int, Identite]: """dict { etudid : Identite } inscrits au semestre, @@ -122,7 +158,7 @@ class ResultatsSemestre(ResultatsCache): - En BUT: on considère que l'étudiant va (ou non) valider toutes les UEs des modules du parcours. XXX notion à implémenter, pour l'instant toutes les UE du semestre. - - En classique: toutes les UEs des modimpls auxquels l'étufdiant est inscrit sont + - En classique: toutes les UEs des modimpls auxquels l'étudiant est inscrit sont susceptibles d'être validées. Les UE "bonus" (sport) ne sont jamais "validables". @@ -140,15 +176,24 @@ class ResultatsSemestre(ResultatsCache): ues = sorted(list(ues), key=lambda x: x.numero or 0) return ues - def modimpls_in_ue(self, ue_id, etudid) -> list[ModuleImpl]: - """Liste des modimpl de cette UE auxquels l'étudiant est inscrit""" + def modimpls_in_ue(self, ue_id, etudid, with_bonus=True) -> list[ModuleImpl]: + """Liste des modimpl de cette UE auxquels l'étudiant est inscrit. + Utile en formations classiques, surchargée pour le BUT. + Inclus modules bonus le cas échéant. + """ # sert pour l'affichage ou non de l'UE sur le bulletin - return [ + # Méthode surchargée en BUT + modimpls = [ modimpl for modimpl in self.formsemestre.modimpls_sorted if modimpl.module.ue.id == ue_id and self.modimpl_inscr_df[modimpl.id][etudid] ] + if not with_bonus: + return [ + modimpl for modimpl in modimpls if modimpl.module.ue.type != UE_SPORT + ] + return modimpls @cached_property def ue_au_dessus(self, seuil=10.0) -> pd.DataFrame: @@ -164,11 +209,9 @@ class ResultatsSemestre(ResultatsCache): """ # Supposant qu'il y a peu d'UE capitalisées, # on va soustraire la moyenne d'UE et ajouter celle de l'UE capitalisée. - # return # XXX XXX XXX if not self.validations: self.validations = res_sem.load_formsemestre_validations(self.formsemestre) ue_capitalisees = self.validations.ue_capitalisees - ue_by_code = {} for etudid in ue_capitalisees.index: recompute_mg = False # ue_codes = set(ue_capitalisees.loc[etudid]["ue_code"]) @@ -183,10 +226,12 @@ class ResultatsSemestre(ResultatsCache): sum_coefs_ue = 0.0 for ue in self.formsemestre.query_ues(): ue_cap = self.get_etud_ue_status(etudid, ue.id) + if ue_cap is None: + continue if ue_cap["is_capitalized"]: recompute_mg = True coef = ue_cap["coef_ue"] - if not np.isnan(ue_cap["moy"]): + if not np.isnan(ue_cap["moy"]) and coef: sum_notes_ue += ue_cap["moy"] * coef sum_coefs_ue += coef @@ -194,13 +239,32 @@ class ResultatsSemestre(ResultatsCache): # On doit prendre en compte une ou plusieurs UE capitalisées # et donc recalculer la moyenne générale self.etud_moy_gen[etudid] = sum_notes_ue / sum_coefs_ue + # Ajoute le bonus sport + if self.bonus is not None and self.bonus[etudid]: + self.etud_moy_gen[etudid] += self.bonus[etudid] + self.etud_moy_gen[etudid] = max( + 0.0, min(self.etud_moy_gen[etudid], 20.0) + ) - def _get_etud_ue_cap(self, etudid, ue): - """""" + def get_etud_etat(self, etudid: int) -> str: + "Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)" + ins = self.formsemestre.etuds_inscriptions.get(etudid, None) + if ins is None: + return "" + return ins.etat + + def _get_etud_ue_cap(self, etudid: int, ue: UniteEns) -> dict: + """Donne les informations sur la capitalisation de l'UE ue pour cet étudiant. + Résultat: + Si pas capitalisée: None + Si capitalisée: un dict, avec les colonnes de validation. + """ capitalisations = self.validations.ue_capitalisees.loc[etudid] if isinstance(capitalisations, pd.DataFrame): ue_cap = capitalisations[capitalisations["ue_code"] == ue.ue_code] - if isinstance(ue_cap, pd.DataFrame) and not ue_cap.empty: + if ue_cap.empty: + return None + if isinstance(ue_cap, pd.DataFrame): # si plusieurs fois capitalisée, prend le max cap_idx = ue_cap["moy_ue"].values.argmax() ue_cap = ue_cap.iloc[cap_idx] @@ -208,21 +272,19 @@ class ResultatsSemestre(ResultatsCache): if capitalisations["ue_code"] == ue.ue_code: ue_cap = capitalisations else: - ue_cap = None - return ue_cap + return None + # converti la Series en dict, afin que les np.int64 reviennent en int + return ue_cap.to_dict() def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict: """L'état de l'UE pour cet étudiant. Result: dict, ou None si l'UE n'est pas dans ce semestre. """ - if not ue_id in self.etud_moy_ue: - return None - if not self.validations: - self.validations = res_sem.load_formsemestre_validations(self.formsemestre) ue = UniteEns.query.get(ue_id) # TODO cacher nos UEs ? if ue.type == UE_SPORT: return { "is_capitalized": False, + "was_capitalized": False, "is_external": False, "coef_ue": 0.0, "cur_moy_ue": 0.0, @@ -233,24 +295,56 @@ class ResultatsSemestre(ResultatsCache): "capitalized_ue_id": None, "ects_pot": 0.0, } + if not ue_id in self.etud_moy_ue: + return None + if not self.validations: + self.validations = res_sem.load_formsemestre_validations(self.formsemestre) cur_moy_ue = self.etud_moy_ue[ue_id][etudid] moy_ue = cur_moy_ue - is_capitalized = False + is_capitalized = False # si l'UE prise en compte est une UE capitalisée + # s'il y a precedemment une UE capitalisée (pas forcement meilleure): + was_capitalized = False if etudid in self.validations.ue_capitalisees.index: ue_cap = self._get_etud_ue_cap(etudid, ue) - if ( - ue_cap is not None - and not ue_cap.empty - and not np.isnan(ue_cap["moy_ue"]) - ): + if ue_cap and not np.isnan(ue_cap["moy_ue"]): + was_capitalized = True if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue): moy_ue = ue_cap["moy_ue"] is_capitalized = True - coef_ue = self.etud_coef_ue_df[ue_id][etudid] + # Coef l'UE dans le semestre courant: + if self.is_apc: + # utilise les ECTS comme coef. + coef_ue = ue.ects + else: + # formations classiques + coef_ue = self.etud_coef_ue_df[ue_id][etudid] + if (not coef_ue) and is_capitalized: # étudiant non inscrit dans l'UE courante + if self.is_apc: + # Coefs de l'UE capitalisée en formation APC: donné par ses ECTS + ue_capitalized = UniteEns.query.get(ue_cap["ue_id"]) + coef_ue = ue_capitalized.ects + if coef_ue is None: + orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"]) + raise ScoValueError( + f"""L'UE capitalisée {ue_capitalized.acronyme} + du semestre {orig_sem.titre_annee()} + n'a pas d'indication d'ECTS. + Corrigez ou faite corriger le programme + via cette page. + """ + ) + else: + # Coefs de l'UE capitalisée en formation classique: + # va chercher le coef dans le semestre d'origine + coef_ue = ModuleImplInscription.sum_coefs_modimpl_ue( + ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"] + ) return { "is_capitalized": is_capitalized, + "was_capitalized": was_capitalized, "is_external": ue_cap["is_external"] if is_capitalized else ue.is_external, "coef_ue": coef_ue, "ects_pot": ue.ects or 0.0, @@ -262,6 +356,11 @@ class ResultatsSemestre(ResultatsCache): "capitalized_ue_id": ue_cap["ue_id"] if is_capitalized else None, } + def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float: + "Détermine le coefficient de l'UE pour cet étudiant." + # calcul différent en classqiue et BUT + raise NotImplementedError() + def get_etud_ue_cap_coef(self, etudid, ue, ue_cap): """Calcule le coefficient d'une UE capitalisée, pour cet étudiant, injectée dans le semestre courant. @@ -286,377 +385,421 @@ class ResultatsSemestre(ResultatsCache): # somme des coefs des modules de l'UE auxquels il est inscrit return self.compute_etud_ue_coef(etudid, ue) + # --- TABLEAU RECAP -# Pour raccorder le code des anciens codes qui attendent une NoteTable -class NotesTableCompat(ResultatsSemestre): - """Implementation partielle de NotesTable WIP TODO + def get_table_recap(self, convert_values=False): + """Result: tuple avec + - rows: liste de dicts { column_id : value } + - titles: { column_id : title } + - columns_ids: (liste des id de colonnes) - Les méthodes définies dans cette classe sont là - pour conserver la compatibilité abvec les codes anciens et - il n'est pas recommandé de les utiliser dans de nouveaux - développements (API malcommode et peu efficace). - """ + . Si convert_values, transforme les notes en chaines ("12.34"). + Les colonnes générées sont: + etudid + rang : rang indicatif (basé sur moy gen) + moy_gen : moy gen indicative + moy_ue_, ..., les moyennes d'UE + moy_res__, ... les moyennes de ressources dans l'UE + moy_sae__, ... les moyennes de SAE dans l'UE - _cached_attrs = ResultatsSemestre._cached_attrs + ( - "bonus", - "bonus_ues", - "malus", - "etud_moy_gen_ranks", - "ue_rangs", - ) - - def __init__(self, formsemestre: FormSemestre): - super().__init__(formsemestre) - - nb_etuds = len(self.etuds) - self.bonus = None # virtuel - self.bonus_ues = None # virtuel - self.ue_rangs = {u.id: (None, nb_etuds) for u in self.ues} - self.mod_rangs = None # sera surchargé en Classic, mais pas en APC - self.moy_min = "NA" - self.moy_max = "NA" - self.moy_moy = "NA" - self.expr_diagnostics = "" - self.parcours = self.formsemestre.formation.get_parcours() - - def get_etudids(self, sorted=False) -> list[int]: - """Liste des etudids inscrits, incluant les démissionnaires. - Si sorted, triée par moy. générale décroissante - Sinon, triée par ordre alphabetique de NOM + On ajoute aussi des attributs: + - pour les lignes: + _css_row_class (inutilisé pour le monent) + __class classe css: + - la moyenne générale a la classe col_moy_gen + - les colonnes SAE ont la classe col_sae + - les colonnes Resources ont la classe col_res + - les colonnes d'UE ont la classe col_ue + - les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_ + __order : clé de tri """ - # Note: pour avoir les inscrits non triés, - # utiliser [ ins.etudid for ins in self.formsemestre.inscriptions ] - if sorted: - # Tri par moy. generale décroissante - return [x[-1] for x in self.T] - return [x["etudid"] for x in self.inscrlist] - - @cached_property - def sem(self) -> dict: - """le formsemestre, comme un gros et gras dict (nt.sem)""" - return self.formsemestre.get_infos_dict() - - @cached_property - def inscrlist(self) -> list[dict]: # utilisé par PE seulement - """Liste des inscrits au semestre (avec DEM et DEF), - sous forme de dict etud, - classée dans l'ordre alphabétique de noms. - """ - etuds = self.formsemestre.get_inscrits(include_demdef=True) - etuds.sort(key=lambda e: e.sort_key) - return [e.to_dict_scodoc7() for e in etuds] - - @cached_property - def stats_moy_gen(self): - """Stats (moy/min/max) sur la moyenne générale""" - return StatsMoyenne(self.etud_moy_gen) - - def get_ues_stat_dict(self, filter_sport=False): # was get_ues() - """Liste des UEs, ordonnée par numero. - Si filter_sport, retire les UE de type SPORT. - Résultat: liste de dicts { champs UE U stats moyenne UE } - """ - ues = [] - for ue in self.formsemestre.query_ues(with_sport=not filter_sport): - d = ue.to_dict() - if ue.type != UE_SPORT: - moys = self.etud_moy_ue[ue.id] - else: - moys = None - d.update(StatsMoyenne(moys).to_dict()) - ues.append(d) - return ues - - def get_modimpls_dict(self, ue_id=None) -> list[dict]: - """Liste des modules pour une UE (ou toutes si ue_id==None), - triés par numéros (selon le type de formation) - """ - modimpls_dict = [] - for modimpl in self.formsemestre.modimpls_sorted: - if ue_id == None or modimpl.module.ue.id == ue_id: - d = modimpl.to_dict() - # compat ScoDoc < 9.2: ajoute matières - d["mat"] = modimpl.module.matiere.to_dict() - modimpls_dict.append(d) - return modimpls_dict - - def compute_rangs(self): - """Calcule les classements - Moyenne générale: etud_moy_gen_ranks - Par UE (sauf ue bonus) - """ - self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen) - for ue in self.formsemestre.query_ues(): - moy_ue = self.etud_moy_ue[ue.id] - self.ue_rangs[ue.id] = ( - moy_sem.comp_ranks_series(moy_ue), - int(moy_ue.count()), - ) - # .count() -> nb of non NaN values - - def get_etud_ue_rang(self, ue_id, etudid) -> tuple[str, int]: - """Le rang de l'étudiant dans cette ue - Result: rang:str, effectif:str - """ - rangs, effectif = self.ue_rangs[ue_id] - if rangs is not None: - rang = rangs[etudid] + if convert_values: + fmt_note = scu.fmt_note else: - return "", "" - return rang, effectif + fmt_note = lambda x: x - def etud_check_conditions_ues(self, etudid): - """Vrai si les conditions sur les UE sont remplies. - Ne considère que les UE ayant des notes (moyenne calculée). - (les UE sans notes ne sont pas comptées comme sous la barre) - Prend en compte les éventuelles UE capitalisées. + parcours = self.formsemestre.formation.get_parcours() + barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE + barre_valid_ue = parcours.NOTES_BARRE_VALID_UE + barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING + NO_NOTE = "-" # contenu des cellules sans notes + rows = [] + # column_id : title + titles = {} + # les titres en footer: les mêmes, mais avec des bulles et liens: + titles_bot = {} - Pour les parcours habituels, cela revient à vérifier que - les moyennes d'UE sont toutes > à leur barre (sauf celles sans notes) + def add_cell( + row: dict, + col_id: str, + title: str, + content: str, + classes: str = "", + idx: int = 100, + ): + "Add a row to our table. classes is a list of css class names" + row[col_id] = content + if classes: + row[f"_{col_id}_class"] = classes + f" c{idx}" + if not col_id in titles: + titles[col_id] = title + titles[f"_{col_id}_col_order"] = idx + if classes: + titles[f"_{col_id}_class"] = classes + return idx + 1 - Pour les parcours non standards (LP2014), cela peut être plus compliqué. - - Return: True|False, message explicatif - """ - return self.parcours.check_barre_ues( - [ - self.get_etud_ue_status(etudid, ue.id) - for ue in self.formsemestre.query_ues() - ] - ) - - def get_etud_decision_ues(self, etudid: int) -> dict: - """Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu. - Ne tient pas compte des UE capitalisées. - { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' : } - Ne renvoie aucune decision d'UE pour les défaillants - """ - if self.get_etud_etat(etudid) == DEF: - return {} - else: - if not self.validations: - self.validations = res_sem.load_formsemestre_validations( - self.formsemestre - ) - return self.validations.decisions_jury_ues.get(etudid, None) - - def get_etud_decision_sem(self, etudid: int) -> dict: - """Decision du jury prise pour cet etudiant, ou None s'il n'y en pas eu. - { 'code' : None|ATT|..., 'assidu' : 0|1, 'event_date' : , compense_formsemestre_id } - Si état défaillant, force le code a DEF - """ - if self.get_etud_etat(etudid) == DEF: - return { - "code": DEF, - "assidu": False, - "event_date": "", - "compense_formsemestre_id": None, - } - else: - if not self.validations: - self.validations = res_sem.load_formsemestre_validations( - self.formsemestre - ) - return self.validations.decisions_jury.get(etudid, None) - - def get_etud_etat(self, etudid: int) -> str: - "Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)" - ins = self.formsemestre.etuds_inscriptions.get(etudid, None) - if ins is None: - return "" - return ins.etat - - def get_etud_mat_moy(self, matiere_id, etudid): - """moyenne d'un étudiant dans une matière (ou NA si pas de notes)""" - # non supporté en 9.2 - return "na" - - def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: - """La moyenne de l'étudiant dans le moduleimpl - En APC, il s'agira d'une moyenne indicative sans valeur. - Result: valeur float (peut être naN) ou chaîne "NI" (non inscrit ou DEM) - """ - raise NotImplementedError() # virtual method - - def get_etud_moy_gen(self, etudid): # -> float | str - """Moyenne générale de cet etudiant dans ce semestre. - Prend en compte les UE capitalisées. - Si apc, moyenne indicative. - Si pas de notes: 'NA' - """ - return self.etud_moy_gen[etudid] - - def get_etud_ects_pot(self, etudid: int) -> dict: - """ - Un dict avec les champs - ects_pot : (float) nb de crédits ECTS qui seraient validés (sous réserve de validation par le jury), - ects_pot_fond: (float) nb d'ECTS issus d'UE fondamentales (non électives) - - Ce sont les ECTS des UE au dessus de la barre (10/20 en principe), avant le jury (donc non - encore enregistrées). - """ - # was nt.get_etud_moy_infos - # XXX pour compat nt, à remplacer ultérieurement - ues = self.get_etud_ue_validables(etudid) - ects_pot = 0.0 - for ue in ues: - if ( - ue.id in self.etud_moy_ue - and ue.ects is not None - and self.etud_moy_ue[ue.id][etudid] > self.parcours.NOTES_BARRE_VALID_UE - ): - ects_pot += ue.ects - return { - "ects_pot": ects_pot, - "ects_pot_fond": 0.0, # not implemented (anciennemment pour école ingé) - } - - def get_etud_rang(self, etudid: int): - return self.etud_moy_gen_ranks.get(etudid, 99999) # XXX - - def get_etud_rang_group(self, etudid: int, group_id: int): - return (None, 0) # XXX unimplemented TODO - - def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]: - """Liste d'informations (compat NotesTable) sur évaluations completes - de ce module. - Évaluation "complete" ssi toutes notes saisies ou en attente. - """ - modimpl = ModuleImpl.query.get(moduleimpl_id) - evals_results = [] - for e in modimpl.evaluations: - if self.modimpls_results[moduleimpl_id].evaluations_completes_dict[e.id]: - d = e.to_dict() - moduleimpl_results = self.modimpls_results[e.moduleimpl_id] - d["heure_debut"] = e.heure_debut # datetime.time - d["heure_fin"] = e.heure_fin - d["jour"] = e.jour # datetime - d["notes"] = { - etud.id: { - "etudid": etud.id, - "value": moduleimpl_results.evals_notes[e.id][etud.id], - } - for etud in self.etuds - } - d["etat"] = { - "evalattente": moduleimpl_results.evaluations_etat[e.id].nb_attente, - } - evals_results.append(d) - return evals_results - - def get_evaluations_etats(self): - """[ {...evaluation et son etat...} ]""" - # TODO: à moderniser - from app.scodoc import sco_evaluations - - if not hasattr(self, "_evaluations_etats"): - self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem( - self.formsemestre.id - ) - - return self._evaluations_etats - - def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]: - """Liste des états des évaluations de ce module""" - # XXX TODO à moderniser: lent, recharge des donénes que l'on a déjà... - return [ - e - for e in self.get_evaluations_etats() - if e["moduleimpl_id"] == moduleimpl_id - ] - - def get_moduleimpls_attente(self): - """Liste des modimpls du semestre ayant des notes en attente""" - return [ - modimpl - for modimpl in self.formsemestre.modimpls_sorted - if self.modimpls_results[modimpl.id].en_attente - ] - - def get_mod_stats(self, moduleimpl_id: int) -> dict: - """Stats sur les notes obtenues dans un modimpl - Vide en APC - """ - return { - "moy": "-", - "max": "-", - "min": "-", - "nb_notes": "-", - "nb_missing": "-", - "nb_valid_evals": "-", - } - - def get_nom_short(self, etudid): - "formatte nom d'un etud (pour table recap)" - etud = self.identdict[etudid] - return ( - (etud["nom_usuel"] or etud["nom"]).upper() - + " " - + etud["prenom"].capitalize()[:2] - + "." - ) - - @cached_property - def T(self): - return self.get_table_moyennes_triees() - - def get_table_moyennes_triees(self) -> list: - """Result: liste de tuples - moy_gen, moy_ue_0, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid - """ - table_moyennes = [] etuds_inscriptions = self.formsemestre.etuds_inscriptions ues = self.formsemestre.query_ues(with_sport=True) # avec bonus + ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT] + modimpl_ids = set() # modimpl effectivement présents dans la table for etudid in etuds_inscriptions: + idx = 0 # index de la colonne + etud = Identite.query.get(etudid) + row = {"etudid": etudid} + # --- Rang + idx = add_cell( + row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx + ) + row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}" + # --- Identité étudiant + idx = add_cell( + row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx + ) + idx = add_cell( + row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail", idx + ) + row["_nom_disp_order"] = etud.sort_key + idx = add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail", idx) + idx = add_cell( + row, "nom_short", "Nom", etud.nom_short, "identite_court", idx + ) + row["_nom_short_order"] = etud.sort_key + row["_nom_short_target"] = url_for( + "notes.formsemestre_bulletinetud", + scodoc_dept=g.scodoc_dept, + formsemestre_id=self.formsemestre.id, + etudid=etudid, + ) + row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"' + row["_nom_disp_target"] = row["_nom_short_target"] + row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"] + + idx = 30 # début des colonnes de notes + # --- Moyenne générale moy_gen = self.etud_moy_gen.get(etudid, False) + note_class = "" if moy_gen is False: - # pas de moyenne: démissionnaire ou def - t = ( - ["-"] - + ["0.00"] * len(self.ues) - + ["NI"] * len(self.formsemestre.modimpls_sorted) - ) - else: - moy_ues = [] - ue_is_cap = {} - for ue in ues: - ue_status = self.get_etud_ue_status(etudid, ue.id) - moy_ues.append(ue_status["moy"]) - ue_is_cap[ue.id] = ue_status["is_capitalized"] - t = [moy_gen] + list(moy_ues) - # Moyennes modules: - for modimpl in self.formsemestre.modimpls_sorted: - if ue_is_cap.get(modimpl.module.ue.id, False): - val = "-c-" + moy_gen = NO_NOTE + elif isinstance(moy_gen, float) and moy_gen < barre_moy: + note_class = " moy_ue_warning" # en rouge + idx = add_cell( + row, + "moy_gen", + "Moy", + fmt_note(moy_gen), + "col_moy_gen" + note_class, + idx, + ) + titles_bot["_moy_gen_target_attrs"] = ( + 'title="moyenne indicative"' if self.is_apc else "" + ) + # --- Moyenne d'UE + nb_ues_validables, nb_ues_warning = 0, 0 + for ue in ues_sans_bonus: + ue_status = self.get_etud_ue_status(etudid, ue.id) + if ue_status is not None: + col_id = f"moy_ue_{ue.id}" + val = ue_status["moy"] + note_class = "" + if isinstance(val, float): + if val < barre_moy: + note_class = " moy_inf" + elif val >= barre_valid_ue: + note_class = " moy_ue_valid" + nb_ues_validables += 1 + if val < barre_warning_ue: + note_class = " moy_ue_warning" # notes très basses + nb_ues_warning += 1 + idx = add_cell( + row, + col_id, + ue.acronyme, + fmt_note(val), + "col_ue" + note_class, + idx, + ) + titles_bot[ + f"_{col_id}_target_attrs" + ] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """ + # Bonus (sport) dans cette UE ? + # Le bonus sport appliqué sur cette UE + if (self.bonus_ues is not None) and (ue.id in self.bonus_ues): + val = self.bonus_ues[ue.id][etud.id] or "" + val_fmt = fmt_note(val) + if val: + val_fmt = f'{val_fmt}' + idx = add_cell( + row, + f"bonus_ue_{ue.id}", + f"Bonus {ue.acronyme}", + val_fmt, + "col_ue_bonus", + idx, + ) + # Les moyennes des modules (ou ressources et SAÉs) dans cette UE + for modimpl in self.modimpls_in_ue(ue.id, etudid, with_bonus=False): + if ue_status["is_capitalized"]: + val = "-c-" + else: + modimpl_results = self.modimpls_results.get(modimpl.id) + if modimpl_results: # pas bonus + if self.is_apc: # BUT + moys_vers_ue = modimpl_results.etuds_moy_module.get( + ue.id + ) + val = ( + moys_vers_ue.get(etudid, "?") + if moys_vers_ue is not None + else "" + ) + else: # classique: Series indépendante de l'UE + val = modimpl_results.etuds_moy_module.get( + etudid, "?" + ) + else: + val = "" + + col_id = ( + f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" + ) + val_fmt = fmt_note(val) + if modimpl.module.module_type == scu.ModuleType.MALUS: + val_fmt = ( + (scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else "" + ) + idx = add_cell( + row, + col_id, + modimpl.module.code, + val_fmt, + # class col_res mod_ue_123 + f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}", + idx, + ) + titles_bot[f"_{col_id}_target"] = url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=modimpl.id, + ) + titles_bot[ + f"_{col_id}_target_attrs" + ] = f""" + title="{modimpl.module.titre} + ({sco_users.user_info(modimpl.responsable_id)['nomcomplet']})" """ + modimpl_ids.add(modimpl.id) + ue_valid_txt = f"{nb_ues_validables}/{len(ues_sans_bonus)}" + if nb_ues_warning: + ue_valid_txt += " " + scu.EMO_WARNING + add_cell( + row, + "ues_validables", + "UEs", + ue_valid_txt, + "col_ue col_ues_validables", + 29, # juste avant moy. gen. + ) + if nb_ues_warning: + row["_ues_validables_class"] += " moy_ue_warning" + elif nb_ues_validables < len(ues_sans_bonus): + row["_ues_validables_class"] += " moy_inf" + row["_ues_validables_order"] = nb_ues_validables # pour tri + rows.append(row) + + self._recap_add_partitions(rows, titles) + self._recap_add_admissions(rows, titles) + # tri par rang croissant + rows.sort(key=lambda e: e["_rang_order"]) + + # INFOS POUR FOOTER + bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note) + + # Ajoute style "col_empty" aux colonnes de modules vides + for col_id in titles: + c_class = f"_{col_id}_class" + if "col_empty" in bottom_infos["moy"].get(c_class, ""): + for row in rows: + row[c_class] += " col_empty" + titles[c_class] += " col_empty" + for row in bottom_infos.values(): + row[c_class] = row.get(c_class, "") + " col_empty" + + # --- TABLE FOOTER: ECTS, moyennes, min, max... + footer_rows = [] + for (bottom_line, row) in bottom_infos.items(): + # Cases vides à styler: + row["moy_gen"] = row.get("moy_gen", "") + row["_moy_gen_class"] = "col_moy_gen" + # titre de la ligne: + row["prenom"] = row["nom_short"] = bottom_line.capitalize() + row["_tr_class"] = bottom_line.lower() + ( + (" " + row["_tr_class"]) if "_tr_class" in row else "" + ) + footer_rows.append(row) + titles_bot.update(titles) + footer_rows.append(titles_bot) + column_ids = [title for title in titles if not title.startswith("_")] + column_ids.sort( + key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000) + ) + return (rows, footer_rows, titles, column_ids) + + def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict: + """Les informations à mettre en bas de la table: min, max, moy, ECTS""" + row_min, row_max, row_moy, row_coef, row_ects = ( + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info"}, + ) + # --- ECTS + for ue in ues: + row_ects[f"moy_ue_{ue.id}"] = ue.ects + row_ects[f"_moy_ue_{ue.id}_class"] = "col_ue" + # style cases vides pour borders verticales + row_coef[f"moy_ue_{ue.id}"] = "" + row_coef[f"_moy_ue_{ue.id}_class"] = "col_ue" + row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]) + row_ects["_moy_gen_class"] = "col_moy_gen" + + # --- MIN, MAX, MOY + + row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min()) + row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max()) + row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean()) + for ue in ues: + col_id = f"moy_ue_{ue.id}" + row_min[col_id] = fmt_note(self.etud_moy_ue[ue.id].min()) + row_max[col_id] = fmt_note(self.etud_moy_ue[ue.id].max()) + row_moy[col_id] = fmt_note(self.etud_moy_ue[ue.id].mean()) + row_min[f"_{col_id}_class"] = "col_ue" + row_max[f"_{col_id}_class"] = "col_ue" + row_moy[f"_{col_id}_class"] = "col_ue" + + for modimpl in self.formsemestre.modimpls_sorted: + if modimpl.id in modimpl_ids: + col_id = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" + if self.is_apc: + coef = self.modimpl_coefs_df[modimpl.id][ue.id] else: - val = self.get_etud_mod_moy(modimpl.id, etudid) - t.append(val) - t.append(etudid) - table_moyennes.append(t) - # tri par moyennes décroissantes, - # en laissant les démissionnaires à la fin, par ordre alphabetique - etuds = [ins.etud for ins in etuds_inscriptions.values()] - etuds.sort(key=lambda e: e.sort_key) - self._rang_alpha = {e.id: i for i, e in enumerate(etuds)} - table_moyennes.sort(key=self._row_key) - return table_moyennes + coef = modimpl.module.coefficient or 0 + row_coef[col_id] = fmt_note(coef) + notes = self.modimpl_notes(modimpl.id, ue.id) + row_min[col_id] = fmt_note(np.nanmin(notes)) + row_max[col_id] = fmt_note(np.nanmax(notes)) + moy = np.nanmean(notes) + row_moy[col_id] = fmt_note(moy) + if np.isnan(moy): + # aucune note dans ce module + row_moy[f"_{col_id}_class"] = "col_empty" - def _row_key(self, x): - """clé de tri par moyennes décroissantes, - en laissant les demissionnaires à la fin, par ordre alphabetique. - (moy_gen, rang_alpha) - """ - try: - moy = -float(x[0]) - except (ValueError, TypeError): - moy = 1000.0 - return (moy, self._rang_alpha[x[-1]]) - - @cached_property - def identdict(self) -> dict: - """{ etudid : etud_dict } pour tous les inscrits au semestre""" - return { - ins.etud.id: ins.etud.to_dict_scodoc7() - for ins in self.formsemestre.inscriptions + return { # { key : row } avec key = min, max, moy, coef + "min": row_min, + "max": row_max, + "moy": row_moy, + "coef": row_coef, + "ects": row_ects, } + + def _recap_etud_groups_infos(self, etudid: int, row: dict, titles: dict): + """Table recap: ajoute à row les colonnes sur les groupes pour cet etud""" + # dec = self.get_etud_decision_sem(etudid) + # if dec: + # codes_nb[dec["code"]] += 1 + row_class = "" + etud_etat = self.get_etud_etat(etudid) + if etud_etat == DEM: + gr_name = "Dém." + row_class = "dem" + elif etud_etat == DEF: + gr_name = "Déf." + row_class = "def" + else: + # XXX probablement à revoir pour utiliser données cachées, + # via get_etud_groups_in_partition ou autre + group = sco_groups.get_etud_main_group(etudid, self.formsemestre.id) + gr_name = group["group_name"] or "" + row["group"] = gr_name + row["_group_class"] = "group" + if row_class: + row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class]) + titles["group"] = "Gr" + + def _recap_add_admissions(self, rows: list[dict], titles: dict): + """Ajoute les colonnes "admission" + rows est une liste de dict avec une clé "etudid" + Les colonnes ont la classe css "admission" + """ + fields = { + "bac": "Bac", + "specialite": "Spécialité", + "type_admission": "Type Adm.", + "classement": "Rg. Adm.", + } + first = True + for i, cid in enumerate(fields): + titles[f"_{cid}_col_order"] = 10000 + i # tout à droite + if first: + titles[f"_{cid}_class"] = "admission admission_first" + first = False + else: + titles[f"_{cid}_class"] = "admission" + titles.update(fields) + for row in rows: + etud = Identite.query.get(row["etudid"]) + admission = etud.admission.first() + first = True + for cid in fields: + row[cid] = getattr(admission, cid) or "" + if first: + row[f"_{cid}_class"] = "admission admission_first" + first = False + else: + row[f"_{cid}_class"] = "admission" + + def _recap_add_partitions(self, rows: list[dict], titles: dict): + """Ajoute les colonnes indiquant les groupes + rows est une liste de dict avec une clé "etudid" + Les colonnes ont la classe css "partition" + """ + partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups( + self.formsemestre.id + ) + first_partition = True + for partition in partitions: + cid = f"part_{partition['partition_id']}" + titles[cid] = partition["partition_name"] + if first_partition: + klass = "partition" + else: + klass = "partition partition_aux" + titles[f"_{cid}_class"] = klass + titles[f"_{cid}_col_order"] = 10 + partition_etud_groups = partitions_etud_groups[partition["partition_id"]] + for row in rows: + # dans NotesTableCompat, à revoir + etud_etat = self.get_etud_etat(row["etudid"]) + if etud_etat == "D": + gr_name = "Dém." + row["_tr_class"] = "dem" + elif etud_etat == DEF: + gr_name = "Déf." + row["_tr_class"] = "def" + else: + group = partition_etud_groups.get(row["etudid"]) + gr_name = group["group_name"] if group else "" + if gr_name: + row[f"{cid}"] = gr_name + row[f"_{cid}_class"] = klass + first_partition = False diff --git a/app/comp/res_compat.py b/app/comp/res_compat.py new file mode 100644 index 0000000000..8bbed09041 --- /dev/null +++ b/app/comp/res_compat.py @@ -0,0 +1,462 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Classe résultats pour compatibilité avec le code ScoDoc 7 +""" +from functools import cached_property + +from flask import flash, g, Markup, url_for + +from app import log +from app.comp import moy_sem +from app.comp.aux_stats import StatsMoyenne +from app.comp.res_common import ResultatsSemestre +from app.comp import res_sem +from app.models import FormSemestre +from app.models import Identite +from app.models import ModuleImpl +from app.scodoc.sco_codes_parcours import UE_SPORT, DEF +from app.scodoc import sco_utils as scu + +# Pour raccorder le code des anciens codes qui attendent une NoteTable +class NotesTableCompat(ResultatsSemestre): + """Implementation partielle de NotesTable + + Les méthodes définies dans cette classe sont là + pour conserver la compatibilité abvec les codes anciens et + il n'est pas recommandé de les utiliser dans de nouveaux + développements (API malcommode et peu efficace). + """ + + _cached_attrs = ResultatsSemestre._cached_attrs + ( + "malus", + "etud_moy_gen_ranks", + "etud_moy_gen_ranks_int", + "ue_rangs", + ) + + def __init__(self, formsemestre: FormSemestre): + super().__init__(formsemestre) + + nb_etuds = len(self.etuds) + self.ue_rangs = {u.id: (None, nb_etuds) for u in self.ues} + self.mod_rangs = None # sera surchargé en Classic, mais pas en APC + """{ modimpl_id : (rangs, effectif) }""" + self.moy_min = "NA" + self.moy_max = "NA" + self.moy_moy = "NA" + self.expr_diagnostics = "" + self.parcours = self.formsemestre.formation.get_parcours() + + def get_inscrits(self, include_demdef=True, order_by=False) -> list[Identite]: + """Liste des étudiants inscrits + order_by = False|'nom'|'moy' tri sur nom ou sur moyenne générale (indicative) + + Note: pour récupérer les etudids des inscrits, non triés, il est plus efficace + d'utiliser `[ ins.etudid for ins in nt.formsemestre.inscriptions ]` + """ + etuds = self.formsemestre.get_inscrits( + include_demdef=include_demdef, order=(order_by == "nom") + ) + if order_by == "moy": + etuds.sort( + key=lambda e: ( + self.etud_moy_gen_ranks_int.get(e.id, 100000), + e.sort_key, + ) + ) + return etuds + + def get_etudids(self) -> list[int]: + """(deprecated) + Liste des etudids inscrits, incluant les démissionnaires. + triée par ordre alphabetique de NOM + (à éviter: renvoie les etudids, mais est moins efficace que get_inscrits) + """ + # Note: pour avoir les inscrits non triés, + # utiliser [ ins.etudid for ins in self.formsemestre.inscriptions ] + return [x["etudid"] for x in self.inscrlist] + + @cached_property + def sem(self) -> dict: + """le formsemestre, comme un gros et gras dict (nt.sem)""" + return self.formsemestre.get_infos_dict() + + @cached_property + def inscrlist(self) -> list[dict]: # utilisé par PE + """Liste des inscrits au semestre (avec DEM et DEF), + sous forme de dict etud, + classée dans l'ordre alphabétique de noms. + """ + etuds = self.formsemestre.get_inscrits(include_demdef=True, order=True) + return [e.to_dict_scodoc7() for e in etuds] + + @cached_property + def stats_moy_gen(self): + """Stats (moy/min/max) sur la moyenne générale""" + return StatsMoyenne(self.etud_moy_gen) + + def get_ues_stat_dict( + self, filter_sport=False, check_apc_ects=True + ) -> list[dict]: # was get_ues() + """Liste des UEs, ordonnée par numero. + Si filter_sport, retire les UE de type SPORT. + Résultat: liste de dicts { champs UE U stats moyenne UE } + """ + ues = self.formsemestre.query_ues(with_sport=not filter_sport) + ues_dict = [] + for ue in ues: + d = ue.to_dict() + if ue.type != UE_SPORT: + moys = self.etud_moy_ue[ue.id] + else: + moys = None + d.update(StatsMoyenne(moys).to_dict()) + ues_dict.append(d) + if check_apc_ects and self.is_apc and not hasattr(g, "checked_apc_ects"): + g.checked_apc_ects = True + if None in [ue.ects for ue in ues if ue.type != UE_SPORT]: + formation = self.formsemestre.formation + ue_sans_ects = [ + ue for ue in ues if ue.type != UE_SPORT and ue.ects is None + ] + flash( + Markup( + f"""Calcul moyenne générale impossible: ECTS des UE manquants !
+ (dans {' ,'.join([ue.acronyme for ue in ue_sans_ects])} + de la formation: {formation.get_titre_version()}) + ) + """ + ), + category="danger", + ) + return ues_dict + + def get_modimpls_dict(self, ue_id=None) -> list[dict]: + """Liste des modules pour une UE (ou toutes si ue_id==None), + triés par numéros (selon le type de formation) + """ + modimpls_dict = [] + for modimpl in self.formsemestre.modimpls_sorted: + if (ue_id is None) or (modimpl.module.ue.id == ue_id): + d = modimpl.to_dict() + # compat ScoDoc < 9.2: ajoute matières + d["mat"] = modimpl.module.matiere.to_dict() + modimpls_dict.append(d) + return modimpls_dict + + def compute_rangs(self): + """Calcule les classements + Moyenne générale: etud_moy_gen_ranks + Par UE (sauf ue bonus) + """ + ( + self.etud_moy_gen_ranks, + self.etud_moy_gen_ranks_int, + ) = moy_sem.comp_ranks_series(self.etud_moy_gen) + for ue in self.formsemestre.query_ues(): + moy_ue = self.etud_moy_ue[ue.id] + self.ue_rangs[ue.id] = ( + moy_sem.comp_ranks_series(moy_ue)[0], # juste en chaine + int(moy_ue.count()), + ) + # .count() -> nb of non NaN values + + def get_etud_ue_rang(self, ue_id, etudid) -> tuple[str, int]: + """Le rang de l'étudiant dans cette ue + Result: rang:str, effectif:str + """ + rangs, effectif = self.ue_rangs[ue_id] + if rangs is not None: + rang = rangs[etudid] + else: + return "", "" + return rang, effectif + + def etud_check_conditions_ues(self, etudid): + """Vrai si les conditions sur les UE sont remplies. + Ne considère que les UE ayant des notes (moyenne calculée). + (les UE sans notes ne sont pas comptées comme sous la barre) + Prend en compte les éventuelles UE capitalisées. + + Pour les parcours habituels, cela revient à vérifier que + les moyennes d'UE sont toutes > à leur barre (sauf celles sans notes) + + Pour les parcours non standards (LP2014), cela peut être plus compliqué. + + Return: True|False, message explicatif + """ + ue_status_list = [] + for ue in self.formsemestre.query_ues(): + ue_status = self.get_etud_ue_status(etudid, ue.id) + if ue_status: + ue_status_list.append(ue_status) + return self.parcours.check_barre_ues(ue_status_list) + + def all_etuds_have_sem_decisions(self): + """True si tous les étudiants du semestre ont une décision de jury. + Ne regarde pas les décisions d'UE. + """ + for ins in self.formsemestre.inscriptions: + if ins.etat != scu.INSCRIT: + continue # skip démissionnaires + if self.get_etud_decision_sem(ins.etudid) is None: + return False + return True + + def etud_has_decision(self, etudid): + """True s'il y a une décision de jury pour cet étudiant""" + return self.get_etud_decision_ues(etudid) or self.get_etud_decision_sem(etudid) + + def get_etud_decision_ues(self, etudid: int) -> dict: + """Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu. + Ne tient pas compte des UE capitalisées. + { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' : } + Ne renvoie aucune decision d'UE pour les défaillants + """ + if self.get_etud_etat(etudid) == DEF: + return {} + else: + if not self.validations: + self.validations = res_sem.load_formsemestre_validations( + self.formsemestre + ) + return self.validations.decisions_jury_ues.get(etudid, None) + + def get_etud_decision_sem(self, etudid: int) -> dict: + """Decision du jury prise pour cet etudiant, ou None s'il n'y en pas eu. + { 'code' : None|ATT|..., 'assidu' : 0|1, 'event_date' : , compense_formsemestre_id } + Si état défaillant, force le code a DEF + """ + if self.get_etud_etat(etudid) == DEF: + return { + "code": DEF, + "assidu": False, + "event_date": "", + "compense_formsemestre_id": None, + } + else: + if not self.validations: + self.validations = res_sem.load_formsemestre_validations( + self.formsemestre + ) + return self.validations.decisions_jury.get(etudid, None) + + def get_etud_mat_moy(self, matiere_id: int, etudid: int) -> str: + """moyenne d'un étudiant dans une matière (ou NA si pas de notes)""" + if not self.moyennes_matieres: + return "nd" + return ( + self.moyennes_matieres[matiere_id].get(etudid, "-") + if matiere_id in self.moyennes_matieres + else "-" + ) + + def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: + """La moyenne de l'étudiant dans le moduleimpl + En APC, il s'agira d'une moyenne indicative sans valeur. + Result: valeur float (peut être naN) ou chaîne "NI" (non inscrit ou DEM) + """ + raise NotImplementedError() # virtual method + + def get_etud_moy_gen(self, etudid): # -> float | str + """Moyenne générale de cet etudiant dans ce semestre. + Prend en compte les UE capitalisées. + Si apc, moyenne indicative. + Si pas de notes: 'NA' + """ + return self.etud_moy_gen[etudid] + + def get_etud_ects_pot(self, etudid: int) -> dict: + """ + Un dict avec les champs + ects_pot : (float) nb de crédits ECTS qui seraient validés + (sous réserve de validation par le jury) + ects_pot_fond: (float) nb d'ECTS issus d'UE fondamentales (non électives) + + Ce sont les ECTS des UE au dessus de la barre (10/20 en principe), avant le jury (donc non + encore enregistrées). + """ + # was nt.get_etud_moy_infos + # XXX pour compat nt, à remplacer ultérieurement + ues = self.get_etud_ue_validables(etudid) + ects_pot = 0.0 + for ue in ues: + if ( + ue.id in self.etud_moy_ue + and ue.ects is not None + and self.etud_moy_ue[ue.id][etudid] > self.parcours.NOTES_BARRE_VALID_UE + ): + ects_pot += ue.ects + return { + "ects_pot": ects_pot, + "ects_pot_fond": 0.0, # not implemented (anciennemment pour école ingé) + } + + def get_etud_rang(self, etudid: int) -> str: + """Le rang (classement) de l'étudiant dans le semestre. + Result: "13" ou "12 ex" + """ + return self.etud_moy_gen_ranks.get(etudid, 99999) + + def get_etud_rang_group(self, etudid: int, group_id: int): + "Le rang de l'étudiant dans ce groupe (NON IMPLEMENTE)" + return (None, 0) # XXX unimplemented TODO + + def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]: + """Liste d'informations (compat NotesTable) sur évaluations completes + de ce module. + Évaluation "complete" ssi toutes notes saisies ou en attente. + """ + modimpl = ModuleImpl.query.get(moduleimpl_id) + modimpl_results = self.modimpls_results.get(moduleimpl_id) + if not modimpl_results: + return [] # safeguard + evals_results = [] + for e in modimpl.evaluations: + if modimpl_results.evaluations_completes_dict.get(e.id, False): + d = e.to_dict() + d["heure_debut"] = e.heure_debut # datetime.time + d["heure_fin"] = e.heure_fin + d["jour"] = e.jour # datetime + d["notes"] = { + etud.id: { + "etudid": etud.id, + "value": modimpl_results.evals_notes[e.id][etud.id], + } + for etud in self.etuds + } + d["etat"] = { + "evalattente": modimpl_results.evaluations_etat[e.id].nb_attente, + } + evals_results.append(d) + elif e.id not in modimpl_results.evaluations_completes_dict: + # ne devrait pas arriver ? XXX + log( + f"Warning: 220213 get_evals_in_mod {e.id} not in mod {moduleimpl_id} ?" + ) + return evals_results + + def get_evaluations_etats(self): + """[ {...evaluation et son etat...} ]""" + # TODO: à moderniser + from app.scodoc import sco_evaluations + + if not hasattr(self, "_evaluations_etats"): + self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem( + self.formsemestre.id + ) + + return self._evaluations_etats + + def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]: + """Liste des états des évaluations de ce module""" + # XXX TODO à moderniser: lent, recharge des données que l'on a déjà... + return [ + e + for e in self.get_evaluations_etats() + if e["moduleimpl_id"] == moduleimpl_id + ] + + def get_moduleimpls_attente(self): + """Liste des modimpls du semestre ayant des notes en attente""" + return [ + modimpl + for modimpl in self.formsemestre.modimpls_sorted + if self.modimpls_results[modimpl.id].en_attente + ] + + def get_mod_stats(self, moduleimpl_id: int) -> dict: + """Stats sur les notes obtenues dans un modimpl + Vide en APC + """ + return { + "moy": "-", + "max": "-", + "min": "-", + "nb_notes": "-", + "nb_missing": "-", + "nb_valid_evals": "-", + } + + def get_nom_short(self, etudid): + "formatte nom d'un etud (pour table recap)" + etud = self.identdict[etudid] + return ( + (etud["nom_usuel"] or etud["nom"]).upper() + + " " + + etud["prenom"].capitalize()[:2] + + "." + ) + + @cached_property + def T(self): + return self.get_table_moyennes_triees() + + def get_table_moyennes_triees(self) -> list: + """Result: liste de tuples + moy_gen, moy_ue_0, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid + """ + table_moyennes = [] + etuds_inscriptions = self.formsemestre.etuds_inscriptions + ues = self.formsemestre.query_ues(with_sport=True) # avec bonus + for etudid in etuds_inscriptions: + moy_gen = self.etud_moy_gen.get(etudid, False) + if moy_gen is False: + # pas de moyenne: démissionnaire ou def + t = ( + ["-"] + + ["0.00"] * len(self.ues) + + ["NI"] * len(self.formsemestre.modimpls_sorted) + ) + else: + moy_ues = [] + ue_is_cap = {} + for ue in ues: + ue_status = self.get_etud_ue_status(etudid, ue.id) + if ue_status: + moy_ues.append(ue_status["moy"]) + ue_is_cap[ue.id] = ue_status["is_capitalized"] + else: + moy_ues.append("?") + t = [moy_gen] + list(moy_ues) + # Moyennes modules: + for modimpl in self.formsemestre.modimpls_sorted: + if ue_is_cap.get(modimpl.module.ue.id, False): + val = "-c-" + else: + val = self.get_etud_mod_moy(modimpl.id, etudid) + t.append(val) + t.append(etudid) + table_moyennes.append(t) + # tri par moyennes décroissantes, + # en laissant les démissionnaires à la fin, par ordre alphabetique + etuds = [ins.etud for ins in etuds_inscriptions.values()] + etuds.sort(key=lambda e: e.sort_key) + self._rang_alpha = {e.id: i for i, e in enumerate(etuds)} + table_moyennes.sort(key=self._row_key) + return table_moyennes + + def _row_key(self, x): + """clé de tri par moyennes décroissantes, + en laissant les demissionnaires à la fin, par ordre alphabetique. + (moy_gen, rang_alpha) + """ + try: + moy = -float(x[0]) + except (ValueError, TypeError): + moy = 1000.0 + return (moy, self._rang_alpha[x[-1]]) + + @cached_property + def identdict(self) -> dict: + """{ etudid : etud_dict } pour tous les inscrits au semestre""" + return { + ins.etud.id: ins.etud.to_dict_scodoc7() + for ins in self.formsemestre.inscriptions + } diff --git a/app/comp/res_sem.py b/app/comp/res_sem.py index 607ad16811..e27a157c1a 100644 --- a/app/comp/res_sem.py +++ b/app/comp/res_sem.py @@ -8,11 +8,13 @@ """ from flask import g +from app import db from app.comp.jury import ValidationsSemestre from app.comp.res_common import ResultatsSemestre from app.comp.res_classic import ResultatsSemestreClassic from app.comp.res_but import ResultatsSemestreBUT from app.models.formsemestre import FormSemestre +from app.scodoc import sco_cache def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre: @@ -23,6 +25,13 @@ def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre: Search in local cache (g.formsemestre_result_cache) If not in cache, build it and cache it. """ + is_apc = formsemestre.formation.is_apc() + if is_apc and formsemestre.semestre_id == -1: + formsemestre.semestre_id = 1 + db.session.add(formsemestre) + db.session.commit() + sco_cache.invalidate_formsemestre(formsemestre.id) + # --- Try local cache (within the same request context) if not hasattr(g, "formsemestre_results_cache"): g.formsemestre_results_cache = {} @@ -30,11 +39,7 @@ def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre: if formsemestre.id in g.formsemestre_results_cache: return g.formsemestre_results_cache[formsemestre.id] - klass = ( - ResultatsSemestreBUT - if formsemestre.formation.is_apc() - else ResultatsSemestreClassic - ) + klass = ResultatsSemestreBUT if is_apc else ResultatsSemestreClassic g.formsemestre_results_cache[formsemestre.id] = klass(formsemestre) return g.formsemestre_results_cache[formsemestre.id] diff --git a/app/decorators.py b/app/decorators.py index 8ebf5deabc..d6c6ed2346 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -1,14 +1,11 @@ # -*- coding: UTF-8 -* """Decorators for permissions, roles and ScoDoc7 Zope compatibility """ -import functools from functools import wraps import inspect -import types -import logging + import werkzeug -from werkzeug.exceptions import BadRequest import flask from flask import g, current_app, request from flask import abort, url_for, redirect @@ -193,7 +190,7 @@ def scodoc7func(func): # necessary for db ids and boolean values try: v = int(v) - except ValueError: + except (ValueError, TypeError): pass pos_arg_values.append(v) # current_app.logger.info("pos_arg_values=%s" % pos_arg_values) diff --git a/app/email.py b/app/email.py index 226429df24..ebd3ae0d15 100644 --- a/app/email.py +++ b/app/email.py @@ -1,30 +1,86 @@ # -*- coding: UTF-8 -* +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + from threading import Thread -from flask import current_app + +from flask import current_app, g from flask_mail import Message + from app import mail +from app.scodoc import sco_preferences def send_async_email(app, msg): + "Send an email, async" with app.app_context(): mail.send(msg) def send_email( - subject: str, sender: str, recipients: list, text_body: str, html_body="" + subject: str, + sender: str, + recipients: list, + text_body: str, + html_body="", + bcc=(), + attachments=(), ): """ - Send an email + Send an email. _All_ ScoDoc mails SHOULD be sent using this function. + If html_body is specified, build a multipart message with HTML content, else send a plain text email. + + attachements: list of dict { 'filename', 'mimetype', 'data' } """ - msg = Message(subject, sender=sender, recipients=recipients) + msg = Message(subject, sender=sender, recipients=recipients, bcc=bcc) msg.body = text_body msg.html = html_body + if attachments: + for attachment in attachments: + msg.attach( + attachment["filename"], attachment["mimetype"], attachment["data"] + ) + send_message(msg) -def send_message(msg): +def send_message(msg: Message): + """Send a message. + All ScoDoc emails MUST be sent by this function. + + In mail debug mode, addresses are discarded and all mails are sent to the + specified debugging address. + """ + if hasattr(g, "scodoc_dept"): + # on est dans un département, on peut accéder aux préférences + email_test_mode_address = sco_preferences.get_preference( + "email_test_mode_address" + ) + if email_test_mode_address: + # Mode spécial test: remplace les adresses de destination + orig_to = msg.recipients + orig_cc = msg.cc + orig_bcc = msg.bcc + msg.recipients = [email_test_mode_address] + msg.cc = None + msg.bcc = None + msg.subject = "[TEST SCODOC] " + msg.subject + msg.body = ( + f"""--- Message ScoDoc dérouté pour tests --- +Adresses d'origine: + to : {orig_to} + cc : {orig_cc} + bcc: {orig_bcc} +--- + \n\n""" + + msg.body + ) + Thread( target=send_async_email, args=(current_app._get_current_object(), msg) ).start() diff --git a/app/entreprises/__init__.py b/app/entreprises/__init__.py index 44968ffb1b..b211670b21 100644 --- a/app/entreprises/__init__.py +++ b/app/entreprises/__init__.py @@ -4,10 +4,11 @@ from flask import Blueprint from app.scodoc import sco_etud from app.auth.models import User +from app.models import Departement bp = Blueprint("entreprises", __name__) -LOGS_LEN = 10 +LOGS_LEN = 5 @bp.app_template_filter() @@ -21,9 +22,21 @@ def format_nom(s): @bp.app_template_filter() -def get_nomcomplet(s): +def get_nomcomplet_by_username(s): user = User.query.filter_by(user_name=s).first() return user.get_nomcomplet() +@bp.app_template_filter() +def get_nomcomplet_by_id(id): + user = User.query.filter_by(id=id).first() + return user.get_nomcomplet() + + +@bp.app_template_filter() +def get_dept_acronym(id): + dept = Departement.query.filter_by(id=id).first() + return dept.acronym + + from app.entreprises import routes diff --git a/app/entreprises/app_relations_entreprises.py b/app/entreprises/app_relations_entreprises.py new file mode 100644 index 0000000000..68ff998657 --- /dev/null +++ b/app/entreprises/app_relations_entreprises.py @@ -0,0 +1,198 @@ +# -*- 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 +# +# +############################################################################## + +import os +from config import Config +import re +import requests +import glob + +from flask_login import current_user + +from app.entreprises.models import ( + Entreprise, + EntrepriseContact, + EntrepriseOffre, + EntrepriseOffreDepartement, + EntreprisePreferences, +) + +from app import email +from app.scodoc import sco_preferences +from app.models import Departement +from app.scodoc.sco_permissions import Permission + + +def get_depts(): + """ + Retourne une liste contenant les l'id des départements des roles de l'utilisateur courant + """ + depts = [] + for role in current_user.user_roles: + dept_id = get_dept_id_by_acronym(role.dept) + if dept_id is not None: + depts.append(dept_id) + return depts + + +def get_dept_id_by_acronym(acronym): + """ + Retourne l'id d'un departement a l'aide de son acronym + """ + dept = Departement.query.filter_by(acronym=acronym).first() + if dept is not None: + return dept.id + return None + + +def check_offre_depts(depts, offre_depts): + """ + Retourne vrai si l'utilisateur a le droit de visibilité sur l'offre + """ + if current_user.has_permission(Permission.RelationsEntreprisesChange, None): + return True + for offre_dept in offre_depts: + if offre_dept.dept_id in depts: + return True + return False + + +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() + if not offre_depts or check_offre_depts(depts, offre_depts): + files = [] + path = os.path.join( + Config.SCODOC_VAR_DIR, + "entreprises", + f"{offre.entreprise_id}", + f"{offre.id}", + ) + if os.path.exists(path): + for dir in glob.glob( + f"{path}/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]" + ): + 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 None + + +def send_email_notifications_entreprise( + subject, entreprise: Entreprise, contact: EntrepriseContact +): + txt = [ + "Une entreprise est en attente de validation", + "Entreprise:", + f"\tnom: {entreprise.nom}", + f"\tsiret: {entreprise.siret}", + f"\tadresse: {entreprise.adresse}", + 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( + subject, + sco_preferences.get_preference("email_from_addr"), + [EntreprisePreferences.get_email_notifications], + txt, + ) + return txt + + +def verif_contact_data(contact_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 + """ + # champs obligatoires + if contact_data[0] == "" or contact_data[1] == "" or contact_data[6] == "": + return False + + # entreprise_id existant + entreprise = Entreprise.query.filter_by(siret=contact_data[6]).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 + ).first() + if contact is not None: + return False + + if contact_data[2] == "" and contact_data[3] == "": # 1 moyen de contact + return False + + return True + + +def verif_entreprise_data(entreprise_data): + """ + Verifie les données d'une ligne Excel (entreprise) + """ + if EntreprisePreferences.get_check_siret(): + for data in entreprise_data: # champs obligatoires + if data == "": + return False + else: + for data in entreprise_data[1:]: # champs obligatoires + if data == "": + return False + if EntreprisePreferences.get_check_siret(): + siret = entreprise_data[0].strip() # 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}" + ) + 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: + return False + return True diff --git a/app/entreprises/forms.py b/app/entreprises/forms.py index 1f76732dce..0540c0802b 100644 --- a/app/entreprises/forms.py +++ b/app/entreprises/forms.py @@ -31,215 +31,281 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed, FileRequired from markupsafe import Markup from sqlalchemy import text -from wtforms import StringField, SubmitField, TextAreaField, SelectField, HiddenField -from wtforms.fields import EmailField, DateField -from wtforms.validators import ValidationError, DataRequired, Email +from wtforms import ( + StringField, + SubmitField, + TextAreaField, + SelectField, + HiddenField, + SelectMultipleField, + DateField, + BooleanField, +) +from wtforms.validators import ValidationError, DataRequired, Email, Optional +from wtforms.widgets import ListWidget, CheckboxInput -from app.entreprises.models import Entreprise, EntrepriseContact -from app.models import Identite +from app.entreprises.models import Entreprise, EntrepriseContact, EntreprisePreferences +from app.models import Identite, Departement from app.auth.models import User CHAMP_REQUIS = "Ce champ est requis" +SUBMIT_MARGE = {"style": "margin-bottom: 10px;"} + + +def _build_string_field(label, required=True, render_kw=None): + if required: + return StringField( + label, + validators=[DataRequired(message=CHAMP_REQUIS)], + render_kw=render_kw, + ) + else: + return StringField(label, validators=[Optional()], render_kw=render_kw) class EntrepriseCreationForm(FlaskForm): - siret = StringField( - "SIRET", - validators=[DataRequired(message=CHAMP_REQUIS)], + siret = _build_string_field( + "SIRET (*)", render_kw={"placeholder": "Numéro composé de 14 chiffres", "maxlength": "14"}, ) - nom_entreprise = StringField( - "Nom de l'entreprise", - validators=[DataRequired(message=CHAMP_REQUIS)], - ) - adresse = StringField( - "Adresse de l'entreprise", - validators=[DataRequired(message=CHAMP_REQUIS)], - ) - codepostal = StringField( - "Code postal de l'entreprise", - validators=[DataRequired(message=CHAMP_REQUIS)], - ) - ville = StringField( - "Ville de l'entreprise", - validators=[DataRequired(message=CHAMP_REQUIS)], - ) - pays = StringField( - "Pays de l'entreprise", - validators=[DataRequired(message=CHAMP_REQUIS)], - render_kw={"style": "margin-bottom: 50px;"}, - ) + nom_entreprise = _build_string_field("Nom de l'entreprise (*)") + adresse = _build_string_field("Adresse de l'entreprise (*)") + codepostal = _build_string_field("Code postal de l'entreprise (*)") + ville = _build_string_field("Ville de l'entreprise (*)") + pays = _build_string_field("Pays de l'entreprise", required=False) - nom_contact = StringField( - "Nom du contact", validators=[DataRequired(message=CHAMP_REQUIS)] + 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) + mail = StringField( + "Mail du contact (*)", + validators=[Optional(), Email(message="Adresse e-mail invalide")], ) - prenom_contact = StringField( - "Prénom du contact", - validators=[DataRequired(message=CHAMP_REQUIS)], - ) - telephone = StringField( - "Téléphone du contact", - validators=[DataRequired(message=CHAMP_REQUIS)], - ) - mail = EmailField( - "Mail du contact", - validators=[ - DataRequired(message=CHAMP_REQUIS), - Email(message="Adresse e-mail invalide"), - ], - ) - poste = StringField("Poste du contact", validators=[]) - service = StringField("Service du contact", validators=[]) - submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + poste = _build_string_field("Poste du contact", required=False) + service = _build_string_field("Service du contact", required=False) + submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + + 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 def validate_siret(self, siret): - siret = siret.data.strip() - if re.match("^\d{14}$", siret) == None: - raise ValidationError("Format incorrect") - req = requests.get( - f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}" - ) - if req.status_code != 200: - raise ValidationError("SIRET inexistant") - entreprise = Entreprise.query.filter_by(siret=siret).first() - if entreprise is not None: - lien = f'ici' - raise ValidationError( - Markup(f"Entreprise déjà présent, lien vers la fiche : {lien}") - ) + if EntreprisePreferences.get_check_siret(): + siret = siret.data.strip() + if re.match("^\d{14}$", siret) is None: + raise ValidationError("Format incorrect") + try: + req = requests.get( + f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}" + ) + except requests.ConnectionError: + print("no internet") + if req.status_code != 200: + raise ValidationError("SIRET inexistant") + entreprise = Entreprise.query.filter_by(siret=siret).first() + if entreprise is not None: + lien = f'ici' + raise ValidationError( + Markup(f"Entreprise déjà présent, lien vers la fiche : {lien}") + ) class EntrepriseModificationForm(FlaskForm): - siret = StringField("SIRET", validators=[], render_kw={"disabled": ""}) - nom = StringField( - "Nom de l'entreprise", - validators=[DataRequired(message=CHAMP_REQUIS)], - ) - adresse = StringField("Adresse", validators=[DataRequired(message=CHAMP_REQUIS)]) - codepostal = StringField( - "Code postal", validators=[DataRequired(message=CHAMP_REQUIS)] - ) - ville = StringField("Ville", validators=[DataRequired(message=CHAMP_REQUIS)]) - pays = StringField("Pays", validators=[DataRequired(message=CHAMP_REQUIS)]) - submit = SubmitField("Modifier", render_kw={"style": "margin-bottom: 10px;"}) + hidden_entreprise_siret = HiddenField() + siret = StringField("SIRET (*)") + nom = _build_string_field("Nom de l'entreprise (*)") + adresse = _build_string_field("Adresse (*)") + codepostal = _build_string_field("Code postal (*)") + ville = _build_string_field("Ville (*)") + pays = _build_string_field("Pays", required=False) + submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.siret.render_kw = { + "disabled": "", + "value": self.hidden_entreprise_siret.data, + } + + +class MultiCheckboxField(SelectMultipleField): + widget = ListWidget(prefix_label=False) + option_widget = CheckboxInput() class OffreCreationForm(FlaskForm): - intitule = StringField("Intitulé", validators=[DataRequired(message=CHAMP_REQUIS)]) + intitule = _build_string_field("Intitulé (*)") description = TextAreaField( - "Description", validators=[DataRequired(message=CHAMP_REQUIS)] + "Description (*)", validators=[DataRequired(message=CHAMP_REQUIS)] ) type_offre = SelectField( - "Type de l'offre", + "Type de l'offre (*)", choices=[("Stage"), ("Alternance")], validators=[DataRequired(message=CHAMP_REQUIS)], ) missions = TextAreaField( - "Missions", validators=[DataRequired(message=CHAMP_REQUIS)] + "Missions (*)", validators=[DataRequired(message=CHAMP_REQUIS)] ) - duree = StringField("Durée", validators=[DataRequired(message=CHAMP_REQUIS)]) - submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + duree = _build_string_field("Durée (*)") + depts = MultiCheckboxField("Départements", validators=[Optional()], coerce=int) + expiration_date = DateField("Date expiration", validators=[Optional()]) + submit = SubmitField("Envoyer", 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() + ] class OffreModificationForm(FlaskForm): - intitule = StringField("Intitulé", validators=[DataRequired(message=CHAMP_REQUIS)]) + intitule = _build_string_field("Intitulé (*)") description = TextAreaField( - "Description", validators=[DataRequired(message=CHAMP_REQUIS)] + "Description (*)", validators=[DataRequired(message=CHAMP_REQUIS)] ) type_offre = SelectField( - "Type de l'offre", + "Type de l'offre (*)", choices=[("Stage"), ("Alternance")], validators=[DataRequired(message=CHAMP_REQUIS)], ) missions = TextAreaField( - "Missions", validators=[DataRequired(message=CHAMP_REQUIS)] + "Missions (*)", validators=[DataRequired(message=CHAMP_REQUIS)] ) - duree = StringField("Durée", validators=[DataRequired(message=CHAMP_REQUIS)]) - submit = SubmitField("Modifier", render_kw={"style": "margin-bottom: 10px;"}) + duree = _build_string_field("Durée (*)") + depts = MultiCheckboxField("Départements", validators=[Optional()], coerce=int) + expiration_date = DateField("Date expiration", validators=[Optional()]) + 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() + ] class ContactCreationForm(FlaskForm): hidden_entreprise_id = HiddenField() - nom = StringField("Nom", validators=[DataRequired(message=CHAMP_REQUIS)]) - prenom = StringField("Prénom", validators=[DataRequired(message=CHAMP_REQUIS)]) - telephone = StringField( - "Téléphone", validators=[DataRequired(message=CHAMP_REQUIS)] + nom = _build_string_field("Nom (*)") + prenom = _build_string_field("Prénom (*)") + telephone = _build_string_field("Téléphone (*)", required=False) + mail = StringField( + "Mail (*)", + validators=[Optional(), Email(message="Adresse e-mail invalide")], ) - mail = EmailField( - "Mail", - validators=[ - DataRequired(message=CHAMP_REQUIS), - Email(message="Adresse e-mail invalide"), - ], - ) - poste = StringField("Poste", validators=[]) - service = StringField("Service", validators=[]) - submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + poste = _build_string_field("Poste", required=False) + service = _build_string_field("Service", required=False) + submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) def validate(self): - rv = FlaskForm.validate(self) - if not rv: - return False + validate = True + 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("") - return False + validate = False - return True + 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 ContactModificationForm(FlaskForm): - nom = StringField("Nom", validators=[DataRequired(message=CHAMP_REQUIS)]) - prenom = StringField("Prénom", validators=[DataRequired(message=CHAMP_REQUIS)]) - telephone = StringField( - "Téléphone", validators=[DataRequired(message=CHAMP_REQUIS)] + hidden_contact_id = HiddenField() + hidden_entreprise_id = HiddenField() + nom = _build_string_field("Nom (*)") + prenom = _build_string_field("Prénom (*)") + telephone = _build_string_field("Téléphone (*)", required=False) + mail = StringField( + "Mail (*)", + validators=[Optional(), Email(message="Adresse e-mail invalide")], ) - mail = EmailField( - "Mail", - validators=[ - DataRequired(message=CHAMP_REQUIS), - Email(message="Adresse e-mail invalide"), - ], - ) - poste = StringField("Poste", validators=[]) - service = StringField("Service", validators=[]) - submit = SubmitField("Modifier", render_kw={"style": "margin-bottom: 10px;"}) + poste = _build_string_field("Poste", required=False) + service = _build_string_field("Service", required=False) + submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE) + + 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, + ).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 + + return validate class HistoriqueCreationForm(FlaskForm): - etudiant = StringField( - "Étudiant", - validators=[DataRequired(message=CHAMP_REQUIS)], - render_kw={"placeholder": "Tapez le nom de l'étudiant puis selectionnez"}, + etudiant = _build_string_field( + "Étudiant (*)", + render_kw={"placeholder": "Tapez le nom de l'étudiant"}, ) type_offre = SelectField( - "Type de l'offre", + "Type de l'offre (*)", choices=[("Stage"), ("Alternance")], validators=[DataRequired(message=CHAMP_REQUIS)], ) date_debut = DateField( - "Date début", validators=[DataRequired(message=CHAMP_REQUIS)] + "Date début (*)", validators=[DataRequired(message=CHAMP_REQUIS)] ) - date_fin = DateField("Date fin", validators=[DataRequired(message=CHAMP_REQUIS)]) - submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + date_fin = DateField( + "Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)] + ) + submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) def validate(self): - rv = FlaskForm.validate(self) - if not rv: - return False + validate = True + if not FlaskForm.validate(self): + validate = False - if self.date_debut.data > self.date_fin.data: + 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") - return False - return True + validate = False + + return validate def validate_etudiant(self, etudiant): etudiant_data = etudiant.data.upper().strip() @@ -254,11 +320,11 @@ class HistoriqueCreationForm(FlaskForm): class EnvoiOffreForm(FlaskForm): - responsable = StringField( - "Responsable de formation", - validators=[DataRequired(message=CHAMP_REQUIS)], + responsable = _build_string_field( + "Responsable de formation (*)", + render_kw={"placeholder": "Tapez le nom du responsable de formation"}, ) - submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) def validate_responsable(self, responsable): responsable_data = responsable.data.upper().strip() @@ -276,14 +342,38 @@ class EnvoiOffreForm(FlaskForm): class AjoutFichierForm(FlaskForm): fichier = FileField( - "Fichier", + "Fichier (*)", validators=[ FileRequired(message=CHAMP_REQUIS), FileAllowed(["pdf", "docx"], "Fichier .pdf ou .docx uniquement"), ], ) - submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + submit = SubmitField("Ajouter", render_kw=SUBMIT_MARGE) class SuppressionConfirmationForm(FlaskForm): - submit = SubmitField("Supprimer", render_kw={"style": "margin-bottom: 10px;"}) + submit = SubmitField("Supprimer", render_kw=SUBMIT_MARGE) + + +class ValidationConfirmationForm(FlaskForm): + submit = SubmitField("Valider", render_kw=SUBMIT_MARGE) + + +class ImportForm(FlaskForm): + fichier = FileField( + "Fichier (*)", + validators=[ + FileRequired(message=CHAMP_REQUIS), + FileAllowed(["xlsx"], "Fichier .xlsx uniquement"), + ], + ) + submit = SubmitField("Importer", render_kw=SUBMIT_MARGE) + + +class PreferencesForm(FlaskForm): + mail_entreprise = StringField( + "Mail notifications", + validators=[Optional(), Email(message="Adresse e-mail invalide")], + ) + check_siret = BooleanField("Vérification SIRET") + submit = SubmitField("Valider", render_kw=SUBMIT_MARGE) diff --git a/app/entreprises/models.py b/app/entreprises/models.py index e743df3d60..dd0b4ba44c 100644 --- a/app/entreprises/models.py +++ b/app/entreprises/models.py @@ -2,14 +2,15 @@ from app import db class Entreprise(db.Model): - __tablename__ = "entreprises" + __tablename__ = "are_entreprises" id = db.Column(db.Integer, primary_key=True) siret = db.Column(db.Text) nom = db.Column(db.Text) adresse = db.Column(db.Text) codepostal = db.Column(db.Text) ville = db.Column(db.Text) - pays = db.Column(db.Text) + pays = db.Column(db.Text, default="FRANCE") + visible = db.Column(db.Boolean, default=False) contacts = db.relationship( "EntrepriseContact", backref="entreprise", @@ -26,19 +27,19 @@ class Entreprise(db.Model): def to_dict(self): return { "siret": self.siret, - "nom": self.nom, + "nom_entreprise": self.nom, "adresse": self.adresse, - "codepostal": self.codepostal, + "code_postal": self.codepostal, "ville": self.ville, "pays": self.pays, } class EntrepriseContact(db.Model): - __tablename__ = "entreprise_contact" + __tablename__ = "are_contacts" id = db.Column(db.Integer, primary_key=True) entreprise_id = db.Column( - db.Integer, db.ForeignKey("entreprises.id", ondelete="cascade") + db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") ) nom = db.Column(db.Text) prenom = db.Column(db.Text) @@ -48,6 +49,7 @@ class EntrepriseContact(db.Model): service = db.Column(db.Text) def to_dict(self): + entreprise = Entreprise.query.filter_by(id=self.entreprise_id).first() return { "nom": self.nom, "prenom": self.prenom, @@ -55,31 +57,15 @@ class EntrepriseContact(db.Model): "mail": self.mail, "poste": self.poste, "service": self.service, - } - - def to_dict_export(self): - entreprise = Entreprise.query.get(self.entreprise_id) - return { - "nom": self.nom, - "prenom": self.prenom, - "telephone": self.telephone, - "mail": self.mail, - "poste": self.poste, - "service": self.service, - "siret": entreprise.siret, - "nom_entreprise": entreprise.nom, - "adresse_entreprise": entreprise.adresse, - "codepostal": entreprise.codepostal, - "ville": entreprise.ville, - "pays": entreprise.pays, + "entreprise_siret": entreprise.siret, } class EntrepriseOffre(db.Model): - __tablename__ = "entreprise_offre" + __tablename__ = "are_offres" id = db.Column(db.Integer, primary_key=True) entreprise_id = db.Column( - db.Integer, db.ForeignKey("entreprises.id", ondelete="cascade") + db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") ) date_ajout = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) intitule = db.Column(db.Text) @@ -87,6 +73,8 @@ class EntrepriseOffre(db.Model): type_offre = db.Column(db.Text) missions = db.Column(db.Text) duree = db.Column(db.Text) + expiration_date = db.Column(db.Date) + expired = db.Column(db.Boolean, default=False) def to_dict(self): return { @@ -99,7 +87,7 @@ class EntrepriseOffre(db.Model): class EntrepriseLog(db.Model): - __tablename__ = "entreprise_log" + __tablename__ = "are_logs" id = db.Column(db.Integer, primary_key=True) date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) authenticated_user = db.Column(db.Text) @@ -108,9 +96,11 @@ class EntrepriseLog(db.Model): class EntrepriseEtudiant(db.Model): - __tablename__ = "entreprise_etudiant" + __tablename__ = "are_etudiants" id = db.Column(db.Integer, primary_key=True) - entreprise_id = db.Column(db.Integer, db.ForeignKey("entreprises.id")) + entreprise_id = db.Column( + db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") + ) etudid = db.Column(db.Integer) type_offre = db.Column(db.Text) date_debut = db.Column(db.Date) @@ -120,18 +110,78 @@ class EntrepriseEtudiant(db.Model): class EntrepriseEnvoiOffre(db.Model): - __tablename__ = "entreprise_envoi_offre" + __tablename__ = "are_envoi_offre" id = db.Column(db.Integer, primary_key=True) - sender_id = db.Column(db.Integer, db.ForeignKey("user.id")) - receiver_id = db.Column(db.Integer, db.ForeignKey("user.id")) - offre_id = db.Column(db.Integer, db.ForeignKey("entreprise_offre.id")) + sender_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="cascade")) + receiver_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="cascade")) + offre_id = db.Column(db.Integer, db.ForeignKey("are_offres.id", ondelete="cascade")) date_envoi = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) class EntrepriseEnvoiOffreEtudiant(db.Model): - __tablename__ = "entreprise_envoi_offre_etudiant" + __tablename__ = "are_envoi_offre_etudiant" id = db.Column(db.Integer, primary_key=True) - sender_id = db.Column(db.Integer, db.ForeignKey("user.id")) - receiver_id = db.Column(db.Integer, db.ForeignKey("identite.id")) - offre_id = db.Column(db.Integer, db.ForeignKey("entreprise_offre.id")) + sender_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="cascade")) + receiver_id = db.Column( + db.Integer, db.ForeignKey("identite.id", ondelete="cascade") + ) + offre_id = db.Column(db.Integer, db.ForeignKey("are_offres.id", ondelete="cascade")) date_envoi = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + + +class EntrepriseOffreDepartement(db.Model): + __tablename__ = "are_offre_departement" + id = db.Column(db.Integer, primary_key=True) + offre_id = db.Column(db.Integer, db.ForeignKey("are_offres.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) + name = db.Column(db.Text) + value = db.Column(db.Text) + + @classmethod + def get_email_notifications(cls): + mail = EntreprisePreferences.query.filter_by( + name="mail_notifications_entreprise" + ).first() + if mail is None: + return "" + else: + return mail.value + + @classmethod + def set_email_notifications(cls, mail: str): + if mail != cls.get_email_notifications(): + m = EntreprisePreferences.query.filter_by( + name="mail_notifications_entreprise" + ).first() + if m is None: + prefs = EntreprisePreferences( + name="mail_notifications_entreprise", + value=mail, + ) + db.session.add(prefs) + else: + m.value = mail + db.session.commit() + + @classmethod + def get_check_siret(cls): + check_siret = EntreprisePreferences.query.filter_by(name="check_siret").first() + if check_siret is None: + return 1 + else: + return int(check_siret.value) + + @classmethod + def set_check_siret(cls, check_siret: int): + cs = EntreprisePreferences.query.filter_by(name="check_siret").first() + if cs is None: + prefs = EntreprisePreferences(name="check_siret", value=check_siret) + db.session.add(prefs) + else: + cs.value = check_siret + db.session.commit() diff --git a/app/entreprises/routes.py b/app/entreprises/routes.py index f99fa8eef7..d1633e71da 100644 --- a/app/entreprises/routes.py +++ b/app/entreprises/routes.py @@ -1,6 +1,6 @@ import os from config import Config -from datetime import datetime, timedelta +from datetime import datetime, date import glob import shutil @@ -22,6 +22,9 @@ from app.entreprises.forms import ( HistoriqueCreationForm, EnvoiOffreForm, AjoutFichierForm, + ValidationConfirmationForm, + ImportForm, + PreferencesForm, ) from app.entreprises import bp from app.entreprises.models import ( @@ -31,7 +34,10 @@ from app.entreprises.models import ( EntrepriseLog, EntrepriseEtudiant, EntrepriseEnvoiOffre, + EntrepriseOffreDepartement, + EntreprisePreferences, ) +from app.entreprises import app_relations_entreprises as are from app.models import Identite from app.auth.models import User from app.scodoc.sco_permissions import Permission @@ -44,105 +50,98 @@ from werkzeug.utils import secure_filename @bp.route("/", methods=["GET"]) +@permission_required(Permission.RelationsEntreprisesView) def index(): """ - Permet d'afficher une page avec la liste des entreprises et une liste des dernières opérations - - Retourne: template de la page (entreprises.html) - Arguments du template: - title: - titre de la page - entreprises: - liste des entreprises - logs: - liste des logs + Permet d'afficher une page avec la liste des entreprises (visible) et une liste des dernières opérations """ - entreprises = Entreprise.query.all() + entreprises = Entreprise.query.filter_by(visible=True) logs = EntrepriseLog.query.order_by(EntrepriseLog.date.desc()).limit(LOGS_LEN).all() return render_template( "entreprises/entreprises.html", - title=("Entreprises"), + title="Entreprises", entreprises=entreprises, logs=logs, ) +@bp.route("/logs", methods=["GET"]) +@permission_required(Permission.RelationsEntreprisesView) +def logs(): + """ + Permet d'afficher les logs (toutes les entreprises) + """ + page = request.args.get("page", 1, type=int) + logs = EntrepriseLog.query.order_by(EntrepriseLog.date.desc()).paginate( + page=page, per_page=20 + ) + return render_template( + "entreprises/logs.html", + title="Logs", + logs=logs, + ) + + +@bp.route("/validation", methods=["GET"]) +@permission_required(Permission.RelationsEntreprisesValidate) +def validation(): + """ + Permet d'afficher une page avec la liste des entreprises a valider (non visible) + """ + entreprises = Entreprise.query.filter_by(visible=False).all() + return render_template( + "entreprises/entreprises_validation.html", + title="Validation entreprises", + entreprises=entreprises, + ) + + @bp.route("/contacts", methods=["GET"]) +@permission_required(Permission.RelationsEntreprisesView) def contacts(): """ - Permet d'afficher une page la liste des contacts et une liste des dernières opérations - - Retourne: template de la page (contacts.html) - Arguments du template: - title: - titre de la page - contacts: - liste des contacts - logs: - liste des logs + Permet d'afficher une page avec la liste des contacts des entreprises visibles et une liste des dernières opérations """ contacts = ( db.session.query(EntrepriseContact, Entreprise) .join(Entreprise, EntrepriseContact.entreprise_id == Entreprise.id) - .all() + .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, logs=logs + "entreprises/contacts.html", + title="Contacts", + contacts=contacts, + logs=logs, ) @bp.route("/fiche_entreprise/", methods=["GET"]) +@permission_required(Permission.RelationsEntreprisesView) 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 les offres de l'entreprise. - - Arguments: - id: - l'id de l'entreprise - - Retourne: template de la page (fiche_entreprise.html) - Arguments du template: - title: - titre de la page - entreprise: - un objet entreprise - contacts: - liste des contacts de l'entreprise - offres: - liste des offres de l'entreprise avec leurs fichiers - logs: - liste des logs - historique: - liste des étudiants ayant réaliser un stage ou une alternance dans l'entreprise """ - entreprise = Entreprise.query.filter_by(id=id).first_or_404() - offres = entreprise.offres + entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( + description=f"fiche entreprise {id} inconnue" + ) offres_with_files = [] - for offre in offres: - if datetime.now() - offre.date_ajout.replace(tzinfo=None) >= timedelta( - days=90 - ): # pour une date d'expiration ? - break - files = [] - path = os.path.join( - Config.SCODOC_VAR_DIR, - "entreprises", - f"{offre.entreprise_id}", - f"{offre.id}", - ) - if os.path.exists(path): - for dir in glob.glob( - f"{path}/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]" - ): - for file in glob.glob(f"{dir}/*"): - file = [os.path.basename(dir), os.path.basename(file)] - files.append(file) - offres_with_files.append([offre, files]) - contacts = entreprise.contacts + depts = are.get_depts() + for offre in entreprise.offres: + if not offre.expired and ( + offre.expiration_date is None + or ( + offre.expiration_date is not None + and date.today() < offre.expiration_date + ) + ): + 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[:] logs = ( EntrepriseLog.query.order_by(EntrepriseLog.date.desc()) .filter_by(object=id) @@ -158,7 +157,7 @@ def fiche_entreprise(id): ) return render_template( "entreprises/fiche_entreprise.html", - title=("Fiche entreprise"), + title="Fiche entreprise", entreprise=entreprise, contacts=contacts, offres=offres_with_files, @@ -167,17 +166,52 @@ def fiche_entreprise(id): ) -@bp.route("/offres", methods=["GET"]) -def offres(): +@bp.route("/logs/", methods=["GET"]) +@permission_required(Permission.RelationsEntreprisesView) +def logs_entreprise(id): """ - Permet d'afficher la page où l'on recoit les offres + Permet d'afficher les logs d'une entreprise + """ + page = request.args.get("page", 1, type=int) + entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( + description=f"logs fiche entreprise {id} inconnu" + ) + logs = ( + EntrepriseLog.query.order_by(EntrepriseLog.date.desc()) + .filter_by(object=id) + .paginate(page=page, per_page=20) + ) + return render_template( + "entreprises/logs_entreprise.html", + title="Logs", + logs=logs, + entreprise=entreprise, + ) - Retourne: template de la page (offres.html) - Arguments du template: - title: - titre de la page - offres_recus: - liste des offres reçues + +@bp.route("/fiche_entreprise_validation/", methods=["GET"]) +@permission_required(Permission.RelationsEntreprisesValidate) +def fiche_entreprise_validation(id): + """ + Permet d'afficher la fiche entreprise d'une entreprise a valider + """ + entreprise = Entreprise.query.filter_by(id=id, visible=False).first_or_404( + description=f"fiche entreprise (validation) {id} inconnue" + ) + contacts = entreprise.contacts + return render_template( + "entreprises/fiche_entreprise_validation.html", + title="Validation fiche entreprise", + entreprise=entreprise, + contacts=contacts, + ) + + +@bp.route("/offres_recues", methods=["GET"]) +@permission_required(Permission.RelationsEntreprisesView) +def offres_recues(): + """ + Permet d'afficher la page où l'on peut voir les offres reçues """ offres_recues = ( db.session.query(EntrepriseEnvoiOffre, EntrepriseOffre) @@ -185,12 +219,58 @@ def offres(): .join(EntrepriseOffre, EntrepriseOffre.id == EntrepriseEnvoiOffre.offre_id) .all() ) + offres_recues_with_files = [] + for offre in offres_recues: + files = [] + path = os.path.join( + Config.SCODOC_VAR_DIR, + "entreprises", + f"{offre[1].entreprise_id}", + f"{offre[1].id}", + ) + if os.path.exists(path): + for dir in glob.glob( + f"{path}/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]" + ): + 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]) return render_template( - "entreprises/offres.html", title=("Offres"), offres_recues=offres_recues + "entreprises/offres_recues.html", + title="Offres reçues", + offres_recues=offres_recues_with_files, + ) + + +@bp.route("/fiche_entreprise//offres_expirees") +@permission_required(Permission.RelationsEntreprisesView) +def offres_expirees(id): + """ + Permet d'afficher la liste des offres expirés d'une entreprise + """ + entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( + description=f"fiche entreprise {id} inconnue" + ) + offres_expirees_with_files = [] + depts = are.get_depts() + for offre in entreprise.offres: + if offre.expired or ( + offre.expiration_date is not None and date.today() > offre.expiration_date + ): + offre_expiree_with_files = are.get_offre_files_and_depts(offre, depts) + if offre_expiree_with_files is not None: + offres_expirees_with_files.append(offre_expiree_with_files) + return render_template( + "entreprises/offres_expirees.html", + title="Offres expirées", + entreprise=entreprise, + offres_expirees=offres_expirees_with_files, ) @bp.route("/add_entreprise", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def add_entreprise(): """ Permet d'ajouter une entreprise dans la base avec un formulaire @@ -203,7 +283,7 @@ def add_entreprise(): adresse=form.adresse.data.strip(), codepostal=form.codepostal.data.strip(), ville=form.ville.data.strip(), - pays=form.pays.data.strip(), + pays=form.pays.data.strip() if form.pays.data.strip() else "FRANCE", ) db.session.add(entreprise) db.session.commit() @@ -218,40 +298,50 @@ def add_entreprise(): service=form.service.data.strip(), ) db.session.add(contact) - 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", - ) - db.session.add(log) - db.session.commit() - flash("L'entreprise a été ajouté à la liste.") - return redirect(url_for("entreprises.index")) + 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", + ) + db.session.add(log) + db.session.commit() + flash("L'entreprise a été ajouté à la liste.") + return redirect(url_for("entreprises.index")) + else: + entreprise.visible = False + db.session.commit() + if EntreprisePreferences.get_email_notifications(): + are.send_email_notifications_entreprise( + "entreprise en attente de validation", entreprise, contact + ) + 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 + contact"), + title="Ajout entreprise avec contact", form=form, ) @bp.route("/edit_entreprise/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def edit_entreprise(id): """ Permet de modifier une entreprise de la base avec un formulaire - - Arguments: - id: - l'id de l'entreprise """ - entreprise = Entreprise.query.filter_by(id=id).first_or_404() - form = EntrepriseModificationForm() + entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( + description=f"entreprise {id} inconnue" + ) + form = EntrepriseModificationForm(hidden_entreprise_siret=entreprise.siret) if form.validate_on_submit(): nom_entreprise = f"{form.nom.data.strip()}" if entreprise.nom != form.nom.data.strip(): log = EntrepriseLog( authenticated_user=current_user.user_name, object=entreprise.id, - text=f"{nom_entreprise} - Modification du nom (ancien nom : {entreprise.nom})", + text=f"{nom_entreprise} - Modification du nom (ancien nom: {entreprise.nom})", ) entreprise.nom = form.nom.data.strip() db.session.add(log) @@ -259,7 +349,7 @@ def edit_entreprise(id): log = EntrepriseLog( authenticated_user=current_user.user_name, object=entreprise.id, - text=f"{nom_entreprise} - Modification de l'adresse (ancienne adresse : {entreprise.adresse})", + text=f"{nom_entreprise} - Modification de l'adresse (ancienne adresse: {entreprise.adresse})", ) entreprise.adresse = form.adresse.data.strip() db.session.add(log) @@ -267,7 +357,7 @@ def edit_entreprise(id): log = EntrepriseLog( authenticated_user=current_user.user_name, object=entreprise.id, - text=f"{nom_entreprise} - Modification du code postal (ancien code postal : {entreprise.codepostal})", + text=f"{nom_entreprise} - Modification du code postal (ancien code postal: {entreprise.codepostal})", ) entreprise.codepostal = form.codepostal.data.strip() db.session.add(log) @@ -275,17 +365,19 @@ def edit_entreprise(id): log = EntrepriseLog( authenticated_user=current_user.user_name, object=entreprise.id, - text=f"{nom_entreprise} - Modification de la ville (ancienne ville : {entreprise.ville})", + text=f"{nom_entreprise} - Modification de la ville (ancienne ville: {entreprise.ville})", ) entreprise.ville = form.ville.data.strip() db.session.add(log) - if entreprise.pays != form.pays.data.strip(): + if entreprise.pays != form.pays.data.strip() or not form.pays.data.strip(): log = EntrepriseLog( authenticated_user=current_user.user_name, object=entreprise.id, - text=f"{nom_entreprise} - Modification du pays (ancien pays : {entreprise.pays})", + text=f"{nom_entreprise} - Modification du pays (ancien pays: {entreprise.pays})", + ) + entreprise.pays = ( + form.pays.data.strip() if form.pays.data.strip() else "FRANCE" ) - entreprise.pays = form.pays.data.strip() db.session.add(log) db.session.commit() flash("L'entreprise a été modifié.") @@ -298,23 +390,32 @@ def edit_entreprise(id): form.ville.data = entreprise.ville form.pays.data = entreprise.pays return render_template( - "entreprises/form.html", title=("Modification entreprise"), form=form + "entreprises/form_modification_entreprise.html", + title="Modification entreprise", + form=form, ) @bp.route("/delete_entreprise/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def delete_entreprise(id): """ Permet de supprimer une entreprise de la base avec un formulaire de confirmation - - Arguments: - id: - l'id de l'entreprise """ - entreprise = Entreprise.query.filter_by(id=id).first_or_404() + entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( + description=f"entreprise {id} inconnue" + ) form = SuppressionConfirmationForm() if form.validate_on_submit(): db.session.delete(entreprise) + # supprime les fichiers attachés aux offres + path = os.path.join( + Config.SCODOC_VAR_DIR, + "entreprises", + f"{entreprise.id}", + ) + if os.path.isdir(path): + shutil.rmtree(path) log = EntrepriseLog( authenticated_user=current_user.user_name, object=entreprise.id, @@ -326,21 +427,70 @@ def delete_entreprise(id): return redirect(url_for("entreprises.index")) return render_template( "entreprises/delete_confirmation.html", - title=("Supression entreprise"), + title="Supression entreprise", + form=form, + ) + + +@bp.route("/validate_entreprise/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesValidate) +def validate_entreprise(id): + """ + Permet de valider une entreprise + """ + form = ValidationConfirmationForm() + entreprise = Entreprise.query.filter_by(id=id, visible=False).first_or_404( + description=f"entreprise (validation) {id} inconnue" + ) + if form.validate_on_submit(): + entreprise.visible = True + 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", + ) + db.session.add(log) + db.session.commit() + flash("L'entreprise a été validé et ajouté à la liste.") + return redirect(url_for("entreprises.index")) + return render_template( + "entreprises/validate_confirmation.html", + title="Validation entreprise", + form=form, + ) + + +@bp.route("/delete_validation_entreprise/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesValidate) +def delete_validation_entreprise(id): + """ + Permet de supprimer une entreprise en attente de validation avec une formulaire de validation + """ + entreprise = Entreprise.query.filter_by(id=id, visible=False).first_or_404( + description=f"entreprise (validation) {id} inconnue" + ) + form = SuppressionConfirmationForm() + if form.validate_on_submit(): + db.session.delete(entreprise) + db.session.commit() + flash("L'entreprise a été supprimé de la liste des entreprise à valider.") + return redirect(url_for("entreprises.validation")) + return render_template( + "entreprises/delete_confirmation.html", + title="Supression entreprise", form=form, ) @bp.route("/add_offre/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def add_offre(id): """ Permet d'ajouter une offre a une entreprise - - Arguments: - id: - l'id de l'entreprise """ - entreprise = Entreprise.query.filter_by(id=id).first_or_404() + entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( + description=f"entreprise {id} inconnue" + ) form = OffreCreationForm() if form.validate_on_submit(): offre = EntrepriseOffre( @@ -350,37 +500,66 @@ def add_offre(id): type_offre=form.type_offre.data.strip(), missions=form.missions.data.strip(), duree=form.duree.data.strip(), + expiration_date=form.expiration_date.data, ) + db.session.add(offre) + db.session.commit() + db.session.refresh(offre) + for dept in form.depts.data: + offre_dept = EntrepriseOffreDepartement( + offre_id=offre.id, + dept_id=dept, + ) + db.session.add(offre_dept) log = EntrepriseLog( authenticated_user=current_user.user_name, object=entreprise.id, text="Création d'une offre", ) - db.session.add(offre) db.session.add(log) db.session.commit() flash("L'offre a été ajouté à la fiche entreprise.") return redirect(url_for("entreprises.fiche_entreprise", id=entreprise.id)) - return render_template("entreprises/form.html", title=("Ajout offre"), form=form) + return render_template( + "entreprises/form.html", + title="Ajout offre", + form=form, + ) @bp.route("/edit_offre/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def edit_offre(id): """ Permet de modifier une offre - - Arguments: - id: - l'id de l'offre """ - offre = EntrepriseOffre.query.filter_by(id=id).first_or_404() + offre = EntrepriseOffre.query.filter_by(id=id).first_or_404( + description=f"offre {id} inconnue" + ) + offre_depts = EntrepriseOffreDepartement.query.filter_by(offre_id=offre.id).all() form = OffreModificationForm() + offre_depts_list = [(offre_dept.dept_id) for offre_dept in offre_depts] if form.validate_on_submit(): offre.intitule = form.intitule.data.strip() offre.description = form.description.data.strip() offre.type_offre = form.type_offre.data.strip() offre.missions = form.missions.data.strip() offre.duree = form.duree.data.strip() + offre.expiration_date = form.expiration_date.data + if offre_depts_list != form.depts.data: + for dept in form.depts.data: + if dept not in offre_depts_list: + offre_dept = EntrepriseOffreDepartement( + offre_id=offre.id, + dept_id=dept, + ) + db.session.add(offre_dept) + for dept in offre_depts_list: + if dept not in form.depts.data: + offre_dept = EntrepriseOffreDepartement.query.filter_by( + offre_id=offre.id, dept_id=dept + ).first_or_404() + db.session.delete(offre_dept) log = EntrepriseLog( authenticated_user=current_user.user_name, object=offre.entreprise_id, @@ -396,25 +575,36 @@ def edit_offre(id): form.type_offre.data = offre.type_offre form.missions.data = offre.missions form.duree.data = offre.duree + form.expiration_date.data = offre.expiration_date + form.depts.data = offre_depts_list return render_template( - "entreprises/form.html", title=("Modification offre"), form=form + "entreprises/form.html", + title="Modification offre", + form=form, ) @bp.route("/delete_offre/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def delete_offre(id): """ Permet de supprimer une offre - - Arguments: - id: - l'id de l'offre """ - offre = EntrepriseOffre.query.filter_by(id=id).first_or_404() + offre = EntrepriseOffre.query.filter_by(id=id).first_or_404( + description=f"offre {id} inconnue" + ) entreprise_id = offre.entreprise.id form = SuppressionConfirmationForm() if form.validate_on_submit(): db.session.delete(offre) + path = os.path.join( + Config.SCODOC_VAR_DIR, + "entreprises", + f"{entreprise_id}", + f"{offre.id}", + ) + if os.path.isdir(path): + shutil.rmtree(path) log = EntrepriseLog( authenticated_user=current_user.user_name, object=offre.entreprise_id, @@ -425,20 +615,53 @@ def delete_offre(id): flash("L'offre a été supprimé de la fiche entreprise.") return redirect(url_for("entreprises.fiche_entreprise", id=entreprise_id)) return render_template( - "entreprises/delete_confirmation.html", title=("Supression offre"), form=form + "entreprises/delete_confirmation.html", + title="Supression offre", + form=form, ) +@bp.route("/delete_offre_recue/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesView) +def delete_offre_recue(id): + """ + Permet de supprimer une offre reçue + """ + offre_recue = EntrepriseEnvoiOffre.query.filter_by( + id=id, receiver_id=current_user.id + ).first_or_404(description=f"offre recu {id} inconnue") + db.session.delete(offre_recue) + db.session.commit() + return redirect(url_for("entreprises.offres_recues")) + + +@bp.route("/expired/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) +def expired(id): + """ + Permet de rendre expirée et non expirée une offre + """ + offre = EntrepriseOffre.query.filter_by(id=id).first_or_404( + description=f"offre {id} inconnue" + ) + offre.expired = not offre.expired + db.session.commit() + if offre.expired: + flash("L'offre a été rendu expirée") + else: + flash("L'offre a été rendu non expirée") + return redirect(url_for("entreprises.fiche_entreprise", id=offre.entreprise_id)) + + @bp.route("/add_contact/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def add_contact(id): """ Permet d'ajouter un contact a une entreprise - - Arguments: - id: - l'id de l'entreprise """ - entreprise = Entreprise.query.filter_by(id=id).first_or_404() + entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( + description=f"entreprise {id} inconnue" + ) form = ContactCreationForm(hidden_entreprise_id=entreprise.id) if form.validate_on_submit(): contact = EntrepriseContact( @@ -460,20 +683,26 @@ def add_contact(id): db.session.commit() flash("Le contact a été ajouté à la fiche entreprise.") return redirect(url_for("entreprises.fiche_entreprise", id=entreprise.id)) - return render_template("entreprises/form.html", title=("Ajout contact"), form=form) + return render_template( + "entreprises/form.html", + title="Ajout contact", + form=form, + ) @bp.route("/edit_contact/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def edit_contact(id): """ Permet de modifier un contact - - Arguments: - id: - l'id du contact """ - contact = EntrepriseContact.query.filter_by(id=id).first_or_404() - form = ContactModificationForm() + 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, + ) if form.validate_on_submit(): contact.nom = form.nom.data.strip() contact.prenom = form.prenom.data.strip() @@ -500,31 +729,33 @@ def edit_contact(id): form.poste.data = contact.poste form.service.data = contact.service return render_template( - "entreprises/form.html", title=("Modification contact"), form=form + "entreprises/form.html", + title="Modification contact", + form=form, ) @bp.route("/delete_contact/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def delete_contact(id): """ Permet de supprimer un contact - - Arguments: - id: - l'id du contact """ - contact = EntrepriseContact.query.filter_by(id=id).first_or_404() - entreprise_id = contact.entreprise.id + 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 + 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=entreprise_id)) + return redirect( + url_for("entreprises.fiche_entreprise", id=contact.entreprise_id) + ) else: db.session.delete(contact) log = EntrepriseLog( @@ -535,22 +766,25 @@ def delete_contact(id): 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=entreprise_id)) + return redirect( + url_for("entreprises.fiche_entreprise", id=contact.entreprise_id) + ) return render_template( - "entreprises/delete_confirmation.html", title=("Supression contact"), form=form + "entreprises/delete_confirmation.html", + title="Supression contact", + form=form, ) @bp.route("/add_historique/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def add_historique(id): """ Permet d'ajouter un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise - - Arguments: - id: - l'id de l'entreprise """ - entreprise = Entreprise.query.filter_by(id=id).first_or_404() + entreprise = Entreprise.query.filter_by(id=id, visible=True).first_or_404( + description=f"entreprise {id} inconnue" + ) form = HistoriqueCreationForm() if form.validate_on_submit(): etudiant_nomcomplet = form.etudiant.data.upper().strip() @@ -581,20 +815,21 @@ def add_historique(id): 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"), form=form + "entreprises/ajout_historique.html", + title="Ajout historique", + form=form, ) @bp.route("/envoyer_offre/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesSend) def envoyer_offre(id): """ - Permet d'envoyer une offre à un utilisateur - - Arguments: - id: - l'id de l'offre + Permet d'envoyer une offre à un utilisateur ScoDoc """ - offre = EntrepriseOffre.query.filter_by(id=id).first_or_404() + offre = EntrepriseOffre.query.filter_by(id=id).first_or_404( + description=f"offre {id} inconnue" + ) form = EnvoiOffreForm() if form.validate_on_submit(): responsable_data = form.responsable.data.upper().strip() @@ -607,36 +842,34 @@ def envoyer_offre(id): .first() ) envoi_offre = EntrepriseEnvoiOffre( - sender_id=current_user.id, receiver_id=responsable.id, offre_id=offre.id + 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", title=("Envoyer une offre"), form=form + "entreprises/envoi_offre_form.html", + title="Envoyer une offre", + form=form, ) @bp.route("/etudiants") +@permission_required(Permission.RelationsEntreprisesChange) def json_etudiants(): """ Permet de récuperer un JSON avec tous les étudiants - - Arguments: - term: - le terme utilisé pour le filtre de l'autosuggest - - Retourne: - le JSON de tous les étudiants (nom, prenom, formation actuelle?) correspondant au terme """ - if request.args.get("term") == None: + if request.args.get("term") is None: abort(400) term = request.args.get("term").strip() etudiants = Identite.query.filter(Identite.nom.ilike(f"%{term}%")).all() list = [] - content = {} for etudiant in etudiants: + content = {} value = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}" if etudiant.inscription_courante() is not None: content = { @@ -647,52 +880,46 @@ def json_etudiants(): else: content = {"id": f"{etudiant.id}", "value": value} list.append(content) - content = {} return jsonify(results=list) @bp.route("/responsables") +@permission_required(Permission.RelationsEntreprisesChange) def json_responsables(): """ - Permet de récuperer un JSON avec tous les étudiants - - Arguments: - term: - le terme utilisé pour le filtre de l'autosuggest - - Retourne: - le JSON de tous les utilisateurs (nom, prenom, login) correspondant au terme + Permet de récuperer un JSON avec tous les utilisateurs ScoDoc """ - if request.args.get("term") == None: + if request.args.get("term") is None: abort(400) term = request.args.get("term").strip() responsables = User.query.filter( User.nom.ilike(f"%{term}%"), User.nom.is_not(None), User.prenom.is_not(None) ).all() list = [] - content = {} + for responsable in responsables: + content = {} value = f"{responsable.get_nomplogin()}" content = {"id": f"{responsable.id}", "value": value, "info": ""} list.append(content) - content = {} return jsonify(results=list) @bp.route("/export_entreprises") +@permission_required(Permission.RelationsEntreprisesExport) def export_entreprises(): """ Permet d'exporter la liste des entreprises sous format excel (.xlsx) """ - entreprises = Entreprise.query.all() + entreprises = Entreprise.query.filter_by(visible=True).all() if entreprises: - keys = ["siret", "nom", "adresse", "ville", "codepostal", "pays"] + keys = ["siret", "nom_entreprise", "adresse", "ville", "code_postal", "pays"] titles = keys[:] L = [ [entreprise.to_dict().get(k, "") for k in keys] for entreprise in entreprises ] - title = "entreprises" + title = "Entreprises" 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) @@ -700,30 +927,117 @@ def export_entreprises(): abort(404) +@bp.route("/get_import_entreprises_file_sample") +@permission_required(Permission.RelationsEntreprisesExport) +def get_import_entreprises_file_sample(): + """ + Permet de récupérer un fichier exemple vide pour pouvoir importer des entreprises + """ + keys = [ + "siret", + "nom_entreprise", + "adresse", + "ville", + "code_postal", + "pays", + ] + titles = keys[:] + title = "ImportEntreprises" + xlsx = sco_excel.excel_simple_table(titles=titles, sheet_name="Entreprises") + filename = title + return scu.send_file(xlsx, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) + + +@bp.route("/import_entreprises", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesExport) +def import_entreprises(): + """ + Permet d'importer des entreprises a l'aide d'un fichier excel (.xlsx) + """ + form = ImportForm() + if form.validate_on_submit(): + file = form.fichier.data + file_path = os.path.join( + Config.SCODOC_VAR_DIR, "tmp", secure_filename(file.filename) + ) + file.save(file_path) + data = sco_excel.excel_file_to_list(file_path) + os.remove(file_path) + entreprises_import = [] + siret_list = [] + ligne = 0 + titles = ["siret", "nom_entreprise", "adresse", "ville", "code_postal", "pays"] + if data[1][0] != titles: + flash("Veuillez utilisez la feuille excel à remplir") + return render_template( + "entreprises/import_entreprises.html", + title="Importation entreprises", + form=form, + ) + for entreprise_data in data[1][1:]: + ligne += 1 + if ( + are.verif_entreprise_data(entreprise_data) + and entreprise_data[0] not in siret_list + ): + siret_list.append(entreprise_data[0]) + 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], + visible=True, + ) + entreprises_import.append(entreprise) + + else: + flash(f"Erreur lors de l'importation (ligne {ligne})") + return render_template( + "entreprises/import_entreprises.html", + title="Importation entreprises", + form=form, + ) + + if len(entreprises_import) > 0: + for entreprise in entreprises_import: + db.session.add(entreprise) + log = EntrepriseLog( + authenticated_user=current_user.user_name, + text=f"Importation de {len(entreprises_import)} entreprise(s)", + ) + db.session.add(log) + db.session.commit() + flash(f"Importation réussie de {len(entreprises_import)} entreprise(s)") + return render_template( + "entreprises/import_entreprises.html", + title="Importation entreprises", + form=form, + entreprises_import=entreprises_import, + ) + else: + flash('Feuille "Entreprises" vide') + + return render_template( + "entreprises/import_entreprises.html", + title="Importation entreprises", + form=form, + ) + + @bp.route("/export_contacts") +@permission_required(Permission.RelationsEntreprisesExport) def export_contacts(): """ Permet d'exporter la liste des contacts sous format excel (.xlsx) """ - contacts = EntrepriseContact.query.all() - if contacts: - keys = ["nom", "prenom", "telephone", "mail", "poste", "service"] - titles = keys[:] - L = [[contact.to_dict().get(k, "") for k in keys] for contact in contacts] - title = "contacts" - 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) - - -@bp.route("/export_contacts_bis") -def export_contacts_bis(): - """ - Permet d'exporter la liste des contacts avec leur entreprise sous format excel (.xlsx) - """ - contacts = EntrepriseContact.query.all() + contacts = ( + db.session.query(EntrepriseContact) + .join(Entreprise, EntrepriseContact.entreprise_id == Entreprise.id) + .filter_by(visible=True) + .all() + ) if contacts: keys = [ "nom", @@ -732,18 +1046,11 @@ def export_contacts_bis(): "mail", "poste", "service", - "nom_entreprise", - "siret", - "adresse_entreprise", - "ville", - "codepostal", - "pays", + "entreprise_siret", ] titles = keys[:] - L = [ - [contact.to_dict_export().get(k, "") for k in keys] for contact in contacts - ] - title = "contacts" + L = [[contact.to_dict().get(k, "") for k in keys] for contact in contacts] + title = "Contacts" 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) @@ -751,22 +1058,120 @@ def export_contacts_bis(): abort(404) +@bp.route("/get_import_contacts_file_sample") +@permission_required(Permission.RelationsEntreprisesExport) +def get_import_contacts_file_sample(): + """ + Permet de récupérer un fichier exemple vide pour pouvoir importer des contacts + """ + keys = [ + "nom", + "prenom", + "telephone", + "mail", + "poste", + "service", + "entreprise_siret", + ] + titles = keys[:] + title = "ImportContacts" + xlsx = sco_excel.excel_simple_table(titles=titles, sheet_name="Contacts") + filename = title + return scu.send_file(xlsx, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) + + +@bp.route("/import_contacts", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesExport) +def import_contacts(): + """ + Permet d'importer des contacts a l'aide d'un fichier excel (.xlsx) + """ + form = ImportForm() + if form.validate_on_submit(): + file = form.fichier.data + file_path = os.path.join( + Config.SCODOC_VAR_DIR, "tmp", secure_filename(file.filename) + ) + file.save(file_path) + data = sco_excel.excel_file_to_list(file_path) + os.remove(file_path) + contacts_import = [] + contact_list = [] + ligne = 0 + titles = [ + "nom", + "prenom", + "telephone", + "mail", + "poste", + "service", + "entreprise_siret", + ] + if data[1][0] != titles: + flash("Veuillez utilisez la feuille excel à remplir") + return render_template( + "entreprises/import_contacts.html", + title="Importation contacts", + form=form, + ) + for contact_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], + ) + contacts_import.append(contact) + else: + flash(f"Erreur lors de l'importation (ligne {ligne})") + return render_template( + "entreprises/import_contacts.html", + title="Importation contacts", + form=form, + ) + + if len(contacts_import) > 0: + for contact in contacts_import: + db.session.add(contact) + log = EntrepriseLog( + authenticated_user=current_user.user_name, + text=f"Importation de {len(contacts_import)} contact(s)", + ) + db.session.add(log) + db.session.commit() + flash(f"Importation réussie de {len(contacts_import)} contact(s)") + return render_template( + "entreprises/import_contacts.html", + title="Importation Contacts", + form=form, + contacts_import=contacts_import, + ) + else: + flash('Feuille "Contacts" vide') + return render_template( + "entreprises/import_contacts.html", + title="Importation contacts", + form=form, + ) + + @bp.route( "/get_offre_file////" ) +@permission_required(Permission.RelationsEntreprisesView) def get_offre_file(entreprise_id, offre_id, filedir, filename): """ Permet de télécharger un fichier d'une offre - - Arguments: - entreprise_id: - l'id de l'entreprise - offre_id: - l'id de l'offre - filedir: - le répertoire du fichier - filename: - le nom du fichier """ if os.path.isfile( os.path.join( @@ -790,19 +1195,18 @@ def get_offre_file(entreprise_id, offre_id, filedir, filename): as_attachment=True, ) else: - abort(404) + abort(404, description=f"fichier {filename} inconnu") @bp.route("/add_offre_file/", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def add_offre_file(offre_id): """ Permet d'ajouter un fichier à une offre - - Arguments: - offre_id: - l'id de l'offre """ - offre = EntrepriseOffre.query.filter_by(id=offre_id).first_or_404() + offre = EntrepriseOffre.query.filter_by(id=offre_id).first_or_404( + description=f"offre {offre_id} inconnue" + ) form = AjoutFichierForm() if form.validate_on_submit(): date = f"{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}" @@ -820,22 +1224,21 @@ def add_offre_file(offre_id): flash("Le fichier a été ajouté a l'offre.") return redirect(url_for("entreprises.fiche_entreprise", id=offre.entreprise_id)) return render_template( - "entreprises/form.html", title=("Ajout fichier à une offre"), form=form + "entreprises/form.html", + title="Ajout fichier à une offre", + form=form, ) @bp.route("/delete_offre_file//", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesChange) def delete_offre_file(offre_id, filedir): """ Permet de supprimer un fichier d'une offre - - Arguments: - offre_id: - l'id de l'offre - filedir: - le répertoire du fichier """ - offre = EntrepriseOffre.query.filter_by(id=offre_id).first_or_404() + offre = EntrepriseOffre.query.filter_by(id=offre_id).first_or_404( + description=f"offre {offre_id} inconnue" + ) form = SuppressionConfirmationForm() if form.validate_on_submit(): path = os.path.join( @@ -853,6 +1256,27 @@ def delete_offre_file(offre_id, filedir): ) return render_template( "entreprises/delete_confirmation.html", - title=("Suppression fichier d'une offre"), + title="Suppression fichier d'une offre", + form=form, + ) + + +@bp.route("/preferences", methods=["GET", "POST"]) +@permission_required(Permission.RelationsEntreprisesValidate) +def preferences(): + """ + Permet d'afficher la page des préférences du module gestion des relations entreprises + """ + form = PreferencesForm() + if form.validate_on_submit(): + EntreprisePreferences.set_email_notifications(form.mail_entreprise.data.strip()) + EntreprisePreferences.set_check_siret(int(form.check_siret.data)) + return redirect(url_for("entreprises.index")) + elif request.method == "GET": + form.mail_entreprise.data = EntreprisePreferences.get_email_notifications() + form.check_siret.data = int(EntreprisePreferences.get_check_siret()) + return render_template( + "entreprises/preferences.html", + title="Préférences", form=form, ) diff --git a/app/forms/__init__.py b/app/forms/__init__.py new file mode 100644 index 0000000000..f850a58c35 --- /dev/null +++ b/app/forms/__init__.py @@ -0,0 +1 @@ +# empty but required for pylint diff --git a/app/forms/main/__init__.py b/app/forms/main/__init__.py new file mode 100644 index 0000000000..f850a58c35 --- /dev/null +++ b/app/forms/main/__init__.py @@ -0,0 +1 @@ +# empty but required for pylint diff --git a/app/forms/main/config_apo.py b/app/forms/main/config_apo.py index 6ebb6a8b30..facbf85fe3 100644 --- a/app/forms/main/config_apo.py +++ b/app/forms/main/config_apo.py @@ -29,17 +29,13 @@ Formulaires configuration Exports Apogée (codes) """ -from flask import flash, url_for, redirect, render_template from flask_wtf import FlaskForm from wtforms import SubmitField, validators from wtforms.fields.simple import StringField -from app import models -from app.models import ScoDocSiteConfig from app.models import SHORT_STR_LEN from app.scodoc import sco_codes_parcours -from app.scodoc import sco_utils as scu def _build_code_field(code): @@ -61,6 +57,7 @@ def _build_code_field(code): class CodesDecisionsForm(FlaskForm): + "Formulaire code décisions Apogée" ADC = _build_code_field("ADC") ADJ = _build_code_field("ADJ") ADM = _build_code_field("ADM") diff --git a/app/forms/main/config_logos.py b/app/forms/main/config_logos.py index 2be78713d6..db69ae35bf 100644 --- a/app/forms/main/config_logos.py +++ b/app/forms/main/config_logos.py @@ -30,17 +30,15 @@ Formulaires configuration logos Contrib @jmp, dec 21 """ -import re from flask import flash, url_for, redirect, render_template from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed -from wtforms import SelectField, SubmitField, FormField, validators, FieldList +from wtforms import SubmitField, FormField, validators, FieldList +from wtforms import ValidationError from wtforms.fields.simple import StringField, HiddenField -from app import AccessDenied from app.models import Departement -from app.models import ScoDocSiteConfig from app.scodoc import sco_logos, html_sco_header from app.scodoc import sco_utils as scu from app.scodoc.sco_config_actions import ( @@ -49,10 +47,9 @@ from app.scodoc.sco_config_actions import ( LogoInsert, ) -from flask_login import current_user - from app.scodoc.sco_logos import find_logo + JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [] CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS @@ -111,6 +108,15 @@ def dept_key_to_id(dept_key): return dept_key +def logo_name_validator(message=None): + def validate_logo_name(form, field): + name = field.data if field.data else "" + if not scu.is_valid_filename(name): + raise ValidationError(message) + + return validate_logo_name + + class AddLogoForm(FlaskForm): """Formulaire permettant l'ajout d'un logo (dans un département)""" @@ -118,11 +124,7 @@ class AddLogoForm(FlaskForm): name = StringField( label="Nom", validators=[ - validators.regexp( - r"^[a-zA-Z0-9-_]*$", - re.IGNORECASE, - "Ne doit comporter que lettres, chiffres, _ ou -", - ), + logo_name_validator("Nom de logo invalide (alphanumérique, _)"), validators.Length( max=20, message="Un nom ne doit pas dépasser 20 caractères" ), @@ -373,11 +375,11 @@ def config_logos(): if action: action.execute() flash(action.message) - return redirect( - url_for( - "scodoc.configure_logos", - ) - ) + return redirect(url_for("scodoc.configure_logos")) + else: + if not form.validate(): + scu.flash_errors(form) + return render_template( "config_logos.html", scodoc_dept=None, diff --git a/app/forms/main/config_main.py b/app/forms/main/config_main.py index d6536a8de7..2c2aa3d5ea 100644 --- a/app/forms/main/config_main.py +++ b/app/forms/main/config_main.py @@ -31,13 +31,14 @@ Formulaires configuration Exports Apogée (codes) from flask import flash, url_for, redirect, request, render_template from flask_wtf import FlaskForm -from wtforms import SelectField, SubmitField +from wtforms import BooleanField, SelectField, SubmitField import app from app.models import ScoDocSiteConfig +import app.scodoc.sco_utils as scu -class ScoDocConfigurationForm(FlaskForm): +class BonusConfigurationForm(FlaskForm): "Panneau de configuration des logos" bonus_sport_func_name = SelectField( label="Fonction de calcul des bonus sport&culture", @@ -46,31 +47,57 @@ class ScoDocConfigurationForm(FlaskForm): for (name, displayed_name) in ScoDocSiteConfig.get_bonus_sport_class_list() ], ) - submit = SubmitField("Valider") - cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) + submit_bonus = SubmitField("Valider") + cancel_bonus = SubmitField("Annuler", render_kw={"formnovalidate": True}) + + +class ScoDocConfigurationForm(FlaskForm): + "Panneau de configuration avancée" + enable_entreprises = BooleanField("activer le module entreprises") + submit_scodoc = SubmitField("Valider") + cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True}) def configuration(): "Page de configuration principale" # nb: le contrôle d'accès (SuperAdmin) doit être fait dans la vue - form = ScoDocConfigurationForm( + form_bonus = BonusConfigurationForm( data={ "bonus_sport_func_name": ScoDocSiteConfig.get_bonus_sport_class_name(), } ) - if request.method == "POST" and form.cancel.data: # cancel button + form_scodoc = ScoDocConfigurationForm( + data={"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled()} + ) + if request.method == "POST" and ( + form_bonus.cancel_bonus.data or form_scodoc.cancel_scodoc.data + ): # cancel button return redirect(url_for("scodoc.index")) - if form.validate_on_submit(): + if form_bonus.submit_bonus.data and form_bonus.validate(): if ( - form.data["bonus_sport_func_name"] + form_bonus.data["bonus_sport_func_name"] != ScoDocSiteConfig.get_bonus_sport_class_name() ): - ScoDocSiteConfig.set_bonus_sport_class(form.data["bonus_sport_func_name"]) + ScoDocSiteConfig.set_bonus_sport_class( + form_bonus.data["bonus_sport_func_name"] + ) app.clear_scodoc_cache() flash(f"Fonction bonus sport&culture configurée.") return redirect(url_for("scodoc.index")) + elif form_scodoc.submit_scodoc.data and form_scodoc.validate(): + if ScoDocSiteConfig.enable_entreprises( + enabled=form_scodoc.data["enable_entreprises"] + ): + flash( + "Module entreprise " + + ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé") + ) + return redirect(url_for("scodoc.index")) return render_template( "configuration.html", - form=form, + form_bonus=form_bonus, + form_scodoc=form_scodoc, + scu=scu, + title="Configuration", ) diff --git a/app/forms/main/create_dept.py b/app/forms/main/create_dept.py index cd05340574..c0e18eff04 100644 --- a/app/forms/main/create_dept.py +++ b/app/forms/main/create_dept.py @@ -29,7 +29,6 @@ Formulaires création département """ -from flask import flash, url_for, redirect, render_template from flask_wtf import FlaskForm from wtforms import SubmitField, validators from wtforms.fields.simple import StringField, BooleanField diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index 7981642539..429c63898e 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -81,6 +81,9 @@ class ApcReferentielCompetences(db.Model, XMLModel): ) formations = db.relationship("Formation", backref="referentiel_competence") + def __repr__(self): + return f"" + def to_dict(self): """Représentation complète du ref. de comp. comme un dict. @@ -110,7 +113,8 @@ class ApcCompetence(db.Model, XMLModel): db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False ) # les compétences dans Orébut sont identifiées par leur id unique - id_orebut = db.Column(db.Text(), nullable=True, index=True, unique=True) + # (mais id_orebut n'est pas unique car le même ref. pourra être chargé dans plusieurs depts) + id_orebut = db.Column(db.Text(), nullable=True, index=True) titre = db.Column(db.Text(), nullable=False, index=True) titre_long = db.Column(db.Text()) couleur = db.Column(db.Text()) @@ -139,6 +143,9 @@ class ApcCompetence(db.Model, XMLModel): cascade="all, delete-orphan", ) + def __repr__(self): + return f"" + def to_dict(self): return { "id_orebut": self.id_orebut, diff --git a/app/models/config.py b/app/models/config.py index 8a56d3879e..1271beeb98 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -69,6 +69,7 @@ class ScoDocSiteConfig(db.Model): "INSTITUTION_ADDRESS": str, "INSTITUTION_CITY": str, "DEFAULT_PDF_FOOTER_TEMPLATE": str, + "enable_entreprises": bool, } def __init__(self, name, value): @@ -207,3 +208,27 @@ class ScoDocSiteConfig(db.Model): cfg.value = code_apo db.session.add(cfg) db.session.commit() + + @classmethod + def is_entreprises_enabled(cls) -> bool: + """True si on doit activer le module entreprise""" + cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first() + if (cfg is None) or not cfg.value: + return False + return True + + @classmethod + def enable_entreprises(cls, enabled=True) -> bool: + """Active (ou déactive) le module entreprises. True si changement.""" + if enabled != ScoDocSiteConfig.is_entreprises_enabled(): + cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first() + if cfg is None: + cfg = ScoDocSiteConfig( + name="enable_entreprises", value="on" if enabled else "" + ) + else: + cfg.value = "on" if enabled else "" + db.session.add(cfg) + db.session.commit() + return True + return False diff --git a/app/models/departements.py b/app/models/departements.py index ebe5cc1451..44a963ca0b 100644 --- a/app/models/departements.py +++ b/app/models/departements.py @@ -55,6 +55,9 @@ def create_dept(acronym: str, visible=True) -> Departement: "Create new departement" from app.models import ScoPreference + existing = Departement.query.filter_by(acronym=acronym).count() + if existing: + raise ValueError(f"acronyme {acronym} déjà existant") departement = Departement(acronym=acronym, visible=visible) p1 = ScoPreference(name="DeptName", value=acronym, departement=departement) db.session.add(p1) diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 72e2088f5c..6c342482c5 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -4,16 +4,19 @@ et données rattachées (adresses, annotations, ...) """ +import datetime from functools import cached_property from flask import abort, url_for from flask import g, request import sqlalchemy +from sqlalchemy import desc, text -from app import db +from app import db, log from app import models from app.scodoc import notesdb as ndb from app.scodoc.sco_bac import Baccalaureat +import app.scodoc.sco_utils as scu class Identite(db.Model): @@ -74,6 +77,18 @@ class Identite(db.Model): """ return {"M": "M.", "F": "Mme", "X": ""}[self.civilite] + def sex_nom(self, no_accents=False) -> str: + "'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'" + s = f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()}" + if no_accents: + return scu.suppress_accents(s) + return s + + @property + def e(self): + "terminaison en français: 'ne', '', 'ou '(e)'" + return {"M": "", "F": "e"}.get(self.civilite, "(e)") + def nom_disp(self) -> str: "Nom à afficher" if self.nom_usuel: @@ -108,14 +123,22 @@ class Identite(db.Model): r.append("-".join([x.lower().capitalize() for x in fields])) return " ".join(r) + @property + def nom_short(self): + "Nom et début du prénom pour table recap: 'DUPONT Pi.'" + return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}." + @cached_property def sort_key(self) -> tuple: "clé pour tris par ordre alphabétique" - return (self.nom_usuel or self.nom).lower(), self.prenom.lower() + return ( + scu.suppress_accents(self.nom_usuel or self.nom or "").lower(), + scu.suppress_accents(self.prenom or "").lower(), + ) def get_first_email(self, field="email") -> str: "Le mail associé à la première adrese de l'étudiant, ou None" - return self.adresses[0].email or None if self.adresses.count() > 0 else None + return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None def to_dict_scodoc7(self): """Représentation dictionnaire, @@ -126,30 +149,41 @@ class Identite(db.Model): # ScoDoc7 output_formators: (backward compat) e["etudid"] = self.id e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"]) + e["ne"] = self.e return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty def to_dict_bul(self, include_urls=True): - """Infos exportées dans les bulletins""" + """Infos exportées dans les bulletins + L'étudiant, et sa première adresse. + """ from app.scodoc import sco_photos d = { "civilite": self.civilite, - "code_ine": self.code_ine, - "code_nip": self.code_nip, - "date_naissance": self.date_naissance.isoformat() + "code_ine": self.code_ine or "", + "code_nip": self.code_nip or "", + "date_naissance": self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance - else None, - "email": self.get_first_email(), + else "", + "email": self.get_first_email() or "", "emailperso": self.get_first_email("emailperso"), "etudid": self.id, "nom": self.nom_disp(), - "prenom": self.prenom, + "prenom": self.prenom or "", + "nomprenom": self.nomprenom or "", + "lieu_naissance": self.lieu_naissance or "", + "dept_naissance": self.dept_naissance or "", + "nationalite": self.nationalite or "", + "boursier": self.boursier or "", } if include_urls: d["fiche_url"] = url_for( "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id ) - d["photo_url"] = (sco_photos.get_etud_photo_url(self.id),) + d["photo_url"] = sco_photos.get_etud_photo_url(self.id) + adresse = self.adresses.first() + if adresse: + d.update(adresse.to_dict(convert_nulls_to_str=True)) return d def inscription_courante(self): @@ -163,6 +197,23 @@ class Identite(db.Model): ] return r[0] if r else None + def inscriptions_courantes(self) -> list: # -> list[FormSemestreInscription]: + """Liste des inscriptions à des semestres _courants_ + (il est rare qu'il y en ai plus d'une, mais c'est possible). + Triées par date de début de semestre décroissante (le plus récent en premier). + """ + from app.models.formsemestre import FormSemestre, FormSemestreInscription + + return ( + FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) + .filter( + FormSemestreInscription.etudid == self.id, + text("date_debut < now() and date_fin > now()"), + ) + .order_by(desc(FormSemestre.date_debut)) + .all() + ) + def inscription_courante_date(self, date_debut, date_fin): """La première inscription à un formsemestre incluant la période [date_debut, date_fin] @@ -174,8 +225,8 @@ class Identite(db.Model): ] return r[0] if r else None - def etat_inscription(self, formsemestre_id): - """etat de l'inscription de cet étudiant au semestre: + def inscription_etat(self, formsemestre_id): + """État de l'inscription de cet étudiant au semestre: False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF """ # voir si ce n'est pas trop lent: @@ -186,6 +237,110 @@ class Identite(db.Model): return ins.etat return False + def inscription_descr(self) -> dict: + """Description de l'état d'inscription""" + inscription_courante = self.inscription_courante() + if inscription_courante: + titre_sem = inscription_courante.formsemestre.titre_mois() + return { + "etat_in_cursem": inscription_courante.etat, + "inscription_courante": inscription_courante, + "inscription": titre_sem, + "inscription_str": "Inscrit en " + titre_sem, + "situation": self.descr_situation_etud(), + } + else: + if self.formsemestre_inscriptions: + # cherche l'inscription la plus récente: + fin_dernier_sem = max( + [ + inscr.formsemestre.date_debut + for inscr in self.formsemestre_inscriptions + ] + ) + if fin_dernier_sem > datetime.date.today(): + inscription = "futur" + situation = "futur élève" + else: + inscription = "ancien" + situation = "ancien élève" + else: + inscription = ("non inscrit",) + situation = inscription + return { + "etat_in_cursem": "?", + "inscription_courante": None, + "inscription": inscription, + "inscription_str": inscription, + "situation": situation, + } + + def descr_situation_etud(self) -> str: + """Chaîne décrivant la situation _actuelle_ de l'étudiant. + Exemple: + "inscrit en BUT R&T semestre 2 FI (Jan 2022 - Jul 2022) le 16/01/2022" + ou + "non inscrit" + """ + inscriptions_courantes = self.inscriptions_courantes() + if inscriptions_courantes: + inscr = inscriptions_courantes[0] + if inscr.etat == scu.INSCRIT: + situation = f"inscrit{self.e} en {inscr.formsemestre.titre_mois()}" + # Cherche la date d'inscription dans scolar_events: + events = models.ScolarEvent.query.filter_by( + etudid=self.id, + formsemestre_id=inscr.formsemestre.id, + event_type="INSCRIPTION", + ).all() + if not events: + log( + f"*** situation inconsistante pour {self} (inscrit mais pas d'event)" + ) + situation += " (inscription non enregistrée)" # ??? + else: + date_ins = events[0].event_date + situation += date_ins.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: + log( + f"*** situation inconsistante pour {self} (demission mais pas d'event)" + ) + date_dem = "???" # ??? + else: + date_dem = events[0].event_date + situation += date_dem.strftime(" le %d/%m/%Y") + else: + situation = "non inscrit" + self.e + + return situation + + def photo_html(self, title=None, size="small") -> str: + """HTML img tag for the photo, either in small size (h90) + or original size (size=="orig") + """ + from app.scodoc import sco_photos + + # sco_photo traite des dicts: + return sco_photos.etud_photo_html( + etud=dict( + etudid=self.id, + code_nip=self.code_nip, + nomprenom=self.nomprenom, + nom_disp=self.nom_disp(), + photo_filename=self.photo_filename, + ), + title=title, + size=size, + ) + def make_etud_args( etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True @@ -209,12 +364,15 @@ def make_etud_args( vals = request.args else: vals = {} - if "etudid" in vals: - args = {"etudid": int(vals["etudid"])} - elif "code_nip" in vals: - args = {"code_nip": str(vals["code_nip"])} - elif "code_ine" in vals: - args = {"code_ine": str(vals["code_ine"])} + try: + if "etudid" in vals: + args = {"etudid": int(vals["etudid"])} + elif "code_nip" in vals: + args = {"code_nip": str(vals["code_nip"])} + elif "code_ine" in vals: + args = {"code_ine": str(vals["code_ine"])} + except ValueError: + args = {} if not args: if abort_404: abort(404, "pas d'étudiant sélectionné") @@ -250,6 +408,14 @@ class Adresse(db.Model): ) description = db.Column(db.Text) + def to_dict(self, convert_nulls_to_str=False): + """Représentation dictionnaire,""" + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + if convert_nulls_to_str: + return {k: e[k] or "" for k in e} + return e + class Admission(db.Model): """Informations liées à l'admission d'un étudiant""" diff --git a/app/models/formations.py b/app/models/formations.py index edd57097d0..c0f375ddc6 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -59,6 +59,10 @@ class Formation(db.Model): """get l'instance de TypeParcours de cette formation""" return sco_codes_parcours.get_parcours_from_code(self.type_parcours) + def get_titre_version(self) -> str: + """Titre avec version""" + return f"{self.acronyme} {self.titre} v{self.version}" + def is_apc(self): "True si formation APC avec SAE (BUT)" return self.get_parcours().APC_SAE diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index d874a3ad14..4ec90052b7 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -12,7 +12,6 @@ from app import log from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN from app.models import CODE_STR_LEN -from app.models import UniteEns import app.scodoc.sco_utils as scu from app.models.ues import UniteEns @@ -23,6 +22,7 @@ from app.scodoc import sco_codes_parcours from app.scodoc import sco_preferences from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_utils import MONTH_NAMES_ABBREV class FormSemestre(db.Model): @@ -104,6 +104,11 @@ class FormSemestre(db.Model): lazy=True, backref=db.backref("formsemestres", lazy=True), ) + partitions = db.relationship( + "Partition", + backref=db.backref("formsemestre", lazy=True), + lazy="dynamic", + ) # Ancien id ScoDoc7 pour les migrations de bases anciennes # ne pas utiliser après migrate_scodoc7_dept_archives scodoc7_id = db.Column(db.Text(), nullable=True) @@ -117,10 +122,12 @@ class FormSemestre(db.Model): return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>" def to_dict(self): + "dict (compatible ScoDoc7)" d = dict(self.__dict__) d.pop("_sa_instance_state", None) # ScoDoc7 output_formators: (backward compat) d["formsemestre_id"] = self.id + d["titre_num"] = self.titre_num() if self.date_debut: d["date_debut"] = self.date_debut.strftime("%d/%m/%Y") d["date_debut_iso"] = self.date_debut.isoformat() @@ -154,10 +161,9 @@ class FormSemestre(db.Model): d["periode"] = 1 # typiquement, début en septembre: S1, S3... else: d["periode"] = 2 # typiquement, début en février: S2, S4... - d["titre_num"] = self.titre_num() d["titreannee"] = self.titre_annee() - d["mois_debut"] = f"{self.date_debut.month} {self.date_debut.year}" - d["mois_fin"] = f"{self.date_fin.month} {self.date_fin.year}" + d["mois_debut"] = self.mois_debut() + d["mois_fin"] = self.mois_fin() d["titremois"] = "%s %s (%s - %s)" % ( d["titre_num"], self.modalite or "", @@ -167,7 +173,6 @@ class FormSemestre(db.Model): d["session_id"] = self.session_id() d["etapes"] = self.etapes_apo_vdi() d["etapes_apo_str"] = self.etapes_apo_str() - d["responsables"] = [u.id for u in self.responsables] # liste des ids return d def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery: @@ -200,7 +205,11 @@ class FormSemestre(db.Model): modimpls = self.modimpls.all() if self.formation.is_apc(): modimpls.sort( - key=lambda m: (m.module.module_type, m.module.numero, m.module.code) + key=lambda m: ( + m.module.module_type or 0, + m.module.numero or 0, + m.module.code or 0, + ) ) else: modimpls.sort( @@ -283,6 +292,7 @@ class FormSemestre(db.Model): """chaîne "J. Dupond, X. Martin" ou "Jacques Dupond, Xavier Martin" """ + # was "nomcomplet" if not self.responsables: return "" if abbrev_prenom: @@ -290,10 +300,22 @@ class FormSemestre(db.Model): else: return ", ".join([u.get_nomcomplet() for u in self.responsables]) + def est_responsable(self, user): + "True si l'user est l'un des responsables du semestre" + return user.id in [u.id for u in self.responsables] + def annee_scolaire_str(self): "2021 - 2022" return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month) + def mois_debut(self) -> str: + "Oct 2021" + return f"{MONTH_NAMES_ABBREV[self.date_debut.month - 1]} {self.date_debut.year}" + + def mois_fin(self) -> str: + "Jul 2022" + return f"{MONTH_NAMES_ABBREV[self.date_fin.month - 1]} {self.date_fin.year}" + def session_id(self) -> str: """identifiant externe de semestre de formation Exemple: RT-DUT-FI-S1-ANNEE @@ -354,7 +376,7 @@ class FormSemestre(db.Model): def get_abs_count(self, etudid): """Les comptes d'absences de cet étudiant dans ce semestre: - tuple (nb abs non justifiées, nb abs justifiées) + tuple (nb abs, nb abs justifiées) Utilise un cache. """ from app.scodoc import sco_abs @@ -363,19 +385,23 @@ class FormSemestre(db.Model): etudid, self.date_debut.isoformat(), self.date_fin.isoformat() ) - def get_inscrits(self, include_demdef=False) -> list[Identite]: + def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]: """Liste des étudiants inscrits à ce semestre Si include_demdef, tous les étudiants, avec les démissionnaires et défaillants. + Si order, tri par clé sort_key """ if include_demdef: - return [ins.etud for ins in self.inscriptions] + etuds = [ins.etud for ins in self.inscriptions] else: - return [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] + etuds = [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] + if order: + etuds.sort(key=lambda e: e.sort_key) + return etuds @cached_property def etudids_actifs(self) -> set: - "Set des etudids inscrits non démissionnaires" + "Set des etudids inscrits non démissionnaires et non défaillants" return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT} @cached_property @@ -554,6 +580,9 @@ class FormSemestreInscription(db.Model): # etape apogee d'inscription (experimental 2020) etape = db.Column(db.String(APO_CODE_STR_LEN)) + def __repr__(self): + return f"<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={self.formsemestre_id} etat={self.etat}>" + class NotesSemSet(db.Model): """semsets: ensemble de formsemestres pour exports Apogée""" diff --git a/app/models/groups.py b/app/models/groups.py index 902298ccf1..f6452cf7ca 100644 --- a/app/models/groups.py +++ b/app/models/groups.py @@ -31,6 +31,11 @@ class Partition(db.Model): show_in_lists = db.Column( db.Boolean(), nullable=False, default=True, server_default="true" ) + groups = db.relationship( + "GroupDescr", + backref=db.backref("partition", lazy=True), + lazy="dynamic", + ) def __init__(self, **kwargs): super(Partition, self).__init__(**kwargs) @@ -42,6 +47,9 @@ class Partition(db.Model): else: self.numero = 1 + def __repr__(self): + return f"""<{self.__class__.__name__} {self.id} "{self.partition_name or '(default)'}">""" + class GroupDescr(db.Model): """Description d'un groupe d'une partition""" @@ -55,6 +63,17 @@ class GroupDescr(db.Model): # "A", "C2", ... (NULL for 'all'): group_name = db.Column(db.String(GROUPNAME_STR_LEN)) + etuds = db.relationship( + "Identite", + secondary="group_membership", + lazy="dynamic", + ) + + def __repr__(self): + return ( + f"""<{self.__class__.__name__} {self.id} "{self.group_name or '(tous)'}">""" + ) + group_membership = db.Table( "group_membership", diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index d51a620b08..292ec8ffde 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -2,10 +2,12 @@ """ScoDoc models: moduleimpls """ import pandas as pd +import flask_sqlalchemy from app import db from app.comp import df_cache -from app.models import Identite, Module +from app.models.etudiants import Identite +from app.models.modules import Module import app.scodoc.notesdb as ndb from app.scodoc import sco_utils as scu @@ -79,7 +81,7 @@ class ModuleImpl(db.Model): ) def to_dict(self): - """as a dict, with the same conversions as in ScoDoc7""" + """as a dict, with the same conversions as in ScoDoc7, including module""" e = dict(self.__dict__) e.pop("_sa_instance_state", None) # ScoDoc7 output_formators: (backward compat) @@ -129,14 +131,38 @@ class ModuleImplInscription(db.Model): ) @classmethod - def nb_inscriptions_dans_ue( + def etud_modimpls_in_ue( cls, formsemestre_id: int, etudid: int, ue_id: int - ) -> int: - """Nombre de moduleimpls de l'UE auxquels l'étudiant est inscrit""" + ) -> flask_sqlalchemy.BaseQuery: + """moduleimpls de l'UE auxquels l'étudiant est inscrit. + (Attention: inutile en APC, il faut considérer les coefficients) + """ return ModuleImplInscription.query.filter( ModuleImplInscription.etudid == etudid, ModuleImplInscription.moduleimpl_id == ModuleImpl.id, ModuleImpl.formsemestre_id == formsemestre_id, ModuleImpl.module_id == Module.id, Module.ue_id == ue_id, - ).count() + ) + + @classmethod + def nb_inscriptions_dans_ue( + cls, formsemestre_id: int, etudid: int, ue_id: int + ) -> int: + """Nombre de moduleimpls de l'UE auxquels l'étudiant est inscrit""" + return cls.etud_modimpls_in_ue(formsemestre_id, etudid, ue_id).count() + + @classmethod + def sum_coefs_modimpl_ue( + cls, formsemestre_id: int, etudid: int, ue_id: int + ) -> float: + """Somme des coefficients des modules auxquels l'étudiant est inscrit + dans l'UE du semestre indiqué. + N'utilise que les coefficients, donc inadapté aux formations APC. + """ + return sum( + [ + inscr.modimpl.module.coefficient + for inscr in cls.etud_modimpls_in_ue(formsemestre_id, etudid, ue_id) + ] + ) diff --git a/app/models/modules.py b/app/models/modules.py index 393cc8c0f4..67ff3de0df 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -33,8 +33,8 @@ class Module(db.Model): numero = db.Column(db.Integer) # ordre de présentation # id de l'element pedagogique Apogee correspondant: code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) - # Type: ModuleType: DEFAULT, MALUS, RESSOURCE, MODULE_SAE (enum) - module_type = db.Column(db.Integer) + # Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum) + module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0") # Relations: modimpls = db.relationship("ModuleImpl", backref="module", lazy="dynamic") ues_apc = db.relationship("UniteEns", secondary="module_ue_coef", viewonly=True) @@ -76,6 +76,11 @@ class Module(db.Model): def type_name(self): return scu.MODULE_TYPE_NAMES[self.module_type] + def type_abbrv(self): + """ "mod", "malus", "res", "sae" + (utilisées pour style css)""" + return scu.ModuleType.get_abbrev(self.module_type) + def set_ue_coef(self, ue, coef: float) -> None: """Set coef module vers cette UE""" self.update_ue_coef_dict({ue.id: coef}) diff --git a/app/models/notes.py b/app/models/notes.py index 7e5583579d..6da4ef5d60 100644 --- a/app/models/notes.py +++ b/app/models/notes.py @@ -4,8 +4,9 @@ """ from app import db -from app.models import SHORT_STR_LEN -from app.models import CODE_STR_LEN + +import app.scodoc.notesdb as ndb +import app.scodoc.sco_utils as scu class BulAppreciations(db.Model): @@ -67,3 +68,29 @@ class NotesNotesLog(db.Model): comment = db.Column(db.Text) # texte libre date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) uid = db.Column(db.Integer, db.ForeignKey("user.id")) + + +def etud_has_notes_attente(etudid, formsemestre_id): + """Vrai si cet etudiant a au moins une note en attente dans ce semestre. + (ne compte que les notes en attente dans des évaluations avec coef. non nul). + """ + cursor = db.session.execute( + """SELECT COUNT(*) + FROM notes_notes n, notes_evaluation e, notes_moduleimpl m, + notes_moduleimpl_inscription i + WHERE n.etudid = :etudid + and n.value = :code_attente + and n.evaluation_id = e.id + and e.moduleimpl_id = m.id + and m.formsemestre_id = :formsemestre_id + and e.coefficient != 0 + and m.id = i.moduleimpl_id + and i.etudid = :etudid + """, + { + "formsemestre_id": formsemestre_id, + "etudid": etudid, + "code_attente": scu.NOTES_ATTENTE, + }, + ) + return cursor.fetchone()[0] > 0 diff --git a/app/models/ues.py b/app/models/ues.py index 09469fb051..518bd72192 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -4,7 +4,6 @@ from app import db from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN -from app.scodoc import notesdb as ndb from app.scodoc import sco_utils as scu @@ -54,13 +53,15 @@ class UniteEns(db.Model): 'EXTERNE' if self.is_external else ''})>""" def to_dict(self): - """as a dict, with the same conversions as in ScoDoc7""" + """as a dict, with the same conversions as in ScoDoc7 + (except ECTS: keep None) + """ e = dict(self.__dict__) e.pop("_sa_instance_state", None) # ScoDoc7 output_formators e["ue_id"] = self.id e["numero"] = e["numero"] if e["numero"] else 0 - e["ects"] = e["ects"] if e["ects"] else 0.0 + e["ects"] = e["ects"] e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0 e["code_apogee"] = e["code_apogee"] or "" # pas de None return e diff --git a/app/pe/__init__.py b/app/pe/__init__.py new file mode 100644 index 0000000000..f850a58c35 --- /dev/null +++ b/app/pe/__init__.py @@ -0,0 +1 @@ +# empty but required for pylint diff --git a/app/pe/pe_avislatex.py b/app/pe/pe_avislatex.py index 2ff507152d..fc64253ca0 100644 --- a/app/pe/pe_avislatex.py +++ b/app/pe/pe_avislatex.py @@ -87,7 +87,7 @@ def get_tags_latex(code_latex): """ if code_latex: # changé par EV: était r"([\*]{2}[a-zA-Z0-9:éèàâêëïôöù]+[\*]{2})" - res = re.findall(r"([\*]{2}[^ \t\n\r\f\v\*]+[\*]{2})", code_latex) + res = re.findall(r"([\*]{2}[^\t\n\r\f\v\*]+[\*]{2})", code_latex) return [tag[2:-2] for tag in res] else: return [] diff --git a/app/pe/pe_jurype.py b/app/pe/pe_jurype.py index d29d040ff4..0943649cff 100644 --- a/app/pe/pe_jurype.py +++ b/app/pe/pe_jurype.py @@ -47,7 +47,7 @@ import os from zipfile import ZipFile from app.comp import res_sem -from app.comp.res_common import NotesTableCompat +from app.comp.res_compat import NotesTableCompat from app.models import FormSemestre from app.scodoc.gen_tables import GenTable, SeqGenTable @@ -322,12 +322,10 @@ class JuryPE(object): etudiants = [] for sem in semsListe: # pour chacun des semestres de la liste - # nt = self.get_notes_d_un_semestre( sem['formsemestre_id'] ) nt = self.get_cache_notes_d_un_semestre(sem["formsemestre_id"]) - # sco_cache.NotesTableCache.get( sem['formsemestre_id']) etudiantsDuSemestre = ( nt.get_etudids() - ) # nt.identdict.keys() # identification des etudiants du semestre + ) # identification des etudiants du semestre if pe_tools.PE_DEBUG: pe_tools.pe_print( diff --git a/app/pe/pe_semestretag.py b/app/pe/pe_semestretag.py index f48e69c403..0a7adcaba6 100644 --- a/app/pe/pe_semestretag.py +++ b/app/pe/pe_semestretag.py @@ -38,11 +38,10 @@ Created on Fri Sep 9 09:15:05 2016 from app import log from app.comp import res_sem -from app.comp.res_common import NotesTableCompat +from app.comp.res_compat import NotesTableCompat from app.models import FormSemestre from app.models.moduleimpls import ModuleImpl -from app.models.ues import UniteEns from app.scodoc import sco_codes_parcours from app.scodoc import sco_tag_module from app.pe import pe_tagtable @@ -194,12 +193,14 @@ class SemestreTag(pe_tagtable.TableTag): return tagdict # ----------------------------------------------------------------------------- - def comp_MoyennesTag(self, tag, force=False): - """Calcule et renvoie les "moyennes" de tous les étudiants du SemTag (non défaillants) - à un tag donné, en prenant en compte + def comp_MoyennesTag(self, tag, force=False) -> list: + """Calcule et renvoie les "moyennes" de tous les étudiants du SemTag + (non défaillants) à un tag donné, en prenant en compte tous les modimpl_id concerné par le tag, leur coeff et leur pondération. Force ou non le calcul de la moyenne lorsque des notes sont manquantes. - Renvoie les informations sous la forme d'une liste [ (moy, somme_coeff_normalise, etudid), ...] + + Renvoie les informations sous la forme d'une liste + [ (moy, somme_coeff_normalise, etudid), ...] """ lesMoyennes = [] for etudid in self.get_etudids(): diff --git a/app/pe/pe_tagtable.py b/app/pe/pe_tagtable.py index 0e5045cbaa..26cc8e242f 100644 --- a/app/pe/pe_tagtable.py +++ b/app/pe/pe_tagtable.py @@ -38,6 +38,7 @@ Created on Thu Sep 8 09:36:33 2016 """ import datetime +import numpy as np from app.scodoc import notes_table @@ -287,48 +288,53 @@ class TableTag(object): # ********************************************* -def moyenne_ponderee_terme_a_terme(notes, coeffs=None, force=False): +def moyenne_ponderee_terme_a_terme(notes, coefs=None, force=False): """ Calcule la moyenne pondérée d'une liste de notes avec d'éventuels coeffs de pondération. Renvoie le résultat sous forme d'un tuple (moy, somme_coeff) - La liste de notes contient soit : 1) des valeurs numériques 2) des strings "-NA-" (pas de notes) ou "-NI-" (pas inscrit) - ou "-c-" ue capitalisée, 3) None. + La liste de notes contient soit : + 1) des valeurs numériques + 2) des strings "-NA-" (pas de notes) ou "-NI-" (pas inscrit) ou "-c-" ue capitalisée, + 3) None. + Le paramètre force indique si le calcul de la moyenne doit être forcée ou non, c'est à - dire s'il y a ou non omission des notes non numériques (auquel cas la moyenne est calculée sur les - notes disponibles) ; sinon renvoie (None, None). + dire s'il y a ou non omission des notes non numériques (auquel cas la moyenne est + calculée sur les notes disponibles) ; sinon renvoie (None, None). """ # Vérification des paramètres d'entrée if not isinstance(notes, list) or ( - coeffs != None and not isinstance(coeffs, list) and len(coeffs) != len(notes) + coefs != None and not isinstance(coefs, list) and len(coefs) != len(notes) ): raise ValueError("Erreur de paramètres dans moyenne_ponderee_terme_a_terme") # Récupération des valeurs des paramètres d'entrée - coeffs = [1] * len(notes) if coeffs == None else coeffs + coefs = [1] * len(notes) if coefs is None else coefs # S'il n'y a pas de notes if not notes: # Si notes = [] return (None, None) - notesValides = [ - (1 if isinstance(note, float) or isinstance(note, int) else 0) for note in notes - ] # Liste indiquant les notes valides - if force == True or ( - force == False and sum(notesValides) == len(notes) - ): # Si on force le calcul de la moyenne ou qu'on ne le force pas et qu'on a le bon nombre de notes - (moyenne, ponderation) = (0.0, 0.0) + # Liste indiquant les notes valides + notes_valides = [ + (isinstance(note, float) and not np.isnan(note)) or isinstance(note, int) + for note in notes + ] + # Si on force le calcul de la moyenne ou qu'on ne le force pas + # et qu'on a le bon nombre de notes + if force or sum(notes_valides) == len(notes): + moyenne, ponderation = 0.0, 0.0 for i in range(len(notes)): - if notesValides[i]: - moyenne += coeffs[i] * notes[i] - ponderation += coeffs[i] + if notes_valides[i]: + moyenne += coefs[i] * notes[i] + ponderation += coefs[i] return ( (moyenne / (ponderation * 1.0), ponderation) if ponderation != 0 else (None, 0) ) - else: # Si on ne force pas le calcul de la moyenne - return (None, None) + # Si on ne force pas le calcul de la moyenne + return (None, None) # ------------------------------------------------------------------------------------------- diff --git a/app/pe/pe_view.py b/app/pe/pe_view.py index 5a98d375fb..ba129f7b6e 100644 --- a/app/pe/pe_view.py +++ b/app/pe/pe_view.py @@ -36,6 +36,7 @@ """ from flask import send_file, request +from app.scodoc.sco_exceptions import ScoValueError import app.scodoc.sco_utils as scu from app.scodoc import sco_formsemestre @@ -50,27 +51,34 @@ from app.pe import pe_avislatex def _pe_view_sem_recap_form(formsemestre_id): H = [ html_sco_header.sco_header(page_title="Avis de poursuite d'études"), - """

Génération des avis de poursuites d'études

+ f"""

Génération des avis de poursuites d'études

- Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de poursuites d'études. + Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de + poursuites d'études.
- De nombreux aspects sont paramétrables: - + De nombreux aspects sont paramétrables: + voir la documentation.

-
+
- Les templates sont généralement installés sur le serveur ou dans le paramétrage de ScoDoc.
- Au besoin, vous pouvez spécifier ici votre propre fichier de template (un_avis.tex): -
Template:
-
Pied de page:
+ Les templates sont généralement installés sur le serveur ou dans le + paramétrage de ScoDoc. +
+ Au besoin, vous pouvez spécifier ici votre propre fichier de template + (un_avis.tex): +
Template: + +
+
Pied de page: + +
- """.format( - formsemestre_id=formsemestre_id - ), + """, ] return "\n".join(H) + html_sco_header.sco_footer() @@ -97,8 +105,12 @@ def pe_view_sem_recap( template_latex = "" # template fourni via le formulaire Web if avis_tmpl_file: - template_latex = avis_tmpl_file.read().decode('utf-8') - template_latex = template_latex + try: + template_latex = avis_tmpl_file.read().decode("utf-8") + except UnicodeDecodeError as e: + raise ScoValueError( + "Données (template) invalides (caractères non UTF8 ?)" + ) from e else: # template indiqué dans préférences ScoDoc ? template_latex = pe_avislatex.get_code_latex_from_scodoc_preference( @@ -114,8 +126,7 @@ def pe_view_sem_recap( footer_latex = "" # template fourni via le formulaire Web if footer_tmpl_file: - footer_latex = footer_tmpl_file.read().decode('utf-8') - footer_latex = footer_latex + footer_latex = footer_tmpl_file.read().decode("utf-8") else: footer_latex = pe_avislatex.get_code_latex_from_scodoc_preference( formsemestre_id, champ="pe_avis_latex_footer" diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index 25442194f2..c8acefaf53 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -254,13 +254,13 @@ class TF(object): continue # allowed empty field, skip # type typ = descr.get("type", "string") - if val != "" and val != None: + if val != "" and val is not None: # check only non-null values if typ[:3] == "int": try: val = int(val) self.values[field] = val - except: + except ValueError: msg.append( "La valeur du champ '%s' doit être un nombre entier" % field ) @@ -270,35 +270,36 @@ class TF(object): try: val = float(val.replace(",", ".")) # allow , self.values[field] = val - except: + except ValueError: msg.append( "La valeur du champ '%s' doit être un nombre" % field ) ok = 0 - if typ[:3] == "int" or typ == "float" or typ == "real": - if ( - val != "" - and val != None - and "min_value" in descr - and val < descr["min_value"] - ): + if ( + ok + and (typ[:3] == "int" or typ == "float" or typ == "real") + and val != "" + and val != None + ): + if "min_value" in descr and self.values[field] < descr["min_value"]: msg.append( "La valeur (%d) du champ '%s' est trop petite (min=%s)" % (val, field, descr["min_value"]) ) ok = 0 - - if ( - val != "" - and val != None - and "max_value" in descr - and val > descr["max_value"] - ): + if "max_value" in descr and self.values[field] > descr["max_value"]: msg.append( "La valeur (%s) du champ '%s' est trop grande (max=%s)" % (val, field, descr["max_value"]) ) ok = 0 + if ok and (typ[:3] == "str") and "max_length" in descr: + if len(self.values[field]) > descr["max_length"]: + msg.append( + "Le champ '%s' est trop long (max %d caractères)" + % (field, descr["max_length"]) + ) + ok = 0 # allowed values if "allowed_values" in descr: diff --git a/app/scodoc/gen_tables.py b/app/scodoc/gen_tables.py index 855545556a..7f5531c6f9 100644 --- a/app/scodoc/gen_tables.py +++ b/app/scodoc/gen_tables.py @@ -63,12 +63,15 @@ from app.scodoc.sco_pdf import SU from app import log -def mark_paras(L, tags): - """Put each (string) element of L between """ +def mark_paras(L, tags) -> list[str]: + """Put each (string) element of L between ..., + for each supplied tag. + Leave non string elements untouched. + """ for tag in tags: - b = "<" + tag + ">" - c = "" - L = [b + (x or "") + c for x in L] + start = "<" + tag + ">" + end = "" + L = [(start + (x or "") + end) if isinstance(x, str) else x for x in L] return L @@ -233,7 +236,10 @@ class GenTable(object): colspan_count -= 1 # if colspan_count > 0: # continue # skip cells after a span - content = row.get(cid, "") or "" # nota: None converted to '' + if pdf_mode: + content = row.get(f"_{cid}_pdf", "") or row.get(cid, "") or "" + else: + content = row.get(cid, "") or "" # nota: None converted to '' colspan = row.get("_%s_colspan" % cid, 0) if colspan > 1: pdf_style_list.append( @@ -547,9 +553,16 @@ class GenTable(object): omit_hidden_lines=True, ) try: - Pt = [ - [Paragraph(SU(str(x)), CellStyle) for x in line] for line in data_list - ] + Pt = [] + for line in data_list: + Pt.append( + [ + Paragraph(SU(str(x)), CellStyle) + if (not isinstance(x, Paragraph)) + else x + for x in line + ] + ) except ValueError as exc: raise ScoPDFFormatError(str(exc)) from exc pdf_style_list += self.pdf_table_style @@ -748,7 +761,7 @@ if __name__ == "__main__": doc = io.BytesIO() document = sco_pdf.BaseDocTemplate(doc) document.addPageTemplates( - sco_pdf.ScolarsPageTemplate( + sco_pdf.ScoDocPageTemplate( document, ) ) diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 23a8b8340c..2cf0be400e 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -30,12 +30,12 @@ import html -from flask import g +from flask import render_template from flask import request from flask_login import current_user import app.scodoc.sco_utils as scu -from app import log +from app import scodoc_flash_status_messages from app.scodoc import html_sidebar import sco_version @@ -105,7 +105,6 @@ _HTML_BEGIN = """ - ') + # H.append( + # '' + # ) # JS additionels for js in javascripts: H.append("""\n""" % js) @@ -280,6 +283,9 @@ def sco_header( if not no_side_bar: H.append(html_sidebar.sidebar()) H.append("""
""") + # En attendant le replacement complet de cette fonction, + # inclusion ici des messages flask + H.append(render_template("flashed_messages.html")) # # Barre menu semestre: H.append(formsemestre_page_title()) diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index d014d9faf8..a8cd0eb731 100644 --- a/app/scodoc/notes_table.py +++ b/app/scodoc/notes_table.py @@ -51,8 +51,8 @@ from app.scodoc.sco_formsemestre import ( from app.scodoc.sco_codes_parcours import ( DEF, UE_SPORT, - UE_is_fondamentale, - UE_is_professionnelle, + ue_is_fondamentale, + ue_is_professionnelle, ) from app.scodoc.sco_parcours_dut import formsemestre_get_etud_capitalisation from app.scodoc import sco_codes_parcours @@ -171,7 +171,7 @@ class NotesTable: def __init__(self, formsemestre_id): # log(f"NotesTable( formsemestre_id={formsemestre_id} )") - # raise NotImplementedError() # XXX + raise NotImplementedError() # XXX if not formsemestre_id: raise ValueError("invalid formsemestre_id (%s)" % formsemestre_id) self.formsemestre_id = formsemestre_id @@ -826,11 +826,11 @@ class NotesTable: and mu["moy"] >= self.parcours.NOTES_BARRE_VALID_UE ): mu["ects_pot"] = ue["ects"] or 0.0 - if UE_is_fondamentale(ue["type"]): + if ue_is_fondamentale(ue["type"]): mu["ects_pot_fond"] = mu["ects_pot"] else: mu["ects_pot_fond"] = 0.0 - if UE_is_professionnelle(ue["type"]): + if ue_is_professionnelle(ue["type"]): mu["ects_pot_pro"] = mu["ects_pot"] else: mu["ects_pot_pro"] = 0.0 @@ -954,9 +954,12 @@ class NotesTable: Return: True|False, message explicatif """ - return self.parcours.check_barre_ues( - [self.get_etud_ue_status(etudid, ue["ue_id"]) for ue in self._ues] - ) + ue_status_list = [] + for ue in self._ues: + ue_status = self.get_etud_ue_status(etudid, ue["ue_id"]) + if ue_status: + ue_status_list.append(ue_status) + return self.parcours.check_barre_ues(ue_status_list) def get_table_moyennes_triees(self): return self.T @@ -1160,9 +1163,11 @@ class NotesTable: nt_cap = sco_cache.NotesTableCache.get( ue_cap["formsemestre_id"] ) # > UE capitalisees par un etud - moy_ue_cap = nt_cap.get_etud_ue_status(etudid, ue_cap["ue_id"])[ - "moy" - ] + ue_cap_status = nt_cap.get_etud_ue_status(etudid, ue_cap["ue_id"]) + if ue_cap_status: + moy_ue_cap = ue_cap_status["moy"] + else: + moy_ue_cap = "" ue_cap["moy_ue"] = moy_ue_cap if ( isinstance(moy_ue_cap, float) diff --git a/app/scodoc/sco_abs.py b/app/scodoc/sco_abs.py index 0b38559f7a..77cc6048de 100644 --- a/app/scodoc/sco_abs.py +++ b/app/scodoc/sco_abs.py @@ -53,7 +53,11 @@ def _isFarFutur(jour): # check si jour est dans le futur "lointain" # pour autoriser les saisies dans le futur mais pas a plus de 6 mois y, m, d = [int(x) for x in jour.split("-")] - j = datetime.date(y, m, d) + try: + j = datetime.date(y, m, d) + except ValueError: + # les dates erronées, genre année 20022, sont considéres dans le futur + return True # 6 mois ~ 182 jours: return j - datetime.date.today() > datetime.timedelta(182) @@ -225,8 +229,11 @@ def DateRangeISO(date_beg, date_end, workable=1): date_end = date_beg r = [] work_saturday = is_work_saturday() - cur = ddmmyyyy(date_beg, work_saturday=work_saturday) - end = ddmmyyyy(date_end, work_saturday=work_saturday) + try: + cur = ddmmyyyy(date_beg, work_saturday=work_saturday) + end = ddmmyyyy(date_end, work_saturday=work_saturday) + except (AttributeError, ValueError) as e: + raise ScoValueError("date invalide !") from e while cur <= end: if (not workable) or cur.iswork(): r.append(cur) @@ -479,7 +486,7 @@ def _get_abs_description(a, cursor=None): ) if Mlist: M = Mlist[0] - module += "%s " % M["module"]["code"] + module += "%s " % (M["module"]["code"] or "(module sans code)") if desc: return "(%s) %s" % (desc, module) @@ -631,7 +638,7 @@ def add_absence( ): "Ajoute une absence dans la bd" if _isFarFutur(jour): - raise ScoValueError("date absence trop loin dans le futur !") + raise ScoValueError("date absence erronée ou trop loin dans le futur !") estjust = _toboolean(estjust) matin = _toboolean(matin) cnx = ndb.GetDBConnexion() @@ -1037,7 +1044,7 @@ def get_abs_count(etudid, sem): def get_abs_count_in_interval(etudid, date_debut_iso, date_fin_iso): """Les comptes d'absences de cet étudiant entre ces deux dates, incluses: - tuple (nb abs non justifiées, nb abs justifiées) + tuple (nb abs, nb abs justifiées) Utilise un cache. """ key = str(etudid) + "_" + date_debut_iso + "_" + date_fin_iso diff --git a/app/scodoc/sco_abs_notification.py b/app/scodoc/sco_abs_notification.py index f15e7d4c87..5f9670f504 100644 --- a/app/scodoc/sco_abs_notification.py +++ b/app/scodoc/sco_abs_notification.py @@ -35,6 +35,7 @@ import datetime from flask import g, url_for from flask_mail import Message +from app.models.formsemestre import FormSemestre import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu @@ -55,27 +56,30 @@ def abs_notify(etudid, date): """ from app.scodoc import sco_abs - sem = retreive_current_formsemestre(etudid, date) - if not sem: + formsemestre = retreive_current_formsemestre(etudid, date) + if not formsemestre: return # non inscrit a la date, pas de notification - nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem) - do_abs_notify(sem, etudid, date, nbabs, nbabsjust) + nbabs, nbabsjust = sco_abs.get_abs_count_in_interval( + etudid, formsemestre.date_debut.isoformat(), formsemestre.date_fin.isoformat() + ) + do_abs_notify(formsemestre, etudid, date, nbabs, nbabsjust) -def do_abs_notify(sem, etudid, date, nbabs, nbabsjust): +def do_abs_notify(formsemestre: FormSemestre, etudid, date, nbabs, nbabsjust): """Given new counts of absences, check if notifications are requested and send them.""" # prefs fallback to global pref if sem is None: - if sem: - formsemestre_id = sem["formsemestre_id"] + if formsemestre: + formsemestre_id = formsemestre.id else: formsemestre_id = None - prefs = sco_preferences.SemPreferences(formsemestre_id=sem["formsemestre_id"]) + prefs = sco_preferences.SemPreferences(formsemestre_id=formsemestre_id) destinations = abs_notify_get_destinations( - sem, prefs, etudid, date, nbabs, nbabsjust + formsemestre, prefs, etudid, date, nbabs, nbabsjust ) - msg = abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust) + + msg = abs_notification_message(formsemestre, prefs, etudid, nbabs, nbabsjust) if not msg: return # abort @@ -131,19 +135,19 @@ def abs_notify_send(destinations, etudid, msg, nbabs, nbabsjust, formsemestre_id ) -def abs_notify_get_destinations(sem, prefs, etudid, date, nbabs, nbabsjust): +def abs_notify_get_destinations( + formsemestre: FormSemestre, prefs, etudid, date, nbabs, nbabsjust +) -> set: """Returns set of destination emails to be notified""" - formsemestre_id = sem["formsemestre_id"] destinations = [] # list of email address to notify - if abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre_id): - if sem and prefs["abs_notify_respsem"]: + if abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre.id): + if prefs["abs_notify_respsem"]: # notifie chaque responsable du semestre - for responsable_id in sem["responsables"]: - u = sco_users.user_info(responsable_id) - if u["email"]: - destinations.append(u["email"]) + for responsable in formsemestre.responsables: + if responsable.email: + destinations.append(responsable.email) if prefs["abs_notify_chief"] and prefs["email_chefdpt"]: destinations.append(prefs["email_chefdpt"]) if prefs["abs_notify_email"]: @@ -156,7 +160,7 @@ def abs_notify_get_destinations(sem, prefs, etudid, date, nbabs, nbabsjust): # Notification (à chaque fois) des resp. de modules ayant des évaluations # à cette date # nb: on pourrait prevoir d'utiliser un autre format de message pour ce cas - if sem and prefs["abs_notify_respeval"]: + if prefs["abs_notify_respeval"]: mods = mod_with_evals_at_date(date, etudid) for mod in mods: u = sco_users.user_info(mod["responsable_id"]) @@ -232,7 +236,9 @@ def user_nbdays_since_last_notif(email_addr, etudid): return None -def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust): +def abs_notification_message( + formsemestre: FormSemestre, prefs, etudid, nbabs, nbabsjust +): """Mime notification message based on template. returns a Message instance or None if sending should be canceled (empty template). @@ -242,13 +248,13 @@ def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust): etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] # Variables accessibles dans les balises du template: %(nom_variable)s : - values = sco_bulletins.make_context_dict(sem, etud) + values = sco_bulletins.make_context_dict(formsemestre, etud) values["nbabs"] = nbabs values["nbabsjust"] = nbabsjust values["nbabsnonjust"] = nbabs - nbabsjust values["url_ficheetud"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True ) template = prefs["abs_notification_mail_tmpl"] @@ -264,9 +270,11 @@ def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust): return msg -def retreive_current_formsemestre(etudid, cur_date): +def retreive_current_formsemestre(etudid: int, cur_date) -> FormSemestre: """Get formsemestre dans lequel etudid est (ou était) inscrit a la date indiquée date est une chaine au format ISO (yyyy-mm-dd) + + Result: FormSemestre ou None si pas inscrit à la date indiquée """ req = """SELECT i.formsemestre_id FROM notes_formsemestre_inscription i, notes_formsemestre sem @@ -278,8 +286,8 @@ def retreive_current_formsemestre(etudid, cur_date): if not r: return None # s'il y a plusieurs semestres, prend le premier (rarissime et non significatif): - sem = sco_formsemestre.get_formsemestre(r[0]["formsemestre_id"]) - return sem + formsemestre = FormSemestre.query.get(r[0]["formsemestre_id"]) + return formsemestre def mod_with_evals_at_date(date_abs, etudid): diff --git a/app/scodoc/sco_abs_views.py b/app/scodoc/sco_abs_views.py index 2f108c2437..a4e1d3a720 100644 --- a/app/scodoc/sco_abs_views.py +++ b/app/scodoc/sco_abs_views.py @@ -33,7 +33,9 @@ import datetime from flask import url_for, g, request, abort from app import log -from app.models import Identite +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import Identite, FormSemestre import app.scodoc.sco_utils as scu from app.scodoc import notesdb as ndb from app.scodoc.scolog import logdb @@ -118,13 +120,16 @@ def doSignaleAbsence( if moduleimpl_id and moduleimpl_id != "NULL": mod = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] formsemestre_id = mod["formsemestre_id"] - nt = sco_cache.NotesTableCache.get(formsemestre_id) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) ues = nt.get_ues_stat_dict() for ue in ues: modimpls = nt.get_modimpls_dict(ue_id=ue["ue_id"]) for modimpl in modimpls: if modimpl["moduleimpl_id"] == moduleimpl_id: - indication_module = "dans le module %s" % modimpl["module"]["code"] + indication_module = "dans le module %s" % ( + modimpl["module"]["code"] or "(pas de code)" + ) H = [ html_sco_header.sco_header( page_title=f"Signalement d'une absence pour {etud.nomprenom}", @@ -179,11 +184,12 @@ def SignaleAbsenceEtud(): # etudid implied menu_module = "" else: formsemestre_id = etud["cursem"]["formsemestre_id"] + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + ues = nt.get_ues_stat_dict() require_module = sco_preferences.get_preference( "abs_require_module", formsemestre_id ) - nt = sco_cache.NotesTableCache.get(formsemestre_id) - ues = nt.get_ues_stat_dict() if require_module: menu_module = """ - +
""" ] if sortcol: # sort table using JS sorttable H.append( """ """ % (int(sortcol)) ) - cells = '' - for i in range(len(F[0]) - 2): - if i in ue_index: - cls = "recap_tit_ue" - else: - cls = "recap_tit" - if ( - i == 0 or F[0][i] == "classement" - ): # Rang: force tri numerique pour sortable - cls = cls + " sortnumeric" - if F[0][i] in cod2mod: # lien vers etat module - modimpl = cod2mod[F[0][i]] - cells += '' % ( - cls, - modimpl.id, - modimpl.module.titre, - sco_users.user_info(modimpl.responsable_id)["nomcomplet"], - F[0][i], - ) - else: - cells += '' % (cls, F[0][i]) - if modejury: - cells += '' - ligne_titres = cells + "" - H.append(ligne_titres) # titres + + ligne_titres_head = _ligne_titres( + ue_index, F, cod2mod, modejury, with_modules_links=False + ) + ligne_titres_foot = _ligne_titres( + ue_index, F, cod2mod, modejury, with_modules_links=True + ) + + H.append("\n" + ligne_titres_head + "\n\n\n") if disable_etudlink: etudlink = "%(name)s" else: @@ -666,6 +629,9 @@ def make_formsemestre_recapcomplet( nblines = len(F) - 1 for l in F[1:]: etudid = l[-1] + if ir == nblines - 6: + H.append("") + H.append("") if ir >= nblines - 6: # dernieres lignes: el = l[1] @@ -677,7 +643,7 @@ def make_formsemestre_recapcomplet( "recap_row_nbeval", "recap_row_ects", )[ir - nblines + 6] - cells = '' % styl + cells = f'' else: el = etudlink % { "formsemestre_id": formsemestre_id, @@ -685,17 +651,23 @@ def make_formsemestre_recapcomplet( "name": l[1], } if ir % 2 == 0: - cells = '' % etudid + cells = f'' else: - cells = '' % etudid + cells = f'' ir += 1 # XXX nsn = [ x.replace('NA', '-') for x in l[:-2] ] # notes sans le NA: nsn = l[:-2] # copy - for i in range(len(nsn)): + for i, _ in enumerate(nsn): if nsn[i] == "NA": nsn[i] = "-" - cells += '' % nsn[0] # rang + try: + order = int(nsn[0].split()[0]) + except: + order = 99999 + cells += ( + f'' # rang + ) cells += '' % el # nom etud (lien) if not hidebac: cells += '' % nsn[2] # bac @@ -763,7 +735,8 @@ def make_formsemestre_recapcomplet( cells += "" H.append(cells + "") - H.append(ligne_titres) + H.append(ligne_titres_foot) + H.append("") H.append("
%s%sDécision
%s{nsn[0]}%s%s
") # Form pour choisir partition de classement: @@ -831,6 +804,40 @@ def make_formsemestre_recapcomplet( raise ValueError("unknown format %s" % format) +def _ligne_titres(ue_index, F, cod2mod, modejury, with_modules_links=True): + """Cellules de la ligne de titre (haut ou bas)""" + cells = '' + for i in range(len(F[0]) - 2): + if i in ue_index: + cls = "recap_tit_ue" + else: + cls = "recap_tit" + attr = f'class="{cls}"' + if i == 0 or F[0][i] == "classement": # Rang: force tri numerique + try: + order = int(F[0][i].split()[0]) + except: + order = 99999 + attr += f' data-order="{order:05d}"' + if F[0][i] in cod2mod: # lien vers etat module + modimpl = cod2mod[F[0][i]] + if with_modules_links: + href = url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=modimpl.id, + ) + else: + href = "" + cells += f"""{F[0][i]}""" + else: + cells += f"{F[0][i]}" + if modejury: + cells += 'Décision' + return cells + "" + + def _list_notes_evals(evals: list[Evaluation], etudid: int) -> list[str]: """Liste des notes des evaluations completes de ce module (pour table xls avec evals) @@ -891,8 +898,8 @@ def _formsemestre_recapcomplet_xml( force_publishing=True, ): "XML export: liste tous les bulletins XML." - - nt = sco_cache.NotesTableCache.get(formsemestre_id) # > get_table_moyennes_triees + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) T = nt.get_table_moyennes_triees() if not T: return "", "", "xml" @@ -962,7 +969,8 @@ def _formsemestre_recapcomplet_json( "bulletins": [], } bulletins = J["bulletins"] - nt = sco_cache.NotesTableCache.get(formsemestre_id) # > get_table_moyennes_triees + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) T = nt.get_table_moyennes_triees() for t in T: etudid = t[-1] @@ -996,3 +1004,71 @@ def formsemestres_bulletins(annee_scolaire): jslist.append(J) return scu.sendJSON(jslist) + + +def _gen_cell(key: str, row: dict, elt="td"): + "html table cell" + klass = row.get(f"_{key}_class") + attrs = f'class="{klass}"' if klass else "" + order = row.get(f"_{key}_order") + if order: + attrs += f' data-order="{order}"' + content = row.get(key, "") + target = row.get(f"_{key}_target") + target_attrs = row.get(f"_{key}_target_attrs", "") + if target or target_attrs: # avec lien + href = f'href="{target}"' if target else "" + content = f"{content}" + return f"<{elt} {attrs}>{content}" + + +def _gen_row(keys: list[str], row, elt="td"): + klass = row.get("_tr_class") + tr_class = f'class="{klass}"' if klass else "" + return f'{"".join([_gen_cell(key, row, elt) for key in keys])}' + + +def gen_formsemestre_recapcomplet_html( + formsemestre: FormSemestre, res: NotesTableCompat +): + """Construit table recap pour le BUT + Return: data, filename + """ + rows, footer_rows, titles, column_ids = res.get_table_recap(convert_values=True) + if not rows: + return ( + '
aucun étudiant !
', + "", + ) + H = [ + f"""
""" + ] + # header + H.append( + f""" + + {_gen_row(column_ids, titles, "th")} + + """ + ) + # body + H.append("") + for row in rows: + H.append(f"{_gen_row(column_ids, row)}\n") + H.append("\n") + # footer + H.append("") + idx_last = len(footer_rows) - 1 + for i, row in enumerate(footer_rows): + H.append(f'{_gen_row(column_ids, row, "th" if i == idx_last else "td")}\n') + H.append( + """ + +
+
+ """ + ) + return ( + "".join(H), + f'recap-{formsemestre.titre_num().replace(" ", "_")}-{time.strftime("%d-%m-%Y")}', + ) # suffix ? diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py index 6c2582ef10..214532a553 100644 --- a/app/scodoc/sco_report.py +++ b/app/scodoc/sco_report.py @@ -39,14 +39,16 @@ from operator import itemgetter from flask import url_for, g, request import pydot +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre + import app.scodoc.sco_utils as scu from app.models import FormationModalite from app.scodoc import notesdb as ndb from app.scodoc import html_sco_header from app.scodoc import sco_codes_parcours -from app.scodoc import sco_cache from app.scodoc import sco_etud -from app.scodoc import sco_excel from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_parcours_dut @@ -61,9 +63,9 @@ MAX_ETUD_IN_DESCR = 20 def formsemestre_etuds_stats(sem, only_primo=False): """Récupère liste d'etudiants avec etat et decision.""" - nt = sco_cache.NotesTableCache.get( - sem["formsemestre_id"] - ) # > get_table_moyennes_triees, identdict, get_etud_decision_sem, get_etud_etat, + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + T = nt.get_table_moyennes_triees() # Construit liste d'étudiants du semestre avec leur decision etuds = [] @@ -400,9 +402,8 @@ def table_suivi_cohorte( logt("table_suivi_cohorte: start") # 1-- Liste des semestres posterieurs dans lesquels ont été les etudiants de sem - nt = sco_cache.NotesTableCache.get( - formsemestre_id - ) # > get_etudids, get_etud_decision_sem + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) etudids = nt.get_etudids() logt("A: orig etuds set") @@ -456,9 +457,8 @@ def table_suivi_cohorte( s["members"] = orig_set.intersection(inset) nb_dipl = 0 # combien de diplomes dans ce semestre ? if s["semestre_id"] == nt.parcours.NB_SEM: - nt = sco_cache.NotesTableCache.get( - s["formsemestre_id"] - ) # > get_etud_decision_sem + s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) for etudid in s["members"]: dec = nt.get_etud_decision_sem(etudid) if dec and code_semestre_validant(dec["code"]): @@ -905,9 +905,9 @@ def _descr_etud_set(etudids): def _count_dem_reo(formsemestre_id, etudids): "count nb of demissions and reorientation in this etud set" - nt = sco_cache.NotesTableCache.get( - formsemestre_id - ) # > get_etud_etat, get_etud_decision_sem + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + dems = set() reos = set() for etudid in etudids: @@ -971,9 +971,9 @@ def get_codeparcoursetud(etud, prefix="", separator=""): i = len(sems) - 1 while i >= 0: s = sems[i] # 'sems' est a l'envers, du plus recent au plus ancien - nt = sco_cache.NotesTableCache.get( - s["formsemestre_id"] - ) # > get_etud_etat, get_etud_decision_sem + s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) + p.append(_codesem(s, prefix=prefix)) # code decisions jury de chaque semestre: if nt.get_etud_etat(etud["etudid"]) == "D": @@ -1017,7 +1017,8 @@ def tsp_etud_list( """ # log('tsp_etud_list(%s, bac="%s")' % (formsemestre_id,bac)) sem = sco_formsemestre.get_formsemestre(formsemestre_id) - nt = sco_cache.NotesTableCache.get(formsemestre_id) # > get_etudids, + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) etudids = nt.get_etudids() etuds = [] bacs = set() @@ -1260,9 +1261,8 @@ def graph_parcours( nxt = {} etudid = etud["etudid"] for s in etud["sems"]: # du plus recent au plus ancien - nt = sco_cache.NotesTableCache.get( - s["formsemestre_id"] - ) # > get_etud_decision_sem, get_etud_etat + s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) dec = nt.get_etud_decision_sem(etudid) if nxt: if ( diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index cc15907728..fbef924585 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -29,15 +29,16 @@ Formulaire revu en juillet 2016 """ -import sys import time -import datetime import psycopg2 import flask from flask import g, url_for, request from flask_login import current_user +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType import app.scodoc.notesdb as ndb @@ -49,7 +50,6 @@ from app.scodoc.sco_exceptions import ( ScoGenError, ScoValueError, ) -from app.scodoc.sco_permissions import Permission from app.scodoc.TrivialFormulator import TrivialFormulator, TF from app.scodoc import html_sco_header, sco_users from app.scodoc import htmlutils @@ -272,6 +272,7 @@ def do_evaluation_upload_xls(): "notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, moduleimpl_id=mod["moduleimpl_id"], + _external=True, ) sco_news.add( typ=sco_news.NEWS_NOTE, @@ -813,8 +814,8 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]): evaltitre = "évaluation du %s" % E["jour"] description = "%s en %s (%s) resp. %s" % ( evaltitre, - Mod["abbrev"], - Mod["code"], + Mod["abbrev"] or "", + Mod["code"] or "", mod_responsable["prenomnom"], ) @@ -846,7 +847,7 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]): etuds = _get_sorted_etuds(E, etudids, formsemestre_id) for e in etuds: etudid = e["etudid"] - groups = sco_groups.get_etud_groups(etudid, sem) + groups = sco_groups.get_etud_groups(etudid, formsemestre_id) grc = sco_groups.listgroups_abbrev(groups) L.append( @@ -872,9 +873,8 @@ def has_existing_decision(M, E, etudid): Si oui, return True """ formsemestre_id = M["formsemestre_id"] - nt = sco_cache.NotesTableCache.get( - formsemestre_id - ) # > get_etud_decision_sem, get_etud_decision_ues + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) if nt.get_etud_decision_sem(etudid): return True dec_ues = nt.get_etud_decision_ues(etudid) @@ -1020,7 +1020,7 @@ def _get_sorted_etuds(E, etudids, formsemestre_id): {"etudid": etudid, "formsemestre_id": formsemestre_id} )[0] # Groupes auxquels appartient cet étudiant: - e["groups"] = sco_groups.get_etud_groups(etudid, sem) + e["groups"] = sco_groups.get_etud_groups(etudid, formsemestre_id) # Information sur absence (tenant compte de la demi-journée) jour_iso = ndb.DateDMYtoISO(E["jour"]) @@ -1271,6 +1271,7 @@ def save_note(etudid=None, evaluation_id=None, value=None, comment=""): "notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, moduleimpl_id=M["moduleimpl_id"], + _external=True, ) result = {"nbchanged": 0} # JSON # Check access: admin, respformation, or responsable_id diff --git a/app/scodoc/sco_semset.py b/app/scodoc/sco_semset.py index 3001d5aed7..37b39fa839 100644 --- a/app/scodoc/sco_semset.py +++ b/app/scodoc/sco_semset.py @@ -42,6 +42,9 @@ sem_set_list() import flask from flask import g +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre from app.scodoc import html_sco_header from app.scodoc import sco_cache from app.scodoc import sco_etape_apogee @@ -86,7 +89,7 @@ class SemSet(dict): if semset_id: # read existing set L = semset_list(cnx, args={"semset_id": semset_id}) if not L: - raise ValueError("invalid semset_id %s" % semset_id) + raise ScoValueError(f"Ensemble inexistant ! (semset {semset_id})") self["title"] = L[0]["title"] self["annee_scolaire"] = L[0]["annee_scolaire"] self["sem_id"] = L[0]["sem_id"] @@ -239,7 +242,8 @@ class SemSet(dict): self["etuds_without_nip"] = set() # etudids self["jury_ok"] = True for sem in self.sems: - nt = sco_cache.NotesTableCache.get(sem["formsemestre_id"]) + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) sem["etuds"] = list(nt.identdict.values()) sem["nips"] = {e["code_nip"] for e in sem["etuds"] if e["code_nip"]} sem["etuds_without_nip"] = { diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py index bbcf4a0837..db775438c9 100644 --- a/app/scodoc/sco_synchro_etuds.py +++ b/app/scodoc/sco_synchro_etuds.py @@ -854,23 +854,27 @@ def formsemestre_import_etud_admission( apo_emailperso = etud.get("mailperso", "") if info["emailperso"] and not apo_emailperso: apo_emailperso = info["emailperso"] - if ( - import_email - and info["email"] != etud["mail"] - or info["emailperso"] != apo_emailperso - ): - sco_etud.adresse_edit( - cnx, - args={ - "etudid": etudid, - "adresse_id": info["adresse_id"], - "email": etud["mail"], - "emailperso": apo_emailperso, - }, - ) - # notifie seulement les changements d'adresse mail institutionnelle - if info["email"] != etud["mail"]: - changed_mails.append((info, etud["mail"])) + if import_email: + if not "mail" in etud: + raise ScoValueError( + "la réponse portail n'a pas le champs requis 'mail'" + ) + if ( + info["email"] != etud["mail"] + or info["emailperso"] != apo_emailperso + ): + sco_etud.adresse_edit( + cnx, + args={ + "etudid": etudid, + "adresse_id": info["adresse_id"], + "email": etud["mail"], + "emailperso": apo_emailperso, + }, + ) + # notifie seulement les changements d'adresse mail institutionnelle + if info["email"] != etud["mail"]: + changed_mails.append((info, etud["mail"])) else: unknowns.append(code_nip) sco_cache.invalidate_formsemestre(formsemestre_id=sem["formsemestre_id"]) diff --git a/app/scodoc/sco_tag_module.py b/app/scodoc/sco_tag_module.py index 7a98951bcd..1b41cd876e 100644 --- a/app/scodoc/sco_tag_module.py +++ b/app/scodoc/sco_tag_module.py @@ -37,6 +37,9 @@ import http from flask import g, url_for +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app import log @@ -269,7 +272,8 @@ def get_etud_tagged_modules(etudid, tagname): etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] R = [] for sem in etud["sems"]: - nt = sco_cache.NotesTableCache.get(sem["formsemestre_id"]) + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) modimpls = nt.get_modimpls_dict() for modimpl in modimpls: tags = module_tag_list(module_id=modimpl["module_id"]) diff --git a/app/scodoc/sco_trombino.py b/app/scodoc/sco_trombino.py index 9132336eaf..e9102fc677 100644 --- a/app/scodoc/sco_trombino.py +++ b/app/scodoc/sco_trombino.py @@ -378,7 +378,7 @@ def _trombino_pdf(groups_infos): # Build document document = BaseDocTemplate(report) document.addPageTemplates( - sco_pdf.ScolarsPageTemplate( + sco_pdf.ScoDocPageTemplate( document, preferences=sco_preferences.SemPreferences(sem["formsemestre_id"]), ) @@ -458,7 +458,7 @@ def _listeappel_photos_pdf(groups_infos): # Build document document = BaseDocTemplate(report) document.addPageTemplates( - sco_pdf.ScolarsPageTemplate( + sco_pdf.ScoDocPageTemplate( document, preferences=sco_preferences.SemPreferences(sem["formsemestre_id"]), ) diff --git a/app/scodoc/sco_trombino_tours.py b/app/scodoc/sco_trombino_tours.py index 3861bb74ff..fd11da4e89 100644 --- a/app/scodoc/sco_trombino_tours.py +++ b/app/scodoc/sco_trombino_tours.py @@ -33,20 +33,23 @@ import io from reportlab.lib import colors -from reportlab.lib import pagesizes +from reportlab.lib.colors import black from reportlab.lib.pagesizes import A4, A3 +from reportlab.lib import styles +from reportlab.lib.pagesizes import landscape +from reportlab.lib.units import cm +from reportlab.platypus import KeepInFrame, Paragraph, Table, TableStyle +from reportlab.platypus.doctemplate import BaseDocTemplate -import app.scodoc.sco_utils as scu -from app import log from app.scodoc import sco_abs +from app.scodoc import sco_etud +from app.scodoc.sco_exceptions import ScoPDFFormatError from app.scodoc import sco_groups from app.scodoc import sco_groups_view from app.scodoc import sco_preferences from app.scodoc import sco_trombino -from app.scodoc import sco_etud -from app.scodoc.sco_exceptions import ScoPDFFormatError -from app.scodoc.sco_pdf import * - +import app.scodoc.sco_utils as scu +from app.scodoc.sco_pdf import SU, ScoDocPageTemplate # Paramétrage de l'aspect graphique: PHOTOWIDTH = 2.8 * cm @@ -55,7 +58,7 @@ N_PER_ROW = 5 def pdf_trombino_tours( - group_ids=[], # liste des groupes à afficher + group_ids=(), # liste des groupes à afficher formsemestre_id=None, # utilisé si pas de groupes selectionné ): """Generation du trombinoscope en fichier PDF""" @@ -66,7 +69,6 @@ def pdf_trombino_tours( DeptName = sco_preferences.get_preference("DeptName") DeptFullName = sco_preferences.get_preference("DeptFullName") - UnivName = sco_preferences.get_preference("UnivName") InstituteName = sco_preferences.get_preference("InstituteName") # Generate PDF page StyleSheet = styles.getSampleStyleSheet() @@ -74,7 +76,11 @@ def pdf_trombino_tours( T = Table( [ [Paragraph(SU(InstituteName), StyleSheet["Heading3"])], - [Paragraph(SU("Département " + DeptFullName), StyleSheet["Heading3"])], + [ + Paragraph( + SU("Département " + DeptFullName or "(?)"), StyleSheet["Heading3"] + ) + ], [ Paragraph( SU("Date ............ / ............ / ......................"), @@ -139,9 +145,7 @@ def pdf_trombino_tours( for group_id in groups_infos.group_ids: if group_id != "None": - members, group, group_tit, sem, nbdem = sco_groups.get_group_infos( - group_id, "I" - ) + members, _, group_tit, sem, _ = sco_groups.get_group_infos(group_id, "I") groups += " %s" % group_tit L = [] currow = [] @@ -176,7 +180,9 @@ def pdf_trombino_tours( n = 1 for m in members: img = sco_trombino._get_etud_platypus_image(m, image_width=PHOTOWIDTH) - etud_main_group = sco_groups.get_etud_main_group(m["etudid"], sem) + etud_main_group = sco_groups.get_etud_main_group( + m["etudid"], sem["formsemestre_id"] + ) if group_id != etud_main_group["group_id"]: text_group = " (" + etud_main_group["group_name"] + ")" else: @@ -264,7 +270,7 @@ def pdf_trombino_tours( filename = "trombino-%s-%s.pdf" % (DeptName, groups_infos.groups_filename) document = BaseDocTemplate(report) document.addPageTemplates( - ScolarsPageTemplate( + ScoDocPageTemplate( document, preferences=sco_preferences.SemPreferences(), ) @@ -282,14 +288,14 @@ def pdf_trombino_tours( def pdf_feuille_releve_absences( - group_ids=[], # liste des groupes à afficher + group_ids=(), # liste des groupes à afficher formsemestre_id=None, # utilisé si pas de groupes selectionné ): """Generation de la feuille d'absence en fichier PDF, avec photos""" NB_CELL_AM = sco_preferences.get_preference("feuille_releve_abs_AM") NB_CELL_PM = sco_preferences.get_preference("feuille_releve_abs_PM") - COLWIDTH = 0.85 * cm + col_width = 0.85 * cm if sco_preferences.get_preference("feuille_releve_abs_samedi"): days = sco_abs.DAYNAMES[:6] # Lundi, ..., Samedi else: @@ -303,7 +309,6 @@ def pdf_feuille_releve_absences( DeptName = sco_preferences.get_preference("DeptName") DeptFullName = sco_preferences.get_preference("DeptFullName") - UnivName = sco_preferences.get_preference("UnivName") InstituteName = sco_preferences.get_preference("InstituteName") # Generate PDF page StyleSheet = styles.getSampleStyleSheet() @@ -321,7 +326,8 @@ def pdf_feuille_releve_absences( ], [ Paragraph( - SU("Département " + DeptFullName), StyleSheet["Heading3"] + SU("Département " + (DeptFullName or "(?)")), + StyleSheet["Heading3"], ), "", ], @@ -335,7 +341,7 @@ def pdf_feuille_releve_absences( currow = [""] * (NB_CELL_AM + 1 + NB_CELL_PM + 1) elem_day = Table( [currow], - colWidths=([COLWIDTH] * (NB_CELL_AM + 1 + NB_CELL_PM + 1)), + colWidths=([col_width] * (NB_CELL_AM + 1 + NB_CELL_PM + 1)), style=TableStyle( [ ("GRID", (0, 0), (NB_CELL_AM - 1, 0), 0.25, black), @@ -357,7 +363,7 @@ def pdf_feuille_releve_absences( elem_week = Table( W, - colWidths=([COLWIDTH * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days), + colWidths=([col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days), style=TableStyle( [ ("LEFTPADDING", (0, 0), (-1, -1), 0), @@ -373,7 +379,7 @@ def pdf_feuille_releve_absences( elem_day_name = Table( [currow], - colWidths=([COLWIDTH * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days), + colWidths=([col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days), style=TableStyle( [ ("LEFTPADDING", (0, 0), (-1, -1), 0), @@ -385,9 +391,7 @@ def pdf_feuille_releve_absences( ) for group_id in groups_infos.group_ids: - members, group, group_tit, sem, nbdem = sco_groups.get_group_infos( - group_id, "I" - ) + members, _, group_tit, _, _ = sco_groups.get_group_infos(group_id, "I") L = [] currow = [ @@ -424,7 +428,10 @@ def pdf_feuille_releve_absences( T = Table( L, colWidths=( - [5.0 * cm, (COLWIDTH * (NB_CELL_AM + 1 + NB_CELL_PM + 1) * nb_days)] + [ + 5.0 * cm, + (col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1) * nb_days), + ] ), style=TableStyle( [ @@ -460,7 +467,7 @@ def pdf_feuille_releve_absences( else: document = BaseDocTemplate(report, pagesize=taille) document.addPageTemplates( - ScolarsPageTemplate( + ScoDocPageTemplate( document, preferences=sco_preferences.SemPreferences(), ) diff --git a/app/scodoc/sco_undo_notes.py b/app/scodoc/sco_undo_notes.py index 5d169b8bc4..5cff6fe444 100644 --- a/app/scodoc/sco_undo_notes.py +++ b/app/scodoc/sco_undo_notes.py @@ -225,7 +225,8 @@ def formsemestre_list_saisies_notes(formsemestre_id, format="html"): columns_ids=columns_ids, rows=r, html_title="

Saisies de notes dans %s

" % sem["titreannee"], - html_class="table_leftalign table_coldate", + html_class="table_leftalign table_coldate gt_table_searchable", + html_class_ignore_default=True, html_sortable=True, caption="Saisies de notes dans %s" % sem["titreannee"], preferences=sco_preferences.SemPreferences(formsemestre_id), diff --git a/app/scodoc/sco_up_to_date.py b/app/scodoc/sco_up_to_date.py index 02c35c77d4..1ca56b80fd 100644 --- a/app/scodoc/sco_up_to_date.py +++ b/app/scodoc/sco_up_to_date.py @@ -28,30 +28,68 @@ """ Verification version logiciel vs version "stable" sur serveur N'effectue pas la mise à jour automatiquement, mais permet un affichage d'avertissement. - - Désactivé temporairement pour ScoDoc 9. """ - +import json +import requests +import time from flask import current_app +from app import log +import app.scodoc.sco_utils as scu +from sco_version import SCOVERSION, SCONAME -def is_up_to_date(): - """True if up_to_date - Returns status, message + +def is_up_to_date() -> str: + """Check installed version vs last release. + Returns html message, empty of ok. """ - current_app.logger.debug("Warning: is_up_to_date not implemented for ScoDoc9") - return True, "unimplemented" + if current_app.testing or current_app.debug: + return "
Mode développement
" + diag = "" + try: + response = requests.get(scu.SCO_UP2DATE + "/" + SCOVERSION) + except requests.exceptions.ConnectionError: + current_app.logger.debug("is_up_to_date: %s", diag) + return f"""
Attention: installation de {SCONAME} non fonctionnelle.
+
Détails: pas de connexion à {scu.SCO_WEBSITE}. + Vérifier paramètrages réseau, + voir la documentation. +
+ """ + except: + current_app.logger.debug("is_up_to_date: %s", diag) + return f"""
Attention: installation de {SCONAME} non fonctionnelle.
+
Détails: erreur inconnue lors de la connexion à {scu.SCO_WEBSITE}. + Vérifier paramètrages réseau, + voir la documentation. +
+ """ + if response.status_code != 200: + current_app.logger.debug( + f"is_up_to_date: invalid response code ({response.status_code})" + ) + return f"""
Attention: réponse invalide de {scu.SCO_WEBSITE}
+
(erreur http {response.status_code}).
""" + try: + infos = json.loads(response.text) + except json.decoder.JSONDecodeError: + current_app.logger.debug(f"is_up_to_date: invalid response (json)") + return f"""
Attention: réponse invalide de {scu.SCO_WEBSITE}
+
(erreur json).
""" -def html_up_to_date_box(): - """""" - status, msg = is_up_to_date() - if status: + if infos["status"] != "ok": + # problème coté serveur, ignore discrètement + log(f"is_up_to_date: server {infos['status']}") return "" - return ( - """
- Attention: cette installation de ScoDoc n'est pas à jour. -
Contactez votre administrateur. %s
-
""" - % msg - ) + if (SCOVERSION != infos["last_version"]) and ( + (time.time() - infos["last_version_date"]) > (24 * 60 * 60) + ): + # nouvelle version publiée depuis plus de 24h ! + return f"""
Attention: {SCONAME} version ({SCOVERSION}) non à jour + ({infos["last_version"]} disponible).
+
Contacter votre administrateur système + (documentation). +
+ """ + return "" # ok diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 1cc6e89c0a..a332037f6e 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -50,7 +50,7 @@ import pydot import requests from flask import g, request -from flask import url_for, make_response, jsonify +from flask import flash, url_for, make_response, jsonify from config import Config from app import log @@ -87,6 +87,19 @@ class ModuleType(IntEnum): RESSOURCE = 2 # BUT SAE = 3 # BUT + @classmethod + def get_abbrev(cls, code) -> str: + """Chaine abregée décrivant le type de module à partir du code integer: + "mod", "malus", "res", "sae" + (utilisées pour style css) + """ + return { + ModuleType.STANDARD: "mod", + ModuleType.MALUS: "malus", + ModuleType.RESSOURCE: "res", + ModuleType.SAE: "sae", + }.get(code, "???") + MODULE_TYPE_NAMES = { ModuleType.STANDARD: "Module", @@ -361,7 +374,7 @@ SCO_DEV_MAIL = "emmanuel.viennet@gmail.com" # SVP ne pas changer # Adresse pour l'envoi des dumps (pour assistance technnique): # ne pas changer (ou vous perdez le support) SCO_DUMP_UP_URL = "https://scodoc.org/scodoc-installmgr/upload-dump" - +SCO_UP2DATE = "https://scodoc.org/scodoc-installmgr/check_version" CSV_FIELDSEP = ";" CSV_LINESEP = "\n" CSV_MIMETYPE = "text/comma-separated-values" @@ -608,7 +621,7 @@ def is_valid_filename(filename): return VALID_EXP.match(filename) -def bul_filename(sem, etud, format): +def bul_filename_old(sem: dict, etud: dict, format): """Build a filename for this bulletin""" dt = time.strftime("%Y-%m-%d") filename = f"bul-{sem['titre_num']}-{dt}-{etud['nom']}.{format}" @@ -616,6 +629,24 @@ def bul_filename(sem, etud, format): return filename +def bul_filename(formsemestre, etud, format): + """Build a filename for this bulletin""" + dt = time.strftime("%Y-%m-%d") + filename = f"bul-{formsemestre.titre_num()}-{dt}-{etud.nom}.{format}" + filename = make_filename(filename) + return filename + + +def flash_errors(form): + """Flashes form errors (version sommaire)""" + for field, errors in form.errors.items(): + flash( + "Erreur: voir le champs %s" % (getattr(form, field).label.text,), + "warning", + ) + # see https://getbootstrap.com/docs/4.0/components/alerts/ + + def sendCSVFile(data, filename): # DEPRECATED utiliser send_file """publication fichier CSV.""" return send_file(data, filename=filename, mime=CSV_MIMETYPE, attached=True) @@ -635,21 +666,30 @@ class ScoDocJSONEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, o) -def sendJSON(data, attached=False): +def sendJSON(data, attached=False, filename=None): js = json.dumps(data, indent=1, cls=ScoDocJSONEncoder) return send_file( - js, filename="sco_data.json", mime=JSON_MIMETYPE, attached=attached + js, filename=filename or "sco_data.json", mime=JSON_MIMETYPE, attached=attached ) -def sendXML(data, tagname=None, force_outer_xml_tag=True, attached=False, quote=True): +def sendXML( + data, + tagname=None, + force_outer_xml_tag=True, + attached=False, + quote=True, + filename=None, +): if type(data) != list: data = [data] # always list-of-dicts if force_outer_xml_tag: data = [{tagname: data}] tagname += "_list" doc = sco_xml.simple_dictlist2xml(data, tagname=tagname, quote=quote) - return send_file(doc, filename="sco_data.xml", mime=XML_MIMETYPE, attached=attached) + return send_file( + doc, filename=filename or "sco_data.xml", mime=XML_MIMETYPE, attached=attached + ) def sendResult( @@ -659,6 +699,7 @@ def sendResult( force_outer_xml_tag=True, attached=False, quote_xml=True, + filename=None, ): if (format is None) or (format == "html"): return data @@ -669,9 +710,10 @@ def sendResult( force_outer_xml_tag=force_outer_xml_tag, attached=attached, quote=quote_xml, + filename=filename, ) elif format == "json": - return sendJSON(data, attached=attached) + return sendJSON(data, attached=attached, filename=filename) else: raise ValueError("invalid format: %s" % format) @@ -789,7 +831,7 @@ def abbrev_prenom(prenom): # def timedate_human_repr(): - "representation du temps courant pour utilisateur: a localiser" + "representation du temps courant pour utilisateur" return time.strftime("%d/%m/%Y à %Hh%M") @@ -889,6 +931,10 @@ def icontag(name, file_format="png", no_size=False, **attrs): ICON_PDF = icontag("pdficon16x20_img", title="Version PDF") ICON_XLS = icontag("xlsicon_img", title="Version tableur") +# HTML emojis +EMO_WARNING = "⚠️" # warning /!\ +EMO_RED_TRIANGLE_DOWN = "🔻" # red triangle pointed down + def sort_dates(L, reverse=False): """Return sorted list of dates, allowing None items (they are put at the beginning)""" @@ -925,7 +971,7 @@ def query_portal(req, msg="Portail Apogee", timeout=3): return r.text -def AnneeScolaire(sco_year=None): +def AnneeScolaire(sco_year=None) -> int: "annee de debut de l'annee scolaire courante" if sco_year: year = sco_year @@ -1009,3 +1055,10 @@ def objects_renumber(db, obj_list) -> None: obj.numero = i db.session.add(obj) db.session.commit() + + +# Pour accès depuis les templates jinja +def is_entreprises_enabled(): + from app.models import ScoDocSiteConfig + + return ScoDocSiteConfig.is_entreprises_enabled() diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap.css new file mode 100644 index 0000000000..92485409e5 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap.css @@ -0,0 +1,380 @@ +@keyframes dtb-spinner { + 100% { + transform: rotate(360deg); + } +} +@-o-keyframes dtb-spinner { + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-ms-keyframes dtb-spinner { + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-webkit-keyframes dtb-spinner { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-moz-keyframes dtb-spinner { + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } +} +div.dataTables_wrapper { + position: relative; +} + +div.dt-buttons { + position: initial; +} + +div.dt-button-info { + position: fixed; + top: 50%; + left: 50%; + width: 400px; + margin-top: -100px; + margin-left: -200px; + background-color: white; + border: 2px solid #111; + box-shadow: 3px 4px 10px 1px rgba(0, 0, 0, 0.3); + border-radius: 3px; + text-align: center; + z-index: 21; +} +div.dt-button-info h2 { + padding: 0.5em; + margin: 0; + font-weight: normal; + border-bottom: 1px solid #ddd; + background-color: #f3f3f3; +} +div.dt-button-info > div { + padding: 1em; +} + +div.dtb-popover-close { + position: absolute; + top: 10px; + right: 10px; + width: 22px; + height: 22px; + border: 1px solid #eaeaea; + background-color: #f9f9f9; + text-align: center; + border-radius: 3px; + cursor: pointer; + z-index: 12; +} + +button.dtb-hide-drop { + display: none !important; +} + +div.dt-button-collection-title { + text-align: center; + padding: 0.3em 0 0.5em; + margin-left: 0.5em; + margin-right: 0.5em; + font-size: 0.9em; +} + +div.dt-button-collection-title:empty { + display: none; +} + +span.dt-button-spacer { + display: inline-block; + margin: 0.5em; + white-space: nowrap; +} +span.dt-button-spacer.bar { + border-left: 1px solid rgba(0, 0, 0, 0.3); + vertical-align: middle; + padding-left: 0.5em; +} +span.dt-button-spacer.bar:empty { + height: 1em; + width: 1px; + padding-left: 0; +} + +div.dt-button-collection span.dt-button-spacer { + width: 100%; + font-size: 0.9em; + text-align: center; + margin: 0.5em 0; +} +div.dt-button-collection span.dt-button-spacer:empty { + height: 0; + width: 100%; +} +div.dt-button-collection span.dt-button-spacer.bar { + border-left: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.3); + padding-left: 0; +} + +div.dt-button-collection { + position: absolute; + z-index: 2001; + background-color: white; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + padding: 0.5rem 0; + min-width: 200px; +} +div.dt-button-collection ul.dropdown-menu { + position: relative; + display: block; + z-index: 2002; + min-width: 100%; + background-color: transparent; + border: none; + box-shadow: none; + padding: 0; + border-radius: 0; +} +div.dt-button-collection div.dt-btn-split-wrapper { + width: 100%; + display: inline-flex; + padding-left: 5px; + padding-right: 5px; +} +div.dt-button-collection button.dt-btn-split-drop-button { + width: 100%; + border: none; + border-radius: 0px; + margin-left: 0px !important; +} +div.dt-button-collection button.dt-btn-split-drop-button:focus { + border: none; + border-radius: 0px; + outline: none; +} +div.dt-button-collection.fixed { + position: fixed; + display: block; + top: 50%; + left: 50%; + margin-left: -75px; + border-radius: 5px; + background-color: white; +} +div.dt-button-collection.fixed.two-column { + margin-left: -200px; +} +div.dt-button-collection.fixed.three-column { + margin-left: -225px; +} +div.dt-button-collection.fixed.four-column { + margin-left: -300px; +} +div.dt-button-collection.fixed.columns { + margin-left: -409px; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.fixed.columns { + margin-left: -308px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.fixed.columns { + margin-left: -203px; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.fixed.columns { + margin-left: -100px; + } +} +div.dt-button-collection.fixed > :last-child { + max-height: 100vh; + overflow: auto; +} +div.dt-button-collection.two-column > :last-child, div.dt-button-collection.three-column > :last-child, div.dt-button-collection.four-column > :last-child { + display: block !important; + -webkit-column-gap: 8px; + -moz-column-gap: 8px; + -ms-column-gap: 8px; + -o-column-gap: 8px; + column-gap: 8px; +} +div.dt-button-collection.two-column > :last-child > *, div.dt-button-collection.three-column > :last-child > *, div.dt-button-collection.four-column > :last-child > * { + -webkit-column-break-inside: avoid; + break-inside: avoid; +} +div.dt-button-collection.two-column { + width: 400px; +} +div.dt-button-collection.two-column > :last-child { + padding-bottom: 1px; + column-count: 2; +} +div.dt-button-collection.three-column { + width: 450px; +} +div.dt-button-collection.three-column > :last-child { + padding-bottom: 1px; + column-count: 3; +} +div.dt-button-collection.four-column { + width: 600px; +} +div.dt-button-collection.four-column > :last-child { + padding-bottom: 1px; + column-count: 4; +} +div.dt-button-collection .dt-button { + border-radius: 0; +} +div.dt-button-collection.columns { + width: auto; +} +div.dt-button-collection.columns > :last-child { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + gap: 6px; + width: 818px; + padding-bottom: 1px; +} +div.dt-button-collection.columns > :last-child .dt-button { + min-width: 200px; + flex: 0 1; + margin: 0; +} +div.dt-button-collection.columns.dtb-b3 > :last-child, div.dt-button-collection.columns.dtb-b2 > :last-child, div.dt-button-collection.columns.dtb-b1 > :last-child { + justify-content: space-between; +} +div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 1 1 32%; +} +div.dt-button-collection.columns.dtb-b2 .dt-button { + flex: 1 1 48%; +} +div.dt-button-collection.columns.dtb-b1 .dt-button { + flex: 1 1 100%; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.columns > :last-child { + width: 612px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.columns > :last-child { + width: 406px; + } + div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 0 1 32%; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.columns > :last-child { + width: 200px; + } +} +div.dt-button-collection .dt-button { + min-width: 200px; +} + +div.dt-button-background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 2001; +} + +@media screen and (max-width: 767px) { + div.dt-buttons { + float: none; + width: 100%; + text-align: center; + margin-bottom: 0.5em; + } + div.dt-buttons a.btn { + float: none; + } +} +div.dt-buttons button.btn.processing, +div.dt-buttons div.btn.processing, +div.dt-buttons a.btn.processing { + color: rgba(0, 0, 0, 0.2); +} +div.dt-buttons button.btn.processing:after, +div.dt-buttons div.btn.processing:after, +div.dt-buttons a.btn.processing:after { + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + box-sizing: border-box; + display: block; + content: " "; + border: 2px solid #282828; + border-radius: 50%; + border-left-color: transparent; + border-right-color: transparent; + animation: dtb-spinner 1500ms infinite linear; + -o-animation: dtb-spinner 1500ms infinite linear; + -ms-animation: dtb-spinner 1500ms infinite linear; + -webkit-animation: dtb-spinner 1500ms infinite linear; + -moz-animation: dtb-spinner 1500ms infinite linear; +} + +div.dt-btn-split-wrapper button.dt-btn-split-drop { + border-top-right-radius: 4px !important; + border-bottom-right-radius: 4px !important; +} +div.dt-btn-split-wrapper:active:not(.disabled) button, div.dt-btn-split-wrapper.active:not(.disabled) button { + background-color: #e6e6e6; + border-color: #adadad; +} +div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop, div.dt-btn-split-wrapper.active:not(.disabled) button.dt-btn-split-drop { + box-shadow: none; + background-color: #fff; + border-color: #adadad; +} +div.dt-btn-split-wrapper:active:not(.disabled) button:hover, div.dt-btn-split-wrapper.active:not(.disabled) button:hover { + background-color: #e6e6e6; + border-color: #adadad; +} + +span.dt-down-arrow { + color: rgba(70, 70, 70, 0.9); + font-size: 10px; + padding-left: 10px; +} + +div.dataTables_wrapper div.dt-buttons.btn-group button.btn:last-of-type:first-of-type { + border-radius: 4px !important; +} + +span.dt-down-arrow { + display: none; +} + +span.dt-button-spacer { + float: left; +} +span.dt-button-spacer.bar:empty { + height: inherit; +} + +div.dt-button-collection span.dt-button-spacer { + padding-left: 1rem !important; + text-align: left; +} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap.min.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap.min.css new file mode 100644 index 0000000000..0c7adb4265 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap.min.css @@ -0,0 +1 @@ +@keyframes dtb-spinner{100%{transform:rotate(360deg)}}@-o-keyframes dtb-spinner{100%{-o-transform:rotate(360deg);transform:rotate(360deg)}}@-ms-keyframes dtb-spinner{100%{-ms-transform:rotate(360deg);transform:rotate(360deg)}}@-webkit-keyframes dtb-spinner{100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@-moz-keyframes dtb-spinner{100%{-moz-transform:rotate(360deg);transform:rotate(360deg)}}div.dataTables_wrapper{position:relative}div.dt-buttons{position:initial}div.dt-button-info{position:fixed;top:50%;left:50%;width:400px;margin-top:-100px;margin-left:-200px;background-color:white;border:2px solid #111;box-shadow:3px 4px 10px 1px rgba(0, 0, 0, 0.3);border-radius:3px;text-align:center;z-index:21}div.dt-button-info h2{padding:.5em;margin:0;font-weight:normal;border-bottom:1px solid #ddd;background-color:#f3f3f3}div.dt-button-info>div{padding:1em}div.dtb-popover-close{position:absolute;top:10px;right:10px;width:22px;height:22px;border:1px solid #eaeaea;background-color:#f9f9f9;text-align:center;border-radius:3px;cursor:pointer;z-index:12}button.dtb-hide-drop{display:none !important}div.dt-button-collection-title{text-align:center;padding:.3em 0 .5em;margin-left:.5em;margin-right:.5em;font-size:.9em}div.dt-button-collection-title:empty{display:none}span.dt-button-spacer{display:inline-block;margin:.5em;white-space:nowrap}span.dt-button-spacer.bar{border-left:1px solid rgba(0, 0, 0, 0.3);vertical-align:middle;padding-left:.5em}span.dt-button-spacer.bar:empty{height:1em;width:1px;padding-left:0}div.dt-button-collection span.dt-button-spacer{width:100%;font-size:.9em;text-align:center;margin:.5em 0}div.dt-button-collection span.dt-button-spacer:empty{height:0;width:100%}div.dt-button-collection span.dt-button-spacer.bar{border-left:none;border-bottom:1px solid rgba(0, 0, 0, 0.3);padding-left:0}div.dt-button-collection{position:absolute;z-index:2001;background-color:white;border:1px solid rgba(0, 0, 0, 0.15);border-radius:4px;box-shadow:0 6px 12px rgba(0, 0, 0, 0.175);padding:.5rem 0;min-width:200px}div.dt-button-collection ul.dropdown-menu{position:relative;display:block;z-index:2002;min-width:100%;background-color:transparent;border:none;box-shadow:none;padding:0;border-radius:0}div.dt-button-collection div.dt-btn-split-wrapper{width:100%;display:inline-flex;padding-left:5px;padding-right:5px}div.dt-button-collection button.dt-btn-split-drop-button{width:100%;border:none;border-radius:0px;margin-left:0px !important}div.dt-button-collection button.dt-btn-split-drop-button:focus{border:none;border-radius:0px;outline:none}div.dt-button-collection.fixed{position:fixed;display:block;top:50%;left:50%;margin-left:-75px;border-radius:5px;background-color:white}div.dt-button-collection.fixed.two-column{margin-left:-200px}div.dt-button-collection.fixed.three-column{margin-left:-225px}div.dt-button-collection.fixed.four-column{margin-left:-300px}div.dt-button-collection.fixed.columns{margin-left:-409px}@media screen and (max-width: 1024px){div.dt-button-collection.fixed.columns{margin-left:-308px}}@media screen and (max-width: 640px){div.dt-button-collection.fixed.columns{margin-left:-203px}}@media screen and (max-width: 460px){div.dt-button-collection.fixed.columns{margin-left:-100px}}div.dt-button-collection.fixed>:last-child{max-height:100vh;overflow:auto}div.dt-button-collection.two-column>:last-child,div.dt-button-collection.three-column>:last-child,div.dt-button-collection.four-column>:last-child{display:block !important;-webkit-column-gap:8px;-moz-column-gap:8px;-ms-column-gap:8px;-o-column-gap:8px;column-gap:8px}div.dt-button-collection.two-column>:last-child>*,div.dt-button-collection.three-column>:last-child>*,div.dt-button-collection.four-column>:last-child>*{-webkit-column-break-inside:avoid;break-inside:avoid}div.dt-button-collection.two-column{width:400px}div.dt-button-collection.two-column>:last-child{padding-bottom:1px;column-count:2}div.dt-button-collection.three-column{width:450px}div.dt-button-collection.three-column>:last-child{padding-bottom:1px;column-count:3}div.dt-button-collection.four-column{width:600px}div.dt-button-collection.four-column>:last-child{padding-bottom:1px;column-count:4}div.dt-button-collection .dt-button{border-radius:0}div.dt-button-collection.columns{width:auto}div.dt-button-collection.columns>:last-child{display:flex;flex-wrap:wrap;justify-content:flex-start;align-items:center;gap:6px;width:818px;padding-bottom:1px}div.dt-button-collection.columns>:last-child .dt-button{min-width:200px;flex:0 1;margin:0}div.dt-button-collection.columns.dtb-b3>:last-child,div.dt-button-collection.columns.dtb-b2>:last-child,div.dt-button-collection.columns.dtb-b1>:last-child{justify-content:space-between}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:1 1 32%}div.dt-button-collection.columns.dtb-b2 .dt-button{flex:1 1 48%}div.dt-button-collection.columns.dtb-b1 .dt-button{flex:1 1 100%}@media screen and (max-width: 1024px){div.dt-button-collection.columns>:last-child{width:612px}}@media screen and (max-width: 640px){div.dt-button-collection.columns>:last-child{width:406px}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:0 1 32%}}@media screen and (max-width: 460px){div.dt-button-collection.columns>:last-child{width:200px}}div.dt-button-collection .dt-button{min-width:200px}div.dt-button-background{position:fixed;top:0;left:0;width:100%;height:100%;z-index:2001}@media screen and (max-width: 767px){div.dt-buttons{float:none;width:100%;text-align:center;margin-bottom:.5em}div.dt-buttons a.btn{float:none}}div.dt-buttons button.btn.processing,div.dt-buttons div.btn.processing,div.dt-buttons a.btn.processing{color:rgba(0, 0, 0, 0.2)}div.dt-buttons button.btn.processing:after,div.dt-buttons div.btn.processing:after,div.dt-buttons a.btn.processing:after{position:absolute;top:50%;left:50%;width:16px;height:16px;margin:-8px 0 0 -8px;box-sizing:border-box;display:block;content:" ";border:2px solid #282828;border-radius:50%;border-left-color:transparent;border-right-color:transparent;animation:dtb-spinner 1500ms infinite linear;-o-animation:dtb-spinner 1500ms infinite linear;-ms-animation:dtb-spinner 1500ms infinite linear;-webkit-animation:dtb-spinner 1500ms infinite linear;-moz-animation:dtb-spinner 1500ms infinite linear}div.dt-btn-split-wrapper button.dt-btn-split-drop{border-top-right-radius:4px !important;border-bottom-right-radius:4px !important}div.dt-btn-split-wrapper:active:not(.disabled) button,div.dt-btn-split-wrapper.active:not(.disabled) button{background-color:#e6e6e6;border-color:#adadad}div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop,div.dt-btn-split-wrapper.active:not(.disabled) button.dt-btn-split-drop{box-shadow:none;background-color:#fff;border-color:#adadad}div.dt-btn-split-wrapper:active:not(.disabled) button:hover,div.dt-btn-split-wrapper.active:not(.disabled) button:hover{background-color:#e6e6e6;border-color:#adadad}span.dt-down-arrow{color:rgba(70, 70, 70, 0.9);font-size:10px;padding-left:10px}div.dataTables_wrapper div.dt-buttons.btn-group button.btn:last-of-type:first-of-type{border-radius:4px !important}span.dt-down-arrow{display:none}span.dt-button-spacer{float:left}span.dt-button-spacer.bar:empty{height:inherit}div.dt-button-collection span.dt-button-spacer{padding-left:1rem !important;text-align:left} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap4.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap4.css new file mode 100644 index 0000000000..45d37f50ec --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap4.css @@ -0,0 +1,426 @@ +@keyframes dtb-spinner { + 100% { + transform: rotate(360deg); + } +} +@-o-keyframes dtb-spinner { + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-ms-keyframes dtb-spinner { + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-webkit-keyframes dtb-spinner { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-moz-keyframes dtb-spinner { + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } +} +div.dataTables_wrapper { + position: relative; +} + +div.dt-buttons { + position: initial; +} + +div.dt-button-info { + position: fixed; + top: 50%; + left: 50%; + width: 400px; + margin-top: -100px; + margin-left: -200px; + background-color: white; + border: 2px solid #111; + box-shadow: 3px 4px 10px 1px rgba(0, 0, 0, 0.3); + border-radius: 3px; + text-align: center; + z-index: 21; +} +div.dt-button-info h2 { + padding: 0.5em; + margin: 0; + font-weight: normal; + border-bottom: 1px solid #ddd; + background-color: #f3f3f3; +} +div.dt-button-info > div { + padding: 1em; +} + +div.dtb-popover-close { + position: absolute; + top: 10px; + right: 10px; + width: 22px; + height: 22px; + border: 1px solid #eaeaea; + background-color: #f9f9f9; + text-align: center; + border-radius: 3px; + cursor: pointer; + z-index: 12; +} + +button.dtb-hide-drop { + display: none !important; +} + +div.dt-button-collection-title { + text-align: center; + padding: 0.3em 0 0.5em; + margin-left: 0.5em; + margin-right: 0.5em; + font-size: 0.9em; +} + +div.dt-button-collection-title:empty { + display: none; +} + +span.dt-button-spacer { + display: inline-block; + margin: 0.5em; + white-space: nowrap; +} +span.dt-button-spacer.bar { + border-left: 1px solid rgba(0, 0, 0, 0.3); + vertical-align: middle; + padding-left: 0.5em; +} +span.dt-button-spacer.bar:empty { + height: 1em; + width: 1px; + padding-left: 0; +} + +div.dt-button-collection span.dt-button-spacer { + width: 100%; + font-size: 0.9em; + text-align: center; + margin: 0.5em 0; +} +div.dt-button-collection span.dt-button-spacer:empty { + height: 0; + width: 100%; +} +div.dt-button-collection span.dt-button-spacer.bar { + border-left: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.3); + padding-left: 0; +} + +div.dt-button-collection { + position: absolute; + z-index: 2001; + background-color: white; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + padding: 0.5rem 0; + width: 200px; +} +div.dt-button-collection div.dropdown-menu { + position: relative; + display: block; + z-index: 2002; + min-width: 100%; + background-color: transparent; + border: none; + box-shadow: none; + padding: 0; + border-radius: 0; +} +div.dt-button-collection.fixed { + position: fixed; + display: block; + top: 50%; + left: 50%; + margin-left: -75px; + border-radius: 5px; + background-color: white; +} +div.dt-button-collection.fixed.two-column { + margin-left: -200px; +} +div.dt-button-collection.fixed.three-column { + margin-left: -225px; +} +div.dt-button-collection.fixed.four-column { + margin-left: -300px; +} +div.dt-button-collection.fixed.columns { + margin-left: -409px; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.fixed.columns { + margin-left: -308px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.fixed.columns { + margin-left: -203px; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.fixed.columns { + margin-left: -100px; + } +} +div.dt-button-collection.fixed > :last-child { + max-height: 100vh; + overflow: auto; +} +div.dt-button-collection.two-column > :last-child, div.dt-button-collection.three-column > :last-child, div.dt-button-collection.four-column > :last-child { + display: block !important; + -webkit-column-gap: 8px; + -moz-column-gap: 8px; + -ms-column-gap: 8px; + -o-column-gap: 8px; + column-gap: 8px; +} +div.dt-button-collection.two-column > :last-child > *, div.dt-button-collection.three-column > :last-child > *, div.dt-button-collection.four-column > :last-child > * { + -webkit-column-break-inside: avoid; + break-inside: avoid; +} +div.dt-button-collection.two-column { + width: 400px; +} +div.dt-button-collection.two-column > :last-child { + padding-bottom: 1px; + column-count: 2; +} +div.dt-button-collection.three-column { + width: 450px; +} +div.dt-button-collection.three-column > :last-child { + padding-bottom: 1px; + column-count: 3; +} +div.dt-button-collection.four-column { + width: 600px; +} +div.dt-button-collection.four-column > :last-child { + padding-bottom: 1px; + column-count: 4; +} +div.dt-button-collection .dt-button { + border-radius: 0; +} +div.dt-button-collection.columns { + width: auto; +} +div.dt-button-collection.columns > :last-child { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + gap: 6px; + width: 818px; + padding-bottom: 1px; +} +div.dt-button-collection.columns > :last-child .dt-button { + min-width: 200px; + flex: 0 1; + margin: 0; +} +div.dt-button-collection.columns.dtb-b3 > :last-child, div.dt-button-collection.columns.dtb-b2 > :last-child, div.dt-button-collection.columns.dtb-b1 > :last-child { + justify-content: space-between; +} +div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 1 1 32%; +} +div.dt-button-collection.columns.dtb-b2 .dt-button { + flex: 1 1 48%; +} +div.dt-button-collection.columns.dtb-b1 .dt-button { + flex: 1 1 100%; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.columns > :last-child { + width: 612px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.columns > :last-child { + width: 406px; + } + div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 0 1 32%; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.columns > :last-child { + width: 200px; + } +} +div.dt-button-collection.fixed:before, div.dt-button-collection.fixed:after { + display: none; +} +div.dt-button-collection .btn-group { + flex: 1 1 auto; +} +div.dt-button-collection .dt-button { + min-width: 200px; +} +div.dt-button-collection div.dt-btn-split-wrapper { + width: 100%; + padding-left: 5px; + padding-right: 5px; +} +div.dt-button-collection button.dt-btn-split-drop-button { + width: 100%; + color: #212529; + border: none; + background-color: white; + border-radius: 0px; + margin-left: 0px !important; +} +div.dt-button-collection button.dt-btn-split-drop-button:focus { + border: none; + border-radius: 0px; + outline: none; +} +div.dt-button-collection button.dt-btn-split-drop-button:hover { + background-color: #e9ecef; +} +div.dt-button-collection button.dt-btn-split-drop-button:active { + background-color: #007bff !important; +} + +div.dt-button-background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 999; +} + +@media screen and (max-width: 767px) { + div.dt-buttons { + float: none; + width: 100%; + text-align: center; + margin-bottom: 0.5em; + } + div.dt-buttons a.btn { + float: none; + } +} +div.dt-buttons button.btn.processing, +div.dt-buttons div.btn.processing, +div.dt-buttons a.btn.processing { + color: rgba(0, 0, 0, 0.2); +} +div.dt-buttons button.btn.processing:after, +div.dt-buttons div.btn.processing:after, +div.dt-buttons a.btn.processing:after { + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + box-sizing: border-box; + display: block; + content: " "; + border: 2px solid #282828; + border-radius: 50%; + border-left-color: transparent; + border-right-color: transparent; + animation: dtb-spinner 1500ms infinite linear; + -o-animation: dtb-spinner 1500ms infinite linear; + -ms-animation: dtb-spinner 1500ms infinite linear; + -webkit-animation: dtb-spinner 1500ms infinite linear; + -moz-animation: dtb-spinner 1500ms infinite linear; +} +div.dt-buttons div.btn-group { + position: initial; +} + +div.dt-btn-split-wrapper:active:not(.disabled) button, div.dt-btn-split-wrapper.active:not(.disabled) button { + background-color: #5a6268; + border-color: #545b62; +} +div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop, div.dt-btn-split-wrapper.active:not(.disabled) button.dt-btn-split-drop { + box-shadow: none; + background-color: #6c757d; + border-color: #6c757d; +} +div.dt-btn-split-wrapper:active:not(.disabled) button:hover, div.dt-btn-split-wrapper.active:not(.disabled) button:hover { + background-color: #5a6268; + border-color: #545b62; +} + +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group { + border-radius: 4px !important; +} +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:last-child { + border-top-left-radius: 0px !important; + border-bottom-left-radius: 0px !important; +} +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:first-child { + border-top-right-radius: 0px !important; + border-bottom-right-radius: 0px !important; +} +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:last-child:first-child { + border-top-left-radius: 4px !important; + border-bottom-left-radius: 4px !important; + border-top-right-radius: 4px !important; + border-bottom-right-radius: 4px !important; +} +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group button.dt-btn-split-drop:last-child { + border: 1px solid #6c757d; +} +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group div.dt-btn-split-wrapper { + border: none; +} + +div.dt-button-collection div.btn-group { + border-radius: 4px !important; +} +div.dt-button-collection div.btn-group button { + border-radius: 4px; +} +div.dt-button-collection div.btn-group button:last-child { + border-top-left-radius: 0px !important; + border-bottom-left-radius: 0px !important; +} +div.dt-button-collection div.btn-group button:first-child { + border-top-right-radius: 0px !important; + border-bottom-right-radius: 0px !important; +} +div.dt-button-collection div.btn-group button:last-child:first-child { + border-top-left-radius: 4px !important; + border-bottom-left-radius: 4px !important; + border-top-right-radius: 4px !important; + border-bottom-right-radius: 4px !important; +} +div.dt-button-collection div.btn-group button.dt-btn-split-drop:last-child { + border: 1px solid #6c757d; +} +div.dt-button-collection div.btn-group div.dt-btn-split-wrapper { + border: none; +} + +span.dt-button-spacer.bar:empty { + height: inherit; +} + +div.dt-button-collection span.dt-button-spacer { + padding-left: 1rem !important; + text-align: left; +} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap4.min.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap4.min.css new file mode 100644 index 0000000000..d688b82e06 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap4.min.css @@ -0,0 +1 @@ +@keyframes dtb-spinner{100%{transform:rotate(360deg)}}@-o-keyframes dtb-spinner{100%{-o-transform:rotate(360deg);transform:rotate(360deg)}}@-ms-keyframes dtb-spinner{100%{-ms-transform:rotate(360deg);transform:rotate(360deg)}}@-webkit-keyframes dtb-spinner{100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@-moz-keyframes dtb-spinner{100%{-moz-transform:rotate(360deg);transform:rotate(360deg)}}div.dataTables_wrapper{position:relative}div.dt-buttons{position:initial}div.dt-button-info{position:fixed;top:50%;left:50%;width:400px;margin-top:-100px;margin-left:-200px;background-color:white;border:2px solid #111;box-shadow:3px 4px 10px 1px rgba(0, 0, 0, 0.3);border-radius:3px;text-align:center;z-index:21}div.dt-button-info h2{padding:.5em;margin:0;font-weight:normal;border-bottom:1px solid #ddd;background-color:#f3f3f3}div.dt-button-info>div{padding:1em}div.dtb-popover-close{position:absolute;top:10px;right:10px;width:22px;height:22px;border:1px solid #eaeaea;background-color:#f9f9f9;text-align:center;border-radius:3px;cursor:pointer;z-index:12}button.dtb-hide-drop{display:none !important}div.dt-button-collection-title{text-align:center;padding:.3em 0 .5em;margin-left:.5em;margin-right:.5em;font-size:.9em}div.dt-button-collection-title:empty{display:none}span.dt-button-spacer{display:inline-block;margin:.5em;white-space:nowrap}span.dt-button-spacer.bar{border-left:1px solid rgba(0, 0, 0, 0.3);vertical-align:middle;padding-left:.5em}span.dt-button-spacer.bar:empty{height:1em;width:1px;padding-left:0}div.dt-button-collection span.dt-button-spacer{width:100%;font-size:.9em;text-align:center;margin:.5em 0}div.dt-button-collection span.dt-button-spacer:empty{height:0;width:100%}div.dt-button-collection span.dt-button-spacer.bar{border-left:none;border-bottom:1px solid rgba(0, 0, 0, 0.3);padding-left:0}div.dt-button-collection{position:absolute;z-index:2001;background-color:white;border:1px solid rgba(0, 0, 0, 0.15);border-radius:4px;box-shadow:0 6px 12px rgba(0, 0, 0, 0.175);padding:.5rem 0;width:200px}div.dt-button-collection div.dropdown-menu{position:relative;display:block;z-index:2002;min-width:100%;background-color:transparent;border:none;box-shadow:none;padding:0;border-radius:0}div.dt-button-collection.fixed{position:fixed;display:block;top:50%;left:50%;margin-left:-75px;border-radius:5px;background-color:white}div.dt-button-collection.fixed.two-column{margin-left:-200px}div.dt-button-collection.fixed.three-column{margin-left:-225px}div.dt-button-collection.fixed.four-column{margin-left:-300px}div.dt-button-collection.fixed.columns{margin-left:-409px}@media screen and (max-width: 1024px){div.dt-button-collection.fixed.columns{margin-left:-308px}}@media screen and (max-width: 640px){div.dt-button-collection.fixed.columns{margin-left:-203px}}@media screen and (max-width: 460px){div.dt-button-collection.fixed.columns{margin-left:-100px}}div.dt-button-collection.fixed>:last-child{max-height:100vh;overflow:auto}div.dt-button-collection.two-column>:last-child,div.dt-button-collection.three-column>:last-child,div.dt-button-collection.four-column>:last-child{display:block !important;-webkit-column-gap:8px;-moz-column-gap:8px;-ms-column-gap:8px;-o-column-gap:8px;column-gap:8px}div.dt-button-collection.two-column>:last-child>*,div.dt-button-collection.three-column>:last-child>*,div.dt-button-collection.four-column>:last-child>*{-webkit-column-break-inside:avoid;break-inside:avoid}div.dt-button-collection.two-column{width:400px}div.dt-button-collection.two-column>:last-child{padding-bottom:1px;column-count:2}div.dt-button-collection.three-column{width:450px}div.dt-button-collection.three-column>:last-child{padding-bottom:1px;column-count:3}div.dt-button-collection.four-column{width:600px}div.dt-button-collection.four-column>:last-child{padding-bottom:1px;column-count:4}div.dt-button-collection .dt-button{border-radius:0}div.dt-button-collection.columns{width:auto}div.dt-button-collection.columns>:last-child{display:flex;flex-wrap:wrap;justify-content:flex-start;align-items:center;gap:6px;width:818px;padding-bottom:1px}div.dt-button-collection.columns>:last-child .dt-button{min-width:200px;flex:0 1;margin:0}div.dt-button-collection.columns.dtb-b3>:last-child,div.dt-button-collection.columns.dtb-b2>:last-child,div.dt-button-collection.columns.dtb-b1>:last-child{justify-content:space-between}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:1 1 32%}div.dt-button-collection.columns.dtb-b2 .dt-button{flex:1 1 48%}div.dt-button-collection.columns.dtb-b1 .dt-button{flex:1 1 100%}@media screen and (max-width: 1024px){div.dt-button-collection.columns>:last-child{width:612px}}@media screen and (max-width: 640px){div.dt-button-collection.columns>:last-child{width:406px}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:0 1 32%}}@media screen and (max-width: 460px){div.dt-button-collection.columns>:last-child{width:200px}}div.dt-button-collection.fixed:before,div.dt-button-collection.fixed:after{display:none}div.dt-button-collection .btn-group{flex:1 1 auto}div.dt-button-collection .dt-button{min-width:200px}div.dt-button-collection div.dt-btn-split-wrapper{width:100%;padding-left:5px;padding-right:5px}div.dt-button-collection button.dt-btn-split-drop-button{width:100%;color:#212529;border:none;background-color:white;border-radius:0px;margin-left:0px !important}div.dt-button-collection button.dt-btn-split-drop-button:focus{border:none;border-radius:0px;outline:none}div.dt-button-collection button.dt-btn-split-drop-button:hover{background-color:#e9ecef}div.dt-button-collection button.dt-btn-split-drop-button:active{background-color:#007bff !important}div.dt-button-background{position:fixed;top:0;left:0;width:100%;height:100%;z-index:999}@media screen and (max-width: 767px){div.dt-buttons{float:none;width:100%;text-align:center;margin-bottom:.5em}div.dt-buttons a.btn{float:none}}div.dt-buttons button.btn.processing,div.dt-buttons div.btn.processing,div.dt-buttons a.btn.processing{color:rgba(0, 0, 0, 0.2)}div.dt-buttons button.btn.processing:after,div.dt-buttons div.btn.processing:after,div.dt-buttons a.btn.processing:after{position:absolute;top:50%;left:50%;width:16px;height:16px;margin:-8px 0 0 -8px;box-sizing:border-box;display:block;content:" ";border:2px solid #282828;border-radius:50%;border-left-color:transparent;border-right-color:transparent;animation:dtb-spinner 1500ms infinite linear;-o-animation:dtb-spinner 1500ms infinite linear;-ms-animation:dtb-spinner 1500ms infinite linear;-webkit-animation:dtb-spinner 1500ms infinite linear;-moz-animation:dtb-spinner 1500ms infinite linear}div.dt-buttons div.btn-group{position:initial}div.dt-btn-split-wrapper:active:not(.disabled) button,div.dt-btn-split-wrapper.active:not(.disabled) button{background-color:#5a6268;border-color:#545b62}div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop,div.dt-btn-split-wrapper.active:not(.disabled) button.dt-btn-split-drop{box-shadow:none;background-color:#6c757d;border-color:#6c757d}div.dt-btn-split-wrapper:active:not(.disabled) button:hover,div.dt-btn-split-wrapper.active:not(.disabled) button:hover{background-color:#5a6268;border-color:#545b62}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group{border-radius:4px !important}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:last-child{border-top-left-radius:0px !important;border-bottom-left-radius:0px !important}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:first-child{border-top-right-radius:0px !important;border-bottom-right-radius:0px !important}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:last-child:first-child{border-top-left-radius:4px !important;border-bottom-left-radius:4px !important;border-top-right-radius:4px !important;border-bottom-right-radius:4px !important}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group button.dt-btn-split-drop:last-child{border:1px solid #6c757d}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group div.dt-btn-split-wrapper{border:none}div.dt-button-collection div.btn-group{border-radius:4px !important}div.dt-button-collection div.btn-group button{border-radius:4px}div.dt-button-collection div.btn-group button:last-child{border-top-left-radius:0px !important;border-bottom-left-radius:0px !important}div.dt-button-collection div.btn-group button:first-child{border-top-right-radius:0px !important;border-bottom-right-radius:0px !important}div.dt-button-collection div.btn-group button:last-child:first-child{border-top-left-radius:4px !important;border-bottom-left-radius:4px !important;border-top-right-radius:4px !important;border-bottom-right-radius:4px !important}div.dt-button-collection div.btn-group button.dt-btn-split-drop:last-child{border:1px solid #6c757d}div.dt-button-collection div.btn-group div.dt-btn-split-wrapper{border:none}span.dt-button-spacer.bar:empty{height:inherit}div.dt-button-collection span.dt-button-spacer{padding-left:1rem !important;text-align:left} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap5.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap5.css new file mode 100644 index 0000000000..40ccc2e02b --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap5.css @@ -0,0 +1,428 @@ +@keyframes dtb-spinner { + 100% { + transform: rotate(360deg); + } +} +@-o-keyframes dtb-spinner { + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-ms-keyframes dtb-spinner { + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-webkit-keyframes dtb-spinner { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-moz-keyframes dtb-spinner { + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } +} +div.dataTables_wrapper { + position: relative; +} + +div.dt-buttons { + position: initial; +} + +div.dt-button-info { + position: fixed; + top: 50%; + left: 50%; + width: 400px; + margin-top: -100px; + margin-left: -200px; + background-color: white; + border: 2px solid #111; + box-shadow: 3px 4px 10px 1px rgba(0, 0, 0, 0.3); + border-radius: 3px; + text-align: center; + z-index: 21; +} +div.dt-button-info h2 { + padding: 0.5em; + margin: 0; + font-weight: normal; + border-bottom: 1px solid #ddd; + background-color: #f3f3f3; +} +div.dt-button-info > div { + padding: 1em; +} + +div.dtb-popover-close { + position: absolute; + top: 10px; + right: 10px; + width: 22px; + height: 22px; + border: 1px solid #eaeaea; + background-color: #f9f9f9; + text-align: center; + border-radius: 3px; + cursor: pointer; + z-index: 12; +} + +button.dtb-hide-drop { + display: none !important; +} + +div.dt-button-collection-title { + text-align: center; + padding: 0.3em 0 0.5em; + margin-left: 0.5em; + margin-right: 0.5em; + font-size: 0.9em; +} + +div.dt-button-collection-title:empty { + display: none; +} + +span.dt-button-spacer { + display: inline-block; + margin: 0.5em; + white-space: nowrap; +} +span.dt-button-spacer.bar { + border-left: 1px solid rgba(0, 0, 0, 0.3); + vertical-align: middle; + padding-left: 0.5em; +} +span.dt-button-spacer.bar:empty { + height: 1em; + width: 1px; + padding-left: 0; +} + +div.dt-button-collection span.dt-button-spacer { + width: 100%; + font-size: 0.9em; + text-align: center; + margin: 0.5em 0; +} +div.dt-button-collection span.dt-button-spacer:empty { + height: 0; + width: 100%; +} +div.dt-button-collection span.dt-button-spacer.bar { + border-left: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.3); + padding-left: 0; +} + +div.dt-button-collection { + position: absolute; + z-index: 2001; + background-color: white; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + padding: 0.5rem 0; + width: 200px; +} +div.dt-button-collection div.dropdown-menu { + position: relative; + display: block; + background-color: transparent; + border: none; + box-shadow: none; + padding: 0; + border-radius: 0; + z-index: 2002; + min-width: 100%; +} +div.dt-button-collection.fixed { + position: fixed; + display: block; + top: 50%; + left: 50%; + margin-left: -75px; + border-radius: 5px; + background-color: white; +} +div.dt-button-collection.fixed.two-column { + margin-left: -200px; +} +div.dt-button-collection.fixed.three-column { + margin-left: -225px; +} +div.dt-button-collection.fixed.four-column { + margin-left: -300px; +} +div.dt-button-collection.fixed.columns { + margin-left: -409px; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.fixed.columns { + margin-left: -308px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.fixed.columns { + margin-left: -203px; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.fixed.columns { + margin-left: -100px; + } +} +div.dt-button-collection.fixed > :last-child { + max-height: 100vh; + overflow: auto; +} +div.dt-button-collection.two-column > :last-child, div.dt-button-collection.three-column > :last-child, div.dt-button-collection.four-column > :last-child { + display: block !important; + -webkit-column-gap: 8px; + -moz-column-gap: 8px; + -ms-column-gap: 8px; + -o-column-gap: 8px; + column-gap: 8px; +} +div.dt-button-collection.two-column > :last-child > *, div.dt-button-collection.three-column > :last-child > *, div.dt-button-collection.four-column > :last-child > * { + -webkit-column-break-inside: avoid; + break-inside: avoid; +} +div.dt-button-collection.two-column { + width: 400px; +} +div.dt-button-collection.two-column > :last-child { + padding-bottom: 1px; + column-count: 2; +} +div.dt-button-collection.three-column { + width: 450px; +} +div.dt-button-collection.three-column > :last-child { + padding-bottom: 1px; + column-count: 3; +} +div.dt-button-collection.four-column { + width: 600px; +} +div.dt-button-collection.four-column > :last-child { + padding-bottom: 1px; + column-count: 4; +} +div.dt-button-collection .dt-button { + border-radius: 0; +} +div.dt-button-collection.columns { + width: auto; +} +div.dt-button-collection.columns > :last-child { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + gap: 6px; + width: 818px; + padding-bottom: 1px; +} +div.dt-button-collection.columns > :last-child .dt-button { + min-width: 200px; + flex: 0 1; + margin: 0; +} +div.dt-button-collection.columns.dtb-b3 > :last-child, div.dt-button-collection.columns.dtb-b2 > :last-child, div.dt-button-collection.columns.dtb-b1 > :last-child { + justify-content: space-between; +} +div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 1 1 32%; +} +div.dt-button-collection.columns.dtb-b2 .dt-button { + flex: 1 1 48%; +} +div.dt-button-collection.columns.dtb-b1 .dt-button { + flex: 1 1 100%; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.columns > :last-child { + width: 612px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.columns > :last-child { + width: 406px; + } + div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 0 1 32%; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.columns > :last-child { + width: 200px; + } +} +div.dt-button-collection.fixed:before, div.dt-button-collection.fixed:after { + display: none; +} +div.dt-button-collection .btn-group { + flex: 1 1 auto; +} +div.dt-button-collection .dt-button { + min-width: 200px; +} +div.dt-button-collection div.dt-btn-split-wrapper { + width: 100%; +} +div.dt-button-collection button.dt-btn-split-drop-button { + width: 100%; + color: #212529; + border: none; + background-color: white; + border-radius: 0px; + margin-left: 0px !important; +} +div.dt-button-collection button.dt-btn-split-drop-button:focus { + border: none; + border-radius: 0px; + outline: none; +} +div.dt-button-collection button.dt-btn-split-drop-button:hover { + background-color: #e9ecef; +} +div.dt-button-collection button.dt-btn-split-drop-button:active { + background-color: #007bff !important; +} + +div.dt-button-background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 999; +} + +@media screen and (max-width: 767px) { + div.dt-buttons { + float: none; + width: 100%; + text-align: center; + margin-bottom: 0.5em; + } + div.dt-buttons a.btn { + float: none; + } +} +div.dt-buttons button.btn.processing, +div.dt-buttons div.btn.processing, +div.dt-buttons a.btn.processing { + color: rgba(0, 0, 0, 0.2); +} +div.dt-buttons button.btn.processing:after, +div.dt-buttons div.btn.processing:after, +div.dt-buttons a.btn.processing:after { + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + box-sizing: border-box; + display: block; + content: " "; + border: 2px solid #282828; + border-radius: 50%; + border-left-color: transparent; + border-right-color: transparent; + animation: dtb-spinner 1500ms infinite linear; + -o-animation: dtb-spinner 1500ms infinite linear; + -ms-animation: dtb-spinner 1500ms infinite linear; + -webkit-animation: dtb-spinner 1500ms infinite linear; + -moz-animation: dtb-spinner 1500ms infinite linear; +} +div.dt-buttons div.btn-group { + position: initial; +} + +div.dt-btn-split-wrapper button.dt-btn-split-drop { + border-top-right-radius: 0.25rem !important; + border-bottom-right-radius: 0.25rem !important; +} +div.dt-btn-split-wrapper:active:not(.disabled) button, div.dt-btn-split-wrapper.active:not(.disabled) button { + background-color: #5a6268; + border-color: #545b62; +} +div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop, div.dt-btn-split-wrapper.active:not(.disabled) button.dt-btn-split-drop { + box-shadow: none; + background-color: #6c757d; + border-color: #6c757d; +} +div.dt-btn-split-wrapper:active:not(.disabled) button:hover, div.dt-btn-split-wrapper.active:not(.disabled) button:hover { + background-color: #5a6268; + border-color: #545b62; +} + +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group { + border-radius: 4px !important; +} +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:last-child { + border-top-left-radius: 0px !important; + border-bottom-left-radius: 0px !important; +} +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:first-child { + border-top-right-radius: 0px !important; + border-bottom-right-radius: 0px !important; +} +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:last-child:first-child { + border-top-left-radius: 4px !important; + border-bottom-left-radius: 4px !important; + border-top-right-radius: 4px !important; + border-bottom-right-radius: 4px !important; +} +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group button.dt-btn-split-drop:last-child { + border: 1px solid #6c757d; +} +div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group div.dt-btn-split-wrapper { + border: none; +} + +div.dt-button-collection div.btn-group { + border-radius: 4px !important; +} +div.dt-button-collection div.btn-group button { + border-radius: 4px; +} +div.dt-button-collection div.btn-group button:last-child { + border-top-left-radius: 0px !important; + border-bottom-left-radius: 0px !important; +} +div.dt-button-collection div.btn-group button:first-child { + border-top-right-radius: 0px !important; + border-bottom-right-radius: 0px !important; +} +div.dt-button-collection div.btn-group button:last-child:first-child { + border-top-left-radius: 4px !important; + border-bottom-left-radius: 4px !important; + border-top-right-radius: 4px !important; + border-bottom-right-radius: 4px !important; +} +div.dt-button-collection div.btn-group button.dt-btn-split-drop:last-child { + border: 1px solid #6c757d; +} +div.dt-button-collection div.btn-group div.dt-btn-split-wrapper { + border: none; +} + +span.dt-button-spacer.bar:empty { + height: inherit; +} + +div.dt-button-collection span.dt-button-spacer { + padding-left: 1rem !important; + text-align: left; +} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap5.min.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap5.min.css new file mode 100644 index 0000000000..52d830322f --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.bootstrap5.min.css @@ -0,0 +1 @@ +@keyframes dtb-spinner{100%{transform:rotate(360deg)}}@-o-keyframes dtb-spinner{100%{-o-transform:rotate(360deg);transform:rotate(360deg)}}@-ms-keyframes dtb-spinner{100%{-ms-transform:rotate(360deg);transform:rotate(360deg)}}@-webkit-keyframes dtb-spinner{100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@-moz-keyframes dtb-spinner{100%{-moz-transform:rotate(360deg);transform:rotate(360deg)}}div.dataTables_wrapper{position:relative}div.dt-buttons{position:initial}div.dt-button-info{position:fixed;top:50%;left:50%;width:400px;margin-top:-100px;margin-left:-200px;background-color:white;border:2px solid #111;box-shadow:3px 4px 10px 1px rgba(0, 0, 0, 0.3);border-radius:3px;text-align:center;z-index:21}div.dt-button-info h2{padding:.5em;margin:0;font-weight:normal;border-bottom:1px solid #ddd;background-color:#f3f3f3}div.dt-button-info>div{padding:1em}div.dtb-popover-close{position:absolute;top:10px;right:10px;width:22px;height:22px;border:1px solid #eaeaea;background-color:#f9f9f9;text-align:center;border-radius:3px;cursor:pointer;z-index:12}button.dtb-hide-drop{display:none !important}div.dt-button-collection-title{text-align:center;padding:.3em 0 .5em;margin-left:.5em;margin-right:.5em;font-size:.9em}div.dt-button-collection-title:empty{display:none}span.dt-button-spacer{display:inline-block;margin:.5em;white-space:nowrap}span.dt-button-spacer.bar{border-left:1px solid rgba(0, 0, 0, 0.3);vertical-align:middle;padding-left:.5em}span.dt-button-spacer.bar:empty{height:1em;width:1px;padding-left:0}div.dt-button-collection span.dt-button-spacer{width:100%;font-size:.9em;text-align:center;margin:.5em 0}div.dt-button-collection span.dt-button-spacer:empty{height:0;width:100%}div.dt-button-collection span.dt-button-spacer.bar{border-left:none;border-bottom:1px solid rgba(0, 0, 0, 0.3);padding-left:0}div.dt-button-collection{position:absolute;z-index:2001;background-color:white;border:1px solid rgba(0, 0, 0, 0.15);border-radius:4px;box-shadow:0 6px 12px rgba(0, 0, 0, 0.175);padding:.5rem 0;width:200px}div.dt-button-collection div.dropdown-menu{position:relative;display:block;background-color:transparent;border:none;box-shadow:none;padding:0;border-radius:0;z-index:2002;min-width:100%}div.dt-button-collection.fixed{position:fixed;display:block;top:50%;left:50%;margin-left:-75px;border-radius:5px;background-color:white}div.dt-button-collection.fixed.two-column{margin-left:-200px}div.dt-button-collection.fixed.three-column{margin-left:-225px}div.dt-button-collection.fixed.four-column{margin-left:-300px}div.dt-button-collection.fixed.columns{margin-left:-409px}@media screen and (max-width: 1024px){div.dt-button-collection.fixed.columns{margin-left:-308px}}@media screen and (max-width: 640px){div.dt-button-collection.fixed.columns{margin-left:-203px}}@media screen and (max-width: 460px){div.dt-button-collection.fixed.columns{margin-left:-100px}}div.dt-button-collection.fixed>:last-child{max-height:100vh;overflow:auto}div.dt-button-collection.two-column>:last-child,div.dt-button-collection.three-column>:last-child,div.dt-button-collection.four-column>:last-child{display:block !important;-webkit-column-gap:8px;-moz-column-gap:8px;-ms-column-gap:8px;-o-column-gap:8px;column-gap:8px}div.dt-button-collection.two-column>:last-child>*,div.dt-button-collection.three-column>:last-child>*,div.dt-button-collection.four-column>:last-child>*{-webkit-column-break-inside:avoid;break-inside:avoid}div.dt-button-collection.two-column{width:400px}div.dt-button-collection.two-column>:last-child{padding-bottom:1px;column-count:2}div.dt-button-collection.three-column{width:450px}div.dt-button-collection.three-column>:last-child{padding-bottom:1px;column-count:3}div.dt-button-collection.four-column{width:600px}div.dt-button-collection.four-column>:last-child{padding-bottom:1px;column-count:4}div.dt-button-collection .dt-button{border-radius:0}div.dt-button-collection.columns{width:auto}div.dt-button-collection.columns>:last-child{display:flex;flex-wrap:wrap;justify-content:flex-start;align-items:center;gap:6px;width:818px;padding-bottom:1px}div.dt-button-collection.columns>:last-child .dt-button{min-width:200px;flex:0 1;margin:0}div.dt-button-collection.columns.dtb-b3>:last-child,div.dt-button-collection.columns.dtb-b2>:last-child,div.dt-button-collection.columns.dtb-b1>:last-child{justify-content:space-between}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:1 1 32%}div.dt-button-collection.columns.dtb-b2 .dt-button{flex:1 1 48%}div.dt-button-collection.columns.dtb-b1 .dt-button{flex:1 1 100%}@media screen and (max-width: 1024px){div.dt-button-collection.columns>:last-child{width:612px}}@media screen and (max-width: 640px){div.dt-button-collection.columns>:last-child{width:406px}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:0 1 32%}}@media screen and (max-width: 460px){div.dt-button-collection.columns>:last-child{width:200px}}div.dt-button-collection.fixed:before,div.dt-button-collection.fixed:after{display:none}div.dt-button-collection .btn-group{flex:1 1 auto}div.dt-button-collection .dt-button{min-width:200px}div.dt-button-collection div.dt-btn-split-wrapper{width:100%}div.dt-button-collection button.dt-btn-split-drop-button{width:100%;color:#212529;border:none;background-color:white;border-radius:0px;margin-left:0px !important}div.dt-button-collection button.dt-btn-split-drop-button:focus{border:none;border-radius:0px;outline:none}div.dt-button-collection button.dt-btn-split-drop-button:hover{background-color:#e9ecef}div.dt-button-collection button.dt-btn-split-drop-button:active{background-color:#007bff !important}div.dt-button-background{position:fixed;top:0;left:0;width:100%;height:100%;z-index:999}@media screen and (max-width: 767px){div.dt-buttons{float:none;width:100%;text-align:center;margin-bottom:.5em}div.dt-buttons a.btn{float:none}}div.dt-buttons button.btn.processing,div.dt-buttons div.btn.processing,div.dt-buttons a.btn.processing{color:rgba(0, 0, 0, 0.2)}div.dt-buttons button.btn.processing:after,div.dt-buttons div.btn.processing:after,div.dt-buttons a.btn.processing:after{position:absolute;top:50%;left:50%;width:16px;height:16px;margin:-8px 0 0 -8px;box-sizing:border-box;display:block;content:" ";border:2px solid #282828;border-radius:50%;border-left-color:transparent;border-right-color:transparent;animation:dtb-spinner 1500ms infinite linear;-o-animation:dtb-spinner 1500ms infinite linear;-ms-animation:dtb-spinner 1500ms infinite linear;-webkit-animation:dtb-spinner 1500ms infinite linear;-moz-animation:dtb-spinner 1500ms infinite linear}div.dt-buttons div.btn-group{position:initial}div.dt-btn-split-wrapper button.dt-btn-split-drop{border-top-right-radius:.25rem !important;border-bottom-right-radius:.25rem !important}div.dt-btn-split-wrapper:active:not(.disabled) button,div.dt-btn-split-wrapper.active:not(.disabled) button{background-color:#5a6268;border-color:#545b62}div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop,div.dt-btn-split-wrapper.active:not(.disabled) button.dt-btn-split-drop{box-shadow:none;background-color:#6c757d;border-color:#6c757d}div.dt-btn-split-wrapper:active:not(.disabled) button:hover,div.dt-btn-split-wrapper.active:not(.disabled) button:hover{background-color:#5a6268;border-color:#545b62}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group{border-radius:4px !important}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:last-child{border-top-left-radius:0px !important;border-bottom-left-radius:0px !important}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:first-child{border-top-right-radius:0px !important;border-bottom-right-radius:0px !important}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group:last-child:first-child{border-top-left-radius:4px !important;border-bottom-left-radius:4px !important;border-top-right-radius:4px !important;border-bottom-right-radius:4px !important}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group button.dt-btn-split-drop:last-child{border:1px solid #6c757d}div.dataTables_wrapper div.dt-buttons.btn-group div.btn-group div.dt-btn-split-wrapper{border:none}div.dt-button-collection div.btn-group{border-radius:4px !important}div.dt-button-collection div.btn-group button{border-radius:4px}div.dt-button-collection div.btn-group button:last-child{border-top-left-radius:0px !important;border-bottom-left-radius:0px !important}div.dt-button-collection div.btn-group button:first-child{border-top-right-radius:0px !important;border-bottom-right-radius:0px !important}div.dt-button-collection div.btn-group button:last-child:first-child{border-top-left-radius:4px !important;border-bottom-left-radius:4px !important;border-top-right-radius:4px !important;border-bottom-right-radius:4px !important}div.dt-button-collection div.btn-group button.dt-btn-split-drop:last-child{border:1px solid #6c757d}div.dt-button-collection div.btn-group div.dt-btn-split-wrapper{border:none}span.dt-button-spacer.bar:empty{height:inherit}div.dt-button-collection span.dt-button-spacer{padding-left:1rem !important;text-align:left} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.bulma.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.bulma.css new file mode 100644 index 0000000000..6ac1424c55 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.bulma.css @@ -0,0 +1,425 @@ +@keyframes dtb-spinner { + 100% { + transform: rotate(360deg); + } +} +@-o-keyframes dtb-spinner { + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-ms-keyframes dtb-spinner { + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-webkit-keyframes dtb-spinner { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-moz-keyframes dtb-spinner { + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } +} +div.dataTables_wrapper { + position: relative; +} + +div.dt-buttons { + position: initial; +} + +div.dt-button-info { + position: fixed; + top: 50%; + left: 50%; + width: 400px; + margin-top: -100px; + margin-left: -200px; + background-color: white; + border: 2px solid #111; + box-shadow: 3px 4px 10px 1px rgba(0, 0, 0, 0.3); + border-radius: 3px; + text-align: center; + z-index: 21; +} +div.dt-button-info h2 { + padding: 0.5em; + margin: 0; + font-weight: normal; + border-bottom: 1px solid #ddd; + background-color: #f3f3f3; +} +div.dt-button-info > div { + padding: 1em; +} + +div.dtb-popover-close { + position: absolute; + top: 10px; + right: 10px; + width: 22px; + height: 22px; + border: 1px solid #eaeaea; + background-color: #f9f9f9; + text-align: center; + border-radius: 3px; + cursor: pointer; + z-index: 12; +} + +button.dtb-hide-drop { + display: none !important; +} + +div.dt-button-collection-title { + text-align: center; + padding: 0.3em 0 0.5em; + margin-left: 0.5em; + margin-right: 0.5em; + font-size: 0.9em; +} + +div.dt-button-collection-title:empty { + display: none; +} + +span.dt-button-spacer { + display: inline-block; + margin: 0.5em; + white-space: nowrap; +} +span.dt-button-spacer.bar { + border-left: 1px solid rgba(0, 0, 0, 0.3); + vertical-align: middle; + padding-left: 0.5em; +} +span.dt-button-spacer.bar:empty { + height: 1em; + width: 1px; + padding-left: 0; +} + +div.dt-button-collection span.dt-button-spacer { + width: 100%; + font-size: 0.9em; + text-align: center; + margin: 0.5em 0; +} +div.dt-button-collection span.dt-button-spacer:empty { + height: 0; + width: 100%; +} +div.dt-button-collection span.dt-button-spacer.bar { + border-left: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.3); + padding-left: 0; +} + +div.dt-button-collection { + position: absolute; + z-index: 2001; + min-width: 200px; + background: white; + max-width: none; + display: block; + box-shadow: 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.02); + border-radius: 4; + padding-top: 0.5rem; +} +div.dt-button-collection div.dropdown-menu { + display: block; + z-index: 2002; + min-width: 100%; +} +div.dt-button-collection div.dt-btn-split-wrapper { + width: 100%; + padding-left: 5px; + padding-right: 5px; + margin-bottom: 0px; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + align-content: flex-start; + align-items: stretch; +} +div.dt-button-collection div.dt-btn-split-wrapper button { + margin-right: 0px; + display: inline-block; + width: 0; + flex-grow: 1; + flex-shrink: 0; + flex-basis: 50px; + margin-top: 0px; + border-bottom-left-radius: 3px; + border-top-left-radius: 3px; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + overflow: hidden; + text-overflow: ellipsis; +} +div.dt-button-collection div.dt-btn-split-wrapper button.dt-button { + min-width: 30px; + margin-left: -1px; + flex-grow: 0; + flex-shrink: 0; + flex-basis: 0; + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + padding: 0px; +} +div.dt-button-collection.fixed { + position: fixed; + display: block; + top: 50%; + left: 50%; + margin-left: -75px; + border-radius: 5px; + background-color: white; +} +div.dt-button-collection.fixed.two-column { + margin-left: -200px; +} +div.dt-button-collection.fixed.three-column { + margin-left: -225px; +} +div.dt-button-collection.fixed.four-column { + margin-left: -300px; +} +div.dt-button-collection.fixed.columns { + margin-left: -409px; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.fixed.columns { + margin-left: -308px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.fixed.columns { + margin-left: -203px; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.fixed.columns { + margin-left: -100px; + } +} +div.dt-button-collection.fixed > :last-child { + max-height: 100vh; + overflow: auto; +} +div.dt-button-collection.two-column > :last-child, div.dt-button-collection.three-column > :last-child, div.dt-button-collection.four-column > :last-child { + display: block !important; + -webkit-column-gap: 8px; + -moz-column-gap: 8px; + -ms-column-gap: 8px; + -o-column-gap: 8px; + column-gap: 8px; +} +div.dt-button-collection.two-column > :last-child > *, div.dt-button-collection.three-column > :last-child > *, div.dt-button-collection.four-column > :last-child > * { + -webkit-column-break-inside: avoid; + break-inside: avoid; +} +div.dt-button-collection.two-column { + width: 400px; +} +div.dt-button-collection.two-column > :last-child { + padding-bottom: 1px; + column-count: 2; +} +div.dt-button-collection.three-column { + width: 450px; +} +div.dt-button-collection.three-column > :last-child { + padding-bottom: 1px; + column-count: 3; +} +div.dt-button-collection.four-column { + width: 600px; +} +div.dt-button-collection.four-column > :last-child { + padding-bottom: 1px; + column-count: 4; +} +div.dt-button-collection .dt-button { + border-radius: 0; +} +div.dt-button-collection.columns { + width: auto; +} +div.dt-button-collection.columns > :last-child { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + gap: 6px; + width: 818px; + padding-bottom: 1px; +} +div.dt-button-collection.columns > :last-child .dt-button { + min-width: 200px; + flex: 0 1; + margin: 0; +} +div.dt-button-collection.columns.dtb-b3 > :last-child, div.dt-button-collection.columns.dtb-b2 > :last-child, div.dt-button-collection.columns.dtb-b1 > :last-child { + justify-content: space-between; +} +div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 1 1 32%; +} +div.dt-button-collection.columns.dtb-b2 .dt-button { + flex: 1 1 48%; +} +div.dt-button-collection.columns.dtb-b1 .dt-button { + flex: 1 1 100%; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.columns > :last-child { + width: 612px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.columns > :last-child { + width: 406px; + } + div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 0 1 32%; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.columns > :last-child { + width: 200px; + } +} +div.dt-button-collection .dropdown-content { + box-shadow: none; + padding-top: 0; + border-radius: 0; +} +div.dt-button-collection.fixed:before, div.dt-button-collection.fixed:after { + display: none; +} + +div.dt-button-background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 999; +} + +@media screen and (max-width: 767px) { + div.dt-buttons { + float: none; + width: 100%; + text-align: center; + margin-bottom: 0.5em; + } + div.dt-buttons a.btn { + float: none; + } +} +div.dt-buttons button.btn.processing, +div.dt-buttons div.btn.processing, +div.dt-buttons a.btn.processing { + color: rgba(0, 0, 0, 0.2); +} +div.dt-buttons button.btn.processing:after, +div.dt-buttons div.btn.processing:after, +div.dt-buttons a.btn.processing:after { + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + box-sizing: border-box; + display: block; + content: " "; + border: 2px solid #282828; + border-radius: 50%; + border-left-color: transparent; + border-right-color: transparent; + animation: dtb-spinner 1500ms infinite linear; + -o-animation: dtb-spinner 1500ms infinite linear; + -ms-animation: dtb-spinner 1500ms infinite linear; + -webkit-animation: dtb-spinner 1500ms infinite linear; + -moz-animation: dtb-spinner 1500ms infinite linear; +} +div.dt-buttons button.button { + margin-left: 5px; +} +div.dt-buttons button.button:first-child { + margin-left: 0px; +} + +span.dt-down-arrow { + display: none; +} + +span.dt-button-spacer { + display: inline-flex; + margin: 0.5em; + white-space: nowrap; + align-items: center; + font-size: 1rem; +} +span.dt-button-spacer.bar:empty { + height: inherit; +} + +div.dt-button-collection span.dt-button-spacer { + text-align: left; + font-size: 0.875rem; + padding-left: 1rem !important; +} + +div.dt-btn-split-wrapper { + padding-left: 5px; + padding-right: 5px; + margin-bottom: 0px; + margin-bottom: 0px !important; +} +div.dt-btn-split-wrapper button { + margin-right: 0px; + display: inline-block; + margin-top: 0px; + border-bottom-left-radius: 3px; + border-top-left-radius: 3px; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + overflow: hidden; + text-overflow: ellipsis; +} +div.dt-btn-split-wrapper button.dt-button { + min-width: 30px; + margin-left: -1px; + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + padding: 0px; +} +div.dt-btn-split-wrapper:active:not(.disabled) button, div.dt-btn-split-wrapper.active:not(.disabled) button, div.dt-btn-split-wrapper.is-active:not(.disabled) button { + background-color: #eee; + border-color: transparent; +} +div.dt-btn-split-wrapper:active:not(.disabled) button.dt-button, div.dt-btn-split-wrapper.active:not(.disabled) button.dt-button, div.dt-btn-split-wrapper.is-active:not(.disabled) button.dt-button { + box-shadow: none; + background-color: whitesmoke; + border-color: transparent; +} +div.dt-btn-split-wrapper:active:not(.disabled) button:hover, div.dt-btn-split-wrapper.active:not(.disabled) button:hover, div.dt-btn-split-wrapper.is-active:not(.disabled) button:hover { + background-color: #eee; + border-color: transparent; +} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.bulma.min.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.bulma.min.css new file mode 100644 index 0000000000..99ee3ac888 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.bulma.min.css @@ -0,0 +1 @@ +@keyframes dtb-spinner{100%{transform:rotate(360deg)}}@-o-keyframes dtb-spinner{100%{-o-transform:rotate(360deg);transform:rotate(360deg)}}@-ms-keyframes dtb-spinner{100%{-ms-transform:rotate(360deg);transform:rotate(360deg)}}@-webkit-keyframes dtb-spinner{100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@-moz-keyframes dtb-spinner{100%{-moz-transform:rotate(360deg);transform:rotate(360deg)}}div.dataTables_wrapper{position:relative}div.dt-buttons{position:initial}div.dt-button-info{position:fixed;top:50%;left:50%;width:400px;margin-top:-100px;margin-left:-200px;background-color:white;border:2px solid #111;box-shadow:3px 4px 10px 1px rgba(0, 0, 0, 0.3);border-radius:3px;text-align:center;z-index:21}div.dt-button-info h2{padding:.5em;margin:0;font-weight:normal;border-bottom:1px solid #ddd;background-color:#f3f3f3}div.dt-button-info>div{padding:1em}div.dtb-popover-close{position:absolute;top:10px;right:10px;width:22px;height:22px;border:1px solid #eaeaea;background-color:#f9f9f9;text-align:center;border-radius:3px;cursor:pointer;z-index:12}button.dtb-hide-drop{display:none !important}div.dt-button-collection-title{text-align:center;padding:.3em 0 .5em;margin-left:.5em;margin-right:.5em;font-size:.9em}div.dt-button-collection-title:empty{display:none}span.dt-button-spacer{display:inline-block;margin:.5em;white-space:nowrap}span.dt-button-spacer.bar{border-left:1px solid rgba(0, 0, 0, 0.3);vertical-align:middle;padding-left:.5em}span.dt-button-spacer.bar:empty{height:1em;width:1px;padding-left:0}div.dt-button-collection span.dt-button-spacer{width:100%;font-size:.9em;text-align:center;margin:.5em 0}div.dt-button-collection span.dt-button-spacer:empty{height:0;width:100%}div.dt-button-collection span.dt-button-spacer.bar{border-left:none;border-bottom:1px solid rgba(0, 0, 0, 0.3);padding-left:0}div.dt-button-collection{position:absolute;z-index:2001;min-width:200px;background:white;max-width:none;display:block;box-shadow:0 .5em 1em -0.125em rgba(10, 10, 10, 0.1),0 0 0 1px rgba(10, 10, 10, 0.02);border-radius:4;padding-top:.5rem}div.dt-button-collection div.dropdown-menu{display:block;z-index:2002;min-width:100%}div.dt-button-collection div.dt-btn-split-wrapper{width:100%;padding-left:5px;padding-right:5px;margin-bottom:0px;display:flex;flex-direction:row;flex-wrap:wrap;justify-content:flex-start;align-content:flex-start;align-items:stretch}div.dt-button-collection div.dt-btn-split-wrapper button{margin-right:0px;display:inline-block;width:0;flex-grow:1;flex-shrink:0;flex-basis:50px;margin-top:0px;border-bottom-left-radius:3px;border-top-left-radius:3px;border-top-right-radius:0px;border-bottom-right-radius:0px;overflow:hidden;text-overflow:ellipsis}div.dt-button-collection div.dt-btn-split-wrapper button.dt-button{min-width:30px;margin-left:-1px;flex-grow:0;flex-shrink:0;flex-basis:0;border-bottom-left-radius:0px;border-top-left-radius:0px;border-top-right-radius:3px;border-bottom-right-radius:3px;padding:0px}div.dt-button-collection.fixed{position:fixed;display:block;top:50%;left:50%;margin-left:-75px;border-radius:5px;background-color:white}div.dt-button-collection.fixed.two-column{margin-left:-200px}div.dt-button-collection.fixed.three-column{margin-left:-225px}div.dt-button-collection.fixed.four-column{margin-left:-300px}div.dt-button-collection.fixed.columns{margin-left:-409px}@media screen and (max-width: 1024px){div.dt-button-collection.fixed.columns{margin-left:-308px}}@media screen and (max-width: 640px){div.dt-button-collection.fixed.columns{margin-left:-203px}}@media screen and (max-width: 460px){div.dt-button-collection.fixed.columns{margin-left:-100px}}div.dt-button-collection.fixed>:last-child{max-height:100vh;overflow:auto}div.dt-button-collection.two-column>:last-child,div.dt-button-collection.three-column>:last-child,div.dt-button-collection.four-column>:last-child{display:block !important;-webkit-column-gap:8px;-moz-column-gap:8px;-ms-column-gap:8px;-o-column-gap:8px;column-gap:8px}div.dt-button-collection.two-column>:last-child>*,div.dt-button-collection.three-column>:last-child>*,div.dt-button-collection.four-column>:last-child>*{-webkit-column-break-inside:avoid;break-inside:avoid}div.dt-button-collection.two-column{width:400px}div.dt-button-collection.two-column>:last-child{padding-bottom:1px;column-count:2}div.dt-button-collection.three-column{width:450px}div.dt-button-collection.three-column>:last-child{padding-bottom:1px;column-count:3}div.dt-button-collection.four-column{width:600px}div.dt-button-collection.four-column>:last-child{padding-bottom:1px;column-count:4}div.dt-button-collection .dt-button{border-radius:0}div.dt-button-collection.columns{width:auto}div.dt-button-collection.columns>:last-child{display:flex;flex-wrap:wrap;justify-content:flex-start;align-items:center;gap:6px;width:818px;padding-bottom:1px}div.dt-button-collection.columns>:last-child .dt-button{min-width:200px;flex:0 1;margin:0}div.dt-button-collection.columns.dtb-b3>:last-child,div.dt-button-collection.columns.dtb-b2>:last-child,div.dt-button-collection.columns.dtb-b1>:last-child{justify-content:space-between}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:1 1 32%}div.dt-button-collection.columns.dtb-b2 .dt-button{flex:1 1 48%}div.dt-button-collection.columns.dtb-b1 .dt-button{flex:1 1 100%}@media screen and (max-width: 1024px){div.dt-button-collection.columns>:last-child{width:612px}}@media screen and (max-width: 640px){div.dt-button-collection.columns>:last-child{width:406px}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:0 1 32%}}@media screen and (max-width: 460px){div.dt-button-collection.columns>:last-child{width:200px}}div.dt-button-collection .dropdown-content{box-shadow:none;padding-top:0;border-radius:0}div.dt-button-collection.fixed:before,div.dt-button-collection.fixed:after{display:none}div.dt-button-background{position:fixed;top:0;left:0;width:100%;height:100%;z-index:999}@media screen and (max-width: 767px){div.dt-buttons{float:none;width:100%;text-align:center;margin-bottom:.5em}div.dt-buttons a.btn{float:none}}div.dt-buttons button.btn.processing,div.dt-buttons div.btn.processing,div.dt-buttons a.btn.processing{color:rgba(0, 0, 0, 0.2)}div.dt-buttons button.btn.processing:after,div.dt-buttons div.btn.processing:after,div.dt-buttons a.btn.processing:after{position:absolute;top:50%;left:50%;width:16px;height:16px;margin:-8px 0 0 -8px;box-sizing:border-box;display:block;content:" ";border:2px solid #282828;border-radius:50%;border-left-color:transparent;border-right-color:transparent;animation:dtb-spinner 1500ms infinite linear;-o-animation:dtb-spinner 1500ms infinite linear;-ms-animation:dtb-spinner 1500ms infinite linear;-webkit-animation:dtb-spinner 1500ms infinite linear;-moz-animation:dtb-spinner 1500ms infinite linear}div.dt-buttons button.button{margin-left:5px}div.dt-buttons button.button:first-child{margin-left:0px}span.dt-down-arrow{display:none}span.dt-button-spacer{display:inline-flex;margin:.5em;white-space:nowrap;align-items:center;font-size:1rem}span.dt-button-spacer.bar:empty{height:inherit}div.dt-button-collection span.dt-button-spacer{text-align:left;font-size:.875rem;padding-left:1rem !important}div.dt-btn-split-wrapper{padding-left:5px;padding-right:5px;margin-bottom:0px;margin-bottom:0px !important}div.dt-btn-split-wrapper button{margin-right:0px;display:inline-block;margin-top:0px;border-bottom-left-radius:3px;border-top-left-radius:3px;border-top-right-radius:0px;border-bottom-right-radius:0px;overflow:hidden;text-overflow:ellipsis}div.dt-btn-split-wrapper button.dt-button{min-width:30px;margin-left:-1px;border-bottom-left-radius:0px;border-top-left-radius:0px;border-top-right-radius:3px;border-bottom-right-radius:3px;padding:0px}div.dt-btn-split-wrapper:active:not(.disabled) button,div.dt-btn-split-wrapper.active:not(.disabled) button,div.dt-btn-split-wrapper.is-active:not(.disabled) button{background-color:#eee;border-color:transparent}div.dt-btn-split-wrapper:active:not(.disabled) button.dt-button,div.dt-btn-split-wrapper.active:not(.disabled) button.dt-button,div.dt-btn-split-wrapper.is-active:not(.disabled) button.dt-button{box-shadow:none;background-color:whitesmoke;border-color:transparent}div.dt-btn-split-wrapper:active:not(.disabled) button:hover,div.dt-btn-split-wrapper.active:not(.disabled) button:hover,div.dt-btn-split-wrapper.is-active:not(.disabled) button:hover{background-color:#eee;border-color:transparent} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.dataTables.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.dataTables.css new file mode 100644 index 0000000000..ce33a3c966 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.dataTables.css @@ -0,0 +1,631 @@ +@keyframes dtb-spinner { + 100% { + transform: rotate(360deg); + } +} +@-o-keyframes dtb-spinner { + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-ms-keyframes dtb-spinner { + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-webkit-keyframes dtb-spinner { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-moz-keyframes dtb-spinner { + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } +} +div.dataTables_wrapper { + position: relative; +} + +div.dt-buttons { + position: initial; +} + +div.dt-button-info { + position: fixed; + top: 50%; + left: 50%; + width: 400px; + margin-top: -100px; + margin-left: -200px; + background-color: white; + border: 2px solid #111; + box-shadow: 3px 4px 10px 1px rgba(0, 0, 0, 0.3); + border-radius: 3px; + text-align: center; + z-index: 21; +} +div.dt-button-info h2 { + padding: 0.5em; + margin: 0; + font-weight: normal; + border-bottom: 1px solid #ddd; + background-color: #f3f3f3; +} +div.dt-button-info > div { + padding: 1em; +} + +div.dtb-popover-close { + position: absolute; + top: 10px; + right: 10px; + width: 22px; + height: 22px; + border: 1px solid #eaeaea; + background-color: #f9f9f9; + text-align: center; + border-radius: 3px; + cursor: pointer; + z-index: 12; +} + +button.dtb-hide-drop { + display: none !important; +} + +div.dt-button-collection-title { + text-align: center; + padding: 0.3em 0 0.5em; + margin-left: 0.5em; + margin-right: 0.5em; + font-size: 0.9em; +} + +div.dt-button-collection-title:empty { + display: none; +} + +span.dt-button-spacer { + display: inline-block; + margin: 0.5em; + white-space: nowrap; +} +span.dt-button-spacer.bar { + border-left: 1px solid rgba(0, 0, 0, 0.3); + vertical-align: middle; + padding-left: 0.5em; +} +span.dt-button-spacer.bar:empty { + height: 1em; + width: 1px; + padding-left: 0; +} + +div.dt-button-collection span.dt-button-spacer { + width: 100%; + font-size: 0.9em; + text-align: center; + margin: 0.5em 0; +} +div.dt-button-collection span.dt-button-spacer:empty { + height: 0; + width: 100%; +} +div.dt-button-collection span.dt-button-spacer.bar { + border-left: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.3); + padding-left: 0; +} + +button.dt-button, +div.dt-button, +a.dt-button, +input.dt-button { + position: relative; + display: inline-block; + box-sizing: border-box; + margin-left: 0.167em; + margin-right: 0.167em; + margin-bottom: 0.333em; + padding: 0.5em 1em; + border: 1px solid rgba(0, 0, 0, 0.3); + border-radius: 2px; + cursor: pointer; + font-size: 0.88em; + line-height: 1.6em; + color: black; + white-space: nowrap; + overflow: hidden; + background-color: rgba(0, 0, 0, 0.1); + /* Fallback */ + background: -webkit-linear-gradient(top, rgba(230, 230, 230, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* Chrome 10+, Saf5.1+, iOS 5+ */ + background: -moz-linear-gradient(top, rgba(230, 230, 230, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* FF3.6 */ + background: -ms-linear-gradient(top, rgba(230, 230, 230, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* IE10 */ + background: -o-linear-gradient(top, rgba(230, 230, 230, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, rgba(230, 230, 230, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="rgba(230, 230, 230, 0.1)", EndColorStr="rgba(0, 0, 0, 0.1)"); + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + text-decoration: none; + outline: none; + text-overflow: ellipsis; +} +button.dt-button:first-child, +div.dt-button:first-child, +a.dt-button:first-child, +input.dt-button:first-child { + margin-left: 0; +} +button.dt-button.disabled, +div.dt-button.disabled, +a.dt-button.disabled, +input.dt-button.disabled { + cursor: default; + opacity: 0.4; +} +button.dt-button:active:not(.disabled), button.dt-button.active:not(.disabled), +div.dt-button:active:not(.disabled), +div.dt-button.active:not(.disabled), +a.dt-button:active:not(.disabled), +a.dt-button.active:not(.disabled), +input.dt-button:active:not(.disabled), +input.dt-button.active:not(.disabled) { + background-color: rgba(0, 0, 0, 0.1); + /* Fallback */ + background: -webkit-linear-gradient(top, rgba(179, 179, 179, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* Chrome 10+, Saf5.1+, iOS 5+ */ + background: -moz-linear-gradient(top, rgba(179, 179, 179, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* FF3.6 */ + background: -ms-linear-gradient(top, rgba(179, 179, 179, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* IE10 */ + background: -o-linear-gradient(top, rgba(179, 179, 179, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, rgba(179, 179, 179, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="rgba(179, 179, 179, 0.1)", EndColorStr="rgba(0, 0, 0, 0.1)"); + box-shadow: inset 1px 1px 3px #999999; +} +button.dt-button:active:not(.disabled):hover:not(.disabled), button.dt-button.active:not(.disabled):hover:not(.disabled), +div.dt-button:active:not(.disabled):hover:not(.disabled), +div.dt-button.active:not(.disabled):hover:not(.disabled), +a.dt-button:active:not(.disabled):hover:not(.disabled), +a.dt-button.active:not(.disabled):hover:not(.disabled), +input.dt-button:active:not(.disabled):hover:not(.disabled), +input.dt-button.active:not(.disabled):hover:not(.disabled) { + box-shadow: inset 1px 1px 3px #999999; + background-color: rgba(0, 0, 0, 0.1); + /* Fallback */ + background: -webkit-linear-gradient(top, rgba(128, 128, 128, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* Chrome 10+, Saf5.1+, iOS 5+ */ + background: -moz-linear-gradient(top, rgba(128, 128, 128, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* FF3.6 */ + background: -ms-linear-gradient(top, rgba(128, 128, 128, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* IE10 */ + background: -o-linear-gradient(top, rgba(128, 128, 128, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, rgba(128, 128, 128, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="rgba(128, 128, 128, 0.1)", EndColorStr="rgba(0, 0, 0, 0.1)"); +} +button.dt-button:hover, +div.dt-button:hover, +a.dt-button:hover, +input.dt-button:hover { + text-decoration: none; +} +button.dt-button:hover:not(.disabled), +div.dt-button:hover:not(.disabled), +a.dt-button:hover:not(.disabled), +input.dt-button:hover:not(.disabled) { + border: 1px solid #666; + background-color: rgba(0, 0, 0, 0.1); + /* Fallback */ + background: -webkit-linear-gradient(top, rgba(153, 153, 153, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* Chrome 10+, Saf5.1+, iOS 5+ */ + background: -moz-linear-gradient(top, rgba(153, 153, 153, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* FF3.6 */ + background: -ms-linear-gradient(top, rgba(153, 153, 153, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* IE10 */ + background: -o-linear-gradient(top, rgba(153, 153, 153, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, rgba(153, 153, 153, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%); + filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="rgba(153, 153, 153, 0.1)", EndColorStr="rgba(0, 0, 0, 0.1)"); +} +button.dt-button:focus:not(.disabled), +div.dt-button:focus:not(.disabled), +a.dt-button:focus:not(.disabled), +input.dt-button:focus:not(.disabled) { + border: 1px solid #426c9e; + text-shadow: 0 1px 0 #c4def1; + outline: none; + background-color: #79ace9; + /* Fallback */ + background: -webkit-linear-gradient(top, #d1e2f7 0%, #79ace9 100%); + /* Chrome 10+, Saf5.1+, iOS 5+ */ + background: -moz-linear-gradient(top, #d1e2f7 0%, #79ace9 100%); + /* FF3.6 */ + background: -ms-linear-gradient(top, #d1e2f7 0%, #79ace9 100%); + /* IE10 */ + background: -o-linear-gradient(top, #d1e2f7 0%, #79ace9 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, #d1e2f7 0%, #79ace9 100%); + filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="#d1e2f7", EndColorStr="#79ace9"); +} +button.dt-button span.dt-down-arrow, +div.dt-button span.dt-down-arrow, +a.dt-button span.dt-down-arrow, +input.dt-button span.dt-down-arrow { + position: relative; + top: -2px; + color: rgba(70, 70, 70, 0.75); + font-size: 8px; + padding-left: 10px; + line-height: 1em; +} + +.dt-button embed { + outline: none; +} + +div.dt-buttons { + float: left; +} +div.dt-buttons.buttons-right { + float: right; +} + +div.dataTables_layout_cell div.dt-buttons { + float: none; +} +div.dataTables_layout_cell div.dt-buttons.buttons-right { + float: none; +} + +div.dt-btn-split-wrapper { + display: inline-block; +} + +div.dt-button-collection { + position: absolute; + top: 0; + left: 0; + width: 200px; + margin-top: 3px; + margin-bottom: 3px; + padding: 4px 4px 2px 4px; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, 0.4); + background-color: white; + overflow: hidden; + z-index: 2002; + border-radius: 5px; + box-shadow: 3px 4px 10px 1px rgba(0, 0, 0, 0.3); + box-sizing: border-box; +} +div.dt-button-collection button.dt-button, +div.dt-button-collection div.dt-button, +div.dt-button-collection a.dt-button { + position: relative; + left: 0; + right: 0; + width: 100%; + display: block; + float: none; + margin: 4px 0 2px 0; +} +div.dt-button-collection button.dt-button:active:not(.disabled), div.dt-button-collection button.dt-button.active:not(.disabled), +div.dt-button-collection div.dt-button:active:not(.disabled), +div.dt-button-collection div.dt-button.active:not(.disabled), +div.dt-button-collection a.dt-button:active:not(.disabled), +div.dt-button-collection a.dt-button.active:not(.disabled) { + background-color: #dadada; + /* Fallback */ + background: -webkit-linear-gradient(top, #f0f0f0 0%, #dadada 100%); + /* Chrome 10+, Saf5.1+, iOS 5+ */ + background: -moz-linear-gradient(top, #f0f0f0 0%, #dadada 100%); + /* FF3.6 */ + background: -ms-linear-gradient(top, #f0f0f0 0%, #dadada 100%); + /* IE10 */ + background: -o-linear-gradient(top, #f0f0f0 0%, #dadada 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, #f0f0f0 0%, #dadada 100%); + filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="#f0f0f0", EndColorStr="#dadada"); + box-shadow: inset 1px 1px 3px #666; +} +div.dt-button-collection button.dt-button:first-child, +div.dt-button-collection div.dt-button:first-child, +div.dt-button-collection a.dt-button:first-child { + margin-top: 0; + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +div.dt-button-collection button.dt-button:last-child, +div.dt-button-collection div.dt-button:last-child, +div.dt-button-collection a.dt-button:last-child { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +} +div.dt-button-collection div.dt-btn-split-wrapper { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + align-content: flex-start; + align-items: stretch; + margin: 4px 0 2px 0; +} +div.dt-button-collection div.dt-btn-split-wrapper button.dt-button { + margin: 0; + display: inline-block; + width: 0; + flex-grow: 1; + flex-shrink: 0; + flex-basis: 50px; + border-radius: 0; +} +div.dt-button-collection div.dt-btn-split-wrapper button.dt-btn-split-drop { + min-width: 20px; + flex-grow: 0; + flex-shrink: 0; + flex-basis: 0; +} +div.dt-button-collection div.dt-btn-split-wrapper:first-child { + margin-top: 0; +} +div.dt-button-collection div.dt-btn-split-wrapper:first-child button.dt-button { + border-top-left-radius: 3px; +} +div.dt-button-collection div.dt-btn-split-wrapper:first-child button.dt-btn-split-drop { + border-top-right-radius: 3px; +} +div.dt-button-collection div.dt-btn-split-wrapper:last-child button.dt-button { + border-bottom-left-radius: 3px; +} +div.dt-button-collection div.dt-btn-split-wrapper:last-child button.dt-btn-split-drop { + border-bottom-right-radius: 3px; +} +div.dt-button-collection div.dt-btn-split-wrapper:active:not(.disabled) button.dt-button, div.dt-button-collection div.dt-btn-split-wrapper.active:not(.disabled) button.dt-button { + background-color: #dadada; + /* Fallback */ + background: -webkit-linear-gradient(top, #f0f0f0 0%, #dadada 100%); + /* Chrome 10+, Saf5.1+, iOS 5+ */ + background: -moz-linear-gradient(top, #f0f0f0 0%, #dadada 100%); + /* FF3.6 */ + background: -ms-linear-gradient(top, #f0f0f0 0%, #dadada 100%); + /* IE10 */ + background: -o-linear-gradient(top, #f0f0f0 0%, #dadada 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, #f0f0f0 0%, #dadada 100%); + filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="#f0f0f0", EndColorStr="#dadada"); + box-shadow: inset 0px 0px 4px #666; +} +div.dt-button-collection div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop, div.dt-button-collection div.dt-btn-split-wrapper.active:not(.disabled) button.dt-btn-split-drop { + box-shadow: none; +} +div.dt-button-collection.fixed .dt-button:first-child { + margin-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +div.dt-button-collection.fixed .dt-button:last-child { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +div.dt-button-collection.fixed { + position: fixed; + display: block; + top: 50%; + left: 50%; + margin-left: -75px; + border-radius: 5px; + background-color: white; +} +div.dt-button-collection.fixed.two-column { + margin-left: -200px; +} +div.dt-button-collection.fixed.three-column { + margin-left: -225px; +} +div.dt-button-collection.fixed.four-column { + margin-left: -300px; +} +div.dt-button-collection.fixed.columns { + margin-left: -409px; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.fixed.columns { + margin-left: -308px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.fixed.columns { + margin-left: -203px; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.fixed.columns { + margin-left: -100px; + } +} +div.dt-button-collection.fixed > :last-child { + max-height: 100vh; + overflow: auto; +} +div.dt-button-collection.two-column > :last-child, div.dt-button-collection.three-column > :last-child, div.dt-button-collection.four-column > :last-child { + display: block !important; + -webkit-column-gap: 8px; + -moz-column-gap: 8px; + -ms-column-gap: 8px; + -o-column-gap: 8px; + column-gap: 8px; +} +div.dt-button-collection.two-column > :last-child > *, div.dt-button-collection.three-column > :last-child > *, div.dt-button-collection.four-column > :last-child > * { + -webkit-column-break-inside: avoid; + break-inside: avoid; +} +div.dt-button-collection.two-column { + width: 400px; +} +div.dt-button-collection.two-column > :last-child { + padding-bottom: 1px; + column-count: 2; +} +div.dt-button-collection.three-column { + width: 450px; +} +div.dt-button-collection.three-column > :last-child { + padding-bottom: 1px; + column-count: 3; +} +div.dt-button-collection.four-column { + width: 600px; +} +div.dt-button-collection.four-column > :last-child { + padding-bottom: 1px; + column-count: 4; +} +div.dt-button-collection .dt-button { + border-radius: 0; +} +div.dt-button-collection.columns { + width: auto; +} +div.dt-button-collection.columns > :last-child { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + gap: 6px; + width: 818px; + padding-bottom: 1px; +} +div.dt-button-collection.columns > :last-child .dt-button { + min-width: 200px; + flex: 0 1; + margin: 0; +} +div.dt-button-collection.columns.dtb-b3 > :last-child, div.dt-button-collection.columns.dtb-b2 > :last-child, div.dt-button-collection.columns.dtb-b1 > :last-child { + justify-content: space-between; +} +div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 1 1 32%; +} +div.dt-button-collection.columns.dtb-b2 .dt-button { + flex: 1 1 48%; +} +div.dt-button-collection.columns.dtb-b1 .dt-button { + flex: 1 1 100%; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.columns > :last-child { + width: 612px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.columns > :last-child { + width: 406px; + } + div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 0 1 32%; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.columns > :last-child { + width: 200px; + } +} + +div.dt-button-background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + /* Fallback */ + background: -ms-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); + /* IE10 Consumer Preview */ + background: -moz-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); + /* Firefox */ + background: -o-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); + /* Opera */ + background: -webkit-gradient(radial, center center, 0, center center, 497, color-stop(0, rgba(0, 0, 0, 0.3)), color-stop(1, rgba(0, 0, 0, 0.7))); + /* Webkit (Safari/Chrome 10) */ + background: -webkit-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); + /* Webkit (Chrome 11+) */ + background: radial-gradient(ellipse farthest-corner at center, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); + /* W3C Markup, IE10 Release Preview */ + z-index: 2001; +} + +@media screen and (max-width: 640px) { + div.dt-buttons { + float: none !important; + text-align: center; + } +} +button.dt-button.processing, +div.dt-button.processing, +a.dt-button.processing { + color: rgba(0, 0, 0, 0.2); +} +button.dt-button.processing:after, +div.dt-button.processing:after, +a.dt-button.processing:after { + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + box-sizing: border-box; + display: block; + content: " "; + border: 2px solid #282828; + border-radius: 50%; + border-left-color: transparent; + border-right-color: transparent; + animation: dtb-spinner 1500ms infinite linear; + -o-animation: dtb-spinner 1500ms infinite linear; + -ms-animation: dtb-spinner 1500ms infinite linear; + -webkit-animation: dtb-spinner 1500ms infinite linear; + -moz-animation: dtb-spinner 1500ms infinite linear; +} + +button.dt-btn-split-drop { + margin-left: calc(-1px - 0.333em); + padding-bottom: calc(0.5em - 1px); + border-radius: 0px 1px 1px 0px; + color: rgba(70, 70, 70, 0.9); + border-left: none; +} +button.dt-btn-split-drop span.dt-btn-split-drop-arrow { + position: relative; + top: -1px; + left: -2px; + font-size: 8px; +} +button.dt-btn-split-drop:hover { + z-index: 2; +} + +button.buttons-split { + border-right: 1px solid rgba(70, 70, 70, 0); + border-radius: 1px 0px 0px 1px; +} + +button.dt-btn-split-drop-button { + background-color: white; +} +button.dt-btn-split-drop-button:hover { + background-color: white; +} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.dataTables.min.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.dataTables.min.css new file mode 100644 index 0000000000..fd38c86a72 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.dataTables.min.css @@ -0,0 +1 @@ +@keyframes dtb-spinner{100%{transform:rotate(360deg)}}@-o-keyframes dtb-spinner{100%{-o-transform:rotate(360deg);transform:rotate(360deg)}}@-ms-keyframes dtb-spinner{100%{-ms-transform:rotate(360deg);transform:rotate(360deg)}}@-webkit-keyframes dtb-spinner{100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@-moz-keyframes dtb-spinner{100%{-moz-transform:rotate(360deg);transform:rotate(360deg)}}div.dataTables_wrapper{position:relative}div.dt-buttons{position:initial}div.dt-button-info{position:fixed;top:50%;left:50%;width:400px;margin-top:-100px;margin-left:-200px;background-color:white;border:2px solid #111;box-shadow:3px 4px 10px 1px rgba(0, 0, 0, 0.3);border-radius:3px;text-align:center;z-index:21}div.dt-button-info h2{padding:.5em;margin:0;font-weight:normal;border-bottom:1px solid #ddd;background-color:#f3f3f3}div.dt-button-info>div{padding:1em}div.dtb-popover-close{position:absolute;top:10px;right:10px;width:22px;height:22px;border:1px solid #eaeaea;background-color:#f9f9f9;text-align:center;border-radius:3px;cursor:pointer;z-index:12}button.dtb-hide-drop{display:none !important}div.dt-button-collection-title{text-align:center;padding:.3em 0 .5em;margin-left:.5em;margin-right:.5em;font-size:.9em}div.dt-button-collection-title:empty{display:none}span.dt-button-spacer{display:inline-block;margin:.5em;white-space:nowrap}span.dt-button-spacer.bar{border-left:1px solid rgba(0, 0, 0, 0.3);vertical-align:middle;padding-left:.5em}span.dt-button-spacer.bar:empty{height:1em;width:1px;padding-left:0}div.dt-button-collection span.dt-button-spacer{width:100%;font-size:.9em;text-align:center;margin:.5em 0}div.dt-button-collection span.dt-button-spacer:empty{height:0;width:100%}div.dt-button-collection span.dt-button-spacer.bar{border-left:none;border-bottom:1px solid rgba(0, 0, 0, 0.3);padding-left:0}button.dt-button,div.dt-button,a.dt-button,input.dt-button{position:relative;display:inline-block;box-sizing:border-box;margin-left:.167em;margin-right:.167em;margin-bottom:.333em;padding:.5em 1em;border:1px solid rgba(0, 0, 0, 0.3);border-radius:2px;cursor:pointer;font-size:.88em;line-height:1.6em;color:black;white-space:nowrap;overflow:hidden;background-color:rgba(0, 0, 0, 0.1);background:-webkit-linear-gradient(top, rgba(230, 230, 230, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-moz-linear-gradient(top, rgba(230, 230, 230, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-ms-linear-gradient(top, rgba(230, 230, 230, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-o-linear-gradient(top, rgba(230, 230, 230, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:linear-gradient(to bottom, rgba(230, 230, 230, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);filter:progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="rgba(230, 230, 230, 0.1)", EndColorStr="rgba(0, 0, 0, 0.1)");-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-decoration:none;outline:none;text-overflow:ellipsis}button.dt-button:first-child,div.dt-button:first-child,a.dt-button:first-child,input.dt-button:first-child{margin-left:0}button.dt-button.disabled,div.dt-button.disabled,a.dt-button.disabled,input.dt-button.disabled{cursor:default;opacity:.4}button.dt-button:active:not(.disabled),button.dt-button.active:not(.disabled),div.dt-button:active:not(.disabled),div.dt-button.active:not(.disabled),a.dt-button:active:not(.disabled),a.dt-button.active:not(.disabled),input.dt-button:active:not(.disabled),input.dt-button.active:not(.disabled){background-color:rgba(0, 0, 0, 0.1);background:-webkit-linear-gradient(top, rgba(179, 179, 179, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-moz-linear-gradient(top, rgba(179, 179, 179, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-ms-linear-gradient(top, rgba(179, 179, 179, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-o-linear-gradient(top, rgba(179, 179, 179, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:linear-gradient(to bottom, rgba(179, 179, 179, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);filter:progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="rgba(179, 179, 179, 0.1)", EndColorStr="rgba(0, 0, 0, 0.1)");box-shadow:inset 1px 1px 3px #999}button.dt-button:active:not(.disabled):hover:not(.disabled),button.dt-button.active:not(.disabled):hover:not(.disabled),div.dt-button:active:not(.disabled):hover:not(.disabled),div.dt-button.active:not(.disabled):hover:not(.disabled),a.dt-button:active:not(.disabled):hover:not(.disabled),a.dt-button.active:not(.disabled):hover:not(.disabled),input.dt-button:active:not(.disabled):hover:not(.disabled),input.dt-button.active:not(.disabled):hover:not(.disabled){box-shadow:inset 1px 1px 3px #999;background-color:rgba(0, 0, 0, 0.1);background:-webkit-linear-gradient(top, rgba(128, 128, 128, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-moz-linear-gradient(top, rgba(128, 128, 128, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-ms-linear-gradient(top, rgba(128, 128, 128, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-o-linear-gradient(top, rgba(128, 128, 128, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:linear-gradient(to bottom, rgba(128, 128, 128, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);filter:progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="rgba(128, 128, 128, 0.1)", EndColorStr="rgba(0, 0, 0, 0.1)")}button.dt-button:hover,div.dt-button:hover,a.dt-button:hover,input.dt-button:hover{text-decoration:none}button.dt-button:hover:not(.disabled),div.dt-button:hover:not(.disabled),a.dt-button:hover:not(.disabled),input.dt-button:hover:not(.disabled){border:1px solid #666;background-color:rgba(0, 0, 0, 0.1);background:-webkit-linear-gradient(top, rgba(153, 153, 153, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-moz-linear-gradient(top, rgba(153, 153, 153, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-ms-linear-gradient(top, rgba(153, 153, 153, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:-o-linear-gradient(top, rgba(153, 153, 153, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);background:linear-gradient(to bottom, rgba(153, 153, 153, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%);filter:progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="rgba(153, 153, 153, 0.1)", EndColorStr="rgba(0, 0, 0, 0.1)")}button.dt-button:focus:not(.disabled),div.dt-button:focus:not(.disabled),a.dt-button:focus:not(.disabled),input.dt-button:focus:not(.disabled){border:1px solid #426c9e;text-shadow:0 1px 0 #c4def1;outline:none;background-color:#79ace9;background:-webkit-linear-gradient(top, #d1e2f7 0%, #79ace9 100%);background:-moz-linear-gradient(top, #d1e2f7 0%, #79ace9 100%);background:-ms-linear-gradient(top, #d1e2f7 0%, #79ace9 100%);background:-o-linear-gradient(top, #d1e2f7 0%, #79ace9 100%);background:linear-gradient(to bottom, #d1e2f7 0%, #79ace9 100%);filter:progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="#d1e2f7", EndColorStr="#79ace9")}button.dt-button span.dt-down-arrow,div.dt-button span.dt-down-arrow,a.dt-button span.dt-down-arrow,input.dt-button span.dt-down-arrow{position:relative;top:-2px;color:rgba(70, 70, 70, 0.75);font-size:8px;padding-left:10px;line-height:1em}.dt-button embed{outline:none}div.dt-buttons{float:left}div.dt-buttons.buttons-right{float:right}div.dataTables_layout_cell div.dt-buttons{float:none}div.dataTables_layout_cell div.dt-buttons.buttons-right{float:none}div.dt-btn-split-wrapper{display:inline-block}div.dt-button-collection{position:absolute;top:0;left:0;width:200px;margin-top:3px;margin-bottom:3px;padding:4px 4px 2px 4px;border:1px solid #ccc;border:1px solid rgba(0, 0, 0, 0.4);background-color:white;overflow:hidden;z-index:2002;border-radius:5px;box-shadow:3px 4px 10px 1px rgba(0, 0, 0, 0.3);box-sizing:border-box}div.dt-button-collection button.dt-button,div.dt-button-collection div.dt-button,div.dt-button-collection a.dt-button{position:relative;left:0;right:0;width:100%;display:block;float:none;margin:4px 0 2px 0}div.dt-button-collection button.dt-button:active:not(.disabled),div.dt-button-collection button.dt-button.active:not(.disabled),div.dt-button-collection div.dt-button:active:not(.disabled),div.dt-button-collection div.dt-button.active:not(.disabled),div.dt-button-collection a.dt-button:active:not(.disabled),div.dt-button-collection a.dt-button.active:not(.disabled){background-color:#dadada;background:-webkit-linear-gradient(top, #f0f0f0 0%, #dadada 100%);background:-moz-linear-gradient(top, #f0f0f0 0%, #dadada 100%);background:-ms-linear-gradient(top, #f0f0f0 0%, #dadada 100%);background:-o-linear-gradient(top, #f0f0f0 0%, #dadada 100%);background:linear-gradient(to bottom, #f0f0f0 0%, #dadada 100%);filter:progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="#f0f0f0", EndColorStr="#dadada");box-shadow:inset 1px 1px 3px #666}div.dt-button-collection button.dt-button:first-child,div.dt-button-collection div.dt-button:first-child,div.dt-button-collection a.dt-button:first-child{margin-top:0;border-top-left-radius:3px;border-top-right-radius:3px}div.dt-button-collection button.dt-button:last-child,div.dt-button-collection div.dt-button:last-child,div.dt-button-collection a.dt-button:last-child{border-bottom-left-radius:3px;border-bottom-right-radius:3px}div.dt-button-collection div.dt-btn-split-wrapper{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:flex-start;align-content:flex-start;align-items:stretch;margin:4px 0 2px 0}div.dt-button-collection div.dt-btn-split-wrapper button.dt-button{margin:0;display:inline-block;width:0;flex-grow:1;flex-shrink:0;flex-basis:50px;border-radius:0}div.dt-button-collection div.dt-btn-split-wrapper button.dt-btn-split-drop{min-width:20px;flex-grow:0;flex-shrink:0;flex-basis:0}div.dt-button-collection div.dt-btn-split-wrapper:first-child{margin-top:0}div.dt-button-collection div.dt-btn-split-wrapper:first-child button.dt-button{border-top-left-radius:3px}div.dt-button-collection div.dt-btn-split-wrapper:first-child button.dt-btn-split-drop{border-top-right-radius:3px}div.dt-button-collection div.dt-btn-split-wrapper:last-child button.dt-button{border-bottom-left-radius:3px}div.dt-button-collection div.dt-btn-split-wrapper:last-child button.dt-btn-split-drop{border-bottom-right-radius:3px}div.dt-button-collection div.dt-btn-split-wrapper:active:not(.disabled) button.dt-button,div.dt-button-collection div.dt-btn-split-wrapper.active:not(.disabled) button.dt-button{background-color:#dadada;background:-webkit-linear-gradient(top, #f0f0f0 0%, #dadada 100%);background:-moz-linear-gradient(top, #f0f0f0 0%, #dadada 100%);background:-ms-linear-gradient(top, #f0f0f0 0%, #dadada 100%);background:-o-linear-gradient(top, #f0f0f0 0%, #dadada 100%);background:linear-gradient(to bottom, #f0f0f0 0%, #dadada 100%);filter:progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr="#f0f0f0", EndColorStr="#dadada");box-shadow:inset 0px 0px 4px #666}div.dt-button-collection div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop,div.dt-button-collection div.dt-btn-split-wrapper.active:not(.disabled) button.dt-btn-split-drop{box-shadow:none}div.dt-button-collection.fixed .dt-button:first-child{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}div.dt-button-collection.fixed .dt-button:last-child{border-bottom-left-radius:0;border-bottom-right-radius:0}div.dt-button-collection.fixed{position:fixed;display:block;top:50%;left:50%;margin-left:-75px;border-radius:5px;background-color:white}div.dt-button-collection.fixed.two-column{margin-left:-200px}div.dt-button-collection.fixed.three-column{margin-left:-225px}div.dt-button-collection.fixed.four-column{margin-left:-300px}div.dt-button-collection.fixed.columns{margin-left:-409px}@media screen and (max-width: 1024px){div.dt-button-collection.fixed.columns{margin-left:-308px}}@media screen and (max-width: 640px){div.dt-button-collection.fixed.columns{margin-left:-203px}}@media screen and (max-width: 460px){div.dt-button-collection.fixed.columns{margin-left:-100px}}div.dt-button-collection.fixed>:last-child{max-height:100vh;overflow:auto}div.dt-button-collection.two-column>:last-child,div.dt-button-collection.three-column>:last-child,div.dt-button-collection.four-column>:last-child{display:block !important;-webkit-column-gap:8px;-moz-column-gap:8px;-ms-column-gap:8px;-o-column-gap:8px;column-gap:8px}div.dt-button-collection.two-column>:last-child>*,div.dt-button-collection.three-column>:last-child>*,div.dt-button-collection.four-column>:last-child>*{-webkit-column-break-inside:avoid;break-inside:avoid}div.dt-button-collection.two-column{width:400px}div.dt-button-collection.two-column>:last-child{padding-bottom:1px;column-count:2}div.dt-button-collection.three-column{width:450px}div.dt-button-collection.three-column>:last-child{padding-bottom:1px;column-count:3}div.dt-button-collection.four-column{width:600px}div.dt-button-collection.four-column>:last-child{padding-bottom:1px;column-count:4}div.dt-button-collection .dt-button{border-radius:0}div.dt-button-collection.columns{width:auto}div.dt-button-collection.columns>:last-child{display:flex;flex-wrap:wrap;justify-content:flex-start;align-items:center;gap:6px;width:818px;padding-bottom:1px}div.dt-button-collection.columns>:last-child .dt-button{min-width:200px;flex:0 1;margin:0}div.dt-button-collection.columns.dtb-b3>:last-child,div.dt-button-collection.columns.dtb-b2>:last-child,div.dt-button-collection.columns.dtb-b1>:last-child{justify-content:space-between}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:1 1 32%}div.dt-button-collection.columns.dtb-b2 .dt-button{flex:1 1 48%}div.dt-button-collection.columns.dtb-b1 .dt-button{flex:1 1 100%}@media screen and (max-width: 1024px){div.dt-button-collection.columns>:last-child{width:612px}}@media screen and (max-width: 640px){div.dt-button-collection.columns>:last-child{width:406px}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:0 1 32%}}@media screen and (max-width: 460px){div.dt-button-collection.columns>:last-child{width:200px}}div.dt-button-background{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0, 0, 0, 0.7);background:-ms-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);background:-moz-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);background:-o-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);background:-webkit-gradient(radial, center center, 0, center center, 497, color-stop(0, rgba(0, 0, 0, 0.3)), color-stop(1, rgba(0, 0, 0, 0.7)));background:-webkit-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);background:radial-gradient(ellipse farthest-corner at center, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);z-index:2001}@media screen and (max-width: 640px){div.dt-buttons{float:none !important;text-align:center}}button.dt-button.processing,div.dt-button.processing,a.dt-button.processing{color:rgba(0, 0, 0, 0.2)}button.dt-button.processing:after,div.dt-button.processing:after,a.dt-button.processing:after{position:absolute;top:50%;left:50%;width:16px;height:16px;margin:-8px 0 0 -8px;box-sizing:border-box;display:block;content:" ";border:2px solid #282828;border-radius:50%;border-left-color:transparent;border-right-color:transparent;animation:dtb-spinner 1500ms infinite linear;-o-animation:dtb-spinner 1500ms infinite linear;-ms-animation:dtb-spinner 1500ms infinite linear;-webkit-animation:dtb-spinner 1500ms infinite linear;-moz-animation:dtb-spinner 1500ms infinite linear}button.dt-btn-split-drop{margin-left:calc(-1px - .333em);padding-bottom:calc(.5em - 1px);border-radius:0px 1px 1px 0px;color:rgba(70, 70, 70, 0.9);border-left:none}button.dt-btn-split-drop span.dt-btn-split-drop-arrow{position:relative;top:-1px;left:-2px;font-size:8px}button.dt-btn-split-drop:hover{z-index:2}button.buttons-split{border-right:1px solid rgba(70, 70, 70, 0);border-radius:1px 0px 0px 1px}button.dt-btn-split-drop-button{background-color:white}button.dt-btn-split-drop-button:hover{background-color:white} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.foundation.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.foundation.css new file mode 100644 index 0000000000..f697d843d6 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.foundation.css @@ -0,0 +1,367 @@ +@keyframes dtb-spinner { + 100% { + transform: rotate(360deg); + } +} +@-o-keyframes dtb-spinner { + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-ms-keyframes dtb-spinner { + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-webkit-keyframes dtb-spinner { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-moz-keyframes dtb-spinner { + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } +} +div.dataTables_wrapper { + position: relative; +} + +div.dt-buttons { + position: initial; +} + +div.dt-button-info { + position: fixed; + top: 50%; + left: 50%; + width: 400px; + margin-top: -100px; + margin-left: -200px; + background-color: white; + border: 2px solid #111; + box-shadow: 3px 4px 10px 1px rgba(0, 0, 0, 0.3); + border-radius: 3px; + text-align: center; + z-index: 21; +} +div.dt-button-info h2 { + padding: 0.5em; + margin: 0; + font-weight: normal; + border-bottom: 1px solid #ddd; + background-color: #f3f3f3; +} +div.dt-button-info > div { + padding: 1em; +} + +div.dtb-popover-close { + position: absolute; + top: 10px; + right: 10px; + width: 22px; + height: 22px; + border: 1px solid #eaeaea; + background-color: #f9f9f9; + text-align: center; + border-radius: 3px; + cursor: pointer; + z-index: 12; +} + +button.dtb-hide-drop { + display: none !important; +} + +div.dt-button-collection-title { + text-align: center; + padding: 0.3em 0 0.5em; + margin-left: 0.5em; + margin-right: 0.5em; + font-size: 0.9em; +} + +div.dt-button-collection-title:empty { + display: none; +} + +span.dt-button-spacer { + display: inline-block; + margin: 0.5em; + white-space: nowrap; +} +span.dt-button-spacer.bar { + border-left: 1px solid rgba(0, 0, 0, 0.3); + vertical-align: middle; + padding-left: 0.5em; +} +span.dt-button-spacer.bar:empty { + height: 1em; + width: 1px; + padding-left: 0; +} + +div.dt-button-collection span.dt-button-spacer { + width: 100%; + font-size: 0.9em; + text-align: center; + margin: 0.5em 0; +} +div.dt-button-collection span.dt-button-spacer:empty { + height: 0; + width: 100%; +} +div.dt-button-collection span.dt-button-spacer.bar { + border-left: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.3); + padding-left: 0; +} + +ul.dt-buttons li { + margin: 0; +} +ul.dt-buttons li.active a { + box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.6); +} + +ul.dt-buttons.button-group a { + margin-bottom: 0; +} + +div.dt-button-collection { + position: absolute; + z-index: 2002; + max-width: none; + border: 1px solid #cacaca; + padding: 0.5rem; + background-color: white; +} +div.dt-button-collection.fixed { + position: fixed; + display: block; + top: 50%; + left: 50%; + margin-left: -75px; + border-radius: 5px; + background-color: white; +} +div.dt-button-collection.fixed.two-column { + margin-left: -200px; +} +div.dt-button-collection.fixed.three-column { + margin-left: -225px; +} +div.dt-button-collection.fixed.four-column { + margin-left: -300px; +} +div.dt-button-collection.fixed.columns { + margin-left: -409px; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.fixed.columns { + margin-left: -308px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.fixed.columns { + margin-left: -203px; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.fixed.columns { + margin-left: -100px; + } +} +div.dt-button-collection.fixed > :last-child { + max-height: 100vh; + overflow: auto; +} +div.dt-button-collection.two-column > :last-child, div.dt-button-collection.three-column > :last-child, div.dt-button-collection.four-column > :last-child { + display: block !important; + -webkit-column-gap: 8px; + -moz-column-gap: 8px; + -ms-column-gap: 8px; + -o-column-gap: 8px; + column-gap: 8px; +} +div.dt-button-collection.two-column > :last-child > *, div.dt-button-collection.three-column > :last-child > *, div.dt-button-collection.four-column > :last-child > * { + -webkit-column-break-inside: avoid; + break-inside: avoid; +} +div.dt-button-collection.two-column { + width: 400px; +} +div.dt-button-collection.two-column > :last-child { + padding-bottom: 1px; + column-count: 2; +} +div.dt-button-collection.three-column { + width: 450px; +} +div.dt-button-collection.three-column > :last-child { + padding-bottom: 1px; + column-count: 3; +} +div.dt-button-collection.four-column { + width: 600px; +} +div.dt-button-collection.four-column > :last-child { + padding-bottom: 1px; + column-count: 4; +} +div.dt-button-collection .dt-button { + border-radius: 0; +} +div.dt-button-collection.columns { + width: auto; +} +div.dt-button-collection.columns > :last-child { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + gap: 6px; + width: 818px; + padding-bottom: 1px; +} +div.dt-button-collection.columns > :last-child .dt-button { + min-width: 200px; + flex: 0 1; + margin: 0; +} +div.dt-button-collection.columns.dtb-b3 > :last-child, div.dt-button-collection.columns.dtb-b2 > :last-child, div.dt-button-collection.columns.dtb-b1 > :last-child { + justify-content: space-between; +} +div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 1 1 32%; +} +div.dt-button-collection.columns.dtb-b2 .dt-button { + flex: 1 1 48%; +} +div.dt-button-collection.columns.dtb-b1 .dt-button { + flex: 1 1 100%; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.columns > :last-child { + width: 612px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.columns > :last-child { + width: 406px; + } + div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 0 1 32%; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.columns > :last-child { + width: 200px; + } +} +div.dt-button-collection .button-group.stacked { + position: relative; + border: none; + padding: 0; + margin: 0; +} +div.dt-button-collection.columns .button-group.stacked { + flex-direction: row; + padding: 0; +} +div.dt-button-collection.columns .dt-button { + flex-basis: 200px; +} +div.dt-button-collection div.dt-btn-split-wrapper a.button { + flex-grow: 1; +} +div.dt-button-collection div.dt-btn-split-wrapper a.button, +div.dt-button-collection div.dt-btn-split-wrapper button.button { + display: inline-block !important; + white-space: nowrap; + height: 40px; + flex-basis: auto; + overflow: hidden; + text-overflow: ellipsis; +} + +div.dt-button-background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 88; +} + +@media screen and (max-width: 767px) { + ul.dt-buttons { + float: none; + width: 100%; + text-align: center; + margin-bottom: 0.5rem; + } + ul.dt-buttons li { + float: none; + } +} +div.button-group.stacked.dropdown-pane { + margin-top: 2px; + padding: 1px; + z-index: 89; +} +div.button-group.stacked.dropdown-pane a.button { + display: block; + margin-bottom: 1px; + border-right: none; +} +div.button-group.stacked.dropdown-pane a.button:last-child { + margin-bottom: 0; + margin-right: 1px; +} + +div.dt-buttons button.button.processing, +div.dt-buttons div.button.processing, +div.dt-buttons a.button.processing { + color: rgba(0, 0, 0, 0.2); + color: rgba(255, 255, 255, 0.2); + border-top-color: white; + border-bottom-color: white; +} +div.dt-buttons button.button.processing:after, +div.dt-buttons div.button.processing:after, +div.dt-buttons a.button.processing:after { + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + box-sizing: border-box; + display: block; + content: " "; + border: 2px solid #282828; + border-radius: 50%; + border-left-color: transparent; + border-right-color: transparent; + animation: dtb-spinner 1500ms infinite linear; + -o-animation: dtb-spinner 1500ms infinite linear; + -ms-animation: dtb-spinner 1500ms infinite linear; + -webkit-animation: dtb-spinner 1500ms infinite linear; + -moz-animation: dtb-spinner 1500ms infinite linear; +} + +div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop, div.dt-btn-split-wrapper.secondary:not(.disabled) button.dt-btn-split-drop { + box-shadow: none; + background-color: #1779ba; + border-color: transparent; +} +div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop:hover, div.dt-btn-split-wrapper.secondary:not(.disabled) button.dt-btn-split-drop:hover { + background-color: #14679e; + border-color: transparent; +} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.foundation.min.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.foundation.min.css new file mode 100644 index 0000000000..f4d4a69cdd --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.foundation.min.css @@ -0,0 +1 @@ +@keyframes dtb-spinner{100%{transform:rotate(360deg)}}@-o-keyframes dtb-spinner{100%{-o-transform:rotate(360deg);transform:rotate(360deg)}}@-ms-keyframes dtb-spinner{100%{-ms-transform:rotate(360deg);transform:rotate(360deg)}}@-webkit-keyframes dtb-spinner{100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@-moz-keyframes dtb-spinner{100%{-moz-transform:rotate(360deg);transform:rotate(360deg)}}div.dataTables_wrapper{position:relative}div.dt-buttons{position:initial}div.dt-button-info{position:fixed;top:50%;left:50%;width:400px;margin-top:-100px;margin-left:-200px;background-color:white;border:2px solid #111;box-shadow:3px 4px 10px 1px rgba(0, 0, 0, 0.3);border-radius:3px;text-align:center;z-index:21}div.dt-button-info h2{padding:.5em;margin:0;font-weight:normal;border-bottom:1px solid #ddd;background-color:#f3f3f3}div.dt-button-info>div{padding:1em}div.dtb-popover-close{position:absolute;top:10px;right:10px;width:22px;height:22px;border:1px solid #eaeaea;background-color:#f9f9f9;text-align:center;border-radius:3px;cursor:pointer;z-index:12}button.dtb-hide-drop{display:none !important}div.dt-button-collection-title{text-align:center;padding:.3em 0 .5em;margin-left:.5em;margin-right:.5em;font-size:.9em}div.dt-button-collection-title:empty{display:none}span.dt-button-spacer{display:inline-block;margin:.5em;white-space:nowrap}span.dt-button-spacer.bar{border-left:1px solid rgba(0, 0, 0, 0.3);vertical-align:middle;padding-left:.5em}span.dt-button-spacer.bar:empty{height:1em;width:1px;padding-left:0}div.dt-button-collection span.dt-button-spacer{width:100%;font-size:.9em;text-align:center;margin:.5em 0}div.dt-button-collection span.dt-button-spacer:empty{height:0;width:100%}div.dt-button-collection span.dt-button-spacer.bar{border-left:none;border-bottom:1px solid rgba(0, 0, 0, 0.3);padding-left:0}ul.dt-buttons li{margin:0}ul.dt-buttons li.active a{box-shadow:inset 0 0 10px rgba(0, 0, 0, 0.6)}ul.dt-buttons.button-group a{margin-bottom:0}div.dt-button-collection{position:absolute;z-index:2002;max-width:none;border:1px solid #cacaca;padding:.5rem;background-color:white}div.dt-button-collection.fixed{position:fixed;display:block;top:50%;left:50%;margin-left:-75px;border-radius:5px;background-color:white}div.dt-button-collection.fixed.two-column{margin-left:-200px}div.dt-button-collection.fixed.three-column{margin-left:-225px}div.dt-button-collection.fixed.four-column{margin-left:-300px}div.dt-button-collection.fixed.columns{margin-left:-409px}@media screen and (max-width: 1024px){div.dt-button-collection.fixed.columns{margin-left:-308px}}@media screen and (max-width: 640px){div.dt-button-collection.fixed.columns{margin-left:-203px}}@media screen and (max-width: 460px){div.dt-button-collection.fixed.columns{margin-left:-100px}}div.dt-button-collection.fixed>:last-child{max-height:100vh;overflow:auto}div.dt-button-collection.two-column>:last-child,div.dt-button-collection.three-column>:last-child,div.dt-button-collection.four-column>:last-child{display:block !important;-webkit-column-gap:8px;-moz-column-gap:8px;-ms-column-gap:8px;-o-column-gap:8px;column-gap:8px}div.dt-button-collection.two-column>:last-child>*,div.dt-button-collection.three-column>:last-child>*,div.dt-button-collection.four-column>:last-child>*{-webkit-column-break-inside:avoid;break-inside:avoid}div.dt-button-collection.two-column{width:400px}div.dt-button-collection.two-column>:last-child{padding-bottom:1px;column-count:2}div.dt-button-collection.three-column{width:450px}div.dt-button-collection.three-column>:last-child{padding-bottom:1px;column-count:3}div.dt-button-collection.four-column{width:600px}div.dt-button-collection.four-column>:last-child{padding-bottom:1px;column-count:4}div.dt-button-collection .dt-button{border-radius:0}div.dt-button-collection.columns{width:auto}div.dt-button-collection.columns>:last-child{display:flex;flex-wrap:wrap;justify-content:flex-start;align-items:center;gap:6px;width:818px;padding-bottom:1px}div.dt-button-collection.columns>:last-child .dt-button{min-width:200px;flex:0 1;margin:0}div.dt-button-collection.columns.dtb-b3>:last-child,div.dt-button-collection.columns.dtb-b2>:last-child,div.dt-button-collection.columns.dtb-b1>:last-child{justify-content:space-between}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:1 1 32%}div.dt-button-collection.columns.dtb-b2 .dt-button{flex:1 1 48%}div.dt-button-collection.columns.dtb-b1 .dt-button{flex:1 1 100%}@media screen and (max-width: 1024px){div.dt-button-collection.columns>:last-child{width:612px}}@media screen and (max-width: 640px){div.dt-button-collection.columns>:last-child{width:406px}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:0 1 32%}}@media screen and (max-width: 460px){div.dt-button-collection.columns>:last-child{width:200px}}div.dt-button-collection .button-group.stacked{position:relative;border:none;padding:0;margin:0}div.dt-button-collection.columns .button-group.stacked{flex-direction:row;padding:0}div.dt-button-collection.columns .dt-button{flex-basis:200px}div.dt-button-collection div.dt-btn-split-wrapper a.button{flex-grow:1}div.dt-button-collection div.dt-btn-split-wrapper a.button,div.dt-button-collection div.dt-btn-split-wrapper button.button{display:inline-block !important;white-space:nowrap;height:40px;flex-basis:auto;overflow:hidden;text-overflow:ellipsis}div.dt-button-background{position:fixed;top:0;left:0;width:100%;height:100%;z-index:88}@media screen and (max-width: 767px){ul.dt-buttons{float:none;width:100%;text-align:center;margin-bottom:.5rem}ul.dt-buttons li{float:none}}div.button-group.stacked.dropdown-pane{margin-top:2px;padding:1px;z-index:89}div.button-group.stacked.dropdown-pane a.button{display:block;margin-bottom:1px;border-right:none}div.button-group.stacked.dropdown-pane a.button:last-child{margin-bottom:0;margin-right:1px}div.dt-buttons button.button.processing,div.dt-buttons div.button.processing,div.dt-buttons a.button.processing{color:rgba(0, 0, 0, 0.2);color:rgba(255, 255, 255, 0.2);border-top-color:white;border-bottom-color:white}div.dt-buttons button.button.processing:after,div.dt-buttons div.button.processing:after,div.dt-buttons a.button.processing:after{position:absolute;top:50%;left:50%;width:16px;height:16px;margin:-8px 0 0 -8px;box-sizing:border-box;display:block;content:" ";border:2px solid #282828;border-radius:50%;border-left-color:transparent;border-right-color:transparent;animation:dtb-spinner 1500ms infinite linear;-o-animation:dtb-spinner 1500ms infinite linear;-ms-animation:dtb-spinner 1500ms infinite linear;-webkit-animation:dtb-spinner 1500ms infinite linear;-moz-animation:dtb-spinner 1500ms infinite linear}div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop,div.dt-btn-split-wrapper.secondary:not(.disabled) button.dt-btn-split-drop{box-shadow:none;background-color:#1779ba;border-color:transparent}div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop:hover,div.dt-btn-split-wrapper.secondary:not(.disabled) button.dt-btn-split-drop:hover{background-color:#14679e;border-color:transparent} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.jqueryui.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.jqueryui.css new file mode 100644 index 0000000000..857470d07a --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.jqueryui.css @@ -0,0 +1,395 @@ +@keyframes dtb-spinner { + 100% { + transform: rotate(360deg); + } +} +@-o-keyframes dtb-spinner { + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-ms-keyframes dtb-spinner { + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-webkit-keyframes dtb-spinner { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-moz-keyframes dtb-spinner { + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } +} +div.dataTables_wrapper { + position: relative; +} + +div.dt-buttons { + position: initial; +} + +div.dt-button-info { + position: fixed; + top: 50%; + left: 50%; + width: 400px; + margin-top: -100px; + margin-left: -200px; + background-color: white; + border: 2px solid #111; + box-shadow: 3px 4px 10px 1px rgba(0, 0, 0, 0.3); + border-radius: 3px; + text-align: center; + z-index: 21; +} +div.dt-button-info h2 { + padding: 0.5em; + margin: 0; + font-weight: normal; + border-bottom: 1px solid #ddd; + background-color: #f3f3f3; +} +div.dt-button-info > div { + padding: 1em; +} + +div.dtb-popover-close { + position: absolute; + top: 10px; + right: 10px; + width: 22px; + height: 22px; + border: 1px solid #eaeaea; + background-color: #f9f9f9; + text-align: center; + border-radius: 3px; + cursor: pointer; + z-index: 12; +} + +button.dtb-hide-drop { + display: none !important; +} + +div.dt-button-collection-title { + text-align: center; + padding: 0.3em 0 0.5em; + margin-left: 0.5em; + margin-right: 0.5em; + font-size: 0.9em; +} + +div.dt-button-collection-title:empty { + display: none; +} + +span.dt-button-spacer { + display: inline-block; + margin: 0.5em; + white-space: nowrap; +} +span.dt-button-spacer.bar { + border-left: 1px solid rgba(0, 0, 0, 0.3); + vertical-align: middle; + padding-left: 0.5em; +} +span.dt-button-spacer.bar:empty { + height: 1em; + width: 1px; + padding-left: 0; +} + +div.dt-button-collection span.dt-button-spacer { + width: 100%; + font-size: 0.9em; + text-align: center; + margin: 0.5em 0; +} +div.dt-button-collection span.dt-button-spacer:empty { + height: 0; + width: 100%; +} +div.dt-button-collection span.dt-button-spacer.bar { + border-left: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.3); + padding-left: 0; +} + +div.dt-buttons { + position: relative; + float: left; +} +div.dt-buttons .dt-button { + margin-right: 0; +} +div.dt-buttons .dt-button span.ui-icon { + display: inline-block; + vertical-align: middle; + margin-top: -2px; +} +div.dt-buttons .dt-button:active { + outline: none; +} +div.dt-buttons .dt-button:hover > span { + background-color: rgba(0, 0, 0, 0.05); +} + +div.dt-button-collection { + position: absolute; + top: 0; + left: 0; + width: 150px; + margin-top: 3px; + padding: 8px 8px 4px 8px; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, 0.4); + background-color: #f3f3f3; + overflow: hidden; + z-index: 2002; + border-radius: 5px; + box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.3); + z-index: 2002; + -webkit-column-gap: 0; + -moz-column-gap: 0; + -ms-column-gap: 0; + -o-column-gap: 0; + column-gap: 0; +} +div.dt-button-collection .dt-button { + position: relative; + left: 0; + right: 0; + width: 100%; + box-sizing: border-box; + display: block; + float: none; + margin-right: 0; + margin-bottom: 4px; +} +div.dt-button-collection .dt-button:hover > span { + background-color: rgba(0, 0, 0, 0.05); +} +div.dt-button-collection.fixed { + position: fixed; + display: block; + top: 50%; + left: 50%; + margin-left: -75px; + border-radius: 5px; + background-color: white; +} +div.dt-button-collection.fixed.two-column { + margin-left: -200px; +} +div.dt-button-collection.fixed.three-column { + margin-left: -225px; +} +div.dt-button-collection.fixed.four-column { + margin-left: -300px; +} +div.dt-button-collection.fixed.columns { + margin-left: -409px; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.fixed.columns { + margin-left: -308px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.fixed.columns { + margin-left: -203px; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.fixed.columns { + margin-left: -100px; + } +} +div.dt-button-collection.fixed > :last-child { + max-height: 100vh; + overflow: auto; +} +div.dt-button-collection.two-column > :last-child, div.dt-button-collection.three-column > :last-child, div.dt-button-collection.four-column > :last-child { + display: block !important; + -webkit-column-gap: 8px; + -moz-column-gap: 8px; + -ms-column-gap: 8px; + -o-column-gap: 8px; + column-gap: 8px; +} +div.dt-button-collection.two-column > :last-child > *, div.dt-button-collection.three-column > :last-child > *, div.dt-button-collection.four-column > :last-child > * { + -webkit-column-break-inside: avoid; + break-inside: avoid; +} +div.dt-button-collection.two-column { + width: 400px; +} +div.dt-button-collection.two-column > :last-child { + padding-bottom: 1px; + column-count: 2; +} +div.dt-button-collection.three-column { + width: 450px; +} +div.dt-button-collection.three-column > :last-child { + padding-bottom: 1px; + column-count: 3; +} +div.dt-button-collection.four-column { + width: 600px; +} +div.dt-button-collection.four-column > :last-child { + padding-bottom: 1px; + column-count: 4; +} +div.dt-button-collection .dt-button { + border-radius: 0; +} +div.dt-button-collection.columns { + width: auto; +} +div.dt-button-collection.columns > :last-child { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + gap: 6px; + width: 818px; + padding-bottom: 1px; +} +div.dt-button-collection.columns > :last-child .dt-button { + min-width: 200px; + flex: 0 1; + margin: 0; +} +div.dt-button-collection.columns.dtb-b3 > :last-child, div.dt-button-collection.columns.dtb-b2 > :last-child, div.dt-button-collection.columns.dtb-b1 > :last-child { + justify-content: space-between; +} +div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 1 1 32%; +} +div.dt-button-collection.columns.dtb-b2 .dt-button { + flex: 1 1 48%; +} +div.dt-button-collection.columns.dtb-b1 .dt-button { + flex: 1 1 100%; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.columns > :last-child { + width: 612px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.columns > :last-child { + width: 406px; + } + div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 0 1 32%; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.columns > :last-child { + width: 200px; + } +} + +div.dt-btn-split-wrapper { + padding: 0px !important; + background-color: transparent !important; + display: flex; + border: none !important; + margin: 0px; +} +div.dt-btn-split-wrapper:hover { + border: none; +} +div.dt-btn-split-wrapper button.dt-btn-split-drop { + width: 24px; + padding-left: 6px; + padding-right: 6px; + font-size: 10px; + height: 29.5px; + border-radius: 0px; + margin-left: -1px; +} +div.dt-btn-split-wrapper:active:not(.disabled) button.dt-button, div.dt-btn-split-wrapper.ui-state-active:not(.disabled) button.dt-button, div.dt-btn-split-wrapper.is-active:not(.disabled) button.dt-button { + background-color: #007fff; + border-color: #003eff; +} +div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop, div.dt-btn-split-wrapper.ui-state-active:not(.disabled) button.dt-btn-split-drop, div.dt-btn-split-wrapper.is-active:not(.disabled) button.dt-btn-split-drop { + box-shadow: none; + background-color: #f6f6f6; + border-color: #c5c5c5; +} +div.dt-btn-split-wrapper:active:not(.disabled) button:hover, div.dt-btn-split-wrapper.ui-state-active:not(.disabled) button:hover, div.dt-btn-split-wrapper.is-active:not(.disabled) button:hover { + background-color: #ededed; + border-color: #cccccc; +} + +div.dt-button-background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + /* Fallback */ + background: -ms-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); + /* IE10 Consumer Preview */ + background: -moz-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); + /* Firefox */ + background: -o-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); + /* Opera */ + background: -webkit-gradient(radial, center center, 0, center center, 497, color-stop(0, rgba(0, 0, 0, 0.3)), color-stop(1, rgba(0, 0, 0, 0.7))); + /* Webkit (Safari/Chrome 10) */ + background: -webkit-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); + /* Webkit (Chrome 11+) */ + background: radial-gradient(ellipse farthest-corner at center, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); + /* W3C Markup, IE10 Release Preview */ + z-index: 2001; +} + +@media screen and (max-width: 640px) { + div.dt-buttons { + float: none !important; + text-align: center; + } +} +button.dt-button.processing, +div.dt-button.processing, +a.dt-button.processing { + color: rgba(0, 0, 0, 0.2); +} +button.dt-button.processing:after, +div.dt-button.processing:after, +a.dt-button.processing:after { + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + box-sizing: border-box; + display: block; + content: " "; + border: 2px solid #282828; + border-radius: 50%; + border-left-color: transparent; + border-right-color: transparent; + animation: dtb-spinner 1500ms infinite linear; + -o-animation: dtb-spinner 1500ms infinite linear; + -ms-animation: dtb-spinner 1500ms infinite linear; + -webkit-animation: dtb-spinner 1500ms infinite linear; + -moz-animation: dtb-spinner 1500ms infinite linear; +} + +span.dt-down-arrow { + display: none; +} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.jqueryui.min.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.jqueryui.min.css new file mode 100644 index 0000000000..5c2f9d82f3 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.jqueryui.min.css @@ -0,0 +1 @@ +@keyframes dtb-spinner{100%{transform:rotate(360deg)}}@-o-keyframes dtb-spinner{100%{-o-transform:rotate(360deg);transform:rotate(360deg)}}@-ms-keyframes dtb-spinner{100%{-ms-transform:rotate(360deg);transform:rotate(360deg)}}@-webkit-keyframes dtb-spinner{100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@-moz-keyframes dtb-spinner{100%{-moz-transform:rotate(360deg);transform:rotate(360deg)}}div.dataTables_wrapper{position:relative}div.dt-buttons{position:initial}div.dt-button-info{position:fixed;top:50%;left:50%;width:400px;margin-top:-100px;margin-left:-200px;background-color:white;border:2px solid #111;box-shadow:3px 4px 10px 1px rgba(0, 0, 0, 0.3);border-radius:3px;text-align:center;z-index:21}div.dt-button-info h2{padding:.5em;margin:0;font-weight:normal;border-bottom:1px solid #ddd;background-color:#f3f3f3}div.dt-button-info>div{padding:1em}div.dtb-popover-close{position:absolute;top:10px;right:10px;width:22px;height:22px;border:1px solid #eaeaea;background-color:#f9f9f9;text-align:center;border-radius:3px;cursor:pointer;z-index:12}button.dtb-hide-drop{display:none !important}div.dt-button-collection-title{text-align:center;padding:.3em 0 .5em;margin-left:.5em;margin-right:.5em;font-size:.9em}div.dt-button-collection-title:empty{display:none}span.dt-button-spacer{display:inline-block;margin:.5em;white-space:nowrap}span.dt-button-spacer.bar{border-left:1px solid rgba(0, 0, 0, 0.3);vertical-align:middle;padding-left:.5em}span.dt-button-spacer.bar:empty{height:1em;width:1px;padding-left:0}div.dt-button-collection span.dt-button-spacer{width:100%;font-size:.9em;text-align:center;margin:.5em 0}div.dt-button-collection span.dt-button-spacer:empty{height:0;width:100%}div.dt-button-collection span.dt-button-spacer.bar{border-left:none;border-bottom:1px solid rgba(0, 0, 0, 0.3);padding-left:0}div.dt-buttons{position:relative;float:left}div.dt-buttons .dt-button{margin-right:0}div.dt-buttons .dt-button span.ui-icon{display:inline-block;vertical-align:middle;margin-top:-2px}div.dt-buttons .dt-button:active{outline:none}div.dt-buttons .dt-button:hover>span{background-color:rgba(0, 0, 0, 0.05)}div.dt-button-collection{position:absolute;top:0;left:0;width:150px;margin-top:3px;padding:8px 8px 4px 8px;border:1px solid #ccc;border:1px solid rgba(0, 0, 0, 0.4);background-color:#f3f3f3;overflow:hidden;z-index:2002;border-radius:5px;box-shadow:3px 3px 5px rgba(0, 0, 0, 0.3);z-index:2002;-webkit-column-gap:0;-moz-column-gap:0;-ms-column-gap:0;-o-column-gap:0;column-gap:0}div.dt-button-collection .dt-button{position:relative;left:0;right:0;width:100%;box-sizing:border-box;display:block;float:none;margin-right:0;margin-bottom:4px}div.dt-button-collection .dt-button:hover>span{background-color:rgba(0, 0, 0, 0.05)}div.dt-button-collection.fixed{position:fixed;display:block;top:50%;left:50%;margin-left:-75px;border-radius:5px;background-color:white}div.dt-button-collection.fixed.two-column{margin-left:-200px}div.dt-button-collection.fixed.three-column{margin-left:-225px}div.dt-button-collection.fixed.four-column{margin-left:-300px}div.dt-button-collection.fixed.columns{margin-left:-409px}@media screen and (max-width: 1024px){div.dt-button-collection.fixed.columns{margin-left:-308px}}@media screen and (max-width: 640px){div.dt-button-collection.fixed.columns{margin-left:-203px}}@media screen and (max-width: 460px){div.dt-button-collection.fixed.columns{margin-left:-100px}}div.dt-button-collection.fixed>:last-child{max-height:100vh;overflow:auto}div.dt-button-collection.two-column>:last-child,div.dt-button-collection.three-column>:last-child,div.dt-button-collection.four-column>:last-child{display:block !important;-webkit-column-gap:8px;-moz-column-gap:8px;-ms-column-gap:8px;-o-column-gap:8px;column-gap:8px}div.dt-button-collection.two-column>:last-child>*,div.dt-button-collection.three-column>:last-child>*,div.dt-button-collection.four-column>:last-child>*{-webkit-column-break-inside:avoid;break-inside:avoid}div.dt-button-collection.two-column{width:400px}div.dt-button-collection.two-column>:last-child{padding-bottom:1px;column-count:2}div.dt-button-collection.three-column{width:450px}div.dt-button-collection.three-column>:last-child{padding-bottom:1px;column-count:3}div.dt-button-collection.four-column{width:600px}div.dt-button-collection.four-column>:last-child{padding-bottom:1px;column-count:4}div.dt-button-collection .dt-button{border-radius:0}div.dt-button-collection.columns{width:auto}div.dt-button-collection.columns>:last-child{display:flex;flex-wrap:wrap;justify-content:flex-start;align-items:center;gap:6px;width:818px;padding-bottom:1px}div.dt-button-collection.columns>:last-child .dt-button{min-width:200px;flex:0 1;margin:0}div.dt-button-collection.columns.dtb-b3>:last-child,div.dt-button-collection.columns.dtb-b2>:last-child,div.dt-button-collection.columns.dtb-b1>:last-child{justify-content:space-between}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:1 1 32%}div.dt-button-collection.columns.dtb-b2 .dt-button{flex:1 1 48%}div.dt-button-collection.columns.dtb-b1 .dt-button{flex:1 1 100%}@media screen and (max-width: 1024px){div.dt-button-collection.columns>:last-child{width:612px}}@media screen and (max-width: 640px){div.dt-button-collection.columns>:last-child{width:406px}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:0 1 32%}}@media screen and (max-width: 460px){div.dt-button-collection.columns>:last-child{width:200px}}div.dt-btn-split-wrapper{padding:0px !important;background-color:transparent !important;display:flex;border:none !important;margin:0px}div.dt-btn-split-wrapper:hover{border:none}div.dt-btn-split-wrapper button.dt-btn-split-drop{width:24px;padding-left:6px;padding-right:6px;font-size:10px;height:29.5px;border-radius:0px;margin-left:-1px}div.dt-btn-split-wrapper:active:not(.disabled) button.dt-button,div.dt-btn-split-wrapper.ui-state-active:not(.disabled) button.dt-button,div.dt-btn-split-wrapper.is-active:not(.disabled) button.dt-button{background-color:#007fff;border-color:#003eff}div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop,div.dt-btn-split-wrapper.ui-state-active:not(.disabled) button.dt-btn-split-drop,div.dt-btn-split-wrapper.is-active:not(.disabled) button.dt-btn-split-drop{box-shadow:none;background-color:#f6f6f6;border-color:#c5c5c5}div.dt-btn-split-wrapper:active:not(.disabled) button:hover,div.dt-btn-split-wrapper.ui-state-active:not(.disabled) button:hover,div.dt-btn-split-wrapper.is-active:not(.disabled) button:hover{background-color:#ededed;border-color:#ccc}div.dt-button-background{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0, 0, 0, 0.7);background:-ms-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);background:-moz-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);background:-o-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);background:-webkit-gradient(radial, center center, 0, center center, 497, color-stop(0, rgba(0, 0, 0, 0.3)), color-stop(1, rgba(0, 0, 0, 0.7)));background:-webkit-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);background:radial-gradient(ellipse farthest-corner at center, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);z-index:2001}@media screen and (max-width: 640px){div.dt-buttons{float:none !important;text-align:center}}button.dt-button.processing,div.dt-button.processing,a.dt-button.processing{color:rgba(0, 0, 0, 0.2)}button.dt-button.processing:after,div.dt-button.processing:after,a.dt-button.processing:after{position:absolute;top:50%;left:50%;width:16px;height:16px;margin:-8px 0 0 -8px;box-sizing:border-box;display:block;content:" ";border:2px solid #282828;border-radius:50%;border-left-color:transparent;border-right-color:transparent;animation:dtb-spinner 1500ms infinite linear;-o-animation:dtb-spinner 1500ms infinite linear;-ms-animation:dtb-spinner 1500ms infinite linear;-webkit-animation:dtb-spinner 1500ms infinite linear;-moz-animation:dtb-spinner 1500ms infinite linear}span.dt-down-arrow{display:none} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.semanticui.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.semanticui.css new file mode 100644 index 0000000000..901251e88d --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.semanticui.css @@ -0,0 +1,397 @@ +@keyframes dtb-spinner { + 100% { + transform: rotate(360deg); + } +} +@-o-keyframes dtb-spinner { + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-ms-keyframes dtb-spinner { + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-webkit-keyframes dtb-spinner { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-moz-keyframes dtb-spinner { + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } +} +div.dataTables_wrapper { + position: relative; +} + +div.dt-buttons { + position: initial; +} + +div.dt-button-info { + position: fixed; + top: 50%; + left: 50%; + width: 400px; + margin-top: -100px; + margin-left: -200px; + background-color: white; + border: 2px solid #111; + box-shadow: 3px 4px 10px 1px rgba(0, 0, 0, 0.3); + border-radius: 3px; + text-align: center; + z-index: 21; +} +div.dt-button-info h2 { + padding: 0.5em; + margin: 0; + font-weight: normal; + border-bottom: 1px solid #ddd; + background-color: #f3f3f3; +} +div.dt-button-info > div { + padding: 1em; +} + +div.dtb-popover-close { + position: absolute; + top: 10px; + right: 10px; + width: 22px; + height: 22px; + border: 1px solid #eaeaea; + background-color: #f9f9f9; + text-align: center; + border-radius: 3px; + cursor: pointer; + z-index: 12; +} + +button.dtb-hide-drop { + display: none !important; +} + +div.dt-button-collection-title { + text-align: center; + padding: 0.3em 0 0.5em; + margin-left: 0.5em; + margin-right: 0.5em; + font-size: 0.9em; +} + +div.dt-button-collection-title:empty { + display: none; +} + +span.dt-button-spacer { + display: inline-block; + margin: 0.5em; + white-space: nowrap; +} +span.dt-button-spacer.bar { + border-left: 1px solid rgba(0, 0, 0, 0.3); + vertical-align: middle; + padding-left: 0.5em; +} +span.dt-button-spacer.bar:empty { + height: 1em; + width: 1px; + padding-left: 0; +} + +div.dt-button-collection span.dt-button-spacer { + width: 100%; + font-size: 0.9em; + text-align: center; + margin: 0.5em 0; +} +div.dt-button-collection span.dt-button-spacer:empty { + height: 0; + width: 100%; +} +div.dt-button-collection span.dt-button-spacer.bar { + border-left: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.3); + padding-left: 0; +} + +div.dt-button-collection { + position: absolute; + top: 0; + left: 0; + min-width: 200px; + margin-top: 3px !important; + margin-bottom: 3px !important; + z-index: 2002; + background: white; + border: 1px solid rgba(34, 36, 38, 0.15); + font-size: 1em; + padding: 0.5rem; +} +div.dt-button-collection.fixed { + position: fixed; + display: block; + top: 50%; + left: 50%; + margin-left: -75px; + border-radius: 5px; + background-color: white; +} +div.dt-button-collection.fixed.two-column { + margin-left: -200px; +} +div.dt-button-collection.fixed.three-column { + margin-left: -225px; +} +div.dt-button-collection.fixed.four-column { + margin-left: -300px; +} +div.dt-button-collection.fixed.columns { + margin-left: -409px; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.fixed.columns { + margin-left: -308px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.fixed.columns { + margin-left: -203px; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.fixed.columns { + margin-left: -100px; + } +} +div.dt-button-collection.fixed > :last-child { + max-height: 100vh; + overflow: auto; +} +div.dt-button-collection.two-column > :last-child, div.dt-button-collection.three-column > :last-child, div.dt-button-collection.four-column > :last-child { + display: block !important; + -webkit-column-gap: 8px; + -moz-column-gap: 8px; + -ms-column-gap: 8px; + -o-column-gap: 8px; + column-gap: 8px; +} +div.dt-button-collection.two-column > :last-child > *, div.dt-button-collection.three-column > :last-child > *, div.dt-button-collection.four-column > :last-child > * { + -webkit-column-break-inside: avoid; + break-inside: avoid; +} +div.dt-button-collection.two-column { + width: 400px; +} +div.dt-button-collection.two-column > :last-child { + padding-bottom: 1px; + column-count: 2; +} +div.dt-button-collection.three-column { + width: 450px; +} +div.dt-button-collection.three-column > :last-child { + padding-bottom: 1px; + column-count: 3; +} +div.dt-button-collection.four-column { + width: 600px; +} +div.dt-button-collection.four-column > :last-child { + padding-bottom: 1px; + column-count: 4; +} +div.dt-button-collection .dt-button { + border-radius: 0; +} +div.dt-button-collection.columns { + width: auto; +} +div.dt-button-collection.columns > :last-child { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + gap: 6px; + width: 818px; + padding-bottom: 1px; +} +div.dt-button-collection.columns > :last-child .dt-button { + min-width: 200px; + flex: 0 1; + margin: 0; +} +div.dt-button-collection.columns.dtb-b3 > :last-child, div.dt-button-collection.columns.dtb-b2 > :last-child, div.dt-button-collection.columns.dtb-b1 > :last-child { + justify-content: space-between; +} +div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 1 1 32%; +} +div.dt-button-collection.columns.dtb-b2 .dt-button { + flex: 1 1 48%; +} +div.dt-button-collection.columns.dtb-b1 .dt-button { + flex: 1 1 100%; +} +@media screen and (max-width: 1024px) { + div.dt-button-collection.columns > :last-child { + width: 612px; + } +} +@media screen and (max-width: 640px) { + div.dt-button-collection.columns > :last-child { + width: 406px; + } + div.dt-button-collection.columns.dtb-b3 .dt-button { + flex: 0 1 32%; + } +} +@media screen and (max-width: 460px) { + div.dt-button-collection.columns > :last-child { + width: 200px; + } +} +div.dt-button-collection div.dt-button-collection-title { + font-size: 1rem; +} +div.dt-button-collection:not(.columns) .ui.vertical.buttons { + width: 100%; + border: none; +} +div.dt-button-collection.columns .ui.vertical.buttons { + flex-direction: row; + border: none; +} +div.dt-button-collection button.dt-button { + border: 1px solid rgba(34, 36, 38, 0.15) !important; +} +div.dt-button-collection div.dt-btn-split-wrapper { + display: flex; +} +div.dt-button-collection div.dt-btn-split-wrapper button { + flex-grow: 1 !important; + flex-basis: auto !important; + width: auto !important; + border-top-right-radius: 0px !important; +} +div.dt-button-collection div.dt-btn-split-wrapper button.dt-btn-split-drop { + flex-grow: 0 !important; + flex-basis: auto !important; + border-bottom-left-radius: 0px !important; + border-bottom-right-radius: 0px !important; + border-top-right-radius: 4px !important; +} + +button.buttons-collection.ui.button span:after { + display: inline-block; + content: "▾"; + padding-left: 0.5em; +} + +div.dt-button-background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 2001; +} + +@media screen and (max-width: 767px) { + div.dt-buttons { + float: none; + width: 100%; + text-align: center; + margin-bottom: 0.5em; + } + div.dt-buttons a.btn { + float: none; + } +} +div.dt-buttons button.button.processing, +div.dt-buttons div.button.processing, +div.dt-buttons a.button.processing { + position: relative; + color: rgba(0, 0, 0, 0.2); +} +div.dt-buttons button.button.processing:after, +div.dt-buttons div.button.processing:after, +div.dt-buttons a.button.processing:after { + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + box-sizing: border-box; + display: block; + content: " "; + border: 2px solid #282828; + border-radius: 50%; + border-left-color: transparent; + border-right-color: transparent; + animation: dtb-spinner 1500ms infinite linear; + -o-animation: dtb-spinner 1500ms infinite linear; + -ms-animation: dtb-spinner 1500ms infinite linear; + -webkit-animation: dtb-spinner 1500ms infinite linear; + -moz-animation: dtb-spinner 1500ms infinite linear; +} +div.dt-buttons.ui.buttons { + flex-wrap: wrap; +} +div.dt-buttons.ui.basic.buttons .ui.button { + border-bottom: 1px solid rgba(34, 36, 38, 0.15); + margin-bottom: -1px; +} +div.dt-buttons.ui.basic.buttons .ui.button:hover { + background: transparent !important; +} + +span.dt-down-arrow { + display: none; +} + +span.dt-button-spacer { + cursor: inherit; +} +span.dt-button-spacer.bar { + padding-left: 1.5em; +} +span.dt-button-spacer.bar:empty { + height: inherit; +} + +div.dt-button-collection span.dt-button-spacer { + border-top: 1px solid rgba(34, 36, 38, 0.15); +} +div.dt-button-collection span.dt-button-spacer.bar { + border-bottom: none; + padding-left: 1.5em; +} + +div.dt-buttons.ui.basic.buttons .button.dt-button-spacer { + background: rgba(34, 36, 38, 0.05) !important; + box-shadow: none; + cursor: initial; +} +div.dt-buttons.ui.basic.buttons .button.dt-button-spacer:hover { + background-color: rgba(34, 36, 38, 0.05) !important; +} + +div.dt-btn-split-wrapper:active:not(.disabled) button.button, div.dt-btn-split-wrapper.active:not(.disabled) button.button { + background-color: #f8f8f8 !important; +} +div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop, div.dt-btn-split-wrapper.active:not(.disabled) button.dt-btn-split-drop { + box-shadow: none; + background-color: transparent !important; +} +div.dt-btn-split-wrapper:active:not(.disabled) button.button:hover, div.dt-btn-split-wrapper.active:not(.disabled) button.button:hover { + background-color: transparent !important; +} diff --git a/app/static/DataTables/Buttons-2.2.2/css/buttons.semanticui.min.css b/app/static/DataTables/Buttons-2.2.2/css/buttons.semanticui.min.css new file mode 100644 index 0000000000..3bdfbc4a21 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/buttons.semanticui.min.css @@ -0,0 +1 @@ +@keyframes dtb-spinner{100%{transform:rotate(360deg)}}@-o-keyframes dtb-spinner{100%{-o-transform:rotate(360deg);transform:rotate(360deg)}}@-ms-keyframes dtb-spinner{100%{-ms-transform:rotate(360deg);transform:rotate(360deg)}}@-webkit-keyframes dtb-spinner{100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@-moz-keyframes dtb-spinner{100%{-moz-transform:rotate(360deg);transform:rotate(360deg)}}div.dataTables_wrapper{position:relative}div.dt-buttons{position:initial}div.dt-button-info{position:fixed;top:50%;left:50%;width:400px;margin-top:-100px;margin-left:-200px;background-color:white;border:2px solid #111;box-shadow:3px 4px 10px 1px rgba(0, 0, 0, 0.3);border-radius:3px;text-align:center;z-index:21}div.dt-button-info h2{padding:.5em;margin:0;font-weight:normal;border-bottom:1px solid #ddd;background-color:#f3f3f3}div.dt-button-info>div{padding:1em}div.dtb-popover-close{position:absolute;top:10px;right:10px;width:22px;height:22px;border:1px solid #eaeaea;background-color:#f9f9f9;text-align:center;border-radius:3px;cursor:pointer;z-index:12}button.dtb-hide-drop{display:none !important}div.dt-button-collection-title{text-align:center;padding:.3em 0 .5em;margin-left:.5em;margin-right:.5em;font-size:.9em}div.dt-button-collection-title:empty{display:none}span.dt-button-spacer{display:inline-block;margin:.5em;white-space:nowrap}span.dt-button-spacer.bar{border-left:1px solid rgba(0, 0, 0, 0.3);vertical-align:middle;padding-left:.5em}span.dt-button-spacer.bar:empty{height:1em;width:1px;padding-left:0}div.dt-button-collection span.dt-button-spacer{width:100%;font-size:.9em;text-align:center;margin:.5em 0}div.dt-button-collection span.dt-button-spacer:empty{height:0;width:100%}div.dt-button-collection span.dt-button-spacer.bar{border-left:none;border-bottom:1px solid rgba(0, 0, 0, 0.3);padding-left:0}div.dt-button-collection{position:absolute;top:0;left:0;min-width:200px;margin-top:3px !important;margin-bottom:3px !important;z-index:2002;background:white;border:1px solid rgba(34, 36, 38, 0.15);font-size:1em;padding:.5rem}div.dt-button-collection.fixed{position:fixed;display:block;top:50%;left:50%;margin-left:-75px;border-radius:5px;background-color:white}div.dt-button-collection.fixed.two-column{margin-left:-200px}div.dt-button-collection.fixed.three-column{margin-left:-225px}div.dt-button-collection.fixed.four-column{margin-left:-300px}div.dt-button-collection.fixed.columns{margin-left:-409px}@media screen and (max-width: 1024px){div.dt-button-collection.fixed.columns{margin-left:-308px}}@media screen and (max-width: 640px){div.dt-button-collection.fixed.columns{margin-left:-203px}}@media screen and (max-width: 460px){div.dt-button-collection.fixed.columns{margin-left:-100px}}div.dt-button-collection.fixed>:last-child{max-height:100vh;overflow:auto}div.dt-button-collection.two-column>:last-child,div.dt-button-collection.three-column>:last-child,div.dt-button-collection.four-column>:last-child{display:block !important;-webkit-column-gap:8px;-moz-column-gap:8px;-ms-column-gap:8px;-o-column-gap:8px;column-gap:8px}div.dt-button-collection.two-column>:last-child>*,div.dt-button-collection.three-column>:last-child>*,div.dt-button-collection.four-column>:last-child>*{-webkit-column-break-inside:avoid;break-inside:avoid}div.dt-button-collection.two-column{width:400px}div.dt-button-collection.two-column>:last-child{padding-bottom:1px;column-count:2}div.dt-button-collection.three-column{width:450px}div.dt-button-collection.three-column>:last-child{padding-bottom:1px;column-count:3}div.dt-button-collection.four-column{width:600px}div.dt-button-collection.four-column>:last-child{padding-bottom:1px;column-count:4}div.dt-button-collection .dt-button{border-radius:0}div.dt-button-collection.columns{width:auto}div.dt-button-collection.columns>:last-child{display:flex;flex-wrap:wrap;justify-content:flex-start;align-items:center;gap:6px;width:818px;padding-bottom:1px}div.dt-button-collection.columns>:last-child .dt-button{min-width:200px;flex:0 1;margin:0}div.dt-button-collection.columns.dtb-b3>:last-child,div.dt-button-collection.columns.dtb-b2>:last-child,div.dt-button-collection.columns.dtb-b1>:last-child{justify-content:space-between}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:1 1 32%}div.dt-button-collection.columns.dtb-b2 .dt-button{flex:1 1 48%}div.dt-button-collection.columns.dtb-b1 .dt-button{flex:1 1 100%}@media screen and (max-width: 1024px){div.dt-button-collection.columns>:last-child{width:612px}}@media screen and (max-width: 640px){div.dt-button-collection.columns>:last-child{width:406px}div.dt-button-collection.columns.dtb-b3 .dt-button{flex:0 1 32%}}@media screen and (max-width: 460px){div.dt-button-collection.columns>:last-child{width:200px}}div.dt-button-collection div.dt-button-collection-title{font-size:1rem}div.dt-button-collection:not(.columns) .ui.vertical.buttons{width:100%;border:none}div.dt-button-collection.columns .ui.vertical.buttons{flex-direction:row;border:none}div.dt-button-collection button.dt-button{border:1px solid rgba(34, 36, 38, 0.15) !important}div.dt-button-collection div.dt-btn-split-wrapper{display:flex}div.dt-button-collection div.dt-btn-split-wrapper button{flex-grow:1 !important;flex-basis:auto !important;width:auto !important;border-top-right-radius:0px !important}div.dt-button-collection div.dt-btn-split-wrapper button.dt-btn-split-drop{flex-grow:0 !important;flex-basis:auto !important;border-bottom-left-radius:0px !important;border-bottom-right-radius:0px !important;border-top-right-radius:4px !important}button.buttons-collection.ui.button span:after{display:inline-block;content:"▾";padding-left:.5em}div.dt-button-background{position:fixed;top:0;left:0;width:100%;height:100%;z-index:2001}@media screen and (max-width: 767px){div.dt-buttons{float:none;width:100%;text-align:center;margin-bottom:.5em}div.dt-buttons a.btn{float:none}}div.dt-buttons button.button.processing,div.dt-buttons div.button.processing,div.dt-buttons a.button.processing{position:relative;color:rgba(0, 0, 0, 0.2)}div.dt-buttons button.button.processing:after,div.dt-buttons div.button.processing:after,div.dt-buttons a.button.processing:after{position:absolute;top:50%;left:50%;width:16px;height:16px;margin:-8px 0 0 -8px;box-sizing:border-box;display:block;content:" ";border:2px solid #282828;border-radius:50%;border-left-color:transparent;border-right-color:transparent;animation:dtb-spinner 1500ms infinite linear;-o-animation:dtb-spinner 1500ms infinite linear;-ms-animation:dtb-spinner 1500ms infinite linear;-webkit-animation:dtb-spinner 1500ms infinite linear;-moz-animation:dtb-spinner 1500ms infinite linear}div.dt-buttons.ui.buttons{flex-wrap:wrap}div.dt-buttons.ui.basic.buttons .ui.button{border-bottom:1px solid rgba(34, 36, 38, 0.15);margin-bottom:-1px}div.dt-buttons.ui.basic.buttons .ui.button:hover{background:transparent !important}span.dt-down-arrow{display:none}span.dt-button-spacer{cursor:inherit}span.dt-button-spacer.bar{padding-left:1.5em}span.dt-button-spacer.bar:empty{height:inherit}div.dt-button-collection span.dt-button-spacer{border-top:1px solid rgba(34, 36, 38, 0.15)}div.dt-button-collection span.dt-button-spacer.bar{border-bottom:none;padding-left:1.5em}div.dt-buttons.ui.basic.buttons .button.dt-button-spacer{background:rgba(34, 36, 38, 0.05) !important;box-shadow:none;cursor:initial}div.dt-buttons.ui.basic.buttons .button.dt-button-spacer:hover{background-color:rgba(34, 36, 38, 0.05) !important}div.dt-btn-split-wrapper:active:not(.disabled) button.button,div.dt-btn-split-wrapper.active:not(.disabled) button.button{background-color:#f8f8f8 !important}div.dt-btn-split-wrapper:active:not(.disabled) button.dt-btn-split-drop,div.dt-btn-split-wrapper.active:not(.disabled) button.dt-btn-split-drop{box-shadow:none;background-color:transparent !important}div.dt-btn-split-wrapper:active:not(.disabled) button.button:hover,div.dt-btn-split-wrapper.active:not(.disabled) button.button:hover{background-color:transparent !important} diff --git a/app/static/DataTables/Buttons-2.2.2/css/common.scss b/app/static/DataTables/Buttons-2.2.2/css/common.scss new file mode 100644 index 0000000000..3535d9d23a --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/common.scss @@ -0,0 +1,101 @@ + +div.dataTables_wrapper { + position: relative; +} + +div.dt-buttons { + position: initial; +} + +div.dt-button-info { + position: fixed; + top: 50%; + left: 50%; + width: 400px; + margin-top: -100px; + margin-left: -200px; + background-color: white; + border: 2px solid #111; + box-shadow: 3px 4px 10px 1px rgba(0, 0, 0, 0.3); + border-radius: 3px; + text-align: center; + z-index: 21; + + h2 { + padding: 0.5em; + margin: 0; + font-weight: normal; + border-bottom: 1px solid #ddd; + background-color: #f3f3f3; + } + + > div { + padding: 1em; + } +} + +div.dtb-popover-close { + position: absolute; + top: 10px; + right: 10px; + width: 22px; + height: 22px; + border: 1px solid #eaeaea; + background-color: #f9f9f9; + text-align: center; + border-radius: 3px; + cursor: pointer; + z-index: 12; +} + +button.dtb-hide-drop { + display: none !important; +} + +div.dt-button-collection-title { + text-align: center; + padding: 0.3em 0 0.5em; + margin-left: 0.5em; + margin-right: 0.5em; + font-size: 0.9em; +} + +div.dt-button-collection-title:empty { + display: none; +} + +span.dt-button-spacer { + display: inline-block; + margin: 0.5em; + white-space: nowrap; + + &.bar { + border-left: 1px solid rgba(0, 0, 0, 0.3); + vertical-align: middle; + padding-left: 0.5em; + + &:empty { + height: 1em; + width: 1px; + padding-left: 0; + } + } +} + +div.dt-button-collection span.dt-button-spacer { + width: 100%; + font-size: 0.9em; + text-align: center; + margin: 0.5em 0; + + &:empty { + height: 0; + width: 100%; + } + + &.bar { + border-left: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.3); + padding-left: 0; + } +} diff --git a/app/static/DataTables/Buttons-2.2.2/css/mixins.scss b/app/static/DataTables/Buttons-2.2.2/css/mixins.scss new file mode 100644 index 0000000000..b50a0a4c0d --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/css/mixins.scss @@ -0,0 +1,237 @@ + +@function dtb-tint( $color, $percent ) { + @return mix(white, $color, $percent); +} + +@function dtb-shade( $color, $percent ) { + @return mix(black, $color, $percent); +} + +@mixin dtb-two-stop-gradient($fromColor, $toColor) { + background-color: $toColor; /* Fallback */ + background: -webkit-linear-gradient(top, $fromColor 0%, $toColor 100%); /* Chrome 10+, Saf5.1+, iOS 5+ */ + background: -moz-linear-gradient(top, $fromColor 0%, $toColor 100%); /* FF3.6 */ + background: -ms-linear-gradient(top, $fromColor 0%, $toColor 100%); /* IE10 */ + background: -o-linear-gradient(top, $fromColor 0%, $toColor 100%); /* Opera 11.10+ */ + background: linear-gradient(to bottom, $fromColor 0%, $toColor 100%); + filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#{nth( $fromColor, 1 )}', EndColorStr='#{nth( $toColor, 1 )}'); +} + +@mixin dtb-radial-gradient ($fromColor, $toColor ) { + background: $toColor; /* Fallback */ + background: -ms-radial-gradient(center, ellipse farthest-corner, $fromColor 0%, $toColor 100%); /* IE10 Consumer Preview */ + background: -moz-radial-gradient(center, ellipse farthest-corner, $fromColor 0%, $toColor 100%); /* Firefox */ + background: -o-radial-gradient(center, ellipse farthest-corner, $fromColor 0%, $toColor 100%); /* Opera */ + background: -webkit-gradient(radial, center center, 0, center center, 497, color-stop(0, $fromColor), color-stop(1, $toColor)); /* Webkit (Safari/Chrome 10) */ + background: -webkit-radial-gradient(center, ellipse farthest-corner, $fromColor 0%, $toColor 100%); /* Webkit (Chrome 11+) */ + background: radial-gradient(ellipse farthest-corner at center, $fromColor 0%, $toColor 100%); /* W3C Markup, IE10 Release Preview */ +} + + +@mixin dtb-fixed-collection { + // Fixed positioning feature + &.fixed { + position: fixed; + display: block; + top: 50%; + left: 50%; + margin-left: -75px; + border-radius: 5px; + background-color: white; + + &.two-column { + margin-left: -200px; + } + + &.three-column { + margin-left: -225px; + } + + &.four-column { + margin-left: -300px; + } + + &.columns { + // Four column + margin-left: -409px; + + @media screen and (max-width: 1024px) { + margin-left: -308px; + } + + @media screen and (max-width: 640px) { + margin-left: -203px; + } + + @media screen and (max-width: 460px) { + margin-left: -100px; + } + } + + > :last-child { + max-height: 100vh; + overflow: auto; + } + } + + &.two-column > :last-child, + &.three-column > :last-child, + &.four-column > :last-child { + > * { + -webkit-column-break-inside: avoid; + break-inside: avoid; + } + + // Multi-column layout feature + display: block !important; + -webkit-column-gap: 8px; + -moz-column-gap: 8px; + -ms-column-gap: 8px; + -o-column-gap: 8px; + column-gap: 8px; + } + + &.two-column { + width: 400px; + + > :last-child { + padding-bottom: 1px; + column-count: 2; + } + } + + &.three-column { + width: 450px; + + > :last-child { + padding-bottom: 1px; + column-count: 3; + } + } + + &.four-column { + width: 600px; + + > :last-child { + padding-bottom: 1px; + column-count: 4; + } + } + + // Chrome fix - 531528 + .dt-button { + border-radius: 0; + } + + &.columns { + // Four column layout + width: auto; + + > :last-child { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + gap: 6px; + + width: 818px; + padding-bottom: 1px; + + .dt-button { + min-width: 200px; + flex: 0 1; + margin: 0; + } + } + + &.dtb-b3, + &.dtb-b2, + &.dtb-b1 { + > :last-child { + justify-content: space-between; + } + } + + &.dtb-b3 .dt-button { + flex: 1 1 32%; + } + &.dtb-b2 .dt-button { + flex: 1 1 48%; + } + &.dtb-b1 .dt-button { + flex: 1 1 100%; + } + + @media screen and (max-width: 1024px) { + // Three column layout + > :last-child { + width: 612px; + } + } + + @media screen and (max-width: 640px) { + // Two column layout + > :last-child { + width: 406px; + } + + &.dtb-b3 .dt-button { + flex: 0 1 32%; + } + } + + @media screen and (max-width: 460px) { + // Single column + > :last-child { + width: 200px; + } + } + } +} + + +@mixin dtb-processing { + color: rgba(0, 0, 0, 0.2); + + &:after { + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + box-sizing: border-box; + + display: block; + content: ' '; + border: 2px solid rgb(40,40,40); + border-radius: 50%; + border-left-color: transparent; + border-right-color: transparent; + animation: dtb-spinner 1500ms infinite linear; + -o-animation: dtb-spinner 1500ms infinite linear; + -ms-animation: dtb-spinner 1500ms infinite linear; + -webkit-animation: dtb-spinner 1500ms infinite linear; + -moz-animation: dtb-spinner 1500ms infinite linear; + } +} + +@keyframes dtb-spinner { + 100%{ transform: rotate(360deg); } +} + +@-o-keyframes dtb-spinner { + 100%{ -o-transform: rotate(360deg); transform: rotate(360deg); } +} + +@-ms-keyframes dtb-spinner { + 100%{ -ms-transform: rotate(360deg); transform: rotate(360deg); } +} + +@-webkit-keyframes dtb-spinner { + 100%{ -webkit-transform: rotate(360deg); transform: rotate(360deg); } +} + +@-moz-keyframes dtb-spinner { + 100%{ -moz-transform: rotate(360deg); transform: rotate(360deg); } +} diff --git a/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap.js b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap.js new file mode 100644 index 0000000000..0644cd67fb --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap.js @@ -0,0 +1,89 @@ +/*! Bootstrap integration for DataTables' Buttons + * ©2016 SpryMedia Ltd - datatables.net/license + */ + +(function( factory ){ + if ( typeof define === 'function' && define.amd ) { + // AMD + define( ['jquery', 'datatables.net-bs', 'datatables.net-buttons'], function ( $ ) { + return factory( $, window, document ); + } ); + } + else if ( typeof exports === 'object' ) { + // CommonJS + module.exports = function (root, $) { + if ( ! root ) { + root = window; + } + + if ( ! $ || ! $.fn.dataTable ) { + $ = require('datatables.net-bs')(root, $).$; + } + + if ( ! $.fn.dataTable.Buttons ) { + require('datatables.net-buttons')(root, $); + } + + return factory( $, root, root.document ); + }; + } + else { + // Browser + factory( jQuery, window, document ); + } +}(function( $, window, document, undefined ) { +'use strict'; +var DataTable = $.fn.dataTable; + + +$.extend( true, DataTable.Buttons.defaults, { + dom: { + container: { + className: 'dt-buttons btn-group' + }, + button: { + className: 'btn btn-default' + }, + collection: { + tag: 'ul', + className: 'dropdown-menu', + closeButton: false, + button: { + tag: 'li', + className: 'dt-button', + active: 'active', + disabled: 'disabled' + }, + buttonLiner: { + tag: 'a', + className: '' + } + }, + splitWrapper: { + tag: 'div', + className: 'dt-btn-split-wrapper btn-group', + closeButton: false, + }, + splitDropdown: { + tag: 'button', + text: '▼', + className: 'btn btn-default dt-btn-split-drop dropdown-toggle', + closeButton: false, + align: 'split-left', + splitAlignClass: 'dt-button-split-left' + }, + splitDropdownButton: { + tag: 'button', + className: 'dt-btn-split-drop-button btn btn-default', + closeButton: false + } + } +} ); + +DataTable.ext.buttons.collection.text = function ( dt ) { + return dt.i18n('buttons.collection', 'Collection '); +}; + + +return DataTable.Buttons; +})); diff --git a/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap.min.js b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap.min.js new file mode 100644 index 0000000000..88c40f917e --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap.min.js @@ -0,0 +1,7 @@ +/*! + Bootstrap integration for DataTables' Buttons + ©2016 SpryMedia Ltd - datatables.net/license +*/ +(function(c){"function"===typeof define&&define.amd?define(["jquery","datatables.net-bs","datatables.net-buttons"],function(a){return c(a,window,document)}):"object"===typeof exports?module.exports=function(a,b){a||(a=window);b&&b.fn.dataTable||(b=require("datatables.net-bs")(a,b).$);b.fn.dataTable.Buttons||require("datatables.net-buttons")(a,b);return c(b,a,a.document)}:c(jQuery,window,document)})(function(c,a,b,e){a=c.fn.dataTable;c.extend(!0,a.Buttons.defaults,{dom:{container:{className:"dt-buttons btn-group"}, +button:{className:"btn btn-default"},collection:{tag:"ul",className:"dropdown-menu",closeButton:!1,button:{tag:"li",className:"dt-button",active:"active",disabled:"disabled"},buttonLiner:{tag:"a",className:""}},splitWrapper:{tag:"div",className:"dt-btn-split-wrapper btn-group",closeButton:!1},splitDropdown:{tag:"button",text:"▼",className:"btn btn-default dt-btn-split-drop dropdown-toggle",closeButton:!1,align:"split-left",splitAlignClass:"dt-button-split-left"},splitDropdownButton:{tag:"button", +className:"dt-btn-split-drop-button btn btn-default",closeButton:!1}}});a.ext.buttons.collection.text=function(d){return d.i18n("buttons.collection",'Collection ')};return a.Buttons}); diff --git a/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap4.js b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap4.js new file mode 100644 index 0000000000..599b836b75 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap4.js @@ -0,0 +1,87 @@ +/*! Bootstrap integration for DataTables' Buttons + * ©2016 SpryMedia Ltd - datatables.net/license + */ + +(function( factory ){ + if ( typeof define === 'function' && define.amd ) { + // AMD + define( ['jquery', 'datatables.net-bs4', 'datatables.net-buttons'], function ( $ ) { + return factory( $, window, document ); + } ); + } + else if ( typeof exports === 'object' ) { + // CommonJS + module.exports = function (root, $) { + if ( ! root ) { + root = window; + } + + if ( ! $ || ! $.fn.dataTable ) { + $ = require('datatables.net-bs4')(root, $).$; + } + + if ( ! $.fn.dataTable.Buttons ) { + require('datatables.net-buttons')(root, $); + } + + return factory( $, root, root.document ); + }; + } + else { + // Browser + factory( jQuery, window, document ); + } +}(function( $, window, document, undefined ) { +'use strict'; +var DataTable = $.fn.dataTable; + +$.extend( true, DataTable.Buttons.defaults, { + dom: { + container: { + className: 'dt-buttons btn-group flex-wrap' + }, + button: { + className: 'btn btn-secondary' + }, + collection: { + tag: 'div', + className: 'dropdown-menu', + closeButton: false, + button: { + tag: 'a', + className: 'dt-button dropdown-item', + active: 'active', + disabled: 'disabled' + } + }, + splitWrapper: { + tag: 'div', + className: 'dt-btn-split-wrapper btn-group', + closeButton: false, + }, + splitDropdown: { + tag: 'button', + text: '', + className: 'btn btn-secondary dt-btn-split-drop dropdown-toggle dropdown-toggle-split', + closeButton: false, + align: 'split-left', + splitAlignClass: 'dt-button-split-left' + }, + splitDropdownButton: { + tag: 'button', + className: 'dt-btn-split-drop-button btn btn-secondary', + closeButton: false + } + }, + buttonCreated: function ( config, button ) { + return config.buttons ? + $('
').append(button) : + button; + } +} ); + +DataTable.ext.buttons.collection.className += ' dropdown-toggle'; +DataTable.ext.buttons.collection.rightAlignClassName = 'dropdown-menu-right'; + +return DataTable.Buttons; +})); diff --git a/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap4.min.js b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap4.min.js new file mode 100644 index 0000000000..3d3d41c83d --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap4.min.js @@ -0,0 +1,7 @@ +/*! + Bootstrap integration for DataTables' Buttons + ©2016 SpryMedia Ltd - datatables.net/license +*/ +(function(c){"function"===typeof define&&define.amd?define(["jquery","datatables.net-bs4","datatables.net-buttons"],function(a){return c(a,window,document)}):"object"===typeof exports?module.exports=function(a,b){a||(a=window);b&&b.fn.dataTable||(b=require("datatables.net-bs4")(a,b).$);b.fn.dataTable.Buttons||require("datatables.net-buttons")(a,b);return c(b,a,a.document)}:c(jQuery,window,document)})(function(c,a,b,f){a=c.fn.dataTable;c.extend(!0,a.Buttons.defaults,{dom:{container:{className:"dt-buttons btn-group flex-wrap"}, +button:{className:"btn btn-secondary"},collection:{tag:"div",className:"dropdown-menu",closeButton:!1,button:{tag:"a",className:"dt-button dropdown-item",active:"active",disabled:"disabled"}},splitWrapper:{tag:"div",className:"dt-btn-split-wrapper btn-group",closeButton:!1},splitDropdown:{tag:"button",text:"",className:"btn btn-secondary dt-btn-split-drop dropdown-toggle dropdown-toggle-split",closeButton:!1,align:"split-left",splitAlignClass:"dt-button-split-left"},splitDropdownButton:{tag:"button", +className:"dt-btn-split-drop-button btn btn-secondary",closeButton:!1}},buttonCreated:function(e,d){return e.buttons?c('
').append(d):d}});a.ext.buttons.collection.className+=" dropdown-toggle";a.ext.buttons.collection.rightAlignClassName="dropdown-menu-right";return a.Buttons}); diff --git a/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap5.js b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap5.js new file mode 100644 index 0000000000..69c5da6382 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap5.js @@ -0,0 +1,87 @@ +/*! Bootstrap integration for DataTables' Buttons + * ©2016 SpryMedia Ltd - datatables.net/license + */ + +(function( factory ){ + if ( typeof define === 'function' && define.amd ) { + // AMD + define( ['jquery', 'datatables.net-bs5', 'datatables.net-buttons'], function ( $ ) { + return factory( $, window, document ); + } ); + } + else if ( typeof exports === 'object' ) { + // CommonJS + module.exports = function (root, $) { + if ( ! root ) { + root = window; + } + + if ( ! $ || ! $.fn.dataTable ) { + $ = require('datatables.net-bs5')(root, $).$; + } + + if ( ! $.fn.dataTable.Buttons ) { + require('datatables.net-buttons')(root, $); + } + + return factory( $, root, root.document ); + }; + } + else { + // Browser + factory( jQuery, window, document ); + } +}(function( $, window, document, undefined ) { +'use strict'; +var DataTable = $.fn.dataTable; + +$.extend( true, DataTable.Buttons.defaults, { + dom: { + container: { + className: 'dt-buttons btn-group flex-wrap' + }, + button: { + className: 'btn btn-secondary' + }, + collection: { + tag: 'div', + className: 'dropdown-menu', + closeButton: false, + button: { + tag: 'a', + className: 'dt-button dropdown-item', + active: 'active', + disabled: 'disabled' + } + }, + splitWrapper: { + tag: 'div', + className: 'dt-btn-split-wrapper btn-group', + closeButton: false, + }, + splitDropdown: { + tag: 'button', + text: '', + className: 'btn btn-secondary dt-btn-split-drop dropdown-toggle dropdown-toggle-split', + closeButton: false, + align: 'split-left', + splitAlignClass: 'dt-button-split-left' + }, + splitDropdownButton: { + tag: 'button', + className: 'dt-btn-split-drop-button btn btn-secondary', + closeButton: false + } + }, + buttonCreated: function ( config, button ) { + return config.buttons ? + $('
').append(button) : + button; + } +} ); + +DataTable.ext.buttons.collection.className += ' dropdown-toggle'; +DataTable.ext.buttons.collection.rightAlignClassName = 'dropdown-menu-right'; + +return DataTable.Buttons; +})); diff --git a/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap5.min.js b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap5.min.js new file mode 100644 index 0000000000..b05ff7a680 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/js/buttons.bootstrap5.min.js @@ -0,0 +1,7 @@ +/*! + Bootstrap integration for DataTables' Buttons + ©2016 SpryMedia Ltd - datatables.net/license +*/ +(function(c){"function"===typeof define&&define.amd?define(["jquery","datatables.net-bs5","datatables.net-buttons"],function(a){return c(a,window,document)}):"object"===typeof exports?module.exports=function(a,b){a||(a=window);b&&b.fn.dataTable||(b=require("datatables.net-bs5")(a,b).$);b.fn.dataTable.Buttons||require("datatables.net-buttons")(a,b);return c(b,a,a.document)}:c(jQuery,window,document)})(function(c,a,b,f){a=c.fn.dataTable;c.extend(!0,a.Buttons.defaults,{dom:{container:{className:"dt-buttons btn-group flex-wrap"}, +button:{className:"btn btn-secondary"},collection:{tag:"div",className:"dropdown-menu",closeButton:!1,button:{tag:"a",className:"dt-button dropdown-item",active:"active",disabled:"disabled"}},splitWrapper:{tag:"div",className:"dt-btn-split-wrapper btn-group",closeButton:!1},splitDropdown:{tag:"button",text:"",className:"btn btn-secondary dt-btn-split-drop dropdown-toggle dropdown-toggle-split",closeButton:!1,align:"split-left",splitAlignClass:"dt-button-split-left"},splitDropdownButton:{tag:"button", +className:"dt-btn-split-drop-button btn btn-secondary",closeButton:!1}},buttonCreated:function(e,d){return e.buttons?c('
').append(d):d}});a.ext.buttons.collection.className+=" dropdown-toggle";a.ext.buttons.collection.rightAlignClassName="dropdown-menu-right";return a.Buttons}); diff --git a/app/static/DataTables/Buttons-2.2.2/js/buttons.bulma.js b/app/static/DataTables/Buttons-2.2.2/js/buttons.bulma.js new file mode 100644 index 0000000000..08d743a118 --- /dev/null +++ b/app/static/DataTables/Buttons-2.2.2/js/buttons.bulma.js @@ -0,0 +1,98 @@ +/*! Bulma integration for DataTables' Buttons + * ©2021 SpryMedia Ltd - datatables.net/license + */ + +(function( factory ){ + if ( typeof define === 'function' && define.amd ) { + // AMD + define( ['jquery', 'datatables.net-bm', 'datatables.net-buttons'], function ( $ ) { + return factory( $, window, document ); + } ); + } + else if ( typeof exports === 'object' ) { + // CommonJS + module.exports = function (root, $) { + if ( ! root ) { + root = window; + } + + if ( ! $ || ! $.fn.dataTable ) { + $ = require('datatables.net-bm')(root, $).$; + } + + if ( ! $.fn.dataTable.Buttons ) { + require('datatables.net-buttons')(root, $); + } + + return factory( $, root, root.document ); + }; + } + else { + // Browser + factory( jQuery, window, document ); + } +}(function( $, window, document, undefined ) { +'use strict'; +var DataTable = $.fn.dataTable; + +$.extend( true, DataTable.Buttons.defaults, { + dom: { + container: { + className: 'dt-buttons field is-grouped' + }, + button: { + className: 'button is-light', + active: 'is-active', + disabled: 'is-disabled' + }, + collection: { + tag: 'div', + closeButton: false, + className: 'dropdown-content', + button: { + tag: 'a', + className: 'dt-button dropdown-item', + active: 'is-active', + disabled: 'is-disabled' + } + }, + splitWrapper: { + tag: 'div', + className: 'dt-btn-split-wrapper dropdown-trigger buttons has-addons', + closeButton: false + }, + splitDropdownButton: { + tag: 'button', + className: 'dt-btn-split-drop-button button is-light', + closeButton: false + }, + splitDropdown: { + tag: 'button', + text: '▼', + className: 'button is-light', + closeButton: false, + align: 'split-left', + splitAlignClass: 'dt-button-split-left' + } + }, + buttonCreated: function ( config, button ) { + // For collections + if (config.buttons) { + // Wrap the dropdown content in a menu element + config._collection = $('