diff --git a/app/__init__.py b/app/__init__.py index a976da7f..a1862aaa 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,10 +10,11 @@ import traceback import logging from logging.handlers import SMTPHandler, WatchedFileHandler +from threading import Thread from flask import current_app, g, request from flask import Flask -from flask import abort, has_request_context, jsonify +from flask import abort, flash, has_request_context, jsonify from flask import render_template from flask.logging import default_handler from flask_sqlalchemy import SQLAlchemy @@ -27,6 +28,7 @@ import sqlalchemy from app.scodoc.sco_exceptions import ( AccessDenied, + ScoBugCatcher, ScoGenError, ScoValueError, APIInvalidParams, @@ -43,11 +45,13 @@ mail = Mail() bootstrap = Bootstrap() moment = Moment() -cache = Cache( # XXX TODO: configuration file +CACHE_TYPE = os.environ.get("CACHE_TYPE") +cache = Cache( config={ # see https://flask-caching.readthedocs.io/en/latest/index.html#configuring-flask-caching - "CACHE_TYPE": "RedisCache", - "CACHE_DEFAULT_TIMEOUT": 0, # by default, never expire + "CACHE_TYPE": CACHE_TYPE or "RedisCache", + # by default, never expire: + "CACHE_DEFAULT_TIMEOUT": os.environ.get("CACHE_DEFAULT_TIMEOUT") or 0, } ) @@ -60,7 +64,7 @@ def handle_access_denied(exc): return render_template("error_access_denied.html", exc=exc), 403 -def internal_server_error(e): +def internal_server_error(exc): """Bugs scodoc, erreurs 500""" # note that we set the 500 status explicitly return ( @@ -68,11 +72,35 @@ def internal_server_error(e): "error_500.html", SCOVERSION=sco_version.SCOVERSION, date=datetime.datetime.now().isoformat(), + exc=exc, + request_url=request.url, ), 500, ) +def handle_sco_bug(exc): + """Un bug, en général rare, sur lequel les dev cherchent des + informations pour le corriger. + """ + Thread( + target=_async_dump, args=(current_app._get_current_object(), request.url) + ).start() + + return internal_server_error(exc) + + +def _async_dump(app, request_url: str): + from app.scodoc.sco_dump_db import sco_dump_and_send_db + + with app.app_context(): + ndb.open_db_connection() + try: + sco_dump_and_send_db("ScoBugCatcher", request_url=request_url) + except ScoValueError: + pass + + def handle_invalid_usage(error): response = jsonify(error.to_dict()) response.status_code = error.status_code @@ -187,11 +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(404, 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) @@ -337,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") @@ -460,15 +489,12 @@ from app.models import Departement from app.scodoc import notesdb as ndb, sco_preferences from app.scodoc import sco_cache -# admin_role = Role.query.filter_by(name="SuperAdmin").first() -# if admin_role: -# admin = ( -# User.query.join(UserRole) -# .filter((UserRole.user_id == User.id) & (UserRole.role_id == admin_role.id)) -# .first() -# ) -# else: -# click.echo( -# "Warning: user database not initialized !\n (use: flask user-db-init)" -# ) -# admin = None + +def scodoc_flash_status_messages(): + """Should be called on each page: flash messages indicating specific ScoDoc status""" + email_test_mode_address = sco_preferences.get_preference("email_test_mode_address") + if email_test_mode_address: + flash( + f"Mode test: mails redirigés vers {email_test_mode_address}", + category="warning", + ) diff --git a/app/auth/models.py b/app/auth/models.py index 329bc386..cfab21a9 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 df340151..24daa8ca 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/bulletin_but.py b/app/but/bulletin_but.py index 87ec62a2..23fd8c58 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -7,16 +7,19 @@ """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, formsemestre +from app.models import FormSemestre, Identite +from app.models.ues import UniteEns from app.scodoc import sco_bulletins, sco_utils as scu from app.scodoc import sco_bulletins_json from app.scodoc import sco_bulletins_pdf from app.scodoc import sco_preferences -from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_codes_parcours import UE_SPORT, DEF from app.scodoc.sco_utils import fmt_note @@ -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, @@ -80,11 +80,16 @@ class BulletinBUT: "bonus": fmt_note(res.bonus_ues[ue.id][etud.id]) if res.bonus_ues is not None and ue.id in res.bonus_ues else fmt_note(0.0), - "malus": res.malus[ue.id][etud.id], + "malus": fmt_note(res.malus[ue.id][etud.id]), "capitalise": None, # "AAAA-MM-JJ" TODO #sco92 "ressources": self.etud_ue_mod_results(etud, ue, res.ressources), "saes": self.etud_ue_mod_results(etud, ue, res.saes), } + if self.prefs["bul_show_ects"]: + d["ECTS"] = { + "acquis": decision_ue.get("ects", 0.0), + "total": ue.ects or 0.0, # float même si non renseigné + } if ue.type != UE_SPORT: if self.prefs["bul_show_ue_rangs"]: rangs, effectif = res.ue_rangs[ue.id] @@ -111,9 +116,10 @@ class BulletinBUT: d["modules"] = self.etud_mods_results(etud, modimpls_spo) return d - def etud_mods_results(self, etud, modimpls) -> dict: + def etud_mods_results(self, etud, modimpls, version="long") -> dict: """dict synthèse résultats des modules indiqués, - avec évaluations de chacun.""" + avec évaluations de chacun (sauf si version == "short") + """ res = self.res d = {} # etud_idx = self.etud_index[etud.id] @@ -154,12 +160,14 @@ class BulletinBUT: "evaluations": [ self.etud_eval_results(etud, e) for e in modimpl.evaluations - if e.visibulletin + if (e.visibulletin or version == "long") and ( modimpl_results.evaluations_etat[e.id].is_complete or self.prefs["bul_show_all_evals"] ) - ], + ] + if version != "short" + else [], } return d @@ -168,14 +176,23 @@ class BulletinBUT: # eval_notes est une pd.Series avec toutes les notes des étudiants inscrits eval_notes = self.res.modimpls_results[e.moduleimpl_id].evals_notes[e.id] notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna() + modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id] + try: + poids = { + ue.acronyme: modimpls_evals_poids[ue.id][e.id] + for ue in self.res.ues + if ue.type != UE_SPORT + } + except KeyError: + poids = collections.defaultdict(lambda: 0.0) d = { "id": e.id, "description": e.description, "date": e.jour.isoformat() if e.jour else None, "heure_debut": e.heure_debut.strftime("%H:%M") if e.heure_debut else None, "heure_fin": e.heure_fin.strftime("%H:%M") if e.heure_debut else None, - "coef": e.coefficient, - "poids": {p.ue.acronyme: p.poids for p in e.ue_poids}, + "coef": fmt_note(e.coefficient), + "poids": poids, "note": { "value": fmt_note( eval_notes[etud.id], @@ -205,7 +222,8 @@ class BulletinBUT: details = [ f"{fmt_note(bonus_vect[ue.id])} sur {ue.acronyme}" for ue in res.ues - if res.modimpls_in_ue(ue.id, etudid) + if ue.type != UE_SPORT + and res.modimpls_in_ue(ue.id, etudid) and ue.id in res.bonus_ues and bonus_vect[ue.id] > 0.0 ] @@ -217,14 +235,22 @@ class BulletinBUT: return f"Bonus de {fmt_note(bonus_vect.iloc[0])}" def bulletin_etud( - self, etud: Identite, formsemestre: FormSemestre, force_publishing=False + self, + etud: Identite, + formsemestre: FormSemestre, + force_publishing=False, + version="long", ) -> dict: """Le bulletin de l'étudiant dans ce semestre: dict pour la version JSON / HTML. + - version: + "long", "selectedevals": toutes les infos (notes des évaluations) + "short" : ne descend pas plus bas que les modules. + - Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai (bulletins non publiés). """ res = self.res - etat_inscription = etud.etat_inscription(formsemestre.id) + etat_inscription = etud.inscription_etat(formsemestre.id) nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT] published = (not formsemestre.bul_hide_xml) or force_publishing d = { @@ -257,39 +283,55 @@ 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(etud, res.ressources), - "saes": self.etud_mods_results(etud, res.saes), + "ressources": self.etud_mods_results( + etud, res.ressources, version=version + ), + "saes": self.etud_mods_results(etud, res.saes, version=version), "ues": { - ue.acronyme: self.etud_ue_results(etud, ue) + ue.acronyme: self.etud_ue_results( + etud, ue, decision_ue=decisions_ues.get(ue.id, {}) + ) for ue in res.ues - if self.res.modimpls_in_ue( - ue.id, etud.id - ) # si l'UE comporte des modules auxquels on est inscrit + # si l'UE comporte des modules auxquels on est inscrit: + if ( + (ue.type == UE_SPORT) + or self.res.modimpls_in_ue(ue.id, etud.id) + ) }, "semestre": semestre_infos, }, @@ -317,20 +359,45 @@ class BulletinBUT: return d - def bulletin_etud_complet(self, etud: Identite) -> dict: - """Bulletin dict complet avec toutes les infos pour les bulletins pdf""" - d = self.bulletin_etud(etud, self.res.formsemestre, force_publishing=True) + def bulletin_etud_complet(self, etud: Identite, version="long") -> dict: + """Bulletin dict complet avec toutes les infos pour les bulletins BUT pdf + Résultat compatible avec celui de sco_bulletins.formsemestre_bulletinetud_dict + """ + d = self.bulletin_etud( + etud, self.res.formsemestre, version=version, force_publishing=True + ) d["etudid"] = etud.id d["etud"] = d["etudiant"] d["etud"]["nomprenom"] = etud.nomprenom d.update(self.res.sem) + etud_etat = self.res.get_etud_etat(etud.id) d["filigranne"] = sco_bulletins_pdf.get_filigranne( - self.res.get_etud_etat(etud.id), + etud_etat, self.prefs, decision_sem=d["semestre"].get("decision_sem"), ) + if etud_etat == scu.DEMISSION: + d["demission"] = "(Démission)" + elif etud_etat == DEF: + d["demission"] = "(Défaillant)" + else: + d["demission"] = "" + # --- Absences d["nbabs"], d["nbabsjust"] = self.res.formsemestre.get_abs_count(etud.id) + + # --- Decision Jury + infos, dpv = sco_bulletins.etud_descr_situation_semestre( + etud.id, + self.res.formsemestre.id, + format="html", + show_date_inscr=self.prefs["bul_show_date_inscr"], + show_decisions=self.prefs["bul_show_decision"], + show_uevalid=self.prefs["bul_show_uevalid"], + show_mention=self.prefs["bul_show_mention"], + ) + + d.update(infos) # --- Rangs d[ "rang_nt" @@ -341,5 +408,6 @@ class BulletinBUT: d.update( sco_bulletins.get_appreciations_list(self.res.formsemestre.id, etud.id) ) - # XXX TODO A COMPLETER ? + d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"])) + return d diff --git a/app/but/bulletin_but_pdf.py b/app/but/bulletin_but_pdf.py index 5003e216..29b9d674 100644 --- a/app/but/bulletin_but_pdf.py +++ b/app/but/bulletin_but_pdf.py @@ -6,22 +6,13 @@ """Génération bulletin BUT au format PDF standard """ - -import datetime -from app.scodoc.sco_pdf import blue, cm, mm - -from flask import url_for, g -from app.models.formsemestre import FormSemestre - -from app.scodoc import gen_tables -from app.scodoc import sco_utils as scu -from app.scodoc import sco_bulletins_json -from app.scodoc import sco_preferences -from app.scodoc.sco_codes_parcours import UE_SPORT -from app.scodoc.sco_utils import fmt_note -from app.comp.res_but import ResultatsSemestreBUT +from reportlab.lib.colors import blue +from reportlab.lib.units import cm, mm +from reportlab.platypus import Paragraph, Spacer from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard +from app.scodoc import gen_tables +from app.scodoc.sco_codes_parcours import UE_SPORT class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): @@ -31,6 +22,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): """ list_in_menu = False # spécialisation du BulletinGeneratorStandard, ne pas présenter à l'utilisateur + 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 @@ -38,31 +32,38 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): - en HTML: une chaine - en PDF: une liste d'objets PLATYPUS (eg instance de Table). """ - formsemestre_id = self.infos["formsemestre_id"] - ( - synth_col_keys, - synth_P, - synth_pdf_style, - synth_col_widths, - ) = self.but_table_synthese() - # - table_synthese = gen_tables.GenTable( - rows=synth_P, - columns_ids=synth_col_keys, - pdf_table_style=synth_pdf_style, - pdf_col_widths=[synth_col_widths[k] for k in synth_col_keys], - preferences=self.preferences, - html_class="notes_bulletin", - html_class_ignore_default=True, - html_with_td_classes=True, - ) - # Ici on ajoutera table des ressources, tables des UE - # TODO + tables_infos = [ + # ---- TABLE SYNTHESE UES + self.but_table_synthese_ues(), + ] + if self.version != "short": + tables_infos += [ + # ---- TABLE RESSOURCES + self.but_table_ressources(), + # ---- TABLE SAE + self.but_table_saes(), + ] + objects = [] + for i, (col_keys, rows, pdf_style, col_widths) in enumerate(tables_infos): + table = gen_tables.GenTable( + rows=rows, + columns_ids=col_keys, + pdf_table_style=pdf_style, + pdf_col_widths=[col_widths[k] for k in col_keys], + preferences=self.preferences, + html_class="notes_bulletin", + html_class_ignore_default=True, + html_with_td_classes=True, + ) + table_objects = table.gen(format=format) + objects += table_objects + # objects += [KeepInFrame(0, 0, table_objects, mode="shrink")] + if i != 2: + objects.append(Spacer(1, 6 * mm)) - # XXX à modifier pour générer plusieurs tables: - return table_synthese.gen(format=format) + return objects - def but_table_synthese(self): + def but_table_synthese_ues(self, title_bg=(182, 235, 255)): """La table de synthèse; pour chaque UE, liste des ressources et SAÉs avec leurs notes et leurs coefs. Renvoie: colkeys, P, pdf_style, colWidths @@ -76,41 +77,270 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): "moyenne": 2 * cm, "coef": 2 * cm, } - P = [] # elems pour générer table avec gen_table (liste de dicts) - col_keys = ["titre", "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": [], - } - P.append(t) - # 2eme ligne titre UE (bonus/malus/ects) - t = { - "titre": "", - "moyenne": f"""Bonus: {ue['bonus']} - Malus: { - ue["malus"]} - ECTS: {ue["ECTS"]["acquis"]} / {ue["ECTS"]["total"]}""", + title_bg = tuple(x / 255.0 for x in title_bg) + nota_bene = "La moyenne des ressources et SAÉs dans une UE dépend des poids donnés aux évaluations." + # elems pour générer table avec gen_table (liste de dicts) + rows = [ + # Ligne de titres + { + "titre": "Unités d'enseignement", + "moyenne": Paragraph("Note/20"), + "coef": "Coef.", + "_coef_pdf": Paragraph("Coef."), "_css_row_class": "note_bold", "_pdf_row_markup": ["b"], "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + # ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ], + }, + { + "titre": nota_bene, + "_titre_pdf": Paragraph( + f"{nota_bene}" + ), + "_titre_colspan": 3, + "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), ( "LINEBELOW", (0, 0), (-1, 0), self.PDF_LINEWIDTH, + blue, + ), + ], + }, + ] + col_keys = ["titre", "coef", "moyenne"] # noms des colonnes à afficher + for ue_acronym, ue in self.infos["ues"].items(): + self.ue_rows(rows, ue_acronym, ue, title_bg) + # Global pdf style commands: + pdf_style = [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("BOX", (0, 0), (-1, -1), 0.4, blue), # ajoute cadre extérieur bleu: + ] + return col_keys, rows, pdf_style, col_widths + + def ue_rows(self, rows: list, ue_acronym: str, ue: dict, title_bg: tuple): + "Décrit une UE dans la table synthèse: titre, sous-titre et liste modules" + # 1er ligne titre UE + moy_ue = ue.get("moyenne") + t = { + "titre": f"{ue_acronym} - {ue['titre']}", + "moyenne": Paragraph( + f"""{moy_ue.get("value", "-") if moy_ue is not None else "-"}""" + ), + "_css_row_class": "note_bold", + "_pdf_row_markup": ["b"], + "_pdf_style": [ + ( + "LINEABOVE", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + self.PDF_LINECOLOR, + ), + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ], + } + rows.append(t) + if ue["type"] == UE_SPORT: + self.ue_sport_rows(rows, ue, title_bg) + else: + self.ue_std_rows(rows, ue, title_bg) + + def ue_std_rows(self, rows: list, ue: dict, title_bg: tuple): + "Lignes décrivant une UE standard dans la table de synthèse" + # 2eme ligne titre UE (bonus/malus/ects) + if "ECTS" in ue: + ects_txt = f'ECTS: {ue["ECTS"]["acquis"]:.3g} / {ue["ECTS"]["total"]:.3g}' + else: + ects_txt = "" + t = { + "titre": f"""Bonus: {ue['bonus']} - Malus: { + ue["malus"]}""", + "coef": ects_txt, + "_coef_pdf": Paragraph(f"""{ects_txt}"""), + "_coef_colspan": 2, + "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("LINEBELOW", (0, 0), (-1, 0), self.PDF_LINEWIDTH, self.PDF_LINECOLOR), + # cadre autour du bonus/malus, gris clair + ("BOX", (0, 0), (0, 0), self.PDF_LINEWIDTH, (0.7, 0.7, 0.7)), + ], + } + rows.append(t) + + # Liste chaque ressource puis chaque SAE + for mod_type in ("ressources", "saes"): + for mod_code, mod in ue[mod_type].items(): + t = { + "titre": f"{mod_code} {self.infos[mod_type][mod_code]['titre']}", + "moyenne": Paragraph(f'{mod["moyenne"]}'), + "coef": mod["coef"], + "_coef_pdf": Paragraph( + f"{mod['coef']}" + ), + "_pdf_style": [ + ( + "LINEBELOW", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + (0.7, 0.7, 0.7), # gris clair + ) + ], + } + rows.append(t) + + def ue_sport_rows(self, rows: list, ue: dict, title_bg: tuple): + "Lignes décrivant l'UE bonus dans la table de synthèse" + # UE BONUS + for mod_code, mod in ue["modules"].items(): + rows.append( + { + "titre": f"{mod_code} {mod['titre']}", + } + ) + self.evaluations_rows(rows, mod["evaluations"]) + + def but_table_ressources(self): + """La table de synthèse; pour chaque ressources, note et liste d'évaluations + Renvoie: colkeys, P, pdf_style, colWidths + """ + return self.bul_table_modules( + mod_type="ressources", title="Ressources", title_bg=(248, 200, 68) + ) + + def but_table_saes(self): + "table des SAEs" + return self.bul_table_modules( + mod_type="saes", + title="Situations d'apprentissage et d'évaluation", + title_bg=(198, 255, 171), + ) + + def bul_table_modules(self, mod_type=None, title="", title_bg=(248, 200, 68)): + """Table ressources ou SAEs + - colkeys: nom des colonnes de la table (clés) + - P : table (liste de dicts de chaines de caracteres) + - pdf_style : commandes table Platypus + - largeurs de colonnes pour PDF + """ + # UE à utiliser pour les poids (# colonne/UE) + ue_infos = self.infos["ues"] + ue_acros = list( + [k for k in ue_infos if ue_infos[k]["type"] != UE_SPORT] + ) # ['RT1.1', 'RT2.1', 'RT3.1'] + # Colonnes à afficher: + col_keys = ["titre"] + ue_acros + ["coef", "moyenne"] + # Largeurs des colonnes: + col_widths = { + "titre": None, + # "poids": None, + "moyenne": 2 * cm, + "coef": 2 * cm, + } + for ue_acro in ue_acros: + col_widths[ue_acro] = 12 * mm # largeur col. poids + + title_bg = tuple(x / 255.0 for x in title_bg) + # elems pour générer table avec gen_table (liste de dicts) + # Ligne de titres + t = { + "titre": title, + # "_titre_colspan": 1 + len(ue_acros), + "moyenne": "Note/20", + "coef": "Coef.", + "_coef_pdf": Paragraph("Coef."), + "_css_row_class": "note_bold", + "_pdf_row_markup": ["b"], + "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ( + "LINEBELOW", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + blue, + ), + ], + } + for ue_acro in ue_acros: + t[ue_acro] = Paragraph( + f"{ue_acro}" + ) + rows = [t] + for mod_code, mod in self.infos[mod_type].items(): + # 1er ligne titre module + t = { + "titre": f"{mod_code} - {mod['titre']}", + "_titre_colspan": 2 + len(ue_acros), + "_css_row_class": "note_bold", + "_pdf_row_markup": ["b"], + "_pdf_style": [ + ( + "LINEABOVE", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, self.PDF_LINECOLOR, - ) + ), + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), ], } - P.append(t) + rows.append(t) + # Evaluations: + self.evaluations_rows(rows, mod["evaluations"], ue_acros) # Global pdf style commands: pdf_style = [ ("VALIGN", (0, 0), (-1, -1), "TOP"), ("BOX", (0, 0), (-1, -1), 0.4, blue), # ajoute cadre extérieur bleu: ] - return col_keys, P, pdf_style, col_widths + return col_keys, rows, pdf_style, col_widths + + def evaluations_rows(self, rows, evaluations, ue_acros=()): + "lignes des évaluations" + for e in evaluations: + t = { + "titre": f"{e['description']}", + "moyenne": e["note"]["value"], + "_moyenne_pdf": Paragraph( + f"""{e["note"]["value"]}""" + ), + "coef": e["coef"], + "_coef_pdf": Paragraph( + f"{e['coef']}" + ), + "_pdf_style": [ + ( + "LINEBELOW", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + (0.7, 0.7, 0.7), # gris clair + ) + ], + } + col_idx = 1 # 1ere col. poids + for ue_acro in ue_acros: + t[ue_acro] = Paragraph( + f"""{e["poids"].get(ue_acro, "") or ""}""" + ) + t["_pdf_style"].append( + ( + "BOX", + (col_idx, 0), + (col_idx, 0), + self.PDF_LINEWIDTH, + (0.7, 0.7, 0.7), # gris clair + ), + ) + col_idx += 1 + rows.append(t) diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index 73e06c4d..bab7b728 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -72,7 +72,7 @@ def bulletin_but_xml_compat( etud: Identite = Identite.query.get_or_404(etudid) results = bulletin_but.ResultatsSemestreBUT(formsemestre) nb_inscrits = results.get_inscriptions_counts()[scu.INSCRIT] - # etat_inscription = etud.etat_inscription(formsemestre.id) + # etat_inscription = etud.inscription_etat(formsemestre.id) etat_inscription = results.formsemestre.etuds_inscriptions[etudid].etat if (not formsemestre.bul_hide_xml) or force_publishing: published = 1 diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py index d1c3ddca..5846e46f 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 @@ -21,6 +20,7 @@ from flask import g from app.models.formsemestre import FormSemestre from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_codes_parcours import ParcoursDUT, ParcoursDUTMono from app.scodoc.sco_utils import ModuleType @@ -88,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 @@ -199,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 @@ -219,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 0.0 + np.clip(bonus_moy_arr, self.bonus_min, bonus_max, out=bonus_moy_arr) self.bonus_additif(bonus_moy_arr) @@ -455,8 +453,8 @@ class BonusBezier(BonusSportAdditif): class BonusBordeaux1(BonusSportMultiplicatif): - """Calcul bonus modules optionnels (sport, culture), règle IUT Bordeaux 1, sur moyenne générale - et UE. + """Calcul bonus modules optionnels (sport, culture), règle IUT Bordeaux 1, + sur moyenne générale et UEs.

Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Bordeaux 1 (sport, théâtre) non rattachés à une unité d'enseignement. @@ -476,6 +474,29 @@ class BonusBordeaux1(BonusSportMultiplicatif): amplitude = 0.005 +# Exactement le même que Bordeaux: +class BonusBrest(BonusSportMultiplicatif): + """Calcul bonus modules optionnels (sport, culture), règle IUT de Brest, + sur moyenne générale et UEs. +

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

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

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

+ """ + + name = "bonus_iut_brest" + displayed_name = "IUT de Brest" + classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP + seuil_moy_gen = 10.0 + amplitude = 0.005 + + class BonusCachan1(BonusSportAdditif): """Calcul bonus optionnels (sport, culture), règle IUT de Cachan 1. @@ -486,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): @@ -538,6 +559,44 @@ class BonusCachan1(BonusSportAdditif): self.bonus_ues[ue.id] = 0.0 +class BonusCalais(BonusSportAdditif): + """Calcul bonus modules optionnels (sport, culture), règle IUT LCO. + + Les étudiants de l'IUT LCO peuvent suivre des enseignements optionnels non + rattachés à une unité d'enseignement. Les points au-dessus de 10 + sur 20 obtenus dans chacune des matières optionnelles sont cumulés + dans la limite de 10 points. 6% de ces points cumulés s'ajoutent : + + """ + + name = "bonus_calais" + displayed_name = "IUT du Littoral" + bonus_max = 0.6 + seuil_moy_gen = 10.0 # au dessus de 10 + proportion_point = 0.06 # 6% + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + parcours = self.formsemestre.formation.get_parcours() + # Variantes de DUT ? + if ( + isinstance(parcours, ParcoursDUT) + or parcours.TYPE_PARCOURS == ParcoursDUTMono.TYPE_PARCOURS + ): # DUT + super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + else: + self.classic_use_bonus_ues = True # pour les LP + super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + ues = self.formsemestre.query_ues(with_sport=False).all() + ues_sans_bs = [ + ue for ue in ues if ue.acronyme[-2:].upper() != "BS" + ] # les 2 derniers cars forcés en majus + for ue in ues_sans_bs: + self.bonus_ues[ue.id] = 0.0 + + class BonusColmar(BonusSportAdditif): """Calcul bonus modules optionnels (sport, culture), règle IUT Colmar. @@ -692,8 +751,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 @@ -707,8 +783,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, @@ -747,6 +851,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. @@ -762,6 +879,27 @@ class BonusRoanne(BonusSportAdditif): proportion_point = 1 +class BonusStBrieuc(BonusSportAdditif): + """IUT de Saint Brieuc + + Ne s'applique qu'aux semestres pairs (S2, S4, S6), et bonifie les moyennes d'UE: + + """ + + # Utilisé aussi par St Malo, voir plus bas + name = "bonus_iut_stbrieuc" + displayed_name = "IUT de Saint-Brieuc" + proportion_point = 1 / 20.0 + classic_use_bonus_ues = True + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + if self.formsemestre.semestre_id % 2 == 0: + super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + + class BonusStDenis(BonusSportAdditif): """Calcul bonus modules optionnels (sport, culture), règle IUT Saint-Denis @@ -779,6 +917,64 @@ class BonusStDenis(BonusSportAdditif): bonus_max = 0.5 +class BonusStMalo(BonusStBrieuc): + # identique à St Brieux, sauf la doc + """IUT de Saint Malo + + Ne s'applique qu'aux semestres pairs (S2, S4, S6), et bonifie les moyennes d'UE: + + """ + name = "bonus_iut_stmalo" + displayed_name = "IUT de Saint-Malo" + + +class BonusTarbes(BonusSportAdditif): + """Calcul bonus optionnels (sport, culture), règle IUT de Tarbes. + + + """ + + name = "bonus_tarbes" + displayed_name = "IUT de Tazrbes" + seuil_moy_gen = 10.0 + proportion_point = 1 / 30.0 + classic_use_bonus_ues = True + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + # Prend la note de chaque modimpl, sans considération d'UE + if len(sem_modimpl_moys_inscrits.shape) > 2: # apc + sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] + # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic + note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds + ues = self.formsemestre.query_ues(with_sport=False).all() + ues_idx = [ue.id for ue in ues] + + if self.formsemestre.formation.is_apc(): # --- BUT + bonus_moy_arr = np.where( + note_bonus_max > self.seuil_moy_gen, + (note_bonus_max - self.seuil_moy_gen) * self.proportion_point, + 0.0, + ) + self.bonus_ues = pd.DataFrame( + np.stack([bonus_moy_arr] * len(ues)).T, + index=self.etuds_idx, + columns=ues_idx, + dtype=float, + ) + + class BonusTours(BonusDirect): """Calcul bonus sport & culture IUT Tours. diff --git a/app/comp/inscr_mod.py b/app/comp/inscr_mod.py index 667eacbd..cc03e673 100644 --- a/app/comp/inscr_mod.py +++ b/app/comp/inscr_mod.py @@ -17,7 +17,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/moy_sem.py b/app/comp/moy_sem.py index db42616c..61b5fd15 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/moy_ue.py b/app/comp/moy_ue.py index efbe7cd3..6d80f0b7 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -197,6 +197,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id) etuds_moy_module = mod_results.compute_module_moy(evals_poids) modimpls_results[modimpl.id] = mod_results + modimpls_evals_poids[modimpl.id] = evals_poids modimpls_notes.append(etuds_moy_module) if len(modimpls_notes): cube = notes_sem_assemble_cube(modimpls_notes) diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 20e63cba..bc6e7dd6 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -12,11 +12,14 @@ 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 +import app.scodoc.sco_utils as scu class ResultatsSemestreBUT(NotesTableCompat): @@ -30,6 +33,9 @@ class ResultatsSemestreBUT(NotesTableCompat): def __init__(self, formsemestre): super().__init__(formsemestre) + """DataFrame, row UEs(sans bonus), cols modimplid, value coef""" + self.sem_cube = None + """ndarray (etuds x modimpl x ue)""" if not self.load_cached(): t0 = time.time() @@ -98,7 +104,9 @@ class ResultatsSemestreBUT(NotesTableCompat): self.bonus_ues = bonus.get_bonus_ues() if self.bonus_ues is not None: self.etud_moy_ue += self.bonus_ues # somme les dataframes - self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) + + # Clippe toutes les moyennes d'UE dans [0,20] + self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) # Moyenne générale indicative: # (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte @@ -110,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() @@ -137,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_classic.py b/app/comp/res_classic.py index ecc1e500..605ba64c 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() @@ -125,12 +128,16 @@ class ResultatsSemestreClassic(NotesTableCompat): # Applique le bonus moyenne générale renvoyé self.etud_moy_gen += bonus_mg - self.etud_moy_gen.clip(lower=0.0, upper=20.0, inplace=True) # compat nt, utilisé pour l'afficher sur les bulletins: self.bonus = bonus_mg + # --- UE capitalisées self.apply_capitalisation() + # Clippe toutes les moyennes dans [0,20] + self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) + self.etud_moy_gen.clip(lower=0.0, upper=20.0, inplace=True) + # --- Classements: self.compute_rangs() @@ -138,11 +145,32 @@ 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) """ - return self.modimpls_results[moduleimpl_id].etuds_moy_module.get(etudid, "NI") + try: + if self.modimpl_inscr_df[moduleimpl_id][etudid]: + return self.modimpls_results[moduleimpl_id].etuds_moy_module[etudid] + except KeyError: + pass + return "NI" def get_mod_stats(self, moduleimpl_id: int) -> dict: """Stats sur les notes obtenues dans un modimpl""" @@ -163,6 +191,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( diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 8fa106f5..f89639e2 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -9,11 +9,8 @@ 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.comp.res_cache import ResultatsCache from app.comp import res_sem from app.comp.moy_mod import ModuleImplResults @@ -21,10 +18,13 @@ 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.sco_exceptions import ScoValueError +from app.scodoc import sco_groups +from app.scodoc import sco_users +from app.scodoc import sco_utils as scu + # Il faut bien distinguer # - ce qui est caché de façon persistente (via redis): @@ -36,6 +36,10 @@ 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 = ( "etud_moy_gen_ranks", "etud_moy_gen", @@ -54,17 +58,25 @@ class ResultatsSemestre(ResultatsCache): # 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 @@ -130,7 +142,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 +160,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 +196,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"]) @@ -337,436 +357,275 @@ class ResultatsSemestre(ResultatsCache): # somme des coefs des modules de l'UE auxquels il est inscrit return self.compute_etud_ue_coef(etudid, ue) + # --- TABLEAU RECAP -# Pour raccorder le code des anciens codes qui attendent une NoteTable -class NotesTableCompat(ResultatsSemestre): - """Implementation partielle de NotesTable WIP TODO + def get_table_recap(self, convert_values=False): + """Result: tuple avec + - rows: liste de dicts { column_id : value } + - titles: { column_id : title } + - columns_ids: (liste des id de colonnes) - Les méthodes définies dans cette classe sont là - pour conserver la compatibilité abvec les codes anciens et - il n'est pas recommandé de les utiliser dans de nouveaux - développements (API malcommode et peu efficace). - """ + . Si convert_values, transforme les notes en chaines ("12.34"). + Les colonnes générées sont: + etudid + rang : rang indicatif (basé sur moy gen) + moy_gen : moy gen indicative + moy_ue_, ..., les moyennes d'UE + moy_res__, ... les moyennes de ressources dans l'UE + moy_sae__, ... les moyennes de SAE dans l'UE - _cached_attrs = ResultatsSemestre._cached_attrs + ( - "bonus", - "bonus_ues", - "malus", - "etud_moy_gen_ranks", - "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. - - 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_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 "-" + barre_moy = ( + self.formsemestre.formation.get_parcours().BARRE_MOY - scu.NOTES_TOLERANCE ) + barre_valid_ue = self.formsemestre.formation.get_parcours().NOTES_BARRE_VALID_UE + NO_NOTE = "-" # contenu des cellules sans notes + rows = [] + titles = {"rang": "Rg"} # column_id : title + # les titres en footer: les mêmes, mais avec des bulles et liens: + titles_bot = {} - 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 add_cell( + row: dict, col_id: str, title: str, content: str, classes: str = "" + ): + "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 + if not col_id in titles: + titles[col_id] = title + if classes: + titles[f"_{col_id}_class"] = classes - 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 + modimpl_ids = set() # modimpl effectivement présents dans la table for etudid in etuds_inscriptions: + etud = Identite.query.get(etudid) + row = {"etudid": etudid} + # --- Rang + add_cell(row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang") + row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}" + # --- Identité étudiant + add_cell(row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail") + add_cell(row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail") + add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail") + add_cell(row, "nom_short", "Nom", etud.nom_short, "identite_court") + 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"] + self._recap_etud_groups_infos(etudid, row, titles) + # --- 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_inf" + add_cell( + row, + "moy_gen", + "Moy", + fmt_note(moy_gen), + "col_moy_gen" + note_class, + ) + titles_bot["_moy_gen_target_attrs"] = ( + 'title="moyenne indicative"' if self.is_apc else "" + ) + # --- Moyenne d'UE + for ue in [ue for ue in ues if ue.type != UE_SPORT]: + 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" + add_cell( + row, + col_id, + ue.acronyme, + fmt_note(val), + "col_ue" + note_class, + ) + titles_bot[ + f"_{col_id}_target_attrs" + ] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """ + # Les moyennes des modules (ou ressources et SAÉs) dans cette UE + for modimpl in self.modimpls_in_ue(ue.id, etudid, with_bonus=False): + if ue_status["is_capitalized"]: + val = "-c-" + else: + modimpl_results = self.modimpls_results.get(modimpl.id) + if modimpl_results: # pas bonus + if self.is_apc: # BUT + moys_vers_ue = modimpl_results.etuds_moy_module.get( + ue.id + ) + val = ( + moys_vers_ue.get(etudid, "?") + if moys_vers_ue is not None + else "" + ) + else: # classique: Series indépendante de l'UE + val = modimpl_results.etuds_moy_module.get( + etudid, "?" + ) + else: + val = "" - 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}" + ) + add_cell( + row, + col_id, + modimpl.module.code, + fmt_note(val), + # class col_res mod_ue_123 + f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}", + ) + titles_bot[f"_{col_id}_target"] = url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=modimpl.id, + ) + titles_bot[ + f"_{col_id}_target_attrs" + ] = f""" + title="{modimpl.module.titre} + ({sco_users.user_info(modimpl.responsable_id)['nomcomplet']})" """ + modimpl_ids.add(modimpl.id) - @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 + rows.append(row) + + # tri par rang croissant + rows.sort(key=lambda e: e["_rang_order"]) + + # INFOS POUR FOOTER + bottom_infos = self._recap_bottom_infos( + [ue for ue in ues if ue.type != UE_SPORT], modimpl_ids, fmt_note + ) + + # --- TABLE FOOTER: ECTS, moyennes, min, max... + footer_rows = [] + for bottom_line in bottom_infos: + row = bottom_infos[bottom_line] + # Cases vides à styler: + row["moy_gen"] = row.get("moy_gen", "") + row["_moy_gen_class"] = "col_moy_gen" + # titre de la ligne: + row["prenom"] = row["nom_short"] = bottom_line.capitalize() + row["_tr_class"] = bottom_line.lower() + footer_rows.append(row) + titles_bot.update(titles) + footer_rows.append(titles_bot) + return ( + rows, + footer_rows, + titles, + [title for title in titles if not title.startswith("_")], + ) + + def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict: + """Les informations à mettre en bas de la table: min, max, moy, ECTS""" + bottom_infos = { # { key : row } avec key = min, max, moy, coef + "min": {}, + "max": {}, + "moy": {}, + "coef": {}, } + # --- ECTS + row = {} + for ue in ues: + row[f"moy_ue_{ue.id}"] = ue.ects + row[f"_moy_ue_{ue.id}_class"] = "col_ue" + # style cases vides pour borders verticales + bottom_infos["coef"][f"moy_ue_{ue.id}"] = "" + bottom_infos["coef"][f"_moy_ue_{ue.id}_class"] = "col_ue" + row["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]) + row["_moy_gen_class"] = "col_moy_gen" + bottom_infos["ects"] = row + + # --- MIN, MAX, MOY + row_min, row_max, row_moy = {}, {}, {} + row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min()) + row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max()) + row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean()) + for ue in [ue for ue in ues if ue.type != UE_SPORT]: + col_id = f"moy_ue_{ue.id}" + row_min[col_id] = fmt_note(self.etud_moy_ue[ue.id].min()) + row_max[col_id] = fmt_note(self.etud_moy_ue[ue.id].max()) + row_moy[col_id] = fmt_note(self.etud_moy_ue[ue.id].mean()) + row_min[f"_{col_id}_class"] = "col_ue" + row_max[f"_{col_id}_class"] = "col_ue" + row_moy[f"_{col_id}_class"] = "col_ue" + + for modimpl in self.formsemestre.modimpls_sorted: + if modimpl.id in modimpl_ids: + col_id = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" + if self.is_apc: + coef = self.modimpl_coefs_df[modimpl.id][ue.id] * ( + modimpl.module.coefficient or 0.0 + ) + else: + coef = modimpl.module.coefficient or 0 + bottom_infos["coef"][col_id] = fmt_note(coef) + notes = self.modimpl_notes(modimpl.id, ue.id) + row_min[col_id] = fmt_note(np.nanmin(notes)) + row_max[col_id] = fmt_note(np.nanmax(notes)) + row_moy[col_id] = fmt_note(np.nanmean(notes)) + + bottom_infos["min"] = row_min + bottom_infos["max"] = row_max + bottom_infos["moy"] = row_moy + return bottom_infos + + 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) # dans NotesTableCompat, à revoir + 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" diff --git a/app/comp/res_compat.py b/app/comp/res_compat.py new file mode 100644 index 00000000..690f7099 --- /dev/null +++ b/app/comp/res_compat.py @@ -0,0 +1,456 @@ +############################################################################## +# 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 g, flash + +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 + ( + "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 + """{ 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]: + 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] + 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_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) + + 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 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/email.py b/app/email.py index 226429df..1fc7632b 100644 --- a/app/email.py +++ b/app/email.py @@ -1,8 +1,17 @@ # -*- coding: UTF-8 -* +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + from threading import Thread -from flask import current_app + +from flask import current_app, g from flask_mail import Message + from app import mail +from app.scodoc import sco_preferences def send_async_email(app, msg): @@ -11,20 +20,66 @@ def send_async_email(app, msg): def send_email( - subject: str, sender: str, recipients: list, text_body: str, html_body="" + subject: str, + sender: str, + recipients: list, + text_body: str, + html_body="", + bcc=(), + attachments=(), ): """ - Send an email + Send an email. _All_ ScoDoc mails SHOULD be sent using this function. + If html_body is specified, build a multipart message with HTML content, else send a plain text email. + + attachements: list of dict { 'filename', 'mimetype', 'data' } """ - msg = Message(subject, sender=sender, recipients=recipients) + msg = Message(subject, sender=sender, recipients=recipients, bcc=bcc) msg.body = text_body msg.html = html_body + if attachments: + for attachment in attachments: + msg.attach( + attachment["filename"], attachment["mimetype"], attachment["data"] + ) + send_message(msg) -def send_message(msg): +def send_message(msg: Message): + """Send a message. + All ScoDoc emails MUST be sent by this function. + + In mail debug mode, addresses are discarded and all mails are sent to the + specified debugging address. + """ + if hasattr(g, "scodoc_dept"): + # on est dans un département, on peut accéder aux préférences + email_test_mode_address = sco_preferences.get_preference( + "email_test_mode_address" + ) + if email_test_mode_address: + # Mode spécial test: remplace les adresses de destination + orig_to = msg.recipients + orig_cc = msg.cc + orig_bcc = msg.bcc + msg.recipients = [email_test_mode_address] + msg.cc = None + msg.bcc = None + msg.subject = "[TEST SCODOC] " + msg.subject + msg.body = ( + f"""--- Message ScoDoc dérouté pour tests --- +Adresses d'origine: + to : {orig_to} + cc : {orig_cc} + bcc: {orig_bcc} +--- + \n\n""" + + msg.body + ) + Thread( target=send_async_email, args=(current_app._get_current_object(), msg) ).start() diff --git a/app/forms/main/config_main.py b/app/forms/main/config_main.py index d6536a8d..2c2aa3d5 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/models/config.py b/app/models/config.py index 8a56d387..1271beeb 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -69,6 +69,7 @@ class ScoDocSiteConfig(db.Model): "INSTITUTION_ADDRESS": str, "INSTITUTION_CITY": str, "DEFAULT_PDF_FOOTER_TEMPLATE": str, + "enable_entreprises": bool, } def __init__(self, name, value): @@ -207,3 +208,27 @@ class ScoDocSiteConfig(db.Model): cfg.value = code_apo db.session.add(cfg) db.session.commit() + + @classmethod + def is_entreprises_enabled(cls) -> bool: + """True si on doit activer le module entreprise""" + cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first() + if (cfg is None) or not cfg.value: + return False + return True + + @classmethod + def enable_entreprises(cls, enabled=True) -> bool: + """Active (ou déactive) le module entreprises. True si changement.""" + if enabled != ScoDocSiteConfig.is_entreprises_enabled(): + cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first() + if cfg is None: + cfg = ScoDocSiteConfig( + name="enable_entreprises", value="on" if enabled else "" + ) + else: + cfg.value = "on" if enabled else "" + db.session.add(cfg) + db.session.commit() + return True + return False diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 18f13380..b1a49e5c 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -4,12 +4,14 @@ et données rattachées (adresses, annotations, ...) """ +import datetime from functools import cached_property from flask import abort, url_for from flask import g, request import sqlalchemy +from sqlalchemy import desc, text -from app import db +from app import db, log from app import models from app.scodoc import notesdb as ndb @@ -82,6 +84,11 @@ class Identite(db.Model): return scu.suppress_accents(s) return s + @property + def e(self): + "terminaison en français: 'ne', '', 'ou '(e)'" + return {"M": "", "F": "e"}.get(self.civilite, "(e)") + def nom_disp(self) -> str: "Nom à afficher" if self.nom_usuel: @@ -116,6 +123,11 @@ 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" @@ -123,7 +135,7 @@ class Identite(db.Model): def get_first_email(self, field="email") -> str: "Le mail associé à la première adrese de l'étudiant, ou None" - return self.adresses[0].email or None if self.adresses.count() > 0 else None + return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None def to_dict_scodoc7(self): """Représentation dictionnaire, @@ -134,31 +146,41 @@ class Identite(db.Model): # ScoDoc7 output_formators: (backward compat) e["etudid"] = self.id e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"]) - e["ne"] = {"M": "", "F": "ne"}.get(self.civilite, "(e)") + e["ne"] = self.e return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty def to_dict_bul(self, include_urls=True): - """Infos exportées dans les bulletins""" + """Infos exportées dans les bulletins + L'étudiant, et sa première adresse. + """ from app.scodoc import sco_photos d = { "civilite": self.civilite, - "code_ine": self.code_ine, - "code_nip": self.code_nip, - "date_naissance": self.date_naissance.isoformat() + "code_ine": self.code_ine or "", + "code_nip": self.code_nip or "", + "date_naissance": self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance - else None, - "email": self.get_first_email(), + else "", + "email": self.get_first_email() or "", "emailperso": self.get_first_email("emailperso"), "etudid": self.id, "nom": self.nom_disp(), - "prenom": self.prenom, + "prenom": self.prenom or "", + "nomprenom": self.nomprenom or "", + "lieu_naissance": self.lieu_naissance or "", + "dept_naissance": self.dept_naissance or "", + "nationalite": self.nationalite or "", + "boursier": self.boursier or "", } if include_urls: d["fiche_url"] = url_for( "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id ) - d["photo_url"] = (sco_photos.get_etud_photo_url(self.id),) + d["photo_url"] = sco_photos.get_etud_photo_url(self.id) + adresse = self.adresses.first() + if adresse: + d.update(adresse.to_dict(convert_nulls_to_str=True)) return d def inscription_courante(self): @@ -172,6 +194,23 @@ class Identite(db.Model): ] return r[0] if r else None + def inscriptions_courantes(self) -> list: # -> list[FormSemestreInscription]: + """Liste des inscriptions à des semestres _courants_ + (il est rare qu'il y en ai plus d'une, mais c'est possible). + Triées par date de début de semestre décroissante (le plus récent en premier). + """ + from app.models.formsemestre import FormSemestre, FormSemestreInscription + + return ( + FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) + .filter( + FormSemestreInscription.etudid == self.id, + text("date_debut < now() and date_fin > now()"), + ) + .order_by(desc(FormSemestre.date_debut)) + .all() + ) + def inscription_courante_date(self, date_debut, date_fin): """La première inscription à un formsemestre incluant la période [date_debut, date_fin] @@ -183,8 +222,8 @@ class Identite(db.Model): ] return r[0] if r else None - def etat_inscription(self, formsemestre_id): - """etat de l'inscription de cet étudiant au semestre: + def inscription_etat(self, formsemestre_id): + """État de l'inscription de cet étudiant au semestre: False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF """ # voir si ce n'est pas trop lent: @@ -195,6 +234,110 @@ class Identite(db.Model): return ins.etat return False + def inscription_descr(self) -> dict: + """Description de l'état d'inscription""" + inscription_courante = self.inscription_courante() + if inscription_courante: + titre_sem = inscription_courante.formsemestre.titre_mois() + return { + "etat_in_cursem": inscription_courante.etat, + "inscription_courante": inscription_courante, + "inscription": titre_sem, + "inscription_str": "Inscrit en " + titre_sem, + "situation": self.descr_situation_etud(), + } + else: + if self.formsemestre_inscriptions: + # cherche l'inscription la plus récente: + fin_dernier_sem = max( + [ + inscr.formsemestre.date_debut + for inscr in self.formsemestre_inscriptions + ] + ) + if fin_dernier_sem > datetime.date.today(): + inscription = "futur" + situation = "futur élève" + else: + inscription = "ancien" + situation = "ancien élève" + else: + inscription = ("non inscrit",) + situation = inscription + return { + "etat_in_cursem": "?", + "inscription_courante": None, + "inscription": inscription, + "inscription_str": inscription, + "situation": situation, + } + + def descr_situation_etud(self) -> str: + """Chaîne décrivant la situation _actuelle_ de l'étudiant. + Exemple: + "inscrit en BUT R&T semestre 2 FI (Jan 2022 - Jul 2022) le 16/01/2022" + ou + "non inscrit" + """ + inscriptions_courantes = self.inscriptions_courantes() + if inscriptions_courantes: + inscr = inscriptions_courantes[0] + if inscr.etat == scu.INSCRIT: + situation = f"inscrit{self.e} en {inscr.formsemestre.titre_mois()}" + # Cherche la date d'inscription dans scolar_events: + events = models.ScolarEvent.query.filter_by( + etudid=self.id, + formsemestre_id=inscr.formsemestre.id, + event_type="INSCRIPTION", + ).all() + if not events: + log( + f"*** situation inconsistante pour {self} (inscrit mais pas d'event)" + ) + situation += " (inscription non enregistrée)" # ??? + else: + date_ins = events[0].event_date + situation += date_ins.strftime(" le %d/%m/%Y") + else: + situation = f"démission de {inscr.formsemestre.titre_mois()}" + # Cherche la date de demission dans scolar_events: + events = models.ScolarEvent.query.filter_by( + etudid=self.id, + formsemestre_id=inscr.formsemestre.id, + event_type="DEMISSION", + ).all() + if not events: + log( + f"*** situation inconsistante pour {self} (demission mais pas d'event)" + ) + date_dem = "???" # ??? + else: + date_dem = events[0].event_date + situation += date_dem.strftime(" le %d/%m/%Y") + else: + situation = "non inscrit" + self.e + + return situation + + def photo_html(self, title=None, size="small") -> str: + """HTML img tag for the photo, either in small size (h90) + or original size (size=="orig") + """ + from app.scodoc import sco_photos + + # sco_photo traite des dicts: + return sco_photos.etud_photo_html( + etud=dict( + etudid=self.id, + code_nip=self.code_nip, + nomprenom=self.nomprenom, + nom_disp=self.nom_disp(), + photo_filename=self.photo_filename, + ), + title=title, + size=size, + ) + def make_etud_args( etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True @@ -218,12 +361,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é") @@ -259,6 +405,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 edd57097..c0f375dd 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 c66df782..3b1c35d0 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -12,7 +12,6 @@ from app import log from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN from app.models import CODE_STR_LEN -from app.models import UniteEns import app.scodoc.sco_utils as scu from app.models.ues import UniteEns @@ -23,6 +22,7 @@ from app.scodoc import sco_codes_parcours from app.scodoc import sco_preferences from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_utils import MONTH_NAMES_ABBREV class FormSemestre(db.Model): @@ -161,10 +161,9 @@ class FormSemestre(db.Model): d["periode"] = 1 # typiquement, début en septembre: S1, S3... else: d["periode"] = 2 # typiquement, début en février: S2, S4... - d["titre_num"] = self.titre_num() d["titreannee"] = self.titre_annee() - d["mois_debut"] = f"{self.date_debut.month} {self.date_debut.year}" - d["mois_fin"] = f"{self.date_fin.month} {self.date_fin.year}" + d["mois_debut"] = self.mois_debut() + d["mois_fin"] = self.mois_fin() d["titremois"] = "%s %s (%s - %s)" % ( d["titre_num"], self.modalite or "", @@ -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: @@ -294,6 +292,7 @@ class FormSemestre(db.Model): """chaîne "J. Dupond, X. Martin" ou "Jacques Dupond, Xavier Martin" """ + # was "nomcomplet" if not self.responsables: return "" if abbrev_prenom: @@ -301,10 +300,22 @@ class FormSemestre(db.Model): else: return ", ".join([u.get_nomcomplet() for u in self.responsables]) + def est_responsable(self, user): + "True si l'user est l'un des responsables du semestre" + return user.id in [u.id for u in self.responsables] + def annee_scolaire_str(self): "2021 - 2022" return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month) + def mois_debut(self) -> str: + "Oct 2021" + return f"{MONTH_NAMES_ABBREV[self.date_debut.month - 1]} {self.date_debut.year}" + + def mois_fin(self) -> str: + "Jul 2022" + return f"{MONTH_NAMES_ABBREV[self.date_fin.month - 1]} {self.date_fin.year}" + def session_id(self) -> str: """identifiant externe de semestre de formation Exemple: RT-DUT-FI-S1-ANNEE @@ -390,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 diff --git a/app/models/groups.py b/app/models/groups.py index 976d465b..f6452cf7 100644 --- a/app/models/groups.py +++ b/app/models/groups.py @@ -63,6 +63,12 @@ class GroupDescr(db.Model): # "A", "C2", ... (NULL for 'all'): group_name = db.Column(db.String(GROUPNAME_STR_LEN)) + etuds = db.relationship( + "Identite", + secondary="group_membership", + lazy="dynamic", + ) + def __repr__(self): return ( f"""<{self.__class__.__name__} {self.id} "{self.group_name or '(tous)'}">""" diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 0aa74ef4..292ec8ff 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -6,7 +6,8 @@ import flask_sqlalchemy from app import db from app.comp import df_cache -from app.models import Identite, Module +from app.models.etudiants import Identite +from app.models.modules import Module import app.scodoc.notesdb as ndb from app.scodoc import sco_utils as scu @@ -133,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 5a5f4761..67ff3de0 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/ues.py b/app/models/ues.py index 2bed88a3..518bd721 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/pe_jurype.py b/app/pe/pe_jurype.py index 2720ad43..0943649c 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 f48e69c4..0a7adcab 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 0e5045cb..26cc8e24 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 5af1a575..558b4bf8 100644 --- a/app/pe/pe_view.py +++ b/app/pe/pe_view.py @@ -120,7 +120,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 1cf4ea92..c8acefaf 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 85554555..7f5531c6 100644 --- a/app/scodoc/gen_tables.py +++ b/app/scodoc/gen_tables.py @@ -63,12 +63,15 @@ from app.scodoc.sco_pdf import SU from app import log -def mark_paras(L, tags): - """Put each (string) element of L between """ +def mark_paras(L, tags) -> list[str]: + """Put each (string) element of L between ..., + for each supplied tag. + Leave non string elements untouched. + """ for tag in tags: - b = "<" + tag + ">" - c = "" - L = [b + (x or "") + c for x in L] + start = "<" + tag + ">" + end = "" + L = [(start + (x or "") + end) if isinstance(x, str) else x for x in L] return L @@ -233,7 +236,10 @@ class GenTable(object): colspan_count -= 1 # if colspan_count > 0: # continue # skip cells after a span - content = row.get(cid, "") or "" # nota: None converted to '' + if pdf_mode: + content = row.get(f"_{cid}_pdf", "") or row.get(cid, "") or "" + else: + content = row.get(cid, "") or "" # nota: None converted to '' colspan = row.get("_%s_colspan" % cid, 0) if colspan > 1: pdf_style_list.append( @@ -547,9 +553,16 @@ class GenTable(object): omit_hidden_lines=True, ) try: - Pt = [ - [Paragraph(SU(str(x)), CellStyle) for x in line] for line in data_list - ] + Pt = [] + for line in data_list: + Pt.append( + [ + Paragraph(SU(str(x)), CellStyle) + if (not isinstance(x, Paragraph)) + else x + for x in line + ] + ) except ValueError as exc: raise ScoPDFFormatError(str(exc)) from exc pdf_style_list += self.pdf_table_style @@ -748,7 +761,7 @@ if __name__ == "__main__": doc = io.BytesIO() document = sco_pdf.BaseDocTemplate(doc) document.addPageTemplates( - sco_pdf.ScolarsPageTemplate( + sco_pdf.ScoDocPageTemplate( document, ) ) diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 653cdb80..2cf0be40 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -35,7 +35,7 @@ from flask import request from flask_login import current_user import app.scodoc.sco_utils as scu -from app import log +from app import scodoc_flash_status_messages from app.scodoc import html_sidebar import sco_version @@ -105,7 +105,6 @@ _HTML_BEGIN = """ - ') + # H.append( + # '' + # ) # JS additionels for js in javascripts: H.append("""\n""" % js) diff --git a/app/scodoc/sco_abs.py b/app/scodoc/sco_abs.py index 071cbe8e..2ae03ce8 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) @@ -631,7 +635,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 f15e7d4c..5f9670f5 100644 --- a/app/scodoc/sco_abs_notification.py +++ b/app/scodoc/sco_abs_notification.py @@ -35,6 +35,7 @@ import datetime from flask import g, url_for from flask_mail import Message +from app.models.formsemestre import FormSemestre import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu @@ -55,27 +56,30 @@ def abs_notify(etudid, date): """ from app.scodoc import sco_abs - sem = retreive_current_formsemestre(etudid, date) - if not sem: + formsemestre = retreive_current_formsemestre(etudid, date) + if not formsemestre: return # non inscrit a la date, pas de notification - nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem) - do_abs_notify(sem, etudid, date, nbabs, nbabsjust) + nbabs, nbabsjust = sco_abs.get_abs_count_in_interval( + etudid, formsemestre.date_debut.isoformat(), formsemestre.date_fin.isoformat() + ) + do_abs_notify(formsemestre, etudid, date, nbabs, nbabsjust) -def do_abs_notify(sem, etudid, date, nbabs, nbabsjust): +def do_abs_notify(formsemestre: FormSemestre, etudid, date, nbabs, nbabsjust): """Given new counts of absences, check if notifications are requested and send them.""" # prefs fallback to global pref if sem is None: - if sem: - formsemestre_id = sem["formsemestre_id"] + if formsemestre: + formsemestre_id = formsemestre.id else: formsemestre_id = None - prefs = sco_preferences.SemPreferences(formsemestre_id=sem["formsemestre_id"]) + prefs = sco_preferences.SemPreferences(formsemestre_id=formsemestre_id) destinations = abs_notify_get_destinations( - sem, prefs, etudid, date, nbabs, nbabsjust + formsemestre, prefs, etudid, date, nbabs, nbabsjust ) - msg = abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust) + + msg = abs_notification_message(formsemestre, prefs, etudid, nbabs, nbabsjust) if not msg: return # abort @@ -131,19 +135,19 @@ def abs_notify_send(destinations, etudid, msg, nbabs, nbabsjust, formsemestre_id ) -def abs_notify_get_destinations(sem, prefs, etudid, date, nbabs, nbabsjust): +def abs_notify_get_destinations( + formsemestre: FormSemestre, prefs, etudid, date, nbabs, nbabsjust +) -> set: """Returns set of destination emails to be notified""" - formsemestre_id = sem["formsemestre_id"] destinations = [] # list of email address to notify - if abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre_id): - if sem and prefs["abs_notify_respsem"]: + if abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre.id): + if prefs["abs_notify_respsem"]: # notifie chaque responsable du semestre - for responsable_id in sem["responsables"]: - u = sco_users.user_info(responsable_id) - if u["email"]: - destinations.append(u["email"]) + for responsable in formsemestre.responsables: + if responsable.email: + destinations.append(responsable.email) if prefs["abs_notify_chief"] and prefs["email_chefdpt"]: destinations.append(prefs["email_chefdpt"]) if prefs["abs_notify_email"]: @@ -156,7 +160,7 @@ def abs_notify_get_destinations(sem, prefs, etudid, date, nbabs, nbabsjust): # Notification (à chaque fois) des resp. de modules ayant des évaluations # à cette date # nb: on pourrait prevoir d'utiliser un autre format de message pour ce cas - if sem and prefs["abs_notify_respeval"]: + if prefs["abs_notify_respeval"]: mods = mod_with_evals_at_date(date, etudid) for mod in mods: u = sco_users.user_info(mod["responsable_id"]) @@ -232,7 +236,9 @@ def user_nbdays_since_last_notif(email_addr, etudid): return None -def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust): +def abs_notification_message( + formsemestre: FormSemestre, prefs, etudid, nbabs, nbabsjust +): """Mime notification message based on template. returns a Message instance or None if sending should be canceled (empty template). @@ -242,13 +248,13 @@ def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust): etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] # Variables accessibles dans les balises du template: %(nom_variable)s : - values = sco_bulletins.make_context_dict(sem, etud) + values = sco_bulletins.make_context_dict(formsemestre, etud) values["nbabs"] = nbabs values["nbabsjust"] = nbabsjust values["nbabsnonjust"] = nbabs - nbabsjust values["url_ficheetud"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True ) template = prefs["abs_notification_mail_tmpl"] @@ -264,9 +270,11 @@ def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust): return msg -def retreive_current_formsemestre(etudid, cur_date): +def retreive_current_formsemestre(etudid: int, cur_date) -> FormSemestre: """Get formsemestre dans lequel etudid est (ou était) inscrit a la date indiquée date est une chaine au format ISO (yyyy-mm-dd) + + Result: FormSemestre ou None si pas inscrit à la date indiquée """ req = """SELECT i.formsemestre_id FROM notes_formsemestre_inscription i, notes_formsemestre sem @@ -278,8 +286,8 @@ def retreive_current_formsemestre(etudid, cur_date): if not r: return None # s'il y a plusieurs semestres, prend le premier (rarissime et non significatif): - sem = sco_formsemestre.get_formsemestre(r[0]["formsemestre_id"]) - return sem + formsemestre = FormSemestre.query.get(r[0]["formsemestre_id"]) + return formsemestre def mod_with_evals_at_date(date_abs, etudid): diff --git a/app/scodoc/sco_abs_views.py b/app/scodoc/sco_abs_views.py index 686d589d..a4e1d3a7 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: - - """ - % (etudid, formsemestre_id) - ) - H.append('
    ') - - # --- Pied de page - H.append(html_sco_header.sco_footer()) - return "".join(H) @@ -897,8 +851,7 @@ def do_formsemestre_bulletinetud( formsemestre: FormSemestre, etudid: int, version="long", # short, long, selectedevals - format="html", - nohtml=False, + format=None, xml_with_decisions=False, # force décisions dans XML force_publishing=False, # force publication meme si semestre non publié sur "portail" prefer_mail_perso=False, # mails envoyés sur adresse perso si non vide @@ -908,6 +861,7 @@ def do_formsemestre_bulletinetud( où bul est str ou bytes au format demandé (html, pdf, pdfmail, pdfpart, xml, json) et filigranne est un message à placer en "filigranne" (eg "Provisoire"). """ + format = format or "html" if format == "xml": bul = sco_bulletins_xml.make_xml_formsemestre_bulletinetud( formsemestre.id, @@ -930,12 +884,12 @@ def do_formsemestre_bulletinetud( return bul, "" if formsemestre.formation.is_apc(): - etud = Identite.query.get(etudid) + etudiant = Identite.query.get(etudid) r = bulletin_but.BulletinBUT(formsemestre) - I = r.bulletin_etud_complet(etud) + I = r.bulletin_etud_complet(etudiant, version=version) else: I = formsemestre_bulletinetud_dict(formsemestre.id, etudid) - etud = I["etud"] + etud = I["etud"] if format == "html": htm, _ = sco_bulletins_generator.make_formsemestre_bulletinetud( @@ -964,13 +918,6 @@ def do_formsemestre_bulletinetud( if not can_send_bulletin_by_mail(formsemestre.id): raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - if nohtml: - htm = "" # speed up if html version not needed - else: - htm, _ = sco_bulletins_generator.make_formsemestre_bulletinetud( - I, version=version, format="html" - ) - pdfdata, filename = sco_bulletins_generator.make_formsemestre_bulletinetud( I, version=version, format="pdf" ) @@ -978,31 +925,18 @@ def do_formsemestre_bulletinetud( if prefer_mail_perso: recipient_addr = etud.get("emailperso", "") or etud.get("email", "") else: - recipient_addr = etud["email_default"] + recipient_addr = etud.get("email", "") or etud.get("emailperso", "") if not recipient_addr: - if nohtml: - h = "" # permet de compter les non-envois - else: - h = ( - "
    %s n'a pas d'adresse e-mail !
    " - % etud["nomprenom"] - ) + htm - return h, I["filigranne"] - # - mail_bulletin(formsemestre.id, I, pdfdata, filename, recipient_addr) - emaillink = '%s' % ( - recipient_addr, - recipient_addr, - ) - return ( - ('
    Message mail envoyé à %s
    ' % (emaillink)) - + htm, - I["filigranne"], - ) + flash(f"{etud['nomprenom']} n'a pas d'adresse e-mail !") + return False, I["filigranne"] + else: + mail_bulletin(formsemestre.id, I, pdfdata, filename, recipient_addr) + flash(f"mail envoyé à {recipient_addr}") - else: - raise ValueError("do_formsemestre_bulletinetud: invalid format (%s)" % format) + return True, I["filigranne"] + + raise ValueError("do_formsemestre_bulletinetud: invalid format (%s)" % format) def mail_bulletin(formsemestre_id, I, pdfdata, filename, recipient_addr): @@ -1027,7 +961,7 @@ def mail_bulletin(formsemestre_id, I, pdfdata, filename, recipient_addr): except KeyError as e: raise ScoValueError( "format 'Message d'accompagnement' (bul_intro_mail) invalide, revoir les réglages dans les préférences" - ) + ) from e else: hea = "" @@ -1043,81 +977,32 @@ def mail_bulletin(formsemestre_id, I, pdfdata, filename, recipient_addr): bcc = copy_addr.strip() else: bcc = "" - msg = Message(subject, sender=sender, recipients=recipients, bcc=[bcc]) - msg.body = hea # Attach pdf - msg.attach(filename, scu.PDF_MIMETYPE, pdfdata) log("mail bulletin a %s" % recipient_addr) - email.send_message(msg) + email.send_email( + subject, + sender, + recipients, + bcc=[bcc], + text_body=hea, + attachments=[ + {"filename": filename, "mimetype": scu.PDF_MIMETYPE, "data": pdfdata} + ], + ) -def _formsemestre_bulletinetud_header_html( - etud, - etudid, - sem, - formsemestre_id=None, - format=None, - version=None, -): - H = [ - html_sco_header.sco_header( - page_title="Bulletin de %(nomprenom)s" % etud, - javascripts=[ - "js/bulletin.js", - "libjs/d3.v3.min.js", - "js/radar_bulletin.js", - ], - cssstyles=["css/radar_bulletin.css"], - ), - """ -
    -

    %s

    - """ - % ( - url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] - ), - etud["nomprenom"], - ), - """ -
    """ - % request.base_url, - f"""Bulletin {sem["titremois"]} -
    """ - % sem, - """""", - """""" % time.strftime("%d/%m/%Y à %Hh%M"), - """""") - # Menu - endpoint = "notes.formsemestre_bulletinetud" - - menuBul = [ +def make_menu_autres_operations( + formsemestre: FormSemestre, etud: Identite, endpoint: str, version: str +) -> str: + etud_email = etud.get_first_email() or "" + etud_perso = etud.get_first_email("emailperso") or "" + menu_items = [ { "title": "Réglages bulletins", "endpoint": "notes.formsemestre_edit_options", "args": { - "formsemestre_id": formsemestre_id, + "formsemestre_id": formsemestre.id, # "target_url": url_for( # "notes.formsemestre_bulletinetud", # scodoc_dept=g.scodoc_dept, @@ -1125,54 +1010,52 @@ def _formsemestre_bulletinetud_header_html( # etudid=etudid, # ), }, - "enabled": (current_user.id in sem["responsables"]) - or current_user.has_permission(Permission.ScoImplement), + "enabled": formsemestre.can_be_edited_by(current_user), }, { "title": 'Version papier (pdf, format "%s")' % sco_bulletins_generator.bulletin_get_class_name_displayed( - formsemestre_id + formsemestre.id ), "endpoint": endpoint, "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, "version": version, "format": "pdf", }, }, { - "title": "Envoi par mail à %s" % etud["email"], + "title": f"Envoi par mail à {etud_email}", "endpoint": endpoint, "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, "version": version, "format": "pdfmail", }, # possible slt si on a un mail... - "enabled": etud["email"] and can_send_bulletin_by_mail(formsemestre_id), + "enabled": etud_email and can_send_bulletin_by_mail(formsemestre.id), }, { - "title": "Envoi par mail à %s (adr. personnelle)" % etud["emailperso"], + "title": f"Envoi par mail à {etud_perso} (adr. personnelle)", "endpoint": endpoint, "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, "version": version, "format": "pdfmail", "prefer_mail_perso": 1, }, # possible slt si on a un mail... - "enabled": etud["emailperso"] - and can_send_bulletin_by_mail(formsemestre_id), + "enabled": etud_perso and can_send_bulletin_by_mail(formsemestre.id), }, { "title": "Version json", "endpoint": endpoint, "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, "version": version, "format": "json", }, @@ -1181,8 +1064,8 @@ def _formsemestre_bulletinetud_header_html( "title": "Version XML", "endpoint": endpoint, "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, "version": version, "format": "xml", }, @@ -1191,20 +1074,20 @@ def _formsemestre_bulletinetud_header_html( "title": "Ajouter une appréciation", "endpoint": "notes.appreciation_add_form", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, "enabled": ( - (current_user.id in sem["responsables"]) - or (current_user.has_permission(Permission.ScoEtudInscrit)) + formsemestre.can_be_edited_by(current_user) + or current_user.has_permission(Permission.ScoEtudInscrit) ), }, { "title": "Enregistrer un semestre effectué ailleurs", "endpoint": "notes.formsemestre_ext_create_form", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, "enabled": current_user.has_permission(Permission.ScoImplement), }, @@ -1212,71 +1095,72 @@ def _formsemestre_bulletinetud_header_html( "title": "Enregistrer une validation d'UE antérieure", "endpoint": "notes.formsemestre_validate_previous_ue", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre_id), + "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), }, { "title": "Enregistrer note d'une UE externe", "endpoint": "notes.external_ue_create_form", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre_id), + "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), }, { "title": "Entrer décisions jury", "endpoint": "notes.formsemestre_validation_etud_form", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre_id), + "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), }, { - "title": "Editer PV jury", + "title": "Éditer PV jury", "endpoint": "notes.formsemestre_pvjury_pdf", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, "enabled": True, }, ] + return htmlutils.make_menu("Autres opérations", menu_items, alone=True) - H.append("""""") - H.append( - '' - % ( - url_for( - "notes.formsemestre_bulletinetud", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etudid=etudid, - format="pdf", + +def _formsemestre_bulletinetud_header_html( + etud, + formsemestre: FormSemestre, + format=None, + version=None, +): + H = [ + html_sco_header.sco_header( + page_title=f"Bulletin de {etud.nomprenom}", + javascripts=[ + "js/bulletin.js", + "libjs/d3.v3.min.js", + "js/radar_bulletin.js", + ], + cssstyles=["css/radar_bulletin.css"], + ), + render_template( + "bul_head.html", + etud=etud, + format=format, + formsemestre=formsemestre, + menu_autres_operations=make_menu_autres_operations( + etud=etud, + formsemestre=formsemestre, + endpoint="notes.formsemestre_bulletinetud", version=version, ), - scu.ICON_PDF, - ) - ) - H.append("""
    établi le %s (notes sur 20) - """ - % formsemestre_id, - """""" % etudid, - """""" % format, - """
    """) - H.append(htmlutils.make_menu("Autres opérations", menuBul, alone=True)) - H.append("""
    %s
    """) - # - H.append( - """
    %s - """ - % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - sco_photos.etud_photo_html(etud, title="fiche de " + etud["nom"]), - ) - ) - H.append( - """
    - """ - ) - - return "".join(H) + scu=scu, + time=time, + version=version, + ), + ] + return "\n".join(H) diff --git a/app/scodoc/sco_bulletins_generator.py b/app/scodoc/sco_bulletins_generator.py index ceeb0aac..c62eff05 100644 --- a/app/scodoc/sco_bulletins_generator.py +++ b/app/scodoc/sco_bulletins_generator.py @@ -49,7 +49,14 @@ import traceback import reportlab -from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak +from reportlab.platypus import ( + SimpleDocTemplate, + DocIf, + Paragraph, + Spacer, + Frame, + PageBreak, +) from reportlab.platypus import Table, TableStyle, Image, KeepInFrame from flask import request @@ -71,6 +78,8 @@ class BulletinGenerator: supported_formats = [] # should list supported formats, eg [ 'html', 'pdf' ] description = "superclass for bulletins" # description for user interface list_in_menu = True # la classe doit-elle est montrée dans le menu de config ? + scale_table_in_page = True # rescale la table sur 1 page + multi_pages = False def __init__( self, @@ -117,7 +126,7 @@ class BulletinGenerator: def get_filename(self): """Build a filename to be proposed to the web client""" sem = sco_formsemestre.get_formsemestre(self.infos["formsemestre_id"]) - return scu.bul_filename(sem, self.infos["etud"], "pdf") + return scu.bul_filename_old(sem, self.infos["etud"], "pdf") def generate(self, format="", stand_alone=True): """Return bulletin in specified format""" @@ -153,29 +162,47 @@ class BulletinGenerator: from app.scodoc import sco_preferences formsemestre_id = self.infos["formsemestre_id"] - + marque_debut_bulletin = sco_pdf.DebutBulletin( + self.infos["etud"]["nomprenom"], + filigranne=self.infos["filigranne"], + footer_content=f"""ScoDoc - Bulletin de {self.infos["etud"]["nomprenom"]} - {time.strftime("%d/%m/%Y %H:%M")}""", + ) + story = [] # partie haute du bulletin - objects = self.bul_title_pdf() # pylint: disable=no-member - # table des notes - objects += self.bul_table(format="pdf") # pylint: disable=no-member - # infos sous la table - objects += self.bul_part_below(format="pdf") # pylint: disable=no-member - # signatures - objects += self.bul_signatures_pdf() # pylint: disable=no-member + story += self.bul_title_pdf() # pylint: disable=no-member + index_obj_debut = len(story) - # Réduit sur une page - objects = [KeepInFrame(0, 0, objects, mode="shrink")] + # table des notes + story += self.bul_table(format="pdf") # pylint: disable=no-member + # infos sous la table + story += self.bul_part_below(format="pdf") # pylint: disable=no-member + # signatures + story += self.bul_signatures_pdf() # pylint: disable=no-member + if self.scale_table_in_page: + # Réduit sur une page + story = [marque_debut_bulletin, KeepInFrame(0, 0, story, mode="shrink")] + else: + # Insere notre marqueur qui permet de générer les bookmarks et filigrannes: + story.insert(index_obj_debut, marque_debut_bulletin) # + # objects.append(sco_pdf.FinBulletin()) if not stand_alone: - objects.append(PageBreak()) # insert page break at end - return objects + if self.multi_pages: + # Bulletins sur plusieurs page, force début suivant sur page impaire + story.append( + DocIf("doc.page%2 == 1", [PageBreak(), PageBreak()], [PageBreak()]) + ) + else: + story.append(PageBreak()) # insert page break at end + + return story else: # Generation du document PDF sem = sco_formsemestre.get_formsemestre(formsemestre_id) report = io.BytesIO() # in-memory document, no disk file document = sco_pdf.BaseDocTemplate(report) document.addPageTemplates( - sco_pdf.ScolarsPageTemplate( + sco_pdf.ScoDocPageTemplate( document, author="%s %s (E. Viennet) [%s]" % (sco_version.SCONAME, sco_version.SCOVERSION, self.description), @@ -188,7 +215,7 @@ class BulletinGenerator: preferences=sco_preferences.SemPreferences(formsemestre_id), ) ) - document.build(objects) + document.build(story) data = report.getvalue() return data @@ -219,7 +246,7 @@ class BulletinGenerator: # --------------------------------------------------------------------------- def make_formsemestre_bulletinetud( infos, - version="long", # short, long, selectedevals + version=None, # short, long, selectedevals format="pdf", # html, pdf stand_alone=True, ): @@ -231,6 +258,7 @@ def make_formsemestre_bulletinetud( """ from app.scodoc import sco_preferences + version = version or "long" if not version in scu.BULLETINS_VERSIONS: raise ValueError("invalid version code !") @@ -238,10 +266,15 @@ def make_formsemestre_bulletinetud( bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id) gen_class = None - if infos.get("type") == "BUT" and format.startswith("pdf"): - gen_class = bulletin_get_class(bul_class_name + "BUT") - if gen_class is None: - gen_class = bulletin_get_class(bul_class_name) + for bul_class_name in ( + sco_preferences.get_preference("bul_class_name", formsemestre_id), + # si pas trouvé (modifs locales bizarres ,), ré-essaye avec la valeur par défaut + bulletin_default_class_name(), + ): + if infos.get("type") == "BUT" and format.startswith("pdf"): + gen_class = bulletin_get_class(bul_class_name + "BUT") + if gen_class is None: + gen_class = bulletin_get_class(bul_class_name) if gen_class is None: raise ValueError( @@ -297,7 +330,11 @@ def register_bulletin_class(klass): def bulletin_class_descriptions(): - return [x.description for x in BULLETIN_CLASSES.values()] + return [ + BULLETIN_CLASSES[class_name].description + for class_name in BULLETIN_CLASSES + if BULLETIN_CLASSES[class_name].list_in_menu + ] def bulletin_class_names() -> list[str]: diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py index ee57b60e..f34923a5 100644 --- a/app/scodoc/sco_bulletins_json.py +++ b/app/scodoc/sco_bulletins_json.py @@ -33,7 +33,7 @@ import json from app.but import bulletin_but from app.comp import res_sem -from app.comp.res_common import NotesTableCompat +from app.comp.res_compat import NotesTableCompat from app.models.formsemestre import FormSemestre from app.models.etudiants import Identite @@ -138,7 +138,7 @@ def formsemestre_bulletinetud_published_dict( if not published: return d # stop ! - etat_inscription = etud.etat_inscription(formsemestre.id) + etat_inscription = etud.inscription_etat(formsemestre.id) if etat_inscription != scu.INSCRIT: d.update(dict_decision_jury(etudid, formsemestre_id, with_decisions=True)) return d diff --git a/app/scodoc/sco_bulletins_legacy.py b/app/scodoc/sco_bulletins_legacy.py index 7d570d07..314abb0d 100644 --- a/app/scodoc/sco_bulletins_legacy.py +++ b/app/scodoc/sco_bulletins_legacy.py @@ -34,17 +34,19 @@ CE FORMAT N'EVOLUERA PLUS ET EST CONSIDERE COMME OBSOLETE. """ +from reportlab.lib.colors import Color, blue +from reportlab.lib.units import cm, mm +from reportlab.platypus import Paragraph, Spacer, Table -import app.scodoc.sco_utils as scu -from app.scodoc.sco_permissions import Permission -from app.scodoc import sco_formsemestre -from app.scodoc import sco_pdf -from app.scodoc.sco_pdf import Color, Paragraph, Spacer, Table -from app.scodoc.sco_pdf import blue, cm, mm -from app.scodoc.sco_pdf import SU -from app.scodoc import sco_preferences from app.scodoc import sco_bulletins_generator from app.scodoc import sco_bulletins_pdf +from app.scodoc import sco_formsemestre +from app.scodoc.sco_permissions import Permission +from app.scodoc import sco_pdf +from app.scodoc.sco_pdf import SU +from app.scodoc import sco_preferences +import app.scodoc.sco_utils as scu + # Important: Le nom de la classe ne doit pas changer (bien le choisir), car il sera stocké en base de données (dans les préférences) class BulletinGeneratorLegacy(sco_bulletins_generator.BulletinGenerator): diff --git a/app/scodoc/sco_bulletins_pdf.py b/app/scodoc/sco_bulletins_pdf.py index 1df2ca66..501bd98c 100644 --- a/app/scodoc/sco_bulletins_pdf.py +++ b/app/scodoc/sco_bulletins_pdf.py @@ -51,12 +51,11 @@ Chaque semestre peut si nécessaire utiliser un type de bulletin différent. """ import io +import pprint +import pydoc import re import time import traceback -from pydoc import html - -from reportlab.platypus.doctemplate import BaseDocTemplate from flask import g, request @@ -74,17 +73,17 @@ import app.scodoc.sco_utils as scu import sco_version -def pdfassemblebulletins( - formsemestre_id, - objects, - bul_title, +def assemble_bulletins_pdf( + formsemestre_id: int, + story: list, + bul_title: str, infos, - pagesbookmarks, + pagesbookmarks=None, filigranne=None, server_name="", ): - "generate PDF document from a list of PLATYPUS objects" - if not objects: + "Generate PDF document from a story (list of PLATYPUS objects)." + if not story: return "" # Paramètres de mise en page margins = ( @@ -93,11 +92,10 @@ def pdfassemblebulletins( sco_preferences.get_preference("right_margin", formsemestre_id), sco_preferences.get_preference("bottom_margin", formsemestre_id), ) - report = io.BytesIO() # in-memory document, no disk file - document = BaseDocTemplate(report) + document = sco_pdf.BulletinDocTemplate(report) document.addPageTemplates( - sco_pdf.ScolarsPageTemplate( + sco_pdf.ScoDocPageTemplate( document, author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION), title="Bulletin %s" % bul_title, @@ -109,7 +107,7 @@ def pdfassemblebulletins( preferences=sco_preferences.SemPreferences(formsemestre_id), ) ) - document.build(objects) + document.multiBuild(story) data = report.getvalue() return data @@ -121,7 +119,8 @@ def replacement_function(match): if logo is not None: return r'' % (match.group(2), logo.filepath, match.group(4)) raise ScoValueError( - 'balise "%s": logo "%s" introuvable' % (html.escape(balise), html.escape(name)) + 'balise "%s": logo "%s" introuvable' + % (pydoc.html.escape(balise), pydoc.html.escape(name)) ) @@ -142,7 +141,11 @@ def process_field(field, cdict, style, suppress_empty_pars=False, format="pdf"): cdict ) # note that None values are mapped to empty strings except: - log("process_field: invalid format=%s" % field) + log( + f"""process_field: invalid format. field={field!r} + values={pprint.pformat(cdict)} + """ + ) text = ( "format invalide !" + traceback.format_exc() @@ -174,7 +177,7 @@ def process_field(field, cdict, style, suppress_empty_pars=False, format="pdf"): def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): - "document pdf et filename" + "Document pdf avec tous les bulletins du semestre, et filename" from app.scodoc import sco_bulletins cached = sco_cache.SemBulletinsPDFCache.get(str(formsemestre_id) + "_" + version) @@ -183,20 +186,14 @@ def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): fragments = [] # Make each bulletin formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - bookmarks = {} - filigrannes = {} - i = 1 for etud in formsemestre.get_inscrits(include_demdef=True, order=True): - frag, filigranne = sco_bulletins.do_formsemestre_bulletinetud( + frag, _ = sco_bulletins.do_formsemestre_bulletinetud( formsemestre, etud.id, format="pdfpart", version=version, ) fragments += frag - filigrannes[i] = filigranne - bookmarks[i] = etud.sex_nom(no_accents=True) - i = i + 1 # infos = {"DeptName": sco_preferences.get_preference("DeptName", formsemestre_id)} if request: @@ -205,20 +202,18 @@ def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): server_name = "" try: sco_pdf.PDFLOCK.acquire() - pdfdoc = pdfassemblebulletins( + pdfdoc = assemble_bulletins_pdf( formsemestre_id, fragments, formsemestre.titre_mois(), infos, - bookmarks, - filigranne=filigrannes, server_name=server_name, ) finally: sco_pdf.PDFLOCK.release() # - dt = time.strftime("%Y-%m-%d") - filename = "bul-%s-%s.pdf" % (formsemestre.titre_num(), dt) + date_iso = time.strftime("%Y-%m-%d") + filename = "bul-%s-%s.pdf" % (formsemestre.titre_num(), date_iso) filename = scu.unescape_html(filename).replace(" ", "_").replace("&", "") # fill cache sco_cache.SemBulletinsPDFCache.set( @@ -255,7 +250,7 @@ def get_etud_bulletins_pdf(etudid, version="selectedevals"): server_name = "" try: sco_pdf.PDFLOCK.acquire() - pdfdoc = pdfassemblebulletins( + pdfdoc = assemble_bulletins_pdf( None, fragments, etud["nomprenom"], diff --git a/app/scodoc/sco_bulletins_standard.py b/app/scodoc/sco_bulletins_standard.py index fd84e7d9..e7c92ad7 100644 --- a/app/scodoc/sco_bulletins_standard.py +++ b/app/scodoc/sco_bulletins_standard.py @@ -46,10 +46,13 @@ de la forme %(XXX)s sont remplacées par la valeur de XXX, pour XXX dans: Balises img: actuellement interdites. """ +from reportlab.platypus import KeepTogether, Paragraph, Spacer, Table +from reportlab.lib.units import cm, mm +from reportlab.lib.colors import Color, blue +from app.models import FormSemestre +from app.scodoc.sco_exceptions import ScoBugCatcher import app.scodoc.sco_utils as scu -from app.scodoc.sco_pdf import Color, Paragraph, Spacer, Table -from app.scodoc.sco_pdf import blue, cm, mm from app.scodoc.sco_pdf import SU from app.scodoc import sco_preferences from app.scodoc.sco_permissions import Permission @@ -72,7 +75,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): description = "standard ScoDoc (version 2011)" # la description doit être courte: elle apparait dans le menu de paramètrage ScoDoc supported_formats = ["html", "pdf"] - def bul_title_pdf(self): + def bul_title_pdf(self) -> list: """Génère la partie "titre" du bulletin de notes. Renvoie une liste d'objets platypus """ @@ -114,11 +117,11 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): - en PDF: une liste d'objets platypus """ H = [] # html - Op = [] # objets platypus + story = [] # objets platypus # ----- ABSENCES if self.preferences["bul_show_abs"]: nbabs = self.infos["nbabs"] - Op.append(Spacer(1, 2 * mm)) + story.append(Spacer(1, 2 * mm)) if nbabs: H.append( """

    @@ -129,7 +132,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): """ % self.infos ) - Op.append( + story.append( Paragraph( SU( "%(nbabs)s absences (1/2 journées), dont %(nbabsjust)s justifiées." @@ -140,7 +143,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): ) else: H.append("""

    Pas d'absences signalées.

    """) - Op.append(Paragraph(SU("Pas d'absences signalées."), self.CellStyle)) + story.append(Paragraph(SU("Pas d'absences signalées."), self.CellStyle)) # ---- APPRECIATIONS # le dir. des etud peut ajouter des appreciations, @@ -167,10 +170,10 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): % self.infos ) H.append("") - # Appreciations sur PDF: + # Appréciations sur PDF: if self.infos.get("appreciations_list", False): - Op.append(Spacer(1, 3 * mm)) - Op.append( + story.append(Spacer(1, 3 * mm)) + story.append( Paragraph( SU("Appréciation : " + "\n".join(self.infos["appreciations_txt"])), self.CellStyle, @@ -179,7 +182,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): # ----- DECISION JURY if self.preferences["bul_show_decision"]: - Op += sco_bulletins_pdf.process_field( + story += sco_bulletins_pdf.process_field( self.preferences["bul_pdf_caption"], self.infos, self.FieldStyle, @@ -195,7 +198,12 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): # ----- if format == "pdf": - return Op + if self.scale_table_in_page: + # le scaling (pour tenir sur une page) semble incompatible avec + # le KeepTogether() + return story + else: + return [KeepTogether(story)] elif format == "html": return "\n".join(H) @@ -264,7 +272,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): ) def build_bulletin_table(self): - """Génère la table centrale du bulletin de notes + """Génère la table centrale du bulletin de notes classique (pas BUT) Renvoie: col_keys, P, pdf_style, col_widths - col_keys: nom des colonnes de la table (clés) - table: liste de dicts de chaines de caractères @@ -374,10 +382,10 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): t = { "titre": "Moyenne générale:", "rang": I["rang_nt"], - "note": I["moy_gen"], - "min": I["moy_min"], - "max": I["moy_max"], - "moy": I["moy_moy"], + "note": I.get("moy_gen", "-"), + "min": I.get("moy_min", "-"), + "max": I.get("moy_max", "-"), + "moy": I.get("moy_moy", "-"), "abs": "%s / %s" % (nbabs, nbabsjust), "_css_row_class": "notes_bulletin_row_gen", "_titre_colspan": 2, @@ -411,6 +419,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): for ue in I["ues"]: ue_type = None coef_ue = ue["coef_ue_txt"] if prefs["bul_show_ue_coef"] else "" + ue_descr = ue["ue_descr_txt"] rowstyle = "" plusminus = minuslink # diff --git a/app/scodoc/sco_bulletins_ucac.py b/app/scodoc/sco_bulletins_ucac.py index 2114c60a..fc49ffd9 100644 --- a/app/scodoc/sco_bulletins_ucac.py +++ b/app/scodoc/sco_bulletins_ucac.py @@ -32,16 +32,12 @@ On redéfini la table centrale du bulletin de note et hérite de tout le reste d E. Viennet, juillet 2011 """ +from reportlab.lib.colors import Color +from reportlab.lib.units import mm -import app.scodoc.sco_utils as scu -from app.scodoc.sco_pdf import blue, cm, mm -from app.scodoc.sco_pdf import Color, Paragraph, Spacer, Table - -from app.scodoc import sco_preferences - -from app.scodoc import sco_bulletins_generator from app.scodoc import sco_bulletins_standard -from app.scodoc import gen_tables +from app.scodoc import sco_preferences +import app.scodoc.sco_utils as scu class BulletinGeneratorUCAC(sco_bulletins_standard.BulletinGeneratorStandard): diff --git a/app/scodoc/sco_bulletins_xml.py b/app/scodoc/sco_bulletins_xml.py index f41361c2..d6925d8c 100644 --- a/app/scodoc/sco_bulletins_xml.py +++ b/app/scodoc/sco_bulletins_xml.py @@ -45,7 +45,7 @@ from xml.etree import ElementTree from xml.etree.ElementTree import Element from app.comp import res_sem -from app.comp.res_common import NotesTableCompat +from app.comp.res_compat import NotesTableCompat import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app import log diff --git a/app/scodoc/sco_config.py b/app/scodoc/sco_config.py index dd69b971..95b9dcc9 100644 --- a/app/scodoc/sco_config.py +++ b/app/scodoc/sco_config.py @@ -46,7 +46,9 @@ CONFIG.LOGO_HEADER_HEIGHT = 28 # # server_url: URL du serveur ScoDoc # scodoc_name: le nom du logiciel (ScoDoc actuellement, voir sco_version.py) -CONFIG.DEFAULT_PDF_FOOTER_TEMPLATE = "Edité par %(scodoc_name)s le %(day)s/%(month)s/%(year)s à %(hour)sh%(minute)s sur %(server_url)s" +CONFIG.DEFAULT_PDF_FOOTER_TEMPLATE = ( + "Edité par %(scodoc_name)s le %(day)s/%(month)s/%(year)s à %(hour)sh%(minute)s" +) # ------------- Capitalisation des UEs ------------- diff --git a/app/scodoc/sco_debouche.py b/app/scodoc/sco_debouche.py index a567a344..ea96ff1c 100644 --- a/app/scodoc/sco_debouche.py +++ b/app/scodoc/sco_debouche.py @@ -33,7 +33,7 @@ from flask import url_for, g, request 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 import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb diff --git a/app/scodoc/sco_dept.py b/app/scodoc/sco_dept.py index 274b41d7..453aa2f6 100644 --- a/app/scodoc/sco_dept.py +++ b/app/scodoc/sco_dept.py @@ -56,7 +56,7 @@ def index_html(showcodes=0, showsemtable=0): H.append(sco_news.scolar_news_summary_html()) # Avertissement de mise à jour: - H.append(sco_up_to_date.html_up_to_date_box()) + H.append("""
    """) # Liste de toutes les sessions: sems = sco_formsemestre.do_formsemestre_list() @@ -293,7 +293,6 @@ def delete_dept(dept_id: int): "create temp table formsemestres_temp as select id from notes_formsemestre where dept_id = %(dept_id)s", "create temp table moduleimpls_temp as select id from notes_moduleimpl where formsemestre_id in (select id from formsemestres_temp)", "create temp table formations_temp as select id from notes_formations where dept_id = %(dept_id)s", - "create temp table entreprises_temp as select id from entreprises where dept_id = %(dept_id)s", "create temp table tags_temp as select id from notes_tags where dept_id = %(dept_id)s", ] for r in reqs: @@ -345,13 +344,9 @@ def delete_dept(dept_id: int): "delete from notes_formsemestre where dept_id = %(dept_id)s", "delete from scolar_news where dept_id = %(dept_id)s", "delete from notes_semset where dept_id = %(dept_id)s", - "delete from entreprise_contact where entreprise_id in (select id from entreprises_temp) ", - "delete from entreprise_correspondant where entreprise_id in (select id from entreprises_temp) ", - "delete from entreprises where dept_id = %(dept_id)s", "delete from notes_formations where dept_id = %(dept_id)s", "delete from departement where id = %(dept_id)s", "drop table tags_temp", - "drop table entreprises_temp", "drop table formations_temp", "drop table moduleimpls_temp", "drop table etudids_temp", diff --git a/app/scodoc/sco_dump_db.py b/app/scodoc/sco_dump_db.py index 4b55b41f..fd0b15c0 100644 --- a/app/scodoc/sco_dump_db.py +++ b/app/scodoc/sco_dump_db.py @@ -51,14 +51,12 @@ import fcntl import subprocess import requests -from flask import flash +from flask import g, request from flask_login import current_user import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app import log -from app.scodoc import html_sco_header -from app.scodoc import sco_preferences from app.scodoc import sco_users import sco_version from app.scodoc.sco_exceptions import ScoValueError @@ -66,10 +64,9 @@ from app.scodoc.sco_exceptions import ScoValueError SCO_DUMP_LOCK = "/tmp/scodump.lock" -def sco_dump_and_send_db(): +def sco_dump_and_send_db(message: str = "", request_url: str = ""): """Dump base de données et l'envoie anonymisée pour debug""" - H = [html_sco_header.sco_header(page_title="Assistance technique")] - # get currect (dept) DB name: + # get current (dept) DB name: cursor = ndb.SimpleQuery("SELECT current_database()", {}) db_name = cursor.fetchone()[0] ano_db_name = "ANO" + db_name @@ -95,28 +92,8 @@ def sco_dump_and_send_db(): _anonymize_db(ano_db_name) # Send - r = _send_db(ano_db_name) - if ( - r.status_code - == requests.codes.INSUFFICIENT_STORAGE # pylint: disable=no-member - ): - H.append( - """

    - Erreur: espace serveur trop plein. - Merci de contacter {0}

    """.format( - scu.SCO_DEV_MAIL - ) - ) - elif r.status_code == requests.codes.OK: # pylint: disable=no-member - H.append("""

    Opération effectuée.

    """) - else: - H.append( - """

    - Erreur: code {0} {1} - Merci de contacter {2}

    """.format( - r.status_code, r.reason, scu.SCO_DEV_MAIL - ) - ) + r = _send_db(ano_db_name, message, request_url) + code = r.status_code finally: # Drop anonymized database @@ -125,8 +102,8 @@ def sco_dump_and_send_db(): fcntl.flock(x, fcntl.LOCK_UN) log("sco_dump_and_send_db: done.") - flash("Données envoyées au serveur d'assistance") - return "\n".join(H) + html_sco_header.sco_footer() + + return code def _duplicate_db(db_name, ano_db_name): @@ -175,7 +152,7 @@ def _get_scodoc_serial(): return 0 -def _send_db(ano_db_name): +def _send_db(ano_db_name: str, message: str = "", request_url: str = ""): """Dump this (anonymized) database and send it to tech support""" log(f"dumping anonymized database {ano_db_name}") try: @@ -184,7 +161,9 @@ def _send_db(ano_db_name): ) except subprocess.CalledProcessError as e: log(f"sco_dump_and_send_db: exception in anonymisation: {e}") - raise ScoValueError(f"erreur lors de l'anonymisation de la base {ano_db_name}") + raise ScoValueError( + f"erreur lors de l'anonymisation de la base {ano_db_name}" + ) from e log("uploading anonymized dump...") files = {"file": (ano_db_name + ".dump", dump)} @@ -193,7 +172,9 @@ def _send_db(ano_db_name): scu.SCO_DUMP_UP_URL, files=files, data={ - "dept_name": sco_preferences.get_preference("DeptName"), + "dept_name": getattr(g, "scodoc_dept", "-"), + "message": message or "", + "request_url": request_url or request.url, "serial": _get_scodoc_serial(), "sco_user": str(current_user), "sent_by": sco_users.user_info(str(current_user))["nomcomplet"], diff --git a/app/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py index cec8b7c2..c53094ef 100644 --- a/app/scodoc/sco_edit_apc.py +++ b/app/scodoc/sco_edit_apc.py @@ -166,7 +166,7 @@ def html_edit_formation_apc( def html_ue_infos(ue): - """page d'information sur une UE""" + """Page d'information sur une UE""" from app.views import ScoData formsemestres = ( @@ -189,7 +189,6 @@ def html_ue_infos(ue): ) return render_template( "pn/ue_infos.html", - # "pn/tmp.html", titre=f"UE {ue.acronyme} {ue.titre}", ue=ue, formsemestres=formsemestres, diff --git a/app/scodoc/sco_edit_matiere.py b/app/scodoc/sco_edit_matiere.py index fb40c2b0..f691e350 100644 --- a/app/scodoc/sco_edit_matiere.py +++ b/app/scodoc/sco_edit_matiere.py @@ -92,7 +92,7 @@ def do_matiere_create(args): sco_news.add( typ=sco_news.NEWS_FORM, object=ue["formation_id"], - text="Modification de la formation {formation.acronyme}", + text=f"Modification de la formation {formation.acronyme}", max_frequency=3, ) formation.invalidate_cached_sems() @@ -200,7 +200,7 @@ def do_matiere_delete(oid): sco_news.add( typ=sco_news.NEWS_FORM, object=ue["formation_id"], - text="Modification de la formation {formation.acronyme}", + text=f"Modification de la formation {formation.acronyme}", max_frequency=3, ) formation.invalidate_cached_sems() diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 8f18a5b2..ffe4d64f 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -35,7 +35,7 @@ from flask_login import current_user from app import db from app import log -from app.models import APO_CODE_STR_LEN +from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN from app.models import Formation, UniteEns, ModuleImpl, Module from app.models.formations import Matiere import app.scodoc.notesdb as ndb @@ -141,7 +141,7 @@ def do_ue_create(args): sco_news.add( typ=sco_news.NEWS_FORM, object=args["formation_id"], - text="Modification de la formation {formation.acronyme}", + text=f"Modification de la formation {formation.acronyme}", max_frequency=3, ) formation.invalidate_cached_sems() @@ -347,7 +347,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No "size": 4, "type": "float", "title": "ECTS", - "explanation": "nombre de crédits ECTS", + "explanation": "nombre de crédits ECTS (indiquer 0 si UE bonus)", "allow_null": not is_apc, # ects requis en APC }, ), @@ -372,7 +372,10 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No { "size": 12, "title": "Code UE", - "explanation": "code interne (non vide). Toutes les UE partageant le même code (et le même code de formation) sont compatibles (compensation de semestres, capitalisation d'UE). Voir liste ci-dessous.", + "max_length": SHORT_STR_LEN, + "explanation": """code interne (non vide). Toutes les UE partageant le même code + (et le même code de formation) sont compatibles (compensation de semestres, capitalisation d'UE). + Voir liste ci-dessous.""", }, ), ( @@ -381,7 +384,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No "title": "Code Apogée", "size": 25, "explanation": "(optionnel) code élément pédagogique Apogée ou liste de codes ELP séparés par des virgules", - "validator": lambda val, _: len(val) < APO_CODE_STR_LEN, + "max_length": APO_CODE_STR_LEN, }, ), ( @@ -724,13 +727,16 @@ du programme" (menu "Semestre") si vous avez un semestre en cours); {formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long}  """ msg_refcomp = "changer" - H.append( - f""" -
      -
    • {descr_refcomp}
    • {descr_refcomp}""") + if current_user.has_permission(Permission.ScoChangeFormation): + H.append( + f"""{msg_refcomp} -
    • + }">{msg_refcomp}""" + ) + + H.append( + f"""
    • éditer les coefficients des ressources et SAÉs diff --git a/app/scodoc/sco_entreprises.py b/app/scodoc/sco_entreprises.py deleted file mode 100644 index 6c9b887f..00000000 --- a/app/scodoc/sco_entreprises.py +++ /dev/null @@ -1,324 +0,0 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Fonctions sur les entreprises -""" -# codes anciens déplacés de ZEntreprise -import datetime -from operator import itemgetter - -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app.scodoc.notesdb import ScoDocCursor, EditableTable, DateISOtoDMY, DateDMYtoISO - - -def _format_nom(nom): - "formatte nom (filtre en entree db) d'une entreprise" - if not nom: - return nom - return nom[0].upper() + nom[1:] - - -class EntreprisesEditor(EditableTable): - def delete(self, cnx, oid): - "delete correspondants and contacts, then self" - # first, delete all correspondants and contacts - cursor = cnx.cursor(cursor_factory=ScoDocCursor) - cursor.execute( - "delete from entreprise_contact where entreprise_id=%(entreprise_id)s", - {"entreprise_id": oid}, - ) - cursor.execute( - "delete from entreprise_correspondant where entreprise_id=%(entreprise_id)s", - {"entreprise_id": oid}, - ) - cnx.commit() - EditableTable.delete(self, cnx, oid) - - def list( - self, - cnx, - args={}, - operator="and", - test="=", - sortkey=None, - sort_on_contact=False, - limit="", - offset="", - ): - # list, then sort on date of last contact - R = EditableTable.list( - self, - cnx, - args=args, - operator=operator, - test=test, - sortkey=sortkey, - limit=limit, - offset=offset, - ) - if sort_on_contact: - for r in R: - c = do_entreprise_contact_list( - args={"entreprise_id": r["entreprise_id"]}, - disable_formatting=True, - ) - if c: - r["date"] = max([x["date"] or datetime.date.min for x in c]) - else: - r["date"] = datetime.date.min - # sort - R.sort(key=itemgetter("date")) - for r in R: - r["date"] = DateISOtoDMY(r["date"]) - return R - - def list_by_etud( - self, cnx, args={}, sort_on_contact=False, disable_formatting=False - ): - "cherche rentreprise ayant eu contact avec etudiant" - cursor = cnx.cursor(cursor_factory=ScoDocCursor) - cursor.execute( - "select E.*, I.nom as etud_nom, I.prenom as etud_prenom, C.date from entreprises E, entreprise_contact C, identite I where C.entreprise_id = E.entreprise_id and C.etudid = I.etudid and I.nom ~* %(etud_nom)s ORDER BY E.nom", - args, - ) - _, res = [x[0] for x in cursor.description], cursor.dictfetchall() - R = [] - for r in res: - r["etud_prenom"] = r["etud_prenom"] or "" - d = {} - for key in r: - v = r[key] - # format value - if not disable_formatting and key in self.output_formators: - v = self.output_formators[key](v) - d[key] = v - R.append(d) - # sort - if sort_on_contact: - R.sort(key=lambda x: (x["date"] or datetime.date.min)) - - for r in R: - r["date"] = DateISOtoDMY(r["date"] or datetime.date.min) - return R - - -_entreprisesEditor = EntreprisesEditor( - "entreprises", - "entreprise_id", - ( - "entreprise_id", - "nom", - "adresse", - "ville", - "codepostal", - "pays", - "contact_origine", - "secteur", - "privee", - "localisation", - "qualite_relation", - "plus10salaries", - "note", - "date_creation", - ), - filter_dept=True, - sortkey="nom", - input_formators={ - "nom": _format_nom, - "plus10salaries": bool, - }, -) - -# ----------- Correspondants -_entreprise_correspEditor = EditableTable( - "entreprise_correspondant", - "entreprise_corresp_id", - ( - "entreprise_corresp_id", - "entreprise_id", - "civilite", - "nom", - "prenom", - "fonction", - "phone1", - "phone2", - "mobile", - "fax", - "mail1", - "mail2", - "note", - ), - sortkey="nom", -) - - -# ----------- Contacts -_entreprise_contactEditor = EditableTable( - "entreprise_contact", - "entreprise_contact_id", - ( - "entreprise_contact_id", - "date", - "type_contact", - "entreprise_id", - "entreprise_corresp_id", - "etudid", - "description", - "enseignant", - ), - sortkey="date", - output_formators={"date": DateISOtoDMY}, - input_formators={"date": DateDMYtoISO}, -) - - -def do_entreprise_create(args): - "entreprise_create" - cnx = ndb.GetDBConnexion() - r = _entreprisesEditor.create(cnx, args) - return r - - -def do_entreprise_delete(oid): - "entreprise_delete" - cnx = ndb.GetDBConnexion() - _entreprisesEditor.delete(cnx, oid) - - -def do_entreprise_list(**kw): - "entreprise_list" - cnx = ndb.GetDBConnexion() - return _entreprisesEditor.list(cnx, **kw) - - -def do_entreprise_list_by_etud(**kw): - "entreprise_list_by_etud" - cnx = ndb.GetDBConnexion() - return _entreprisesEditor.list_by_etud(cnx, **kw) - - -def do_entreprise_edit(*args, **kw): - "entreprise_edit" - cnx = ndb.GetDBConnexion() - _entreprisesEditor.edit(cnx, *args, **kw) - - -def do_entreprise_correspondant_create(args): - "entreprise_correspondant_create" - cnx = ndb.GetDBConnexion() - r = _entreprise_correspEditor.create(cnx, args) - return r - - -def do_entreprise_correspondant_delete(oid): - "entreprise_correspondant_delete" - cnx = ndb.GetDBConnexion() - _entreprise_correspEditor.delete(cnx, oid) - - -def do_entreprise_correspondant_list(**kw): - "entreprise_correspondant_list" - cnx = ndb.GetDBConnexion() - return _entreprise_correspEditor.list(cnx, **kw) - - -def do_entreprise_correspondant_edit(*args, **kw): - "entreprise_correspondant_edit" - cnx = ndb.GetDBConnexion() - _entreprise_correspEditor.edit(cnx, *args, **kw) - - -def do_entreprise_correspondant_listnames(args={}): - "-> liste des noms des correspondants (pour affichage menu)" - C = do_entreprise_correspondant_list(args=args) - return [(x["prenom"] + " " + x["nom"], str(x["entreprise_corresp_id"])) for x in C] - - -def do_entreprise_contact_delete(oid): - "entreprise_contact_delete" - cnx = ndb.GetDBConnexion() - _entreprise_contactEditor.delete(cnx, oid) - - -def do_entreprise_contact_list(**kw): - "entreprise_contact_list" - cnx = ndb.GetDBConnexion() - return _entreprise_contactEditor.list(cnx, **kw) - - -def do_entreprise_contact_edit(*args, **kw): - "entreprise_contact_edit" - cnx = ndb.GetDBConnexion() - _entreprise_contactEditor.edit(cnx, *args, **kw) - - -def do_entreprise_contact_create(args): - "entreprise_contact_create" - cnx = ndb.GetDBConnexion() - r = _entreprise_contactEditor.create(cnx, args) - return r - - -def do_entreprise_check_etudiant(etudiant): - """Si etudiant est vide, ou un ETUDID valide, ou un nom unique, - retourne (1, ETUDID). - Sinon, retourne (0, 'message explicatif') - """ - etudiant = etudiant.strip().translate( - str.maketrans("", "", "'()") - ) # suppress parens and quote from name - if not etudiant: - return 1, None - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ScoDocCursor) - cursor.execute( - "select etudid, nom, prenom from identite where upper(nom) ~ upper(%(etudiant)s) or etudid=%(etudiant)s", - {"etudiant": etudiant}, - ) - r = cursor.fetchall() - if len(r) < 1: - return 0, 'Aucun etudiant ne correspond à "%s"' % etudiant - elif len(r) > 10: - return ( - 0, - "%d etudiants correspondent à ce nom (utilisez le code)" % len(r), - ) - elif len(r) > 1: - e = ['
        '] - for x in r: - e.append( - "
      • %s %s (code %s)
      • " % ((x[1]).upper(), x[2] or "", x[0].strip()) - ) - e.append("
      ") - return ( - 0, - "Les étudiants suivants correspondent: préciser le nom complet ou le code\n" - + "\n".join(e), - ) - else: # une seule reponse ! - return 1, r[0][0].strip() \ No newline at end of file diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index d019d2df..710f85bc 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -33,8 +33,7 @@ import os import time from operator import itemgetter -from flask import url_for, g, request -from flask_mail import Message +from flask import url_for, g from app import email from app import log @@ -46,7 +45,6 @@ from app.scodoc.sco_exceptions import ScoGenError, ScoValueError from app.scodoc import safehtml from app.scodoc import sco_preferences from app.scodoc.scolog import logdb -from app.scodoc.TrivialFormulator import TrivialFormulator def format_etud_ident(etud): @@ -451,7 +449,6 @@ _adresseEditor = ndb.EditableTable( "telephonemobile", "fax", "typeadresse", - "entreprise_id", "description", ), convert_null_outputs_to_empty=True, @@ -860,7 +857,7 @@ def list_scolog(etudid): return cursor.dictfetchall() -def fill_etuds_info(etuds, add_admission=True): +def fill_etuds_info(etuds: list[dict], add_admission=True): """etuds est une liste d'etudiants (mappings) Pour chaque etudiant, ajoute ou formatte les champs -> informations pour fiche etudiant ou listes diverses @@ -977,7 +974,10 @@ def etud_inscriptions_infos(etudid: int, ne="") -> dict: def descr_situation_etud(etudid: int, ne="") -> str: - """chaîne décrivant la situation actuelle de l'étudiant""" + """Chaîne décrivant la situation actuelle de l'étudiant + XXX Obsolete, utiliser Identite.descr_situation_etud() dans + les nouveaux codes + """ from app.scodoc import sco_formsemestre cnx = ndb.GetDBConnexion() diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py index d895a8a3..43b58590 100644 --- a/app/scodoc/sco_evaluations.py +++ b/app/scodoc/sco_evaluations.py @@ -39,7 +39,7 @@ from flask import request 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 import app.scodoc.sco_utils as scu diff --git a/app/scodoc/sco_exceptions.py b/app/scodoc/sco_exceptions.py index 112658e6..35d2d9d6 100644 --- a/app/scodoc/sco_exceptions.py +++ b/app/scodoc/sco_exceptions.py @@ -47,9 +47,12 @@ class ScoValueError(ScoException): self.dest_url = dest_url +class ScoBugCatcher(ScoException): + "bug avec enquete en cours" + + class NoteProcessError(ScoValueError): "Valeurs notes invalides" - pass class InvalidEtudId(NoteProcessError): @@ -112,8 +115,9 @@ class ScoNonEmptyFormationObject(ScoValueError): class ScoInvalidIdType(ScoValueError): - """Pour les clients qui s'obstinnent à utiliser des bookmarks ou - historiques anciens avec des ID ScoDoc7""" + """Pour les clients qui s'obstinent à utiliser des bookmarks + ou historiques anciens avec des ID ScoDoc7. + """ def __init__(self, msg=""): import app.scodoc.sco_utils as scu diff --git a/app/scodoc/sco_export_results.py b/app/scodoc/sco_export_results.py index 1003790b..30750e3e 100644 --- a/app/scodoc/sco_export_results.py +++ b/app/scodoc/sco_export_results.py @@ -30,7 +30,7 @@ from flask import url_for, g, request 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.notesdb as ndb import app.scodoc.sco_utils as scu diff --git a/app/scodoc/sco_find_etud.py b/app/scodoc/sco_find_etud.py index 4bc039ba..9c563048 100644 --- a/app/scodoc/sco_find_etud.py +++ b/app/scodoc/sco_find_etud.py @@ -180,7 +180,9 @@ def search_etud_in_dept(expnom=""): e["_nomprenom_target"] = target e["inscription_target"] = target e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"]) - sco_groups.etud_add_group_infos(e, e["cursem"]) + sco_groups.etud_add_group_infos( + e, e["cursem"]["formsemestre_id"] if e["cursem"] else None + ) tab = GenTable( columns_ids=("nomprenom", "code_nip", "inscription", "groupes"), diff --git a/app/scodoc/sco_formations.py b/app/scodoc/sco_formations.py index 573c95df..8b99a067 100644 --- a/app/scodoc/sco_formations.py +++ b/app/scodoc/sco_formations.py @@ -328,11 +328,15 @@ def formation_list_table(formation_id=None, args={}): "session_id)s " % s for s in f["sems"] ] - + [ - 'ajouter ' - % (f["acronyme"].lower().replace(" ", "-"), f["formation_id"]) - ] + + ( + [ + 'ajouter ' + % (f["acronyme"].lower().replace(" ", "-"), f["formation_id"]) + ] + if current_user.has_permission(Permission.ScoImplement) + else [] + ) ) if f["sems"]: f["date_fin_dernier_sem"] = max([s["date_fin_iso"] for s in f["sems"]]) diff --git a/app/scodoc/sco_formsemestre_exterieurs.py b/app/scodoc/sco_formsemestre_exterieurs.py index 99f64a22..9dfcbbb6 100644 --- a/app/scodoc/sco_formsemestre_exterieurs.py +++ b/app/scodoc/sco_formsemestre_exterieurs.py @@ -38,7 +38,7 @@ from flask import url_for, g, 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 import app.scodoc.notesdb as ndb diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index 44c75dd0..b3fe9853 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -33,7 +33,7 @@ import flask from flask import url_for, g, request 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 import log diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 2080e957..275605d8 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -31,12 +31,12 @@ from flask import current_app from flask import g from flask import request -from flask import url_for +from flask import render_template, url_for from flask_login import current_user 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 Module from app.models.formsemestre import FormSemestre import app.scodoc.sco_utils as scu @@ -411,7 +411,7 @@ def formsemestre_status_menubar(sem): "enabled": sco_permissions_check.can_validate_sem(formsemestre_id), }, { - "title": "Editer les PV et archiver les résultats", + "title": "Éditer les PV et archiver les résultats", "endpoint": "notes.formsemestre_archive", "args": {"formsemestre_id": formsemestre_id}, "enabled": sco_permissions_check.can_edit_pv(formsemestre_id), @@ -445,6 +445,7 @@ def retreive_formsemestre_from_request() -> int: """Cherche si on a de quoi déduire le semestre affiché à partir des arguments de la requête: formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id + Returns None si pas défini. """ if request.method == "GET": args = request.args @@ -505,34 +506,17 @@ def formsemestre_page_title(): return "" try: formsemestre_id = int(formsemestre_id) - sem = sco_formsemestre.get_formsemestre(formsemestre_id).copy() + formsemestre = FormSemestre.query.get(formsemestre_id) except: log("can't find formsemestre_id %s" % formsemestre_id) return "" - fill_formsemestre(sem) - - h = f"""
      - - {formsemestre_status_menubar(sem)} -
      - """ + h = render_template( + "formsemestre_page_title.html", + formsemestre=formsemestre, + scu=scu, + sem_menu_bar=formsemestre_status_menubar(formsemestre.to_dict()), + ) return h @@ -1186,8 +1170,10 @@ def formsemestre_tableau_modules( H.append('' % fontorange) H.append( - '%s' - % (modimpl["moduleimpl_id"], mod_descr, mod.code) + f"""{mod.code}""" ) H.append( '%s' diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index c83e1cc4..96d0a737 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -37,7 +37,7 @@ import app.scodoc.sco_utils as scu 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.notes import etud_has_notes_attente @@ -591,12 +591,14 @@ def formsemestre_recap_parcours_table( etud_ue_status = { ue["ue_id"]: nt.get_etud_ue_status(etudid, ue["ue_id"]) for ue in ues } - ues = [ - ue - for ue in ues - if etud_est_inscrit_ue(cnx, etudid, sem["formsemestre_id"], ue["ue_id"]) - or etud_ue_status[ue["ue_id"]]["is_capitalized"] - ] + if not nt.is_apc: + # formations classiques: filtre UE sur inscriptions (et garde UE capitalisées) + ues = [ + ue + for ue in ues + if etud_est_inscrit_ue(cnx, etudid, sem["formsemestre_id"], ue["ue_id"]) + or etud_ue_status[ue["ue_id"]]["is_capitalized"] + ] for ue in ues: H.append('%s' % ue["acronyme"]) diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py index 6a1ee467..8e834b23 100644 --- a/app/scodoc/sco_groups.py +++ b/app/scodoc/sco_groups.py @@ -46,7 +46,7 @@ from flask import url_for, make_response from app import db 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, formsemestre from app.models import GROUPNAME_STR_LEN, SHORT_STR_LEN from app.models.groups import Partition @@ -124,7 +124,7 @@ def get_partition(partition_id): {"partition_id": partition_id}, ) if not r: - raise ValueError("invalid partition_id (%s)" % partition_id) + raise ScoValueError(f"Partition inconnue (déjà supprimée ?) ({partition_id})") return r[0] @@ -321,7 +321,7 @@ def get_group_infos(group_id, etat=None): # was _getlisteetud t["etath"] = t["etat"] # Add membership for all partitions, 'partition_id' : group for etud in members: # long: comment eviter ces boucles ? - etud_add_group_infos(etud, sem) + etud_add_group_infos(etud, sem["formsemestre_id"]) if group["group_name"] != None: group_tit = "%s %s" % (group["partition_name"], group["group_name"]) @@ -343,7 +343,7 @@ def get_group_other_partitions(group): return other_partitions -def get_etud_groups(etudid, sem, exclude_default=False): +def get_etud_groups(etudid: int, formsemestre_id: int, exclude_default=False): """Infos sur groupes de l'etudiant dans ce semestre [ group + partition_name ] """ @@ -358,18 +358,18 @@ def get_etud_groups(etudid, sem, exclude_default=False): req += " and p.partition_name is not NULL" groups = ndb.SimpleDictFetch( req + " ORDER BY p.numero", - {"etudid": etudid, "formsemestre_id": sem["formsemestre_id"]}, + {"etudid": etudid, "formsemestre_id": formsemestre_id}, ) return _sortgroups(groups) -def get_etud_main_group(etudid, sem): +def get_etud_main_group(etudid: int, formsemestre_id: int): """Return main group (the first one) for etud, or default one if no groups""" - groups = get_etud_groups(etudid, sem, exclude_default=True) + groups = get_etud_groups(etudid, formsemestre_id, exclude_default=True) if groups: return groups[0] else: - return get_group(get_default_group(sem["formsemestre_id"])) + return get_group(get_default_group(formsemestre_id)) def formsemestre_get_main_partition(formsemestre_id): @@ -413,12 +413,12 @@ def formsemestre_get_etud_groupnames(formsemestre_id, attr="group_name"): return R -def etud_add_group_infos(etud, sem, sep=" "): +def etud_add_group_infos(etud, formsemestre_id, sep=" "): """Add informations on partitions and group memberships to etud (a dict with an etudid)""" etud[ "partitions" ] = collections.OrderedDict() # partition_id : group + partition_name - if not sem: + if not formsemestre_id: etud["groupes"] = "" return etud @@ -430,7 +430,7 @@ def etud_add_group_infos(etud, sem, sep=" "): and p.formsemestre_id = %(formsemestre_id)s ORDER BY p.numero """, - {"etudid": etud["etudid"], "formsemestre_id": sem["formsemestre_id"]}, + {"etudid": etud["etudid"], "formsemestre_id": formsemestre_id}, ) for info in infos: @@ -439,13 +439,13 @@ def etud_add_group_infos(etud, sem, sep=" "): # resume textuel des groupes: etud["groupes"] = sep.join( - [g["group_name"] for g in infos if g["group_name"] != None] + [gr["group_name"] for gr in infos if gr["group_name"] is not None] ) etud["partitionsgroupes"] = sep.join( [ - g["partition_name"] + ":" + g["group_name"] - for g in infos - if g["group_name"] != None + gr["partition_name"] + ":" + gr["group_name"] + for gr in infos + if gr["group_name"] is not None ] ) diff --git a/app/scodoc/sco_import_etuds.py b/app/scodoc/sco_import_etuds.py index b717f2d1..da117b5d 100644 --- a/app/scodoc/sco_import_etuds.py +++ b/app/scodoc/sco_import_etuds.py @@ -154,9 +154,9 @@ def sco_import_generate_excel_sample( with_codesemestre=True, only_tables=None, with_groups=True, - exclude_cols=[], - extra_cols=[], - group_ids=[], + exclude_cols=(), + extra_cols=(), + group_ids=(), ): """Generates an excel document based on format fmt (format is the result of sco_import_format()) @@ -167,7 +167,7 @@ def sco_import_generate_excel_sample( style = sco_excel.excel_make_style(bold=True) style_required = sco_excel.excel_make_style(bold=True, color=COLORS.RED) titles = [] - titlesStyles = [] + titles_styles = [] for l in fmt: name = l[0].lower() if (not with_codesemestre) and name == "codesemestre": @@ -177,15 +177,15 @@ def sco_import_generate_excel_sample( if name in exclude_cols: continue # colonne exclue if int(l[3]): - titlesStyles.append(style) + titles_styles.append(style) else: - titlesStyles.append(style_required) + titles_styles.append(style_required) titles.append(name) if with_groups and "groupes" not in titles: titles.append("groupes") - titlesStyles.append(style) + titles_styles.append(style) titles += extra_cols - titlesStyles += [style] * len(extra_cols) + titles_styles += [style] * len(extra_cols) if group_ids: groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) members = groups_infos.members @@ -194,7 +194,7 @@ def sco_import_generate_excel_sample( % (group_ids, len(members)) ) titles = ["etudid"] + titles - titlesStyles = [style] + titlesStyles + titles_styles = [style] + titles_styles # rempli table avec données actuelles lines = [] for i in members: @@ -203,7 +203,7 @@ def sco_import_generate_excel_sample( for field in titles: if field == "groupes": sco_groups.etud_add_group_infos( - etud, groups_infos.formsemestre, sep=";" + etud, groups_infos.formsemestre_id, sep=";" ) l.append(etud["partitionsgroupes"]) else: @@ -213,7 +213,7 @@ def sco_import_generate_excel_sample( else: lines = [[]] # empty content, titles only return sco_excel.excel_simple_table( - titles=titles, titles_styles=titlesStyles, sheet_name="Etudiants", lines=lines + titles=titles, titles_styles=titles_styles, sheet_name="Etudiants", lines=lines ) @@ -256,7 +256,7 @@ def scolars_import_excel_file( formsemestre_id=None, check_homonyms=True, require_ine=False, - exclude_cols=[], + exclude_cols=(), ): """Importe etudiants depuis fichier Excel et les inscrit dans le semestre indiqué (et à TOUS ses modules) @@ -302,7 +302,8 @@ def scolars_import_excel_file( else: unknown.append(f) raise ScoValueError( - "Nombre de colonnes incorrect (devrait être %d, et non %d)
      (colonnes manquantes: %s, colonnes invalides: %s)" + """Nombre de colonnes incorrect (devrait être %d, et non %d)
      + (colonnes manquantes: %s, colonnes invalides: %s)""" % (len(titles), len(fs), list(missing.keys()), unknown) ) titleslist = [] @@ -313,7 +314,7 @@ def scolars_import_excel_file( # ok, same titles # Start inserting data, abort whole transaction in case of error created_etudids = [] - NbImportedHomonyms = 0 + np_imported_homonyms = 0 GroupIdInferers = {} try: # --- begin DB transaction linenum = 0 @@ -377,10 +378,10 @@ def scolars_import_excel_file( if val: try: val = sco_excel.xldate_as_datetime(val) - except ValueError: + except ValueError as exc: raise ScoValueError( f"date invalide ({val}) sur ligne {linenum}, colonne {titleslist[i]}" - ) + ) from exc # INE if ( titleslist[i].lower() == "code_ine" @@ -404,15 +405,17 @@ def scolars_import_excel_file( if values["code_ine"] and not is_new_ine: raise ScoValueError("Code INE dupliqué (%s)" % values["code_ine"]) # Check nom/prenom - ok, NbHomonyms = sco_etud.check_nom_prenom( - cnx, nom=values["nom"], prenom=values["prenom"] - ) + ok = False + if "nom" in values and "prenom" in values: + ok, nb_homonyms = sco_etud.check_nom_prenom( + cnx, nom=values["nom"], prenom=values["prenom"] + ) if not ok: raise ScoValueError( "nom ou prénom invalide sur la ligne %d" % (linenum) ) - if NbHomonyms: - NbImportedHomonyms += 1 + if nb_homonyms: + np_imported_homonyms += 1 # Insert in DB tables formsemestre_id_etud = _import_one_student( cnx, @@ -425,11 +428,11 @@ def scolars_import_excel_file( ) # Verification proportion d'homonymes: si > 10%, abandonne - log("scolars_import_excel_file: detected %d homonyms" % NbImportedHomonyms) - if check_homonyms and NbImportedHomonyms > len(created_etudids) / 10: + log("scolars_import_excel_file: detected %d homonyms" % np_imported_homonyms) + if check_homonyms and np_imported_homonyms > len(created_etudids) / 10: log("scolars_import_excel_file: too many homonyms") raise ScoValueError( - "Il y a trop d'homonymes (%d étudiants)" % NbImportedHomonyms + "Il y a trop d'homonymes (%d étudiants)" % np_imported_homonyms ) except: cnx.rollback() diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py index 831cc87a..807792fb 100644 --- a/app/scodoc/sco_inscr_passage.py +++ b/app/scodoc/sco_inscr_passage.py @@ -196,7 +196,10 @@ def do_inscrit(sem, etudids, inscrit_groupes=False): if len(etud["sems"]) < 2: continue prev_formsemestre = etud["sems"][1] - sco_groups.etud_add_group_infos(etud, prev_formsemestre) + sco_groups.etud_add_group_infos( + etud, + prev_formsemestre["formsemestre_id"] if prev_formsemestre else None, + ) cursem_groups_by_name = dict( [ diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index 3c41935e..7a22ccbe 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -27,6 +27,8 @@ """Liste des notes d'une évaluation """ +from collections import defaultdict +import numpy as np import flask from flask import url_for, g, request @@ -36,15 +38,15 @@ from app import models from app.comp import res_sem from app.comp import moy_mod from app.comp.moy_mod import ModuleImplResults -from app.comp.res_common import NotesTableCompat +from app.comp.res_compat import NotesTableCompat +from app.comp.res_but import ResultatsSemestreBUT from app.models import FormSemestre from app.models.evaluations import Evaluation from app.models.moduleimpls import ModuleImpl import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app.scodoc.TrivialFormulator import TrivialFormulator -from app.scodoc import sco_cache -from app.scodoc import sco_edit_module + from app.scodoc import sco_evaluations from app.scodoc import sco_evaluation_db from app.scodoc import sco_formsemestre @@ -321,7 +323,7 @@ def _make_table_notes( etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] if etat == "I": # si inscrit, indique groupe - groups = sco_groups.get_etud_groups(etudid, sem) + groups = sco_groups.get_etud_groups(etudid, modimpl_o["formsemestre_id"]) grc = sco_groups.listgroups_abbrev(groups) else: if etat == "D": @@ -340,12 +342,17 @@ def _make_table_notes( "_code_td_attrs": 'style="padding-left: 1em; padding-right: 2em;"', "etudid": etudid, "nom": etud["nom"].upper(), - "_nomprenom_target": "formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s" - % (modimpl_o["formsemestre_id"], etudid), - "_nomprenom_td_attrs": 'id="%s" class="etudinfo"' % (etud["etudid"]), + "_nomprenom_target": url_for( + "notes.formsemestre_bulletinetud", + scodoc_dept=g.scodoc_dept, + formsemestre_id=modimpl_o["formsemestre_id"], + etudid=etudid, + ), + "_nomprenom_td_attrs": f"""id="{etudid}" class="etudinfo" data-sort="{etud.get('nom', '').upper()}" """, "prenom": etud["prenom"].lower().capitalize(), "nomprenom": etud["nomprenom"], "group": grc, + "_group_td_attrs": 'class="group"', "email": etud["email"], "emailperso": etud["emailperso"], "_css_row_class": css_row_class or "", @@ -384,7 +391,7 @@ def _make_table_notes( "_css_row_class": "moyenne sortbottom", "_table_part": "foot", #'_nomprenom_td_attrs' : 'colspan="2" ', - "nomprenom": "Moyenne (sans les absents) :", + "nomprenom": "Moyenne :", "comment": "", } # Ajoute les notes de chaque évaluation: @@ -574,7 +581,7 @@ def _make_table_notes( page_title="Notes de " + sem["titremois"], html_title=html_title, pdf_title=pdf_title, - html_class="table_leftalign notes_evaluation", + html_class="notes_evaluation", preferences=sco_preferences.SemPreferences(modimpl_o["formsemestre_id"]), # html_generate_cells=False # la derniere ligne (moyennes) est incomplete ) @@ -590,9 +597,15 @@ def _make_table_notes( if not e["eval_state"]["evalcomplete"]: all_complete = False if all_complete: - eval_info = 'Evaluations prises en compte dans les moyennes' + eval_info = 'Evaluations prises en compte dans les moyennes.' else: - eval_info = 'Les évaluations en vert et orange sont prises en compte dans les moyennes. Celles en rouge n\'ont pas toutes leurs notes.' + eval_info = """ + Les évaluations en vert et orange sont prises en compte dans les moyennes. + Celles en rouge n'ont pas toutes leurs notes.""" + if is_apc: + eval_info += """ La moyenne indicative est la moyenne des moyennes d'UE, et n'est pas utilisée en BUT. + Les moyennes sur le groupe sont estimées sans les absents (sauf pour les moyennes des moyennes d'UE) ni les démissionnaires.""" + eval_info += """""" return html_form + eval_info + t + "

      " else: # Une seule evaluation: ajoute histogramme @@ -657,6 +670,7 @@ def _add_eval_columns( notes = [] # liste des notes numeriques, pour calcul histogramme uniquement evaluation_id = e["evaluation_id"] e_o = Evaluation.query.get(evaluation_id) # XXX en attendant ré-écriture + inscrits = e_o.moduleimpl.formsemestre.etudids_actifs # set d'etudids notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) for row in rows: etudid = row["etudid"] @@ -666,8 +680,13 @@ def _add_eval_columns( nb_abs += 1 if val == scu.NOTES_ATTENTE: nb_att += 1 - # calcul moyenne SANS LES ABSENTS - if val != None and val != scu.NOTES_NEUTRALISE and val != scu.NOTES_ATTENTE: + # calcul moyenne SANS LES ABSENTS ni les DEMISSIONNAIRES + if ( + (etudid in inscrits) + and val != None + and val != scu.NOTES_NEUTRALISE + and val != scu.NOTES_ATTENTE + ): if e["note_max"] > 0: valsur20 = val * 20.0 / e["note_max"] # remet sur 20 else: @@ -791,6 +810,7 @@ def _add_moymod_column( col_id = "moymod" formsemestre = FormSemestre.query.get_or_404(formsemestre_id) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + inscrits = formsemestre.etudids_actifs nb_notes = 0 sum_notes = 0 @@ -800,7 +820,7 @@ def _add_moymod_column( val = nt.get_etud_mod_moy(moduleimpl_id, etudid) # note sur 20, ou 'NA','NI' row[col_id] = scu.fmt_note(val, keep_numeric=keep_numeric) row["_" + col_id + "_td_attrs"] = ' class="moyenne" ' - if not isinstance(val, str): + if etudid in inscrits and not isinstance(val, str): notes.append(val) nb_notes = nb_notes + 1 sum_notes += val @@ -840,18 +860,16 @@ def _add_apc_columns( # => On recharge tout dans les nouveaux modèles # rows est une liste de dict avec une clé "etudid" # on va y ajouter une clé par UE du semestre - nt: NotesTableCompat = res_sem.load_formsemestre_results(modimpl.formsemestre) + nt: ResultatsSemestreBUT = res_sem.load_formsemestre_results(modimpl.formsemestre) modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl.id] - - # XXX A ENLEVER TODO - # modimpl = ModuleImpl.query.get(moduleimpl_id) - - # evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( - # moduleimpl_id - # ) - # etuds_moy_module = moy_mod.compute_module_moy( - # evals_notes, evals_poids, evaluations, evaluations_completes - # ) + inscrits = modimpl.formsemestre.etudids_actifs + # les UE dans lesquelles ce module a un coef non nul: + ues_with_coef = nt.modimpl_coefs_df[modimpl.id][ + nt.modimpl_coefs_df[modimpl.id] > 0 + ].index + ues = [ue for ue in ues if ue.id in ues_with_coef] + sum_by_ue = defaultdict(float) + nb_notes_by_ue = defaultdict(int) if is_conforme: # valeur des moyennes vers les UEs: for row in rows: @@ -859,6 +877,13 @@ def _add_apc_columns( moy_ue = modimpl_results.etuds_moy_module[ue.id].get(row["etudid"], "?") row[f"moy_ue_{ue.id}"] = scu.fmt_note(moy_ue, keep_numeric=keep_numeric) row[f"_moy_ue_{ue.id}_class"] = "moy_ue" + if ( + isinstance(moy_ue, float) + and not np.isnan(moy_ue) + and row["etudid"] in inscrits + ): + sum_by_ue[ue.id] += moy_ue + nb_notes_by_ue[ue.id] += 1 # Nom et coefs des UE (lignes titres): ue_coefs = modimpl.module.ue_coefs if is_conforme: @@ -873,3 +898,8 @@ def _add_apc_columns( if coefs: row_coefs[f"moy_ue_{ue.id}"] = coefs[0].coef row_coefs[f"_moy_ue_{ue.id}_td_attrs"] = f' class="{coef_class}" ' + if nb_notes_by_ue[ue.id] > 0: + row_moys[col_id] = "%.3g" % (sum_by_ue[ue.id] / nb_notes_by_ue[ue.id]) + row_moys["_" + col_id + "_help"] = "moyenne des moyennes" + else: + row_moys[col_id] = "" diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index 453f63f1..76871cf3 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -34,7 +34,7 @@ from flask import url_for, g, 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.notesdb as ndb @@ -192,7 +192,7 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): ) H.append("""""") - groups = sco_groups.get_etud_groups(etud["etudid"], sem) + groups = sco_groups.get_etud_groups(etud["etudid"], formsemestre_id) for partition in partitions: if partition["partition_name"]: gr_name = "" @@ -303,12 +303,9 @@ def moduleimpl_inscriptions_stats(formsemestre_id): ) for mod in options: if can_change: - c_link = ( - '%s' - % ( - mod["moduleimpl_id"], - mod["descri"] or "(inscrire des étudiants)", - ) + c_link = '%s' % ( + mod["moduleimpl_id"], + mod["descri"] or "(inscrire des étudiants)", ) else: c_link = mod["descri"] diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index 52d3282f..a82cc2b1 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -34,8 +34,7 @@ from flask_login import current_user from app.auth.models import User from app.comp import res_sem -from app.comp.res_common import NotesTableCompat -from app.models import FormSemestre +from app.comp.res_compat import NotesTableCompat from app.models import ModuleImpl from app.models.evaluations import Evaluation import app.scodoc.sco_utils as scu diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 35852139..3a99d223 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -215,7 +215,9 @@ def ficheEtud(etudid=None): info["modifadresse"] = "" # Groupes: - sco_groups.etud_add_group_infos(info, info["cursem"]) + sco_groups.etud_add_group_infos( + info, info["cursem"]["formsemestre_id"] if info["cursem"] else None + ) # Parcours de l'étudiant if info["sems"]: @@ -233,7 +235,7 @@ def ficheEtud(etudid=None): ) grlink = '%s' % descr["situation"] else: - group = sco_groups.get_etud_main_group(etudid, sem) + group = sco_groups.get_etud_main_group(etudid, sem["formsemestre_id"]) if group["partition_name"]: gr_name = group["group_name"] else: @@ -582,7 +584,7 @@ def etud_info_html(etudid, with_photo="1", debug=False): elif etud["cursem"]: # le semestre "en cours" pour l'étudiant sem = etud["cursem"] if sem: - groups = sco_groups.get_etud_groups(etudid, sem) + groups = sco_groups.get_etud_groups(etudid, formsemestre_id) grc = sco_groups.listgroups_abbrev(groups) H += '
      En S%d: %s
      ' % (sem["semestre_id"], grc) H += "" # fin partie gauche (eid_left) diff --git a/app/scodoc/sco_parcours_dut.py b/app/scodoc/sco_parcours_dut.py index fbf190c5..8c1d23a4 100644 --- a/app/scodoc/sco_parcours_dut.py +++ b/app/scodoc/sco_parcours_dut.py @@ -29,7 +29,7 @@ """ 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, UniteEns import app.scodoc.sco_utils as scu @@ -1011,7 +1011,9 @@ def formsemestre_has_decisions(formsemestre_id): def etud_est_inscrit_ue(cnx, etudid, formsemestre_id, ue_id): - """Vrai si l'étudiant est inscrit à au moins un module de cette UE dans ce semestre""" + """Vrai si l'étudiant est inscrit à au moins un module de cette UE dans ce semestre. + Ne pas utiliser pour les formations APC ! + """ cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) cursor.execute( """SELECT mi.* diff --git a/app/scodoc/sco_pdf.py b/app/scodoc/sco_pdf.py index 3949f50a..2f468ccf 100755 --- a/app/scodoc/sco_pdf.py +++ b/app/scodoc/sco_pdf.py @@ -44,22 +44,17 @@ import traceback import unicodedata import reportlab -from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak -from reportlab.platypus import Table, TableStyle, Image, KeepInFrame +from reportlab.pdfgen import canvas +from reportlab.platypus import Paragraph, Frame from reportlab.platypus.flowables import Flowable from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate -from reportlab.lib.styles import getSampleStyleSheet from reportlab.rl_config import defaultPageSize # pylint: disable=no-name-in-module from reportlab.lib.units import inch, cm, mm -from reportlab.lib.colors import pink, black, red, blue, green, magenta, red -from reportlab.lib.colors import Color -from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY from reportlab.lib import styles -from reportlab.lib.pagesizes import letter, A4, landscape + from flask import g -import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import CONFIG from app import log from app.scodoc.sco_exceptions import ScoGenError, ScoValueError @@ -89,6 +84,12 @@ def SU(s): return s +def get_available_font_names() -> list[str]: + """List installed font names""" + can = canvas.Canvas(io.StringIO()) + return can.getAvailableFonts() + + def _splitPara(txt): "split a string, returns a list of ... " L = [] @@ -147,12 +148,26 @@ def makeParas(txt, style, suppress_empty=False): except Exception as e: log(traceback.format_exc()) log("Invalid pdf para format: %s" % txt) - result = [ - Paragraph( - SU('Erreur: format invalide'), - style, - ) - ] + try: + result = [ + Paragraph( + SU('Erreur: format invalide'), + style, + ) + ] + except ValueError as e: # probleme font ? essaye sans style + # recupere font en cause ? + m = re.match(r".*family/bold/italic for (.*)", e.args[0], re.DOTALL) + if m: + message = f"police non disponible: {m[1]}" + else: + message = "format invalide" + return [ + Paragraph( + SU(f'Erreur: {message}'), + reportlab.lib.styles.ParagraphStyle({}), + ) + ] return result @@ -166,7 +181,6 @@ def bold_paras(L, tag="b", close=None): if hasattr(L, "keys"): # L is a dict for k in L: - x = L[k] if k[0] != "_": L[k] = b + L[k] or "" + close return L @@ -175,7 +189,29 @@ def bold_paras(L, tag="b", close=None): return [b + (x or "") + close for x in L] -class ScolarsPageTemplate(PageTemplate): +class BulMarker(Flowable): + """Custom Flowables pour nos bulletins PDF: invisibles, juste pour se repérer""" + + def wrap(self, *args): + return (0, 0) + + def draw(self): + return + + +class DebutBulletin(BulMarker): + """Début d'un bulletin. + Element vide utilisé pour générer les bookmarks + """ + + def __init__(self, bookmark=None, filigranne=None, footer_content=None): + self.bookmark = bookmark + self.filigranne = filigranne + self.footer_content = footer_content + super().__init__() + + +class ScoDocPageTemplate(PageTemplate): """Our own page template.""" def __init__( @@ -192,17 +228,17 @@ class ScolarsPageTemplate(PageTemplate): preferences=None, # dictionnary with preferences, required ): """Initialise our page template.""" - from app.scodoc.sco_logos import ( - find_logo, - ) # defered import (solve circular dependency ->sco_logo ->scodoc, ->sco_pdf + # defered import (solve circular dependency ->sco_logo ->scodoc, ->sco_pdf + from app.scodoc.sco_logos import find_logo self.preferences = preferences - self.pagesbookmarks = pagesbookmarks + self.pagesbookmarks = pagesbookmarks or {} self.pdfmeta_author = author self.pdfmeta_title = title self.pdfmeta_subject = subject self.server_name = server_name self.filigranne = filigranne + self.page_number = 1 self.footer_template = footer_template if self.preferences: self.with_page_background = self.preferences["bul_pdf_with_background"] @@ -217,7 +253,7 @@ class ScolarsPageTemplate(PageTemplate): document.pagesize[0] - 20.0 * mm - left * mm - right * mm, document.pagesize[1] - 18.0 * mm - top * mm - bottom * mm, ) - PageTemplate.__init__(self, "ScolarsPageTemplate", [content]) + super().__init__("ScoDocPageTemplate", [content]) self.logo = None logo = find_logo( logoname="bul_pdf_background", dept_id=g.scodoc_dept_id @@ -234,7 +270,7 @@ class ScolarsPageTemplate(PageTemplate): if logo is not None: self.background_image_filename = logo.filepath - def beforeDrawPage(self, canvas, doc): + def beforeDrawPage(self, canv, doc): """Draws (optional) background, logo and contribution message on each page. day : Day of the month as a decimal number [01,31] @@ -249,10 +285,10 @@ class ScolarsPageTemplate(PageTemplate): """ if not self.preferences: return - canvas.saveState() + canv.saveState() # ---- Background image if self.background_image_filename and self.with_page_background: - canvas.drawImage( + canv.drawImage( self.background_image_filename, 0, 0, doc.pagesize[0], doc.pagesize[1] ) @@ -263,50 +299,93 @@ class ScolarsPageTemplate(PageTemplate): (width, height), image, ) = self.logo - canvas.drawImage(image, inch, doc.pagesize[1] - inch, width, height) + canv.drawImage(image, inch, doc.pagesize[1] - inch, width, height) + # ---- Add some meta data and bookmarks + if self.pdfmeta_author: + canv.setAuthor(SU(self.pdfmeta_author)) + if self.pdfmeta_title: + canv.setTitle(SU(self.pdfmeta_title)) + if self.pdfmeta_subject: + canv.setSubject(SU(self.pdfmeta_subject)) + + bookmark = self.pagesbookmarks.get(doc.page, None) + if bookmark: + canv.bookmarkPage(bookmark) + canv.addOutlineEntry(SU(bookmark), bookmark) + + def draw_footer(self, canv, content): + """Print the footer""" + canv.setFont( + self.preferences["SCOLAR_FONT"], self.preferences["SCOLAR_FONT_SIZE_FOOT"] + ) + canv.drawString( + self.preferences["pdf_footer_x"] * mm, + self.preferences["pdf_footer_y"] * mm, + content, + ) + canv.restoreState() + + def footer_string(self) -> str: + """String contenu du pied de page""" + d = _makeTimeDict() + d["scodoc_name"] = sco_version.SCONAME + d["server_url"] = self.server_name + return SU(self.footer_template % d) + + def afterDrawPage(self, canv, doc): + if not self.preferences: + return + # ---- Footer + foot_content = None + if hasattr(doc, "current_footer"): + foot_content = doc.current_footer + self.draw_footer(canv, foot_content or self.footer_string()) # ---- Filigranne (texte en diagonal en haut a gauche de chaque page) - if self.filigranne: + filigranne = None + if hasattr(doc, "filigranne"): + # filigranne crée par DebutBulletin + filigranne = doc.filigranne + if not filigranne and self.filigranne: if isinstance(self.filigranne, str): filigranne = self.filigranne # same for all pages else: filigranne = self.filigranne.get(doc.page, None) - if filigranne: - canvas.saveState() - canvas.translate(9 * cm, 27.6 * cm) - canvas.rotate(30) - canvas.scale(4.5, 4.5) - canvas.setFillColorRGB(1.0, 0.65, 0.65) - canvas.drawRightString(0, 0, SU(filigranne)) - canvas.restoreState() + if filigranne: + canv.saveState() + canv.translate(9 * cm, 27.6 * cm) + canv.rotate(30) + canv.scale(4.5, 4.5) + canv.setFillColorRGB(1.0, 0.65, 0.65, alpha=0.6) + canv.drawRightString(0, 0, SU(filigranne)) + canv.restoreState() + doc.filigranne = None - # ---- Add some meta data and bookmarks - if self.pdfmeta_author: - canvas.setAuthor(SU(self.pdfmeta_author)) - if self.pdfmeta_title: - canvas.setTitle(SU(self.pdfmeta_title)) - if self.pdfmeta_subject: - canvas.setSubject(SU(self.pdfmeta_subject)) - bm = self.pagesbookmarks.get(doc.page, None) - if bm != None: - key = bm - txt = SU(bm) - canvas.bookmarkPage(key) - canvas.addOutlineEntry(txt, bm) - # ---- Footer - canvas.setFont( - self.preferences["SCOLAR_FONT"], self.preferences["SCOLAR_FONT_SIZE_FOOT"] - ) - d = _makeTimeDict() - d["scodoc_name"] = sco_version.SCONAME - d["server_url"] = self.server_name - footer_str = SU(self.footer_template % d) - canvas.drawString( - self.preferences["pdf_footer_x"] * mm, - self.preferences["pdf_footer_y"] * mm, - footer_str, - ) - canvas.restoreState() + def afterPage(self): + """Called after all flowables have been drawn on a page. + Increment pageNum since the page has been completed. + """ + self.page_number += 1 + + +class BulletinDocTemplate(BaseDocTemplate): + """Doc template pour les bulletins PDF + ajoute la gestion des bookmarks + """ + + # inspired by https://www.reportlab.com/snippets/13/ + def afterFlowable(self, flowable): + """Called by Reportlab after each flowable""" + if isinstance(flowable, DebutBulletin): + self.current_footer = "" + if flowable.bookmark: + self.current_footer = flowable.footer_content + self.canv.bookmarkPage(flowable.bookmark) + self.canv.addOutlineEntry( + SU(flowable.bookmark), flowable.bookmark, level=0, closed=None + ) + if flowable.filigranne: + self.filigranne = flowable.filigranne def _makeTimeDict(): @@ -333,7 +412,7 @@ def pdf_basic_page( report = io.BytesIO() # in-memory document, no disk file document = BaseDocTemplate(report) document.addPageTemplates( - ScolarsPageTemplate( + ScoDocPageTemplate( document, title=title, author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION), @@ -378,8 +457,8 @@ class PDFLock(object): return # deja lock pour ce thread try: self.Q.put(1, True, self.timeout) - except queue.Full: - raise ScoGenError(msg="Traitement PDF occupé: ré-essayez") + except queue.Full as e: + raise ScoGenError(msg="Traitement PDF occupé: ré-essayez") from e self.current_thread = threading.get_ident() self.nref = 1 log("PDFLock: granted to %s" % self.current_thread) @@ -406,7 +485,6 @@ class WatchLock: def release(self): t = threading.current_thread() assert (self.native_id == t.native_id) and (self.ident == t.ident) - pass class FakeLock: diff --git a/app/scodoc/sco_photos.py b/app/scodoc/sco_photos.py index 80f3553e..0dfeaafe 100644 --- a/app/scodoc/sco_photos.py +++ b/app/scodoc/sco_photos.py @@ -175,7 +175,7 @@ def etud_photo_is_local(etud: dict, size="small"): return photo_pathname(etud["photo_filename"], size=size) -def etud_photo_html(etud=None, etudid=None, title=None, size="small"): +def etud_photo_html(etud: dict = None, etudid=None, title=None, size="small"): """HTML img tag for the photo, either in small size (h90) or original size (size=="orig") """ @@ -351,7 +351,8 @@ def copy_portal_photo_to_fs(etud): """Copy the photo from portal (distant website) to local fs. Returns rel. path or None if copy failed, with a diagnostic message """ - sco_etud.format_etud_ident(etud) + if "nomprenom" not in etud: + sco_etud.format_etud_ident(etud) url = photo_portal_url(etud) if not url: return None, "%(nomprenom)s: pas de code NIP" % etud diff --git a/app/scodoc/sco_poursuite_dut.py b/app/scodoc/sco_poursuite_dut.py index cfa3dff8..41b29259 100644 --- a/app/scodoc/sco_poursuite_dut.py +++ b/app/scodoc/sco_poursuite_dut.py @@ -34,7 +34,7 @@ import collections from flask import url_for, g, request 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 import sco_abs diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index ce73420d..7f1d8cc4 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -121,6 +121,7 @@ from app import log from app.scodoc.sco_exceptions import ScoValueError, ScoException from app.scodoc.TrivialFormulator import TrivialFormulator import app.scodoc.notesdb as ndb +from app.scodoc import sco_pdf import app.scodoc.sco_utils as scu @@ -152,19 +153,18 @@ def get_preference(name, formsemestre_id=None): def _convert_pref_type(p, pref_spec): """p est une ligne de la bd {'id': , 'dept_id': , 'name': '', 'value': '', 'formsemestre_id': } - converti la valeur chane en le type désiré spécifié par pref_spec + converti la valeur chaine en le type désiré spécifié par pref_spec """ if "type" in pref_spec: typ = pref_spec["type"] if typ == "float": # special case for float values (where NULL means 0) - if p["value"]: - p["value"] = float(p["value"]) - else: - p["value"] = 0.0 + p["value"] = float(p["value"] or 0) + elif typ == "int": + p["value"] = int(p["value"] or 0) else: - func = eval(typ) - p["value"] = func(p["value"]) + raise ValueError("invalid preference type") + if pref_spec.get("input_type", None) == "boolcheckbox": # boolcheckbox: la valeur stockée en base est une chaine "0" ou "1" # que l'on ressort en True|False @@ -195,6 +195,8 @@ def _get_pref_default_value_from_config(name, pref_spec): return value +_INSTALLED_FONTS = ", ".join(sco_pdf.get_available_font_names()) + PREF_CATEGORIES = ( # sur page "Paramètres" ("general", {}), @@ -245,6 +247,7 @@ PREF_CATEGORIES = ( ), ("pe", {"title": "Avis de poursuites d'études"}), ("edt", {"title": "Connexion avec le logiciel d'emplois du temps"}), + ("debug", {"title": "Tests / mise au point"}), ) @@ -357,8 +360,22 @@ class BasePreferences(object): "use_ue_coefs", { "initvalue": 0, - "title": "Utiliser les coefficients d'UE pour calculer la moyenne générale", - "explanation": """Calcule les moyennes dans chaque UE, puis pondère ces résultats pour obtenir la moyenne générale. Par défaut, le coefficient d'une UE est simplement la somme des coefficients des modules dans lesquels l'étudiant a des notes. Attention: changer ce réglage va modifier toutes les moyennes du semestre !""", + "title": "Utiliser les coefficients d'UE pour calculer la moyenne générale (hors BUT)", + "explanation": """Calcule les moyennes dans chaque UE, puis pondère ces résultats pour obtenir la moyenne générale. Par défaut, le coefficient d'une UE est simplement la somme des coefficients des modules dans lesquels l'étudiant a des notes. Attention: changer ce réglage va modifier toutes les moyennes du semestre !. Aucun effet en BUT.""", + "input_type": "boolcheckbox", + "category": "misc", + "labels": ["non", "oui"], + "only_global": False, + }, + ), + ( + "but_moy_skip_empty_ues", + { + "initvalue": 0, + "title": "BUT: moyenne générale sans les UE sans notes", + "explanation": """La moyenne générale indicative BUT est basée sur les moyennes d'UE pondérées par leurs ECTS. + Si cette option est cochée, ne prend pas en compte les UEs sans notes. Attention: changer ce réglage va modifier toutes + les moyennes du semestre !. Aucun effet dans les formations non BUT.""", "input_type": "boolcheckbox", "category": "misc", "labels": ["non", "oui"], @@ -763,7 +780,7 @@ class BasePreferences(object): { "initvalue": "Helvetica", "title": "Police de caractère principale", - "explanation": "pour les pdf (Helvetica est recommandée)", + "explanation": f"pour les pdf (Helvetica est recommandée, parmi {_INSTALLED_FONTS})", "size": 25, "category": "pdf", }, @@ -1126,7 +1143,7 @@ class BasePreferences(object): { "initvalue": "Times-Roman", "title": "Police de caractère pour les PV", - "explanation": "pour les pdf", + "explanation": f"pour les pdf ({_INSTALLED_FONTS})", "size": 25, "category": "pvpdf", }, @@ -1158,7 +1175,7 @@ class BasePreferences(object): "bul_show_abs", # ex "gestion_absence" { "initvalue": 1, - "title": "Indiquer les absences sous les bulletins", + "title": "Indiquer les absences dans les bulletins", "input_type": "boolcheckbox", "category": "bul", "labels": ["non", "oui"], @@ -1220,7 +1237,7 @@ class BasePreferences(object): { "initvalue": 0, "title": "Afficher toutes les évaluations sur les bulletins", - "explanation": "y compris incomplètes ou futures", + "explanation": "y compris incomplètes ou futures (déconseillé, risque de publier des notes non définitives)", "input_type": "boolcheckbox", "category": "bul", "labels": ["non", "oui"], @@ -1528,7 +1545,7 @@ class BasePreferences(object): { "initvalue": "Times-Roman", "title": "Police titres bulletins", - "explanation": "pour les pdf", + "explanation": f"pour les pdf ({_INSTALLED_FONTS})", "size": 25, "category": "bul", }, @@ -1859,6 +1876,19 @@ class BasePreferences(object): "category": "edt", }, ), + ( + "email_test_mode_address", + { + "title": "Adresse de test", + "initvalue": "", + "explanation": """si cette adresse est indiquée, TOUS les mails + envoyés par ScoDoc de ce département vont aller vers elle + AU LIEU DE LEUR DESTINATION NORMALE !""", + "size": 30, + "category": "debug", + "only_global": True, + }, + ), ) self.prefs_name = set([x[0] for x in self.prefs_definition]) diff --git a/app/scodoc/sco_prepajury.py b/app/scodoc/sco_prepajury.py index df0cb4af..678b81ab 100644 --- a/app/scodoc/sco_prepajury.py +++ b/app/scodoc/sco_prepajury.py @@ -36,7 +36,7 @@ from flask import 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, Identite from app.scodoc import sco_abs from app.scodoc import sco_codes_parcours diff --git a/app/scodoc/sco_pvjury.py b/app/scodoc/sco_pvjury.py index 00e88751..cfa7bae6 100644 --- a/app/scodoc/sco_pvjury.py +++ b/app/scodoc/sco_pvjury.py @@ -55,7 +55,7 @@ import flask from flask import url_for, g, request 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, UniteEns import app.scodoc.sco_utils as scu diff --git a/app/scodoc/sco_pvpdf.py b/app/scodoc/sco_pvpdf.py index 2e8cdaf6..3ac0096d 100644 --- a/app/scodoc/sco_pvpdf.py +++ b/app/scodoc/sco_pvpdf.py @@ -28,15 +28,13 @@ """Edition des PV de jury """ import io -import os import re import reportlab from reportlab.lib.units import cm, mm -from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY -from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak -from reportlab.platypus import Table, TableStyle, Image, KeepInFrame -from reportlab.platypus.flowables import Flowable +from reportlab.lib.enums import TA_RIGHT, TA_JUSTIFY +from reportlab.platypus import Paragraph, Spacer, Frame, PageBreak +from reportlab.platypus import Table, TableStyle, Image from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate from reportlab.lib.pagesizes import A4, landscape from reportlab.lib import styles @@ -53,7 +51,6 @@ from app.scodoc import sco_preferences from app.scodoc import sco_etud import sco_version from app.scodoc.sco_logos import find_logo -from app.scodoc.sco_pdf import PDFLOCK from app.scodoc.sco_pdf import SU LOGO_FOOTER_ASPECT = scu.CONFIG.LOGO_FOOTER_ASPECT # XXX A AUTOMATISER @@ -317,14 +314,14 @@ class PVTemplate(CourrierIndividuelTemplate): self.with_footer = self.preferences["PV_WITH_FOOTER"] self.with_page_background = self.preferences["PV_WITH_BACKGROUND"] - def afterDrawPage(self, canvas, doc): + def afterDrawPage(self, canv, doc): """Called after all flowables have been drawn on a page""" pass - def beforeDrawPage(self, canvas, doc): + def beforeDrawPage(self, canv, doc): """Called before any flowables are drawn on a page""" # If the page number is even, force a page break - CourrierIndividuelTemplate.beforeDrawPage(self, canvas, doc) + CourrierIndividuelTemplate.beforeDrawPage(self, canv, doc) # Note: on cherche un moyen de generer un saut de page double # (redémarrer sur page impaire, nouvelle feuille en recto/verso). Pas trouvé en Platypus. # diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index ee7006d9..2d002f9f 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -32,20 +32,19 @@ import json import time from xml.etree import ElementTree -from flask import request -from flask import make_response +from flask import g, request +from flask import make_response, url_for from app import log from app.but import bulletin_but 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.etudiants import Identite from app.models.evaluations import Evaluation import app.scodoc.sco_utils as scu from app.scodoc import html_sco_header -from app.scodoc import sco_bac from app.scodoc import sco_bulletins_json from app.scodoc import sco_bulletins_xml from app.scodoc import sco_bulletins, sco_excel @@ -108,55 +107,52 @@ def formsemestre_recapcomplet( page_title="Récapitulatif", no_side_bar=True, init_qtip=True, - javascripts=["libjs/sorttable.js", "js/etud_info.js"], + javascripts=["js/etud_info.js", "js/table_recap.js"], ), sco_formsemestre_status.formsemestre_status_head( formsemestre_id=formsemestre_id ), - '
      ' % request.base_url, - '' - % 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", "HTML"), + ("xls", "Fichier tableur (Excel)"), + ("xlsall", "Fichier tableur avec toutes les évals"), + ("csv", "Fichier tableur (CSV)"), + ("xml", "Fichier XML"), + ("json", "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) +
      Nouvelle version: export excel inachevés. Merci de signaler les problèmes.
      + """ + ) + data = do_formsemestre_recapcomplet( formsemestre_id, format=tabformat, @@ -175,30 +171,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,19 +220,25 @@ 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, - ) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if format == "html" and not modejury: + res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + data, filename = gen_formsemestre_recapcomplet_html(formsemestre, res) + 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": return data elif format == "csv": @@ -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] @@ -692,7 +661,13 @@ def make_formsemestre_recapcomplet( for i in range(len(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,68 @@ 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") + if content or target: # avec lien + href = f'href="{target}"' if target else "" + content = f'{content}' + return f"<{elt} {attrs}>{content}" + + +def _gen_row(keys: list[str], row, elt="td"): + klass = row.get("_tr_class") + tr_class = f'class="{klass}"' if klass else "" + return f'{"".join([_gen_cell(key, row, elt) for key in keys])}' + + +def gen_formsemestre_recapcomplet_html( + formsemestre: FormSemestre, res: NotesTableCompat +): + """Construit table recap pour le BUT + Return: data, filename + """ + rows, footer_rows, titles, column_ids = res.get_table_recap(convert_values=True) + if not rows: + return ( + '
      aucun étudiant !
      ', + "", + ) + H = ['
      '] + # header + H.append( + f""" + + {_gen_row(column_ids, titles, "th")} + + """ + ) + # body + H.append("") + for row in rows: + H.append(f"{_gen_row(column_ids, row)}\n") + H.append("\n") + # footer + H.append("") + idx_last = len(footer_rows) - 1 + for i, row in enumerate(footer_rows): + H.append(f'{_gen_row(column_ids, row, "th" if i == idx_last else "td")}\n') + H.append( + """ + +
      +
      + """ + ) + return ( + "".join(H), + f'recap-{formsemestre.titre_num().replace(" ", "_")}-{time.strftime("%d-%m-%Y")}', + ) # suffix ? diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py index fff02647..214532a5 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 8dee1a7e..fbef9245 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 a91e8788..37b39fa8 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 45ed6e07..1b41cd87 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 9132336e..e9102fc6 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 3861bb74..fd11da4e 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 5d169b8b..5cff6fe4 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 02c35c77..1ca56b80 100644 --- a/app/scodoc/sco_up_to_date.py +++ b/app/scodoc/sco_up_to_date.py @@ -28,30 +28,68 @@ """ Verification version logiciel vs version "stable" sur serveur N'effectue pas la mise à jour automatiquement, mais permet un affichage d'avertissement. - - Désactivé temporairement pour ScoDoc 9. """ - +import json +import requests +import time from flask import current_app +from app import log +import app.scodoc.sco_utils as scu +from sco_version import SCOVERSION, SCONAME -def is_up_to_date(): - """True if up_to_date - Returns status, message + +def is_up_to_date() -> str: + """Check installed version vs last release. + Returns html message, empty of ok. """ - current_app.logger.debug("Warning: is_up_to_date not implemented for ScoDoc9") - return True, "unimplemented" + if current_app.testing or current_app.debug: + return "
      Mode développement
      " + diag = "" + try: + response = requests.get(scu.SCO_UP2DATE + "/" + SCOVERSION) + except requests.exceptions.ConnectionError: + current_app.logger.debug("is_up_to_date: %s", diag) + return f"""
      Attention: installation de {SCONAME} non fonctionnelle.
      +
      Détails: pas de connexion à {scu.SCO_WEBSITE}. + Vérifier paramètrages réseau, + voir la documentation. +
      + """ + except: + current_app.logger.debug("is_up_to_date: %s", diag) + return f"""
      Attention: installation de {SCONAME} non fonctionnelle.
      +
      Détails: erreur inconnue lors de la connexion à {scu.SCO_WEBSITE}. + Vérifier paramètrages réseau, + voir la documentation. +
      + """ + if response.status_code != 200: + current_app.logger.debug( + f"is_up_to_date: invalid response code ({response.status_code})" + ) + return f"""
      Attention: réponse invalide de {scu.SCO_WEBSITE}
      +
      (erreur http {response.status_code}).
      """ + try: + infos = json.loads(response.text) + except json.decoder.JSONDecodeError: + current_app.logger.debug(f"is_up_to_date: invalid response (json)") + return f"""
      Attention: réponse invalide de {scu.SCO_WEBSITE}
      +
      (erreur json).
      """ -def html_up_to_date_box(): - """""" - status, msg = is_up_to_date() - if status: + if infos["status"] != "ok": + # problème coté serveur, ignore discrètement + log(f"is_up_to_date: server {infos['status']}") return "" - return ( - """
      - Attention: cette installation de ScoDoc n'est pas à jour. -
      Contactez votre administrateur. %s
      -
      """ - % msg - ) + if (SCOVERSION != infos["last_version"]) and ( + (time.time() - infos["last_version_date"]) > (24 * 60 * 60) + ): + # nouvelle version publiée depuis plus de 24h ! + return f"""
      Attention: {SCONAME} version ({SCOVERSION}) non à jour + ({infos["last_version"]} disponible).
      +
      Contacter votre administrateur système + (documentation). +
      + """ + return "" # ok diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 196c2c21..216a0b80 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", @@ -361,7 +374,7 @@ SCO_DEV_MAIL = "emmanuel.viennet@gmail.com" # SVP ne pas changer # Adresse pour l'envoi des dumps (pour assistance technnique): # ne pas changer (ou vous perdez le support) SCO_DUMP_UP_URL = "https://scodoc.org/scodoc-installmgr/upload-dump" - +SCO_UP2DATE = "https://scodoc.org/scodoc-installmgr/check_version" CSV_FIELDSEP = ";" CSV_LINESEP = "\n" CSV_MIMETYPE = "text/comma-separated-values" @@ -608,7 +621,7 @@ def is_valid_filename(filename): return VALID_EXP.match(filename) -def bul_filename(sem, etud, format): +def bul_filename_old(sem: dict, etud: dict, format): """Build a filename for this bulletin""" dt = time.strftime("%Y-%m-%d") filename = f"bul-{sem['titre_num']}-{dt}-{etud['nom']}.{format}" @@ -616,6 +629,14 @@ def bul_filename(sem, etud, format): return filename +def bul_filename(formsemestre, etud, format): + """Build a filename for this bulletin""" + dt = time.strftime("%Y-%m-%d") + filename = f"bul-{formsemestre.titre_num()}-{dt}-{etud.nom}.{format}" + filename = make_filename(filename) + return filename + + def flash_errors(form): """Flashes form errors (version sommaire)""" for field, errors in form.errors.items(): @@ -946,7 +967,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 @@ -1030,3 +1051,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 00000000..92485409 --- /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 00000000..0c7adb42 --- /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 00000000..45d37f50 --- /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 00000000..d688b82e --- /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 00000000..40ccc2e0 --- /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 00000000..52d83032 --- /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 00000000..6ac1424c --- /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 00000000..99ee3ac8 --- /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 00000000..ce33a3c9 --- /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 00000000..fd38c86a --- /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 00000000..f697d843 --- /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 00000000..f4d4a69c --- /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 00000000..857470d0 --- /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 00000000..5c2f9d82 --- /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 00000000..901251e8 --- /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 00000000..3bdfbc4a --- /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 00000000..3535d9d2 --- /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 00000000..b50a0a4c --- /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 00000000..0644cd67 --- /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 00000000..88c40f91 --- /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 00000000..599b836b --- /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 00000000..3d3d41c8 --- /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 00000000..69c5da63 --- /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 00000000..b05ff7a6 --- /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 00000000..08d743a1 --- /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 = $('