diff --git a/lacommunaute/event/models.py b/lacommunaute/event/models.py index b29c5b6c..73a02c84 100644 --- a/lacommunaute/event/models.py +++ b/lacommunaute/event/models.py @@ -2,6 +2,9 @@ from django.db import models from machina.models.abstract_models import DatedModel +from lacommunaute.users.enums import EmailLastSeenKind +from lacommunaute.users.models import EmailLastSeen + class Event(DatedModel): name = models.CharField(max_length=100, verbose_name="Nom") @@ -28,3 +31,7 @@ class Meta: def __str__(self): return f"{self.name} - {self.date}" + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + EmailLastSeen.objects.seen(self.poster.email, EmailLastSeenKind.EVENT) diff --git a/lacommunaute/event/tests/tests_model.py b/lacommunaute/event/tests/tests_model.py index 501dff72..4452e378 100644 --- a/lacommunaute/event/tests/tests_model.py +++ b/lacommunaute/event/tests/tests_model.py @@ -1,10 +1,17 @@ +import pytest from django.db import IntegrityError -from django.test import TestCase from lacommunaute.event.factories import EventFactory +from lacommunaute.users.models import EmailLastSeen -class EventModelTest(TestCase): - def test_user_is_mandatory(self): - with self.assertRaises(IntegrityError): +class TestEventModel: + def test_user_is_mandatory(self, db): + with pytest.raises(IntegrityError): EventFactory(poster=None) + + def test_email_last_seen_updated_on_save(self, db): + EventFactory() + + email_last_seen = EmailLastSeen.objects.get() + assert email_last_seen.last_seen_kind == "EVENT" diff --git a/lacommunaute/forum/models.py b/lacommunaute/forum/models.py index 0f73b9ba..b6db4644 100644 --- a/lacommunaute/forum/models.py +++ b/lacommunaute/forum/models.py @@ -11,6 +11,8 @@ from lacommunaute.forum_conversation.models import Topic from lacommunaute.forum_upvote.models import UpVote from lacommunaute.partner.models import Partner +from lacommunaute.users.enums import EmailLastSeenKind +from lacommunaute.users.models import EmailLastSeen from lacommunaute.utils.validators import validate_image_size @@ -82,3 +84,8 @@ class Meta: verbose_name = "Notation Forum" verbose_name_plural = "Notations Forum" ordering = ("-created",) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.user: + EmailLastSeen.objects.seen(self.user.email, EmailLastSeenKind.FORUM_RATING) diff --git a/lacommunaute/forum/tests/__snapshots__/test_forum_rating_view.ambr b/lacommunaute/forum/tests/__snapshots__/test_forum_rating_view.ambr index 253a15c0..28ea05fd 100644 --- a/lacommunaute/forum/tests/__snapshots__/test_forum_rating_view.ambr +++ b/lacommunaute/forum/tests/__snapshots__/test_forum_rating_view.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_anonymous_post_forum_rating_view[anonymous_post_forum_rating_view] +# name: test_post_forum_rating_view[-1-authenticated_post_forum_rating_view][authenticated_post_forum_rating_view] '''

@@ -15,25 +15,25 @@
- +
- +
- +
- +
@@ -44,7 +44,7 @@ ''' # --- -# name: test_authenticated_post_forum_rating_view[authenticated_post_forum_rating_view] +# name: test_post_forum_rating_view[None-5-anonymous_post_forum_rating_view][anonymous_post_forum_rating_view] '''

@@ -60,25 +60,25 @@
- +
- +
- +
- +
diff --git a/lacommunaute/forum/tests/test_forum_rating_view.py b/lacommunaute/forum/tests/test_forum_rating_view.py index e0f8e3e2..66185161 100644 --- a/lacommunaute/forum/tests/test_forum_rating_view.py +++ b/lacommunaute/forum/tests/test_forum_rating_view.py @@ -3,7 +3,9 @@ from lacommunaute.forum.factories import ForumFactory from lacommunaute.forum.models import ForumRating +from lacommunaute.users.enums import EmailLastSeenKind from lacommunaute.users.factories import UserFactory +from lacommunaute.users.models import EmailLastSeen from lacommunaute.utils.testing import parse_response_to_soup @@ -17,40 +19,62 @@ def test_get_forum_rating_view(client, db, public_forum): assert response.status_code == 405 -def test_anonymous_post_forum_rating_view(client, db, public_forum, snapshot): - client.session.save() +@pytest.mark.parametrize( + "user,rating, snapshot_name", + [ + (None, 5, "anonymous_post_forum_rating_view"), + (lambda: UserFactory(), 1, "authenticated_post_forum_rating_view"), + ], +) +def test_post_forum_rating_view(client, db, public_forum, user, rating, snapshot_name, snapshot): + if user: + user = user() + client.force_login(user) + else: + client.session.save() response = client.post( reverse("forum_extension:rate", kwargs={"pk": public_forum.pk, "slug": public_forum.slug}), - data={"rating": "5"}, + data={"rating": rating}, ) assert response.status_code == 200 - assert response.context["forum"] == public_forum - assert response.context["rating"] == 5 content = parse_response_to_soup(response, replace_in_href=[public_forum]) - assert str(content) == snapshot(name="anonymous_post_forum_rating_view") + assert str(content) == snapshot(name=snapshot_name) forum_rating = ForumRating.objects.get() assert forum_rating.forum == public_forum - assert forum_rating.user is None - assert forum_rating.rating == 5 + assert forum_rating.rating == rating assert forum_rating.session_id == client.session.session_key + if user: + assert forum_rating.user == user + else: + assert forum_rating.user is None -def test_authenticated_post_forum_rating_view(client, db, public_forum, snapshot): - user = UserFactory() - client.force_login(user) +@pytest.mark.parametrize( + "user", + [ + None, + lambda: UserFactory(), + ], +) +def test_email_last_seen_is_updated_on_save(client, db, public_forum, user): + if user: + user = user() + client.force_login(user) + else: + client.session.save() response = client.post( reverse("forum_extension:rate", kwargs={"pk": public_forum.pk, "slug": public_forum.slug}), - data={"rating": "1"}, + data={"rating": 5}, ) assert response.status_code == 200 - content = parse_response_to_soup(response, replace_in_href=[public_forum]) - assert str(content) == snapshot(name="authenticated_post_forum_rating_view") - forum_rating = ForumRating.objects.get() - assert forum_rating.forum == public_forum - assert forum_rating.user == user - assert forum_rating.rating == 1 - assert forum_rating.session_id == client.session.session_key + if user: + email_last_seen = EmailLastSeen.objects.get() + assert email_last_seen.email == user.email + assert email_last_seen.last_seen_kind == EmailLastSeenKind.FORUM_RATING + assert email_last_seen.last_seen_at is not None + else: + assert not EmailLastSeen.objects.exists() diff --git a/lacommunaute/forum_conversation/models.py b/lacommunaute/forum_conversation/models.py index 6f1eda48..b14fb868 100644 --- a/lacommunaute/forum_conversation/models.py +++ b/lacommunaute/forum_conversation/models.py @@ -12,7 +12,8 @@ from lacommunaute.forum_conversation.signals import post_create from lacommunaute.forum_member.shortcuts import get_forum_member_display_name from lacommunaute.forum_upvote.models import UpVote -from lacommunaute.users.models import User +from lacommunaute.users.enums import EmailLastSeenKind +from lacommunaute.users.models import EmailLastSeen, User class TopicQuerySet(models.QuerySet): @@ -156,6 +157,8 @@ def save(self, *args, **kwargs): if created: post_create.send(sender=self.__class__, instance=self) + EmailLastSeen.objects.seen(self.username if self.username else self.poster.email, EmailLastSeenKind.POST) + class CertifiedPost(DatedModel): topic = models.OneToOneField( diff --git a/lacommunaute/forum_conversation/tests/tests_models.py b/lacommunaute/forum_conversation/tests/tests_models.py index 18565d7d..7b32eec8 100644 --- a/lacommunaute/forum_conversation/tests/tests_models.py +++ b/lacommunaute/forum_conversation/tests/tests_models.py @@ -15,7 +15,9 @@ ) from lacommunaute.forum_conversation.models import Post, Topic from lacommunaute.forum_member.shortcuts import get_forum_member_display_name +from lacommunaute.users.enums import EmailLastSeenKind from lacommunaute.users.factories import UserFactory +from lacommunaute.users.models import EmailLastSeen @pytest.fixture(name="forum") @@ -23,20 +25,39 @@ def fixture_forum(db): return ForumFactory() -class PostModelTest(TestCase): - def test_username_is_emailfield(self): +class TestPostModel: + def test_username_is_emailfield(self, db): topic = TopicFactory() post = Post(username="not an email", subject="xxx", content="xxx", topic=topic) - with self.assertRaisesMessage(ValidationError, "Saisissez une adresse de courriel valide."): + with pytest.raises(ValidationError): post.full_clean() - def test_is_certified(self): + def test_is_certified(self, db): topic = TopicFactory(with_post=True) - self.assertFalse(topic.last_post.is_certified) + assert topic.last_post.is_certified is False topic = TopicFactory(with_certified_post=True) - self.assertTrue(topic.last_post.is_certified) + assert topic.last_post.is_certified is True + + @pytest.mark.parametrize( + "user,username", [(None, "adam@ondra.com"), (lambda: UserFactory(email="yvon@chouinard.com"), None)] + ) + def test_email_last_seen_is_update_on_save(self, db, user, username): + if user: + user = user() + PostFactory(topic=TopicFactory(), poster=user) + else: + PostFactory(topic=TopicFactory(), username=username) + + email_last_seen = EmailLastSeen.objects.get() + + assert email_last_seen.last_seen_kind == EmailLastSeenKind.POST + assert email_last_seen.last_seen_at is not None + if user: + assert email_last_seen.email == user.email + else: + assert email_last_seen.email == username class TestTopicManager: diff --git a/lacommunaute/forum_conversation/tests/tests_shortcuts.py b/lacommunaute/forum_conversation/tests/tests_shortcuts.py index 79d01597..170a096d 100644 --- a/lacommunaute/forum_conversation/tests/tests_shortcuts.py +++ b/lacommunaute/forum_conversation/tests/tests_shortcuts.py @@ -50,7 +50,7 @@ def test_topic_has_two_posts_requested_by_authenticated_user(self): def test_topic_has_been_upvoted(self): topic = TopicFactory(with_post=True) post = PostFactory(topic=topic) - UpVoteFactory(content_object=post) + UpVoteFactory(content_object=post, voter=post.poster) posts = get_posts_of_a_topic_except_first_one(topic, AnonymousUser()) post = posts.first() diff --git a/lacommunaute/forum_upvote/models.py b/lacommunaute/forum_upvote/models.py index a9990122..dc043b8a 100644 --- a/lacommunaute/forum_upvote/models.py +++ b/lacommunaute/forum_upvote/models.py @@ -3,6 +3,9 @@ from django.contrib.contenttypes.models import ContentType from django.db import models +from lacommunaute.users.enums import EmailLastSeenKind +from lacommunaute.users.models import EmailLastSeen + class UpVote(models.Model): voter = models.ForeignKey( @@ -27,3 +30,7 @@ class Meta: ordering = [ "-created_at", ] + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + EmailLastSeen.objects.seen(self.voter.email, EmailLastSeenKind.UPVOTE) diff --git a/lacommunaute/forum_upvote/tests/tests_models.py b/lacommunaute/forum_upvote/tests/tests_models.py index 0d4f0526..b2b1148e 100644 --- a/lacommunaute/forum_upvote/tests/tests_models.py +++ b/lacommunaute/forum_upvote/tests/tests_models.py @@ -1,28 +1,37 @@ +import pytest from django.db import IntegrityError -from django.test import TestCase from lacommunaute.forum_conversation.factories import TopicFactory from lacommunaute.forum_upvote.models import UpVote +from lacommunaute.users.enums import EmailLastSeenKind +from lacommunaute.users.models import EmailLastSeen -class UpVoteModelTest(TestCase): - def test_generic_relation(self): +class TestUpVoteModel: + def test_generic_relation(self, db): topic = TopicFactory(with_post=True) UpVote.objects.create(content_object=topic.first_post, voter=topic.first_post.poster) UpVote.objects.create(content_object=topic.forum, voter=topic.first_post.poster) - self.assertEqual(UpVote.objects.count(), 2) + assert UpVote.objects.count() == 2 - def test_upvoted_post_unicity(self): + def test_upvoted_post_unicity(self, db): topic = TopicFactory(with_post=True) UpVote.objects.create(content_object=topic.first_post, voter=topic.first_post.poster) - with self.assertRaises(IntegrityError): + with pytest.raises(IntegrityError): UpVote.objects.create(content_object=topic.first_post, voter=topic.first_post.poster) - def test_upvoted_forum_unicity(self): + def test_upvoted_forum_unicity(self, db): topic = TopicFactory(with_post=True) UpVote.objects.create(content_object=topic.forum, voter=topic.first_post.poster) - with self.assertRaises(IntegrityError): + with pytest.raises(IntegrityError): UpVote.objects.create(content_object=topic.forum, voter=topic.first_post.poster) + + def test_email_last_seen_is_updated_on_save(self, db): + topic = TopicFactory(with_post=True) + UpVote.objects.create(content_object=topic.first_post, voter=topic.first_post.poster) + + email_last_seen = EmailLastSeen.objects.get() + assert email_last_seen.last_seen_kind == EmailLastSeenKind.UPVOTE diff --git a/lacommunaute/notification/models.py b/lacommunaute/notification/models.py index 5b65d42e..caaa0048 100644 --- a/lacommunaute/notification/models.py +++ b/lacommunaute/notification/models.py @@ -79,3 +79,9 @@ def sent(self): return self.sent_at is not None objects = NotificationQuerySet().as_manager() + + # TODO: vincentporte, en attente de la PR#891 + # def update(self, *args, **kwargs): + # super().update(*args, **kwargs) + # if visited_at: + # EmailLastSeen.objects.seen(self.recipient, EmailLastSeenKind.NOTIFICATION) diff --git a/lacommunaute/surveys/models.py b/lacommunaute/surveys/models.py index df1c97c3..f8cbb691 100644 --- a/lacommunaute/surveys/models.py +++ b/lacommunaute/surveys/models.py @@ -2,7 +2,8 @@ from machina.models.abstract_models import DatedModel from lacommunaute.surveys import enums -from lacommunaute.users.models import User +from lacommunaute.users.enums import EmailLastSeenKind +from lacommunaute.users.models import EmailLastSeen, User class Recommendation(models.Model): @@ -69,3 +70,7 @@ class DSP(DatedModel): class Meta: verbose_name = "diagnostic parcours IAE" verbose_name_plural = "diagnostics parcours IAE" + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + EmailLastSeen.objects.seen(self.user.email, EmailLastSeenKind.DSP) diff --git a/lacommunaute/surveys/tests/test_views.py b/lacommunaute/surveys/tests/test_views.py index a299d44b..37e5e4a0 100644 --- a/lacommunaute/surveys/tests/test_views.py +++ b/lacommunaute/surveys/tests/test_views.py @@ -1,3 +1,4 @@ +import pytest from django.test import override_settings from django.urls import reverse from pytest_django.asserts import assertContains @@ -6,6 +7,7 @@ from lacommunaute.surveys.factories import DSPFactory from lacommunaute.surveys.models import DSP from lacommunaute.users.factories import UserFactory +from lacommunaute.users.models import EmailLastSeen from lacommunaute.utils.testing import parse_response_to_soup @@ -22,46 +24,51 @@ location_field_list = ["location", "city_code"] +@pytest.fixture(name="dsp_create_url") +def fixture_dsp_create_url(): + return reverse("surveys:dsp_create") + + +@pytest.fixture(name="choices") +def fixture_choices(): + choices = {key: "0" for key in dsp_choices_list} + choices.update({"location": "Le Mans", "city_code": "72000"}) + return choices + + class TestDSPCreateView: - def test_action_box(self, db, client, snapshot): - url = reverse("surveys:dsp_create") - response = client.get(url) + def test_action_box(self, db, client, dsp_create_url, snapshot): + response = client.get(dsp_create_url) content = parse_response_to_soup(response, selector="#action-box") assert str(content) == snapshot(name="action_box") - def test_form_fields(self, db, client): - url = reverse("surveys:dsp_create") + def test_form_fields(self, db, client, dsp_create_url): client.force_login(UserFactory()) - response = client.get(url) + response = client.get(dsp_create_url) assert response.status_code == 200 assert "form" in response.context for field in dsp_choices_list + location_field_list: assert field in response.context["form"].fields - def test_related_forums(self, db, client): + def test_related_forums(self, db, client, dsp_create_url): forum = CategoryForumFactory(with_child=True) - url = reverse("surveys:dsp_create") with override_settings(DSP_FORUM_RELATED_ID=forum.id): - response = client.get(url) + response = client.get(dsp_create_url) for related_forum in forum.get_children(): assertContains(response, related_forum.name) - def test_form_valid(self, db, client): - url = reverse("surveys:dsp_create") + def test_form_valid(self, db, client, choices, dsp_create_url): client.force_login(UserFactory()) - choices = {key: "0" for key in dsp_choices_list} - choices.update({"location": "Le Mans", "city_code": "72000"}) - response = client.post(url, choices) + response = client.post(dsp_create_url, choices) assert response.status_code == 302 dsp = DSP.objects.get() assert response.url == reverse("surveys:dsp_detail", kwargs={"pk": dsp.pk}) assert dsp.recommendations is not None - def test_form_invalid(self, db, client): - url = reverse("surveys:dsp_create") + def test_form_invalid(self, db, client, dsp_create_url): client.force_login(UserFactory()) - response = client.post(url, {}) + response = client.post(dsp_create_url, {}) assert response.status_code == 200 assert response.context["form"].is_valid() is False @@ -69,6 +76,17 @@ def test_form_invalid(self, db, client): for field in dsp_choices_list + ["location"]: assert errors[field] == ["Ce champ est obligatoire."] + def test_email_last_seen_is_updated_on_save(self, client, db, choices, dsp_create_url): + user = UserFactory() + client.force_login(user) + response = client.post(dsp_create_url, choices) + assert response.status_code == 302 + + email_last_seen = EmailLastSeen.objects.get() + assert email_last_seen.email == user.email + assert email_last_seen.last_seen_kind == "DSP" + assert email_last_seen.last_seen_at is not None + class TestDSPDetailView: def test_login_required(self, db, client): diff --git a/lacommunaute/users/admin.py b/lacommunaute/users/admin.py index d875bcab..eedbb954 100644 --- a/lacommunaute/users/admin.py +++ b/lacommunaute/users/admin.py @@ -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): @@ -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" diff --git a/lacommunaute/users/enums.py b/lacommunaute/users/enums.py index 2e7051fb..f35f37de 100644 --- a/lacommunaute/users/enums.py +++ b/lacommunaute/users/enums.py @@ -5,3 +5,12 @@ 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" diff --git a/lacommunaute/users/factories.py b/lacommunaute/users/factories.py index 928e9d38..1a3c3af8 100644 --- a/lacommunaute/users/factories.py +++ b/lacommunaute/users/factories.py @@ -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" @@ -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]) diff --git a/lacommunaute/users/management/commands/populate_emaillastseen.py b/lacommunaute/users/management/commands/populate_emaillastseen.py new file mode 100644 index 00000000..a71bda11 --- /dev/null +++ b/lacommunaute/users/management/commands/populate_emaillastseen.py @@ -0,0 +1,125 @@ +import sys + +from django.core.management.base import BaseCommand +from django.db.models import Value + +from lacommunaute.event.models import Event +from lacommunaute.forum.models import ForumRating +from lacommunaute.forum_conversation.models import Post +from lacommunaute.forum_upvote.models import UpVote +from lacommunaute.surveys.models import DSP +from lacommunaute.users.enums import EmailLastSeenKind +from lacommunaute.users.models import EmailLastSeen, User + + +def collect_users_logged_in(): + qs = ( + User.objects.exclude(last_login=None) + .annotate(kind=Value(EmailLastSeenKind.LOGGED)) + .values_list("email", "last_login", "kind") + ) + return list(qs) + + +def collect_event(): + qs = ( + Event.objects.all() + .annotate(kind=Value(EmailLastSeenKind.EVENT)) + .values_list("poster__email", "created", "kind") + ) + return list(qs) + + +def collect_DSP(): + qs = DSP.objects.all().annotate(kind=Value(EmailLastSeenKind.DSP)).values_list("user__email", "created", "kind") + return list(qs) + + +def collect_upvote(): + qs = ( + UpVote.objects.exclude(voter=None) + .annotate(kind=Value(EmailLastSeenKind.UPVOTE)) + .values_list("voter__email", "created_at", "kind") + ) + return list(qs) + + +def collect_forum_rating(): + qs = ( + ForumRating.objects.exclude(user=None) + .annotate(kind=Value(EmailLastSeenKind.FORUM_RATING)) + .values_list("user__email", "created", "kind") + ) + return list(qs) + + +def collect_post(): + qs_authenticated = ( + Post.objects.exclude(poster=None) + .annotate(kind=Value(EmailLastSeenKind.POST)) + .values_list("poster__email", "created", "kind") + ) + qs_anonymous = ( + Post.objects.filter(poster=None) + .annotate(kind=Value(EmailLastSeenKind.POST)) + .values_list("username", "created", "kind") + ) + return list(qs_authenticated) + list(qs_anonymous) + + +def collect_clicked_notifs(): + # TODO VincentPorte, en attente #891 + sys.stdout.write("collect_clicked_notifs: pending #891\n") + return [] + + +def deduplicate(last_seen): + return {tup[0]: tup for tup in sorted(last_seen, key=lambda tup: (tup[0], tup[1]))} + + +def remove_known_last_seen(dedup_last_seen_dict): + known_last_seen = EmailLastSeen.objects.values_list("email", flat=True) + return {k: v for k, v in dedup_last_seen_dict.items() if k not in known_last_seen} + + +def insert_last_seen(dedup_last_seen_dict): + obj = [EmailLastSeen(email=k, last_seen_at=v[1], last_seen_kind=v[2]) for k, v in dedup_last_seen_dict.items()] + return EmailLastSeen.objects.bulk_create(obj, batch_size=1000) + + +class Command(BaseCommand): + help = "hydratation de la table EmailLastSeen avec la date de dernière visite des emails connus" + + def handle(self, *args, **options): + last_seen = collect_users_logged_in() + sys.stdout.write(f"users logged in: collected {len(last_seen)}\n") + + last_seen += collect_event() + sys.stdout.write(f"events: collected {len(last_seen)}\n") + + last_seen += collect_DSP() + sys.stdout.write(f"DSP: collected {len(last_seen)}\n") + + last_seen += collect_upvote() + sys.stdout.write(f"UpVotes: collected {len(last_seen)}\n") + + last_seen += collect_forum_rating() + sys.stdout.write(f"forum ratings: collected {len(last_seen)}\n") + + last_seen += collect_post() + sys.stdout.write(f"posts: collected {len(last_seen)}\n") + + last_seen += collect_clicked_notifs() + sys.stdout.write(f"clicked notifications: collected {len(last_seen)}\n") + + dedup_last_seen_dict = deduplicate(last_seen) + sys.stdout.write(f"deduplication: {len(dedup_last_seen_dict)}\n") + + dedup_last_seen_dict = remove_known_last_seen(dedup_last_seen_dict) + sys.stdout.write(f"remove known last seen: {len(dedup_last_seen_dict)}\n") + + res = insert_last_seen(dedup_last_seen_dict) + sys.stdout.write(f"insert last seen: {len(res)}\n") + + sys.stdout.write("that's all folks!\n") + sys.stdout.flush() diff --git a/lacommunaute/users/migrations/0006_emaillastseen.py b/lacommunaute/users/migrations/0006_emaillastseen.py new file mode 100644 index 00000000..cccbd8f6 --- /dev/null +++ b/lacommunaute/users/migrations/0006_emaillastseen.py @@ -0,0 +1,60 @@ +# 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"), + ], + max_length=12, + verbose_name="last seen kind", + ), + ), + ( + "deleted_at", + models.DateTimeField(blank=True, null=True, verbose_name="deleted at"), + ), + ], + ), + ] diff --git a/lacommunaute/users/models.py b/lacommunaute/users/models.py index 6d100f4b..2d55f125 100644 --- a/lacommunaute/users/models.py +++ b/lacommunaute/users/models.py @@ -1,9 +1,12 @@ +import hashlib from uuid import uuid4 from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager +from django.contrib.auth.signals import user_logged_in 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): @@ -25,3 +28,43 @@ 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]: + 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() + super().save(*args, **kwargs) + + def soft_delete(self): + self.deleted_at = timezone.now() + self.email = None + self.save() + + +def update_email_last_seen(sender, user, request, **kwargs): + EmailLastSeen.objects.seen(email=user.email, kind=EmailLastSeenKind.LOGGED) + + +user_logged_in.connect(update_email_last_seen, dispatch_uid="update_email_last_seen") diff --git a/lacommunaute/users/tests/tests_management_commands.py b/lacommunaute/users/tests/tests_management_commands.py new file mode 100644 index 00000000..4e0ea2e0 --- /dev/null +++ b/lacommunaute/users/tests/tests_management_commands.py @@ -0,0 +1,144 @@ +from datetime import datetime + +from django.core.management import call_command +from django.utils import timezone + +from lacommunaute.event.factories import EventFactory +from lacommunaute.forum.factories import ForumFactory, ForumRatingFactory +from lacommunaute.forum_conversation.factories import AnonymousTopicFactory, TopicFactory +from lacommunaute.forum_upvote.factories import UpVoteFactory +from lacommunaute.surveys.factories import DSPFactory +from lacommunaute.users.enums import EmailLastSeenKind +from lacommunaute.users.factories import EmailLastSeenFactory, UserFactory +from lacommunaute.users.management.commands.populate_emaillastseen import ( + collect_DSP, + collect_event, + collect_forum_rating, + collect_post, + collect_upvote, + collect_users_logged_in, + deduplicate, + insert_last_seen, + remove_known_last_seen, +) +from lacommunaute.users.models import EmailLastSeen + + +def test_collect_users_logged_in(db): + logged_user = UserFactory(last_login=timezone.make_aware(datetime(2024, 10, 22))) + UserFactory(last_login=None) + assert collect_users_logged_in() == [(logged_user.email, logged_user.last_login, EmailLastSeenKind.LOGGED)] + + +def test_collect_event(db): + event = EventFactory() + assert collect_event() == [(event.poster.email, event.created, EmailLastSeenKind.EVENT)] + + +def test_collect_DSP(db): + dsp = DSPFactory() + assert collect_DSP() == [(dsp.user.email, dsp.created, "DSP")] + + +def test_upvote(db): + upvote = UpVoteFactory(content_object=ForumFactory(), voter=UserFactory()) + assert collect_upvote() == [(upvote.voter.email, upvote.created_at, EmailLastSeenKind.UPVOTE)] + + +def test_forum_rating(db): + ForumRatingFactory(user=None) + forum_rating = ForumRatingFactory(user=UserFactory()) + assert collect_forum_rating() == [(forum_rating.user.email, forum_rating.created, EmailLastSeenKind.FORUM_RATING)] + + +def test_collect_post(db): + topic = TopicFactory(with_post=True) + anonymous_topic = AnonymousTopicFactory(with_post=True) + + assert collect_post() == [ + (topic.first_post.poster.email, topic.first_post.created, EmailLastSeenKind.POST), + (anonymous_topic.first_post.username, anonymous_topic.first_post.created, EmailLastSeenKind.POST), + ] + + +def test_collect_clicked_notifs(): + # TODO VincentPorte, en attente #891 + assert False + + +def test_deduplicate(): + emails = ["toby@roberts.com", "adam@ondra.com", "jakob@schubert.com"] + last_seen = [(email, timezone.now(), kind) for email in emails for kind in EmailLastSeenKind.values] + + deduplicated = deduplicate(last_seen) + for email in list(set(emails)): + assert deduplicated[email][0] == email + assert deduplicated[email][2] == EmailLastSeenKind.LOGGED + + +def test_remove_known_last_seen(db): + emails = ["oriane@bertone.com", "catherine@destivelle.com"] + EmailLastSeenFactory(email=emails[1]) + deduplicated = {email: (email, datetime(2024, 10, 22), EmailLastSeenKind.FORUM_RATING) for email in emails} + + output = remove_known_last_seen(deduplicated) + assert emails[0] in output + assert emails[1] not in output + + +def test_insert_last_seen(db): + emails = ["brooke@raboutou.com", "natalia@grossman.com"] + kinds = [EmailLastSeenKind.POST, EmailLastSeenKind.LOGGED] + deduplicated = {email: (email, datetime(2024, 10, 22), kind) for email, kind in zip(emails, kinds)} + + insert_last_seen(deduplicated) + assert EmailLastSeen.objects.count() == 2 + for email, kind in zip(emails, kinds): + email_last_seen = EmailLastSeen.objects.get(email=email) + assert email_last_seen.last_seen_kind == kind + + +def test_populate_emaillastseen_command(db): + user = UserFactory(last_login=timezone.make_aware(datetime(2024, 10, 22))) + event = EventFactory() + dsp = DSPFactory() + upvote = UpVoteFactory(content_object=ForumFactory(), voter=UserFactory()) + forum_rating = ForumRatingFactory(user=UserFactory()) + topic = TopicFactory(with_post=True) + anonymous_topic = AnonymousTopicFactory(with_post=True) + # TODO VincentPorte, en attente #891 + # clicked_notification = NotificationFactory(visited_at=timezone.now()) + + # duplicated email + event_for_duplicated = EventFactory() + DSPFactory(user=event_for_duplicated.poster) + + # already known email + event_for_known = EventFactory() + EmailLastSeen.objects.all().delete() + EmailLastSeenFactory(email=event_for_known.poster.email, last_seen_kind=EmailLastSeenKind.FORUM_RATING) + + call_command("populate_emaillastseen") + + assert EmailLastSeen.objects.count() == 9 + assert EmailLastSeen.objects.filter(email=user.email, last_seen_kind=EmailLastSeenKind.LOGGED).exists() + assert EmailLastSeen.objects.filter(email=event.poster.email, last_seen_kind=EmailLastSeenKind.EVENT).exists() + assert EmailLastSeen.objects.filter(email=dsp.user.email, last_seen_kind=EmailLastSeenKind.DSP).exists() + assert EmailLastSeen.objects.filter(email=upvote.voter.email, last_seen_kind=EmailLastSeenKind.UPVOTE).exists() + assert EmailLastSeen.objects.filter( + email=forum_rating.user.email, last_seen_kind=EmailLastSeenKind.FORUM_RATING + ).exists() + assert EmailLastSeen.objects.filter( + email=topic.first_post.poster.email, last_seen_kind=EmailLastSeenKind.POST + ).exists() + assert EmailLastSeen.objects.filter( + email=anonymous_topic.first_post.username, last_seen_kind=EmailLastSeenKind.POST + ).exists() + # TODO VincentPorte, en attente #891 + # assert EmailLastSeen.objects.filter(email=clicked_notification.recipient, last_seen_kind=XXXX).exists() + assert EmailLastSeen.objects.filter( + email=event_for_duplicated.poster.email, last_seen_kind=EmailLastSeenKind.DSP + ).exists() + assert EmailLastSeen.objects.filter( + email=event_for_known.poster.email, last_seen_kind=EmailLastSeenKind.FORUM_RATING + ).exists() diff --git a/lacommunaute/users/tests/tests_models.py b/lacommunaute/users/tests/tests_models.py index d533fd3e..f981eec6 100644 --- a/lacommunaute/users/tests/tests_models.py +++ b/lacommunaute/users/tests/tests_models.py @@ -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="alex@honnold.com") - assert re.match(r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$", user.username) - assert user.email == "alex@honnold.com" + +EMAIL = "alex@honnold.com" + + +@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 = "janja@garnbret.com" + 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 diff --git a/lacommunaute/users/tests/tests_views.py b/lacommunaute/users/tests/tests_views.py index d50fb0b1..261b5fa3 100644 --- a/lacommunaute/users/tests/tests_views.py +++ b/lacommunaute/users/tests/tests_views.py @@ -19,9 +19,9 @@ from lacommunaute.forum_member.models import ForumProfile from lacommunaute.notification.emails import SIB_SMTP_URL -from lacommunaute.users.enums import IdentityProvider +from lacommunaute.users.enums import EmailLastSeenKind, IdentityProvider from lacommunaute.users.factories import UserFactory -from lacommunaute.users.models import User +from lacommunaute.users.models import EmailLastSeen, User from lacommunaute.users.views import send_magic_link from lacommunaute.utils.enums import Environment from lacommunaute.utils.testing import parse_response_to_soup @@ -178,6 +178,15 @@ def test_user_is_already_authenticated(self, client, db, next_url, expected): assert response.status_code == 302 assert response.url == expected + def test_email_last_seen_is_updated_on_login_signal(self, client, db): + user = UserFactory() + client.force_login(user) + + email_last_seen = EmailLastSeen.objects.get() + assert email_last_seen.email == user.email + assert email_last_seen.last_seen_at is not None + assert email_last_seen.last_seen_kind == EmailLastSeenKind.LOGGED + class TestCreateUserView: @override_settings(ENVIRONMENT=Environment.PROD)