Compare commits
17 Commits
6889ddd97c
...
refact
| Author | SHA1 | Date | |
|---|---|---|---|
| 83eb9c327b | |||
| ff1ecfef25 | |||
| 921292a0d2 | |||
| 2d08671247 | |||
| a16211cbd4 | |||
| 876f583d51 | |||
| 97b97af2de | |||
| d8d84690c6 | |||
| 18f855ab83 | |||
| 36425e587e | |||
| 8cdeecfc53 | |||
| 1a7c97d869 | |||
| ab5de2711e | |||
| 235019102b | |||
| 8ec24a24b3 | |||
| 2e86b3a0a2 | |||
| 7e6b24aaea |
@@ -1,5 +1,5 @@
|
|||||||
Trimestre,Nom,Date,Exercice,Question,Competence,Domaine,Commentaire,Bareme,Est_nivele,Star Tice,Umberto Dingate,Starlin Crangle,Humbert Bourcq,Gabriella Handyside,Stewart Eaves,Erick Going,Ase Praton,Rollins Planks,Dunstan Sarjant,Stacy Guiton,Ange Stanes,Amabelle Elleton,Darn Broomhall,Dyan Chatto,Keane Rennebach,Nari Paulton,Brandy Wase,Jaclyn Firidolfi,Violette Lockney
|
Trimestre,Nom,Date,Exercice,Question,Competence,Domaine,Commentaire,Bareme,Est_nivele,Star Tice,Umberto Dingate,Starlin Crangle,Humbert Bourcq,Gabriella Handyside,Stewart Eaves,Erick Going,Ase Praton,Rollins Planks,Dunstan Sarjant,Stacy Guiton,Ange Stanes,Amabelle Elleton,Darn Broomhall,Dyan Chatto,Keane Rennebach,Nari Paulton,Brandy Wase,Jaclyn Firidolfi,Violette Lockney
|
||||||
1,DS,12/01/2021,Exercice 1,1,Calculer,Plop,Coucou,1,1,,,1.0,0,1.0,2.0,3.0,0.0,3.0,3.0,2.0,,1.0,,,,,,,
|
1,DS,12/01/2021,Exercice 1,1,Calculer,Plop,Coucou,1,1,,,1,0,1,2,3,0,3,3,2,,1,,,,,,,
|
||||||
1,DS,12/01/2021,Exercice 1,2,Calculer,C'est trop chouette!,Coucou,1,1,,,1.0,2,,,3.0,3.0,,,,,2.0,,,,,,,
|
1,DS,12/01/2021,Exercice 1,2,Calculer,C'est trop chouette!,Coucou,1,1,,,1,2,,,3,3,,,,,2,,,,,,,
|
||||||
1,DS,12/01/2021,Exercice 1,3,Calculer,Null,Coucou,1,1,,,,3,2.0,,,,,,,,3.0,,,,,,,
|
1,DS,12/01/2021,Exercice 1,3,Calculer,Null,Coucou,1,1,,,,3,2,,,,,,,,3,,,,,,,
|
||||||
1,DS,12/01/2021,Exercice 1,3,Calculer,Nié,DChic,1,1,,,,2,,,,,,,,,,,,,,,,
|
1,DS,12/01/2021,Exercice 1,3,Calculer,Nié,DChic,1,1,,,,2,.,,,,,,,,,,,,,,,
|
||||||
|
|||||||
|
@@ -43,9 +43,45 @@ layout = html.Div(
|
|||||||
html.Section(
|
html.Section(
|
||||||
children=[
|
children=[
|
||||||
html.Div(
|
html.Div(
|
||||||
children=[],
|
children=[
|
||||||
|
dash_table.DataTable(
|
||||||
|
id="final_score_table",
|
||||||
|
columns=[
|
||||||
|
{"name": "Étudiant", "id": "student_name"},
|
||||||
|
{"name": "Note", "id": "mark"},
|
||||||
|
{"name": "Barème", "id": "score_rate"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
id="final_score_table_container",
|
id="final_score_table_container",
|
||||||
),
|
),
|
||||||
|
html.Div(
|
||||||
|
children=[
|
||||||
|
dash_table.DataTable(
|
||||||
|
id="score_statistics_table",
|
||||||
|
columns=[],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
id="score_statistics_table_container",
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
children=[
|
||||||
|
dcc.Graph(
|
||||||
|
id="fig_exam_histo",
|
||||||
|
config={"displayModeBar": False},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
id="fig_exam_histo_container",
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
children=[
|
||||||
|
dcc.Graph(
|
||||||
|
id="fig_questions_bar",
|
||||||
|
config={"displayModeBar": False},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
id="fig_questions_bar_container",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
id="analysis",
|
id="analysis",
|
||||||
),
|
),
|
||||||
@@ -57,6 +93,14 @@ layout = html.Div(
|
|||||||
style_data_conditional=[],
|
style_data_conditional=[],
|
||||||
fixed_columns={},
|
fixed_columns={},
|
||||||
editable=True,
|
editable=True,
|
||||||
|
style_table={"minWidth": "100%"},
|
||||||
|
style_cell={
|
||||||
|
"minWidth": "100px",
|
||||||
|
"width": "100px",
|
||||||
|
"maxWidth": "100px",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"textOverflow": "ellipsis",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
id="edit",
|
id="edit",
|
||||||
@@ -65,4 +109,4 @@ layout = html.Div(
|
|||||||
),
|
),
|
||||||
dcc.Store(id="scores"),
|
dcc.Store(id="scores"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
# encoding: utf-8
|
# encoding: utf-8
|
||||||
|
|
||||||
from dash.dependencies import Input, Output, State
|
from dash.dependencies import Input, Output, State
|
||||||
import dash
|
|
||||||
from dash.exceptions import PreventUpdate
|
from dash.exceptions import PreventUpdate
|
||||||
|
import plotly.graph_objects as go
|
||||||
import dash_table
|
import dash_table
|
||||||
import json
|
import json
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from recopytex.dashboard.app import app
|
from recopytex.dashboard.app import app
|
||||||
from recopytex.dashboard.common.formating import highlight_scores
|
from recopytex.dashboard.common.formating import highlight_scores
|
||||||
@@ -17,6 +18,10 @@ from .models import (
|
|||||||
get_unstack_scores,
|
get_unstack_scores,
|
||||||
get_students_from_exam,
|
get_students_from_exam,
|
||||||
get_score_colors,
|
get_score_colors,
|
||||||
|
get_level_color_bar,
|
||||||
|
score_to_final_mark,
|
||||||
|
stack_scores,
|
||||||
|
pivot_score_on,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +56,6 @@ def update_exams_choices(tribe):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def update_scores_store(exam):
|
def update_scores_store(exam):
|
||||||
ctx = dash.callback_context
|
|
||||||
if not exam:
|
if not exam:
|
||||||
return [[], [], [], {}]
|
return [[], [], [], {}]
|
||||||
exam = pd.DataFrame.from_dict([json.loads(exam)])
|
exam = pd.DataFrame.from_dict([json.loads(exam)])
|
||||||
@@ -77,3 +81,136 @@ def update_scores_store(exam):
|
|||||||
highlight_scores(students, score_color),
|
highlight_scores(students, score_color),
|
||||||
{"headers": True, "data": len(fixed_columns)},
|
{"headers": True, "data": len(fixed_columns)},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback(
|
||||||
|
[
|
||||||
|
Output("final_score_table", "data"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Input("scores_table", "data"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def update_finale_score_table(scores):
|
||||||
|
scores_df = pd.DataFrame.from_records(scores)
|
||||||
|
stacked_scores = stack_scores(scores_df)
|
||||||
|
return score_to_final_mark(stacked_scores)
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback(
|
||||||
|
[
|
||||||
|
Output("score_statistics_table", "columns"),
|
||||||
|
Output("score_statistics_table", "data"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Input("final_score_table", "data"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def update_statictics_table(finale_score):
|
||||||
|
df = pd.DataFrame.from_records(finale_score)
|
||||||
|
statistics = df["mark"].describe().to_frame().T
|
||||||
|
return [
|
||||||
|
[{"id": c, "name": c} for c in statistics.columns],
|
||||||
|
statistics.to_dict("records"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback(
|
||||||
|
[
|
||||||
|
Output("fig_exam_histo", "figure"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Input("final_score_table", "data"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def update_exam_histo(finale_scores):
|
||||||
|
scores = pd.DataFrame.from_records(finale_scores)
|
||||||
|
|
||||||
|
if scores.empty:
|
||||||
|
return [go.Figure(data=[go.Scatter(x=[], y=[])])]
|
||||||
|
|
||||||
|
ranges = np.linspace(
|
||||||
|
-0.5,
|
||||||
|
scores["score_rate"].max(),
|
||||||
|
int(scores["score_rate"].max() * 2 + 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
bins = pd.cut(scores["mark"], ranges)
|
||||||
|
scores["Bin"] = bins
|
||||||
|
grouped = (
|
||||||
|
scores.reset_index()
|
||||||
|
.groupby("Bin")
|
||||||
|
.agg({"score_rate": "count", "student_name": lambda x: "\n".join(x)})
|
||||||
|
)
|
||||||
|
grouped.index = grouped.index.map(lambda i: i.right)
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_bar(
|
||||||
|
x=grouped.index,
|
||||||
|
y=grouped["score_rate"],
|
||||||
|
text=grouped["student_name"],
|
||||||
|
textposition="auto",
|
||||||
|
hovertemplate="",
|
||||||
|
marker_color="#4E89DE",
|
||||||
|
)
|
||||||
|
fig.update_layout(
|
||||||
|
height=300,
|
||||||
|
margin=dict(l=5, r=5, b=5, t=5),
|
||||||
|
)
|
||||||
|
return [fig]
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback(
|
||||||
|
[
|
||||||
|
Output("fig_questions_bar", "figure"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Input("scores_table", "data"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def update_questions_bar(finale_scores):
|
||||||
|
scores = pd.DataFrame.from_records(finale_scores)
|
||||||
|
scores = stack_scores(scores)
|
||||||
|
|
||||||
|
if scores.empty:
|
||||||
|
return [go.Figure(data=[go.Scatter(x=[], y=[])])]
|
||||||
|
|
||||||
|
pt = pivot_score_on(scores, ["exercise", "question", "comment"], "score")
|
||||||
|
|
||||||
|
# separation between exercises
|
||||||
|
for i in {i for i in pt.index.get_level_values(0)}:
|
||||||
|
pt.loc[(str(i), "", ""), :] = ""
|
||||||
|
pt.sort_index(inplace=True)
|
||||||
|
|
||||||
|
# Bar label
|
||||||
|
index = (
|
||||||
|
pt.index.get_level_values(0).map(str)
|
||||||
|
+ ":"
|
||||||
|
+ pt.index.get_level_values(1).map(str)
|
||||||
|
+ " "
|
||||||
|
+ pt.index.get_level_values(2).map(str)
|
||||||
|
)
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
|
||||||
|
bars = get_level_color_bar()
|
||||||
|
|
||||||
|
for b in bars:
|
||||||
|
try:
|
||||||
|
fig.add_bar(
|
||||||
|
x=index, y=pt[b["score"]], name=b["name"], marker_color=b["color"]
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
fig.update_layout(barmode="relative")
|
||||||
|
fig.update_layout(
|
||||||
|
height=500,
|
||||||
|
margin=dict(l=5, r=5, b=5, t=5),
|
||||||
|
legend=dict(
|
||||||
|
orientation="h",
|
||||||
|
yanchor="bottom",
|
||||||
|
y=1.02,
|
||||||
|
xanchor="right",
|
||||||
|
x=1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [fig]
|
||||||
|
|||||||
@@ -1,10 +1,44 @@
|
|||||||
#!/usr/bin/env python
|
#!/use/bin/env python
|
||||||
# encoding: utf-8
|
# encoding: utf-8
|
||||||
|
|
||||||
from recopytex.database.filesystem.loader import CSVLoader
|
from recopytex.database.filesystem.loader import CSVLoader
|
||||||
from recopytex.datalib.dataframe import column_values_to_column
|
from recopytex.datalib.dataframe import column_values_to_column
|
||||||
|
import recopytex.datalib.on_score_column as on_column
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
LOADER = CSVLoader("./test_config.yml")
|
LOADER = CSVLoader("./test_confia.ml")
|
||||||
|
SCORES_CONFIG = LOADER.get_config()["scores"]
|
||||||
|
|
||||||
|
|
||||||
|
def unstack_scores(scores):
|
||||||
|
"""Put student_name values to columns
|
||||||
|
|
||||||
|
:param scores: Score dataframe with one line per score
|
||||||
|
:returns: Scrore dataframe with student_name in columns
|
||||||
|
|
||||||
|
"""
|
||||||
|
kept_columns = [col for col in LOADER.score_columns if col != "score"]
|
||||||
|
return column_values_to_column("student_name", "score", kept_columns, scores)
|
||||||
|
|
||||||
|
|
||||||
|
def stack_scores(scores):
|
||||||
|
"""Student columns are melt to rows with student_name column
|
||||||
|
|
||||||
|
:param scores: Score dataframe with student_name in columns
|
||||||
|
:returns: Scrore dataframe with one line per score
|
||||||
|
|
||||||
|
"""
|
||||||
|
kept_columns = [
|
||||||
|
c for c in LOADER.score_columns if c not in ["score", "student_name"]
|
||||||
|
]
|
||||||
|
student_names = [c for c in scores.columns if c not in kept_columns]
|
||||||
|
return pd.melt(
|
||||||
|
scores,
|
||||||
|
id_vars=kept_columns,
|
||||||
|
value_vars=student_names,
|
||||||
|
var_name="student_name",
|
||||||
|
value_name="score",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_tribes():
|
def get_tribes():
|
||||||
@@ -21,8 +55,7 @@ def get_record_scores(exam):
|
|||||||
|
|
||||||
def get_unstack_scores(exam):
|
def get_unstack_scores(exam):
|
||||||
flat_scores = LOADER.get_exam_scores(exam)
|
flat_scores = LOADER.get_exam_scores(exam)
|
||||||
kept_columns = [col for col in LOADER.score_columns if col != "score"]
|
return unstack_scores(flat_scores)
|
||||||
return column_values_to_column("student_name", "score", kept_columns, flat_scores)
|
|
||||||
|
|
||||||
|
|
||||||
def get_students_from_exam(exam):
|
def get_students_from_exam(exam):
|
||||||
@@ -31,8 +64,65 @@ def get_students_from_exam(exam):
|
|||||||
|
|
||||||
|
|
||||||
def get_score_colors():
|
def get_score_colors():
|
||||||
scores_config = LOADER.get_config()["valid_scores"]
|
|
||||||
score_color = {}
|
score_color = {}
|
||||||
for key, score in scores_config.items():
|
for key, score in SCORES_CONFIG.items():
|
||||||
score_color[score["value"]] = score["color"]
|
score_color[score["value"]] = score["color"]
|
||||||
return score_color
|
return score_color
|
||||||
|
|
||||||
|
|
||||||
|
def get_level_color_bar():
|
||||||
|
return [
|
||||||
|
{"score": str(s["value"]), "name": s["comment"], "color": s["color"]}
|
||||||
|
for s in SCORES_CONFIG.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
is_none_score = lambda x: on_column.is_none_score(x, SCORES_CONFIG)
|
||||||
|
format_score = lambda x: on_column.format_score(x, SCORES_CONFIG)
|
||||||
|
score_to_numeric_score = lambda x: on_column.score_to_numeric_score(x, SCORES_CONFIG)
|
||||||
|
score_to_mark = lambda x: on_column.score_to_mark(
|
||||||
|
x, max([v["value"] for v in SCORES_CONFIG.values() if isinstance(v["value"], int)])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_clean_score(scores):
|
||||||
|
filtered_scores = scores[~scores.apply(is_none_score, axis=1)]
|
||||||
|
filtered_scores = filtered_scores.assign(
|
||||||
|
score=filtered_scores.apply(format_score, axis=1)
|
||||||
|
)
|
||||||
|
return filtered_scores
|
||||||
|
|
||||||
|
|
||||||
|
def score_to_final_mark(scores):
|
||||||
|
""" Compute marks then reduce to final mark per student """
|
||||||
|
|
||||||
|
filtered_scores = filter_clean_score(scores)
|
||||||
|
filtered_scores = filtered_scores.assign(
|
||||||
|
score=filtered_scores.apply(score_to_numeric_score, axis=1)
|
||||||
|
)
|
||||||
|
filtered_scores = filtered_scores.assign(
|
||||||
|
mark=filtered_scores.apply(score_to_mark, axis=1)
|
||||||
|
)
|
||||||
|
final_score = filtered_scores.groupby(["student_name"])[
|
||||||
|
["mark", "score_rate"]
|
||||||
|
].sum()
|
||||||
|
return [final_score.reset_index().to_dict("records")]
|
||||||
|
|
||||||
|
|
||||||
|
def pivot_score_on(scores, index, columns, aggfunc="size"):
|
||||||
|
"""Pivot scores on index, columns with aggfunc
|
||||||
|
|
||||||
|
It assumes thant scores are levels
|
||||||
|
|
||||||
|
"""
|
||||||
|
filtered_scores = filter_clean_score(scores)
|
||||||
|
filtered_scores["score"] = filtered_scores["score"].astype(str)
|
||||||
|
pt = pd.pivot_table(
|
||||||
|
filtered_scores,
|
||||||
|
index=index,
|
||||||
|
columns=columns,
|
||||||
|
aggfunc=aggfunc,
|
||||||
|
fill_value=0,
|
||||||
|
)
|
||||||
|
return pt
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from dash.dependencies import Input, Output
|
|||||||
from .app import app
|
from .app import app
|
||||||
from .pages.home import app as home
|
from .pages.home import app as home
|
||||||
from .pages.exams_scores import app as exams_scores
|
from .pages.exams_scores import app as exams_scores
|
||||||
|
import dash_html_components as html
|
||||||
|
|
||||||
|
|
||||||
@app.callback(Output("page-content", "children"), [Input("url", "pathname")])
|
@app.callback(Output("page-content", "children"), [Input("url", "pathname")])
|
||||||
|
|||||||
@@ -21,28 +21,42 @@ competences: # Competences
|
|||||||
name: Communiquer
|
name: Communiquer
|
||||||
abrv: Com
|
abrv: Com
|
||||||
|
|
||||||
valid_scores: #
|
scores: #
|
||||||
BAD: # Everything is bad
|
BAD: # Everything is bad
|
||||||
value: 0
|
value: 0
|
||||||
|
numeric_value: 0
|
||||||
color: "#E7472B"
|
color: "#E7472B"
|
||||||
|
comment: Faux
|
||||||
FEW: # Few good things
|
FEW: # Few good things
|
||||||
value: 1
|
value: 1
|
||||||
|
numeric_value: 1
|
||||||
color: "#FF712B"
|
color: "#FF712B"
|
||||||
|
comment: Peu juste
|
||||||
NEARLY: # Nearly good but things are missing
|
NEARLY: # Nearly good but things are missing
|
||||||
value: 2
|
value: 2
|
||||||
|
numeric_value: 2
|
||||||
color: "#F2EC4C"
|
color: "#F2EC4C"
|
||||||
|
comment: Presque juste
|
||||||
GOOD: # Everything is good
|
GOOD: # Everything is good
|
||||||
value: 3
|
value: 3
|
||||||
|
numeric_value: 3
|
||||||
color: "#68D42F"
|
color: "#68D42F"
|
||||||
|
comment: Juste
|
||||||
NOTFILLED: # The item is not scored yet
|
NOTFILLED: # The item is not scored yet
|
||||||
value: ""
|
value: ""
|
||||||
|
numeric_value: None
|
||||||
color: white
|
color: white
|
||||||
|
comment: En attente
|
||||||
NOANSWER: # Student gives no answer (count as 0)
|
NOANSWER: # Student gives no answer (count as 0)
|
||||||
value: "."
|
value: "."
|
||||||
|
numeric_value: 0
|
||||||
color: black
|
color: black
|
||||||
|
comment: Pas de réponse
|
||||||
ABS: # Student has absent (this score won't be impact the final mark)
|
ABS: # Student has absent (this score won't be impact the final mark)
|
||||||
value: a
|
value: a
|
||||||
|
numeric_value: None
|
||||||
color: lightgray
|
color: lightgray
|
||||||
|
comment: Non noté
|
||||||
|
|
||||||
csv_fields: # dataframe_field: csv_field
|
csv_fields: # dataframe_field: csv_field
|
||||||
term: Trimestre
|
term: Trimestre
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ class CSVLoader(Loader):
|
|||||||
:example:
|
:example:
|
||||||
>>> loader = CSVLoader()
|
>>> loader = CSVLoader()
|
||||||
>>> loader.get_config()
|
>>> loader.get_config()
|
||||||
{'source': './', 'competences': {'Chercher': {'name': 'Chercher', 'abrv': 'Cher'}, 'Représenter': {'name': 'Représenter', 'abrv': 'Rep'}, 'Modéliser': {'name': 'Modéliser', 'abrv': 'Mod'}, 'Raisonner': {'name': 'Raisonner', 'abrv': 'Rai'}, 'Calculer': {'name': 'Calculer', 'abrv': 'Cal'}, 'Communiquer': {'name': 'Communiquer', 'abrv': 'Com'}}, 'valid_scores': {'BAD': {'value': 0, 'color': '#E7472B'}, 'FEW': {'value': 1, 'color': '#FF712B'}, 'NEARLY': {'value': 2, 'color': '#F2EC4C'}, 'GOOD': {'value': 3, 'color': '#68D42F'}, 'NOTFILLED': {'value': '', 'color': 'white'}, 'NOANSWER': {'value': '.', 'color': 'black'}, 'ABS': {'value': 'a', 'color': 'lightgray'}}, 'csv_fields': {'term': 'Trimestre', 'exam': 'Nom', 'date': 'Date', 'exercise': 'Exercice', 'question': 'Question', 'competence': 'Competence', 'theme': 'Domaine', 'comment': 'Commentaire', 'score_rate': 'Bareme', 'is_leveled': 'Est_nivele'}, 'id_templates': {'exam': '{name}_{tribe}', 'question': '{exam_id}_{exercise}_{question}_{comment}'}}
|
{'source': './', 'competences': {'Chercher': {'name': 'Chercher', 'abrv': 'Cher'}, 'Représenter': {'name': 'Représenter', 'abrv': 'Rep'}, 'Modéliser': {'name': 'Modéliser', 'abrv': 'Mod'}, 'Raisonner': {'name': 'Raisonner', 'abrv': 'Rai'}, 'Calculer': {'name': 'Calculer', 'abrv': 'Cal'}, 'Communiquer': {'name': 'Communiquer', 'abrv': 'Com'}}, 'scores': {'BAD': {'value': 0, 'numeric_value': 0, 'color': '#E7472B', 'comment': 'Faux'}, 'FEW': {'value': 1, 'numeric_value': 1, 'color': '#FF712B', 'comment': 'Peu juste'}, 'NEARLY': {'value': 2, 'numeric_value': 2, 'color': '#F2EC4C', 'comment': 'Presque juste'}, 'GOOD': {'value': 3, 'numeric_value': 3, 'color': '#68D42F', 'comment': 'Juste'}, 'NOTFILLED': {'value': '', 'numeric_value': 'None', 'color': 'white', 'comment': 'En attente'}, 'NOANSWER': {'value': '.', 'numeric_value': 0, 'color': 'black', 'comment': 'Pas de réponse'}, 'ABS': {'value': 'a', 'numeric_value': 'None', 'color': 'lightgray', 'comment': 'Non noté'}}, 'csv_fields': {'term': 'Trimestre', 'exam': 'Nom', 'date': 'Date', 'exercise': 'Exercice', 'question': 'Question', 'competence': 'Competence', 'theme': 'Domaine', 'comment': 'Commentaire', 'score_rate': 'Bareme', 'is_leveled': 'Est_nivele'}, 'id_templates': {'exam': '{name}_{tribe}', 'question': '{exam_id}_{exercise}_{question}_{comment}'}}
|
||||||
|
|
||||||
>>> loader = CSVLoader("./test_config.yml")
|
>>> loader = CSVLoader("./test_config.yml")
|
||||||
>>> loader.get_config()
|
>>> loader.get_config()
|
||||||
{'source': './example', 'competences': {'Chercher': {'name': 'Chercher', 'abrv': 'Cher'}, 'Représenter': {'name': 'Représenter', 'abrv': 'Rep'}, 'Modéliser': {'name': 'Modéliser', 'abrv': 'Mod'}, 'Raisonner': {'name': 'Raisonner', 'abrv': 'Rai'}, 'Calculer': {'name': 'Calculer', 'abrv': 'Cal'}, 'Communiquer': {'name': 'Communiquer', 'abrv': 'Com'}}, 'valid_scores': {'BAD': {'value': 0, 'color': '#E7472B'}, 'FEW': {'value': 1, 'color': '#FF712B'}, 'NEARLY': {'value': 2, 'color': '#F2EC4C'}, 'GOOD': {'value': 3, 'color': '#68D42F'}, 'NOTFILLED': {'value': '', 'color': 'white'}, 'NOANSWER': {'value': '.', 'color': 'black'}, 'ABS': {'value': 'a', 'color': 'lightgray'}}, 'csv_fields': {'term': 'Trimestre', 'exam': 'Nom', 'date': 'Date', 'exercise': 'Exercice', 'question': 'Question', 'competence': 'Competence', 'theme': 'Domaine', 'comment': 'Commentaire', 'score_rate': 'Bareme', 'is_leveled': 'Est_nivele'}, 'id_templates': {'exam': '{name}_{tribe}', 'question': '{exam_id}_{exercise}_{question}_{comment}'}, 'output': './output', 'templates': 'templates/', 'tribes': {'Tribe1': {'name': 'Tribe1', 'type': 'Type1', 'students': 'tribe1.csv'}, 'Tribe2': {'name': 'Tribe2', 'students': 'tribe2.csv'}}}
|
{'source': './example', 'competences': {'Chercher': {'name': 'Chercher', 'abrv': 'Cher'}, 'Représenter': {'name': 'Représenter', 'abrv': 'Rep'}, 'Modéliser': {'name': 'Modéliser', 'abrv': 'Mod'}, 'Raisonner': {'name': 'Raisonner', 'abrv': 'Rai'}, 'Calculer': {'name': 'Calculer', 'abrv': 'Cal'}, 'Communiquer': {'name': 'Communiquer', 'abrv': 'Com'}}, 'scores': {'BAD': {'value': 0, 'numeric_value': 0, 'color': '#E7472B', 'comment': 'Faux'}, 'FEW': {'value': 1, 'numeric_value': 1, 'color': '#FF712B', 'comment': 'Peu juste'}, 'NEARLY': {'value': 2, 'numeric_value': 2, 'color': '#F2EC4C', 'comment': 'Presque juste'}, 'GOOD': {'value': 3, 'numeric_value': 3, 'color': '#68D42F', 'comment': 'Juste'}, 'NOTFILLED': {'value': '', 'numeric_value': 'None', 'color': 'white', 'comment': 'En attente'}, 'NOANSWER': {'value': '.', 'numeric_value': 0, 'color': 'black', 'comment': 'Pas de réponse'}, 'ABS': {'value': 'a', 'numeric_value': 'None', 'color': 'lightgray', 'comment': 'Non noté'}}, 'csv_fields': {'term': 'Trimestre', 'exam': 'Nom', 'date': 'Date', 'exercise': 'Exercice', 'question': 'Question', 'competence': 'Competence', 'theme': 'Domaine', 'comment': 'Commentaire', 'score_rate': 'Bareme', 'is_leveled': 'Est_nivele'}, 'id_templates': {'exam': '{name}_{tribe}', 'question': '{exam_id}_{exercise}_{question}_{comment}'}, 'output': './output', 'templates': 'templates/', 'tribes': {'Tribe1': {'name': 'Tribe1', 'type': 'Type1', 'students': 'tribe1.csv'}, 'Tribe2': {'name': 'Tribe2', 'students': 'tribe2.csv'}}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CONFIG = DEFAULT_CONFIG
|
CONFIG = DEFAULT_CONFIG
|
||||||
|
|||||||
@@ -2,53 +2,179 @@
|
|||||||
# encoding: utf-8
|
# encoding: utf-8
|
||||||
|
|
||||||
from math import ceil
|
from math import ceil
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def is_none_score(x, score_config):
|
||||||
|
"""Is a score correspond to a None numeric_value which
|
||||||
|
|
||||||
|
>>> import pandas as pd
|
||||||
|
>>> d = {"Eleve":["E1"]*7,
|
||||||
|
... "score_rate": [1]*7,
|
||||||
|
... "is_leveled":[0]+[1]*6,
|
||||||
|
... "score":[0.33, "", ".", "a", 1, 2, 3],
|
||||||
|
... }
|
||||||
|
>>> score_config = {
|
||||||
|
... 'BAD': {'value': 0, 'numeric_value': 0},
|
||||||
|
... 'FEW': {'value': 1, 'numeric_value': 1},
|
||||||
|
... 'NEARLY': {'value': 2, 'numeric_value': 2},
|
||||||
|
... 'GOOD': {'value': 3, 'numeric_value': 3},
|
||||||
|
... 'NOTFILLED': {'value': '', 'numeric_value': 'None'},
|
||||||
|
... 'NOANSWER': {'value': '.', 'numeric_value': 0},
|
||||||
|
... 'ABS': {'value': 'a', 'numeric_value': 'None'}
|
||||||
|
... }
|
||||||
|
>>> df = pd.DataFrame(d)
|
||||||
|
>>> df.apply(lambda x:is_none_score(x, score_config), axis=1)
|
||||||
|
0 False
|
||||||
|
1 True
|
||||||
|
2 False
|
||||||
|
3 True
|
||||||
|
4 False
|
||||||
|
5 False
|
||||||
|
6 False
|
||||||
|
dtype: bool
|
||||||
|
|
||||||
|
"""
|
||||||
|
none_values = [
|
||||||
|
v["value"]
|
||||||
|
for v in score_config.values()
|
||||||
|
if str(v["numeric_value"]).lower() == "none"
|
||||||
|
]
|
||||||
|
return x["score"] in none_values or pd.isnull(x["score"])
|
||||||
|
|
||||||
|
|
||||||
|
def format_score(x, score_config):
|
||||||
|
"""Make sure that score have the appropriate format
|
||||||
|
|
||||||
|
>>> import pandas as pd
|
||||||
|
>>> d = {"Eleve":["E1"]*6,
|
||||||
|
... "score_rate": [1]*6,
|
||||||
|
... "is_leveled":[0]+[1]*5,
|
||||||
|
... "score":[0.33, ".", "a", 1, 2, 3],
|
||||||
|
... }
|
||||||
|
>>> score_config = {
|
||||||
|
... 'BAD': {'value': 0, 'numeric_value': 0},
|
||||||
|
... 'FEW': {'value': 1, 'numeric_value': 1},
|
||||||
|
... 'NEARLY': {'value': 2, 'numeric_value': 2},
|
||||||
|
... 'GOOD': {'value': 3, 'numeric_value': 3},
|
||||||
|
... 'NOTFILLED': {'value': '', 'numeric_value': 'None'},
|
||||||
|
... 'NOANSWER': {'value': '.', 'numeric_value': 0},
|
||||||
|
... 'ABS': {'value': 'a', 'numeric_value': 'None'}
|
||||||
|
... }
|
||||||
|
>>> df = pd.DataFrame(d)
|
||||||
|
>>> df.apply(lambda x:format_score(x, score_config), axis=1)
|
||||||
|
0 0.33
|
||||||
|
1 .
|
||||||
|
2 a
|
||||||
|
3 1
|
||||||
|
4 2
|
||||||
|
5 3
|
||||||
|
dtype: object
|
||||||
|
>>> format_score({"score": "1.0", "is_leveled": 1}, score_config)
|
||||||
|
1
|
||||||
|
>>> format_score({"score": "3.0", "is_leveled": 1}, score_config)
|
||||||
|
3
|
||||||
|
>>> format_score({"score": 4, "is_leveled": 1}, score_config)
|
||||||
|
Traceback (most recent call last):
|
||||||
|
...
|
||||||
|
ValueError: 4 (<class 'int'>) can't be a score
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not x["is_leveled"]:
|
||||||
|
return float(x["score"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
score = int(float(x["score"]))
|
||||||
|
except ValueError:
|
||||||
|
score = str(x["score"])
|
||||||
|
|
||||||
|
if score in [v["value"] for v in score_config.values()]:
|
||||||
|
return score
|
||||||
|
|
||||||
|
raise ValueError(f"{x['score']} ({type(x['score'])}) can't be a score")
|
||||||
|
|
||||||
|
|
||||||
|
def score_to_numeric_score(x, score_config):
|
||||||
|
"""Convert a score to the corresponding numeric value
|
||||||
|
|
||||||
|
>>> import pandas as pd
|
||||||
|
>>> d = {"Eleve":["E1"]*7,
|
||||||
|
... "score_rate": [1]*7,
|
||||||
|
... "is_leveled":[0]+[1]*6,
|
||||||
|
... "score":[0.33, "", ".", "a", 1, 2, 3],
|
||||||
|
... }
|
||||||
|
>>> score_config = {
|
||||||
|
... 'BAD': {'value': 0, 'numeric_value': 0},
|
||||||
|
... 'FEW': {'value': 1, 'numeric_value': 1},
|
||||||
|
... 'NEARLY': {'value': 2, 'numeric_value': 2},
|
||||||
|
... 'GOOD': {'value': 3, 'numeric_value': 3},
|
||||||
|
... 'NOTFILLED': {'value': '', 'numeric_value': 'None'},
|
||||||
|
... 'NOANSWER': {'value': '.', 'numeric_value': 0},
|
||||||
|
... 'ABS': {'value': 'a', 'numeric_value': 'None'}
|
||||||
|
... }
|
||||||
|
>>> df = pd.DataFrame(d)
|
||||||
|
>>> df.apply(lambda x:score_to_numeric_score(x, score_config), axis=1)
|
||||||
|
0 0.33
|
||||||
|
1 None
|
||||||
|
2 0
|
||||||
|
3 None
|
||||||
|
4 1
|
||||||
|
5 2
|
||||||
|
6 3
|
||||||
|
dtype: object
|
||||||
|
|
||||||
|
"""
|
||||||
|
if x["is_leveled"]:
|
||||||
|
replacements = {v["value"]: v["numeric_value"] for v in score_config.values()}
|
||||||
|
return replacements[x["score"]]
|
||||||
|
|
||||||
|
return x["score"]
|
||||||
|
|
||||||
|
|
||||||
def score_to_mark(x, score_max, rounding=lambda x: round(x, 2)):
|
def score_to_mark(x, score_max, rounding=lambda x: round(x, 2)):
|
||||||
"""Compute the mark from the score
|
"""Compute the mark from "score" which have to be filtered and in numeric form
|
||||||
|
|
||||||
if the item is leveled then the score is multiply by the score_rate
|
if the item is leveled then the score is multiply by the score_rate
|
||||||
otherwise it copies the score
|
otherwise it copies the score
|
||||||
|
|
||||||
:param x: dictionnary with "is_leveled", "score" and "score_rate" keys
|
:param x: dictionnary with "is_leveled", "score" (need to be number) and "score_rate" keys
|
||||||
:param score_max:
|
:param score_max:
|
||||||
:param rounding: rounding mark function
|
:param rounding: rounding mark function
|
||||||
:return: the mark
|
:return: the mark
|
||||||
|
|
||||||
>>> import pandas as pd
|
>>> import pandas as pd
|
||||||
>>> d = {"Eleve":["E1"]*6 + ["E2"]*6,
|
>>> d = {"Eleve":["E1"]*7,
|
||||||
... "score_rate":[1]*2+[2]*2+[2]*2 + [1]*2+[2]*2+[2]*2,
|
... "score_rate": [1]*7,
|
||||||
... "is_leveled":[0]*4+[1]*2 + [0]*4+[1]*2,
|
... "is_leveled":[0]+[1]*6,
|
||||||
... "score":[1, 0.33, 2, 1.5, 1, 3, 0.666, 1, 1.5, 1.2, 2, 3],
|
... "score":[0.33, "", ".", "a", 1, 2, 3],
|
||||||
|
... }
|
||||||
|
>>> score_config = {
|
||||||
|
... 'BAD': {'value': 0, 'numeric_value': 0},
|
||||||
|
... 'FEW': {'value': 1, 'numeric_value': 1},
|
||||||
|
... 'NEARLY': {'value': 2, 'numeric_value': 2},
|
||||||
|
... 'GOOD': {'value': 3, 'numeric_value': 3},
|
||||||
|
... 'NOTFILLED': {'value': '', 'numeric_value': 'None'},
|
||||||
|
... 'NOANSWER': {'value': '.', 'numeric_value': 0},
|
||||||
|
... 'ABS': {'value': 'a', 'numeric_value': 'None'}
|
||||||
... }
|
... }
|
||||||
>>> df = pd.DataFrame(d)
|
>>> df = pd.DataFrame(d)
|
||||||
>>> df.loc[0]
|
>>> df = df[~df.apply(lambda x:is_none_score(x, score_config), axis=1)]
|
||||||
Eleve E1
|
>>> df["score"] = df.apply(lambda x:score_to_numeric_score(x, score_config), axis=1)
|
||||||
score_rate 1
|
>>> df.apply(lambda x:score_to_mark(x, 3), axis=1)
|
||||||
is_leveled 0
|
0 0.33
|
||||||
score 1.0
|
2 0.00
|
||||||
Name: 0, dtype: object
|
4 0.33
|
||||||
>>> score_to_mark(df.loc[0], 3)
|
5 0.67
|
||||||
1.0
|
6 1.00
|
||||||
>>> df.loc[10]
|
dtype: float64
|
||||||
Eleve E2
|
|
||||||
score_rate 2
|
|
||||||
is_leveled 1
|
|
||||||
score 2.0
|
|
||||||
Name: 10, dtype: object
|
|
||||||
>>> score_to_mark(df.loc[10], 3)
|
|
||||||
1.33
|
|
||||||
>>> from .on_value import round_half_point
|
>>> from .on_value import round_half_point
|
||||||
>>> score_to_mark(df.loc[10], 3, round_half_point)
|
>>> df.apply(lambda x:score_to_mark(x, 3, round_half_point), axis=1)
|
||||||
1.5
|
0 0.5
|
||||||
>>> df.loc[1]
|
2 0.0
|
||||||
Eleve E1
|
4 0.5
|
||||||
score_rate 1
|
5 0.5
|
||||||
is_leveled 0
|
6 1.0
|
||||||
score 0.33
|
dtype: float64
|
||||||
Name: 1, dtype: object
|
|
||||||
>>> score_to_mark(df.loc[1], 3)
|
|
||||||
0.33
|
|
||||||
"""
|
"""
|
||||||
if x["is_leveled"]:
|
if x["is_leveled"]:
|
||||||
if x["score"] not in list(range(score_max + 1)):
|
if x["score"] not in list(range(score_max + 1)):
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# encoding: utf-8
|
|
||||||
|
|
||||||
from .on_score_column import score_to_mark, score_to_level
|
|
||||||
|
|
||||||
|
|
||||||
def compute_marks(df, score_max, rounding=lambda x: round(x, 2)):
|
|
||||||
"""Compute the mark for the dataframe
|
|
||||||
|
|
||||||
apply score_to_mark to each row
|
|
||||||
|
|
||||||
:param df: DataFrame with "score", "is_leveled" and "score_rate" columns.
|
|
||||||
|
|
||||||
>>> import pandas as pd
|
|
||||||
>>> d = {"Eleve":["E1"]*6 + ["E2"]*6,
|
|
||||||
... "score_rate":[1]*2+[2]*2+[2]*2 + [1]*2+[2]*2+[2]*2,
|
|
||||||
... "is_leveled":[0]*4+[1]*2 + [0]*4+[1]*2,
|
|
||||||
... "score":[1, 0.33, 2, 1.5, 1, 3, 0.666, 1, 1.5, 1, 2, 3],
|
|
||||||
... }
|
|
||||||
>>> df = pd.DataFrame(d)
|
|
||||||
>>> df
|
|
||||||
Eleve score_rate is_leveled score
|
|
||||||
0 E1 1 0 1.000
|
|
||||||
1 E1 1 0 0.330
|
|
||||||
2 E1 2 0 2.000
|
|
||||||
3 E1 2 0 1.500
|
|
||||||
4 E1 2 1 1.000
|
|
||||||
5 E1 2 1 3.000
|
|
||||||
6 E2 1 0 0.666
|
|
||||||
7 E2 1 0 1.000
|
|
||||||
8 E2 2 0 1.500
|
|
||||||
9 E2 2 0 1.000
|
|
||||||
10 E2 2 1 2.000
|
|
||||||
11 E2 2 1 3.000
|
|
||||||
>>> compute_marks(df, 3)
|
|
||||||
0 1.00
|
|
||||||
1 0.33
|
|
||||||
2 2.00
|
|
||||||
3 1.50
|
|
||||||
4 0.67
|
|
||||||
5 2.00
|
|
||||||
6 0.67
|
|
||||||
7 1.00
|
|
||||||
8 1.50
|
|
||||||
9 1.00
|
|
||||||
10 1.33
|
|
||||||
11 2.00
|
|
||||||
dtype: float64
|
|
||||||
>>> from .on_value import round_half_point
|
|
||||||
>>> compute_marks(df, 3, round_half_point)
|
|
||||||
0 1.0
|
|
||||||
1 0.5
|
|
||||||
2 2.0
|
|
||||||
3 1.5
|
|
||||||
4 0.5
|
|
||||||
5 2.0
|
|
||||||
6 0.5
|
|
||||||
7 1.0
|
|
||||||
8 1.5
|
|
||||||
9 1.0
|
|
||||||
10 1.5
|
|
||||||
11 2.0
|
|
||||||
dtype: float64
|
|
||||||
"""
|
|
||||||
return df[["score", "is_leveled", "score_rate"]].apply(
|
|
||||||
lambda x: score_to_mark(x, score_max, rounding), axis=1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def compute_level(df, level_max=3):
|
|
||||||
"""Compute level for the dataframe
|
|
||||||
|
|
||||||
Applies score_to_level to each row
|
|
||||||
|
|
||||||
:param df: DataFrame with "score", "is_leveled" and "score_rate" columns.
|
|
||||||
:return: Columns with level
|
|
||||||
|
|
||||||
>>> import pandas as pd
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> d = {"Eleve":["E1"]*6 + ["E2"]*6,
|
|
||||||
... "score_rate":[1]*2+[2]*2+[2]*2 + [1]*2+[2]*2+[2]*2,
|
|
||||||
... "is_leveled":[0]*4+[1]*2 + [0]*4+[1]*2,
|
|
||||||
... "score":[0, 0.33, 2, 1.5, 1, 3, 0.666, 1, 1.5, 1, 2, 3],
|
|
||||||
... }
|
|
||||||
>>> df = pd.DataFrame(d)
|
|
||||||
>>> compute_level(df)
|
|
||||||
0 0
|
|
||||||
1 1
|
|
||||||
2 3
|
|
||||||
3 3
|
|
||||||
4 1
|
|
||||||
5 3
|
|
||||||
6 2
|
|
||||||
7 3
|
|
||||||
8 3
|
|
||||||
9 2
|
|
||||||
10 2
|
|
||||||
11 3
|
|
||||||
dtype: int64
|
|
||||||
"""
|
|
||||||
return df[["score", "is_leveled", "score_rate"]].apply(
|
|
||||||
lambda x: score_to_level(x, level_max), axis=1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def compute_normalized(df, rounding=lambda x: round(x, 2)):
|
|
||||||
"""Compute the normalized mark (Mark / score_rate)
|
|
||||||
|
|
||||||
:param df: DataFrame with "Mark" and "score_rate" columns
|
|
||||||
:return: column with normalized mark
|
|
||||||
|
|
||||||
>>> import pandas as pd
|
|
||||||
>>> d = {"Eleve":["E1"]*6 + ["E2"]*6,
|
|
||||||
... "score_rate":[1]*2+[2]*2+[2]*2 + [1]*2+[2]*2+[2]*2,
|
|
||||||
... "is_leveled":[0]*4+[1]*2 + [0]*4+[1]*2,
|
|
||||||
... "score":[1, 0.33, 2, 1.5, 1, 3, 0.666, 1, 1.5, 1, 2, 3],
|
|
||||||
... }
|
|
||||||
>>> df = pd.DataFrame(d)
|
|
||||||
>>> df["mark"] = compute_marks(df, 3)
|
|
||||||
>>> compute_normalized(df)
|
|
||||||
0 1.00
|
|
||||||
1 0.33
|
|
||||||
2 1.00
|
|
||||||
3 0.75
|
|
||||||
4 0.34
|
|
||||||
5 1.00
|
|
||||||
6 0.67
|
|
||||||
7 1.00
|
|
||||||
8 0.75
|
|
||||||
9 0.50
|
|
||||||
10 0.66
|
|
||||||
11 1.00
|
|
||||||
dtype: float64
|
|
||||||
"""
|
|
||||||
return rounding(df["mark"] / df["score_rate"])
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Reglages pour 'vim'
|
|
||||||
# vim:set autoindent expandtab tabstop=4 shiftwidth=4:
|
|
||||||
# cursor: 16 del
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
# encoding: utf-8
|
# encoding: utf-8
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from recopytex.dashboard.app import app as dash
|
from recopytex.dashboard.index import app as dash
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
def cli():
|
def cli():
|
||||||
@@ -14,3 +13,6 @@ def cli():
|
|||||||
@click.option("--debug", default=0, help="Debug mode for dash")
|
@click.option("--debug", default=0, help="Debug mode for dash")
|
||||||
def dashboard(debug):
|
def dashboard(debug):
|
||||||
dash.run_server(debug=bool(debug))
|
dash.run_server(debug=bool(debug))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
|
|||||||
Reference in New Issue
Block a user