diff --git a/app/__init__.py b/app/__init__.py index 6f94517860..7998f19a95 100755 --- a/app/__init__.py +++ b/app/__init__.py @@ -19,11 +19,7 @@ from flask import current_app, g, request from flask import Flask from flask import abort, flash, has_request_context from flask import render_template - -# from flask.json import JSONEncoder from flask.logging import default_handler - -from flask_bootstrap import Bootstrap from flask_caching import Cache from flask_json import FlaskJSON, json_response from flask_login import LoginManager, current_user @@ -34,6 +30,7 @@ from flask_sqlalchemy import SQLAlchemy from jinja2 import select_autoescape import sqlalchemy as sa import werkzeug.debug +from wtforms.fields import HiddenField from flask_cas import CAS @@ -59,8 +56,6 @@ login.login_view = "auth.login" login.login_message = "Identifiez-vous pour accéder à cette page." mail = Mail() -bootstrap = Bootstrap() -# moment = Moment() CACHE_TYPE = os.environ.get("CACHE_TYPE") cache = Cache( @@ -304,7 +299,6 @@ def create_app(config_class=DevConfig): login.init_app(app) mail.init_app(app) app.extensions["mail"].debug = 0 # disable copy of mails to stderr - bootstrap.init_app(app) cache.init_app(app) sco_cache.CACHE = cache if CACHE_TYPE: # non default @@ -320,6 +314,12 @@ def create_app(config_class=DevConfig): app.register_error_handler(503, postgresql_server_error) app.register_error_handler(APIInvalidParams, handle_invalid_usage) + # Add some globals + # previously in Flask-Bootstrap: + app.jinja_env.globals["bootstrap_is_hidden_field"] = lambda field: isinstance( + field, HiddenField + ) + from app.auth import bp as auth_bp app.register_blueprint(auth_bp, url_prefix="/auth") @@ -549,8 +549,8 @@ def truncate_database(): CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$ DECLARE statements CURSOR FOR - SELECT sequence_name - FROM information_schema.sequences + SELECT sequence_name + FROM information_schema.sequences ORDER BY sequence_name ; BEGIN FOR stmt IN statements LOOP diff --git a/app/api/assiduites.py b/app/api/assiduites.py index ae0993a765..1abb6479df 100644 --- a/app/api/assiduites.py +++ b/app/api/assiduites.py @@ -1014,7 +1014,7 @@ def _edit_one(assiduite_unique: Assiduite, data: dict) -> tuple[int, str]: else assiduite_unique.external_data ) - if force and not external_data.get("module", False): + if force and not (external_data is not None and external_data.get("module", False) != ""): errors.append( "param 'moduleimpl_id' : le moduleimpl_id ne peut pas être nul" ) diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 40fc81bb7a..6c24790217 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -33,6 +33,7 @@ from app.models import ( ) from app.models.formsemestre import GROUPS_AUTO_ASSIGNMENT_DATA_MAX from app.scodoc.sco_bulletins import get_formsemestre_bulletin_etud_json +from app.scodoc import sco_edt_cal from app.scodoc import sco_groups from app.scodoc.sco_permissions import Permission from app.scodoc.sco_utils import ModuleType @@ -555,3 +556,23 @@ def save_groups_auto_assignment(formsemestre_id: int): formsemestre.groups_auto_assignment_data = request.data db.session.add(formsemestre) db.session.commit() + + +@bp.route("/formsemestre//edt") +@api_web_bp.route("/formsemestre//edt") +@login_required +@scodoc +@permission_required(Permission.ScoView) +@as_json +def formsemestre_edt(formsemestre_id: int): + """l'emploi du temps du semestre. + Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur. + + group_ids permet de filtrer sur les groupes ScoDoc. + """ + query = FormSemestre.query.filter_by(id=formsemestre_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestre: FormSemestre = query.first_or_404(formsemestre_id) + group_ids = request.args.getlist("group_ids", int) + return sco_edt_cal.formsemestre_edt_dict(formsemestre, group_ids=group_ids) diff --git a/app/auth/cas.py b/app/auth/cas.py index b9584f83a0..3b9f14e478 100644 --- a/app/auth/cas.py +++ b/app/auth/cas.py @@ -39,6 +39,15 @@ def after_cas_login(): "scodoc_cas_login_date" ] = datetime.datetime.now().isoformat() user.cas_last_login = datetime.datetime.utcnow() + if flask.session.get("CAS_EDT_ID"): + # essaie de récupérer l'edt_id s'il est présent + # cet ID peut être renvoyé par le CAS et extrait par ScoDoc + # via l'expression `cas_edt_id_from_xml_regexp` + # voir flask_cas.routing + edt_id = flask.session.get("CAS_EDT_ID") + current_app.logger.info(f"""after_cas_login: storing edt_id for { + user.user_name}: '{edt_id}'""") + user.edt_id = edt_id db.session.add(user) db.session.commit() return flask.redirect(url_for("scodoc.index")) diff --git a/app/auth/models.py b/app/auth/models.py index 1330b9b7dd..dcf762a2d1 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -12,6 +12,7 @@ from typing import Optional import cracklib # pylint: disable=import-error +import flask from flask import current_app, g from flask_login import UserMixin, AnonymousUserMixin @@ -88,7 +89,8 @@ class User(UserMixin, db.Model): """ cas_last_login = db.Column(db.DateTime, nullable=True) """date du dernier login via CAS""" - + edt_id = db.Column(db.Text(), index=True, nullable=True) + "identifiant emplois du temps (unicité non imposée)" password_hash = db.Column(db.String(128)) password_scodoc7 = db.Column(db.String(42)) last_seen = db.Column(db.DateTime, default=datetime.utcnow) @@ -172,7 +174,8 @@ class User(UserMixin, db.Model): return False # if CAS activated and forced, allow only super-user and users with cas_allow_scodoc_login - if ScoDocSiteConfig.is_cas_enabled() and ScoDocSiteConfig.get("cas_force"): + cas_enabled = ScoDocSiteConfig.is_cas_enabled() + if cas_enabled and ScoDocSiteConfig.get("cas_force"): if (not self.is_administrator()) and not self.cas_allow_scodoc_login: return False diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 238f38d5a8..1d7b3ad7f9 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -307,7 +307,7 @@ class ResultatsSemestreBUT(NotesTableCompat): return ues_ids - def etud_has_decision(self, etudid) -> bool: + def etud_has_decision(self, etudid, include_rcues=True) -> bool: """True s'il y a une décision (quelconque) de jury émanant de ce formsemestre pour cet étudiant. prend aussi en compte les autorisations de passage. @@ -318,9 +318,12 @@ class ResultatsSemestreBUT(NotesTableCompat): or ApcValidationAnnee.query.filter_by( formsemestre_id=self.formsemestre.id, etudid=etudid ).count() - or ApcValidationRCUE.query.filter_by( - formsemestre_id=self.formsemestre.id, etudid=etudid - ).count() + or ( + include_rcues + and ApcValidationRCUE.query.filter_by( + formsemestre_id=self.formsemestre.id, etudid=etudid + ).count() + ) ) def get_validations_annee(self) -> dict[int, ApcValidationAnnee]: diff --git a/app/comp/res_compat.py b/app/comp/res_compat.py index 2934f1e93d..0e6cf75718 100644 --- a/app/comp/res_compat.py +++ b/app/comp/res_compat.py @@ -290,9 +290,10 @@ class NotesTableCompat(ResultatsSemestre): ] return etudids - def etud_has_decision(self, etudid) -> bool: + def etud_has_decision(self, etudid, include_rcues=True) -> bool: """True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre. prend aussi en compte les autorisations de passage. + Si include_rcues, prend en compte les validation d'RCUEs en BUT (pas d'effet en classic). Sous-classée en BUT pour les RCUEs et années. """ return bool( diff --git a/app/forms/formsemestre/edit_modimpls_codes_apo.py b/app/forms/formsemestre/edit_modimpls_codes_apo.py new file mode 100644 index 0000000000..cccf1978c9 --- /dev/null +++ b/app/forms/formsemestre/edit_modimpls_codes_apo.py @@ -0,0 +1,52 @@ +""" +Formulaire configuration des codes Apo et EDT des modimps d'un formsemestre +""" + +from flask_wtf import FlaskForm +from wtforms import validators +from wtforms.fields.simple import StringField, SubmitField + +from app.models import FormSemestre, ModuleImpl + + +class _EditModimplsCodesForm(FlaskForm): + "form. définition des liens personnalisés" + # construit dynamiquement ci-dessous + + +def EditModimplsCodesForm(formsemestre: FormSemestre) -> _EditModimplsCodesForm: + "Création d'un formulaire pour éditer les codes" + + # Formulaire dynamique, on créé une classe ad-hoc + class F(_EditModimplsCodesForm): + pass + + def _gen_mod_form(modimpl: ModuleImpl): + field = StringField( + modimpl.module.code, + validators=[ + validators.Optional(), + validators.Length(min=1, max=80), + ], + default="", + render_kw={"size": 32}, + ) + setattr(F, f"modimpl_apo_{modimpl.id}", field) + field = StringField( + "", + validators=[ + validators.Optional(), + validators.Length(min=1, max=80), + ], + default="", + render_kw={"size": 12}, + ) + setattr(F, f"modimpl_edt_{modimpl.id}", field) + + for modimpl in formsemestre.modimpls_sorted: + _gen_mod_form(modimpl) + + F.submit = SubmitField("Valider") + F.cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) + + return F() diff --git a/app/forms/main/config_assiduites.py b/app/forms/main/config_assiduites.py index ba7198b823..d4adb05713 100644 --- a/app/forms/main/config_assiduites.py +++ b/app/forms/main/config_assiduites.py @@ -28,12 +28,15 @@ """ Formulaire configuration Module Assiduités """ +import datetime +import re from flask_wtf import FlaskForm -from wtforms import SubmitField, DecimalField +from wtforms import DecimalField, SubmitField, ValidationError from wtforms.fields.simple import StringField +from wtforms.validators import Optional + from wtforms.widgets import TimeInput -import datetime class TimeField(StringField): @@ -72,9 +75,45 @@ class TimeField(StringField): else: raise ValueError self.data = datetime.time(hour, minutes, seconds) - except ValueError: + except ValueError as exc: self.data = None - raise ValueError(self.gettext("Not a valid time string")) + raise ValueError(self.gettext("Not a valid time string")) from exc + + +def check_tick_time(form, field): + """Le tick_time doit être entre 0 et 60 minutes""" + if field.data < 1 or field.data > 59: + raise ValidationError("Valeur de granularité invalide (entre 1 et 59)") + + +def check_ics_path(form, field): + """Vérifie que le chemin est bien un chemin absolu + et qu'il contient edt_id + """ + data = field.data.strip() + if not data: + return + if not data.startswith("/"): + raise ValidationError("Le chemin vers les ics doit commencer par /") + if not "{edt_id}" in data: + raise ValidationError("Le chemin vers les ics doit utiliser {edt_id}") + + +def check_ics_field(form, field): + """Vérifie que c'est un nom de champ crédible: un mot alphanumérique""" + if not re.match(r"^[a-zA-Z\-_0-9]+$", field.data): + raise ValidationError("nom de champ ics invalide") + + +def check_ics_regexp(form, field): + """Vérifie que field est une expresssion régulière""" + value = field.data.strip() + # check that it compiles + try: + _ = re.compile(value) + except re.error as exc: + raise ValidationError("expression invalide") from exc + return True class ConfigAssiduitesForm(FlaskForm): @@ -84,7 +123,60 @@ class ConfigAssiduitesForm(FlaskForm): lunch_time = TimeField("Heure de midi (date pivot entre Matin et Après Midi)") afternoon_time = TimeField("Fin de la journée") - tick_time = DecimalField("Granularité de la Time Line (temps en minutes)", places=0) + tick_time = DecimalField( + "Granularité de la timeline (temps en minutes)", + places=0, + validators=[check_tick_time], + ) + + edt_ics_path = StringField( + label="Chemin vers les ics", + description="""Chemin absolu unix sur le serveur vers le fichier ics donnant l'emploi + du temps d'un semestre. La balise {edt_id} sera remplacée par l'edt_id du + semestre (par défaut, son code étape Apogée). + Si ce champ n'est pas renseigné, les emplois du temps ne seront pas utilisés.""", + validators=[Optional(), check_ics_path], + ) + + edt_ics_title_field = StringField( + label="Champs contenant le titre", + description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""", + validators=[Optional(), check_ics_field], + ) + edt_ics_title_regexp = StringField( + label="Extraction du titre", + description=r"""expression régulière python dont le premier groupe doit + sera le titre de l'évènement affcihé dans le calendrier ScoDoc. + Exemple: Matière : \w+ - ([\w\.\s']+) + """, + validators=[Optional(), check_ics_regexp], + ) + edt_ics_group_field = StringField( + label="Champs contenant le groupe", + description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""", + validators=[Optional(), check_ics_field], + ) + edt_ics_group_regexp = StringField( + label="Extraction du groupe", + description=r"""expression régulière python dont le premier groupe doit + correspondre à l'identifiant de groupe de l'emploi du temps. + Exemple: .*- ([\w\s]+)$ + """, + validators=[Optional(), check_ics_regexp], + ) + edt_ics_mod_field = StringField( + label="Champs contenant le module", + description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""", + validators=[Optional(), check_ics_field], + ) + edt_ics_mod_regexp = StringField( + label="Extraction du module", + description=r"""expression régulière python dont le premier groupe doit + correspondre à l'identifiant (code) du module de l'emploi du temps. + Exemple: Matière : ([A-Z][A-Z0-9]+) + """, + validators=[Optional(), check_ics_regexp], + ) submit = SubmitField("Valider") cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/forms/main/config_cas.py b/app/forms/main/config_cas.py index 7e2b73c568..3a31b47fe4 100644 --- a/app/forms/main/config_cas.py +++ b/app/forms/main/config_cas.py @@ -38,11 +38,17 @@ from app.models import ScoDocSiteConfig def check_cas_uid_from_mail_regexp(form, field): - "Vérifie la regexp fournie pur l'extraction du CAS id" + "Vérifie la regexp fournie pour l'extraction du CAS id" if not ScoDocSiteConfig.cas_uid_from_mail_regexp_is_valid(field.data): raise ValidationError("expression régulière invalide") +def check_cas_edt_id_from_xml_regexp(form, field): + "Vérifie la regexp fournie pour l'extraction du CAS id" + if not ScoDocSiteConfig.cas_edt_id_from_xml_regexp_is_valid(field.data): + raise ValidationError("expression régulière pour edt_id invalide") + + class ConfigCASForm(FlaskForm): "Formulaire paramétrage CAS" cas_enable = BooleanField("Activer le CAS") @@ -58,18 +64,18 @@ class ConfigCASForm(FlaskForm): description="""url complète. Commence en général par https://.""", ) cas_login_route = StringField( - label="Route du login CAS", + label="Optionnel: route du login CAS", description="""ajouté à l'URL du serveur: exemple /cas (si commence par /, part de la racine)""", default="/cas", ) cas_logout_route = StringField( - label="Route du logout CAS", + label="Optionnel: route du logout CAS", description="""ajouté à l'URL du serveur: exemple /cas/logout""", default="/cas/logout", ) cas_validate_route = StringField( - label="Route de validation CAS", + label="Optionnel: route de validation CAS", description="""ajouté à l'URL du serveur: exemple /cas/serviceValidate""", default="/cas/serviceValidate", ) @@ -81,7 +87,7 @@ class ConfigCASForm(FlaskForm): ) cas_uid_from_mail_regexp = StringField( - label="Expression pour extraire l'identifiant utilisateur", + label="Optionnel: expression pour extraire l'identifiant utilisateur", description="""regexp python appliquée au mail institutionnel de l'utilisateur, dont le premier groupe doit donner l'identifiant CAS. Si non fournie, le super-admin devra saisir cet identifiant pour chaque compte. @@ -92,6 +98,17 @@ class ConfigCASForm(FlaskForm): validators=[Optional(), check_cas_uid_from_mail_regexp], ) + cas_edt_id_from_xml_regexp = StringField( + label="Optionnel: expression pour extraire l'identifiant edt", + description="""regexp python appliquée à la réponse XML du serveur CAS pour + retrouver l'id de l'utilisateur sur le SI de l'institution, et notamment sur les + calendrier d'emploi du temps. Par exemple, si cet id est renvoyé dans le champ + supannEmpId, utiliser: + <cas:supannEmpId>(.*?)</cas:supannEmpId> + """, + validators=[Optional(), check_cas_edt_id_from_xml_regexp], + ) + cas_ssl_verify = BooleanField("Vérification du certificat SSL") cas_ssl_certificate_file = FileField( label="Certificat (PEM)", diff --git a/app/forms/main/config_personalized_links.py b/app/forms/main/config_personalized_links.py index 1aed313023..2a261e766a 100644 --- a/app/forms/main/config_personalized_links.py +++ b/app/forms/main/config_personalized_links.py @@ -2,9 +2,8 @@ Formulaire configuration liens personalisés (menu "Liens") """ -from flask import g, url_for from flask_wtf import FlaskForm -from wtforms import FieldList, Form, validators +from wtforms import validators from wtforms.fields.simple import BooleanField, StringField, SubmitField from app.models import ScoDocSiteConfig @@ -29,7 +28,7 @@ def PersonalizedLinksForm() -> _PersonalizedLinksForm: F, f"link_{idx}", StringField( - f"Titre", + "Titre", validators=[ validators.Optional(), validators.Length(min=1, max=80), @@ -42,7 +41,7 @@ def PersonalizedLinksForm() -> _PersonalizedLinksForm: F, f"link_url_{idx}", StringField( - f"URL", + "URL", description="adresse, incluant le http.", validators=[ validators.Optional(), @@ -56,7 +55,7 @@ def PersonalizedLinksForm() -> _PersonalizedLinksForm: F, f"link_with_args_{idx}", BooleanField( - f"ajouter arguments", + "ajouter arguments", description="query string avec ids", ), ) diff --git a/app/models/but_validations.py b/app/models/but_validations.py index 7efd3069e5..9a6953b942 100644 --- a/app/models/but_validations.py +++ b/app/models/but_validations.py @@ -7,7 +7,6 @@ from app import db from app.models import CODE_STR_LEN from app.models.but_refcomp import ApcNiveau from app.models.etudiants import Identite -from app.models.formations import Formation from app.models.formsemestre import FormSemestre from app.models.ues import UniteEns diff --git a/app/models/config.py b/app/models/config.py index 60ce884b4d..561bf6dfc2 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -4,8 +4,8 @@ """ import json -import urllib.parse import re +import urllib.parse from flask import flash from app import current_app, db, log @@ -13,8 +13,6 @@ from app.comp import bonus_spo from app.scodoc.sco_exceptions import ScoValueError from app.scodoc import sco_utils as scu -from datetime import time - from app.scodoc.codes_cursus import ( ABAN, ABL, @@ -105,6 +103,7 @@ class ScoDocSiteConfig(db.Model): "cas_validate_route": str, "cas_attribute_id": str, "cas_uid_from_mail_regexp": str, + "cas_edt_id_from_xml_regexp": str, # Assiduité "morning_time": str, "lunch_time": str, @@ -174,7 +173,7 @@ class ScoDocSiteConfig(db.Model): klass = bonus_spo.get_bonus_class_dict().get(class_name) if klass is None: flash( - f"""Fonction de calcul bonus sport inexistante: {class_name}. + f"""Fonction de calcul bonus sport inexistante: {class_name}. Changez là ou contactez votre administrateur local.""" ) return klass @@ -287,7 +286,7 @@ class ScoDocSiteConfig(db.Model): @classmethod def set(cls, name: str, value: str) -> bool: "Set parameter, returns True if change. Commit session." - value_str = str(value or "") + value_str = str(value or "").strip() if (cls.get(name) or "") != value_str: cfg = ScoDocSiteConfig.query.filter_by(name=name).first() if cfg is None: @@ -430,7 +429,17 @@ class ScoDocSiteConfig(db.Model): return False # and returns at least one group on a simple cannonical address match = pattern.search("emmanuel@exemple.fr") - return len(match.groups()) > 0 + return match is not None and len(match.groups()) > 0 + + @classmethod + def cas_edt_id_from_xml_regexp_is_valid(cls, exp: str) -> bool: + "True si l'expression régulière semble valide" + # check that it compiles + try: + _ = re.compile(exp) + except re.error: + return False + return True @classmethod def assi_get_rounded_time(cls, label: str, default: str) -> float: diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 1d99342c06..6bfbbdbb2b 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -64,6 +64,8 @@ class FormSemestre(db.Model): titre = db.Column(db.Text(), nullable=False) date_debut = db.Column(db.Date(), nullable=False) date_fin = db.Column(db.Date(), nullable=False) + edt_id: str | None = db.Column(db.Text(), index=True, nullable=True) + "identifiant emplois du temps (unicité non imposée)" etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true") "False si verrouillé" modalite = db.Column( @@ -257,6 +259,24 @@ class FormSemestre(db.Model): d["session_id"] = self.session_id() return d + def get_default_group(self) -> GroupDescr: + """default ('tous') group. + Le groupe par défaut contient tous les étudiants et existe toujours. + C'est l'unique groupe de la partition sans nom. + """ + default_partition = self.partitions.filter_by(partition_name=None).first() + if default_partition: + return default_partition.groups.first() + raise ScoValueError("Le semestre n'a pas de groupe par défaut") + + def get_edt_ids(self) -> list[str]: + "l'ids pour l'emploi du temps: à défaut, les codes étape Apogée" + return ( + scu.split_id(self.edt_id) + or [e.etape_apo.strip() for e in self.etapes if e.etape_apo] + or [] + ) + def get_infos_dict(self) -> dict: """Un dict avec des informations sur le semestre pour les bulletins et autres templates @@ -613,7 +633,7 @@ class FormSemestre(db.Model): def can_change_groups(self, user: User = None) -> bool: """Vrai si l'utilisateur (par def. current) peut changer les groupes dans - ce semestre: vérifie permission et verrouillage. + ce semestre: vérifie permission et verrouillage (mais pas si la partition est éditable). """ if not self.etat: return False # semestre verrouillé @@ -1022,6 +1042,33 @@ class FormSemestre(db.Model): nb_recorded += 1 return nb_recorded + def change_formation(self, formation_dest: Formation): + """Associe ce formsemestre à une autre formation. + Ce n'est possible que si la formation destination possède des modules de + même code que ceux utilisés dans la formation d'origine du formsemestre. + S'il manque un module, l'opération est annulée. + Commit (or rollback) session. + """ + ok = True + for mi in self.modimpls: + dest_modules = formation_dest.modules.filter_by(code=mi.module.code).all() + match len(dest_modules): + case 1: + mi.module = dest_modules[0] + db.session.add(mi) + case 0: + print(f"Argh ! no module found with code={mi.module.code}") + ok = False + case _: + print(f"Arg ! several modules found with code={mi.module.code}") + ok = False + + if ok: + self.formation_id = formation_dest.id + db.session.commit() + else: + db.session.rollback() + # Association id des utilisateurs responsables (aka directeurs des etudes) du semestre notes_formsemestre_responsables = db.Table( diff --git a/app/models/groups.py b/app/models/groups.py index a4a5792f6f..72a54acf6f 100644 --- a/app/models/groups.py +++ b/app/models/groups.py @@ -180,7 +180,7 @@ class Partition(db.Model): "Crée un groupe dans cette partition" if not self.formsemestre.can_change_groups(): raise AccessDenied( - """Vous n'avez pas le droit d'effectuer cette opération, + """Vous n'avez pas le droit d'effectuer cette opération, ou bien le semestre est verrouillé !""" ) if group_name: @@ -213,10 +213,12 @@ class GroupDescr(db.Model): id = db.Column(db.Integer, primary_key=True) group_id = db.synonym("id") partition_id = db.Column(db.Integer, db.ForeignKey("partition.id")) - # "A", "C2", ... (NULL for 'all'): group_name = db.Column(db.String(GROUPNAME_STR_LEN)) - # Numero = ordre de presentation + """nom du groupe: "A", "C2", ... (NULL for 'all')""" + edt_id: str | None = db.Column(db.Text(), index=True, nullable=True) + "identifiant emplois du temps (unicité non imposée)" numero = db.Column(db.Integer, nullable=False, default=0) + "Numero = ordre de presentation" etuds = db.relationship( "Identite", @@ -229,8 +231,12 @@ class GroupDescr(db.Model): f"""<{self.__class__.__name__} {self.id} "{self.group_name or '(tous)'}">""" ) - def get_nom_with_part(self) -> str: - "Nom avec partition: 'TD A'" + def get_nom_with_part(self, default="-") -> str: + """Nom avec partition: 'TD A' + Si groupe par défaut (tous), utilise default ou "-" + """ + if self.partition.partition_name is None: + return default return f"{self.partition.partition_name or ''} {self.group_name or '-'}" def to_dict(self, with_partition=True) -> dict: @@ -241,10 +247,14 @@ class GroupDescr(db.Model): d["partition"] = self.partition.to_dict(with_groups=False) return d + def get_edt_ids(self) -> list[str]: + "les ids pour l'emploi du temps: à défaut, le nom scodoc du groupe" + return scu.split_id(self.edt_id) or [self.group_name] or [] + def get_nb_inscrits(self) -> int: """Nombre inscrits à ce group et au formsemestre. C'est nécessaire car lors d'une désinscription, on conserve l'appartenance - aux groupes pour facilier une éventuelle ré-inscription. + aux groupes pour faciliter une éventuelle ré-inscription. """ from app.models.formsemestre import FormSemestreInscription @@ -272,6 +282,46 @@ class GroupDescr(db.Model): return False return True + def set_name(self, group_name: str, dest_url: str = None): + """Set group name, and optionally edt_id. + Check permission (partition must be groups_editable) + and invalidate caches. Commit session. + dest_url is used for error messages. + """ + if not self.partition.formsemestre.can_change_groups(): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + if self.group_name is None: + raise ValueError("can't set a name to default group") + if not self.partition.groups_editable: + raise AccessDenied("Partition non éditable") + if group_name: + group_name = group_name.strip() + if not group_name: + raise ScoValueError("nom de groupe vide !", dest_url=dest_url) + if group_name != self.group_name and not GroupDescr.check_name( + self.partition, group_name + ): + raise ScoValueError( + "Le nom de groupe existe déjà dans la partition", dest_url=dest_url + ) + + self.group_name = group_name + db.session.add(self) + db.session.commit() + sco_cache.invalidate_formsemestre( + formsemestre_id=self.partition.formsemestre_id + ) + + def set_edt_id(self, edt_id: str): + "Set edt_id. Check permission. Commit session." + if not self.partition.formsemestre.can_change_groups(): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + if isinstance(edt_id, str): + edt_id = edt_id.strip() or None + self.edt_id = edt_id + db.session.add(self) + db.session.commit() + def remove_etud(self, etud: "Identite"): "Enlève l'étudiant de ce groupe s'il en fait partie (ne fait rien sinon)" if etud in self.etuds: diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 3ccff4cf8c..77eba7da82 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -7,6 +7,7 @@ from flask_sqlalchemy.query import Query from app import db from app.auth.models import User from app.comp import df_cache +from app.models import APO_CODE_STR_LEN from app.models.etudiants import Identite from app.models.modules import Module from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError @@ -21,6 +22,10 @@ class ModuleImpl(db.Model): __table_args__ = (db.UniqueConstraint("formsemestre_id", "module_id"),) id = db.Column(db.Integer, primary_key=True) + code_apogee = db.Column(db.String(APO_CODE_STR_LEN), index=True, nullable=True) + "id de l'element pedagogique Apogee correspondant" + edt_id: str | None = db.Column(db.Text(), index=True, nullable=True) + "identifiant emplois du temps (unicité non imposée)" moduleimpl_id = db.synonym("id") module_id = db.Column(db.Integer, db.ForeignKey("notes_modules.id"), nullable=False) formsemestre_id = db.Column( @@ -42,12 +47,25 @@ class ModuleImpl(db.Model): viewonly=True, ) - def __init__(self, **kwargs): - super(ModuleImpl, self).__init__(**kwargs) - def __repr__(self): return f"<{self.__class__.__name__} {self.id} module={repr(self.module)}>" + def get_codes_apogee(self) -> set[str]: + """Les codes Apogée (codés en base comme "VRT1,VRT2"). + (si non renseigné, ceux du module) + """ + if self.code_apogee: + return {x.strip() for x in self.code_apogee.split(",") if x} + return self.module.get_codes_apogee() + + def get_edt_ids(self) -> list[str]: + "les ids pour l'emploi du temps: à défaut, les codes Apogée" + return ( + scu.split_id(self.edt_id) + or scu.split_id(self.code_apogee) + or self.module.get_edt_ids() + ) + def get_evaluations_poids(self) -> pd.DataFrame: """Les poids des évaluations vers les UE (accès via cache)""" evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id) @@ -99,6 +117,7 @@ class ModuleImpl(db.Model): d["module"] = self.module.to_dict(convert_objects=convert_objects) else: d.pop("module", None) + d["code_apogee"] = d["code_apogee"] or "" # pas de None return d def can_edit_evaluation(self, user) -> bool: diff --git a/app/models/modules.py b/app/models/modules.py index b4aa00ad89..891a476dcc 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -34,8 +34,10 @@ class Module(db.Model): # note: en APC, le semestre qui fait autorité est celui de l'UE semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1") numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation - # id de l'element pedagogique Apogee correspondant: code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) + "id de l'element pedagogique Apogee correspondant" + edt_id: str | None = db.Column(db.Text(), index=True, nullable=True) + "identifiant emplois du temps (unicité non imposée)" # Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum) module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0") # Relations: @@ -283,6 +285,10 @@ class Module(db.Model): return {x.strip() for x in self.code_apogee.split(",") if x} return set() + def get_edt_ids(self) -> list[str]: + "les ids pour l'emploi du temps: à défaut, le 1er code Apogée" + return scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) or [] + def get_parcours(self) -> list[ApcParcours]: """Les parcours utilisant ce module. Si tous les parcours, liste vide (!). diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index fd62737092..1b9e451948 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -44,15 +44,15 @@ import sco_version # Multiselect menus are used on a few pages and not loaded by default BOOTSTRAP_MULTISELECT_JS = [ - "libjs/bootstrap-3.1.1-dist/js/bootstrap.min.js", - "libjs/bootstrap-multiselect/bootstrap-multiselect.js", + "libjs/bootstrap/js/bootstrap.min.js", + "libjs/bootstrap-multiselect-1.1.2/bootstrap-multiselect.min.js", "libjs/purl.js", ] BOOTSTRAP_MULTISELECT_CSS = [ - "libjs/bootstrap-3.1.1-dist/css/bootstrap.min.css", - "libjs/bootstrap-3.1.1-dist/css/bootstrap-theme.min.css", - "libjs/bootstrap-multiselect/bootstrap-multiselect.css", + "libjs/bootstrap/css/bootstrap.min.css", + "libjs/bootstrap/css/bootstrap-theme.min.css", + "libjs/bootstrap-multiselect-1.1.2/bootstrap-multiselect.min.css", ] @@ -95,7 +95,7 @@ _HTML_BEGIN = f""" %(page_title)s - + @@ -212,7 +212,8 @@ def sco_header( """ ) diff --git a/app/scodoc/sco_archives_justificatifs.py b/app/scodoc/sco_archives_justificatifs.py index 125569d019..60c3bd8db1 100644 --- a/app/scodoc/sco_archives_justificatifs.py +++ b/app/scodoc/sco_archives_justificatifs.py @@ -157,10 +157,15 @@ class JustificatifArchiver(BaseArchiver): Si trace == True : sauvegarde le nom du/des fichier(s) supprimé(s) dans la trace de l'étudiant """ + print("debug : ", archive_name, filename, has_trace) if str(etud.id) not in self.list_oids(etud.dept_id): raise ValueError(f"Aucune archive pour etudid[{etud.id}]") - - archive_id = self.get_id_from_name(etud.id, archive_name, dept_id=etud.dept_id) + try: + archive_id = self.get_id_from_name( + etud.id, archive_name, dept_id=etud.dept_id + ) + except ScoValueError: + raise ValueError(f"Archive Inconnue [{archive_name}]") if filename is not None: if filename not in self.list_archive(archive_id, dept_id=etud.dept_id): diff --git a/app/scodoc/sco_cursus_dut.py b/app/scodoc/sco_cursus_dut.py index e3bbe0f67d..f19fa00f3c 100644 --- a/app/scodoc/sco_cursus_dut.py +++ b/app/scodoc/sco_cursus_dut.py @@ -987,7 +987,7 @@ def formsemestre_has_decisions(formsemestre_id): """ cnx = ndb.GetDBConnexion() validations = scolar_formsemestre_validation_list( - cnx, args={"formsemestre_id": formsemestre_id} + cnx, args={"formsemestre_id": formsemestre_id, "is_external": False} ) return len(validations) > 0 diff --git a/app/scodoc/sco_edit_formation.py b/app/scodoc/sco_edit_formation.py index 2327071ba0..0857656df2 100644 --- a/app/scodoc/sco_edit_formation.py +++ b/app/scodoc/sco_edit_formation.py @@ -80,7 +80,7 @@ def formation_delete(formation_id=None, dialog_confirmed=False): f"""

Confirmer la suppression de la formation {formation.titre} ({formation.acronyme}) ?

-

Attention: la suppression d'une formation est irréversible +

Attention: la suppression d'une formation est irréversible et implique la supression de toutes les UE, matières et modules de la formation !

""", @@ -273,7 +273,8 @@ def formation_edit(formation_id=None, create=False): "\n".join(H) + tf_error_message( f"""Valeurs incorrectes: il existe déjà une formation avec même titre, acronyme et version. """ @@ -285,11 +286,11 @@ def formation_edit(formation_id=None, create=False): if create: formation = do_formation_create(tf[2]) else: - do_formation_edit(tf[2]) - flash( - f"""Création de la formation { - formation.titre} ({formation.acronyme}) version {formation.version}""" - ) + if do_formation_edit(tf[2]): + flash( + f"""Modification de la formation { + formation.titre} ({formation.acronyme}) version {formation.version}""" + ) return flask.redirect( url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id @@ -335,8 +336,8 @@ def do_formation_create(args: dict) -> Formation: return formation -def do_formation_edit(args): - "edit a formation" +def do_formation_edit(args) -> bool: + "edit a formation, returns True if modified" # On ne peut jamais supprimer le code formation: if "formation_code" in args and not args["formation_code"]: @@ -350,11 +351,16 @@ def do_formation_edit(args): if "type_parcours" in args: del args["type_parcours"] + modified = False for field in formation.__dict__: if field in args: value = args[field].strip() if isinstance(args[field], str) else args[field] - if field and field[0] != "_": + if field and field[0] != "_" and getattr(formation, field, None) != value: setattr(formation, field, value) + modified = True + + if not modified: + return False db.session.add(formation) try: @@ -370,6 +376,7 @@ def do_formation_edit(args): ), ) from exc formation.invalidate_cached_sems() + return True def module_move(module_id, after=0, redirect=True): diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index ea0ca9fb7b..4f5b9235ed 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -74,7 +74,8 @@ _moduleEditor = ndb.EditableTable( "semestre_id", "numero", "code_apogee", - "module_type" + "module_type", + "edt_id", #'ects' ), sortkey="numero, code, titre", @@ -171,7 +172,7 @@ def do_module_delete(oid): d'en créer une nouvelle version pour la modifier sans affecter les semestres déjà en place.

- reprendre """ raise ScoGenError(err_page) @@ -645,7 +646,7 @@ def module_edit( "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 (ce code est propre à chaque établissement, se rapprocher + séparés par des virgules (ce code est propre à chaque établissement, se rapprocher du référent Apogée). """, "validator": lambda val, _: len(val) < APO_CODE_STR_LEN, @@ -682,7 +683,7 @@ def module_edit( ] + ["-1"], "explanation": """Parcours dans lesquels est utilisé ce module.
- Attention: si le module ne doit pas avoir les mêmes coefficients suivant le parcours, + Attention: si le module ne doit pas avoir les mêmes coefficients suivant le parcours, il faut en créer plusieurs versions, car dans ScoDoc chaque module a ses coefficients.""", }, ) @@ -746,7 +747,7 @@ def module_edit( "input_type": "separator", "title": f"""{scu.EMO_WARNING } Pas de parcours: - associer un référentiel de compétence """, diff --git a/app/scodoc/sco_edt_cal.py b/app/scodoc/sco_edt_cal.py index 613428ed16..eaa172a0f4 100644 --- a/app/scodoc/sco_edt_cal.py +++ b/app/scodoc/sco_edt_cal.py @@ -29,178 +29,387 @@ XXX usage uniquement experimental pour tests implémentations -XXX incompatible avec les ics HyperPlanning Paris 13 (était pour GPU). - """ - +from datetime import timezone +import re import icalendar -import pprint -import traceback -import urllib -import app.scodoc.sco_utils as scu +from flask import g, url_for from app import log -from app.scodoc import html_sco_header -from app.scodoc import sco_formsemestre -from app.scodoc import sco_groups -from app.scodoc import sco_groups_view -from app.scodoc import sco_preferences +from app.models import FormSemestre, GroupDescr, ModuleImpl, ScoDocSiteConfig +from app.scodoc.sco_exceptions import ScoValueError +import app.scodoc.sco_utils as scu -def formsemestre_get_ics_url(sem): - """ - edt_sem_ics_url est un template - utilisé avec .format(sem=sem) - Par exemple: - https://example.fr/agenda/{sem[etapes][0]} - """ - ics_url_tmpl = sco_preferences.get_preference( - "edt_sem_ics_url", sem["formsemestre_id"] - ) - if not ics_url_tmpl: +def get_ics_filename(edt_id: str) -> str | None: + "Le chemin vers l'ics de cet edt_id" + edt_ics_path = ScoDocSiteConfig.get("edt_ics_path") + if not edt_ics_path.strip(): return None - try: - ics_url = ics_url_tmpl.format(sem=sem) - except: - log( - f"""Exception in formsemestre_get_ics_url(formsemestre_id={sem["formsemestre_id"]}) - ics_url_tmpl='{ics_url_tmpl}' - """ + return edt_ics_path.format(edt_id=edt_id) + + +def formsemestre_load_calendar( + formsemestre: FormSemestre = None, edt_id: str = None +) -> tuple[bytes, icalendar.cal.Calendar]: + """Load ics data, return raw ics and decoded calendar. + Raises ScoValueError if not configured or not available or invalid format. + """ + edt_ids = [] + if edt_id is None and formsemestre: + edt_ids = formsemestre.get_edt_ids() + if not edt_ids: + raise ScoValueError( + "accès aux emplois du temps non configuré pour ce semestre (pas d'edt_id)" ) - log(traceback.format_exc()) - return None - return ics_url + # Ne charge qu'un seul ics pour le semestre, prend uniquement + # le premier edt_id + ics_filename = get_ics_filename(edt_ids[0]) + if ics_filename is None: + raise ScoValueError("accès aux emplois du temps non configuré (pas de chemin)") + try: + with open(ics_filename, "rb") as file: + log(f"Loading edt from {ics_filename}") + data = file.read() + try: + calendar = icalendar.Calendar.from_ical(data) + except ValueError as exc: + log( + f"""formsemestre_load_calendar: error importing ics for { + formsemestre or ''}\npath='{ics_filename}'""" + ) + raise ScoValueError( + f"calendrier ics illisible (edt_id={edt_id})" + ) from exc + except FileNotFoundError as exc: + log( + f"formsemestre_load_calendar: ics not found for {formsemestre or ''}\npath='{ics_filename}'" + ) + raise ScoValueError( + f"Fichier ics introuvable (filename={ics_filename})" + ) from exc + except PermissionError as exc: + log( + f"""formsemestre_load_calendar: permission denied for {formsemestre or '' + }\npath='{ics_filename}'""" + ) + raise ScoValueError( + f"Fichier ics inaccessible: vérifier permissions (filename={ics_filename})" + ) from exc + + return data, calendar -def formsemestre_load_ics(sem): - """Load ics data, from our cache or, when necessary, from external provider""" - # TODO: cacher le résultat - ics_url = formsemestre_get_ics_url(sem) - if not ics_url: - ics_data = "" - else: - log(f"Loading edt from {ics_url}") - # 5s TODO: add config parameter, eg for slow networks - f = urllib.request.urlopen(ics_url, timeout=5) - ics_data = f.read() - f.close() - - cal = icalendar.Calendar.from_ical(ics_data) - return cal +# --- Couleurs des évènements emploi du temps +_COLOR_PALETTE = [ + "#ff6961", + "#ffb480", + "#f8f38d", + "#42d6a4", + "#08cad1", + "#59adf6", + "#9d94ff", + "#c780e8", +] +_EVENT_DEFAULT_COLOR = "rgb(214, 233, 248)" -def get_edt_transcodage_groups(formsemestre_id): - """-> { nom_groupe_edt : nom_groupe_scodoc }""" - # TODO: valider ces données au moment où on enregistre les préférences - edt2sco = {} - sco2edt = {} - msg = "" # message erreur, '' si ok - txt = sco_preferences.get_preference("edt_groups2scodoc", formsemestre_id) - if not txt: - return edt2sco, sco2edt, msg - - line_num = 1 - for line in txt.split("\n"): - fs = [s.strip() for s in line.split(";")] - if len(fs) == 1: # groupe 'tous' - edt2sco[fs[0]] = None - sco2edt[None] = fs[0] - elif len(fs) == 2: - edt2sco[fs[0]] = fs[1] - sco2edt[fs[1]] = fs[0] - else: - msg = f"ligne {line_num} invalide" - line_num += 1 - - log(f"sco2edt={pprint.pformat(sco2edt)}") - return edt2sco, sco2edt, msg - - -def group_edt_json(group_id, start="", end=""): # actuellement inutilisé - """EDT complet du semestre, au format JSON - TODO: indiquer un groupe - TODO: utiliser start et end (2 dates au format ISO YYYY-MM-DD) - TODO: cacher +def formsemestre_edt_dict( + formsemestre: FormSemestre, group_ids: list[int] = None +) -> list[dict]: + """EDT complet du semestre, comme une liste de dict serialisable en json. + Fonction appelée par l'API /formsemestre//edt + group_ids indiquer les groupes ScoDoc à afficher (les autres sont filtrés). + Les évènements pour lesquels le groupe ScoDoc n'est pas reconnu sont + toujours présents. + TODO: spécifier intervalle de dates start et end """ - group = sco_groups.get_group(group_id) - sem = sco_formsemestre.get_formsemestre(group["formsemestre_id"]) - edt2sco, sco2edt, msg = get_edt_transcodage_groups(group["formsemestre_id"]) + group_ids_set = set(group_ids) if group_ids else set() + try: + events_scodoc = _load_and_convert_ics(formsemestre) + except ScoValueError as exc: + return exc.args[0] + # Génération des événements pour le calendrier html + events_cal = [] + for event in events_scodoc: + group: GroupDescr | bool = event["group"] + if group is False: + group_disp = f"""
+ + {scu.EMO_WARNING} non configuré +
""" + else: + group_disp = ( + f"""
{group.get_nom_with_part(default="promo")}
""" + if group + else f"""
{event['edt_group']} + + {scu.EMO_WARNING} non reconnu +
""" + ) + if group and group_ids_set and group.id not in group_ids_set: + continue # ignore cet évènement + modimpl: ModuleImpl | bool = event["modimpl"] + url_abs = ( + url_for( + "assiduites.signal_assiduites_group", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre.id, + group_ids=group.id, + heure_deb=event["heure_deb"], + heure_fin=event["heure_fin"], + moduleimpl_id=modimpl.id, + jour=event["jour"], + ) + if modimpl and group + else None + ) + match modimpl: + case False: # EDT non configuré + mod_disp = f"""{scu.EMO_WARNING} non configuré""" + bubble = "extraction emploi du temps non configurée" + case None: # Module edt non trouvé dans ScoDoc + mod_disp = f"""{ + scu.EMO_WARNING} {event['edt_module']}""" + bubble = "code module non trouvé dans ScoDoc. Vérifier configuration." + case _: # module EDT bien retrouvé dans ScoDoc + mod_disp = f"""{ + modimpl.module.code}""" + bubble = f"{modimpl.module.abbrev or ''} ({event['edt_module']})" - edt_group_name = sco2edt.get(group["group_name"], group["group_name"]) - log("group scodoc=%s : edt=%s" % (group["group_name"], edt_group_name)) + title = f""" + """ - cal = formsemestre_load_ics(sem) - events = [e for e in cal.walk() if e.name == "VEVENT"] - J = [] - for e in events: - # if e['X-GROUP-ID'].strip() == edt_group_name: - if "DESCRIPTION" in e: - d = { - "title": e.decoded("DESCRIPTION"), # + '/' + e['X-GROUP-ID'], - "start": e.decoded("dtstart").isoformat(), - "end": e.decoded("dtend").isoformat(), - } - J.append(d) + # --- Lien saisie abs + link_abs = ( + f"""""" + if url_abs + else "" + ) + d = { + # Champs utilisés par tui.calendar + "calendarId": "cal1", + "title": f"""{title} {group_disp} {link_abs}""", + "start": event["start"], + "end": event["end"], + "backgroundColor": event["group_bg_color"], + # Infos brutes pour usage API éventuel + "group_id": group.id if group else None, + "group_edt_id": event["edt_group"], + "moduleimpl_id": modimpl.id if modimpl else None, + } + events_cal.append(d) - return scu.sendJSON(J) + return events_cal -# def experimental_calendar(group_id=None, formsemestre_id=None): # inutilisé -# """experimental page""" -# return "\n".join( -# [ -# html_sco_header.sco_header( -# javascripts=[ -# "libjs/purl.js", -# "libjs/moment.min.js", -# "libjs/fullcalendar/fullcalendar.min.js", -# ], -# cssstyles=[ -# # 'libjs/bootstrap-3.1.1-dist/css/bootstrap.min.css', -# # 'libjs/bootstrap-3.1.1-dist/css/bootstrap-theme.min.css', -# # 'libjs/bootstrap-multiselect/bootstrap-multiselect.css' -# "libjs/fullcalendar/fullcalendar.css", -# # media='print' 'libjs/fullcalendar/fullcalendar.print.css' -# ], -# ), -# """ -# """, -# """
-# Emplois du temps du groupe""", -# sco_groups_view.menu_group_choice( -# group_id=group_id, formsemestre_id=formsemestre_id -# ), -# """
loading...
-#
-# """, -# html_sco_header.sco_footer(), -# """ -# """, -# ] -# ) + +def extract_event_data( + event: icalendar.cal.Event, ics_field: str, pattern: re.Pattern +) -> str: + """Extrait la chaine (id) de l'évènement.""" + if not event.has_key(ics_field): + return "-" + data = event.decoded(ics_field).decode("utf-8") # assume ics in utf8 + m = pattern.search(data) + if m and len(m.groups()) > 0: + return m.group(1) + # fallback: ics field, complete + return data + + +# def extract_event_title(event: icalendar.cal.Event) -> str: +# """Extrait le titre à afficher dans nos calendriers. +# En effet, le titre présent dans l'ics emploi du temps est souvent complexe et peu parlant. +# Par exemple, à l'USPN, Hyperplanning nous donne: +# 'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n' +# """ +# # TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ? +# if not event.has_key("DESCRIPTION"): +# return "-" +# description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8 +# # ici on prend le nom du module +# m = re.search(r"Matière : \w+ - ([\w\.\s']+)", description) +# if m and len(m.groups()) > 0: +# return m.group(1) +# # fallback: full description +# return description + + +# def extract_event_module(event: icalendar.cal.Event) -> str: +# """Extrait le code module de l'emplois du temps. +# Chaine vide si ne le trouve pas. +# Par exemple, à l'USPN, Hyperplanning nous donne le code 'VRETR113' dans DESCRIPTION +# 'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n' +# """ +# # TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ? +# if not event.has_key("DESCRIPTION"): +# return "-" +# description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8 +# # extraction du code: +# m = re.search(r"Matière : ([A-Z][A-Z0-9]+)", description) +# if m and len(m.groups()) > 0: +# return m.group(1) +# return "" + + +# def extract_event_group(event: icalendar.cal.Event) -> str: +# """Extrait le nom du groupe (TD, ...). "" si pas de match.""" +# # Utilise ici le SUMMARY +# # qui est de la forme +# # SUMMARY;LANGUAGE=fr:TP2 GPR1 - VCYR303 - Services reseaux ava (VCYR303) - 1234 - M. VIENNET EMMANUEL - V2ROM - BUT2 RT pa. ROM - Groupe 1 +# if not event.has_key("SUMMARY"): +# return "-" +# summary = event.decoded("SUMMARY").decode("utf-8") # assume ics in utf8 +# # extraction du code: +# m = re.search(r".*- ([\w\s]+)$", summary) +# if m and len(m.groups()) > 0: +# return m.group(1).strip() +# return "" + + +def formsemestre_retreive_modimpls_from_edt_id( + formsemestre: FormSemestre, +) -> dict[str, ModuleImpl]: + """Construit un dict donnant le moduleimpl de chaque edt_id""" + edt2modimpl = {} + for modimpl in formsemestre.modimpls: + for edt_id in modimpl.get_edt_ids(): + if edt_id: + edt2modimpl[edt_id] = modimpl + return edt2modimpl + + +def formsemestre_retreive_groups_from_edt_id( + formsemestre: FormSemestre, +) -> dict[str, GroupDescr]: + """Construit un dict donnant le groupe de chaque edt_id""" + edt2group = {} + for partition in formsemestre.partitions: + for g in partition.groups: + for edt_id in g.get_edt_ids(): + edt2group[edt_id] = g + return edt2group diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py index c24be275cf..ed63817cd9 100644 --- a/app/scodoc/sco_evaluation_edit.py +++ b/app/scodoc/sco_evaluation_edit.py @@ -45,6 +45,7 @@ from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc import html_sco_header +from app.scodoc import sco_cache from app.scodoc import sco_evaluations from app.scodoc import sco_moduleimpl from app.scodoc import sco_preferences @@ -87,7 +88,7 @@ def evaluation_create_form( {html_sco_header.sco_header()}

Opération non autorisée

Modification évaluation impossible pour {current_user.get_nomplogin()}

-

Revenir

@@ -131,7 +132,7 @@ def evaluation_create_form( H = [ f"""

{action} en {scu.MODULE_TYPE_NAMES[mod["module_type"]]} {mod["code"] or "module sans code"} {mod["titre"]} {link}

""" @@ -299,7 +300,7 @@ def evaluation_create_form( "type": "float", "explanation": f""" ({ - "coef. mod.:" +str(coef_ue) if coef_ue + "coef. mod.:" +str(coef_ue) if coef_ue else "ce module n'a pas de coef. dans cette UE" }) {ue.titre} @@ -382,4 +383,5 @@ def evaluation_create_form( evaluation.set_ue_poids(ue, tf[2][f"poids_{ue.id}"]) db.session.add(evaluation) db.session.commit() + sco_cache.invalidate_formsemestre(evaluation.moduleimpl.formsemestre.id) return flask.redirect(dest_url) diff --git a/app/scodoc/sco_formations.py b/app/scodoc/sco_formations.py index dbd864f531..e17b4945a2 100644 --- a/app/scodoc/sco_formations.py +++ b/app/scodoc/sco_formations.py @@ -307,7 +307,7 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False): D = sco_xml.xml_to_dicts(f) except Exception as exc: raise ScoFormatError( - """Ce document xml ne correspond pas à un programme exporté par ScoDoc. + """Ce document xml ne correspond pas à un programme exporté par ScoDoc. (élément 'formation' inexistant par exemple).""" ) from exc assert D[0] == "formation" @@ -322,8 +322,13 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False): referentiel_competence_id = _formation_retreive_refcomp(f_dict) f_dict["referentiel_competence_id"] = referentiel_competence_id # find new version number + acronyme_lower = f_dict["acronyme"].lower() if f_dict["acronyme"] else "" + titre_lower = f_dict["titre"].lower() if f_dict["titre"] else "" formations: list[Formation] = Formation.query.filter_by( - acronyme=f_dict["acronyme"], titre=f_dict["titre"], dept_id=f_dict["dept_id"] + dept_id=f_dict["dept_id"] + ).filter( + db.func.lower(Formation.acronyme) == acronyme_lower, + db.func.lower(Formation.titre) == titre_lower, ) if formations.count(): version = max(f.version or 0 for f in formations) @@ -518,6 +523,7 @@ def formation_list_table() -> GenTable: "_titre_link_class": "stdlink", "_titre_id": f"""titre-{acronyme_no_spaces}""", "version": formation.version or 0, + "commentaire": formation.commentaire or "", } # Ajoute les semestres associés à chaque formation: row["formsemestres"] = formation.formsemestres.order_by( @@ -594,10 +600,12 @@ def formation_list_table() -> GenTable: "formation_code", "version", "titre", + "commentaire", "sems_list_txt", ) titles = { "buttons": "", + "commentaire": "Commentaire", "acronyme": "Acro.", "parcours_name": "Type", "titre": "Titre", diff --git a/app/scodoc/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py index 8c77ed6a35..d01c7af82f 100644 --- a/app/scodoc/sco_formsemestre.py +++ b/app/scodoc/sco_formsemestre.py @@ -68,6 +68,7 @@ _formsemestreEditor = ndb.EditableTable( "ens_can_edit_eval", "elt_sem_apo", "elt_annee_apo", + "edt_id", ), filter_dept=True, sortkey="date_debut", @@ -571,7 +572,7 @@ def view_formsemestre_by_etape(etape_apo=None, fmt="html"): ), html_title=html_title, html_next_section="""
- Etape: + Etape:
""", ) tab.base_url = "%s?etape_apo=%s" % (request.base_url, etape_apo or "") diff --git a/app/scodoc/sco_formsemestre_custommenu.py b/app/scodoc/sco_formsemestre_custommenu.py index a98c07ac19..86c5bb3915 100644 --- a/app/scodoc/sco_formsemestre_custommenu.py +++ b/app/scodoc/sco_formsemestre_custommenu.py @@ -86,11 +86,6 @@ def build_context_dict(formsemestre_id: int) -> dict: def formsemestre_custommenu_html(formsemestre_id): "HTML code for custom menu" menu = [] - # Calendrier électronique ? - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - ics_url = sco_edt_cal.formsemestre_get_ics_url(sem) - if ics_url: - menu.append({"title": "Emploi du temps (ics)", "url": ics_url}) # Liens globaux (config. générale) params = build_context_dict(formsemestre_id) for link in ScoDocSiteConfig.get_perso_links(): diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 1aedb43e74..257cbae19a 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -40,6 +40,7 @@ from app.models import ( ModuleImpl, Evaluation, UniteEns, + ScoDocSiteConfig, ScolarFormSemestreValidation, ScolarAutorisationInscription, ApcValidationAnnee, @@ -213,6 +214,9 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N ) for index, resp in enumerate(formsemestre.responsables): initvalues[resp_fields[index]] = uid2display.get(resp.id) + group_tous = formsemestre.get_default_group() + if group_tous: + initvalues["edt_promo_id"] = group_tous.edt_id or "" # Liste des ID de semestres if formation.type_parcours is not None: @@ -321,7 +325,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N "size": 40, "title": "Nom de ce semestre", "explanation": f"""n'indiquez pas les dates, ni le semestre, ni la modalité dans - le titre: ils seront automatiquement ajoutés """, "allow_null": False, @@ -445,13 +449,41 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N }, ) ) + if ScoDocSiteConfig.get("edt_ics_path"): + modform.append( + ( + "edt_id", + { + "size": 32, + "title": "Identifiant EDT", + "explanation": "optionnel, identifiant sur le logiciel emploi du temps (par défaut, utilise la première étape Apogée).", + "allow_null": True, + }, + ) + ) + modform.append( + ( + "edt_promo_id", + { + "size": 32, + "title": "Identifiant EDT promo", + "explanation": """optionnel, identifiant du groupe "tous" + (promotion complète) dans l'emploi du temps.""", + "allow_null": True, + }, + ) + ) if edit: formtit = f""" -

Modifier les coefficients des UE capitalisées

-

Sélectionner les modules, leurs responsables et les étudiants +

Modifier les codes Apogée et emploi du temps des modules +

+

Sélectionner les modules, leurs responsables et les étudiants à inscrire:

""" else: @@ -510,7 +542,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N { "input_type": "boolcheckbox", "title": "", - "explanation": """Autoriser tous les enseignants associés + "explanation": """Autoriser tous les enseignants associés à un module à y créer des évaluations""", }, ), @@ -585,8 +617,8 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N { "input_type": "separator", "title": f"""{scu.EMO_WARNING } - Pas de parcours: - vérifier la formation """, @@ -784,7 +816,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N if tf[0] == 0 or msg: return f"""

Formation {formation.titre} ({formation.acronyme}), version { formation.version}, code {formation.formation_code} @@ -953,8 +985,16 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N db.session.get(ApcParcours, int(parcour_id_str)) for parcour_id_str in tf[2]["parcours"] ] + # --- Id edt du groupe par défault + if "edt_promo_id" in tf[2]: + group_tous = formsemestre.get_default_group() + if group_tous: + group_tous.edt_id = tf[2]["edt_promo_id"] + db.session.add(group_tous) + db.session.add(formsemestre) db.session.commit() + # --- Crée ou met à jour les groupes de parcours BUT formsemestre.setup_parcours_groups() # peut être nécessaire dans certains cas: @@ -969,11 +1009,11 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N - {"

Modification effectuée

" if ok + {"

Modification effectuée

" if ok else "

Modules non modifiés

" } retour au tableau de bord """ @@ -1309,11 +1349,11 @@ def formsemestre_delete(formsemestre_id): html_sco_header.html_sem_header("Suppression du semestre"), """
Attention !

A n'utiliser qu'en cas d'erreur lors de la saisie d'une formation. Normalement, -un semestre ne doit jamais être supprimé +un semestre ne doit jamais être supprimé (on perd la mémoire des notes et de tous les événements liés à ce semestre !).

-

Tous les modules de ce semestre seront supprimés. +

Tous les modules de ce semestre seront supprimés. Ceci n'est possible que si :

    @@ -1497,24 +1537,24 @@ def do_formsemestre_delete(formsemestre_id): req = "DELETE FROM sco_prefs WHERE formsemestre_id=%(formsemestre_id)s" cursor.execute(req, {"formsemestre_id": formsemestre_id}) # --- Suppression des groupes et partitions - req = """DELETE FROM group_membership - WHERE group_id IN + req = """DELETE FROM group_membership + WHERE group_id IN (SELECT gm.group_id FROM group_membership gm, partition p, group_descr gd - WHERE gm.group_id = gd.id AND gd.partition_id = p.id + WHERE gm.group_id = gd.id AND gd.partition_id = p.id AND p.formsemestre_id=%(formsemestre_id)s) """ cursor.execute(req, {"formsemestre_id": formsemestre_id}) - req = """DELETE FROM group_descr - WHERE id IN - (SELECT gd.id FROM group_descr gd, partition p - WHERE gd.partition_id = p.id + req = """DELETE FROM group_descr + WHERE id IN + (SELECT gd.id FROM group_descr gd, partition p + WHERE gd.partition_id = p.id AND p.formsemestre_id=%(formsemestre_id)s) """ cursor.execute(req, {"formsemestre_id": formsemestre_id}) req = "DELETE FROM partition WHERE formsemestre_id=%(formsemestre_id)s" cursor.execute(req, {"formsemestre_id": formsemestre_id}) # --- Responsables - req = """DELETE FROM notes_formsemestre_responsables + req = """DELETE FROM notes_formsemestre_responsables WHERE formsemestre_id=%(formsemestre_id)s""" cursor.execute(req, {"formsemestre_id": formsemestre_id}) # --- Etapes @@ -1606,7 +1646,7 @@ def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None): Indiquez "auto" (ou laisser vide) pour que ScoDoc calcule automatiquement le coefficient, ou bien entrez une valeur (nombre réel).

    -

    Dans le doute, si le mode auto n'est pas applicable et que tous les étudiants sont inscrits aux mêmes modules de ce semestre, prenez comme coefficient la somme indiquée. +

    Dans le doute, si le mode auto n'est pas applicable et que tous les étudiants sont inscrits aux mêmes modules de ce semestre, prenez comme coefficient la somme indiquée. Sinon, référez vous au programme pédagogique. Les lignes en rouge sont à changer.

    @@ -1734,7 +1774,7 @@ def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None): return f"""{html_sco_header.html_sem_header("Coefficients des UE du semestre")} {" ".join(message)} -

    Revenir au tableau de bord

    diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index c716cd0981..767f5a2fac 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -257,6 +257,13 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str: "enabled": current_user.has_permission(Permission.EditFormSemestre), "helpmsg": "", }, + { + "title": "Expérimental: emploi du temps", + "endpoint": "notes.formsemestre_edt", + "args": {"formsemestre_id": formsemestre_id}, + "enabled": True, + "helpmsg": "", + }, ] # debug : if current_app.config["DEBUG"]: @@ -796,10 +803,10 @@ def formsemestre_description( tab.html_before_table = f"""
    - indiquer les évaluations - indiquer les parcours BUT """ @@ -836,7 +843,7 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str: 'Tous les étudiants'}
{ - "Gestion de l'assiduité" if not partition_is_empty else "" + "Gestion de l'assiduité" if not partition_is_empty else "" }
""" ) @@ -925,8 +932,8 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str: if formsemestre.can_change_groups(): H.append( f""" (créer)""" ) @@ -937,8 +944,8 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str: H.append( f"""

Ajouter une partition

""" ) @@ -1310,13 +1317,13 @@ def formsemestre_tableau_modules( {mod.code} - {mod.abbrev or mod.titre or ""} {len(mod_inscrits)} { - sco_users.user_info(modimpl["responsable_id"])["prenomnom"] + sco_users.user_info(modimpl["responsable_id"])["prenomnom"] } @@ -1457,8 +1464,8 @@ def formsemestre_warning_etuds_sans_note( "notes.formsemestre_note_etuds_sans_notes", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id, - )}">{"lui" if nb_sans_notes == 1 else "leur"} - {"lui" if nb_sans_notes == 1 else "leur"} + affecter des notes. """ diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py index 6a4d77e841..a37e7934cd 100644 --- a/app/scodoc/sco_groups.py +++ b/app/scodoc/sco_groups.py @@ -42,7 +42,7 @@ from app import cache, db, log from app.comp import res_sem from app.comp.res_compat import NotesTableCompat from app.models import FormSemestre, Identite, Scolog -from app.models import GROUPNAME_STR_LEN, SHORT_STR_LEN +from app.models import SHORT_STR_LEN from app.models.groups import GroupDescr, Partition import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb @@ -79,7 +79,9 @@ partitionEditor = ndb.EditableTable( ) groupEditor = ndb.EditableTable( - "group_descr", "group_id", ("group_id", "partition_id", "group_name", "numero") + "group_descr", + "group_id", + ("group_id", "partition_id", "group_name", "numero", "edt_id"), ) group_list = groupEditor.list @@ -136,7 +138,7 @@ def get_partitions_list(formsemestre_id, with_default=True) -> list[dict]: partitions = ndb.SimpleDictFetch( """SELECT p.id AS partition_id, p.* FROM partition p - WHERE formsemestre_id=%(formsemestre_id)s + WHERE formsemestre_id=%(formsemestre_id)s ORDER BY numero""", {"formsemestre_id": formsemestre_id}, ) @@ -206,8 +208,10 @@ def get_partition_groups(partition): # OBSOLETE ! ) -def get_default_group(formsemestre_id, fix_if_missing=False): - """Returns group_id for default ('tous') group""" +def get_default_group(formsemestre_id, fix_if_missing=False) -> int: + """Returns group_id for default ('tous') group + XXX remplacé par formsemestre.get_default_group + """ r = ndb.SimpleDictFetch( """SELECT gd.id AS group_id FROM group_descr gd, partition p @@ -258,14 +262,14 @@ def get_group_members(group_id, etat=None): Trié par nom_usuel (ou nom) puis prénom """ req = """SELECT i.id as etudid, i.*, a.*, gm.*, ins.etat - FROM identite i, adresse a, group_membership gm, - group_descr gd, partition p, notes_formsemestre_inscription ins - WHERE i.id = gm.etudid - and a.etudid = i.id - and ins.etudid = i.id - and ins.formsemestre_id = p.formsemestre_id - and p.id = gd.partition_id - and gd.id = gm.group_id + FROM identite i, adresse a, group_membership gm, + group_descr gd, partition p, notes_formsemestre_inscription ins + WHERE i.id = gm.etudid + and a.etudid = i.id + and ins.etudid = i.id + and ins.formsemestre_id = p.formsemestre_id + and p.id = gd.partition_id + and gd.id = gm.group_id and gm.group_id=%(group_id)s """ if etat is not None: @@ -286,8 +290,9 @@ def get_group_members(group_id, etat=None): return r -def get_group_infos(group_id, etat=None): # was _getlisteetud - """legacy code: used by group_list and trombino""" +def get_group_infos(group_id, etat: str | None = None): # was _getlisteetud + """legacy code: used by group_list and trombino. + etat: état de l'inscription.""" from app.scodoc import sco_formsemestre cnx = ndb.GetDBConnexion() @@ -350,12 +355,12 @@ def get_etud_groups(etudid: int, formsemestre_id: int, exclude_default=False): """Infos sur groupes de l'etudiant dans ce semestre [ group + partition_name ] """ - req = """SELECT p.id AS partition_id, p.*, + req = """SELECT p.id AS partition_id, p.*, g.id AS group_id, g.numero as group_numero, g.group_name - FROM group_descr g, partition p, group_membership gm - WHERE gm.etudid=%(etudid)s - and gm.group_id = g.id - and g.partition_id = p.id + FROM group_descr g, partition p, group_membership gm + WHERE gm.etudid=%(etudid)s + and gm.group_id = g.id + and g.partition_id = p.id and p.formsemestre_id = %(formsemestre_id)s """ if exclude_default: @@ -393,7 +398,7 @@ def formsemestre_get_etud_groupnames(formsemestre_id, attr="group_name"): p.id AS partition_id, gd.group_name, gd.id AS group_id - FROM + FROM notes_formsemestre_inscription i, partition p, group_descr gd, @@ -967,8 +972,8 @@ def edit_partition_form(formsemestre_id=None): for p in partitions: if p["partition_name"] is not None: H.append( - f"""{suppricon} """ ) @@ -1299,85 +1304,6 @@ def partition_set_name(partition_id, partition_name, redirect=1): ) -def group_set_name(group: GroupDescr, group_name: str, redirect=True): - """Set group name""" - if not group.partition.formsemestre.can_change_groups(): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - if group.group_name is None: - raise ValueError("can't set a name to default group") - destination = url_for( - "scolar.affect_groups", - scodoc_dept=g.scodoc_dept, - partition_id=group.partition_id, - ) - if group_name: - group_name = group_name.strip() - if not group_name: - raise ScoValueError("nom de groupe vide !", dest_url=destination) - if not GroupDescr.check_name(group.partition, group_name): - raise ScoValueError( - "Le nom de groupe existe déjà dans la partition", dest_url=destination - ) - - redirect = int(redirect) - group.group_name = group_name - db.session.add(group) - db.session.commit() - sco_cache.invalidate_formsemestre(formsemestre_id=group.partition.formsemestre_id) - - # redirect to partition edit page: - if redirect: - return flask.redirect(destination) - - -def group_rename(group_id): - """Form to rename a group""" - group = GroupDescr.query.get_or_404(group_id) - formsemestre_id = group.partition.formsemestre_id - formsemestre = FormSemestre.get_formsemestre(formsemestre_id) - if not formsemestre.can_change_groups(): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - H = [f"

Renommer un groupe de {group.partition.partition_name or '-'}

"] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("group_id", {"default": group_id, "input_type": "hidden"}), - ( - "group_name", - { - "title": "Nouveau nom", - "default": group.group_name, - "size": 12, - "allow_null": False, - "validator": lambda val, _: len(val) < GROUPNAME_STR_LEN, - }, - ), - ), - submitlabel="Renommer", - cancelbutton="Annuler", - ) - if tf[0] == 0: - return ( - html_sco_header.sco_header() - + "\n".join(H) - + "\n" - + tf[1] - + html_sco_header.sco_footer() - ) - elif tf[0] == -1: - return flask.redirect( - url_for( - "scolar.affect_groups", - scodoc_dept=g.scodoc_dept, - partition_id=group.partition_id, - ) - ) - else: - # form submission - return group_set_name(group, tf[2]["group_name"]) - - def groups_auto_repartition(partition: Partition): """Réparti les etudiants dans des groupes dans une partition, en respectant le niveau et la mixité. @@ -1570,7 +1496,7 @@ def do_evaluation_listeetuds_groups( return [] # no groups, so no students rg = ["gm.group_id = '%(group_id)s'" % g for g in groups] rq = """and Isem.etudid = gm.etudid - and gd.partition_id = p.id + and gd.partition_id = p.id and p.formsemestre_id = Isem.formsemestre_id """ r = rq + " AND (" + " or ".join(rg) + " )" @@ -1583,9 +1509,9 @@ def do_evaluation_listeetuds_groups( "SELECT distinct Im.etudid, Isem.etat FROM " + ", ".join(fromtables) + """ WHERE Isem.etudid = Im.etudid - and Im.moduleimpl_id = M.id - and Isem.formsemestre_id = M.formsemestre_id - and E.moduleimpl_id = M.id + and Im.moduleimpl_id = M.id + and Isem.formsemestre_id = M.formsemestre_id + and E.moduleimpl_id = M.id and E.id = %(evaluation_id)s """ ) @@ -1612,7 +1538,7 @@ def do_evaluation_listegroupes(evaluation_id, include_default=False): cursor = cnx.cursor() cursor.execute( """SELECT DISTINCT gd.id AS group_id - FROM group_descr gd, group_membership gm, partition p, + FROM group_descr gd, group_membership gm, partition p, notes_moduleimpl m, notes_evaluation e WHERE gm.group_id = gd.id and gd.partition_id = p.id diff --git a/app/scodoc/sco_groups_edit.py b/app/scodoc/sco_groups_edit.py index 4fd1e103cb..6c6cfd422e 100644 --- a/app/scodoc/sco_groups_edit.py +++ b/app/scodoc/sco_groups_edit.py @@ -27,11 +27,15 @@ """Formulaires gestion des groupes """ -from flask import render_template +import flask +from flask import flash, g, render_template, request, url_for -from app.models import Partition +from app.models import FormSemestre, GroupDescr, Partition +from app.models import GROUPNAME_STR_LEN from app.scodoc import html_sco_header from app.scodoc.sco_exceptions import AccessDenied +import app.scodoc.sco_utils as scu +from app.scodoc.TrivialFormulator import TrivialFormulator def affect_groups(partition_id): @@ -59,3 +63,73 @@ def affect_groups(partition_id): ), formsemestre_id=formsemestre.id, ) + + +def group_rename(group_id): + """Form to rename a group""" + group: GroupDescr = GroupDescr.query.get_or_404(group_id) + formsemestre_id = group.partition.formsemestre_id + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + if not formsemestre.can_change_groups(): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + H = [f"

Renommer un groupe de {group.partition.partition_name or '-'}

"] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("group_id", {"default": group_id, "input_type": "hidden"}), + ( + "group_name", + { + "title": "Nouveau nom", + "default": group.group_name, + "size": 12, + "allow_null": False, + "validator": lambda val, _: len(val) < GROUPNAME_STR_LEN, + "explanation": "doit être unique dans cette partition" + if group.partition.groups_editable + else "groupes non modifiables dans cette partition", + "enabled": group.partition.groups_editable, + }, + ), + ( + "edt_id", + { + "title": "Id EDT", + "default": group.edt_id or "", + "size": 12, + "allow_null": True, + "explanation": """optionnel : identifiant du groupe dans le logiciel + d'emploi du temps, pour le cas où les noms de groupes ne seraient pas + les mêmes dans ScoDoc et dans l'emploi du temps (si plusieurs ids, + les séparer par des virgules).""", + }, + ), + ), + submitlabel="Enregistrer", + cancelbutton="Annuler", + ) + dest_url = url_for( + "scolar.partition_editor", + scodoc_dept=g.scodoc_dept, + formsemestre_id=group.partition.formsemestre_id, + edit_partition=1, + ) + if tf[0] == 0: + return ( + html_sco_header.sco_header() + + "\n".join(H) + + "\n" + + tf[1] + + html_sco_header.sco_footer() + ) + elif tf[0] == -1: + return flask.redirect(dest_url) + else: + # form submission + # Si la partition n'est pas editable, on ne peut changer que l'edt_id + group.set_edt_id(tf[2]["edt_id"]) + if group.partition.groups_editable: + group.set_name(tf[2]["group_name"], dest_url=dest_url) + flash("groupe modifié") + return flask.redirect(dest_url) diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index c44e3c0cd1..0e4b8ac23c 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -31,11 +31,8 @@ # Re-ecriture en 2014 (re-organisation de l'interface, modernisation du code) -import collections import datetime -import urllib from urllib.parse import parse_qs -import time from flask import url_for, g, request @@ -45,7 +42,6 @@ from app import db from app.models import FormSemestre import app.scodoc.sco_utils as scu from app.scodoc import html_sco_header -from app.scodoc import sco_cal from app.scodoc import sco_excel from app.scodoc import sco_formsemestre from app.scodoc import sco_groups @@ -71,18 +67,24 @@ CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS def groups_view( group_ids=(), fmt="html", - # Options pour listes: with_codes=0, etat=None, - with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) - with_archives=0, # ajoute colonne avec noms fichiers archivés + with_paiement=0, + with_archives=0, with_annotations=0, with_bourse=0, - formsemestre_id=None, # utilise si aucun groupe selectionné + formsemestre_id=None, ): """Affichage des étudiants des groupes indiqués group_ids: liste de group_id fmt: csv, json, xml, xls, allxls, xlsappel, moodlecsv, pdf + + Options pour listes: + with_paiement: si vrai, ajoute colonnes infos paiement droits + et finalisation inscription (lent car interrogation portail) + with_archives: ajoute colonne avec noms fichiers archivés + + formsemestre_id est utilisé si aucun groupe selectionné pour construire la liste des groupes. """ # Informations sur les groupes à afficher: groups_infos = DisplayedGroupsInfos( @@ -104,117 +106,127 @@ def groups_view( with_bourse=with_bourse, ) - H = [ - html_sco_header.sco_header( - javascripts=JAVASCRIPTS, - cssstyles=CSSSTYLES, - init_qtip=True, - ) - ] - # Menu choix groupe - H.append("""
""") - H.append(form_groups_choice(groups_infos, submit_on_change=True)) # Note: le formulaire est soumis a chaque modif des groupes # on pourrait faire comme pour le form de saisie des notes. Il faudrait pour cela: # - charger tous les etudiants au debut, quels que soient les groupes selectionnés # - ajouter du JS pour modifier les liens (arguments group_ids) quand le menu change - # Tabs - # H.extend( ("""toto
  • item 1
  • item 2
""",) ) - H.extend( - ( - """ + return f""" + { html_sco_header.sco_header( + javascripts=JAVASCRIPTS, + cssstyles=CSSSTYLES, + init_qtip=True, + ) + } +
+ + {form_groups_choice(groups_infos, submit_on_change=True)} +
-
- """, - groups_table( - groups_infos=groups_infos, - fmt=fmt, - with_codes=with_codes, - etat=etat, - with_paiement=with_paiement, - with_archives=with_archives, - with_annotations=with_annotations, - with_bourse=with_bourse, - ), - "
", - """
""", - tab_photos_html(groups_infos, etat=etat), - #'

hello

', - "
", - '
', - tab_absences_html(groups_infos, etat=etat), - "
", - ) - ) - - H.append(html_sco_header.sco_footer()) - return "\n".join(H) +
+ { + groups_table( + groups_infos=groups_infos, + fmt=fmt, + with_codes=with_codes, + etat=etat, + with_paiement=with_paiement, + with_archives=with_archives, + with_annotations=with_annotations, + with_bourse=with_bourse, + ) + } +
+
+ { tab_photos_html(groups_infos, etat=etat) } +
+
+ { tab_absences_html(groups_infos, etat=etat) } +
+
+ { html_sco_header.sco_footer() } + """ -def form_groups_choice(groups_infos, with_selectall_butt=False, submit_on_change=False): +def form_groups_choice( + groups_infos, + with_selectall_butt=False, + with_deselect_butt=False, + submit_on_change=False, + default_deselect_others=True, +): """form pour selection groupes group_ids est la liste des groupes actuellement sélectionnés et doit comporter au moins un élément, sauf si formsemestre_id est spécifié. (utilisé pour retrouver le semestre et proposer la liste des autres groupes) - Si submit_on_change, ajoute une classe "submit_on_change" qui est utilisee en JS + Si submit_on_change, soumet (recharge la page) à chaque modif. + Si default_deselect_others, désélectionne le groupe "Tous" quand on sélectionne un autre groupe. + + Ces deux options ajoutent des classes utilisées en JS pour la gestion du formulaire. """ default_group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) H = [ - """ - - + f""" + + + Groupes: + { + menu_groups_choice( + groups_infos, + submit_on_change=submit_on_change, + default_deselect_others=default_deselect_others, + ) + } """ - % (groups_infos.formsemestre_id, default_group_id) ] - - H.append(menu_groups_choice(groups_infos, submit_on_change=submit_on_change)) - if with_selectall_butt: H.append( - """""" + """""" + ) + if with_deselect_butt: + H.append( + """""" ) H.append("") return "\n".join(H) -def menu_groups_choice(groups_infos, submit_on_change=False): +def menu_groups_choice( + groups_infos, submit_on_change=False, default_deselect_others=True +): """menu pour selection groupes group_ids est la liste des groupes actuellement sélectionnés et doit comporter au moins un élément, sauf si formsemestre_id est spécifié. (utilisé pour retrouver le semestre et proposer la liste des autres groupes) """ default_group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) - - if submit_on_change: - klass = "submit_on_change" - else: - klass = "" - H = [ - """ + + """ + ] for partition in groups_infos.partitions: H.append('' % partition["partition_name"]) @@ -294,14 +306,19 @@ class DisplayedGroupsInfos: .formsemestre_id : semestre "principal" (en fait celui du 1er groupe de la liste) .members .groups_titles + + etat: filtrage selon l'état de l'inscription + select_all_when_unspecified : sélectionne le groupe "tous" si aucun groupe n'est indiqué. + empty_list_select_all: si vrai (défaut) on sélectionne le groupe tous si aucun groupe indiqué. """ def __init__( self, group_ids=(), # groupes specifies dans l'URL, ou un seul int - formsemestre_id=None, - etat=None, + formsemestre_id: int | None = None, + etat: str | None = None, select_all_when_unspecified=False, + empty_list_select_all=True, moduleimpl_id=None, # used to find formsemestre when unspecified ): if isinstance(group_ids, int): @@ -322,21 +339,26 @@ class DisplayedGroupsInfos: if not group_ids: # appel sans groupe (eg page accueil) if not formsemestre_id: - raise Exception("missing parameter formsemestre_id or group_ids") - if select_all_when_unspecified: - group_ids = [ - sco_groups.get_default_group(formsemestre_id, fix_if_missing=True) - ] - else: - # selectionne le premier groupe trouvé, s'il y en a un - partition = sco_groups.get_partitions_list( - formsemestre_id, with_default=True - )[0] - groups = sco_groups.get_partition_groups(partition) - if groups: - group_ids = [groups[0]["group_id"]] + raise ValueError("missing parameter formsemestre_id or group_ids") + if empty_list_select_all: + if select_all_when_unspecified: + group_ids = [ + sco_groups.get_default_group( + formsemestre_id, fix_if_missing=True + ) + ] else: - group_ids = [sco_groups.get_default_group(formsemestre_id)] + # selectionne le premier groupe trouvé, s'il y en a un + partition = sco_groups.get_partitions_list( + formsemestre_id, with_default=True + )[0] + groups = sco_groups.get_partition_groups(partition) + if groups: + group_ids = [groups[0]["group_id"]] + else: + group_ids = [sco_groups.get_default_group(formsemestre_id)] + else: + group_ids = [] gq = [] for group_id in group_ids: @@ -380,7 +402,8 @@ class DisplayedGroupsInfos: if not self.formsemestre: # aucun groupe selectionne self.formsemestre = sco_formsemestre.get_formsemestre(formsemestre_id) - + if formsemestre_id not in self.sems: + self.sems[formsemestre_id] = self.formsemestre self.sortuniq() if len(self.sems) > 1: @@ -635,9 +658,10 @@ def groups_table( else: htitle = "Aucun étudiant !" H = [ - '
' '

', - htitle, - "", + f"""
+ + {htitle} + """ ] if groups_infos.members: Of = [] @@ -679,7 +703,7 @@ def groups_table( """, ] ) - H.append("

") + H.append("
") if groups_infos.members: H.extend( [ @@ -722,7 +746,7 @@ def groups_table( H.append("") - return "".join(H) + "
" + return "".join(H) elif ( fmt == "pdf" diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index b687263910..b90382a462 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -163,7 +163,7 @@ def _ue_coefs_html(coefs_lst) -> str: """ + "\n".join( [ - f"""
{coef}
{ue.acronyme}
""" for ue, coef in coefs_lst @@ -331,15 +331,16 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None): >Saisie Absences journée
""" ) - + year, week, day = datetime.date.today().isocalendar() + semaine: str = f"{year}-W{week}" H.append( f""" -
  • Décisions de jury saisies: seul le responsable du - semestre peut saisir des notes (il devra modifier les décisions de jury). +
  • Décisions de jury saisies: seul le ou la responsable du + semestre peut saisir des notes (elle devra modifier les décisions de jury).
  • """ ) @@ -419,7 +420,7 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None): if nb_evaluations > 0: top_table_links += f"""
    Trier par date """ diff --git a/app/scodoc/sco_portal_apogee.py b/app/scodoc/sco_portal_apogee.py index 95ad30d998..ae58aa737c 100644 --- a/app/scodoc/sco_portal_apogee.py +++ b/app/scodoc/sco_portal_apogee.py @@ -35,6 +35,8 @@ import xml import xml.sax.saxutils import xml.dom.minidom +from flask import flash + import app.scodoc.sco_utils as scu from app import log from app.scodoc import sco_cache @@ -67,7 +69,7 @@ class PortalInterface(object): portal_url += "/" if self.first_time: if portal_url: - log("Portal URL=%s" % portal_url) + log(f"Portal URL={portal_url}") else: log("Portal not configured") self.first_time = False @@ -338,25 +340,27 @@ def get_etud_apogee(code_nip): if not d: return None if len(d) > 1: - raise ValueError("invalid XML response from Etudiant Web Service\n%s" % doc) + log(f"get_etud_apogee({code_nip}): {len(d)} etudiants !\n{doc}") + flash("Attention: plusieurs étudiants inscrits avec le NIP {code_nip}") + # dans ce cas, renvoie le premier étudiant return d[0] def get_default_etapes(): """Liste par défaut, lue du fichier de config""" filename = scu.SCO_TOOLS_DIR + "/default-etapes.txt" - log("get_default_etapes: reading %s" % filename) - f = open(filename) + log(f"get_default_etapes: reading {filename}") etapes = {} - for line in f.readlines(): - line = line.strip() - if line and line[0] != "#": - dept, code, intitule = [x.strip() for x in line.split(":")] - if dept and code: - if dept in etapes: - etapes[dept][code] = intitule - else: - etapes[dept] = {code: intitule} + with open(filename, encoding=scu.SCO_ENCODING) as f: + for line in f.readlines(): + line = line.strip() + if line and line[0] != "#": + dept, code, intitule = [x.strip() for x in line.split(":")] + if dept and code: + if dept in etapes: + etapes[dept][code] = intitule + else: + etapes[dept] = {code: intitule} return etapes @@ -369,7 +373,7 @@ def _parse_etapes_from_xml(doc): dom = xml.dom.minidom.parseString(doc) infos = {} if dom.childNodes[0].nodeName != "etapes": - raise ValueError + raise ValueError("élément 'etapes' attendu") if xml_etapes_by_dept: # Ancien format XML avec des sections par departement: for d in dom.childNodes[0].childNodes: @@ -394,8 +398,7 @@ def get_etapes_apogee(): if etapes_url: portal_timeout = sco_preferences.get_preference("portal_timeout") log( - "get_etapes_apogee: requesting '%s' with timeout=%s" - % (etapes_url, portal_timeout) + f"""get_etapes_apogee: requesting '{etapes_url}' with timeout={portal_timeout}""" ) doc = scu.query_portal(etapes_url, timeout=portal_timeout) try: @@ -403,15 +406,17 @@ def get_etapes_apogee(): # cache le resultat (utile si le portail repond de façon intermitente) if infos: log("get_etapes_apogee: caching result") - with open(SCO_CACHE_ETAPE_FILENAME, "w") as f: + with open( + SCO_CACHE_ETAPE_FILENAME, "w", encoding=scu.SCO_ENCODING + ) as f: f.write(doc) except: - log("invalid XML response from getEtapes Web Service\n%s" % etapes_url) - # Avons nous la copie d'une réponse récente ? + log(f"invalid XML response from getEtapes Web Service\n{etapes_url}") + # Avons-nous la copie d'une réponse récente ? try: - doc = open(SCO_CACHE_ETAPE_FILENAME).read() + doc = open(SCO_CACHE_ETAPE_FILENAME, encoding=scu.SCO_ENCODING).read() infos = _parse_etapes_from_xml(doc) - log("using last saved version from " + SCO_CACHE_ETAPE_FILENAME) + log(f"using last saved version from {SCO_CACHE_ETAPE_FILENAME}") except: infos = {} else: diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index cab6feaeed..557b73b0c7 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -2033,24 +2033,13 @@ class BasePreferences: "category": "edt", }, ), - ( - "edt_groups2scodoc", - { - "input_type": "textarea", - "initvalue": "", - "title": "Noms Groupes", - "explanation": "Transcodage: nom de groupe EDT ; non de groupe ScoDoc (sur plusieurs lignes)", - "rows": 8, - "cols": 16, - "category": "edt", - }, - ), + # Divers ( "ImputationDept", { "title": "Département d'imputation", "initvalue": "", - "explanation": "préfixe id de session (optionnel, remplace nom département)", + "explanation": "optionnel: préfixe id de formsemestre (par défaut, le nom du département). Pour usages API avancés.", "size": 10, "category": "edt", }, diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index 489165bd01..e67ebea569 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -294,7 +294,7 @@ def do_evaluation_upload_xls(): len(absents)} absents, {nb_suppress} note supprimées)

    """ if etudids_with_decisions: - msg += """

    Important: il y avait déjà des décisions de jury + msg += """

    Important: il y avait déjà des décisions de jury enregistrées, qui sont peut-être à revoir suite à cette modification !

    """ return 1, msg @@ -642,7 +642,7 @@ def notes_add( ) cursor.execute( """DELETE FROM notes_notes - WHERE etudid = %(etudid)s + WHERE etudid = %(etudid)s AND evaluation_id = %(evaluation_id)s """, args, @@ -660,7 +660,7 @@ def notes_add( nb_suppress += 1 if changed: etudids_changed.append(etudid) - if res.etud_has_decision(etudid): + if res.etud_has_decision(etudid, include_rcues=False): etudids_with_decision.append(etudid) except Exception as exc: log("*** exception in notes_add") @@ -691,7 +691,7 @@ def saisie_notes_tableur(evaluation_id, group_ids=()): avez l'autorisation d'effectuer cette opération)

    Continuer

    """ @@ -740,7 +740,7 @@ def saisie_notes_tableur(evaluation_id, group_ids=()):
    -
    """ @@ -1168,7 +1168,11 @@ def _form_saisie_notes( return '
    Aucun étudiant sélectionné !
    ' # Décisions de jury existantes ? - decisions_jury = {etudid: res.etud_has_decision(etudid) for etudid in etudids} + # en BUT on ne considère pas les RCUEs car ils peuvenut avoir été validés depuis + # d'autres semestres (les validations de RCUE n'indiquent pas si elles sont "externes") + decisions_jury = { + etudid: res.etud_has_decision(etudid, include_rcues=False) for etudid in etudids + } # Nb de décisions de jury (pour les inscrits à l'évaluation): nb_decisions = sum(decisions_jury.values()) @@ -1308,7 +1312,7 @@ def _form_saisie_notes( ) H.append(tf.getform()) # check and init H.append( - f"""Terminer """ diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 6faad0fbde..f93bf05269 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -53,7 +53,6 @@ import requests from pytz import timezone -import dateutil.parser as dtparser import flask from flask import g, request, Response @@ -230,9 +229,9 @@ def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or No """ try: - date: datetime.datetime = dtparser.isoparse(date) + date: datetime.datetime = datetime.datetime.fromisoformat(date) return date if convert else True - except (dtparser.ParserError, ValueError, TypeError): + except (ValueError, TypeError): return None if convert else False @@ -765,13 +764,23 @@ FORBIDDEN_CHARS_EXP = re.compile(r"[*\|~\(\)\\]") ALPHANUM_EXP = re.compile(r"^[\w-]+$", re.UNICODE) -def is_valid_code_nip(s): +def is_valid_code_nip(s: str) -> bool: """True si s peut être un code NIP: au moins 6 chiffres décimaux""" if not s: return False return re.match(r"^[0-9]{6,32}$", s) +def split_id(ident: str) -> list[str]: + """ident est une chaine 'X, Y, Z' + Renvoie ['X','Y', 'Z'] + """ + if ident: + ident = ident.strip() + return [x.strip() for x in ident.strip().split(",")] if ident else [] + return [] + + def strnone(s): "convert s to string, '' if s is false" if s: @@ -1467,3 +1476,14 @@ def is_assiduites_module_forced( except (TypeError, ValueError): retour = sco_preferences.get_preference("forcer_module", dept_id=dept_id) return retour + + +def get_assiduites_time_config(config_type: str) -> str: + from app.models import ScoDocSiteConfig + match config_type: + case "matin": + return ScoDocSiteConfig.get("assi_morning_time", "08:00:00") + case "aprem": + return ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00") + case "pivot": + return ScoDocSiteConfig.get("assi_lunch_time", "13:00:00") diff --git a/app/static/css/assiduites.css b/app/static/css/assiduites.css index 4fe7ee090b..ed5daa259f 100644 --- a/app/static/css/assiduites.css +++ b/app/static/css/assiduites.css @@ -1,3 +1,35 @@ +:root { + --color-present: #6bdb83; + --color-absent: #e62a11; + --color-absent-clair: #F25D4A; + --color-retard: #f0c865; + --color-justi: #7059FF; + --color-justi-clair: #6885E3; + --color-justi-invalide: #a84476; + --color-nonwork: #badfff; + + --color-absent-justi: #e65ab7; + --color-retard-justi: #ffef7a; + + --color-error: #e62a11; + --color-warning: #eec660; + --color-information: #658ef0; + + --color-def: #d61616; + --color-conflit: #ff00009c; + --color-bg-def: #c8c8c8; + --color-primary: #7059FF; + --color-secondary: #6f9fff; + + --color-defaut: #FFF; + --color-defaut-dark: #444; + --color-default-text: #1F1F1F; + + + --motif-justi: repeating-linear-gradient(135deg, transparent, transparent 4px, var(--color-justi) 4px, var(--color-justi) 8px); + --motif-justi-invalide: repeating-linear-gradient(-135deg, transparent, transparent 4px, var(--color-justi-invalide) 4px, var(--color-justi-invalide) 8px); +} + * { box-sizing: border-box; } @@ -36,10 +68,10 @@ .infos { position: relative; - width: fit-content; display: flex; - justify-content: space-evenly; + justify-content: start; align-content: center; + gap: 10px; } #datestr { @@ -48,7 +80,7 @@ border: 1px #444 solid; border-radius: 5px; padding: 5px; - min-width: 100px; + min-width: 250px; display: inline-block; min-height: 20px; } @@ -87,7 +119,7 @@ } .ui-slider-range.ui-widget-header.ui-corner-all { - background-color: #F9C768; + background-color: var(--color-warning); background-image: none; opacity: 0.50; visibility: visible; @@ -122,7 +154,7 @@ .etud_row.def, .etud_row.dem { - background-color: #c8c8c8; + background-color: var(--color-bg-def); } /* --- Index --- */ @@ -149,7 +181,7 @@ .tr.def .td.sticky span::after { display: block; content: " (Déf.)"; - color: #d61616; + color: var(--color-def); margin-left: 2px; } @@ -157,7 +189,7 @@ .tr.dem .td.sticky span::after { display: block; content: " (Dém.)"; - color: #d61616; + color: var(--color-def); margin-left: 2px; } @@ -213,32 +245,36 @@ } .etud_row.conflit { - background-color: #ff0000c2; + background-color: var(--color-conflit); } .etud_row .assiduites_bar .absent, .demo.absent { - background-color: #F1A69C !important; + background-color: var(--color-absent) !important; } .etud_row .assiduites_bar .present, .demo.present { - background-color: #9CF1AF !important; + background-color: var(--color-present) !important; } .etud_row .assiduites_bar .retard, .demo.retard { - background-color: #F1D99C !important; + background-color: var(--color-retard) !important; +} + +.demo.nonwork { + background-color: var(--color-nonwork) !important; } .etud_row .assiduites_bar .justified, .demo.justified { - background-image: repeating-linear-gradient(135deg, transparent, transparent 4px, #7059FF 4px, #7059FF 8px); + background-image: var(--motif-justi); } .etud_row .assiduites_bar .invalid_justified, .demo.invalid_justified { - background-image: repeating-linear-gradient(225deg, transparent, transparent 4px, #d61616 4px, #d61616 8px); + background-image: var(--motif-justi-invalide); } @@ -273,27 +309,35 @@ height: 35px; background-position: center; background-size: cover; + border-radius: 5px; + border: 1px solid var(--color-defaut-dark); } .rbtn.present::before { background-image: url(../icons/present.svg); + background-color: var(--color-present); + } .rbtn.absent::before { + background-color: var(--color-absent); background-image: url(../icons/absent.svg); } .rbtn.aucun::before { background-image: url(../icons/aucun.svg); + background-color: var(--color-defaut-dark); + } .rbtn.retard::before { + background-color: var(--color-retard); background-image: url(../icons/retard.svg); } .rbtn:checked:before { - outline: 5px solid #7059FF; + outline: 5px solid var(--color-primary); border-radius: 50%; } @@ -486,7 +530,7 @@ .loader { border: 6px solid #f3f3f3; border-radius: 50%; - border-top: 6px solid #3498db; + border-top: 6px solid var(--color-primary); width: 60px; height: 60px; position: absolute; @@ -532,7 +576,7 @@ } .rouge { - color: crimson; + color: var(--color-error); } .legende { @@ -588,7 +632,7 @@ #forcemodule { border-radius: 8px; - background: crimson; + background: var(--color-error); max-width: fit-content; padding: 5px; color: white; diff --git a/app/static/css/edt.css b/app/static/css/edt.css new file mode 100644 index 0000000000..e604082998 --- /dev/null +++ b/app/static/css/edt.css @@ -0,0 +1,67 @@ +.toastui-calendar-template-time { + padding: 4px; + word-break: break-all; + white-space: normal !important; + align-items: normal !important; + font-size: 12pt; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; +} +.module-edt { + display: inline; +} +.mod-code { + font-weight: bold; + color: rgb(21, 21, 116); + font-size: 110%; +} +.group-name { + color: rgb(25, 113, 25); + display: inline; +} +.group-edt { + color: red; + background-color: yellow; +} + +#renderRange { + margin-left: 16px; +} +.toastui-calendar-timegrid { + height: 100% !important; + min-height: auto !important; +} +.toastui-calendar-time { + height: calc(100% - 44px) !important; +} +.toastui-calendar-week-view-day-names, +.toastui-calendar-time { + overflow: hidden !important; +} + +.btn { + border-radius: 25px; + border-color: #ddd; +} + +.btn:hover { + border: solid 1px #bbb; + background-color: #fff; +} + +.btn:active { + background-color: #f9f9f9; + border: solid 1px #bbb; + outline: none; +} + +.btn:disabled { + background-color: #f9f9f9; + border: solid 1px #ddd; + color: #bbb; +} + +.btn:focus:active, +.btn:focus, +.btn:active { + outline: none; +} diff --git a/app/static/css/partition_editor.css b/app/static/css/partition_editor.css index 09f042fefc..e7abd0c296 100644 --- a/app/static/css/partition_editor.css +++ b/app/static/css/partition_editor.css @@ -91,6 +91,10 @@ body:not(.editionActivated) .editing { .nonEditable .editing { display: none; } +.nonEditable .editing.rename { + display: inline; +} + .editionActivated #zoneChoix, .editionActivated #zoneGroupes { @@ -302,6 +306,10 @@ body.editionActivated .filtres>div>div>div>div { display: none; } +#zonePartitions span.editing a { + text-decoration: none; +} + .editionActivated #zonePartitions .filtres .config { display: block; } @@ -598,4 +606,4 @@ h3 { #zoneGroupes .groupe[data-idgroupe=aucun]>div:nth-child(1) { color: red; -} \ No newline at end of file +} diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index c31d0de605..0f9072c81c 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -39,6 +39,41 @@ h3 { font-weight: bold; } +/* customization of multiselect style */ +.multiselect-container.dropdown-menu { + background-color: #e9e9e9; +} +.multiselect-container label.form-check-label { + font-weight: normal; + margin-left: 8px; +} +button.multiselect-option { + width: 100%; + text-align: left; + border: none; +} +.multiselect-container button.multiselect-option span.form-check { + padding-left: 2px; +} +.multiselect-container span.multiselect-group { + font-weight: bold; +} +.multiselect-container + .multiselect-all.active:not(.multiselect-active-item-fallback), +.multiselect-container + .multiselect-all:not(.multiselect-active-item-fallback):active, +.multiselect-container + .multiselect-group.active:not(.multiselect-active-item-fallback), +.multiselect-container + .multiselect-group:not(.multiselect-active-item-fallback):active, +.multiselect-container + .multiselect-option.active:not(.multiselect-active-item-fallback), +.multiselect-container + .multiselect-option:not(.multiselect-active-item-fallback):active { + background-color: #e9e9e9; +} + +/* Legacy ScoDoc pages */ div#gtrcontent { margin-bottom: 16px; } @@ -61,7 +96,8 @@ div#gtrcontent { } .scotext { - font-family: TimesNewRoman, "Times New Roman", Times, Baskerville, Georgia, serif; + font-family: TimesNewRoman, "Times New Roman", Times, Baskerville, Georgia, + serif; } .sco-hidden { @@ -176,7 +212,7 @@ tr.bandeaugtr { text-decoration: underline; } -.navbar-default .navbar-nav>li.logout a { +.navbar-default .navbar-nav > li.logout a { color: rgb(255, 0, 0); } @@ -212,7 +248,6 @@ div.about-logo { padding-top: 10px; } - div.head_message { margin-top: 12px; margin-bottom: 8px; @@ -289,7 +324,6 @@ p.footer { border-top: 1px solid rgb(60, 60, 60); } - /* ---- (left) SIDEBAR ----- */ div.sidebar { @@ -403,7 +437,6 @@ div.table_etud_in_dept table.gt_table { font-size: medium; } - .etud-insidebar ul { padding-left: 1.5em; margin-left: 0; @@ -571,7 +604,7 @@ table.semlist tbody tr td.modalite { .sco_modified { font-weight: bold; - color: indigo + color: indigo; } /***************************/ @@ -586,7 +619,7 @@ table.semlist tbody tr td.modalite { border-radius: 0 0 10px 10px; background: #ec7068; background: #90c; - color: #FFF; + color: #fff; font-size: 24px; animation: message 3s; transform: translate(-50%, 0); @@ -594,15 +627,14 @@ table.semlist tbody tr td.modalite { @keyframes message { 20% { - transform: translate(-50%, 100%) + transform: translate(-50%, 100%); } 80% { - transform: translate(-50%, 100%) + transform: translate(-50%, 100%); } } - div#gtrcontent table.semlist tbody tr.css_S-1 td { background-color: rgb(251, 250, 216); } @@ -643,7 +675,8 @@ div.news { border-radius: 8px; } -div.news a, div.news a.stdlink { +div.news a, +div.news a.stdlink { color: black; text-decoration: none; } @@ -672,7 +705,6 @@ span.newstext { font-style: normal; } - span.gt_export_icons { margin-left: 1.5em; } @@ -683,7 +715,7 @@ div.scoinfos { margin-bottom: 0px; padding: 2px; padding-bottom: 0px; - background-color: #F4F4B2; + background-color: #f4f4b2; } /* ----- fiches etudiants ------ */ @@ -752,17 +784,17 @@ div.etudarchive ul { div.etudarchive ul li { background-image: url(/ScoDoc/static/icons/bullet_arrow.png); background-repeat: no-repeat; - background-position: 0 .4em; - padding-left: .6em; + background-position: 0 0.4em; + padding-left: 0.6em; } div.etudarchive ul li.addetudarchive { background-image: url(/ScoDoc/static/icons/bullet_plus.png); - padding-left: 1.2em + padding-left: 1.2em; } span.etudarchive_descr { - margin-right: .4em; + margin-right: 0.4em; } span.deletudarchive { @@ -871,7 +903,6 @@ div.ficheinscriptions { color: red; } - td.photocell { padding-left: 32px; } @@ -989,7 +1020,6 @@ span.linktitresem a:visited { color: red; } - a.stdlink, a.stdlink:visited { color: blue; @@ -1070,7 +1100,6 @@ span.trombi_box a img { margin-bottom: 0px; } - /* markup non semantique pour les cas simples */ .fontred { @@ -1126,7 +1155,8 @@ a.discretelink:hover { text-align: center; } -.expl, .help { +.expl, +.help { max-width: var(--sco-content-max-width); } .help { @@ -1138,7 +1168,8 @@ a.discretelink:hover { color: red; } -div.sco_box, div.sco_help { +div.sco_box, +div.sco_help { margin-top: 12px; margin-bottom: 4px; margin-left: 0px; @@ -1208,7 +1239,7 @@ span.wtf-field ul.errors li { display: inline-block; } -.configuration_logo details>*:not(summary) { +.configuration_logo details > *:not(summary) { margin-left: 32px; } @@ -1327,7 +1358,7 @@ table.gt_table tr.etuddem td a { table.gt_table tr.etuddem td.etudinfo:first-child::after { color: red; content: " (dém.)"; -} +} td.etudabs, td.etudabs a.discretelink, @@ -1349,7 +1380,7 @@ table.notes_evaluation th.eval_incomplete { font-size: 80%; } -table.notes_evaluation td.eval_incomplete>a { +table.notes_evaluation td.eval_incomplete > a { font-size: 80%; color: rgb(166, 50, 159); } @@ -1369,7 +1400,6 @@ table.notes_evaluation td.exc a { color: rgb(0, 131, 0); } - table.notes_evaluation tr td a.discretelink:hover { text-decoration: none; } @@ -1418,7 +1448,7 @@ div.jury_footer { justify-content: space-evenly; } -div.jury_footer>span { +div.jury_footer > span { border: 2px solid rgb(90, 90, 90); border-radius: 4px; padding: 4px; @@ -1444,7 +1474,6 @@ div.jury_footer>span { color: red; } - span.eval_info { font-style: italic; } @@ -1592,7 +1621,7 @@ formsemestre_page_title .lock img { } #formnotes td.tf-ro-fieldlabel:after { - content: ''; + content: ""; } #formnotes .tf-ro-field.formnote_bareme { @@ -1639,7 +1668,7 @@ formsemestre_page_title .lock img { margin-left: -1px; } -#sco_menu>li { +#sco_menu > li { float: left; width: auto; /* 120px !important; */ @@ -1648,20 +1677,20 @@ formsemestre_page_title .lock img { text-transform: uppercase; } -#sco_menu>li li { +#sco_menu > li li { text-transform: none; font-size: 14px; font-family: Arial, Helvetica, sans-serif; } -#sco_menu>li>a { +#sco_menu > li > a { font-weight: bold !important; padding-left: 15px; padding-right: 15px; } -#sco_menu>li>a.ui-menu-item, -#sco_menu>li>a.ui-menu-item:visited { +#sco_menu > li > a.ui-menu-item, +#sco_menu > li > a.ui-menu-item:visited { text-decoration: none; } @@ -1669,14 +1698,14 @@ formsemestre_page_title .lock img { width: 200px; } -.sco_dropdown_menu>li { +.sco_dropdown_menu > li { width: auto; /* 120px !important; */ font-size: 12px; font-family: Arial, Helvetica, sans-serif; } -.menu-etudiant>li { +.menu-etudiant > li { width: 200px !important; } @@ -1731,7 +1760,7 @@ tr.formsemestre_status { } tr.formsemestre_status_green { - background-color: #EFF7F2; + background-color: #eff7f2; } tr.formsemestre_status_ue { @@ -1814,7 +1843,6 @@ span.mod_coef_indicator_zero { border: 1px solid rgb(156, 156, 156); } - span.status_ue_acro { font-weight: bold; } @@ -1851,7 +1879,7 @@ ul.ue_inscr_list li.etud { } .sem-groups-abs { - background-color: rgb(137,137,137); + background-color: rgb(137, 137, 137); border-radius: 16px; padding: 16px; width: fit-content; @@ -1867,7 +1895,7 @@ ul.ue_inscr_list li.etud { font-size: 110%; } .sem-groups-partition { - background-color: rgb(213,203,183); + background-color: rgb(213, 203, 183); border-radius: 12px; margin-bottom: 8px; padding: 12px; @@ -1875,7 +1903,8 @@ ul.ue_inscr_list li.etud { grid-template-columns: 240px auto; } -.sem-groups-list, .sem-groups-assi { +.sem-groups-list, +.sem-groups-assi { background-color: white; border-radius: 6px; margin: 4px; @@ -1936,7 +1965,7 @@ div#modimpl_coefs { font-size: 60%; } -.coefs_histo>div { +.coefs_histo > div { --height: calc(32px * var(--coef) / max(var(--max), 1)); height: var(--height); padding: var(--height) 4px 0 4px; @@ -1944,7 +1973,7 @@ div#modimpl_coefs { box-sizing: border-box; } -.coefs_histo>div:nth-child(odd) { +.coefs_histo > div:nth-child(odd) { background-color: #9c0; } @@ -1963,7 +1992,7 @@ div.evaluation_titre { margin-left: 4px; } -.evaluation_poids>div { +.evaluation_poids > div { display: inline-flex; height: 12px; width: 12px; @@ -1974,7 +2003,7 @@ div.evaluation_titre { justify-content: center; } -.evaluation_poids>div>div { +.evaluation_poids > div > div { height: var(--size); width: var(--size); background: #09c; @@ -2028,7 +2057,6 @@ span.mievr_rattr { margin-left: 2em; margin-top: 1px; margin-bottom: 2px; - ; border: 1px solid red; padding: 1px 3px 1px 3px; } @@ -2054,15 +2082,16 @@ tr.mievr td { background-color: white; } -tr.mievr.non_visible_inter td, tr.mievr.non_visible_inter th { +tr.mievr.non_visible_inter td, +tr.mievr.non_visible_inter th { /* background-color: #d2cdc5; */ background: repeating-linear-gradient( - 45deg, - #f0f0f0, - #f0f0f0 10px, - #e0e0e0 10px, - #e0e0e0 20px -); + 45deg, + #f0f0f0, + #f0f0f0 10px, + #e0e0e0 10px, + #e0e0e0 20px + ); } tr.mievr th { @@ -2145,7 +2174,8 @@ span.eval_coef_ue { margin-right: 2em; } -span.eval_coef_ue_titre {} +span.eval_coef_ue_titre { +} /* Inscriptions modules/UE */ div.list_but_ue_inscriptions { @@ -2201,7 +2231,6 @@ form.list_but_ue_inscriptions td { text-align: center; } - table#but_ue_inscriptions { margin-left: 16px; width: auto; @@ -2290,7 +2319,10 @@ table.formation_list_table td.buttons span.but_placeholder { } .formation_list_table td.titre { - width: 50%; + width: 45%; +} +.formation_list_table td.commentaire { + font-style: italic; } .formation_list_table td.sems_list_txt { @@ -2355,10 +2387,11 @@ div.formation_list_ues { div.formation_list_ues { background-color: #b7d2fa; - margin-top: 20px + margin-top: 20px; } -div.formation_list_ues_content {} +div.formation_list_ues_content { +} div.formation_list_modules { margin-top: 20px; @@ -2428,7 +2461,7 @@ div.formation_parcs { column-gap: 8px; } -div.formation_parcs>div { +div.formation_parcs > div { font-size: 100%; color: white; background-color: #09c; @@ -2438,24 +2471,23 @@ div.formation_parcs>div { padding: 4px 8px; } -div.formation_parcs>div.focus { +div.formation_parcs > div.focus { opacity: 1; } -div.formation_parcs>div>a:hover { +div.formation_parcs > div > a:hover { color: #ccc; } -div.formation_parcs>div>a, -div.formation_parcs>div>a:visited { +div.formation_parcs > div > a, +div.formation_parcs > div > a:visited { color: white; } -div.ue_choix_niveau>div.formation_parcs>div { +div.ue_choix_niveau > div.formation_parcs > div { font-size: 80%; } - div.ue_list_tit { font-weight: bold; margin-top: 8px; @@ -2516,7 +2548,7 @@ span.ue_type { } table.formsemestre_description td.ue_coef_nul { - background-color: yellow!important; + background-color: yellow !important; color: red; font-weight: bold; } @@ -2652,7 +2684,7 @@ div.cont_ue_choix_niveau { flex-wrap: wrap; } -div.cont_ue_choix_niveau>div { +div.cont_ue_choix_niveau > div { display: inline-flex; margin-left: 8px; align-items: center; @@ -2688,7 +2720,6 @@ div#ue_list_modules { margin-right: 15px; } - span.ue_share { font-weight: bold; } @@ -2734,11 +2765,11 @@ span.code_parcours.no_parcours { background-color: firebrick; } -tr#tf_module_parcours>td { +tr#tf_module_parcours > td { background-color: rgb(229, 229, 229); } -tr#tf_module_app_critiques>td { +tr#tf_module_app_critiques > td { background-color: rgb(194, 209, 228); } @@ -3074,7 +3105,6 @@ a.bull_link:hover { text-decoration: underline; } - div.bulletin_menubar { padding-left: 25px; } @@ -3122,9 +3152,9 @@ div.eval_description { div.bul_foot { max-width: 1000px; - background: #FFE7D5; + background: #ffe7d5; border-radius: 16px; - border: 1px solid #AAA; + border: 1px solid #aaa; padding: 16px 32px; margin-left: 16px; } @@ -3155,7 +3185,6 @@ span.titredivsaisienote { font-size: 115%; } - .etud_dem { color: rgb(130, 130, 130); } @@ -3293,7 +3322,6 @@ mark { padding-right: 5px; } - form.sco_pref table.tf { border-spacing: 5px 15px; } @@ -3305,7 +3333,7 @@ td.tf-ro-fieldlabel { } td.tf-ro-fieldlabel:after { - content: ' :'; + content: " :"; } td.tf-ro-field { @@ -3416,7 +3444,7 @@ P.gtr_devel:before { /* ---- Sortable tables --- */ /* Sortable tables */ table.sortable a.sortheader { - background-color: #E6E6E6; + background-color: #e6e6e6; color: black; font-weight: bold; text-decoration: none; @@ -3502,7 +3530,6 @@ table.recap_parcours td { td.rcp_dec { color: rgb(0%, 0%, 50%); - ; } td.rcp_nonass, @@ -3541,7 +3568,13 @@ table.recap_hide_details tr.sem_precedent td.ue_acro span { } .recap_parcours tr.sem_autre_formation td.rcp_titre_sem { - background-image: repeating-linear-gradient(-45deg, rgb(100, 205, 193), rgb(100, 205, 193) 2px, transparent 5px, transparent 40px); + background-image: repeating-linear-gradient( + -45deg, + rgb(100, 205, 193), + rgb(100, 205, 193) 2px, + transparent 5px, + transparent 40px + ); } .rcp_l2 td { @@ -3612,7 +3645,6 @@ th.sfv_subtitle { font-style: italic; } - tr.sfv_ass { background-color: rgb(90%, 90%, 80%); } @@ -3659,7 +3691,7 @@ span.finalisationinscription { .pas_sembox_title a { font-weight: bold; font-size: 100%; - color: #1C721C; + color: #1c721c; } .pas_sembox_subtitle { @@ -3773,20 +3805,19 @@ div.module_check_absences ul { z-index: 1; } -.scoplement>div { +.scoplement > div { text-align: left; display: inline-block; white-space: nowrap; } -.scoplement>div:nth-child(1), -.scoplement>div:nth-child(7) { +.scoplement > div:nth-child(1), +.scoplement > div:nth-child(7) { margin-bottom: 8px; } /* ----------------------------------------------- */ - /* ----------------------------- */ /* TABLES generees par gen_table */ /* ----------------------------- */ @@ -3811,7 +3842,6 @@ table.table_coldate tr td:first-child { color: rgb(0%, 0%, 50%); } - table.table_listegroupe tr td { padding-left: 0.5em; padding-right: 0.5em; @@ -3827,7 +3857,6 @@ table.list_users th.roles_string { overflow-wrap: break-word; } - table.formsemestre_description tr.table_row_ue td { font-weight: bold; } @@ -3847,15 +3876,15 @@ table.formsemestre_description tbody tr.evaluation td { } /* --- */ -tr#tf_extue_decl>td, -tr#tf_extue_note>td { +tr#tf_extue_decl > td, +tr#tf_extue_note > td { padding-top: 20px; } -tr#tf_extue_titre>td, -tr#tf_extue_acronyme>td, -tr#tf_extue_type>td, -tr#tf_extue_ects>td { +tr#tf_extue_titre > td, +tr#tf_extue_acronyme > td, +tr#tf_extue_type > td, +tr#tf_extue_ects > td { padding-left: 20px; } @@ -3866,7 +3895,6 @@ div.form_rename_partition { margin-bottom: 2em; } - td.calday { text-align: right; vertical-align: top; @@ -3877,7 +3905,6 @@ div.cal_evaluations table.monthcalendar td.calcell { width: 6em; } - div.cal_evaluations table.monthcalendar td a { color: rgb(128, 0, 0); } @@ -3895,12 +3922,10 @@ div.othersemlist { border: 1px solid gray; } - div.othersemlist input { margin-left: 20px; } - div#update_warning { display: none; border: 1px solid red; @@ -3911,19 +3936,19 @@ div#update_warning { padding-bottom: 1ex; } -div#update_warning>div:first-child:before { +div#update_warning > div:first-child:before { content: url(/ScoDoc/static/icons/warning_img.png); vertical-align: -80%; } -div#update_warning>div:nth-child(2) { +div#update_warning > div:nth-child(2) { font-size: 80%; padding-left: 8ex; } -/* +/* Titres des tabs: - .nav-tabs li a { + .nav-tabs li a { font-variant: small-caps; font-size: 13pt; } @@ -3959,9 +3984,8 @@ ul.main li { padding-bottom: 2ex; } - #scodoc_admin { - background-color: #EEFFFF; + background-color: #eeffff; } #message, @@ -4027,11 +4051,13 @@ div.apo_csv_status span { } div.apo_csv_status_nok { - background: url(/ScoDoc/static/icons/bullet_warning_img.png) no-repeat left top 0px; + background: url(/ScoDoc/static/icons/bullet_warning_img.png) no-repeat left + top 0px; } div.apo_csv_status_missing_elems { - background: url(/ScoDoc/static/icons/bullet_warning_img.png) no-repeat left top 0px; + background: url(/ScoDoc/static/icons/bullet_warning_img.png) no-repeat left + top 0px; padding-left: 22px; } @@ -4054,7 +4080,6 @@ div.apo_csv_jury_nok li { color: red; } - pre.small_pre_acc { font-size: 60%; width: 90%; @@ -4063,7 +4088,7 @@ pre.small_pre_acc { overflow: scroll; } -.apo_csv_jury_ok input[type=submit] { +.apo_csv_jury_ok input[type="submit"] { color: green; } @@ -4354,7 +4379,7 @@ button.unselect { /* Non supproté par les navigateurs (en Fev. 2023) .table_recap button:has(span a.clearreaload) { -} +} */ div.table_recap table.table_recap, @@ -4610,7 +4635,6 @@ table.table_recap th.col_malus { color: rgb(165, 0, 0); } - table.table_recap tr.ects td { color: rgb(160, 86, 3); font-weight: bold; @@ -4688,7 +4712,6 @@ table.table_recap th.evaluation.first_of_mod { border-left: 1px dashed rgb(4, 16, 159); } - table.table_recap td.evaluation.att { color: rgb(255, 0, 217); font-weight: bold; @@ -4760,7 +4783,6 @@ table.evaluations_recap tr.sae td { background-color: #d8fcc8; } - table.evaluations_recap tr.module td { font-weight: bold; } @@ -4823,7 +4845,7 @@ div.cas_settings { background-color: #feb4e54f; } -div.cas_settings>div, +div.cas_settings > div, div.cas_settings div.form-group { margin-left: 8px; } @@ -4833,4 +4855,8 @@ div.cas_etat_certif_ssl { margin-bottom: 8px; font-style: italic; color: rgb(231, 0, 0); -} \ No newline at end of file +} + +.edt_id { + color: rgb(85, 255, 24); +} diff --git a/app/static/icons/absent.svg b/app/static/icons/absent.svg old mode 100755 new mode 100644 index 697635cd96..5c6385bf52 --- a/app/static/icons/absent.svg +++ b/app/static/icons/absent.svg @@ -1,11 +1,9 @@ - - - - + + + + + + + - - - - - diff --git a/app/static/icons/absent_ancien.svg b/app/static/icons/absent_ancien.svg new file mode 100755 index 0000000000..cb82811c49 --- /dev/null +++ b/app/static/icons/absent_ancien.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/static/icons/aucun.svg b/app/static/icons/aucun.svg index eaff200431..c23efffc7a 100755 --- a/app/static/icons/aucun.svg +++ b/app/static/icons/aucun.svg @@ -1,5 +1,5 @@ - + diff --git a/app/static/icons/present.svg b/app/static/icons/present.svg index e1628c8363..4a649b1caa 100755 --- a/app/static/icons/present.svg +++ b/app/static/icons/present.svg @@ -1,7 +1,7 @@ - + - + diff --git a/app/static/icons/retard.svg b/app/static/icons/retard.svg index b8a7f3d254..b41ef3e74b 100755 --- a/app/static/icons/retard.svg +++ b/app/static/icons/retard.svg @@ -1,6 +1,6 @@ - - + + diff --git a/app/static/js/apo_semset_maq_status.js b/app/static/js/apo_semset_maq_status.js index 445bdf8768..161a221146 100644 --- a/app/static/js/apo_semset_maq_status.js +++ b/app/static/js/apo_semset_maq_status.js @@ -1,10 +1,9 @@ - $(function () { - $("div#export_help").accordion({ - heightStyle: "content", - collapsible: true, - active: false, - }); + $("div#export_help").accordion({ + heightStyle: "content", + collapsible: true, + active: false, + }); }); // Affichage des listes par type @@ -14,77 +13,89 @@ $(function () { // -> surligne le cas sélectionné function display(r, c, row, col) { - if ((row != r) && (row != '*')) return 'none'; - if ((col != c) && (col != '*')) return 'none'; - return ''; + if (row != r && row != "*") return "none"; + if (col != c && col != "*") return "none"; + return ""; } function show_tag(all_rows, all_cols, tag) { - // Filtrer tous les étudiants - all_rows.split(',').forEach(function (r) { - all_cols.split(',').forEach(function (c) { - etudiants = r + c.substring(1); - $(etudiants).css("display", "none"); - }) - }) - // sauf le tag - $('.' + tag).css('display', ''); + // Filtrer tous les étudiants + all_rows.split(",").forEach(function (r) { + all_cols.split(",").forEach(function (c) { + etudiants = r + c.substring(1); + $(etudiants).css("display", "none"); + }); + }); + // sauf le tag + $("." + tag).css("display", ""); } function show_filtres(effectifs, filtre_row, filtre_col) { - $("#compte").html(effectifs); - if ((filtre_row == '') && (filtre_col == '')) { - $("#sans_filtre").css("display", ""); - $("#filtre_row").css("display", "none"); - $("#filtre_col").css("display", "none"); + $("#compte").html(effectifs); + if (filtre_row == "" && filtre_col == "") { + $("#sans_filtre").css("display", ""); + $("#filtre_row").css("display", "none"); + $("#filtre_col").css("display", "none"); + } else { + $("#sans_filtre").css("display", "none"); + if (filtre_row == "") { + $("#filtre_row").css("display", "none"); + $("#filtre_col").css("display", ""); + $("#filtre_col").html("Filtre sur code étape: " + filtre_col); + } else if (filtre_col == "") { + $("#filtre_row").css("display", ""); + $("#filtre_col").css("display", "none"); + $("#filtre_row").html("Filtre sur semestre: " + filtre_row); } else { - $("#sans_filtre").css("display", "none"); - if (filtre_row == '') { - $("#filtre_row").css("display", "none"); - $("#filtre_col").css("display", ""); - $("#filtre_col").html("Filtre sur code étape: " + filtre_col); - } else if (filtre_col == '') { - $("#filtre_row").css("display", ""); - $("#filtre_col").css("display", "none"); - $("#filtre_row").html("Filtre sur semestre: " + filtre_row); - } else { - $("#filtre_row").css("display", ""); - $("#filtre_col").css("display", ""); - $("#filtre_row").html("Filtre sur semestre: " + filtre_row); - $("#filtre_col").html("Filtre sur code étape: " + filtre_col); - } + $("#filtre_row").css("display", ""); + $("#filtre_col").css("display", ""); + $("#filtre_row").html("Filtre sur semestre: " + filtre_row); + $("#filtre_col").html("Filtre sur code étape: " + filtre_col); } + } } -function doFiltrage(all_rows, all_cols, row, col, effectifs, filtre_row, filtre_col) { - show_filtres(effectifs, filtre_row, filtre_col) - all_rows.split(',').forEach(function (r) { - all_cols.split(',').forEach(function (c) { - etudiants = r + c.substring(1); - $(etudiants).css("display", display(r, c, row, col)); - }); +function doFiltrage( + all_rows, + all_cols, + row, + col, + effectifs, + filtre_row, + filtre_col +) { + show_filtres(effectifs, filtre_row, filtre_col); + all_rows.split(",").forEach(function (r) { + all_cols.split(",").forEach(function (c) { + etudiants = r + c.substring(1); + $(etudiants).css("display", display(r, c, row, col)); }); + }); - $('.repartition td').css("background-color", ""); - $('.repartition th').css("background-color", ""); + $(".repartition td").css("background-color", ""); + $(".repartition th").css("background-color", ""); - if (row == '*' && col == '*') { // Aucun filtre - } else if (row == '*') { // filtrage sur 1 colonne - $(col).css("background-color", "lightblue"); - } else if (col == '*') { // Filtrage sur 1 ligne - $(row + '>td').css("background-color", "lightblue"); - $(row + '>th').css("background-color", "lightblue"); - } else { // filtrage sur 1 case - $(row + '>td' + col).css("background-color", "lightblue"); - } + if (row == "*" && col == "*") { + // Aucun filtre + } else if (row == "*") { + // filtrage sur 1 colonne + $(col).css("background-color", "lightblue"); + } else if (col == "*") { + // Filtrage sur 1 ligne + $(row + ">td").css("background-color", "lightblue"); + $(row + ">th").css("background-color", "lightblue"); + } else { + // filtrage sur 1 case + $(row + ">td" + col).css("background-color", "lightblue"); + } - // Modifie le titre de la section pour indiquer la sélection: - // elt est le lien cliqué - // var td_class = elt.parentNode.className.trim(); - // if (td_class) { - // var titre_col = $("table.repartition th.")[0].textContent.trim(); - // if (titre_col) { - // $("h4#effectifs").html("Liste des étudiants de " + titre_col); - // } - // } + // Modifie le titre de la section pour indiquer la sélection: + // elt est le lien cliqué + // var td_class = elt.parentNode.className.trim(); + // if (td_class) { + // var titre_col = $("table.repartition th.")[0].textContent.trim(); + // if (titre_col) { + // $("h4#effectifs").html("Liste des étudiants de " + titre_col); + // } + // } } diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 90ed21d642..8b4df342c9 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -1,6 +1,5 @@ // <=== CONSTANTS and GLOBALS ===> -const TIMEZONE = "Europe/Paris"; let url; function getUrl() { @@ -32,6 +31,17 @@ Object.defineProperty(String.prototype, "capitalize", { }, enumerable: false, }); + +const DatePrecisions = [ + "year", + "month", + "day", + "hour", + "minute", + "second", + "millisecond", +]; + // <<== Outils ==>> Object.defineProperty(Array.prototype, "reversed", { value: function () { @@ -84,7 +94,7 @@ function validateSelectors(btn) { ); }); - if (getModuleImplId() == null && window.forceModule) { + if (getModuleImplId() == null && window.forceModule && !readOnly) { const HTML = `

    Attention, le module doit obligatoirement être renseigné.

    Cela vient de la configuration du semestre ou plus largement du département.

    @@ -101,6 +111,7 @@ function validateSelectors(btn) { getAssiduitesFromEtuds(true); document.querySelector(".selectors").disabled = true; + $("#tl_date").datepicker("option", "disabled", true); generateMassAssiduites(); generateAllEtudRow(); btn.remove(); @@ -126,7 +137,7 @@ function validateSelectors(btn) { } function onlyAbs() { - if (getDate() > moment()) { + if (getDate() > Date.now()) { document .querySelectorAll(".rbtn.present, .rbtn.retard") .forEach((el) => el.remove()); @@ -162,6 +173,7 @@ function uniqueCheckBox(box) { * @param {CallableFunction} errors fonction à effectuer en cas d'échec */ function sync_get(path, success, errors) { + //TODO Optimiser : rendre asynchrone + sans jquery console.log("sync_get " + path); $.ajax({ async: false, @@ -177,16 +189,25 @@ function sync_get(path, success, errors) { * @param {CallableFunction} success fonction à effectuer en cas de succès * @param {CallableFunction} errors fonction à effectuer en cas d'échec */ -function async_get(path, success, errors) { +async function async_get(path, success, errors) { console.log("async_get " + path); - $.ajax({ - async: true, - type: "GET", - url: path, - success: success, - error: errors, - }); + let response; + try { + response = await fetch(path); + if (response.ok) { + const data = await response.json(); + success(data); + } else { + throw new Error("Network response was not ok."); + } + } catch (error) { + console.error(error); + if (errors) errors(error); + } + + return response; } + /** * Fait une requête POST de façon synchrone * @param {String} path adresse distante @@ -195,6 +216,7 @@ function async_get(path, success, errors) { * @param {CallableFunction} errors fonction à effectuer en cas d'échec */ function sync_post(path, data, success, errors) { + //TODO Optimiser : rendre asynchrone + sans jquery console.log("sync_post " + path); $.ajax({ async: false, @@ -212,17 +234,32 @@ function sync_post(path, data, success, errors) { * @param {CallableFunction} success fonction à effectuer en cas de succès * @param {CallableFunction} errors fonction à effectuer en cas d'échec */ -function async_post(path, data, success, errors) { - console.log("sync_post " + path); - return $.ajax({ - async: true, - type: "POST", - url: path, - data: JSON.stringify(data), - success: success, - error: errors, - }); +async function async_post(path, data, success, errors) { + console.log("async_post " + path); + let response; + try { + response = await fetch(path, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (response.ok) { + const responseData = await response.json(); + success(responseData); + } else { + throw new Error("Network response was not ok."); + } + } catch (error) { + console.error(error); + if (errors) errors(error); + } + + return response; } + // <<== Gestion des actions de masse ==>> const massActionQueue = new Map(); @@ -268,8 +305,8 @@ function executeMassActionQueue() { */ const tlTimes = getTimeLineTimes(); let assiduite = { - date_debut: tlTimes.deb.format(), - date_fin: tlTimes.fin.format(), + date_debut: tlTimes.deb.toFakeIso(), + date_fin: tlTimes.fin.toFakeIso(), }; assiduite = setModuleImplId(assiduite); @@ -564,7 +601,10 @@ function toTime(time) { * @returns */ function formatDate(date, styles = { dateStyle: "full" }) { - return new Intl.DateTimeFormat("fr-FR", styles).format(date); + return new Intl.DateTimeFormat("fr-FR", { + ...{ timeZone: SCO_TIMEZONE }, + ...styles, + }).format(date); } /** @@ -572,17 +612,26 @@ function formatDate(date, styles = { dateStyle: "full" }) { */ function updateDate() { const dateInput = document.querySelector("#tl_date"); - - const date = dateInput.valueAsDate ?? new Date(); + let date = $(dateInput).datepicker("getDate"); + if (date == null) { + date = new Date(Date.fromFRA(dateInput.value)); + } + const intlOptions = { + dateStyle: "full", + timeZone: SCO_TIMEZONE, + }; let dateStr = ""; - if (!verifyNonWorkDays(date.getDay(), nonWorkDays)) { - dateStr = formatDate(date).capitalize(); + if (!isNonWorkDay(date, nonWorkDays)) { + dateStr = formatDate(date, intlOptions).capitalize(); } else { // On se rend au dernier jour travaillé disponible const lastWorkDay = getNearestWorkDay(date); const att = document.createTextNode( - `Le jour sélectionné (${formatDate(date)}) n'est pas un jour travaillé.` + `Le jour sélectionné (${formatDate( + date, + intlOptions + )}) n'est pas un jour travaillé.` ); const div = document.createElement("div"); div.appendChild(att); @@ -590,14 +639,23 @@ function updateDate() { div.appendChild( document.createTextNode( `Le dernier jour travaillé disponible a été sélectionné : ${formatDate( - lastWorkDay + lastWorkDay, + intlOptions )}.` ) ); openAlertModal("Attention", div, "", "#eec660"); - dateInput.value = lastWorkDay.toISOString().split("T")[0]; - dateStr = formatDate(lastWorkDay).capitalize(); + + $(dateInput).datepicker("setDate", date_fra); + dateInput.value = date_fra; + date = lastWorkDay; + + dateStr = formatDate(lastWorkDay, { + dateStyle: "full", + timeZone: SCO_TIMEZONE, + }).capitalize(); } + document.querySelector("#datestr").textContent = dateStr; return true; } @@ -606,26 +664,17 @@ function getNearestWorkDay(date) { const aDay = 86400000; // 24 * 3600 * 1000 | H * s * ms let day = date; let count = 0; - while (verifyNonWorkDays(day.getDay(), nonWorkDays) && count++ < 7) { + while (isNonWorkDay(day, nonWorkDays) && count++ < 7) { day = new Date(day - aDay); } return day; } function verifyDateInSemester() { - const date = new moment.tz( - document.querySelector("#tl_date").value, - TIMEZONE - ); + const date = getDate(); const periodSemester = getFormSemestreDates(); - - return date.isBetween( - periodSemester.deb, - periodSemester.fin, - undefined, - "[]" - ); + return date.isBetween(periodSemester.deb, periodSemester.fin, "[]"); } /** @@ -637,15 +686,15 @@ function setupDate(onchange = null) { const input = document.querySelector("#tl_date"); datestr.addEventListener("click", () => { - if (!input.disabled) { + if (!document.querySelector(".selectors").disabled) { try { - input.showPicker(); + document.querySelector(".infos .ui-datepicker-trigger").click(); } catch {} } }); if (onchange != null) { - input.addEventListener("change", onchange); + $(input).change(onchange); } } @@ -664,40 +713,21 @@ function getAssiduitesOnDateChange() { * @param {String} separator le séparateur de la date intelligible (01/01/2000 {separtor} 10:00) * @returns {String} la date intelligible */ -function formatDateModal(str, separator = "·") { - return new moment.tz(str, TIMEZONE).format(`DD/MM/Y ${separator} HH:mm`); +function formatDateModal(str, separator = " ") { + return new Date(str).format("DD/MM/Y HH:mm").replace(" ", separator); } /** * Vérifie si la date sélectionnée n'est pas un jour non travaillé * Renvoie Vrai si le jour est non travaillé */ -function verifyNonWorkDays(day, nonWorkdays) { - let d = ""; - switch (day) { - case 0: - d = "dim"; - break; - case 1: - d = "lun"; - break; - case 2: - d = "mar"; - break; - case 3: - d = "mer"; - break; - case 4: - d = "jeu"; - break; - case 5: - d = "ven"; - break; - case 6: - d = "sam"; - break; - } - +function isNonWorkDay(day, nonWorkdays) { + const d = Intl.DateTimeFormat("fr-FR", { + timeZone: SCO_TIMEZONE, + weekday: "short", + }) + .format(day) + .replace(".", ""); return nonWorkdays.indexOf(d) != -1; } @@ -705,8 +735,8 @@ function verifyNonWorkDays(day, nonWorkdays) { * Fonction qui vérifie si une période est dans un interval * Objet période / interval * { - * deb: moment.tz(), - * fin: moment.tz(), + * deb: Date, + * fin: Date, * } * @param {object} period * @param {object} interval @@ -718,19 +748,19 @@ function hasTimeConflict(period, interval) { /** * On récupère la période de la timeline - * @returns {deb : moment.tz(), fin: moment.tz()} + * @returns {deb : Date, fin: Date)} */ function getTimeLineTimes() { //getPeriodValues() -> retourne la position de la timeline [a,b] avec a et b des number let values = getPeriodValues(); //On récupère la date - const dateiso = document.querySelector("#tl_date").value; + const dateiso = getDate().format("YYYY-MM-DD"); - //On génère des objets temps (moment.tz) + //On génère des objets temps values = values.map((el) => { el = toTime(el).replace("h", ":"); el = `${dateiso}T${el}`; - return moment.tz(el, TIMEZONE); + return new Date(el); }); return { deb: values[0], fin: values[1] }; @@ -744,8 +774,8 @@ function getTimeLineTimes() { function isConflictSameAsPeriod(conflict, period = undefined) { const tlTimes = period == undefined ? getTimeLineTimes() : period; const clTimes = { - deb: moment.tz(conflict.date_debut, TIMEZONE), - fin: moment.tz(conflict.date_fin, TIMEZONE), + deb: new Date(Date.removeUTC(conflict.date_debut)), + fin: new Date(Date.removeUTC(conflict.date_fin)), }; return tlTimes.deb.isSame(clTimes.deb) && tlTimes.fin.isSame(clTimes.fin); } @@ -755,9 +785,10 @@ function isConflictSameAsPeriod(conflict, period = undefined) { * @returns {Date} la date sélectionnée */ function getDate() { - const date = new Date(document.querySelector("#tl_date").value); - date.setHours(0, 0, 0, 0); - return date; + const date = + $("#tl_date").datepicker("getDate") ?? + new Date(Date.fromFRA(document.querySelector("#tl_date").value)); + return date.startOf("day"); } /** @@ -766,10 +797,7 @@ function getDate() { */ function getNextDate() { const date = getDate(); - const next = new Date(date.valueOf()); - next.setDate(date.getDate() + 1); - next.setHours(0, 0, 0, 0); - return next; + return date.clone().add(1, "days"); } /** * Retourne un objet date représentant le jour précédent @@ -777,10 +805,7 @@ function getNextDate() { */ function getPrevDate() { const date = getDate(); - const next = new Date(date.valueOf()); - next.setDate(date.getDate() - 1); - next.setHours(0, 0, 0, 0); - return next; + return date.clone().add(-1, "days"); } /** @@ -788,44 +813,19 @@ function getPrevDate() { * @param {Date} date * @returns {string} la date iso avec le timezone */ -function toIsoString(date) { - var tzo = -date.getTimezoneOffset(), - dif = tzo >= 0 ? "+" : "-", - pad = function (num) { - return (num < 10 ? "0" : "") + num; - }; - - return ( - date.getFullYear() + - "-" + - pad(date.getMonth() + 1) + - "-" + - pad(date.getDate()) + - "T" + - pad(date.getHours()) + - ":" + - pad(date.getMinutes()) + - ":" + - pad(date.getSeconds()) + - dif + - pad(Math.floor(Math.abs(tzo) / 60)) + - ":" + - pad(Math.abs(tzo) % 60) - ); -} /** - * Transforme un temps numérique en une date moment.tz + * Transforme un temps numérique en une date * @param {number} nb - * @returns {moment.tz} Une date formée du temps donné et de la date courante + * @returns {Date} Une date formée du temps donné et de la date courante */ function numberTimeToDate(nb) { time = toTime(nb).replace("h", ":"); - date = document.querySelector("#tl_date").value; + date = getDate().format("YYYY-MM-DD"); datetime = `${date}T${time}`; - return moment.tz(datetime, TIMEZONE); + return new Date(datetime); } // <<== Gestion des assiduités ==>> @@ -841,8 +841,8 @@ function numberTimeToDate(nb) { function getAssiduitesFromEtuds(clear, deb, fin) { const etudIds = Object.keys(etuds).join(","); - const date_debut = deb ? deb : toIsoString(getPrevDate()); - const date_fin = fin ? fin : toIsoString(getNextDate()); + const date_debut = deb ? deb : getPrevDate().toFakeIso(); + const date_fin = fin ? fin : getNextDate().toFakeIso(); if (clear) { assiduites = {}; @@ -885,8 +885,8 @@ function getAssiduitesFromEtuds(clear, deb, fin) { function createAssiduite(etat, etudid) { const tlTimes = getTimeLineTimes(); let assiduite = { - date_debut: tlTimes.deb.format(), - date_fin: tlTimes.fin.format(), + date_debut: tlTimes.deb.toFakeIso(), + date_fin: tlTimes.fin.toFakeIso(), etat: etat, }; @@ -928,6 +928,102 @@ function createAssiduite(etat, etudid) { openAlertModal("Sélection du module", content); } + if ( + data.errors["0"].message == "L'étudiant n'est pas inscrit au module" + ) { + const HTML = ` +

    Attention, l'étudiant n'est pas inscrit à ce module.

    +

    Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.

    + `; + + const content = document.createElement("div"); + content.innerHTML = HTML; + + openAlertModal("Sélection du module", content); + } + with_errors = true; + } + }, + (data, status) => { + //error + console.error(data, status); + errorAlert(); + with_errors = true; + } + ); + return !with_errors; +} +/** + * Création d'une assiduité pour un étudiant + * @param {String} etat l'état de l'étudiant + * @param {Number | String} etudid l'identifiant de l'étudiant + * + * TODO : Rendre asynchrone + */ +function createAssiduiteComplete(assiduite, etudid) { + if (!hasModuleImpl(assiduite) && window.forceModule) { + const html = ` +

    Aucun module n'a été spécifié

    + `; + const div = document.createElement("div"); + div.innerHTML = html; + openAlertModal("Erreur Module", div); + return false; + } + + const path = getUrl() + `/api/assiduite/${etudid}/create`; + + let with_errors = false; + + sync_post( + path, + [assiduite], + (data, status) => { + //success + if (data.success.length > 0) { + let obj = data.success["0"].message.assiduite_id; + } + if (data.errors.length > 0) { + console.error(data.errors["0"].message); + if (data.errors["0"].message == "Module non renseigné") { + const HTML = ` +

    Attention, le module doit obligatoirement être renseigné.

    +

    Cela vient de la configuration du semestre ou plus largement du département.

    +

    Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.

    + `; + + const content = document.createElement("div"); + content.innerHTML = HTML; + + openAlertModal("Sélection du module", content); + } + if ( + data.errors["0"].message == "L'étudiant n'est pas inscrit au module" + ) { + const HTML = ` +

    Attention, l'étudiant n'est pas inscrit à ce module.

    +

    Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.

    + `; + + const content = document.createElement("div"); + content.innerHTML = HTML; + + openAlertModal("Sélection du module", content); + } + if ( + data.errors["0"].message == + "Duplication: la période rentre en conflit avec une plage enregistrée" + ) { + const HTML = ` +

    L'assiduité n'a pas pu être enregistrée car une autre assiduité existe sur la période sélectionnée

    +

    Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.

    + `; + + const content = document.createElement("div"); + content.innerHTML = HTML; + + openAlertModal("Période conflictuelle", content); + } with_errors = true; } }, @@ -1052,7 +1148,7 @@ function editAssiduite(assiduite_id, etat, assi) { } /** - * Récupération des assiduités conflictuelles avec la période de la time line + * Récupération des assiduités conflictuelles avec la période de la timeline * @param {String | Number} etudid identifiant de l'étudiant * @returns {Array[Assiduité]} un tableau d'assiduité */ @@ -1067,10 +1163,11 @@ function getAssiduitesConflict(etudid, periode) { return etudAssiduites.filter((assi) => { const interval = { - deb: moment.tz(assi.date_debut, TIMEZONE), - fin: moment.tz(assi.date_fin, TIMEZONE), + deb: new Date(Date.removeUTC(assi.date_debut)), + fin: new Date(Date.removeUTC(assi.date_fin)), }; - return hasTimeConflict(periode, interval); + const test = hasTimeConflict(periode, interval); + return test; }); } @@ -1085,21 +1182,21 @@ function getLastAssiduiteOfPrevDate(etudid) { return ""; } const period = { - deb: moment.tz(getPrevDate(), TIMEZONE), - fin: moment.tz(getDate(), TIMEZONE), + deb: getPrevDate(), + fin: getDate(), }; const prevAssiduites = etudAssiduites .filter((assi) => { const interval = { - deb: moment.tz(assi.date_debut, TIMEZONE), - fin: moment.tz(assi.date_fin, TIMEZONE), + deb: new Date(Date.removeUTC(assi.date_debut)), + fin: new Date(Date.removeUTC(assi.date_fin)), }; return hasTimeConflict(period, interval); }) .sort((a, b) => { - const a_fin = moment.tz(a.date_fin, TIMEZONE); - const b_fin = moment.tz(b.date_fin, TIMEZONE); + const a_fin = new Date(Date.removeUTC(a.date_fin)); + const b_fin = new Date(Date.removeUTC(b.date_fin)); return b_fin < a_fin; }); @@ -1134,8 +1231,8 @@ function getAssiduiteValue(field) { * @param {String | Number} etudid identifiant de l'étudiant */ function actualizeEtudAssiduite(etudid) { - const date_debut = toIsoString(getPrevDate()); - const date_fin = toIsoString(getNextDate()); + const date_debut = getPrevDate().toFakeIso(); + const date_fin = getNextDate().toFakeIso(); const url_api = getUrl() + @@ -1163,7 +1260,7 @@ function getAllAssiduitesFromEtud( .replace("°", courant ? "&courant" : "") : "" }`; - + //TODO Utiliser async_get au lieu de jquery $.ajax({ async: true, type: "GET", @@ -1232,8 +1329,8 @@ function assiduiteAction(element) { assiduites[etudid], getTimeLineTimes(), { - deb: new moment.tz(getDate(), TIMEZONE), - fin: new moment.tz(getNextDate(), TIMEZONE), + deb: getDate(), + fin: getNextDate(), } ); const update = (assi) => { @@ -1328,17 +1425,17 @@ function generateEtudRow( const HTML = `
    - +
    ${index}
    @@ -1347,12 +1444,12 @@ function generateEtudRow(
    - + ${assi} - +
    - - + +
    `; return HTML; @@ -1377,13 +1474,12 @@ function insertEtudRow(etud, index, output = false) { date_fin: null, prevAssiduites: prevAssiduite, }; - if (conflict.length > 0) { assiduite.etatAssiduite = conflict[0].etat; assiduite.id = conflict[0].assiduite_id; - assiduite.date_debut = conflict[0].date_debut; - assiduite.date_fin = conflict[0].date_fin; + assiduite.date_debut = Date.removeUTC(conflict[0].date_debut); + assiduite.date_fin = Date.removeUTC(conflict[0].date_fin); if (isConflictSameAsPeriod(conflict[0])) { assiduite.type = "édition"; } else { @@ -1545,8 +1641,8 @@ function getFormSemestreDates() { const dateFin = document.getElementById("formsemestre_date_fin").textContent; return { - deb: dateDeb, - fin: dateFin, + deb: new Date(dateDeb), + fin: new Date(dateFin), }; } @@ -1613,8 +1709,8 @@ function getJustificatifFromPeriod(date, etudid, update) { url: getUrl() + `/api/justificatifs/${etudid}/query?date_debut=${date.deb - .add(1, "s") - .format()}&date_fin=${date.fin.subtract(1, "s").format()}`, + .add(1, "seconds") + .toFakeIso()}&date_fin=${date.fin.add(-1, "seconds").toFakeIso()}`, success: (data) => { update(data); }, @@ -1646,8 +1742,8 @@ function fastJustify(assiduite) { } const period = { - deb: new moment.tz(assiduite.date_debut, TIMEZONE), - fin: new moment.tz(assiduite.date_fin, TIMEZONE), + deb: new Date(Date.removeUTC(assiduite.date_debut)), + fin: new Date(Date.removeUTC(assiduite.date_fin)), }; const action = (justifs) => { //créer un nouveau justificatif @@ -1660,8 +1756,8 @@ function fastJustify(assiduite) { //créer justificatif const justif = { - date_debut: new moment.tz(assiduite.date_debut, TIMEZONE).format(), - date_fin: new moment.tz(assiduite.date_fin, TIMEZONE).format(), + date_debut: new Date(Date.removeUTC(assiduite.date_debut)).toFakeIso(), + date_fin: new Date(Date.removeUTC(assiduite.date_fin)).toFakeIso(), raison: raison, etat: etat, }; @@ -1694,7 +1790,7 @@ function fastJustify(assiduite) { content, success, () => {}, - "#7059FF" + "var(--color-primary)" ); }; if (assiduite.etudid) { @@ -1744,6 +1840,8 @@ function getAllJustificatifsFromEtud( `/api/justificatifs/${etudid}${ order ? "/query?order°".replace("°", courant ? "&courant" : "") : "" }`; + + //TODO Utiliser async_get au lieu de jquery $.ajax({ async: true, type: "GET", diff --git a/app/static/js/bulletin.js b/app/static/js/bulletin.js index 843f70f83a..ffb05347b7 100644 --- a/app/static/js/bulletin.js +++ b/app/static/js/bulletin.js @@ -1,7 +1,6 @@ // Affichage anciens (non BUT) bulletin de notes // (uses jQuery) - // Change visibility of UE details (les de classe "notes_bulletin_row_mod" suivant) // La table a la structure suivante: // +/-...... @@ -11,40 +10,44 @@ // On change la visi de tous les jusqu'au notes_bulletin_row_ue suivant. // function toggle_vis_ue(e, new_state) { - // e is the span containg the clicked +/- icon - var tr = e.parentNode.parentNode; - if (new_state == undefined) { - // current state: use alt attribute of current image - if (e.childNodes[0].alt == '+') { - new_state = false; - } else { - new_state = true; - } - } - // find next tr in siblings - var tr = tr.nextSibling; - //while ((tr != null) && sibl.tagName == 'TR') { - var current = true; - while ((tr != null) && current) { - if ((tr.nodeType == 1) && (tr.tagName == 'TR')) { - for (var i = 0; i < tr.classList.length; i++) { - if ((tr.classList[i] == 'notes_bulletin_row_ue') || (tr.classList[i] == 'notes_bulletin_row_sum_ects')) - current = false; - } - if (current) { - if (new_state) { - tr.style.display = 'none'; - } else { - tr.style.display = 'table-row'; - } - } - } - tr = tr.nextSibling; - } - if (new_state) { - e.innerHTML = '+'; - } else { - e.innerHTML = '-'; - } + // e is the span containg the clicked +/- icon + var tr = e.parentNode.parentNode; + if (new_state == undefined) { + // current state: use alt attribute of current image + if (e.childNodes[0].alt == "+") { + new_state = false; + } else { + new_state = true; + } + } + // find next tr in siblings + var tr = tr.nextSibling; + //while ((tr != null) && sibl.tagName == 'TR') { + var current = true; + while (tr != null && current) { + if (tr.nodeType == 1 && tr.tagName == "TR") { + for (var i = 0; i < tr.classList.length; i++) { + if ( + tr.classList[i] == "notes_bulletin_row_ue" || + tr.classList[i] == "notes_bulletin_row_sum_ects" + ) + current = false; + } + if (current) { + if (new_state) { + tr.style.display = "none"; + } else { + tr.style.display = "table-row"; + } + } + } + tr = tr.nextSibling; + } + if (new_state) { + e.innerHTML = + '+'; + } else { + e.innerHTML = + '-'; + } } - diff --git a/app/static/js/calabs.js b/app/static/js/calabs.js index 50f4ff184a..ebf64de985 100644 --- a/app/static/js/calabs.js +++ b/app/static/js/calabs.js @@ -12,13 +12,12 @@ var CURRENTWEEKCOLOR = "yellow"; // get all tr elements from this class // (no getElementBuClassName) -function getTRweek( week ) { - var tablecal = document.getElementById('maincalendar'); - var all = tablecal.getElementsByTagName('tr'); - var res = [] ; - for(var i=0; i < all.length; i++) { - if (all[i].className == week) - res[res.length] = all[i]; +function getTRweek(week) { + var tablecal = document.getElementById("maincalendar"); + var all = tablecal.getElementsByTagName("tr"); + var res = []; + for (var i = 0; i < all.length; i++) { + if (all[i].className == week) res[res.length] = all[i]; } return res; } @@ -26,14 +25,13 @@ function getTRweek( week ) { var HIGHLIGHTEDCELLS = []; function deselectweeks() { - - for(var i=0; i < HIGHLIGHTEDCELLS.length; i++) { + for (var i = 0; i < HIGHLIGHTEDCELLS.length; i++) { var row = rows[i]; if (row) { - if (row.className.match('currentweek')) { - row.style.backgroundColor = CURRENTWEEKCOLOR; + if (row.className.match("currentweek")) { + row.style.backgroundColor = CURRENTWEEKCOLOR; } else { - row.style.backgroundColor = WEEKDAYCOLOR; + row.style.backgroundColor = WEEKDAYCOLOR; } rows[i] = null; } @@ -44,11 +42,11 @@ function deselectweeks() { function highlightweek(el) { deselectweeks(); var week = el.className; - if ((week == 'wkend') || (week.substring(0,2) != 'wk')) { + if (week == "wkend" || week.substring(0, 2) != "wk") { return; /* does not hightlight weekends */ } rows = getTRweek(week); - for (var i=0; i < rows.length; i++) { + for (var i = 0; i < rows.length; i++) { var row = rows[i]; row.style.backgroundColor = DAYHIGHLIGHT; HIGHLIGHTEDCELLS[HIGHLIGHTEDCELLS.length] = row; @@ -58,7 +56,7 @@ function highlightweek(el) { // click on a day function wclick(el) { monday = el.className; - form = document.getElementById('formw'); - form.datelundi.value = monday.substr(2).replace(/_/g,'/').split(' ')[0]; + form = document.getElementById("formw"); + form.datelundi.value = monday.substr(2).replace(/_/g, "/").split(" ")[0]; form.submit(); } diff --git a/app/static/js/config_logos.js b/app/static/js/config_logos.js index 56b7cd27d5..8c129da1eb 100644 --- a/app/static/js/config_logos.js +++ b/app/static/js/config_logos.js @@ -1,6 +1,5 @@ function submit_form() { - $("#config_logos_form").submit(); + $("#config_logos_form").submit(); } -$(function () { -}) \ No newline at end of file +$(function () {}); diff --git a/app/static/js/date_utils.js b/app/static/js/date_utils.js new file mode 100644 index 0000000000..05babeedec --- /dev/null +++ b/app/static/js/date_utils.js @@ -0,0 +1,595 @@ +/** + * Transforme une date du format français (DD/MM/YYYY) au format iso (YYYY-MM-DD) + * Exemple d'utilisation : + * new Date(Date.fromFRA("30/06/2024")) -> new Date("2024-06-30") + * @param {string} dateFra + * @returns {string} dateIso + */ +Date.fromFRA = function (dateFra) { + if (dateFra == "") return ""; + // Expression régulière pour valider le format de date ISO (YYYY-MM-DD) + const regexDateFra = /^(0[1-9]|[12][0-9]|3[01])\/(0[1-9]|1[0-2])\/\d{4}$/; + + // Vérification du format de la date ISO + if (!regexDateFra.test(dateFra)) { + throw new Error( + `La date (format français) passée en paramètre [${dateFra}] n'est pas valide.` + ); + } + + // Conversion du format français (DD/MM/YYYY) au format ISO (YYYY-MM-DD) + return `${dateFra.substring(6, 10)}-${dateFra.substring( + 3, + 5 + )}-${dateFra.substring(0, 2)}`; +}; +/** + * Transforme une date du format iso (YYYY-MM-DD) au format français (DD/MM/YYYY) + * Exemple d'utilisation : + * Date.toFRA("2024-06-30") -> "30/06/2024" + * @param {string} dateIso + * @returns {string} dateFra + */ +Date.toFRA = function (dateIso) { + if (dateIso == "") return ""; + // Expression régulière pour valider le format de date ISO (YYYY-MM-DD) + const regexDateIso = /^\d{4}-(0\d|1[0-2])-([0-2]\d|3[01])$/; + + // Vérification du format de la date ISO + if (!regexDateIso.test(dateIso)) { + throw new Error( + `La date ISO passée en paramètre [${dateIso}] n'est pas valide.` + ); + } + + // Conversion du format ISO (YYYY-MM-DD) en format français (DD/MM/YYYY) + return `${dateIso.substring(8, 10)}/${dateIso.substring( + 5, + 7 + )}/${dateIso.substring(0, 4)}`; +}; +/** + * Vérifie si le début de l'une des périodes est avant la fin de l'autre + * et si la fin de cette période est après le début de l'autre. + * @param {Object} period {deb:Object, fin:Object} + * @param {Object} interval {deb:Object, fin:Object} + * @returns vrai si la periode et l'interval ont une intersection commune + */ +Date.intersect = function (period, interval) { + return period.deb <= interval.fin && period.fin >= interval.deb; +}; + +Date.removeUTC = function (isoString) { + const reg = new RegExp(/[+-][\d:]+$/); + return isoString.replace(reg, ""); +}; + +Object.defineProperty(Date.prototype, "isValid", { + value: function () { + return !Number.isNaN(this.getTime()); + }, +}); +Object.defineProperty(Date.prototype, "startOf", { + /** + * Génère u la date à la plus petite valeur pour la précision donnée. + * @param {string} precision - La précision souhaitée (year, month, day, hours, minutes, seconds, milliseconds). + * @returns {Date} - Une nouvelle date ajustée. + */ + value: function (precision) { + const newDate = this.clone(); + switch (precision) { + case "year": + newDate.setMonth(0); + case "month": + newDate.setDate(1); + case "day": + newDate.setHours(0); + case "hours": + newDate.setMinutes(0); + case "minutes": + newDate.setSeconds(0); + case "seconds": + newDate.setMilliseconds(0); + break; + case "milliseconds": + break; + default: + throw new Error( + `Invalid precision for startOf function [${precision}]` + ); + } + return newDate; + }, +}); + +Object.defineProperty(Date.prototype, "endOf", { + /** + * Ajuste la date à la plus grande valeur pour la précision donnée. + * @param {string} precision - La précision souhaitée (year, month, day, hours, minutes, seconds, milliseconds). + * @returns {Date} - Une nouvelle date ajustée. + */ + value: function (precision) { + const newDate = this.clone(); + switch (precision) { + case "year": + newDate.setMonth(11); // Décembre est le 11ème mois (0-indexé) + case "month": + newDate.setDate(0); // Le jour 0 du mois suivant est le dernier jour du mois courant + newDate.setMonth(newDate.getMonth() + 1); + case "day": + newDate.setHours(23); // 23 heures est la dernière heure de la journée + case "hours": + newDate.setMinutes(59); // 59 minutes est la dernière minute de l'heure + case "minutes": + newDate.setSeconds(59); // 59 secondes est la dernière seconde de la minute + case "seconds": + newDate.setMilliseconds(999); // 999 millisecondes est la dernière milliseconde de la seconde + break; + case "milliseconds": + // Rien à faire pour les millisecondes + break; + default: + throw new Error("Invalid precision for endOf function"); + } + return newDate; + }, +}); + +Object.defineProperty(Date.prototype, "isBefore", { + /** + * Retourne vrai si la date est située avant la date fournie + * @param {Date} date + * @returns {boolean} + */ + value: function (date) { + return this.valueOf() < date.valueOf(); + }, +}); +Object.defineProperty(Date.prototype, "isAfter", { + /** + * Retourne vrai si la date est située après la date fournie + * @param {Date} date + * @returns {boolean} + */ + value: function (date) { + return this.valueOf() > date.valueOf(); + }, +}); + +Object.defineProperty(Date.prototype, "isSame", { + /** + * Retourne vrai si les dates sont les mêmes + * @param {Date} date + * @param {string} precision default : "milliseconds" + * @returns boolean + */ + value: function (date, precision = "milliseconds") { + return ( + this.startOf(precision).valueOf() == date.startOf(precision).valueOf() + ); + }, +}); + +Object.defineProperty(Date.prototype, "isBetween", { + /** + * Vérifie si la date est comprise dans une période avec une précision et une inclusivité optionnelles + * @param {Date} deb - La date de début de la période + * @param {Date} fin - La date de fin de la période + * @param {String} bornes - L'inclusivité/exclusivité de la comparaison ("[]", "()", "[)", "(]") + * - bornes incluses : [] + * - bornes excluses : () + * - borne gauche incluse et borne droit excluse : [) + * - borne gauche excluse et borne droit incluse : (] + */ + value: function (deb, fin, bornes = "[]") { + // Ajuste la date actuelle, la date de début et la date de fin à la précision spécifiée + + // Vérifie les bornes en fonction de l'inclusivité/exclusivité spécifiée dans 'bornes' + const check_deb = + bornes[0] === "(" + ? this.valueOf() > deb.valueOf() + : this.valueOf() >= deb.valueOf(); + const check_fin = + bornes[1] === ")" + ? fin.valueOf() > this.valueOf() + : fin.valueOf() >= this.valueOf(); + + return check_deb && check_fin; + }, +}); + +Object.defineProperty(Date.prototype, "toIsoUtcString", { + /** + * @returns date au format iso utc (yyyy-mm-ddThh:MM±oo:oo:oo) + */ + value: function () { + // Formater la date et l'heure + const date = this; + var tzo = -date.getTimezoneOffset(), + dif = tzo >= 0 ? "+" : "-", + pad = function (num) { + return (num < 10 ? "0" : "") + num; + }; + return ( + this.toFakeIso() + + dif + + pad(Math.floor(Math.abs(tzo) / 60)) + + ":" + + pad(Math.abs(tzo) % 60) + ); + }, +}); + +Object.defineProperty(Date.prototype, "toFakeIso", { + value: function () { + const date = this; + pad = function (num) { + return (num < 10 ? "0" : "") + num; + }; + return ( + date.getFullYear() + + "-" + + pad(date.getMonth() + 1) + + "-" + + pad(date.getDate()) + + "T" + + pad(date.getHours()) + + ":" + + pad(date.getMinutes()) + + ":" + + pad(date.getSeconds()) + ); + }, +}); + +Object.defineProperty(Date.prototype, "clone", { + /** + * @returns Retourne une copie de la date (copie non liée) + */ + value: function () { + return structuredClone(this); + }, +}); + +Object.defineProperty(Date.prototype, "format", { + value: function (formatString) { + let iso = this.toIsoUtcString(); + switch (formatString) { + case "DD/MM/Y HH:mm": + return this.toLocaleString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: SCO_TIMEZONE, + }); + case "DD/MM/YYYY HH:mm": + return this.toLocaleString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: SCO_TIMEZONE, + }); + + case "YYYY-MM-DDTHH:mm": + // slice : YYYY-MM-DDTHH + // slice + 3 : YYYY-MM-DDTHH:mm + return iso.slice(0, iso.indexOf(":") + 3); + case "YYYY-MM-DD": + return iso.slice(0, iso.indexOf("T")); + default: + return this.toFakeIso(); + } + }, +}); + +Object.defineProperty(Date.prototype, "add", { + /** + * Ajoute une valeur spécifiée à un élément de la date. + * @param {number} value - La valeur à ajouter. + * @param {string} type - Le type de la valeur (year, month, day, hours, minutes, seconds). + */ + value: function (value, type) { + switch (type) { + case "years": + this.setFullYear(this.getFullYear() + value); + break; + case "months": + this.setMonth(this.getMonth() + value); + break; + case "days": + this.setDate(this.getDate() + value); + break; + case "hours": + this.setHours(this.getHours() + value); + break; + case "minutes": + this.setMinutes(this.getMinutes() + value); + break; + case "seconds": + this.setSeconds(this.getSeconds() + value); + break; + default: + throw new Error( + `Invalid type for adding to date | type : ${type} value : ${value}` + ); + } + return this; // Return the modified date + }, +}); + +class Duration { + /** + * Constructeur de la classe Duration. + * @param {Date} start - La date de début de la période. + * @param {Date} end - La date de fin de la période. + */ + constructor(start, end) { + this.start = start; // Stocke la date de début. + this.end = end; // Stocke la date de fin. + this.duration = end - start; // Calcule la durée en millisecondes entre les deux dates. + } + + /** + * Calcule le nombre d'années entre les deux dates et arrondit le résultat à quatre décimales. + * @return {number} Le nombre d'années arrondi à quatre décimales. + */ + get years() { + const startYear = this.start.getFullYear(); // Obtient l'année de la date de début. + const endYear = this.end.getFullYear(); // Obtient l'année de la date de fin. + // Calcule la différence en années et arrondit à quatre décimales. + return parseFloat((endYear - startYear).toFixed(4)); + } + + /** + * Calcule le nombre de mois entre les deux dates, en tenant compte des années et des jours, et arrondit le résultat à quatre décimales. + * @return {number} Le nombre de mois arrondi à quatre décimales. + */ + get months() { + const years = this.years; // Nombre d'années complètes. + // Calcule la différence en mois, en ajoutant la différence en jours divisée par 30 pour une approximation. + const months = + years * 12 + + (this.end.getMonth() - this.start.getMonth()) + + (this.end.getDate() - this.start.getDate()) / 30; + // Arrondit à quatre décimales. + return parseFloat(months.toFixed(4)); + } + + /** + * Calcule le nombre de jours entre les deux dates et arrondit le résultat à quatre décimales. + * @return {number} Le nombre de jours arrondi à quatre décimales. + */ + get days() { + // Convertit la durée en millisecondes en jours et arrondit à quatre décimales. + return parseFloat((this.duration / (24 * 60 * 60 * 1000)).toFixed(4)); + } + + /** + * Calcule le nombre d'heures entre les deux dates et arrondit le résultat à quatre décimales. + * @return {number} Le nombre d'heures arrondi à quatre décimales. + */ + get hours() { + // Convertit la durée en millisecondes en heures et arrondit à quatre décimales. + return parseFloat((this.duration / (60 * 60 * 1000)).toFixed(4)); + } + + /** + * Calcule le nombre de minutes entre les deux dates et arrondit le résultat à quatre décimales. + * @return {number} Le nombre de minutes arrondi à quatre décimales. + */ + get minutes() { + // Convertit la durée en millisecondes en minutes et arrondit à quatre décimales. + return parseFloat((this.duration / (60 * 1000)).toFixed(4)); + } + + /** + * Calcule le nombre de secondes entre les deux dates et arrondit le résultat à quatre décimales. + * @return {number} Le nombre de secondes arrondi à quatre décimales. + */ + get seconds() { + // Convertit la durée en millisecondes en secondes et arrondit à quatre décimales. + return parseFloat((this.duration / 1000).toFixed(4)); + } + + /** + * Obtient le nombre de millisecondes entre les deux dates et arrondit le résultat à quatre décimales. + * @return {number} Le nombre de millisecondes arrondi à quatre décimales. + */ + get milliseconds() { + // Arrondit la durée totale en millisecondes à quatre décimales. + return parseFloat(this.duration.toFixed(4)); + } +} + +class ScoDocDateTimePicker extends HTMLElement { + constructor() { + super(); + // Définir si le champ est requis + this.required = this.hasAttribute("required"); + + // Initialiser le shadow DOM + const shadow = this.attachShadow({ mode: "open" }); + + // Créer l'input pour la date + const dateInput = document.createElement("input"); + dateInput.type = "date"; + dateInput.id = "date"; + + // Créer l'input pour l'heure + const timeInput = document.createElement("input"); + timeInput.type = "time"; + timeInput.id = "time"; + timeInput.step = 60; + + // Ajouter les inputs dans le shadow DOM + shadow.appendChild(dateInput); + shadow.appendChild(timeInput); + + // Gestionnaires d'événements pour la mise à jour de la valeur + dateInput.addEventListener("change", () => this.updateValue()); + timeInput.addEventListener("change", () => this.updateValue()); + + // Style CSS pour les inputs + const style = document.createElement("style"); + style.textContent = ` + input { + display: inline-block; + } + input:invalid { + border: 1px solid red; + } + `; + + // Ajouter le style au shadow DOM + shadow.appendChild(style); + } + + static get observedAttributes() { + return ["show"]; // Ajoute 'show' à la liste des attributs observés + } + + connectedCallback() { + // Récupérer l'attribut 'name' + this.name = this.getAttribute("name"); + + // Créer un input caché pour la valeur datetime + this.hiddenInput = document.createElement("input"); + this.hiddenInput.type = "hidden"; + this.hiddenInput.name = this.name; + this.appendChild(this.hiddenInput); + + // Gérer la soumission du formulaire + this.closest("form")?.addEventListener("submit", (e) => { + if (!this.validate()) { + e.preventDefault(); // Empêcher la soumission si non valide + this.dispatchEvent( + new Event("invalid", { bubbles: true, cancelable: true }) + ); + } else { + // Mettre à jour la valeur de l'input caché avant la soumission + this.hiddenInput.value = this.isValid() + ? this.valueAsDate.toIsoUtcString() + : ""; + } + }); + this.updateDisplay(); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === "show") { + this.updateDisplay(); // Met à jour l'affichage si l'attribut 'show' change + } + } + + updateDisplay() { + const mode = this.getAttribute("show") || "both"; + const dateInput = this.shadowRoot.querySelector("#date"); + const timeInput = this.shadowRoot.querySelector("#time"); + + switch (mode) { + case "date": + dateInput.style.display = "inline-block"; + timeInput.style.display = "none"; + break; + case "time": + dateInput.style.display = "none"; + timeInput.style.display = "inline-block"; + break; + case "both": + default: + dateInput.style.display = "inline-block"; + timeInput.style.display = "inline-block"; + } + } + + // Vérifier si la valeur forme une date valide + isValid() { + return !Number.isNaN(this.valueAsDate.getTime()); + } + + // Valider l'élément + validate() { + if (this.required && !this.isValid()) { + return false; + } + return true; + } + + // Mettre à jour la valeur interne + updateValue() { + const dateInput = this.shadowRoot.querySelector("#date"); + const timeInput = this.shadowRoot.querySelector("#time"); + this._value = `${dateInput.value}T${timeInput.value}`; + this.dispatchEvent(new Event("change", { bubbles: true })); + + // Appliquer le style 'invalid' si nécessaire + dateInput.classList.toggle("invalid", this.required && !this.isValid()); + timeInput.classList.toggle("invalid", this.required && !this.isValid()); + } + + // Getter pour obtenir la valeur actuelle. + get value() { + return this._value; + } + + get valueAsObject() { + const dateInput = this.shadowRoot.querySelector("#date"); + const timeInput = this.shadowRoot.querySelector("#time"); + return { + date: dateInput.value, + time: timeInput.value, + }; + } + + // Getter pour obtenir la valeur en tant qu'objet Date. + get valueAsDate() { + return new Date(this._value); + } + + // Setter pour définir la valeur. Sépare la valeur en date et heure et les définit individuellement. + set value(val) { + let [date, time] = val.split("T"); + this.shadowRoot.querySelector("#date").value = date; + + time = time.substring(0, 5); + + this.shadowRoot.querySelector("#time").value = time; + this._value = val; + } + + // Setter pour définir la valeur à partir d'un objet avec les propriétés 'date' et 'time'. + set valueAsObject(obj) { + const dateInput = this.shadowRoot.querySelector("#date"); + const timeInput = this.shadowRoot.querySelector("#time"); + + if (obj.hasOwnProperty("date")) { + dateInput.value = obj.date || ""; // Définit la valeur de l'input de date si elle est fournie + } + + if (obj.hasOwnProperty("time")) { + timeInput.value = obj.time.substring(0, 5) || ""; // Définit la valeur de l'input d'heure si elle est fournie + } + + // Met à jour la valeur interne en fonction des nouvelles valeurs des inputs + this.updateValue(); + } + + // Setter pour définir la valeur à partir d'un objet Date. + set valueAsDate(dateVal) { + // Formatage de l'objet Date en string et mise à jour de la valeur. + this.value = `${dateVal.getFullYear()}-${String( + dateVal.getMonth() + 1 + ).padStart(2, "0")}-${String(dateVal.getDate()).padStart(2, "0")}T${String( + dateVal.getHours() + ).padStart(2, "0")}:${String(dateVal.getMinutes()).padStart(2, "0")}`; + } +} + +// Définition du nouvel élément personnalisé 'scodoc-datetime'. +customElements.define("scodoc-datetime", ScoDocDateTimePicker); diff --git a/app/static/js/detail_summary_persistence.js b/app/static/js/detail_summary_persistence.js index 1a8848896a..0b5645e654 100644 --- a/app/static/js/detail_summary_persistence.js +++ b/app/static/js/detail_summary_persistence.js @@ -13,70 +13,70 @@ les balises (fermées par défaut sauf si attribut open déjà activé dans le c */ -const ID_ATTRIBUTE = "ds_id" +const ID_ATTRIBUTE = "ds_id"; function genere_id(detail, idnum) { - let id = "ds_" + idnum; - if (detail.getAttribute("id")) { - id = "#" + detail.getAttribute("id"); - } - detail.setAttribute(ID_ATTRIBUTE, id); - return id; + let id = "ds_" + idnum; + if (detail.getAttribute("id")) { + id = "#" + detail.getAttribute("id"); + } + detail.setAttribute(ID_ATTRIBUTE, id); + return id; } // remise à l'état initial. doit être exécuté dès le chargement de la page pour que l'état 'open' // des balises soit celui indiqué par le serveur (et donc indépendant du localstorage) function reset_detail(detail, id) { - let opened = detail.getAttribute("open"); - if (opened) { - detail.setAttribute("open", true); - localStorage.setItem(id, true); - } else { - detail.removeAttribute("open"); - localStorage.setItem(id, false); - } + let opened = detail.getAttribute("open"); + if (opened) { + detail.setAttribute("open", true); + localStorage.setItem(id, true); + } else { + detail.removeAttribute("open"); + localStorage.setItem(id, false); + } } function restore_detail(detail, id) { - let status = localStorage.getItem(id); - if (status == "true") { - detail.setAttribute("open", true); - } else { - detail.removeAttribute("open"); - } + let status = localStorage.getItem(id); + if (status == "true") { + detail.setAttribute("open", true); + } else { + detail.removeAttribute("open"); + } } function add_listener(detail) { - detail.addEventListener('toggle', (e) => { - let id = e.target.getAttribute(ID_ATTRIBUTE); - let ante = e.target.getAttribute("open"); - if (ante == null) { - localStorage.setItem(id, false); - } else { - localStorage.setItem(id, true); - } - e.stopPropagation(); - }) + detail.addEventListener("toggle", (e) => { + let id = e.target.getAttribute(ID_ATTRIBUTE); + let ante = e.target.getAttribute("open"); + if (ante == null) { + localStorage.setItem(id, false); + } else { + localStorage.setItem(id, true); + } + e.stopPropagation(); + }); } function reset_ds() { - let idnum = 0; - keepDetails = true; - details = document.querySelectorAll("details") - details.forEach(function (detail) { - let id = genere_id(detail, idnum); - console.log("Processing " + id) - if (keepDetails) { - restore_detail(detail, id); - } else { - reset_detail(detail, id); - } - add_listener(detail); - idnum++; - }); + let idnum = 0; + keepDetails = true; + details = document.querySelectorAll("details"); + details.forEach(function (detail) { + let id = genere_id(detail, idnum); + console.log("Processing " + id); + if (keepDetails) { + restore_detail(detail, id); + } else { + reset_detail(detail, id); + } + add_listener(detail); + idnum++; + }); } -window.addEventListener('load', function() { - console.log("details/summary persistence ON"); - reset_ds(); -}) +window.addEventListener("load", function () { + console.log("details/summary persistence ON"); + reset_ds(); +}); diff --git a/app/static/js/edit_partition_form.js b/app/static/js/edit_partition_form.js index 9d4b7fd92d..c92090177e 100644 --- a/app/static/js/edit_partition_form.js +++ b/app/static/js/edit_partition_form.js @@ -1,38 +1,32 @@ - - function _partition_set_attr(partition_id, attr_name, attr_value) { - $.post(SCO_URL + '/partition_set_attr', - { - 'partition_id': partition_id, - 'attr': attr_name, - 'value': attr_value - }, - function (result) { - sco_message(result); - }); - return; + $.post( + SCO_URL + "/partition_set_attr", + { + partition_id: partition_id, + attr: attr_name, + value: attr_value, + }, + function (result) { + sco_message(result); + } + ); + return; } // Met à jour bul_show_rank lorsque checkbox modifiees: function update_rk(e) { - var partition_id = $(e).attr('data-partition_id'); - var v; - if (e.checked) - v = '1'; - else - v = '0'; - _partition_set_attr(partition_id, 'bul_show_rank', v); + var partition_id = $(e).attr("data-partition_id"); + var v; + if (e.checked) v = "1"; + else v = "0"; + _partition_set_attr(partition_id, "bul_show_rank", v); } - function update_show_in_list(e) { - var partition_id = $(e).attr('data-partition_id'); - var v; - if (e.checked) - v = '1'; - else - v = '0'; + var partition_id = $(e).attr("data-partition_id"); + var v; + if (e.checked) v = "1"; + else v = "0"; - _partition_set_attr(partition_id, 'show_in_lists', v); + _partition_set_attr(partition_id, "show_in_lists", v); } - diff --git a/app/static/js/edit_ue.js b/app/static/js/edit_ue.js index 488d5ea940..ef4dc60c97 100644 --- a/app/static/js/edit_ue.js +++ b/app/static/js/edit_ue.js @@ -1,63 +1,72 @@ // Affiche et met a jour la liste des UE partageant le meme code $().ready(function () { - if (document.querySelector("#tf_ue_id")) { - /* fonctions spécifiques pour edition UE */ - update_ue_list(); - $("#tf_ue_code").bind("keyup", update_ue_list); + if (document.querySelector("#tf_ue_id")) { + /* fonctions spécifiques pour edition UE */ + update_ue_list(); + $("#tf_ue_code").bind("keyup", update_ue_list); - $("select#tf_type").change(function () { - update_bonus_description(); - }); - update_bonus_description(); - } + $("select#tf_type").change(function () { + update_bonus_description(); + }); + update_bonus_description(); + } }); function update_bonus_description() { - var ue_type = $("#tf_type")[0].value; - if (ue_type == "1") { /* UE SPORT */ - $("#bonus_description").show(); - var query = "/ScoDoc/get_bonus_description/default"; - $.get(query, '', function (data) { - $("#bonus_description").html(data); - }); - } else { - $("#bonus_description").html(""); - $("#bonus_description").hide(); - } + var ue_type = $("#tf_type")[0].value; + if (ue_type == "1") { + /* UE SPORT */ + $("#bonus_description").show(); + var query = "/ScoDoc/get_bonus_description/default"; + $.get(query, "", function (data) { + $("#bonus_description").html(data); + }); + } else { + $("#bonus_description").html(""); + $("#bonus_description").hide(); + } } function update_ue_list() { - let ue_id = $("#tf_ue_id")[0].value; - let ue_code = $("#tf_ue_code")[0].value; - let query = SCO_URL + "/Notes/ue_sharing_code?ue_code=" + ue_code + "&hide_ue_id=" + ue_id + "&ue_id=" + ue_id; - $.get(query, '', function (data) { - $("#ue_list_code").html(data); - }); + let ue_id = $("#tf_ue_id")[0].value; + let ue_code = $("#tf_ue_code")[0].value; + let query = + SCO_URL + + "/Notes/ue_sharing_code?ue_code=" + + ue_code + + "&hide_ue_id=" + + ue_id + + "&ue_id=" + + ue_id; + $.get(query, "", function (data) { + $("#ue_list_code").html(data); + }); } function set_ue_parcour(checkbox) { - let url = checkbox.dataset.setter; - const checkboxes = document.querySelectorAll('#choix_parcours input[type="checkbox"]:checked'); - const parcours_ids = []; - checkboxes.forEach(function (checkbox) { - parcours_ids.push(checkbox.value); + let url = checkbox.dataset.setter; + const checkboxes = document.querySelectorAll( + '#choix_parcours input[type="checkbox"]:checked' + ); + const parcours_ids = []; + checkboxes.forEach(function (checkbox) { + parcours_ids.push(checkbox.value); + }); + + fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(parcours_ids), + }) + .then((response) => response.json()) + .then((data) => { + if (data.status == 404) { + sco_error_message(data.message); + } else { + sco_message(data.message); + } }); - - fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(parcours_ids) - }) - .then(response => response.json()) - .then(data => { - if (data.status == 404) { - sco_error_message(data.message); - } else { - sco_message(data.message); - } - }); } - diff --git a/app/static/js/etud_autocomplete.js b/app/static/js/etud_autocomplete.js index 217707aaa5..8f6e0bfa5d 100644 --- a/app/static/js/etud_autocomplete.js +++ b/app/static/js/etud_autocomplete.js @@ -1,65 +1,68 @@ - // Mécanisme d'auto-complétion (choix) d'un étudiant // Il faut un champs #etudiant (text input) et à coté un champ hidden etudid qui sera rempli. // utilise autoComplete.js, source https://tarekraafat.github.io/autoComplete.js // EV 2023-06-01 function etud_autocomplete_config(with_dept = false) { - return { - selector: "#etudiant", - placeHolder: "Nom...", - threshold: 3, - data: { - src: async (query) => { - try { - // Fetch Data from external Source - const source = await fetch(`/ScoDoc/api/etudiants/name/${query}`); - // Data should be an array of `Objects` or `Strings` - const data = await source.json(); - return data; - } catch (error) { - return error; - } - }, - // Data source 'Object' key to be searched - keys: ["nom"] + return { + selector: "#etudiant", + placeHolder: "Nom...", + threshold: 3, + data: { + src: async (query) => { + try { + // Fetch Data from external Source + const source = await fetch(`/ScoDoc/api/etudiants/name/${query}`); + // Data should be an array of `Objects` or `Strings` + const data = await source.json(); + return data; + } catch (error) { + return error; + } + }, + // Data source 'Object' key to be searched + keys: ["nom"], + }, + events: { + input: { + selection: (event) => { + const prenom = sco_capitalize(event.detail.selection.value.prenom); + const selection = with_dept + ? `${event.detail.selection.value.nom} ${prenom} (${event.detail.selection.value.dept_acronym})` + : `${event.detail.selection.value.nom} ${prenom}`; + // store etudid + const etudidField = document.getElementById("etudid"); + etudidField.value = event.detail.selection.value.id; + autoCompleteJS.input.value = selection; }, - events: { - input: { - selection: (event) => { - const prenom = sco_capitalize(event.detail.selection.value.prenom); - const selection = with_dept ? `${event.detail.selection.value.nom} ${prenom} (${event.detail.selection.value.dept_acronym})` : `${event.detail.selection.value.nom} ${prenom}`; - // store etudid - const etudidField = document.getElementById('etudid'); - etudidField.value = event.detail.selection.value.id; - autoCompleteJS.input.value = selection; - } - } - }, - resultsList: { - element: (list, data) => { - if (!data.results.length) { - // Create "No Results" message element - const message = document.createElement("div"); - // Add class to the created element - message.setAttribute("class", "no_result"); - // Add message text content - message.innerHTML = `Pas de résultat pour "${data.query}"`; - // Append message element to the results list - list.prepend(message); - // Efface l'etudid - const etudidField = document.getElementById('etudid'); - etudidField.value = ""; - } - }, - noResults: true, - }, - resultItem: { - highlight: true, - element: (item, data) => { - const prenom = sco_capitalize(data.value.prenom); - item.innerHTML += with_dept ? ` ${prenom} (${data.value.dept_acronym})` : ` ${prenom}`; - }, - }, - } + }, + }, + resultsList: { + element: (list, data) => { + if (!data.results.length) { + // Create "No Results" message element + const message = document.createElement("div"); + // Add class to the created element + message.setAttribute("class", "no_result"); + // Add message text content + message.innerHTML = `Pas de résultat pour "${data.query}"`; + // Append message element to the results list + list.prepend(message); + // Efface l'etudid + const etudidField = document.getElementById("etudid"); + etudidField.value = ""; + } + }, + noResults: true, + }, + resultItem: { + highlight: true, + element: (item, data) => { + const prenom = sco_capitalize(data.value.prenom); + item.innerHTML += with_dept + ? ` ${prenom} (${data.value.dept_acronym})` + : ` ${prenom}`; + }, + }, + }; } diff --git a/app/static/js/evaluations_recap.js b/app/static/js/evaluations_recap.js index b3b60c3fb8..3cedb71239 100644 --- a/app/static/js/evaluations_recap.js +++ b/app/static/js/evaluations_recap.js @@ -1,38 +1,37 @@ // Tableau recap evaluations du semestre $(function () { - $('table.evaluations_recap').DataTable( - { - paging: false, - searching: true, - info: false, - autoWidth: false, - fixedHeader: { - header: true, - footer: false - }, - orderCellsTop: true, // cellules ligne 1 pour tri - aaSorting: [], // Prevent initial sorting - colReorder: true, - "columnDefs": [ - { - // colonne date, triable (XXX ne fonctionne pas) - targets: ["date"], - "type": "string", - }, - ], - dom: 'Bfrtip', - buttons: [ - { - extend: 'copyHtml5', - text: 'Copier', - exportOptions: { orthogonal: 'export' } - }, - { - extend: 'excelHtml5', - exportOptions: { orthogonal: 'export' }, - title: document.querySelector('table.evaluations_recap').dataset.filename - }, - ], - - }) + $("table.evaluations_recap").DataTable({ + paging: false, + searching: true, + info: false, + autoWidth: false, + fixedHeader: { + header: true, + footer: false, + }, + orderCellsTop: true, // cellules ligne 1 pour tri + aaSorting: [], // Prevent initial sorting + colReorder: true, + columnDefs: [ + { + // colonne date, triable (XXX ne fonctionne pas) + targets: ["date"], + type: "string", + }, + ], + dom: "Bfrtip", + buttons: [ + { + extend: "copyHtml5", + text: "Copier", + exportOptions: { orthogonal: "export" }, + }, + { + extend: "excelHtml5", + exportOptions: { orthogonal: "export" }, + title: document.querySelector("table.evaluations_recap").dataset + .filename, + }, + ], + }); }); diff --git a/app/static/js/export_results.js b/app/static/js/export_results.js index ba117fa4ae..e5be718bbf 100644 --- a/app/static/js/export_results.js +++ b/app/static/js/export_results.js @@ -1,14 +1,11 @@ // Export table tous les resultats // Menu choix parcours: -$(function() { - $('#parcours_sel').multiselect( - { - includeSelectAllOption: true, - nonSelectedText:'Choisir le(s) parcours...', - selectAllValue: '', - numberDisplayed: 3, - } - ); +$(function () { + $("#parcours_sel").multiselect({ + includeSelectAllOption: true, + nonSelectedText: "Choisir le(s) parcours...", + selectAllValue: "", + numberDisplayed: 3, + }); }); - diff --git a/app/static/js/formation_recap.js b/app/static/js/formation_recap.js index 0bcb009f47..6437cbf9ac 100644 --- a/app/static/js/formation_recap.js +++ b/app/static/js/formation_recap.js @@ -3,26 +3,38 @@ var apo_ue_editor = null; var apo_mod_editor = null; $(document).ready(function () { - var table_options = { - "paging": false, - "searching": false, - "info": false, - /* "autoWidth" : false, */ - "fixedHeader": { - "header": true, - "footer": true - }, - "orderCellsTop": true, // cellules ligne 1 pour tri - "aaSorting": [], // Prevent initial sorting - }; - $('table#formation_table_recap').DataTable(table_options); - let table_editable = document.querySelector("table#formation_table_recap.apo_editable"); - if (table_editable) { - let apo_ue_save_url = document.querySelector("table#formation_table_recap.apo_editable").dataset.apo_ue_save_url; - apo_ue_editor = new ScoFieldEditor("table#formation_table_recap tr.ue td.apo", apo_ue_save_url, false); - let apo_mod_save_url = document.querySelector("table#formation_table_recap.apo_editable").dataset.apo_mod_save_url; - apo_mod_editor = new ScoFieldEditor("table#formation_table_recap tr.mod td.apo", apo_mod_save_url, false); - } + var table_options = { + paging: false, + searching: false, + info: false, + /* "autoWidth" : false, */ + fixedHeader: { + header: true, + footer: true, + }, + orderCellsTop: true, // cellules ligne 1 pour tri + aaSorting: [], // Prevent initial sorting + }; + $("table#formation_table_recap").DataTable(table_options); + let table_editable = document.querySelector( + "table#formation_table_recap.apo_editable" + ); + if (table_editable) { + let apo_ue_save_url = document.querySelector( + "table#formation_table_recap.apo_editable" + ).dataset.apo_ue_save_url; + apo_ue_editor = new ScoFieldEditor( + "table#formation_table_recap tr.ue td.apo", + apo_ue_save_url, + false + ); + let apo_mod_save_url = document.querySelector( + "table#formation_table_recap.apo_editable" + ).dataset.apo_mod_save_url; + apo_mod_editor = new ScoFieldEditor( + "table#formation_table_recap tr.mod td.apo", + apo_mod_save_url, + false + ); + } }); - - diff --git a/app/static/js/formsemestre_edit.js b/app/static/js/formsemestre_edit.js index 3394d659c7..b763cc941c 100644 --- a/app/static/js/formsemestre_edit.js +++ b/app/static/js/formsemestre_edit.js @@ -1,14 +1,13 @@ // Formulaire formsemestre_createwithmodules function change_semestre_id() { - var semestre_id = $("#tf_semestre_id")[0].value; - for (var i = -1; i < 12; i++) { - $(".sem" + i).hide(); - } - $(".sem" + semestre_id).show(); + var semestre_id = $("#tf_semestre_id")[0].value; + for (var i = -1; i < 12; i++) { + $(".sem" + i).hide(); + } + $(".sem" + semestre_id).show(); } - -$(window).on('load', function () { - change_semestre_id(); -}); \ No newline at end of file +$(window).on("load", function () { + change_semestre_id(); +}); diff --git a/app/static/js/formsemestre_ext_edit_ue_validations.js b/app/static/js/formsemestre_ext_edit_ue_validations.js index ed9576b528..571d27220a 100644 --- a/app/static/js/formsemestre_ext_edit_ue_validations.js +++ b/app/static/js/formsemestre_ext_edit_ue_validations.js @@ -1,81 +1,83 @@ - - - function compute_moyenne() { - var notes = $(".tf_field_note input").map( - function () { return parseFloat($(this).val()); } - ).get(); - // les coefs sont donnes (ECTS en BUT) - let coefs = $("form.tf_ext_edit_ue_validations").data("ue_coefs"); - // ou saisis (formations classiques) - if (coefs == 'undefined') { - coefs = $(".tf_field_coef input").map( - function () { return parseFloat($(this).val()); } - ).get(); + var notes = $(".tf_field_note input") + .map(function () { + return parseFloat($(this).val()); + }) + .get(); + // les coefs sont donnes (ECTS en BUT) + let coefs = $("form.tf_ext_edit_ue_validations").data("ue_coefs"); + // ou saisis (formations classiques) + if (coefs == "undefined") { + coefs = $(".tf_field_coef input") + .map(function () { + return parseFloat($(this).val()); + }) + .get(); + } + var N = notes.length; + var dp = 0; + var sum_coefs = 0; + for (var i = 0; i < N; i++) { + if (!(isNaN(notes[i]) || isNaN(coefs[i]))) { + dp += notes[i] * coefs[i]; + sum_coefs += coefs[i]; } - var N = notes.length; - var dp = 0.; - var sum_coefs = 0.; - for (var i = 0; i < N; i++) { - if (!(isNaN(notes[i]) || isNaN(coefs[i]))) { - dp += notes[i] * coefs[i]; - sum_coefs += coefs[i]; - } - } - let moy = dp / sum_coefs; - if (isNaN(moy)) { - moy = "-"; - } - if (typeof moy == "number") { - moy = moy.toFixed(2); - } - return moy; + } + let moy = dp / sum_coefs; + if (isNaN(moy)) { + moy = "-"; + } + if (typeof moy == "number") { + moy = moy.toFixed(2); + } + return moy; } // Callback select menu (UE code) function enable_disable_fields_cb() { - enable_disable_fields(this); + enable_disable_fields(this); } function enable_disable_fields(select_elt) { - // input fields controled by this menu - var input_fields = $(select_elt).parent().parent().find('input:not(.ext_coef_disabled)'); - var disabled = false; - if ($(select_elt).val() === "None") { - disabled = true; + // input fields controled by this menu + var input_fields = $(select_elt) + .parent() + .parent() + .find("input:not(.ext_coef_disabled)"); + var disabled = false; + if ($(select_elt).val() === "None") { + disabled = true; + } + input_fields.each(function () { + if (disabled) { + let cur_value = $(this).val(); + $(this).data("saved-value", cur_value); + $(this).val(""); + } else { + let saved_value = $(this).data("saved-value"); + if (typeof saved_value == "undefined") { + saved_value = ""; + } + if (saved_value) { + $(this).val(saved_value); + } } - input_fields.each(function () { - if (disabled) { - let cur_value = $(this).val(); - $(this).data('saved-value', cur_value); - $(this).val(""); - } else { - let saved_value = $(this).data('saved-value'); - if (typeof saved_value == 'undefined') { - saved_value = ''; - } - if (saved_value) { - $(this).val(saved_value); - } - } - }); - input_fields.prop('disabled', disabled); + }); + input_fields.prop("disabled", disabled); } function setup_text_fields() { - $(".ueext_valid_select").each( - function () { - enable_disable_fields(this); - } - ); + $(".ueext_valid_select").each(function () { + enable_disable_fields(this); + }); } $().ready(function () { - $(".tf_ext_edit_ue_validations").change(function () { - $(".ext_sem_moy_val")[0].innerHTML = compute_moyenne(); - }); - $("form.tf_ext_edit_ue_validations input").blur(function () { - $(".ext_sem_moy_val")[0].innerHTML = compute_moyenne(); - }); - $(".ueext_valid_select").change(enable_disable_fields_cb); + $(".tf_ext_edit_ue_validations").change(function () { + $(".ext_sem_moy_val")[0].innerHTML = compute_moyenne(); + }); + $("form.tf_ext_edit_ue_validations input").blur(function () { + $(".ext_sem_moy_val")[0].innerHTML = compute_moyenne(); + }); + $(".ueext_valid_select").change(enable_disable_fields_cb); - setup_text_fields(); + setup_text_fields(); }); diff --git a/app/static/js/groups_view.js b/app/static/js/groups_view.js index 8daabe574b..74433b6430 100644 --- a/app/static/js/groups_view.js +++ b/app/static/js/groups_view.js @@ -1,186 +1,213 @@ // Affichage progressif du trombinoscope html $().ready(function () { - var spans = $(".unloaded_img"); - for (var i = 0; i < spans.length; i++) { - var sp = spans[i]; - var etudid = sp.id; - $(sp).load(SCO_URL + '/etud_photo_html?etudid=' + etudid); - } + var spans = $(".unloaded_img"); + for (var i = 0; i < spans.length; i++) { + var sp = spans[i]; + var etudid = sp.id; + $(sp).load(SCO_URL + "/etud_photo_html?etudid=" + etudid); + } }); - // L'URL pour recharger l'état courant de la page (groupes et tab selectionnes) // (ne fonctionne que pour les requetes GET: manipule la query string) function groups_view_url() { - var url = $.url(); - delete url.param()['group_ids']; // retire anciens groupes de l'URL - delete url.param()['curtab']; // retire ancien tab actif - if (CURRENT_TAB_HASH) { - url.param()['curtab'] = CURRENT_TAB_HASH; - } - delete url.param()['formsemestre_id']; - url.param()['formsemestre_id'] = $("#group_selector")[0].formsemestre_id.value; + var url = $.url(); + delete url.param()["group_ids"]; // retire anciens groupes de l'URL + delete url.param()["curtab"]; // retire ancien tab actif + if (CURRENT_TAB_HASH) { + url.param()["curtab"] = CURRENT_TAB_HASH; + } + delete url.param()["formsemestre_id"]; + url.param()["formsemestre_id"] = + $("#group_selector")[0].formsemestre_id.value; - var selected_groups = $("#group_selector select").val(); - url.param()['group_ids'] = selected_groups; // remplace par groupes selectionnes + var selected_groups = $("#group_selector select").val(); + url.param()["group_ids"] = selected_groups; // remplace par groupes selectionnes - return url; + return url; } -// Selectionne tous les etudiants et recharge la page: -function select_tous() { - var url = groups_view_url(); - var default_group_id = $("#group_selector")[0].default_group_id.value; - delete url.param()['group_ids']; - url.param()['group_ids'] = [default_group_id]; +// Sélectionne le groupe "tous" et recharge la page: +function select_groupe_tous() { + var url = groups_view_url(); + var default_group_id = $("#group_selector")[0].default_group_id.value; + delete url.param()["group_ids"]; + url.param()["group_ids"] = [default_group_id]; - var query_string = $.param(url.param(), traditional = true); - window.location = url.attr('base') + url.attr('path') + '?' + query_string; + var query_string = $.param(url.param(), (traditional = true)); + window.location = url.attr("base") + url.attr("path") + "?" + query_string; +} + +// Recharge la page sans arguments group_ids +function remove_group_filter() { + var url = groups_view_url(); + delete url.param()["group_ids"]; + var query_string = $.param(url.param(), (traditional = true)); + window.location = url.attr("base") + url.attr("path") + "?" + query_string; } // L'URL pour l'état courant de la page: function get_current_url() { - var url = groups_view_url(); - var query_string = $.param(url.param(), traditional = true); - return url.attr('base') + url.attr('path') + '?' + query_string; + var url = groups_view_url(); + var query_string = $.param(url.param(), (traditional = true)); + return url.attr("base") + url.attr("path") + "?" + query_string; } // Recharge la page en changeant les groupes selectionnés et en conservant le tab actif: function submit_group_selector() { - window.location = get_current_url(); + window.location = get_current_url(); } function show_current_tab() { - $('.nav-tabs [href="#' + CURRENT_TAB_HASH + '"]').tab('show'); + if (document.getElementsByClassName("nav-tabs").length > 0) { + $('.nav-tabs [href="#' + CURRENT_TAB_HASH + '"]').tab("show"); + } } -var CURRENT_TAB_HASH = $.url().param()['curtab']; +var CURRENT_TAB_HASH = $.url().param()["curtab"]; $().ready(function () { - $('.nav-tabs a').on('shown.bs.tab', function (e) { - CURRENT_TAB_HASH = e.target.hash.slice(1); // sans le # - }); + $(".nav-tabs a").on("shown.bs.tab", function (e) { + CURRENT_TAB_HASH = e.target.hash.slice(1); // sans le # + }); - show_current_tab(); + show_current_tab(); }); function change_list_options() { - var url = groups_view_url(); - var selected_options = $("#group_list_options").val(); - var options = ["with_paiement", "with_archives", "with_annotations", "with_codes", "with_bourse"]; - for (var i = 0; i < options.length; i++) { - var option = options[i]; - delete url.param()[option]; - if ($.inArray(option, selected_options) >= 0) { - url.param()[option] = 1; - } + var url = groups_view_url(); + var selected_options = $("#group_list_options").val(); + var options = [ + "with_paiement", + "with_archives", + "with_annotations", + "with_codes", + "with_bourse", + ]; + for (var i = 0; i < options.length; i++) { + var option = options[i]; + delete url.param()[option]; + if ($.inArray(option, selected_options) >= 0) { + url.param()[option] = 1; } - var query_string = $.param(url.param(), traditional = true); - window.location = url.attr('base') + url.attr('path') + '?' + query_string; + } + var query_string = $.param(url.param(), (traditional = true)); + window.location = url.attr("base") + url.attr("path") + "?" + query_string; } // Menu choix groupe: function toggle_visible_etuds() { - // - $(".etud_elem").hide(); - var qargs = ""; - $("#group_ids_sel option:selected").each(function (index, opt) { - var group_id = opt.value; - $(".group-" + group_id).show(); - qargs += "&group_ids=" + group_id; - }); - // Update url saisie tableur: - var input_eval = $("#formnotes_evaluation_id"); - if (input_eval.length > 0) { - var evaluation_id = input_eval[0].value; - $("#menu_saisie_tableur a").attr("href", "saisie_notes_tableur?evaluation_id=" + evaluation_id + qargs); - // lien feuille excel: - $("#lnk_feuille_saisie").attr("href", "feuille_saisie_notes?evaluation_id=" + evaluation_id + qargs); - } - // Update champs form group_ids_str - let group_ids_str = Array.from( - document.querySelectorAll("#group_ids_sel option:checked") - ).map( - function (elem) { return elem.value; } - ).join(); - document.querySelectorAll("input.group_ids_str").forEach(elem => elem.value = group_ids_str); + // + $(".etud_elem").hide(); + var qargs = ""; + $("#group_ids_sel option:selected").each(function (index, opt) { + var group_id = opt.value; + $(".group-" + group_id).show(); + qargs += "&group_ids=" + group_id; + }); + // Update url saisie tableur: + var input_eval = $("#formnotes_evaluation_id"); + if (input_eval.length > 0) { + var evaluation_id = input_eval[0].value; + $("#menu_saisie_tableur a").attr( + "href", + "saisie_notes_tableur?evaluation_id=" + evaluation_id + qargs + ); + // lien feuille excel: + $("#lnk_feuille_saisie").attr( + "href", + "feuille_saisie_notes?evaluation_id=" + evaluation_id + qargs + ); + } + // Update champs form group_ids_str + let group_ids_str = Array.from( + document.querySelectorAll("#group_ids_sel option:checked") + ) + .map(function (elem) { + return elem.value; + }) + .join(); + document + .querySelectorAll("input.group_ids_str") + .forEach((elem) => (elem.value = group_ids_str)); } $().ready(function () { - $('#group_ids_sel').multiselect( - { - includeSelectAllOption: false, - nonSelectedText: 'choisir...', - // buttonContainer: '
    ', - onChange: function (element, checked) { - if (checked == true) { - var default_group_id = $(".default_group")[0].value; + $("#group_ids_sel").multiselect({ + includeSelectAllOption: false, + nonSelectedText: "choisir...", + // buttonContainer: '
    ', + onChange: function (element, checked) { + // Gestion du groupe "tous" + if ( + checked == true && + $("#group_ids_sel").hasClass("default_deselect_others") + ) { + var default_group_id = $(".default_group")[0].value; - if (element.hasClass("default_group")) { - // click sur groupe "tous" - // deselectionne les autres - $("#group_ids_sel option:selected").each(function (index, opt) { - if (opt.value != default_group_id) { - $("#group_ids_sel").multiselect('deselect', opt.value); - } - }); - - } else { - // click sur un autre item - // si le groupe "tous" est selectionne et que l'on coche un autre, le deselectionner - var default_is_selected = false; - $("#group_ids_sel option:selected").each(function (index, opt) { - if (opt.value == default_group_id) { - default_is_selected = true; - return false; - } - }); - if (default_is_selected) { - $("#group_ids_sel").multiselect('deselect', default_group_id); - } - } - } - - toggle_visible_etuds(); - // referme le menu apres chaque choix: - $("#group_selector .btn-group").removeClass('open'); - - if ($("#group_ids_sel").hasClass("submit_on_change")) { - submit_group_selector(); - } + if (element.hasClass("default_group")) { + // click sur groupe "tous" + // deselectionne les autres + $("#group_ids_sel option:selected").each(function (index, opt) { + if (opt.value != default_group_id) { + $("#group_ids_sel").multiselect("deselect", opt.value); } + }); + } else { + // click sur un autre item + // si le groupe "tous" est selectionne et que l'on coche un autre, le deselectionner + var default_is_selected = false; + $("#group_ids_sel option:selected").each(function (index, opt) { + if (opt.value == default_group_id) { + default_is_selected = true; + return false; + } + }); + if (default_is_selected) { + $("#group_ids_sel").multiselect("deselect", default_group_id); + } } - ); + } - // initial setup - toggle_visible_etuds(); + toggle_visible_etuds(); + // referme le menu apres chaque choix: + $("#group_selector .btn-group").removeClass("open"); + + if ($("#group_ids_sel").hasClass("submit_on_change")) { + submit_group_selector(); + } + }, + }); + + // initial setup + toggle_visible_etuds(); }); // Trombinoscope $().ready(function () { - - var elems = $(".trombi-photo"); - for (var i = 0; i < elems.length; i++) { - $(elems[i]).qtip( - { - content: { - ajax: { - url: SCO_URL + "/etud_info_html?with_photo=0&etudid=" + get_etudid_from_elem(elems[i]) - }, - text: "Loading..." - }, - position: { - at: "right", - my: "left top" - }, - style: { - classes: 'qtip-etud' - }, - // utile pour debugguer le css: - // hide: { event: 'unfocus' } - } - ); - } + var elems = $(".trombi-photo"); + for (var i = 0; i < elems.length; i++) { + $(elems[i]).qtip({ + content: { + ajax: { + url: + SCO_URL + + "/etud_info_html?with_photo=0&etudid=" + + get_etudid_from_elem(elems[i]), + }, + text: "Loading...", + }, + position: { + at: "right", + my: "left top", + }, + style: { + classes: "qtip-etud", + }, + // utile pour debugguer le css: + // hide: { event: 'unfocus' } + }); + } }); diff --git a/app/static/js/jury_but.js b/app/static/js/jury_but.js index c53b223adb..945e93333e 100644 --- a/app/static/js/jury_but.js +++ b/app/static/js/jury_but.js @@ -1,88 +1,90 @@ - - // active les menus des codes "manuels" (année, RCUEs) function enable_manual_codes(elt) { - $(".jury_but select.manual").prop("disabled", !elt.checked); + $(".jury_but select.manual").prop("disabled", !elt.checked); } // changement d'un menu code: function change_menu_code(elt) { - // Ajuste styles pour visualiser codes enregistrés/modifiés - if (elt.value != elt.dataset.orig_code) { - elt.parentElement.parentElement.classList.add("modified"); - } else { - elt.parentElement.parentElement.classList.remove("modified"); - } - if (elt.value == elt.dataset.orig_recorded) { - elt.parentElement.parentElement.classList.add("recorded"); - } else { - elt.parentElement.parentElement.classList.remove("recorded"); - } - // Si RCUE passant en ADJ, change les menus des UEs associées ADJR - if (elt.classList.contains("code_rcue") - && elt.dataset.niveau_id - && elt.value == "ADJ" - && elt.value != elt.dataset.orig_recorded) { - let ue_selects = elt.parentElement.parentElement.parentElement.querySelectorAll( - "select.ue_rcue_" + elt.dataset.niveau_id); - ue_selects.forEach(select => { - if (select.value != "ADM") { - select.value = "ADJR"; - change_menu_code(select); // pour changer les styles - } - }); - } + // Ajuste styles pour visualiser codes enregistrés/modifiés + if (elt.value != elt.dataset.orig_code) { + elt.parentElement.parentElement.classList.add("modified"); + } else { + elt.parentElement.parentElement.classList.remove("modified"); + } + if (elt.value == elt.dataset.orig_recorded) { + elt.parentElement.parentElement.classList.add("recorded"); + } else { + elt.parentElement.parentElement.classList.remove("recorded"); + } + // Si RCUE passant en ADJ, change les menus des UEs associées ADJR + if ( + elt.classList.contains("code_rcue") && + elt.dataset.niveau_id && + elt.value == "ADJ" && + elt.value != elt.dataset.orig_recorded + ) { + let ue_selects = + elt.parentElement.parentElement.parentElement.querySelectorAll( + "select.ue_rcue_" + elt.dataset.niveau_id + ); + ue_selects.forEach((select) => { + if (select.value != "ADM") { + select.value = "ADJR"; + change_menu_code(select); // pour changer les styles + } + }); + } } $(function () { - // Recupère la liste ordonnées des etudids - // pour avoir le "suivant" et le "précédent" - // (liens de navigation) - const url = new URL(document.URL); - const frags = url.pathname.split("/"); // .../formsemestre_validation_but/formsemestre_id/etudid - const etudid = frags[frags.length - 1]; - const formsemestre_id = frags[frags.length - 2]; - const etudids_key = JSON.stringify(["etudids", url.origin, formsemestre_id]); - const etudids_str = localStorage.getItem(etudids_key); - const noms_key = JSON.stringify(["noms", url.origin, formsemestre_id]); - const noms_str = localStorage.getItem(noms_key); - if (etudids_str && noms_str) { - const etudids = JSON.parse(etudids_str); - const noms = JSON.parse(noms_str); - const cur_idx = etudids.indexOf(etudid); - let prev_idx = -1; - let next_idx = -1 - if (cur_idx != -1) { - if (cur_idx > 0) { - prev_idx = cur_idx - 1; - } - if (cur_idx < etudids.length - 1) { - next_idx = cur_idx + 1; - } - } - if (prev_idx != -1) { - let elem = document.querySelector("div.prev a"); - if (elem) { - elem.href = elem.href.replace("PREV", etudids[prev_idx]); - elem.innerHTML = noms[prev_idx]; - } - } else { - document.querySelector("div.prev").innerHTML = ""; - } - if (next_idx != -1) { - let elem = document.querySelector("div.next a"); - if (elem) { - elem.href = elem.href.replace("NEXT", etudids[next_idx]); - elem.innerHTML = noms[next_idx]; - } - } else { - document.querySelector("div.next").innerHTML = ""; - } - } else { - // Supprime les liens de navigation - document.querySelector("div.prev").innerHTML = ""; - document.querySelector("div.next").innerHTML = ""; + // Recupère la liste ordonnées des etudids + // pour avoir le "suivant" et le "précédent" + // (liens de navigation) + const url = new URL(document.URL); + const frags = url.pathname.split("/"); // .../formsemestre_validation_but/formsemestre_id/etudid + const etudid = frags[frags.length - 1]; + const formsemestre_id = frags[frags.length - 2]; + const etudids_key = JSON.stringify(["etudids", url.origin, formsemestre_id]); + const etudids_str = localStorage.getItem(etudids_key); + const noms_key = JSON.stringify(["noms", url.origin, formsemestre_id]); + const noms_str = localStorage.getItem(noms_key); + if (etudids_str && noms_str) { + const etudids = JSON.parse(etudids_str); + const noms = JSON.parse(noms_str); + const cur_idx = etudids.indexOf(etudid); + let prev_idx = -1; + let next_idx = -1; + if (cur_idx != -1) { + if (cur_idx > 0) { + prev_idx = cur_idx - 1; + } + if (cur_idx < etudids.length - 1) { + next_idx = cur_idx + 1; + } } + if (prev_idx != -1) { + let elem = document.querySelector("div.prev a"); + if (elem) { + elem.href = elem.href.replace("PREV", etudids[prev_idx]); + elem.innerHTML = noms[prev_idx]; + } + } else { + document.querySelector("div.prev").innerHTML = ""; + } + if (next_idx != -1) { + let elem = document.querySelector("div.next a"); + if (elem) { + elem.href = elem.href.replace("NEXT", etudids[next_idx]); + elem.innerHTML = noms[next_idx]; + } + } else { + document.querySelector("div.next").innerHTML = ""; + } + } else { + // Supprime les liens de navigation + document.querySelector("div.prev").innerHTML = ""; + document.querySelector("div.next").innerHTML = ""; + } }); // ----- Etat du formulaire jury pour éviter sortie sans enregistrer @@ -91,29 +93,31 @@ let IS_SUBMITTING = false; // Une chaine décrivant l'état du form function get_form_state() { - let codes = []; - // il n'y a que des + document.querySelectorAll("select").forEach((sel) => codes.push(sel.value)); + return codes.join(); } -$('document').ready(function () { - FORM_STATE = get_form_state(); - document.querySelector("form#jury_but").addEventListener('submit', jury_form_submit); +$("document").ready(function () { + FORM_STATE = get_form_state(); + document + .querySelector("form#jury_but") + .addEventListener("submit", jury_form_submit); }); function is_modified() { - return FORM_STATE != get_form_state(); + return FORM_STATE != get_form_state(); } function jury_form_submit(event) { - IS_SUBMITTING = true; + IS_SUBMITTING = true; } window.addEventListener("beforeunload", function (e) { - if ((!IS_SUBMITTING) && is_modified()) { - var confirmationMessage = 'Changements non enregistrés !'; - (e || window.event).returnValue = confirmationMessage; - return confirmationMessage; - } + if (!IS_SUBMITTING && is_modified()) { + var confirmationMessage = "Changements non enregistrés !"; + (e || window.event).returnValue = confirmationMessage; + return confirmationMessage; + } }); diff --git a/app/static/js/map_lycees.js b/app/static/js/map_lycees.js index dae9f43377..514ac4c4c7 100644 --- a/app/static/js/map_lycees.js +++ b/app/static/js/map_lycees.js @@ -2,33 +2,42 @@ var ScoMarkerIcons = {}; -$().ready(function(){ - $('#lyc_map_canvas').gmap( - { 'center': '48.955741,2.34141', - 'zoom' : 8, - 'mapTypeId': google.maps.MapTypeId.ROADMAP - }).bind('init', function(event, map) { - for (var i =0; i < lycees_coords.length; i++) { - var lycee = lycees_coords[i]; - var nb = lycee['number']; - var icon; - if (nb in ScoMarkerIcons) { - icon = ScoMarkerIcons[nb]; - } else { - icon = new google.maps.MarkerImage( 'https://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=' + nb + '|FF0000|000000' ); - ScoMarkerIcons[nb] = icon; // cache - } - $('#lyc_map_canvas').gmap( - 'addMarker', - {'position': lycee['position'], 'bounds': true, 'nomlycee' : lycee['name'], 'icon' : icon } - ).click( - function() { - $('#lyc_map_canvas').gmap('openInfoWindow', {'content': this.nomlycee}, this); - } - ); - } - }); +$().ready(function () { + $("#lyc_map_canvas") + .gmap({ + center: "48.955741,2.34141", + zoom: 8, + mapTypeId: google.maps.MapTypeId.ROADMAP, + }) + .bind("init", function (event, map) { + for (var i = 0; i < lycees_coords.length; i++) { + var lycee = lycees_coords[i]; + var nb = lycee["number"]; + var icon; + if (nb in ScoMarkerIcons) { + icon = ScoMarkerIcons[nb]; + } else { + icon = new google.maps.MarkerImage( + "https://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=" + + nb + + "|FF0000|000000" + ); + ScoMarkerIcons[nb] = icon; // cache + } + $("#lyc_map_canvas") + .gmap("addMarker", { + position: lycee["position"], + bounds: true, + nomlycee: lycee["name"], + icon: icon, + }) + .click(function () { + $("#lyc_map_canvas").gmap( + "openInfoWindow", + { content: this.nomlycee }, + this + ); + }); + } + }); }); - - - diff --git a/app/static/js/module_edit.js b/app/static/js/module_edit.js index 9882fdff56..2917c0174a 100644 --- a/app/static/js/module_edit.js +++ b/app/static/js/module_edit.js @@ -1,8 +1,3 @@ /* Page édition module */ - -$(document).ready(function () { - -}); - - +$(document).ready(function () {}); diff --git a/app/static/js/module_tag_editor.js b/app/static/js/module_tag_editor.js index 13ca64d7d2..9aff530a69 100644 --- a/app/static/js/module_tag_editor.js +++ b/app/static/js/module_tag_editor.js @@ -1,35 +1,31 @@ // Edition tags sur modules +$(function () { + $(".module_tag_editor").tagEditor({ + initialTags: "", + placeholder: "Tags du module ...", + forceLowercase: false, + onChange: function (field, editor, tags) { + $.post("module_tag_set", { + module_id: field.data("module_id"), + taglist: tags.join(), + }); + }, + autocomplete: { + delay: 200, // ms before suggest + position: { collision: "flip" }, // automatic menu position up/down + source: "module_tag_search", + }, + }); -$(function() { - $('.module_tag_editor').tagEditor({ - initialTags: '', - placeholder: 'Tags du module ...', - forceLowercase: false, - onChange: function(field, editor, tags) { - $.post('module_tag_set', - { - module_id: field.data("module_id"), - taglist: tags.join() - }); - }, - autocomplete: { - delay: 200, // ms before suggest - position: { collision: 'flip' }, // automatic menu position up/down - source: "module_tag_search" - }, - }); - - // version readonly - readOnlyTags($('.module_tag_editor_ro')); - - - $('.sco_tag_checkbox').click(function() { - if( $(this).is(':checked')) { - $(".sco_tag_edit").show(); - } else { - $(".sco_tag_edit").hide(); - } - }); + // version readonly + readOnlyTags($(".module_tag_editor_ro")); + $(".sco_tag_checkbox").click(function () { + if ($(this).is(":checked")) { + $(".sco_tag_edit").show(); + } else { + $(".sco_tag_edit").hide(); + } + }); }); diff --git a/app/static/js/moduleimpl_inscriptions_stats.js b/app/static/js/moduleimpl_inscriptions_stats.js index af8d4bb3cb..9390acbe0c 100644 --- a/app/static/js/moduleimpl_inscriptions_stats.js +++ b/app/static/js/moduleimpl_inscriptions_stats.js @@ -2,36 +2,31 @@ */ function change_ue_inscr(elt) { - let url = ""; - if (elt.checked) { - url = elt.dataset.url_inscr; - } else { - url = elt.dataset.url_desinscr; - } - $.post(url, - {}, - function (result) { - sco_message("changement inscription UE enregistré"); - } - ); + let url = ""; + if (elt.checked) { + url = elt.dataset.url_inscr; + } else { + url = elt.dataset.url_desinscr; + } + $.post(url, {}, function (result) { + sco_message("changement inscription UE enregistré"); + }); } $(function () { - $("table#but_ue_inscriptions").DataTable( - { - paging: false, - searching: true, - info: false, - autoWidth: false, - fixedHeader: { - header: true, - footer: false - }, - orderCellsTop: true, // cellules ligne 1 pour tri - aaSorting: [], // Prevent initial sorting - "oLanguage": { - "sSearch": "Chercher :" - } - } - ); + $("table#but_ue_inscriptions").DataTable({ + paging: false, + searching: true, + info: false, + autoWidth: false, + fixedHeader: { + header: true, + footer: false, + }, + orderCellsTop: true, // cellules ligne 1 pour tri + aaSorting: [], // Prevent initial sorting + oLanguage: { + sSearch: "Chercher :", + }, + }); }); diff --git a/app/static/js/radar_bulletin.js b/app/static/js/radar_bulletin.js index 43fe13dcdc..533b3bf596 100644 --- a/app/static/js/radar_bulletin.js +++ b/app/static/js/radar_bulletin.js @@ -25,7 +25,7 @@ var NB_TICS = R_TICS.length; function get_notes_and_draw(formsemestre_id, etudid) { console.log("get_notes(" + formsemestre_id + ", " + etudid + " )"); /* Recupère le bulletin de note et extrait tableau de notes */ - /* + /* var notes = [ { 'module' : 'E1', 'note' : 13, diff --git a/app/static/js/recap_parcours.js b/app/static/js/recap_parcours.js index 8f9329d381..8615ad1798 100644 --- a/app/static/js/recap_parcours.js +++ b/app/static/js/recap_parcours.js @@ -1,59 +1,67 @@ // Affichage parcours etudiant // (uses jQuery) -function toggle_vis(e, new_state) { // change visibility of tr (UE in tr and next tr) - // e is the span containg the clicked +/- icon - var formsemestre_class = e.classList[1]; - var tr = e.parentNode.parentNode; - if (new_state == undefined) { - // current state: use alt attribute of current image - if (e.childNodes[0].alt == '+') { - new_state=false; - } else { - new_state=true; - } - } - if (new_state) { - new_tr_display = 'none'; +function toggle_vis(e, new_state) { + // change visibility of tr (UE in tr and next tr) + // e is the span containg the clicked +/- icon + var formsemestre_class = e.classList[1]; + var tr = e.parentNode.parentNode; + if (new_state == undefined) { + // current state: use alt attribute of current image + if (e.childNodes[0].alt == "+") { + new_state = false; } else { - new_tr_display = 'table-row'; + new_state = true; } - $("tr."+formsemestre_class+":not(.rcp_l1)").css('display', new_tr_display) - - // find next tr in siblings (xxx legacy code, could be optimized) - var sibl = tr.nextSibling; - while ((sibl != null) && sibl.nodeType != 1 && sibl.tagName != 'TR') { - sibl = sibl.nextSibling; + } + if (new_state) { + new_tr_display = "none"; + } else { + new_tr_display = "table-row"; + } + $("tr." + formsemestre_class + ":not(.rcp_l1)").css( + "display", + new_tr_display + ); + + // find next tr in siblings (xxx legacy code, could be optimized) + var sibl = tr.nextSibling; + while (sibl != null && sibl.nodeType != 1 && sibl.tagName != "TR") { + sibl = sibl.nextSibling; + } + if (sibl) { + var td_disp = "none"; + if (new_state) { + e.innerHTML = + '+'; + } else { + e.innerHTML = + '-'; + td_disp = "inline"; } - if (sibl) { - var td_disp = 'none'; - if (new_state) { - e.innerHTML = '+'; - } else { - e.innerHTML = '-'; - td_disp = 'inline'; - } - // acronymes d'UE - sibl = e.parentNode.nextSibling; - while (sibl != null) { - if (sibl.nodeType == 1 && sibl.className == 'ue_acro') - sibl.childNodes[0].style.display = td_disp; - sibl = sibl.nextSibling; - } + // acronymes d'UE + sibl = e.parentNode.nextSibling; + while (sibl != null) { + if (sibl.nodeType == 1 && sibl.className == "ue_acro") + sibl.childNodes[0].style.display = td_disp; + sibl = sibl.nextSibling; } + } } var sems_state = false; function toggle_all_sems(e) { - var elems = $("span.toggle_sem"); - for (var i=0; i < elems.length; i++) { - toggle_vis(elems[i], sems_state); - } - sems_state = !sems_state; - if (sems_state) { - e.innerHTML = '-'; - } else { - e.innerHTML = '+'; - } -} \ No newline at end of file + var elems = $("span.toggle_sem"); + for (var i = 0; i < elems.length; i++) { + toggle_vis(elems[i], sems_state); + } + sems_state = !sems_state; + if (sems_state) { + e.innerHTML = + '-'; + } else { + e.innerHTML = + '+'; + } +} diff --git a/app/static/js/ref_competences.js b/app/static/js/ref_competences.js index 32b9f9db34..38d2730b6e 100644 --- a/app/static/js/ref_competences.js +++ b/app/static/js/ref_competences.js @@ -1,106 +1,131 @@ - class ref_competences extends HTMLElement { - constructor() { - super(); - this.shadow = this.attachShadow({ mode: 'open' }); + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); - /* Template de base */ - this.shadow.innerHTML = ` + /* Template de base */ + this.shadow.innerHTML = `
    Cliquer sur un parcours pour afficher ses niveaux de compétences
    `; - /* Style du module */ - const styles = document.createElement('link'); - styles.setAttribute('rel', 'stylesheet'); - styles.setAttribute('href', removeLastTwoComponents(getCurrentScriptPath()) + '/css/ref-competences.css'); + /* Style du module */ + const styles = document.createElement("link"); + styles.setAttribute("rel", "stylesheet"); + styles.setAttribute( + "href", + removeLastTwoComponents(getCurrentScriptPath()) + + "/css/ref-competences.css" + ); - this.shadow.appendChild(styles); - } + this.shadow.appendChild(styles); + } - set setData(data) { - this.data = data; - this.parcours(); - } + set setData(data) { + this.data = data; + this.parcours(); + } - parcours() { - let parcoursDIV = this.shadow.querySelector(".parcours"); - Object.entries(this.data.parcours).forEach(([cle, parcours]) => { - let div = document.createElement("div"); - div.innerHTML = `${parcours.code}`; - div.addEventListener("click", (event) => { this.competences(event, cle) }) - parcoursDIV.appendChild(div); - }) - this.initCompetences(); - } + parcours() { + let parcoursDIV = this.shadow.querySelector(".parcours"); + Object.entries(this.data.parcours).forEach(([cle, parcours]) => { + let div = document.createElement("div"); + div.innerHTML = `${parcours.code}`; + div.addEventListener("click", (event) => { + this.competences(event, cle); + }); + parcoursDIV.appendChild(div); + }); + this.initCompetences(); + } - initCompetences() { - this.competencesNumber = {}; - let i = 0; - Object.keys(this.data.competences).forEach(competence => { - this.competencesNumber[competence] = 1 + i++ % 6; - }) - } + initCompetences() { + this.competencesNumber = {}; + let i = 0; + Object.keys(this.data.competences).forEach((competence) => { + this.competencesNumber[competence] = 1 + (i++ % 6); + }); + } - competences(event, cle) { - this.shadow.querySelector(".parcours>.focus")?.classList.remove("focus"); - event.currentTarget.classList.add("focus"); - let divCompetences = this.shadow.querySelector(".competences"); + competences(event, cle) { + this.shadow.querySelector(".parcours>.focus")?.classList.remove("focus"); + event.currentTarget.classList.add("focus"); + let divCompetences = this.shadow.querySelector(".competences"); - this.shadow.querySelector(".competences").innerHTML = ""; + this.shadow.querySelector(".competences").innerHTML = ""; - /* Création des compétences */ - let competencesBucket = []; - Object.entries(this.data.parcours[cle].annees).forEach(([annee, dataAnnee]) => { - Object.entries(dataAnnee.competences).forEach(([competence, niveauCle]) => { - let numComp = this.competencesNumber[competence]; - let divCompetence = document.createElement("div"); - divCompetence.innerText = `${competence} ${niveauCle.niveau}`; - divCompetence.style.gridRowStart = annee; - divCompetence.style.gridColumnStart = competence.replaceAll(" ", "_"); - divCompetence.className = "comp" + numComp; - divCompetence.dataset.competence = `${competence} ${niveauCle.niveau}`; - divCompetence.addEventListener("click", (event) => { this.AC(event, competence, niveauCle.niveau, annee, numComp) }) - divCompetences.appendChild(divCompetence); + /* Création des compétences */ + let competencesBucket = []; + Object.entries(this.data.parcours[cle].annees).forEach( + ([annee, dataAnnee]) => { + Object.entries(dataAnnee.competences).forEach( + ([competence, niveauCle]) => { + let numComp = this.competencesNumber[competence]; + let divCompetence = document.createElement("div"); + divCompetence.innerText = `${competence} ${niveauCle.niveau}`; + divCompetence.style.gridRowStart = annee; + divCompetence.style.gridColumnStart = competence.replaceAll( + " ", + "_" + ); + divCompetence.className = "comp" + numComp; + divCompetence.dataset.competence = `${competence} ${niveauCle.niveau}`; + divCompetence.addEventListener("click", (event) => { + this.AC(event, competence, niveauCle.niveau, annee, numComp); + }); + divCompetences.appendChild(divCompetence); - competencesBucket.push(competence); - }) - }) + competencesBucket.push(competence); + } + ); + } + ); - /* Affectation de la taille des éléments */ - //divCompetences.style.setProperty("--competence-size", `calc(${100 / competencesBucket.length}% )`); - let gridTemplate = ""; - Object.keys(this.data.competences).forEach(competence => { - if (competencesBucket.indexOf(competence) == -1) { - gridTemplate += `[${competence.replaceAll(" ", "_")}] 0`; - } else { - gridTemplate += `[${competence.replaceAll(" ", "_")}] 1fr`; - } - }) - this.shadow.querySelector(".competences").style.gridTemplateColumns = gridTemplate; + /* Affectation de la taille des éléments */ + //divCompetences.style.setProperty("--competence-size", `calc(${100 / competencesBucket.length}% )`); + let gridTemplate = ""; + Object.keys(this.data.competences).forEach((competence) => { + if (competencesBucket.indexOf(competence) == -1) { + gridTemplate += `[${competence.replaceAll(" ", "_")}] 0`; + } else { + gridTemplate += `[${competence.replaceAll(" ", "_")}] 1fr`; + } + }); + this.shadow.querySelector(".competences").style.gridTemplateColumns = + gridTemplate; - /* Réaffectation des focus */ - this.shadow.querySelectorAll(".AC").forEach(ac => { - this.shadow.querySelector(`[data-competence="${ac.dataset.competence}"]`).classList.add("focus"); - }); - } + /* Réaffectation des focus */ + this.shadow.querySelectorAll(".AC").forEach((ac) => { + this.shadow + .querySelector(`[data-competence="${ac.dataset.competence}"]`) + .classList.add("focus"); + }); + } - AC(event, competence, niveau, annee, numComp) { - event.currentTarget.classList.toggle("focus"); - if (this.shadow.querySelector(`.ACs [data-competence="${competence} ${niveau}"]`)) { - this.shadow.querySelector(`.ACs [data-competence="${competence} ${niveau}"]`).remove(); - } else { - let output = ` + AC(event, competence, niveau, annee, numComp) { + event.currentTarget.classList.toggle("focus"); + if ( + this.shadow.querySelector( + `.ACs [data-competence="${competence} ${niveau}"]` + ) + ) { + this.shadow + .querySelector(`.ACs [data-competence="${competence} ${niveau}"]`) + .remove(); + } else { + let output = `

      ${competence} ${niveau}

      `; - Object.entries(this.data.competences[competence].niveaux["BUT" + annee].app_critiques).forEach(([num, contenu]) => { - output += `
    • ${num}
      ${contenu.libelle}
    • `; - }) - this.shadow.querySelector(".ACs").innerHTML += output + "
    "; - } - } + Object.entries( + this.data.competences[competence].niveaux["BUT" + annee].app_critiques + ).forEach(([num, contenu]) => { + output += `
  • ${num}
    ${contenu.libelle}
  • `; + }); + this.shadow.querySelector(".ACs").innerHTML += output + ""; + } + } } -customElements.define('ref-competences', ref_competences); \ No newline at end of file +customElements.define("ref-competences", ref_competences); diff --git a/app/static/js/releve-but.js b/app/static/js/releve-but.js index e011d395db..d76ec53599 100644 --- a/app/static/js/releve-but.js +++ b/app/static/js/releve-but.js @@ -74,10 +74,10 @@ class releveBUT extends HTMLElement { template() { return ` -
    +
    - + @@ -99,7 +99,7 @@ class releveBUT extends HTMLElement { Les moyennes ci-dessus servent à situer l'étudiant dans la promotion et ne correspondent pas à des validations de compétences ou d'UE.
    - + @@ -181,7 +181,7 @@ class releveBUT extends HTMLElement { output += `
    - Numéro étudiant : ${data.etudiant.code_nip || "~"} - + Numéro étudiant : ${data.etudiant.code_nip || "~"} - Code INE : ${data.etudiant.code_ine || "~"}
    ${data.formation.titre}
    @@ -376,12 +376,14 @@ class releveBUT extends HTMLElement { }
    `; if (!dataUE.date_capitalisation) { - output += ` Bonus : ${dataUE.bonus || 0} - `; - if(dataUE.malus >= 0) { - output += `Malus : ${dataUE.malus || 0}`; - } else { - output += `Bonus complémentaire : ${-dataUE.malus || 0}`; - } + output += ` Bonus : ${dataUE.bonus || 0} - `; + if (dataUE.malus >= 0) { + output += `Malus : ${dataUE.malus || 0}`; + } else { + output += `Bonus complémentaire : ${ + -dataUE.malus || 0 + }`; + } } else { output += ` le ${this.ISOToDate( dataUE.date_capitalisation.split("T")[0] @@ -468,9 +470,9 @@ class releveBUT extends HTMLElement { content.moyenne.value }
    - Classe : ${content.moyenne.moy} - + Classe : ${content.moyenne.moy} - Max : ${content.moyenne.max} - - Min : ${content.moyenne.min} + Min : ${content.moyenne.min}
    diff --git a/app/static/js/sco_ue_external.js b/app/static/js/sco_ue_external.js index fb57807f45..e63815550e 100644 --- a/app/static/js/sco_ue_external.js +++ b/app/static/js/sco_ue_external.js @@ -1,33 +1,28 @@ // Gestion formulaire UE externes function toggle_new_ue_form(state) { - // active/desactive le formulaire "nouvelle UE" - var text_color; - if (state) { - text_color = 'rgb(180,160,160)'; - } else { - text_color = 'rgb(0,0,0)'; - } + // active/desactive le formulaire "nouvelle UE" + var text_color; + if (state) { + text_color = "rgb(180,160,160)"; + } else { + text_color = "rgb(0,0,0)"; + } - $("#tf_extue_titre td:eq(1) input").prop("disabled", state); - $("#tf_extue_titre").css('color', text_color) + $("#tf_extue_titre td:eq(1) input").prop("disabled", state); + $("#tf_extue_titre").css("color", text_color); - $("#tf_extue_acronyme td:eq(1) input").prop("disabled", state); - $("#tf_extue_acronyme").css('color', text_color) + $("#tf_extue_acronyme td:eq(1) input").prop("disabled", state); + $("#tf_extue_acronyme").css("color", text_color); - $("#tf_extue_type td:eq(1) select").prop("disabled", state); - $("#tf_extue_type").css('color', text_color) + $("#tf_extue_type td:eq(1) select").prop("disabled", state); + $("#tf_extue_type").css("color", text_color); - $("#tf_extue_ects td:eq(1) input").prop("disabled", state); - $("#tf_extue_ects").css('color', text_color) + $("#tf_extue_ects td:eq(1) input").prop("disabled", state); + $("#tf_extue_ects").css("color", text_color); } - function update_external_ue_form() { - var state = (tf.existing_ue.value != ""); - toggle_new_ue_form(state); + var state = tf.existing_ue.value != ""; + toggle_new_ue_form(state); } - - - - diff --git a/app/static/js/scolar_index.js b/app/static/js/scolar_index.js index 90c20b45f8..9b75eb0df0 100644 --- a/app/static/js/scolar_index.js +++ b/app/static/js/scolar_index.js @@ -4,30 +4,35 @@ var elt_annee_apo_editor = null; var elt_sem_apo_editor = null; $(document).ready(function () { - var table_options = { - "paging": false, - "searching": false, - "info": false, - /* "autoWidth" : false, */ - "fixedHeader": { - "header": true, - "footer": true - }, - "orderCellsTop": true, // cellules ligne 1 pour tri - "aaSorting": [], // Prevent initial sorting - }; - $('table.semlist').DataTable(table_options); - let table_editable = document.querySelector("table#semlist.apo_editable"); - if (table_editable) { - let save_url = document.querySelector("table#semlist.apo_editable").dataset.apo_save_url; - apo_editor = new ScoFieldEditor(".etapes_apo_str", save_url, false); + var table_options = { + paging: false, + searching: false, + info: false, + /* "autoWidth" : false, */ + fixedHeader: { + header: true, + footer: true, + }, + orderCellsTop: true, // cellules ligne 1 pour tri + aaSorting: [], // Prevent initial sorting + }; + $("table.semlist").DataTable(table_options); + let table_editable = document.querySelector("table#semlist.apo_editable"); + if (table_editable) { + let save_url = document.querySelector("table#semlist.apo_editable").dataset + .apo_save_url; + apo_editor = new ScoFieldEditor(".etapes_apo_str", save_url, false); - save_url = document.querySelector("table#semlist.apo_editable").dataset.elt_annee_apo_save_url; - elt_annee_apo_editor = new ScoFieldEditor(".elt_annee_apo", save_url, false); + save_url = document.querySelector("table#semlist.apo_editable").dataset + .elt_annee_apo_save_url; + elt_annee_apo_editor = new ScoFieldEditor( + ".elt_annee_apo", + save_url, + false + ); - save_url = document.querySelector("table#semlist.apo_editable").dataset.elt_sem_apo_save_url; - elt_sem_apo_editor = new ScoFieldEditor(".elt_sem_apo", save_url, false); - } + save_url = document.querySelector("table#semlist.apo_editable").dataset + .elt_sem_apo_save_url; + elt_sem_apo_editor = new ScoFieldEditor(".elt_sem_apo", save_url, false); + } }); - - diff --git a/app/static/js/table_editor.js b/app/static/js/table_editor.js index 603e0792bf..18b4bd6865 100644 --- a/app/static/js/table_editor.js +++ b/app/static/js/table_editor.js @@ -8,14 +8,14 @@ let lastX; let lastY; function build_table(data) { - let output = ""; - let sumsUE = {}; - let sumsRessources = {}; - let value; + let output = ""; + let sumsUE = {}; + let sumsRessources = {}; + let value; - data.forEach((cellule) => { - output += ` -
    { + output += ` +
    ${cellule.data}
    `; // ne pas mettre d'espace car c'est utilisé par :not(:empty) après - if (cellule.style.includes("champs")) { - if (cellule.editable == true && cellule.data) { - value = parseFloat(cellule.data) * 100; - } else { - value = 0; - } - sumsRessources[cellule.y] = (sumsRessources[cellule.y] ?? 0) + value; - sumsUE[cellule.x] = (sumsUE[cellule.x] ?? 0) + value; - } - }) + if (cellule.style.includes("champs")) { + if (cellule.editable == true && cellule.data) { + value = parseFloat(cellule.data) * 100; + } else { + value = 0; + } + sumsRessources[cellule.y] = (sumsRessources[cellule.y] ?? 0) + value; + sumsUE[cellule.x] = (sumsUE[cellule.x] ?? 0) + value; + } + }); - output += showSums(sumsRessources, sumsUE); - document.querySelector(".tableau").innerHTML = output; - installListeners(); + output += showSums(sumsRessources, sumsUE); + document.querySelector(".tableau").innerHTML = output; + installListeners(); } function showSums(sumsRessources, sumsUE) { - lastX = Object.keys(sumsUE).length + 2; - lastY = Object.keys(sumsRessources).length + 2; + lastX = Object.keys(sumsUE).length + 2; + lastY = Object.keys(sumsRessources).length + 2; - let output = ""; + let output = ""; - Object.entries(sumsUE).forEach(([num, value]) => { - output += ` -
    { + output += ` +
    ${value / 100}
    `; - }) + }); - Object.entries(sumsRessources).forEach(([num, value]) => { - output += ` -
    { + output += ` +
    ${value / 100}
    `; - }) + }); - return output; + return output; } /*****************************/ @@ -98,125 +98,158 @@ function showSums(sumsRessources, sumsUE) { /*****************************/ function installListeners() { - if (read_only) { - return; - } - document.body.addEventListener("keydown", key); - document.querySelectorAll("[data-editable=true]").forEach(cellule => { - cellule.addEventListener("click", function () { selectCell(this) }); - cellule.addEventListener("dblclick", function () { modifCell(this) }); - cellule.addEventListener("blur", function () { - let currentModif = document.querySelector(".modifying"); - if (currentModif) { - if (!save(currentModif)) { - return; - } - } - }); - cellule.addEventListener("input", processSums); + if (read_only) { + return; + } + document.body.addEventListener("keydown", key); + document.querySelectorAll("[data-editable=true]").forEach((cellule) => { + cellule.addEventListener("click", function () { + selectCell(this); }); + cellule.addEventListener("dblclick", function () { + modifCell(this); + }); + cellule.addEventListener("blur", function () { + let currentModif = document.querySelector(".modifying"); + if (currentModif) { + if (!save(currentModif)) { + return; + } + } + }); + cellule.addEventListener("input", processSums); + }); } - /*********************************/ /* Interaction avec les cellules */ /*********************************/ function selectCell(obj) { - if (obj.classList.contains("modifying")) { - return; // Cellule en cours de modification, ne pas sélectionner. - } - let currentModif = document.querySelector(".modifying"); - if (currentModif) { - if (!save(currentModif)) { - return; - } + if (obj.classList.contains("modifying")) { + return; // Cellule en cours de modification, ne pas sélectionner. + } + let currentModif = document.querySelector(".modifying"); + if (currentModif) { + if (!save(currentModif)) { + return; } + } - document.querySelectorAll(".selected, .modifying").forEach(cellule => { - cellule.classList.remove("selected", "modifying"); - cellule.removeAttribute("contentEditable"); - cellule.removeEventListener("keydown", keyCell); - }) - obj.classList.add("selected"); + document.querySelectorAll(".selected, .modifying").forEach((cellule) => { + cellule.classList.remove("selected", "modifying"); + cellule.removeAttribute("contentEditable"); + cellule.removeEventListener("keydown", keyCell); + }); + obj.classList.add("selected"); } function modifCell(obj) { - if (obj) { - obj.classList.add("modifying"); - obj.contentEditable = true; - obj.addEventListener("keydown", keyCell); - obj.focus(); - } + if (obj) { + obj.classList.add("modifying"); + obj.contentEditable = true; + obj.addEventListener("keydown", keyCell); + obj.focus(); + } } function key(event) { - switch (event.key) { - case "Enter": modifCell(document.querySelector(".selected")); event.preventDefault(); break; - case "ArrowRight": ArrowMove(1, 0); break; - case "ArrowLeft": ArrowMove(-1, 0); break; - case "ArrowUp": ArrowMove(0, -1); break; - case "ArrowDown": ArrowMove(0, 1); break; - } + switch (event.key) { + case "Enter": + modifCell(document.querySelector(".selected")); + event.preventDefault(); + break; + case "ArrowRight": + ArrowMove(1, 0); + break; + case "ArrowLeft": + ArrowMove(-1, 0); + break; + case "ArrowUp": + ArrowMove(0, -1); + break; + case "ArrowDown": + ArrowMove(0, 1); + break; + } } function ArrowMove(x, y) { - if (document.querySelector(".modifying") || !document.querySelector(".selected")) { - return; // S'il n'y a aucune cellule selectionnée ou si une cellule est encours de modification, on ne change pas - } + if ( + document.querySelector(".modifying") || + !document.querySelector(".selected") + ) { + return; // S'il n'y a aucune cellule selectionnée ou si une cellule est encours de modification, on ne change pas + } - let selected = document.querySelector(".selected"); - let next = document.querySelector(`[data-x="${parseInt(selected.dataset.x) + x}"][data-y="${parseInt(selected.dataset.y) + y}"][data-editable="true"]`); + let selected = document.querySelector(".selected"); + let next = document.querySelector( + `[data-x="${parseInt(selected.dataset.x) + x}"][data-y="${ + parseInt(selected.dataset.y) + y + }"][data-editable="true"]` + ); - if (next) { - selectCell(next); - } + if (next) { + selectCell(next); + } } function keyCell(event) { - if (event.key == "Enter") { - event.preventDefault(); - event.stopPropagation(); - if (!save(this)) { - return - } - this.classList.remove("modifying"); - let selected = document.querySelector(".selected"); - ArrowMove(0, 1); - if (selected != document.querySelector(".selected")) { - modifCell(document.querySelector(".selected")); - } + if (event.key == "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (!save(this)) { + return; } + this.classList.remove("modifying"); + let selected = document.querySelector(".selected"); + ArrowMove(0, 1); + if (selected != document.querySelector(".selected")) { + modifCell(document.querySelector(".selected")); + } + } } function processSums() { - let sum = 0; - document.querySelectorAll(`[data-editable="true"][data-x="${this.dataset.x}"]:not(:empty)`).forEach(e => { - let val = parseFloat(e.innerText); - if (!isNaN(val)) { - sum += val * 100; - } - }) - document.querySelector(`.sums[data-x="${this.dataset.x}"][data-y="${lastY}"]`).innerText = sum / 100; + let sum = 0; + document + .querySelectorAll( + `[data-editable="true"][data-x="${this.dataset.x}"]:not(:empty)` + ) + .forEach((e) => { + let val = parseFloat(e.innerText); + if (!isNaN(val)) { + sum += val * 100; + } + }); + document.querySelector( + `.sums[data-x="${this.dataset.x}"][data-y="${lastY}"]` + ).innerText = sum / 100; - sum = 0; - document.querySelectorAll(`[data-editable="true"][data-y="${this.dataset.y}"]:not(:empty)`).forEach(e => { - let val = parseFloat(e.innerText); - if (!isNaN(val)) { - sum += val * 100; - } - }) - document.querySelector(`.sums[data-x="${lastX}"][data-y="${this.dataset.y}"]`).innerText = sum / 100; + sum = 0; + document + .querySelectorAll( + `[data-editable="true"][data-y="${this.dataset.y}"]:not(:empty)` + ) + .forEach((e) => { + let val = parseFloat(e.innerText); + if (!isNaN(val)) { + sum += val * 100; + } + }); + document.querySelector( + `.sums[data-x="${lastX}"][data-y="${this.dataset.y}"]` + ).innerText = sum / 100; } /******************************/ /* Affichage d'un message */ /******************************/ function message(msg) { - var div = document.createElement("div"); - div.className = "message"; - div.innerHTML = msg; - document.querySelector("body").appendChild(div); - setTimeout(() => { - div.remove(); - }, 3000); + var div = document.createElement("div"); + div.className = "message"; + div.innerHTML = msg; + document.querySelector("body").appendChild(div); + setTimeout(() => { + div.remove(); + }, 3000); } diff --git a/app/static/js/trombino.js b/app/static/js/trombino.js index c6a560bc75..f6bf989dbb 100644 --- a/app/static/js/trombino.js +++ b/app/static/js/trombino.js @@ -1,11 +1,10 @@ // Affichage progressif du trombinoscope html $().ready(function () { - var spans = $(".unloaded_img"); - for (var i = 0; i < spans.size(); i++) { - var sp = spans[i]; - var etudid = sp.id; - $(sp).load(SCO_URL + "/etud_photo_html?etudid=" + etudid); - } + var spans = $(".unloaded_img"); + for (var i = 0; i < spans.size(); i++) { + var sp = spans[i]; + var etudid = sp.id; + $(sp).load(SCO_URL + "/etud_photo_html?etudid=" + etudid); + } }); - diff --git a/app/static/js/ue_list.js b/app/static/js/ue_list.js index f6e9ea7578..35e79ac61e 100644 --- a/app/static/js/ue_list.js +++ b/app/static/js/ue_list.js @@ -1,5 +1,5 @@ // Edition elements programme "en place" -$(function() { - $('.span_apo_edit').jinplace(); -}); \ No newline at end of file +$(function () { + $(".span_apo_edit").jinplace(); +}); diff --git a/app/static/js/user_form.js b/app/static/js/user_form.js index 4ad06672a3..c926179031 100644 --- a/app/static/js/user_form.js +++ b/app/static/js/user_form.js @@ -1,30 +1,30 @@ - function refresh() { - if ($("input[name='welcome:list']").is(":checked")) { - $("input[name='reset_password:list']").closest("tr").css("display", "table-row") - if ($("input[name='reset_password:list']").is(":checked")) { - $("#tf_password").closest('tr').css("display", "none"); - $("#tf_password2").closest('tr').css("display", "none"); - } else { - // Le mot de passe doit être saisi - $("#tf_password").closest('tr').css("display", "table-row"); - $("#tf_password2").closest('tr').css("display", "table-row"); - } + if ($("input[name='welcome:list']").is(":checked")) { + $("input[name='reset_password:list']") + .closest("tr") + .css("display", "table-row"); + if ($("input[name='reset_password:list']").is(":checked")) { + $("#tf_password").closest("tr").css("display", "none"); + $("#tf_password2").closest("tr").css("display", "none"); } else { - // Le mot de passe doit être saisi - $("input[name='reset_password:list']").closest("tr").css("display", "none") - $("#tf_password").closest('tr').css("display", "table-row"); - $("#tf_password2").closest('tr').css("display", "table-row"); + // Le mot de passe doit être saisi + $("#tf_password").closest("tr").css("display", "table-row"); + $("#tf_password2").closest("tr").css("display", "table-row"); } + } else { + // Le mot de passe doit être saisi + $("input[name='reset_password:list']").closest("tr").css("display", "none"); + $("#tf_password").closest("tr").css("display", "table-row"); + $("#tf_password2").closest("tr").css("display", "table-row"); + } } $(function () { - $("input[name='welcome:list']").click(function () { - refresh(); - }) - $("input[name='reset_password:list']").click(function () { - refresh(); - }) + $("input[name='welcome:list']").click(function () { refresh(); -}) - + }); + $("input[name='reset_password:list']").click(function () { + refresh(); + }); + refresh(); +}); diff --git a/app/static/libjs/bootstrap b/app/static/libjs/bootstrap new file mode 120000 index 0000000000..a45433c65e --- /dev/null +++ b/app/static/libjs/bootstrap @@ -0,0 +1 @@ +bootstrap-3.3.7-dist \ No newline at end of file diff --git a/app/static/libjs/bootstrap-3.1.1-dist/._css b/app/static/libjs/bootstrap-3.1.1-dist/._css deleted file mode 100755 index 9f1360166b..0000000000 Binary files a/app/static/libjs/bootstrap-3.1.1-dist/._css and /dev/null differ diff --git a/app/static/libjs/bootstrap-3.1.1-dist/._fonts b/app/static/libjs/bootstrap-3.1.1-dist/._fonts deleted file mode 100755 index 8884c95fa8..0000000000 Binary files a/app/static/libjs/bootstrap-3.1.1-dist/._fonts and /dev/null differ diff --git a/app/static/libjs/bootstrap-3.1.1-dist/._js b/app/static/libjs/bootstrap-3.1.1-dist/._js deleted file mode 100755 index 32df7440c0..0000000000 Binary files a/app/static/libjs/bootstrap-3.1.1-dist/._js and /dev/null differ diff --git a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap-theme.css b/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap-theme.css deleted file mode 100644 index 5d2c244e57..0000000000 Binary files a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap-theme.css and /dev/null differ diff --git a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap-theme.css.map b/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap-theme.css.map deleted file mode 100644 index 8f3ee4d48a..0000000000 Binary files a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap-theme.css.map and /dev/null differ diff --git a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap-theme.min.css b/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap-theme.min.css deleted file mode 100644 index 7f7a855a23..0000000000 Binary files a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap-theme.min.css and /dev/null differ diff --git a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap.css b/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap.css deleted file mode 100644 index c0b1c0dfac..0000000000 Binary files a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap.css and /dev/null differ diff --git a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap.css.map b/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap.css.map deleted file mode 100644 index 491b8ba57c..0000000000 Binary files a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap.css.map and /dev/null differ diff --git a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap.min.css b/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap.min.css deleted file mode 100644 index 45b6abda4f..0000000000 Binary files a/app/static/libjs/bootstrap-3.1.1-dist/css/._bootstrap.min.css and /dev/null differ diff --git a/app/static/libjs/bootstrap-3.1.1-dist/css/bootstrap-theme.css.map b/app/static/libjs/bootstrap-3.1.1-dist/css/bootstrap-theme.css.map deleted file mode 100644 index b36fc9a497..0000000000 --- a/app/static/libjs/bootstrap-3.1.1-dist/css/bootstrap-theme.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["less/theme.less","less/mixins.less"],"names":[],"mappings":"AAeA;AACA;AACA;AACA;AACA;AACA;EACE,wCAAA;ECoGA,2FAAA;EACQ,mFAAA;;ADhGR,YAAC;AAAD,YAAC;AAAD,YAAC;AAAD,SAAC;AAAD,YAAC;AAAD,WAAC;AACD,YAAC;AAAD,YAAC;AAAD,YAAC;AAAD,SAAC;AAAD,YAAC;AAAD,WAAC;EC8FD,wDAAA;EACQ,gDAAA;;ADnER,IAAC;AACD,IAAC;EACC,sBAAA;;AAKJ;EC4PI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EAEA,sHAAA;EAoCF,mEAAA;ED7TA,2BAAA;EACA,qBAAA;EAyB2C,yBAAA;EAA2B,kBAAA;;AAvBtE,YAAC;AACD,YAAC;EACC,yBAAA;EACA,4BAAA;;AAGF,YAAC;AACD,YAAC;EACC,yBAAA;EACA,qBAAA;;AAeJ;EC2PI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EAEA,sHAAA;EAoCF,mEAAA;ED7TA,2BAAA;EACA,qBAAA;;AAEA,YAAC;AACD,YAAC;EACC,yBAAA;EACA,4BAAA;;AAGF,YAAC;AACD,YAAC;EACC,yBAAA;EACA,qBAAA;;AAgBJ;EC0PI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EAEA,sHAAA;EAoCF,mEAAA;ED7TA,2BAAA;EACA,qBAAA;;AAEA,YAAC;AACD,YAAC;EACC,yBAAA;EACA,4BAAA;;AAGF,YAAC;AACD,YAAC;EACC,yBAAA;EACA,qBAAA;;AAiBJ;ECyPI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EAEA,sHAAA;EAoCF,mEAAA;ED7TA,2BAAA;EACA,qBAAA;;AAEA,SAAC;AACD,SAAC;EACC,yBAAA;EACA,4BAAA;;AAGF,SAAC;AACD,SAAC;EACC,yBAAA;EACA,qBAAA;;AAkBJ;ECwPI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EAEA,sHAAA;EAoCF,mEAAA;ED7TA,2BAAA;EACA,qBAAA;;AAEA,YAAC;AACD,YAAC;EACC,yBAAA;EACA,4BAAA;;AAGF,YAAC;AACD,YAAC;EACC,yBAAA;EACA,qBAAA;;AAmBJ;ECuPI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EAEA,sHAAA;EAoCF,mEAAA;ED7TA,2BAAA;EACA,qBAAA;;AAEA,WAAC;AACD,WAAC;EACC,yBAAA;EACA,4BAAA;;AAGF,WAAC;AACD,WAAC;EACC,yBAAA;EACA,qBAAA;;AA2BJ;AACA;EC6CE,kDAAA;EACQ,0CAAA;;ADpCV,cAAe,KAAK,IAAG;AACvB,cAAe,KAAK,IAAG;ECmOnB,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;EDpOF,yBAAA;;AAEF,cAAe,UAAU;AACzB,cAAe,UAAU,IAAG;AAC5B,cAAe,UAAU,IAAG;EC6NxB,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;ED9NF,yBAAA;;AAUF;ECiNI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;EAoCF,mEAAA;EDrPA,kBAAA;ECaA,2FAAA;EACQ,mFAAA;;ADjBV,eAOE,YAAY,UAAU;EC0MpB,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;EApMF,wDAAA;EACQ,gDAAA;;ADLV;AACA,WAAY,KAAK;EACf,8CAAA;;AAIF;EC+LI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;EAoCF,mEAAA;;ADtOF,eAIE,YAAY,UAAU;EC2LpB,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;EApMF,uDAAA;EACQ,+CAAA;;ADCV,eASE;AATF,eAUE,YAAY,KAAK;EACf,yCAAA;;AAKJ;AACA;AACA;EACE,gBAAA;;AAUF;EACE,6CAAA;EChCA,0FAAA;EACQ,kFAAA;;AD2CV;ECqJI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;ED5JF,qBAAA;;AAKF;ECoJI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;ED5JF,qBAAA;;AAMF;ECmJI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;ED5JF,qBAAA;;AAOF;ECkJI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;ED5JF,qBAAA;;AAgBF;ECyII,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;ADlIJ;EC+HI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;ADjIJ;EC8HI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;ADhIJ;EC6HI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;AD/HJ;EC4HI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;AD9HJ;EC2HI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;ADtHJ;EACE,kBAAA;EC/EA,kDAAA;EACQ,0CAAA;;ADiFV,gBAAgB;AAChB,gBAAgB,OAAO;AACvB,gBAAgB,OAAO;EACrB,6BAAA;EC4GE,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;ED7GF,qBAAA;;AAUF;ECjGE,iDAAA;EACQ,yCAAA;;AD0GV,cAAe;ECsFX,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;ADxFJ,cAAe;ECqFX,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;ADvFJ,cAAe;ECoFX,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;ADtFJ,WAAY;ECmFR,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;ADrFJ,cAAe;ECkFX,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;ADpFJ,aAAc;ECiFV,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;;AD5EJ;ECyEI,kBAAkB,sDAAlB;EACA,kBAAkB,oDAAlB;EACA,2BAAA;EACA,sHAAA;ED1EF,qBAAA;EC1HA,yFAAA;EACQ,iFAAA","sourcesContent":["\n//\n// Load core variables and mixins\n// --------------------------------------------------\n\n@import \"variables.less\";\n@import \"mixins.less\";\n\n\n\n//\n// Buttons\n// --------------------------------------------------\n\n// Common styles\n.btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0,0,0,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n // Reset the shadow\n &:active,\n &.active {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n}\n\n// Mixin for generating new styles\n.btn-styles(@btn-color: #555) {\n #gradient > .vertical(@start-color: @btn-color; @end-color: darken(@btn-color, 12%));\n .reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners\n background-repeat: repeat-x;\n border-color: darken(@btn-color, 14%);\n\n &:hover,\n &:focus {\n background-color: darken(@btn-color, 12%);\n background-position: 0 -15px;\n }\n\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n border-color: darken(@btn-color, 14%);\n }\n}\n\n// Common styles\n.btn {\n // Remove the gradient for the pressed/active state\n &:active,\n &.active {\n background-image: none;\n }\n}\n\n// Apply the mixin to the buttons\n.btn-default { .btn-styles(@btn-default-bg); text-shadow: 0 1px 0 #fff; border-color: #ccc; }\n.btn-primary { .btn-styles(@btn-primary-bg); }\n.btn-success { .btn-styles(@btn-success-bg); }\n.btn-info { .btn-styles(@btn-info-bg); }\n.btn-warning { .btn-styles(@btn-warning-bg); }\n.btn-danger { .btn-styles(@btn-danger-bg); }\n\n\n\n//\n// Images\n// --------------------------------------------------\n\n.thumbnail,\n.img-thumbnail {\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n\n\n\n//\n// Dropdowns\n// --------------------------------------------------\n\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-hover-bg; @end-color: darken(@dropdown-link-hover-bg, 5%));\n background-color: darken(@dropdown-link-hover-bg, 5%);\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n background-color: darken(@dropdown-link-active-bg, 5%);\n}\n\n\n\n//\n// Navbar\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n #gradient > .vertical(@start-color: lighten(@navbar-default-bg, 10%); @end-color: @navbar-default-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered\n border-radius: @navbar-border-radius;\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 5px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: darken(@navbar-default-bg, 5%); @end-color: darken(@navbar-default-bg, 2%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.075));\n }\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255,255,255,.25);\n}\n\n// Inverted navbar\n.navbar-inverse {\n #gradient > .vertical(@start-color: lighten(@navbar-inverse-bg, 10%); @end-color: @navbar-inverse-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered\n\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: @navbar-inverse-bg; @end-color: lighten(@navbar-inverse-bg, 2.5%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.25));\n }\n\n .navbar-brand,\n .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0,0,0,.25);\n }\n}\n\n// Undo rounded corners in static and fixed navbars\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n\n\n\n//\n// Alerts\n// --------------------------------------------------\n\n// Common styles\n.alert {\n text-shadow: 0 1px 0 rgba(255,255,255,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 2px rgba(0,0,0,.05);\n .box-shadow(@shadow);\n}\n\n// Mixin for generating new styles\n.alert-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 7.5%));\n border-color: darken(@color, 15%);\n}\n\n// Apply the mixin to the alerts\n.alert-success { .alert-styles(@alert-success-bg); }\n.alert-info { .alert-styles(@alert-info-bg); }\n.alert-warning { .alert-styles(@alert-warning-bg); }\n.alert-danger { .alert-styles(@alert-danger-bg); }\n\n\n\n//\n// Progress bars\n// --------------------------------------------------\n\n// Give the progress background some depth\n.progress {\n #gradient > .vertical(@start-color: darken(@progress-bg, 4%); @end-color: @progress-bg)\n}\n\n// Mixin for generating new styles\n.progress-bar-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 10%));\n}\n\n// Apply the mixin to the progress bars\n.progress-bar { .progress-bar-styles(@progress-bar-bg); }\n.progress-bar-success { .progress-bar-styles(@progress-bar-success-bg); }\n.progress-bar-info { .progress-bar-styles(@progress-bar-info-bg); }\n.progress-bar-warning { .progress-bar-styles(@progress-bar-warning-bg); }\n.progress-bar-danger { .progress-bar-styles(@progress-bar-danger-bg); }\n\n\n\n//\n// List groups\n// --------------------------------------------------\n\n.list-group {\n border-radius: @border-radius-base;\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 darken(@list-group-active-bg, 10%);\n #gradient > .vertical(@start-color: @list-group-active-bg; @end-color: darken(@list-group-active-bg, 7.5%));\n border-color: darken(@list-group-active-border, 7.5%);\n}\n\n\n\n//\n// Panels\n// --------------------------------------------------\n\n// Common styles\n.panel {\n .box-shadow(0 1px 2px rgba(0,0,0,.05));\n}\n\n// Mixin for generating new styles\n.panel-heading-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 5%));\n}\n\n// Apply the mixin to the panel headings only\n.panel-default > .panel-heading { .panel-heading-styles(@panel-default-heading-bg); }\n.panel-primary > .panel-heading { .panel-heading-styles(@panel-primary-heading-bg); }\n.panel-success > .panel-heading { .panel-heading-styles(@panel-success-heading-bg); }\n.panel-info > .panel-heading { .panel-heading-styles(@panel-info-heading-bg); }\n.panel-warning > .panel-heading { .panel-heading-styles(@panel-warning-heading-bg); }\n.panel-danger > .panel-heading { .panel-heading-styles(@panel-danger-heading-bg); }\n\n\n\n//\n// Wells\n// --------------------------------------------------\n\n.well {\n #gradient > .vertical(@start-color: darken(@well-bg, 5%); @end-color: @well-bg);\n border-color: darken(@well-bg, 10%);\n @shadow: inset 0 1px 3px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n}\n","//\n// Mixins\n// --------------------------------------------------\n\n\n// Utilities\n// -------------------------\n\n// Clearfix\n// Source: http://nicolasgallagher.com/micro-clearfix-hack/\n//\n// For modern browsers\n// 1. The space content is one way to avoid an Opera bug when the\n// contenteditable attribute is included anywhere else in the document.\n// Otherwise it causes space to appear at the top and bottom of elements\n// that are clearfixed.\n// 2. The use of `table` rather than `block` is only necessary if using\n// `:before` to contain the top-margins of child elements.\n.clearfix() {\n &:before,\n &:after {\n content: \" \"; // 1\n display: table; // 2\n }\n &:after {\n clear: both;\n }\n}\n\n// WebKit-style focus\n.tab-focus() {\n // Default\n outline: thin dotted;\n // WebKit\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n\n// Center-align a block level element\n.center-block() {\n display: block;\n margin-left: auto;\n margin-right: auto;\n}\n\n// Sizing shortcuts\n.size(@width; @height) {\n width: @width;\n height: @height;\n}\n.square(@size) {\n .size(@size; @size);\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n &::-moz-placeholder { color: @color; // Firefox\n opacity: 1; } // See https://github.com/twbs/bootstrap/pull/11526\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Text overflow\n// Requires inline-block or block for proper styling\n.text-overflow() {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n// CSS image replacement\n//\n// Heads up! v3 launched with with only `.hide-text()`, but per our pattern for\n// mixins being reused as classes with the same name, this doesn't hold up. As\n// of v3.0.1 we have added `.text-hide()` and deprecated `.hide-text()`. Note\n// that we cannot chain the mixins together in Less, so they are repeated.\n//\n// Source: https://github.com/h5bp/html5-boilerplate/commit/aa0396eae757\n\n// Deprecated as of v3.0.1 (will be removed in v4)\n.hide-text() {\n font: ~\"0/0\" a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n// New mixin to use as of v3.0.1\n.text-hide() {\n .hide-text();\n}\n\n\n\n// CSS3 PROPERTIES\n// --------------------------------------------------\n\n// Single side border-radius\n.border-top-radius(@radius) {\n border-top-right-radius: @radius;\n border-top-left-radius: @radius;\n}\n.border-right-radius(@radius) {\n border-bottom-right-radius: @radius;\n border-top-right-radius: @radius;\n}\n.border-bottom-radius(@radius) {\n border-bottom-right-radius: @radius;\n border-bottom-left-radius: @radius;\n}\n.border-left-radius(@radius) {\n border-bottom-left-radius: @radius;\n border-top-left-radius: @radius;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support the\n// standard `box-shadow` property.\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Transitions\n.transition(@transition) {\n -webkit-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n// Transformations\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n transform: rotate(@degrees);\n}\n.scale(@ratio; @ratio-y...) {\n -webkit-transform: scale(@ratio, @ratio-y);\n -ms-transform: scale(@ratio, @ratio-y); // IE9 only\n transform: scale(@ratio, @ratio-y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n transform: translate(@x, @y);\n}\n.skew(@x; @y) {\n -webkit-transform: skew(@x, @y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n transform: skew(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n.backface-visibility(@visibility){\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// User select\n// For selecting text on the page\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n\n// Resize anything\n.resizable(@direction) {\n resize: @direction; // Options: horizontal, vertical, both\n overflow: auto; // Safari fix\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Opacity\n.opacity(@opacity) {\n opacity: @opacity;\n // IE8 filter\n @opacity-ie: (@opacity * 100);\n filter: ~\"alpha(opacity=@{opacity-ie})\";\n}\n\n\n\n// GRADIENTS\n// --------------------------------------------------\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, color-stop(@start-color @start-percent), color-stop(@end-color @end-percent)); // Safari 5.1-6, Chrome 10+\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n\n// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n\n\n\n// Retina images\n//\n// Short retina mixin for setting background-image and -size\n\n.img-retina(@file-1x; @file-2x; @width-1x; @height-1x) {\n background-image: url(\"@{file-1x}\");\n\n @media\n only screen and (-webkit-min-device-pixel-ratio: 2),\n only screen and ( min--moz-device-pixel-ratio: 2),\n only screen and ( -o-min-device-pixel-ratio: 2/1),\n only screen and ( min-device-pixel-ratio: 2),\n only screen and ( min-resolution: 192dpi),\n only screen and ( min-resolution: 2dppx) {\n background-image: url(\"@{file-2x}\");\n background-size: @width-1x @height-1x;\n }\n}\n\n\n// Responsive image\n//\n// Keep images from scaling beyond the width of their parents.\n\n.img-responsive(@display: block) {\n display: @display;\n max-width: 100%; // Part 1: Set a maximum relative to the parent\n height: auto; // Part 2: Scale the height according to the width, otherwise you get stretching\n}\n\n\n// COMPONENT MIXINS\n// --------------------------------------------------\n\n// Horizontal dividers\n// -------------------------\n// Dividers (basically an hr) within dropdowns and nav lists\n.nav-divider(@color: #e5e5e5) {\n height: 1px;\n margin: ((@line-height-computed / 2) - 1) 0;\n overflow: hidden;\n background-color: @color;\n}\n\n// Panels\n// -------------------------\n.panel-variant(@border; @heading-text-color; @heading-bg-color; @heading-border) {\n border-color: @border;\n\n & > .panel-heading {\n color: @heading-text-color;\n background-color: @heading-bg-color;\n border-color: @heading-border;\n\n + .panel-collapse .panel-body {\n border-top-color: @border;\n }\n }\n & > .panel-footer {\n + .panel-collapse .panel-body {\n border-bottom-color: @border;\n }\n }\n}\n\n// Alerts\n// -------------------------\n.alert-variant(@background; @border; @text-color) {\n background-color: @background;\n border-color: @border;\n color: @text-color;\n\n hr {\n border-top-color: darken(@border, 5%);\n }\n .alert-link {\n color: darken(@text-color, 10%);\n }\n}\n\n// Tables\n// -------------------------\n.table-row-variant(@state; @background) {\n // Exact selectors below required to override `.table-striped` and prevent\n // inheritance to nested tables.\n .table > thead > tr,\n .table > tbody > tr,\n .table > tfoot > tr {\n > td.@{state},\n > th.@{state},\n &.@{state} > td,\n &.@{state} > th {\n background-color: @background;\n }\n }\n\n // Hover states for `.table-hover`\n // Note: this is not available for cells or rows within `thead` or `tfoot`.\n .table-hover > tbody > tr {\n > td.@{state}:hover,\n > th.@{state}:hover,\n &.@{state}:hover > td,\n &.@{state}:hover > th {\n background-color: darken(@background, 5%);\n }\n }\n}\n\n// List Groups\n// -------------------------\n.list-group-item-variant(@state; @background; @color) {\n .list-group-item-@{state} {\n color: @color;\n background-color: @background;\n\n a& {\n color: @color;\n\n .list-group-item-heading { color: inherit; }\n\n &:hover,\n &:focus {\n color: @color;\n background-color: darken(@background, 5%);\n }\n &.active,\n &.active:hover,\n &.active:focus {\n color: #fff;\n background-color: @color;\n border-color: @color;\n }\n }\n }\n}\n\n// Button variants\n// -------------------------\n// Easily pump out default styles, as well as :hover, :focus, :active,\n// and disabled options for all buttons\n.button-variant(@color; @background; @border) {\n color: @color;\n background-color: @background;\n border-color: @border;\n\n &:hover,\n &:focus,\n &:active,\n &.active,\n .open .dropdown-toggle& {\n color: @color;\n background-color: darken(@background, 8%);\n border-color: darken(@border, 12%);\n }\n &:active,\n &.active,\n .open .dropdown-toggle& {\n background-image: none;\n }\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n &,\n &:hover,\n &:focus,\n &:active,\n &.active {\n background-color: @background;\n border-color: @border;\n }\n }\n\n .badge {\n color: @background;\n background-color: @color;\n }\n}\n\n// Button sizes\n// -------------------------\n.button-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n border-radius: @border-radius;\n}\n\n// Pagination\n// -------------------------\n.pagination-size(@padding-vertical; @padding-horizontal; @font-size; @border-radius) {\n > li {\n > a,\n > span {\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n }\n &:first-child {\n > a,\n > span {\n .border-left-radius(@border-radius);\n }\n }\n &:last-child {\n > a,\n > span {\n .border-right-radius(@border-radius);\n }\n }\n }\n}\n\n// Labels\n// -------------------------\n.label-variant(@color) {\n background-color: @color;\n &[href] {\n &:hover,\n &:focus {\n background-color: darken(@color, 10%);\n }\n }\n}\n\n// Contextual backgrounds\n// -------------------------\n.bg-variant(@color) {\n background-color: @color;\n a&:hover {\n background-color: darken(@color, 10%);\n }\n}\n\n// Typography\n// -------------------------\n.text-emphasis-variant(@color) {\n color: @color;\n a&:hover {\n color: darken(@color, 10%);\n }\n}\n\n// Navbar vertical align\n// -------------------------\n// Vertically center elements in the navbar.\n// Example: an element has a height of 30px, so write out `.navbar-vertical-align(30px);` to calculate the appropriate top margin.\n.navbar-vertical-align(@element-height) {\n margin-top: ((@navbar-height - @element-height) / 2);\n margin-bottom: ((@navbar-height - @element-height) / 2);\n}\n\n// Progress bars\n// -------------------------\n.progress-bar-variant(@color) {\n background-color: @color;\n .progress-striped & {\n #gradient > .striped();\n }\n}\n\n// Responsive utilities\n// -------------------------\n// More easily include all the states for responsive-utilities.less.\n.responsive-visibility() {\n display: block !important;\n table& { display: table; }\n tr& { display: table-row !important; }\n th&,\n td& { display: table-cell !important; }\n}\n\n.responsive-invisibility() {\n display: none !important;\n}\n\n\n// Grid System\n// -----------\n\n// Centered container element\n.container-fixed() {\n margin-right: auto;\n margin-left: auto;\n padding-left: (@grid-gutter-width / 2);\n padding-right: (@grid-gutter-width / 2);\n &:extend(.clearfix all);\n}\n\n// Creates a wrapper for a series of columns\n.make-row(@gutter: @grid-gutter-width) {\n margin-left: (@gutter / -2);\n margin-right: (@gutter / -2);\n &:extend(.clearfix all);\n}\n\n// Generate the extra small columns\n.make-xs-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n float: left;\n width: percentage((@columns / @grid-columns));\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n}\n.make-xs-column-offset(@columns) {\n @media (min-width: @screen-xs-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-xs-column-push(@columns) {\n @media (min-width: @screen-xs-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-xs-column-pull(@columns) {\n @media (min-width: @screen-xs-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n\n// Generate the small columns\n.make-sm-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-sm-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-offset(@columns) {\n @media (min-width: @screen-sm-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-push(@columns) {\n @media (min-width: @screen-sm-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-pull(@columns) {\n @media (min-width: @screen-sm-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n\n// Generate the medium columns\n.make-md-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-md-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-offset(@columns) {\n @media (min-width: @screen-md-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-push(@columns) {\n @media (min-width: @screen-md-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-pull(@columns) {\n @media (min-width: @screen-md-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n\n// Generate the large columns\n.make-lg-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-lg-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-offset(@columns) {\n @media (min-width: @screen-lg-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-push(@columns) {\n @media (min-width: @screen-lg-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-pull(@columns) {\n @media (min-width: @screen-lg-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n\n// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `@grid-columns`.\n\n.make-grid-columns() {\n // Common styles for all sizes of grid columns, widths 1-12\n .col(@index) when (@index = 1) { // initial\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general; \"=<\" isn't a typo\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n position: relative;\n // Prevent columns from collapsing when empty\n min-height: 1px;\n // Inner gutter via padding\n padding-left: (@grid-gutter-width / 2);\n padding-right: (@grid-gutter-width / 2);\n }\n }\n .col(1); // kickstart it\n}\n\n.float-grid-columns(@class) {\n .col(@index) when (@index = 1) { // initial\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n float: left;\n }\n }\n .col(1); // kickstart it\n}\n\n.calc-grid-column(@index, @class, @type) when (@type = width) and (@index > 0) {\n .col-@{class}-@{index} {\n width: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = push) {\n .col-@{class}-push-@{index} {\n left: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = pull) {\n .col-@{class}-pull-@{index} {\n right: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = offset) {\n .col-@{class}-offset-@{index} {\n margin-left: percentage((@index / @grid-columns));\n }\n}\n\n// Basic looping in LESS\n.loop-grid-columns(@index, @class, @type) when (@index >= 0) {\n .calc-grid-column(@index, @class, @type);\n // next iteration\n .loop-grid-columns((@index - 1), @class, @type);\n}\n\n// Create grid for specific class\n.make-grid(@class) {\n .float-grid-columns(@class);\n .loop-grid-columns(@grid-columns, @class, width);\n .loop-grid-columns(@grid-columns, @class, pull);\n .loop-grid-columns(@grid-columns, @class, push);\n .loop-grid-columns(@grid-columns, @class, offset);\n}\n\n// Form validation states\n//\n// Used in forms.less to generate the form validation CSS for warnings, errors,\n// and successes.\n\n.form-control-validation(@text-color: #555; @border-color: #ccc; @background-color: #f5f5f5) {\n // Color the label and help text\n .help-block,\n .control-label,\n .radio,\n .checkbox,\n .radio-inline,\n .checkbox-inline {\n color: @text-color;\n }\n // Set the border and box shadow on specific inputs to match\n .form-control {\n border-color: @border-color;\n .box-shadow(inset 0 1px 1px rgba(0,0,0,.075)); // Redeclare so transitions work\n &:focus {\n border-color: darken(@border-color, 10%);\n @shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px lighten(@border-color, 20%);\n .box-shadow(@shadow);\n }\n }\n // Set validation states also for addons\n .input-group-addon {\n color: @text-color;\n border-color: @border-color;\n background-color: @background-color;\n }\n // Optional feedback icon\n .form-control-feedback {\n color: @text-color;\n }\n}\n\n// Form control focus state\n//\n// Generate a customized focus state and for any input with the specified color,\n// which defaults to the `@input-focus-border` variable.\n//\n// We highly encourage you to not customize the default value, but instead use\n// this to tweak colors on an as-needed basis. This aesthetic change is based on\n// WebKit's default styles, but applicable to a wider range of browsers. Its\n// usability and accessibility should be taken into account with any change.\n//\n// Example usage: change the default blue border and shadow to white for better\n// contrast against a dark gray background.\n\n.form-control-focus(@color: @input-border-focus) {\n @color-rgba: rgba(red(@color), green(@color), blue(@color), .6);\n &:focus {\n border-color: @color;\n outline: 0;\n .box-shadow(~\"inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px @{color-rgba}\");\n }\n}\n\n// Form control sizing\n//\n// Relative text size, padding, and border-radii changes for form controls. For\n// horizontal sizing, wrap controls in the predefined grid classes. ``\n// element gets special love because it's special, and that's a fact!\n\n.input-size(@input-height; @padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n height: @input-height;\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n border-radius: @border-radius;\n\n select& {\n height: @input-height;\n line-height: @input-height;\n }\n\n textarea&,\n select[multiple]& {\n height: auto;\n }\n}\n","//\n// Variables\n// --------------------------------------------------\n\n\n//== Colors\n//\n//## Gray and brand colors for use across Bootstrap.\n\n@gray-darker: lighten(#000, 13.5%); // #222\n@gray-dark: lighten(#000, 20%); // #333\n@gray: lighten(#000, 33.5%); // #555\n@gray-light: lighten(#000, 60%); // #999\n@gray-lighter: lighten(#000, 93.5%); // #eee\n\n@brand-primary: #428bca;\n@brand-success: #5cb85c;\n@brand-info: #5bc0de;\n@brand-warning: #f0ad4e;\n@brand-danger: #d9534f;\n\n\n//== Scaffolding\n//\n// ## Settings for some of the most global styles.\n\n//** Background color for ``.\n@body-bg: #fff;\n//** Global text color on ``.\n@text-color: @gray-dark;\n\n//** Global textual link color.\n@link-color: @brand-primary;\n//** Link hover color set via `darken()` function.\n@link-hover-color: darken(@link-color, 15%);\n\n\n//== Typography\n//\n//## Font, line-height, and color for body text, headings, and more.\n\n@font-family-sans-serif: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n@font-family-serif: Georgia, \"Times New Roman\", Times, serif;\n//** Default monospace fonts for ``, ``, and `
    `.\n@font-family-monospace:   Menlo, Monaco, Consolas, \"Courier New\", monospace;\n@font-family-base:        @font-family-sans-serif;\n\n@font-size-base:          14px;\n@font-size-large:         ceil((@font-size-base * 1.25)); // ~18px\n@font-size-small:         ceil((@font-size-base * 0.85)); // ~12px\n\n@font-size-h1:            floor((@font-size-base * 2.6)); // ~36px\n@font-size-h2:            floor((@font-size-base * 2.15)); // ~30px\n@font-size-h3:            ceil((@font-size-base * 1.7)); // ~24px\n@font-size-h4:            ceil((@font-size-base * 1.25)); // ~18px\n@font-size-h5:            @font-size-base;\n@font-size-h6:            ceil((@font-size-base * 0.85)); // ~12px\n\n//** Unit-less `line-height` for use in components like buttons.\n@line-height-base:        1.428571429; // 20/14\n//** Computed \"line-height\" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.\n@line-height-computed:    floor((@font-size-base * @line-height-base)); // ~20px\n\n//** By default, this inherits from the ``.\n@headings-font-family:    inherit;\n@headings-font-weight:    500;\n@headings-line-height:    1.1;\n@headings-color:          inherit;\n\n\n//-- Iconography\n//\n//## Specify custom locations of the include Glyphicons icon font. Useful for those including Bootstrap via Bower.\n\n@icon-font-path:          \"../fonts/\";\n@icon-font-name:          \"glyphicons-halflings-regular\";\n@icon-font-svg-id:        \"glyphicons_halflingsregular\";\n\n//== Components\n//\n//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).\n\n@padding-base-vertical:     6px;\n@padding-base-horizontal:   12px;\n\n@padding-large-vertical:    10px;\n@padding-large-horizontal:  16px;\n\n@padding-small-vertical:    5px;\n@padding-small-horizontal:  10px;\n\n@padding-xs-vertical:       1px;\n@padding-xs-horizontal:     5px;\n\n@line-height-large:         1.33;\n@line-height-small:         1.5;\n\n@border-radius-base:        4px;\n@border-radius-large:       6px;\n@border-radius-small:       3px;\n\n//** Global color for active items (e.g., navs or dropdowns).\n@component-active-color:    #fff;\n//** Global background color for active items (e.g., navs or dropdowns).\n@component-active-bg:       @brand-primary;\n\n//** Width of the `border` for generating carets that indicator dropdowns.\n@caret-width-base:          4px;\n//** Carets increase slightly in size for larger components.\n@caret-width-large:         5px;\n\n\n//== Tables\n//\n//## Customizes the `.table` component with basic values, each used across all table variations.\n\n//** Padding for ``s and ``s.\n@table-cell-padding:            8px;\n//** Padding for cells in `.table-condensed`.\n@table-condensed-cell-padding:  5px;\n\n//** Default background color used for all tables.\n@table-bg:                      transparent;\n//** Background color used for `.table-striped`.\n@table-bg-accent:               #f9f9f9;\n//** Background color used for `.table-hover`.\n@table-bg-hover:                #f5f5f5;\n@table-bg-active:               @table-bg-hover;\n\n//** Border color for table and cell borders.\n@table-border-color:            #ddd;\n\n\n//== Buttons\n//\n//## For each of Bootstrap's buttons, define text, background and border color.\n\n@btn-font-weight:                normal;\n\n@btn-default-color:              #333;\n@btn-default-bg:                 #fff;\n@btn-default-border:             #ccc;\n\n@btn-primary-color:              #fff;\n@btn-primary-bg:                 @brand-primary;\n@btn-primary-border:             darken(@btn-primary-bg, 5%);\n\n@btn-success-color:              #fff;\n@btn-success-bg:                 @brand-success;\n@btn-success-border:             darken(@btn-success-bg, 5%);\n\n@btn-info-color:                 #fff;\n@btn-info-bg:                    @brand-info;\n@btn-info-border:                darken(@btn-info-bg, 5%);\n\n@btn-warning-color:              #fff;\n@btn-warning-bg:                 @brand-warning;\n@btn-warning-border:             darken(@btn-warning-bg, 5%);\n\n@btn-danger-color:               #fff;\n@btn-danger-bg:                  @brand-danger;\n@btn-danger-border:              darken(@btn-danger-bg, 5%);\n\n@btn-link-disabled-color:        @gray-light;\n\n\n//== Forms\n//\n//##\n\n//** `` background color\n@input-bg:                       #fff;\n//** `` background color\n@input-bg-disabled:              @gray-lighter;\n\n//** Text color for ``s\n@input-color:                    @gray;\n//** `` border color\n@input-border:                   #ccc;\n//** `` border radius\n@input-border-radius:            @border-radius-base;\n//** Border color for inputs on focus\n@input-border-focus:             #66afe9;\n\n//** Placeholder text color\n@input-color-placeholder:        @gray-light;\n\n//** Default `.form-control` height\n@input-height-base:              (@line-height-computed + (@padding-base-vertical * 2) + 2);\n//** Large `.form-control` height\n@input-height-large:             (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);\n//** Small `.form-control` height\n@input-height-small:             (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);\n\n@legend-color:                   @gray-dark;\n@legend-border-color:            #e5e5e5;\n\n//** Background color for textual input addons\n@input-group-addon-bg:           @gray-lighter;\n//** Border color for textual input addons\n@input-group-addon-border-color: @input-border;\n\n\n//== Dropdowns\n//\n//## Dropdown menu container and contents.\n\n//** Background for the dropdown menu.\n@dropdown-bg:                    #fff;\n//** Dropdown menu `border-color`.\n@dropdown-border:                rgba(0,0,0,.15);\n//** Dropdown menu `border-color` **for IE8**.\n@dropdown-fallback-border:       #ccc;\n//** Divider color for between dropdown items.\n@dropdown-divider-bg:            #e5e5e5;\n\n//** Dropdown link text color.\n@dropdown-link-color:            @gray-dark;\n//** Hover color for dropdown links.\n@dropdown-link-hover-color:      darken(@gray-dark, 5%);\n//** Hover background for dropdown links.\n@dropdown-link-hover-bg:         #f5f5f5;\n\n//** Active dropdown menu item text color.\n@dropdown-link-active-color:     @component-active-color;\n//** Active dropdown menu item background color.\n@dropdown-link-active-bg:        @component-active-bg;\n\n//** Disabled dropdown menu item background color.\n@dropdown-link-disabled-color:   @gray-light;\n\n//** Text color for headers within dropdown menus.\n@dropdown-header-color:          @gray-light;\n\n// Note: Deprecated @dropdown-caret-color as of v3.1.0\n@dropdown-caret-color:           #000;\n\n\n//-- Z-index master list\n//\n// Warning: Avoid customizing these values. They're used for a bird's eye view\n// of components dependent on the z-axis and are designed to all work together.\n//\n// Note: These variables are not generated into the Customizer.\n\n@zindex-navbar:            1000;\n@zindex-dropdown:          1000;\n@zindex-popover:           1010;\n@zindex-tooltip:           1030;\n@zindex-navbar-fixed:      1030;\n@zindex-modal-background:  1040;\n@zindex-modal:             1050;\n\n\n//== Media queries breakpoints\n//\n//## Define the breakpoints at which your layout will change, adapting to different screen sizes.\n\n// Extra small screen / phone\n// Note: Deprecated @screen-xs and @screen-phone as of v3.0.1\n@screen-xs:                  480px;\n@screen-xs-min:              @screen-xs;\n@screen-phone:               @screen-xs-min;\n\n// Small screen / tablet\n// Note: Deprecated @screen-sm and @screen-tablet as of v3.0.1\n@screen-sm:                  768px;\n@screen-sm-min:              @screen-sm;\n@screen-tablet:              @screen-sm-min;\n\n// Medium screen / desktop\n// Note: Deprecated @screen-md and @screen-desktop as of v3.0.1\n@screen-md:                  992px;\n@screen-md-min:              @screen-md;\n@screen-desktop:             @screen-md-min;\n\n// Large screen / wide desktop\n// Note: Deprecated @screen-lg and @screen-lg-desktop as of v3.0.1\n@screen-lg:                  1200px;\n@screen-lg-min:              @screen-lg;\n@screen-lg-desktop:          @screen-lg-min;\n\n// So media queries don't overlap when required, provide a maximum\n@screen-xs-max:              (@screen-sm-min - 1);\n@screen-sm-max:              (@screen-md-min - 1);\n@screen-md-max:              (@screen-lg-min - 1);\n\n\n//== Grid system\n//\n//## Define your custom responsive grid.\n\n//** Number of columns in the grid.\n@grid-columns:              12;\n//** Padding between columns. Gets divided in half for the left and right.\n@grid-gutter-width:         30px;\n// Navbar collapse\n//** Point at which the navbar becomes uncollapsed.\n@grid-float-breakpoint:     @screen-sm-min;\n//** Point at which the navbar begins collapsing.\n@grid-float-breakpoint-max: (@grid-float-breakpoint - 1);\n\n\n//== Container sizes\n//\n//## Define the maximum width of `.container` for different screen sizes.\n\n// Small screen / tablet\n@container-tablet:             ((720px + @grid-gutter-width));\n//** For `@screen-sm-min` and up.\n@container-sm:                 @container-tablet;\n\n// Medium screen / desktop\n@container-desktop:            ((940px + @grid-gutter-width));\n//** For `@screen-md-min` and up.\n@container-md:                 @container-desktop;\n\n// Large screen / wide desktop\n@container-large-desktop:      ((1140px + @grid-gutter-width));\n//** For `@screen-lg-min` and up.\n@container-lg:                 @container-large-desktop;\n\n\n//== Navbar\n//\n//##\n\n// Basics of a navbar\n@navbar-height:                    50px;\n@navbar-margin-bottom:             @line-height-computed;\n@navbar-border-radius:             @border-radius-base;\n@navbar-padding-horizontal:        floor((@grid-gutter-width / 2));\n@navbar-padding-vertical:          ((@navbar-height - @line-height-computed) / 2);\n@navbar-collapse-max-height:       340px;\n\n@navbar-default-color:             #777;\n@navbar-default-bg:                #f8f8f8;\n@navbar-default-border:            darken(@navbar-default-bg, 6.5%);\n\n// Navbar links\n@navbar-default-link-color:                #777;\n@navbar-default-link-hover-color:          #333;\n@navbar-default-link-hover-bg:             transparent;\n@navbar-default-link-active-color:         #555;\n@navbar-default-link-active-bg:            darken(@navbar-default-bg, 6.5%);\n@navbar-default-link-disabled-color:       #ccc;\n@navbar-default-link-disabled-bg:          transparent;\n\n// Navbar brand label\n@navbar-default-brand-color:               @navbar-default-link-color;\n@navbar-default-brand-hover-color:         darken(@navbar-default-brand-color, 10%);\n@navbar-default-brand-hover-bg:            transparent;\n\n// Navbar toggle\n@navbar-default-toggle-hover-bg:           #ddd;\n@navbar-default-toggle-icon-bar-bg:        #888;\n@navbar-default-toggle-border-color:       #ddd;\n\n\n// Inverted navbar\n// Reset inverted navbar basics\n@navbar-inverse-color:                      @gray-light;\n@navbar-inverse-bg:                         #222;\n@navbar-inverse-border:                     darken(@navbar-inverse-bg, 10%);\n\n// Inverted navbar links\n@navbar-inverse-link-color:                 @gray-light;\n@navbar-inverse-link-hover-color:           #fff;\n@navbar-inverse-link-hover-bg:              transparent;\n@navbar-inverse-link-active-color:          @navbar-inverse-link-hover-color;\n@navbar-inverse-link-active-bg:             darken(@navbar-inverse-bg, 10%);\n@navbar-inverse-link-disabled-color:        #444;\n@navbar-inverse-link-disabled-bg:           transparent;\n\n// Inverted navbar brand label\n@navbar-inverse-brand-color:                @navbar-inverse-link-color;\n@navbar-inverse-brand-hover-color:          #fff;\n@navbar-inverse-brand-hover-bg:             transparent;\n\n// Inverted navbar toggle\n@navbar-inverse-toggle-hover-bg:            #333;\n@navbar-inverse-toggle-icon-bar-bg:         #fff;\n@navbar-inverse-toggle-border-color:        #333;\n\n\n//== Navs\n//\n//##\n\n//=== Shared nav styles\n@nav-link-padding:                          10px 15px;\n@nav-link-hover-bg:                         @gray-lighter;\n\n@nav-disabled-link-color:                   @gray-light;\n@nav-disabled-link-hover-color:             @gray-light;\n\n@nav-open-link-hover-color:                 #fff;\n\n//== Tabs\n@nav-tabs-border-color:                     #ddd;\n\n@nav-tabs-link-hover-border-color:          @gray-lighter;\n\n@nav-tabs-active-link-hover-bg:             @body-bg;\n@nav-tabs-active-link-hover-color:          @gray;\n@nav-tabs-active-link-hover-border-color:   #ddd;\n\n@nav-tabs-justified-link-border-color:            #ddd;\n@nav-tabs-justified-active-link-border-color:     @body-bg;\n\n//== Pills\n@nav-pills-border-radius:                   @border-radius-base;\n@nav-pills-active-link-hover-bg:            @component-active-bg;\n@nav-pills-active-link-hover-color:         @component-active-color;\n\n\n//== Pagination\n//\n//##\n\n@pagination-color:                     @link-color;\n@pagination-bg:                        #fff;\n@pagination-border:                    #ddd;\n\n@pagination-hover-color:               @link-hover-color;\n@pagination-hover-bg:                  @gray-lighter;\n@pagination-hover-border:              #ddd;\n\n@pagination-active-color:              #fff;\n@pagination-active-bg:                 @brand-primary;\n@pagination-active-border:             @brand-primary;\n\n@pagination-disabled-color:            @gray-light;\n@pagination-disabled-bg:               #fff;\n@pagination-disabled-border:           #ddd;\n\n\n//== Pager\n//\n//##\n\n@pager-bg:                             @pagination-bg;\n@pager-border:                         @pagination-border;\n@pager-border-radius:                  15px;\n\n@pager-hover-bg:                       @pagination-hover-bg;\n\n@pager-active-bg:                      @pagination-active-bg;\n@pager-active-color:                   @pagination-active-color;\n\n@pager-disabled-color:                 @pagination-disabled-color;\n\n\n//== Jumbotron\n//\n//##\n\n@jumbotron-padding:              30px;\n@jumbotron-color:                inherit;\n@jumbotron-bg:                   @gray-lighter;\n@jumbotron-heading-color:        inherit;\n@jumbotron-font-size:            ceil((@font-size-base * 1.5));\n\n\n//== Form states and alerts\n//\n//## Define colors for form feedback states and, by default, alerts.\n\n@state-success-text:             #3c763d;\n@state-success-bg:               #dff0d8;\n@state-success-border:           darken(spin(@state-success-bg, -10), 5%);\n\n@state-info-text:                #31708f;\n@state-info-bg:                  #d9edf7;\n@state-info-border:              darken(spin(@state-info-bg, -10), 7%);\n\n@state-warning-text:             #8a6d3b;\n@state-warning-bg:               #fcf8e3;\n@state-warning-border:           darken(spin(@state-warning-bg, -10), 5%);\n\n@state-danger-text:              #a94442;\n@state-danger-bg:                #f2dede;\n@state-danger-border:            darken(spin(@state-danger-bg, -10), 5%);\n\n\n//== Tooltips\n//\n//##\n\n//** Tooltip max width\n@tooltip-max-width:           200px;\n//** Tooltip text color\n@tooltip-color:               #fff;\n//** Tooltip background color\n@tooltip-bg:                  #000;\n@tooltip-opacity:             .9;\n\n//** Tooltip arrow width\n@tooltip-arrow-width:         5px;\n//** Tooltip arrow color\n@tooltip-arrow-color:         @tooltip-bg;\n\n\n//== Popovers\n//\n//##\n\n//** Popover body background color\n@popover-bg:                          #fff;\n//** Popover maximum width\n@popover-max-width:                   276px;\n//** Popover border color\n@popover-border-color:                rgba(0,0,0,.2);\n//** Popover fallback border color\n@popover-fallback-border-color:       #ccc;\n\n//** Popover title background color\n@popover-title-bg:                    darken(@popover-bg, 3%);\n\n//** Popover arrow width\n@popover-arrow-width:                 10px;\n//** Popover arrow color\n@popover-arrow-color:                 #fff;\n\n//** Popover outer arrow width\n@popover-arrow-outer-width:           (@popover-arrow-width + 1);\n//** Popover outer arrow color\n@popover-arrow-outer-color:           fadein(@popover-border-color, 5%);\n//** Popover outer arrow fallback color\n@popover-arrow-outer-fallback-color:  darken(@popover-fallback-border-color, 20%);\n\n\n//== Labels\n//\n//##\n\n//** Default label background color\n@label-default-bg:            @gray-light;\n//** Primary label background color\n@label-primary-bg:            @brand-primary;\n//** Success label background color\n@label-success-bg:            @brand-success;\n//** Info label background color\n@label-info-bg:               @brand-info;\n//** Warning label background color\n@label-warning-bg:            @brand-warning;\n//** Danger label background color\n@label-danger-bg:             @brand-danger;\n\n//** Default label text color\n@label-color:                 #fff;\n//** Default text color of a linked label\n@label-link-hover-color:      #fff;\n\n\n//== Modals\n//\n//##\n\n//** Padding applied to the modal body\n@modal-inner-padding:         20px;\n\n//** Padding applied to the modal title\n@modal-title-padding:         15px;\n//** Modal title line-height\n@modal-title-line-height:     @line-height-base;\n\n//** Background color of modal content area\n@modal-content-bg:                             #fff;\n//** Modal content border color\n@modal-content-border-color:                   rgba(0,0,0,.2);\n//** Modal content border color **for IE8**\n@modal-content-fallback-border-color:          #999;\n\n//** Modal backdrop background color\n@modal-backdrop-bg:           #000;\n//** Modal backdrop opacity\n@modal-backdrop-opacity:      .5;\n//** Modal header border color\n@modal-header-border-color:   #e5e5e5;\n//** Modal footer border color\n@modal-footer-border-color:   @modal-header-border-color;\n\n@modal-lg:                    900px;\n@modal-md:                    600px;\n@modal-sm:                    300px;\n\n\n//== Alerts\n//\n//## Define alert colors, border radius, and padding.\n\n@alert-padding:               15px;\n@alert-border-radius:         @border-radius-base;\n@alert-link-font-weight:      bold;\n\n@alert-success-bg:            @state-success-bg;\n@alert-success-text:          @state-success-text;\n@alert-success-border:        @state-success-border;\n\n@alert-info-bg:               @state-info-bg;\n@alert-info-text:             @state-info-text;\n@alert-info-border:           @state-info-border;\n\n@alert-warning-bg:            @state-warning-bg;\n@alert-warning-text:          @state-warning-text;\n@alert-warning-border:        @state-warning-border;\n\n@alert-danger-bg:             @state-danger-bg;\n@alert-danger-text:           @state-danger-text;\n@alert-danger-border:         @state-danger-border;\n\n\n//== Progress bars\n//\n//##\n\n//** Background color of the whole progress component\n@progress-bg:                 #f5f5f5;\n//** Progress bar text color\n@progress-bar-color:          #fff;\n\n//** Default progress bar color\n@progress-bar-bg:             @brand-primary;\n//** Success progress bar color\n@progress-bar-success-bg:     @brand-success;\n//** Warning progress bar color\n@progress-bar-warning-bg:     @brand-warning;\n//** Danger progress bar color\n@progress-bar-danger-bg:      @brand-danger;\n//** Info progress bar color\n@progress-bar-info-bg:        @brand-info;\n\n\n//== List group\n//\n//##\n\n//** Background color on `.list-group-item`\n@list-group-bg:                 #fff;\n//** `.list-group-item` border color\n@list-group-border:             #ddd;\n//** List group border radius\n@list-group-border-radius:      @border-radius-base;\n\n//** Background color of single list elements on hover\n@list-group-hover-bg:           #f5f5f5;\n//** Text color of active list elements\n@list-group-active-color:       @component-active-color;\n//** Background color of active list elements\n@list-group-active-bg:          @component-active-bg;\n//** Border color of active list elements\n@list-group-active-border:      @list-group-active-bg;\n@list-group-active-text-color:  lighten(@list-group-active-bg, 40%);\n\n@list-group-link-color:         #555;\n@list-group-link-heading-color: #333;\n\n\n//== Panels\n//\n//##\n\n@panel-bg:                    #fff;\n@panel-body-padding:          15px;\n@panel-border-radius:         @border-radius-base;\n\n//** Border color for elements within panels\n@panel-inner-border:          #ddd;\n@panel-footer-bg:             #f5f5f5;\n\n@panel-default-text:          @gray-dark;\n@panel-default-border:        #ddd;\n@panel-default-heading-bg:    #f5f5f5;\n\n@panel-primary-text:          #fff;\n@panel-primary-border:        @brand-primary;\n@panel-primary-heading-bg:    @brand-primary;\n\n@panel-success-text:          @state-success-text;\n@panel-success-border:        @state-success-border;\n@panel-success-heading-bg:    @state-success-bg;\n\n@panel-info-text:             @state-info-text;\n@panel-info-border:           @state-info-border;\n@panel-info-heading-bg:       @state-info-bg;\n\n@panel-warning-text:          @state-warning-text;\n@panel-warning-border:        @state-warning-border;\n@panel-warning-heading-bg:    @state-warning-bg;\n\n@panel-danger-text:           @state-danger-text;\n@panel-danger-border:         @state-danger-border;\n@panel-danger-heading-bg:     @state-danger-bg;\n\n\n//== Thumbnails\n//\n//##\n\n//** Padding around the thumbnail image\n@thumbnail-padding:           4px;\n//** Thumbnail background color\n@thumbnail-bg:                @body-bg;\n//** Thumbnail border color\n@thumbnail-border:            #ddd;\n//** Thumbnail border radius\n@thumbnail-border-radius:     @border-radius-base;\n\n//** Custom text color for thumbnail captions\n@thumbnail-caption-color:     @text-color;\n//** Padding around the thumbnail caption\n@thumbnail-caption-padding:   9px;\n\n\n//== Wells\n//\n//##\n\n@well-bg:                     #f5f5f5;\n@well-border:                 darken(@well-bg, 7%);\n\n\n//== Badges\n//\n//##\n\n@badge-color:                 #fff;\n//** Linked badge text color on hover\n@badge-link-hover-color:      #fff;\n@badge-bg:                    @gray-light;\n\n//** Badge text color in active nav link\n@badge-active-color:          @link-color;\n//** Badge background color in active nav link\n@badge-active-bg:             #fff;\n\n@badge-font-weight:           bold;\n@badge-line-height:           1;\n@badge-border-radius:         10px;\n\n\n//== Breadcrumbs\n//\n//##\n\n@breadcrumb-padding-vertical:   8px;\n@breadcrumb-padding-horizontal: 15px;\n//** Breadcrumb background color\n@breadcrumb-bg:                 #f5f5f5;\n//** Breadcrumb text color\n@breadcrumb-color:              #ccc;\n//** Text color of current page in the breadcrumb\n@breadcrumb-active-color:       @gray-light;\n//** Textual separator for between breadcrumb elements\n@breadcrumb-separator:          \"/\";\n\n\n//== Carousel\n//\n//##\n\n@carousel-text-shadow:                        0 1px 2px rgba(0,0,0,.6);\n\n@carousel-control-color:                      #fff;\n@carousel-control-width:                      15%;\n@carousel-control-opacity:                    .5;\n@carousel-control-font-size:                  20px;\n\n@carousel-indicator-active-bg:                #fff;\n@carousel-indicator-border-color:             #fff;\n\n@carousel-caption-color:                      #fff;\n\n\n//== Close\n//\n//##\n\n@close-font-weight:           bold;\n@close-color:                 #000;\n@close-text-shadow:           0 1px 0 #fff;\n\n\n//== Code\n//\n//##\n\n@code-color:                  #c7254e;\n@code-bg:                     #f9f2f4;\n\n@kbd-color:                   #fff;\n@kbd-bg:                      #333;\n\n@pre-bg:                      #f5f5f5;\n@pre-color:                   @gray-dark;\n@pre-border-color:            #ccc;\n@pre-scrollable-max-height:   340px;\n\n\n//== Type\n//\n//##\n\n//** Text muted color\n@text-muted:                  @gray-light;\n//** Abbreviations and acronyms border color\n@abbr-border-color:           @gray-light;\n//** Headings small color\n@headings-small-color:        @gray-light;\n//** Blockquote small color\n@blockquote-small-color:      @gray-light;\n//** Blockquote font size\n@blockquote-font-size:        (@font-size-base * 1.25);\n//** Blockquote border color\n@blockquote-border-color:     @gray-lighter;\n//** Page header border color\n@page-header-border-color:    @gray-lighter;\n\n\n//== Miscellaneous\n//\n//##\n\n//** Horizontal line color.\n@hr-border:                   @gray-lighter;\n\n//** Horizontal offset for forms and lists.\n@component-offset-horizontal: 180px;\n","//\n// Thumbnails\n// --------------------------------------------------\n\n\n// Mixin and adjust the regular image class\n.thumbnail {\n  display: block;\n  padding: @thumbnail-padding;\n  margin-bottom: @line-height-computed;\n  line-height: @line-height-base;\n  background-color: @thumbnail-bg;\n  border: 1px solid @thumbnail-border;\n  border-radius: @thumbnail-border-radius;\n  .transition(all .2s ease-in-out);\n\n  > img,\n  a > img {\n    &:extend(.img-responsive);\n    margin-left: auto;\n    margin-right: auto;\n  }\n\n  // Add a hover state for linked versions only\n  a&:hover,\n  a&:focus,\n  a&.active {\n    border-color: @link-color;\n  }\n\n  // Image captions\n  .caption {\n    padding: @thumbnail-caption-padding;\n    color: @thumbnail-caption-color;\n  }\n}\n","//\n// Carousel\n// --------------------------------------------------\n\n\n// Wrapper for the slide container and indicators\n.carousel {\n  position: relative;\n}\n\n.carousel-inner {\n  position: relative;\n  overflow: hidden;\n  width: 100%;\n\n  > .item {\n    display: none;\n    position: relative;\n    .transition(.6s ease-in-out left);\n\n    // Account for jankitude on images\n    > img,\n    > a > img {\n      &:extend(.img-responsive);\n      line-height: 1;\n    }\n  }\n\n  > .active,\n  > .next,\n  > .prev { display: block; }\n\n  > .active {\n    left: 0;\n  }\n\n  > .next,\n  > .prev {\n    position: absolute;\n    top: 0;\n    width: 100%;\n  }\n\n  > .next {\n    left: 100%;\n  }\n  > .prev {\n    left: -100%;\n  }\n  > .next.left,\n  > .prev.right {\n    left: 0;\n  }\n\n  > .active.left {\n    left: -100%;\n  }\n  > .active.right {\n    left: 100%;\n  }\n\n}\n\n// Left/right controls for nav\n// ---------------------------\n\n.carousel-control {\n  position: absolute;\n  top: 0;\n  left: 0;\n  bottom: 0;\n  width: @carousel-control-width;\n  .opacity(@carousel-control-opacity);\n  font-size: @carousel-control-font-size;\n  color: @carousel-control-color;\n  text-align: center;\n  text-shadow: @carousel-text-shadow;\n  // We can't have this transition here because WebKit cancels the carousel\n  // animation if you trip this while in the middle of another animation.\n\n  // Set gradients for backgrounds\n  &.left {\n    #gradient > .horizontal(@start-color: rgba(0,0,0,.5); @end-color: rgba(0,0,0,.0001));\n  }\n  &.right {\n    left: auto;\n    right: 0;\n    #gradient > .horizontal(@start-color: rgba(0,0,0,.0001); @end-color: rgba(0,0,0,.5));\n  }\n\n  // Hover/focus state\n  &:hover,\n  &:focus {\n    outline: none;\n    color: @carousel-control-color;\n    text-decoration: none;\n    .opacity(.9);\n  }\n\n  // Toggles\n  .icon-prev,\n  .icon-next,\n  .glyphicon-chevron-left,\n  .glyphicon-chevron-right {\n    position: absolute;\n    top: 50%;\n    z-index: 5;\n    display: inline-block;\n  }\n  .icon-prev,\n  .glyphicon-chevron-left {\n    left: 50%;\n  }\n  .icon-next,\n  .glyphicon-chevron-right {\n    right: 50%;\n  }\n  .icon-prev,\n  .icon-next {\n    width:  20px;\n    height: 20px;\n    margin-top: -10px;\n    margin-left: -10px;\n    font-family: serif;\n  }\n\n  .icon-prev {\n    &:before {\n      content: '\\2039';// SINGLE LEFT-POINTING ANGLE QUOTATION MARK (U+2039)\n    }\n  }\n  .icon-next {\n    &:before {\n      content: '\\203a';// SINGLE RIGHT-POINTING ANGLE QUOTATION MARK (U+203A)\n    }\n  }\n}\n\n// Optional indicator pips\n//\n// Add an unordered list with the following class and add a list item for each\n// slide your carousel holds.\n\n.carousel-indicators {\n  position: absolute;\n  bottom: 10px;\n  left: 50%;\n  z-index: 15;\n  width: 60%;\n  margin-left: -30%;\n  padding-left: 0;\n  list-style: none;\n  text-align: center;\n\n  li {\n    display: inline-block;\n    width:  10px;\n    height: 10px;\n    margin: 1px;\n    text-indent: -999px;\n    border: 1px solid @carousel-indicator-border-color;\n    border-radius: 10px;\n    cursor: pointer;\n\n    // IE8-9 hack for event handling\n    //\n    // Internet Explorer 8-9 does not support clicks on elements without a set\n    // `background-color`. We cannot use `filter` since that's not viewed as a\n    // background color by the browser. Thus, a hack is needed.\n    //\n    // For IE8, we set solid black as it doesn't support `rgba()`. For IE9, we\n    // set alpha transparency for the best results possible.\n    background-color: #000 \\9; // IE8\n    background-color: rgba(0,0,0,0); // IE9\n  }\n  .active {\n    margin: 0;\n    width:  12px;\n    height: 12px;\n    background-color: @carousel-indicator-active-bg;\n  }\n}\n\n// Optional captions\n// -----------------------------\n// Hidden by default for smaller viewports\n.carousel-caption {\n  position: absolute;\n  left: 15%;\n  right: 15%;\n  bottom: 20px;\n  z-index: 10;\n  padding-top: 20px;\n  padding-bottom: 20px;\n  color: @carousel-caption-color;\n  text-align: center;\n  text-shadow: @carousel-text-shadow;\n  & .btn {\n    text-shadow: none; // No shadow for button elements in carousel-caption\n  }\n}\n\n\n// Scale up controls for tablets and up\n@media screen and (min-width: @screen-sm-min) {\n\n  // Scale up the controls a smidge\n  .carousel-control {\n    .glyphicon-chevron-left,\n    .glyphicon-chevron-right,\n    .icon-prev,\n    .icon-next {\n      width: 30px;\n      height: 30px;\n      margin-top: -15px;\n      margin-left: -15px;\n      font-size: 30px;\n    }\n  }\n\n  // Show and left align the captions\n  .carousel-caption {\n    left: 20%;\n    right: 20%;\n    padding-bottom: 30px;\n  }\n\n  // Move up the indicators\n  .carousel-indicators {\n    bottom: 20px;\n  }\n}\n","//\n// Typography\n// --------------------------------------------------\n\n\n// Headings\n// -------------------------\n\nh1, h2, h3, h4, h5, h6,\n.h1, .h2, .h3, .h4, .h5, .h6 {\n  font-family: @headings-font-family;\n  font-weight: @headings-font-weight;\n  line-height: @headings-line-height;\n  color: @headings-color;\n\n  small,\n  .small {\n    font-weight: normal;\n    line-height: 1;\n    color: @headings-small-color;\n  }\n}\n\nh1, .h1,\nh2, .h2,\nh3, .h3 {\n  margin-top: @line-height-computed;\n  margin-bottom: (@line-height-computed / 2);\n\n  small,\n  .small {\n    font-size: 65%;\n  }\n}\nh4, .h4,\nh5, .h5,\nh6, .h6 {\n  margin-top: (@line-height-computed / 2);\n  margin-bottom: (@line-height-computed / 2);\n\n  small,\n  .small {\n    font-size: 75%;\n  }\n}\n\nh1, .h1 { font-size: @font-size-h1; }\nh2, .h2 { font-size: @font-size-h2; }\nh3, .h3 { font-size: @font-size-h3; }\nh4, .h4 { font-size: @font-size-h4; }\nh5, .h5 { font-size: @font-size-h5; }\nh6, .h6 { font-size: @font-size-h6; }\n\n\n// Body text\n// -------------------------\n\np {\n  margin: 0 0 (@line-height-computed / 2);\n}\n\n.lead {\n  margin-bottom: @line-height-computed;\n  font-size: floor((@font-size-base * 1.15));\n  font-weight: 200;\n  line-height: 1.4;\n\n  @media (min-width: @screen-sm-min) {\n    font-size: (@font-size-base * 1.5);\n  }\n}\n\n\n// Emphasis & misc\n// -------------------------\n\n// Ex: 14px base font * 85% = about 12px\nsmall,\n.small  { font-size: 85%; }\n\n// Undo browser default styling\ncite    { font-style: normal; }\n\n// Alignment\n.text-left           { text-align: left; }\n.text-right          { text-align: right; }\n.text-center         { text-align: center; }\n.text-justify        { text-align: justify; }\n\n// Contextual colors\n.text-muted {\n  color: @text-muted;\n}\n.text-primary {\n  .text-emphasis-variant(@brand-primary);\n}\n.text-success {\n  .text-emphasis-variant(@state-success-text);\n}\n.text-info {\n  .text-emphasis-variant(@state-info-text);\n}\n.text-warning {\n  .text-emphasis-variant(@state-warning-text);\n}\n.text-danger {\n  .text-emphasis-variant(@state-danger-text);\n}\n\n// Contextual backgrounds\n// For now we'll leave these alongside the text classes until v4 when we can\n// safely shift things around (per SemVer rules).\n.bg-primary {\n  // Given the contrast here, this is the only class to have its color inverted\n  // automatically.\n  color: #fff;\n  .bg-variant(@brand-primary);\n}\n.bg-success {\n  .bg-variant(@state-success-bg);\n}\n.bg-info {\n  .bg-variant(@state-info-bg);\n}\n.bg-warning {\n  .bg-variant(@state-warning-bg);\n}\n.bg-danger {\n  .bg-variant(@state-danger-bg);\n}\n\n\n// Page header\n// -------------------------\n\n.page-header {\n  padding-bottom: ((@line-height-computed / 2) - 1);\n  margin: (@line-height-computed * 2) 0 @line-height-computed;\n  border-bottom: 1px solid @page-header-border-color;\n}\n\n\n// Lists\n// --------------------------------------------------\n\n// Unordered and Ordered lists\nul,\nol {\n  margin-top: 0;\n  margin-bottom: (@line-height-computed / 2);\n  ul,\n  ol {\n    margin-bottom: 0;\n  }\n}\n\n// List options\n\n// Unstyled keeps list items block level, just removes default browser padding and list-style\n.list-unstyled {\n  padding-left: 0;\n  list-style: none;\n}\n\n// Inline turns list items into inline-block\n.list-inline {\n  .list-unstyled();\n  margin-left: -5px;\n\n  > li {\n    display: inline-block;\n    padding-left: 5px;\n    padding-right: 5px;\n  }\n}\n\n// Description Lists\ndl {\n  margin-top: 0; // Remove browser default\n  margin-bottom: @line-height-computed;\n}\ndt,\ndd {\n  line-height: @line-height-base;\n}\ndt {\n  font-weight: bold;\n}\ndd {\n  margin-left: 0; // Undo browser default\n}\n\n// Horizontal description lists\n//\n// Defaults to being stacked without any of the below styles applied, until the\n// grid breakpoint is reached (default of ~768px).\n\n@media (min-width: @grid-float-breakpoint) {\n  .dl-horizontal {\n    dt {\n      float: left;\n      width: (@component-offset-horizontal - 20);\n      clear: left;\n      text-align: right;\n      .text-overflow();\n    }\n    dd {\n      margin-left: @component-offset-horizontal;\n      &:extend(.clearfix all); // Clear the floated `dt` if an empty `dd` is present\n    }\n  }\n}\n\n// MISC\n// ----\n\n// Abbreviations and acronyms\nabbr[title],\n// Add data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257\nabbr[data-original-title] {\n  cursor: help;\n  border-bottom: 1px dotted @abbr-border-color;\n}\n.initialism {\n  font-size: 90%;\n  text-transform: uppercase;\n}\n\n// Blockquotes\nblockquote {\n  padding: (@line-height-computed / 2) @line-height-computed;\n  margin: 0 0 @line-height-computed;\n  font-size: @blockquote-font-size;\n  border-left: 5px solid @blockquote-border-color;\n\n  p,\n  ul,\n  ol {\n    &:last-child {\n      margin-bottom: 0;\n    }\n  }\n\n  // Note: Deprecated small and .small as of v3.1.0\n  // Context: https://github.com/twbs/bootstrap/issues/11660\n  footer,\n  small,\n  .small {\n    display: block;\n    font-size: 80%; // back to default font-size\n    line-height: @line-height-base;\n    color: @blockquote-small-color;\n\n    &:before {\n      content: '\\2014 \\00A0'; // em dash, nbsp\n    }\n  }\n}\n\n// Opposite alignment of blockquote\n//\n// Heads up: `blockquote.pull-right` has been deprecated as of v3.1.0.\n.blockquote-reverse,\nblockquote.pull-right {\n  padding-right: 15px;\n  padding-left: 0;\n  border-right: 5px solid @blockquote-border-color;\n  border-left: 0;\n  text-align: right;\n\n  // Account for citation\n  footer,\n  small,\n  .small {\n    &:before { content: ''; }\n    &:after {\n      content: '\\00A0 \\2014'; // nbsp, em dash\n    }\n  }\n}\n\n// Quotes\nblockquote:before,\nblockquote:after {\n  content: \"\";\n}\n\n// Addresses\naddress {\n  margin-bottom: @line-height-computed;\n  font-style: normal;\n  line-height: @line-height-base;\n}\n","//\n// Code (inline and block)\n// --------------------------------------------------\n\n\n// Inline and block code styles\ncode,\nkbd,\npre,\nsamp {\n  font-family: @font-family-monospace;\n}\n\n// Inline code\ncode {\n  padding: 2px 4px;\n  font-size: 90%;\n  color: @code-color;\n  background-color: @code-bg;\n  white-space: nowrap;\n  border-radius: @border-radius-base;\n}\n\n// User input typically entered via keyboard\nkbd {\n  padding: 2px 4px;\n  font-size: 90%;\n  color: @kbd-color;\n  background-color: @kbd-bg;\n  border-radius: @border-radius-small;\n  box-shadow: inset 0 -1px 0 rgba(0,0,0,.25);\n}\n\n// Blocks of code\npre {\n  display: block;\n  padding: ((@line-height-computed - 1) / 2);\n  margin: 0 0 (@line-height-computed / 2);\n  font-size: (@font-size-base - 1); // 14px to 13px\n  line-height: @line-height-base;\n  word-break: break-all;\n  word-wrap: break-word;\n  color: @pre-color;\n  background-color: @pre-bg;\n  border: 1px solid @pre-border-color;\n  border-radius: @border-radius-base;\n\n  // Account for some code outputs that place code tags in pre tags\n  code {\n    padding: 0;\n    font-size: inherit;\n    color: inherit;\n    white-space: pre-wrap;\n    background-color: transparent;\n    border-radius: 0;\n  }\n}\n\n// Enable scrollable blocks of code\n.pre-scrollable {\n  max-height: @pre-scrollable-max-height;\n  overflow-y: scroll;\n}\n","//\n// Grid system\n// --------------------------------------------------\n\n\n// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n.container {\n  .container-fixed();\n\n  @media (min-width: @screen-sm-min) {\n    width: @container-sm;\n  }\n  @media (min-width: @screen-md-min) {\n    width: @container-md;\n  }\n  @media (min-width: @screen-lg-min) {\n    width: @container-lg;\n  }\n}\n\n\n// Fluid container\n//\n// Utilizes the mixin meant for fixed width containers, but without any defined\n// width for fluid, full width layouts.\n\n.container-fluid {\n  .container-fixed();\n}\n\n\n// Row\n//\n// Rows contain and clear the floats of your columns.\n\n.row {\n  .make-row();\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n.make-grid-columns();\n\n\n// Extra small grid\n//\n// Columns, offsets, pushes, and pulls for extra small devices like\n// smartphones.\n\n.make-grid(xs);\n\n\n// Small grid\n//\n// Columns, offsets, pushes, and pulls for the small device range, from phones\n// to tablets.\n\n@media (min-width: @screen-sm-min) {\n  .make-grid(sm);\n}\n\n\n// Medium grid\n//\n// Columns, offsets, pushes, and pulls for the desktop device range.\n\n@media (min-width: @screen-md-min) {\n  .make-grid(md);\n}\n\n\n// Large grid\n//\n// Columns, offsets, pushes, and pulls for the large desktop device range.\n\n@media (min-width: @screen-lg-min) {\n  .make-grid(lg);\n}\n","//\n// Tables\n// --------------------------------------------------\n\n\ntable {\n  max-width: 100%;\n  background-color: @table-bg;\n}\nth {\n  text-align: left;\n}\n\n\n// Baseline styles\n\n.table {\n  width: 100%;\n  margin-bottom: @line-height-computed;\n  // Cells\n  > thead,\n  > tbody,\n  > tfoot {\n    > tr {\n      > th,\n      > td {\n        padding: @table-cell-padding;\n        line-height: @line-height-base;\n        vertical-align: top;\n        border-top: 1px solid @table-border-color;\n      }\n    }\n  }\n  // Bottom align for column headings\n  > thead > tr > th {\n    vertical-align: bottom;\n    border-bottom: 2px solid @table-border-color;\n  }\n  // Remove top border from thead by default\n  > caption + thead,\n  > colgroup + thead,\n  > thead:first-child {\n    > tr:first-child {\n      > th,\n      > td {\n        border-top: 0;\n      }\n    }\n  }\n  // Account for multiple tbody instances\n  > tbody + tbody {\n    border-top: 2px solid @table-border-color;\n  }\n\n  // Nesting\n  .table {\n    background-color: @body-bg;\n  }\n}\n\n\n// Condensed table w/ half padding\n\n.table-condensed {\n  > thead,\n  > tbody,\n  > tfoot {\n    > tr {\n      > th,\n      > td {\n        padding: @table-condensed-cell-padding;\n      }\n    }\n  }\n}\n\n\n// Bordered version\n//\n// Add borders all around the table and between all the columns.\n\n.table-bordered {\n  border: 1px solid @table-border-color;\n  > thead,\n  > tbody,\n  > tfoot {\n    > tr {\n      > th,\n      > td {\n        border: 1px solid @table-border-color;\n      }\n    }\n  }\n  > thead > tr {\n    > th,\n    > td {\n      border-bottom-width: 2px;\n    }\n  }\n}\n\n\n// Zebra-striping\n//\n// Default zebra-stripe styles (alternating gray and transparent backgrounds)\n\n.table-striped {\n  > tbody > tr:nth-child(odd) {\n    > td,\n    > th {\n      background-color: @table-bg-accent;\n    }\n  }\n}\n\n\n// Hover effect\n//\n// Placed here since it has to come after the potential zebra striping\n\n.table-hover {\n  > tbody > tr:hover {\n    > td,\n    > th {\n      background-color: @table-bg-hover;\n    }\n  }\n}\n\n\n// Table cell sizing\n//\n// Reset default table behavior\n\ntable col[class*=\"col-\"] {\n  position: static; // Prevent border hiding in Firefox and IE9/10 (see https://github.com/twbs/bootstrap/issues/11623)\n  float: none;\n  display: table-column;\n}\ntable {\n  td,\n  th {\n    &[class*=\"col-\"] {\n      position: static; // Prevent border hiding in Firefox and IE9/10 (see https://github.com/twbs/bootstrap/issues/11623)\n      float: none;\n      display: table-cell;\n    }\n  }\n}\n\n\n// Table backgrounds\n//\n// Exact selectors below required to override `.table-striped` and prevent\n// inheritance to nested tables.\n\n// Generate the contextual variants\n.table-row-variant(active; @table-bg-active);\n.table-row-variant(success; @state-success-bg);\n.table-row-variant(info; @state-info-bg);\n.table-row-variant(warning; @state-warning-bg);\n.table-row-variant(danger; @state-danger-bg);\n\n\n// Responsive tables\n//\n// Wrap your tables in `.table-responsive` and we'll make them mobile friendly\n// by enabling horizontal scrolling. Only applies <768px. Everything above that\n// will display normally.\n\n@media (max-width: @screen-xs-max) {\n  .table-responsive {\n    width: 100%;\n    margin-bottom: (@line-height-computed * 0.75);\n    overflow-y: hidden;\n    overflow-x: scroll;\n    -ms-overflow-style: -ms-autohiding-scrollbar;\n    border: 1px solid @table-border-color;\n    -webkit-overflow-scrolling: touch;\n\n    // Tighten up spacing\n    > .table {\n      margin-bottom: 0;\n\n      // Ensure the content doesn't wrap\n      > thead,\n      > tbody,\n      > tfoot {\n        > tr {\n          > th,\n          > td {\n            white-space: nowrap;\n          }\n        }\n      }\n    }\n\n    // Special overrides for the bordered tables\n    > .table-bordered {\n      border: 0;\n\n      // Nuke the appropriate borders so that the parent can handle them\n      > thead,\n      > tbody,\n      > tfoot {\n        > tr {\n          > th:first-child,\n          > td:first-child {\n            border-left: 0;\n          }\n          > th:last-child,\n          > td:last-child {\n            border-right: 0;\n          }\n        }\n      }\n\n      // Only nuke the last row's bottom-border in `tbody` and `tfoot` since\n      // chances are there will be only one `tr` in a `thead` and that would\n      // remove the border altogether.\n      > tbody,\n      > tfoot {\n        > tr:last-child {\n          > th,\n          > td {\n            border-bottom: 0;\n          }\n        }\n      }\n\n    }\n  }\n}\n","//\n// Forms\n// --------------------------------------------------\n\n\n// Normalize non-controls\n//\n// Restyle and baseline non-control form elements.\n\nfieldset {\n  padding: 0;\n  margin: 0;\n  border: 0;\n  // Chrome and Firefox set a `min-width: -webkit-min-content;` on fieldsets,\n  // so we reset that to ensure it behaves more like a standard block element.\n  // See https://github.com/twbs/bootstrap/issues/12359.\n  min-width: 0;\n}\n\nlegend {\n  display: block;\n  width: 100%;\n  padding: 0;\n  margin-bottom: @line-height-computed;\n  font-size: (@font-size-base * 1.5);\n  line-height: inherit;\n  color: @legend-color;\n  border: 0;\n  border-bottom: 1px solid @legend-border-color;\n}\n\nlabel {\n  display: inline-block;\n  margin-bottom: 5px;\n  font-weight: bold;\n}\n\n\n// Normalize form controls\n//\n// While most of our form styles require extra classes, some basic normalization\n// is required to ensure optimum display with or without those classes to better\n// address browser inconsistencies.\n\n// Override content-box in Normalize (* isn't specific enough)\ninput[type=\"search\"] {\n  .box-sizing(border-box);\n}\n\n// Position radios and checkboxes better\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n  margin: 4px 0 0;\n  margin-top: 1px \\9; /* IE8-9 */\n  line-height: normal;\n}\n\n// Set the height of file controls to match text inputs\ninput[type=\"file\"] {\n  display: block;\n}\n\n// Make range inputs behave like textual form controls\ninput[type=\"range\"] {\n  display: block;\n  width: 100%;\n}\n\n// Make multiple select elements height not fixed\nselect[multiple],\nselect[size] {\n  height: auto;\n}\n\n// Focus for file, radio, and checkbox\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n  .tab-focus();\n}\n\n// Adjust output element\noutput {\n  display: block;\n  padding-top: (@padding-base-vertical + 1);\n  font-size: @font-size-base;\n  line-height: @line-height-base;\n  color: @input-color;\n}\n\n\n// Common form controls\n//\n// Shared size and type resets for form controls. Apply `.form-control` to any\n// of the following form controls:\n//\n// select\n// textarea\n// input[type=\"text\"]\n// input[type=\"password\"]\n// input[type=\"datetime\"]\n// input[type=\"datetime-local\"]\n// input[type=\"date\"]\n// input[type=\"month\"]\n// input[type=\"time\"]\n// input[type=\"week\"]\n// input[type=\"number\"]\n// input[type=\"email\"]\n// input[type=\"url\"]\n// input[type=\"search\"]\n// input[type=\"tel\"]\n// input[type=\"color\"]\n\n.form-control {\n  display: block;\n  width: 100%;\n  height: @input-height-base; // Make inputs at least the height of their button counterpart (base line-height + padding + border)\n  padding: @padding-base-vertical @padding-base-horizontal;\n  font-size: @font-size-base;\n  line-height: @line-height-base;\n  color: @input-color;\n  background-color: @input-bg;\n  background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n  border: 1px solid @input-border;\n  border-radius: @input-border-radius;\n  .box-shadow(inset 0 1px 1px rgba(0,0,0,.075));\n  .transition(~\"border-color ease-in-out .15s, box-shadow ease-in-out .15s\");\n\n  // Customize the `:focus` state to imitate native WebKit styles.\n  .form-control-focus();\n\n  // Placeholder\n  .placeholder();\n\n  // Disabled and read-only inputs\n  //\n  // HTML5 says that controls under a fieldset > legend:first-child won't be\n  // disabled if the fieldset is disabled. Due to implementation difficulty, we\n  // don't honor that edge case; we style them as disabled anyway.\n  &[disabled],\n  &[readonly],\n  fieldset[disabled] & {\n    cursor: not-allowed;\n    background-color: @input-bg-disabled;\n    opacity: 1; // iOS fix for unreadable disabled content\n  }\n\n  // Reset height for `textarea`s\n  textarea& {\n    height: auto;\n  }\n}\n\n\n// Search inputs in iOS\n//\n// This overrides the extra rounded corners on search inputs in iOS so that our\n// `.form-control` class can properly style them. Note that this cannot simply\n// be added to `.form-control` as it's not specific enough. For details, see\n// https://github.com/twbs/bootstrap/issues/11586.\n\ninput[type=\"search\"] {\n  -webkit-appearance: none;\n}\n\n\n// Special styles for iOS date input\n//\n// In Mobile Safari, date inputs require a pixel line-height that matches the\n// given height of the input.\n\ninput[type=\"date\"] {\n  line-height: @input-height-base;\n}\n\n\n// Form groups\n//\n// Designed to help with the organization and spacing of vertical forms. For\n// horizontal forms, use the predefined grid classes.\n\n.form-group {\n  margin-bottom: 15px;\n}\n\n\n// Checkboxes and radios\n//\n// Indent the labels to position radios/checkboxes as hanging controls.\n\n.radio,\n.checkbox {\n  display: block;\n  min-height: @line-height-computed; // clear the floating input if there is no label text\n  margin-top: 10px;\n  margin-bottom: 10px;\n  padding-left: 20px;\n  label {\n    display: inline;\n    font-weight: normal;\n    cursor: pointer;\n  }\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n  float: left;\n  margin-left: -20px;\n}\n.radio + .radio,\n.checkbox + .checkbox {\n  margin-top: -5px; // Move up sibling radios or checkboxes for tighter spacing\n}\n\n// Radios and checkboxes on same line\n.radio-inline,\n.checkbox-inline {\n  display: inline-block;\n  padding-left: 20px;\n  margin-bottom: 0;\n  vertical-align: middle;\n  font-weight: normal;\n  cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n  margin-top: 0;\n  margin-left: 10px; // space out consecutive inline controls\n}\n\n// Apply same disabled cursor tweak as for inputs\n//\n// Note: Neither radios nor checkboxes can be readonly.\ninput[type=\"radio\"],\ninput[type=\"checkbox\"],\n.radio,\n.radio-inline,\n.checkbox,\n.checkbox-inline {\n  &[disabled],\n  fieldset[disabled] & {\n    cursor: not-allowed;\n  }\n}\n\n\n// Form control sizing\n//\n// Build on `.form-control` with modifier classes to decrease or increase the\n// height and font-size of form controls.\n\n.input-sm {\n  .input-size(@input-height-small; @padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @border-radius-small);\n}\n\n.input-lg {\n  .input-size(@input-height-large; @padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @border-radius-large);\n}\n\n\n// Form control feedback states\n//\n// Apply contextual and semantic states to individual form controls.\n\n.has-feedback {\n  // Enable absolute positioning\n  position: relative;\n\n  // Ensure icons don't overlap text\n  .form-control {\n    padding-right: (@input-height-base * 1.25);\n  }\n\n  // Feedback icon (requires .glyphicon classes)\n  .form-control-feedback {\n    position: absolute;\n    top: (@line-height-computed + 5); // Height of the `label` and its margin\n    right: 0;\n    display: block;\n    width: @input-height-base;\n    height: @input-height-base;\n    line-height: @input-height-base;\n    text-align: center;\n  }\n}\n\n// Feedback states\n.has-success {\n  .form-control-validation(@state-success-text; @state-success-text; @state-success-bg);\n}\n.has-warning {\n  .form-control-validation(@state-warning-text; @state-warning-text; @state-warning-bg);\n}\n.has-error {\n  .form-control-validation(@state-danger-text; @state-danger-text; @state-danger-bg);\n}\n\n\n// Static form control text\n//\n// Apply class to a `p` element to make any string of text align with labels in\n// a horizontal form layout.\n\n.form-control-static {\n  margin-bottom: 0; // Remove default margin from `p`\n}\n\n\n// Help text\n//\n// Apply to any element you wish to create light text for placement immediately\n// below a form control. Use for general help, formatting, or instructional text.\n\n.help-block {\n  display: block; // account for any element using help-block\n  margin-top: 5px;\n  margin-bottom: 10px;\n  color: lighten(@text-color, 25%); // lighten the text some for contrast\n}\n\n\n\n// Inline forms\n//\n// Make forms appear inline(-block) by adding the `.form-inline` class. Inline\n// forms begin stacked on extra small (mobile) devices and then go inline when\n// viewports reach <768px.\n//\n// Requires wrapping inputs and labels with `.form-group` for proper display of\n// default HTML form controls and our custom form controls (e.g., input groups).\n//\n// Heads up! This is mixin-ed into `.navbar-form` in navbars.less.\n\n.form-inline {\n\n  // Kick in the inline\n  @media (min-width: @screen-sm-min) {\n    // Inline-block all the things for \"inline\"\n    .form-group {\n      display: inline-block;\n      margin-bottom: 0;\n      vertical-align: middle;\n    }\n\n    // In navbar-form, allow folks to *not* use `.form-group`\n    .form-control {\n      display: inline-block;\n      width: auto; // Prevent labels from stacking above inputs in `.form-group`\n      vertical-align: middle;\n    }\n    // Input groups need that 100% width though\n    .input-group > .form-control {\n      width: 100%;\n    }\n\n    .control-label {\n      margin-bottom: 0;\n      vertical-align: middle;\n    }\n\n    // Remove default margin on radios/checkboxes that were used for stacking, and\n    // then undo the floating of radios and checkboxes to match (which also avoids\n    // a bug in WebKit: https://github.com/twbs/bootstrap/issues/1969).\n    .radio,\n    .checkbox {\n      display: inline-block;\n      margin-top: 0;\n      margin-bottom: 0;\n      padding-left: 0;\n      vertical-align: middle;\n    }\n    .radio input[type=\"radio\"],\n    .checkbox input[type=\"checkbox\"] {\n      float: none;\n      margin-left: 0;\n    }\n\n    // Validation states\n    //\n    // Reposition the icon because it's now within a grid column and columns have\n    // `position: relative;` on them. Also accounts for the grid gutter padding.\n    .has-feedback .form-control-feedback {\n      top: 0;\n    }\n  }\n}\n\n\n// Horizontal forms\n//\n// Horizontal forms are built on grid classes and allow you to create forms with\n// labels on the left and inputs on the right.\n\n.form-horizontal {\n\n  // Consistent vertical alignment of labels, radios, and checkboxes\n  .control-label,\n  .radio,\n  .checkbox,\n  .radio-inline,\n  .checkbox-inline {\n    margin-top: 0;\n    margin-bottom: 0;\n    padding-top: (@padding-base-vertical + 1); // Default padding plus a border\n  }\n  // Account for padding we're adding to ensure the alignment and of help text\n  // and other content below items\n  .radio,\n  .checkbox {\n    min-height: (@line-height-computed + (@padding-base-vertical + 1));\n  }\n\n  // Make form groups behave like rows\n  .form-group {\n    .make-row();\n  }\n\n  .form-control-static {\n    padding-top: (@padding-base-vertical + 1);\n  }\n\n  // Only right align form labels here when the columns stop stacking\n  @media (min-width: @screen-sm-min) {\n    .control-label {\n      text-align: right;\n    }\n  }\n\n  // Validation states\n  //\n  // Reposition the icon because it's now within a grid column and columns have\n  // `position: relative;` on them. Also accounts for the grid gutter padding.\n  .has-feedback .form-control-feedback {\n    top: 0;\n    right: (@grid-gutter-width / 2);\n  }\n}\n","//\n// Buttons\n// --------------------------------------------------\n\n\n// Base styles\n// --------------------------------------------------\n\n.btn {\n  display: inline-block;\n  margin-bottom: 0; // For input.btn\n  font-weight: @btn-font-weight;\n  text-align: center;\n  vertical-align: middle;\n  cursor: pointer;\n  background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n  border: 1px solid transparent;\n  white-space: nowrap;\n  .button-size(@padding-base-vertical; @padding-base-horizontal; @font-size-base; @line-height-base; @border-radius-base);\n  .user-select(none);\n\n  &,\n  &:active,\n  &.active {\n    &:focus {\n      .tab-focus();\n    }\n  }\n\n  &:hover,\n  &:focus {\n    color: @btn-default-color;\n    text-decoration: none;\n  }\n\n  &:active,\n  &.active {\n    outline: 0;\n    background-image: none;\n    .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n  }\n\n  &.disabled,\n  &[disabled],\n  fieldset[disabled] & {\n    cursor: not-allowed;\n    pointer-events: none; // Future-proof disabling of clicks\n    .opacity(.65);\n    .box-shadow(none);\n  }\n}\n\n\n// Alternate buttons\n// --------------------------------------------------\n\n.btn-default {\n  .button-variant(@btn-default-color; @btn-default-bg; @btn-default-border);\n}\n.btn-primary {\n  .button-variant(@btn-primary-color; @btn-primary-bg; @btn-primary-border);\n}\n// Success appears as green\n.btn-success {\n  .button-variant(@btn-success-color; @btn-success-bg; @btn-success-border);\n}\n// Info appears as blue-green\n.btn-info {\n  .button-variant(@btn-info-color; @btn-info-bg; @btn-info-border);\n}\n// Warning appears as orange\n.btn-warning {\n  .button-variant(@btn-warning-color; @btn-warning-bg; @btn-warning-border);\n}\n// Danger and error appear as red\n.btn-danger {\n  .button-variant(@btn-danger-color; @btn-danger-bg; @btn-danger-border);\n}\n\n\n// Link buttons\n// -------------------------\n\n// Make a button look and behave like a link\n.btn-link {\n  color: @link-color;\n  font-weight: normal;\n  cursor: pointer;\n  border-radius: 0;\n\n  &,\n  &:active,\n  &[disabled],\n  fieldset[disabled] & {\n    background-color: transparent;\n    .box-shadow(none);\n  }\n  &,\n  &:hover,\n  &:focus,\n  &:active {\n    border-color: transparent;\n  }\n  &:hover,\n  &:focus {\n    color: @link-hover-color;\n    text-decoration: underline;\n    background-color: transparent;\n  }\n  &[disabled],\n  fieldset[disabled] & {\n    &:hover,\n    &:focus {\n      color: @btn-link-disabled-color;\n      text-decoration: none;\n    }\n  }\n}\n\n\n// Button Sizes\n// --------------------------------------------------\n\n.btn-lg {\n  // line-height: ensure even-numbered height of button next to large input\n  .button-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @border-radius-large);\n}\n.btn-sm {\n  // line-height: ensure proper height of button next to small input\n  .button-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @border-radius-small);\n}\n.btn-xs {\n  .button-size(@padding-xs-vertical; @padding-xs-horizontal; @font-size-small; @line-height-small; @border-radius-small);\n}\n\n\n// Block button\n// --------------------------------------------------\n\n.btn-block {\n  display: block;\n  width: 100%;\n  padding-left: 0;\n  padding-right: 0;\n}\n\n// Vertically space out multiple block buttons\n.btn-block + .btn-block {\n  margin-top: 5px;\n}\n\n// Specificity overrides\ninput[type=\"submit\"],\ninput[type=\"reset\"],\ninput[type=\"button\"] {\n  &.btn-block {\n    width: 100%;\n  }\n}\n","//\n// Button groups\n// --------------------------------------------------\n\n// Make the div behave like a button\n.btn-group,\n.btn-group-vertical {\n  position: relative;\n  display: inline-block;\n  vertical-align: middle; // match .btn alignment given font-size hack above\n  > .btn {\n    position: relative;\n    float: left;\n    // Bring the \"active\" button to the front\n    &:hover,\n    &:focus,\n    &:active,\n    &.active {\n      z-index: 2;\n    }\n    &:focus {\n      // Remove focus outline when dropdown JS adds it after closing the menu\n      outline: none;\n    }\n  }\n}\n\n// Prevent double borders when buttons are next to each other\n.btn-group {\n  .btn + .btn,\n  .btn + .btn-group,\n  .btn-group + .btn,\n  .btn-group + .btn-group {\n    margin-left: -1px;\n  }\n}\n\n// Optional: Group multiple button groups together for a toolbar\n.btn-toolbar {\n  margin-left: -5px; // Offset the first child's margin\n  &:extend(.clearfix all);\n\n  .btn-group,\n  .input-group {\n    float: left;\n  }\n  > .btn,\n  > .btn-group,\n  > .input-group {\n    margin-left: 5px;\n  }\n}\n\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n  border-radius: 0;\n}\n\n// Set corners individual because sometimes a single button can be in a .btn-group and we need :first-child and :last-child to both match\n.btn-group > .btn:first-child {\n  margin-left: 0;\n  &:not(:last-child):not(.dropdown-toggle) {\n    .border-right-radius(0);\n  }\n}\n// Need .dropdown-toggle since :last-child doesn't apply given a .dropdown-menu immediately after it\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n  .border-left-radius(0);\n}\n\n// Custom edits for including btn-groups within btn-groups (useful for including dropdown buttons within a btn-group)\n.btn-group > .btn-group {\n  float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n  border-radius: 0;\n}\n.btn-group > .btn-group:first-child {\n  > .btn:last-child,\n  > .dropdown-toggle {\n    .border-right-radius(0);\n  }\n}\n.btn-group > .btn-group:last-child > .btn:first-child {\n  .border-left-radius(0);\n}\n\n// On active and open, don't show outline\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n  outline: 0;\n}\n\n\n// Sizing\n//\n// Remix the default button sizing classes into new ones for easier manipulation.\n\n.btn-group-xs > .btn { &:extend(.btn-xs); }\n.btn-group-sm > .btn { &:extend(.btn-sm); }\n.btn-group-lg > .btn { &:extend(.btn-lg); }\n\n\n// Split button dropdowns\n// ----------------------\n\n// Give the line between buttons some depth\n.btn-group > .btn + .dropdown-toggle {\n  padding-left: 8px;\n  padding-right: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n  padding-left: 12px;\n  padding-right: 12px;\n}\n\n// The clickable button for toggling the menu\n// Remove the gradient and set the same inset shadow as the :active state\n.btn-group.open .dropdown-toggle {\n  .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n\n  // Show no shadow for `.btn-link` since it has no other button styles.\n  &.btn-link {\n    .box-shadow(none);\n  }\n}\n\n\n// Reposition the caret\n.btn .caret {\n  margin-left: 0;\n}\n// Carets in other button sizes\n.btn-lg .caret {\n  border-width: @caret-width-large @caret-width-large 0;\n  border-bottom-width: 0;\n}\n// Upside down carets for .dropup\n.dropup .btn-lg .caret {\n  border-width: 0 @caret-width-large @caret-width-large;\n}\n\n\n// Vertical button groups\n// ----------------------\n\n.btn-group-vertical {\n  > .btn,\n  > .btn-group,\n  > .btn-group > .btn {\n    display: block;\n    float: none;\n    width: 100%;\n    max-width: 100%;\n  }\n\n  // Clear floats so dropdown menus can be properly placed\n  > .btn-group {\n    &:extend(.clearfix all);\n    > .btn {\n      float: none;\n    }\n  }\n\n  > .btn + .btn,\n  > .btn + .btn-group,\n  > .btn-group + .btn,\n  > .btn-group + .btn-group {\n    margin-top: -1px;\n    margin-left: 0;\n  }\n}\n\n.btn-group-vertical > .btn {\n  &:not(:first-child):not(:last-child) {\n    border-radius: 0;\n  }\n  &:first-child:not(:last-child) {\n    border-top-right-radius: @border-radius-base;\n    .border-bottom-radius(0);\n  }\n  &:last-child:not(:first-child) {\n    border-bottom-left-radius: @border-radius-base;\n    .border-top-radius(0);\n  }\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n  border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) {\n  > .btn:last-child,\n  > .dropdown-toggle {\n    .border-bottom-radius(0);\n  }\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n  .border-top-radius(0);\n}\n\n\n\n// Justified button groups\n// ----------------------\n\n.btn-group-justified {\n  display: table;\n  width: 100%;\n  table-layout: fixed;\n  border-collapse: separate;\n  > .btn,\n  > .btn-group {\n    float: none;\n    display: table-cell;\n    width: 1%;\n  }\n  > .btn-group .btn {\n    width: 100%;\n  }\n}\n\n\n// Checkbox and radio options\n[data-toggle=\"buttons\"] > .btn > input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn > input[type=\"checkbox\"] {\n  display: none;\n}\n","//\n// Component animations\n// --------------------------------------------------\n\n// Heads up!\n//\n// We don't use the `.opacity()` mixin here since it causes a bug with text\n// fields in IE7-8. Source: https://github.com/twitter/bootstrap/pull/3552.\n\n.fade {\n  opacity: 0;\n  .transition(opacity .15s linear);\n  &.in {\n    opacity: 1;\n  }\n}\n\n.collapse {\n  display: none;\n  &.in {\n    display: block;\n  }\n}\n.collapsing {\n  position: relative;\n  height: 0;\n  overflow: hidden;\n  .transition(height .35s ease);\n}\n","//\n// Glyphicons for Bootstrap\n//\n// Since icons are fonts, they can be placed anywhere text is placed and are\n// thus automatically sized to match the surrounding child. To use, create an\n// inline element with the appropriate classes, like so:\n//\n//  Star\n\n// Import the fonts\n@font-face {\n  font-family: 'Glyphicons Halflings';\n  src: ~\"url('@{icon-font-path}@{icon-font-name}.eot')\";\n  src: ~\"url('@{icon-font-path}@{icon-font-name}.eot?#iefix') format('embedded-opentype')\",\n       ~\"url('@{icon-font-path}@{icon-font-name}.woff') format('woff')\",\n       ~\"url('@{icon-font-path}@{icon-font-name}.ttf') format('truetype')\",\n       ~\"url('@{icon-font-path}@{icon-font-name}.svg#@{icon-font-svg-id}') format('svg')\";\n}\n\n// Catchall baseclass\n.glyphicon {\n  position: relative;\n  top: 1px;\n  display: inline-block;\n  font-family: 'Glyphicons Halflings';\n  font-style: normal;\n  font-weight: normal;\n  line-height: 1;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n// Individual icons\n.glyphicon-asterisk               { &:before { content: \"\\2a\"; } }\n.glyphicon-plus                   { &:before { content: \"\\2b\"; } }\n.glyphicon-euro                   { &:before { content: \"\\20ac\"; } }\n.glyphicon-minus                  { &:before { content: \"\\2212\"; } }\n.glyphicon-cloud                  { &:before { content: \"\\2601\"; } }\n.glyphicon-envelope               { &:before { content: \"\\2709\"; } }\n.glyphicon-pencil                 { &:before { content: \"\\270f\"; } }\n.glyphicon-glass                  { &:before { content: \"\\e001\"; } }\n.glyphicon-music                  { &:before { content: \"\\e002\"; } }\n.glyphicon-search                 { &:before { content: \"\\e003\"; } }\n.glyphicon-heart                  { &:before { content: \"\\e005\"; } }\n.glyphicon-star                   { &:before { content: \"\\e006\"; } }\n.glyphicon-star-empty             { &:before { content: \"\\e007\"; } }\n.glyphicon-user                   { &:before { content: \"\\e008\"; } }\n.glyphicon-film                   { &:before { content: \"\\e009\"; } }\n.glyphicon-th-large               { &:before { content: \"\\e010\"; } }\n.glyphicon-th                     { &:before { content: \"\\e011\"; } }\n.glyphicon-th-list                { &:before { content: \"\\e012\"; } }\n.glyphicon-ok                     { &:before { content: \"\\e013\"; } }\n.glyphicon-remove                 { &:before { content: \"\\e014\"; } }\n.glyphicon-zoom-in                { &:before { content: \"\\e015\"; } }\n.glyphicon-zoom-out               { &:before { content: \"\\e016\"; } }\n.glyphicon-off                    { &:before { content: \"\\e017\"; } }\n.glyphicon-signal                 { &:before { content: \"\\e018\"; } }\n.glyphicon-cog                    { &:before { content: \"\\e019\"; } }\n.glyphicon-trash                  { &:before { content: \"\\e020\"; } }\n.glyphicon-home                   { &:before { content: \"\\e021\"; } }\n.glyphicon-file                   { &:before { content: \"\\e022\"; } }\n.glyphicon-time                   { &:before { content: \"\\e023\"; } }\n.glyphicon-road                   { &:before { content: \"\\e024\"; } }\n.glyphicon-download-alt           { &:before { content: \"\\e025\"; } }\n.glyphicon-download               { &:before { content: \"\\e026\"; } }\n.glyphicon-upload                 { &:before { content: \"\\e027\"; } }\n.glyphicon-inbox                  { &:before { content: \"\\e028\"; } }\n.glyphicon-play-circle            { &:before { content: \"\\e029\"; } }\n.glyphicon-repeat                 { &:before { content: \"\\e030\"; } }\n.glyphicon-refresh                { &:before { content: \"\\e031\"; } }\n.glyphicon-list-alt               { &:before { content: \"\\e032\"; } }\n.glyphicon-lock                   { &:before { content: \"\\e033\"; } }\n.glyphicon-flag                   { &:before { content: \"\\e034\"; } }\n.glyphicon-headphones             { &:before { content: \"\\e035\"; } }\n.glyphicon-volume-off             { &:before { content: \"\\e036\"; } }\n.glyphicon-volume-down            { &:before { content: \"\\e037\"; } }\n.glyphicon-volume-up              { &:before { content: \"\\e038\"; } }\n.glyphicon-qrcode                 { &:before { content: \"\\e039\"; } }\n.glyphicon-barcode                { &:before { content: \"\\e040\"; } }\n.glyphicon-tag                    { &:before { content: \"\\e041\"; } }\n.glyphicon-tags                   { &:before { content: \"\\e042\"; } }\n.glyphicon-book                   { &:before { content: \"\\e043\"; } }\n.glyphicon-bookmark               { &:before { content: \"\\e044\"; } }\n.glyphicon-print                  { &:before { content: \"\\e045\"; } }\n.glyphicon-camera                 { &:before { content: \"\\e046\"; } }\n.glyphicon-font                   { &:before { content: \"\\e047\"; } }\n.glyphicon-bold                   { &:before { content: \"\\e048\"; } }\n.glyphicon-italic                 { &:before { content: \"\\e049\"; } }\n.glyphicon-text-height            { &:before { content: \"\\e050\"; } }\n.glyphicon-text-width             { &:before { content: \"\\e051\"; } }\n.glyphicon-align-left             { &:before { content: \"\\e052\"; } }\n.glyphicon-align-center           { &:before { content: \"\\e053\"; } }\n.glyphicon-align-right            { &:before { content: \"\\e054\"; } }\n.glyphicon-align-justify          { &:before { content: \"\\e055\"; } }\n.glyphicon-list                   { &:before { content: \"\\e056\"; } }\n.glyphicon-indent-left            { &:before { content: \"\\e057\"; } }\n.glyphicon-indent-right           { &:before { content: \"\\e058\"; } }\n.glyphicon-facetime-video         { &:before { content: \"\\e059\"; } }\n.glyphicon-picture                { &:before { content: \"\\e060\"; } }\n.glyphicon-map-marker             { &:before { content: \"\\e062\"; } }\n.glyphicon-adjust                 { &:before { content: \"\\e063\"; } }\n.glyphicon-tint                   { &:before { content: \"\\e064\"; } }\n.glyphicon-edit                   { &:before { content: \"\\e065\"; } }\n.glyphicon-share                  { &:before { content: \"\\e066\"; } }\n.glyphicon-check                  { &:before { content: \"\\e067\"; } }\n.glyphicon-move                   { &:before { content: \"\\e068\"; } }\n.glyphicon-step-backward          { &:before { content: \"\\e069\"; } }\n.glyphicon-fast-backward          { &:before { content: \"\\e070\"; } }\n.glyphicon-backward               { &:before { content: \"\\e071\"; } }\n.glyphicon-play                   { &:before { content: \"\\e072\"; } }\n.glyphicon-pause                  { &:before { content: \"\\e073\"; } }\n.glyphicon-stop                   { &:before { content: \"\\e074\"; } }\n.glyphicon-forward                { &:before { content: \"\\e075\"; } }\n.glyphicon-fast-forward           { &:before { content: \"\\e076\"; } }\n.glyphicon-step-forward           { &:before { content: \"\\e077\"; } }\n.glyphicon-eject                  { &:before { content: \"\\e078\"; } }\n.glyphicon-chevron-left           { &:before { content: \"\\e079\"; } }\n.glyphicon-chevron-right          { &:before { content: \"\\e080\"; } }\n.glyphicon-plus-sign              { &:before { content: \"\\e081\"; } }\n.glyphicon-minus-sign             { &:before { content: \"\\e082\"; } }\n.glyphicon-remove-sign            { &:before { content: \"\\e083\"; } }\n.glyphicon-ok-sign                { &:before { content: \"\\e084\"; } }\n.glyphicon-question-sign          { &:before { content: \"\\e085\"; } }\n.glyphicon-info-sign              { &:before { content: \"\\e086\"; } }\n.glyphicon-screenshot             { &:before { content: \"\\e087\"; } }\n.glyphicon-remove-circle          { &:before { content: \"\\e088\"; } }\n.glyphicon-ok-circle              { &:before { content: \"\\e089\"; } }\n.glyphicon-ban-circle             { &:before { content: \"\\e090\"; } }\n.glyphicon-arrow-left             { &:before { content: \"\\e091\"; } }\n.glyphicon-arrow-right            { &:before { content: \"\\e092\"; } }\n.glyphicon-arrow-up               { &:before { content: \"\\e093\"; } }\n.glyphicon-arrow-down             { &:before { content: \"\\e094\"; } }\n.glyphicon-share-alt              { &:before { content: \"\\e095\"; } }\n.glyphicon-resize-full            { &:before { content: \"\\e096\"; } }\n.glyphicon-resize-small           { &:before { content: \"\\e097\"; } }\n.glyphicon-exclamation-sign       { &:before { content: \"\\e101\"; } }\n.glyphicon-gift                   { &:before { content: \"\\e102\"; } }\n.glyphicon-leaf                   { &:before { content: \"\\e103\"; } }\n.glyphicon-fire                   { &:before { content: \"\\e104\"; } }\n.glyphicon-eye-open               { &:before { content: \"\\e105\"; } }\n.glyphicon-eye-close              { &:before { content: \"\\e106\"; } }\n.glyphicon-warning-sign           { &:before { content: \"\\e107\"; } }\n.glyphicon-plane                  { &:before { content: \"\\e108\"; } }\n.glyphicon-calendar               { &:before { content: \"\\e109\"; } }\n.glyphicon-random                 { &:before { content: \"\\e110\"; } }\n.glyphicon-comment                { &:before { content: \"\\e111\"; } }\n.glyphicon-magnet                 { &:before { content: \"\\e112\"; } }\n.glyphicon-chevron-up             { &:before { content: \"\\e113\"; } }\n.glyphicon-chevron-down           { &:before { content: \"\\e114\"; } }\n.glyphicon-retweet                { &:before { content: \"\\e115\"; } }\n.glyphicon-shopping-cart          { &:before { content: \"\\e116\"; } }\n.glyphicon-folder-close           { &:before { content: \"\\e117\"; } }\n.glyphicon-folder-open            { &:before { content: \"\\e118\"; } }\n.glyphicon-resize-vertical        { &:before { content: \"\\e119\"; } }\n.glyphicon-resize-horizontal      { &:before { content: \"\\e120\"; } }\n.glyphicon-hdd                    { &:before { content: \"\\e121\"; } }\n.glyphicon-bullhorn               { &:before { content: \"\\e122\"; } }\n.glyphicon-bell                   { &:before { content: \"\\e123\"; } }\n.glyphicon-certificate            { &:before { content: \"\\e124\"; } }\n.glyphicon-thumbs-up              { &:before { content: \"\\e125\"; } }\n.glyphicon-thumbs-down            { &:before { content: \"\\e126\"; } }\n.glyphicon-hand-right             { &:before { content: \"\\e127\"; } }\n.glyphicon-hand-left              { &:before { content: \"\\e128\"; } }\n.glyphicon-hand-up                { &:before { content: \"\\e129\"; } }\n.glyphicon-hand-down              { &:before { content: \"\\e130\"; } }\n.glyphicon-circle-arrow-right     { &:before { content: \"\\e131\"; } }\n.glyphicon-circle-arrow-left      { &:before { content: \"\\e132\"; } }\n.glyphicon-circle-arrow-up        { &:before { content: \"\\e133\"; } }\n.glyphicon-circle-arrow-down      { &:before { content: \"\\e134\"; } }\n.glyphicon-globe                  { &:before { content: \"\\e135\"; } }\n.glyphicon-wrench                 { &:before { content: \"\\e136\"; } }\n.glyphicon-tasks                  { &:before { content: \"\\e137\"; } }\n.glyphicon-filter                 { &:before { content: \"\\e138\"; } }\n.glyphicon-briefcase              { &:before { content: \"\\e139\"; } }\n.glyphicon-fullscreen             { &:before { content: \"\\e140\"; } }\n.glyphicon-dashboard              { &:before { content: \"\\e141\"; } }\n.glyphicon-paperclip              { &:before { content: \"\\e142\"; } }\n.glyphicon-heart-empty            { &:before { content: \"\\e143\"; } }\n.glyphicon-link                   { &:before { content: \"\\e144\"; } }\n.glyphicon-phone                  { &:before { content: \"\\e145\"; } }\n.glyphicon-pushpin                { &:before { content: \"\\e146\"; } }\n.glyphicon-usd                    { &:before { content: \"\\e148\"; } }\n.glyphicon-gbp                    { &:before { content: \"\\e149\"; } }\n.glyphicon-sort                   { &:before { content: \"\\e150\"; } }\n.glyphicon-sort-by-alphabet       { &:before { content: \"\\e151\"; } }\n.glyphicon-sort-by-alphabet-alt   { &:before { content: \"\\e152\"; } }\n.glyphicon-sort-by-order          { &:before { content: \"\\e153\"; } }\n.glyphicon-sort-by-order-alt      { &:before { content: \"\\e154\"; } }\n.glyphicon-sort-by-attributes     { &:before { content: \"\\e155\"; } }\n.glyphicon-sort-by-attributes-alt { &:before { content: \"\\e156\"; } }\n.glyphicon-unchecked              { &:before { content: \"\\e157\"; } }\n.glyphicon-expand                 { &:before { content: \"\\e158\"; } }\n.glyphicon-collapse-down          { &:before { content: \"\\e159\"; } }\n.glyphicon-collapse-up            { &:before { content: \"\\e160\"; } }\n.glyphicon-log-in                 { &:before { content: \"\\e161\"; } }\n.glyphicon-flash                  { &:before { content: \"\\e162\"; } }\n.glyphicon-log-out                { &:before { content: \"\\e163\"; } }\n.glyphicon-new-window             { &:before { content: \"\\e164\"; } }\n.glyphicon-record                 { &:before { content: \"\\e165\"; } }\n.glyphicon-save                   { &:before { content: \"\\e166\"; } }\n.glyphicon-open                   { &:before { content: \"\\e167\"; } }\n.glyphicon-saved                  { &:before { content: \"\\e168\"; } }\n.glyphicon-import                 { &:before { content: \"\\e169\"; } }\n.glyphicon-export                 { &:before { content: \"\\e170\"; } }\n.glyphicon-send                   { &:before { content: \"\\e171\"; } }\n.glyphicon-floppy-disk            { &:before { content: \"\\e172\"; } }\n.glyphicon-floppy-saved           { &:before { content: \"\\e173\"; } }\n.glyphicon-floppy-remove          { &:before { content: \"\\e174\"; } }\n.glyphicon-floppy-save            { &:before { content: \"\\e175\"; } }\n.glyphicon-floppy-open            { &:before { content: \"\\e176\"; } }\n.glyphicon-credit-card            { &:before { content: \"\\e177\"; } }\n.glyphicon-transfer               { &:before { content: \"\\e178\"; } }\n.glyphicon-cutlery                { &:before { content: \"\\e179\"; } }\n.glyphicon-header                 { &:before { content: \"\\e180\"; } }\n.glyphicon-compressed             { &:before { content: \"\\e181\"; } }\n.glyphicon-earphone               { &:before { content: \"\\e182\"; } }\n.glyphicon-phone-alt              { &:before { content: \"\\e183\"; } }\n.glyphicon-tower                  { &:before { content: \"\\e184\"; } }\n.glyphicon-stats                  { &:before { content: \"\\e185\"; } }\n.glyphicon-sd-video               { &:before { content: \"\\e186\"; } }\n.glyphicon-hd-video               { &:before { content: \"\\e187\"; } }\n.glyphicon-subtitles              { &:before { content: \"\\e188\"; } }\n.glyphicon-sound-stereo           { &:before { content: \"\\e189\"; } }\n.glyphicon-sound-dolby            { &:before { content: \"\\e190\"; } }\n.glyphicon-sound-5-1              { &:before { content: \"\\e191\"; } }\n.glyphicon-sound-6-1              { &:before { content: \"\\e192\"; } }\n.glyphicon-sound-7-1              { &:before { content: \"\\e193\"; } }\n.glyphicon-copyright-mark         { &:before { content: \"\\e194\"; } }\n.glyphicon-registration-mark      { &:before { content: \"\\e195\"; } }\n.glyphicon-cloud-download         { &:before { content: \"\\e197\"; } }\n.glyphicon-cloud-upload           { &:before { content: \"\\e198\"; } }\n.glyphicon-tree-conifer           { &:before { content: \"\\e199\"; } }\n.glyphicon-tree-deciduous         { &:before { content: \"\\e200\"; } }\n","//\n// Dropdown menus\n// --------------------------------------------------\n\n\n// Dropdown arrow/caret\n.caret {\n  display: inline-block;\n  width: 0;\n  height: 0;\n  margin-left: 2px;\n  vertical-align: middle;\n  border-top:   @caret-width-base solid;\n  border-right: @caret-width-base solid transparent;\n  border-left:  @caret-width-base solid transparent;\n}\n\n// The dropdown wrapper (div)\n.dropdown {\n  position: relative;\n}\n\n// Prevent the focus on the dropdown toggle when closing dropdowns\n.dropdown-toggle:focus {\n  outline: 0;\n}\n\n// The dropdown menu (ul)\n.dropdown-menu {\n  position: absolute;\n  top: 100%;\n  left: 0;\n  z-index: @zindex-dropdown;\n  display: none; // none by default, but block on \"open\" of the menu\n  float: left;\n  min-width: 160px;\n  padding: 5px 0;\n  margin: 2px 0 0; // override default ul\n  list-style: none;\n  font-size: @font-size-base;\n  background-color: @dropdown-bg;\n  border: 1px solid @dropdown-fallback-border; // IE8 fallback\n  border: 1px solid @dropdown-border;\n  border-radius: @border-radius-base;\n  .box-shadow(0 6px 12px rgba(0,0,0,.175));\n  background-clip: padding-box;\n\n  // Aligns the dropdown menu to right\n  //\n  // Deprecated as of 3.1.0 in favor of `.dropdown-menu-[dir]`\n  &.pull-right {\n    right: 0;\n    left: auto;\n  }\n\n  // Dividers (basically an hr) within the dropdown\n  .divider {\n    .nav-divider(@dropdown-divider-bg);\n  }\n\n  // Links within the dropdown menu\n  > li > a {\n    display: block;\n    padding: 3px 20px;\n    clear: both;\n    font-weight: normal;\n    line-height: @line-height-base;\n    color: @dropdown-link-color;\n    white-space: nowrap; // prevent links from randomly breaking onto new lines\n  }\n}\n\n// Hover/Focus state\n.dropdown-menu > li > a {\n  &:hover,\n  &:focus {\n    text-decoration: none;\n    color: @dropdown-link-hover-color;\n    background-color: @dropdown-link-hover-bg;\n  }\n}\n\n// Active state\n.dropdown-menu > .active > a {\n  &,\n  &:hover,\n  &:focus {\n    color: @dropdown-link-active-color;\n    text-decoration: none;\n    outline: 0;\n    background-color: @dropdown-link-active-bg;\n  }\n}\n\n// Disabled state\n//\n// Gray out text and ensure the hover/focus state remains gray\n\n.dropdown-menu > .disabled > a {\n  &,\n  &:hover,\n  &:focus {\n    color: @dropdown-link-disabled-color;\n  }\n}\n// Nuke hover/focus effects\n.dropdown-menu > .disabled > a {\n  &:hover,\n  &:focus {\n    text-decoration: none;\n    background-color: transparent;\n    background-image: none; // Remove CSS gradient\n    .reset-filter();\n    cursor: not-allowed;\n  }\n}\n\n// Open state for the dropdown\n.open {\n  // Show the menu\n  > .dropdown-menu {\n    display: block;\n  }\n\n  // Remove the outline when :focus is triggered\n  > a {\n    outline: 0;\n  }\n}\n\n// Menu positioning\n//\n// Add extra class to `.dropdown-menu` to flip the alignment of the dropdown\n// menu with the parent.\n.dropdown-menu-right {\n  left: auto; // Reset the default from `.dropdown-menu`\n  right: 0;\n}\n// With v3, we enabled auto-flipping if you have a dropdown within a right\n// aligned nav component. To enable the undoing of that, we provide an override\n// to restore the default dropdown menu alignment.\n//\n// This is only for left-aligning a dropdown menu within a `.navbar-right` or\n// `.pull-right` nav component.\n.dropdown-menu-left {\n  left: 0;\n  right: auto;\n}\n\n// Dropdown section headers\n.dropdown-header {\n  display: block;\n  padding: 3px 20px;\n  font-size: @font-size-small;\n  line-height: @line-height-base;\n  color: @dropdown-header-color;\n}\n\n// Backdrop to catch body clicks on mobile, etc.\n.dropdown-backdrop {\n  position: fixed;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  top: 0;\n  z-index: (@zindex-dropdown - 10);\n}\n\n// Right aligned dropdowns\n.pull-right > .dropdown-menu {\n  right: 0;\n  left: auto;\n}\n\n// Allow for dropdowns to go bottom up (aka, dropup-menu)\n//\n// Just add .dropup after the standard .dropdown class and you're set, bro.\n// TODO: abstract this so that the navbar fixed styles are not placed here?\n\n.dropup,\n.navbar-fixed-bottom .dropdown {\n  // Reverse the caret\n  .caret {\n    border-top: 0;\n    border-bottom: @caret-width-base solid;\n    content: \"\";\n  }\n  // Different positioning for bottom up menu\n  .dropdown-menu {\n    top: auto;\n    bottom: 100%;\n    margin-bottom: 1px;\n  }\n}\n\n\n// Component alignment\n//\n// Reiterate per navbar.less and the modified component alignment there.\n\n@media (min-width: @grid-float-breakpoint) {\n  .navbar-right {\n    .dropdown-menu {\n      .dropdown-menu-right();\n    }\n    // Necessary for overrides of the default right aligned menu.\n    // Will remove come v4 in all likelihood.\n    .dropdown-menu-left {\n      .dropdown-menu-left();\n    }\n  }\n}\n\n","//\n// Input groups\n// --------------------------------------------------\n\n// Base styles\n// -------------------------\n.input-group {\n  position: relative; // For dropdowns\n  display: table;\n  border-collapse: separate; // prevent input groups from inheriting border styles from table cells when placed within a table\n\n  // Undo padding and float of grid classes\n  &[class*=\"col-\"] {\n    float: none;\n    padding-left: 0;\n    padding-right: 0;\n  }\n\n  .form-control {\n    // Ensure that the input is always above the *appended* addon button for\n    // proper border colors.\n    position: relative;\n    z-index: 2;\n\n    // IE9 fubars the placeholder attribute in text inputs and the arrows on\n    // select elements in input groups. To fix it, we float the input. Details:\n    // https://github.com/twbs/bootstrap/issues/11561#issuecomment-28936855\n    float: left;\n\n    width: 100%;\n    margin-bottom: 0;\n  }\n}\n\n// Sizing options\n//\n// Remix the default form control sizing classes into new ones for easier\n// manipulation.\n\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn { .input-lg(); }\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn { .input-sm(); }\n\n\n// Display as table-cell\n// -------------------------\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n  display: table-cell;\n\n  &:not(:first-child):not(:last-child) {\n    border-radius: 0;\n  }\n}\n// Addon and addon wrapper for buttons\n.input-group-addon,\n.input-group-btn {\n  width: 1%;\n  white-space: nowrap;\n  vertical-align: middle; // Match the inputs\n}\n\n// Text input groups\n// -------------------------\n.input-group-addon {\n  padding: @padding-base-vertical @padding-base-horizontal;\n  font-size: @font-size-base;\n  font-weight: normal;\n  line-height: 1;\n  color: @input-color;\n  text-align: center;\n  background-color: @input-group-addon-bg;\n  border: 1px solid @input-group-addon-border-color;\n  border-radius: @border-radius-base;\n\n  // Sizing\n  &.input-sm {\n    padding: @padding-small-vertical @padding-small-horizontal;\n    font-size: @font-size-small;\n    border-radius: @border-radius-small;\n  }\n  &.input-lg {\n    padding: @padding-large-vertical @padding-large-horizontal;\n    font-size: @font-size-large;\n    border-radius: @border-radius-large;\n  }\n\n  // Nuke default margins from checkboxes and radios to vertically center within.\n  input[type=\"radio\"],\n  input[type=\"checkbox\"] {\n    margin-top: 0;\n  }\n}\n\n// Reset rounded corners\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n  .border-right-radius(0);\n}\n.input-group-addon:first-child {\n  border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n  .border-left-radius(0);\n}\n.input-group-addon:last-child {\n  border-left: 0;\n}\n\n// Button input groups\n// -------------------------\n.input-group-btn {\n  position: relative;\n  // Jankily prevent input button groups from wrapping with `white-space` and\n  // `font-size` in combination with `inline-block` on buttons.\n  font-size: 0;\n  white-space: nowrap;\n\n  // Negative margin for spacing, position for bringing hovered/focused/actived\n  // element above the siblings.\n  > .btn {\n    position: relative;\n    + .btn {\n      margin-left: -1px;\n    }\n    // Bring the \"active\" button to the front\n    &:hover,\n    &:focus,\n    &:active {\n      z-index: 2;\n    }\n  }\n\n  // Negative margin to only have a 1px border between the two\n  &:first-child {\n    > .btn,\n    > .btn-group {\n      margin-right: -1px;\n    }\n  }\n  &:last-child {\n    > .btn,\n    > .btn-group {\n      margin-left: -1px;\n    }\n  }\n}\n","//\n// Navs\n// --------------------------------------------------\n\n\n// Base class\n// --------------------------------------------------\n\n.nav {\n  margin-bottom: 0;\n  padding-left: 0; // Override default ul/ol\n  list-style: none;\n  &:extend(.clearfix all);\n\n  > li {\n    position: relative;\n    display: block;\n\n    > a {\n      position: relative;\n      display: block;\n      padding: @nav-link-padding;\n      &:hover,\n      &:focus {\n        text-decoration: none;\n        background-color: @nav-link-hover-bg;\n      }\n    }\n\n    // Disabled state sets text to gray and nukes hover/tab effects\n    &.disabled > a {\n      color: @nav-disabled-link-color;\n\n      &:hover,\n      &:focus {\n        color: @nav-disabled-link-hover-color;\n        text-decoration: none;\n        background-color: transparent;\n        cursor: not-allowed;\n      }\n    }\n  }\n\n  // Open dropdowns\n  .open > a {\n    &,\n    &:hover,\n    &:focus {\n      background-color: @nav-link-hover-bg;\n      border-color: @link-color;\n    }\n  }\n\n  // Nav dividers (deprecated with v3.0.1)\n  //\n  // This should have been removed in v3 with the dropping of `.nav-list`, but\n  // we missed it. We don't currently support this anywhere, but in the interest\n  // of maintaining backward compatibility in case you use it, it's deprecated.\n  .nav-divider {\n    .nav-divider();\n  }\n\n  // Prevent IE8 from misplacing imgs\n  //\n  // See https://github.com/h5bp/html5-boilerplate/issues/984#issuecomment-3985989\n  > li > a > img {\n    max-width: none;\n  }\n}\n\n\n// Tabs\n// -------------------------\n\n// Give the tabs something to sit on\n.nav-tabs {\n  border-bottom: 1px solid @nav-tabs-border-color;\n  > li {\n    float: left;\n    // Make the list-items overlay the bottom border\n    margin-bottom: -1px;\n\n    // Actual tabs (as links)\n    > a {\n      margin-right: 2px;\n      line-height: @line-height-base;\n      border: 1px solid transparent;\n      border-radius: @border-radius-base @border-radius-base 0 0;\n      &:hover {\n        border-color: @nav-tabs-link-hover-border-color @nav-tabs-link-hover-border-color @nav-tabs-border-color;\n      }\n    }\n\n    // Active state, and its :hover to override normal :hover\n    &.active > a {\n      &,\n      &:hover,\n      &:focus {\n        color: @nav-tabs-active-link-hover-color;\n        background-color: @nav-tabs-active-link-hover-bg;\n        border: 1px solid @nav-tabs-active-link-hover-border-color;\n        border-bottom-color: transparent;\n        cursor: default;\n      }\n    }\n  }\n  // pulling this in mainly for less shorthand\n  &.nav-justified {\n    .nav-justified();\n    .nav-tabs-justified();\n  }\n}\n\n\n// Pills\n// -------------------------\n.nav-pills {\n  > li {\n    float: left;\n\n    // Links rendered as pills\n    > a {\n      border-radius: @nav-pills-border-radius;\n    }\n    + li {\n      margin-left: 2px;\n    }\n\n    // Active state\n    &.active > a {\n      &,\n      &:hover,\n      &:focus {\n        color: @nav-pills-active-link-hover-color;\n        background-color: @nav-pills-active-link-hover-bg;\n      }\n    }\n  }\n}\n\n\n// Stacked pills\n.nav-stacked {\n  > li {\n    float: none;\n    + li {\n      margin-top: 2px;\n      margin-left: 0; // no need for this gap between nav items\n    }\n  }\n}\n\n\n// Nav variations\n// --------------------------------------------------\n\n// Justified nav links\n// -------------------------\n\n.nav-justified {\n  width: 100%;\n\n  > li {\n    float: none;\n     > a {\n      text-align: center;\n      margin-bottom: 5px;\n    }\n  }\n\n  > .dropdown .dropdown-menu {\n    top: auto;\n    left: auto;\n  }\n\n  @media (min-width: @screen-sm-min) {\n    > li {\n      display: table-cell;\n      width: 1%;\n      > a {\n        margin-bottom: 0;\n      }\n    }\n  }\n}\n\n// Move borders to anchors instead of bottom of list\n//\n// Mixin for adding on top the shared `.nav-justified` styles for our tabs\n.nav-tabs-justified {\n  border-bottom: 0;\n\n  > li > a {\n    // Override margin from .nav-tabs\n    margin-right: 0;\n    border-radius: @border-radius-base;\n  }\n\n  > .active > a,\n  > .active > a:hover,\n  > .active > a:focus {\n    border: 1px solid @nav-tabs-justified-link-border-color;\n  }\n\n  @media (min-width: @screen-sm-min) {\n    > li > a {\n      border-bottom: 1px solid @nav-tabs-justified-link-border-color;\n      border-radius: @border-radius-base @border-radius-base 0 0;\n    }\n    > .active > a,\n    > .active > a:hover,\n    > .active > a:focus {\n      border-bottom-color: @nav-tabs-justified-active-link-border-color;\n    }\n  }\n}\n\n\n// Tabbable tabs\n// -------------------------\n\n// Hide tabbable panes to start, show them when `.active`\n.tab-content {\n  > .tab-pane {\n    display: none;\n  }\n  > .active {\n    display: block;\n  }\n}\n\n\n// Dropdowns\n// -------------------------\n\n// Specific dropdowns\n.nav-tabs .dropdown-menu {\n  // make dropdown border overlap tab border\n  margin-top: -1px;\n  // Remove the top rounded corners here since there is a hard edge above the menu\n  .border-top-radius(0);\n}\n","//\n// Navbars\n// --------------------------------------------------\n\n\n// Wrapper and base class\n//\n// Provide a static navbar from which we expand to create full-width, fixed, and\n// other navbar variations.\n\n.navbar {\n  position: relative;\n  min-height: @navbar-height; // Ensure a navbar always shows (e.g., without a .navbar-brand in collapsed mode)\n  margin-bottom: @navbar-margin-bottom;\n  border: 1px solid transparent;\n\n  // Prevent floats from breaking the navbar\n  &:extend(.clearfix all);\n\n  @media (min-width: @grid-float-breakpoint) {\n    border-radius: @navbar-border-radius;\n  }\n}\n\n\n// Navbar heading\n//\n// Groups `.navbar-brand` and `.navbar-toggle` into a single component for easy\n// styling of responsive aspects.\n\n.navbar-header {\n  &:extend(.clearfix all);\n\n  @media (min-width: @grid-float-breakpoint) {\n    float: left;\n  }\n}\n\n\n// Navbar collapse (body)\n//\n// Group your navbar content into this for easy collapsing and expanding across\n// various device sizes. By default, this content is collapsed when <768px, but\n// will expand past that for a horizontal display.\n//\n// To start (on mobile devices) the navbar links, forms, and buttons are stacked\n// vertically and include a `max-height` to overflow in case you have too much\n// content for the user's viewport.\n\n.navbar-collapse {\n  max-height: @navbar-collapse-max-height;\n  overflow-x: visible;\n  padding-right: @navbar-padding-horizontal;\n  padding-left:  @navbar-padding-horizontal;\n  border-top: 1px solid transparent;\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.1);\n  &:extend(.clearfix all);\n  -webkit-overflow-scrolling: touch;\n\n  &.in {\n    overflow-y: auto;\n  }\n\n  @media (min-width: @grid-float-breakpoint) {\n    width: auto;\n    border-top: 0;\n    box-shadow: none;\n\n    &.collapse {\n      display: block !important;\n      height: auto !important;\n      padding-bottom: 0; // Override default setting\n      overflow: visible !important;\n    }\n\n    &.in {\n      overflow-y: visible;\n    }\n\n    // Undo the collapse side padding for navbars with containers to ensure\n    // alignment of right-aligned contents.\n    .navbar-fixed-top &,\n    .navbar-static-top &,\n    .navbar-fixed-bottom & {\n      padding-left: 0;\n      padding-right: 0;\n    }\n  }\n}\n\n\n// Both navbar header and collapse\n//\n// When a container is present, change the behavior of the header and collapse.\n\n.container,\n.container-fluid {\n  > .navbar-header,\n  > .navbar-collapse {\n    margin-right: -@navbar-padding-horizontal;\n    margin-left:  -@navbar-padding-horizontal;\n\n    @media (min-width: @grid-float-breakpoint) {\n      margin-right: 0;\n      margin-left:  0;\n    }\n  }\n}\n\n\n//\n// Navbar alignment options\n//\n// Display the navbar across the entirety of the page or fixed it to the top or\n// bottom of the page.\n\n// Static top (unfixed, but 100% wide) navbar\n.navbar-static-top {\n  z-index: @zindex-navbar;\n  border-width: 0 0 1px;\n\n  @media (min-width: @grid-float-breakpoint) {\n    border-radius: 0;\n  }\n}\n\n// Fix the top/bottom navbars when screen real estate supports it\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n  position: fixed;\n  right: 0;\n  left: 0;\n  z-index: @zindex-navbar-fixed;\n\n  // Undo the rounded corners\n  @media (min-width: @grid-float-breakpoint) {\n    border-radius: 0;\n  }\n}\n.navbar-fixed-top {\n  top: 0;\n  border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n  bottom: 0;\n  margin-bottom: 0; // override .navbar defaults\n  border-width: 1px 0 0;\n}\n\n\n// Brand/project name\n\n.navbar-brand {\n  float: left;\n  padding: @navbar-padding-vertical @navbar-padding-horizontal;\n  font-size: @font-size-large;\n  line-height: @line-height-computed;\n  height: @navbar-height;\n\n  &:hover,\n  &:focus {\n    text-decoration: none;\n  }\n\n  @media (min-width: @grid-float-breakpoint) {\n    .navbar > .container &,\n    .navbar > .container-fluid & {\n      margin-left: -@navbar-padding-horizontal;\n    }\n  }\n}\n\n\n// Navbar toggle\n//\n// Custom button for toggling the `.navbar-collapse`, powered by the collapse\n// JavaScript plugin.\n\n.navbar-toggle {\n  position: relative;\n  float: right;\n  margin-right: @navbar-padding-horizontal;\n  padding: 9px 10px;\n  .navbar-vertical-align(34px);\n  background-color: transparent;\n  background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n  border: 1px solid transparent;\n  border-radius: @border-radius-base;\n\n  // We remove the `outline` here, but later compensate by attaching `:hover`\n  // styles to `:focus`.\n  &:focus {\n    outline: none;\n  }\n\n  // Bars\n  .icon-bar {\n    display: block;\n    width: 22px;\n    height: 2px;\n    border-radius: 1px;\n  }\n  .icon-bar + .icon-bar {\n    margin-top: 4px;\n  }\n\n  @media (min-width: @grid-float-breakpoint) {\n    display: none;\n  }\n}\n\n\n// Navbar nav links\n//\n// Builds on top of the `.nav` components with its own modifier class to make\n// the nav the full height of the horizontal nav (above 768px).\n\n.navbar-nav {\n  margin: (@navbar-padding-vertical / 2) -@navbar-padding-horizontal;\n\n  > li > a {\n    padding-top:    10px;\n    padding-bottom: 10px;\n    line-height: @line-height-computed;\n  }\n\n  @media (max-width: @grid-float-breakpoint-max) {\n    // Dropdowns get custom display when collapsed\n    .open .dropdown-menu {\n      position: static;\n      float: none;\n      width: auto;\n      margin-top: 0;\n      background-color: transparent;\n      border: 0;\n      box-shadow: none;\n      > li > a,\n      .dropdown-header {\n        padding: 5px 15px 5px 25px;\n      }\n      > li > a {\n        line-height: @line-height-computed;\n        &:hover,\n        &:focus {\n          background-image: none;\n        }\n      }\n    }\n  }\n\n  // Uncollapse the nav\n  @media (min-width: @grid-float-breakpoint) {\n    float: left;\n    margin: 0;\n\n    > li {\n      float: left;\n      > a {\n        padding-top:    @navbar-padding-vertical;\n        padding-bottom: @navbar-padding-vertical;\n      }\n    }\n\n    &.navbar-right:last-child {\n      margin-right: -@navbar-padding-horizontal;\n    }\n  }\n}\n\n\n// Component alignment\n//\n// Repurpose the pull utilities as their own navbar utilities to avoid specificity\n// issues with parents and chaining. Only do this when the navbar is uncollapsed\n// though so that navbar contents properly stack and align in mobile.\n\n@media (min-width: @grid-float-breakpoint) {\n  .navbar-left  { .pull-left(); }\n  .navbar-right { .pull-right(); }\n}\n\n\n// Navbar form\n//\n// Extension of the `.form-inline` with some extra flavor for optimum display in\n// our navbars.\n\n.navbar-form {\n  margin-left: -@navbar-padding-horizontal;\n  margin-right: -@navbar-padding-horizontal;\n  padding: 10px @navbar-padding-horizontal;\n  border-top: 1px solid transparent;\n  border-bottom: 1px solid transparent;\n  @shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.1);\n  .box-shadow(@shadow);\n\n  // Mixin behavior for optimum display\n  .form-inline();\n\n  .form-group {\n    @media (max-width: @grid-float-breakpoint-max) {\n      margin-bottom: 5px;\n    }\n  }\n\n  // Vertically center in expanded, horizontal navbar\n  .navbar-vertical-align(@input-height-base);\n\n  // Undo 100% width for pull classes\n  @media (min-width: @grid-float-breakpoint) {\n    width: auto;\n    border: 0;\n    margin-left: 0;\n    margin-right: 0;\n    padding-top: 0;\n    padding-bottom: 0;\n    .box-shadow(none);\n\n    // Outdent the form if last child to line up with content down the page\n    &.navbar-right:last-child {\n      margin-right: -@navbar-padding-horizontal;\n    }\n  }\n}\n\n\n// Dropdown menus\n\n// Menu position and menu carets\n.navbar-nav > li > .dropdown-menu {\n  margin-top: 0;\n  .border-top-radius(0);\n}\n// Menu position and menu caret support for dropups via extra dropup class\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n  .border-bottom-radius(0);\n}\n\n\n// Buttons in navbars\n//\n// Vertically center a button within a navbar (when *not* in a form).\n\n.navbar-btn {\n  .navbar-vertical-align(@input-height-base);\n\n  &.btn-sm {\n    .navbar-vertical-align(@input-height-small);\n  }\n  &.btn-xs {\n    .navbar-vertical-align(22);\n  }\n}\n\n\n// Text in navbars\n//\n// Add a class to make any element properly align itself vertically within the navbars.\n\n.navbar-text {\n  .navbar-vertical-align(@line-height-computed);\n\n  @media (min-width: @grid-float-breakpoint) {\n    float: left;\n    margin-left: @navbar-padding-horizontal;\n    margin-right: @navbar-padding-horizontal;\n\n    // Outdent the form if last child to line up with content down the page\n    &.navbar-right:last-child {\n      margin-right: 0;\n    }\n  }\n}\n\n// Alternate navbars\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n  background-color: @navbar-default-bg;\n  border-color: @navbar-default-border;\n\n  .navbar-brand {\n    color: @navbar-default-brand-color;\n    &:hover,\n    &:focus {\n      color: @navbar-default-brand-hover-color;\n      background-color: @navbar-default-brand-hover-bg;\n    }\n  }\n\n  .navbar-text {\n    color: @navbar-default-color;\n  }\n\n  .navbar-nav {\n    > li > a {\n      color: @navbar-default-link-color;\n\n      &:hover,\n      &:focus {\n        color: @navbar-default-link-hover-color;\n        background-color: @navbar-default-link-hover-bg;\n      }\n    }\n    > .active > a {\n      &,\n      &:hover,\n      &:focus {\n        color: @navbar-default-link-active-color;\n        background-color: @navbar-default-link-active-bg;\n      }\n    }\n    > .disabled > a {\n      &,\n      &:hover,\n      &:focus {\n        color: @navbar-default-link-disabled-color;\n        background-color: @navbar-default-link-disabled-bg;\n      }\n    }\n  }\n\n  .navbar-toggle {\n    border-color: @navbar-default-toggle-border-color;\n    &:hover,\n    &:focus {\n      background-color: @navbar-default-toggle-hover-bg;\n    }\n    .icon-bar {\n      background-color: @navbar-default-toggle-icon-bar-bg;\n    }\n  }\n\n  .navbar-collapse,\n  .navbar-form {\n    border-color: @navbar-default-border;\n  }\n\n  // Dropdown menu items\n  .navbar-nav {\n    // Remove background color from open dropdown\n    > .open > a {\n      &,\n      &:hover,\n      &:focus {\n        background-color: @navbar-default-link-active-bg;\n        color: @navbar-default-link-active-color;\n      }\n    }\n\n    @media (max-width: @grid-float-breakpoint-max) {\n      // Dropdowns get custom display when collapsed\n      .open .dropdown-menu {\n        > li > a {\n          color: @navbar-default-link-color;\n          &:hover,\n          &:focus {\n            color: @navbar-default-link-hover-color;\n            background-color: @navbar-default-link-hover-bg;\n          }\n        }\n        > .active > a {\n          &,\n          &:hover,\n          &:focus {\n            color: @navbar-default-link-active-color;\n            background-color: @navbar-default-link-active-bg;\n          }\n        }\n        > .disabled > a {\n          &,\n          &:hover,\n          &:focus {\n            color: @navbar-default-link-disabled-color;\n            background-color: @navbar-default-link-disabled-bg;\n          }\n        }\n      }\n    }\n  }\n\n\n  // Links in navbars\n  //\n  // Add a class to ensure links outside the navbar nav are colored correctly.\n\n  .navbar-link {\n    color: @navbar-default-link-color;\n    &:hover {\n      color: @navbar-default-link-hover-color;\n    }\n  }\n\n}\n\n// Inverse navbar\n\n.navbar-inverse {\n  background-color: @navbar-inverse-bg;\n  border-color: @navbar-inverse-border;\n\n  .navbar-brand {\n    color: @navbar-inverse-brand-color;\n    &:hover,\n    &:focus {\n      color: @navbar-inverse-brand-hover-color;\n      background-color: @navbar-inverse-brand-hover-bg;\n    }\n  }\n\n  .navbar-text {\n    color: @navbar-inverse-color;\n  }\n\n  .navbar-nav {\n    > li > a {\n      color: @navbar-inverse-link-color;\n\n      &:hover,\n      &:focus {\n        color: @navbar-inverse-link-hover-color;\n        background-color: @navbar-inverse-link-hover-bg;\n      }\n    }\n    > .active > a {\n      &,\n      &:hover,\n      &:focus {\n        color: @navbar-inverse-link-active-color;\n        background-color: @navbar-inverse-link-active-bg;\n      }\n    }\n    > .disabled > a {\n      &,\n      &:hover,\n      &:focus {\n        color: @navbar-inverse-link-disabled-color;\n        background-color: @navbar-inverse-link-disabled-bg;\n      }\n    }\n  }\n\n  // Darken the responsive nav toggle\n  .navbar-toggle {\n    border-color: @navbar-inverse-toggle-border-color;\n    &:hover,\n    &:focus {\n      background-color: @navbar-inverse-toggle-hover-bg;\n    }\n    .icon-bar {\n      background-color: @navbar-inverse-toggle-icon-bar-bg;\n    }\n  }\n\n  .navbar-collapse,\n  .navbar-form {\n    border-color: darken(@navbar-inverse-bg, 7%);\n  }\n\n  // Dropdowns\n  .navbar-nav {\n    > .open > a {\n      &,\n      &:hover,\n      &:focus {\n        background-color: @navbar-inverse-link-active-bg;\n        color: @navbar-inverse-link-active-color;\n      }\n    }\n\n    @media (max-width: @grid-float-breakpoint-max) {\n      // Dropdowns get custom display\n      .open .dropdown-menu {\n        > .dropdown-header {\n          border-color: @navbar-inverse-border;\n        }\n        .divider {\n          background-color: @navbar-inverse-border;\n        }\n        > li > a {\n          color: @navbar-inverse-link-color;\n          &:hover,\n          &:focus {\n            color: @navbar-inverse-link-hover-color;\n            background-color: @navbar-inverse-link-hover-bg;\n          }\n        }\n        > .active > a {\n          &,\n          &:hover,\n          &:focus {\n            color: @navbar-inverse-link-active-color;\n            background-color: @navbar-inverse-link-active-bg;\n          }\n        }\n        > .disabled > a {\n          &,\n          &:hover,\n          &:focus {\n            color: @navbar-inverse-link-disabled-color;\n            background-color: @navbar-inverse-link-disabled-bg;\n          }\n        }\n      }\n    }\n  }\n\n  .navbar-link {\n    color: @navbar-inverse-link-color;\n    &:hover {\n      color: @navbar-inverse-link-hover-color;\n    }\n  }\n\n}\n","//\n// Utility classes\n// --------------------------------------------------\n\n\n// Floats\n// -------------------------\n\n.clearfix {\n  .clearfix();\n}\n.center-block {\n  .center-block();\n}\n.pull-right {\n  float: right !important;\n}\n.pull-left {\n  float: left !important;\n}\n\n\n// Toggling content\n// -------------------------\n\n// Note: Deprecated .hide in favor of .hidden or .sr-only (as appropriate) in v3.0.1\n.hide {\n  display: none !important;\n}\n.show {\n  display: block !important;\n}\n.invisible {\n  visibility: hidden;\n}\n.text-hide {\n  .text-hide();\n}\n\n\n// Hide from screenreaders and browsers\n//\n// Credit: HTML5 Boilerplate\n\n.hidden {\n  display: none !important;\n  visibility: hidden !important;\n}\n\n\n// For Affix plugin\n// -------------------------\n\n.affix {\n  position: fixed;\n}\n","//\n// Breadcrumbs\n// --------------------------------------------------\n\n\n.breadcrumb {\n  padding: @breadcrumb-padding-vertical @breadcrumb-padding-horizontal;\n  margin-bottom: @line-height-computed;\n  list-style: none;\n  background-color: @breadcrumb-bg;\n  border-radius: @border-radius-base;\n\n  > li {\n    display: inline-block;\n\n    + li:before {\n      content: \"@{breadcrumb-separator}\\00a0\"; // Unicode space added since inline-block means non-collapsing white-space\n      padding: 0 5px;\n      color: @breadcrumb-color;\n    }\n  }\n\n  > .active {\n    color: @breadcrumb-active-color;\n  }\n}\n","//\n// Pagination (multiple pages)\n// --------------------------------------------------\n.pagination {\n  display: inline-block;\n  padding-left: 0;\n  margin: @line-height-computed 0;\n  border-radius: @border-radius-base;\n\n  > li {\n    display: inline; // Remove list-style and block-level defaults\n    > a,\n    > span {\n      position: relative;\n      float: left; // Collapse white-space\n      padding: @padding-base-vertical @padding-base-horizontal;\n      line-height: @line-height-base;\n      text-decoration: none;\n      color: @pagination-color;\n      background-color: @pagination-bg;\n      border: 1px solid @pagination-border;\n      margin-left: -1px;\n    }\n    &:first-child {\n      > a,\n      > span {\n        margin-left: 0;\n        .border-left-radius(@border-radius-base);\n      }\n    }\n    &:last-child {\n      > a,\n      > span {\n        .border-right-radius(@border-radius-base);\n      }\n    }\n  }\n\n  > li > a,\n  > li > span {\n    &:hover,\n    &:focus {\n      color: @pagination-hover-color;\n      background-color: @pagination-hover-bg;\n      border-color: @pagination-hover-border;\n    }\n  }\n\n  > .active > a,\n  > .active > span {\n    &,\n    &:hover,\n    &:focus {\n      z-index: 2;\n      color: @pagination-active-color;\n      background-color: @pagination-active-bg;\n      border-color: @pagination-active-border;\n      cursor: default;\n    }\n  }\n\n  > .disabled {\n    > span,\n    > span:hover,\n    > span:focus,\n    > a,\n    > a:hover,\n    > a:focus {\n      color: @pagination-disabled-color;\n      background-color: @pagination-disabled-bg;\n      border-color: @pagination-disabled-border;\n      cursor: not-allowed;\n    }\n  }\n}\n\n// Sizing\n// --------------------------------------------------\n\n// Large\n.pagination-lg {\n  .pagination-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @border-radius-large);\n}\n\n// Small\n.pagination-sm {\n  .pagination-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @border-radius-small);\n}\n","//\n// Pager pagination\n// --------------------------------------------------\n\n\n.pager {\n  padding-left: 0;\n  margin: @line-height-computed 0;\n  list-style: none;\n  text-align: center;\n  &:extend(.clearfix all);\n  li {\n    display: inline;\n    > a,\n    > span {\n      display: inline-block;\n      padding: 5px 14px;\n      background-color: @pager-bg;\n      border: 1px solid @pager-border;\n      border-radius: @pager-border-radius;\n    }\n\n    > a:hover,\n    > a:focus {\n      text-decoration: none;\n      background-color: @pager-hover-bg;\n    }\n  }\n\n  .next {\n    > a,\n    > span {\n      float: right;\n    }\n  }\n\n  .previous {\n    > a,\n    > span {\n      float: left;\n    }\n  }\n\n  .disabled {\n    > a,\n    > a:hover,\n    > a:focus,\n    > span {\n      color: @pager-disabled-color;\n      background-color: @pager-bg;\n      cursor: not-allowed;\n    }\n  }\n\n}\n","//\n// Labels\n// --------------------------------------------------\n\n.label {\n  display: inline;\n  padding: .2em .6em .3em;\n  font-size: 75%;\n  font-weight: bold;\n  line-height: 1;\n  color: @label-color;\n  text-align: center;\n  white-space: nowrap;\n  vertical-align: baseline;\n  border-radius: .25em;\n\n  // Add hover effects, but only for links\n  &[href] {\n    &:hover,\n    &:focus {\n      color: @label-link-hover-color;\n      text-decoration: none;\n      cursor: pointer;\n    }\n  }\n\n  // Empty labels collapse automatically (not available in IE8)\n  &:empty {\n    display: none;\n  }\n\n  // Quick fix for labels in buttons\n  .btn & {\n    position: relative;\n    top: -1px;\n  }\n}\n\n// Colors\n// Contextual variations (linked labels get darker on :hover)\n\n.label-default {\n  .label-variant(@label-default-bg);\n}\n\n.label-primary {\n  .label-variant(@label-primary-bg);\n}\n\n.label-success {\n  .label-variant(@label-success-bg);\n}\n\n.label-info {\n  .label-variant(@label-info-bg);\n}\n\n.label-warning {\n  .label-variant(@label-warning-bg);\n}\n\n.label-danger {\n  .label-variant(@label-danger-bg);\n}\n","//\n// Badges\n// --------------------------------------------------\n\n\n// Base classes\n.badge {\n  display: inline-block;\n  min-width: 10px;\n  padding: 3px 7px;\n  font-size: @font-size-small;\n  font-weight: @badge-font-weight;\n  color: @badge-color;\n  line-height: @badge-line-height;\n  vertical-align: baseline;\n  white-space: nowrap;\n  text-align: center;\n  background-color: @badge-bg;\n  border-radius: @badge-border-radius;\n\n  // Empty badges collapse automatically (not available in IE8)\n  &:empty {\n    display: none;\n  }\n\n  // Quick fix for badges in buttons\n  .btn & {\n    position: relative;\n    top: -1px;\n  }\n  .btn-xs & {\n    top: 0;\n    padding: 1px 5px;\n  }\n}\n\n// Hover state, but only for links\na.badge {\n  &:hover,\n  &:focus {\n    color: @badge-link-hover-color;\n    text-decoration: none;\n    cursor: pointer;\n  }\n}\n\n// Account for counters in navs\na.list-group-item.active > .badge,\n.nav-pills > .active > a > .badge {\n  color: @badge-active-color;\n  background-color: @badge-active-bg;\n}\n.nav-pills > li > a > .badge {\n  margin-left: 3px;\n}\n","//\n// Jumbotron\n// --------------------------------------------------\n\n\n.jumbotron {\n  padding: @jumbotron-padding;\n  margin-bottom: @jumbotron-padding;\n  color: @jumbotron-color;\n  background-color: @jumbotron-bg;\n\n  h1,\n  .h1 {\n    color: @jumbotron-heading-color;\n  }\n  p {\n    margin-bottom: (@jumbotron-padding / 2);\n    font-size: @jumbotron-font-size;\n    font-weight: 200;\n  }\n\n  .container & {\n    border-radius: @border-radius-large; // Only round corners at higher resolutions if contained in a container\n  }\n\n  .container {\n    max-width: 100%;\n  }\n\n  @media screen and (min-width: @screen-sm-min) {\n    padding-top:    (@jumbotron-padding * 1.6);\n    padding-bottom: (@jumbotron-padding * 1.6);\n\n    .container & {\n      padding-left:  (@jumbotron-padding * 2);\n      padding-right: (@jumbotron-padding * 2);\n    }\n\n    h1,\n    .h1 {\n      font-size: (@font-size-base * 4.5);\n    }\n  }\n}\n","//\n// Alerts\n// --------------------------------------------------\n\n\n// Base styles\n// -------------------------\n\n.alert {\n  padding: @alert-padding;\n  margin-bottom: @line-height-computed;\n  border: 1px solid transparent;\n  border-radius: @alert-border-radius;\n\n  // Headings for larger alerts\n  h4 {\n    margin-top: 0;\n    // Specified for the h4 to prevent conflicts of changing @headings-color\n    color: inherit;\n  }\n  // Provide class for links that match alerts\n  .alert-link {\n    font-weight: @alert-link-font-weight;\n  }\n\n  // Improve alignment and spacing of inner content\n  > p,\n  > ul {\n    margin-bottom: 0;\n  }\n  > p + p {\n    margin-top: 5px;\n  }\n}\n\n// Dismissable alerts\n//\n// Expand the right padding and account for the close button's positioning.\n\n.alert-dismissable {\n padding-right: (@alert-padding + 20);\n\n  // Adjust close link position\n  .close {\n    position: relative;\n    top: -2px;\n    right: -21px;\n    color: inherit;\n  }\n}\n\n// Alternate styles\n//\n// Generate contextual modifier classes for colorizing the alert.\n\n.alert-success {\n  .alert-variant(@alert-success-bg; @alert-success-border; @alert-success-text);\n}\n.alert-info {\n  .alert-variant(@alert-info-bg; @alert-info-border; @alert-info-text);\n}\n.alert-warning {\n  .alert-variant(@alert-warning-bg; @alert-warning-border; @alert-warning-text);\n}\n.alert-danger {\n  .alert-variant(@alert-danger-bg; @alert-danger-border; @alert-danger-text);\n}\n","//\n// Progress bars\n// --------------------------------------------------\n\n\n// Bar animations\n// -------------------------\n\n// WebKit\n@-webkit-keyframes progress-bar-stripes {\n  from  { background-position: 40px 0; }\n  to    { background-position: 0 0; }\n}\n\n// Spec and IE10+\n@keyframes progress-bar-stripes {\n  from  { background-position: 40px 0; }\n  to    { background-position: 0 0; }\n}\n\n\n\n// Bar itself\n// -------------------------\n\n// Outer container\n.progress {\n  overflow: hidden;\n  height: @line-height-computed;\n  margin-bottom: @line-height-computed;\n  background-color: @progress-bg;\n  border-radius: @border-radius-base;\n  .box-shadow(inset 0 1px 2px rgba(0,0,0,.1));\n}\n\n// Bar of progress\n.progress-bar {\n  float: left;\n  width: 0%;\n  height: 100%;\n  font-size: @font-size-small;\n  line-height: @line-height-computed;\n  color: @progress-bar-color;\n  text-align: center;\n  background-color: @progress-bar-bg;\n  .box-shadow(inset 0 -1px 0 rgba(0,0,0,.15));\n  .transition(width .6s ease);\n}\n\n// Striped bars\n.progress-striped .progress-bar {\n  #gradient > .striped();\n  background-size: 40px 40px;\n}\n\n// Call animation for the active one\n.progress.active .progress-bar {\n  .animation(progress-bar-stripes 2s linear infinite);\n}\n\n\n\n// Variations\n// -------------------------\n\n.progress-bar-success {\n  .progress-bar-variant(@progress-bar-success-bg);\n}\n\n.progress-bar-info {\n  .progress-bar-variant(@progress-bar-info-bg);\n}\n\n.progress-bar-warning {\n  .progress-bar-variant(@progress-bar-warning-bg);\n}\n\n.progress-bar-danger {\n  .progress-bar-variant(@progress-bar-danger-bg);\n}\n","// Media objects\n// Source: http://stubbornella.org/content/?p=497\n// --------------------------------------------------\n\n\n// Common styles\n// -------------------------\n\n// Clear the floats\n.media,\n.media-body {\n  overflow: hidden;\n  zoom: 1;\n}\n\n// Proper spacing between instances of .media\n.media,\n.media .media {\n  margin-top: 15px;\n}\n.media:first-child {\n  margin-top: 0;\n}\n\n// For images and videos, set to block\n.media-object {\n  display: block;\n}\n\n// Reset margins on headings for tighter default spacing\n.media-heading {\n  margin: 0 0 5px;\n}\n\n\n// Media image alignment\n// -------------------------\n\n.media {\n  > .pull-left {\n    margin-right: 10px;\n  }\n  > .pull-right {\n    margin-left: 10px;\n  }\n}\n\n\n// Media list variation\n// -------------------------\n\n// Undo default ul/ol styles\n.media-list {\n  padding-left: 0;\n  list-style: none;\n}\n","//\n// List groups\n// --------------------------------------------------\n\n\n// Base class\n//\n// Easily usable on 
    diff --git a/app/templates/scodoc/role_create.j2 b/app/templates/scodoc/role_create.j2 index 9c21b5d686..4f2627e7d4 100644 --- a/app/templates/scodoc/role_create.j2 +++ b/app/templates/scodoc/role_create.j2 @@ -1,12 +1,12 @@ {# -*- mode: jinja-html -*- #} {% extends "base.j2" %} -{% import 'bootstrap/wtf.html' as wtf %} +{% import 'wtf.j2' as wtf %} {% block app_content %}

    Créer un rôle

    -Un rôle est associé à un ensemble de permissions. +Un rôle est associé à un ensemble de permissions. Les utilisateurs peuvent avoir un ou plusieurs rôles dans chaque département.
    @@ -19,4 +19,4 @@ Les utilisateurs peuvent avoir un ou plusieurs rôles dans chaque département.
    -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/scodoc/role_editor.j2 b/app/templates/scodoc/role_editor.j2 index 9656291ca8..9439887b82 100644 --- a/app/templates/scodoc/role_editor.j2 +++ b/app/templates/scodoc/role_editor.j2 @@ -1,7 +1,7 @@ {# -*- mode: jinja-html -*- #} {# -*- Edition des rôles/permissions -*- #} {% extends "base.j2" %} -{% import 'bootstrap/wtf.html' as wtf %} +{% import 'wtf.j2' as wtf %} {% block styles %} {{super()}} @@ -14,7 +14,7 @@
    Les rôles sont associés à un ensemble de permissions. Chaque utilisateur peut avoir un nombre quelconque de rôles dans chaque -département. +département. Sur cette page vous pouvez modifier les permissions associée à chaque rôle, ou créer de nouveaux rôles. Les rôles en gras sont les rôles standards de ScoDoc. @@ -38,7 +38,7 @@ Les rôles en gras sont les rôles standards de ScoDoc.
    {% for permission_name in permissions_names %}
    -
    +
    @@ -63,7 +64,7 @@ - + \ No newline at end of file + diff --git a/app/templates/scolar/students_groups_auto_assignment.j2 b/app/templates/scolar/students_groups_auto_assignment.j2 index 7236389805..ce0899c7df 100644 --- a/app/templates/scolar/students_groups_auto_assignment.j2 +++ b/app/templates/scolar/students_groups_auto_assignment.j2 @@ -249,7 +249,7 @@ - + \ No newline at end of file + diff --git a/app/templates/wtf.j2 b/app/templates/wtf.j2 new file mode 100644 index 0000000000..b71a0c1a10 --- /dev/null +++ b/app/templates/wtf.j2 @@ -0,0 +1,214 @@ +{# was wtf.html in Flask-Bootstrap #} +{% macro form_errors(form, hiddens=True) %} + {%- if form.errors %} + {%- for fieldname, errors in form.errors.items() %} + {%- if bootstrap_is_hidden_field(form[fieldname]) and hiddens or + not bootstrap_is_hidden_field(form[fieldname]) and hiddens != 'only' %} + {%- for error in errors %} +

    {{error}}

    + {%- endfor %} + {%- endif %} + {%- endfor %} + {%- endif %} +{%- endmacro %} + +{% macro _hz_form_wrap(horizontal_columns, form_type, add_group=False, required=False) %} +{% if form_type == "horizontal" %} + {% if add_group %}
    {% endif %} +
    +{% endif %} +{{caller()}} + +{% if form_type == "horizontal" %} + {% if add_group %}
    {% endif %} +
    +{% endif %} +{% endmacro %} + +{% macro form_field(field, + form_type="basic", + horizontal_columns=('lg', 2, 10), + button_map={}) %} + +{# this is a workaround hack for the more straightforward-code of just passing required=required parameter. older versions of wtforms do not have +the necessary fix for required=False attributes, but will also not set the required flag in the first place. we skirt the issue using the code below #} +{% if field.flags.required and not required in kwargs %} +{% set kwargs = dict(required=True, **kwargs) %} +{% endif %} + +{% if field.widget.input_type == 'checkbox' %} + {% call _hz_form_wrap(horizontal_columns, form_type, True, required=required) %} +
    + +
    + {% endcall %} +{%- elif field.type == 'RadioField' -%} + {# note: A cleaner solution would be rendering depending on the widget, + this is just a hack for now, until I can think of something better #} + {% call _hz_form_wrap(horizontal_columns, form_type, True, required=required) %} + {% for item in field -%} +
    + +
    + {% endfor %} + {% endcall %} +{%- elif field.type == 'SubmitField' -%} + {# deal with jinja scoping issues? #} + {% set field_kwargs = kwargs %} + + {# note: same issue as above - should check widget, not field type #} + {% call _hz_form_wrap(horizontal_columns, form_type, True, required=required) %} + {{field(class='btn btn-%s' % button_map.get(field.name, 'default'), + **field_kwargs)}} + {% endcall %} +{%- elif field.type == 'FormField' -%} +{# note: FormFields are tricky to get right and complex setups requiring + these are probably beyond the scope of what this macro tries to do. + the code below ensures that things don't break horribly if we run into + one, but does not try too hard to get things pretty. #} +
    + {{field.label}} + {%- for subfield in field %} + {% if not bootstrap_is_hidden_field(subfield) -%} + {{ form_field(subfield, + form_type=form_type, + horizontal_columns=horizontal_columns, + button_map=button_map) }} + {%- endif %} + {%- endfor %} +
    +{% else -%} +
    + {%- if form_type == "inline" %} + {{field.label(class="sr-only")|safe}} + {% if field.type == 'FileField' %} + {{field(**kwargs)|safe}} + {% else %} + {{field(class="form-control", **kwargs)|safe}} + {% endif %} + {% elif form_type == "horizontal" %} + {{field.label(class="control-label " + ( + " col-%s-%s" % horizontal_columns[0:2] + ))|safe}} +
    + {% if field.type == 'FileField' %} + {{field(**kwargs)|safe}} + {% else %} + {{field(class="form-control", **kwargs)|safe}} + {% endif %} +
    + {%- if field.errors %} + {%- for error in field.errors %} + {% call _hz_form_wrap(horizontal_columns, form_type, required=required) %} +

    {{error}}

    + {% endcall %} + {%- endfor %} + {%- elif field.description -%} + {% call _hz_form_wrap(horizontal_columns, form_type, required=required) %} +

    {{field.description|safe}}

    + {% endcall %} + {%- endif %} + {%- else -%} + {{field.label(class="control-label")|safe}} + {% if field.type == 'FileField' %} + {{field(**kwargs)|safe}} + {% else %} + {{field(class="form-control", **kwargs)|safe}} + {% endif %} + + {%- if field.errors %} + {%- for error in field.errors %} +

    {{error}}

    + {%- endfor %} + {%- elif field.description -%} +

    {{field.description|safe}}

    + {%- endif %} + {%- endif %} +
    +{% endif %} +{% endmacro %} + +{# valid form types are "basic", "inline" and "horizontal" #} +{% macro quick_form(form, + action="", + method="post", + extra_classes=None, + role="form", + form_type="basic", + horizontal_columns=('lg', 2, 10), + enctype=None, + button_map={}, + id="", + novalidate=False) %} +{#- +action="" is what we want, from http://www.ietf.org/rfc/rfc2396.txt: + +4.2. Same-document References + + A URI reference that does not contain a URI is a reference to the + current document. In other words, an empty URI reference within a + document is interpreted as a reference to the start of that document, + and a reference containing only a fragment identifier is a reference + to the identified fragment of that document. Traversal of such a + reference should not result in an additional retrieval action. + However, if the URI reference occurs in a context that is always + intended to result in a new request, as in the case of HTML's FORM + element, then an empty URI reference represents the base URI of the + current document and should be replaced by that URI when transformed + into a request. + + -#} +{#- if any file fields are inside the form and enctype is automatic, adjust + if file fields are found. could really use the equalto test of jinja2 + here, but latter is not available until 2.8 + + warning: the code below is guaranteed to make you cry =( +#} +{%- set _enctype = [] %} +{%- if enctype is none -%} + {%- for field in form %} + {%- if field.type == 'FileField' %} + {#- for loops come with a fairly watertight scope, so this list-hack is + used to be able to set values outside of it #} + {%- set _ = _enctype.append('multipart/form-data') -%} + {%- endif %} + {%- endfor %} +{%- else %} + {% set _ = _enctype.append(enctype) %} +{%- endif %} +
    + {{ form.hidden_tag() }} + {{ form_errors(form, hiddens='only') }} + + {%- for field in form %} + {% if not bootstrap_is_hidden_field(field) -%} + {{ form_field(field, + form_type=form_type, + horizontal_columns=horizontal_columns, + button_map=button_map) }} + {%- endif %} + {%- endfor %} + +
    +{%- endmacro %} diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 333117c25c..74215069b3 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -25,6 +25,7 @@ ############################################################################## import datetime +import re from flask import g, request, render_template, flash from flask import abort, url_for, redirect @@ -167,8 +168,7 @@ def bilan_dept(): page_title="Saisie de l'assiduité", javascripts=[ "js/assiduites.js", - "libjs/moment-2.29.4.min.js", - "libjs/moment-timezone.js", + "js/date_utils.js", ], cssstyles=[ "css/assiduites.css", @@ -176,13 +176,13 @@ def bilan_dept(): ), """

    Traitement de l'assiduité

    - Pour saisir l'assiduité ou consulter les états, il est recommandé de passer par + Pour saisir l'assiduité ou consulter les états, il est recommandé de passer par le semestre concerné (saisie par jour ou saisie différée).

    """, ] H.append( - """

    Pour signaler, annuler ou justifier l'assiduité d'un seul étudiant, + """

    Pour signaler, annuler ou justifier l'assiduité d'un seul étudiant, choisissez d'abord la personne concernée :

    """ ) # Ajout de la barre de recherche d'étudiant (redirection vers bilan etud) @@ -262,6 +262,10 @@ def signal_assiduites_etud(): # Récupération de la date (par défaut la date du jour) date = request.args.get("date", datetime.date.today().isoformat()) + heures: list[str] = [ + request.args.get("heure_deb", ""), + request.args.get("heure_fin", ""), + ] # gestion évaluations (Appel à la page depuis les évaluations) @@ -286,11 +290,11 @@ def signal_assiduites_etud(): init_qtip=True, javascripts=[ "js/assiduites.js", - "libjs/moment-2.29.4.min.js", - "libjs/moment-timezone.js", + "js/date_utils.js", "js/etud_info.js", ], - cssstyles=[ + cssstyles=CSSSTYLES + + [ "css/assiduites.css", ], ) @@ -315,27 +319,43 @@ def signal_assiduites_etud(): header, _mini_timeline(), render_template( - "assiduites/pages/signal_assiduites_etud.j2", + "assiduites/pages/ajout_assiduites.j2", sco=ScoData(etud), - date=date, - morning=morning, - lunch=lunch, - timeline=_timeline(), - afternoon=afternoon, - nonworkdays=_non_work_days(), - forcer_module=sco_preferences.get_preference( - "forcer_module", dept_id=g.scodoc_dept_id - ), - diff=_differee( - etudiants=[sco_etud.get_etud_info(etudid=etud.etudid, filled=True)[0]], - moduleimpl_select=select, + assi_limit_annee=sco_preferences.get_preference( + "assi_limit_annee", + dept_id=g.scodoc_dept_id, ), + assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"), + assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"), + saisie_eval=saisie_eval, date_deb=date_deb, date_fin=date_fin, redirect_url=redirect_url, moduleimpl_id=moduleimpl_id, ), + # render_template( + # "assiduites/pages/signal_assiduites_etud.j2", + # sco=ScoData(etud), + # date=_dateiso_to_datefr(date), + # morning=morning, + # lunch=lunch, + # timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])), + # afternoon=afternoon, + # nonworkdays=_non_work_days(), + # forcer_module=sco_preferences.get_preference( + # "forcer_module", dept_id=g.scodoc_dept_id + # ), + # diff=_differee( + # etudiants=[sco_etud.get_etud_info(etudid=etud.etudid, filled=True)[0]], + # moduleimpl_select=select, + # ), + # saisie_eval=saisie_eval, + # date_deb=date_deb, + # date_fin=date_fin, + # redirect_url=redirect_url, + # moduleimpl_id=moduleimpl_id, + # ), ).build() @@ -367,8 +387,7 @@ def liste_assiduites_etud(): init_qtip=True, javascripts=[ "js/assiduites.js", - "libjs/moment-2.29.4.min.js", - "libjs/moment-timezone.js", + "js/date_utils.js", ], cssstyles=CSSSTYLES + [ @@ -415,8 +434,7 @@ def bilan_etud(): init_qtip=True, javascripts=[ "js/assiduites.js", - "libjs/moment-2.29.4.min.js", - "libjs/moment-timezone.js", + "js/date_utils.js", ], cssstyles=CSSSTYLES + [ @@ -425,8 +443,8 @@ def bilan_etud(): ) # Gestion des dates du bilan (par défaut l'année scolaire) - date_debut: str = f"{scu.annee_scolaire()}-09-01" - date_fin: str = f"{scu.annee_scolaire()+1}-06-30" + date_debut: str = f"01/09/{scu.annee_scolaire()}" + date_fin: str = f"30/06/{scu.annee_scolaire()+1}" # Récupération de la métrique d'assiduité assi_metric = scu.translate_assiduites_metric( @@ -476,8 +494,7 @@ def ajout_justificatif_etud(): init_qtip=True, javascripts=[ "js/assiduites.js", - "libjs/moment-2.29.4.min.js", - "libjs/moment-timezone.js", + "js/date_utils.js", ], cssstyles=CSSSTYLES + [ @@ -526,8 +543,7 @@ def calendrier_etud(): init_qtip=True, javascripts=[ "js/assiduites.js", - "libjs/moment-2.29.4.min.js", - "libjs/moment-timezone.js", + "js/date_utils.js", ], cssstyles=CSSSTYLES + [ @@ -536,10 +552,12 @@ def calendrier_etud(): ) # Récupération des années d'étude de l'étudiant - annees: list[int] = sorted( - [ins.formsemestre.date_debut.year for ins in etud.formsemestre_inscriptions], - reverse=True, - ) + annees: list[int] = [] + for ins in etud.formsemestre_inscriptions: + annees.extend( + (ins.formsemestre.date_debut.year, ins.formsemestre.date_fin.year) + ) + annees = sorted(annees, reverse=True) # Transformation en une liste "json" # (sera utilisé pour générer le selecteur d'année) @@ -576,6 +594,10 @@ def signal_assiduites_group(): formsemestre_id: int = request.args.get("formsemestre_id", -1) moduleimpl_id: int = request.args.get("moduleimpl_id") date: str = request.args.get("jour", datetime.date.today().isoformat()) + heures: list[str] = [ + request.args.get("heure_deb", ""), + request.args.get("heure_fin", ""), + ] group_ids: list[int] = request.args.get("group_ids", None) if group_ids is None: group_ids = [] @@ -673,12 +695,10 @@ def signal_assiduites_group(): javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ # Voir fonctionnement JS - # XXX Retirer moment "js/etud_info.js", "js/groups_view.js", "js/assiduites.js", - "libjs/moment-2.29.4.min.js", - "libjs/moment-timezone.js", + "js/date_utils.js", ], cssstyles=CSSSTYLES + [ @@ -697,11 +717,11 @@ def signal_assiduites_group(): "assiduites/pages/signal_assiduites_group.j2", gr_tit=gr_tit, sem=sem["titre_num"], - date=date, + date=_dateiso_to_datefr(date), formsemestre_id=formsemestre_id, grp=sco_groups_view.menu_groups_choice(groups_infos), moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), - timeline=_timeline(), + timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])), nonworkdays=_non_work_days(), formsemestre_date_debut=str(formsemestre.date_debut), formsemestre_date_fin=str(formsemestre.date_fin), @@ -825,8 +845,7 @@ def visu_assiduites_group(): "js/etud_info.js", "js/groups_view.js", "js/assiduites.js", - "libjs/moment-2.29.4.min.js", - "libjs/moment-timezone.js", + "js/date_utils.js", ], cssstyles=CSSSTYLES + [ @@ -844,7 +863,7 @@ def visu_assiduites_group(): "assiduites/pages/signal_assiduites_group.j2", gr_tit=gr_tit, sem=sem["titre_num"], - date=date, + date=_dateiso_to_datefr(date), formsemestre_id=formsemestre_id, grp=sco_groups_view.menu_groups_choice(groups_infos), moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), @@ -921,8 +940,8 @@ def etat_abs_date(): # On génère l'objet simplifié etudiant = { "nom": f"""
    {etud["nomprenom"]}""", "etat": etat, @@ -1016,8 +1035,8 @@ def visu_assi_group(): inverse=False, short=False, ), - date_debut=dates["debut"], - date_fin=dates["fin"], + date_debut=_dateiso_to_datefr(dates["debut"]), + date_fin=_dateiso_to_datefr(dates["fin"]), gr_tit=gr_tit, group_ids=request.args.get("group_ids", None), sco=ScoData(formsemestre=groups_infos.get_formsemestre()), @@ -1134,8 +1153,7 @@ def signal_assiduites_diff(): javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ "js/assiduites.js", - "libjs/moment-2.29.4.min.js", - "libjs/moment-timezone.js", + "js/date_utils.js", "js/etud_info.js", ], ) @@ -1336,9 +1354,44 @@ def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str: ) +@bp.route("/test", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +def test(): + """XXX fonction de test a retirer""" + if request.method == "POST": + print("test date_utils : ", request.form) + return render_template("assiduites/pages/test.j2") + + # --- Fonctions internes --- +def _dateiso_to_datefr(date_iso: str) -> str: + """ + _dateiso_to_datefr Transforme une date iso en date format français + + Args: + date_iso (str): date au format iso (YYYY-MM-DD) + + Raises: + ValueError: Si l'argument `date_iso` n'est pas au bon format + + Returns: + str: date au format français (DD/MM/YYYY) + """ + + regex_date_iso: str = r"^\d{4}-([0]\d|1[0-2])-([0-2]\d|3[01])$" + + # Vérification de la date_iso + if not re.match(regex_date_iso, date_iso): + raise ValueError( + f"La dateiso passée en paramètre [{date_iso}] n'est pas valide." + ) + + return f"{date_iso[8:10]}/{date_iso[5:7]}/{date_iso[0:4]}" + + def _get_date_str(deb: datetime.datetime, fin: datetime.datetime) -> str: """ _get_date_str transforme une période en chaîne lisible @@ -1492,7 +1545,7 @@ def _dynamic_module_selector() -> str: ) -def _timeline(formsemestre_id: int = None) -> str: +def _timeline(formsemestre_id: int = None, heures=None) -> str: """ _timeline retourne l'html de la timeline @@ -1512,6 +1565,7 @@ def _timeline(formsemestre_id: int = None) -> str: periode_defaut=sco_preferences.get_preference( "periode_defaut", formsemestre_id ), + heures=heures, ) diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py index 843370422c..589e20aa1d 100644 --- a/app/views/notes_formsemestre.py +++ b/app/views/notes_formsemestre.py @@ -32,13 +32,15 @@ Emmanuel Viennet, 2023 from flask import flash, redirect, render_template, url_for from flask import g, request +from app import db, log from app.decorators import ( scodoc, permission_required, ) -from app.forms.formsemestre import change_formation -from app.models import Formation, FormSemestre +from app.forms.formsemestre import change_formation, edit_modimpls_codes_apo +from app.models import Formation, FormSemestre, ScoDocSiteConfig from app.scodoc import sco_formations, sco_formation_versions +from app.scodoc import sco_groups_view from app.scodoc.sco_permissions import Permission from app.views import notes_bp as bp from app.views import ScoData @@ -105,3 +107,79 @@ def formsemestre_change_formation(formsemestre_id: int): formsemestre=formsemestre, sco=ScoData(formsemestre=formsemestre), ) + + +@bp.route( + "/formsemestre_edit_modimpls_codes/", methods=["GET", "POST"] +) +@scodoc +@permission_required(Permission.EditFormSemestre) +def formsemestre_edit_modimpls_codes(formsemestre_id: int): + """Edition des codes Apogée et EDT""" + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + form = edit_modimpls_codes_apo.EditModimplsCodesForm(formsemestre) + + if request.method == "POST" and form.validate: + if not form.cancel.data: + # record codes + for modimpl in formsemestre.modimpls_sorted: + field_apo = getattr(form, f"modimpl_apo_{modimpl.id}") + field_edt = getattr(form, f"modimpl_edt_{modimpl.id}") + if field_apo and field_edt: + modimpl.code_apogee = field_apo.data.strip() or None + modimpl.edt_id = field_edt.data.strip() or None + log(f"setting codes for {modimpl}: apo={field_apo} edt={field_edt}") + db.session.add(modimpl) + db.session.commit() + flash("Codes enregistrés") + return redirect( + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + # GET + for modimpl in formsemestre.modimpls_sorted: + field_apo = getattr(form, f"modimpl_apo_{modimpl.id}") + field_edt = getattr(form, f"modimpl_edt_{modimpl.id}") + field_apo.data = modimpl.code_apogee or "" + field_edt.data = modimpl.edt_id or "" + return render_template( + "formsemestre/edit_modimpls_codes.j2", + form=form, + formsemestre=formsemestre, + sco=ScoData(formsemestre=formsemestre), + ) + + +@bp.route("/formsemestre/edt/") +@scodoc +@permission_required(Permission.ScoView) +def formsemestre_edt(formsemestre_id: int): + """Expérimental: affiche emploi du temps du semestre""" + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + cfg = ScoDocSiteConfig.query.filter_by(name="assi_morning_time").first() + hour_start = cfg.value.split(":")[0].lstrip(" 0") if cfg else "7" + cfg = ScoDocSiteConfig.query.filter_by(name="assi_afternoon_time").first() + hour_end = cfg.value.split(":")[0].lstrip(" 0") if cfg else "18" + group_ids = request.args.getlist("group_ids", int) + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids=group_ids, + formsemestre_id=formsemestre_id, + empty_list_select_all=False, + ) + return render_template( + "formsemestre/edt.j2", + formsemestre=formsemestre, + hour_start=hour_start, + hour_end=hour_end, + form_groups_choice=sco_groups_view.form_groups_choice( + groups_infos, + submit_on_change=True, + default_deselect_others=False, + with_deselect_butt=True, + ), + groups_query_args=groups_infos.groups_query_args, + sco=ScoData(formsemestre=formsemestre), + ) diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 46dd8d8ff7..6c30eec34a 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -81,7 +81,7 @@ from app.models import ( from app.models import departements from app.models.config import PersonalizedLink - +from app.scodoc import sco_edt_cal from app.scodoc import sco_find_etud from app.scodoc import sco_logos from app.scodoc import sco_utils as scu @@ -273,6 +273,10 @@ def config_cas(): "cas_uid_from_mail_regexp", form.data["cas_uid_from_mail_regexp"] ): flash("Expression extraction identifiant CAS enregistrée") + if ScoDocSiteConfig.set( + "cas_edt_id_from_xml_regexp", form.data["cas_edt_id_from_xml_regexp"] + ): + flash("Expression extraction identifiant edt enregistrée") if ScoDocSiteConfig.set("cas_ssl_verify", form.data["cas_ssl_verify"]): flash("Vérification SSL modifiée") if form.cas_ssl_certificate_file.data: @@ -300,6 +304,9 @@ def config_cas(): form.cas_uid_from_mail_regexp.data = ScoDocSiteConfig.get( "cas_uid_from_mail_regexp" ) + form.cas_edt_id_from_xml_regexp.data = ScoDocSiteConfig.get( + "cas_edt_id_from_xml_regexp" + ) form.cas_ssl_verify.data = ScoDocSiteConfig.get("cas_ssl_verify") return render_template( "config_cas.j2", @@ -316,6 +323,17 @@ def config_assiduites(): form = ConfigAssiduitesForm() if request.method == "POST" and form.cancel.data: # cancel button return redirect(url_for("scodoc.index")) + + edt_options = ( + ("edt_ics_path", "Chemin vers les calendriers ics"), + ("edt_ics_title_field", "Champ contenant titre"), + ("edt_ics_title_regexp", "Expression extraction titre"), + ("edt_ics_group_field", "Champ contenant groupe"), + ("edt_ics_group_regexp", "Expression extraction groupe"), + ("edt_ics_mod_field", "Champ contenant module"), + ("edt_ics_mod_regexp", "Expression extraction module"), + ) + if form.validate_on_submit(): if ScoDocSiteConfig.set("assi_morning_time", form.data["morning_time"]): flash("Heure du début de la journée enregistrée") @@ -323,18 +341,16 @@ def config_assiduites(): flash("Heure de midi enregistrée") if ScoDocSiteConfig.set("assi_afternoon_time", form.data["afternoon_time"]): flash("Heure de fin de la journée enregistrée") - if ( - form.data["tick_time"] > 0 - and form.data["tick_time"] < 60 - and ScoDocSiteConfig.set("assi_tick_time", float(form.data["tick_time"])) - ): + if ScoDocSiteConfig.set("assi_tick_time", float(form.data["tick_time"])): flash("Granularité de la timeline enregistrée") - else: - flash("Erreur : Granularité invalide ou identique") + # --- Calendriers emploi du temps + for opt_name, message in edt_options: + if ScoDocSiteConfig.set(opt_name, form.data[opt_name]): + flash(f"{message} enregistré") return redirect(url_for("scodoc.configuration")) - elif request.method == "GET": + if request.method == "GET": form.morning_time.data = ScoDocSiteConfig.get( "assi_morning_time", datetime.time(8, 0, 0) ) @@ -349,12 +365,33 @@ def config_assiduites(): except ValueError: form.tick_time.data = 15.0 ScoDocSiteConfig.set("assi_tick_time", 15.0) + # --- Emplois du temps + for opt_name, _ in edt_options: + getattr(form, opt_name).data = ScoDocSiteConfig.get(opt_name) - return render_template( - "assiduites/pages/config_assiduites.j2", - form=form, - title="Configuration du module Assiduité", - ) + return render_template( + "assiduites/pages/config_assiduites.j2", + form=form, + title="Configuration du module Assiduité", + ) + + +@bp.route("/ScoDoc/ics_raw_sample/") +@admin_required +def ics_raw_sample(edt_id: str): + "Renvoie un extrait de l'ics brut, pour aider à configurer les extractions" + try: + raw_ics, _ = sco_edt_cal.formsemestre_load_calendar(edt_id=edt_id) + except ScoValueError as exc: + return exc.args[0] + try: + ics = raw_ics.decode(scu.SCO_ENCODING) + except SyntaxError: + return f"Erreur lors de la conversion vers {scu.SCO_ENCODING}" + evs = ics.split("BEGIN:VEVENT") + if len(evs) < 1: + return "pas d'évènements VEVENT détectés dans ce fichier" + return "BEGIN:VEVENT" + evs[len(evs) // 2] @bp.route("/ScoDoc/config_codes_decisions", methods=["GET", "POST"]) diff --git a/app/views/scolar.py b/app/views/scolar.py index 0388c4c2e1..ac908ce768 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -842,7 +842,7 @@ sco_publish("/setGroups", sco_groups.setGroups, Permission.ScoView, methods=["PO sco_publish( "/group_rename", - sco_groups.group_rename, + sco_groups_edit.group_rename, Permission.ScoView, methods=["GET", "POST"], ) diff --git a/flask_cas/routing.py b/flask_cas/routing.py index ec9eef6396..6078b79cad 100644 --- a/flask_cas/routing.py +++ b/flask_cas/routing.py @@ -1,19 +1,21 @@ +""" +Routes for CAS authentication +Modified for ScoDoc +""" +import re import ssl +from urllib.error import URLError +from urllib.request import urlopen import flask -from xmltodict import parse from flask import current_app +from xmltodict import parse + from .cas_urls import create_cas_login_url from .cas_urls import create_cas_logout_url from .cas_urls import create_cas_validate_url -try: - from urllib import urlopen # python 2 -except ImportError: - from urllib.request import urlopen # python 3 -from urllib.error import URLError - blueprint = flask.Blueprint("cas", __name__) @@ -53,7 +55,6 @@ def login(): flask.session[cas_token_session_key] = flask.request.args["ticket"] if cas_token_session_key in flask.session: - if validate(flask.session[cas_token_session_key]): if "CAS_AFTER_LOGIN_SESSION_URL" in flask.session: redirect_url = flask.session.pop("CAS_AFTER_LOGIN_SESSION_URL") @@ -64,7 +65,7 @@ def login(): else: flask.session.pop(cas_token_session_key, None) - current_app.logger.debug("Redirecting to: {redirect_url}") + current_app.logger.debug(f"cas.login: redirecting to {redirect_url}") return flask.redirect(redirect_url) @@ -84,6 +85,7 @@ def logout(): flask.session.pop(cas_username_session_key, None) flask.session.pop(cas_attributes_session_key, None) flask.session.pop(cas_token_session_key, None) # added by EV + flask.session.pop("CAS_EDT_ID", None) # added by EV cas_after_logout = current_app.config["CAS_AFTER_LOGOUT"] if cas_after_logout is not None: @@ -102,7 +104,7 @@ def logout(): else: redirect_url = create_cas_logout_url(current_app.config["CAS_SERVER"], None) - current_app.logger.debug(f"Redirecting to: {redirect_url}") + current_app.logger.debug(f"cas.logout: redirecting to {redirect_url}") return flask.redirect(redirect_url) @@ -114,11 +116,12 @@ def validate(ticket): key `CAS_USERNAME_SESSION_KEY` while the validated attributes dictionary is saved under the key 'CAS_ATTRIBUTES_SESSION_KEY'. """ + from app.models.config import ScoDocSiteConfig cas_username_session_key = current_app.config["CAS_USERNAME_SESSION_KEY"] cas_attributes_session_key = current_app.config["CAS_ATTRIBUTES_SESSION_KEY"] cas_error_callback = current_app.config.get("CAS_ERROR_CALLBACK") - current_app.logger.debug("validating token {0}".format(ticket)) + current_app.logger.debug(f"validating token {ticket}") cas_validate_url = create_cas_validate_url( current_app.config["CAS_SERVER"], @@ -182,7 +185,7 @@ def validate(ticket): attributes = xml_from_dict.get("cas:attributes", {}) if attributes and "cas:memberOf" in attributes: - if isinstance(attributes["cas:memberOf"], basestring): + if isinstance(attributes["cas:memberOf"], str): attributes["cas:memberOf"] = ( attributes["cas:memberOf"].lstrip("[").rstrip("]").split(",") ) @@ -190,6 +193,15 @@ def validate(ticket): attributes["cas:memberOf"][group_number] = ( attributes["cas:memberOf"][group_number].lstrip(" ").rstrip(" ") ) + # Extract auxiliary informations (utilisé pour edt_id) + exp = ScoDocSiteConfig.get("cas_edt_id_from_xml_regexp") + if exp: + m = re.search(exp, xmldump) + if m and len(m.groups()) > 0: + cas_edt_id = m.group(1) + if cas_edt_id: + flask.session["CAS_EDT_ID"] = cas_edt_id + flask.session[cas_username_session_key] = username flask.session[cas_attributes_session_key] = attributes else: diff --git a/migrations/versions/6fb956addd69_edt_id.py b/migrations/versions/6fb956addd69_edt_id.py new file mode 100644 index 0000000000..801ba62b5e --- /dev/null +++ b/migrations/versions/6fb956addd69_edt_id.py @@ -0,0 +1,58 @@ +"""edt_id + +Revision ID: 6fb956addd69 +Revises: fd805feb7ba8 +Create Date: 2023-11-06 12:14:42.808476 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "6fb956addd69" +down_revision = "fd805feb7ba8" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("group_descr", schema=None) as batch_op: + batch_op.add_column(sa.Column("edt_id", sa.Text(), nullable=True)) + batch_op.create_index( + batch_op.f("ix_group_descr_edt_id"), ["edt_id"], unique=False + ) + + with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op: + batch_op.add_column(sa.Column("edt_id", sa.Text(), nullable=True)) + batch_op.create_index( + batch_op.f("ix_notes_formsemestre_edt_id"), ["edt_id"], unique=False + ) + + with op.batch_alter_table("notes_modules", schema=None) as batch_op: + batch_op.add_column(sa.Column("edt_id", sa.Text(), nullable=True)) + batch_op.create_index( + batch_op.f("ix_notes_modules_edt_id"), ["edt_id"], unique=False + ) + + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.add_column(sa.Column("edt_id", sa.Text(), nullable=True)) + batch_op.create_index(batch_op.f("ix_user_edt_id"), ["edt_id"], unique=False) + + +def downgrade(): + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_user_edt_id")) + batch_op.drop_column("edt_id") + + with op.batch_alter_table("notes_modules", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_notes_modules_edt_id")) + batch_op.drop_column("edt_id") + + with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_notes_formsemestre_edt_id")) + batch_op.drop_column("edt_id") + + with op.batch_alter_table("group_descr", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_group_descr_edt_id")) + batch_op.drop_column("edt_id") diff --git a/migrations/versions/c8f66652c77f_code_apo_sur_modimpls.py b/migrations/versions/c8f66652c77f_code_apo_sur_modimpls.py new file mode 100644 index 0000000000..5d818b767e --- /dev/null +++ b/migrations/versions/c8f66652c77f_code_apo_sur_modimpls.py @@ -0,0 +1,38 @@ +"""code apo sur modimpls + +Revision ID: c8f66652c77f +Revises: 6fb956addd69 +Create Date: 2023-11-12 10:01:42.424734 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c8f66652c77f" +down_revision = "6fb956addd69" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("notes_moduleimpl", schema=None) as batch_op: + batch_op.add_column( + sa.Column("code_apogee", sa.String(length=512), nullable=True) + ) + batch_op.add_column(sa.Column("edt_id", sa.Text(), nullable=True)) + batch_op.create_index( + batch_op.f("ix_notes_moduleimpl_code_apogee"), ["code_apogee"], unique=False + ) + batch_op.create_index( + batch_op.f("ix_notes_moduleimpl_edt_id"), ["edt_id"], unique=False + ) + + +def downgrade(): + with op.batch_alter_table("notes_moduleimpl", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_notes_moduleimpl_edt_id")) + batch_op.drop_index(batch_op.f("ix_notes_moduleimpl_code_apogee")) + batch_op.drop_column("edt_id") + batch_op.drop_column("code_apogee") diff --git a/requirements-3.11.txt b/requirements-3.11.txt index 2ae2916b4a..1a6fc31e88 100644 --- a/requirements-3.11.txt +++ b/requirements-3.11.txt @@ -27,7 +27,6 @@ execnet==2.0.2 flake8==6.1.0 Flask==2.3.3 flask-babel==4.0.0 -Flask-Bootstrap==3.3.7.1 Flask-Caching==2.0.2 Flask-HTTPAuth==4.8.0 Flask-JSON==0.4.0 diff --git a/sco_version.py b/sco_version.py index 3fa1a68597..21b533cd95 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.50" +SCOVERSION = "9.6.60" SCONAME = "ScoDoc" diff --git a/tests/api/test_api_permissions.py b/tests/api/test_api_permissions.py index 974e01874a..f02e16a11b 100755 --- a/tests/api/test_api_permissions.py +++ b/tests/api/test_api_permissions.py @@ -2,8 +2,8 @@ """Test permissions - On a deux utilisateurs dans la base test API: - - "test", avec le rôle LecteurAPI qui a la permission ScoView, + On a deux utilisateurs dans la base test API: + - "test", avec le rôle LecteurAPI qui a la permission ScoView, - et "other", qui n'a aucune permission. @@ -70,6 +70,9 @@ def test_permissions(api_headers): # par défaut, on passe tous les arguments de all_args endpoint_args = { "api.formsemestres_query": {}, + "api.formsemestre_edt": { + "formsemestre_id": 1, + }, } for rule in api_rules: args = endpoint_args.get(rule.endpoint, all_args) diff --git a/tools/anonymize_db.py b/tools/anonymize_db.py index 1d95a11b07..cc7a5a042b 100755 --- a/tools/anonymize_db.py +++ b/tools/anonymize_db.py @@ -55,6 +55,7 @@ def usage(): anonymize_name = "random_text_md5(8)" anonymize_date = "'1970-01-01'" +anonymize_false = "FALSE" anonymize_question_str = "'?'" anonymize_null = "NULL" @@ -69,13 +70,14 @@ ANONYMIZED_FIELDS = { "identite.nom": anonymize_name, "identite.prenom": anonymize_name, "identite.nom_usuel": anonymize_null, - "identite.civilite": "'X'", + "identite.civilite_etat_civil" : anonymize_null, + "identite.prenom_etat_civil" : anonymize_null, "identite.date_naissance": anonymize_date, "identite.lieu_naissance": anonymize_question_str, "identite.dept_naissance": anonymize_question_str, "identite.nationalite": anonymize_question_str, "identite.statut": anonymize_null, - "identite.boursier": anonymize_null, + "identite.boursier": anonymize_false, "identite.photo_filename": anonymize_null, "identite.code_nip": anonymize_null, "identite.code_ine": anonymize_null,