diff --git a/back/admin/admin_tasks/migrations/0013_admintask_hardware.py b/back/admin/admin_tasks/migrations/0013_admintask_hardware.py new file mode 100644 index 000000000..acabceb9e --- /dev/null +++ b/back/admin/admin_tasks/migrations/0013_admintask_hardware.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.5 on 2023-11-07 01:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hardware", "0001_initial"), + ("admin_tasks", "0012_admintask_manual_integration"), + ] + + operations = [ + migrations.AddField( + model_name="admintask", + name="hardware", + field=models.ForeignKey( + help_text="Only set if generated based on hardware.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="hardware.hardware", + ), + ), + ] diff --git a/back/admin/admin_tasks/models.py b/back/admin/admin_tasks/models.py index 5da24149b..eb66c0109 100644 --- a/back/admin/admin_tasks/models.py +++ b/back/admin/admin_tasks/models.py @@ -19,15 +19,16 @@ def create_admin_task( new_hire, assigned_to, name, - option, - slack_user, - email, - date, - priority, - pending_admin_task, - manual_integration, - comment, - send_notification, + option=0, + slack_user=None, + email="", + date=None, + priority=2, + pending_admin_task=None, + hardware=None, + manual_integration=None, + comment="-", + send_notification=True, ): admin_task = AdminTask.objects.create( new_hire=new_hire, @@ -39,6 +40,7 @@ def create_admin_task( date=date, priority=priority, based_on=pending_admin_task, + hardware=hardware, manual_integration=manual_integration, ) AdminTaskComment.objects.create( @@ -116,6 +118,12 @@ class Notification(models.IntegerChoices): on_delete=models.SET_NULL, help_text=_("Only set if generated based on a manual integration."), ) + hardware = models.ForeignKey( + "hardware.Hardware", + null=True, + on_delete=models.SET_NULL, + help_text=_("Only set if generated based on hardware."), + ) objects = AminTaskManager() @@ -205,6 +213,10 @@ def mark_completed(self): if self.manual_integration is not None: self.manual_integration.register_manual_integration_run(self.new_hire) + # Check if we need to register hardware + if self.hardware is not None: + self.hardware.remove_or_add_to_user(self.new_hire) + # Get conditions with this to do item as (part of the) condition conditions = self.new_hire.conditions.filter( condition_admin_tasks=self.based_on diff --git a/back/admin/hardware/__init__.py b/back/admin/hardware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/back/admin/hardware/apps.py b/back/admin/hardware/apps.py new file mode 100644 index 000000000..6b4b02f67 --- /dev/null +++ b/back/admin/hardware/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HardwareConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "admin.hardware" diff --git a/back/admin/hardware/factories.py b/back/admin/hardware/factories.py new file mode 100644 index 000000000..a0d2ec537 --- /dev/null +++ b/back/admin/hardware/factories.py @@ -0,0 +1,22 @@ +import factory +from factory.fuzzy import FuzzyText +from pytest_factoryboy import register + +from admin.hardware.models import Hardware + + +@register +class HardwareFactory(factory.django.DjangoModelFactory): + name = FuzzyText() + content = { + "time": 0, + "blocks": [ + { + "data": {"text": "Should be returned when they get terminated"}, + "type": "paragraph", + } + ], + } + + class Meta: + model = Hardware diff --git a/back/admin/hardware/forms.py b/back/admin/hardware/forms.py new file mode 100644 index 000000000..8cf335ae6 --- /dev/null +++ b/back/admin/hardware/forms.py @@ -0,0 +1,54 @@ +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Div, Field, Layout +from django.utils.translation import gettext_lazy as _ + +from admin.hardware.models import Hardware +from admin.templates.forms import MultiSelectField, TagModelForm, WYSIWYGField + + +class HardwareForm(TagModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + + # Check if assigned_to field should be hidden + hide_assigned_to = "d-none" + if self.data.get("person_type", None) == str(Hardware.PersonType.CUSTOM) or ( + self.instance is not None + and self.instance.person_type == Hardware.PersonType.CUSTOM + ): + hide_assigned_to = "" + + layout = Layout( + Div( + Div( + Field("name"), + Field("person_type"), + Div( + Field("assigned_to"), + css_class=hide_assigned_to, + ), + MultiSelectField("tags"), + css_class="col-4", + ), + Div( + WYSIWYGField("content"), + css_class="col-8", + ), + css_class="row", + ), + ) + self.helper.layout = layout + + class Meta: + model = Hardware + exclude = ("template",) + + def clean(self): + cleaned_data = super().clean() + assigned_to = cleaned_data.get("assigned_to") + person_type = cleaned_data["person_type"] + if person_type == Hardware.PersonType.CUSTOM and assigned_to is None: + self.add_error("assigned_to", _("This field is required")) + return cleaned_data diff --git a/back/admin/hardware/migrations/0001_initial.py b/back/admin/hardware/migrations/0001_initial.py new file mode 100644 index 000000000..3956ea8e4 --- /dev/null +++ b/back/admin/hardware/migrations/0001_initial.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.6 on 2023-11-03 00:20 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import misc.fields +import misc.mixins + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Hardware", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=240, verbose_name="Name")), + ( + "tags", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=10200), + blank=True, + size=None, + verbose_name="Tags", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ("template", models.BooleanField(default=True)), + ( + "content", + misc.fields.ContentJSONField(default=dict, verbose_name="Content"), + ), + ( + "person_type", + models.IntegerField( + blank=True, + choices=[(1, "Manager"), (2, "Buddy"), (3, "Custom")], + help_text=( + "Leave empty to automatically remove/add hardware without " + "notifications." + ), + null=True, + verbose_name="Assigned to", + ), + ), + ( + "assigned_to", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assigned_user_hardware", + to=settings.AUTH_USER_MODEL, + verbose_name="Pick user", + ), + ), + ], + options={ + "abstract": False, + }, + bases=(misc.mixins.ContentMixin, models.Model), + ), + ] diff --git a/back/admin/hardware/migrations/__init__.py b/back/admin/hardware/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/back/admin/hardware/models.py b/back/admin/hardware/models.py new file mode 100644 index 000000000..ee4e2686f --- /dev/null +++ b/back/admin/hardware/models.py @@ -0,0 +1,102 @@ +from django.conf import settings +from django.db import models +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from admin.admin_tasks.models import AdminTask +from misc.fields import ContentJSONField +from organization.models import BaseItem, Notification + + +class Hardware(BaseItem): + class PersonType(models.IntegerChoices): + MANAGER = 1, _("Manager") + BUDDY = 2, _("Buddy") + CUSTOM = 3, _("Custom") + + content = ContentJSONField(default=dict, verbose_name=_("Content")) + person_type = models.IntegerField( + verbose_name=_("Assigned to"), + choices=PersonType.choices, + null=True, + blank=True, + help_text=_( + "Leave empty to automatically remove/add hardware without notifications." + ), + ) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_("Pick user"), + on_delete=models.CASCADE, + related_name="assigned_user_hardware", + null=True, + blank=True, + ) + + def remove_or_add_to_user(self, user): + add = user.termination_date is None + if add: + user.hardware.add(self) + else: + user.hardware.remove(self) + + Notification.objects.create( + notification_type=self.notification_add_type + if add + else self.notification_remove_type, + extra_text=self.name, + created_for=user, + item_id=self.id, + ) + + def execute(self, user): + add = user.termination_date is None + + if self.person_type is None: + # no person assigned, so add directly + self.remove_or_add_to_user(user) + return + + if self.person_type == Hardware.PersonType.MANAGER: + assigned_to = user.manager + elif self.person_type == Hardware.PersonType.BUDDY: + assigned_to = user.buddy + else: + assigned_to = self.assigned_to + + if add: + admin_task_name = _( + "Send hardware to new hire (%(new_hire)s): %(name)s" + ) % {"new_hire": user.full_name, "name": self.name} + else: + admin_task_name = _( + "Reclaim hardware from employee (%(new_hire)s): %(name)s" + ) % {"new_hire": user.full_name, "name": self.name} + + AdminTask.objects.create_admin_task( + new_hire=user, + assigned_to=assigned_to, + name=admin_task_name, + hardware=self, + ) + + @property + def get_icon_template(self): + return render_to_string("_hardware_icon.html") + + @property + def notification_add_type(self): + return Notification.Type.ADDED_HARDWARE + + @property + def notification_remove_type(self): + return Notification.Type.REMOVED_HARDWARE + + @property + def update_url(self): + return reverse("hardware:update", args=[self.id]) + + @property + def delete_url(self): + return reverse("hardware:delete", args=[self.id]) diff --git a/back/admin/hardware/templates/_hardware_icon.html b/back/admin/hardware/templates/_hardware_icon.html new file mode 100644 index 000000000..bc94384a3 --- /dev/null +++ b/back/admin/hardware/templates/_hardware_icon.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/back/admin/hardware/tests.py b/back/admin/hardware/tests.py new file mode 100644 index 000000000..a7606f4da --- /dev/null +++ b/back/admin/hardware/tests.py @@ -0,0 +1,147 @@ +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.utils import timezone + +from admin.admin_tasks.models import AdminTask +from admin.hardware.models import Hardware +from organization.models import Notification + + +@pytest.mark.django_db +def test_create_hardware_with_assigned_user(client, django_user_model): + admin_user = django_user_model.objects.create(role=get_user_model().Role.ADMIN) + client.force_login(admin_user) + + url = reverse("hardware:create") + + # missing assigned user, but selected "custom" + data = { + "name": "test", + "tags": ["test", "test"], + "content": '{ "time": 0, "blocks": [] }', + "person_type": 3, + } + + response = client.post(url, data, follow=True) + + assert "This field is required" in response.content.decode() + assert Hardware.objects.all().count() == 0 + + data["assigned_to"] = admin_user.id + + response = client.post(url, data, follow=True) + assert Hardware.objects.all().count() == 1 + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_hardware_execute( + employee_factory, + admin_factory, + hardware_factory, +): + emp1 = employee_factory() + + assert emp1.hardware.count() == 0 + + # hardware without any notifications + hardware = hardware_factory() + hardware.execute(emp1) + + assert emp1.hardware.count() == 1 + assert not AdminTask.objects.all().exists() + assert Notification.objects.count() == 1 + + # run once again for offboarding + emp1.termination_date = timezone.now() + emp1.save() + hardware.execute(emp1) + assert Notification.objects.count() == 2 + assert emp1.hardware.count() == 0 + + # create admin task based on manager + admin1 = admin_factory() + emp2 = employee_factory(manager=admin1) + hardware.person_type = Hardware.PersonType.MANAGER + hardware.save() + + hardware.execute(emp2) + assert AdminTask.objects.filter( + new_hire=emp2, assigned_to=admin1, hardware=hardware + ).exists() + # not attached to user yet + assert emp1.hardware.count() == 0 + assert Notification.objects.count() == 4 + + # create admin task based on buddy + admin2 = admin_factory() + emp3 = employee_factory(buddy=admin2) + hardware.person_type = Hardware.PersonType.BUDDY + hardware.save() + + hardware.execute(emp3) + assert AdminTask.objects.filter( + new_hire=emp3, assigned_to=admin2, hardware=hardware + ).exists() + assert Notification.objects.count() == 6 + + # create admin task based on specific person + admin3 = admin_factory() + emp4 = employee_factory() + hardware.person_type = Hardware.PersonType.CUSTOM + hardware.assigned_to = admin3 + hardware.save() + + hardware.execute(emp4) + assert AdminTask.objects.filter( + new_hire=emp4, assigned_to=admin3, hardware=hardware + ).exists() + assert Notification.objects.count() == 8 + + +@pytest.mark.django_db +def test_hardware_admin_task( + employee_factory, + admin_factory, + hardware_factory, +): + emp1 = employee_factory() + admin1 = admin_factory() + hardware = hardware_factory( + person_type=Hardware.PersonType.CUSTOM, assigned_to=admin1 + ) + hardware.execute(emp1) + + admin_task = AdminTask.objects.get( + new_hire=emp1, assigned_to=admin1, hardware=hardware + ) + assert emp1.hardware.count() == 0 + assert Notification.objects.count() == 2 + + admin_task.mark_completed() + + assert emp1.hardware.count() == 1 + assert Notification.objects.count() == 3 + + # trigger hardware another time for reclaiming hardware + emp1.termination_date = timezone.now() + emp1.save() + hardware.execute(emp1) + + # we now have two admin tasks + assert ( + AdminTask.objects.filter( + new_hire=emp1, assigned_to=admin1, hardware=hardware + ).count() + == 2 + ) + + admin_task = AdminTask.objects.get( + name=f"Reclaim hardware from employee ({emp1.full_name}): {hardware.name}" + ) + assert not admin_task.completed + admin_task.mark_completed() + + assert Notification.objects.count() == 6 + assert emp1.hardware.count() == 0 diff --git a/back/admin/hardware/urls.py b/back/admin/hardware/urls.py new file mode 100644 index 000000000..e109c3d4e --- /dev/null +++ b/back/admin/hardware/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from admin.hardware import views + +app_name = "hardware" +urlpatterns = [ + path("", views.HardwareListView.as_view(), name="list"), + path("create/", views.HardwareCreateView.as_view(), name="create"), + path("/edit/", views.HardwareUpdateView.as_view(), name="update"), + path("/delete/", views.HardwareDeleteView.as_view(), name="delete"), +] diff --git a/back/admin/hardware/views.py b/back/admin/hardware/views.py new file mode 100644 index 000000000..b23a8472c --- /dev/null +++ b/back/admin/hardware/views.py @@ -0,0 +1,61 @@ +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ +from django.views.generic.edit import CreateView, DeleteView, UpdateView +from django.views.generic.list import ListView + +from admin.hardware.forms import HardwareForm +from admin.hardware.models import Hardware +from users.mixins import LoginRequiredMixin, ManagerPermMixin + + +class HardwareListView(LoginRequiredMixin, ManagerPermMixin, ListView): + template_name = "templates.html" + queryset = Hardware.templates.all().order_by("name").defer("content") + paginate_by = 10 + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Hardware items") + context["subtitle"] = _("templates") + context["add_action"] = reverse_lazy("hardware:create") + return context + + +class HardwareCreateView( + LoginRequiredMixin, ManagerPermMixin, SuccessMessageMixin, CreateView +): + template_name = "template_update.html" + form_class = HardwareForm + success_url = reverse_lazy("hardware:list") + success_message = _("Hardware item has been created") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Create hardware item") + context["subtitle"] = _("templates") + return context + + +class HardwareUpdateView( + LoginRequiredMixin, ManagerPermMixin, SuccessMessageMixin, UpdateView +): + template_name = "template_update.html" + form_class = HardwareForm + success_url = reverse_lazy("hardware:list") + queryset = Hardware.templates.all() + success_message = _("Hardware item has been updated") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Update hardware item") + context["subtitle"] = _("templates") + return context + + +class HardwareDeleteView( + LoginRequiredMixin, ManagerPermMixin, SuccessMessageMixin, DeleteView +): + queryset = Hardware.objects.all() + success_url = reverse_lazy("hardware:list") + success_message = _("Hardware item has been removed") diff --git a/back/admin/people/templates/colleague_update.html b/back/admin/people/templates/colleague_update.html index 3c5974c6d..34ac4828e 100644 --- a/back/admin/people/templates/colleague_update.html +++ b/back/admin/people/templates/colleague_update.html @@ -55,6 +55,30 @@

{% translate "Resources available" %}

+
+
+

{% translate "Hardware" %}

+
+
+ {% for hardware in object.hardware.all %} +
+
+
+ {{ hardware.name }} +
+
+
+ {% empty %} +
+
+
+ {% translate "No hardware assigned to this user yet" %} +
+
+
+ {% endfor %} +
+
{% endblock %} diff --git a/back/admin/people/templates/new_hire_detail.html b/back/admin/people/templates/new_hire_detail.html index e03ec5586..9ca4c6210 100644 --- a/back/admin/people/templates/new_hire_detail.html +++ b/back/admin/people/templates/new_hire_detail.html @@ -150,5 +150,24 @@

{% translate "Latest activity..." %}

+ {% if object.hardware.all %} +
+
+

{% translate "Hardware" %}

+
+
+ {% for hardware in object.hardware.all %} +
+
+
+ {{ hardware.name }} +
+
+
+ {% endfor %} +
+
+ {% endif %} + {% endblock %} diff --git a/back/admin/sequences/migrations/0043_condition_hardware.py b/back/admin/sequences/migrations/0043_condition_hardware.py new file mode 100644 index 000000000..7af4cf429 --- /dev/null +++ b/back/admin/sequences/migrations/0043_condition_hardware.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.6 on 2023-11-03 00:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hardware", "0001_initial"), + ("sequences", "0042_integrationconfig_assigned_to_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="condition", + name="hardware", + field=models.ManyToManyField(to="hardware.hardware"), + ), + ] diff --git a/back/admin/sequences/models.py b/back/admin/sequences/models.py index 1cf52de87..6ff6ec68c 100644 --- a/back/admin/sequences/models.py +++ b/back/admin/sequences/models.py @@ -9,6 +9,7 @@ from admin.admin_tasks.models import AdminTask from admin.appointments.models import Appointment from admin.badges.models import Badge +from admin.hardware.models import Hardware from admin.integrations.models import Integration from admin.introductions.models import Introduction from admin.preboarding.models import Preboarding @@ -191,6 +192,7 @@ def remove_from_user(self, new_hire): preboarding = Preboarding.objects.none() appointments = Appointment.objects.none() integration_configs = IntegrationConfig.objects.none() + hardware = Hardware.objects.none() # TODO: this is going to make a lot of queries, should be optimized for condition in self.conditions.all(): @@ -203,6 +205,7 @@ def remove_from_user(self, new_hire): preboarding |= condition.preboarding.all() appointments |= condition.appointments.all() integration_configs |= condition.integration_configs.all() + hardware |= condition.hardware.all() # Cycle through new hire's item and remove the ones that aren't supposed to # be there @@ -211,6 +214,7 @@ def remove_from_user(self, new_hire): new_hire.appointments.remove(*appointments) new_hire.preboarding.remove(*preboarding) new_hire.introductions.remove(*introductions) + new_hire.hardware.remove(*hardware) # Do the same with the conditions conditions_to_be_deleted = [] @@ -224,6 +228,7 @@ def remove_from_user(self, new_hire): "preboarding": preboarding, "appointments": appointments, "integration_configs": integration_configs, + "hardware": hardware, } for condition in new_hire.conditions.all(): for field in condition._meta.many_to_many: @@ -523,9 +528,7 @@ def execute(self, user): date=self.date, priority=self.priority, pending_admin_task=self, - manual_integration=None, comment=self.comment, - send_notification=True, ) @property @@ -624,15 +627,7 @@ def execute(self, user): new_hire=user, assigned_to=assigned_to, name=admin_task_name, - option=AdminTask.Notification.NO, - slack_user=None, - email="", - date=None, - priority=AdminTask.Priority.MEDIUM, - pending_admin_task=None, manual_integration=self.integration, - comment="-", - send_notification=True, ) @@ -713,6 +708,7 @@ class Type(models.IntegerChoices): preboarding = models.ManyToManyField(Preboarding) appointments = models.ManyToManyField(Appointment) integration_configs = models.ManyToManyField(IntegrationConfig) + hardware = models.ManyToManyField(Hardware) objects = ConditionPrefetchManager.from_queryset(ConditionQuerySet)() @@ -728,6 +724,7 @@ def is_empty(self): or self.preboarding.exists() or self.appointments.exists() or self.integration_configs.exists() + or self.hardware.exists() ) @property @@ -864,6 +861,11 @@ def process_condition(self, user, skip_notification=False): # For the ones that aren't a quick copy/paste, follow back to their model and # execute them. It will also add an item to the notification model there. - for field in ["admin_tasks", "external_messages", "integration_configs"]: + for field in [ + "admin_tasks", + "external_messages", + "integration_configs", + "hardware", + ]: for item in getattr(self, field).all(): item.execute(user) diff --git a/back/admin/sequences/templates/_condition_body.html b/back/admin/sequences/templates/_condition_body.html index 101370fa8..0e00abe57 100644 --- a/back/admin/sequences/templates/_condition_body.html +++ b/back/admin/sequences/templates/_condition_body.html @@ -37,6 +37,9 @@ {% for item in condition.integration_configs.all %} {% include '_condition_line_item.html' with type='integrationconfig' provision_created=1 %} {% endfor %} +{% for item in condition.hardware.all %} + {% include '_condition_line_item.html' with type='hardware' %} +{% endfor %} {# FOR ADMINS #} {% if condition.external_admin|length or condition.admin_tasks.all|length %} diff --git a/back/admin/sequences/templates/_sequence_templates_list.html b/back/admin/sequences/templates/_sequence_templates_list.html index 161cc9de1..5614c9b2a 100644 --- a/back/admin/sequences/templates/_sequence_templates_list.html +++ b/back/admin/sequences/templates/_sequence_templates_list.html @@ -1,5 +1,5 @@ {% load i18n %} -
+

{% translate "Templates" %}

- +
diff --git a/back/admin/sequences/templates/sequence.html b/back/admin/sequences/templates/sequence.html index 25d8222c5..2b00c54bd 100644 --- a/back/admin/sequences/templates/sequence.html +++ b/back/admin/sequences/templates/sequence.html @@ -323,11 +323,11 @@ var placeholder = '{% translate "Start typing here..." %}'; var newItemText = '{% translate "New item" %}'; {% if sequence.is_onboarding %} -var allowedBefore = ["pendingadmintask", "pendingemailmessage", "pendingslackmessage", "pendingtextmessage", "integration"] -var allowedAfter = ["pendingadmintask","appointment", "todo", "resource", "introduction", "badge", "pendingemailmessage", "pendingslackmessage", "pendingtextmessage", "integration"] +var allowedBefore = ["pendingadmintask", "pendingemailmessage", "pendingslackmessage", "pendingtextmessage", "integration", "hardware"] +var allowedAfter = ["pendingadmintask","appointment", "todo", "resource", "introduction", "badge", "pendingemailmessage", "pendingslackmessage", "pendingtextmessage", "integration", "hardware"] {% else %} -var allowedBefore = ["pendingadmintask","appointment", "todo", "resource", "introduction", "badge", "pendingemailmessage", "pendingslackmessage", "pendingtextmessage", "integration"] -var allowedAfter = ["pendingadmintask", "pendingemailmessage", "pendingslackmessage", "pendingtextmessage", "integration"] +var allowedBefore = ["pendingadmintask","appointment", "todo", "resource", "introduction", "badge", "pendingemailmessage", "pendingslackmessage", "pendingtextmessage", "integration", "hardware"] +var allowedAfter = ["pendingadmintask", "pendingemailmessage", "pendingslackmessage", "pendingtextmessage", "integration", "hardware"] {% endif %} diff --git a/back/admin/sequences/tests.py b/back/admin/sequences/tests.py index 98a9350d1..012822e43 100644 --- a/back/admin/sequences/tests.py +++ b/back/admin/sequences/tests.py @@ -13,6 +13,8 @@ from admin.appointments.forms import AppointmentForm from admin.badges.factories import BadgeFactory from admin.badges.forms import BadgeForm +from admin.hardware.factories import HardwareFactory +from admin.hardware.forms import HardwareForm from admin.integrations.models import Integration from admin.introductions.factories import IntroductionFactory from admin.introductions.forms import IntroductionForm @@ -414,6 +416,7 @@ def test_sequence_detail_view( ("appointment", AppointmentForm, AppointmentFactory), ("introduction", IntroductionForm, IntroductionFactory), ("badge", BadgeForm, BadgeFactory), + ("hardware", HardwareForm, HardwareFactory), ("preboarding", PreboardingForm, PreboardingFactory), ("pendingadmintask", PendingAdminTaskForm, PendingAdminTaskFactory), ("pendingslackmessage", PendingSlackMessageForm, PendingSlackMessageFactory), @@ -964,6 +967,7 @@ def test_delete_sequence(client, admin_factory, sequence_factory): ("appointment", AppointmentFactory), ("introduction", IntroductionFactory), ("badge", BadgeFactory), + ("hardware", HardwareFactory), ("preboarding", PreboardingFactory), ], ) @@ -1081,6 +1085,7 @@ def test_onboarding_sequence_trigger_task( pending_text_message1 = pending_text_message_factory() manual_provisioning = manual_user_provision_integration_factory() manual_config = IntegrationConfigFactory(integration=manual_provisioning) + hardware = HardwareFactory() seq = sequence_factory() unconditioned_condition = seq.conditions.all().first() @@ -1102,6 +1107,7 @@ def test_onboarding_sequence_trigger_task( condition.add_item(pending_admin_task1) condition.add_item(pending_text_message1) condition.add_item(manual_config) + condition.add_item(hardware) seq.conditions.add(condition) @@ -1122,6 +1128,7 @@ def test_onboarding_sequence_trigger_task( assert new_hire1.introductions.all().count() == 1 assert new_hire1.badges.all().count() == 1 assert new_hire1.integrations.all().count() == 1 + assert new_hire1.hardware.all().count() == 1 @pytest.mark.django_db diff --git a/back/admin/sequences/views.py b/back/admin/sequences/views.py index 01b1de82e..addfc36f3 100644 --- a/back/admin/sequences/views.py +++ b/back/admin/sequences/views.py @@ -375,7 +375,11 @@ def post(self, request, pk, type, template_pk, *args, **kwargs): return render( request, "_sequence_condition.html", - {"condition": condition, "object": condition.sequence}, + { + "condition": condition, + "object": condition.sequence, + "sequence": condition.sequence, + }, ) diff --git a/back/admin/templates/templates/template_update.html b/back/admin/templates/templates/template_update.html index 10af07404..815ae0c43 100644 --- a/back/admin/templates/templates/template_update.html +++ b/back/admin/templates/templates/template_update.html @@ -55,5 +55,14 @@ $(".slack_channel_dissapear").addClass("d-none") } }) +// FOR HARDWARE +$("#id_person_type").on('change', function() { + let selectedItem = $(this).val() + if (selectedItem == 3){ + $("#div_id_assigned_to").parent().removeClass("d-none") + } else { + $("#div_id_assigned_to").parent().addClass("d-none") + } +}) {% endblock %} diff --git a/back/admin/templates/tests.py b/back/admin/templates/tests.py index 19b4a6a64..570f0ec54 100644 --- a/back/admin/templates/tests.py +++ b/back/admin/templates/tests.py @@ -5,6 +5,7 @@ from admin.appointments.factories import AppointmentFactory # noqa from admin.badges.factories import BadgeFactory # noqa +from admin.hardware.factories import HardwareFactory # noqa from admin.introductions.factories import IntroductionFactory # noqa from admin.preboarding.factories import PreboardingFactory # noqa from admin.resources.factories import ResourceFactory # noqa @@ -22,6 +23,7 @@ ("preboarding:list", "preboarding", "preboarding"), ("appointments:list", "appointments", "appointment"), ("resources:list", "resources", "resource"), + ("hardware:list", "hardware", "hardware"), ], ) def test_templates_crud( @@ -68,6 +70,10 @@ def test_templates_crud( ResourceFactory(template=False) ResourceFactory(template=False) + HardwareFactory() + HardwareFactory(template=False) + HardwareFactory(template=False) + # Get first object of template object_model = apps.get_model(app, model) obj = object_model.objects.first() diff --git a/back/admin/templates/utils.py b/back/admin/templates/utils.py index dd50efc6f..4bb6a0387 100644 --- a/back/admin/templates/utils.py +++ b/back/admin/templates/utils.py @@ -2,6 +2,7 @@ from admin.appointments.forms import AppointmentForm from admin.badges.forms import BadgeForm +from admin.hardware.forms import HardwareForm from admin.introductions.forms import IntroductionForm from admin.preboarding.forms import PreboardingForm from admin.resources.forms import ResourceForm @@ -34,6 +35,12 @@ "form": PreboardingForm, }, {"app": "badges", "model": "Badge", "user_field": "badges", "form": BadgeForm}, + { + "app": "hardware", + "model": "Hardware", + "user_field": "hardware", + "form": HardwareForm, + }, ] diff --git a/back/back/settings.py b/back/back/settings.py index 6c41f2f02..bbcee8b05 100644 --- a/back/back/settings.py +++ b/back/back/settings.py @@ -81,6 +81,7 @@ "admin.sequences", "admin.people", "admin.settings", + "admin.hardware", # new hires "new_hire", # slack diff --git a/back/back/urls.py b/back/back/urls.py index 55ea8f60c..f13df5cc2 100644 --- a/back/back/urls.py +++ b/back/back/urls.py @@ -6,6 +6,7 @@ from admin.admin_tasks import urls as admin_tasks_urls from admin.appointments import urls as appointment_urls from admin.badges import urls as badge_urls +from admin.hardware import urls as hardware_urls from admin.integrations import urls as integrations_urls from admin.introductions import urls as intro_urls from admin.people import urls as people_urls @@ -40,6 +41,7 @@ path("admin/tasks/", include(admin_tasks_urls)), path("admin/templates/", include(template_urls)), path("admin/templates/todo/", include(to_do_urls)), + path("admin/templates/hardware/", include(hardware_urls)), path("admin/templates/introductions/", include(intro_urls)), path("admin/templates/resources/", include(resource_urls)), path("admin/templates/badges/", include(badge_urls)), diff --git a/back/conftest.py b/back/conftest.py index 682286d9a..ead7d04da 100644 --- a/back/conftest.py +++ b/back/conftest.py @@ -6,6 +6,7 @@ from admin.admin_tasks.factories import AdminTaskFactory from admin.appointments.factories import AppointmentFactory from admin.badges.factories import BadgeFactory +from admin.hardware.factories import HardwareFactory from admin.integrations.factories import ( CustomIntegrationFactory, CustomUserImportIntegrationFactory, @@ -89,6 +90,7 @@ def run_around_tests(request, settings): register(ResourceUserFactory) register(AdminTaskFactory) register(ToDoFactory) +register(HardwareFactory) register(AppointmentFactory) register(IntroductionFactory) register(NoteFactory) diff --git a/back/organization/migrations/0039_alter_notification_notification_type.py b/back/organization/migrations/0039_alter_notification_notification_type.py new file mode 100644 index 000000000..f0c631bc0 --- /dev/null +++ b/back/organization/migrations/0039_alter_notification_notification_type.py @@ -0,0 +1,107 @@ +# Generated by Django 4.2.6 on 2023-11-03 00:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("organization", "0038_alter_notification_notification_type"), + ] + + operations = [ + migrations.AlterField( + model_name="notification", + name="notification_type", + field=models.CharField( + choices=[ + ("added_todo", "A new to do item has been added"), + ("completed_todo", "To do item has been marked as completed"), + ("added_resource", "A new resource item has been added"), + ("completed_course", "Course has been completed"), + ("added_badge", "A new badge item has been added"), + ("added_introduction", "A new introduction item has been added"), + ("added_preboarding", "A new preboarding item has been added"), + ("added_appointment", "A new appointment item has been added"), + ("added_new_hire", "A new hire has been added"), + ("added_administrator", "A new administrator has been added"), + ("added_manager", "A new manager has been added"), + ("added_admin_task", "A new admin task has been added"), + ("added_sequence", "A new sequence has been added"), + ("added_hardware", "A new hardware item has been added"), + ("removed_hardware", "A new hardware item has been removed"), + ("sent_email_message", "A new email has been sent"), + ("sent_text_message", "A new text message has been sent"), + ("sent_slack_message", "A new slack message has been sent"), + ("updated_slack_message", "A new slack message has been updated"), + ( + "sent_email_login_credentials", + "Login credentials have been sent", + ), + ("sent_email_task_reopened", "Reopened task email has been sent"), + ("sent_email_task_reminder", "Task reminder email has been sent"), + ( + "sent_email_new_hire_credentials", + "Sent new hire credentials email", + ), + ( + "sent_email_preboarding_access", + "Sent new hire preboarding email", + ), + ("sent_email_custom_sequence", "Sent email from sequence"), + ( + "sent_email_new_hire_with_updates", + "Sent email with updates to new hire", + ), + ( + "sent_email_admin_task_extra", + "Sent email to extra person in admin task", + ), + ( + "sent_email_admin_task_new_assigned", + "Sent email about new person assigned to admin task", + ), + ( + "sent_email_admin_task_new_comment", + "Sent email about new comment on admin task", + ), + ( + "sent_email_integration_notification", + "Sent email about completing integration call", + ), + ( + "failed_no_phone", + "Couldn't send text message: number is missing", + ), + ( + "failed_no_email", + "Couldn't send email message: email is missing", + ), + ( + "failed_email_recipients_refused", + "Couldn't deliver email message: recipient refused", + ), + ( + "failed_email_delivery", + "Couldn't deliver email message: provider error", + ), + ( + "failed_email_address", + "Couldn't deliver email message: provider error", + ), + ("failed_send_slack_message", "Couldn't send Slack message"), + ("failed_update_slack_message", "Couldn't update Slack message"), + ("ran_integration", "Integration has been triggered"), + ("remove_manual_integration", "Disable manual integration"), + ("add_manual_integration", "Enable manual integration"), + ("failed_integration", "Couldn't complete integration"), + ("blocked_integration", "Integration was blocked due to condition"), + ( + "failed_text_integration_notification", + "Couldn't send integration notification", + ), + ], + default="added_todo", + max_length=100, + ), + ), + ] diff --git a/back/organization/models.py b/back/organization/models.py index 33af0fcc4..b42f9cd8d 100644 --- a/back/organization/models.py +++ b/back/organization/models.py @@ -314,6 +314,8 @@ class Type(models.TextChoices): ADDED_MANAGER = "added_manager", _("A new manager has been added") ADDED_ADMIN_TASK = "added_admin_task", _("A new admin task has been added") ADDED_SEQUENCE = "added_sequence", _("A new sequence has been added") + ADDED_HARDWARE = "added_hardware", _("A new hardware item has been added") + REMOVED_HARDWARE = "removed_hardware", _("A new hardware item has been removed") SENT_EMAIL_MESSAGE = "sent_email_message", _("A new email has been sent") SENT_TEXT_MESSAGE = "sent_text_message", _("A new text message has been sent") SENT_SLACK_MESSAGE = "sent_slack_message", _( diff --git a/back/static/css/theme.css b/back/static/css/theme.css index ab0f9af60..af0bb6995 100644 --- a/back/static/css/theme.css +++ b/back/static/css/theme.css @@ -12887,3 +12887,8 @@ form .upload-field .btn { .badge:empty { margin-bottom: 6px; } + +.select-template .btn { + margin-top: 5px; + margin-bottom: 5px; +} diff --git a/back/users/migrations/0037_user_hardware.py b/back/users/migrations/0037_user_hardware.py new file mode 100644 index 000000000..cf1d1e73f --- /dev/null +++ b/back/users/migrations/0037_user_hardware.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.6 on 2023-11-03 00:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hardware", "0001_initial"), + ("users", "0036_user_termination_date"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="hardware", + field=models.ManyToManyField( + related_name="user_hardware", to="hardware.hardware" + ), + ), + ] diff --git a/back/users/models.py b/back/users/models.py index 0caac8b14..25d796e01 100644 --- a/back/users/models.py +++ b/back/users/models.py @@ -15,6 +15,7 @@ from admin.appointments.models import Appointment from admin.badges.models import Badge +from admin.hardware.models import Hardware from admin.introductions.models import Introduction from admin.preboarding.models import Preboarding from admin.resources.models import CourseAnswer, Resource @@ -207,6 +208,7 @@ class Role(models.IntegerChoices): Preboarding, through="PreboardingUser", related_name="user_preboardings" ) badges = models.ManyToManyField(Badge, related_name="user_introductions") + hardware = models.ManyToManyField(Hardware, related_name="user_hardware") integrations = models.ManyToManyField( "integrations.Integration", through="IntegrationUser", diff --git a/back/users/templates/admin_base.html b/back/users/templates/admin_base.html index 853b59575..218bba088 100644 --- a/back/users/templates/admin_base.html +++ b/back/users/templates/admin_base.html @@ -79,21 +79,24 @@ {% translate "To do" %} - - {% translate "Resources" %} - - - {% translate "Introductions" %} - - - {% translate "Appointments" %} - - - {% translate "Preboarding" %} - - - {% translate "Badges" %} - + + {% translate "Resources" %} + + + {% translate "Introductions" %} + + + {% translate "Appointments" %} + + + {% translate "Preboarding" %} + + + {% translate "Badges" %} + + + {% translate "Hardware" %} +