Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: creation de la table de suivi RGPD #892

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
10 changes: 9 additions & 1 deletion lacommunaute/users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group

from .models import User
from lacommunaute.users.models import EmailLastSeen, User


class UserAdmin(UserAdmin):
Expand All @@ -26,3 +26,11 @@ class GroupAdmin(admin.ModelAdmin):

admin.site.unregister(Group)
admin.site.register(Group, GroupAdmin)


@admin.register(EmailLastSeen)
class EmailLastSeenAdmin(admin.ModelAdmin):
list_display = ("email", "last_seen_at", "last_seen_kind", "deleted_at")
search_fields = ("email",)
list_filter = ("last_seen_kind", "deleted_at")
date_hierarchy = "last_seen_at"
10 changes: 10 additions & 0 deletions lacommunaute/users/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,13 @@ class IdentityProvider(models.TextChoices):
INCLUSION_CONNECT = "IC", "Inclusion Connect"
PRO_CONNECT = "PC", "Pro Connect"
MAGIC_LINK = "ML", "Magic Link"


class EmailLastSeenKind(models.TextChoices):
POST = "POST", "message"
DSP = "DSP", "Diag Parcours IAE"
EVENT = "EVENT", "évènement public"
UPVOTE = "UPVOTE", "abonnement"
FORUM_RATING = "FORUM_RATING", "notation de forum"
LOGGED = "LOGGED", "connexion"
VISITED = "VISITED", "notification cliquée"
14 changes: 12 additions & 2 deletions lacommunaute/users/factories.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import random
from datetime import UTC

import factory
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Group

from lacommunaute.users.enums import IdentityProvider
from lacommunaute.users.models import User
from lacommunaute.users.enums import EmailLastSeenKind, IdentityProvider
from lacommunaute.users.models import EmailLastSeen, User


DEFAULT_PASSWORD = "supercalifragilisticexpialidocious"
Expand Down Expand Up @@ -40,3 +41,12 @@ def with_perm(obj, create, extracted, **kwargs):
if not create or not extracted:
return
obj.user_permissions.add(*extracted)


class EmailLastSeenFactory(factory.django.DjangoModelFactory):
class Meta:
model = EmailLastSeen

email = factory.Faker("email")
last_seen_at = factory.Faker("date_time", tzinfo=UTC)
last_seen_kind = factory.Iterator(EmailLastSeenKind.choices, getter=lambda c: c[0])
61 changes: 61 additions & 0 deletions lacommunaute/users/migrations/0006_emaillastseen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Generated by Django 5.1.5 on 2025-01-27 12:07

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("users", "0005_run_management"),
]

operations = [
migrations.CreateModel(
name="EmailLastSeen",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"email",
models.EmailField(
blank=True,
max_length=254,
null=True,
unique=True,
verbose_name="email",
),
),
(
"email_hash",
models.CharField(max_length=255, verbose_name="email hash"),
),
("last_seen_at", models.DateTimeField(verbose_name="last seen at")),
(
"last_seen_kind",
models.CharField(
choices=[
("POST", "message"),
("DSP", "Diag Parcours IAE"),
("EVENT", "évènement public"),
("UPVOTE", "abonnement"),
("FORUM_RATING", "notation de forum"),
("LOGGED", "connexion"),
("VISITED", "notification cliquée"),
],
max_length=12,
verbose_name="last seen kind",
),
),
(
"deleted_at",
models.DateTimeField(blank=True, null=True, verbose_name="deleted at"),
),
],
),
]
37 changes: 36 additions & 1 deletion lacommunaute/users/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import hashlib
from uuid import uuid4

from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager
from django.db import models
from django.utils import timezone

from lacommunaute.users.enums import IdentityProvider
from lacommunaute.users.enums import EmailLastSeenKind, IdentityProvider


class UserManager(BaseUserManager):
Expand All @@ -25,3 +27,36 @@ class User(AbstractUser):

def __str__(self):
return self.email


class EmailLastSeenQuerySet(models.QuerySet):
def seen(self, email, kind):
if kind not in [kind for kind, _ in EmailLastSeenKind.choices]:
vincentporte marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError(f"Invalid kind: {kind}")

return self.update_or_create(email=email, defaults={"last_seen_at": timezone.now(), "last_seen_kind": kind})


class EmailLastSeen(models.Model):
email = models.EmailField(verbose_name="email", null=True, blank=True, unique=True)
email_hash = models.CharField(max_length=255, verbose_name="email hash", null=False)
last_seen_at = models.DateTimeField(verbose_name="last seen at", null=False)
last_seen_kind = models.CharField(
max_length=12, verbose_name="last seen kind", choices=EmailLastSeenKind.choices, null=False
)
deleted_at = models.DateTimeField(verbose_name="deleted at", null=True, blank=True)

objects = EmailLastSeenQuerySet.as_manager()

def __str__(self):
return f"{self.email} - {self.last_seen_at}"

def save(self, *args, **kwargs):
if self.email:
self.email_hash = hashlib.sha256(self.email.encode("utf-8")).hexdigest()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

En général, pour éviter les rainbow table, on sale les hash.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pour éviter de hasher souvent, j’aurais uniquement calculé le hash au moment de la suppression. Hasher n’est pas gratuit. 🤷

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On pourrait mettre une contrainte pour avoir soit l'email soit le hash de renseigné

super().save(*args, **kwargs)

def soft_delete(self):
self.deleted_at = timezone.now()
self.email = None
self.save()
75 changes: 70 additions & 5 deletions lacommunaute/users/tests/tests_models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,74 @@
import hashlib
import re

from lacommunaute.users.models import User
import pytest
from django.db import IntegrityError

from lacommunaute.users.enums import EmailLastSeenKind
from lacommunaute.users.factories import EmailLastSeenFactory
from lacommunaute.users.models import EmailLastSeen, User

def test_create_user_without_username(db):
user = User.objects.create_user(email="[email protected]")
assert re.match(r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$", user.username)
assert user.email == "[email protected]"

EMAIL = "[email protected]"


@pytest.fixture(name="email_last_seen")
def fixture_email_last_seen(db):
return EmailLastSeenFactory(email=EMAIL)


class TestUserModel:
def test_create_user_without_username(self, db):
user = User.objects.create_user(email=EMAIL)
assert re.match(r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$", user.username)
assert user.email == EMAIL


class TestEmailLastSeenModel:
def test_email_uniqueness(self, db, email_last_seen):
with pytest.raises(IntegrityError):
EmailLastSeenFactory(email=EMAIL)

def test_compute_hash_on_save(self, db, email_last_seen):
assert email_last_seen.email_hash == hashlib.sha256(EMAIL.encode("utf-8")).hexdigest()

def test_email_hash(self, db):
email = "[email protected]"
hashed_email = "bb247dfe5de638e67be1f4d5414ffbef8d3c93b6dd0513598b013e59640f584b"
assert hashlib.sha256(email.encode("utf-8")).hexdigest() == hashed_email

@pytest.mark.parametrize("updated_email", [None, EMAIL])
def test_hash_remains_unchanged_on_update(self, db, email_last_seen, updated_email):
email_last_seen.email = updated_email
email_last_seen.save()
email_last_seen.refresh_from_db()
assert email_last_seen.email_hash == hashlib.sha256(EMAIL.encode("utf-8")).hexdigest()

def test_soft_delete(self, db, email_last_seen):
email_last_seen.soft_delete()
email_last_seen.refresh_from_db()
assert email_last_seen.deleted_at is not None
assert email_last_seen.email is None
assert email_last_seen.email_hash not in [None, ""]


class TestEmailLastSeenQueryset:
@pytest.mark.parametrize("kind", [kind for kind, _ in EmailLastSeenKind.choices])
def test_seen(self, db, email_last_seen, kind):
EmailLastSeen.objects.seen(EMAIL, kind)

email_last_seen.refresh_from_db()
assert email_last_seen.last_seen_kind == kind
assert email_last_seen.last_seen_at is not None

def test_seen_invalid_kind(self, db, email_last_seen):
with pytest.raises(ValueError):
EmailLastSeen.objects.seen(EMAIL, "invalid_kind")

@pytest.mark.parametrize("kind", [kind for kind, _ in EmailLastSeenKind.choices])
def test_seen_unknown_email(self, db, kind):
EmailLastSeen.objects.seen(EMAIL, kind)

email_last_seen = EmailLastSeen.objects.get()
assert email_last_seen.last_seen_kind == kind
assert email_last_seen.last_seen_at is not None