WIP: replaced FullCalendar by Vuetify internal calendar

This commit is contained in:
Antoine Nguyen
2026-01-05 15:34:46 +01:00
parent 3fe5c334b8
commit d79aa41df4
8 changed files with 497 additions and 168 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ LABEL org.opencontainers.image.authors="Antoine Nguyen <tonio@ngyn.org>"
WORKDIR /app
RUN pip install Radicale pytz radicale-modoboa-auth-oauth2
RUN pip install -U Radicale pytz radicale-modoboa-auth-oauth2
EXPOSE 5232
+1 -1
View File
@@ -41,7 +41,7 @@
"vue-router": "^4.6.3",
"vue3-apexcharts": "^1.10.0",
"vue3-gettext": "^4.0.0-beta.1",
"vuetify": "^3.10.11"
"vuetify": "^3.11.0"
},
"devDependencies": {
"@vitejs/plugin-basic-ssl": "^1.1.0",
+36 -14
View File
@@ -26,7 +26,7 @@
:error-messages="formErrors.start"
density="compact"
variant="outlined"
type="datetime-local"
:type="dateType"
class="mr-4"
/>
<v-text-field
@@ -36,7 +36,7 @@
:error-messages="formErrors.end"
density="compact"
variant="outlined"
type="datetime-local"
:type="dateType"
/>
</div>
<div class="d-flex mb-3">
@@ -133,14 +133,27 @@ const title = computed(() => {
return props.event ? $gettext('Edit event') : $gettext('New event')
})
const dateType = computed(() => {
return form.value.allDay ? 'date' : 'datetime-local'
})
watch(
() => props.info,
(value) => {
if (value) {
const start =
typeof value.start === 'number'
? DateTime.fromMillis(value.start)
: DateTime.fromJSDate(value.start)
const end =
typeof value.end === 'number'
? DateTime.fromMillis(value.end)
: DateTime.fromJSDate(value.end)
const format = value.timed ? 'yyyy-MM-dd HH:mm' : 'yyyy-MM-dd'
form.value = {
start: DateTime.fromISO(value.startStr).toFormat('yyyy-MM-dd HH:mm'),
end: DateTime.fromISO(value.endStr).toFormat('yyyy-MM-dd HH:mm'),
allDay: value.allDay,
start: start.toFormat(format),
end: end.toFormat(format),
allDay: !value.timed,
}
}
},
@@ -153,10 +166,9 @@ watch(
if (value) {
form.value = { ...value }
form.value.calendar = form.value.calendar.pk
form.value.start = DateTime.fromISO(value.start).toFormat(
'yyyy-MM-dd HH:mm'
)
form.value.end = DateTime.fromISO(value.end).toFormat('yyyy-MM-dd HH:mm')
const format = form.value.timed ? 'yyyy-MM-dd HH:mm' : 'yyyy-MM-dd'
form.value.start = DateTime.fromMillis(value.start).toFormat(format)
form.value.end = DateTime.fromMillis(value.end).toFormat(format)
api.getAttendees().then((resp) => {
attendees.value = resp.data
})
@@ -176,11 +188,21 @@ async function saveEvent() {
}
working.value = true
try {
var data = JSON.parse(JSON.stringify(form.value))
const fromDate = new Date(data.start)
data.start = fromDate.toISOString()
const toDate = new Date(data.end)
data.end = toDate.toISOString()
const data = {
title: form.value.title,
description: form.value.description,
allDay: form.value.allDay,
attendees: form.value.attendees,
calendar: form.value.calendar,
}
if (data.allDay) {
data.start_date = form.value.start
data.end_date = form.value.end
} else {
data.start = new Date(form.value.start).toISOString()
data.end = new Date(form.value.end).toISOString()
}
if (!props.event) {
await api.createUserEvent(form.value.calendar, data)
busStore.displayNotification({ msg: $gettext('Event added') })
+429 -127
View File
@@ -1,43 +1,128 @@
<template>
<div class="text-h5 ml-4 mb-6">
{{ $gettext('Calendar') }}
</div>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
<div style="display: grid; grid-template-rows: auto auto 1fr; height: 96vh">
<div class="text-h5 ml-4 mb-6">
{{ $gettext('Calendar') }}
</div>
<v-dialog v-model="showInformation" persistent max-width="800px">
<CalendarDetail :calendar="selectedCalendar" @close="closeInformation" />
</v-dialog>
<v-dialog v-model="showCalendarForm" persistent max-width="800px">
<CalendarForm
:initial-calendar="selectedCalendar"
@color-changed="refreshCalendarEvents"
@close="closeCalendarForm"
/>
</v-dialog>
<v-dialog v-model="showEventForm" persistent max-width="800px">
<EventForm
:info="selectInfo"
:event="selectedEvent"
@refresh-calendar="refreshCalendarEvents"
@close="closeEventForm"
/>
</v-dialog>
<v-dialog v-model="showAccessRulesForm" persistent max-width="800px">
<CalendarAccessRulesForm
v-if="selectedCalendar"
:calendar-pk="selectedCalendar.pk"
@close="closeAccessRulesForm"
/>
</v-dialog>
<v-dialog v-model="showImportEventsForm" persistent max-width="800px">
<ImportEventsForm
v-if="selectedCalendar"
:calendar="selectedCalendar"
@events-imported="refreshCalendarEvents"
@close="closeImportEventsForm"
/>
</v-dialog>
<ConfirmDialog ref="confirm" />
<v-toolbar v-if="calendarRef" color="white" flat>
<v-btn
class="me-4"
color="grey-darken-2"
variant="outlined"
@click="setToday"
>
{{ $gettext('Today') }}
</v-btn>
<v-btn
color="grey-darken-2"
size="small"
variant="text"
icon
@click="goPrev"
>
<v-icon size="small"> mdi-chevron-left </v-icon>
</v-btn>
<v-btn
color="grey-darken-2"
size="small"
variant="text"
icon
@click="goNext"
>
<v-icon size="small"> mdi-chevron-right </v-icon>
</v-btn>
<v-toolbar-title>
{{ calendarRef.title }}
</v-toolbar-title>
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn color="grey-darken-2" variant="outlined" v-bind="props">
<span>{{ typeToLabel[ctype] }}</span>
<v-icon end> mdi-menu-down </v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="ctype = 'day'">
<v-list-item-title>{{ $gettext('Day') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="ctype = 'week'">
<v-list-item-title>{{ $gettext('Week') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="ctype = 'month'">
<v-list-item-title>{{ $gettext('Month') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar>
<div style="overflow: auto">
<v-calendar
ref="calendarRef"
v-model="focus"
:events="events"
first-day-of-week="1"
:type="ctype"
:locale="current"
event-name="title"
:event-color="getEventColor"
:event-ripple="false"
@mousedown:event="startDrag"
@mousedown:time="startTime"
@click:event="eventClickCallback"
@click:day="dayClickCallback"
@mouseleave="cancelDrag"
@mousemove:time="mouseMove"
@mouseup:time="endDrag"
@change="fetchUserEvents"
>
<template #event="{ event, timed, eventSummary }">
<div class="v-event-draggable">
<component :is="eventSummary"></component>
</div>
<div
v-if="timed"
class="v-event-drag-bottom"
@mousedown.stop="extendBottom(event)"
></div>
</template>
</v-calendar>
</div>
<v-dialog v-model="showInformation" persistent max-width="800px">
<CalendarDetail :calendar="selectedCalendar" @close="closeInformation" />
</v-dialog>
<v-dialog v-model="showCalendarForm" persistent max-width="800px">
<CalendarForm
:initial-calendar="selectedCalendar"
@color-changed="refreshCalendarEvents"
@close="closeCalendarForm"
/>
</v-dialog>
<v-dialog v-model="showEventForm" persistent max-width="800px">
<EventForm
:info="selectInfo"
:event="selectedEvent"
@refresh-calendar="refreshCalendarEvents"
@close="closeEventForm"
/>
</v-dialog>
<v-dialog v-model="showAccessRulesForm" persistent max-width="800px">
<CalendarAccessRulesForm
v-if="selectedCalendar"
:calendar-pk="selectedCalendar.pk"
@close="closeAccessRulesForm"
/>
</v-dialog>
<v-dialog v-model="showImportEventsForm" persistent max-width="800px">
<ImportEventsForm
v-if="selectedCalendar"
:calendar="selectedCalendar"
@events-imported="refreshCalendarEvents"
@close="closeImportEventsForm"
/>
</v-dialog>
<ConfirmDialog ref="confirm" />
</div>
</template>
<script setup>
@@ -53,10 +138,6 @@ import CalendarDetail from '@/components/calendars/CalendarDetail'
import ConfirmDialog from '@/components/tools/ConfirmDialog'
import EventForm from '@/components/calendars/EventForm'
import ImportEventsForm from '@/components/calendars/ImportEventsForm'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import timeGridPlugin from '@fullcalendar/timegrid'
const { $gettext, current } = useGettext()
const layoutStore = useLayoutStore()
@@ -64,6 +145,7 @@ const busStore = useBusStore()
const calendarRef = ref()
const confirm = ref()
const events = ref([])
const selectInfo = ref()
const selectedCalendar = ref(null)
const selectedEvent = ref(null)
@@ -73,6 +155,21 @@ const showCalendarForm = ref(false)
const showInformation = ref(false)
const showEventForm = ref(false)
const showImportEventsForm = ref(false)
const ctype = ref('week')
const focus = ref('')
const dragEvent = ref(null)
const dragTime = ref(null)
const createEvent = ref(null)
const createStart = ref(null)
const extendOriginal = ref(null)
const updating = ref(false)
const moving = ref(false)
const typeToLabel = {
day: $gettext('Day'),
week: $gettext('Week'),
month: $gettext('Month'),
}
const leftMenuItems = computed(() => {
const result = [
@@ -121,21 +218,36 @@ const leftMenuItems = computed(() => {
return result
})
async function fetchUserCalendarEvents(
calendarPk,
info,
successCallback,
failureCallback
) {
try {
const resp = await api.getUserCalendarEvents(calendarPk, {
start: info.startStr,
end: info.endStr,
async function fetchUserEvents({ start, end }) {
let newEvents = []
for (const calendar of userCalendars.value) {
const resp = await api.getUserCalendarEvents(calendar.pk, {
start: start.date,
end: end.date,
})
successCallback(resp.data)
} catch (err) {
failureCallback(err)
newEvents = newEvents.concat(
resp.data.map((event) => {
const newEvent = { ...event }
newEvent.start = Date.parse(event.start)
newEvent.end = Date.parse(event.end)
newEvent.timed = !event.allDay
return newEvent
})
)
}
events.value = newEvents
}
const setToday = () => {
focus.value = ''
}
const goPrev = () => {
calendarRef.value.prev()
}
const goNext = () => {
calendarRef.value.next()
}
function openCalendarForm() {
@@ -178,39 +290,47 @@ function closeImportEventsForm() {
showImportEventsForm.value = false
}
function fetchUserCalendars() {
api.getUserCalendars().then((resp) => {
userCalendars.value = resp.data
})
async function fetchUserCalendars() {
const resp = await api.getUserCalendars()
userCalendars.value = resp.data
}
function refreshCalendarEvents(calendarPk) {
const fullc = calendarRef.value.getApi()
const evtSource = fullc.getEventSourceById(`user-${calendarPk}`)
evtSource.refetch()
fetchUserEvents({
start: calendarRef.value.renderProps.start,
end: calendarRef.value.renderProps.end,
})
}
function selectCallback(info) {
selectInfo.value = info
showEventForm.value = true
}
async function eventClickCallback(info) {
const resp = await api.getUserEvent(
info.event.extendedProps.calendar.pk,
info.event.id
)
selectedEvent.value = resp.data
showEventForm.value = true
}
function updateEventDates(calEvent) {
var data = {
start: calEvent.start,
end: calEvent.end,
async function eventClickCallback(nativeEvent, { event }) {
if (!updating.value && !moving.value) {
selectedEvent.value = event
showEventForm.value = true
}
const evtCalendar = calEvent.extendedProps.calendar
if (calEvent.allDay) {
}
const dayClickCallback = (nativeEvent, { date, time }) => {
if (!selectedEvent.value) {
const startDate = new Date(date)
const endDate = new Date(date)
selectInfo.value = {
color: '#2196F3',
start: startDate,
end: endDate,
timed: false,
}
showEventForm.value = true
}
}
async function updateEventDates(calEvent) {
updating.value = true
var data = {
start: DateTime.fromMillis(calEvent.start).toJSDate(),
end: calEvent.end ? DateTime.fromMillis(calEvent.end).toJSDate() : null,
}
const evtCalendar = calEvent.calendar
if (!calEvent.timed) {
const end = DateTime.fromJSDate(data.start)
end.plus({ days: 1 })
data.start.setHours(0, 0, 0)
@@ -221,9 +341,12 @@ function updateEventDates(calEvent) {
end.plus({ hours: 1 })
data.end = end
}
api.patchUserEvent(evtCalendar.pk, calEvent.id, data).then((response) => {
try {
await api.patchUserEvent(evtCalendar.pk, calEvent.id, data)
busStore.displayNotification({ msg: $gettext('Event updated') })
})
} finally {
updating.value = false
}
}
function eventDropCallback(info) {
updateEventDates(info.event)
@@ -232,6 +355,142 @@ function eventResizeCallback(info) {
updateEventDates(info.event)
}
const roundTime = (time, down = true) => {
const roundTo = 15 // minutes
const roundDownTime = roundTo * 60 * 1000
return down
? time - (time % roundDownTime)
: time + (roundDownTime - (time % roundDownTime))
}
const toTime = (tms) => {
return new Date(
tms.year,
tms.month - 1,
tms.day,
tms.hour,
tms.minute
).getTime()
}
const getEventColor = (event) => {
const rgb = parseInt(event.color.substring(1), 16)
const r = (rgb >> 16) & 0xff
const g = (rgb >> 8) & 0xff
const b = (rgb >> 0) & 0xff
return event === dragEvent.value
? `rgba(${r}, ${g}, ${b}, 0.7)`
: event === createEvent.value
? `rgba(${r}, ${g}, ${b}, 0.7)`
: event.color
}
const startDrag = (nativeEvent, { event, timed }) => {
console.log('startDrag')
if (event && timed) {
dragEvent.value = event
dragTime.value = null
extendOriginal.value = null
}
}
const startTime = (nativeEvent, tms) => {
console.log('startTime')
const mouse = toTime(tms)
if (dragEvent.value && dragTime.value === null) {
const start = dragEvent.value.start
dragTime.value = mouse - start
} else {
createStart.value = roundTime(mouse)
createEvent.value = {
title: $gettext('New event'),
color: '#2196F3',
start: createStart.value,
end: createStart.value,
timed: true,
}
events.value.push(createEvent.value)
}
}
const mouseMove = (nativeEvent, tms) => {
const mouse = toTime(tms)
if (dragEvent.value && dragTime.value !== null) {
moving.value = true
const start = dragEvent.value.start
const end = dragEvent.value.end
const duration = end - start
const newStartTime = mouse - dragTime.value
const newStart = roundTime(newStartTime)
const newEnd = newStart + duration
dragEvent.value.start = newStart
dragEvent.value.end = newEnd
} else if (createEvent.value && createStart.value !== null) {
moving.value = true
const mouseRounded = roundTime(mouse, false)
const min = Math.min(mouseRounded, createStart.value)
const max = Math.max(mouseRounded, createStart.value)
createEvent.value.start = min
createEvent.value.end = max
}
}
const extendBottom = (event) => {
createEvent.value = event
createStart.value = event.start
extendOriginal.value = event.end
}
const endDrag = (nativeEvent, params) => {
console.log('endDrag')
if (createEvent.value && !extendOriginal.value) {
selectInfo.value = createEvent.value
showEventForm.value = true
} else {
if (moving.value) {
if (dragEvent.value) {
updateEventDates(dragEvent.value)
} else if (createEvent.value) {
updateEventDates(createEvent.value)
}
}
dragTime.value = null
dragEvent.value = null
createEvent.value = null
createStart.value = null
extendOriginal.value = null
moving.value = false
}
}
const cancelDrag = () => {
console.log('cancelDrag')
if (createEvent.value) {
if (extendOriginal.value) {
createEvent.value.end = extendOriginal.value
} else {
const i = events.value.indexOf(createEvent.value)
if (i !== -1) {
events.value.splice(i, 1)
}
}
}
createEvent.value = null
createStart.value = null
dragTime.value = null
dragEvent.value = null
moving.value = false
}
function closeEventForm() {
showEventForm.value = false
selectedEvent.value = null
@@ -255,36 +514,37 @@ async function deleteCalendar(calendarPk) {
const fullc = calendarRef.value.getApi()
const evtSource = fullc.getEventSourceById(`user-${calendarPk}`)
evtSource.remove()
fetchUserCalendars()
await fetchUserCalendars()
busStore.displayNotification({ msg: $gettext('Calendar deleted') })
}
const calendarOptions = {
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
},
scrollTime: '09:00:00',
height: '100%',
firstDay: 1,
initialView: 'timeGridWeek',
locale: current,
selectable: true,
selectMirror: true,
editable: true,
dayMaxEventRows: true,
businessHours: {
daysOfWeek: [1, 2, 3, 4, 5],
startTime: '09:00',
endTime: '18:00',
},
select: selectCallback,
eventClick: eventClickCallback,
eventDrop: eventDropCallback,
eventResize: eventResizeCallback,
}
/* const calendarOptions = {
* plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
* headerToolbar: {
* left: 'prev,next today',
* center: 'title',
* right: 'dayGridMonth,timeGridWeek,timeGridDay',
* },
* scrollTime: '09:00:00',
* height: '100%',
* firstDay: 1,
* initialView: 'timeGridWeek',
* locale: current,
* selectable: true,
* selectMirror: true,
* editable: true,
* dayMaxEventRows: true,
* businessHours: {
* daysOfWeek: [1, 2, 3, 4, 5],
* startTime: '09:00',
* endTime: '18:00',
* },
* select: selectCallback,
* eventClick: eventClickCallback,
* eventDrop: eventDropCallback,
* eventResize: eventResizeCallback,
* }
* */
watch(
leftMenuItems,
@@ -294,23 +554,65 @@ watch(
{ immediate: true }
)
watch(userCalendars, (value) => {
const fullc = calendarRef.value.getApi()
if (!fullc) {
return
}
for (const calendar of value) {
const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
if (!evtSource) {
fullc.addEventSource({
id: `user-${calendar.pk}`,
events: fetchUserCalendarEvents.bind(this, calendar.pk),
})
}
}
})
/* watch(userCalendars, (value) => {
* const fullc = calendarRef.value.getApi()
* if (!fullc) {
* return
* }
* for (const calendar of value) {
* fetchUserCalendars(
* const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
* if (!evtSource) {
* fullc.addEventSource({
* id: `user-${calendar.pk}`,
* events: fetchUserCalendarEvents.bind(this, calendar.pk),
* })
* }
* }
* })
* */
onMounted(() => {
fetchUserCalendars()
calendarRef.value.scrollToTime('07:00')
calendarRef.value.checkChange()
})
await fetchUserCalendars()
</script>
<style scoped>
.v-event-draggable {
padding-left: 6px;
}
.v-event-timed {
user-select: none;
-webkit-user-select: none;
}
.v-event-drag-bottom {
position: absolute;
left: 0;
right: 0;
bottom: 4px;
height: 4px;
cursor: ns-resize;
&::after {
display: none;
position: absolute;
left: 50%;
height: 4px;
border-top: 1px solid white;
border-bottom: 1px solid white;
width: 16px;
margin-left: -8px;
opacity: 0.8;
content: '';
}
&:hover::after {
display: block;
}
}
</style>
+4 -4
View File
@@ -2449,10 +2449,10 @@ vue@^3.4.31:
"@vue/server-renderer" "3.5.24"
"@vue/shared" "3.5.24"
vuetify@^3.10.11:
version "3.10.11"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.10.11.tgz#d2da1ffe9ffcd15e9192c31e08cc0469e57e705b"
integrity sha512-hfllXT0/C3O5nZyIRalaDU7ClMIrKrKAbjH0T8xbSUb7FcJrHOqPZfEkSXwrKxajv6EA1rwEOvCZoLDhunnjrQ==
vuetify@^3.11.0:
version "3.11.4"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.11.4.tgz#14e41b7a7b821501039251fdf952cec520e505d9"
integrity sha512-614MKpopMzNjkQVD5CS/xr77p4CqtF2wNqQByAi7oTEcKzA+gOsy5O3l97ZIqomNidOSGXyVg5s6t5bNO9p4+Q==
w3c-keyname@^2.2.0:
version "2.2.8"
+9 -11
View File
@@ -80,17 +80,15 @@ class Caldav_Backend(CalendarBackend):
def create_event(self, data):
"""Create a new event."""
uid = uuid.uuid4()
cal = vobject.iCalendar()
cal.add("vevent")
cal.vevent.add("uid").value = str(uid)
cal.vevent.add("summary").value = data["title"]
if not data["allDay"]:
cal.vevent.add("dtstart").value = data["start"]
cal.vevent.add("dtend").value = data["end"]
dtstart = data["start"]
dtend = data["end"]
else:
cal.vevent.add("dtstart").value = data["start"].date()
cal.vevent.add("dtend").value = data["end"].date()
self.remote_cal.add_event(cal)
dtstart = data["start_date"]
dtend = data["end_date"]
self.remote_cal.save_event(
uid=uid, dtstart=dtstart, dtend=dtend, summary=data["title"]
)
return uid
def update_event(self, uid, original_data):
@@ -102,8 +100,8 @@ class Caldav_Backend(CalendarBackend):
if "title" in data:
orig_evt.summary.value = data["title"]
if data.get("allDay"):
data["start"] = data["start"].date()
data["end"] = data["end"].date()
data["start"] = data["start_date"]
data["end"] = data["end_date"]
if "start" in data:
del orig_evt.contents["dtstart"]
orig_evt.add("dtstart").value = data["start"]
+14 -9
View File
@@ -116,11 +116,11 @@ class EventSerializer(serializers.Serializer):
id = serializers.CharField(read_only=True)
title = serializers.CharField()
start = serializers.DateTimeField()
end = serializers.DateTimeField()
start = serializers.DateTimeField(required=False)
end = serializers.DateTimeField(required=False)
allDay = serializers.BooleanField(default=False)
color = serializers.CharField(read_only=True)
description = serializers.CharField(required=False)
description = serializers.CharField(required=False, allow_blank=True)
attendees = AttendeeSerializer(many=True, required=False)
@@ -147,10 +147,13 @@ class WritableEventSerializer(EventSerializer):
)
new_calendar_type = serializers.CharField(required=False)
start_date = serializers.DateField(required=False)
end_date = serializers.DateField(required=False)
def __init__(self, *args, **kwargs):
"""Set calendar list."""
calendar_type = kwargs.pop("calendar_type")
super(EventSerializer, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.update_calendar_field(calendar_type)
def update_calendar_field(self, calendar_type):
@@ -170,11 +173,13 @@ class WritableEventSerializer(EventSerializer):
def validate(self, data):
"""Make sure dates are present with allDay flag."""
errors = {}
if "allDay" in data:
if "start" not in data:
errors["start"] = _("This field is required.")
if "end" not in data:
errors["end"] = _("This field is required.")
if data.get("allDay", False):
mandatory_fields = ["start_date", "end_date"]
else:
mandatory_fields = ["start", "end"]
for field in mandatory_fields:
if not data.get(field):
errors[field] = _("This field is required.")
if errors:
raise serializers.ValidationError(errors)
return data
+3 -1
View File
@@ -21,7 +21,9 @@ from . import serializers
def parse_date_from_iso(value):
"""Return a tz aware datetime parsed from an ISO date."""
return dateutil.parser.parse(value)
result = dateutil.parser.parse(value)
print(result)
return result
class CheckTokenMixin: