diff --git a/aleksis/apps/chronos/frontend/components/LessonEventLinkIterator.vue b/aleksis/apps/chronos/frontend/components/LessonEventLinkIterator.vue new file mode 100644 index 0000000000000000000000000000000000000000..a6a737298d496c3a8b7acdcd7fe4d485c77cace2 --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/LessonEventLinkIterator.vue @@ -0,0 +1,25 @@ +<script> +export default { + name: "LessonEventLinkIterator", + props: { + items: { + type: Array, + required: true, + }, + attr: { + type: String, + required: false, + default: "name", + }, + }, +}; +</script> + +<template> + <span v-bind="$attrs"> + <span v-for="(item, idx) in items" :key="idx"> + <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text --> + {{ item[attr] }}{{ idx + 1 < items.length ? "," : "" }} + </span> + </span> +</template> diff --git a/aleksis/apps/chronos/frontend/components/LessonEventOldNew.vue b/aleksis/apps/chronos/frontend/components/LessonEventOldNew.vue new file mode 100644 index 0000000000000000000000000000000000000000..11634a63827276f0cad99bed02f0651fb34940ed --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/LessonEventOldNew.vue @@ -0,0 +1,42 @@ +<script> +import LessonEventLinkIterator from "./LessonEventLinkIterator.vue"; + +export default { + name: "LessonEventOldNew", + components: { LessonEventLinkIterator }, + props: { + oldItems: { + type: Array, + required: true, + }, + newItems: { + type: Array, + required: true, + }, + attr: { + type: String, + required: false, + default: "name", + }, + }, +}; +</script> + +<template> + <span v-bind="$attrs"> + <span v-if="oldItems.length > 0 && newItems.length > 0"> + <span class="text-decoration-line-through" + ><lesson-event-link-iterator :items="oldItems" :attr="attr" + /></span> + <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text --> + <span>→</span> + <lesson-event-link-iterator :items="newItems" :attr="attr" /> + </span> + <span v-else-if="newItems.length > 0"> + <lesson-event-link-iterator :items="newItems" :attr="attr" /> + </span> + <span v-else> + <lesson-event-link-iterator :items="oldItems" :attr="attr" /> + </span> + </span> +</template> diff --git a/aleksis/apps/chronos/frontend/components/LessonEventSubject.vue b/aleksis/apps/chronos/frontend/components/LessonEventSubject.vue new file mode 100644 index 0000000000000000000000000000000000000000..d102c9ddf6993ac72ebe60329a0c843589b8f0b0 --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/LessonEventSubject.vue @@ -0,0 +1,42 @@ +<script> +export default { + name: "LessonEventSubject", + props: { + event: { + type: Object, + required: true, + }, + attr: { + type: String, + required: false, + default: "name", + }, + }, +}; +</script> + +<template> + <span v-bind="$attrs"> + <span + v-if=" + event.meta.subject && event.meta.amended && event.meta.amends.subject + " + > + <span class="text-decoration-line-through"> + {{ event.meta.amends.subject[attr] }}</span + > + <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text --> + <span>→</span> + {{ event.meta.subject[attr] }} + </span> + <span v-else-if="event.meta.subject"> + {{ event.meta.subject[attr] }} + </span> + <span v-else-if="event.amended && event.amends.subject"> + {{ event.meta.amends.subject[attr] }} + </span> + <span v-else> + {{ event[attr] }} + </span> + </span> +</template> diff --git a/aleksis/apps/chronos/frontend/components/LessonRelatedObjectChip.vue b/aleksis/apps/chronos/frontend/components/LessonRelatedObjectChip.vue new file mode 100644 index 0000000000000000000000000000000000000000..657cfafd6d27c5da2e890581b53bddcf9841b777 --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/LessonRelatedObjectChip.vue @@ -0,0 +1,32 @@ +<script> +export default { + name: "LessonRelatedObjectChip", + props: { + status: { + type: String, + default: "regular", + validator: (value) => ["new", "removed", "regular"].includes(value), + }, + newIcon: { + type: String, + default: "mdi-plus", + }, + }, +}; +</script> + +<template> + <v-chip + label + outlined + :class="{ + 'mr-2': true, + 'text-decoration-line-through text--secondary': status === 'removed', + }" + :color="status === 'new' ? 'warning' : ''" + > + <v-icon left v-if="status === 'new'">{{ newIcon }}</v-icon> + + <slot></slot> + </v-chip> +</template> diff --git a/aleksis/apps/chronos/frontend/components/calendar_feeds/details/LessonDetails.vue b/aleksis/apps/chronos/frontend/components/calendar_feeds/details/LessonDetails.vue new file mode 100644 index 0000000000000000000000000000000000000000..7448d65cbd3f83b5161d08c4a008009920857a32 --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/calendar_feeds/details/LessonDetails.vue @@ -0,0 +1,122 @@ +<template> + <base-calendar-feed-details + v-bind="$props" + :color="currentSubject ? currentSubject.colour_bg : null" + > + <template #title> + <div + :style="{ + color: currentSubject ? currentSubject.colour_fg || 'white' : 'white', + }" + > + <lesson-event-subject :event="selectedEvent" /> + </div> + </template> + <template #badge> + <cancelled-calendar-status-chip + v-if="selectedEvent.meta.cancelled" + class="ml-4" + /> + <calendar-status-chip + color="warning" + icon="mdi-clipboard-alert-outline" + v-else-if="selectedEvent.meta.amended" + class="ml-4" + > + {{ $t("chronos.event.current_changes") }} + </calendar-status-chip> + </template> + <template #description> + <v-divider inset /> + <v-list-item v-if="selectedEvent.meta.groups.length > 0"> + <v-list-item-icon> + <v-icon color="primary">mdi-account-group-outline</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title> + <lesson-related-object-chip + v-for="group in selectedEvent.meta.groups" + :key="group.id" + >{{ group.name }}</lesson-related-object-chip + > + </v-list-item-title> + </v-list-item-content> + </v-list-item> + <v-list-item> + <v-list-item-icon> + <v-icon color="primary">mdi-human-male-board </v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title> + <span v-if="teachers.length === 0">{{ + $t("chronos.event.no_teacher") + }}</span> + <lesson-related-object-chip + v-for="teacher in teachers" + :status="teacher.status" + :key="teacher.id" + new-icon="mdi-account-plus-outline" + >{{ teacher.full_name }}</lesson-related-object-chip + > + </v-list-item-title> + </v-list-item-content> + </v-list-item> + <v-list-item> + <v-list-item-icon> + <v-icon color="primary">mdi-door </v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title> + <span v-if="rooms.length === 0" class="body-2 text--secondary">{{ + $t("chronos.event.no_room") + }}</span> + <lesson-related-object-chip + v-for="room in rooms" + :status="room.status" + :key="room.id" + new-icon="mdi-door-open" + >{{ room.name }}</lesson-related-object-chip + > + </v-list-item-title> + </v-list-item-content> + </v-list-item> + <v-divider inset /> + <v-list-item v-if="selectedEvent.meta.comment"> + <v-list-item-content> + <v-list-item-title> + <v-alert + dense + outlined + type="warning" + icon="mdi-information-outline" + > + {{ selectedEvent.meta.comment }} + </v-alert> + </v-list-item-title> + </v-list-item-content> + </v-list-item> + </template> + </base-calendar-feed-details> +</template> + +<script> +import calendarFeedDetailsMixin from "aleksis.core/mixins/calendarFeedDetails.js"; +import BaseCalendarFeedDetails from "aleksis.core/components/calendar/BaseCalendarFeedDetails.vue"; +import CalendarStatusChip from "aleksis.core/components/calendar/CalendarStatusChip.vue"; +import CancelledCalendarStatusChip from "aleksis.core/components/calendar/CancelledCalendarStatusChip.vue"; + +import LessonRelatedObjectChip from "../../LessonRelatedObjectChip.vue"; +import lessonEvent from "../mixins/lessonEvent"; +import LessonEventSubject from "../../LessonEventSubject.vue"; +export default { + name: "LessonDetails", + components: { + LessonEventSubject, + LessonRelatedObjectChip, + BaseCalendarFeedDetails, + CalendarStatusChip, + CancelledCalendarStatusChip, + }, + mixins: [calendarFeedDetailsMixin, lessonEvent], +}; +</script> diff --git a/aleksis/apps/chronos/frontend/components/calendar_feeds/event_bar/LessonEventBar.vue b/aleksis/apps/chronos/frontend/components/calendar_feeds/event_bar/LessonEventBar.vue new file mode 100644 index 0000000000000000000000000000000000000000..aeaaafbe8893924247e2f96f5cbb671054208c2a --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/calendar_feeds/event_bar/LessonEventBar.vue @@ -0,0 +1,81 @@ +<template> + <base-calendar-feed-event-bar :with-padding="false" v-bind="$props"> + <template #icon> </template> + <template #title> + <div + :class="{ + 'px-1': true, + 'orange-border': + selectedEvent.meta.amended && !selectedEvent.meta.cancelled, + 'red-border': selectedEvent.meta.cancelled, + }" + :style="{ + color: currentSubject ? currentSubject.colour_fg || 'white' : 'white', + height: '100%', + borderRadius: '4px', + }" + class="d-flex justify-center align-center flex-wrap" + > + <lesson-event-link-iterator + v-if="!selectedEvent.meta.is_member" + :items="selectedEvent.meta.groups" + attr="short_name" + class="mr-1" + /> + <lesson-event-old-new + v-if="!selectedEvent.meta.is_teacher || newTeachers.length > 0" + :new-items="newTeachers" + :old-items="oldTeachers" + attr="short_name" + class="mr-1" + /> + + <lesson-event-subject + :event="selectedEvent" + attr="short_name" + class="font-weight-medium mr-1" + /> + <lesson-event-old-new + :new-items="newRooms" + :old-items="oldRooms" + attr="short_name" + /> + </div> + </template> + </base-calendar-feed-event-bar> +</template> + +<script> +import calendarFeedEventBarMixin from "aleksis.core/mixins/calendarFeedEventBar.js"; +import BaseCalendarFeedEventBar from "aleksis.core/components/calendar/BaseCalendarFeedEventBar.vue"; +import lessonEvent from "../mixins/lessonEvent"; +import LessonEventSubject from "../../LessonEventSubject.vue"; +import LessonEventLinkIterator from "../../LessonEventLinkIterator.vue"; +import LessonEventOldNew from "../../LessonEventOldNew.vue"; + +export default { + name: "LessonEventBar", + components: { + LessonEventOldNew, + LessonEventLinkIterator, + LessonEventSubject, + BaseCalendarFeedEventBar, + }, + computed: { + selectedEvent() { + return this.event; + }, + }, + mixins: [calendarFeedEventBarMixin, lessonEvent], +}; +</script> + +<style scoped> +.orange-border { + border: 4px orange solid; +} + +.red-border { + border: 4px red solid; +} +</style> diff --git a/aleksis/apps/chronos/frontend/components/calendar_feeds/mixins/lessonEvent.js b/aleksis/apps/chronos/frontend/components/calendar_feeds/mixins/lessonEvent.js new file mode 100644 index 0000000000000000000000000000000000000000..64d2b60a12ca67c5b586f9ed9b9e029ad00948ec --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/calendar_feeds/mixins/lessonEvent.js @@ -0,0 +1,78 @@ +/** + * Mixin with common used API for custom lesson event calendar components + */ +const lessonEvent = { + methods: { + addStatuses(attr) { + let oldItems = this.getOldItems(attr); + let newItems = this.getNewItems(attr); + let oldIds = oldItems.map((item) => item.id); + let newIds = newItems.map((item) => item.id); + let itemsWithStatus = oldItems.concat(newItems).map((item) => { + let status = "regular"; + if (newIds.includes(item.id) && !oldIds.includes(item.id)) { + status = "new"; + } else if ( + newIds.length > 0 && + !newIds.includes(item.id) && + oldIds.includes(item.id) + ) { + status = "removed"; + } + return { ...item, status: status }; + }); + return itemsWithStatus; + }, + getOldItems(attr) { + let oldItems = []; + if (this.selectedEvent.meta.amended) { + oldItems = this.selectedEvent.meta.amends[attr]; + } else { + oldItems = this.selectedEvent.meta[attr]; + } + return oldItems; + }, + getNewItems(attr) { + let newItems = []; + if (this.selectedEvent.meta.amended) { + newItems = this.selectedEvent.meta[attr]; + } + return newItems; + }, + }, + computed: { + teachers() { + return this.addStatuses("teachers"); + }, + oldTeachers() { + return this.getOldItems("teachers"); + }, + newTeachers() { + return this.getNewItems("teachers"); + }, + + rooms() { + return this.addStatuses("rooms"); + }, + oldRooms() { + return this.getOldItems("rooms"); + }, + newRooms() { + return this.getNewItems("rooms"); + }, + currentSubject() { + if (this.selectedEvent.meta.subject) { + return this.selectedEvent.meta.subject; + } else if ( + this.selectedEvent.meta.amended && + this.selectedEvent.meta.amends.subject + ) { + return this.selectedEvent.meta.amends.subject; + } else { + return null; + } + }, + }, +}; + +export default lessonEvent; diff --git a/aleksis/apps/chronos/frontend/messages/en.json b/aleksis/apps/chronos/frontend/messages/en.json index d824f79087540ca0b92d4efe18e3755d562c370f..fb73dca543dfd16a3f9371e3f813fc3d8e4cbd58 100644 --- a/aleksis/apps/chronos/frontend/messages/en.json +++ b/aleksis/apps/chronos/frontend/messages/en.json @@ -13,6 +13,11 @@ }, "supervisions": { "menu_title_daily": "Daily supervisions" + }, + "event": { + "no_teacher": "No teacher", + "no_room": "No room", + "current_changes": "Current changes" } } } diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py index 24815bb9b2352b1bd8bcff6e51bb635008b2bc84..47cbd7ba588faaf026473662319dab68c92d021c 100644 --- a/aleksis/apps/chronos/models.py +++ b/aleksis/apps/chronos/models.py @@ -1414,13 +1414,24 @@ class LessonEvent(CalendarEvent): blank=True, ) + @property + def actual_groups(self: "LessonEvent"): + return self.groups.all() if self.amends else self.real_amends.groups.all() + @property def all_members(self: "LessonEvent") -> list[Person]: - return list(itertools.chain(*[list(g.members.all()) for g in self.groups.all()])) + return list(itertools.chain(*[list(g.members.all()) for g in self.actual_groups])) + + @property + def all_teachers(self: "LessonEvent") -> list[Person]: + all_teachers = list(self.teachers.all()) + if self.amends: + all_teachers += list(self.real_amends.teachers.all()) + return all_teachers @property def group_names(self: "LessonEvent") -> str: - return ", ".join([g.name for g in self.groups.all()]) + return ", ".join([g.name for g in self.actual_groups]) @property def teacher_names(self: "LessonEvent") -> str: @@ -1441,17 +1452,6 @@ class LessonEvent(CalendarEvent): return amended_room_names return my_room_names - @property - def group_names_with_amends(self: "LessonEvent") -> str: - my_group_names = self.group_names - amended_group_names = self.real_amends.group_names if self.amends else "" - - if my_group_names and amended_group_names: - return _("{} (instead of {})").format(my_group_names, amended_group_names) - elif not my_group_names and amended_group_names: - return amended_group_names - return my_group_names - @property def teacher_names_with_amends(self: "LessonEvent") -> str: my_teacher_names = self.teacher_names @@ -1476,6 +1476,7 @@ class LessonEvent(CalendarEvent): @property def real_amends(self: "LessonEvent") -> "LessonEvent": + # FIXME THIS IS AWFUL SLOW if self.amends: return LessonEvent.objects.get(pk=self.amends.pk) return self @@ -1504,11 +1505,13 @@ class LessonEvent(CalendarEvent): @classmethod def value_color(cls, reference_object: "LessonEvent", request) -> str: """Get the color of the event.""" - return ( - reference_object.subject.colour_bg - if reference_object.subject - else super().value_color(reference_object, request) - ) + if reference_object.cancelled: + return "#eeeeee" + if reference_object.subject: + return reference_object.subject.colour_bg + if reference_object.amends and reference_object.real_amends.subject: + return reference_object.real_amends.subject.colour_bg + return super().value_color(reference_object, request) @classmethod def value_organizer(cls, reference_object: "LessonEvent", request) -> str: @@ -1537,6 +1540,47 @@ class LessonEvent(CalendarEvent): return "CANCELLED" return "CONFIRMED" + @classmethod + def value_meta(cls, reference_object: "LessonEvent", request) -> str: + """Get the meta of the event.""" + real_amends = reference_object.real_amends + + return { + "amended": bool(reference_object.amends), + "amends": cls.value_meta(real_amends, request) if reference_object.amends else None, + "teachers": [ + { + "id": t.pk, + "first_name": t.first_name, + "last_name": t.last_name, + "full_name": t.full_name, + "short_name": t.short_name, + } + for t in reference_object.teachers.all() + ], + "is_teacher": request.user.person in reference_object.all_teachers, + "groups": [ + {"id": g.pk, "name": g.name, "short_name": g.short_name} + for g in reference_object.actual_groups + ], + "is_member": request.user.person in reference_object.all_members, + "rooms": [ + {"id": r.pk, "name": r.name, "short_name": r.short_name} + for r in reference_object.rooms.all() + ], + "subject": { + "id": reference_object.subject.pk, + "name": reference_object.subject.name, + "short_name": reference_object.subject.short_name, + "colour_fg": reference_object.subject.colour_fg, + "colour_bg": reference_object.subject.colour_bg, + } + if reference_object.subject + else None, + "comment": reference_object.comment, + "cancelled": reference_object.cancelled, + } + @classmethod def get_objects(cls, request) -> Iterable: """Return all objects that should be included in the calendar."""