From 2f9670ec3f2fa8f55c983213eeb63eff6d545953 Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Tue, 14 Oct 2025 04:40:34 +0200 Subject: [PATCH 01/22] add roles and departments --- .../people/templates/department_update.html | 49 ++++++++++++ back/admin/people/templates/departments.html | 80 +++++++++++++------ back/admin/people/templates/role_create.html | 23 ++++++ back/admin/people/templates/role_update.html | 23 ++++++ back/admin/people/urls.py | 15 ++++ back/admin/people/views.py | 66 ++++++++++++++- .../0045_department_sequences_role.py | 34 ++++++++ back/users/models.py | 17 ++++ back/users/selectors.py | 6 ++ 9 files changed, 287 insertions(+), 26 deletions(-) create mode 100644 back/admin/people/templates/department_update.html create mode 100644 back/admin/people/templates/role_create.html create mode 100644 back/admin/people/templates/role_update.html create mode 100644 back/users/migrations/0045_department_sequences_role.py diff --git a/back/admin/people/templates/department_update.html b/back/admin/people/templates/department_update.html new file mode 100644 index 00000000..de480ff0 --- /dev/null +++ b/back/admin/people/templates/department_update.html @@ -0,0 +1,49 @@ +{% extends 'admin_base.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block content %} +
+
+
+
+

{{ object.name }}

+
+
+
+ {% csrf_token %} + {{ form|crispy }} + +
+
+
+
+
+

{% trans "Roles" %}

+
+
+
+ {% for role in department.roles.all %} +
+
+

{{ role }}

+
+ +
+ {% endfor %} +
+ + {% trans "Add" %} + +
+
+
+
+{% endblock %} + diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index 41e73db0..9660767f 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -1,4 +1,5 @@ {% extends 'admin_base.html' %} +{% load static crispy_forms_tags %} {% load i18n %} {% block actions %} @@ -8,33 +9,62 @@ {% endblock %} {% block content %} -
-
-
- - - - - - - - {% for department in object_list %} - - - - {% empty %} - - - +
+
+ +
+
+ {% for department in departments %} +
+
+
+

{{ department }}

+
+ +
+
+
+ {% for sequence in department.sequences.all %} + {{ sequence.name }} + {% endfor %} + {% for role in department.roles.all %} +

{% trans "Role: " %} {{ role }}

+ {% endfor %} + {% if department.sequences.all|length == 0 and department.roles.all|length == 0 %} + {% trans "No sequences added yet to this department" %} + {% endif %} +
+
+
+ {% endfor %} +
+
+
+
+

{% translate "Users" %}

+
+
+
+ {% for user in users %} +
+
+
+
+

{{ user.full_name }}

+
+
+
+
{% endfor %} -
-
{% translate "Name" %}
- {{ department.name }} -
- {% trans "You haven't created any departments yet" %} -
+
+
- {% include "_paginator.html" %} {% endblock %} diff --git a/back/admin/people/templates/role_create.html b/back/admin/people/templates/role_create.html new file mode 100644 index 00000000..ceadef8f --- /dev/null +++ b/back/admin/people/templates/role_create.html @@ -0,0 +1,23 @@ +{% extends 'admin_base.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block content %} +
+
+
+
+

{% translate "New role" %}

+
+
+
+ {% csrf_token %} + {{ form|crispy }} + +
+
+
+
+
+{% endblock %} + diff --git a/back/admin/people/templates/role_update.html b/back/admin/people/templates/role_update.html new file mode 100644 index 00000000..4a21de4c --- /dev/null +++ b/back/admin/people/templates/role_update.html @@ -0,0 +1,23 @@ +{% extends 'admin_base.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block content %} +
+
+
+
+

{{ object.name }}

+
+
+
+ {% csrf_token %} + {{ form|crispy }} + +
+
+
+
+
+{% endblock %} + diff --git a/back/admin/people/urls.py b/back/admin/people/urls.py index bb5d1866..5ab93aa7 100644 --- a/back/admin/people/urls.py +++ b/back/admin/people/urls.py @@ -238,4 +238,19 @@ views.DepartmentCreateView.as_view(), name="department_create", ), + path( + "colleagues/departments//update/", + views.DepartmentUpdateView.as_view(), + name="department_update", + ), + path( + "colleagues/departments//roles/add/", + views.DepartmentRoleCreateView.as_view(), + name="department_role_create", + ), + path( + "colleagues/departments//roles//update/", + views.DepartmentRoleUpdateView.as_view(), + name="department_role_update", + ), ] diff --git a/back/admin/people/views.py b/back/admin/people/views.py index 38b79846..0ffb97d4 100644 --- a/back/admin/people/views.py +++ b/back/admin/people/views.py @@ -41,10 +41,12 @@ AdminOrManagerPermMixin, AdminPermMixin, ) -from users.models import Department, ToDoUser +from users.models import Department, Role, ToDoUser from users.selectors import ( get_all_offboarding_users_for_departments_of_user, + get_all_users_for_departments_of_user, get_available_departments_for_user, + get_available_roles_for_user, ) from .forms import ( @@ -540,6 +542,7 @@ def create(self, request, *args, **kwargs): class DepartmentListView(AdminOrManagerPermMixin, ListView): template_name = "departments.html" paginate_by = 20 + context_object_name = "departments" def get_queryset(self): return get_available_departments_for_user(user=self.request.user) @@ -548,6 +551,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["title"] = _("Roles and departments") context["subtitle"] = _("people") + context["users"] = get_all_users_for_departments_of_user(user=self.request.user) return context @@ -565,3 +569,63 @@ def get_context_data(self, **kwargs): context["title"] = _("Roles and departments") context["subtitle"] = _("people") return context + + +class DepartmentRoleCreateView(AdminOrManagerPermMixin, SuccessMessageMixin, CreateView): + template_name = "role_create.html" + model = Role + fields = [ + "name", + ] + success_message = _("Role has been created") + success_url = reverse_lazy("people:departments") + + def dispatch(self, *args, **kwargs): + self.department = get_object_or_404(get_available_departments_for_user(user=self.request.user), id=self.kwargs.get("pk")) + return super().dispatch(*args, **kwargs) + + def form_valid(self, form): + form.instance.department = self.department + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Roles") + context["subtitle"] = _("people") + return context + + +class DepartmentUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView): + template_name = "department_update.html" + fields = [ + "name", + ] + success_message = _("Department has been updated") + success_url = reverse_lazy("people:departments") + + def get_queryset(self): + return get_available_departments_for_user(user=self.request.user) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Department") + context["subtitle"] = _("people") + return context + + +class DepartmentRoleUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView): + template_name = "role_update.html" + fields = [ + "name", + ] + success_message = _("Role has been updated") + success_url = reverse_lazy("people:departments") + + def get_queryset(self): + return get_available_roles_for_user(user=self.request.user) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Role") + context["subtitle"] = _("people") + return context diff --git a/back/users/migrations/0045_department_sequences_role.py b/back/users/migrations/0045_department_sequences_role.py new file mode 100644 index 00000000..3a567733 --- /dev/null +++ b/back/users/migrations/0045_department_sequences_role.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.7 on 2025-10-14 00:55 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sequences', '0046_alter_sequence_options_sequence_departments'), + ('users', '0044_alter_department_options'), + ] + + operations = [ + migrations.AddField( + model_name='department', + name='sequences', + field=models.ManyToManyField(related_name='+', to='sequences.sequence'), + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('department', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='roles', to='users.department')), + ('sequences', models.ManyToManyField(related_name='roles', to='sequences.sequence')), + ('users', models.ManyToManyField(related_name='users', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('name',), + }, + ), + ] diff --git a/back/users/models.py b/back/users/models.py index 05839f4d..f28bedc3 100644 --- a/back/users/models.py +++ b/back/users/models.py @@ -30,12 +30,29 @@ from .utils import CompletedFormCheck, parse_array_to_string +class Role(models.Model): + """ + Role within a department. Roles are unique to every department. + """ + name = models.CharField(max_length=255) + department = models.ForeignKey("users.Department", related_name="roles", on_delete=models.PROTECT) + sequences = models.ManyToManyField("sequences.Sequence", related_name="roles") + users = models.ManyToManyField("users.User", related_name="users") + + class Meta: + ordering = ("name",) + + def __str__(self): + return "%s" % self.name + + class Department(models.Model): """ Department that has been attached to a user """ name = models.CharField(max_length=255) + sequences = models.ManyToManyField("sequences.Sequence", related_name="+") class Meta: ordering = ("name",) diff --git a/back/users/selectors.py b/back/users/selectors.py index 2ff2cc95..20b69c92 100644 --- a/back/users/selectors.py +++ b/back/users/selectors.py @@ -9,6 +9,12 @@ def get_available_departments_for_user(*, user): return Department.objects.all() return user.departments.all() +def get_available_roles_for_user(*, user): + from users.models import Role + + departments = get_available_departments_for_user(user=user) + return Role.objects.filter(department__in=departments) + def get_departments_query(*, user): from users.models import Department From 7c10070ddc326942decd790ec323a044bf39ab21 Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Thu, 16 Oct 2025 04:08:35 +0200 Subject: [PATCH 02/22] updates --- .../people/templates/department_update.html | 2 + back/admin/people/templates/departments.html | 137 ++++++++++++++++-- back/admin/people/urls.py | 5 + back/admin/people/views.py | 11 ++ back/users/selectors.py | 12 +- 5 files changed, 150 insertions(+), 17 deletions(-) diff --git a/back/admin/people/templates/department_update.html b/back/admin/people/templates/department_update.html index de480ff0..ec4274d3 100644 --- a/back/admin/people/templates/department_update.html +++ b/back/admin/people/templates/department_update.html @@ -36,6 +36,8 @@

{% trans "Roles" %}

+ {% empty %} +

{% trans "No roles added yet" %}

{% endfor %} diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index 9660767f..353c61d9 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -3,6 +3,9 @@ {% load i18n %} {% block actions %} + {% trans "Add" %} @@ -11,11 +14,8 @@ {% block content %}
-
-
+
{% for department in departments %}
@@ -30,14 +30,31 @@

{{ department }}

- {% for sequence in department.sequences.all %} - {{ sequence.name }} - {% endfor %} {% for role in department.roles.all %} -

{% trans "Role: " %} {{ role }}

+
+

{{ role }}

+ {% if role.users.all|length %} +
    + {% for user in role.users.all %} +
  • + {% if user.profile_image is not None %} + + {% else %} + {{ user.initials }} + {% endif %} + {{ user.name }} +
  • + {% endfor %} +
+ {% else %} + {% trans "No users have been added to this role yet." %} + {% endif %} +
{% endfor %} - {% if department.sequences.all|length == 0 and department.roles.all|length == 0 %} - {% trans "No sequences added yet to this department" %} + {% if department.roles.all|length == 0 %} + {% trans "No roles have been added to this department yet." %} +
+ {% trans "Add role" %} {% endif %}
@@ -53,7 +70,7 @@

{% translate "Users" %}

{% for user in users %}
-
+

{{ user.full_name }}

@@ -68,3 +85,101 @@

{% translate "Users" %}

{% endblock %} + +{% block extra_css %} + +{% endblock extra_css %} +{% block extra_js %} + +{% endblock extra_js %} diff --git a/back/admin/people/urls.py b/back/admin/people/urls.py index 5ab93aa7..c9bd34ef 100644 --- a/back/admin/people/urls.py +++ b/back/admin/people/urls.py @@ -248,6 +248,11 @@ views.DepartmentRoleCreateView.as_view(), name="department_role_create", ), + path( + "colleagues/role//user//", + views.AddUserToRoleView.as_view(), + name="add_user_to_role", + ), path( "colleagues/departments//roles//update/", views.DepartmentRoleUpdateView.as_view(), diff --git a/back/admin/people/views.py b/back/admin/people/views.py index 0ffb97d4..02aa48cc 100644 --- a/back/admin/people/views.py +++ b/back/admin/people/views.py @@ -595,6 +595,17 @@ def get_context_data(self, **kwargs): return context +class AddUserToRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): + + def post(self, request, role_pk, user_pk, **kwargs): + print(get_available_roles_for_user(user=request.user)) + role = get_object_or_404(get_available_roles_for_user(user=request.user), id=role_pk) + user = get_object_or_404(get_all_users_for_departments_of_user(user=request.user), id=user_pk) + + role.users.add(user) + return render(request, "departments.html", {"departments": get_available_departments_for_user(user=self.request.user)}) + + class DepartmentUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView): template_name = "department_update.html" fields = [ diff --git a/back/users/selectors.py b/back/users/selectors.py index 20b69c92..5e15045e 100644 --- a/back/users/selectors.py +++ b/back/users/selectors.py @@ -13,7 +13,7 @@ def get_available_roles_for_user(*, user): from users.models import Role departments = get_available_departments_for_user(user=user) - return Role.objects.filter(department__in=departments) + return Role.objects.filter(department__in=departments).distinct() def get_departments_query(*, user): @@ -27,24 +27,24 @@ def get_departments_query(*, user): def get_all_users_for_departments_of_user(*, user): - return get_user_model().objects.filter(get_departments_query(user=user)) + return get_user_model().objects.filter(get_departments_query(user=user)).distinct() def get_all_managers_and_admins_for_departments_of_user(*, user): - return get_user_model().managers_and_admins.filter(get_departments_query(user=user)) + return get_user_model().managers_and_admins.filter(get_departments_query(user=user)).distinct() def get_all_new_hires_for_departments_of_user(*, user): - return get_user_model().new_hires.filter(get_departments_query(user=user)) + return get_user_model().new_hires.filter(get_departments_query(user=user)).distinct() def get_all_managers_and_admins_for_departments_of_user_with_slack(*, user): return ( get_user_model() .managers_and_admins.with_slack() - .filter(get_departments_query(user=user)) + .filter(get_departments_query(user=user)).distinct() ) def get_all_offboarding_users_for_departments_of_user(*, user): - return get_user_model().offboarding.filter(get_departments_query(user=user)) + return get_user_model().offboarding.filter(get_departments_query(user=user)).distinct() From 9261ddd5e8888ced30c38eded7ae7c5d68a2b944 Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Thu, 16 Oct 2025 17:20:51 +0200 Subject: [PATCH 03/22] fix adding users --- .../people/templates/_departments_list.html | 46 +++++++++++++++++ back/admin/people/templates/departments.html | 50 ++----------------- back/admin/people/views.py | 2 +- 3 files changed, 50 insertions(+), 48 deletions(-) create mode 100644 back/admin/people/templates/_departments_list.html diff --git a/back/admin/people/templates/_departments_list.html b/back/admin/people/templates/_departments_list.html new file mode 100644 index 00000000..b1aa0af7 --- /dev/null +++ b/back/admin/people/templates/_departments_list.html @@ -0,0 +1,46 @@ +{% load static crispy_forms_tags %} +{% load i18n %} + +{% for department in departments %} +
+
+
+

{{ department }}

+
+ +
+
+ {% for role in department.roles.all %} +
+

{{ role }}

+ {% if role.users.all|length %} +
    + + {% for user in role.users.all %} +
  • + {% if user.profile_image is not None %} + + {% else %} + {{ user.initials }} + {% endif %} + {{ user.name }} +
  • + {% endfor %} +
+ {% else %} + {% trans "No users have been added to this role yet." %} + {% endif %} +
+ {% endfor %} + {% if department.roles.all|length == 0 %} + {% trans "No roles have been added to this department yet." %} +
+ {% trans "Add role" %} + {% endif %} +
+
+{% endfor %} diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index 353c61d9..df5a3c0d 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -15,51 +15,8 @@
-
- {% for department in departments %} -
-
-
-

{{ department }}

-
- -
-
-
- {% for role in department.roles.all %} -
-

{{ role }}

- {% if role.users.all|length %} -
    - {% for user in role.users.all %} -
  • - {% if user.profile_image is not None %} - - {% else %} - {{ user.initials }} - {% endif %} - {{ user.name }} -
  • - {% endfor %} -
- {% else %} - {% trans "No users have been added to this role yet." %} - {% endif %} -
- {% endfor %} - {% if department.roles.all|length == 0 %} - {% trans "No roles have been added to this department yet." %} -
- {% trans "Add role" %} - {% endif %} -
-
-
- {% endfor %} +
+ {% include "_departments_list.html" %}
@@ -140,8 +97,7 @@

{% translate "Users" %}

const roleId = droppedPlace.dataset.roleId htmx.ajax('POST', `/admin/people/colleagues/role/${roleId}/user/${userId}/`, { - select: "#departments-list", - target: "#departments-list", + target: "#departmentslist", }) } }, false) diff --git a/back/admin/people/views.py b/back/admin/people/views.py index 02aa48cc..60308d37 100644 --- a/back/admin/people/views.py +++ b/back/admin/people/views.py @@ -603,7 +603,7 @@ def post(self, request, role_pk, user_pk, **kwargs): user = get_object_or_404(get_all_users_for_departments_of_user(user=request.user), id=user_pk) role.users.add(user) - return render(request, "departments.html", {"departments": get_available_departments_for_user(user=self.request.user)}) + return render(request, "_departments_list.html", {"departments": get_available_departments_for_user(user=self.request.user)}) class DepartmentUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView): From e5e5500bb3b5ffdd45c88ea33401a80ae961d3d4 Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Thu, 16 Oct 2025 17:21:31 +0200 Subject: [PATCH 04/22] format --- back/admin/people/views.py | 32 ++++++++---- .../0045_department_sequences_role.py | 50 ++++++++++++++----- back/users/models.py | 5 +- back/users/selectors.py | 18 +++++-- 4 files changed, 78 insertions(+), 27 deletions(-) diff --git a/back/admin/people/views.py b/back/admin/people/views.py index 60308d37..d4da0a48 100644 --- a/back/admin/people/views.py +++ b/back/admin/people/views.py @@ -571,7 +571,9 @@ def get_context_data(self, **kwargs): return context -class DepartmentRoleCreateView(AdminOrManagerPermMixin, SuccessMessageMixin, CreateView): +class DepartmentRoleCreateView( + AdminOrManagerPermMixin, SuccessMessageMixin, CreateView +): template_name = "role_create.html" model = Role fields = [ @@ -581,7 +583,10 @@ class DepartmentRoleCreateView(AdminOrManagerPermMixin, SuccessMessageMixin, Cre success_url = reverse_lazy("people:departments") def dispatch(self, *args, **kwargs): - self.department = get_object_or_404(get_available_departments_for_user(user=self.request.user), id=self.kwargs.get("pk")) + self.department = get_object_or_404( + get_available_departments_for_user(user=self.request.user), + id=self.kwargs.get("pk"), + ) return super().dispatch(*args, **kwargs) def form_valid(self, form): @@ -596,14 +601,21 @@ def get_context_data(self, **kwargs): class AddUserToRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): - def post(self, request, role_pk, user_pk, **kwargs): print(get_available_roles_for_user(user=request.user)) - role = get_object_or_404(get_available_roles_for_user(user=request.user), id=role_pk) - user = get_object_or_404(get_all_users_for_departments_of_user(user=request.user), id=user_pk) + role = get_object_or_404( + get_available_roles_for_user(user=request.user), id=role_pk + ) + user = get_object_or_404( + get_all_users_for_departments_of_user(user=request.user), id=user_pk + ) role.users.add(user) - return render(request, "_departments_list.html", {"departments": get_available_departments_for_user(user=self.request.user)}) + return render( + request, + "_departments_list.html", + {"departments": get_available_departments_for_user(user=self.request.user)}, + ) class DepartmentUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView): @@ -615,7 +627,7 @@ class DepartmentUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, UpdateV success_url = reverse_lazy("people:departments") def get_queryset(self): - return get_available_departments_for_user(user=self.request.user) + return get_available_departments_for_user(user=self.request.user) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -624,7 +636,9 @@ def get_context_data(self, **kwargs): return context -class DepartmentRoleUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView): +class DepartmentRoleUpdateView( + AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView +): template_name = "role_update.html" fields = [ "name", @@ -633,7 +647,7 @@ class DepartmentRoleUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, Upd success_url = reverse_lazy("people:departments") def get_queryset(self): - return get_available_roles_for_user(user=self.request.user) + return get_available_roles_for_user(user=self.request.user) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/back/users/migrations/0045_department_sequences_role.py b/back/users/migrations/0045_department_sequences_role.py index 3a567733..01ef7078 100644 --- a/back/users/migrations/0045_department_sequences_role.py +++ b/back/users/migrations/0045_department_sequences_role.py @@ -6,29 +6,53 @@ class Migration(migrations.Migration): - dependencies = [ - ('sequences', '0046_alter_sequence_options_sequence_departments'), - ('users', '0044_alter_department_options'), + ("sequences", "0046_alter_sequence_options_sequence_departments"), + ("users", "0044_alter_department_options"), ] operations = [ migrations.AddField( - model_name='department', - name='sequences', - field=models.ManyToManyField(related_name='+', to='sequences.sequence'), + model_name="department", + name="sequences", + field=models.ManyToManyField(related_name="+", to="sequences.sequence"), ), migrations.CreateModel( - name='Role', + name="Role", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('department', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='roles', to='users.department')), - ('sequences', models.ManyToManyField(related_name='roles', to='sequences.sequence')), - ('users', models.ManyToManyField(related_name='users', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "department", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="roles", + to="users.department", + ), + ), + ( + "sequences", + models.ManyToManyField( + related_name="roles", to="sequences.sequence" + ), + ), + ( + "users", + models.ManyToManyField( + related_name="users", to=settings.AUTH_USER_MODEL + ), + ), ], options={ - 'ordering': ('name',), + "ordering": ("name",), }, ), ] diff --git a/back/users/models.py b/back/users/models.py index f28bedc3..d8fc1b02 100644 --- a/back/users/models.py +++ b/back/users/models.py @@ -34,8 +34,11 @@ class Role(models.Model): """ Role within a department. Roles are unique to every department. """ + name = models.CharField(max_length=255) - department = models.ForeignKey("users.Department", related_name="roles", on_delete=models.PROTECT) + department = models.ForeignKey( + "users.Department", related_name="roles", on_delete=models.PROTECT + ) sequences = models.ManyToManyField("sequences.Sequence", related_name="roles") users = models.ManyToManyField("users.User", related_name="users") diff --git a/back/users/selectors.py b/back/users/selectors.py index 5e15045e..9da440dd 100644 --- a/back/users/selectors.py +++ b/back/users/selectors.py @@ -9,6 +9,7 @@ def get_available_departments_for_user(*, user): return Department.objects.all() return user.departments.all() + def get_available_roles_for_user(*, user): from users.models import Role @@ -31,20 +32,29 @@ def get_all_users_for_departments_of_user(*, user): def get_all_managers_and_admins_for_departments_of_user(*, user): - return get_user_model().managers_and_admins.filter(get_departments_query(user=user)).distinct() + return ( + get_user_model() + .managers_and_admins.filter(get_departments_query(user=user)) + .distinct() + ) def get_all_new_hires_for_departments_of_user(*, user): - return get_user_model().new_hires.filter(get_departments_query(user=user)).distinct() + return ( + get_user_model().new_hires.filter(get_departments_query(user=user)).distinct() + ) def get_all_managers_and_admins_for_departments_of_user_with_slack(*, user): return ( get_user_model() .managers_and_admins.with_slack() - .filter(get_departments_query(user=user)).distinct() + .filter(get_departments_query(user=user)) + .distinct() ) def get_all_offboarding_users_for_departments_of_user(*, user): - return get_user_model().offboarding.filter(get_departments_query(user=user)).distinct() + return ( + get_user_model().offboarding.filter(get_departments_query(user=user)).distinct() + ) From 698f28279d42bba4b3b05e14aa324c2c91193f77 Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:24:39 +0200 Subject: [PATCH 05/22] add tests --- back/admin/people/department_views.py | 137 +++++++++++++ .../people/templates/_departments_list.html | 6 + back/admin/people/tests/department_tests.py | 186 ++++++++++++++++++ back/admin/people/{ => tests}/tests.py | 0 back/admin/people/urls.py | 14 +- back/admin/people/views.py | 122 +----------- back/conftest.py | 2 + back/users/factories.py | 9 + 8 files changed, 348 insertions(+), 128 deletions(-) create mode 100644 back/admin/people/department_views.py create mode 100644 back/admin/people/tests/department_tests.py rename back/admin/people/{ => tests}/tests.py (100%) diff --git a/back/admin/people/department_views.py b/back/admin/people/department_views.py new file mode 100644 index 00000000..e4f5addd --- /dev/null +++ b/back/admin/people/department_views.py @@ -0,0 +1,137 @@ +from django.contrib.messages.views import SuccessMessageMixin +from django.shortcuts import get_object_or_404, render +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic.base import View +from django.views.generic.edit import CreateView, UpdateView +from django.views.generic.list import ListView + +from users.mixins import AdminOrManagerPermMixin +from users.models import Department, Role +from users.selectors import ( + get_all_users_for_departments_of_user, + get_available_departments_for_user, + get_available_roles_for_user, +) + + +class DepartmentListView(AdminOrManagerPermMixin, ListView): + template_name = "departments.html" + paginate_by = 20 + context_object_name = "departments" + + def get_queryset(self): + return get_available_departments_for_user(user=self.request.user) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Roles and departments") + context["subtitle"] = _("people") + context["users"] = get_all_users_for_departments_of_user(user=self.request.user) + return context + + +class DepartmentCreateView(AdminOrManagerPermMixin, SuccessMessageMixin, CreateView): + template_name = "department_create.html" + model = Department + fields = [ + "name", + ] + success_message = _("Department has been created") + success_url = reverse_lazy("people:departments") + + def form_valid(self, form): + response = super().form_valid(form) + if self.request.user.is_manager: + self.request.user.departments.add(self.object) + return response + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Roles and departments") + context["subtitle"] = _("people") + return context + + +class DepartmentRoleCreateView( + AdminOrManagerPermMixin, SuccessMessageMixin, CreateView +): + template_name = "role_create.html" + model = Role + fields = [ + "name", + ] + success_message = _("Role has been created") + success_url = reverse_lazy("people:departments") + + def dispatch(self, *args, **kwargs): + self.department = get_object_or_404( + get_available_departments_for_user(user=self.request.user), + id=self.kwargs.get("pk"), + ) + return super().dispatch(*args, **kwargs) + + def form_valid(self, form): + form.instance.department = self.department + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Roles") + context["subtitle"] = _("people") + return context + + +class AddUserToRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): + def post(self, request, role_pk, user_pk, **kwargs): + role = get_object_or_404( + get_available_roles_for_user(user=request.user), id=role_pk + ) + user = get_object_or_404( + get_all_users_for_departments_of_user(user=request.user), id=user_pk + ) + + role.users.add(user) + return render( + request, + "_departments_list.html", + {"departments": get_available_departments_for_user(user=self.request.user)}, + ) + + +class DepartmentUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView): + template_name = "department_update.html" + fields = [ + "name", + ] + success_message = _("Department has been updated") + success_url = reverse_lazy("people:departments") + + def get_queryset(self): + return get_available_departments_for_user(user=self.request.user) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Department") + context["subtitle"] = _("people") + return context + + +class DepartmentRoleUpdateView( + AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView +): + template_name = "role_update.html" + fields = [ + "name", + ] + success_message = _("Role has been updated") + success_url = reverse_lazy("people:departments") + + def get_queryset(self): + return get_available_roles_for_user(user=self.request.user) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Role") + context["subtitle"] = _("people") + return context diff --git a/back/admin/people/templates/_departments_list.html b/back/admin/people/templates/_departments_list.html index b1aa0af7..13e6d25d 100644 --- a/back/admin/people/templates/_departments_list.html +++ b/back/admin/people/templates/_departments_list.html @@ -43,4 +43,10 @@

{{ role }}

{% endif %}
+{% empty %} +
+
+ {% trans "There are no departments yet." %} +
+
{% endfor %} diff --git a/back/admin/people/tests/department_tests.py b/back/admin/people/tests/department_tests.py new file mode 100644 index 00000000..c74bd2dd --- /dev/null +++ b/back/admin/people/tests/department_tests.py @@ -0,0 +1,186 @@ +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse + +from users.factories import ( + DepartmentFactory, +) +from users.models import Department + + +@pytest.mark.django_db +def test_create_new_department(client, django_user_model): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + + # make other department to make sure it's not showing this + dep2 = DepartmentFactory() + + url = reverse("people:departments") + response = client.get(url) + + assert "There are no departments yet." in response.content.decode() + assert dep2.name not in response.content.decode() + + url = reverse("people:department_create") + response = client.post(url, {"name": "newdepartment"}, follow=True) + + department = Department.objects.get(name="newdepartment") + # has been added to user + user.refresh_from_db() + assert department in user.departments.all() + + assert "Department has been created" in response.content.decode() + + # shows up on list view + assert "newdepartment" in response.content.decode() + assert "Add role" in response.content.decode() + assert "There are no departments yet." not in response.content.decode() + + +@pytest.mark.django_db +def test_update_department(client, django_user_model, department_factory): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + + dep = department_factory() + user.departments.add(dep) + + url = reverse("people:department_update", args=[dep.id]) + response = client.get(url) + + assert "No roles added yet" in response.content.decode() + assert dep.name in response.content.decode() + + response = client.post(url, {"name": "newdepartment"}, follow=True) + + user.refresh_from_db() + department = Department.objects.get(name="newdepartment") + # has been added to user + assert department in user.departments.all() + + assert "Department has been updated" in response.content.decode() + + # shows up on list view + assert "newdepartment" in response.content.decode() + assert "Add role" in response.content.decode() + assert "There are no departments yet." not in response.content.decode() + + # 404 when trying to update a department they are not part of + dep = department_factory() + url = reverse("people:department_update", args=[dep.id]) + response = client.get(url) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_create_new_role_in_department( + client, django_user_model, department_factory, role_factory +): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + + dep = department_factory() + user.departments.add(dep) + + # make other department to make sure it's not showing this + dep2 = DepartmentFactory() + role1 = role_factory(department=dep2) + + url = reverse("people:department_role_create", args=[dep.id]) + response = client.get(url) + + assert "New role" in response.content.decode() + + response = client.post(url, {"name": "newrole"}, follow=True) + + assert "Role has been created" in response.content.decode() + + assert "No users have been added to this role yet." in response.content.decode() + assert dep2.name not in response.content.decode() + assert role1.name not in response.content.decode() + + # role shows up when updating department + url = reverse("people:department_update", args=[dep.id]) + response = client.get(url) + + assert "newrole" in response.content.decode() + # other role is not showing + assert role1.name not in response.content.decode() + + # 404 when trying to create a role for an org they are not part of + url = reverse("people:department_role_create", args=[dep2.id]) + response = client.get(url) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_update_role_in_department( + client, django_user_model, department_factory, role_factory +): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + + dep = department_factory() + role = role_factory(department=dep, name="testrole") + user.departments.add(dep) + + url = reverse("people:department_role_update", args=[dep.id, role.id]) + response = client.get(url) + + assert "testrole" in response.content.decode() + + response = client.post(url, {"name": "testrole12"}, follow=True) + + assert "Role has been updated" in response.content.decode() + + role.refresh_from_db() + assert role.name == "testrole12" + + # other role (not owned) gets 404 + dep = department_factory() + role = role_factory(department=dep, name="testrole") + + url = reverse("people:department_role_update", args=[dep.id, role.id]) + response = client.get(url) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_add_user_to_role_in_department( + client, + django_user_model, + department_factory, + role_factory, + new_hire_factory, + manager_factory, +): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + + dep = department_factory() + role = role_factory(department=dep, name="testrole") + user.departments.add(dep) + + user1 = new_hire_factory(departments=[dep]) + user2 = new_hire_factory() + user3 = manager_factory(departments=[dep]) + + url = reverse("people:departments") + response = client.get(url) + + # user1 and user3 are part of their own org, so they will show up + assert user1.name in response.content.decode() + assert user3.name in response.content.decode() + # user 2 is not part, so doesn't show up + assert user2.name in response.content.decode() + + # user is not part of role + assert user not in role.users.all() + + url = reverse("people:add_user_to_role", args=[role.id, user.id]) + response = client.post(url) + + # user is part of role + role.refresh_from_db() + assert user in role.users.all() diff --git a/back/admin/people/tests.py b/back/admin/people/tests/tests.py similarity index 100% rename from back/admin/people/tests.py rename to back/admin/people/tests/tests.py diff --git a/back/admin/people/urls.py b/back/admin/people/urls.py index c9bd34ef..5ccb66fd 100644 --- a/back/admin/people/urls.py +++ b/back/admin/people/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from . import access_views, new_hire_views, views +from . import access_views, department_views, new_hire_views, views app_name = "people" urlpatterns = [ @@ -230,32 +230,32 @@ ), path( "colleagues/departments/", - views.DepartmentListView.as_view(), + department_views.DepartmentListView.as_view(), name="departments", ), path( "colleagues/departments/create/", - views.DepartmentCreateView.as_view(), + department_views.DepartmentCreateView.as_view(), name="department_create", ), path( "colleagues/departments//update/", - views.DepartmentUpdateView.as_view(), + department_views.DepartmentUpdateView.as_view(), name="department_update", ), path( "colleagues/departments//roles/add/", - views.DepartmentRoleCreateView.as_view(), + department_views.DepartmentRoleCreateView.as_view(), name="department_role_create", ), path( "colleagues/role//user//", - views.AddUserToRoleView.as_view(), + department_views.AddUserToRoleView.as_view(), name="add_user_to_role", ), path( "colleagues/departments//roles//update/", - views.DepartmentRoleUpdateView.as_view(), + department_views.DepartmentRoleUpdateView.as_view(), name="department_role_update", ), ] diff --git a/back/admin/people/views.py b/back/admin/people/views.py index d4da0a48..11b61c85 100644 --- a/back/admin/people/views.py +++ b/back/admin/people/views.py @@ -41,12 +41,9 @@ AdminOrManagerPermMixin, AdminPermMixin, ) -from users.models import Department, Role, ToDoUser +from users.models import ToDoUser from users.selectors import ( get_all_offboarding_users_for_departments_of_user, - get_all_users_for_departments_of_user, - get_available_departments_for_user, - get_available_roles_for_user, ) from .forms import ( @@ -537,120 +534,3 @@ def create(self, request, *args, **kwargs): "Admins and managers will receive an email shortly." ) return HttpResponse(f"
{success_message}
") - - -class DepartmentListView(AdminOrManagerPermMixin, ListView): - template_name = "departments.html" - paginate_by = 20 - context_object_name = "departments" - - def get_queryset(self): - return get_available_departments_for_user(user=self.request.user) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["title"] = _("Roles and departments") - context["subtitle"] = _("people") - context["users"] = get_all_users_for_departments_of_user(user=self.request.user) - return context - - -class DepartmentCreateView(AdminOrManagerPermMixin, SuccessMessageMixin, CreateView): - template_name = "department_create.html" - model = Department - fields = [ - "name", - ] - success_message = _("Department has been created") - success_url = reverse_lazy("people:departments") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["title"] = _("Roles and departments") - context["subtitle"] = _("people") - return context - - -class DepartmentRoleCreateView( - AdminOrManagerPermMixin, SuccessMessageMixin, CreateView -): - template_name = "role_create.html" - model = Role - fields = [ - "name", - ] - success_message = _("Role has been created") - success_url = reverse_lazy("people:departments") - - def dispatch(self, *args, **kwargs): - self.department = get_object_or_404( - get_available_departments_for_user(user=self.request.user), - id=self.kwargs.get("pk"), - ) - return super().dispatch(*args, **kwargs) - - def form_valid(self, form): - form.instance.department = self.department - return super().form_valid(form) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["title"] = _("Roles") - context["subtitle"] = _("people") - return context - - -class AddUserToRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): - def post(self, request, role_pk, user_pk, **kwargs): - print(get_available_roles_for_user(user=request.user)) - role = get_object_or_404( - get_available_roles_for_user(user=request.user), id=role_pk - ) - user = get_object_or_404( - get_all_users_for_departments_of_user(user=request.user), id=user_pk - ) - - role.users.add(user) - return render( - request, - "_departments_list.html", - {"departments": get_available_departments_for_user(user=self.request.user)}, - ) - - -class DepartmentUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView): - template_name = "department_update.html" - fields = [ - "name", - ] - success_message = _("Department has been updated") - success_url = reverse_lazy("people:departments") - - def get_queryset(self): - return get_available_departments_for_user(user=self.request.user) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["title"] = _("Department") - context["subtitle"] = _("people") - return context - - -class DepartmentRoleUpdateView( - AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView -): - template_name = "role_update.html" - fields = [ - "name", - ] - success_message = _("Role has been updated") - success_url = reverse_lazy("people:departments") - - def get_queryset(self): - return get_available_roles_for_user(user=self.request.user) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["title"] = _("Role") - context["subtitle"] = _("people") - return context diff --git a/back/conftest.py b/back/conftest.py index 40f22c60..ae695ad8 100644 --- a/back/conftest.py +++ b/back/conftest.py @@ -52,6 +52,7 @@ NewHireWelcomeMessageFactory, PreboardingUserFactory, ResourceUserFactory, + RoleFactory, ToDoUserFactory, ) @@ -77,6 +78,7 @@ def run_around_tests(request, settings): register(DepartmentFactory) +register(RoleFactory) register(NewHireFactory) register(AdminFactory) register(ManagerFactory) diff --git a/back/users/factories.py b/back/users/factories.py index 02f7a6d4..a002ffe9 100644 --- a/back/users/factories.py +++ b/back/users/factories.py @@ -15,6 +15,7 @@ NewHireWelcomeMessage, PreboardingUser, ResourceUser, + Role, ToDoUser, User, ) @@ -44,6 +45,14 @@ class Meta: model = Department +class RoleFactory(factory.django.DjangoModelFactory): + name = FuzzyText() + department = factory.SubFactory(DepartmentFactory) + + class Meta: + model = Role + + class NewHireFactory(BaseUserFactory): role = get_user_model().Role.NEWHIRE start_day = factory.LazyFunction(lambda: datetime.datetime.now().date()) From 67408c8274c80f64ac71b448e5794a2d79471310 Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:36:18 +0200 Subject: [PATCH 06/22] address copilot issues --- back/admin/people/department_views.py | 4 +++- back/admin/people/templates/departments.html | 6 +++--- back/admin/people/tests/department_tests.py | 12 ++++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/back/admin/people/department_views.py b/back/admin/people/department_views.py index e4f5addd..c4a8d6d7 100644 --- a/back/admin/people/department_views.py +++ b/back/admin/people/department_views.py @@ -21,7 +21,9 @@ class DepartmentListView(AdminOrManagerPermMixin, ListView): context_object_name = "departments" def get_queryset(self): - return get_available_departments_for_user(user=self.request.user) + return get_available_departments_for_user( + user=self.request.user + ).prefetch_related("roles__users") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index df5a3c0d..0a35a62b 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -39,6 +39,7 @@

{% translate "Users" %}

+

{% trans "Drag and drop the users in the roles you want them to be part of." %}

{% endblock %} @@ -51,8 +52,8 @@

{% translate "Users" %}

padding-left: 0px; transition: transform 0.2s ease, - border-left-color 0.1s ease; - padding-left 0.3s ease; + border-left-color 0.1s ease, + padding-left 0.3s ease } .drop.droppable { transform: translateX(5px); @@ -108,7 +109,6 @@

{% translate "Users" %}

event.stopPropagation(); const droppedPlace = event.target.closest(".drop") if (Boolean(droppedPlace)) { - const type = dragged.dataset.type; droppedPlace.style.backgroundColor='rgb(239, 242, 246)'; } }, false); diff --git a/back/admin/people/tests/department_tests.py b/back/admin/people/tests/department_tests.py index c74bd2dd..975ce64d 100644 --- a/back/admin/people/tests/department_tests.py +++ b/back/admin/people/tests/department_tests.py @@ -159,21 +159,25 @@ def test_add_user_to_role_in_department( client.force_login(user) dep = department_factory() + dep2 = department_factory() role = role_factory(department=dep, name="testrole") user.departments.add(dep) user1 = new_hire_factory(departments=[dep]) - user2 = new_hire_factory() + user2 = new_hire_factory(departments=[dep2]) user3 = manager_factory(departments=[dep]) + # not part of any departments, so available everywhere + user4 = manager_factory() url = reverse("people:departments") response = client.get(url) - # user1 and user3 are part of their own org, so they will show up + # user1 and user3 are part of their own dep, so they will show up. user4 is not part of an dep, so shows up as well assert user1.name in response.content.decode() assert user3.name in response.content.decode() - # user 2 is not part, so doesn't show up - assert user2.name in response.content.decode() + assert user4.name in response.content.decode() + # user 2 is part of different dep, so doesn't show up + assert user2.name not in response.content.decode() # user is not part of role assert user not in role.users.all() From 6a6ed9d05e194eb03c27ec43d6714c3b9b38a831 Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:43:59 +0200 Subject: [PATCH 07/22] copilot updates --- back/admin/people/department_views.py | 2 +- back/admin/people/templates/_departments_list.html | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/back/admin/people/department_views.py b/back/admin/people/department_views.py index c4a8d6d7..3d2b86a8 100644 --- a/back/admin/people/department_views.py +++ b/back/admin/people/department_views.py @@ -97,7 +97,7 @@ def post(self, request, role_pk, user_pk, **kwargs): return render( request, "_departments_list.html", - {"departments": get_available_departments_for_user(user=self.request.user)}, + {"departments": get_available_departments_for_user(user=self.request.user).prefetch_related("roles__users")}, ) diff --git a/back/admin/people/templates/_departments_list.html b/back/admin/people/templates/_departments_list.html index 13e6d25d..4da9f344 100644 --- a/back/admin/people/templates/_departments_list.html +++ b/back/admin/people/templates/_departments_list.html @@ -1,4 +1,3 @@ -{% load static crispy_forms_tags %} {% load i18n %} {% for department in departments %} @@ -19,7 +18,6 @@

{{ department }}

{{ role }}

{% if role.users.all|length %}
    - {% for user in role.users.all %}
  • {% if user.profile_image is not None %} From 9b3c7099042909f975f41cd1ec9949b905d13e8b Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Sat, 18 Oct 2025 02:37:01 +0200 Subject: [PATCH 08/22] remove pagination --- back/admin/people/department_views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/back/admin/people/department_views.py b/back/admin/people/department_views.py index 3d2b86a8..50159c7e 100644 --- a/back/admin/people/department_views.py +++ b/back/admin/people/department_views.py @@ -17,7 +17,6 @@ class DepartmentListView(AdminOrManagerPermMixin, ListView): template_name = "departments.html" - paginate_by = 20 context_object_name = "departments" def get_queryset(self): From 92fd99837f426ac2aa43e46336b7649fbb55f69a Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Sat, 18 Oct 2025 03:35:11 +0200 Subject: [PATCH 09/22] remove unused classes/ids --- back/admin/people/templates/departments.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index 0a35a62b..29135a34 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -19,7 +19,7 @@ {% include "_departments_list.html" %}
-
+

{% translate "Users" %}

From 23cf7ab8e9ffb7083acd095d88a06e3a20855916 Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Tue, 21 Oct 2025 03:12:28 +0200 Subject: [PATCH 10/22] updates based on comments --- .../people/templates/_departments_list.html | 9 +- .../people/templates/department_create.html | 2 +- .../people/templates/department_update.html | 4 +- back/admin/people/templates/departments.html | 32 ++---- back/admin/people/templates/role_create.html | 23 ---- back/admin/people/templates/role_form.html | 35 ++++++ back/admin/people/templates/role_update.html | 23 ---- back/admin/people/tests/department_tests.py | 107 ++++++++++++------ back/admin/people/urls.py | 76 ++++++------- back/admin/people/views/__init__.py | 4 + .../{access_views.py => views/access.py} | 0 .../people/{views.py => views/colleagues.py} | 17 ++- .../departments.py} | 14 ++- .../{new_hire_views.py => views/new_hires.py} | 15 ++- back/conftest.py | 4 +- back/users/factories.py | 6 +- ...tment_sequences_departmentrole_and_more.py | 63 +++++++++++ .../migrations/0047_alter_department_name.py | 17 +++ back/users/models.py | 14 ++- back/users/selectors.py | 4 +- back/users/templates/admin_base.html | 2 +- 21 files changed, 288 insertions(+), 183 deletions(-) delete mode 100644 back/admin/people/templates/role_create.html create mode 100644 back/admin/people/templates/role_form.html delete mode 100644 back/admin/people/templates/role_update.html create mode 100644 back/admin/people/views/__init__.py rename back/admin/people/{access_views.py => views/access.py} (100%) rename back/admin/people/{views.py => views/colleagues.py} (99%) rename back/admin/people/{department_views.py => views/departments.py} (92%) rename back/admin/people/{new_hire_views.py => views/new_hires.py} (99%) create mode 100644 back/users/migrations/0046_alter_department_sequences_departmentrole_and_more.py create mode 100644 back/users/migrations/0047_alter_department_name.py diff --git a/back/admin/people/templates/_departments_list.html b/back/admin/people/templates/_departments_list.html index 4da9f344..0f8f0f89 100644 --- a/back/admin/people/templates/_departments_list.html +++ b/back/admin/people/templates/_departments_list.html @@ -19,7 +19,7 @@

{{ role }}

{% if role.users.all|length %}
    {% for user in role.users.all %} -
  • +
  • {% if user.profile_image is not None %} {% else %} @@ -33,12 +33,11 @@

    {{ role }}

    {% trans "No users have been added to this role yet." %} {% endif %}
- {% endfor %} - {% if department.roles.all|length == 0 %} + {% empty %} {% trans "No roles have been added to this department yet." %}
- {% trans "Add role" %} - {% endif %} + {% trans "Add role" %} + {% endfor %}
{% empty %} diff --git a/back/admin/people/templates/department_create.html b/back/admin/people/templates/department_create.html index 9fc2113f..b1f445a5 100644 --- a/back/admin/people/templates/department_create.html +++ b/back/admin/people/templates/department_create.html @@ -10,7 +10,7 @@

{% translate "New department" %}

-
+ {% csrf_token %} {{ form|crispy }} diff --git a/back/admin/people/templates/department_update.html b/back/admin/people/templates/department_update.html index ec4274d3..8091787d 100644 --- a/back/admin/people/templates/department_update.html +++ b/back/admin/people/templates/department_update.html @@ -10,7 +10,7 @@

{{ object.name }}

- + {% csrf_token %} {{ form|crispy }} @@ -26,7 +26,7 @@

{% trans "Roles" %}

{% for role in department.roles.all %}
-

{{ role }}

+

{{ role }}

diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index 29135a34..edb3d5de 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -1,5 +1,4 @@ {% extends 'admin_base.html' %} -{% load static crispy_forms_tags %} {% load i18n %} {% block actions %} @@ -30,7 +29,7 @@

{% translate "Users" %}

-

{{ user.full_name }}

+

{{ user.full_name }}

@@ -65,17 +64,13 @@

{% translate "Users" %}

{% block extra_js %} {% endblock extra_js %} diff --git a/back/admin/people/templates/role_create.html b/back/admin/people/templates/role_create.html deleted file mode 100644 index ceadef8f..00000000 --- a/back/admin/people/templates/role_create.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'admin_base.html' %} -{% load i18n %} -{% load crispy_forms_tags %} - -{% block content %} -
-
-
-
-

{% translate "New role" %}

-
-
- - {% csrf_token %} - {{ form|crispy }} - - -
-
-
-
-{% endblock %} - diff --git a/back/admin/people/templates/role_form.html b/back/admin/people/templates/role_form.html new file mode 100644 index 00000000..9dc1208c --- /dev/null +++ b/back/admin/people/templates/role_form.html @@ -0,0 +1,35 @@ +{% extends 'admin_base.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block content %} +
+
+
+
+

+ {% if form.instance.pk %} + {{ object.name }} + {% else %} + {% translate "New role" %} + {% endif %} +

+
+
+
+ {% csrf_token %} + {{ form|crispy }} + +
+
+
+
+
+{% endblock %} + diff --git a/back/admin/people/templates/role_update.html b/back/admin/people/templates/role_update.html deleted file mode 100644 index 4a21de4c..00000000 --- a/back/admin/people/templates/role_update.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'admin_base.html' %} -{% load i18n %} -{% load crispy_forms_tags %} - -{% block content %} -
-
-
-
-

{{ object.name }}

-
-
-
- {% csrf_token %} - {{ form|crispy }} - -
-
-
-
-
-{% endblock %} - diff --git a/back/admin/people/tests/department_tests.py b/back/admin/people/tests/department_tests.py index 975ce64d..497a5d56 100644 --- a/back/admin/people/tests/department_tests.py +++ b/back/admin/people/tests/department_tests.py @@ -2,19 +2,52 @@ from django.contrib.auth import get_user_model from django.urls import reverse -from users.factories import ( - DepartmentFactory, -) from users.models import Department @pytest.mark.django_db -def test_create_new_department(client, django_user_model): +def test_department_list( + client, + django_user_model, + department_factory, + new_hire_factory, + manager_factory, + department_role_factory, +): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + + dep = department_factory() + dep2 = department_factory() + user.departments.add(dep) + + user1 = new_hire_factory(departments=[dep]) + user2 = new_hire_factory(departments=[dep2]) + user3 = manager_factory(departments=[dep]) + # not part of any departments, so available everywhere + user4 = manager_factory() + + url = reverse("people:departments") + response = client.get(url) + + # user1 and user3 are part of their own dep, so they will show up. user4 is not part of an dep, so shows up as well + assert user1.name in response.content.decode() + assert user3.name in response.content.decode() + assert user4.name in response.content.decode() + # user 2 is part of different dep, so doesn't show up + assert user2.name not in response.content.decode() + + # dep does not show up + assert dep2.name not in response.content.decode() + + +@pytest.mark.django_db +def test_create_new_department(client, django_user_model, department_factory): user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) client.force_login(user) # make other department to make sure it's not showing this - dep2 = DepartmentFactory() + dep2 = department_factory() url = reverse("people:departments") response = client.get(url) @@ -66,6 +99,14 @@ def test_update_department(client, django_user_model, department_factory): assert "Add role" in response.content.decode() assert "There are no departments yet." not in response.content.decode() + +@pytest.mark.django_db +def test_update_department_manager_is_not_part_of( + client, django_user_model, department_factory +): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + # 404 when trying to update a department they are not part of dep = department_factory() url = reverse("people:department_update", args=[dep.id]) @@ -75,7 +116,7 @@ def test_update_department(client, django_user_model, department_factory): @pytest.mark.django_db def test_create_new_role_in_department( - client, django_user_model, department_factory, role_factory + client, django_user_model, department_factory, department_role_factory ): user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) client.force_login(user) @@ -84,8 +125,8 @@ def test_create_new_role_in_department( user.departments.add(dep) # make other department to make sure it's not showing this - dep2 = DepartmentFactory() - role1 = role_factory(department=dep2) + dep2 = department_factory() + role1 = department_role_factory(department=dep2) url = reverse("people:department_role_create", args=[dep.id]) response = client.get(url) @@ -108,6 +149,17 @@ def test_create_new_role_in_department( # other role is not showing assert role1.name not in response.content.decode() + +@pytest.mark.django_db +def test_create_new_role_in_department_manager_is_not_part_of( + client, django_user_model, department_factory +): + # make other department to make sure it's not showing this + dep2 = department_factory() + + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + # 404 when trying to create a role for an org they are not part of url = reverse("people:department_role_create", args=[dep2.id]) response = client.get(url) @@ -116,13 +168,13 @@ def test_create_new_role_in_department( @pytest.mark.django_db def test_update_role_in_department( - client, django_user_model, department_factory, role_factory + client, django_user_model, department_factory, department_role_factory ): user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) client.force_login(user) dep = department_factory() - role = role_factory(department=dep, name="testrole") + role = department_role_factory(department=dep, name="testrole") user.departments.add(dep) url = reverse("people:department_role_update", args=[dep.id, role.id]) @@ -137,9 +189,17 @@ def test_update_role_in_department( role.refresh_from_db() assert role.name == "testrole12" + +@pytest.mark.django_db +def test_update_role_in_other_department( + client, django_user_model, department_factory, department_role_factory +): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + # other role (not owned) gets 404 dep = department_factory() - role = role_factory(department=dep, name="testrole") + role = department_role_factory(department=dep, name="testrole") url = reverse("people:department_role_update", args=[dep.id, role.id]) response = client.get(url) @@ -151,39 +211,20 @@ def test_add_user_to_role_in_department( client, django_user_model, department_factory, - role_factory, - new_hire_factory, - manager_factory, + department_role_factory, ): user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) client.force_login(user) dep = department_factory() - dep2 = department_factory() - role = role_factory(department=dep, name="testrole") + role = department_role_factory(department=dep, name="testrole") user.departments.add(dep) - user1 = new_hire_factory(departments=[dep]) - user2 = new_hire_factory(departments=[dep2]) - user3 = manager_factory(departments=[dep]) - # not part of any departments, so available everywhere - user4 = manager_factory() - - url = reverse("people:departments") - response = client.get(url) - - # user1 and user3 are part of their own dep, so they will show up. user4 is not part of an dep, so shows up as well - assert user1.name in response.content.decode() - assert user3.name in response.content.decode() - assert user4.name in response.content.decode() - # user 2 is part of different dep, so doesn't show up - assert user2.name not in response.content.decode() - # user is not part of role assert user not in role.users.all() url = reverse("people:add_user_to_role", args=[role.id, user.id]) - response = client.post(url) + client.post(url) # user is part of role role.refresh_from_db() diff --git a/back/admin/people/urls.py b/back/admin/people/urls.py index 5ccb66fd..a45da24b 100644 --- a/back/admin/people/urls.py +++ b/back/admin/people/urls.py @@ -1,139 +1,139 @@ from django.urls import path -from . import access_views, department_views, new_hire_views, views +from . import views app_name = "people" urlpatterns = [ - path("", new_hire_views.NewHireListView.as_view(), name="new_hires"), - path("new_hire/add/", new_hire_views.NewHireAddView.as_view(), name="new_hire_add"), + path("", views.NewHireListView.as_view(), name="new_hires"), + path("new_hire/add/", views.NewHireAddView.as_view(), name="new_hire_add"), path( "new_hire//overview/", - new_hire_views.NewHireSequenceView.as_view(), + views.NewHireSequenceView.as_view(), name="new_hire", ), path( "new_hire//profile/", - new_hire_views.NewHireProfileView.as_view(), + views.NewHireProfileView.as_view(), name="new_hire_profile", ), path( "new_hire//notes/", - new_hire_views.NewHireNotesView.as_view(), + views.NewHireNotesView.as_view(), name="new_hire_notes", ), path( "new_hire//welcome_messages/", - new_hire_views.NewHireWelcomeMessagesView.as_view(), + views.NewHireWelcomeMessagesView.as_view(), name="new_hire_welcome_messages", ), path( "new_hire//admin_tasks/", - new_hire_views.NewHireAdminTasksView.as_view(), + views.NewHireAdminTasksView.as_view(), name="new_hire_admin_tasks", ), path( "new_hire//admin_tasks//complete", - new_hire_views.CompleteAdminTaskView.as_view(), + views.CompleteAdminTaskView.as_view(), name="new_hire_admin_task_complete", ), path( "new_hire//forms/", - new_hire_views.NewHireFormsView.as_view(), + views.NewHireFormsView.as_view(), name="new_hire_forms", ), path( "new_hire//progress/", - new_hire_views.NewHireProgressView.as_view(), + views.NewHireProgressView.as_view(), name="new_hire_progress", ), path( "new_hire//remind///", - new_hire_views.NewHireRemindView.as_view(), + views.NewHireRemindView.as_view(), name="new_hire_remind", ), path( "new_hire//reopen///", - new_hire_views.NewHireReopenTaskView.as_view(), + views.NewHireReopenTaskView.as_view(), name="new_hire_reopen", ), path( "new_hire//course_answers//", - new_hire_views.NewHireCourseAnswersView.as_view(), + views.NewHireCourseAnswersView.as_view(), name="new-hire-course-answers", ), path( "new_hire//tasks/", - new_hire_views.NewHireTasksView.as_view(), + views.NewHireTasksView.as_view(), name="new_hire_tasks", ), path( "new_hire//access/", - access_views.NewHireAccessView.as_view(), + views.NewHireAccessView.as_view(), name="new_hire_access", ), path( "user//check_access//", - access_views.UserCheckAccessView.as_view(), + views.UserCheckAccessView.as_view(), name="user_check_integration", ), path( "user//check_access//compact/", - access_views.UserCheckAccessView.as_view(), + views.UserCheckAccessView.as_view(), name="user_check_integration_compact", ), path( "user//give_access//", - access_views.UserGiveAccessView.as_view(), + views.UserGiveAccessView.as_view(), name="user_give_integration", ), path( "user//toggle_access//", - access_views.UserToggleAccessView.as_view(), + views.UserToggleAccessView.as_view(), name="toggle_access", ), path( "new_hire//task//", - new_hire_views.NewHireTaskListView.as_view(), + views.NewHireTaskListView.as_view(), name="new_hire_task_list", ), path( "new_hire//task///", - new_hire_views.NewHireToggleTaskView.as_view(), + views.NewHireToggleTaskView.as_view(), name="toggle_new_hire_task", ), path( "new_hire//send_login_email/", - new_hire_views.NewHireSendLoginEmailView.as_view(), + views.NewHireSendLoginEmailView.as_view(), name="send_login_email", ), path( "new_hire//extra_info/", - new_hire_views.NewHireExtraInfoUpdateView.as_view(), + views.NewHireExtraInfoUpdateView.as_view(), name="new_hire_extra_info", ), path( "new_hire//migrate_to_normal/", - new_hire_views.NewHireMigrateToNormalAccountView.as_view(), + views.NewHireMigrateToNormalAccountView.as_view(), name="migrate-to-normal", ), path( "new_hire//add_sequence/", - new_hire_views.NewHireAddSequenceView.as_view(), + views.NewHireAddSequenceView.as_view(), name="add_sequence", ), path( "new_hire//trigger_condition//", - new_hire_views.NewHireTriggerConditionView.as_view(), + views.NewHireTriggerConditionView.as_view(), name="trigger-condition", ), path( "new_hire//remove_sequence//", - new_hire_views.NewHireRemoveSequenceView.as_view(), + views.NewHireRemoveSequenceView.as_view(), name="remove_sequence", ), path( "new_hire//send_preboarding_notification/", - new_hire_views.NewHireSendPreboardingNotificationView.as_view(), + views.NewHireSendPreboardingNotificationView.as_view(), name="send_preboarding_notification", ), path("colleagues/", views.ColleagueListView.as_view(), name="colleagues"), @@ -149,7 +149,7 @@ ), path( "colleagues//access/", - access_views.ColleagueAccessView.as_view(), + views.ColleagueAccessView.as_view(), name="colleague_access", ), path( @@ -210,12 +210,12 @@ ), path( "colleagues//delete/", - access_views.UserDeleteView.as_view(), + views.UserDeleteView.as_view(), name="delete", ), path( "colleagues//revoke/", - access_views.UserRevokeAllAccessView.as_view(), + views.UserRevokeAllAccessView.as_view(), name="revoke_all_access", ), path( @@ -230,32 +230,32 @@ ), path( "colleagues/departments/", - department_views.DepartmentListView.as_view(), + views.DepartmentListView.as_view(), name="departments", ), path( "colleagues/departments/create/", - department_views.DepartmentCreateView.as_view(), + views.DepartmentCreateView.as_view(), name="department_create", ), path( "colleagues/departments//update/", - department_views.DepartmentUpdateView.as_view(), + views.DepartmentUpdateView.as_view(), name="department_update", ), path( "colleagues/departments//roles/add/", - department_views.DepartmentRoleCreateView.as_view(), + views.DepartmentRoleCreateView.as_view(), name="department_role_create", ), path( "colleagues/role//user//", - department_views.AddUserToRoleView.as_view(), + views.AddUserToRoleView.as_view(), name="add_user_to_role", ), path( "colleagues/departments//roles//update/", - department_views.DepartmentRoleUpdateView.as_view(), + views.DepartmentRoleUpdateView.as_view(), name="department_role_update", ), ] diff --git a/back/admin/people/views/__init__.py b/back/admin/people/views/__init__.py new file mode 100644 index 00000000..78c2a418 --- /dev/null +++ b/back/admin/people/views/__init__.py @@ -0,0 +1,4 @@ +from .access import * # noqa +from .colleagues import * # noqa +from .departments import * # noqa +from .new_hires import * # noqa diff --git a/back/admin/people/access_views.py b/back/admin/people/views/access.py similarity index 100% rename from back/admin/people/access_views.py rename to back/admin/people/views/access.py diff --git a/back/admin/people/views.py b/back/admin/people/views/colleagues.py similarity index 99% rename from back/admin/people/views.py rename to back/admin/people/views/colleagues.py index 11b61c85..7d15663a 100644 --- a/back/admin/people/views.py +++ b/back/admin/people/views/colleagues.py @@ -24,6 +24,13 @@ ) from admin.integrations.models import Integration from admin.integrations.sync_userinfo import SyncUsers +from admin.people.forms import ( + ColleagueCreateForm, + ColleagueUpdateForm, + EmailIgnoreForm, + OffboardingSequenceChoiceForm, + UserRoleForm, +) from admin.people.selectors import ( get_colleagues_for_user, get_offboarding_colleagues_for_user, @@ -46,16 +53,6 @@ get_all_offboarding_users_for_departments_of_user, ) -from .forms import ( - ColleagueCreateForm, - ColleagueUpdateForm, - EmailIgnoreForm, - OffboardingSequenceChoiceForm, - UserRoleForm, -) - -# See new_hire_views.py for new hire functions! - class ColleagueListView(AdminOrManagerPermMixin, ListView): template_name = "colleagues.html" diff --git a/back/admin/people/department_views.py b/back/admin/people/views/departments.py similarity index 92% rename from back/admin/people/department_views.py rename to back/admin/people/views/departments.py index 50159c7e..bc98d78c 100644 --- a/back/admin/people/department_views.py +++ b/back/admin/people/views/departments.py @@ -7,7 +7,7 @@ from django.views.generic.list import ListView from users.mixins import AdminOrManagerPermMixin -from users.models import Department, Role +from users.models import Department, DepartmentRole from users.selectors import ( get_all_users_for_departments_of_user, get_available_departments_for_user, @@ -57,8 +57,8 @@ def get_context_data(self, **kwargs): class DepartmentRoleCreateView( AdminOrManagerPermMixin, SuccessMessageMixin, CreateView ): - template_name = "role_create.html" - model = Role + template_name = "role_form.html" + model = DepartmentRole fields = [ "name", ] @@ -96,7 +96,11 @@ def post(self, request, role_pk, user_pk, **kwargs): return render( request, "_departments_list.html", - {"departments": get_available_departments_for_user(user=self.request.user).prefetch_related("roles__users")}, + { + "departments": get_available_departments_for_user( + user=self.request.user + ).prefetch_related("roles__users") + }, ) @@ -121,7 +125,7 @@ def get_context_data(self, **kwargs): class DepartmentRoleUpdateView( AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView ): - template_name = "role_update.html" + template_name = "role_form.html" fields = [ "name", ] diff --git a/back/admin/people/new_hire_views.py b/back/admin/people/views/new_hires.py similarity index 99% rename from back/admin/people/new_hire_views.py rename to back/admin/people/views/new_hires.py index cf7384f7..3e436866 100644 --- a/back/admin/people/new_hire_views.py +++ b/back/admin/people/views/new_hires.py @@ -19,6 +19,13 @@ from admin.admin_tasks.selectors import get_admin_tasks_for_user from admin.integrations.forms import IntegrationExtraUserInfoForm from admin.notes.models import Note +from admin.people.forms import ( + NewHireAddForm, + NewHireProfileForm, + OnboardingSequenceChoiceForm, + PreboardingSendForm, + RemindMessageForm, +) from admin.people.selectors import get_colleagues_for_user, get_new_hires_for_user from admin.sequences.models import Condition, Sequence from admin.sequences.selectors import get_sequences_for_user @@ -38,14 +45,6 @@ from users.mixins import AdminOrManagerPermMixin from users.models import NewHireWelcomeMessage, PreboardingUser, ResourceUser, ToDoUser -from .forms import ( - NewHireAddForm, - NewHireProfileForm, - OnboardingSequenceChoiceForm, - PreboardingSendForm, - RemindMessageForm, -) - class NewHireListView(AdminOrManagerPermMixin, ListView): template_name = "new_hires.html" diff --git a/back/conftest.py b/back/conftest.py index ae695ad8..6445614c 100644 --- a/back/conftest.py +++ b/back/conftest.py @@ -45,6 +45,7 @@ from users.factories import ( AdminFactory, DepartmentFactory, + DepartmentRoleFactory, EmployeeFactory, IntegrationUserFactory, ManagerFactory, @@ -52,7 +53,6 @@ NewHireWelcomeMessageFactory, PreboardingUserFactory, ResourceUserFactory, - RoleFactory, ToDoUserFactory, ) @@ -78,7 +78,7 @@ def run_around_tests(request, settings): register(DepartmentFactory) -register(RoleFactory) +register(DepartmentRoleFactory) register(NewHireFactory) register(AdminFactory) register(ManagerFactory) diff --git a/back/users/factories.py b/back/users/factories.py index a002ffe9..2e491cf9 100644 --- a/back/users/factories.py +++ b/back/users/factories.py @@ -11,11 +11,11 @@ from .models import ( Department, + DepartmentRole, IntegrationUser, NewHireWelcomeMessage, PreboardingUser, ResourceUser, - Role, ToDoUser, User, ) @@ -45,12 +45,12 @@ class Meta: model = Department -class RoleFactory(factory.django.DjangoModelFactory): +class DepartmentRoleFactory(factory.django.DjangoModelFactory): name = FuzzyText() department = factory.SubFactory(DepartmentFactory) class Meta: - model = Role + model = DepartmentRole class NewHireFactory(BaseUserFactory): diff --git a/back/users/migrations/0046_alter_department_sequences_departmentrole_and_more.py b/back/users/migrations/0046_alter_department_sequences_departmentrole_and_more.py new file mode 100644 index 00000000..b8a73c0f --- /dev/null +++ b/back/users/migrations/0046_alter_department_sequences_departmentrole_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.7 on 2025-10-21 01:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("sequences", "0046_alter_sequence_options_sequence_departments"), + ("users", "0045_department_sequences_role"), + ] + + operations = [ + migrations.AlterField( + model_name="department", + name="sequences", + field=models.ManyToManyField( + blank=True, related_name="+", to="sequences.sequence" + ), + ), + migrations.CreateModel( + name="DepartmentRole", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "department", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="roles", + to="users.department", + ), + ), + ( + "sequences", + models.ManyToManyField( + blank=True, related_name="roles", to="sequences.sequence" + ), + ), + ( + "users", + models.ManyToManyField( + related_name="department_roles", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "ordering": ("name",), + }, + ), + migrations.DeleteModel( + name="Role", + ), + ] diff --git a/back/users/migrations/0047_alter_department_name.py b/back/users/migrations/0047_alter_department_name.py new file mode 100644 index 00000000..fe4d7c3f --- /dev/null +++ b/back/users/migrations/0047_alter_department_name.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.7 on 2025-10-21 01:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0046_alter_department_sequences_departmentrole_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="department", + name="name", + field=models.CharField(max_length=255, unique=True), + ), + ] diff --git a/back/users/models.py b/back/users/models.py index d8fc1b02..c6fd8dd9 100644 --- a/back/users/models.py +++ b/back/users/models.py @@ -30,7 +30,7 @@ from .utils import CompletedFormCheck, parse_array_to_string -class Role(models.Model): +class DepartmentRole(models.Model): """ Role within a department. Roles are unique to every department. """ @@ -39,8 +39,10 @@ class Role(models.Model): department = models.ForeignKey( "users.Department", related_name="roles", on_delete=models.PROTECT ) - sequences = models.ManyToManyField("sequences.Sequence", related_name="roles") - users = models.ManyToManyField("users.User", related_name="users") + sequences = models.ManyToManyField( + "sequences.Sequence", related_name="roles", blank=True + ) + users = models.ManyToManyField("users.User", related_name="department_roles") class Meta: ordering = ("name",) @@ -54,8 +56,10 @@ class Department(models.Model): Department that has been attached to a user """ - name = models.CharField(max_length=255) - sequences = models.ManyToManyField("sequences.Sequence", related_name="+") + name = models.CharField(max_length=255, unique=True) + sequences = models.ManyToManyField( + "sequences.Sequence", related_name="+", blank=True + ) class Meta: ordering = ("name",) diff --git a/back/users/selectors.py b/back/users/selectors.py index 9da440dd..1175c846 100644 --- a/back/users/selectors.py +++ b/back/users/selectors.py @@ -11,10 +11,10 @@ def get_available_departments_for_user(*, user): def get_available_roles_for_user(*, user): - from users.models import Role + from users.models import DepartmentRole departments = get_available_departments_for_user(user=user) - return Role.objects.filter(department__in=departments).distinct() + return DepartmentRole.objects.filter(department__in=departments).distinct() def get_departments_query(*, user): diff --git a/back/users/templates/admin_base.html b/back/users/templates/admin_base.html index e53ce723..2fb446bc 100644 --- a/back/users/templates/admin_base.html +++ b/back/users/templates/admin_base.html @@ -82,7 +82,7 @@ {% translate "Offboarding" %} - {% translate "Departments" %} + {% translate "Roles and departments" %}
From 35ffb418e8d58e3bebcc1521cfa57ce3bc9fed56 Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Tue, 21 Oct 2025 03:37:45 +0200 Subject: [PATCH 11/22] move inline css to class --- back/admin/people/templates/departments.html | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index edb3d5de..38eb8883 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -59,6 +59,9 @@

{% translate "Users" %}

border-left-color: color-mix(in srgb,var(--tblr-primary) 10%,transparent); padding-left: 10px; } + .drop.droppable.drag-hover { + background-color: rgb(239, 242, 246) + } {% endblock extra_css %} {% block extra_js %} @@ -73,8 +76,6 @@

{% translate "Users" %}

document.addEventListener("dragstart", function( event ) { dragged = event.target; - event.target.style.opacity = .5; - $('.drop').each((index, element) => { markAsDroppable(element); }); @@ -95,7 +96,7 @@

{% translate "Users" %}

target: "#departmentslist", }) } -}, false) +}) document.addEventListener("dragover", function( event ) { // prevent default action @@ -103,15 +104,13 @@

{% translate "Users" %}

event.stopPropagation(); const droppedPlace = event.target.closest(".drop") if (droppedPlace) { - droppedPlace.style.backgroundColor='rgb(239, 242, 246)'; + droppedPlace.classList.add("drag-hover"); } -}, false); +}); document.addEventListener("dragend", function( event ) { - removeDroppableClasses(); - event.target.style.opacity = ""; if (draggedToElement) { - draggedToElement.style.backgroundColor='white'; + draggedToElement.classList.remove("drag-hover"); } }); @@ -122,7 +121,7 @@

{% translate "Users" %}

const droppedPlace = event.target.closest(".drop") if (droppedPlace) { const droppedPlace = event.target.closest(".drop") - droppedPlace.style.backgroundColor='white'; + droppedPlace.classList.remove("drag-hover"); } }); From 8bbed2ba51a5b4a17a674325cd9931b3f35ea49e Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Wed, 22 Oct 2025 02:42:32 +0200 Subject: [PATCH 12/22] bug fix --- back/admin/people/templates/departments.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index 38eb8883..ac7e7306 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -73,6 +73,9 @@

{% translate "Users" %}

function markAsDroppable(element) { $(element).addClass("droppable"); } +function removeMarkAsDroppable(element) { + $(element).removeClass("droppable"); +} document.addEventListener("dragstart", function( event ) { dragged = event.target; @@ -109,9 +112,9 @@

{% translate "Users" %}

}); document.addEventListener("dragend", function( event ) { - if (draggedToElement) { - draggedToElement.classList.remove("drag-hover"); - } + $('.drop').each((index, element) => { + removeMarkAsDroppable(element); + }); }); document.addEventListener("dragleave", function( event ) { From 7d6d7a829d953628c32168f8aeac22db3e60660e Mon Sep 17 00:00:00 2001 From: Stan Triepels <1939656+GDay@users.noreply.github.com> Date: Tue, 28 Oct 2025 04:12:04 +0100 Subject: [PATCH 13/22] Add sequences to departments (#585) --- .../people/templates/_departments_list.html | 42 ++++++++---- back/admin/people/templates/departments.html | 46 +++++++++++-- back/admin/people/tests/department_tests.py | 64 ++++++++++++++++++- back/admin/people/urls.py | 10 +++ back/admin/people/views/departments.py | 42 ++++++++++++ back/admin/sequences/factories.py | 5 +- 6 files changed, 188 insertions(+), 21 deletions(-) diff --git a/back/admin/people/templates/_departments_list.html b/back/admin/people/templates/_departments_list.html index 0f8f0f89..4b2158f3 100644 --- a/back/admin/people/templates/_departments_list.html +++ b/back/admin/people/templates/_departments_list.html @@ -16,21 +16,35 @@

{{ department }}

{% for role in department.roles.all %}

{{ role }}

- {% if role.users.all|length %} -
    - {% for user in role.users.all %} -
  • - {% if user.profile_image is not None %} - - {% else %} - {{ user.initials }} - {% endif %} - {{ user.name }} -
  • - {% endfor %} -
+ {% if users %} + {% if role.users.all|length %} +
    + {% for user in role.users.all %} +
  • + {% if user.profile_image is not None %} + + {% else %} + {{ user.initials }} + {% endif %} + {{ user.name }} +
  • + {% endfor %} +
+ {% else %} + {% trans "No users have been added to this role yet." %} + {% endif %} {% else %} - {% trans "No users have been added to this role yet." %} + {% if role.sequences.all|length %} +
    + {% for seq in role.sequences.all %} +
  • + {{ seq.name }} +
  • + {% endfor %} +
+ {% else %} + {% trans "No sequences have been added to this role yet." %} + {% endif %} {% endif %}
{% empty %} diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index ac7e7306..2a310487 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -5,6 +5,16 @@ + {% if users %} + + {% trans "Sequences" %} + + {% else %} + + {% trans "Users" %} + + {% endif %} + {% trans "Add" %} @@ -20,13 +30,19 @@
-

{% translate "Users" %}

+

+ {% if users %} + {% translate "Users" %} + {% else %} + {% translate "Sequences" %} + {% endif %} +

{% for user in users %}
-
+

{{ user.full_name }}

@@ -35,10 +51,29 @@

{% translate "Users" %}

{% endfor %} + {% for sequence in sequences %} +
+
+
+
+

{{ sequence.name }}

+
+
+
+
+ {% endfor %}
-

{% trans "Drag and drop the users in the roles you want them to be part of." %}

+
+

+ {% if users %} + {% trans "Drag and drop the users in the roles you want them to be part of." %} + {% else %} + {% trans "Drag and drop the sequences in the roles." %} + {% endif %} +

+
{% endblock %} @@ -92,10 +127,11 @@

{% translate "Users" %}

draggedToElement = droppedPlace if (droppedPlace) { - const userId = dragged.dataset.userId + const itemId = dragged.dataset.itemId const roleId = droppedPlace.dataset.roleId - htmx.ajax('POST', `/admin/people/colleagues/role/${roleId}/user/${userId}/`, { + const urlPart = "{% if users %}user{% else %}seq{% endif %}" + htmx.ajax('POST', `/admin/people/colleagues/role/${roleId}/${urlPart}/${itemId}/`, { target: "#departmentslist", }) } diff --git a/back/admin/people/tests/department_tests.py b/back/admin/people/tests/department_tests.py index 497a5d56..7b0f15a4 100644 --- a/back/admin/people/tests/department_tests.py +++ b/back/admin/people/tests/department_tests.py @@ -12,7 +12,6 @@ def test_department_list( department_factory, new_hire_factory, manager_factory, - department_role_factory, ): user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) client.force_login(user) @@ -229,3 +228,66 @@ def test_add_user_to_role_in_department( # user is part of role role.refresh_from_db() assert user in role.users.all() + + +@pytest.mark.django_db +def test_department_sequence_list( + client, + django_user_model, + department_factory, + sequence_factory, + new_hire_factory, + manager_factory, +): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + + dep = department_factory() + dep2 = department_factory() + user.departments.add(dep) + + seq1 = sequence_factory(departments=[dep]) + seq2 = sequence_factory(departments=[dep2]) + seq3 = sequence_factory(departments=[dep]) + # not part of any departments, so available everywhere + seq4 = sequence_factory() + + url = reverse("people:departments_sequences") + response = client.get(url) + + # seq1 and seq2 are part of their own dep, so they will show up. seq4 is not part of an dep, so shows up as well + assert seq1.name in response.content.decode() + assert seq3.name in response.content.decode() + assert seq4.name in response.content.decode() + # seq2 is part of different dep, so doesn't show up + assert seq2.name not in response.content.decode() + + # dep does not show up + assert dep2.name not in response.content.decode() + + +@pytest.mark.django_db +def test_add_seq_to_role_in_department( + client, + django_user_model, + department_factory, + department_role_factory, + sequence_factory, +): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + + dep = department_factory() + role = department_role_factory(department=dep, name="testrole") + seq = sequence_factory(departments=[dep]) + user.departments.add(dep) + + # seq is not part of role + assert seq not in role.sequences.all() + + url = reverse("people:add_seq_to_role", args=[role.id, seq.id]) + client.post(url) + + # seq is part of role + role.refresh_from_db() + assert seq in role.sequences.all() diff --git a/back/admin/people/urls.py b/back/admin/people/urls.py index a45da24b..ed814485 100644 --- a/back/admin/people/urls.py +++ b/back/admin/people/urls.py @@ -258,4 +258,14 @@ views.DepartmentRoleUpdateView.as_view(), name="department_role_update", ), + path( + "colleagues/departments/seq/", + views.DepartmentSequenceListView.as_view(), + name="departments_sequences", + ), + path( + "colleagues/role//seq//", + views.AddSequenceToRoleView.as_view(), + name="add_seq_to_role", + ), ] diff --git a/back/admin/people/views/departments.py b/back/admin/people/views/departments.py index bc98d78c..a2f4aaec 100644 --- a/back/admin/people/views/departments.py +++ b/back/admin/people/views/departments.py @@ -6,6 +6,10 @@ from django.views.generic.edit import CreateView, UpdateView from django.views.generic.list import ListView +from admin.sequences.selectors import ( + get_onboarding_sequences_for_user, + get_sequences_for_user, +) from users.mixins import AdminOrManagerPermMixin from users.models import Department, DepartmentRole from users.selectors import ( @@ -140,3 +144,41 @@ def get_context_data(self, **kwargs): context["title"] = _("Role") context["subtitle"] = _("people") return context + + +class DepartmentSequenceListView(AdminOrManagerPermMixin, ListView): + template_name = "departments.html" + context_object_name = "departments" + + def get_queryset(self): + return get_available_departments_for_user( + user=self.request.user + ).prefetch_related("roles__users") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Roles and departments") + context["subtitle"] = _("sequences") + context["sequences"] = get_onboarding_sequences_for_user(user=self.request.user) + return context + + +class AddSequenceToRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): + def post(self, request, role_pk, seq_pk, **kwargs): + role = get_object_or_404( + get_available_roles_for_user(user=request.user), id=role_pk + ) + sequence = get_object_or_404( + get_sequences_for_user(user=request.user), id=seq_pk + ) + + role.sequences.add(sequence) + return render( + request, + "_departments_list.html", + { + "departments": get_available_departments_for_user( + user=self.request.user + ).prefetch_related("roles__users") + }, + ) diff --git a/back/admin/sequences/factories.py b/back/admin/sequences/factories.py index 8743456d..761479a7 100644 --- a/back/admin/sequences/factories.py +++ b/back/admin/sequences/factories.py @@ -8,6 +8,7 @@ from admin.preboarding.factories import PreboardingFactory from admin.resources.factories import ResourceFactory from admin.to_do.factories import ToDoFactory +from misc.mixins import DepartmentsPostGenerationMixin from users.factories import AdminFactory, EmployeeFactory from .models import ( @@ -188,7 +189,9 @@ class Meta: model = Condition -class SequenceFactory(factory.django.DjangoModelFactory): +class SequenceFactory( + DepartmentsPostGenerationMixin, factory.django.DjangoModelFactory +): name = FuzzyText() category = Sequence.Category.ONBOARDING From 5925896f0fdcdef917c82d08d12f1a5f66cf3d34 Mon Sep 17 00:00:00 2001 From: Stan Triepels <1939656+GDay@users.noreply.github.com> Date: Tue, 28 Oct 2025 04:19:24 +0100 Subject: [PATCH 14/22] Add sequences to departments (#590) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../people/templates/_departments_list.html | 21 +- .../_departments_sequences_list.html | 20 ++ back/admin/people/templates/departments.html | 41 ++-- back/admin/people/tests/department_tests.py | 87 +++++++- back/admin/people/urls.py | 23 ++- back/admin/people/views/departments.py | 187 ++++++++++++------ 6 files changed, 270 insertions(+), 109 deletions(-) create mode 100644 back/admin/people/templates/_departments_sequences_list.html diff --git a/back/admin/people/templates/_departments_list.html b/back/admin/people/templates/_departments_list.html index 4b2158f3..957fbbfe 100644 --- a/back/admin/people/templates/_departments_list.html +++ b/back/admin/people/templates/_departments_list.html @@ -1,7 +1,7 @@ {% load i18n %} {% for department in departments %} -
+

{{ department }}

@@ -13,10 +13,14 @@

{{ department }}

+ {% if not is_users_page %} + {# sequences can be assigned to departments only, instead of roles #} + {% include "_departments_sequences_list.html" with sequences=department.sequences.all %} + {% endif %} {% for role in department.roles.all %} -
+

{{ role }}

- {% if users %} + {% if is_users_page %} {% if role.users.all|length %}
    {% for user in role.users.all %} @@ -34,15 +38,8 @@

    {{ role }}

    {% trans "No users have been added to this role yet." %} {% endif %} {% else %} - {% if role.sequences.all|length %} -
      - {% for seq in role.sequences.all %} -
    • - {{ seq.name }} -
    • - {% endfor %} -
    - {% else %} + {% include "_departments_sequences_list.html" with sequences=role.sequences.all %} + {% if not role.sequences.all|length %} {% trans "No sequences have been added to this role yet." %} {% endif %} {% endif %} diff --git a/back/admin/people/templates/_departments_sequences_list.html b/back/admin/people/templates/_departments_sequences_list.html new file mode 100644 index 00000000..d1a4f24b --- /dev/null +++ b/back/admin/people/templates/_departments_sequences_list.html @@ -0,0 +1,20 @@ +{% load i18n %} +{% if sequences %} +
      + {% for seq in sequences %} +
    • + {{ seq.name }} +
      + {% csrf_token %} + +
      +
    • + {% endfor %} +
    +{% endif %} diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index 2a310487..7f780f9b 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -5,7 +5,7 @@ - {% if users %} + {% if is_users_page %} {% trans "Sequences" %} @@ -31,7 +31,7 @@

    - {% if users %} + {% if is_users_page %} {% translate "Users" %} {% else %} {% translate "Sequences" %} @@ -40,24 +40,11 @@

    - {% for user in users %} -
    -
    -
    -
    -

    {{ user.full_name }}

    -
    -
    -
    -
    - {% endfor %} - {% for sequence in sequences %} -
    -
    -
    -
    -

    {{ sequence.name }}

    -
    + {% for item in users_or_sequences %} +
    +
    +
    +

    {{ item.name }}

    @@ -67,7 +54,7 @@

    - {% if users %} + {% if is_users_page %} {% trans "Drag and drop the users in the roles you want them to be part of." %} {% else %} {% trans "Drag and drop the sequences in the roles." %} @@ -94,7 +81,7 @@

    border-left-color: color-mix(in srgb,var(--tblr-primary) 10%,transparent); padding-left: 10px; } - .drop.droppable.drag-hover { + .drop.drag-hover { background-color: rgb(239, 242, 246) } @@ -114,7 +101,7 @@

    document.addEventListener("dragstart", function( event ) { dragged = event.target; - $('.drop').each((index, element) => { + $('.drop').not('.ignore-styling').each((index, element) => { markAsDroppable(element); }); }); @@ -128,10 +115,10 @@

    if (droppedPlace) { const itemId = dragged.dataset.itemId - const roleId = droppedPlace.dataset.roleId - - const urlPart = "{% if users %}user{% else %}seq{% endif %}" - htmx.ajax('POST', `/admin/people/colleagues/role/${roleId}/${urlPart}/${itemId}/`, { + const url = droppedPlace.dataset.url + const method = droppedPlace.dataset.method + + htmx.ajax(method, url + "?item=" + itemId, { target: "#departmentslist", }) } diff --git a/back/admin/people/tests/department_tests.py b/back/admin/people/tests/department_tests.py index 7b0f15a4..9f4aea8c 100644 --- a/back/admin/people/tests/department_tests.py +++ b/back/admin/people/tests/department_tests.py @@ -222,8 +222,8 @@ def test_add_user_to_role_in_department( # user is not part of role assert user not in role.users.all() - url = reverse("people:add_user_to_role", args=[role.id, user.id]) - client.post(url) + url = reverse("people:add_user_to_role", args=[role.id]) + client.post(url + f"?item={user.id}") # user is part of role role.refresh_from_db() @@ -285,9 +285,88 @@ def test_add_seq_to_role_in_department( # seq is not part of role assert seq not in role.sequences.all() - url = reverse("people:add_seq_to_role", args=[role.id, seq.id]) - client.post(url) + url = reverse("people:toggle_seq_role", args=[role.id]) + client.post(url + f"?item={seq.id}") # seq is part of role role.refresh_from_db() assert seq in role.sequences.all() + + +@pytest.mark.django_db +def test_remove_seq_from_role( + client, + django_user_model, + department_factory, + department_role_factory, + sequence_factory, +): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + + dep = department_factory() + role = department_role_factory(department=dep, name="testrole") + seq = sequence_factory(departments=[dep]) + user.departments.add(dep) + role.sequences.add(seq) + + # seq is part of role + assert seq in role.sequences.all() + + url = reverse("people:toggle_seq_role", args=[role.id]) + client.delete(url + f"?item={seq.id}") + + # seq is not part of role + role.refresh_from_db() + assert seq not in role.sequences.all() + + +@pytest.mark.django_db +def test_add_seq_to_department( + client, + django_user_model, + department_factory, + sequence_factory, +): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + + dep = department_factory() + seq = sequence_factory(departments=[dep]) + user.departments.add(dep) + + # seq is not part of department + assert seq not in dep.sequences.all() + + url = reverse("people:toggle_seq_department", args=[dep.id]) + client.post(url + f"?item={seq.id}") + + # seq is part of department + dep.refresh_from_db() + assert seq in dep.sequences.all() + + +@pytest.mark.django_db +def test_remove_seq_from_department( + client, + django_user_model, + department_factory, + sequence_factory, +): + user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) + client.force_login(user) + + dep = department_factory() + seq = sequence_factory(departments=[dep]) + user.departments.add(dep) + dep.sequences.add(seq) + + # seq is part of department + assert seq in dep.sequences.all() + + url = reverse("people:toggle_seq_department", args=[dep.id]) + client.delete(url + f"?item={seq.id}") + + # seq is not part of department + dep.refresh_from_db() + assert seq not in dep.sequences.all() diff --git a/back/admin/people/urls.py b/back/admin/people/urls.py index ed814485..b7a6d51d 100644 --- a/back/admin/people/urls.py +++ b/back/admin/people/urls.py @@ -233,6 +233,11 @@ views.DepartmentListView.as_view(), name="departments", ), + path( + "colleagues/departments/seq/", + views.DepartmentSequenceListView.as_view(), + name="departments_sequences", + ), path( "colleagues/departments/create/", views.DepartmentCreateView.as_view(), @@ -249,14 +254,24 @@ name="department_role_create", ), path( - "colleagues/role//user//", + "colleagues/departments//roles//update/", + views.DepartmentRoleUpdateView.as_view(), + name="department_role_update", + ), + path( + "colleagues/role//user/", views.AddUserToRoleView.as_view(), name="add_user_to_role", ), path( - "colleagues/departments//roles//update/", - views.DepartmentRoleUpdateView.as_view(), - name="department_role_update", + "colleagues/role//seq/", + views.ToggleSequenceRoleView.as_view(), + name="toggle_seq_role", + ), + path( + "colleagues/department//seq/", + views.ToggleSequenceDepartmentView.as_view(), + name="toggle_seq_department", ), path( "colleagues/departments/seq/", diff --git a/back/admin/people/views/departments.py b/back/admin/people/views/departments.py index a2f4aaec..256fe9b9 100644 --- a/back/admin/people/views/departments.py +++ b/back/admin/people/views/departments.py @@ -32,7 +32,30 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["title"] = _("Roles and departments") context["subtitle"] = _("people") - context["users"] = get_all_users_for_departments_of_user(user=self.request.user) + context["users_or_sequences"] = get_all_users_for_departments_of_user( + user=self.request.user + ) + context["is_users_page"] = True + return context + + +class DepartmentSequenceListView(AdminOrManagerPermMixin, ListView): + template_name = "departments.html" + context_object_name = "departments" + + def get_queryset(self): + return get_available_departments_for_user( + user=self.request.user + ).prefetch_related("roles__users") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Roles and departments") + context["subtitle"] = _("sequences") + context["users_or_sequences"] = get_onboarding_sequences_for_user( + user=self.request.user + ) + context["is_users_page"] = False return context @@ -58,6 +81,24 @@ def get_context_data(self, **kwargs): return context +class DepartmentUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView): + template_name = "department_update.html" + fields = [ + "name", + ] + success_message = _("Department has been updated") + success_url = reverse_lazy("people:departments") + + def get_queryset(self): + return get_available_departments_for_user(user=self.request.user) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Department") + context["subtitle"] = _("people") + return context + + class DepartmentRoleCreateView( AdminOrManagerPermMixin, SuccessMessageMixin, CreateView ): @@ -87,45 +128,6 @@ def get_context_data(self, **kwargs): return context -class AddUserToRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): - def post(self, request, role_pk, user_pk, **kwargs): - role = get_object_or_404( - get_available_roles_for_user(user=request.user), id=role_pk - ) - user = get_object_or_404( - get_all_users_for_departments_of_user(user=request.user), id=user_pk - ) - - role.users.add(user) - return render( - request, - "_departments_list.html", - { - "departments": get_available_departments_for_user( - user=self.request.user - ).prefetch_related("roles__users") - }, - ) - - -class DepartmentUpdateView(AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView): - template_name = "department_update.html" - fields = [ - "name", - ] - success_message = _("Department has been updated") - success_url = reverse_lazy("people:departments") - - def get_queryset(self): - return get_available_departments_for_user(user=self.request.user) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["title"] = _("Department") - context["subtitle"] = _("people") - return context - - class DepartmentRoleUpdateView( AdminOrManagerPermMixin, SuccessMessageMixin, UpdateView ): @@ -146,39 +148,100 @@ def get_context_data(self, **kwargs): return context -class DepartmentSequenceListView(AdminOrManagerPermMixin, ListView): - template_name = "departments.html" - context_object_name = "departments" +class AddUserToRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): + def dispatch(self, *args, **kwargs): + self.role = get_object_or_404( + get_available_roles_for_user(user=self.request.user), + id=self.kwargs.get("role_pk", -1), + ) + self.user = get_object_or_404( + get_all_users_for_departments_of_user(user=self.request.user), + id=self.request.GET.get("item", -1), + ) + return super().dispatch(*args, **kwargs) - def get_queryset(self): - return get_available_departments_for_user( - user=self.request.user - ).prefetch_related("roles__users") + def _render_response(self): + return render( + self.request, + "_departments_list.html", + { + "departments": get_available_departments_for_user( + user=self.request.user + ).prefetch_related("roles__users"), + "is_users_page": True, + }, + ) - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["title"] = _("Roles and departments") - context["subtitle"] = _("sequences") - context["sequences"] = get_onboarding_sequences_for_user(user=self.request.user) - return context + def delete(self, request, **kwargs): + self.role.users.remove(self.user) + return self._render_response() + def post(self, request, **kwargs): + self.role.users.add(self.user) + return self._render_response() -class AddSequenceToRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): - def post(self, request, role_pk, seq_pk, **kwargs): - role = get_object_or_404( - get_available_roles_for_user(user=request.user), id=role_pk + +class ToggleSequenceRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): + def dispatch(self, *args, **kwargs): + self.role = get_object_or_404( + get_available_roles_for_user(user=self.request.user), + id=self.kwargs.get("role_pk", -1), ) - sequence = get_object_or_404( - get_sequences_for_user(user=request.user), id=seq_pk + self.sequence = get_object_or_404( + get_sequences_for_user(user=self.request.user), + id=self.request.GET.get("item", -1), ) + return super().dispatch(*args, **kwargs) - role.sequences.add(sequence) + def _render_response(self): return render( - request, + self.request, "_departments_list.html", { "departments": get_available_departments_for_user( user=self.request.user - ).prefetch_related("roles__users") + ).prefetch_related("roles__users"), + "is_users_page": False, }, ) + + def delete(self, request, **kwargs): + self.role.sequences.remove(self.sequence) + return self._render_response() + + def post(self, request, **kwargs): + self.role.sequences.add(self.sequence) + return self._render_response() + + +class ToggleSequenceDepartmentView(AdminOrManagerPermMixin, SuccessMessageMixin, View): + def dispatch(self, *args, **kwargs): + self.department = get_object_or_404( + get_available_departments_for_user(user=self.request.user), + id=self.kwargs.get("department_pk", -1), + ) + self.sequence = get_object_or_404( + get_sequences_for_user(user=self.request.user), + id=self.request.GET.get("item", -1), + ) + return super().dispatch(*args, **kwargs) + + def _render_response(self): + return render( + self.request, + "_departments_list.html", + { + "departments": get_available_departments_for_user( + user=self.request.user + ).prefetch_related("roles__users"), + "is_users_page": False, + }, + ) + + def post(self, request, *args, **kwargs): + self.department.sequences.add(self.sequence) + return self._render_response() + + def delete(self, request, *args, **kwargs): + self.department.sequences.remove(self.sequence) + return self._render_response() From e91d958877258a86581a0d2b4c5b47979a909a0c Mon Sep 17 00:00:00 2001 From: Stan <1939656+GDay@users.noreply.github.com> Date: Tue, 28 Oct 2025 04:21:42 +0100 Subject: [PATCH 15/22] fix broken merge --- back/admin/people/urls.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/back/admin/people/urls.py b/back/admin/people/urls.py index b7a6d51d..30b16311 100644 --- a/back/admin/people/urls.py +++ b/back/admin/people/urls.py @@ -278,9 +278,4 @@ views.DepartmentSequenceListView.as_view(), name="departments_sequences", ), - path( - "colleagues/role//seq//", - views.AddSequenceToRoleView.as_view(), - name="add_seq_to_role", - ), ] From d17551dc5ce3c3b18c701ff801bb5c38ebcb48c5 Mon Sep 17 00:00:00 2001 From: Stan Triepels <1939656+GDay@users.noreply.github.com> Date: Tue, 25 Nov 2025 04:41:27 +0100 Subject: [PATCH 16/22] Popup when sequences are being added (#591) --- back/admin/people/forms.py | 15 +++ .../people/templates/_departments_list.html | 4 +- ...tments_list_with_sequence_apply_modal.html | 19 +++ back/admin/people/templates/departments.html | 28 ++++- back/admin/people/urls.py | 10 ++ back/admin/people/views/departments.py | 118 +++++++++++++++--- 6 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 back/admin/people/templates/_departments_list_with_sequence_apply_modal.html diff --git a/back/admin/people/forms.py b/back/admin/people/forms.py index 0c738480..d9c37082 100644 --- a/back/admin/people/forms.py +++ b/back/admin/people/forms.py @@ -16,6 +16,7 @@ ) from misc.mixins import FilterDepartmentsFieldByUserMixin from organization.models import Organization +from users.models import User class NewHireAddForm(forms.ModelForm): @@ -436,3 +437,17 @@ def __init__(self, *args, **kwargs): class Meta: model = get_user_model() fields = ("role",) + + +class AddUsersToSequenceChoiceForm(forms.Form): + users = forms.ModelMultipleChoiceField( + label=_("Select the users you want to add this sequence to"), + widget=forms.CheckboxSelectMultiple, + queryset=User.objects.none(), + required=False, + ) + + def __init__(self, *args, **kwargs): + users = kwargs.pop("users") + super().__init__(*args, **kwargs) + self.fields["users"].queryset = users diff --git a/back/admin/people/templates/_departments_list.html b/back/admin/people/templates/_departments_list.html index 957fbbfe..f68903d9 100644 --- a/back/admin/people/templates/_departments_list.html +++ b/back/admin/people/templates/_departments_list.html @@ -1,7 +1,8 @@ {% load i18n %} +
    {% for department in departments %} -
    +

    {{ department }}

    @@ -58,3 +59,4 @@

    {{ role }}

    {% endfor %} +
    diff --git a/back/admin/people/templates/_departments_list_with_sequence_apply_modal.html b/back/admin/people/templates/_departments_list_with_sequence_apply_modal.html new file mode 100644 index 00000000..47ab0c66 --- /dev/null +++ b/back/admin/people/templates/_departments_list_with_sequence_apply_modal.html @@ -0,0 +1,19 @@ +{% load i18n %} +{% load crispy_forms_tags %} +{% include "_departments_list.html" %} + + diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index 7f780f9b..42121f20 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -24,7 +24,7 @@
    -
    +
    {% include "_departments_list.html" %}
    @@ -63,6 +63,15 @@

    + {% endblock %} {% block extra_css %} @@ -117,9 +126,17 @@

    const itemId = dragged.dataset.itemId const url = droppedPlace.dataset.url const method = droppedPlace.dataset.method - + htmx.ajax(method, url + "?item=" + itemId, { - target: "#departmentslist", + {% if not is_users_page %} + target: "#department-modals", + {% else %} + target: "#departmentslist", + {% endif %} + }).then(function () { + {% if not is_users_page %} + $('#department-modals').modal("toggle") + {% endif %} }) } }) @@ -150,5 +167,10 @@

    droppedPlace.classList.remove("drag-hover"); } }); + +$(document).on("hide-modal", function(evt){ + $('.modal').modal('hide') +}) + {% endblock extra_js %} diff --git a/back/admin/people/urls.py b/back/admin/people/urls.py index 30b16311..4c0675e3 100644 --- a/back/admin/people/urls.py +++ b/back/admin/people/urls.py @@ -278,4 +278,14 @@ views.DepartmentSequenceListView.as_view(), name="departments_sequences", ), + path( + "colleagues/department/seq///", + views.ApplySequenceToUsersDepartmentView.as_view(), + name="apply_sequence_to_users_in_department", + ), + path( + "colleagues/department/role/seq///", + views.ApplySequenceToUsersRoleView.as_view(), + name="apply_sequence_to_users_in_role", + ), ] diff --git a/back/admin/people/views/departments.py b/back/admin/people/views/departments.py index 256fe9b9..30779992 100644 --- a/back/admin/people/views/departments.py +++ b/back/admin/people/views/departments.py @@ -1,17 +1,19 @@ from django.contrib.messages.views import SuccessMessageMixin -from django.shortcuts import get_object_or_404, render +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse_lazy from django.utils.translation import gettext as _ from django.views.generic.base import View -from django.views.generic.edit import CreateView, UpdateView +from django.views.generic.edit import CreateView, FormView, UpdateView from django.views.generic.list import ListView +from admin.people.forms import AddUsersToSequenceChoiceForm from admin.sequences.selectors import ( get_onboarding_sequences_for_user, get_sequences_for_user, ) from users.mixins import AdminOrManagerPermMixin -from users.models import Department, DepartmentRole +from users.models import Department, DepartmentRole, User from users.selectors import ( get_all_users_for_departments_of_user, get_available_departments_for_user, @@ -193,25 +195,21 @@ def dispatch(self, *args, **kwargs): ) return super().dispatch(*args, **kwargs) - def _render_response(self): - return render( - self.request, - "_departments_list.html", - { - "departments": get_available_departments_for_user( - user=self.request.user - ).prefetch_related("roles__users"), - "is_users_page": False, - }, - ) - def delete(self, request, **kwargs): self.role.sequences.remove(self.sequence) - return self._render_response() + return redirect( + "people:apply_sequence_to_users_in_role", + sequence=self.sequence.pk, + role_pk=self.role.pk, + ) def post(self, request, **kwargs): self.role.sequences.add(self.sequence) - return self._render_response() + return redirect( + "people:apply_sequence_to_users_in_role", + sequence=self.sequence.pk, + role_pk=self.role.pk, + ) class ToggleSequenceDepartmentView(AdminOrManagerPermMixin, SuccessMessageMixin, View): @@ -227,21 +225,103 @@ def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) def _render_response(self): + roles = DepartmentRole.objects.filter(department=self.department).values_list( + "pk", flat=True + ) + users = User.objects.filter(department_roles__in=roles).distinct() + form = AddUsersToSequenceChoiceForm(users=users) return render( self.request, - "_departments_list.html", + "_departments_list_with_sequence_apply_modal.html", { "departments": get_available_departments_for_user( user=self.request.user ).prefetch_related("roles__users"), "is_users_page": False, + "form": form, + "modal_url": reverse_lazy( + "people:apply_sequence_to_users_in_department", + args=[self.sequence.pk, self.department.pk], + ), }, ) def post(self, request, *args, **kwargs): self.department.sequences.add(self.sequence) - return self._render_response() + return redirect( + "people:apply_sequence_to_users_in_department", + sequence=self.sequence.pk, + department_pk=self.department.pk, + ) def delete(self, request, *args, **kwargs): self.department.sequences.remove(self.sequence) return self._render_response() + + +class BaseApplySequenceToUsersView( + AdminOrManagerPermMixin, SuccessMessageMixin, FormView +): + form_class = AddUsersToSequenceChoiceForm + template_name = "_departments_list_with_sequence_apply_modal.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["departments"] = get_available_departments_for_user( + user=self.request.user + ).prefetch_related("roles__users") + context["is_users_page"] = False + return context + + def form_valid(self, form): + users = form.cleaned_data["users"] + for user in users: + user.add_sequences([self.sequence]) + return HttpResponse(headers={"HX-Trigger": "hide-modal"}) + + +class ApplySequenceToUsersRoleView(BaseApplySequenceToUsersView): + def dispatch(self, *args, **kwargs): + self.sequence = get_object_or_404( + get_sequences_for_user(user=self.request.user), + id=self.kwargs.get("sequence"), + ) + self.role = get_object_or_404( + get_available_roles_for_user(user=self.request.user), + id=self.kwargs.get("role_pk"), + ) + self.modal_url = reverse_lazy( + "people:apply_sequence_to_users_in_role", + args=[self.sequence.pk, self.role.pk], + ) + return super().dispatch(*args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["users"] = self.role.users.all() + return kwargs + + +class ApplySequenceToUsersDepartmentView(BaseApplySequenceToUsersView): + def dispatch(self, *args, **kwargs): + self.sequence = get_object_or_404( + get_sequences_for_user(user=self.request.user), + id=self.kwargs.get("sequence"), + ) + self.department = get_object_or_404( + get_available_departments_for_user(user=self.request.user), + id=self.kwargs.get("department_pk"), + ) + self.modal_url = reverse_lazy( + "people:apply_sequence_to_users_in_department", + args=[self.sequence.pk, self.department.pk], + ) + return super().dispatch(*args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + roles = DepartmentRole.objects.filter(department=self.department).values_list( + "pk", flat=True + ) + kwargs["users"] = User.objects.filter(department_roles__in=roles).distinct() + return kwargs From 7ac9e5000e3b7ad8e3d2fd23e73a181287797dc4 Mon Sep 17 00:00:00 2001 From: Stan Triepels <1939656+GDay@users.noreply.github.com> Date: Tue, 25 Nov 2025 04:43:35 +0100 Subject: [PATCH 17/22] add popup when user is added to role (#592) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- back/admin/people/forms.py | 14 +++++ ...nts_list_with_sequences_to_user_modal.html | 19 +++++++ back/admin/people/templates/departments.html | 10 +--- back/admin/people/urls.py | 5 ++ back/admin/people/views/departments.py | 51 ++++++++++++++++++- 5 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 back/admin/people/templates/_departments_list_with_sequences_to_user_modal.html diff --git a/back/admin/people/forms.py b/back/admin/people/forms.py index d9c37082..c383822c 100644 --- a/back/admin/people/forms.py +++ b/back/admin/people/forms.py @@ -451,3 +451,17 @@ def __init__(self, *args, **kwargs): users = kwargs.pop("users") super().__init__(*args, **kwargs) self.fields["users"].queryset = users + + +class AddSequencesToUser(forms.Form): + sequences = forms.ModelMultipleChoiceField( + label=_("Select the sequences you want to add to this user"), + widget=forms.CheckboxSelectMultiple(attrs={"checked":"checked"}), + queryset=Sequence.objects.none(), + required=False, + ) + + def __init__(self, *args, **kwargs): + sequence_pks = kwargs.pop("sequence_pks") + super().__init__(*args, **kwargs) + self.fields["sequences"].queryset = Sequence.objects.filter(pk__in=sequence_pks) diff --git a/back/admin/people/templates/_departments_list_with_sequences_to_user_modal.html b/back/admin/people/templates/_departments_list_with_sequences_to_user_modal.html new file mode 100644 index 00000000..8f3bebcf --- /dev/null +++ b/back/admin/people/templates/_departments_list_with_sequences_to_user_modal.html @@ -0,0 +1,19 @@ +{% load i18n %} +{% load crispy_forms_tags %} +{% include "_departments_list.html" %} + + diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index 42121f20..0df087a4 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -128,15 +128,9 @@

    const method = droppedPlace.dataset.method htmx.ajax(method, url + "?item=" + itemId, { - {% if not is_users_page %} - target: "#department-modals", - {% else %} - target: "#departmentslist", - {% endif %} + target: "#department-modals", }).then(function () { - {% if not is_users_page %} - $('#department-modals').modal("toggle") - {% endif %} + $('#department-modals').modal("toggle") }) } }) diff --git a/back/admin/people/urls.py b/back/admin/people/urls.py index 4c0675e3..e721381d 100644 --- a/back/admin/people/urls.py +++ b/back/admin/people/urls.py @@ -288,4 +288,9 @@ views.ApplySequenceToUsersRoleView.as_view(), name="apply_sequence_to_users_in_role", ), + path( + "colleagues/department/role//user//seq/", + views.ApplySequencesToUserView.as_view(), + name="apply_sequences_to_user", + ), ] diff --git a/back/admin/people/views/departments.py b/back/admin/people/views/departments.py index 30779992..e4c17f4d 100644 --- a/back/admin/people/views/departments.py +++ b/back/admin/people/views/departments.py @@ -7,7 +7,7 @@ from django.views.generic.edit import CreateView, FormView, UpdateView from django.views.generic.list import ListView -from admin.people.forms import AddUsersToSequenceChoiceForm +from admin.people.forms import AddSequencesToUser, AddUsersToSequenceChoiceForm from admin.sequences.selectors import ( get_onboarding_sequences_for_user, get_sequences_for_user, @@ -163,14 +163,22 @@ def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) def _render_response(self): + sequence_pks = list( + self.role.sequences.all().values_list("pk", flat=True) + ) + list(self.role.department.sequences.all().values_list("pk", flat=True)) + form = AddSequencesToUser(sequence_pks=sequence_pks) return render( self.request, - "_departments_list.html", + "_departments_list_with_sequences_to_user_modal.html", { "departments": get_available_departments_for_user( user=self.request.user ).prefetch_related("roles__users"), "is_users_page": True, + "modal_url": reverse_lazy( + "people:apply_sequences_to_user", args=[self.role.pk, self.user.pk] + ), + "form": form, }, ) @@ -212,6 +220,45 @@ def post(self, request, **kwargs): ) +class ApplySequencesToUserView(AdminOrManagerPermMixin, SuccessMessageMixin, FormView): + form_class = AddSequencesToUser + template_name = "_departments_list_with_sequences_to_user_modal.html" + + def dispatch(self, *args, **kwargs): + self.role = get_object_or_404( + get_available_roles_for_user(user=self.request.user), + id=self.kwargs.get("role_pk"), + ) + self.user = get_object_or_404( + get_all_users_for_departments_of_user(user=self.request.user), + id=self.kwargs.get("user_pk"), + ) + return super().dispatch(*args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + sequence_pks = list( + self.role.sequences.all().values_list("pk", flat=True) + ) + list(self.role.department.sequences.all().values_list("pk", flat=True)) + kwargs["sequence_pks"] = sequence_pks + return kwargs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["departments"] = get_available_departments_for_user(user=self.request.user).prefetch_related( + "roles__users" + ) + context["modal_url"] = reverse_lazy( + "people:apply_sequences_to_user", args=[self.role.pk, self.user.pk] + ) + return context + + def form_valid(self, form): + sequences = form.cleaned_data["sequences"] + self.user.add_sequences(sequences) + return HttpResponse(headers={"HX-Trigger": "hide-modal"}) + + class ToggleSequenceDepartmentView(AdminOrManagerPermMixin, SuccessMessageMixin, View): def dispatch(self, *args, **kwargs): self.department = get_object_or_404( From ecf30c658af727b4af612ec7f3f3ed5184f6d805 Mon Sep 17 00:00:00 2001 From: Stan Triepels <1939656+GDay@users.noreply.github.com> Date: Wed, 26 Nov 2025 05:02:14 +0100 Subject: [PATCH 18/22] Remove user (#593) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- back/admin/appointments/factories.py | 2 +- back/admin/badges/factories.py | 2 +- back/admin/hardware/factories.py | 2 +- back/admin/integrations/models.py | 3 + back/admin/introductions/factories.py | 2 +- back/admin/people/forms.py | 19 ++- .../people/templates/_departments_list.html | 18 +-- ...s_list_with_remove_user_options_modal.html | 21 +++ ...tments_list_with_sequence_apply_modal.html | 2 + ...nts_list_with_sequences_to_user_modal.html | 3 +- .../templates/_departments_users_list.html | 19 +++ back/admin/people/templates/departments.html | 8 +- back/admin/people/tests/department_tests.py | 2 +- back/admin/people/urls.py | 9 +- back/admin/people/views/departments.py | 144 +++++++++++------- back/admin/preboarding/factories.py | 2 +- back/admin/resources/factories.py | 2 +- back/admin/sequences/factories.py | 2 +- back/admin/sequences/models.py | 3 + back/admin/to_do/factories.py | 2 +- back/misc/factories.py | 9 ++ back/misc/mixins.py | 10 -- back/new_hire/templates/new_hire_base.html | 2 +- back/static/js/htmx-1.7.0.min.js | 1 - back/static/js/htmx-2.0.8.min.js | 1 + back/users/templates/admin_base.html | 2 +- 26 files changed, 193 insertions(+), 99 deletions(-) create mode 100644 back/admin/people/templates/_departments_list_with_remove_user_options_modal.html create mode 100644 back/admin/people/templates/_departments_users_list.html delete mode 100644 back/static/js/htmx-1.7.0.min.js create mode 100644 back/static/js/htmx-2.0.8.min.js diff --git a/back/admin/appointments/factories.py b/back/admin/appointments/factories.py index 59866bb0..1d956551 100644 --- a/back/admin/appointments/factories.py +++ b/back/admin/appointments/factories.py @@ -3,7 +3,7 @@ from pytest_factoryboy import register from admin.appointments.models import Appointment -from misc.mixins import DepartmentsPostGenerationMixin +from misc.factories import DepartmentsPostGenerationMixin @register diff --git a/back/admin/badges/factories.py b/back/admin/badges/factories.py index 857359df..2b400e70 100644 --- a/back/admin/badges/factories.py +++ b/back/admin/badges/factories.py @@ -3,7 +3,7 @@ from pytest_factoryboy import register from admin.badges.models import Badge -from misc.mixins import DepartmentsPostGenerationMixin +from misc.factories import DepartmentsPostGenerationMixin @register diff --git a/back/admin/hardware/factories.py b/back/admin/hardware/factories.py index 18871f55..09918222 100644 --- a/back/admin/hardware/factories.py +++ b/back/admin/hardware/factories.py @@ -3,7 +3,7 @@ from pytest_factoryboy import register from admin.hardware.models import Hardware -from misc.mixins import DepartmentsPostGenerationMixin +from misc.factories import DepartmentsPostGenerationMixin @register diff --git a/back/admin/integrations/models.py b/back/admin/integrations/models.py index 432cffc6..3f23268d 100644 --- a/back/admin/integrations/models.py +++ b/back/admin/integrations/models.py @@ -218,6 +218,9 @@ class ManifestType(models.IntegerChoices): bot_token = EncryptedTextField(max_length=10000, default="", blank=True) bot_id = models.CharField(max_length=100, default="") + def __str__(self): + return self.name + @property def skip_user_provisioning(self): return self.manifest_type == Integration.ManifestType.MANUAL_USER_PROVISIONING diff --git a/back/admin/introductions/factories.py b/back/admin/introductions/factories.py index 0d5bf3be..06581a01 100644 --- a/back/admin/introductions/factories.py +++ b/back/admin/introductions/factories.py @@ -3,7 +3,7 @@ from pytest_factoryboy import register from admin.introductions.models import Introduction -from misc.mixins import DepartmentsPostGenerationMixin +from misc.factories import DepartmentsPostGenerationMixin from users.factories import EmployeeFactory diff --git a/back/admin/people/forms.py b/back/admin/people/forms.py index c383822c..e62c73aa 100644 --- a/back/admin/people/forms.py +++ b/back/admin/people/forms.py @@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _ from admin.integrations.models import Integration -from admin.sequences.models import Sequence +from admin.sequences.models import IntegrationConfig, Sequence from admin.sequences.selectors import get_onboarding_sequences_for_user from admin.templates.forms import ( MultiSelectField, @@ -456,7 +456,7 @@ def __init__(self, *args, **kwargs): class AddSequencesToUser(forms.Form): sequences = forms.ModelMultipleChoiceField( label=_("Select the sequences you want to add to this user"), - widget=forms.CheckboxSelectMultiple(attrs={"checked":"checked"}), + widget=forms.CheckboxSelectMultiple(attrs={"checked": "checked"}), queryset=Sequence.objects.none(), required=False, ) @@ -465,3 +465,18 @@ def __init__(self, *args, **kwargs): sequence_pks = kwargs.pop("sequence_pks") super().__init__(*args, **kwargs) self.fields["sequences"].queryset = Sequence.objects.filter(pk__in=sequence_pks) + + +class ItemsToBeRemovedForm(forms.Form): + integrations = forms.ModelMultipleChoiceField( + label=_("Select the integrations you want to remove from this user"), + widget=forms.CheckboxSelectMultiple(attrs={"checked": ""}), + queryset=IntegrationConfig.objects.none(), + required=False, + ) + + def __init__(self, *args, **kwargs): + # naming 'items' as it will likely be expanded to other types later + items = kwargs.pop("items") + super().__init__(*args, **kwargs) + self.fields["integrations"].queryset = items diff --git a/back/admin/people/templates/_departments_list.html b/back/admin/people/templates/_departments_list.html index f68903d9..9930bcef 100644 --- a/back/admin/people/templates/_departments_list.html +++ b/back/admin/people/templates/_departments_list.html @@ -19,23 +19,11 @@

    {{ department }}

    {% include "_departments_sequences_list.html" with sequences=department.sequences.all %} {% endif %} {% for role in department.roles.all %} -
    +

    {{ role }}

    {% if is_users_page %} - {% if role.users.all|length %} -
      - {% for user in role.users.all %} -
    • - {% if user.profile_image is not None %} - - {% else %} - {{ user.initials }} - {% endif %} - {{ user.name }} -
    • - {% endfor %} -
    - {% else %} + {% include "_departments_users_list.html" with users=role.users.all %} + {% if not role.users.all|length %} {% trans "No users have been added to this role yet." %} {% endif %} {% else %} diff --git a/back/admin/people/templates/_departments_list_with_remove_user_options_modal.html b/back/admin/people/templates/_departments_list_with_remove_user_options_modal.html new file mode 100644 index 00000000..62eca184 --- /dev/null +++ b/back/admin/people/templates/_departments_list_with_remove_user_options_modal.html @@ -0,0 +1,21 @@ +{% load i18n %} +{% load crispy_forms_tags %} +{% include "_departments_list.html" %} + + diff --git a/back/admin/people/templates/_departments_list_with_sequence_apply_modal.html b/back/admin/people/templates/_departments_list_with_sequence_apply_modal.html index 47ab0c66..28869b71 100644 --- a/back/admin/people/templates/_departments_list_with_sequence_apply_modal.html +++ b/back/admin/people/templates/_departments_list_with_sequence_apply_modal.html @@ -3,6 +3,7 @@ {% include "_departments_list.html" %} diff --git a/back/admin/people/templates/_departments_list_with_sequences_to_user_modal.html b/back/admin/people/templates/_departments_list_with_sequences_to_user_modal.html index 8f3bebcf..34fa63b3 100644 --- a/back/admin/people/templates/_departments_list_with_sequences_to_user_modal.html +++ b/back/admin/people/templates/_departments_list_with_sequences_to_user_modal.html @@ -3,6 +3,7 @@ {% include "_departments_list.html" %} diff --git a/back/admin/people/templates/_departments_users_list.html b/back/admin/people/templates/_departments_users_list.html new file mode 100644 index 00000000..025616ee --- /dev/null +++ b/back/admin/people/templates/_departments_users_list.html @@ -0,0 +1,19 @@ +{% load i18n %} +{% if users %} +
      +{% for user in users %} +
    • + {% if user.profile_image is not None %} + + {% else %} + {{ user.initials }} + {% endif %} + {{ user.name }} +
      + {% csrf_token %} + +
      +
    • +{% endfor %} +
    +{% endif %} diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index 0df087a4..838f52c6 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -130,11 +130,17 @@

    htmx.ajax(method, url + "?item=" + itemId, { target: "#department-modals", }).then(function () { - $('#department-modals').modal("toggle") + openModal() }) } }) +function openModal(){ + if($(".modal-content")[0]) { + $('#department-modals').modal("toggle") + } +} + document.addEventListener("dragover", function( event ) { // prevent default action event.preventDefault(); diff --git a/back/admin/people/tests/department_tests.py b/back/admin/people/tests/department_tests.py index 9f4aea8c..dcf3bfa5 100644 --- a/back/admin/people/tests/department_tests.py +++ b/back/admin/people/tests/department_tests.py @@ -222,7 +222,7 @@ def test_add_user_to_role_in_department( # user is not part of role assert user not in role.users.all() - url = reverse("people:add_user_to_role", args=[role.id]) + url = reverse("people:toggle_user_to_role", args=[role.id]) client.post(url + f"?item={user.id}") # user is part of role diff --git a/back/admin/people/urls.py b/back/admin/people/urls.py index e721381d..3c38e58a 100644 --- a/back/admin/people/urls.py +++ b/back/admin/people/urls.py @@ -260,8 +260,8 @@ ), path( "colleagues/role//user/", - views.AddUserToRoleView.as_view(), - name="add_user_to_role", + views.ToggleUserToRoleView.as_view(), + name="toggle_user_to_role", ), path( "colleagues/role//seq/", @@ -293,4 +293,9 @@ views.ApplySequencesToUserView.as_view(), name="apply_sequences_to_user", ), + path( + "colleagues/department/role//user//remove/", + views.RemoveItemsFromUserView.as_view(), + name="remove_items_from_user", + ), ] diff --git a/back/admin/people/views/departments.py b/back/admin/people/views/departments.py index e4c17f4d..16451af2 100644 --- a/back/admin/people/views/departments.py +++ b/back/admin/people/views/departments.py @@ -1,13 +1,18 @@ from django.contrib.messages.views import SuccessMessageMixin -from django.http import HttpResponse -from django.shortcuts import get_object_or_404, redirect, render +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse_lazy from django.utils.translation import gettext as _ from django.views.generic.base import View from django.views.generic.edit import CreateView, FormView, UpdateView from django.views.generic.list import ListView -from admin.people.forms import AddSequencesToUser, AddUsersToSequenceChoiceForm +from admin.people.forms import ( + AddSequencesToUser, + AddUsersToSequenceChoiceForm, + ItemsToBeRemovedForm, +) +from admin.sequences.models import IntegrationConfig from admin.sequences.selectors import ( get_onboarding_sequences_for_user, get_sequences_for_user, @@ -150,7 +155,7 @@ def get_context_data(self, **kwargs): return context -class AddUserToRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): +class ToggleUserToRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): def dispatch(self, *args, **kwargs): self.role = get_object_or_404( get_available_roles_for_user(user=self.request.user), @@ -162,33 +167,22 @@ def dispatch(self, *args, **kwargs): ) return super().dispatch(*args, **kwargs) - def _render_response(self): - sequence_pks = list( - self.role.sequences.all().values_list("pk", flat=True) - ) + list(self.role.department.sequences.all().values_list("pk", flat=True)) - form = AddSequencesToUser(sequence_pks=sequence_pks) - return render( - self.request, - "_departments_list_with_sequences_to_user_modal.html", - { - "departments": get_available_departments_for_user( - user=self.request.user - ).prefetch_related("roles__users"), - "is_users_page": True, - "modal_url": reverse_lazy( - "people:apply_sequences_to_user", args=[self.role.pk, self.user.pk] - ), - "form": form, - }, - ) - def delete(self, request, **kwargs): self.role.users.remove(self.user) - return self._render_response() + return HttpResponseRedirect( + reverse_lazy( + "people:remove_items_from_user", args=[self.role.pk, self.user.pk] + ), + status=303, + ) def post(self, request, **kwargs): self.role.users.add(self.user) - return self._render_response() + return redirect( + "people:apply_sequences_to_user", + user_pk=self.user.pk, + role_pk=self.role.pk, + ) class ToggleSequenceRoleView(AdminOrManagerPermMixin, SuccessMessageMixin, View): @@ -205,10 +199,12 @@ def dispatch(self, *args, **kwargs): def delete(self, request, **kwargs): self.role.sequences.remove(self.sequence) - return redirect( - "people:apply_sequence_to_users_in_role", - sequence=self.sequence.pk, - role_pk=self.role.pk, + return HttpResponseRedirect( + reverse_lazy( + "people:apply_sequence_to_users_in_role", + args=[self.sequence.pk, self.role.pk], + ), + status=303, ) def post(self, request, **kwargs): @@ -245,9 +241,11 @@ def get_form_kwargs(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["departments"] = get_available_departments_for_user(user=self.request.user).prefetch_related( - "roles__users" - ) + context["departments"] = get_available_departments_for_user( + user=self.request.user + ).prefetch_related("roles__users") + context["role"] = self.role + context["is_users_page"] = True context["modal_url"] = reverse_lazy( "people:apply_sequences_to_user", args=[self.role.pk, self.user.pk] ) @@ -271,28 +269,6 @@ def dispatch(self, *args, **kwargs): ) return super().dispatch(*args, **kwargs) - def _render_response(self): - roles = DepartmentRole.objects.filter(department=self.department).values_list( - "pk", flat=True - ) - users = User.objects.filter(department_roles__in=roles).distinct() - form = AddUsersToSequenceChoiceForm(users=users) - return render( - self.request, - "_departments_list_with_sequence_apply_modal.html", - { - "departments": get_available_departments_for_user( - user=self.request.user - ).prefetch_related("roles__users"), - "is_users_page": False, - "form": form, - "modal_url": reverse_lazy( - "people:apply_sequence_to_users_in_department", - args=[self.sequence.pk, self.department.pk], - ), - }, - ) - def post(self, request, *args, **kwargs): self.department.sequences.add(self.sequence) return redirect( @@ -303,7 +279,10 @@ def post(self, request, *args, **kwargs): def delete(self, request, *args, **kwargs): self.department.sequences.remove(self.sequence) - return self._render_response() + return HttpResponseRedirect( + reverse_lazy("people:departments_sequences"), + status=303, + ) class BaseApplySequenceToUsersView( @@ -318,6 +297,10 @@ def get_context_data(self, **kwargs): user=self.request.user ).prefetch_related("roles__users") context["is_users_page"] = False + context["role"] = self.role + context["department"] = self.department + context["sequence"] = self.sequence + context["modal_url"] = self.modal_url return context def form_valid(self, form): @@ -337,6 +320,7 @@ def dispatch(self, *args, **kwargs): get_available_roles_for_user(user=self.request.user), id=self.kwargs.get("role_pk"), ) + self.department = None self.modal_url = reverse_lazy( "people:apply_sequence_to_users_in_role", args=[self.sequence.pk, self.role.pk], @@ -359,6 +343,7 @@ def dispatch(self, *args, **kwargs): get_available_departments_for_user(user=self.request.user), id=self.kwargs.get("department_pk"), ) + self.role = None self.modal_url = reverse_lazy( "people:apply_sequence_to_users_in_department", args=[self.sequence.pk, self.department.pk], @@ -372,3 +357,50 @@ def get_form_kwargs(self): ) kwargs["users"] = User.objects.filter(department_roles__in=roles).distinct() return kwargs + + +class RemoveItemsFromUserView(AdminOrManagerPermMixin, SuccessMessageMixin, FormView): + form_class = ItemsToBeRemovedForm + template_name = "_departments_list_with_remove_user_options_modal.html" + + def dispatch(self, *args, **kwargs): + self.role = get_object_or_404( + get_available_roles_for_user(user=self.request.user), + id=self.kwargs.get("role_pk"), + ) + self.user = get_object_or_404( + get_all_users_for_departments_of_user(user=self.request.user), + id=self.kwargs.get("user_pk"), + ) + return super().dispatch(*args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + integration_items = IntegrationConfig.objects.none() + for seq in ( + self.role.sequences.all() | self.role.department.sequences.all() + ).prefetch_related("conditions__integration_configs__integration"): + for con in seq.conditions.all(): + integration_items |= con.integration_configs.filter( + integration__manifest__revoke__isnull=False + ) + kwargs["items"] = integration_items + return kwargs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["departments"] = get_available_departments_for_user( + user=self.request.user + ).prefetch_related("roles__users") + context["modal_url"] = reverse_lazy( + "people:apply_sequences_to_user", args=[self.role.pk, self.user.pk] + ) + context["is_users_page"] = True + return context + + def form_valid(self, form): + items = form.cleaned_data["items"] + for integrationconfig in items: + # TODO: error handling + integrationconfig.revoke_user(self.user) + return HttpResponse(headers={"HX-Trigger": "hide-modal"}) diff --git a/back/admin/preboarding/factories.py b/back/admin/preboarding/factories.py index 040edcf8..6f61b3a6 100644 --- a/back/admin/preboarding/factories.py +++ b/back/admin/preboarding/factories.py @@ -3,7 +3,7 @@ from pytest_factoryboy import register from admin.preboarding.models import Preboarding -from misc.mixins import DepartmentsPostGenerationMixin +from misc.factories import DepartmentsPostGenerationMixin @register diff --git a/back/admin/resources/factories.py b/back/admin/resources/factories.py index 46f3f50a..11674e20 100644 --- a/back/admin/resources/factories.py +++ b/back/admin/resources/factories.py @@ -3,7 +3,7 @@ from pytest_factoryboy import register from admin.resources.models import Category, Chapter, Resource -from misc.mixins import DepartmentsPostGenerationMixin +from misc.factories import DepartmentsPostGenerationMixin @register diff --git a/back/admin/sequences/factories.py b/back/admin/sequences/factories.py index 761479a7..94e3b1b0 100644 --- a/back/admin/sequences/factories.py +++ b/back/admin/sequences/factories.py @@ -8,7 +8,7 @@ from admin.preboarding.factories import PreboardingFactory from admin.resources.factories import ResourceFactory from admin.to_do.factories import ToDoFactory -from misc.mixins import DepartmentsPostGenerationMixin +from misc.factories import DepartmentsPostGenerationMixin from users.factories import AdminFactory, EmployeeFactory from .models import ( diff --git a/back/admin/sequences/models.py b/back/admin/sequences/models.py index fe72f95d..6ea16695 100644 --- a/back/admin/sequences/models.py +++ b/back/admin/sequences/models.py @@ -632,6 +632,9 @@ def requires_assigned_manager_or_buddy(self): self.person_type == IntegrationConfig.PersonType.BUDDY, ) + def __str__(self): + return self.integration.name + @property def name(self): return self.integration.name diff --git a/back/admin/to_do/factories.py b/back/admin/to_do/factories.py index ad5c2458..9d602b8b 100644 --- a/back/admin/to_do/factories.py +++ b/back/admin/to_do/factories.py @@ -3,7 +3,7 @@ from pytest_factoryboy import register from admin.to_do.models import ToDo -from misc.mixins import DepartmentsPostGenerationMixin +from misc.factories import DepartmentsPostGenerationMixin @register diff --git a/back/misc/factories.py b/back/misc/factories.py index 52d97cb4..8610cb36 100644 --- a/back/misc/factories.py +++ b/back/misc/factories.py @@ -11,3 +11,12 @@ class FileFactory(factory.django.DjangoModelFactory): class Meta: model = File + + +class DepartmentsPostGenerationMixin(factory.Factory): + @factory.post_generation + def departments(self, create, extracted, **kwargs): + if not create: + return + if extracted: + self.departments.set(extracted) diff --git a/back/misc/mixins.py b/back/misc/mixins.py index 2ef6014f..a1f50eb9 100644 --- a/back/misc/mixins.py +++ b/back/misc/mixins.py @@ -1,4 +1,3 @@ -import factory from django.core.exceptions import ValidationError from django.db.models import Q from django.utils.translation import gettext_lazy as _ @@ -270,12 +269,3 @@ def clean_departments(self): _("You cannot remove a department that you are not part of") ) return new_departments - - -class DepartmentsPostGenerationMixin(factory.Factory): - @factory.post_generation - def departments(self, create, extracted, **kwargs): - if not create: - return - if extracted: - self.departments.set(extracted) diff --git a/back/new_hire/templates/new_hire_base.html b/back/new_hire/templates/new_hire_base.html index 72d0630c..df2e9560 100644 --- a/back/new_hire/templates/new_hire_base.html +++ b/back/new_hire/templates/new_hire_base.html @@ -201,7 +201,7 @@

    {{ title }}

    - + - + From 2e416d07d77dbfd47ea9b73c173281d958ba69d8 Mon Sep 17 00:00:00 2001 From: Stan Triepels <1939656+GDay@users.noreply.github.com> Date: Sun, 30 Nov 2025 02:43:04 +0100 Subject: [PATCH 19/22] Allow setting new start date for existing users (roles) (#599) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- back/admin/people/forms.py | 4 + back/admin/people/templates/departments.html | 3 +- back/admin/people/tests/department_tests.py | 5 +- back/admin/people/views/access.py | 1 + back/admin/people/views/departments.py | 7 +- back/admin/people/views/new_hires.py | 20 +-- back/admin/sequences/models.py | 44 ++++-- back/admin/sequences/tasks.py | 59 +++++--- back/admin/sequences/tests.py | 7 +- back/api/views.py | 2 +- back/new_hire/tests.py | 55 ++++---- back/new_hire/views.py | 25 ++-- back/slack_bot/slack_to_do.py | 2 +- back/slack_bot/tasks.py | 16 ++- back/slack_bot/tests.py | 29 ++-- ...048_usercondition_alter_user_conditions.py | 92 +++++++++++++ ...sourceuser_base_date_todouser_base_date.py | 42 ++++++ ...0_alter_resourceuser_base_date_and_more.py | 27 ++++ ..._remove_resourceuser_base_date_and_more.py | 51 +++++++ back/users/models.py | 126 ++++++++++++++---- back/users/selectors.py | 9 ++ back/users/test_auth.py | 10 +- back/users/tests.py | 6 +- back/users/views.py | 5 +- 24 files changed, 513 insertions(+), 134 deletions(-) create mode 100644 back/users/migrations/0048_usercondition_alter_user_conditions.py create mode 100644 back/users/migrations/0049_resourceuser_base_date_todouser_base_date.py create mode 100644 back/users/migrations/0050_alter_resourceuser_base_date_and_more.py create mode 100644 back/users/migrations/0051_remove_resourceuser_base_date_and_more.py diff --git a/back/admin/people/forms.py b/back/admin/people/forms.py index e62c73aa..077ef2e0 100644 --- a/back/admin/people/forms.py +++ b/back/admin/people/forms.py @@ -454,6 +454,10 @@ def __init__(self, *args, **kwargs): class AddSequencesToUser(forms.Form): + start_day = forms.DateField( + label=_("Date when they will start in this new role"), + widget=forms.DateInput(attrs={"type": "date"}, format=("%Y-%m-%d")), + ) sequences = forms.ModelMultipleChoiceField( label=_("Select the sequences you want to add to this user"), widget=forms.CheckboxSelectMultiple(attrs={"checked": "checked"}), diff --git a/back/admin/people/templates/departments.html b/back/admin/people/templates/departments.html index 838f52c6..2895cbd3 100644 --- a/back/admin/people/templates/departments.html +++ b/back/admin/people/templates/departments.html @@ -55,7 +55,8 @@

    {% if is_users_page %} - {% trans "Drag and drop the users in the roles you want them to be part of." %} + {% trans "Drag and drop the users in the roles you want them to be part of." %}
    + {% trans "New hires are not included. Please convert them to a normal user first." %} {% else %} {% trans "Drag and drop the sequences in the roles." %} {% endif %} diff --git a/back/admin/people/tests/department_tests.py b/back/admin/people/tests/department_tests.py index dcf3bfa5..0ed8e728 100644 --- a/back/admin/people/tests/department_tests.py +++ b/back/admin/people/tests/department_tests.py @@ -10,7 +10,6 @@ def test_department_list( client, django_user_model, department_factory, - new_hire_factory, manager_factory, ): user = django_user_model.objects.create(role=get_user_model().Role.MANAGER) @@ -20,8 +19,8 @@ def test_department_list( dep2 = department_factory() user.departments.add(dep) - user1 = new_hire_factory(departments=[dep]) - user2 = new_hire_factory(departments=[dep2]) + user1 = manager_factory(departments=[dep]) + user2 = manager_factory(departments=[dep2]) user3 = manager_factory(departments=[dep]) # not part of any departments, so available everywhere user4 = manager_factory() diff --git a/back/admin/people/views/access.py b/back/admin/people/views/access.py index da0f8ec2..eeccf527 100644 --- a/back/admin/people/views/access.py +++ b/back/admin/people/views/access.py @@ -73,6 +73,7 @@ def get_context_data(self, **kwargs): def form_valid(self, form): EmailAddress.objects.filter(user=self.object).delete() + self.object.conditions.clear() return super().form_valid(form) diff --git a/back/admin/people/views/departments.py b/back/admin/people/views/departments.py index 16451af2..65ade67d 100644 --- a/back/admin/people/views/departments.py +++ b/back/admin/people/views/departments.py @@ -20,6 +20,7 @@ from users.mixins import AdminOrManagerPermMixin from users.models import Department, DepartmentRole, User from users.selectors import ( + get_all_normal_users_for_departments_of_user, get_all_users_for_departments_of_user, get_available_departments_for_user, get_available_roles_for_user, @@ -39,7 +40,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["title"] = _("Roles and departments") context["subtitle"] = _("people") - context["users_or_sequences"] = get_all_users_for_departments_of_user( + context["users_or_sequences"] = get_all_normal_users_for_departments_of_user( user=self.request.user ) context["is_users_page"] = True @@ -253,7 +254,7 @@ def get_context_data(self, **kwargs): def form_valid(self, form): sequences = form.cleaned_data["sequences"] - self.user.add_sequences(sequences) + self.user.add_sequences(sequences, start_date=form.cleaned_data["start_day"]) return HttpResponse(headers={"HX-Trigger": "hide-modal"}) @@ -306,7 +307,7 @@ def get_context_data(self, **kwargs): def form_valid(self, form): users = form.cleaned_data["users"] for user in users: - user.add_sequences([self.sequence]) + user.add_sequences([self.sequence], start_date=user.get_local_time().date()) return HttpResponse(headers={"HX-Trigger": "hide-modal"}) diff --git a/back/admin/people/views/new_hires.py b/back/admin/people/views/new_hires.py index 3e436866..7cdceb9b 100644 --- a/back/admin/people/views/new_hires.py +++ b/back/admin/people/views/new_hires.py @@ -116,12 +116,12 @@ def form_valid(self, form): # Check if there are items that will not be triggered since date passed conditions = Condition.objects.none() for seq in sequences: - if new_hire.workday == 0: + if new_hire.workday() == 0: # User has not started yet, so we only need the items before they new # hire started that passed conditions |= seq.conditions.filter( condition_type=Condition.Type.BEFORE, - days__gte=new_hire.days_before_starting, + days__gte=new_hire.days_before_starting(), ) else: # user has already started, check both before start day and after for @@ -129,7 +129,7 @@ def form_valid(self, form): conditions |= seq.conditions.filter( condition_type=Condition.Type.BEFORE ) | seq.conditions.filter( - condition_type=Condition.Type.AFTER, days__lte=new_hire.workday + condition_type=Condition.Type.AFTER, days__lte=new_hire.workday() ) if conditions.count(): @@ -202,12 +202,12 @@ def form_valid(self, form): # Check if there are items that will not be triggered since date passed conditions = Condition.objects.none() for seq in sequences: - if new_hire.workday == 0: + if new_hire.workday() == 0: # User has not started yet, so we only need the items before they new # hire started that passed conditions |= seq.conditions.filter( condition_type=Condition.Type.BEFORE, - days__gte=new_hire.days_before_starting, + days__gte=new_hire.days_before_starting(), ) else: # user has already started, check both before start day and after for @@ -215,7 +215,7 @@ def form_valid(self, form): conditions |= seq.conditions.filter( condition_type=Condition.Type.BEFORE ) | seq.conditions.filter( - condition_type=Condition.Type.AFTER, days__lte=new_hire.workday + condition_type=Condition.Type.AFTER, days__lte=new_hire.workday() ) if conditions.count(): @@ -274,7 +274,9 @@ def post(self, request, pk, condition_pk, *args, **kwargs): new_hire = get_object_or_404( get_colleagues_for_user(user=self.request.user), id=pk ) - condition.process_condition(new_hire, skip_notification=True) + condition.process_condition( + new_hire, start_date=timezone.now(), skip_notification=True + ) # Update user amount completed new_hire.update_progress() @@ -325,10 +327,10 @@ def get_context_data(self, **kwargs): context["conditions"] = ( ( conditions.filter( - condition_type=2, days__lte=new_hire.days_before_starting + condition_type=2, days__lte=new_hire.days_before_starting() ) | conditions.filter( - condition_type=Condition.Type.AFTER, days__gte=new_hire.workday + condition_type=Condition.Type.AFTER, days__gte=new_hire.workday() ) | conditions.filter(condition_type=Condition.Type.TODO) | conditions.filter(condition_type=Condition.Type.ADMIN_TASK) diff --git a/back/admin/sequences/models.py b/back/admin/sequences/models.py index 6ea16695..6a184877 100644 --- a/back/admin/sequences/models.py +++ b/back/admin/sequences/models.py @@ -97,7 +97,12 @@ def duplicate(self): self.conditions.add(new_condition) return self - def assign_to_user(self, user): + def assign_to_user(self, user, start_date=None): + from users.models import UserCondition + + if start_date is None: + start_date = user.start_day + # adding conditions for sequence_condition in self.conditions.all(): user_condition = None @@ -108,11 +113,14 @@ def assign_to_user(self, user): Condition.Type.AFTER, ]: # Get the timed based condition or return None if not exist - user_condition = user.conditions.filter( - condition_type=sequence_condition.condition_type, - days=sequence_condition.days, - time=sequence_condition.time, + user_condition_through = UserCondition.objects.filter( + user=user, + condition__days=sequence_condition.days, + condition__time=sequence_condition.time, + role_start_date=start_date, ).first() + if user_condition_through is not None: + user_condition = user_condition_through.condition elif sequence_condition.condition_type == Condition.Type.TODO: # For to_do items, filter all condition items to find if one matches @@ -179,7 +187,7 @@ def assign_to_user(self, user): else: # Condition (always just one) that will be assigned directly (type == 3) # Just run the condition with the new hire - sequence_condition.process_condition(user) + sequence_condition.process_condition(user, start_date=start_date) continue # Let's add the condition to the new hire. Either through adding it to the @@ -208,7 +216,9 @@ def assign_to_user(self, user): sequence_condition.include_other_condition(old_condition) # Add newly created condition back to user - user.conditions.add(sequence_condition) + UserCondition.objects.create( + user=user, condition=sequence_condition, role_start_date=start_date + ) def remove_from_user(self, new_hire): from admin.admin_tasks.models import AdminTask @@ -909,7 +919,14 @@ def duplicate(self, admin_tasks): # returning the new item return self, admin_tasks - def process_condition(self, user, skip_notification=False): + def process_condition(self, user, start_date=None, skip_notification=False): + from users.models import ResourceUser, ToDoUser, UserCondition + + if start_date is None: + start_date = UserCondition.objects.get( + user=user, condition=self + ).role_start_date + # Loop over all m2m fields and add the ones that can be easily added for field in [ "to_do", @@ -920,7 +937,16 @@ def process_condition(self, user, skip_notification=False): "preboarding", ]: for item in getattr(self, field).all(): - getattr(user, field).add(item) + if field == "to_do": + ToDoUser.objects.create( + user=user, to_do=item, role_start_date=start_date + ) + elif field == "resources": + ResourceUser.objects.create( + user=user, resource=item, role_start_date=start_date + ) + else: + getattr(user, field).add(item) Notification.objects.create( notification_type=item.notification_add_type, diff --git a/back/admin/sequences/tasks.py b/back/admin/sequences/tasks.py index 1547561c..1b69e830 100644 --- a/back/admin/sequences/tasks.py +++ b/back/admin/sequences/tasks.py @@ -14,7 +14,7 @@ from slack_bot.slack_resource import SlackResource from slack_bot.slack_to_do import SlackToDo from slack_bot.utils import Slack, paragraph -from users.models import ResourceUser, ToDoUser +from users.models import ResourceUser, ToDoUser, UserCondition def process_condition(condition_id, user_id, send_email=True): @@ -163,28 +163,49 @@ def timed_triggers(): org.timed_triggers_last_check = last_updated org.save() - for user in get_user_model().new_hires.all(): - amount_days = user.workday - amount_days_before = user.days_before_starting + # all users excluding those who are offboarding + for user in get_user_model().objects.exclude(termination_date__isnull=False): current_time = user.get_local_time(last_updated).time() + before_conditions = UserCondition.objects.filter( + user=user, condition__condition_type=Condition.Type.BEFORE + ).distinct("role_start_date") + after_conditions = UserCondition.objects.filter( + user=user, condition__condition_type=Condition.Type.AFTER + ).distinct("role_start_date") + before_start_date_workday_map = { + con.role_start_date: user.days_before_starting(con.role_start_date) + for con in before_conditions + } + after_start_date_workday_map = { + con.role_start_date: user.workday(con.role_start_date) + for con in after_conditions + } + # Get conditions before/after they started - # Generally, this should be only one, but just in case, we can handle more conditions = Condition.objects.none() - if amount_days == 0: - # Before starting - conditions = user.conditions.filter( - condition_type=Condition.Type.BEFORE, - days=amount_days_before, - time=current_time, + # Before starting + for ( + start_date, + days_before_starting, + ) in before_start_date_workday_map.items(): + conditions |= UserCondition.objects.filter( + user=user, + role_start_date=start_date, + condition__condition_type=Condition.Type.BEFORE, + condition__days=days_before_starting, + condition__time=current_time, ) - elif user.get_local_time(last_updated).weekday() < 5: + if user.get_local_time(last_updated).weekday() < 5: # On workday x - conditions = user.conditions.filter( - condition_type=Condition.Type.AFTER, - days=amount_days, - time=current_time, - ) + for start_date, workday in after_start_date_workday_map.items(): + conditions |= UserCondition.objects.filter( + user=user, + role_start_date=start_date, + condition__condition_type=Condition.Type.AFTER, + condition__days=workday, + condition__time=current_time, + ) # Schedule conditions to be executed with new scheduled task, we do this to # avoid long standing tasks. I.e. sending lots of emails might take more @@ -192,9 +213,9 @@ def timed_triggers(): for i in conditions: async_task( process_condition, - i.id, + i.condition.id, user.id, - task_name=f"Process condition: {i.id} for {user.full_name}", + task_name=f"Process condition: {i.condition.id} for {user.full_name}", ) for user in get_user_model().offboarding.all(): diff --git a/back/admin/sequences/tests.py b/back/admin/sequences/tests.py index 53968cb7..d6f0ea4a 100644 --- a/back/admin/sequences/tests.py +++ b/back/admin/sequences/tests.py @@ -1985,7 +1985,7 @@ def test_execute_integration_revoke( "admin.integrations.models.Integration.execute", Mock(return_value=(True, "")), ) as execute_mock: - condition.process_condition(employee) + condition.process_condition(employee, start_date=timezone.now().date()) assert execute_mock.called # integration has revoke part and employee is being offboarded @@ -1997,7 +1997,7 @@ def test_execute_integration_revoke( "admin.integrations.models.Integration.revoke_user", Mock(return_value=(True, "")), ) as revoke_user_mock: - condition.process_condition(employee) + condition.process_condition(employee, start_date=timezone.now().date()) assert revoke_user_mock.called integration.manifest = { @@ -2011,7 +2011,7 @@ def test_execute_integration_revoke( "admin.integrations.models.Integration.execute", Mock(return_value=(True, "")), ) as execute_mock: - condition.process_condition(employee) + condition.process_condition(employee, start_date=timezone.now().date()) assert execute_mock.called @@ -2097,6 +2097,7 @@ def test_send_slack_message_after_process_condition( condition.to_do.add(to_do) # New hire with Slack account new_hire = new_hire_factory(slack_user_id="test") + new_hire.conditions.add(condition) process_condition(condition.id, new_hire.id) diff --git a/back/api/views.py b/back/api/views.py index 37a2ac0a..1f788b73 100644 --- a/back/api/views.py +++ b/back/api/views.py @@ -40,7 +40,7 @@ def perform_create(self, serializer): # Add sequences to new hire if sequences is not None: sequences = Sequence.objects.filter(id__in=sequences) - user.add_sequences(sequences) + user.add_sequences(sequences, serializer.validated_data["start_day"]) # Send credentials email if the user was created after their start day org = Organization.object.get() diff --git a/back/new_hire/tests.py b/back/new_hire/tests.py index 779425c9..a16afd5d 100644 --- a/back/new_hire/tests.py +++ b/back/new_hire/tests.py @@ -15,21 +15,30 @@ @pytest.mark.django_db @freeze_time("2021-01-12") def test_show_to_do_view(client, new_hire_factory, to_do_user_factory): - new_hire = new_hire_factory( - start_day=datetime.datetime.fromisoformat("2021-01-12").date() - ) + start_day = datetime.datetime.fromisoformat("2021-01-12").date() + new_hire = new_hire_factory(start_day=start_day) client.force_login(new_hire) url = reverse("new_hire:todos") - to_do_item1 = to_do_user_factory(to_do__due_on_day=1, user=new_hire) - to_do_item2 = to_do_user_factory(to_do__due_on_day=1, user=new_hire) - to_do_item3 = to_do_user_factory(to_do__due_on_day=2, user=new_hire) - to_do_item4 = to_do_user_factory(to_do__due_on_day=5, user=new_hire) - to_do_item5 = to_do_user_factory(to_do__due_on_day=5, user=new_hire) + to_do_item1 = to_do_user_factory( + to_do__due_on_day=1, user=new_hire, role_start_date=start_day + ) + to_do_item2 = to_do_user_factory( + to_do__due_on_day=1, user=new_hire, role_start_date=start_day + ) + to_do_item3 = to_do_user_factory( + to_do__due_on_day=2, user=new_hire, role_start_date=start_day + ) + to_do_item4 = to_do_user_factory( + to_do__due_on_day=5, user=new_hire, role_start_date=start_day + ) + to_do_item5 = to_do_user_factory( + to_do__due_on_day=5, user=new_hire, role_start_date=start_day + ) # Should not be considered - different user - to_do_item6 = to_do_user_factory(to_do__due_on_day=2) + to_do_item6 = to_do_user_factory(to_do__due_on_day=2, role_start_date=start_day) to_do_items = [ to_do_item1.to_do, @@ -70,17 +79,21 @@ def test_show_to_do_view(client, new_hire_factory, to_do_user_factory): @pytest.mark.django_db @freeze_time("2021-01-13") def test_show_over_due_to_do_view(client, new_hire_factory, to_do_user_factory): - new_hire = new_hire_factory( - start_day=datetime.datetime.fromisoformat("2021-01-12").date() - ) + start_day = datetime.datetime.fromisoformat("2021-01-12").date() + new_hire = new_hire_factory(start_day=start_day) client.force_login(new_hire) url = reverse("new_hire:todos") # overdue - to_do_item1 = to_do_user_factory(to_do__due_on_day=1, user=new_hire) + to_do_item1 = to_do_user_factory( + to_do__due_on_day=1, user=new_hire, role_start_date=start_day + ) + # not overdue - to_do_item2 = to_do_user_factory(to_do__due_on_day=2, user=new_hire) + to_do_item2 = to_do_user_factory( + to_do__due_on_day=2, user=new_hire, role_start_date=start_day + ) response = client.get(url) @@ -92,20 +105,6 @@ def test_show_over_due_to_do_view(client, new_hire_factory, to_do_user_factory): assert response.context["to_do_items"][0]["items"][0] == to_do_item2 -@pytest.mark.django_db -def test_404_to_do_view_for_admin(client, admin_factory, to_do_user_factory): - admin = admin_factory() - client.force_login(admin) - - to_do_user_factory(user=admin) - - url = reverse("new_hire:todos") - - response = client.get(url) - - assert response.status_code == 404 - - @pytest.mark.django_db def test_to_do_item_view(client, new_hire_factory, to_do_user_factory): new_hire = new_hire_factory() diff --git a/back/new_hire/views.py b/back/new_hire/views.py index 5c2b2854..d2dd01d2 100644 --- a/back/new_hire/views.py +++ b/back/new_hire/views.py @@ -34,18 +34,12 @@ class NewHireDashboard(TemplateView): def get_context_data(self, **kwargs): new_hire = self.request.user - # Check that user is allowed to see page (only new hires) - if new_hire.role != get_user_model().Role.NEWHIRE: - raise Http404 - context = super().get_context_data(**kwargs) context["overdue_to_do_items"] = ToDoUser.objects.overdue(new_hire) to_do_items = ( - ToDoUser.objects.filter( - user=new_hire, to_do__due_on_day__gte=new_hire.workday - ) + ToDoUser.objects.upcoming_items(user=new_hire) .select_related("to_do") .defer("to_do__content") .order_by("to_do__due_on_day") @@ -57,10 +51,16 @@ def get_context_data(self, **kwargs): # Check if to do day is already in any of the new items_by_date to_do = to_do_user.to_do if not any( - [item for item in items_by_date if item["day"] == to_do.due_on_day] + [ + item + for item in items_by_date + if item["day"] == to_do.due_on_day + and item["role_start_date"] == to_do_user.role_start_date + ] ): new_date = { "day": to_do.due_on_day, + "role_start_date": to_do_user.role_start_date, "items": [ to_do_user, ], @@ -69,7 +69,10 @@ def get_context_data(self, **kwargs): else: # If it does exist, then add it to the array of that type existing_dates = [ - item for item in items_by_date if item["day"] == to_do.due_on_day + item + for item in items_by_date + if item["day"] == to_do.due_on_day + and item["role_start_date"] == to_do_user.role_start_date ] # Can never be more than one, since it's catching it if it already # exists @@ -77,7 +80,9 @@ def get_context_data(self, **kwargs): # Convert days to date object for obj in items_by_date: - obj["date"] = self.request.user.workday_to_datetime(obj["day"]) + obj["date"] = self.request.user.workday_to_datetime( + obj["day"], start_day=obj["role_start_date"] + ) context["to_do_items"] = items_by_date diff --git a/back/slack_bot/slack_to_do.py b/back/slack_bot/slack_to_do.py index dddb6b5c..26533f3c 100644 --- a/back/slack_bot/slack_to_do.py +++ b/back/slack_bot/slack_to_do.py @@ -14,7 +14,7 @@ def __init__(self, to_do_user, user): self.user = user def footer_text(self): - workday = self.user.workday + workday = self.user.workday(self.to_do_user.role_start_date) if self.to_do_user.to_do.due_on_day == 0: return _("This task has no deadline.") if (self.to_do_user.to_do.due_on_day - workday) < 0: diff --git a/back/slack_bot/tasks.py b/back/slack_bot/tasks.py index 316f9ad4..17066729 100644 --- a/back/slack_bot/tasks.py +++ b/back/slack_bot/tasks.py @@ -102,9 +102,21 @@ def update_new_hire(): overdue_items = ToDoUser.objects.overdue(user) tasks = ToDoUser.objects.due_today(user) | overdue_items - courses_due = ResourceUser.objects.filter( - user=user, resource__on_day__lte=user.workday + resource_items_queryset = ResourceUser.objects.filter(user=user).distinct( + "role_start_date" ) + start_date_workday_map = { + item.role_start_date: user.workday(item.role_start_date) + for item in resource_items_queryset + } + courses_due = ResourceUser.objects.none() + for start_date, workday in start_date_workday_map.items(): + courses_due |= ResourceUser.objects.filter( + user=user, + resource__on_day__lte=workday, + role_start_date=start_date, + ) + # Filter out completed courses course_blocks = [ SlackResource(course, user).get_block() diff --git a/back/slack_bot/tests.py b/back/slack_bot/tests.py index 8d099e25..978dde89 100644 --- a/back/slack_bot/tests.py +++ b/back/slack_bot/tests.py @@ -219,17 +219,28 @@ def test_slack_show_resources_items_in_category( @pytest.mark.django_db @freeze_time("2022-05-13") def test_slack_show_to_do_items_based_on_message(new_hire_factory, to_do_user_factory): - new_hire = new_hire_factory( - slack_user_id="slackx", start_day=datetime.now().today() - timedelta(days=2) - ) + start_day = datetime.now().today() - timedelta(days=2) + new_hire = new_hire_factory(slack_user_id="slackx", start_day=start_day) - to_do_due_in_past1 = to_do_user_factory(to_do__due_on_day=1, user=new_hire) - to_do_due_in_past2 = to_do_user_factory(to_do__due_on_day=1, user=new_hire) - to_do_due_today1 = to_do_user_factory(to_do__due_on_day=3, user=new_hire) - to_do_due_today2 = to_do_user_factory(to_do__due_on_day=3, user=new_hire) + to_do_due_in_past1 = to_do_user_factory( + to_do__due_on_day=1, user=new_hire, role_start_date=start_day + ) + to_do_due_in_past2 = to_do_user_factory( + to_do__due_on_day=1, user=new_hire, role_start_date=start_day + ) + to_do_due_today1 = to_do_user_factory( + to_do__due_on_day=3, user=new_hire, role_start_date=start_day + ) + to_do_due_today2 = to_do_user_factory( + to_do__due_on_day=3, user=new_hire, role_start_date=start_day + ) - to_do_due_future1 = to_do_user_factory(to_do__due_on_day=10, user=new_hire) - to_do_due_future2 = to_do_user_factory(to_do__due_on_day=5, user=new_hire) + to_do_due_future1 = to_do_user_factory( + to_do__due_on_day=10, user=new_hire, role_start_date=start_day + ) + to_do_due_future2 = to_do_user_factory( + to_do__due_on_day=5, user=new_hire, role_start_date=start_day + ) # test without extra text slack_show_to_do_items_based_on_message( diff --git a/back/users/migrations/0048_usercondition_alter_user_conditions.py b/back/users/migrations/0048_usercondition_alter_user_conditions.py new file mode 100644 index 00000000..fec3d9c7 --- /dev/null +++ b/back/users/migrations/0048_usercondition_alter_user_conditions.py @@ -0,0 +1,92 @@ +# Generated by Django 5.2.8 on 2025-11-27 16:51 + +import django.db.models.deletion +from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import migrations, models + + +class Migration(migrations.Migration): + def set_user_condition_role_start_date(apps, schema_editor): + UserCondition = apps.get_model("users", "UserCondition") + User = apps.get_model("users", "User") + for user in User.objects.all(): + UserCondition.objects.filter(user=user).update( + role_start_date=user.start_day + ) + + def clear_conditions_for_normal_users(apps, schema_editor): + User = apps.get_model("users", "User") + for user in User.objects.exclude(role=get_user_model().Role.NEWHIRE).exclude( + termination_date__isnull=False + ): + user.conditions.clear() + + dependencies = [ + ("sequences", "0046_alter_sequence_options_sequence_departments"), + ("users", "0047_alter_department_name"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[ + # Old table name from checking with sqlmigrate, new table + # name from UserCondition._meta.db_table. + migrations.RunSQL( + sql="ALTER TABLE users_user_conditions RENAME TO users_usercondition", + reverse_sql="ALTER TABLE users_usercondition RENAME TO users_user_conditions", + ), + ], + state_operations=[ + migrations.CreateModel( + name="UserCondition", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "condition", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="sequences.condition", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "condition")}, + }, + ), + migrations.AlterField( + model_name="user", + name="conditions", + field=models.ManyToManyField( + through="users.UserCondition", to="sequences.condition" + ), + ), + ], + ), + migrations.AddField( + model_name="UserCondition", + name="base_date", + field=models.DateField(auto_now=True), + ), + migrations.RunPython( + set_user_condition_role_start_date, reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + clear_conditions_for_normal_users, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/back/users/migrations/0049_resourceuser_base_date_todouser_base_date.py b/back/users/migrations/0049_resourceuser_base_date_todouser_base_date.py new file mode 100644 index 00000000..491e4c1d --- /dev/null +++ b/back/users/migrations/0049_resourceuser_base_date_todouser_base_date.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.8 on 2025-11-27 23:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + def set_resource_user_role_start_date(apps, schema_editor): + ResourceUser = apps.get_model("users", "ResourceUser") + User = apps.get_model("users", "User") + for user in User.objects.all(): + ResourceUser.objects.filter(user=user).update( + role_start_date=user.start_day + ) + + def set_to_do_user_role_start_date(apps, schema_editor): + ToDoUser = apps.get_model("users", "ToDoUser") + User = apps.get_model("users", "User") + for user in User.objects.all(): + ToDoUser.objects.filter(user=user).update(role_start_date=user.start_day) + + dependencies = [ + ("users", "0048_usercondition_alter_user_conditions"), + ] + + operations = [ + migrations.AddField( + model_name="resourceuser", + name="base_date", + field=models.DateField(auto_now=True), + ), + migrations.AddField( + model_name="todouser", + name="base_date", + field=models.DateField(auto_now=True), + ), + migrations.RunPython( + set_resource_user_role_start_date, reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + set_to_do_user_role_start_date, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/back/users/migrations/0050_alter_resourceuser_base_date_and_more.py b/back/users/migrations/0050_alter_resourceuser_base_date_and_more.py new file mode 100644 index 00000000..99b0b3cb --- /dev/null +++ b/back/users/migrations/0050_alter_resourceuser_base_date_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.8 on 2025-11-28 04:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0049_resourceuser_base_date_todouser_base_date"), + ] + + operations = [ + migrations.AlterField( + model_name="resourceuser", + name="base_date", + field=models.DateField(default=None, null=True), + ), + migrations.AlterField( + model_name="todouser", + name="base_date", + field=models.DateField(default=None, null=True), + ), + migrations.AlterField( + model_name="usercondition", + name="base_date", + field=models.DateField(default=None, null=True), + ), + ] diff --git a/back/users/migrations/0051_remove_resourceuser_base_date_and_more.py b/back/users/migrations/0051_remove_resourceuser_base_date_and_more.py new file mode 100644 index 00000000..6bc91bf6 --- /dev/null +++ b/back/users/migrations/0051_remove_resourceuser_base_date_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.8 on 2025-11-30 00:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0050_alter_resourceuser_base_date_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="resourceuser", + name="base_date", + ), + migrations.RemoveField( + model_name="todouser", + name="base_date", + ), + migrations.RemoveField( + model_name="usercondition", + name="base_date", + ), + migrations.AddField( + model_name="resourceuser", + name="role_start_date", + field=models.DateField( + default=None, + help_text="Date used to calculate due date for to do item.", + null=True, + ), + ), + migrations.AddField( + model_name="todouser", + name="role_start_date", + field=models.DateField( + default=None, + help_text="Date used to calculate due date for to do item.", + null=True, + ), + ), + migrations.AddField( + model_name="usercondition", + name="role_start_date", + field=models.DateField( + default=None, + help_text="Date used to calculate when to trigger the condition", + null=True, + ), + ), + ] diff --git a/back/users/models.py b/back/users/models.py index c6fd8dd9..8c9a528a 100644 --- a/back/users/models.py +++ b/back/users/models.py @@ -68,6 +68,19 @@ def __str__(self): return "%s" % self.name +class UserCondition(models.Model): + user = models.ForeignKey("users.User", on_delete=models.CASCADE) + condition = models.ForeignKey(Condition, on_delete=models.CASCADE) + role_start_date = models.DateField( + default=None, + null=True, + help_text=_("Date used to calculate when to trigger the condition"), + ) + + class Meta: + unique_together = ["user", "condition"] + + class CustomUserManager(BaseUserManager): def get_by_natural_key(self, email): # Make validation case sensitive @@ -252,7 +265,7 @@ class Role(models.IntegerChoices): ) # Conditions copied over from chosen sequences - conditions = models.ManyToManyField(Condition) + conditions = models.ManyToManyField(Condition, through=UserCondition) USERNAME_FIELD = "email" REQUIRED_FIELDS = ["first_name", "last_name"] @@ -444,9 +457,12 @@ def save(self, *args, **kwargs): self.unique_url = unique_string super(User, self).save(*args, **kwargs) - def add_sequences(self, sequences): + def add_sequences(self, sequences, start_date=None): + if start_date is None: + # fall back to user termination date or start date + start_date = self.termination_date or self.start_day for sequence in sequences: - sequence.assign_to_user(self) + sequence.assign_to_user(self, start_date) Notification.objects.create( notification_type=Notification.Type.ADDED_SEQUENCE, item_id=sequence.id, @@ -457,9 +473,9 @@ def add_sequences(self, sequences): def remove_sequence(self, sequence): sequence.remove_from_user(self) - @cached_property - def workday(self): - start_day = self.start_day + def workday(self, start_day=None): + if start_day is None: + start_day = self.start_day local_day = self.get_local_time().date() if start_day > local_day: @@ -473,8 +489,9 @@ def workday(self): return amount_of_workdays - def workday_to_datetime(self, workdays): - start_day = self.start_day + def workday_to_datetime(self, workdays, start_day=None): + if start_day is None: + start_day = self.start_day if workdays == 0: return None @@ -488,14 +505,14 @@ def workday_to_datetime(self, workdays): def offboarding_workday_to_date(self, workdays): # Converts the workday (before the end date) to the actual date on which it # triggers. This will skip any weekends. - base_date = self.termination_date + term_date = self.termination_date while workdays > 0: - base_date -= timedelta(days=1) - if base_date.weekday() not in [5, 6]: + term_date -= timedelta(days=1) + if term_date.weekday() not in [5, 6]: workdays -= 1 - return base_date + return term_date @cached_property def days_before_termination_date(self): @@ -515,12 +532,13 @@ def days_before_termination_date(self): days += 1 return days - @cached_property - def days_before_starting(self): + def days_before_starting(self, start_day=None): + if start_day is None: + start_day = self.start_day # not counting workdays here - if self.start_day <= self.get_local_time().date(): + if start_day <= self.get_local_time().date(): return 0 - return (self.start_day - self.get_local_time().date()).days + return (start_day - self.get_local_time().date()).days def get_local_time(self, date=None): from organization.models import Organization @@ -624,23 +642,71 @@ def __str__(self): class ToDoUserManager(models.Manager): + def _start_date_to_workday_mapping(self, user, **kwargs): + to_do_items_queryset = self.filter(user=user, **kwargs).distinct( + "role_start_date" + ) + return { + item.role_start_date: user.workday(item.role_start_date) + for item in to_do_items_queryset + } + def all_to_do(self, user): return super().get_queryset().filter(user=user, completed=False) def overdue(self, user): - return ( - super() - .get_queryset() - .filter(user=user, completed=False, to_do__due_on_day__lt=user.workday) - .exclude(to_do__due_on_day=0) + start_date_workday_map = self._start_date_to_workday_mapping( + user=user, completed=False ) + objs = ToDoUser.objects.none() + for start_date, workday in start_date_workday_map.items(): + objs |= ( + super() + .get_queryset() + .filter( + user=user, + completed=False, + to_do__due_on_day__lt=workday, + role_start_date=start_date, + ) + .exclude(to_do__due_on_day=0) + ) + return objs + def due_today(self, user): - return ( - super() - .get_queryset() - .filter(user=user, completed=False, to_do__due_on_day=user.workday) + start_date_workday_map = self._start_date_to_workday_mapping( + user=user, completed=False ) + objs = ToDoUser.objects.none() + for start_date, workday in start_date_workday_map.items(): + objs |= ( + super() + .get_queryset() + .filter( + user=user, + completed=False, + to_do__due_on_day=workday, + role_start_date=start_date, + ) + ) + return objs + + def upcoming_items(self, user): + start_date_workday_map = self._start_date_to_workday_mapping(user=user) + objs = ToDoUser.objects.none() + for start_date, workday in start_date_workday_map.items(): + objs |= ( + super() + .get_queryset() + .filter( + user=user, + completed=False, + to_do__due_on_day__gte=workday, + role_start_date=start_date, + ) + ) + return objs class ToDoUser(CompletedFormCheck, models.Model): @@ -648,6 +714,11 @@ class ToDoUser(CompletedFormCheck, models.Model): get_user_model(), related_name="to_do_new_hire", on_delete=models.CASCADE ) to_do = models.ForeignKey(ToDo, related_name="to_do", on_delete=models.CASCADE) + role_start_date = models.DateField( + default=None, + null=True, + help_text=_("Date used to calculate due date for to do item."), + ) completed = models.BooleanField(default=False) form = models.JSONField(default=list) reminded = models.DateTimeField(null=True) @@ -739,6 +810,11 @@ class ResourceUser(models.Model): answers = models.ManyToManyField(CourseAnswer) reminded = models.DateTimeField(null=True) completed_course = models.BooleanField(default=False) + role_start_date = models.DateField( + default=None, + null=True, + help_text=_("Date used to calculate due date for to do item."), + ) def add_step(self): self.step += 1 diff --git a/back/users/selectors.py b/back/users/selectors.py index 1175c846..fc841fb1 100644 --- a/back/users/selectors.py +++ b/back/users/selectors.py @@ -31,6 +31,15 @@ def get_all_users_for_departments_of_user(*, user): return get_user_model().objects.filter(get_departments_query(user=user)).distinct() +def get_all_normal_users_for_departments_of_user(*, user): + return ( + get_user_model() + .objects.filter(get_departments_query(user=user)) + .exclude(role=get_user_model().Role.NEWHIRE) + .distinct() + ) + + def get_all_managers_and_admins_for_departments_of_user(*, user): return ( get_user_model() diff --git a/back/users/test_auth.py b/back/users/test_auth.py index 0ccff80d..bd2fd6e5 100644 --- a/back/users/test_auth.py +++ b/back/users/test_auth.py @@ -3,6 +3,8 @@ from django.test import override_settings from django.urls import reverse +from users.models import User + @pytest.mark.django_db @pytest.mark.parametrize( @@ -34,10 +36,10 @@ def test_login_data_validation(email, password, logged_in, client, new_hire_fact @pytest.mark.parametrize( "role, redirect_url", [ - (0, "/new_hire/todos/"), - (3, "/new_hire/colleagues/"), - (1, "/admin/people/"), - (2, "/admin/people/"), + (User.Role.NEWHIRE, "/new_hire/todos/"), + (User.Role.OTHER, "/new_hire/todos/"), + (User.Role.ADMIN, "/admin/people/"), + (User.Role.MANAGER, "/admin/people/"), ], ) def test_redirect_after_login(role, redirect_url, client, new_hire_factory): diff --git a/back/users/tests.py b/back/users/tests.py index 5a3042eb..f0bf6f2a 100644 --- a/back/users/tests.py +++ b/back/users/tests.py @@ -62,7 +62,7 @@ def test_workday(date, workday, new_hire_factory): freezer = freeze_time(date) freezer.start() - assert user.workday == workday + assert user.workday() == workday freezer.stop() @@ -161,7 +161,7 @@ def test_days_before_starting(date, daybefore, new_hire_factory): freezer = freeze_time(date) freezer.start() - assert user.days_before_starting == daybefore + assert user.days_before_starting() == daybefore freezer.stop() @@ -504,7 +504,7 @@ def test_integration_user_trigger( condition.to_do.add(to_do_factory()) seq = sequence_factory() seq.conditions.add(condition) - employee.add_sequences([seq]) + employee.add_sequences([seq], employee.get_local_time().date()) # no items yet, because user access items have not been revoked yet assert employee.to_do.count() == 0 diff --git a/back/users/views.py b/back/users/views.py index e8a2c16f..138c6320 100644 --- a/back/users/views.py +++ b/back/users/views.py @@ -1,4 +1,3 @@ -from django.contrib.auth import get_user_model from django.shortcuts import redirect from django.views.generic import View @@ -7,7 +6,5 @@ class LoginRedirectView(View): def get(self, request, *args, **kwargs): if request.user.is_admin_or_manager: return redirect("admin:new_hires") - elif request.user.role == get_user_model().Role.NEWHIRE: - return redirect("new_hire:todos") else: - return redirect("new_hire:colleagues") + return redirect("new_hire:todos") From aa300ca3c118db13295a41aefa243aecdcb7e5f0 Mon Sep 17 00:00:00 2001 From: Stan Triepels <1939656+GDay@users.noreply.github.com> Date: Sun, 30 Nov 2025 04:33:45 +0100 Subject: [PATCH 20/22] Show feedback when integration failed (#600) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- back/admin/integrations/builder_views.py | 3 +- back/admin/integrations/models.py | 11 +++-- back/admin/integrations/tests.py | 16 +++---- back/admin/people/revoke_result.py | 7 +++ ...s_list_with_remove_user_options_modal.html | 2 +- .../templates/_departments_users_list.html | 2 +- .../_integration_revoke_results.html | 19 ++++++++ back/admin/people/templates/departments.html | 12 ++--- back/admin/people/tests/tests.py | 7 +-- back/admin/people/urls.py | 5 -- back/admin/people/views/access.py | 7 +-- back/admin/people/views/departments.py | 47 +++++++++++++++---- back/admin/people/views/new_hires.py | 2 +- back/admin/sequences/tests.py | 3 +- 14 files changed, 97 insertions(+), 46 deletions(-) create mode 100644 back/admin/people/revoke_result.py create mode 100644 back/admin/people/templates/_integration_revoke_results.html diff --git a/back/admin/integrations/builder_views.py b/back/admin/integrations/builder_views.py index 09d93283..23824610 100644 --- a/back/admin/integrations/builder_views.py +++ b/back/admin/integrations/builder_views.py @@ -522,7 +522,8 @@ def post(self, *args, **kwargs): elif test_type == "execute": result = integration.execute(user) elif test_type == "revoke": - result = integration.revoke_user(user) + revoke_result = integration.revoke_user(user) + result = (revoke_result.success, revoke_result.message) tracker = IntegrationTracker.objects.filter( integration=integration, for_user=user diff --git a/back/admin/integrations/models.py b/back/admin/integrations/models.py index 3f23268d..b19a0c8d 100644 --- a/back/admin/integrations/models.py +++ b/back/admin/integrations/models.py @@ -40,6 +40,7 @@ WebhookManifestSerializer, ) from admin.integrations.utils import get_value_from_notation +from admin.people.revoke_result import RevokeResult from misc.fernet_fields import EncryptedTextField from misc.fields import EncryptedJSONField from organization.models import FilteredForManagerQuerySet, Notification @@ -565,14 +566,16 @@ def needs_user_info(self, user): def revoke_user(self, user): if self.skip_user_provisioning: # should never be triggered - return False, "Cannot revoke manual integration" + return RevokeResult( + result=False, message="Cannot revoke manual integration" + ) self.new_hire = user self.has_user_context = True # Renew token if necessary if not self.renew_key(): - return False, "Couldn't renew key" + return RevokeResult(result=False, message="Couldn't renew key") revoke_manifest = self.manifest.get("revoke", []) @@ -588,9 +591,9 @@ def revoke_user(self, user): success, response = self.run_request(item) if not success or not self.tracker.steps.last().found_expected: - return False, self.clean_response(response) + return RevokeResult(result=False, message=self.clean_response(response)) - return True, "" + return RevokeResult(result=True, message="") def renew_key(self): # Oauth2 refreshing access token if needed diff --git a/back/admin/integrations/tests.py b/back/admin/integrations/tests.py index 2e39f081..4487db30 100644 --- a/back/admin/integrations/tests.py +++ b/back/admin/integrations/tests.py @@ -513,25 +513,25 @@ def test_integration_revoke_user( "admin.integrations.models.Integration.run_request", Mock(return_value=(True, Mock())), ): - success, error = integration.revoke_user(new_hire) - assert success - assert error == "" + result = integration.revoke_user(new_hire) + assert result.success + assert result.message == "" # Revoke user unsuccessfully with patch( "admin.integrations.models.Integration.run_request", Mock(return_value=(False, "Something went wrong")), ): - success, error = integration.revoke_user(new_hire) - assert not success + result = integration.revoke_user(new_hire) + assert not result.success assert "Something went wrong" # try the same with a manual integration, this doesn't work as it can't actually # revoke a user manual_integration = manual_user_provision_integration_factory() - success, error = manual_integration.revoke_user(new_hire) - assert not success - assert error == "Cannot revoke manual integration" + result = manual_integration.revoke_user(new_hire) + assert not result.success + assert result.message == "Cannot revoke manual integration" @pytest.mark.django_db diff --git a/back/admin/people/revoke_result.py b/back/admin/people/revoke_result.py new file mode 100644 index 00000000..140fcb17 --- /dev/null +++ b/back/admin/people/revoke_result.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class RevokeResult: + success: bool + message: str diff --git a/back/admin/people/templates/_departments_list_with_remove_user_options_modal.html b/back/admin/people/templates/_departments_list_with_remove_user_options_modal.html index 62eca184..debf2c2d 100644 --- a/back/admin/people/templates/_departments_list_with_remove_user_options_modal.html +++ b/back/admin/people/templates/_departments_list_with_remove_user_options_modal.html @@ -10,7 +10,7 @@