Added Application HolidayViewer

This commit is contained in:
Kodjo Sossouvi
2025-06-27 07:26:58 +02:00
parent 66ea45f501
commit 9f4b8ab4d0
87 changed files with 3756 additions and 212 deletions

View File

View File

@@ -0,0 +1,8 @@
from fastcore.basics import NotStr
# Material - HolidayVillageTwotone
icon_holidays = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
<path opacity=".3" d="M8 6.83l-4 4V18h3v-3h2v3h3v-7.17l-4-4zM9 13H7v-2h2v2z" fill="currentColor"></path>
<path d="M8 4l-6 6v10h12V10L8 4zm4 14H9v-3H7v3H4v-7.17l4-4l4 4V18zm-3-5H7v-2h2v2zm9 7V8.35L13.65 4h-2.83L16 9.18V20h2zm4 0V6.69L19.31 4h-2.83L20 7.52V20h2z" fill="currentColor"></path>
</svg>
""")

View File

View File

@@ -0,0 +1,33 @@
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.hoildays.constants import HOLIDAYS_VIEWER_INSTANCE_ID
from components.datagrid_new.components.DataGrid import DataGrid
from components.hoildays.helpers.calendar_helper import CalendarHelper
from components.hoildays.helpers.nibelisparser import OffPeriodDetails
from components.repositories.constants import USERS_REPOSITORY_NAME, HOLIDAYS_TABLE_NAME
from helpers.Datahelper import DataHelper
class HolidaysViewer(BaseComponent):
def __init__(self, session, _id, settings_manager, boundaries=None):
super().__init__(session, _id)
self._settings_manager = settings_manager
self._boundaries = boundaries
def __ft__(self):
records = DataHelper.get(self._session,
self._settings_manager,
USERS_REPOSITORY_NAME,
HOLIDAYS_TABLE_NAME,
OffPeriodDetails)
names, holidays = CalendarHelper.create_calendar(records)
calendar = DataGrid.new(self._session, holidays, index=names)
return Div(
calendar,
cls="mt-2",
)
@staticmethod
def create_component_id(session):
return f"{HOLIDAYS_VIEWER_INSTANCE_ID}{session['user_id']}"

View File

@@ -0,0 +1 @@
HOLIDAYS_VIEWER_INSTANCE_ID = "__HolidaysViewer__"

View File

@@ -0,0 +1,81 @@
from datetime import date, timedelta
from components.hoildays.helpers.nibelisparser import OffPeriodDetails
class CalendarHelper:
@staticmethod
def create_calendar(records: list[OffPeriodDetails], start_date: date | None = None, end_date: date | None = None):
"""
:param records:
:param start_date:
:param end_date:
:return:
"""
# multiple algorithms possible. The chosen one
# two steps
# step one
# create a dict [date, [name, holiday]] for all record in records
# in the loop, records all the names
#
# step two
# create a dict [date, list[holiday]] according to the sorted list names
# step 1
temp = {}
names = set()
for record in records:
full_name = record.first_name + " " + record.last_name
names.add(full_name)
duration = CalendarHelper.get_period(record.start_date, record.end_date)
last_index = len(duration) - 1
for index, current_date in enumerate(duration):
calendar = temp.setdefault(current_date, {})
if full_name in calendar:
calendar[full_name].append(CalendarHelper.get_reason(record, index == 0, index == last_index))
else:
calendar[full_name] = [CalendarHelper.get_reason(record, index == 0, index == last_index)]
res = {}
names = list(sorted(names))
start_date_to_use = start_date or min(temp.keys())
end_date_to_use = end_date or max(temp.keys())
for current_date in CalendarHelper.get_period(start_date_to_use, end_date_to_use):
if current_date in temp:
values = []
res[current_date] = values
for name in names:
if name in temp[current_date]:
values.append(temp[current_date][name])
else:
values.append(None)
else:
res[current_date] = [None] * len(names)
return names, res
@staticmethod
def get_reason(record: OffPeriodDetails, is_start, is_end):
suffix = ""
if is_start and record.start_am_pm:
suffix += "_" + record.start_am_pm
if is_end and record.end_am_pm:
suffix += "_" + record.end_am_pm
return record.reason + suffix
@staticmethod
def get_period(start_date: date, end_date: date):
if end_date < start_date:
raise ValueError("end date is before start date.")
current_date = start_date
res = [current_date]
while current_date < end_date:
current_date = current_date + timedelta(days=1)
res.append(current_date)
return res

View File

@@ -0,0 +1,240 @@
import dataclasses
import datetime
from typing import Literal
@dataclasses.dataclass
class ParsingError:
def __init__(self, reason: str, parser):
self.reason = reason
self.pos = parser.pos
self.line = parser.line
self.column = parser.column
@dataclasses.dataclass
class OffPeriodDetails:
first_name: str
last_name: str
start_date: datetime.date
start_am_pm: Literal["am", "pm"] | None
end_date: datetime.date
end_am_pm: Literal["am", "pm"] | None
total: float
reason: str
date_import: datetime.date
def get_key(self):
return (f"{self.first_name}|{self.last_name}|"
f"{self.start_date}|{self.start_am_pm}|"
f"{self.end_date}|{self.end_am_pm}")
class NibelisParser:
def __init__(self, data: str):
self.data = data
self.pos = 0
self.line = 1
self.column = 1
self.error_sink: list[ParsingError] = []
def parse(self):
return self.read_input()
def read_input(self):
# self.data = self.data.replace("\n\t\n", "\t\n")
while (line := self.read_line().rstrip("\t")) != "Demandes qualifiées" and line is not None:
pass
res = []
try:
while True:
if (detail := self.read_detail()) is not None:
res.append(detail)
except StopIteration:
pass
return res
def read_detail(self):
start = self.read_line() # Détail de la demande
if start is None:
raise StopIteration()
if start.strip() == "" or start == "Demandes qualifiées":
return None
if start != "Détail de la demande":
self.error_sink.append(ParsingError("'Détail de la demande' not found.", self))
return None
try:
self.read_word() # ENGI402
names = []
while (token := self.read_word()) not in ["Le", "Du"]:
names.append(token)
first_name, last_name = self.get_first_name_last_name(names)
if token == "Le":
start_date, start_am_pm, end_date, end_am_pm = self.read_one_date()
else:
start_date, start_am_pm, end_date, end_am_pm = self.read_period()
total = self.read_total()
reason = " ".join([self.read_word(), self.read_word()])
self.read_line() # finish the line
return OffPeriodDetails(first_name,
last_name,
start_date,
start_am_pm,
end_date,
end_am_pm,
total,
reason,
datetime.date.today())
except Exception as ex:
self.error_sink.append(ParsingError(str(ex), self))
self.read_line() # finish the line
return None
def read_one_date(self):
"""
:return:
"""
# ... Le] lundi 20/05/2024 [0.50 J. ...
# ... Le] jeudi 04/01/2024 après-midi exclu [0.50 J. ...
# ... Le] lundi 29/01/2024 matin exclu [0.50 J. ...
self.read_word() # read day
date_as_str = self.read_word()
start_date = datetime.datetime.strptime(date_as_str, "%d/%m/%Y").date()
end_date = start_date + datetime.timedelta(days=1)
am_pm_as_str = self.read_until(lambda c: c.isdigit(), "").strip()
am_pm = self.decode_am_pm(am_pm_as_str)
return start_date, am_pm, end_date, am_pm
def read_period(self):
"""
:return:
"""
# ... Du] <= lundi 27/05/2024 au mardi 28/05/2024 => [3.50 J. ..
# ... Du] <= lundi 19/02/2024 matin inclus au mardi 20/02/2024 après-midi exclu => [3.50 J. ..
# ... Du] <= mardi 20/02/2024 matin exclu au vendredi 23/02/2024 après-midi inclus => [3.50 J. ..
self.read_word() # read day
date_as_str = self.read_word()
start_am_pm_content = self.read_words_until(["au"])
self.read_word() # read day
end_as_str = self.read_word()
end_am_pm_content = self.read_until(lambda c: c.isdigit())
start_date = datetime.datetime.strptime(date_as_str, "%d/%m/%Y").date()
end_date = datetime.datetime.strptime(end_as_str, "%d/%m/%Y").date()
end_date = end_date + datetime.timedelta(days=1)
start_am_pm = self.decode_am_pm(start_am_pm_content)
end_am_pm = self.decode_am_pm(end_am_pm_content)
return start_date, start_am_pm, end_date, end_am_pm
def read_total(self):
total_as_str = self.read_word()
self.read_word() # parse J.
return float(total_as_str)
def read_line(self, strip=False):
return self._read_content(["\n"], strip=strip)
def read_word(self, strip=True):
return self._read_content(["\n", "\t", " "], strip=strip)
def read_words_until(self, words: list):
names = []
while True:
token = self.read_word()
if token is None or token in words:
break
names.append(token)
return names
def read_until(self, predicate, default=None):
if self.pos == len(self.data):
return default
buffer = ""
while self.pos < len(self.data) and not predicate(self.data[self.pos]):
buffer += self.data[self.pos]
self._move_forward()
return buffer
def _read_content(self, tokens: list, strip):
if self.pos >= len(self.data):
return None
buffer = ""
while self.pos < len(self.data) and self.data[self.pos] not in tokens:
buffer += self.data[self.pos]
self._move_forward()
# eat the token
self._move_forward()
if strip:
while self.pos < len(self.data) and self.data[self.pos] in tokens:
self._move_forward()
return buffer
def _move_forward(self):
self.pos += 1
try:
if self.data[self.pos - 1] == "\n": # \n is a 'new line', so it's counted in the new line
self.line += 1
self.column = 1
else:
self.column += 1
except IndexError:
return False
return True
@staticmethod
def get_first_name_last_name(names: list[str]):
if names[0][0].isdigit():
names.pop(0) # sometimes, the code (ex PROD134) is in two parts
for i, name in enumerate(names):
if name.isupper():
break
return " ".join(names[:i]), " ".join(names[i:])
@staticmethod
def decode_am_pm(input_):
if not input_:
return None
if isinstance(input_, list):
input_ = " ".join(input_)
else:
input_ = input_.replace("\t", " ")
input_ = input_.strip()
input_ = " ".join(input_.split())
match input_:
case "matin inclus":
return "am"
case "matin exclu":
return "pm"
case "après-midi inclus":
return "pm"
case "après-midi exclu":
return "am"
case _:
return None