diff --git a/README.md b/README.md index f571216abc..65c369b3ea 100644 --- a/README.md +++ b/README.md @@ -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 db5a15b089..a1862aaa70 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,6 +10,7 @@ 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 @@ -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) @@ -336,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") diff --git a/app/auth/models.py b/app/auth/models.py index 329bc38683..cfab21a9ca 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -173,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" @@ -270,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): @@ -281,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" @@ -292,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" @@ -402,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 cba3732b07..689ea9ed97 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -7,11 +7,14 @@ """Génération bulletin BUT """ +import collections import datetime +import numpy as np from flask import url_for, g 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 @@ -61,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, @@ -85,6 +85,11 @@ class BulletinBUT: "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 self.prefs["bul_show_ue_rangs"]: rangs, effectif = res.ue_rangs[ue.id] @@ -171,10 +176,15 @@ 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() - poids = { - ue.acronyme: self.res.modimpls_evals_poids[e.moduleimpl_id][ue.id][e.id] - for ue in self.res.ues - } + 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, @@ -212,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 ] @@ -272,29 +283,39 @@ class BulletinBUT: "numero": formsemestre.semestre_id, "inscription": "", # inutilisé mais nécessaire pour le js de Seb. "groupes": [], # XXX TODO - "absences": { + } + 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( @@ -302,11 +323,15 @@ class BulletinBUT: ), "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, }, @@ -349,7 +374,7 @@ class BulletinBUT: d["filigranne"] = sco_bulletins_pdf.get_filigranne( etud_etat, self.prefs, - decision_sem=d["semestre"].get("decision_sem"), + decision_sem=d["semestre"].get("decision"), ) if etud_etat == scu.DEMISSION: d["demission"] = "(Démission)" diff --git a/app/but/bulletin_but_pdf.py b/app/but/bulletin_but_pdf.py index ea3291c8dc..36d11d1e4a 100644 --- a/app/but/bulletin_but_pdf.py +++ b/app/but/bulletin_but_pdf.py @@ -6,14 +6,13 @@ """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_pdf import blue, cm, mm - -from app.scodoc import gen_tables -from app.scodoc.sco_utils import fmt_note - 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): @@ -22,8 +21,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): self.infos est le dict issu de BulletinBUT.bulletin_etud_complet() """ - list_in_menu = False # spécialisation du BulletinGeneratorStandard, ne pas présenter à l'utilisateur - scale_table_in_page = False + # 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 @@ -77,16 +79,29 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): "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": "Note/20", + "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), @@ -98,81 +113,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): blue, ), ], - } + }, ] col_keys = ["titre", "coef", "moyenne"] # noms des colonnes à afficher for ue_acronym, ue in self.infos["ues"].items(): - # 1er ligne titre UE - moy_ue = ue.get("moyenne") - t = { - "titre": f"{ue_acronym} - {ue['titre']}", - "moyenne": 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) - # 2eme ligne titre UE (bonus/malus/ects) - ects_txt = f'ECTS: {ue["ECTS"]["acquis"]:.3g} / {ue["ECTS"]["total"]:.3g}' - t = { - "titre": f"""Bonus: {ue['bonus']} - Malus: { - ue["malus"]}""", - "coef": ects_txt, - "_coef_pdf": Paragraph(f"""{ects_txt}"""), - "_coef_colspan": 2, - # "_css_row_class": "", - # "_pdf_row_markup": [""], - "_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 - ( - "BOX", - (0, 0), - (0, 0), - self.PDF_LINEWIDTH, - (0.7, 0.7, 0.7), # gris clair - ), - ], - } - rows.append(t) - # Liste chaque ressource puis 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": 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) + self.ue_rows(rows, ue_acronym, ue, title_bg) # Global pdf style commands: pdf_style = [ ("VALIGN", (0, 0), (-1, -1), "TOP"), @@ -180,6 +125,92 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): ] 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 @@ -203,9 +234,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): - pdf_style : commandes table Platypus - largeurs de colonnes pour PDF """ - poids_fontsize = "8" # UE à utiliser pour les poids (# colonne/UE) - ue_acros = list(self.infos["ues"].keys()) # ['RT1.1', 'RT2.1', 'RT3.1'] + 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: @@ -243,7 +276,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): } for ue_acro in ue_acros: t[ue_acro] = Paragraph( - f"{ue_acro}" + f"{ue_acro}" ) rows = [t] for mod_code, mod in self.infos[mod_type].items(): @@ -267,43 +300,52 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): } rows.append(t) # Evaluations: - for e in mod["evaluations"]: - t = { - "titre": f"{e['description']}", - "moyenne": 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, "")}""" - ) - 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) + 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/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 b7c506a080..5a549132bd 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -13,7 +13,6 @@ Les classes de Bonus fournissent deux méthodes: """ import datetime -import math import numpy as np import pandas as pd @@ -89,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 @@ -200,10 +199,11 @@ class BonusSportAdditif(BonusSport): """ seuil_moy_gen = 10.0 # seuls les bonus au dessus du seuil sont pris en compte - seuil_comptage = ( - None # les points au dessus du seuil sont comptés (defaut: seuil_moy_gen) - ) + # 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 @@ -220,19 +220,16 @@ class BonusSportAdditif(BonusSport): ) bonus_moy_arr = np.sum( np.where( - sem_modimpl_moys_inscrits > self.seuil_moy_gen, + (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 20.0 + np.clip(bonus_moy_arr, self.bonus_min, bonus_max, out=bonus_moy_arr) self.bonus_additif(bonus_moy_arr) @@ -510,14 +507,14 @@ class BonusCachan1(BonusSportAdditif):
  • BUT : la meilleure note d'option, si elle est supérieure à 10, bonifie - les moyennes d'UE à raison de bonus = (option - 10)*5%.
  • + les moyennes d'UE à raison de bonus = (option - 10) * 3%. """ name = "bonus_cachan1" displayed_name = "IUT de Cachan 1" seuil_moy_gen = 10.0 # tous les points sont comptés - proportion_point = 0.05 + proportion_point = 0.03 classic_use_bonus_ues = True def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): @@ -679,17 +676,40 @@ class BonusLaRochelle(BonusSportAdditif): 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): @@ -754,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 @@ -769,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, @@ -809,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. @@ -831,9 +909,9 @@ class BonusStBrieuc(BonusSportAdditif): -
    (XXX vérifier si S6 est éligible au bonus, et le S2 du DUT XXX)
    """ + # Utilisé aussi par St Malo, voir plus bas name = "bonus_iut_stbrieuc" displayed_name = "IUT de Saint-Brieuc" proportion_point = 1 / 20.0 @@ -845,6 +923,19 @@ class BonusStBrieuc(BonusSportAdditif): 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 optionnels (sport, culture), règle IUT Saint-Denis @@ -862,6 +953,19 @@ 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. 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_sem.py b/app/comp/moy_sem.py index db42616c8e..61b5fd15cc 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -30,7 +30,8 @@ import numpy as np import pandas as pd -from flask import flash +from flask import flash, g, Markup, url_for +from app.models.formations import Formation def compute_sem_moys_apc_using_coefs( @@ -51,7 +52,7 @@ def compute_sem_moys_apc_using_coefs( def compute_sem_moys_apc_using_ects( - etud_moy_ue_df: pd.DataFrame, ects: list, formation_id=None + 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. @@ -59,13 +60,29 @@ def compute_sem_moys_apc_using_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: - moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / sum(ects) + 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: - flash("""Calcul moyenne générale impossible: ECTS des UE manquants !""") + 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 @@ -76,8 +93,12 @@ 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: Series { 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_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 diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 7bd79463fb..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, formsemestre +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): @@ -112,6 +118,9 @@ class ResultatsSemestreBUT(NotesTableCompat): 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() @@ -139,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 91614935dc..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_mat, 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 @@ -35,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() @@ -47,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() @@ -142,6 +146,22 @@ class ResultatsSemestreClassic(NotesTableCompat): 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) @@ -172,6 +192,19 @@ 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( @@ -188,36 +221,29 @@ class ResultatsSemestreClassic(NotesTableCompat): 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 @@ -247,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 8fa106f504..6e37a8c2dd 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -4,16 +4,17 @@ # See LICENSE ############################################################################## +"""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 flask import g, flash, url_for +from flask import g, url_for -from app import log -from app.comp.aux_stats import StatsMoyenne -from app.comp import moy_sem +from app.auth.models import User from app.comp.res_cache import ResultatsCache from app.comp import res_sem from app.comp.moy_mod import ModuleImplResults @@ -21,10 +22,12 @@ 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, DEF +from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM +from app.scodoc import sco_evaluation_db from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc import sco_groups +from app.scodoc import sco_utils as scu # Il faut bien distinguer # - ce qui est caché de façon persistente (via redis): @@ -36,7 +39,13 @@ 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", @@ -51,20 +60,32 @@ 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" # voir ce qui est chargé / calculé ici et dans les sous-classes @@ -90,6 +111,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, @@ -130,7 +159,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". @@ -148,15 +177,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: @@ -175,7 +213,6 @@ class ResultatsSemestre(ResultatsCache): 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"]) @@ -210,6 +247,13 @@ class ResultatsSemestre(ResultatsCache): 0.0, min(self.etud_moy_gen[etudid], 20.0) ) + 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: @@ -284,11 +328,11 @@ class ResultatsSemestre(ResultatsCache): if coef_ue is None: orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"]) raise ScoValueError( - f"""L'UE capitalisée {ue_capitalized.acronyme} + 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. """ ) @@ -313,6 +357,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. @@ -337,436 +386,489 @@ 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, include_evaluations=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", - "etud_moy_gen_ranks_int", - "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_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 ]` + 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 """ - 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]: - flash( - """Calcul moyenne générale impossible: ECTS des UE manquants !""", - 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 == 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] + 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 = {} + dict_nom_res = {} # cache uid : nomcomplet - 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 - """ - 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_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: 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): - 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) - 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 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} + # --- Codes (seront cachés, mais exportés en excel) + idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx) + idx = add_cell( + row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx + ) + # --- 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) - 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 + 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 + idx_malus = idx # place pour colonne malus à gauche des modules + idx += 1 + 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 = "" - 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]]) + 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, + ) + if modimpl.module.module_type == scu.ModuleType.MALUS: + titles[f"_{col_id}_col_order"] = idx_malus + titles_bot[f"_{col_id}_target"] = url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=modimpl.id, + ) + nom_resp = dict_nom_res.get(modimpl.responsable_id) + if nom_resp is None: + user = User.query.get(modimpl.responsable_id) + nom_resp = user.get_nomcomplet() if user else "" + dict_nom_res[modimpl.responsable_id] = nom_resp + titles_bot[ + f"_{col_id}_target_attrs" + ] = f""" title="{modimpl.module.titre} ({nom_resp})" """ + 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) - @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 + 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) + if include_evaluations: + self._recap_add_evaluations(rows, titles, bottom_infos) + + # 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] = row.get(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"] = ( + row.get(f"_title", "") or 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, row_apo = ( + {"_tr_class": "bottom_info", "_title": "Min."}, + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info", "_title": "Code Apogée"}, + ) + # --- ECTS + for ue in ues: + colid = f"moy_ue_{ue.id}" + row_ects[colid] = ue.ects + row_ects[f"_{colid}_class"] = "col_ue" + # style cases vides pour borders verticales + row_coef[colid] = "" + row_coef[f"_{colid}_class"] = "col_ue" + # row_apo[colid] = ue.code_apogee or "" + 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, APO + + 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: + colid = f"moy_ue_{ue.id}" + row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min()) + row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max()) + row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean()) + row_min[f"_{colid}_class"] = "col_ue" + row_max[f"_{colid}_class"] = "col_ue" + row_moy[f"_{colid}_class"] = "col_ue" + row_apo[colid] = ue.code_apogee or "" + + for modimpl in self.formsemestre.modimpls_sorted: + if modimpl.id in modimpl_ids: + colid = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" + if self.is_apc: + coef = self.modimpl_coefs_df[modimpl.id][ue.id] + else: + coef = modimpl.module.coefficient or 0 + row_coef[colid] = fmt_note(coef) + notes = self.modimpl_notes(modimpl.id, ue.id) + row_min[colid] = fmt_note(np.nanmin(notes)) + row_max[colid] = fmt_note(np.nanmax(notes)) + moy = np.nanmean(notes) + row_moy[colid] = fmt_note(moy) + if np.isnan(moy): + # aucune note dans ce module + row_moy[f"_{colid}_class"] = "col_empty" + row_apo[colid] = modimpl.module.code_apogee or "" + + return { # { key : row } avec key = min, max, moy, coef + "min": row_min, + "max": row_max, + "moy": row_moy, + "coef": row_coef, + "ects": row_ects, + "apo": row_apo, } + + 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 + + def _recap_add_evaluations( + self, rows: list[dict], titles: dict, bottom_infos: dict + ): + """Ajoute les colonnes avec les notes aux évaluations + rows est une liste de dict avec une clé "etudid" + Les colonnes ont la classe css "evaluation" + """ + # nouvelle ligne pour description évaluations: + bottom_infos["descr_evaluation"] = { + "_tr_class": "bottom_info", + "_title": "Description évaluation", + } + first = True + for modimpl in self.formsemestre.modimpls_sorted: + evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl) + eval_index = len(evals) - 1 + inscrits = {i.etudid for i in modimpl.inscriptions} + klass = "evaluation first" if first else "evaluation" + first = False + for i, e in enumerate(evals): + cid = f"eval_{e.id}" + titles[ + cid + ] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}' + titles[f"_{cid}_class"] = klass + titles[f"_{cid}_col_order"] = 9000 + i # à droite + eval_index -= 1 + notes_db = sco_evaluation_db.do_evaluation_get_all_notes( + e.evaluation_id + ) + for row in rows: + etudid = row["etudid"] + if etudid in inscrits: + if etudid in notes_db: + val = notes_db[etudid]["value"] + else: + # Note manquante mais prise en compte immédiate: affiche ATT + val = scu.NOTES_ATTENTE + row[cid] = scu.fmt_note(val) + row[f"_{cid}_class"] = klass + bottom_infos["coef"][cid] = e.coefficient + bottom_infos["min"][cid] = "0" + bottom_infos["max"][cid] = scu.fmt_note(e.note_max) + bottom_infos["descr_evaluation"][cid] = e.description or "" 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/decorators.py b/app/decorators.py index 220ece566f..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 diff --git a/app/email.py b/app/email.py index 1fc7632b66..ebd3ae0d15 100644 --- a/app/email.py +++ b/app/email.py @@ -15,6 +15,7 @@ from app.scodoc import sco_preferences def send_async_email(app, msg): + "Send an email, async" with app.app_context(): mail.send(msg) 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..9a5e119899 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") @@ -73,5 +70,16 @@ class CodesDecisionsForm(FlaskForm): DEM = _build_code_field("DEM") NAR = _build_code_field("NAR") RAT = _build_code_field("RAT") + NOTES_FMT = StringField( + label="Format notes exportées", + description="""Format des notes. Par défaut %3.2f (deux chiffres après la virgule)""", + validators=[ + validators.Length( + max=SHORT_STR_LEN, + message=f"Le format ne doit pas dépasser {SHORT_STR_LEN} caractères", + ), + validators.DataRequired("format requis"), + ], + ) submit = SubmitField("Valider") cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/forms/main/config_logos.py b/app/forms/main/config_logos.py index c89983271e..db69ae35bf 100644 --- a/app/forms/main/config_logos.py +++ b/app/forms/main/config_logos.py @@ -47,8 +47,6 @@ from app.scodoc.sco_config_actions import ( LogoInsert, ) - -from app.scodoc import sco_utils as scu from app.scodoc.sco_logos import find_logo 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/config.py b/app/models/config.py index 8a56d3879e..53ac96e9bc 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -36,6 +36,7 @@ CODES_SCODOC_TO_APO = { DEM: "NAR", NAR: "NAR", RAT: "ATT", + "NOTES_FMT": "%3.2f", } @@ -69,6 +70,7 @@ class ScoDocSiteConfig(db.Model): "INSTITUTION_ADDRESS": str, "INSTITUTION_CITY": str, "DEFAULT_PDF_FOOTER_TEMPLATE": str, + "enable_entreprises": bool, } def __init__(self, name, value): @@ -156,32 +158,6 @@ class ScoDocSiteConfig(db.Model): class_list.sort(key=lambda x: x[1].replace(" du ", " de ")) return [("", "")] + class_list - @classmethod - def get_bonus_sport_func(cls): - """Fonction bonus_sport ScoDoc 7 XXX - Transitoire pour les tests durant la transition #sco92 - """ - """returns bonus func with specified name. - If name not specified, return the configured function. - None if no bonus function configured. - Raises ScoValueError if func_name not found in module bonus_sport. - """ - from app.scodoc import bonus_sport - - c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() - if c is None: - return None - func_name = c.value - if func_name == "": # pas de bonus défini - return None - try: - return getattr(bonus_sport, func_name) - except AttributeError: - raise ScoValueError( - f"""Fonction de calcul de l'UE bonus inexistante: "{func_name}". - (contacter votre administrateur local).""" - ) - @classmethod def get_code_apo(cls, code: str) -> str: """La représentation d'un code pour les exports Apogée. @@ -207,3 +183,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/etudiants.py b/app/models/etudiants.py index 060debc3b0..962e7baaf8 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -16,6 +16,7 @@ from app import models from app.scodoc import notesdb as ndb from app.scodoc.sco_bac import Baccalaureat +from app.scodoc.sco_exceptions import ScoValueError import app.scodoc.sco_utils as scu @@ -123,10 +124,18 @@ 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" @@ -145,28 +154,37 @@ class Identite(db.Model): 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, - "nomprenom": self.nomprenom, + "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): @@ -280,10 +298,10 @@ class Identite(db.Model): log( f"*** situation inconsistante pour {self} (inscrit mais pas d'event)" ) - date_ins = "???" # ??? + situation += " (inscription non enregistrée)" # ??? else: date_ins = events[0].event_date - situation += date_ins.strftime(" le %d/%m/%Y") + 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: @@ -337,7 +355,10 @@ def make_etud_args( """ args = None if etudid: - args = {"etudid": etudid} + try: + args = {"etudid": int(etudid)} + except ValueError as exc: + raise ScoValueError("Adresse invalide") from exc elif code_nip: args = {"code_nip": code_nip} elif use_request: # use form from current request (Flask global) @@ -347,12 +368,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é") @@ -388,6 +412,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 d92c375fba..4ec90052b7 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -161,7 +161,6 @@ 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"] = self.mois_debut() d["mois_fin"] = self.mois_fin() @@ -174,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: @@ -302,6 +300,10 @@ 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) @@ -312,7 +314,7 @@ class FormSemestre(db.Model): def mois_fin(self) -> str: "Jul 2022" - return f"{MONTH_NAMES_ABBREV[self.date_fin.month - 1]} {self.date_debut.year}" + 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 @@ -399,7 +401,7 @@ class FormSemestre(db.Model): @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 @@ -578,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/moduleimpls.py b/app/models/moduleimpls.py index 1935036e9d..292ec8ffde 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -134,7 +134,9 @@ class ModuleImplInscription(db.Model): def etud_modimpls_in_ue( cls, formsemestre_id: int, etudid: int, ue_id: int ) -> flask_sqlalchemy.BaseQuery: - """moduleimpls de l'UE auxquels l'étudiant est inscrit""" + """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, diff --git a/app/models/modules.py b/app/models/modules.py index 5a5f476187..67ff3de0df 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -33,7 +33,7 @@ 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) + # 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") @@ -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 239d7bb43f..6da4ef5d60 100644 --- a/app/models/notes.py +++ b/app/models/notes.py @@ -72,23 +72,20 @@ class NotesNotesLog(db.Model): 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 évaluation avec coef. non nul). + (ne compte que les notes en attente dans des évaluations avec coef. non nul). """ - # XXX ancienne méthode de notes_table à ré-écrire - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - """SELECT n.* + cursor = db.session.execute( + """SELECT COUNT(*) FROM notes_notes n, notes_evaluation e, notes_moduleimpl m, notes_moduleimpl_inscription i - WHERE n.etudid = %(etudid)s - and n.value = %(code_attente)s + 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)s + and m.formsemestre_id = :formsemestre_id and e.coefficient != 0 and m.id = i.moduleimpl_id - and i.etudid=%(etudid)s + and i.etudid = :etudid """, { "formsemestre_id": formsemestre_id, @@ -96,4 +93,4 @@ def etud_has_notes_attente(etudid, formsemestre_id): "code_attente": scu.NOTES_ATTENTE, }, ) - return len(cursor.fetchall()) > 0 + return cursor.fetchone()[0] > 0 diff --git a/app/models/ues.py b/app/models/ues.py index 2bed88a383..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 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_jurype.py b/app/pe/pe_jurype.py index 2720ad435d..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 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 5af1a57549..ba129f7b6e 100644 --- a/app/pe/pe_view.py +++ b/app/pe/pe_view.py @@ -51,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() @@ -120,7 +127,6 @@ def pe_view_sem_recap( # template fourni via le formulaire Web if footer_tmpl_file: footer_latex = footer_tmpl_file.read().decode("utf-8") - footer_latex = footer_latex 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 1cf4ea924c..c8acefaf53 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -293,6 +293,13 @@ class TF(object): % (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 0a4b65ca71..7f5531c6f9 100644 --- a/app/scodoc/gen_tables.py +++ b/app/scodoc/gen_tables.py @@ -761,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 8ce3d2cdd7..2cf0be400e 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -105,7 +105,6 @@ _HTML_BEGIN = """ - ') + # H.append( + # '' + # ) # JS additionels for js in javascripts: H.append("""\n""" % js) diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index b1ac97b853..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 @@ -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 diff --git a/app/scodoc/sco_abs.py b/app/scodoc/sco_abs.py index 071cbe8ef2..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) @@ -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() diff --git a/app/scodoc/sco_abs_notification.py b/app/scodoc/sco_abs_notification.py index 466d13df18..5f9670f504 100644 --- a/app/scodoc/sco_abs_notification.py +++ b/app/scodoc/sco_abs_notification.py @@ -254,7 +254,7 @@ def abs_notification_message( 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"] diff --git a/app/scodoc/sco_abs_views.py b/app/scodoc/sco_abs_views.py index 686d589d7f..a4e1d3a720 100644 --- a/app/scodoc/sco_abs_views.py +++ b/app/scodoc/sco_abs_views.py @@ -34,7 +34,7 @@ from flask import url_for, g, request, abort 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 Identite, FormSemestre import app.scodoc.sco_utils as scu from app.scodoc import notesdb as ndb @@ -724,6 +724,7 @@ def CalAbs(etudid, sco_year=None): anneescolaire = int(scu.AnneeScolaire(sco_year)) datedebut = str(anneescolaire) + "-08-01" datefin = str(anneescolaire + 1) + "-07-31" + annee_courante = scu.AnneeScolaire() nbabs = sco_abs.count_abs(etudid=etudid, debut=datedebut, fin=datefin) nbabsjust = sco_abs.count_abs_just(etudid=etudid, debut=datedebut, fin=datefin) events = [] @@ -776,7 +777,7 @@ def CalAbs(etudid, sco_year=None): """Année scolaire %s-%s""" % (anneescolaire, anneescolaire + 1), """  Changer année: ' - % formsemestre_id, - '', ] - if modejury: - H.append( - '' % modejury - ) - H.append( - '") + if len(formsemestre.inscriptions) > 0: + H += [ + '
    ' % request.base_url, + '' + % formsemestre_id, + '', + ] - H.append( - """(cliquer sur un nom pour afficher son bulletin ou ici avoir le classeur papier)""" - % (scu.ScoURL(), formsemestre_id) - ) - if not parcours.UE_IS_MODULE: + if modejury: + H.append( + '' + % modejury + ) H.append( - """' ) - if hidemodules: - H.append("checked") - H.append(""" >cacher les modules""") - H.append( - """cacher bac""") + for (format, label) in ( + ("html", "Tableau"), + ("evals", "Avec toutes les évaluations"), + ("xml", "Bulletins XML (obsolète)"), + ("json", "Bulletins JSON"), + ): + if format == tabformat: + selected = " selected" + else: + selected = "" + H.append('' % (format, selected, label)) + H.append("") + + H.append( + f""" (cliquer sur un nom pour afficher son bulletin ou + ici avoir le classeur papier) + """ + ) + data = do_formsemestre_recapcomplet( formsemestre_id, format=tabformat, @@ -175,30 +169,31 @@ def formsemestre_recapcomplet( H.append(data) if not isFile: - H.append("
    ") - H.append( - """

    Voir les décisions du jury

    """ - % formsemestre_id - ) - if sco_permissions_check.can_validate_sem(formsemestre_id): - H.append("

    ") - if modejury: - H.append( - """Calcul automatique des décisions du jury

    """ - % (formsemestre_id,) - ) - else: - H.append( - """Saisie des décisions du jury""" - % formsemestre_id - ) - H.append("

    ") - if sco_preferences.get_preference("use_ue_coefs", formsemestre_id): + if len(formsemestre.inscriptions) > 0: + H.append("") H.append( - """ -

    utilise les coefficients d'UE pour calculer la moyenne générale.

    - """ + """

    Voir les décisions du jury

    """ + % formsemestre_id ) + if sco_permissions_check.can_validate_sem(formsemestre_id): + H.append("

    ") + if modejury: + H.append( + """Calcul automatique des décisions du jury

    """ + % (formsemestre_id,) + ) + else: + H.append( + """Saisie des décisions du jury""" + % formsemestre_id + ) + H.append("

    ") + if sco_preferences.get_preference("use_ue_coefs", formsemestre_id): + H.append( + """ +

    utilise les coefficients d'UE pour calculer la moyenne générale.

    + """ + ) H.append(html_sco_header.sco_footer()) # HTML or binary data ? if len(H) > 1: @@ -223,20 +218,28 @@ def do_formsemestre_recapcomplet( force_publishing=True, ): """Calcule et renvoie le tableau récapitulatif.""" - data, filename, format = make_formsemestre_recapcomplet( - formsemestre_id=formsemestre_id, - format=format, - hidemodules=hidemodules, - hidebac=hidebac, - xml_nodate=xml_nodate, - modejury=modejury, - sortcol=sortcol, - xml_with_decisions=xml_with_decisions, - disable_etudlink=disable_etudlink, - rank_partition_id=rank_partition_id, - force_publishing=force_publishing, - ) - if format == "xml" or format == "html": + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if (format == "html" or format == "evals") and not modejury: + res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + data, filename = gen_formsemestre_recapcomplet_html( + formsemestre, res, include_evaluations=(format == "evals") + ) + else: + data, filename, format = make_formsemestre_recapcomplet( + formsemestre_id=formsemestre_id, + format=format, + hidemodules=hidemodules, + hidebac=hidebac, + xml_nodate=xml_nodate, + modejury=modejury, + sortcol=sortcol, + xml_with_decisions=xml_with_decisions, + disable_etudlink=disable_etudlink, + rank_partition_id=rank_partition_id, + force_publishing=force_publishing, + ) + # --- + if format == "xml" or format == "html" or format == "evals": return data elif format == "csv": return scu.send_file(data, filename=filename, mime=scu.CSV_MIMETYPE) @@ -248,12 +251,12 @@ def do_formsemestre_recapcomplet( js, filename=filename, suffix=scu.JSON_SUFFIX, mime=scu.JSON_MIMETYPE ) else: - raise ValueError("unknown format %s" % format) + raise ValueError(f"unknown format {format}") def make_formsemestre_recapcomplet( formsemestre_id=None, - format="html", # html, xml, xls, xlsall, json + format="html", # html, evals, xml, json hidemodules=False, # ne pas montrer les modules (ignoré en XML) hidebac=False, # pas de colonne Bac (ignoré en XML) xml_nodate=False, # format XML sans dates (sert pour debug cache: comparaison de XML) @@ -404,7 +407,7 @@ def make_formsemestre_recapcomplet( gr_name = "Déf." is_dem[etudid] = False else: - group = sco_groups.get_etud_main_group(etudid, sem) + group = sco_groups.get_etud_main_group(etudid, formsemestre_id) gr_name = group["group_name"] or "" is_dem[etudid] = False if rank_partition_id: @@ -593,66 +596,29 @@ def make_formsemestre_recapcomplet( - +
    """ ] 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: @@ -663,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] @@ -674,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, @@ -682,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 @@ -760,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: @@ -828,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) @@ -994,3 +1004,99 @@ 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, include_evaluations=False +): + """Construit table recap pour le BUT + Cache le résultat pour le semestre. + Return: data, filename + """ + filename = scu.sanitize_filename( + f"""recap-{formsemestre.titre_num()}-{time.strftime("%Y-%m-%d")}""" + ) + if include_evaluations: + table_html = sco_cache.TableRecapWithEvalsCache.get(formsemestre.id) + else: + table_html = sco_cache.TableRecapCache.get(formsemestre.id) + if table_html is None: + table_html = _gen_formsemestre_recapcomplet_html( + formsemestre, res, include_evaluations, filename + ) + if include_evaluations: + sco_cache.TableRecapWithEvalsCache.set(formsemestre.id, table_html) + else: + sco_cache.TableRecapCache.set(formsemestre.id, table_html) + + return table_html, filename + + +def _gen_formsemestre_recapcomplet_html( + formsemestre: FormSemestre, + res: NotesTableCompat, + include_evaluations=False, + filename: str = "", +) -> str: + """Génère le html""" + rows, footer_rows, titles, column_ids = res.get_table_recap( + convert_values=True, include_evaluations=include_evaluations + ) + 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) diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py index fff026471b..214532a553 100644 --- a/app/scodoc/sco_report.py +++ b/app/scodoc/sco_report.py @@ -40,7 +40,7 @@ from flask import url_for, g, request import pydot 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 import app.scodoc.sco_utils as scu diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index 8dee1a7e2e..fbef924585 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -37,7 +37,7 @@ from flask import g, url_for, request from flask_login import current_user 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 import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType @@ -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, @@ -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( @@ -1019,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"]) @@ -1270,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 a91e8788cd..37b39fa839 100644 --- a/app/scodoc/sco_semset.py +++ b/app/scodoc/sco_semset.py @@ -43,7 +43,7 @@ import flask from flask import g 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 import html_sco_header from app.scodoc import sco_cache @@ -89,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"] diff --git a/app/scodoc/sco_tag_module.py b/app/scodoc/sco_tag_module.py index 45ed6e07f0..1b41cd876e 100644 --- a/app/scodoc/sco_tag_module.py +++ b/app/scodoc/sco_tag_module.py @@ -38,7 +38,7 @@ import http from flask import g, url_for 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 import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb 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_users.py b/app/scodoc/sco_users.py index 19fdf9793a..552d36d6ad 100644 --- a/app/scodoc/sco_users.py +++ b/app/scodoc/sco_users.py @@ -227,7 +227,7 @@ def _user_list(user_name): @cache.memoize(timeout=50) # seconds -def user_info(user_name_or_id=None, user=None): +def user_info(user_name_or_id=None, user: User = None): """Dict avec infos sur l'utilisateur (qui peut ne pas etre dans notre base). Si user_name est specifie (string ou id), interroge la BD. Sinon, user doit etre une instance de User. diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index ba7fd504ae..0a4da781a7 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -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", @@ -178,7 +191,7 @@ def fmt_note(val, note_max=None, keep_numeric=False): if isinstance(val, float) or isinstance(val, int): if np.isnan(val): return "~" - if note_max != None and note_max > 0: + if (note_max is not None) and note_max > 0: val = val * 20.0 / note_max if keep_numeric: return val @@ -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" @@ -918,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)""" @@ -954,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 @@ -1038,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 = $('