forked from ScoDoc/ScoDoc
Merge branch 'master' into assiduites_fixes
This commit is contained in:
commit
fed84559fc
@ -274,6 +274,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
|||||||
return "type_departement mismatch"
|
return "type_departement mismatch"
|
||||||
# Table d'équivalences entre refs:
|
# Table d'équivalences entre refs:
|
||||||
equiv = self._load_config_equivalences()
|
equiv = self._load_config_equivalences()
|
||||||
|
# Même specialité (ou alias) ?
|
||||||
|
if self.specialite != other.specialite and other.specialite not in equiv.get(
|
||||||
|
"alias", []
|
||||||
|
):
|
||||||
|
return "specialite mismatch"
|
||||||
# mêmes parcours ?
|
# mêmes parcours ?
|
||||||
eq_parcours = equiv.get("parcours", {})
|
eq_parcours = equiv.get("parcours", {})
|
||||||
parcours_by_code_1 = {eq_parcours.get(p.code, p.code): p for p in self.parcours}
|
parcours_by_code_1 = {eq_parcours.get(p.code, p.code): p for p in self.parcours}
|
||||||
@ -317,6 +322,9 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
|||||||
def _load_config_equivalences(self) -> dict:
|
def _load_config_equivalences(self) -> dict:
|
||||||
"""Load config file ressources/referentiels/equivalences.yaml
|
"""Load config file ressources/referentiels/equivalences.yaml
|
||||||
used to define equivalences between distinct referentiels
|
used to define equivalences between distinct referentiels
|
||||||
|
return a dict, with optional keys:
|
||||||
|
alias: list of equivalent names for speciality (eg SD == STID)
|
||||||
|
parcours: dict with equivalent parcours acronyms
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(REFCOMP_EQUIVALENCE_FILENAME, encoding="utf-8") as f:
|
with open(REFCOMP_EQUIVALENCE_FILENAME, encoding="utf-8") as f:
|
||||||
|
@ -199,6 +199,11 @@ class Identite(models.ScoDocModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_etud(cls, etudid: int) -> "Identite":
|
def get_etud(cls, etudid: int) -> "Identite":
|
||||||
"""Etudiant ou 404, cherche uniquement dans le département courant"""
|
"""Etudiant ou 404, cherche uniquement dans le département courant"""
|
||||||
|
if not isinstance(etudid, int):
|
||||||
|
try:
|
||||||
|
etudid = int(etudid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
abort(404, "etudid invalide")
|
||||||
if g.scodoc_dept:
|
if g.scodoc_dept:
|
||||||
return cls.query.filter_by(
|
return cls.query.filter_by(
|
||||||
id=etudid, dept_id=g.scodoc_dept_id
|
id=etudid, dept_id=g.scodoc_dept_id
|
||||||
|
@ -884,21 +884,6 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
if can_edit_abs:
|
|
||||||
H.append(
|
|
||||||
f"""
|
|
||||||
<div>
|
|
||||||
<a class="stdlink" href="{
|
|
||||||
url_for("assiduites.signal_assiduites_diff",
|
|
||||||
scodoc_dept=g.scodoc_dept,
|
|
||||||
formsemestre_id=formsemestre.id,
|
|
||||||
group_ids=group.id,
|
|
||||||
)}" title="Page en cours de fusion et sera prochainement supprimée. Veuillez utiliser la page `Saisir l'assiduité`">
|
|
||||||
(Saisie différée)</a>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
H.append("</div>") # /sem-groups-assi
|
H.append("</div>") # /sem-groups-assi
|
||||||
if partition_is_empty:
|
if partition_is_empty:
|
||||||
H.append(
|
H.append(
|
||||||
|
@ -96,13 +96,16 @@ def photo_portal_url(code_nip: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_etud_photo_url(etudid, size="small"):
|
def get_etud_photo_url(etudid, size="small", seed=None):
|
||||||
|
"L'URL scodoc vers la photo de l'étudiant"
|
||||||
|
kwargs = {"seed": seed} if seed else {}
|
||||||
return (
|
return (
|
||||||
url_for(
|
url_for(
|
||||||
"scolar.get_photo_image",
|
"scolar.get_photo_image",
|
||||||
scodoc_dept=g.scodoc_dept,
|
scodoc_dept=g.scodoc_dept,
|
||||||
etudid=etudid,
|
etudid=etudid,
|
||||||
size=size,
|
size=size,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
if has_request_context()
|
if has_request_context()
|
||||||
else ""
|
else ""
|
||||||
@ -114,9 +117,11 @@ def etud_photo_url(etud: dict, size="small", fast=False) -> str:
|
|||||||
If ScoDoc doesn't have an image and a portal is configured, link to it.
|
If ScoDoc doesn't have an image and a portal is configured, link to it.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
photo_url = get_etud_photo_url(etud["etudid"], size=size)
|
|
||||||
if fast:
|
if fast:
|
||||||
return photo_url
|
return get_etud_photo_url(etud["etudid"], size=size)
|
||||||
|
photo_url = get_etud_photo_url(
|
||||||
|
etud["etudid"], size=size, seed=hash(etud.get("photo_filename"))
|
||||||
|
)
|
||||||
path = photo_pathname(etud["photo_filename"], size=size)
|
path = photo_pathname(etud["photo_filename"], size=size)
|
||||||
if not path:
|
if not path:
|
||||||
# Portail ?
|
# Portail ?
|
||||||
@ -374,7 +379,15 @@ def copy_portal_photo_to_fs(etudid: int):
|
|||||||
portal_timeout = sco_preferences.get_preference("portal_timeout")
|
portal_timeout = sco_preferences.get_preference("portal_timeout")
|
||||||
error_message = None
|
error_message = None
|
||||||
try:
|
try:
|
||||||
r = requests.get(url, timeout=portal_timeout)
|
r = requests.get(
|
||||||
|
url,
|
||||||
|
timeout=portal_timeout,
|
||||||
|
params={
|
||||||
|
"nom": etud.nom or "",
|
||||||
|
"prenom": etud.prenom or "",
|
||||||
|
"civilite": etud.civilite,
|
||||||
|
},
|
||||||
|
)
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
error_message = "ConnectionError"
|
error_message = "ConnectionError"
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
|
@ -1085,18 +1085,35 @@ span.spanlink:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trombi_box {
|
.trombi_box {
|
||||||
display: inline-block;
|
|
||||||
width: 110px;
|
|
||||||
vertical-align: top;
|
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
|
width: 140px;
|
||||||
|
/* Constant width for the box */
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* Ensures trombi-photo is above trombi_legend */
|
||||||
|
align-items: center;
|
||||||
|
/* Centers content horizontally */
|
||||||
}
|
}
|
||||||
|
|
||||||
span.trombi_legend {
|
.trombi-photo {
|
||||||
display: inline-block;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
/* Centers image horizontally within the photo container */
|
||||||
|
margin-bottom: 10px;
|
||||||
|
/* Adds some space between the photo and the legend */
|
||||||
}
|
}
|
||||||
|
|
||||||
span.trombi-photo {
|
.trombi-photo img {
|
||||||
|
width: auto;
|
||||||
|
/* Maintains aspect ratio */
|
||||||
|
height: 120px;
|
||||||
|
/* Sets the height to 90px */
|
||||||
|
max-width: 100%;
|
||||||
|
/* Ensures the image doesn't exceed the container's width */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* span.trombi_legend {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1106,7 +1123,9 @@ span.trombi_box a {
|
|||||||
|
|
||||||
span.trombi_box a img {
|
span.trombi_box a img {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
height: 128px;
|
||||||
|
width: auto;
|
||||||
|
} */
|
||||||
|
|
||||||
.trombi_nom {
|
.trombi_nom {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -214,7 +214,7 @@
|
|||||||
] // [0]=Lundi ... [6]=Dimanche -> à 00h00
|
] // [0]=Lundi ... [6]=Dimanche -> à 00h00
|
||||||
|
|
||||||
//Une fonction d'action quand un bouton est cliqué
|
//Une fonction d'action quand un bouton est cliqué
|
||||||
// 3 possibilités :
|
// 3 possibilités :
|
||||||
// - assiduite_id = null -> créer nv assi avec état du bouton
|
// - assiduite_id = null -> créer nv assi avec état du bouton
|
||||||
// - assiduite_id non null et bouton coché == etat assi -> suppression de l'assiduité
|
// - assiduite_id non null et bouton coché == etat assi -> suppression de l'assiduité
|
||||||
// - assiduite_id non null et bouton coché != etat assi -> modification de l'assiduité
|
// - assiduite_id non null et bouton coché != etat assi -> modification de l'assiduité
|
||||||
@ -418,14 +418,14 @@
|
|||||||
|
|
||||||
// Peuplement des boutons en fonction des assiduités
|
// Peuplement des boutons en fonction des assiduités
|
||||||
let boutons = `
|
let boutons = `
|
||||||
<input type="checkbox" name="matin-${etudid}" id="matin-${etudid}"
|
<input type="checkbox" name="matin-${etudid}" id="matin-${etudid}"
|
||||||
class="rbtn retard" value="retard">
|
class="rbtn retard" value="retard">
|
||||||
<input type="checkbox" name="matin-${etudid}" id="matin-${etudid}"
|
<input type="checkbox" name="matin-${etudid}" id="matin-${etudid}"
|
||||||
class="rbtn absent" value="absent">
|
class="rbtn absent" value="absent">
|
||||||
`
|
`
|
||||||
|
|
||||||
if (!non_present) {
|
if (!non_present) {
|
||||||
boutons = `<input type="checkbox" name="matin-${etudid}" id="matin-${etudid}"
|
boutons = `<input type="checkbox" name="matin-${etudid}" id="matin-${etudid}"
|
||||||
class="rbtn present" value="present">`+boutons;
|
class="rbtn present" value="present">`+boutons;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -437,8 +437,8 @@
|
|||||||
const deb = new Date(assi.date_debut);
|
const deb = new Date(assi.date_debut);
|
||||||
const fin = new Date(assi.date_fin);
|
const fin = new Date(assi.date_fin);
|
||||||
|
|
||||||
// si dates == periode -> cocher bouton correspondant
|
// si dates == periode -> cocher bouton correspondant
|
||||||
// Sinon supprimer boutons et mettre case "rouge" + tooltip
|
// Sinon supprimer boutons et mettre case "rouge" + tooltip
|
||||||
|
|
||||||
if (deb.isSame(morningPeriod.deb, "minutes") && fin.isSame(morningPeriod.fin, "minutes")) {
|
if (deb.isSame(morningPeriod.deb, "minutes") && fin.isSame(morningPeriod.fin, "minutes")) {
|
||||||
let etat = assi.etat.toLowerCase();
|
let etat = assi.etat.toLowerCase();
|
||||||
@ -468,7 +468,7 @@
|
|||||||
const deb = new Date(assi.date_debut);
|
const deb = new Date(assi.date_debut);
|
||||||
const fin = new Date(assi.date_fin);
|
const fin = new Date(assi.date_fin);
|
||||||
|
|
||||||
// si dates == periode -> cocher bouton correspondant
|
// si dates == periode -> cocher bouton correspondant
|
||||||
// Sinon supprimer boutons et mettre case "rouge" + tooltip
|
// Sinon supprimer boutons et mettre case "rouge" + tooltip
|
||||||
|
|
||||||
if (deb.isSame(afternoonPeriod.deb, "minutes") && fin.isSame(afternoonPeriod.fin, "minutes")) {
|
if (deb.isSame(afternoonPeriod.deb, "minutes") && fin.isSame(afternoonPeriod.fin, "minutes")) {
|
||||||
@ -504,7 +504,7 @@
|
|||||||
|
|
||||||
let target = e.target;
|
let target = e.target;
|
||||||
let parent = target.parentElement;
|
let parent = target.parentElement;
|
||||||
|
|
||||||
let isCancelled = await actionButton(target, !target.checked);
|
let isCancelled = await actionButton(target, !target.checked);
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -690,7 +690,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
document.getElementById("text-matin").addEventListener("click", (e)=>{
|
document.getElementById("text-matin").addEventListener("click", (e)=>{
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -801,6 +801,7 @@ document.addEventListener("DOMContentLoaded", ()=>{
|
|||||||
{{moduleimpl_select | safe}}
|
{{moduleimpl_select | safe}}
|
||||||
</label>
|
</label>
|
||||||
<button onclick="changeWeek(false)">Semaine suivante</button>
|
<button onclick="changeWeek(false)">Semaine suivante</button>
|
||||||
|
<span><a href="{{url_choix_semaine}}" class="stdlink">autre semaine<a></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 id="tableau-dates">
|
<h3 id="tableau-dates">
|
||||||
|
@ -2026,7 +2026,7 @@ def signal_assiduites_hebdo():
|
|||||||
|
|
||||||
# Vérification semaine dans format iso 8601 et formsemestre
|
# Vérification semaine dans format iso 8601 et formsemestre
|
||||||
regex_iso8601 = r"^\d{4}-W\d{2}$"
|
regex_iso8601 = r"^\d{4}-W\d{2}$"
|
||||||
if not re.match(regex_iso8601, week):
|
if week and not re.match(regex_iso8601, week):
|
||||||
raise ScoValueError("Semaine invalide", dest_url=request.referrer)
|
raise ScoValueError("Semaine invalide", dest_url=request.referrer)
|
||||||
|
|
||||||
fs_deb_iso8601 = formsemestre.date_debut.strftime("%Y-W%W")
|
fs_deb_iso8601 = formsemestre.date_debut.strftime("%Y-W%W")
|
||||||
@ -2035,10 +2035,12 @@ def signal_assiduites_hebdo():
|
|||||||
# Utilisation de la propriété de la norme iso 8601
|
# Utilisation de la propriété de la norme iso 8601
|
||||||
# les chaines sont triables par ordre alphanumérique croissant
|
# les chaines sont triables par ordre alphanumérique croissant
|
||||||
# et produiront le même ordre que les dates par ordre chronologique croissant
|
# et produiront le même ordre que les dates par ordre chronologique croissant
|
||||||
if week < fs_deb_iso8601 or week > fs_fin_iso8601:
|
if (not week) or week < fs_deb_iso8601 or week > fs_fin_iso8601:
|
||||||
flash(
|
if week:
|
||||||
"La semaine n'est pas dans le semestre, choisissez la semaine sur laquelle saisir l'assiduité"
|
flash(
|
||||||
)
|
"""La semaine n'est pas dans le semestre,
|
||||||
|
choisissez la semaine sur laquelle saisir l'assiduité"""
|
||||||
|
)
|
||||||
return sco_gen_cal.calendrier_choix_date(
|
return sco_gen_cal.calendrier_choix_date(
|
||||||
date_debut=formsemestre.date_debut,
|
date_debut=formsemestre.date_debut,
|
||||||
date_fin=formsemestre.date_fin,
|
date_fin=formsemestre.date_fin,
|
||||||
@ -2122,6 +2124,15 @@ def signal_assiduites_hebdo():
|
|||||||
for key, val in jours.items():
|
for key, val in jours.items():
|
||||||
hebdo_jours.append((key in non_travail, val))
|
hebdo_jours.append((key in non_travail, val))
|
||||||
|
|
||||||
|
url_choix_semaine = url_for(
|
||||||
|
"assiduites.signal_assiduites_hebdo",
|
||||||
|
group_ids=",".join(map(str, groups_infos.group_ids)),
|
||||||
|
week="",
|
||||||
|
scodoc_dept=g.scodoc_dept,
|
||||||
|
formsemestre_id=groups_infos.formsemestre_id,
|
||||||
|
moduleimpl_id=moduleimpl_id,
|
||||||
|
)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"assiduites/pages/signal_assiduites_hebdo.j2",
|
"assiduites/pages/signal_assiduites_hebdo.j2",
|
||||||
title="Assiduité: saisie hebdomadaire",
|
title="Assiduité: saisie hebdomadaire",
|
||||||
@ -2137,6 +2148,7 @@ def signal_assiduites_hebdo():
|
|||||||
formsemestre_id=formsemestre_id,
|
formsemestre_id=formsemestre_id,
|
||||||
dept_id=g.scodoc_dept_id,
|
dept_id=g.scodoc_dept_id,
|
||||||
),
|
),
|
||||||
|
url_choix_semaine=url_choix_semaine,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -56,7 +56,9 @@ def refcomp(refcomp_id):
|
|||||||
@permission_required(Permission.ScoView)
|
@permission_required(Permission.ScoView)
|
||||||
def refcomp_show(refcomp_id):
|
def refcomp_show(refcomp_id):
|
||||||
"""Affichage du référentiel de compétences."""
|
"""Affichage du référentiel de compétences."""
|
||||||
referentiel_competence = ApcReferentielCompetences.query.get_or_404(refcomp_id)
|
referentiel_competence: ApcReferentielCompetences = (
|
||||||
|
ApcReferentielCompetences.query.get_or_404(refcomp_id)
|
||||||
|
)
|
||||||
# Autres référentiels "équivalents" pour proposer de changer les formations:
|
# Autres référentiels "équivalents" pour proposer de changer les formations:
|
||||||
referentiels_equivalents = referentiel_competence.equivalents()
|
referentiels_equivalents = referentiel_competence.equivalents()
|
||||||
return render_template(
|
return render_template(
|
||||||
|
@ -15,4 +15,10 @@ QLIO: # la clé est 'specialite'
|
|||||||
ATN: MTD
|
ATN: MTD
|
||||||
# competences: # titres de compétences ('nom_court' dans le XML)
|
# competences: # titres de compétences ('nom_court' dans le XML)
|
||||||
|
|
||||||
SD: STID
|
STID: # passage de STID à SD
|
||||||
|
alias:
|
||||||
|
- SD
|
||||||
|
|
||||||
|
SD: # pour revenir en arrière au besoin
|
||||||
|
alias:
|
||||||
|
- STID
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# -*- mode: python -*-
|
# -*- mode: python -*-
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
SCOVERSION = "9.6.969"
|
SCOVERSION = "9.6.970"
|
||||||
|
|
||||||
SCONAME = "ScoDoc"
|
SCONAME = "ScoDoc"
|
||||||
|
|
||||||
|
@ -28,7 +28,20 @@ script_dir = Path(os.path.abspath(__file__)).parent
|
|||||||
os.chdir(script_dir)
|
os.chdir(script_dir)
|
||||||
|
|
||||||
# Les "photos" des étudiants
|
# Les "photos" des étudiants
|
||||||
FAKE_FACES_PATHS = list((Path("faces").glob("*.jpg")))
|
if os.path.exists("/opt/ExtraFaces"):
|
||||||
|
FAKE_FACES_PATHS = list((Path("extra_faces").glob("*/*.jpg")))
|
||||||
|
FAKE_FACES_PATHS_BY_CIVILITE = {
|
||||||
|
"M": list((Path("extra_faces").glob("M/*.jpg"))),
|
||||||
|
"F": list((Path("extra_faces").glob("F/*.jpg"))),
|
||||||
|
"X": list((Path("extra_faces").glob("X/*.jpg"))),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
FAKE_FACES_PATHS = list((Path("faces").glob("*.jpg")))
|
||||||
|
FAKE_FACES_PATHS_BY_CIVILITE = {
|
||||||
|
"M": FAKE_FACES_PATHS,
|
||||||
|
"F": FAKE_FACES_PATHS,
|
||||||
|
"X": FAKE_FACES_PATHS,
|
||||||
|
}
|
||||||
|
|
||||||
# Etudiant avec tous les champs (USPN)
|
# Etudiant avec tous les champs (USPN)
|
||||||
ETUD_TEMPLATE_FULL = open(script_dir / "etud_template.xml").read()
|
ETUD_TEMPLATE_FULL = open(script_dir / "etud_template.xml").read()
|
||||||
@ -84,16 +97,22 @@ def make_random_etape_etuds(etape, annee):
|
|||||||
return "\n".join(L)
|
return "\n".join(L)
|
||||||
|
|
||||||
|
|
||||||
def get_photo_filename(nip: str) -> str:
|
def get_photo_filename(nip: str, civilite: str | None = None) -> str:
|
||||||
"""get an existing filename for a fake photo, found in faces/
|
"""get an existing filename for a fake photo, found in faces/
|
||||||
Returns a path relative to the current working dir
|
Returns a path relative to the current working dir
|
||||||
|
If civilite is not None, use it to select a subdir
|
||||||
"""
|
"""
|
||||||
#
|
print("get_photo_filename")
|
||||||
nb_faces = len(FAKE_FACES_PATHS)
|
if civilite:
|
||||||
|
faces = FAKE_FACES_PATHS_BY_CIVILITE[civilite]
|
||||||
|
else:
|
||||||
|
faces = FAKE_FACES_PATHS
|
||||||
|
nb_faces = len(faces)
|
||||||
if nb_faces == 0:
|
if nb_faces == 0:
|
||||||
print("WARNING: aucun fichier image disponible !")
|
print("WARNING: aucun fichier image disponible !")
|
||||||
return ""
|
return ""
|
||||||
return FAKE_FACES_PATHS[hash(nip) % nb_faces]
|
print(faces[hash(nip) % nb_faces])
|
||||||
|
return faces[hash(nip) % nb_faces]
|
||||||
|
|
||||||
|
|
||||||
class MyHttpRequestHandler(http.server.SimpleHTTPRequestHandler):
|
class MyHttpRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
@ -139,7 +158,9 @@ class MyHttpRequestHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
elif ("getPhoto" in self.path) or ("scodocPhoto" in self.path):
|
elif ("getPhoto" in self.path) or ("scodocPhoto" in self.path):
|
||||||
nip = query_components["nip"][0]
|
nip = query_components["nip"][0]
|
||||||
self.path = str(get_photo_filename(nip))
|
civilite = query_components.get("civilite")
|
||||||
|
civilite = civilite[0] if civilite else None
|
||||||
|
self.path = str(get_photo_filename(nip, civilite=civilite))
|
||||||
print(f"photo for nip={nip}: {self.path}")
|
print(f"photo for nip={nip}: {self.path}")
|
||||||
else:
|
else:
|
||||||
print(f"Error 404: path={self.path}")
|
print(f"Error 404: path={self.path}")
|
||||||
|
Loading…
Reference in New Issue
Block a user