Skip to content

Commit

Permalink
✨(dashboard) Consent management
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
ssorin committed Dec 6, 2024
1 parent 677fe48 commit 3cb418d
Show file tree
Hide file tree
Showing 30 changed files with 809 additions and 103 deletions.
1 change: 1 addition & 0 deletions src/dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

23 changes: 22 additions & 1 deletion src/dashboard/apps/auth/models.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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()
74 changes: 74 additions & 0 deletions src/dashboard/apps/auth/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
16 changes: 16 additions & 0 deletions src/dashboard/apps/consent/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
1 change: 0 additions & 1 deletion src/dashboard/apps/consent/fixtures/consent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."))
16 changes: 16 additions & 0 deletions src/dashboard/apps/consent/managers.py
Original file line number Diff line number Diff line change
@@ -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(),
)
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
29 changes: 15 additions & 14 deletions src/dashboard/apps/consent/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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"]

Expand All @@ -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)
8 changes: 8 additions & 0 deletions src/dashboard/apps/consent/static/consent/js/app.js
Original file line number Diff line number Diff line change
@@ -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);
});
4 changes: 2 additions & 2 deletions src/dashboard/apps/consent/templates/consent/base.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "base.html" %}

{% block content %}
{% endblock content %}
{% block dashboard_content %}
{% endblock dashboard_content %}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{% load i18n %}

<h2>
{% trans "Consents summary" %}
</h2>

<p>
<a class="fr-link fr-icon-arrow-right-line fr-link--icon-right" href="{% url "consent:manage" %}">
<strong>
{% trans "Validate content for all entities" %}
</strong>
</a>
</p>

<ul>
{% for entity in entities %}

<li>
<strong>{{ entity.name }}</strong>
<br />
<a class="fr-link fr-icon-arrow-right-line fr-link--icon-right" href="{% url "consent:manage" entity.slug %}">
{% blocktranslate with entity_count=entity.count_awaiting_consents pluralize=entity.count_awaiting_consents|pluralize %}
{{ entity_count }} consent{{ pluralize }} to validate for this entity
{% endblocktranslate %}
</a>
</li>
{% endfor %}
</ul>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% load i18n %}

<h2>{% trans "Validated entities" %}</h2>

<ul>
{% for entity in entities %}
<li>
<strong>{{ entity.name }}</strong>
<br />
{% blocktranslate with entity_count=entity.count_validated_consents pluralize=entity.count_validated_consents|pluralize %}
{{ entity_count }} consent{{ pluralize }} validated
{% endblocktranslate %}
</li>
{% endfor %}
</ul>
17 changes: 5 additions & 12 deletions src/dashboard/apps/consent/templates/consent/index.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
{% extends "home/base.html" %}
{% extends "consent/base.html" %}

{% load i18n %}

{% block content %}
<h2>
{% trans "Consents summary" %}
</h2>

<p>
<a class="fr-link fr-icon-arrow-right-line fr-link--icon-right" href="{% url "consent:manage" %}">
{% trans "Manage consent" %}
</a>
</p>
{% endblock content %}
{% block dashboard_content %}
{% include "consent/includes/_resume_awaiting_consents.html" %}
{% include "consent/includes/_resume_validated_consents.html" %}
{% endblock dashboard_content %}
Loading

0 comments on commit 3cb418d

Please sign in to comment.