From fd64e6f3aafae1dfa09217af0886b909d64e4d0d Mon Sep 17 00:00:00 2001 From: ssorin Date: Fri, 29 Nov 2024 11:29:11 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(dashboard)=20Consent=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add Consent management views and template - add JavaScript functionality to select/uncheck all checkboxes in consent forms - enhance the admin interface - add test - add migration to proxy_for symmetrical=False --- src/dashboard/CHANGELOG.md | 1 + src/dashboard/apps/auth/models.py | 11 +- src/dashboard/apps/auth/tests/test_models.py | 53 +++++ src/dashboard/apps/consent/__init__.py | 16 ++ .../apps/consent/fixtures/consent.py | 1 - .../management/commands/seed_consent.py | 4 +- src/dashboard/apps/consent/managers.py | 20 ++ ...t_managers_alter_consent_delivery_point.py | 34 +++ src/dashboard/apps/consent/models.py | 29 +-- .../apps/consent/static/consent/js/app.js | 8 + .../apps/consent/templates/consent/base.html | 4 +- .../templates/consent/consent-management.html | 9 - .../includes/_resume_awaiting_consents.html | 28 +++ .../includes/_resume_validated_consents.html | 15 ++ .../apps/consent/templates/consent/index.html | 17 +- .../consent/templates/consent/manage.html | 66 ++++++ .../apps/consent/tests/test_models.py | 12 +- src/dashboard/apps/consent/urls.py | 5 +- src/dashboard/apps/consent/views.py | 95 ++++++-- src/dashboard/apps/core/abstract_models.py | 32 +++ src/dashboard/apps/core/admin.py | 19 +- src/dashboard/apps/core/managers.py | 11 + .../0002_alter_deliverypoint_entity.py | 24 ++ ...anagers_alter_entity_proxy_for_and_more.py | 41 ++++ src/dashboard/apps/core/models.py | 91 ++++--- src/dashboard/apps/core/tests/test_models.py | 223 +++++++++++++++++- .../apps/home/templates/home/base.html | 4 +- .../apps/home/templates/home/index.html | 4 +- src/dashboard/dashboard/settings.py | 2 +- src/dashboard/templates/base.html | 9 + 30 files changed, 785 insertions(+), 103 deletions(-) create mode 100644 src/dashboard/apps/consent/managers.py create mode 100644 src/dashboard/apps/consent/migrations/0002_alter_consent_managers_alter_consent_delivery_point.py create mode 100644 src/dashboard/apps/consent/static/consent/js/app.js delete mode 100644 src/dashboard/apps/consent/templates/consent/consent-management.html create mode 100644 src/dashboard/apps/consent/templates/consent/includes/_resume_awaiting_consents.html create mode 100644 src/dashboard/apps/consent/templates/consent/includes/_resume_validated_consents.html create mode 100644 src/dashboard/apps/consent/templates/consent/manage.html create mode 100644 src/dashboard/apps/core/abstract_models.py create mode 100644 src/dashboard/apps/core/managers.py create mode 100644 src/dashboard/apps/core/migrations/0002_alter_deliverypoint_entity.py create mode 100644 src/dashboard/apps/core/migrations/0003_alter_deliverypoint_managers_alter_entity_proxy_for_and_more.py diff --git a/src/dashboard/CHANGELOG.md b/src/dashboard/CHANGELOG.md index 36884042..32e1b86f 100644 --- a/src/dashboard/CHANGELOG.md +++ b/src/dashboard/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to - introduce new custom user model - add consent app with Consent model - add core app with Entity and DeliveryPoint models +- add consent form to manage consents of one or many entities [unreleased]: https://github.com/MTES-MCT/qualicharge/compare/main...bootstrap-dashboard-project diff --git a/src/dashboard/apps/auth/models.py b/src/dashboard/apps/auth/models.py index 1dfcdce8..894c9f87 100644 --- a/src/dashboard/apps/auth/models.py +++ b/src/dashboard/apps/auth/models.py @@ -1,6 +1,9 @@ """Dashboard auth models.""" from django.contrib.auth.models import AbstractUser +from django.db.models import Q, QuerySet + +from apps.core.models import Entity class DashboardUser(AbstractUser): @@ -11,4 +14,10 @@ class DashboardUser(AbstractUser): AbstractUser model in Django. """ - pass + def get_entities(self) -> QuerySet[Entity]: + """Get a list of entities, and their proxies associated.""" + return Entity.objects.filter(Q(users=self) | Q(proxies__users=self)).distinct() + + def can_validate_entity(self, entity: Entity) -> bool: + """Determines if the provided entity can be validated.""" + return entity in self.get_entities() diff --git a/src/dashboard/apps/auth/tests/test_models.py b/src/dashboard/apps/auth/tests/test_models.py index d72564b0..518a84c7 100644 --- a/src/dashboard/apps/auth/tests/test_models.py +++ b/src/dashboard/apps/auth/tests/test_models.py @@ -1,8 +1,10 @@ """Dashboard auth models tests.""" import pytest +from pytest_django.asserts import assertQuerySetEqual from apps.auth.factories import AdminUserFactory, UserFactory +from apps.core.factories import EntityFactory @pytest.mark.django_db @@ -25,3 +27,54 @@ def test_create_superuser(): assert admin_user.is_active is True assert admin_user.is_staff is True assert admin_user.is_superuser is True + + +@pytest.mark.django_db +def test_get_entities(): + """Test that user retrieve his entities.""" + user1 = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + entity1 = EntityFactory(users=(user1,), name="entity1") + entity2 = EntityFactory(users=(user2,), name="entity2") + entity3 = EntityFactory( + users=(user3,), proxy_for=(entity1, entity2), name="entity3" + ) + + assertQuerySetEqual(user1.get_entities(), [entity1]) + assertQuerySetEqual(user2.get_entities(), [entity2]) + + user1_entities = user3.get_entities() + user1_expected_entities = [entity1, entity2, entity3] + assertQuerySetEqual(user1_entities, user1_expected_entities) + + +@pytest.mark.django_db +def test_can_validate_entity(): + """Test if user can validate an entity.""" + user1 = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + user4 = UserFactory() + + entity1 = EntityFactory(users=(user1,), name="entity1") + entity2 = EntityFactory(users=(user2,), name="entity2") + entity3 = EntityFactory( + users=(user3,), proxy_for=(entity1, entity2), name="entity3" + ) + # multiple entities including one with proxy_for + entity4 = EntityFactory(users=(user4,), name="entity4") + entity5 = EntityFactory(users=(user4,), proxy_for=(entity2,), name="entity5") + + assert user1.can_validate_entity(entity1) is True + assert user1.can_validate_entity(entity2) is False + assert user1.can_validate_entity(entity3) is False + + assert user3.can_validate_entity(entity1) is True + assert user3.can_validate_entity(entity2) is True + assert user3.can_validate_entity(entity3) is True + assert user3.can_validate_entity(entity4) is False + + assert user4.can_validate_entity(entity4) is True + assert user4.can_validate_entity(entity5) is True + assert user4.can_validate_entity(entity2) is True diff --git a/src/dashboard/apps/consent/__init__.py b/src/dashboard/apps/consent/__init__.py index d9790d47..18607104 100644 --- a/src/dashboard/apps/consent/__init__.py +++ b/src/dashboard/apps/consent/__init__.py @@ -1 +1,17 @@ """Dashboard consent app.""" + +from typing import Literal + +from django.utils.translation import gettext_lazy as _ + +AWAITING: str = "AWAITING" +VALIDATED: str = "VALIDATED" +REVOKED: str = "REVOKED" +CONSENT_STATUS_CHOICE = [ + (AWAITING, _("Awaiting")), + (VALIDATED, _("Validated")), + (REVOKED, _("Revoked")), +] + +# typing +StatusChoices = Literal["AWAITING", "VALIDATED", "REVOKED"] diff --git a/src/dashboard/apps/consent/fixtures/consent.py b/src/dashboard/apps/consent/fixtures/consent.py index 80597646..c25f632e 100644 --- a/src/dashboard/apps/consent/fixtures/consent.py +++ b/src/dashboard/apps/consent/fixtures/consent.py @@ -28,7 +28,6 @@ def seed_consent(): entity3 = EntityFactory(users=(user3,), proxy_for=(entity1, entity2)) entity4 = EntityFactory(users=(user5,)) - # create delivery points for i in range(1, 4): DeliveryPointFactory(provider_assigned_id=f"entity1_{i}", entity=entity1) diff --git a/src/dashboard/apps/consent/management/commands/seed_consent.py b/src/dashboard/apps/consent/management/commands/seed_consent.py index fe00986d..2b14b394 100644 --- a/src/dashboard/apps/consent/management/commands/seed_consent.py +++ b/src/dashboard/apps/consent/management/commands/seed_consent.py @@ -14,6 +14,4 @@ def handle(self, *args, **kwargs): """Executes the command for creating development consent fixtures.""" self.stdout.write(self.style.NOTICE("Seeding database with consents...")) seed_consent() - self.stdout.write( - self.style.SUCCESS("Done.") - ) + self.stdout.write(self.style.SUCCESS("Done.")) diff --git a/src/dashboard/apps/consent/managers.py b/src/dashboard/apps/consent/managers.py new file mode 100644 index 00000000..121e009c --- /dev/null +++ b/src/dashboard/apps/consent/managers.py @@ -0,0 +1,20 @@ +"""Dashboard consent app managers.""" + +from django.db import models +from django.utils import timezone + + +class ConsentManager(models.Manager): + """Custom consent manager.""" + + def get_queryset(self): + """Return consents with active delivery point, for the current period.""" + return ( + super() + .get_queryset() + .filter( + delivery_point__is_active=True, + start__lte=timezone.now(), + end__gte=timezone.now(), + ) + ) diff --git a/src/dashboard/apps/consent/migrations/0002_alter_consent_managers_alter_consent_delivery_point.py b/src/dashboard/apps/consent/migrations/0002_alter_consent_managers_alter_consent_delivery_point.py new file mode 100644 index 00000000..67f55c6d --- /dev/null +++ b/src/dashboard/apps/consent/migrations/0002_alter_consent_managers_alter_consent_delivery_point.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.3 on 2024-12-06 14:53 + +import django.db.models.deletion +import django.db.models.manager +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("qcd_consent", "0001_initial"), + ( + "qcd_core", + "0003_alter_deliverypoint_managers_alter_entity_proxy_for_and_more", + ), + ] + + operations = [ + migrations.AlterModelManagers( + name="consent", + managers=[ + ("active_objects", django.db.models.manager.Manager()), + ], + ), + migrations.AlterField( + model_name="consent", + name="delivery_point", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="consents", + to="qcd_core.deliverypoint", + ), + ), + ] diff --git a/src/dashboard/apps/consent/models.py b/src/dashboard/apps/consent/models.py index cda5d1eb..59053879 100644 --- a/src/dashboard/apps/consent/models.py +++ b/src/dashboard/apps/consent/models.py @@ -4,8 +4,10 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from apps.auth.models import DashboardUser as User -from apps.core.models import DashboardBase, DeliveryPoint +from apps.core.abstract_models import DashboardBase + +from . import AWAITING, CONSENT_STATUS_CHOICE, REVOKED +from .managers import ConsentManager class Consent(DashboardBase): @@ -26,18 +28,14 @@ class Consent(DashboardBase): - revoked_at (DateTimeField): recording the revoked date of the consent, if any. """ - AWAITING = "AWAITING" - VALIDATED = "VALIDATED" - REVOKED = "REVOKED" - CONSENT_STATUS_CHOICE = [ - (AWAITING, _("Awaiting")), - (VALIDATED, _("Validated")), - (REVOKED, _("Revoked")), - ] - - delivery_point = models.ForeignKey(DeliveryPoint, on_delete=models.CASCADE) + delivery_point = models.ForeignKey( + "qcd_core.DeliveryPoint", on_delete=models.CASCADE, related_name="consents" + ) created_by = models.ForeignKey( - User, on_delete=models.SET_NULL, null=True, verbose_name=_("created by") + "qcd_auth.DashboardUser", + on_delete=models.SET_NULL, + null=True, + verbose_name=_("created by"), ) status = models.CharField( _("status"), max_length=20, choices=CONSENT_STATUS_CHOICE, default=AWAITING @@ -48,6 +46,9 @@ class Consent(DashboardBase): end = models.DateTimeField(_("end date")) revoked_at = models.DateTimeField(_("revoked at"), null=True, blank=True) + active_objects = ConsentManager() + objects = models.Manager() + class Meta: # noqa: D106 ordering = ["delivery_point"] @@ -56,6 +57,6 @@ def __str__(self): # noqa: D105 def save(self, *args, **kwargs): """Update the revoked_at timestamps if the consent is revoked.""" - if self.status == self.REVOKED: + if self.status == REVOKED: self.revoked_at = timezone.now() return super(Consent, self).save(*args, **kwargs) diff --git a/src/dashboard/apps/consent/static/consent/js/app.js b/src/dashboard/apps/consent/static/consent/js/app.js new file mode 100644 index 00000000..938eb111 --- /dev/null +++ b/src/dashboard/apps/consent/static/consent/js/app.js @@ -0,0 +1,8 @@ +/** + * check/uncheck all checkbox in consent form + */ +document.getElementById("toggle-all") + .addEventListener("change", function() { + const checkboxes = document.getElementsByName("status"); + checkboxes.forEach(checkbox => checkbox.checked = this.checked); +}); diff --git a/src/dashboard/apps/consent/templates/consent/base.html b/src/dashboard/apps/consent/templates/consent/base.html index 09e9ce3b..848cf306 100644 --- a/src/dashboard/apps/consent/templates/consent/base.html +++ b/src/dashboard/apps/consent/templates/consent/base.html @@ -1,4 +1,4 @@ {% extends "base.html" %} -{% block content %} -{% endblock content %} +{% block dashboard_content %} +{% endblock dashboard_content %} diff --git a/src/dashboard/apps/consent/templates/consent/consent-management.html b/src/dashboard/apps/consent/templates/consent/consent-management.html deleted file mode 100644 index e267143f..00000000 --- a/src/dashboard/apps/consent/templates/consent/consent-management.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "home/base.html" %} - -{% load i18n %} - -{% block content %} -

- {% trans "Consents management" %} -

-{% endblock content %} diff --git a/src/dashboard/apps/consent/templates/consent/includes/_resume_awaiting_consents.html b/src/dashboard/apps/consent/templates/consent/includes/_resume_awaiting_consents.html new file mode 100644 index 00000000..b611592e --- /dev/null +++ b/src/dashboard/apps/consent/templates/consent/includes/_resume_awaiting_consents.html @@ -0,0 +1,28 @@ +{% load i18n %} + +

+ {% trans "Consents summary" %} +

+ +

+ + + {% trans "Validate content for all entities" %} + + +

+ + diff --git a/src/dashboard/apps/consent/templates/consent/includes/_resume_validated_consents.html b/src/dashboard/apps/consent/templates/consent/includes/_resume_validated_consents.html new file mode 100644 index 00000000..c8cc05ba --- /dev/null +++ b/src/dashboard/apps/consent/templates/consent/includes/_resume_validated_consents.html @@ -0,0 +1,15 @@ +{% load i18n %} + +

{% trans "Validated entities" %}

+ + diff --git a/src/dashboard/apps/consent/templates/consent/index.html b/src/dashboard/apps/consent/templates/consent/index.html index ee02c35c..6272ba35 100644 --- a/src/dashboard/apps/consent/templates/consent/index.html +++ b/src/dashboard/apps/consent/templates/consent/index.html @@ -1,15 +1,8 @@ -{% extends "home/base.html" %} +{% extends "consent/base.html" %} {% load i18n %} -{% block content %} -

- {% trans "Consents summary" %} -

- -

- - {% trans "Manage consent" %} - -

-{% endblock content %} +{% block dashboard_content %} + {% include "consent/includes/_resume_awaiting_consents.html" %} + {% include "consent/includes/_resume_validated_consents.html" %} +{% endblock dashboard_content %} diff --git a/src/dashboard/apps/consent/templates/consent/manage.html b/src/dashboard/apps/consent/templates/consent/manage.html new file mode 100644 index 00000000..d6b3d22f --- /dev/null +++ b/src/dashboard/apps/consent/templates/consent/manage.html @@ -0,0 +1,66 @@ +{% extends "consent/base.html" %} + +{% load i18n static %} + +{% block dashboard_content %} +

{% trans "Manage consents" %}

+ +{% if entities %} +
+ {% csrf_token %} +
+ + {% trans "Legend for all elements" %} + + + {# toggle button #} +
+
+ + +
+
+ + {% for entity in entities %} + {{ entity.name }} + + {% for consent in entity.get_consents %} +
+
+ + +
+
+
+ {% endfor %} + {% endfor %} + +
+ {{ field.errors }} +
+
+ + +
+ + {% else %} +

{% trans "No consents to validate" %}

+ {% endif %} +{% endblock dashboard_content %} + +{% block extra_dashboard_js %} + +{% endblock extra_dashboard_js %} diff --git a/src/dashboard/apps/consent/tests/test_models.py b/src/dashboard/apps/consent/tests/test_models.py index fe34a29f..ae81bd12 100644 --- a/src/dashboard/apps/consent/tests/test_models.py +++ b/src/dashboard/apps/consent/tests/test_models.py @@ -6,8 +6,8 @@ from django.utils import formats from apps.auth.factories import UserFactory +from apps.consent import AWAITING, REVOKED, VALIDATED from apps.consent.factories import ConsentFactory -from apps.consent.models import Consent from apps.core.factories import DeliveryPointFactory @@ -24,7 +24,7 @@ def test_create_consent(): assert consent.delivery_point == delivery_point assert consent.created_by == user1 - assert consent.status == Consent.AWAITING + assert consent.status == AWAITING assert consent.revoked_at is None assert consent.start is not None assert consent.end is not None @@ -54,16 +54,16 @@ def test_update_consent_status(): new_updated_at = consent.updated_at # update status to VALIDATED - consent.status = Consent.VALIDATED + consent.status = VALIDATED consent.save() - assert consent.status == Consent.VALIDATED + assert consent.status == VALIDATED assert consent.updated_at > new_updated_at assert consent.revoked_at is None new_updated_at = consent.updated_at # update status to REVOKED - consent.status = Consent.REVOKED + consent.status = REVOKED consent.save() - assert consent.status == Consent.REVOKED + assert consent.status == REVOKED assert consent.updated_at > new_updated_at assert consent.revoked_at is not None diff --git a/src/dashboard/apps/consent/urls.py b/src/dashboard/apps/consent/urls.py index d12e65f8..6f6a0616 100644 --- a/src/dashboard/apps/consent/urls.py +++ b/src/dashboard/apps/consent/urls.py @@ -2,11 +2,12 @@ from django.urls import path -from .views import IndexView, ManageView +from .views import IndexView, consent_form_view app_name = "consent" urlpatterns = [ path("", IndexView.as_view(), name="index"), - path("manage/", ManageView.as_view(), name="manage"), + path("manage/", consent_form_view, name="manage"), + path("manage/", consent_form_view, name="manage"), ] diff --git a/src/dashboard/apps/consent/views.py b/src/dashboard/apps/consent/views.py index 057599ca..eea939a6 100644 --- a/src/dashboard/apps/consent/views.py +++ b/src/dashboard/apps/consent/views.py @@ -1,9 +1,20 @@ """Dashboard consent app views.""" -from django.urls import reverse +from django.contrib import messages +from django.core.exceptions import PermissionDenied +from django.db.models import TextField +from django.db.models.functions import Cast +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse_lazy as reverse +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.views.generic import TemplateView +from apps.core.models import Entity + +from . import AWAITING, VALIDATED +from .models import Consent + class IndexView(TemplateView): """Index view of the consent app.""" @@ -13,24 +24,80 @@ class IndexView(TemplateView): def get_context_data(self, **kwargs): """Add custom context to the view.""" context = super().get_context_data(**kwargs) + context["entities"] = self.request.user.get_entities() + context["breadcrumb_data"] = { "current": _("Consent"), } return context -class ManageView(TemplateView): - """Consents management view.""" +def consent_form_view(request, slug=None): + """Manage consent forms. - template_name = "consent/consent-management.html" + This function performs the following actions: + - Retrieves the entity associated with the given slug, if provided. + - Fetches consents awaiting validation for the current user and the specified + entity. + - If a POST request is received, updates the consent status to either VALIDATED + or AWAITING based on user selections and existing data. + """ + template_name = "consent/manage.html" - def get_context_data(self, **kwargs): - """Add custom context to the view.""" - context = super().get_context_data(**kwargs) - context["breadcrumb_data"] = { - "links": [ - {"url": reverse("consent:index"), "title": _("Consent")}, - ], - "current": _("Manage Consents"), - } - return context + entities = [] + if slug: + entity = get_object_or_404(Entity, slug=slug) + if not request.user.can_validate_entity(entity): + raise PermissionDenied + entities.append(entity) + else: + entities = request.user.get_entities() + + if request.POST: + selected_ids = request.POST.getlist("status") + update_consent_status(request.user, entities, selected_ids) + + messages.success(request, _("Consents updated.")) + return redirect(reverse("consent:index")) + + breadcrumb_data = { + "links": [ + {"url": reverse("consent:index"), "title": _("Consent")}, + ], + "current": _("Manage Consents"), + } + + return render( + request=request, + template_name=template_name, + context={"entities": entities, "breadcrumb_data": breadcrumb_data}, + ) + + +def update_consent_status(user, entities, selected_ids): + """Updates the status of consents..""" + + def _bulk_update_consent(ids: list[str], status: str): + """Bulk update of the consent status for a given status and list of entities.""" + Consent.objects.filter(id__in=ids).update( + status=status, + created_by=user, + updated_at=timezone.now(), + ) + + def _get_awaiting_ids(entities, ids): + """Get the a list of the non selected ids (awaiting ids).""" + base_ids = [] + for entity in entities: + base_ids.extend( + list( + entity.get_consents() + .annotate(str_id=Cast("id", output_field=TextField())) + .values_list("str_id", flat=True) + ) + ) + return list(set(base_ids) - set(ids)) + + _bulk_update_consent(selected_ids, VALIDATED) + awaiting_ids = _get_awaiting_ids(entities, selected_ids) + _bulk_update_consent(awaiting_ids, AWAITING) diff --git a/src/dashboard/apps/core/abstract_models.py b/src/dashboard/apps/core/abstract_models.py new file mode 100644 index 00000000..0c4717f3 --- /dev/null +++ b/src/dashboard/apps/core/abstract_models.py @@ -0,0 +1,32 @@ +"""Dashboard consent app models.""" + +import uuid + +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class DashboardBase(models.Model): + """Abstract base model, providing common fields and functionality. + + Attributes: + - id (UUIDField): serves as the primary key, automatically generated, not editable. + - created_at (DateTimeField): records when the object was created, not editable by + default. + - updated_at (DateTimeField): records when the object was last updated, editable. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + created_at = models.DateTimeField( + _("created at"), editable=False, default=timezone.now + ) + updated_at = models.DateTimeField(_("updated at"), null=True, blank=True) + + class Meta: # noqa: D106 + abstract = True + + def save(self, *args, **kwargs): + """Update the updated_at timestamps.""" + self.updated_at = timezone.now() + return super(DashboardBase, self).save(*args, **kwargs) diff --git a/src/dashboard/apps/core/admin.py b/src/dashboard/apps/core/admin.py index 333db61b..f7ffcc1c 100644 --- a/src/dashboard/apps/core/admin.py +++ b/src/dashboard/apps/core/admin.py @@ -1,6 +1,7 @@ """Dashboard core admin.""" from django.contrib import admin +from django.utils.translation import gettext_lazy as _ from .models import DeliveryPoint, Entity @@ -9,11 +10,25 @@ class EntityAdmin(admin.ModelAdmin): """Entity admin.""" - pass + list_display = ["name", "get_users_name", "get_proxies_for", "slug"] + filter_horizontal = ( + "users", + "proxy_for", + ) + + @admin.display(description=_("Users")) + def get_users_name(self, obj): + """Returns a comma-separated string of usernames for the given object.""" + return ", ".join(user.username for user in obj.users.all()) + + @admin.display(description=_("Proxy for")) + def get_proxies_for(self, obj): + """Returns a comma-separated string of `proxy_for.name` for the given object.""" + return ", ".join(p.name for p in obj.proxy_for.all()) @admin.register(DeliveryPoint) class DeliveryPointAdmin(admin.ModelAdmin): """Delivery point admin.""" - pass + list_display = ["provider_assigned_id", "entity", "is_active"] diff --git a/src/dashboard/apps/core/managers.py b/src/dashboard/apps/core/managers.py new file mode 100644 index 00000000..0745899e --- /dev/null +++ b/src/dashboard/apps/core/managers.py @@ -0,0 +1,11 @@ +"""Dashboard core app managers.""" + +from django.db import models + + +class DeliveryPointManager(models.Manager): + """Delivery point custom manager.""" + + def active(self): + """Return active delivery points.""" + return self.filter(is_active=True) diff --git a/src/dashboard/apps/core/migrations/0002_alter_deliverypoint_entity.py b/src/dashboard/apps/core/migrations/0002_alter_deliverypoint_entity.py new file mode 100644 index 00000000..0c38b747 --- /dev/null +++ b/src/dashboard/apps/core/migrations/0002_alter_deliverypoint_entity.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.3 on 2024-11-29 10:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("qcd_core", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="deliverypoint", + name="entity", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="delivery_points", + to="qcd_core.entity", + verbose_name="entity", + ), + ), + ] diff --git a/src/dashboard/apps/core/migrations/0003_alter_deliverypoint_managers_alter_entity_proxy_for_and_more.py b/src/dashboard/apps/core/migrations/0003_alter_deliverypoint_managers_alter_entity_proxy_for_and_more.py new file mode 100644 index 00000000..eb8edcda --- /dev/null +++ b/src/dashboard/apps/core/migrations/0003_alter_deliverypoint_managers_alter_entity_proxy_for_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1.3 on 2024-12-06 14:53 + +import django.db.models.manager +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("qcd_core", "0002_alter_deliverypoint_entity"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelManagers( + name="deliverypoint", + managers=[ + ("active_objects", django.db.models.manager.Manager()), + ], + ), + migrations.AlterField( + model_name="entity", + name="proxy_for", + field=models.ManyToManyField( + blank=True, + related_name="proxies", + to="qcd_core.entity", + verbose_name="proxy for", + ), + ), + migrations.AlterField( + model_name="entity", + name="users", + field=models.ManyToManyField( + related_name="entities", + to=settings.AUTH_USER_MODEL, + verbose_name="users", + ), + ), + ] diff --git a/src/dashboard/apps/core/models.py b/src/dashboard/apps/core/models.py index 0605a098..e3f8f106 100644 --- a/src/dashboard/apps/core/models.py +++ b/src/dashboard/apps/core/models.py @@ -1,38 +1,15 @@ """Dashboard core app models.""" -import uuid - from django.db import models -from django.utils import timezone +from django.db.models import QuerySet from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import AutoSlugField -from apps.auth.models import DashboardUser as User - - -class DashboardBase(models.Model): - """Abstract base model, providing common fields and functionality. - - Attributes: - - id (UUIDField): serves as the primary key, automatically generated, not editable. - - created_at (DateTimeField): records when the object was created, not editable by - default. - - updated_at (DateTimeField): records when the object was last updated, editable. - """ - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - created_at = models.DateTimeField( - _("created at"), editable=False, default=timezone.now - ) - updated_at = models.DateTimeField(_("updated at"), null=True, blank=True) - - class Meta: # noqa: D106 - abstract = True +from apps.consent import AWAITING, VALIDATED +from apps.consent.models import Consent - def save(self, *args, **kwargs): - """Update the updated_at timestamps.""" - self.updated_at = timezone.now() - return super(DashboardBase, self).save(*args, **kwargs) +from .abstract_models import DashboardBase +from .managers import DeliveryPointManager class Entity(DashboardBase): @@ -47,8 +24,16 @@ class Entity(DashboardBase): slug = AutoSlugField(_("slug"), populate_from="name", unique=True) name = models.CharField(_("name"), max_length=64, unique=True) - users = models.ManyToManyField(User, verbose_name=_("users")) - proxy_for = models.ManyToManyField("self", verbose_name=_("proxy for"), blank=True) + users = models.ManyToManyField( + "qcd_auth.DashboardUser", verbose_name=_("users"), related_name="entities" + ) + proxy_for = models.ManyToManyField( + "self", + verbose_name=_("proxy for"), + blank=True, + symmetrical=False, + related_name="proxies", + ) class Meta: # noqa: D106 verbose_name = "entity" @@ -58,6 +43,44 @@ class Meta: # noqa: D106 def __str__(self): # noqa: D105 return self.name + def is_proxy_for(self) -> bool: + """Return True if the entity is a proxy of other entities, False otherwise.""" + return self.proxy_for.exists() + + def get_proxy_entities(self) -> QuerySet: + """Retrieve entities for which this entity is a proxy.""" + return self.proxy_for.all() + + def get_consents(self, status: str | None = None) -> QuerySet: + """Get consents associated with this entity.""" + queryset_filters: dict = {} + if status: + queryset_filters["status"] = status + + return ( + Consent.active_objects.filter( + delivery_point__entity=self, + **queryset_filters, + ) + .select_related( + "delivery_point", + "delivery_point__entity", + ) + .order_by("delivery_point__provider_assigned_id", "start") + ) + + def count_validated_consents(self) -> int: + """Counts the number of validated consents associated with a given entity.""" + return self.get_consents(VALIDATED).count() + + def count_awaiting_consents(self) -> int: + """Counts the number of validated consents associated with a given entity.""" + return self.get_consents(AWAITING).count() + + def get_awaiting_consents(self) -> QuerySet: + """Get all awaiting consents for this entity.""" + return self.get_consents(AWAITING) + class DeliveryPoint(DashboardBase): """Represents a delivery point for electric vehicles. @@ -71,10 +94,16 @@ class DeliveryPoint(DashboardBase): provider_assigned_id = models.CharField(_("provider assigned id"), max_length=64) entity = models.ForeignKey( - Entity, on_delete=models.CASCADE, verbose_name=_("entity") + Entity, + on_delete=models.CASCADE, + related_name="delivery_points", + verbose_name=_("entity"), ) is_active = models.BooleanField(_("is active"), default=True) + active_objects = DeliveryPointManager() + objects = models.Manager() + class Meta: # noqa: D106 verbose_name = _("delivery point") verbose_name_plural = _("delivery points") diff --git a/src/dashboard/apps/core/tests/test_models.py b/src/dashboard/apps/core/tests/test_models.py index 9aef8e55..3f16baf4 100644 --- a/src/dashboard/apps/core/tests/test_models.py +++ b/src/dashboard/apps/core/tests/test_models.py @@ -1,11 +1,18 @@ """Dashboard core models tests.""" +from datetime import timedelta + import pytest from django.db import IntegrityError +from django.utils import timezone +from pytest_django.asserts import assertQuerySetEqual from apps.auth.factories import UserFactory +from apps.consent import AWAITING, VALIDATED +from apps.consent.factories import ConsentFactory +from apps.consent.models import Consent from apps.core.factories import DeliveryPointFactory, EntityFactory -from apps.core.models import Entity +from apps.core.models import DeliveryPoint, Entity @pytest.mark.django_db @@ -114,3 +121,217 @@ def test_update_delivery_point(): # test updated_at has been updated assert delivery_point.updated_at > delivery_point.created_at + + +@pytest.mark.django_db +def test_is_proxy_for(): + """Test is_proxy_for method.""" + user1 = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + entity1 = EntityFactory(users=(user1,), name="entity1") + entity2 = EntityFactory(users=(user2,), name="entity2") + entity3 = EntityFactory( + users=(user3,), proxy_for=(entity1, entity2), name="entity3" + ) + assert entity1.is_proxy_for() is False + assert entity3.is_proxy_for() is True + + +@pytest.mark.django_db +def test_get_proxy_entities_list(): + """Test get_proxy_entities_list method.""" + user1 = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + entity1 = EntityFactory(users=(user1,), name="entity1") + entity2 = EntityFactory(users=(user2,), name="entity2") + entity3 = EntityFactory( + users=(user3,), proxy_for=(entity1, entity2), name="entity3" + ) + # entity1 is not a proxy_for + assertQuerySetEqual(entity1.get_proxy_entities(), []) + + # entity3 is proxy_for entity1 and entity2 + assertQuerySetEqual(entity3.get_proxy_entities(), [entity1, entity2]) + + +@pytest.mark.django_db +def test_count_validated_consents(): + """Test count_validated_consents method.""" + user1 = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + entity1 = EntityFactory(users=(user1,), name="entity1") + entity2 = EntityFactory(users=(user2,), name="entity2") + entity3 = EntityFactory( + users=(user3,), proxy_for=(entity1, entity2), name="entity3" + ) + + for i in range(1, 4): + DeliveryPointFactory(provider_assigned_id=f"entity1_{i}", entity=entity1) + DeliveryPointFactory(provider_assigned_id=f"entity2_{i}", entity=entity2) + DeliveryPointFactory(provider_assigned_id=f"entity3_{i}", entity=entity3) + + # create awaiting consent for each delivery points + for delivery_point in DeliveryPoint.objects.all(): + ConsentFactory(delivery_point=delivery_point, created_by=user1, status=AWAITING) + + # create validated consent for entity1 + dl = DeliveryPointFactory(provider_assigned_id="entity3_validated", entity=entity1) + ConsentFactory(delivery_point=dl, created_by=user1, status=VALIDATED) + + # create awainting consents for entity1 in past period + dl = DeliveryPointFactory(provider_assigned_id="entity3_past", entity=entity1) + ConsentFactory( + delivery_point=dl, + created_by=user1, + status=AWAITING, + start=timezone.now() - timedelta(days=300), + end=timezone.now() - timedelta(days=270), + ) + + assert ( + Consent.objects.filter(status=AWAITING, delivery_point__entity=entity1).count() + == 4 # noqa: PLR2004 + ) + assert entity1.count_validated_consents() == 1 + assert entity2.count_validated_consents() == 0 + assert entity3.count_validated_consents() == 0 + + +@pytest.mark.django_db +def test_count_awaiting_consents(): + """Test count_awaiting_consents method.""" + user1 = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + entity1 = EntityFactory(users=(user1,), name="entity1") + entity2 = EntityFactory(users=(user2,), name="entity2") + entity3 = EntityFactory( + users=(user3,), proxy_for=(entity1, entity2), name="entity3" + ) + + for i in range(1, 4): + DeliveryPointFactory(provider_assigned_id=f"entity1_{i}", entity=entity1) + DeliveryPointFactory(provider_assigned_id=f"entity2_{i}", entity=entity2) + DeliveryPointFactory(provider_assigned_id=f"entity3_{i}", entity=entity3) + + # create awaiting consent for each delivery points + for delivery_point in DeliveryPoint.objects.all(): + ConsentFactory(delivery_point=delivery_point, created_by=user1, status=AWAITING) + + # create validated consent for entity1 + dl = DeliveryPointFactory(provider_assigned_id="entity3_validated", entity=entity1) + ConsentFactory(delivery_point=dl, created_by=user1, status=VALIDATED) + + # create awainting consents for entity1 in past period + dl = DeliveryPointFactory(provider_assigned_id="entity3_past", entity=entity1) + ConsentFactory( + delivery_point=dl, + created_by=user1, + status=AWAITING, + start=timezone.now() - timedelta(days=300), + end=timezone.now() - timedelta(days=270), + ) + + assert ( + Consent.objects.filter(status=AWAITING, delivery_point__entity=entity1).count() + == 4 # noqa: PLR2004 + ) + assert entity1.count_awaiting_consents() == 3 # noqa: PLR2004 + assert entity2.count_awaiting_consents() == 3 # noqa: PLR2004 + assert entity3.count_awaiting_consents() == 3 # noqa: PLR2004 + + +@pytest.mark.django_db +def test_get_consents(): + """Test get_consents method.""" + user1 = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + entity1 = EntityFactory(users=(user1,), name="entity1") + entity2 = EntityFactory(users=(user2,), name="entity2") + entity3 = EntityFactory( + users=(user3,), proxy_for=(entity1, entity2), name="entity3" + ) + + # create delivery points + dl1_1 = DeliveryPointFactory(provider_assigned_id="entity1_1", entity=entity1) + dl1_2 = DeliveryPointFactory(provider_assigned_id="entity1_2", entity=entity1) + dl1_3 = DeliveryPointFactory(provider_assigned_id="entity1_3", entity=entity1) + dl2_1 = DeliveryPointFactory(provider_assigned_id="entity2_1", entity=entity2) + dl2_2 = DeliveryPointFactory(provider_assigned_id="entity2_2", entity=entity2) + dl3_1 = DeliveryPointFactory(provider_assigned_id="entity3_1", entity=entity3) + dl3_2 = DeliveryPointFactory(provider_assigned_id="entity3_2", entity=entity3) + + # create awaiting consents + c1_1 = ConsentFactory(delivery_point=dl1_1, created_by=user1, status=AWAITING) + c1_2 = ConsentFactory(delivery_point=dl1_2, created_by=user1, status=AWAITING) + c2_1 = ConsentFactory(delivery_point=dl2_1, created_by=user2, status=AWAITING) + c2_2 = ConsentFactory(delivery_point=dl2_2, created_by=user2, status=AWAITING) + c3_1 = ConsentFactory(delivery_point=dl3_1, created_by=user3, status=AWAITING) + c3_2 = ConsentFactory(delivery_point=dl3_2, created_by=user3, status=AWAITING) + + # create validated consent for entity1 + c1_3 = ConsentFactory(delivery_point=dl1_3, created_by=user1, status=VALIDATED) + + # create awaiting consents for entity1 in past period + ConsentFactory( + delivery_point=dl1_1, + created_by=user1, + status=AWAITING, + start=timezone.now() - timedelta(days=300), + end=timezone.now() - timedelta(days=270), + ) + + assertQuerySetEqual(entity1.get_consents(), [c1_1, c1_2, c1_3]) + assertQuerySetEqual(entity2.get_consents(), [c2_1, c2_2]) + assertQuerySetEqual(entity3.get_consents(), [c3_1, c3_2]) + + +@pytest.mark.django_db +def test_get_awaiting_consents(): + """Test get_awaiting_consents method.""" + user1 = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + entity1 = EntityFactory(users=(user1,), name="entity1") + entity2 = EntityFactory(users=(user2,), name="entity2") + entity3 = EntityFactory( + users=(user3,), proxy_for=(entity1, entity2), name="entity3" + ) + + # create delivery points + dl1_1 = DeliveryPointFactory(provider_assigned_id="entity1_1", entity=entity1) + dl1_2 = DeliveryPointFactory(provider_assigned_id="entity1_2", entity=entity1) + dl1_3 = DeliveryPointFactory(provider_assigned_id="entity1_3", entity=entity1) + dl2_1 = DeliveryPointFactory(provider_assigned_id="entity2_1", entity=entity2) + dl2_2 = DeliveryPointFactory(provider_assigned_id="entity2_2", entity=entity2) + dl3_1 = DeliveryPointFactory(provider_assigned_id="entity3_1", entity=entity3) + dl3_2 = DeliveryPointFactory(provider_assigned_id="entity3_2", entity=entity3) + + # create awaiting consents + c1_1 = ConsentFactory(delivery_point=dl1_1, created_by=user1, status=AWAITING) + c1_2 = ConsentFactory(delivery_point=dl1_2, created_by=user1, status=AWAITING) + c2_1 = ConsentFactory(delivery_point=dl2_1, created_by=user2, status=AWAITING) + c2_2 = ConsentFactory(delivery_point=dl2_2, created_by=user2, status=AWAITING) + c3_1 = ConsentFactory(delivery_point=dl3_1, created_by=user3, status=AWAITING) + c3_2 = ConsentFactory(delivery_point=dl3_2, created_by=user3, status=AWAITING) + + # create validated consent for entity1 + ConsentFactory(delivery_point=dl1_3, created_by=user1, status=VALIDATED) + + # create awaiting consents for entity1 in past period + ConsentFactory( + delivery_point=dl1_1, + created_by=user1, + status=AWAITING, + start=timezone.now() - timedelta(days=300), + end=timezone.now() - timedelta(days=270), + ) + + # test with awaiting status + assertQuerySetEqual(entity1.get_awaiting_consents(), [c1_1, c1_2]) + assertQuerySetEqual(entity2.get_awaiting_consents(), [c2_1, c2_2]) + assertQuerySetEqual(entity3.get_awaiting_consents(), [c3_1, c3_2]) diff --git a/src/dashboard/apps/home/templates/home/base.html b/src/dashboard/apps/home/templates/home/base.html index 09e9ce3b..848cf306 100644 --- a/src/dashboard/apps/home/templates/home/base.html +++ b/src/dashboard/apps/home/templates/home/base.html @@ -1,4 +1,4 @@ {% extends "base.html" %} -{% block content %} -{% endblock content %} +{% block dashboard_content %} +{% endblock dashboard_content %} diff --git a/src/dashboard/apps/home/templates/home/index.html b/src/dashboard/apps/home/templates/home/index.html index 4119992b..fee5f3ac 100644 --- a/src/dashboard/apps/home/templates/home/index.html +++ b/src/dashboard/apps/home/templates/home/index.html @@ -2,10 +2,10 @@ {% load i18n %} -{% block content %} +{% block dashboard_content %}

{% trans "QualiCharge dashboard" %}

{% include "home/cards/consentement.html" %} -{% endblock content %} +{% endblock dashboard_content %} diff --git a/src/dashboard/dashboard/settings.py b/src/dashboard/dashboard/settings.py index 710a5794..dd4a0f54 100644 --- a/src/dashboard/dashboard/settings.py +++ b/src/dashboard/dashboard/settings.py @@ -74,7 +74,7 @@ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [ - "templates", + BASE_DIR / "templates", ], "APP_DIRS": True, "OPTIONS": { diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index 8f26b426..9c8b2baf 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -6,11 +6,20 @@ {% endblock header %} {% block content %} + {% if messages %} + + {% endif %} + {% block dashboard_content %}{% endblock dashboard_content %} {% endblock content %} {% block extra_js %} + {% block extra_dashboard_js %}{% endblock extra_dashboard_js %} {% endblock extra_js %} {# djlint:off #}