diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb93b043423373580d7de43abb2d9b9cd2864cde..2473dd414d9eaf67576859d78e37a25839404fb5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,4 +15,4 @@ repos: rev: 6.0.0 hooks: - id: flake8 - args: ['--max-line-length=160', '--ignore=E203,E402,E722,F403,F405,W503'] + args: ['--max-line-length=160', '--ignore=E203,E402,E722,F403,F405,W503,E231,E702'] diff --git a/CHANGELOG.md b/CHANGELOG.md index 0abfe237e2655bcb0fa209ae39c77d2d19b07627..52f9e461c8bd19d4d7480bf96849d4de34cc38ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # ESS OP Shifter Changelog +### v.1.1.4 +- Introduced the Shift market +- ShiftSwap Summary log with filters and details +- [bug fixes] notification email format correction +- [bug fixes] user icon display +- [bug fixes] date filters for shift inconsistency + +### v.1.1.3 +- [bug fixes] using only active users for desiderata +- [bug fixes] plot display issues +- [bug fixes] import messages + ### v.1.1.2 - Rota maker improvements - desideratas overview diff --git a/shifter/notifications.py b/shifter/notifications.py index f744a28098ba6c8420b1087f5240a64e64a8571e..dd2d4caa86f8eab226974c8d1c5dcbdaf2f67518 100644 --- a/shifter/notifications.py +++ b/shifter/notifications.py @@ -5,7 +5,7 @@ Prototype of the shifter-centralised notification implementation and classes. from abc import ABC, abstractmethod from django.template.loader import render_to_string -from shifts.models import Shift, Revision, Member, ShiftExchange +from shifts.models import Shift, Revision, Member, ShiftExchange, ShiftForMarket from studies.models import StudyRequest from django.core.mail import send_mail @@ -53,6 +53,13 @@ class NotificationService(ABC): """ pass + @abstractmethod + def _triggerNotificationForMarket(self, event): + """ + triggers the notification on StudyRequest + """ + pass + def notify(self, event) -> int: if isinstance(event, (Shift, NewShiftsUpload, ShiftExchange)): return self._triggerShiftNotification(event) @@ -61,6 +68,8 @@ class NotificationService(ABC): return self._triggerRevisionNotification(event) elif isinstance(event, StudyRequest): return self._triggerStudyRequestNotification(event) + elif isinstance(event, ShiftForMarket): + return self._triggerNotificationForMarket(event) else: print("Do not know how to notify for ", event) return NOTIFICATION_CODE_ERROR @@ -80,6 +89,9 @@ class NewShiftsUpload: class DummyNotifier(NotificationService): """Mainly for test class""" + def _triggerNotificationForMarket(self, event): + pass + def __init__(self, name): super().__init__(name=name) @@ -184,6 +196,24 @@ class EmailNotifier(NotificationService): affectedMembersEmail=[one for one in affectedMembers if one.notification_shifts], ) + def _triggerNotificationForMarket(self, shiftForMarket: ShiftForMarket): + emailSubject = "[shifter] NEW shift on market" + emailBody = render_to_string( + "shift_market_email.html", + {"shiftForMarket": shiftForMarket, "domain": self.default_domain}, + ) + m = Member.objects.filter(role=shiftForMarket.shift.member.role) + affectedMembers = list(m) + + self._notify_internal_and_external( + actor=shiftForMarket.offerer, + target=shiftForMarket, + emailSubject=emailSubject, + emailBody=emailBody, + affectedMembers=affectedMembers, + affectedMembersEmail=[one for one in affectedMembers if one.notification_shifts], + ) + def _triggerRevisionNotification(self, revision): emailSubject = "[shifter] NEW planning ready for the preview" emailBody = render_to_string( diff --git a/shifter/settings.py b/shifter/settings.py index ca4930d001dd6d2dc10d1765287e0a9b949e29dc..a605e5174d64f4dfbb965de2c15a410424d7760a 100644 --- a/shifter/settings.py +++ b/shifter/settings.py @@ -88,6 +88,7 @@ TEMPLATES = [ "shifts.permanent_contexts.application_context", "shifts.permanent_contexts.rota_maker_role", "shifts.permanent_contexts.nav_bar_context", + "shifts.permanent_contexts.shift_market_count", ], }, }, diff --git a/shifts/admin.py b/shifts/admin.py index ecb3239eb0e6a7b0a151e781f2955c7649d15200..9618b053b31c6c331501a5fdf930d0d5e0ae7030 100644 --- a/shifts/admin.py +++ b/shifts/admin.py @@ -4,7 +4,7 @@ from django.contrib import messages # Register your models here. -from shifts.models import ShifterMessage, Revision, Campaign, Slot, Shift, ShiftRole, ShiftID, Contact, ShiftExchangePair, ShiftExchange +from shifts.models import ShifterMessage, Revision, Campaign, Slot, Shift, ShiftRole, ShiftID, Contact, ShiftExchangePair, ShiftExchange, ShiftForMarket @admin.register(Contact) @@ -95,6 +95,13 @@ class ShiftIDAdmin(admin.ModelAdmin): ordering = ("-label",) +@admin.register(ShiftForMarket) +class ShiftForMarketAdmin(admin.ModelAdmin): + model = ShiftForMarket + list_display = ["shift", "offerer", "offered_date", "available_until", "is_available", "is_taken", "urgency", "comments"] + ordering = ("-offered_date",) + + @admin.register(Shift) class ShiftAdmin(admin.ModelAdmin): def MOVE_to_newest_VALID_revision(self, request, queryset): diff --git a/shifts/exchanges.py b/shifts/exchanges.py index 5b511daec179cef141436dcd5092969a0341713c..73695a9c90b1bef1adf06e99b582ccf8a65bacef 100644 --- a/shifts/exchanges.py +++ b/shifts/exchanges.py @@ -1,11 +1,11 @@ from django.db.models import Q -from shifts.models import ShiftExchange, Member, Shift, ShiftExchangePair, Revision +from shifts.models import ShiftExchange, Member, Shift, ShiftExchangePair, Revision, ShiftForMarket from shifts.workinghours import find_daily_rest_time_violation, find_weekly_rest_time_violation from datetime import timedelta from django.utils import timezone -def get_exchange_exchange_preview(shiftExchange: ShiftExchange, baseRevision, previewRangeDays=7, verbose=False): +def get_exchange_exchange_preview(shiftExchange: ShiftExchange, baseRevision, previewRangeDays=10, verbose=False): """ Returns all new shift plan wrt shifts requested for change (each change component queries previewRangeDays forward and backward) @@ -23,55 +23,62 @@ def get_exchange_exchange_preview(shiftExchange: ShiftExchange, baseRevision, pr print("\t", shiftExchange) allShiftsForNewPlanning = [] - usedSlots = [] + usedSlots = [] # these are needed prior to the main check! for shiftSwap in shiftExchange.shifts.all(): usedSlots += [shiftSwap.shift.id, shiftSwap.shift_for_exchange.id] for shiftSwap in shiftExchange.shifts.all(): - if verbose: - print("\t", shiftSwap) - # create new swapped shifts - newS = _prepare_after_swap_shifts(shiftSwap) - if verbose: - print("\t", newS) - allShiftsForNewPlanning += newS - # member 1 future setup: - sM1TmpBefore = Shift.objects.filter(member=shiftSwap.shift.member, revision=baseRevision).filter( - Q(date__lt=shiftSwap.shift_for_exchange.date) & Q(date__gt=(shiftSwap.shift_for_exchange.date + timedelta(days=-previewRangeDays))) - ) - sM1TmpAfter = Shift.objects.filter(member=shiftSwap.shift.member, revision=baseRevision).filter( - Q(date__gt=shiftSwap.shift_for_exchange.date) & Q(date__lt=(shiftSwap.shift_for_exchange.date + timedelta(days=previewRangeDays))) - ) - if verbose: - print("\t", sM1TmpBefore, sM1TmpAfter) - - for s in list(sM1TmpAfter) + list(sM1TmpBefore): - if s.id in usedSlots: - continue - usedSlots.append(s.id) - allShiftsForNewPlanning.append(s) - - sM2TmpBefore = Shift.objects.filter(member=shiftSwap.shift_for_exchange.member, revision=baseRevision).filter( - Q(date__lt=shiftSwap.shift.date) & Q(date__gt=(shiftSwap.shift.date + timedelta(days=-previewRangeDays))) - ) - sM2TmpAfter = Shift.objects.filter(member=shiftSwap.shift_for_exchange.member, revision=baseRevision).filter( - Q(date__gt=shiftSwap.shift.date) & Q(date__lt=(shiftSwap.shift.date + timedelta(days=previewRangeDays))) - ) - if verbose: - print("\t", sM2TmpBefore, sM2TmpAfter) - - for s in list(sM2TmpAfter) + list(sM2TmpBefore): - if s.id in usedSlots: - continue - usedSlots.append(s.id) - allShiftsForNewPlanning.append(s) + allShiftsForNewPlanning = _verifyForOneShiftSwap(shiftSwap, usedSlots, baseRevision, previewRangeDays=previewRangeDays, verbose=verbose) if verbose: print(allShiftsForNewPlanning) + print(shiftExchange.applicable) print("\t============================") return allShiftsForNewPlanning +def _verifyForOneShiftSwap(shiftSwap: ShiftExchangePair, usedSlots, baseRevision, previewRangeDays=10, verbose=False): + """ + Performs a virtual check for the + """ + allShiftsForNewPlanning = [] + if verbose: + print("\t", shiftSwap) + # create new swapped shifts + newS = _prepare_after_swap_shifts(shiftSwap) + if verbose: + print("\t", newS) + allShiftsForNewPlanning += newS + # member 1 future setup: + sM1TmpBefore = Shift.objects.filter(member=shiftSwap.shift.member, revision=baseRevision).filter( + Q(date__lt=shiftSwap.shift_for_exchange.date) & Q(date__gt=(shiftSwap.shift_for_exchange.date + timedelta(days=-previewRangeDays))) + ) + sM1TmpAfter = Shift.objects.filter(member=shiftSwap.shift.member, revision=baseRevision).filter( + Q(date__gt=shiftSwap.shift_for_exchange.date) & Q(date__lt=(shiftSwap.shift_for_exchange.date + timedelta(days=previewRangeDays))) + ) + if verbose: + print("\t", sM1TmpBefore, sM1TmpAfter) + for s in list(sM1TmpAfter) + list(sM1TmpBefore): + if s.id in usedSlots: + continue + usedSlots.append(s.id) + allShiftsForNewPlanning.append(s) + sM2TmpBefore = Shift.objects.filter(member=shiftSwap.shift_for_exchange.member, revision=baseRevision).filter( + Q(date__lt=shiftSwap.shift.date) & Q(date__gt=(shiftSwap.shift.date + timedelta(days=-previewRangeDays))) + ) + sM2TmpAfter = Shift.objects.filter(member=shiftSwap.shift_for_exchange.member, revision=baseRevision).filter( + Q(date__gt=shiftSwap.shift.date) & Q(date__lt=(shiftSwap.shift.date + timedelta(days=previewRangeDays))) + ) + if verbose: + print("\t", sM2TmpBefore, sM2TmpAfter) + for s in list(sM2TmpAfter) + list(sM2TmpBefore): + if s.id in usedSlots: + continue + usedSlots.append(s.id) + allShiftsForNewPlanning.append(s) + return allShiftsForNewPlanning + + def _create_shift(shift, member, revision=None, permanent=False, pre_comment=None): """ Creates a shift from a shift but with changed member and revision @@ -118,7 +125,20 @@ def is_valid_for_hours_constraints( # FIXME consider ShiftExchange per only TWO members, then one can save calls to check validity for each member s1 = find_daily_rest_time_violation([x for x in closeScheduleAfterUpdate if x.member == member]) s2 = find_weekly_rest_time_violation([x for x in closeScheduleAfterUpdate if x.member == member]) + return len(s1) == 0 & len(s2) == 0, (s1, s2) + + +def is_valid_for_hours_constraints_from_market(sfm: ShiftForMarket, revision: Revision, member: Member) -> tuple: + """ + returns tuple of boolean, and list of violated shifts + """ + shiftSwap = ShiftExchangePair(shift=sfm.shift, shift_for_exchange=_create_shift(sfm.shift, member)) + # print(sfm) + usedSlots = [shiftSwap.shift.id, shiftSwap.shift_for_exchange.id] + a = _verifyForOneShiftSwap(shiftSwap, usedSlots, revision, verbose=False) + s1 = find_daily_rest_time_violation([x for x in a if x.member == member]) + s2 = find_weekly_rest_time_violation([x for x in a if x.member == member]) return len(s1) == 0 & len(s2) == 0, (s1, s2) @@ -153,14 +173,17 @@ def perform_exchange_and_save_backup(shiftExchange: ShiftExchange, approver: Mem return shiftsAfterSwap -def perform_simplified_exchange_and_save_backup(shift: Shift, newMember: Member, approver: Member, revisionBackup: Revision) -> ShiftExchange: +def perform_simplified_exchange_and_save_backup( + shift: Shift, newMember: Member, requestor: Member, approver: Member, revisionBackup: Revision, exType="Normal" +) -> ShiftExchange: """Performs a simplified shift exchange when in the existing shift new member is created""" fakeShift = _create_shift( shift, newMember, revision=revisionBackup, pre_comment="FakeAndTemporary shift created when updating the change of Member", permanent=True ) sPair = ShiftExchangePair.objects.create(shift=shift, shift_for_exchange=fakeShift) sEx = ShiftExchange() - sEx.requestor = approver + sEx.requestor = requestor + sEx.type = exType sEx.approver = approver sEx.backupRevision = revisionBackup sEx.requested = timezone.now() diff --git a/shifts/migrations/0016_auto_20240904_0814.py b/shifts/migrations/0016_auto_20240904_0814.py new file mode 100644 index 0000000000000000000000000000000000000000..30bcb5bcd5366a5dd826df2bb173795edfd8b5e0 --- /dev/null +++ b/shifts/migrations/0016_auto_20240904_0814.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.16 on 2024-09-04 06:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("shifts", "0015_shiftexchange_shiftexchangepair"), + ] + + operations = [ + migrations.AlterField( + model_name="desiderata", + name="type", + field=models.CharField( + choices=[("vac", "Vacation"), ("conf", "Conference"), ("wfh", "Work From Home"), ("other", "Other")], default="vac", max_length=10 + ), + ), + migrations.CreateModel( + name="ShiftForMarket", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("offered_date", models.DateTimeField(default=django.utils.timezone.now)), + ("is_available", models.BooleanField(default=True)), + ("is_taken", models.BooleanField(default=False)), + ("comments", models.TextField(blank=True, null=True)), + ("offerer", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("shift", models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to="shifts.shift")), + ], + options={ + "verbose_name": "Shift for market", + "verbose_name_plural": "Shifts for market", + "ordering": ["offered_date"], + }, + ), + ] diff --git a/shifts/migrations/0017_auto_20240905_1227.py b/shifts/migrations/0017_auto_20240905_1227.py new file mode 100644 index 0000000000000000000000000000000000000000..31cbebbbaab5ea53a50eb90f0d8b7d4840e26814 --- /dev/null +++ b/shifts/migrations/0017_auto_20240905_1227.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.16 on 2024-09-05 10:27 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + ("shifts", "0016_auto_20240904_0814"), + ] + + operations = [ + migrations.AddField( + model_name="shiftformarket", + name="available_until", + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name="shiftformarket", + name="taken_date", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/shifts/migrations/0018_shiftformarket_urgency.py b/shifts/migrations/0018_shiftformarket_urgency.py new file mode 100644 index 0000000000000000000000000000000000000000..d37af2c3de1b368c231743e0c6527bdc9048f0a9 --- /dev/null +++ b/shifts/migrations/0018_shiftformarket_urgency.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.16 on 2024-09-05 10:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shifts", "0017_auto_20240905_1227"), + ] + + operations = [ + migrations.AddField( + model_name="shiftformarket", + name="urgency", + field=models.CharField(choices=[("Normal", "Normal"), ("Urgent", "Urgent")], default="Normal", max_length=10), + ), + ] diff --git a/shifts/migrations/0019_alter_shiftformarket_taken_date.py b/shifts/migrations/0019_alter_shiftformarket_taken_date.py new file mode 100644 index 0000000000000000000000000000000000000000..e3ef861cef475c80186238562c57a1c517757a14 --- /dev/null +++ b/shifts/migrations/0019_alter_shiftformarket_taken_date.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.16 on 2024-09-05 11:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shifts", "0018_shiftformarket_urgency"), + ] + + operations = [ + migrations.AlterField( + model_name="shiftformarket", + name="taken_date", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/shifts/migrations/0020_shiftexchange_type.py b/shifts/migrations/0020_shiftexchange_type.py new file mode 100644 index 0000000000000000000000000000000000000000..142f2a56c1a44ffc5fa400fd355dc32977968361 --- /dev/null +++ b/shifts/migrations/0020_shiftexchange_type.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.16 on 2024-09-05 20:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shifts", "0019_alter_shiftformarket_taken_date"), + ] + + operations = [ + migrations.AddField( + model_name="shiftexchange", + name="type", + field=models.CharField(choices=[("Normal", "Normal"), ("Urgent", "Urgent"), ("Business", "Business")], default="Normal", max_length=10), + ), + ] diff --git a/shifts/migrations/0021_alter_shiftformarket_urgency.py b/shifts/migrations/0021_alter_shiftformarket_urgency.py new file mode 100644 index 0000000000000000000000000000000000000000..c4ba6648ef5ab5149cacb38739b0e14a24d89cb5 --- /dev/null +++ b/shifts/migrations/0021_alter_shiftformarket_urgency.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.16 on 2024-09-07 13:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shifts", "0020_shiftexchange_type"), + ] + + operations = [ + migrations.AlterField( + model_name="shiftformarket", + name="urgency", + field=models.CharField(blank=True, choices=[("Normal", "Normal"), ("Urgent", "Urgent")], default="Normal", max_length=10), + ), + ] diff --git a/shifts/migrations/0022_alter_shiftformarket_urgency.py b/shifts/migrations/0022_alter_shiftformarket_urgency.py new file mode 100644 index 0000000000000000000000000000000000000000..6315fb2d787e815964a2058c7dc8494547f6d735 --- /dev/null +++ b/shifts/migrations/0022_alter_shiftformarket_urgency.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.16 on 2024-09-07 13:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shifts", "0021_alter_shiftformarket_urgency"), + ] + + operations = [ + migrations.AlterField( + model_name="shiftformarket", + name="urgency", + field=models.CharField(choices=[("Normal", "Normal"), ("Urgent", "Urgent")], default="Normal", max_length=10), + ), + ] diff --git a/shifts/models.py b/shifts/models.py index a042c6e3190a561401ac8f8e2a0ca4151d831a6b..a03bafc6eaca30d8a9bf66d121aa5be38bc8aae0 100644 --- a/shifts/models.py +++ b/shifts/models.py @@ -153,7 +153,13 @@ class Desiderata(models.Model): ) def get_as_json_for_gantt(self): - return {"name": self.member.name, "start": int(timezone.localtime(self.start).timestamp()), "end": int(timezone.localtime(self.stop).timestamp())} + event = { + "name": self.member.name, + "role": self.member.role, + "start": int(timezone.localtime(self.start).timestamp()), + "end": int(timezone.localtime(self.stop).timestamp()), + } + return event def get_as_json_event(self, team=False, editable=True): event = { @@ -294,6 +300,10 @@ class Shift(models.Model): def shift_end(self) -> str: return self.get_proper_times(self.Moment.END).strftime(DATE_FORMAT_FULL) + @cached_property + def shift_day_of_week(self): + return self.date.strftime("%A") + def get_proper_times(self, moment) -> datetime: timeToUse = self.slot.hour_start if moment == self.Moment.END: @@ -305,12 +315,81 @@ class Shift(models.Model): return datetime.datetime.combine(self.date, timeToUse) + datetime.timedelta(days=deltaToAdd) +class ShiftForMarket(models.Model): + URGENCY_CHOICES = [ + ("Normal", "Normal"), + ("Urgent", "Urgent"), + ] + + shift = models.ForeignKey(Shift, on_delete=DO_NOTHING) + offerer = models.ForeignKey(Member, on_delete=CASCADE) + offered_date = models.DateTimeField(default=timezone.now) + available_until = models.DateField(null=True, blank=True) + taken_date = models.DateTimeField(null=True, blank=True) + urgency = models.CharField(max_length=10, choices=URGENCY_CHOICES, default="Normal") + is_available = models.BooleanField(default=True) + is_taken = models.BooleanField(default=False) + comments = models.TextField(blank=True, null=True) + + class Meta: + verbose_name = "Shift for market" + verbose_name_plural = "Shifts for market" + ordering = ["offered_date"] + + def __str__(self): + return f"Shift [{self.shift}] put by {self.offerer} on {self.offered_date.strftime(DATE_FORMAT_FULL)}" + + def mark_as_taken(self): + self.is_available = False + self.is_taken = True + self.taken_date = timezone.now() + self.save() + + def check_if_available(self, date): + # print(self.available_until, date) + if self: + if date > self.shift.date: + self.is_available = False + self.save() + if self.available_until is not None: + if date > self.available_until: + self.is_available = False + self.save() + + def mark_as_available(self): + self.is_available = True + self.is_taken = False + self.taken_date = None + self.save() + + def get_offer_details(self): + return { + "shift": str(self.shift), + "offerer": str(self.offerer), + "offered_date": self.offered_date.strftime(DATE_FORMAT_FULL), + "is_available": self.is_available, + "is_taken": self.is_taken, + "comments": self.comments, + } + + def get_market_shift_as_json_event(self) -> dict: + event = { + "id": self.id, + "title": f"{self.shift.slot.name} from Market [{self.urgency}]", + "start": self.shift.get_proper_times(self.shift.Moment.START).strftime(format=DATE_FORMAT_FULL), + "end": self.shift.get_proper_times(self.shift.Moment.END).strftime(format=DATE_FORMAT_FULL), + "url": reverse("shifter:market_shift_view", kwargs={"shift_id": self.id}), + "color": "#FF5733" if "Urgent" in self.urgency else "#ffb8a9", + } + return event + + class ShiftExchangePair(models.Model): shift = models.ForeignKey(Shift, on_delete=DO_NOTHING) shift_for_exchange = models.ForeignKey(Shift, on_delete=DO_NOTHING, related_name="for_exchange") def __str__(self): - return "[BEFORE] {} on {} {} with {} on {} {}".format( + return "[WAS] {} on {} {} with {} on {} {}".format( self.shift.member, self.shift.date, self.shift.slot.abbreviation, @@ -321,6 +400,7 @@ class ShiftExchangePair(models.Model): class ShiftExchange(models.Model): + EXCHANGE_TYPE = [("Normal", "Normal"), ("Market", "Market"), ("Business", "Business")] requestor = models.ForeignKey(Member, on_delete=DO_NOTHING) requested = models.DateTimeField() tentative = models.BooleanField(default=True) @@ -329,15 +409,15 @@ class ShiftExchange(models.Model): blank=True, null=True, ) - + type = models.CharField(max_length=10, choices=EXCHANGE_TYPE, default="Normal") shifts = models.ManyToManyField(ShiftExchangePair, blank=True) applicable = models.BooleanField(default=False) backupRevision = models.ForeignKey(Revision, on_delete=DO_NOTHING, related_name="revision") implemented = models.BooleanField(default=False) def __str__(self): - return "{} on {} for {} shifts swap; implemented:{}".format( - self.requestor, self.requested.strftime(SIMPLE_DATE), self.shifts.all().count(), self.implemented + return "[{}] {} on {} for {} shifts swap; implemented:{}".format( + self.type, self.requestor, self.requested.strftime(SIMPLE_DATE), self.shifts.all().count(), self.implemented ) @cached_property diff --git a/shifts/permanent_contexts.py b/shifts/permanent_contexts.py index 2ac0a538caecb79513534b8dc89a73c0dec675c5..eb2dd86d60a35e2254f82082b7e75d5b08f42139 100644 --- a/shifts/permanent_contexts.py +++ b/shifts/permanent_contexts.py @@ -1,5 +1,5 @@ from .activeshift import prepare_active_crew -from .models import Contact +from .models import Contact, ShiftForMarket import django.contrib.messages as messages from members.models import Team @@ -31,6 +31,13 @@ def useful_contact_context(request): return {"useful_contact": contacts} +def shift_market_count(request): + if request.user.is_authenticated: + shift_count = ShiftForMarket.objects.filter(is_available=True, shift__member__role=request.user.role).count() + return {"shift_count": shift_count} + return {} + + def nav_bar_context(request): teams = Team.objects.all().order_by("name") return {"teams": teams} diff --git a/shifts/static/js/calendar_script.js b/shifts/static/js/calendar_script.js index c3d0186a0eba2858bcdc81ffdb5ab24fabdc12a7..35ca7680d0ca9ab838aebea60ec1ee8a86965b37 100644 --- a/shifts/static/js/calendar_script.js +++ b/shifts/static/js/calendar_script.js @@ -85,10 +85,30 @@ $(document).ready(function () { info.jsEvent.preventDefault(); var eventObj = info.event; $('#modalTitle').html(eventObj.title + " for " + eventObj.extendedProps.slot + " on " + eventObj.start); - $('#modalPre').html( "Pre shift comments : " + eventObj.extendedProps.pre_comment); - $('#modalPost').html("Post shift comments : " + eventObj.extendedProps.post_comment); $('#eventUrl').attr('href',eventObj.url); - $('#eventEdit').attr('href',"/shift/"+eventObj.id); + if (eventObj.url.includes('market')) { + $('#eventUrl').text("See on market"); + $('#eventEdit').addClass("disabled"); + $('#eventView').addClass("disabled"); + $('#modalPre').text(""); + $('#modalPost').text(""); + } + else if (eventObj.url.includes('studies')) { + $('#eventUrl').text("Check the study details"); + $('#eventEdit').addClass("disabled"); + $('#eventView').addClass("disabled"); + $('#modalPre').text(""); + $('#modalPost').text(""); + } + else { + $('#eventUrl').text("Continue to overview"); + $('#eventView').removeClass("disabled"); + $('#eventEdit').removeClass("disabled"); + $('#eventEdit').attr('href',"/shift/"+eventObj.id); + $('#eventView').attr('href',"/shift/"+eventObj.id+"/view"); + $('#modalPre').html( "Pre shift comments : " + eventObj.extendedProps.pre_comment); + $('#modalPost').html("Post shift comments : " + eventObj.extendedProps.post_comment); + } $('#calendarModal').modal("show"); }, eventLimit: true, // allow 'more' link when too many events @@ -117,6 +137,14 @@ $(document).ready(function () { alert('there was an error while fetching events!'); }, + }, + { + id: "market", + url: $('#calendar').data('source-market-events'), + failure: function() { + alert('there was an error while fetching events!'); + }, + }, { id: "holidays", diff --git a/shifts/static/js/calendar_script.min.js b/shifts/static/js/calendar_script.min.js index 20cde604796ededf2185cbd032dd3544594acf62..d9c6607d9b29ff45f94fca8cf122e89cc45b4b23 100644 --- a/shifts/static/js/calendar_script.min.js +++ b/shifts/static/js/calendar_script.min.js @@ -1 +1 @@ -function get_selected_campaigns(){return $(".displayed_campaigns").val()}function get_revision(){return null!==document.getElementById("displayed_revision")?$(".displayed_revision").val():-1}function get_revision_next(){return $("[name='future_revisions_checkboxes']").length?$("input[name='future_revisions_checkboxes']:checked").data("future_rev_id"):-1}function get_specific_users(){return null!==document.getElementById("users_selection")?$(".users_selection").val():-1}function get_team_id(){let e=$("#team_id_for_ajax");return e.length?e.data("id"):-1}function get_member_id(){let e=$("member_id");return e.length?e.data("id"):-1}$(document).ready(function(){let e=document.getElementById("calendar"),t=new FullCalendar.Calendar(e,{themeSystem:"bootstrap5",contentHeight:"auto",customButtons:{myCustomButton:{text:"Tools",click:function(){myOffcanvas=$("#tools_off_canvas"),new bootstrap.Offcanvas(myOffcanvas).show()}}},headerToolbar:{left:"prev,today,next",center:"title",right:"myCustomButton dayGridMonth,timeGridWeek"},columnFormat:{month:"ddd",week:"ddd M/d"},initialDate:$("#calendar").data("default-date"),weekNumbers:!0,navLinks:!0,editable:!1,firstDay:1,businessHours:[{daysOfWeek:[1,2,3,4,5],startTime:"08:00",endTime:"18:00"},],eventClick:function(e){e.jsEvent.preventDefault();var t=e.event;$("#modalTitle").html(t.title+" for "+t.extendedProps.slot+" on "+t.start),$("#modalPre").html("Pre shift comments : "+t.extendedProps.pre_comment),$("#modalPost").html("Post shift comments : "+t.extendedProps.post_comment),$("#eventUrl").attr("href",t.url),$("#eventEdit").attr("href","/shift/"+t.id),$("#calendarModal").modal("show")},eventLimit:!0,eventDisplay:"block",eventOrder:"start,id,name,title",eventSources:[{id:"shifts",url:$("#calendar").data("source-shifts"),extraParams:function(){return{all_roles:$("#all_roles").is(":checked"),all_states:$("#all_states").is(":checked"),companion:$("#show_companion").is(":checked"),revision:get_revision(),revision_next:get_revision_next(),campaigns:get_selected_campaigns(),team:get_team_id(),member:get_member_id(),users:get_specific_users()}},failure:function(){alert("there was an error while fetching events!")}},{id:"holidays",url:$("#calendar").data("source-holidays"),failure:function(){alert("there was an error while fetching public holidays!")}},{id:"teamevents",url:$("#calendar").data("source-team-events"),failure:function(){alert("there was an error while fetching team-events dates!")}},{id:"studies",url:$("#calendar").data("source-studies"),extraParams:function(){return{show_studies:$("#show_studies").is(":checked"),team:get_team_id(),member:get_member_id()}},failure:function(){alert("there was an error while fetching studies planning!")}}]});t.render(),$("#all_roles").change(function(){t.getEventSourceById("shifts").refetch()}),$("#all_states").change(function(){t.getEventSourceById("shifts").refetch()}),$("#show_companion").change(function(){t.getEventSourceById("shifts").refetch()}),$("#show_studies").change(function(){t.getEventSourceById("studies").refetch()}),$(".users_selection").change(function(){t.getEventSourceById("shifts").refetch()}),$(".displayed_campaigns").change(function(){t.getEventSourceById("shifts").refetch()}),$("#planning-tab").click(function(){t.render()}),$("input[type=radio][name=future_revisions_checkboxes]").change(function(){t.getEventSourceById("shifts").refetch()})}); +function get_selected_campaigns(){return $(".displayed_campaigns").val()}function get_revision(){return null!==document.getElementById("displayed_revision")?$(".displayed_revision").val():-1}function get_revision_next(){return $("[name='future_revisions_checkboxes']").length?$("input[name='future_revisions_checkboxes']:checked").data("future_rev_id"):-1}function get_specific_users(){return null!==document.getElementById("users_selection")?$(".users_selection").val():-1}function get_team_id(){let e=$("#team_id_for_ajax");return e.length?e.data("id"):-1}function get_member_id(){let e=$("member_id");return e.length?e.data("id"):-1}$(document).ready(function(){let e=document.getElementById("calendar"),t=new FullCalendar.Calendar(e,{themeSystem:"bootstrap5",contentHeight:"auto",customButtons:{myCustomButton:{text:"Tools",click:function(){myOffcanvas=$("#tools_off_canvas"),new bootstrap.Offcanvas(myOffcanvas).show()}}},headerToolbar:{left:"prev,today,next",center:"title",right:"myCustomButton dayGridMonth,timeGridWeek"},columnFormat:{month:"ddd",week:"ddd M/d"},initialDate:$("#calendar").data("default-date"),weekNumbers:!0,navLinks:!0,editable:!1,firstDay:1,businessHours:[{daysOfWeek:[1,2,3,4,5],startTime:"08:00",endTime:"18:00"},],eventClick:function(e){e.jsEvent.preventDefault();var t=e.event;$("#modalTitle").html(t.title+" for "+t.extendedProps.slot+" on "+t.start),$("#eventUrl").attr("href",t.url),t.url.includes("market")?($("#eventUrl").text("See on market"),$("#eventEdit").addClass("disabled"),$("#eventView").addClass("disabled"),$("#modalPre").text(""),$("#modalPost").text("")):t.url.includes("studies")?($("#eventUrl").text("Check the study details"),$("#eventEdit").addClass("disabled"),$("#eventView").addClass("disabled"),$("#modalPre").text(""),$("#modalPost").text("")):($("#eventUrl").text("Continue to overview"),$("#eventView").removeClass("disabled"),$("#eventEdit").removeClass("disabled"),$("#eventEdit").attr("href","/shift/"+t.id),$("#eventView").attr("href","/shift/"+t.id+"/view"),$("#modalPre").html("Pre shift comments : "+t.extendedProps.pre_comment),$("#modalPost").html("Post shift comments : "+t.extendedProps.post_comment)),$("#calendarModal").modal("show")},eventLimit:!0,eventDisplay:"block",eventOrder:"start,id,name,title",eventSources:[{id:"shifts",url:$("#calendar").data("source-shifts"),extraParams:function(){return{all_roles:$("#all_roles").is(":checked"),all_states:$("#all_states").is(":checked"),companion:$("#show_companion").is(":checked"),revision:get_revision(),revision_next:get_revision_next(),campaigns:get_selected_campaigns(),team:get_team_id(),member:get_member_id(),users:get_specific_users()}},failure:function(){alert("there was an error while fetching events!")}},{id:"market",url:$("#calendar").data("source-market-events"),failure:function(){alert("there was an error while fetching events!")}},{id:"holidays",url:$("#calendar").data("source-holidays"),failure:function(){alert("there was an error while fetching public holidays!")}},{id:"teamevents",url:$("#calendar").data("source-team-events"),failure:function(){alert("there was an error while fetching team-events dates!")}},{id:"studies",url:$("#calendar").data("source-studies"),extraParams:function(){return{show_studies:$("#show_studies").is(":checked"),team:get_team_id(),member:get_member_id()}},failure:function(){alert("there was an error while fetching studies planning!")}}]});t.render(),$("#all_roles").change(function(){t.getEventSourceById("shifts").refetch()}),$("#all_states").change(function(){t.getEventSourceById("shifts").refetch()}),$("#show_companion").change(function(){t.getEventSourceById("shifts").refetch()}),$("#show_studies").change(function(){t.getEventSourceById("studies").refetch()}),$(".users_selection").change(function(){t.getEventSourceById("shifts").refetch()}),$(".displayed_campaigns").change(function(){t.getEventSourceById("shifts").refetch()}),$("#planning-tab").click(function(){t.render()}),$("input[type=radio][name=future_revisions_checkboxes]").change(function(){t.getEventSourceById("shifts").refetch()})}); diff --git a/shifts/static/js/desiderata_gantt.js b/shifts/static/js/desiderata_gantt.js index e2ec41785267ac735a5aa8ee8881fe778512e787..379aff584ad5d966dbb2af1f4bfa07ca98558645 100644 --- a/shifts/static/js/desiderata_gantt.js +++ b/shifts/static/js/desiderata_gantt.js @@ -38,6 +38,15 @@ function fill_gantt_plots() { url: $('#gantt-container').data('content_url'), data: { start: dStart, end: dEnd, team: teamId}, success: function(dataJSON) { + var roleColors = { + 'Operator': '#AFF359', + 'ShiftLeader': '#1338BE', + }; + + dataJSON.series.data.forEach(function(point) { + point.color = roleColors[point.role] || '#000000'; + }); + Highcharts.ganttChart('gantt-container', { title: { text: "Team Desiderata for overview" @@ -47,7 +56,7 @@ function fill_gantt_plots() { max: xAxisMax, labels: { formatter: function() { - var diffInMonths = (endDate.getFullYear() - startDate.getFullYear()) * 12 + endDate.getMonth() - startDate.getMonth() +1; + var diffInMonths = (endDate.getFullYear() - startDate.getFullYear()) * 12 + endDate.getMonth() - startDate.getMonth() + 1; if (diffInMonths < 6) { return Highcharts.dateFormat('W %W', this.value); } else { diff --git a/shifts/static/js/desiderata_gantt.min.js b/shifts/static/js/desiderata_gantt.min.js index a27d26adefd1a78610f92110b074284291a5cdfc..e7f83899ccb127ae314adbbbfa221d4228c2f918 100644 --- a/shifts/static/js/desiderata_gantt.min.js +++ b/shifts/static/js/desiderata_gantt.min.js @@ -1 +1 @@ -var dStart,dEnd;const currentYear=new Date().getFullYear();function fill_gantt_plots(){var t=$("team_id").data("id"),a=new Date(dStart),e=new Date(dEnd),r=Date.UTC(a.getFullYear(),a.getMonth(),a.getDate()),n=Date.UTC(e.getFullYear(),e.getMonth(),e.getDate());$.ajax({dataType:"json",method:"GET",url:$("#gantt-container").data("content_url"),data:{start:dStart,end:dEnd,team:t},success:function(t){Highcharts.ganttChart("gantt-container",{title:{text:"Team Desiderata for overview"},xAxis:{min:r,max:n,labels:{formatter:function(){return(e.getFullYear()-a.getFullYear())*12+e.getMonth()-a.getMonth()+1<6?Highcharts.dateFormat("W %W",this.value):Highcharts.dateFormat("%b",this.value)}}},yAxis:t.yAxis,series:[{name:t.series.Name,data:t.series.data,pointWidth:30}]})},error:function(){alert("There was an error while fetching stats!")}})}$(document).ready(function(){dStart=`${currentYear}-01-01`,dEnd=`${currentYear}-12-31`,$("#stat_desiderata_data_range_picker").daterangepicker({opens:"left"}),$("#stat_desiderata_data_range_picker").on("change",function(){dStart=$("#stat_desiderata_data_range_picker").data("daterangepicker").startDate.format("YYYY-MM-DD"),dEnd=$("#stat_desiderata_data_range_picker").data("daterangepicker").endDate.format("YYYY-MM-DD"),fill_gantt_plots()}),fill_gantt_plots()}); +var dStart,dEnd;const currentYear=new Date().getFullYear();function fill_gantt_plots(){var t=$("team_id").data("id"),a=new Date(dStart),e=new Date(dEnd),r=Date.UTC(a.getFullYear(),a.getMonth(),a.getDate()),n=Date.UTC(e.getFullYear(),e.getMonth(),e.getDate());$.ajax({dataType:"json",method:"GET",url:$("#gantt-container").data("content_url"),data:{start:dStart,end:dEnd,team:t},success:function(t){var d={Operator:"#AFF359",ShiftLeader:"#1338BE"};t.series.data.forEach(function(t){console.log(t),t.color=d[t.role]||"#000000"}),Highcharts.ganttChart("gantt-container",{title:{text:"Team Desiderata for overview"},xAxis:{min:r,max:n,labels:{formatter:function(){return(e.getFullYear()-a.getFullYear())*12+e.getMonth()-a.getMonth()+1<6?Highcharts.dateFormat("W %W",this.value):Highcharts.dateFormat("%b",this.value)}}},yAxis:t.yAxis,series:[{name:t.series.Name,data:t.series.data,pointWidth:30}]})},error:function(){alert("There was an error while fetching stats!")}})}$(document).ready(function(){dStart=`${currentYear}-01-01`,dEnd=`${currentYear}-12-31`,$("#stat_desiderata_data_range_picker").daterangepicker({opens:"left"}),$("#stat_desiderata_data_range_picker").on("change",function(){dStart=$("#stat_desiderata_data_range_picker").data("daterangepicker").startDate.format("YYYY-MM-DD"),dEnd=$("#stat_desiderata_data_range_picker").data("daterangepicker").endDate.format("YYYY-MM-DD"),fill_gantt_plots()}),fill_gantt_plots()}); diff --git a/shifts/static/js/shift_control.js b/shifts/static/js/shift_control.js index 074d9b92d39b2ab3dff930a3926fdf2c8d286146..3c282ff222a4fd40ff19443a91ea3d28e99966cb 100644 --- a/shifts/static/js/shift_control.js +++ b/shifts/static/js/shift_control.js @@ -20,9 +20,12 @@ $(document).ready(function() { dom: 'P', bPaginate: false, paging: false, + searchPanes: { + initCollapsed: true + }, columns: [ { data: 'date', title: 'Day' }, - { data: 'members', title: 'Shifts' }, + { data: 'members', title: 'Crew' }, ], autoWidth: false, }); diff --git a/shifts/static/js/shift_control.min.js b/shifts/static/js/shift_control.min.js new file mode 100644 index 0000000000000000000000000000000000000000..638e5e7cbbb6aad9bf57e269c490effd461b212f --- /dev/null +++ b/shifts/static/js/shift_control.min.js @@ -0,0 +1 @@ +$(document).ready(function(){$("#shiftControl_date_range_picker").daterangepicker({opens:"left"}),$("#shiftControl_date_range_picker").on("change",function(){$("#table_ShiftControl").DataTable().ajax.reload()}),$("#table_ShiftControl").DataTable({ajax:{url:$("#table_ShiftControl").data("source"),data:function(a){a.start=$("#shiftControl_date_range_picker").data("daterangepicker").startDate.format("YYYY-MM-DD"),a.end=$("#shiftControl_date_range_picker").data("daterangepicker").endDate.format("YYYY-MM-DD"),a.team=$("team_id").data("id")},dataSrc:"data"},dom:"P",bPaginate:!1,paging:!1,searchPanes:{initCollapsed:!0},columns:[{data:"date",title:"Day"},{data:"members",title:"Crew"},],autoWidth:!1})}); diff --git a/shifts/static/js/team_shiftswaps.js b/shifts/static/js/team_shiftswaps.js index 091f76993427597a4a62b10b2cf558f3f9f722ff..ce7e5c6b523e85ddc76ec29eade3adfeba79234a 100644 --- a/shifts/static/js/team_shiftswaps.js +++ b/shifts/static/js/team_shiftswaps.js @@ -6,6 +6,7 @@ $(document).ready(function() { $('#shiftswap_date_range_picker').on('change', function() { $('#table_shiftswap').DataTable().ajax.reload(); }); + $('#table_shiftswap').DataTable({ ajax: { url: $('#table_shiftswap').data('source'), @@ -18,25 +19,31 @@ $(document).ready(function() { dom: 'P', bPaginate: false, paging: false, -// searchPanes: { -// initCollapsed: true -// }, - "columns": [{ + searchPanes: { + initCollapsed: true + }, + columns: [{ searchPanes: { show: false } }, + { // type + }, { searchPanes: { show: false } }, + { // name req + }, { -// visible:true, searchPanes: { show: false } }, + { // name approve + }, + { searchPanes: { show: false diff --git a/shifts/static/js/team_shiftswaps.min.js b/shifts/static/js/team_shiftswaps.min.js index 1c7156a8c0fd65f66a28db7937376f332b3b6228..3c875e9c29165bcc5e334a49623f1a64a025f799 100644 --- a/shifts/static/js/team_shiftswaps.min.js +++ b/shifts/static/js/team_shiftswaps.min.js @@ -1 +1 @@ -$(document).ready(function(){$("#shiftswap_date_range_picker").daterangepicker({opens:"left"}),$("#shiftswap_date_range_picker").on("change",function(){$("#table_shiftswap").DataTable().ajax.reload()}),$("#table_shiftswap").DataTable({ajax:{url:$("#table_shiftswap").data("source"),data:function(a){a.start=$("#shiftswap_date_range_picker").data("daterangepicker").startDate.format("YYYY-MM-DD"),a.end=$("#shiftswap_date_range_picker").data("daterangepicker").endDate.format("YYYY-MM-DD"),a.team=$("team_id").data("id")}},dom:"P",bPaginate:!1,paging:!1,columns:[{searchPanes:{show:!1}},{searchPanes:{show:!1}},{searchPanes:{show:!1}},{searchPanes:{show:!1}},],autoWidth:!1})}); +$(document).ready(function(){$("#shiftswap_date_range_picker").daterangepicker({opens:"left"}),$("#shiftswap_date_range_picker").on("change",function(){$("#table_shiftswap").DataTable().ajax.reload()}),$("#table_shiftswap").DataTable({ajax:{url:$("#table_shiftswap").data("source"),data:function(a){a.start=$("#shiftswap_date_range_picker").data("daterangepicker").startDate.format("YYYY-MM-DD"),a.end=$("#shiftswap_date_range_picker").data("daterangepicker").endDate.format("YYYY-MM-DD"),a.team=$("team_id").data("id")}},dom:"P",bPaginate:!1,paging:!1,searchPanes:{initCollapsed:!0},columns:[{searchPanes:{show:!1}},{},{searchPanes:{show:!1}},{},{searchPanes:{show:!1}},{},{searchPanes:{show:!1}},],autoWidth:!1})}); diff --git a/shifts/templates/calendar.html b/shifts/templates/calendar.html index ba41e7ee1a40610fd6e7545914603a2d7e16b414..050f57a8f9079dceb887bf0fb70da415cda4ce7d 100644 --- a/shifts/templates/calendar.html +++ b/shifts/templates/calendar.html @@ -3,6 +3,7 @@ <div class="col-lg-10"> <div id="calendar" style="padding-top: 10px;" data-source-shifts="{{event_source}}" + data-source-market-events="{{market_event_source}}" data-source-studies="{% url 'studies:ajax.get_studies' %}" data-source-holidays="{% url 'ajax.get_holidays' %}" data-source-team-events="{% url 'ajax.get_team_events' %}" @@ -25,8 +26,10 @@ </div> {%endif%} <div class="modal-footer"> - {%if request.user.is_staff %} <a id="eventEdit" type="button" class="btn btn-danger" data-dismiss="modal">Edit pre/post messages</a> {%endif%} - <a id="eventUrl" type="button" class="btn btn-success" data-dismiss="modal">Continue to shifter's schedule</a> + {%if request.user.is_staff or user_view %} <a id="eventEdit" type="button" class="btn btn-danger" data-dismiss="modal"> + <i class="fa-solid fa-user-tie fa-1x"></i>  <strong>EDIT</strong></a> {%endif%} + <a id="eventView" type="button" class="btn btn-success" data-dismiss="modal">View details</a> + <a id="eventUrl" type="button" class="btn btn-success" data-dismiss="modal">Continue to overview</a> </div> </div> </div> diff --git a/shifts/templates/layout/navbar.html b/shifts/templates/layout/navbar.html index f401ae001135f73320f020468ca600f539b28469..9b035fb226419a9533ccf8ca8856e6ba81668cc1 100644 --- a/shifts/templates/layout/navbar.html +++ b/shifts/templates/layout/navbar.html @@ -70,13 +70,24 @@ </div> </a> </li> + <li class="nav-item position-relative"> + <a class="nav-link {% if request.resolver_match.url_name == 'shifts_market' %}active{% endif %}" href="{% url 'shifts_market' %}"> + <div class="d-flex flex-column align-items-center"> + <i class="fa-solid fa-store fa-1x"></i> + <span class="text-center">Shift Market</span> + {% if shift_count > 0 %} + <span class="badge bg-danger position-absolute top-0 start-100 translate-middle badge rounded-pill">{{ shift_count }}</span> + {% endif %} + </div> + </a> + </li> {% endif %} <!-- Rota-maker Link (Rota-maker role required) --> {% if rota_maker_for %} <li class="nav-item dropdown"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <div class="d-flex flex-column align-items-center"> - <i class="fa-solid fa-people-group fa-1x"></i> + <i class="fa-solid fa-user-tie fa-1x"></i> <span class="text-center">Rota-maker</span> </div> </a> @@ -103,7 +114,7 @@ {% if user.is_authenticated %} <li class="nav-item dropdown dropstart"> <a class="nav-link dropdown-toggle d-flex align-items-center m-0 p-0 ps-3" href="#" id="navbarDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false"> - <img class="rounded-circle" loading="lazy" height="35" src={% if shift.member.photo|length > 0 %} "data:image/gif;base64,{{shift.member.photo}}" {% else %} {% static 'img/anonymous.png' %} {% endif %} /> + <img class="rounded-circle" loading="lazy" height="35" src={% if request.user.photo|length > 0 %} "data:image/gif;base64,{{request.user.photo}}" {% else %} {% static 'img/anonymous.png' %} {% endif %} /> {% live_notify_badge badge_class="badge" %} </a> <ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink"> @@ -130,11 +141,11 @@ </nav> <style> -.nav-link { - color: #000000; /* Icons color */ - transition: color 0.3s, background-color 0.3s, transform 0.3s; - display: flex; - align-items: center; - padding: 0.5rem 1rem; /* Adjust padding as needed */ -} + .nav-link { + color: #000000; /* Icons color */ + transition: color 0.3s, background-color 0.3s, transform 0.3s; + display: flex; + align-items: center; + padding: 0.5rem 1rem; /* Adjust padding as needed */ + } </style> diff --git a/shifts/templates/shift_edit.html b/shifts/templates/shift_edit.html index 25263b02ebfce30df67616b8032390c413f53d33..563cf3fd9188aedc0bd75738c825a0d209453d15 100644 --- a/shifts/templates/shift_edit.html +++ b/shifts/templates/shift_edit.html @@ -1,16 +1,16 @@ {% extends 'template_main.html'%} {% block body %} -{%if request.user.is_staff %} +{%if not edit or request.user.is_staff or shift.member == request.user %} <div class="container mb-5 pb-5"> <div class="col"> <h2>{{shift}}</h2> + {% if request.user.is_staff and edit%} <form action="{% url 'shifter:shift-single-exchange-post' shift.id %}" method="POST" enctype="multipart/form-data" class="form-horizontal"> {% csrf_token %} - <div class="form-select bg-warning"> - <label for="shiftMember" class="form-label">Assigned shift member:</label> - <p>Note: Updating member will create an approved ShiftExchange with respective notifications!</p> + <h4>Re-assign shift member</h4> + <p>Note: Updating member will create an <strong>approved ShiftExchange</strong> with respective notifications!</p> <select class="form-select" name='shiftMember' id='shiftMember'> {% for sr in replacement %} <option value="{{sr.id}}" {%if shift.member == sr%} selected {%endif%}>Shifter - {{sr.name}}</option> @@ -18,20 +18,23 @@ </select> <br> <div class="form-floating"> - <button class="btn btn-dark">Update shift member</button> + <button class="btn btn-danger"><i class="fa-solid fa-user-tie fa-1x"></i>  Update shift member</button> </div> </div> - </form> - <form action="{% url 'shifter:shift-edit-post' shift.id %}" method="POST" enctype="multipart/form-data" class="form-horizontal"> + </form> + {% endif %} + {% if edit %} + <form action="{% url 'shifter:shift-edit-post' shift.id %}" method="POST" enctype="multipart/form-data" class="form-horizontal"> {% csrf_token %} <hr class="hr" /> <h4>Update current shift</h4> + Make an update to the shift, accoding to the need. <div class="form-select"> <label for="shiftRole" class="form-label">Assigned shift role:</label> <select class="form-select" name='shiftRole' id='shiftRole'> <option value="-1">Default: Member Role - {{shift.member.role}}</option> {% for sr in shiftRoles %} - <option value="{{sr.id}}" {%if shift.role == sr%} selected {%endif%}>Shift Role - {{sr.name}}</option> + <option value="{{sr.id}}" {% if shift.role == sr %} selected {% endif %}>Shift Role - {{sr.name}}</option> {% endfor %} </select> </div> @@ -40,39 +43,118 @@ style="height: 100px">{{shift.pre_comment}}</textarea> <label for="preShiftComment">Pre-shift memo</label> </div> - <div class="form-check"> <input class="form-check-input" type="checkbox" name="activeShift" - id="activeShift" {%if shift.is_active%}checked{%endif%} > + id="activeShift" {% if shift.is_active %}checked{% endif %}> <label class="form-check-label" for="activeShift"> Is this shift valid and still in schedule? Note: removing will require admin access to undo! </label> </div> - <div class="form-check"> <input class="form-check-input" type="checkbox" name="cancelledLastMinute" - id="cancelledLastMinute" {%if shift.is_cancelled%}checked{%endif%}> + id="cancelledLastMinute" {% if shift.is_cancelled %}checked{% endif %}> <label class="form-check-label" for="cancelledLastMinute"> Canceled last minute? </label> </div> - <div class="form-floating"> <textarea class="form-control" placeholder="Anything to add?" name="postShiftComment" id="postShiftComment" style="height: 100px">{{shift.post_comment}}</textarea> <label for="postShiftComment">Post shift/cancellation comment</label> </div> - <div class="form-floating"> <button class="btn btn-primary">Update shift details</button> </div> </form> + {% endif %} + <div> + <hr/> + </div> + <h4>Submit to shift market or Take shift</h4> + <p>Make it available for others or take it yourself. Note: Once transferred, you may need to find another time to work the missing hours.</p> + + {% if not on_market %} + <button type="button" class="btn btn-secondary btn-lg" data-bs-toggle="modal" data-bs-target="#addShiftToMarketModal"> + Add to market <i class="fa-solid fa-store fa-1x"></i> + </button> + {% else %} + {% if on_market.is_available %} + <button type="button" class="btn btn-success btn-lg" data-bs-toggle="modal" data-bs-target="#takeShiftModal"> + Take shift <i class="fa-solid fa-check fa-1x"></i> + </button> + + <div class="modal fade" id="takeShiftModal" tabindex="-1" aria-labelledby="takeShiftModalLabel" aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="takeShiftModalLabel">Confirm Shift Take</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <p>Are you sure you want to take this shift?</p> + <p><strong>Shift:</strong> {{ shift }}</p> + <p><strong>Date:</strong> {{ shift.date }}</p> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> + <a href="{% url 'shifter:take_shift' on_market.id %}" class="btn btn-success">Confirm</a> + </div> + </div> + </div> + </div> + {% endif %} + + + {% endif %} + + + + + <div class="modal fade" id="addShiftToMarketModal" tabindex="-1" aria-labelledby="addShiftToMarketModalLabel" aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="addShiftToMarketModalLabel">Add Shift to Market</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <form action="{% url 'shifter:add_shift_to_market' shift.id %}" method="POST"> + {% csrf_token %} + <div class="modal-body"> + <div class="form-floating"> + <textarea class="form-control" placeholder="Add a comment" name="marketComment" id="marketComment" style="height: 100px"></textarea> + <label for="marketComment">Comment for the market</label> + </div> + + <div class="form-floating mt-3"> + <input type="date" class="form-control" name="availableUntil" id="availableUntil"> + <label for="availableUntil">Available Until</label> + </div> + + <div class="form-floating mt-3"> + <select class="form-select" id="urgency" name="urgency" required> + <option value="Normal" selected>Normal</option> + <option value="Urgent">Urgent</option> + </select> + <label for="urgency">Urgency</label> + </div> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> + <button type="submit" class="btn btn-primary">Submit to Market</button> + </div> + </form> + </div> + </div> + </div> </div> </div> -{%else%} -<div class="container mb-5 pb-5"> <h3>You do not have permission to navigate here! Contact OP group for further assistance.</h3></div> -{%endif%} +{% else %} +<div class="container mb-5 pb-5"> + <h3>You do not have permission to navigate here! Contact OP group for further assistance.</h3> +</div> +{% endif %} {% endblock %} -{% block js %} +{% block js %} +<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> {% endblock %} diff --git a/shifts/templates/shift_market_email.html b/shifts/templates/shift_market_email.html new file mode 100644 index 0000000000000000000000000000000000000000..b75ee3e00c04d1fe299e18a187777c6f10cf8524 --- /dev/null +++ b/shifts/templates/shift_market_email.html @@ -0,0 +1,13 @@ +Dear Shifter ({{shiftForMarket.shift.member.role}}), + +This is an automatic message regarding a new shift put on market. +Consult {{domain}}/shift-market for details. + +{{shiftForMarket}} + Comment: {{shiftForMarket.comments}} + Urgency: {{shiftForMarket.urgency}} + +Manage your notifications in {{ domain }}/user?notifications +Thanks! +-- +CR_OperationsGroup@ess.eu diff --git a/shifts/templates/shiftexchange_edit.html b/shifts/templates/shiftexchange_edit.html new file mode 100644 index 0000000000000000000000000000000000000000..96a92b5670a201c9c683957f31100aece7f79a0b --- /dev/null +++ b/shifts/templates/shiftexchange_edit.html @@ -0,0 +1,36 @@ +{% extends 'template_main.html'%} +{% block body %} + +{%if request.user.is_staff %} +<div class="container mb-5 pb-5"> + <div class="col"> + <div class="empty-div"></div> + <div class="card text-center"> + <div class="card-header"> + <span class="badge bg-dark">{{sEx.type}}</span> shift exchange. + </div> + <div class="card-body"> + <h5 class="card-title">Shift exchange details</h5> + <p class="card-text">Requested by {{sEx.requestor}} on {{sEx.requested}}</p> + <p class="card-text">The follwoing shifts were exchanged:</p> + <hr> + {% for onePair in sEx.shifts.all%} + <p> <small>[WAS]</small> {{onePair.shift}} <i class="fa-solid fa-arrows-left-right"></i> + <small>[IS now]</small> {{onePair.shift_for_exchange.member}}</p> + <p>[taker old shift] {{onePair.shift_for_exchange}}</p> + <hr> + {%endfor%} + </div> + <div class="card-footer text-muted"> + Approved on: {{sEx.approved}} by {{sEx.approver}} + </div> + </div> + </div> +</div> +{%else%} +<div class="container mb-5 pb-5"> <h3>You do not have permission to navigate here! Contact OP group for further assistance.</h3></div> +{%endif%} +{% endblock %} + +{% block js %} +{% endblock %} diff --git a/shifts/templates/shifts_market.html b/shifts/templates/shifts_market.html new file mode 100644 index 0000000000000000000000000000000000000000..0bbb8cc469d5e736b0d3518995fdbf5bc738bb09 --- /dev/null +++ b/shifts/templates/shifts_market.html @@ -0,0 +1,110 @@ +{% extends 'template_main.html' %} +{% load static %} +{% load crispy_forms_tags %} + +{% block body %} + <div class="container-fluid mb-5 pb-5"> + <h1 class="text-center mb-3">Available Shifts on Market for {{request.user.role}}</h1> + <div class="card-deck"> + {% for marketShift in available_shifts %} + <div class="modal fade" id="confirmTakeModal-{{ marketShift.id }}" tabindex="-1" aria-labelledby="confirmTakeLabel-{{ marketShift.id }}" aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="confirmTakeLabel-{{ marketShift.id }}">Confirm Action</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + Are you sure you want to take this shift? + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> + <a href="{% url 'take_shift' marketShift.id %}" class="btn btn-success">Confirm</a> + </div> + </div> + </div> + </div> + <div class="modal fade" id="confirmDeleteModal-{{ marketShift.id }}" tabindex="-1" aria-labelledby="confirmDeleteLabel-{{ marketShift.id }}" aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="confirmDeleteLabel-{{ marketShift.id }}">Confirm Delete</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + Are you sure you want to delete this shift? This action cannot be undone. The shift is available until {{ marketShift.available_until }}. + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> + <a href="{% url 'delete_shift' marketShift.id %}" class="btn btn-danger">Remove</a> + </div> + </div> + </div> + </div> + + <div id="market-card-{{marketShift.id}}" class="card" style=" display: inline-block; width: 23rem; "> + <h5 class="card-header {% if marketShift.urgency == 'Urgent' %}text-white bg-secondary mb-3{% endif %}"> + {{marketShift.shift.shift_day_of_week}}, {{ marketShift.shift.date }} + <br> {{ marketShift.shift.slot}} {% if marketShift.urgency == 'Urgent' %} + <i class="fa-solid fa-circle-exclamation"></i>{% endif %} </h5> + <div class="card-body"> + <p class="mb-2">Shift to take: {{ marketShift.shift }}</P> + <p class="mb-2"><strong>Offered by:</strong> {{ marketShift.offerer }} on {{ marketShift.offered_date }} </p> +<!-- <p class="mb-2">Status: {{ marketShift.urgency }}</p>--> + <p class="mb-2"><strong>Comment</strong>: {{ marketShift.comments }}</p> + </div> + <div class="card-footer"> + <p><strong>Available until:</strong> + {% if marketShift.available_until %} + {{ marketShift.available_until }} + {% else %} + Not specified + {% endif %} + </p> + <div class="btn-group" role="group" aria-label="Shift actions"> + <button id="take-{{marketShift.id}}" href="#" class="btn btn-success disabled" data-bs-toggle="modal" + data-bs-target="#confirmTakeModal-{{ marketShift.id }}"> <i class="fa-solid fa-thumbs-up"></i> I take it! + <span id="take-spinner-{{marketShift.id}}" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> + </button> + {% if request.user == marketShift.offerer %} + <button href="#" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#confirmDeleteModal-{{ marketShift.id }}"> + <i class="fa-solid fa-trash fa-1x"></i> Remove from Market</button> + {% endif %} + </div> + </div> + </div> + + + {% empty %} + <p>No shifts available on the market at the moment.</p> + {% endfor %} + + </div> + </div> +{% endblock %} + +{% block js %} +<script> +{% for marketShift in available_shifts %} +$("#take-{{marketShift.id}}").ready(function () { + $.ajax({ + type: "GET", + url: '{% url 'ajax.check_market' %}?market_id={{marketShift.id}}', + success: function(res) { + $("#take-spinner-{{marketShift.id}}").removeClass("spinner-border"); + if (res.applicable){ + $("#take-{{marketShift.id}}").removeClass("disabled"); + } else { + $("#take-{{marketShift.id}}").removeClass("btn-success") + $("#take-{{marketShift.id}}").addClass("btn-danger") + $("#take-{{marketShift.id}}").addClass("btn-danger") + $("#take-{{marketShift.id}}").addClass("btn-danger") + $("#take-{{marketShift.id}}").text("Does not fit your schedule!") + $("#market-card-{{marketShift.id}}").addClass("text-secondary") + } + } + }); +}); +{%endfor%} +</script> +{% endblock %} diff --git a/shifts/templates/shifts_upload.html b/shifts/templates/shifts_upload.html index d9c8910eb45641769b86da7a618ddfacca1fe89f..b5f1917a628d683594d256ef5464a5db8a7b2c22 100644 --- a/shifts/templates/shifts_upload.html +++ b/shifts/templates/shifts_upload.html @@ -15,7 +15,7 @@ {% csrf_token %} <div class="mb-3"> <label for="csv_file" class="form-label">CSV File: </label> - <input class="form-control-file" type="file" name="csv_file" id="csv_file" required> + <input class="form-control" type="file" name="csv_file" id="csv_file" required> </div> <div class="mb-3"> <label for="camp" class="form-label">Campaign: </label> @@ -58,7 +58,7 @@ <small>Notify for available preview after successful upload (can be notified later from the admin panel)</small> </label> </div> - <button class="btn btn-primary"> <span class="glyphicon glyphicon-upload" style="margin-right:5px;"></span>Upload </button> + <button class="btn btn-primary"> <span class="glyphicon glyphicon-upload" style="margin-right:5px;"></span><i class="fa-solid fa-user-tie fa-1x"></i>  Upload </button> </form> </div> </div> diff --git a/shifts/templates/team_desiderata.html b/shifts/templates/team_desiderata.html index 787aae0e1f141f5e2df8d476313c99fa974c1751..489563b65db0ac9f26812dd65cbaf1b8deaea7a0 100644 --- a/shifts/templates/team_desiderata.html +++ b/shifts/templates/team_desiderata.html @@ -241,10 +241,13 @@ <table class="table table-striped" id="table_shiftswap" data-source="{% url 'ajax.get_team_shiftswaps' %}"> <thead> <tr> - <th scope="col">#</th> - <th scope="col">When</th> - <th scope="col">Who</th> - <th scope="col">Swap</th> + <th scope="col">Id</th> + <th scope="col">Type</th> + <th scope="col">When requested</th> + <th scope="col">Who requested</th> + <th scope="col">When finalised</th> + <th scope="col">Who finalised</th> + <th scope="col">Swapped shifts</th> </tr> </thead> <tbody> @@ -256,6 +259,9 @@ <th></th> <th></th> <th></th> + <th></th> + <th></th> + <th></th> </tr> </tfoot> </table> @@ -316,7 +322,7 @@ </div> <div class="mb-3"> <div class="col-md-3 col-sm-3 col-xs-12 col-md-offset-3" style="margin-bottom:10px;"> - <button class="btn btn-warning"> <span class="glyphicon glyphicon-upload" style="margin-right:5px;"></span>Update</button> + <button class="btn btn-warning"> <span class="glyphicon glyphicon-upload" style="margin-right:5px;"></span><i class="fa-solid fa-user-tie fa-1x"></i> Update</button> </div> </div> </form> @@ -350,7 +356,7 @@ </div> <div class="mb-3"> <div class="col-md-3 col-sm-3 col-xs-12 col-md-offset-3" style="margin-bottom:10px;"> - <button class="btn btn-danger"> <span class="glyphicon glyphicon-upload" style="margin-right:5px;"></span>Merge revisions</button> + <button class="btn btn-danger"> <span class="glyphicon glyphicon-upload" style="margin-right:5px;"></span><i class="fa-solid fa-user-tie fa-1x"></i> Merge revisions</button> </div> </div> </form> @@ -378,7 +384,7 @@ </div> <div class="mb-3"> <div class="col-md-3 col-sm-3 col-xs-12 col-md-offset-3" style="margin-bottom:10px;"> - <button class="btn btn-danger"> <span class="glyphicon glyphicon-upload" style="margin-right:5px;"></span>Remove</button> + <button class="btn btn-danger"> <span class="glyphicon glyphicon-upload" style="margin-right:5px;"></span><i class="fa-solid fa-user-tie fa-1x"></i> Remove</button> </div> </div> </form> @@ -415,7 +421,7 @@ <script type="text/javascript" src="{% static 'js/desiderata_space.min.js' %}"></script> <script type="text/javascript" src="{% static 'js/team_stats.min.js' %}"></script> <script type="text/javascript" src="{% static 'js/team_shiftswaps.min.js' %}"></script> -<script type="text/javascript" src="{% static 'js/shift_control.js' %}"></script> +<script type="text/javascript" src="{% static 'js/shift_control.min.js' %}"></script> <script type="text/javascript" src="{% static 'js/desiderata_gantt.min.js' %}"></script> diff --git a/shifts/templates/user.html b/shifts/templates/user.html index 38588a5ca2388ff6b4b9303d6cd09bb92584af81..6be7fa5f0e5da62b30e61488c5f990441a065a1e 100644 --- a/shifts/templates/user.html +++ b/shifts/templates/user.html @@ -58,7 +58,7 @@ </ul> <div class="tab-content" id="user_tabs_content"> <div class="tab-pane fade show active" id="planning-tab-pane" role="tabpanel" aria-labelledby="planning-tab" tabindex=""0> - {% include 'calendar.html' with event_source=the_url %} + {% include 'calendar.html' with event_source=the_url market_event_source=the_market_url user_view=True%} </div> <!-- Tab displaying the time reporting breakdown --> <div class="tab-pane fade" id="time_report-tab-pane" role="tabpanel" aria-labelledby="time_report-tab" tabindex="1"> @@ -303,7 +303,7 @@ <div class="accordion-item"> <h2 class="accordion-header" id="headingOne"> <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{oneSE.id}}" aria-expanded="true" aria-controls="collapse{{oneSE.id}}"> - {{oneSE.requestor}} <br> <small> requested on {{oneSE.requested_date}}</small> + <span class="badge text-bg-light">{{oneSE.type}}</span> {{oneSE.requestor}} <br> <small> on {{oneSE.requested_date}}</small> <span class="badge text-bg-{%if oneSE.implemented%}success">IMPLEMENTED{%else%}primary">NOT IMPLEMENTED{%endif%}</span> <span class="badge text-bg-{%if oneSE.applicable%}success">APPLICABLE{%else%}danger">NOT APPLICABLE{%endif%}</span> </button> @@ -319,14 +319,14 @@ {%endfor%} </dd> {%if oneSE.tentative%} - <a class="btn btn-outline-success btn-light" href={% url 'shifter:shift-exchange-close' oneSE.id %}>Close request and await for approval + <a class="btn btn-outline-success btn-light" href="{% url 'shifter:shift-exchange-close' oneSE.id %}">Close request and await for approval <span class="fa-stack"> <i class="fa-solid fa-circle fa-stack-2x" style="color:#198754"></i> <i class="fa-solid fa-bolt fa-stack-1x fa-inverse"></i> </span> </a> {%endif%} {%if not oneSE.implemented%} - <a class="btn btn-outline-danger btn-light" href={% url 'shifter:shift-exchange-cancel' oneSE.id %}>Cancel this request (cannot be undone!) + <a class="btn btn-outline-danger btn-light" href="{% url 'shifter:shift-exchange-cancel' oneSE.id %}">Cancel this request (cannot be undone!) <span class="fa-stack"> <i class="fa-solid fa-circle fa-stack-2x" ></i> <i class="fa-solid fa-trash fa-stack-1x fa-inverse"></i> @@ -336,12 +336,13 @@ <dl class="row"> <dd class="col-sm-1"> {%if oneSE.implemented%}<span class="fa-stack"> + <a href="{% url 'shifter:shift-exchange-view' oneSE.id %}"> <i class="fa-solid fa-circle fa-stack-2x" style="color:#198754">{{oneSE.approver.first_name}}</i> - <i class="fa-solid fa-thumbs-up fa-stack-1x fa-inverse"></i> + <i class="fa-solid fa-thumbs-up fa-stack-1x fa-inverse"></i></a> </span>{%endif%} {%if not oneSE.implemented and oneSE.applicable and not oneSE.tentative%} {%if oneSE.approver == member%} - <a class="btn btn-outline-success btn-light" href={% url 'shifter:shift-exchange' oneSE.id %}>Approve + <a class="btn btn-outline-success btn-light" href="{% url 'shifter:shift-exchange' oneSE.id %}">Approve <span class="fa-stack"> <i class="fa-solid fa-circle fa-stack-2x" style="color:#198754"></i> <i class="fa-solid fa-thumbs-up fa-stack-1x fa-inverse"></i> diff --git a/shifts/urls/ajax.py b/shifts/urls/ajax.py index 7799f58bc78bc4bb0bf9e0e1fad5ce6e5771b501..d8b91110c7be04ba130c5a6127147187a02263db 100644 --- a/shifts/urls/ajax.py +++ b/shifts/urls/ajax.py @@ -21,5 +21,7 @@ urlpatterns = [ path("get_team_shift_inconsistencies", ajax_views.get_team_shift_inconsistencies, name="ajax.get_team_shift_inconsistencies"), path("get_stats", ajax_views.get_shift_stats, name="ajax.get_stats"), path("get_team_shiftswaps", ajax_views.get_team_shiftswaps, name="ajax.get_team_shiftswaps"), + path("get_market_events", ajax_views.get_market_events, name="ajax.get_market_events"), + path("check_market", ajax_views.get_market_validity, name="ajax.check_market"), path("search", ajax_views.search, name="ajax.search"), ] diff --git a/shifts/urls/main.py b/shifts/urls/main.py index 088968d5ddcbf7a980340fa269a7c7a66dad4e9b..df96d273fc8dfcef3bcc26f3a3dc864841d1d9aa 100644 --- a/shifts/urls/main.py +++ b/shifts/urls/main.py @@ -23,10 +23,17 @@ urlpatterns = [ path("scheduled-work-time", views.scheduled_work_time, name="scheduled_work_time"), path("shifts", views.shifts, name="shifts"), path("shift/<int:sid>", views.shift_edit, name="shift-edit"), + path("shift/<int:sid>/view", views.shift_view, name="shift-view"), path("shift/<int:sid>/edit", views.shift_edit_post, name="shift-edit-post"), + path("shift-market/", views.market_shifts, name="shifts_market"), + path("shift-market/<int:shift_id>/view/", views.market_shift_view, name="market_shift_view"), + path("shift-market/<int:shift_id>/take/", views.market_shift_take, name="take_shift"), + path("shift-market/<int:shift_id>/delete/", views.market_shift_delete, name="delete_shift"), + path("shift/<int:shift_id>/add-to-market/", views.market_shift_add, name="add_shift_to_market"), path("shift/<int:sid>/exchange", views.shift_single_exchange_post, name="shift-single-exchange-post"), path("shift-exchange", views.shiftExchangeRequestCreateOrUpdate, name="shift-exchange-request"), path("shift-exchange/<int:ex_id>", views.shiftExchangeRequestCreateOrUpdate, name="shift-exchange-request"), + path("shift-exchange/<int:ex_id>/view", views.shiftExchangeView, name="shift-exchange-view"), path("shift-exchange/<int:ex_id>/close", views.shiftExchangeRequestClose, name="shift-exchange-close"), path("shift-exchange/<int:ex_id>/cancel", views.shiftExchangeRequestCancel, name="shift-exchange-cancel"), path("shift-exchange/<int:ex_id>/finalize", views.shiftExchangePerform, name="shift-exchange"), diff --git a/shifts/views/ajax.py b/shifts/views/ajax.py index 28523ec53c46d41e4c6f24fabbbc13dc7f794f9e..72ff2daf77d5d45d8e403c961021f855ee088d5b 100644 --- a/shifts/views/ajax.py +++ b/shifts/views/ajax.py @@ -3,6 +3,8 @@ from django.http import HttpRequest from django.db.models import Count, Q from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_safe + +from shifts.exchanges import is_valid_for_hours_constraints_from_market from shifts.io import write_csv from shifts.models import * from studies.models import * @@ -387,6 +389,26 @@ def get_shift_breakdown(request: HttpRequest) -> HttpResponse: ) +@login_required +def get_market_events(request: HttpRequest) -> HttpResponse: + shifts_on_market = ShiftForMarket.objects.filter(is_available=True, shift__member__role=request.user.role) + calendar_market_events = [d.get_market_shift_as_json_event() for d in shifts_on_market] + return HttpResponse(json.dumps(calendar_market_events), content_type="application/json") + + +@login_required +def get_market_validity(request: HttpRequest) -> HttpResponse: + revision = _get_revision(request) + try: + sfm = ShiftForMarket.objects.get(id=request.GET.get("market_id")) + except: + return HttpResponse(json.dumps({"applicable": False, "info": "Wrong ID"}), content_type="application/json") + + is_applicable = is_valid_for_hours_constraints_from_market(sfm, revision, request.user) + # print(is_applicable) + return HttpResponse(json.dumps({"applicable": is_applicable[0]}), content_type="application/json") + + @login_required def get_shifts_for_exchange(request: HttpRequest) -> HttpResponse: member = _get_member(request) @@ -404,7 +426,7 @@ def get_shifts_for_exchange(request: HttpRequest) -> HttpResponse: def _get_inconsistencies_per_member(member, revision): # TODO fix it with proper js file to build it out of JSON # TODO once with json, return count of inconsistencies to enable badge - ss = Shift.objects.filter(revision=revision).filter(member=member) + ss = Shift.objects.filter(revision=revision, is_active=True).filter(member=member) dailyViolations = find_daily_rest_time_violation(scheduled_shifts=ss) weeklyViolations = find_weekly_rest_time_violation(scheduled_shifts=ss) toReturnHTML = "" @@ -531,18 +553,32 @@ def get_team_shiftswaps(request: HttpRequest) -> HttpResponse: start = datetime.datetime.fromisoformat(request.GET.get("start")).date() end = datetime.datetime.fromisoformat(request.GET.get("end")).date() team = request.user.team - # TODO apply date filter on CONCERNED shifts (not requesting/approval times) - sExs = ShiftExchange.objects.filter(requestor__team=team, implemented=True) + sExs = ShiftExchange.objects.filter(implemented=True).filter( + (Q(requestor__team=team) | Q(shifts__shift_for_exchange__member__team=team) | Q(shifts__shift__member__team=team)) + & ( + Q(shifts__shift__date__gte=start) + & Q(shifts__shift__date__lte=end) + & Q(shifts__shift_for_exchange__date__gte=start) + & Q(shifts__shift_for_exchange__date__lte=end) + ) + ) + ids = [] data = [] for onesEx in sExs: + if onesEx.id in ids: + continue # poor's man remove duplicates + ids.append(onesEx.id) swaps = "" for oneSwap in onesEx.shifts.all(): swaps += str(oneSwap) + "<br>" data.append( [ - onesEx.id, - "Requested: " + onesEx.requested_date + " <br>Approved: " + onesEx.approved_date, - onesEx.requestor.name + " <br> " + onesEx.approver.name, + '<a class="link" href="' + reverse("shifter:shift-exchange-view", kwargs={"ex_id": onesEx.id}) + '">' + str(onesEx.id) + "</a>", + onesEx.type, + onesEx.requested_date, + onesEx.requestor.name, + onesEx.approved_date, + onesEx.approver.name, swaps, ] ) diff --git a/shifts/views/desiderata.py b/shifts/views/desiderata.py index c58819ebb58de221600f6e77fe6fb3a5bf6c5cac..6e442c444aa1c1abcda02da1d892edb5d34955f0 100644 --- a/shifts/views/desiderata.py +++ b/shifts/views/desiderata.py @@ -225,6 +225,7 @@ def gantt_data(request): toFix["y"] = ordered_names.index(d.member.name) toFix["start"] = int((d.start.timestamp()) + 24 * 60 * 60) * 1000 toFix["end"] = int(d.stop.timestamp() * 1000) + toFix["role"] = str(d.member.role) all_indexed_events.append(toFix) series["data"] = all_indexed_events diff --git a/shifts/views/main.py b/shifts/views/main.py index 3051da198ff80e041d068e1a0a2e2db2b9eba0ca..342df8483cdfeedda4e9f9d5aa42d8332cb2b36b 100644 --- a/shifts/views/main.py +++ b/shifts/views/main.py @@ -23,7 +23,12 @@ from shifts.activeshift import prepare_active_crew, prepare_for_JSON from shifts.contexts import prepare_default_context, prepare_user_context from shifter.settings import DEFAULT_SHIFT_SLOT from shifts.workinghours import find_working_hours -from shifts.exchanges import is_valid_for_hours_constraints, perform_exchange_and_save_backup, perform_simplified_exchange_and_save_backup +from shifts.exchanges import ( + is_valid_for_hours_constraints, + perform_exchange_and_save_backup, + perform_simplified_exchange_and_save_backup, + is_valid_for_hours_constraints_from_market, +) from shifts.io import importShiftsFromCSV from django.utils import timezone @@ -174,6 +179,7 @@ def user(request, u=None, rid=None): context["hide_extra_role_selection"] = True context["show_companion"] = True context["the_url"] = reverse("ajax.get_user_events") + context["the_market_url"] = reverse("ajax.get_market_events") context["unread_notifications"] = member.notifications.unread() if rid is not None: requested_revision = get_object_or_404(Revision, number=rid) @@ -185,7 +191,10 @@ def user(request, u=None, rid=None): messages.warning(request, "On top of the current schedule, you're seeing revision '{}'".format(requested_revision)) context["requested_future_rev_id"] = rid - shiftExchanges = ShiftExchange.objects.filter(Q(requestor=member) | Q(shifts__shift_for_exchange__member__exact=member)).order_by("-requested") + shiftExchanges = ShiftExchange.objects.filter( + Q(requestor=member) | Q(shifts__shift_for_exchange__member__exact=member) | Q(shifts__shift__member__exact=member) + ).order_by("-requested") + shiftExchangesLast = ShiftExchange.objects.filter(requestor=member, tentative=True, applicable=True).order_by("-requested").first() # FIXME this one is to clear the 'over select' from the query above that shiftExchangesUnique = [] @@ -258,6 +267,13 @@ def shiftExchangeRequestCancel(request, ex_id=None): return HttpResponseRedirect(reverse("shifter:user")) +@login_required +def shiftExchangeView(request, ex_id=None): + sEx = ShiftExchange.objects.get(id=ex_id) + context = {"sEx": sEx} + return render(request, "shiftexchange_edit.html", prepare_default_context(request, context)) + + @require_http_methods(["POST"]) @csrf_protect @login_required @@ -597,14 +613,27 @@ def shifts_update_status_post(request): @require_safe @login_required def shift_edit(request, sid=None): + s = Shift.objects.get(id=sid) + sfm = ShiftForMarket.objects.filter(shift=s).first() data = { - "shift": Shift.objects.get(id=sid), + "shift": s, + "on_market": sfm, "shiftRoles": ShiftRole.objects.all(), "replacement": Member.objects.filter(team=request.user.team, is_active=True).order_by("role"), + "edit": True, } return render(request, "shift_edit.html", prepare_default_context(request, data)) +@require_safe +@login_required +def shift_view(request, sid=None): + s = Shift.objects.get(id=sid) + sfm = ShiftForMarket.objects.filter(shift=s).first() + data = {"shift": s, "on_market": sfm} + return render(request, "shift_edit.html", prepare_default_context(request, data)) + + @require_http_methods(["POST"]) @csrf_protect @login_required @@ -637,6 +666,133 @@ def shift_edit_post(request, sid=None): return HttpResponseRedirect(reverse("shifter:index")) +@require_http_methods(["POST"]) +@csrf_protect +@login_required +def market_shift_add(request, shift_id): + shift = get_object_or_404(Shift, id=shift_id) + member = request.user + + if shift.date < timezone.now().date(): + messages.error(request, "You cannot add a shift to the market with a date in the past.") + return redirect("shifter:user") + + if ShiftForMarket.objects.filter(shift=shift, is_available=True).exists(): + messages.error(request, "This shift is already on the market.") + return redirect("shifter:user") + + market_comment = request.POST.get("marketComment", "") + available_until = request.POST.get("availableUntil", None) + urgency = request.POST.get("urgency", "Normal") + + if available_until: + available_until_date = timezone.datetime.strptime(available_until, "%Y-%m-%d").date() + + if available_until_date < timezone.now().date(): + messages.error(request, "The 'available until' date cannot be in the past.") + return redirect("shifter:user") + else: + available_until_date = None + + sfm = ShiftForMarket.objects.create( + shift=shift, + offerer=member, + offered_date=timezone.now(), + comments=market_comment, + available_until=available_until_date, + urgency=urgency, + is_available=True, + is_taken=False, + ) + notificationService.notify(sfm) + messages.success(request, "Shift successfully added to the market.") + return redirect("shifter:shifts_market") + + +@login_required +def market_shifts(request): + user_role = request.user.role + + available_shifts = ShiftForMarket.objects.filter(is_available=True, shift__member__role=user_role).order_by("shift__date") + for a in available_shifts: + a.check_if_available(timezone.now().date()) + available_shifts = ShiftForMarket.objects.filter(is_available=True, shift__member__role=user_role).order_by("shift__date") + + shift_count = available_shifts.count() + + user_shifts = Shift.objects.filter(member=request.user, date__gt=timezone.now().date()).order_by("date") + + context = { + "available_shifts": available_shifts, + "user_shifts": user_shifts, + "shift_count": shift_count, + } + return render(request, "shifts_market.html", context) + + +@login_required +def market_shift_view(request, shift_id): + context = { + "available_shifts": [ShiftForMarket.objects.get(id=shift_id)], + } + return render(request, "shifts_market.html", context) + + +@login_required +def market_shift_take(request, shift_id): + try: + shift_market = ShiftForMarket.objects.get(id=shift_id, is_available=True) + except ShiftForMarket.DoesNotExist: + messages.error(request, "The shift you tried to take is no longer available.") + return redirect("shifts_market") + + is_applicable = is_valid_for_hours_constraints_from_market(shift_market, shift_market.shift.revision, request.user) + if not is_applicable[0]: + messages.error(request, "Your schedule does not fit to accept this shift! Contact your rota maker to find out " "possible solutions!") + return redirect("shifts_market") + + # FIXME think if that should go inside the 'simplified exchange' + # for now, the 'my shift' on the same day are invalidated and put with comment + shifts = Shift.objects.filter(date=shift_market.shift.date, member=request.user, revision=Revision.objects.filter(valid=True).order_by("-number").first()) + for shift in shifts: + shift.is_active = False + shift.pre_comment = "Removed with the market shift: {}".format(shift_market) + shift.save() + + sExDone = perform_simplified_exchange_and_save_backup( + shift_market.shift, + request.user, + requestor=shift_market.offerer, + approver=request.user, + revisionBackup=Revision.objects.filter(name__startswith="BACKUP").first(), + exType="Market", + ) + notificationService.notify(sExDone) + shift_market.mark_as_taken() + + messages.success(request, "Requested shift exchange (update) is now successfully implemented.") + messages.success(request, "You have successfully taken the shift.") + return redirect("shifts_market") + + +@login_required +def market_shift_delete(request, shift_id): + shift_market = get_object_or_404(ShiftForMarket, id=shift_id) + + if request.user != shift_market.offerer: + messages.error(request, "You cannot delete this shift because you did not add it.") + return redirect("shifts_market") + + shift_market.delete() + messages.success(request, "Shift has been deleted from the market.") + return redirect("shifts_market") + + +@login_required +def user_page(request): + return redirect("user") + + @require_http_methods(["POST"]) @csrf_protect @login_required @@ -649,7 +805,7 @@ def shift_single_exchange_post(request, sid=None): return HttpResponseRedirect(reverse("shifter:index")) sExDone = perform_simplified_exchange_and_save_backup( - s, m, approver=request.user, revisionBackup=Revision.objects.filter(name__startswith="BACKUP").first() + s, m, requestor=request.user, approver=request.user, revisionBackup=Revision.objects.filter(name__startswith="BACKUP").first() ) messages.success(request, "Requested shift exchange (update) is now successfully implemented.") notificationService.notify(sExDone) diff --git a/studies/models.py b/studies/models.py index 02c95f51923a09482e18dec31bb386f0e88e1dc4..7324a748d2c406e301c8506be8432fa74a8da49b 100644 --- a/studies/models.py +++ b/studies/models.py @@ -231,6 +231,7 @@ class StudyRequest(models.Model): "start": self.study_start, "end": self.study_end, "color": "#F5D959", + "url": reverse("studies:single_study_view", kwargs={"sid": self.id}), "textColor": "#FF3333" if self.priority else "#676767", "borderColor": "#FF3333" if self.priority else "#BBBBBB", } diff --git a/studies/templates/request.html b/studies/templates/request.html index db0e994a6dced100c7fe0fd482b7bd1387420672..69c773be20f9cb22136a38d8e00363fafb0a0861 100644 --- a/studies/templates/request.html +++ b/studies/templates/request.html @@ -63,6 +63,28 @@ </div> </div> </div> + {%if request.user.is_staff %} + <!-- Import Study Request List --> + <div class="accordion-item"> + <h2 class="accordion-header" id="import_request_header"> + <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#accordion_import_request" aria-expanded="false" aria-controls="accordion_import_request"> + Import Study Request + </button> + </h2> + <div id="accordion_import_request" class="accordion-collapse collapse" aria-labelledby="import_request_header" data-bs-parent="#assets_accordion"> + <div class="accordion-body"> + <form action="{% url 'studies:import_requests' %}" method="POST" enctype="multipart/form-data"> + {% csrf_token %} + <div class="mb-3"> + <label for="csv_file" class="form-label">Upload CSV File</label> + <input class="form-control" type="file" id="csv_file" name="csv_file" accept=".csv" required> + </div> + <input class="btn btn-primary" type="submit" value="Import"> + </form> + </div> + </div> + </div> + {%endif%} </div> </div> </div> diff --git a/studies/urls/main.py b/studies/urls/main.py index 97f8747128c531036fc9817259d032015477d956..b6a2ff7c871f5b32009c6f6d990677b6d9dcba73 100644 --- a/studies/urls/main.py +++ b/studies/urls/main.py @@ -8,4 +8,5 @@ urlpatterns = [ path("", login_required(views.StudyView.as_view()), name="study_request"), path("<int:sid>", login_required(views.SingleStudyView.as_view()), name="single_study_view"), path("close", views.studies_close, name="studies-close"), + path("import-requests/", views.import_requests, name="import_requests"), ] diff --git a/studies/views/main.py b/studies/views/main.py index fc0f45872989341c2c35ad0ec3a5e85d4451daab..a1f9cebafe7eaadefc1d7eca4c5c9c1be71bc6f9 100644 --- a/studies/views/main.py +++ b/studies/views/main.py @@ -5,6 +5,9 @@ from studies.forms import StudyRequestForm, StudyRequestFormClosing import django.contrib.messages as messages from django.views import View from django.utils import timezone +import csv +from members.models import Member +from datetime import datetime from shifter.notifications import notificationService @@ -78,3 +81,63 @@ def studies_close(request): message = "Booking form is not valid, please correct." messages.success(request, message) return redirect("studies:study_request") + + +def import_requests(request): + def get_state_key(state_description): + state_mapping = {"Planned": "P", "Requested": "R", "Booked": "B", "Canceled": "C", "Done": "D"} + return state_mapping.get(state_description, None) + + def parse_date(date_str): + return datetime.strptime(date_str, DATE_FORMAT_FULL) + + if request.method == "POST": + csv_file = request.FILES.get("csv_file") + if csv_file: + data = csv_file.read().decode("utf-8").splitlines() + reader = csv.DictReader(data) + + for row in reader: + state_key = get_state_key(row["State"]) + if not state_key: + messages.error(request, f"Invalid state value: {row['State']} in CSV. Row skipped.") + continue + + booked_by_user = Member.objects.get(first_name=row["# name + bookedby"]) + if not booked_by_user: + messages.error(request, f"User {row['# name + bookedby']} not found. Skipping.") + continue + + slot_start = parse_date(row["slot start"]) + slot_end = parse_date(row["slot end"]) + + if not slot_start or not slot_end: + messages.error(request, f"Invalid date format for start or end time in row: {row}") + continue + + collaborators_names = row["Many users"].split(":") + collaborators = [] + + for name in collaborators_names: + collaborator = Member.objects.get(first_name=name.strip()) + if collaborator: + collaborators.append(collaborator) + + study_request = StudyRequest( + title=row["Title"], + description=row["Description"], + state=state_key, + booked_by=booked_by_user, + member=booked_by_user, + slot_start=slot_start, + slot_end=slot_end, + booking_created=timezone.now(), + ) + study_request.save() + if collaborators: + study_request.collaborators.set(collaborators) + + messages.success(request, "CSV file processed successfully.") + return redirect("studies:study_request") + + return render(request, "your_template.html") diff --git a/tests/test_shift_exchange.py b/tests/test_shift_exchange.py index a46359440b25fb891ed51545759eae0054b59c49..85fa1ece63bf063d02dce8df4618ce2ae8303c47 100644 --- a/tests/test_shift_exchange.py +++ b/tests/test_shift_exchange.py @@ -75,7 +75,7 @@ class ExchangeShifts(TestCase): def test_simple_exchange_just_member(self): self.assertEqual(self.m1, self.shift_m1_am.member) - perform_simplified_exchange_and_save_backup(self.shift_m1_am, self.m2, self.m2, revisionBackup=self.revisionBackup) + perform_simplified_exchange_and_save_backup(self.shift_m1_am, self.m2, self.m2, self.m2, revisionBackup=self.revisionBackup) aa = Shift.objects.filter(member=self.m2, date=self.shift_m1_am.date, slot=self.shift_m1_am.slot, revision=self.shift_m1_am.revision) self.assertEqual(1, len(aa))