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..0ceb613c 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,22 @@ class DashboardUser(AbstractUser):
AbstractUser model in Django.
"""
- pass
+ def get_entities(self) -> QuerySet:
+ """Get a list of entities and their proxies associated."""
+ return Entity.objects.filter(Q(users=self) | Q(proxies__users=self)).distinct()
+
+ def has_proxy(self) -> bool:
+ """Determines if user has at least one entity that is proxy_for."""
+ return Entity.objects.filter(proxies__users=self).exists()
+
+ def can_validate_entity(self, entity: Entity) -> bool:
+ """Determines if the provided entity can be validated."""
+ return self._is_user_for_entity(entity) or self._is_proxy_for_entity(entity)
+
+ def _is_user_for_entity(self, entity: Entity) -> bool:
+ """Determines if the specified entity is associated with the user."""
+ return self.entities.filter(id=entity.id).exists()
+
+ def _is_proxy_for_entity(self, entity: Entity) -> bool:
+ """Determines if the specified entity is associated with the user via proxy."""
+ return self.entities.filter(proxy_for=entity).exists()
diff --git a/src/dashboard/apps/auth/tests/test_models.py b/src/dashboard/apps/auth/tests/test_models.py
index d72564b0..b71f6b54 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,75 @@ 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_has_proxy():
+ """Test if user has at least one entity that is proxy_for."""
+ user1 = UserFactory()
+ user2 = UserFactory()
+ user3 = UserFactory()
+ user4 = UserFactory()
+ entity1 = EntityFactory(users=(user1,), name="entity1")
+ entity2 = EntityFactory(users=(user2,), name="entity2")
+ EntityFactory(users=(user3,), proxy_for=(entity1, entity2), name="entity3")
+
+ # multiple entities including one with proxy_for
+ EntityFactory(users=(user4,), name="entity4")
+ EntityFactory(users=(user4,), proxy_for=(entity2,), name="entity5")
+
+ assert user1.has_proxy() is False
+ assert user2.has_proxy() is False
+ assert user3.has_proxy() is True
+ assert user4.has_proxy() is True
+
+
+@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..52522208
--- /dev/null
+++ b/src/dashboard/apps/consent/managers.py
@@ -0,0 +1,16 @@
+"""Dashboard consent app managers."""
+
+from django.db import models
+from django.utils import timezone
+
+
+class ConsentManager(models.Manager):
+ """Custom consent manager."""
+
+ def active(self):
+ """Return consents with active delivery point, for the current period."""
+ return self.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" %}
+
+
+ {% for entity in entities %}
+ -
+ {{ entity.name }}
+
+ {% blocktranslate with entity_count=entity.count_validated_consents pluralize=entity.count_validated_consents|pluralize %}
+ {{ entity_count }} consent{{ pluralize }} validated
+ {% endblocktranslate %}
+
+ {% endfor %}
+
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 %}
+
+
+ {% 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..3778f137 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,39 @@ 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.active()
+ .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 +89,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 %}
+
+ {% for message in messages %}
+ - {{ message }}
+ {% endfor %}
+
+ {% 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 #}