ositah 24.7.dev1__py3-none-any.whl → 24.7.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. ositah/__init__.py +0 -0
  2. ositah/app.py +17 -0
  3. ositah/apps/__init__.py +0 -0
  4. ositah/apps/analysis.py +774 -0
  5. ositah/apps/configuration/__init__.py +0 -0
  6. ositah/apps/configuration/callbacks.py +917 -0
  7. ositah/apps/configuration/main.py +546 -0
  8. ositah/apps/configuration/parameters.py +74 -0
  9. ositah/apps/configuration/tools.py +112 -0
  10. ositah/apps/export.py +1172 -0
  11. ositah/apps/validation/__init__.py +0 -0
  12. ositah/apps/validation/callbacks.py +240 -0
  13. ositah/apps/validation/main.py +89 -0
  14. ositah/apps/validation/parameters.py +25 -0
  15. ositah/apps/validation/tables.py +654 -0
  16. ositah/apps/validation/tools.py +533 -0
  17. ositah/assets/arrow_down_up.svg +4 -0
  18. ositah/assets/ositah.css +54 -0
  19. ositah/assets/sort_ascending.svg +5 -0
  20. ositah/assets/sort_descending.svg +6 -0
  21. ositah/assets/sorttable.js +499 -0
  22. ositah/main.py +449 -0
  23. ositah/ositah.example.cfg +215 -0
  24. ositah/static/style.css +54 -0
  25. ositah/templates/base.html +22 -0
  26. ositah/templates/bootstrap_login.html +38 -0
  27. ositah/templates/login_form.html +27 -0
  28. ositah/utils/__init__.py +0 -0
  29. ositah/utils/agents.py +117 -0
  30. ositah/utils/authentication.py +287 -0
  31. ositah/utils/cache.py +19 -0
  32. ositah/utils/core.py +13 -0
  33. ositah/utils/exceptions.py +64 -0
  34. ositah/utils/hito_db.py +51 -0
  35. ositah/utils/hito_db_model.py +253 -0
  36. ositah/utils/menus.py +339 -0
  37. ositah/utils/period.py +135 -0
  38. ositah/utils/projects.py +1175 -0
  39. ositah/utils/teams.py +42 -0
  40. ositah/utils/utils.py +458 -0
  41. {ositah-24.7.dev1.dist-info → ositah-24.7.dev3.dist-info}/METADATA +1 -1
  42. ositah-24.7.dev3.dist-info/RECORD +46 -0
  43. ositah-24.7.dev1.dist-info/RECORD +0 -6
  44. {ositah-24.7.dev1.dist-info → ositah-24.7.dev3.dist-info}/LICENSE +0 -0
  45. {ositah-24.7.dev1.dist-info → ositah-24.7.dev3.dist-info}/WHEEL +0 -0
  46. {ositah-24.7.dev1.dist-info → ositah-24.7.dev3.dist-info}/entry_points.txt +0 -0
  47. {ositah-24.7.dev1.dist-info → ositah-24.7.dev3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,533 @@
1
+ """
2
+ Various functions used by Validation sub-application
3
+ """
4
+
5
+ from datetime import datetime
6
+
7
+ import dash_bootstrap_components as dbc
8
+ import numpy as np
9
+ import pandas as pd
10
+ from dash import html
11
+
12
+ from ositah.apps.validation.parameters import (
13
+ VALIDATION_DECLARATIONS_SELECT_ALL,
14
+ VALIDATION_DECLARATIONS_SELECT_NOT_VALIDATED,
15
+ VALIDATION_DECLARATIONS_SELECT_VALIDATED,
16
+ VALIDATION_DECLARATIONS_SWITCH_ID,
17
+ )
18
+ from ositah.utils.exceptions import SessionDataMissing
19
+ from ositah.utils.hito_db import get_db
20
+ from ositah.utils.period import get_validation_period_data
21
+ from ositah.utils.projects import (
22
+ CATEGORY_DEFAULT,
23
+ DATA_SOURCE_HITO,
24
+ get_team_projects,
25
+ project_time,
26
+ time_unit,
27
+ )
28
+ from ositah.utils.utils import (
29
+ SEMESTER_HOURS,
30
+ TIME_UNIT_HOURS,
31
+ TIME_UNIT_HOURS_EN,
32
+ TIME_UNIT_WEEKS,
33
+ WEEK_HOURS,
34
+ GlobalParams,
35
+ no_session_id_jumbotron,
36
+ )
37
+
38
+
39
+ def activity_time_cell(row, column, row_index):
40
+ """
41
+ Build the cell content for an activity time, adding the appropriate class and in case of
42
+ inconsistencies between the Hito declared time and the validated time, add a tooltip
43
+ to display both values.
44
+
45
+ :param row: declaration row
46
+ :param column: column name for the cell to build
47
+ :param row_index: row index of the current row
48
+ :return: html.Td
49
+ """
50
+
51
+ global_params = GlobalParams()
52
+
53
+ classes = "text-center align-middle"
54
+ cell_value = int(round(row[column]))
55
+ cell_id = f"validation-table-value-{row_index}-{column}"
56
+
57
+ if column == "percent_global":
58
+ thresholds = global_params.declaration_options["thresholds"]
59
+ percent = round(row["percent_global"], 1)
60
+ if percent <= thresholds["low"]:
61
+ percent_class = "table-danger"
62
+ tooltip_txt = f"Percentage low (<={thresholds['low']}%)"
63
+ elif percent <= thresholds["suspect"]:
64
+ percent_class = "table-warning"
65
+ tooltip_txt = (
66
+ f"Percentage suspect (>{thresholds['low']}% and" f" <={thresholds['suspect']}%)"
67
+ )
68
+ elif percent > thresholds["good"]:
69
+ percent_class = "table-info"
70
+ tooltip_txt = f"Percentage too high (>{thresholds['good']}%)"
71
+ else:
72
+ percent_class = "table-success"
73
+
74
+ contents = [html.Div(percent, id=cell_id)]
75
+ if percent_class != "table-success":
76
+ contents.append(dbc.Tooltip(html.Div(tooltip_txt), target=cell_id))
77
+
78
+ classes += f" {percent_class}"
79
+
80
+ elif (
81
+ (column != "enseignement" or not global_params.teaching_ratio)
82
+ and time_unit(column) == TIME_UNIT_HOURS_EN
83
+ and row[column] > global_params.declaration_options["max_hours"]
84
+ ):
85
+ contents = [
86
+ html.Div(cell_value, id=cell_id),
87
+ dbc.Tooltip(
88
+ html.Div(
89
+ (
90
+ f"Déclaration supérieure au maximum"
91
+ f" ({global_params.declaration_options['max_hours']} heures)"
92
+ )
93
+ ),
94
+ target=cell_id,
95
+ ),
96
+ ]
97
+ classes += " table-danger"
98
+
99
+ elif row.hito_missing:
100
+ validated_column = f"{column}_val"
101
+ contents = [
102
+ html.Div(int(round(row[validated_column])), id=cell_id),
103
+ dbc.Tooltip(
104
+ [
105
+ html.Div(f"Déclaration validée: {round(row[validated_column], 3)}"),
106
+ html.Div("Déclaration Hito correspondante supprimée"),
107
+ ],
108
+ target=cell_id,
109
+ ),
110
+ ]
111
+ classes += " validated_hito_missing"
112
+
113
+ elif row.validated and not row[f"{column}_time_ok"]:
114
+ validated_column = f"{column}_val"
115
+ contents = [
116
+ html.Div(cell_value, id=cell_id),
117
+ dbc.Tooltip(
118
+ [
119
+ html.Div(f"Dernière déclaration: {round(row[column], 3)}"),
120
+ html.Div(f"Déclaration validée: {round(row[validated_column], 3)}"),
121
+ ],
122
+ target=cell_id,
123
+ ),
124
+ ]
125
+ classes += " table-warning"
126
+
127
+ else:
128
+ contents = [html.Div(cell_value, id=cell_id)]
129
+ if row.suspect:
130
+ contents.append(
131
+ dbc.Tooltip(
132
+ [
133
+ html.Div("Déclaration suspecte: vérifier les quotités déclarées"),
134
+ ],
135
+ target=cell_id,
136
+ )
137
+ )
138
+ classes += " table-warning"
139
+
140
+ return html.Td(contents, className=classes, key=f"validation-table-cell-{row}-{column}")
141
+
142
+
143
+ def agent_tooltip_txt(agent_data, data_columns):
144
+ """
145
+ Build the tooltip text associated with an agent.
146
+
147
+ :param agent_data: the dataframe row corresponding to the agent
148
+ :param data_columns: the list of columns to use to compute the total time
149
+ :return: list of html elements
150
+ """
151
+
152
+ global_params = GlobalParams()
153
+
154
+ if agent_data["hito_missing"]:
155
+ tooltip_detail = "Déclarations validées supprimées de Hito"
156
+ else:
157
+ tooltip_detail = f"Total (semaines) : {total_time(agent_data, data_columns)}"
158
+ tooltip_txt = [
159
+ html.Div(
160
+ f"{global_params.column_titles['team']}: {agent_data[global_params.columns['team']]}"
161
+ ),
162
+ html.Div(tooltip_detail),
163
+ ]
164
+
165
+ return tooltip_txt
166
+
167
+
168
+ def add_validation_declaration_selection_switch(current_set):
169
+ """
170
+ Add a dbc.RadioItems to select whether to show all declaration or only a subset.
171
+
172
+ :param current_set: currently selected declaration set
173
+ :return: dbc.RadioItems
174
+ """
175
+
176
+ return dbc.Row(
177
+ [
178
+ dbc.RadioItems(
179
+ options=[
180
+ {
181
+ "label": "Toutes les déclarations",
182
+ "value": VALIDATION_DECLARATIONS_SELECT_ALL,
183
+ },
184
+ {
185
+ "label": "Déclarations non validées uniquement",
186
+ "value": VALIDATION_DECLARATIONS_SELECT_NOT_VALIDATED,
187
+ },
188
+ {
189
+ "label": "Déclarations validées uniquement",
190
+ "value": VALIDATION_DECLARATIONS_SELECT_VALIDATED,
191
+ },
192
+ ],
193
+ value=current_set,
194
+ id=VALIDATION_DECLARATIONS_SWITCH_ID,
195
+ inline=True,
196
+ ),
197
+ ],
198
+ justify="center",
199
+ )
200
+
201
+
202
+ def agent_list(dataframe: pd.DataFrame) -> pd.DataFrame:
203
+ global_params = GlobalParams()
204
+ fullname_df = pd.DataFrame()
205
+ fullname_df[global_params.columns["fullname"]] = dataframe[
206
+ global_params.columns["fullname"]
207
+ ].drop_duplicates()
208
+ return fullname_df
209
+
210
+
211
+ def total_time(row, categories, rounded=True) -> int:
212
+ """
213
+ Compute total time declared by an agent in weeks
214
+
215
+ :param row: dataframe row for an agent
216
+ :param categories: list of categories to sum up
217
+ :param rounded: if true, returns the rounded value
218
+ :return: number of weeks declared
219
+ """
220
+
221
+ global_params = GlobalParams()
222
+
223
+ weeks_number = 0
224
+ hours_number = 0
225
+ for category in categories:
226
+ if global_params.time_unit[category] == TIME_UNIT_WEEKS:
227
+ weeks_number += row[category]
228
+ elif global_params.time_unit[category] == TIME_UNIT_HOURS:
229
+ hours_number += row[category]
230
+ else:
231
+ raise Exception(
232
+ (
233
+ f"Unsupported time unit '{global_params.time_unit[category]}'"
234
+ f" for category {category}"
235
+ )
236
+ )
237
+
238
+ weeks_number += hours_number / WEEK_HOURS
239
+
240
+ if rounded:
241
+ return int(round(weeks_number))
242
+ else:
243
+ return weeks_number
244
+
245
+
246
+ def category_time(dataframe, category) -> None:
247
+ """
248
+ Convert the number of hours into the time unit of the category. Keep track of the
249
+ conversions done to prevent doing it twice.
250
+
251
+ :param dataframe: dataframe to update
252
+ :param category: category name
253
+ :return: none, dataframe updated
254
+ """
255
+
256
+ global_params = GlobalParams()
257
+
258
+ # Default time unit is hour
259
+ unit_hour = True
260
+ if category in global_params.time_unit:
261
+ if global_params.time_unit[category] == TIME_UNIT_WEEKS:
262
+ unit_hour = False
263
+ elif global_params.time_unit[category] != TIME_UNIT_HOURS:
264
+ raise Exception(
265
+ (
266
+ f"Unsupported time unit '{global_params.time_unit[category]}'"
267
+ f" for category {category}"
268
+ )
269
+ )
270
+
271
+ if not unit_hour:
272
+ dataframe[category] = dataframe[category] / WEEK_HOURS
273
+
274
+ return
275
+
276
+
277
+ def agent_project_time(agent: str) -> html.Div:
278
+ """
279
+ Return a HTML Div with the list of projects and the time spent on them
280
+
281
+ :param agent: agent fullname
282
+ :return: html.Div
283
+ """
284
+
285
+ global_params = GlobalParams()
286
+ columns = global_params.columns
287
+
288
+ try:
289
+ session_data = global_params.session_data
290
+ df = session_data.project_declarations[
291
+ session_data.project_declarations[global_params.columns["fullname"]] == agent
292
+ ]
293
+
294
+ return html.Div(
295
+ [
296
+ html.P(
297
+ (
298
+ f"{df.iloc[i][global_params.columns['activity']]}:"
299
+ f" {' '.join(project_time(df.iloc[i][columns['activity']], df.iloc[i][columns['hours']]))}" # noqa: E501
300
+ )
301
+ )
302
+ for i in range(len(df))
303
+ ]
304
+ )
305
+
306
+ except SessionDataMissing:
307
+ return no_session_id_jumbotron()
308
+
309
+
310
+ def category_declarations(
311
+ project_declarations: pd.DataFrame, use_cache: bool = True
312
+ ) -> pd.DataFrame:
313
+ """
314
+ Process the project declarations (time per project) and convert it into declarations by
315
+ category of projects.
316
+
317
+ :param project_declarations: project declarations to consolidate
318
+ :param use_cache: if True, use and update cache
319
+ :return: dataframe
320
+ """
321
+
322
+ global_params = GlobalParams()
323
+ columns = global_params.columns
324
+
325
+ try:
326
+ session_data = global_params.session_data
327
+ except SessionDataMissing:
328
+ return no_session_id_jumbotron()
329
+
330
+ # Check if there is a cached version
331
+ if session_data.category_declarations is not None and use_cache:
332
+ return session_data.category_declarations
333
+
334
+ if project_declarations is not None:
335
+ category_declarations = project_declarations.copy()
336
+ categories = [CATEGORY_DEFAULT]
337
+ categories.extend(global_params.project_categories.keys())
338
+ for category in categories:
339
+ category_declarations[category] = category_declarations.loc[
340
+ category_declarations[columns["category"]] == category, columns["hours"]
341
+ ]
342
+ category_declarations.drop(columns=columns["hours"], inplace=True)
343
+ category_declarations.fillna(0, inplace=True)
344
+
345
+ agg_functions = {c: np.sum for c in categories}
346
+ agg_functions["suspect"] = np.any
347
+ category_declarations_pt = pd.pivot_table(
348
+ category_declarations,
349
+ index=[
350
+ global_params.columns["fullname"],
351
+ global_params.columns["team"],
352
+ global_params.columns["agent_id"],
353
+ ],
354
+ values=[*categories, "suspect"],
355
+ aggfunc=agg_functions,
356
+ )
357
+ category_declarations = pd.DataFrame(category_declarations_pt.to_records())
358
+
359
+ category_declarations["total_hours"] = category_declarations[categories].sum(axis=1)
360
+ category_declarations["percent_global"] = (
361
+ category_declarations["total_hours"] / SEMESTER_HOURS * 100
362
+ )
363
+
364
+ # Convert category time into the appropriate unit
365
+ for column_name in categories:
366
+ category_time(category_declarations, column_name)
367
+
368
+ else:
369
+ raise Exception("Project declarations are not defined")
370
+
371
+ if use_cache:
372
+ session_data.category_declarations = category_declarations
373
+
374
+ return category_declarations
375
+
376
+
377
+ def get_validation_data(agent_id, period_date: str, session=None):
378
+ """
379
+ Return the validation data for an agent or None if there is no entry in the database for this
380
+ agent_id.
381
+
382
+ :param agent_id: agent_id of the agent to check
383
+ :param session: DB session to use (default one if None)
384
+ :param period_date: a date that must be inside the declaration period
385
+ :return: an OSITAHValidation object or None
386
+ """
387
+ from ositah.utils.hito_db_model import OSITAHValidation
388
+
389
+ db = get_db()
390
+ if session is None:
391
+ session = db.session
392
+
393
+ validation_period = get_validation_period_data(period_date)
394
+ return (
395
+ session.query(OSITAHValidation)
396
+ .filter_by(agent_id=agent_id, period_id=validation_period.id)
397
+ .order_by(OSITAHValidation.timestamp.desc())
398
+ .first()
399
+ )
400
+
401
+
402
+ def get_validation_status(agent_id, period_date: str, session=None):
403
+ """
404
+ Return True if the agent entry has been validated, False otherwise (including if there is no
405
+ validation entry for the agent)
406
+
407
+ :param agent_id: agent_id of the agent to check
408
+ :param session: DB session to use
409
+ :param period_date: a date that must be inside the declaration period
410
+ :return: boolean
411
+ """
412
+
413
+ validation_data = get_validation_data(agent_id, period_date, session)
414
+ if validation_data is None:
415
+ return False
416
+ else:
417
+ return validation_data.validated
418
+
419
+
420
+ def get_all_validation_status(period_date: str):
421
+ """
422
+ Returns the list of agents whose declaration has been validated as a dataframe. It is intended
423
+ to be used to build the validation table but should not be used when updating the status, as
424
+ it may have been updated by somebody else.
425
+
426
+ :param period_date: a date that must be inside the declaration period
427
+ :return: dataframe
428
+ """
429
+
430
+ from ositah.utils.hito_db_model import OSITAHValidation
431
+
432
+ db = get_db()
433
+ validation_period = get_validation_period_data(period_date)
434
+ # By design, only one entry in the declaration period can be with the status validated,
435
+ # except if the database has been messed up...
436
+ validation_query = OSITAHValidation.query.filter(
437
+ OSITAHValidation.period_id == validation_period.id,
438
+ OSITAHValidation.validated,
439
+ )
440
+ validation_data = pd.read_sql(validation_query.statement, con=db.engine)
441
+ if validation_data is None:
442
+ validation_data = pd.DataFrame()
443
+ else:
444
+ validation_data.set_index("agent_id", inplace=True)
445
+
446
+ return validation_data
447
+
448
+
449
+ def validation_started(period_date: str):
450
+ """
451
+ Compare the current date with the validation start date (validation_date) and return True
452
+ if the validation has started, False otherwise.
453
+
454
+ :param period_date: date included in the declaration period
455
+ :return: boolean
456
+ """
457
+
458
+ current_date = datetime.now()
459
+ period_params = get_validation_period_data(period_date)
460
+ if current_date >= period_params.validation_date:
461
+ return True
462
+ else:
463
+ return False
464
+
465
+
466
+ def project_declaration_snapshot(
467
+ agent_id,
468
+ validation_id,
469
+ team,
470
+ team_selection_date,
471
+ period_date,
472
+ db_session=None,
473
+ commit=False,
474
+ ):
475
+ """
476
+ Save into table ositah_validation_project_declaration the validated project declarations
477
+ for an agent.
478
+
479
+ :param agent_id: agent (ID) whose project declarations must be saved
480
+ :param validation_id: validation ID associated with the declarations snapshot
481
+ :param db_session: session for the current transaction. If None, use the default one.
482
+ :param commit: if false, do not commit added rows
483
+ :return: None
484
+ """
485
+
486
+ from ositah.utils.hito_db_model import OSITAHProjectDeclaration
487
+
488
+ global_params = GlobalParams()
489
+ columns = global_params.columns
490
+ try:
491
+ session_data = global_params.session_data
492
+ except SessionDataMissing:
493
+ return no_session_id_jumbotron()
494
+
495
+ db = get_db()
496
+ if db_session:
497
+ session = db_session
498
+ else:
499
+ session = db.session
500
+
501
+ # The cache cannot be used directly as the callback may run on a server where the session
502
+ # cache for the current user doesn't exist yet
503
+ project_declarations = get_team_projects(
504
+ team, team_selection_date, period_date, DATA_SOURCE_HITO
505
+ )
506
+ agent_projects = project_declarations[project_declarations[columns["agent_id"]] == agent_id]
507
+ if agent_projects is None:
508
+ print(f"ERROR: no declaration found for agent ID '{agent_id}' (internal error)")
509
+ else:
510
+ for _, project in agent_projects.iterrows():
511
+ declaration = OSITAHProjectDeclaration(
512
+ projet=project[columns["project"]],
513
+ masterprojet=project[columns["masterproject"]],
514
+ category=project[columns["category"]],
515
+ hours=project[columns["hours"]],
516
+ quotite=project[columns["quotite"]],
517
+ validation_id=validation_id,
518
+ hito_project_id=project[columns["activity_id"]],
519
+ )
520
+ try:
521
+ session.add(declaration)
522
+ except Exception:
523
+ # If the default session is used, let the caller process the exception and
524
+ # eventually do the rollback
525
+ if db_session:
526
+ session.rollback()
527
+ raise
528
+
529
+ # Reset the cache of validated declarations if a modification occured
530
+ session_data.reset_validated_declarations_cache()
531
+
532
+ if commit:
533
+ session.commit
@@ -0,0 +1,4 @@
1
+ <!-- From Bootstrap icon arrow-down-up -->
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-down-up" viewBox="0 0 16 16">
3
+ <path fill-rule="evenodd" d="M11.5 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L11 2.707V14.5a.5.5 0 0 0 .5.5zm-7-14a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L4 13.293V1.5a.5.5 0 0 1 .5-.5z"/>
4
+ </svg>
@@ -0,0 +1,54 @@
1
+ /* CSS for the validation part of OSITAH */
2
+
3
+ div.login_page {
4
+ margin-left: 50px;
5
+ }
6
+
7
+ div.login_form_field {
8
+ font-weight: bold;
9
+ margin-top: 20px;
10
+ }
11
+
12
+ input.login_button {
13
+ margin-top: 30px;
14
+ margin-left: 45px;
15
+ }
16
+
17
+ ul.flash-message {
18
+ display: inline-block;
19
+ list-style-type: none;
20
+ padding-left: 0;
21
+ }
22
+
23
+
24
+ /* CSS for Dash components */
25
+
26
+ .team_list_dropdown {
27
+ margin-top: 15px;
28
+ }
29
+
30
+ .validated_hito_missing {
31
+ background-color: tomato;
32
+ }
33
+
34
+ table.sortable th::after, th.sorttable_sorted::after, th.sorttable_sorted_reverse::after {
35
+ content: " ";
36
+ display: inline-block;
37
+ width: 24px;
38
+ height: 24px;
39
+ }
40
+
41
+ table.sortable th:not(.sorttable_sorted):not(.sorttable_sorted_reverse):not(.sorttable_nosort):after {
42
+ content: url("arrow_down_up.svg");
43
+ }
44
+
45
+ th.sorttable_sorted::after {
46
+ background: url("sort_ascending.svg");
47
+ background-size: contain;
48
+ }
49
+ th.sorttable_sorted_reverse::after {
50
+ background: url("sort_descending.svg");
51
+ background-size: cover;
52
+ }
53
+
54
+ #sorttable_sortfwdind, #sorttable_sortrevind { display: none; }
@@ -0,0 +1,5 @@
1
+ <!-- From Bootstrap icon sort-alpha-down -->
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-sort-alpha-down" viewBox="0 0 16 16">
3
+ <path fill-rule="evenodd" d="M10.082 5.629 9.664 7H8.598l1.789-5.332h1.234L13.402 7h-1.12l-.419-1.371h-1.781zm1.57-.785L11 2.687h-.047l-.652 2.157h1.351z"/>
4
+ <path d="M12.96 14H9.028v-.691l2.579-3.72v-.054H9.098v-.867h3.785v.691l-2.567 3.72v.054h2.645V14zM4.5 2.5a.5.5 0 0 0-1 0v9.793l-1.146-1.147a.5.5 0 0 0-.708.708l2 1.999.007.007a.497.497 0 0 0 .7-.006l2-2a.5.5 0 0 0-.707-.708L4.5 12.293V2.5z"/>
5
+ </svg>
@@ -0,0 +1,6 @@
1
+ <!-- From Bootstrap icon sort-alpha-down-alt -->
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-sort-alpha-down-alt" viewBox="0 0 16 16">
3
+ <path d="M12.96 7H9.028v-.691l2.579-3.72v-.054H9.098v-.867h3.785v.691l-2.567 3.72v.054h2.645V7z"/>
4
+ <path fill-rule="evenodd" d="M10.082 12.629 9.664 14H8.598l1.789-5.332h1.234L13.402 14h-1.12l-.419-1.371h-1.781zm1.57-.785L11 9.688h-.047l-.652 2.156h1.351z"/>
5
+ <path d="M4.5 2.5a.5.5 0 0 0-1 0v9.793l-1.146-1.147a.5.5 0 0 0-.708.708l2 1.999.007.007a.497.497 0 0 0 .7-.006l2-2a.5.5 0 0 0-.707-.708L4.5 12.293V2.5z"/>
6
+ </svg>