Update opolka/ScoDoc from ScoDoc/ScoDoc #2

Merged
opolka merged 1272 commits from ScoDoc/ScoDoc:master into master 2024-05-27 09:11:04 +02:00
11 changed files with 291 additions and 69 deletions
Showing only changes of commit bd860295ba - Show all commits

View File

@ -192,15 +192,16 @@ class ConfigAssiduitesForm(FlaskForm):
validators=[Optional(), check_ics_regexp],
)
edt_ics_uid_field = StringField(
label="Champ contenant l'enseignant",
label="Champ contenant les enseignants",
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
validators=[Optional(), check_ics_field],
)
edt_ics_uid_regexp = StringField(
label="Extraction de l'enseignant",
description=r"""expression régulière python dont le premier groupe doit
correspondre à l'identifiant (edt_id) de l'enseignant associé à l'évènement.
Exemple: <tt>Enseignant : ([0-9]+)</tt>
label="Extraction des enseignants",
description=r"""expression régulière python permettant d'extraire les
identifiants des enseignants associés à l'évènement.
(contrairement aux autres champs, il peut y avoir plusieurs enseignants par évènement.)
Exemple: <tt>[0-9]+</tt>
""",
validators=[Optional(), check_ics_regexp],
)

View File

@ -280,7 +280,10 @@ class FormSemestre(db.Model):
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"
"""Les ids pour l'emploi du temps: à défaut, les codes étape Apogée.
Les edt_id de formsemestres ne sont pas normalisés afin de contrôler
précisément l'accès au fichier ics.
"""
return (
scu.split_id(self.edt_id)
or [e.etape_apo.strip() for e in self.etapes if e.etape_apo]

View File

@ -251,8 +251,11 @@ class GroupDescr(ScoDocModel):
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 []
"les ids normalisés pour l'emploi du temps: à défaut, le nom scodoc du groupe"
return [
scu.normalize_edt_id(x)
for x in scu.split_id(self.edt_id) or [self.group_name] or []
]
def get_nb_inscrits(self) -> int:
"""Nombre inscrits à ce group et au formsemestre.

View File

@ -67,11 +67,10 @@ class ModuleImpl(db.Model):
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()
)
return [
scu.normalize_edt_id(x)
for x in 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)"""

View File

@ -292,7 +292,10 @@ class Module(db.Model):
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 []
return [
scu.normalize_edt_id(x)
for x in 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.

View File

@ -132,7 +132,8 @@ def load_calendar(
) from exc
except FileNotFoundError as exc:
log(
f"formsemestre_load_calendar: ics not found for {formsemestre or ''}\npath='{ics_filename}'"
f"""formsemestre_load_calendar: ics not found for {
formsemestre or ''}\npath='{ics_filename}'"""
)
raise ScoValueError(
f"Fichier ics introuvable (filename={ics_filename})"
@ -229,7 +230,8 @@ def formsemestre_edt_dict(
scu.EMO_WARNING} {event['edt_module']}</span>"""
bubble = "code module non trouvé dans ScoDoc. Vérifier configuration."
case _: # module EDT bien retrouvé dans ScoDoc
bubble = f"{modimpl.module.abbrev or modimpl.module.titre or ''} ({event['edt_module']})"
bubble = f"""{modimpl.module.abbrev or modimpl.module.titre or ''
} ({event['edt_module']})"""
mod_disp = (
f"""<span class="mod-name mod-code">{modimpl.module.code}</span>"""
)
@ -249,20 +251,24 @@ def formsemestre_edt_dict(
else ""
)
ens_user_name = event["ens"].user_name if event["ens"] else None
ens_nomprenom = event["ens"].get_nomprenom() if event["ens"] else None
ens_nomprenoms = (
"(" + ", ".join([u.get_nomprenom() for u in event["users"]]) + ")"
if event["users"]
else ""
)
ens_user_names = (
",".join([u.user_name for u in event["users"]]) if event["users"] else ""
)
d = {
# Champs utilisés par tui.calendar
"calendarId": "cal1",
"title": f"""{title} {group_disp} {
'('+ens_nomprenom+')' if ens_nomprenom else ''
} {link_abs}""",
"title": f"""{title} {group_disp} {ens_nomprenoms} {link_abs}""",
"start": event["start"],
"end": event["end"],
"backgroundColor": event["group_bg_color"],
# Infos brutes pour usage API éventuel
"ens_edt": event["edt_ens"],
"ens_user_name": ens_user_name,
"edt_ens_ids": event["edt_ens_ids"],
"ens_user_names": ens_user_names,
"group_id": group.id if group else None,
"group_edt_id": event["edt_group"],
"moduleimpl_id": modimpl.id if modimpl else None,
@ -343,7 +349,7 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1]
for i, group_name in enumerate(edt2group)
}
edt_groups_ids = set() # les ids de groupes tels que dans l'ics
edt_groups_ids = set() # les ids de groupes normalisés tels que dans l'ics
default_group = formsemestre.get_default_group()
edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre)
edt2user: dict[str, User | None] = {} # construit au fur et à mesure (cache)
@ -354,7 +360,7 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
if "DESCRIPTION" in event:
# --- Titre de l'évènement
title_edt = (
extract_event_data(event, edt_ics_title_field, edt_ics_title_pattern)
extract_event_edt_id(event, edt_ics_title_field, edt_ics_title_pattern)
if edt_ics_title_pattern
else "non configuré"
)
@ -362,7 +368,7 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
title = title_edt
# --- Group
if edt_ics_group_pattern:
edt_group = extract_event_data(
edt_group = extract_event_edt_id(
event, edt_ics_group_field, edt_ics_group_pattern
)
edt_groups_ids.add(edt_group)
@ -385,7 +391,7 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
# --- ModuleImpl
if edt_ics_mod_pattern:
edt_module = extract_event_data(
edt_module = extract_event_edt_id(
event, edt_ics_mod_field, edt_ics_mod_pattern
)
modimpl: ModuleImpl = edt2modimpl.get(edt_module, None)
@ -394,19 +400,22 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
else:
modimpl = False
edt_module = ""
# --- Enseignant
# --- Enseignants
users: list[User] = []
if edt_ics_uid_pattern:
edt_ens = extract_event_data(
ens_edt_ids = extract_event_edt_ids(
event, edt_ics_uid_field, edt_ics_uid_pattern
)
if edt_ens in edt2user:
ens = edt2user[edt_ens]
for ens_edt_id in ens_edt_ids:
if ens_edt_id in edt2user:
ens = edt2user[ens_edt_id]
else:
ens = User.query.filter_by(edt_id=edt_ens).first()
edt2user[edt_ens] = ens
ens = User.query.filter_by(edt_id=ens_edt_id).first()
edt2user[ens_edt_id] = ens
if ens:
users.append(ens)
else:
ens = None
edt_ens = ""
ens_edt_ids = []
#
events_sco.append(
{
@ -418,8 +427,8 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
"modimpl": modimpl, # False si extracteur non configuré
"edt_module": edt_module, # id module edt non traduit
# Enseignant
"edt_ens": edt_ens, # id ens edt, non traduit
"ens": ens,
"edt_ens_ids": ens_edt_ids, # ids ens edt, normalisés mais non traduits
"users": users,
# heures pour saisie abs: en heure LOCALE DU SERVEUR
"heure_deb": event.decoded("dtstart")
.replace(tzinfo=timezone.utc)
@ -437,27 +446,49 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
return events_sco, sorted(edt_groups_ids)
def extract_event_data(
def extract_event_edt_id(
event: icalendar.cal.Event,
ics_field: str,
pattern: re.Pattern,
none_if_no_match=False,
) -> str:
"""Extrait la chaine (id) de l'évènement."""
) -> str | None:
"""Extrait la chaine (id) de l'évènement et la normalise.
Si l'event n'a pas le champ: "-"
Si pas de match: None
"""
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)
return scu.normalize_edt_id(m.group(1))
# fallback: if not none_if_no_match, ics field complete
return None if none_if_no_match else data
def extract_event_edt_ids(
event: icalendar.cal.Event,
ics_field: str,
pattern: re.Pattern,
) -> list[str] | None:
"""Extrait les edt_id de l'évènement et les normalise.
Si l'event n'a pas le champ: None
Si pas de match: liste vide
Utilisé pour les enseignants uniquement.
"""
if not event.has_key(ics_field):
return
data = event.decoded(ics_field).decode("utf-8") # assume ics in utf8
matches = pattern.findall(data)
# nota: pattern may have zero or one group, so the result
# is a list of strings, not a list of matches
return [scu.normalize_edt_id(m) for m in matches if m]
def formsemestre_retreive_modimpls_from_edt_id(
formsemestre: FormSemestre,
) -> dict[str, ModuleImpl]:
"""Construit un dict donnant le moduleimpl de chaque edt_id"""
"""Construit un dict donnant le moduleimpl de chaque edt_id (normalisé)"""
edt2modimpl = {}
for modimpl in formsemestre.modimpls:
for edt_id in modimpl.get_edt_ids():
@ -469,10 +500,13 @@ def formsemestre_retreive_modimpls_from_edt_id(
def formsemestre_retreive_groups_from_edt_id(
formsemestre: FormSemestre,
) -> dict[str, GroupDescr]:
"""Construit un dict donnant le groupe de chaque edt_id"""
"""Construit un dict donnant le groupe de chaque edt_id
La clé edt_id est sans accents, lowercase.
"""
edt2group = {}
group: GroupDescr
for partition in formsemestre.partitions:
for g in partition.groups:
for edt_id in g.get_edt_ids():
edt2group[edt_id] = g
for group in partition.groups:
for edt_id in group.get_edt_ids():
edt2group[edt_id] = group
return edt2group

View File

@ -882,6 +882,18 @@ def suppress_accents(s):
return s # may be int
def normalize_edt_id(edt_id: str) -> str:
"""Normalize les identifiants edt pour faciliter la correspondance
entre les identifiants ScoDoc et ceux dans l'ics:
Passe tout en majuscules sans accents ni espaces.
"""
return (
None
if edt_id is None
else suppress_accents(edt_id or "").upper().replace(" ", "")
)
class PurgeChars:
"""delete all chars except those belonging to the specified string"""

View File

@ -1,8 +1,67 @@
#show_modules_titles_form {
#calendar_control_form {
display: inline-block;
margin-left: 16px;
}
/* Style for the dropdown button */
.dropdown {
position: relative;
display: inline-block;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.dropbtn {
background-color: rgb(233,233,233);
color: black;
padding: 2px 32px 2px 4px;
margin-left: 16px;
font-size: 16px;
border: 1px solid black;
border-radius: 5px; /* Rounded corners */
cursor: pointer;
/* Add arrow to the button */
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2212%22%20height%3D%227%22%20viewBox%3D%220%200%2012%207%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%3Cpath%20d%3D%22M1%201L6%206L11%201%22%20stroke%3D%22black%22%20stroke-width%3D%222%22/%3E%3C/svg%3E');
background-repeat: no-repeat;
background-position: right 10px center;
}
/* Dropdown content (hidden by default) */
.dropdown-content {
display: none;
position: absolute;
background-color: #f9f9f9;
min-width: 210px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
border-radius: 5px; /* Rounded corners */
overflow: hidden; /* Ensures rounded corners for dropdown items */
}
/* Style for the dropdown items */
.dropdown-content ul {
list-style: none;
padding: 0;
margin: 0;
}
.dropdown-content ul li {
color: black;
padding: 12px 16px;
text-decoration: none;
display: block;
}
.dropdown-content ul li a {
color: black;
text-decoration: none;
display: block;
}
.dropdown-content ul li label {
cursor: pointer;
display: block;
}
.toastui-calendar-template-time {
padding: 4px;
word-break: break-all;

View File

@ -16,11 +16,29 @@
{{ form_groups_choice|safe }}
<form id="show_modules_titles_form" method="GET">
<input type="checkbox" name="show_modules_titles" {{
'checked' if show_modules_titles else ''}}
onchange="this.form.submit()"/> noms complets des modules</input>
</form>
<div class="dropdown">
<button class="dropbtn">Semaine</button>
<div class="dropdown-content">
<ul>
<li><a href="#" data-view="day">Jour</a></li>
<li><a href="#" data-view="week">Semaine</a></li>
<li><a href="#" data-view="month">Mois</a></li>
<li>
<label>
<input type="checkbox" id="showModulesTitles"
{{ "checked" if show_modules_titles else "" }}
>
noms des modules
</label>
</li>
</ul>
</div>
</div>
<form id="calendar_control_form" method="GET">
<input type="hidden" name="current_date" value="{{current_date}}">
<input type="hidden" name="show_modules_titles" value="{{ 1 if show_modules_titles else 0 }}">
<input type="submit" value="Apply Changes" hidden>
</div>
<div>
<span id="menu-navi">
@ -35,7 +53,7 @@
</span>
<span id="renderRange" class="render-range"></span>
</div>
<div id="calendar" style="height: calc(100vh - 180px);"></div>
<div id="formsemestre-calendar" style="height: calc(100vh - 180px);"></div>
<div class="help">
<ul>
@ -73,10 +91,23 @@ function getDataAction(target) {
return target.dataset ? target.dataset.action : target.getAttribute('data-action');
}
function getNextDayISODate(isoDate) {
// Parse the ISO date string into a Date object
const date = new Date(isoDate);
// Add one day
date.setDate(date.getDate() + 1);
// Convert back to ISO date string (YYYY-MM-DD)
return date.toISOString().split('T')[0];
}
var calendar;
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('menu-navi').addEventListener('click', onClickNavi);
const Calendar = tui.Calendar;
const container = document.getElementById('calendar');
const container = document.getElementById('formsemestre-calendar');
const options = {
defaultView: 'week',
calendars: [
@ -118,7 +149,7 @@ document.addEventListener('DOMContentLoaded', function() {
},
};
const calendar = new Calendar(container, options);
calendar = new Calendar(container, options);
fetch(`${SCO_URL}/../api/formsemestre/{{formsemestre.id}}/edt?{{groups_query_args|safe}}&show_modules_titles={{show_modules_titles}}`)
.then(r=>{return r.json()})
@ -131,6 +162,12 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
{% if current_date %}
// we need to add one day because our weeks are starting on Monday
calendar.setDate( getNextDayISODate("{{current_date}}") );
{% endif %}
changeCalendarDate();
function formatDate(date) {
let year = date.getFullYear();
let month = (date.getMonth() + 1).toString().padStart(2, '0'); // Months are zero-indexed in JavaScript
@ -145,10 +182,17 @@ document.addEventListener('DOMContentLoaded', function() {
var html = [];
if (viewName === 'day') {
html.push(currentCalendarDate('YYYY.MM.DD'));
html.push(calendar.getDate().toDate().toLocaleString('fr-Fr', {
year: 'numeric',
month: 'long',
day: 'numeric'}));
} else if (viewName === 'month' &&
(!options.month.visibleWeeksCount || options.month.visibleWeeksCount > 4)) {
html.push(currentCalendarDate('YYYY.MM'));
html.push(
calendar.getDate().toDate().toLocaleString('fr-Fr', {
year: 'numeric',
month: 'long',
}));
} else {
html.push(formatDate(calendar.getDateRangeStart()));
html.push(' - ');
@ -173,9 +217,65 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
setRenderRangeText();
// setSchedules();
changeCalendarDate();
}
// Update current URL when date change (newDate=ISO string)
function updateCurrentDateInUrl(newDate) {
// Parse the current URL
const currentUrl = new URL(window.location.href);
// Access and modify the search parameters
const searchParams = currentUrl.searchParams;
searchParams.set('current_date', newDate); // Set the new date
// Create the new URL
const newUrl = `${currentUrl.origin}${currentUrl.pathname}?${searchParams.toString()}`;
// Update the URL without reloading the page
window.history.pushState({ path: newUrl }, '', newUrl);
}
// Update "current" date (URL and title)
function changeCalendarDate() {
setRenderRangeText();
// current calendar date, ISO, without time
const iso_date = calendar.getDateRangeStart().toDate().toISOString().split('T')[0];
updateCurrentDateInUrl(iso_date);
calendar_control_form.current_date = iso_date;
}
// View menu
const dropdown = document.querySelector('.dropdown');
const dropbtn = document.querySelector('.dropbtn');
const dropdownContent = document.querySelector('.dropdown-content');
dropbtn.addEventListener('click', function(event) {
dropdownContent.style.display = dropdownContent.style.display === 'block' ? 'none' : 'block';
event.stopPropagation();
});
document.querySelectorAll('.dropdown-content a').forEach(item => {
item.addEventListener('click', function(e) {
e.preventDefault();
const selectedText = this.textContent;
const selectedView = this.getAttribute('data-view');
calendar.changeView(selectedView); // Change the calendar view
dropbtn.textContent = selectedText; // Update the button text
dropdownContent.style.display = 'none';
});
});
const showModulesTitlesCheckbox = document.getElementById('showModulesTitles');
showModulesTitlesCheckbox.addEventListener('change', function() {
calendar_control_form.show_modules_titles.value = (calendar_control_form.show_modules_titles.value=="1") ? "0" : "1";
calendar_control_form.submit();
});
// Close dropdown when clicking outside
window.addEventListener('click', function() {
if (dropdownContent.style.display === 'block') {
dropdownContent.style.display = 'none';
}
});
});
</script>

View File

@ -163,6 +163,7 @@ def formsemestre_edit_modimpls_codes(formsemestre_id: int):
@permission_required(Permission.ScoView)
def formsemestre_edt(formsemestre_id: int):
"""Expérimental: affiche emploi du temps du semestre"""
current_date = request.args.get("current_date")
show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False))
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
cfg = ScoDocSiteConfig.query.filter_by(name="assi_morning_time").first()
@ -177,6 +178,7 @@ def formsemestre_edt(formsemestre_id: int):
)
return render_template(
"formsemestre/edt.j2",
current_date=current_date,
formsemestre=formsemestre,
hour_start=hour_start,
hour_end=hour_end,

View File

@ -86,17 +86,23 @@ def generate_ens_calendars():
nb_events += len(events)
ens: User | None = None
for event in events:
edt_ens = sco_edt_cal.extract_event_data(
event, edt_ics_uid_field, edt_ics_uid_pattern, none_if_no_match=True
ens_edt_ids = sco_edt_cal.extract_event_edt_ids(
event, edt_ics_uid_field, edt_ics_uid_pattern
)
if edt_ens in edt2user:
ens = edt2user[edt_ens]
users = []
for ens_edt_id in ens_edt_ids:
if ens_edt_id in edt2user:
ens = edt2user[ens_edt_id]
else:
ens = User.query.filter_by(edt_id=edt_ens).first()
edt2user[edt_ens] = ens
if ens: # si l'utilisateur est reconnu
event.add("X-ScoDoc-user", ens.user_name)
edt_by_uid[edt_ens].add_component(event)
ens = User.query.filter_by(edt_id=ens_edt_id).first()
edt2user[ens_edt_id] = ens
if ens:
users.append(ens)
edt_by_uid[ens_edt_id].add_component(event)
if users: # si un utilisateur est reconnu dans l'event
event.add("X-ScoDoc-users", ",".join([u.user_name for u in users]))
_write_user_calendars(edt_by_uid)
log(
f"""generate_ens_calendars: done in {(time.time()-t0):g}s, processed {