diff --git a/config/settings/base.py b/config/settings/base.py index 281867d19c..222ada2d6d 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -168,6 +168,7 @@ "itou.utils.settings_context_processors.expose_settings", "itou.utils.context_processors.expose_enums", "itou.utils.context_processors.matomo", + "itou.utils.context_processors.active_announcement_campaign", ] }, } diff --git a/itou/communications/admin.py b/itou/communications/admin.py new file mode 100644 index 0000000000..a18bce4312 --- /dev/null +++ b/itou/communications/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from itou.communications import models +from itou.utils.admin import ItouModelAdmin, ItouTabularInline + + +class AnnouncementItemInline(ItouTabularInline): + model = models.AnnouncementItem + fields = ("priority", "title", "description") + extra = 0 + + +@admin.register(models.AnnouncementCampaign) +class AnnouncementCampaignAdmin(ItouModelAdmin): + list_display = ("start_date", "end_date", "live") + fields = ("max_items", "start_date", "live") + inlines = (AnnouncementItemInline,) diff --git a/itou/communications/cache.py b/itou/communications/cache.py new file mode 100644 index 0000000000..cd95ab0ada --- /dev/null +++ b/itou/communications/cache.py @@ -0,0 +1,35 @@ +from datetime import date, datetime + +from django.core.cache import cache + +from itou.communications.models import AnnouncementCampaign + + +CACHE_ACTIVE_ANNOUNCEMENTS_KEY = "active-announcement-campaign" +SENTINEL_ACTIVE_ANNOUNCEMENT = object() + + +def update_active_announcement_cache(): + campaign = ( + AnnouncementCampaign.objects.filter(start_date=date.today().replace(day=1), live=True) + .prefetch_related("items") + .first() + ) + + if campaign is None: + cache.set(CACHE_ACTIVE_ANNOUNCEMENTS_KEY, None, None) + else: + cache_exp = ( + datetime.combine(campaign.end_date, datetime.min.time()) - datetime.now() + ).total_seconds() # seconds until the end_date, 00:00 + + cache.set(CACHE_ACTIVE_ANNOUNCEMENTS_KEY, campaign, cache_exp) + + return campaign + + +def get_cached_active_announcement(): + campaign = cache.get(CACHE_ACTIVE_ANNOUNCEMENTS_KEY, SENTINEL_ACTIVE_ANNOUNCEMENT) + if campaign == SENTINEL_ACTIVE_ANNOUNCEMENT: + return update_active_announcement_cache() + return campaign diff --git a/itou/communications/migrations/0002_announcementcampaign_announcementitem_and_more.py b/itou/communications/migrations/0002_announcementcampaign_announcementitem_and_more.py new file mode 100644 index 0000000000..f53e3ef67b --- /dev/null +++ b/itou/communications/migrations/0002_announcementcampaign_announcementitem_and_more.py @@ -0,0 +1,97 @@ +# Generated by Django 5.0.7 on 2024-07-23 17:27 + +import django.core.validators +import django.db.models.deletion +import django.db.models.functions.datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("communications", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="AnnouncementCampaign", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "max_items", + models.PositiveIntegerField( + default=3, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(10), + ], + verbose_name="nombre d'articles affiché", + ), + ), + ( + "start_date", + models.DateField( + help_text="le mois des nouveautés. Automatiquement fixé au premier du mois saisi", + verbose_name="mois concerné", + unique=True, + ), + ), + ( + "live", + models.BooleanField( + default=True, help_text="les modifications sont toujours possible", verbose_name="prêt" + ), + ), + ], + options={ + "verbose_name": "campagne d'annonce", + "ordering": ["-start_date"], + }, + ), + migrations.CreateModel( + name="AnnouncementItem", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "priority", + models.PositiveIntegerField( + default=0, + help_text="le plus bas le valeur, le plus haut dans le fil des articles", + verbose_name="priorité", + ), + ), + ("title", models.TextField(help_text="résumé de nouveauté", verbose_name="titre")), + ( + "description", + models.TextField(help_text="détail du nouveauté ; le contenu", verbose_name="description"), + ), + ], + options={ + "verbose_name": "article d'annonce", + "ordering": ["-campaign__start_date", "priority", "pk"], + }, + ), + migrations.AddConstraint( + model_name="announcementcampaign", + constraint=models.CheckConstraint( + check=models.Q(("max_items__gte", 1), ("max_items__lte", 10)), name="max_items_range" + ), + ), + migrations.AddConstraint( + model_name="announcementcampaign", + constraint=models.CheckConstraint(check=models.Q(("start_date__day", 1)), name="start_on_month"), + ), + migrations.AddField( + model_name="announcementitem", + name="campaign", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="communications.announcementcampaign", + verbose_name="campagne", + ), + ), + migrations.AlterUniqueTogether( + name="announcementitem", + unique_together={("campaign", "priority")}, + ), + ] diff --git a/itou/communications/models.py b/itou/communications/models.py index e3bfa0c27a..9f53da6c72 100644 --- a/itou/communications/models.py +++ b/itou/communications/models.py @@ -1,7 +1,12 @@ +import calendar +from datetime import date + from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.db.models import Q class NotificationRecordQuerySet(models.QuerySet): @@ -107,3 +112,112 @@ def get_or_create(user, structure=None): @property def disabled_notifications_names(self): return self.disabled_notifications.actives().values_list("name", flat=True) + + +class AnnouncementCampaign(models.Model): + """ + It is possible on the website to launch announcement content for a limited time period, + intended for displaying the new features of the site to returning visitors + """ + + max_items = models.PositiveIntegerField( + default=3, + validators=[MinValueValidator(1), MaxValueValidator(10)], + verbose_name="nombre d'articles affiché", + ) + start_date = models.DateField( + null=False, + unique=True, + verbose_name="mois concerné", + help_text="le mois des nouveautés. Automatiquement fixé au premier du mois saisi", + ) + live = models.BooleanField( + default=True, + verbose_name="prêt", + help_text="les modifications sont toujours possible", + ) + + class Meta: + verbose_name = "campagne d'annonce" + ordering = ["-start_date"] + constraints = [ + models.CheckConstraint(name="max_items_range", check=Q(max_items__gte=1, max_items__lte=10)), + models.CheckConstraint(name="start_on_month", check=Q(start_date__day=1)), + ] + + @property + def end_date(self): + """:return: the last day of the month targeted""" + return date( + self.start_date.year, + self.start_date.month, + calendar.monthrange(self.start_date.year, self.start_date.month)[1], + ) + + def __str__(self): + return f"Campagne d'annonce du { self.start_date.strftime('%m/%Y') }" + + def clean(self): + self.start_date = self.start_date.replace(day=1) + return super().clean() + + def _update_cached_active_announcement(self): + from itou.communications.cache import get_cached_active_announcement, update_active_announcement_cache + + campaign = get_cached_active_announcement() + if campaign is None or self.pk == campaign.pk: + update_active_announcement_cache() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self._update_cached_active_announcement() + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + self._update_cached_active_announcement() + + def items_for_template(self): + return self.items.all()[: self.max_items] + + +class AnnouncementItemQuerySet(models.QuerySet): + def get_queryset(self): + return super().get_queryset().select_related("campaign") + + +class AnnouncementItem(models.Model): + campaign = models.ForeignKey( + AnnouncementCampaign, on_delete=models.CASCADE, related_name="items", verbose_name="campagne" + ) + priority = models.PositiveIntegerField( + default=0, verbose_name="priorité", help_text="le plus bas le valeur, le plus haut dans le fil des articles" + ) + title = models.TextField(null=False, blank=False, verbose_name="titre", help_text="résumé de nouveauté") + description = models.TextField( + null=False, blank=False, verbose_name="description", help_text="détail du nouveauté ; le contenu" + ) + + objects = AnnouncementItemQuerySet.as_manager() + + class Meta: + verbose_name = "article d'annonce" + ordering = ["-campaign__start_date", "priority", "pk"] + unique_together = [("campaign", "priority")] + + def __str__(self): + return self.title + + def _update_cached_active_announcement(self): + from itou.communications.cache import get_cached_active_announcement, update_active_announcement_cache + + campaign = get_cached_active_announcement() + if campaign is None or self.campaign == campaign: + update_active_announcement_cache() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self._update_cached_active_announcement() + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + self._update_cached_active_announcement() diff --git a/itou/templates/layout/_news_modal.html b/itou/templates/layout/_news_modal.html new file mode 100644 index 0000000000..a37f23aa52 --- /dev/null +++ b/itou/templates/layout/_news_modal.html @@ -0,0 +1,44 @@ +{% load static %} +{% load theme_inclusion %} +{% load django_bootstrap5 %} + + + + + + diff --git a/itou/templates/layout/base.html b/itou/templates/layout/base.html index 255f9edb99..815eafb7f3 100644 --- a/itou/templates/layout/base.html +++ b/itou/templates/layout/base.html @@ -249,5 +249,9 @@ {% endif %} {% block script %}{% endblock %} + + {% if active_campaign_announce %} + {% include "layout/_news_modal.html" %} + {% endif %} diff --git a/itou/utils/context_processors.py b/itou/utils/context_processors.py index fbe524d96f..5675e5cc0a 100644 --- a/itou/utils/context_processors.py +++ b/itou/utils/context_processors.py @@ -5,6 +5,7 @@ import itou.institutions.enums as institutions_enums import itou.job_applications.enums as job_applications_enums import itou.prescribers.enums as prescribers_enums +from itou.communications.cache import get_cached_active_announcement def expose_enums(*args): @@ -38,3 +39,11 @@ def matomo(request): context["matomo_custom_url"] = url context["matomo_user_id"] = getattr(request.user, "pk", None) return context + + +def active_announcement_campaign(request): + campaign = get_cached_active_announcement() + + return { + "active_campaign_announce": (campaign if campaign is not None and campaign.items.count() else None), + } diff --git a/tests/communications/__snapshots__/test_campaigns.ambr b/tests/communications/__snapshots__/test_campaigns.ambr new file mode 100644 index 0000000000..99031908c1 --- /dev/null +++ b/tests/communications/__snapshots__/test_campaigns.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: TestRenderAnnouncementCampaign.test_campaign_rendered_dashboard + ''' + + ''' +# --- diff --git a/tests/communications/factories.py b/tests/communications/factories.py new file mode 100644 index 0000000000..e98f3ebb98 --- /dev/null +++ b/tests/communications/factories.py @@ -0,0 +1,28 @@ +from datetime import date + +import factory + +from itou.communications.models import AnnouncementCampaign, AnnouncementItem + + +class AnnouncementCampaignFactory(factory.django.DjangoModelFactory): + class Meta: + model = AnnouncementCampaign + skip_postgeneration_save = True + + start_date = factory.LazyFunction(lambda: date.today().replace(day=1)) + + @factory.post_generation + def with_item(obj, create, extracted, **kwargs): + if create and extracted is True: + AnnouncementItemFactory(campaign=obj) + + +class AnnouncementItemFactory(factory.django.DjangoModelFactory): + class Meta: + model = AnnouncementItem + + campaign = factory.SubFactory(AnnouncementCampaignFactory) + title = factory.Faker("sentence", locale="fr_FR") + description = factory.Faker("paragraph", locale="fr_FR") + priority = factory.Sequence(lambda n: n + 1) diff --git a/tests/communications/test_cache.py b/tests/communications/test_cache.py new file mode 100644 index 0000000000..9c538f1eef --- /dev/null +++ b/tests/communications/test_cache.py @@ -0,0 +1,70 @@ +from datetime import date, timedelta + +from django.core.cache import cache +from freezegun import freeze_time +from pytest_django.asserts import assertNumQueries + +from itou.communications.cache import CACHE_ACTIVE_ANNOUNCEMENTS_KEY +from itou.utils.context_processors import active_announcement_campaign +from tests.communications.factories import AnnouncementCampaignFactory, AnnouncementItemFactory +from tests.utils.test import TestCase + + +class AnnouncementCampaignCacheTest(TestCase): + def test_active_announcement_campaign_context_processor_cached(self): + cache.delete(CACHE_ACTIVE_ANNOUNCEMENTS_KEY) + campaign = AnnouncementCampaignFactory(with_item=True) + + with assertNumQueries(0): + active_announcement_campaign(None)["active_campaign_announce"] == campaign + + # test cached value is kept up-to-date + campaign.max_items += 1 + campaign.save() + + with assertNumQueries(0): + assert active_announcement_campaign(None)["active_campaign_announce"].max_items == campaign.max_items + + item = AnnouncementItemFactory(campaign=campaign) + with assertNumQueries(0): + assert active_announcement_campaign(None)["active_campaign_announce"].items.count() == 2 + + item.delete() + with assertNumQueries(0): + assert active_announcement_campaign(None)["active_campaign_announce"].items.count() == 1 + + # test cache does not become invalidated when saving a new campaign + new_campaign = AnnouncementCampaignFactory( + with_item=True, start_date=(campaign.start_date + timedelta(days=40)).replace(day=1) + ) + with assertNumQueries(0): + active_announcement_campaign(None)["active_campaign_announce"] == campaign + + # test cached value is removed with the campaign + campaign.delete() + new_campaign.delete() + with assertNumQueries(0): + active_announcement_campaign(None)["active_campaign_announce"] is None + + def test_costless_announcement_campaign_cache_when_no_announcement_created(self): + cache.delete(CACHE_ACTIVE_ANNOUNCEMENTS_KEY) + cache_updated_query_cost = 1 + + with assertNumQueries(cache_updated_query_cost): + assert active_announcement_campaign(None)["active_campaign_announce"] is None + + with assertNumQueries(0): + assert active_announcement_campaign(None)["active_campaign_announce"] is None + + @freeze_time("2024-01-30") + def test_active_announcement_campaign_cache_timeout(self): + cache.delete(CACHE_ACTIVE_ANNOUNCEMENTS_KEY) + campaign = AnnouncementCampaignFactory(start_date=date(2024, 1, 1), with_item=True) + + with assertNumQueries(0): + assert active_announcement_campaign(None)["active_campaign_announce"] == campaign + + # NOTE: this test requires that the cache client is Redis (for the ttl function) + cache_time_remaining = cache.ttl(CACHE_ACTIVE_ANNOUNCEMENTS_KEY) + twenty_four_hours = 60 * 60 * 24 + assert cache_time_remaining == twenty_four_hours diff --git a/tests/communications/test_campaigns.py b/tests/communications/test_campaigns.py new file mode 100644 index 0000000000..5e9aba1b04 --- /dev/null +++ b/tests/communications/test_campaigns.py @@ -0,0 +1,93 @@ +from datetime import date + +from django.core.cache import cache +from django.forms import ModelForm +from django.forms.models import model_to_dict +from django.urls import reverse + +from itou.communications.cache import CACHE_ACTIVE_ANNOUNCEMENTS_KEY +from itou.communications.models import AnnouncementCampaign +from tests.communications.factories import AnnouncementCampaignFactory, AnnouncementItemFactory +from tests.utils.test import TestCase, parse_response_to_soup + + +class AnnouncementCampaignValidatorTest(TestCase): + class TestForm(ModelForm): + class Meta: + model = AnnouncementCampaign + fields = "__all__" + + def test_valid_campaign(self): + expected_form_fields = ["max_items", "start_date", "live"] + assert list(self.TestForm().fields.keys()) == expected_form_fields + + form = self.TestForm(model_to_dict(AnnouncementCampaignFactory.build())) + assert form.is_valid() + + def test_start_date_conflict(self): + AnnouncementCampaignFactory(start_date=date(2024, 1, 1)) + campaign = AnnouncementCampaignFactory.build(start_date=date(2024, 1, 20)) + form = self.TestForm(model_to_dict(campaign)) + + expected_form_errors = ["Un objet Campagne d'annonce avec ce champ Mois concerné existe déjà."] + assert form.errors["start_date"] == expected_form_errors + + campaign.start_date = date(2024, 2, 1) + form = self.TestForm(model_to_dict(campaign)) + assert form.is_valid() + + def test_modify_start_date(self): + existing_campaign = AnnouncementCampaignFactory(start_date=date(2024, 1, 1)) + existing_campaign.start_date = date(2024, 1, 2) + + form = self.TestForm(model_to_dict(existing_campaign), instance=existing_campaign) + assert form.is_valid() + + def test_max_items_range(self): + campaign = AnnouncementCampaignFactory.build(max_items=0) + + form = self.TestForm(model_to_dict(campaign)) + assert form.errors["max_items"] == ["Assurez-vous que cette valeur est supérieure ou égale à 1."] + + campaign.max_items = 11 + form = self.TestForm(model_to_dict(campaign)) + assert form.errors["max_items"] == ["Assurez-vous que cette valeur est inférieure ou égale à 10."] + + campaign.max_items = 10 + form = self.TestForm(model_to_dict(campaign)) + assert form.is_valid() + + +class TestRenderAnnouncementCampaign: + def test_campaign_rendered_dashboard(self, client, snapshot): + cache.delete(CACHE_ACTIVE_ANNOUNCEMENTS_KEY) + campaign = AnnouncementCampaignFactory(max_items=3) + AnnouncementItemFactory(campaign=campaign, title="Item A", description="Item A", priority=0) + AnnouncementItemFactory(campaign=campaign, title="Item B", description="Item B", priority=1) + AnnouncementItemFactory(campaign=campaign, title="Item D", description="Item D", priority=3) + AnnouncementItemFactory(campaign=campaign, title="Item C", description="Item C", priority=2) + + response = client.get(reverse("search:employers_home")) + assert response.status_code == 200 + content = parse_response_to_soup(response, "#news-modal") + assert str(content) == snapshot + assert len(content.select("p")) == 3 + assert "Item D" not in str(content) + + def test_campaign_not_rendered_without_items(self, client): + cache.delete(CACHE_ACTIVE_ANNOUNCEMENTS_KEY) + AnnouncementCampaignFactory() + + response = client.get(reverse("search:employers_home")) + assert response.status_code == 200 + content = parse_response_to_soup(response) + assert len(content.select("#news-modal")) == 0 + + def test_campaign_not_rendered_draft(self, client): + cache.delete(CACHE_ACTIVE_ANNOUNCEMENTS_KEY) + AnnouncementCampaignFactory(live=False, with_item=True) + + response = client.get(reverse("search:employers_home")) + assert response.status_code == 200 + content = parse_response_to_soup(response) + assert len(content.select("#news-modal")) == 0 diff --git a/tests/communications/test_models.py b/tests/communications/test_models.py index afbb075591..79950535f3 100644 --- a/tests/communications/test_models.py +++ b/tests/communications/test_models.py @@ -1,7 +1,12 @@ +from datetime import date + +from django.db import IntegrityError + from itou.communications import registry as notifications_registry from itou.communications.apps import sync_notifications from itou.communications.dispatch.base import BaseNotification from itou.communications.models import NotificationRecord, NotificationSettings +from tests.communications.factories import AnnouncementCampaignFactory from tests.companies.factories import CompanyMembershipFactory from tests.users.factories import EmployerFactory, JobSeekerFactory, PrescriberFactory from tests.utils.test import TestCase @@ -93,3 +98,34 @@ def test_str(self): str(NotificationSettings.get_or_create(self.prescriber, self.prescriber_structure)[0]) == f"Paramètres de notification de Bob DOE ({self.prescriber_structure})" ) + + +class AnnouncementCampaignModelTest(TestCase): + def test_end_date(self): + campaign = AnnouncementCampaignFactory(start_date=date(2024, 1, 1)) + self.assertEqual(campaign.end_date, date(2024, 1, 31)) + + def test_max_items_constraint_too_low(self): + with self.assertRaises(IntegrityError): + AnnouncementCampaignFactory(max_items=0) + + def test_max_items_constraint_too_high(self): + with self.assertRaises(IntegrityError): + AnnouncementCampaignFactory(max_items=11) + + def test_start_date_day_constraint(self): + # must be on first day of month + with self.assertRaises(IntegrityError): + AnnouncementCampaignFactory(start_date=date(2024, 1, 2)) + + def test_start_date_conflict_constraint(self): + existing_campaign = AnnouncementCampaignFactory() + + # can modify existing value without triggering constraint + existing_campaign = AnnouncementCampaignFactory(start_date=date(2024, 1, 1)) + existing_campaign.start_date = date(2024, 2, 1) + existing_campaign.save() + + # cannot conflict existing date with a new instance + with self.assertRaises(IntegrityError): + AnnouncementCampaignFactory(start_date=existing_campaign.start_date) diff --git a/tests/conftest.py b/tests/conftest.py index 5e7a162361..4b8e4e0f66 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ from django.conf import settings from django.contrib.gis.db.models.fields import get_srid_info from django.core import management +from django.core.cache import cache from django.core.files.storage import default_storage, storages from django.core.management import call_command from django.db import connection @@ -24,6 +25,7 @@ # Rewrite before importing itou code. pytest.register_assert_rewrite("tests.utils.test", "tests.utils.htmx.test") +from itou.communications.cache import CACHE_ACTIVE_ANNOUNCEMENTS_KEY # noqa: E402 from itou.utils import faker_providers # noqa: E402 from itou.utils.storage.s3 import s3_client # noqa: E402 from tests.users.factories import ItouStaffFactory # noqa: E402 @@ -141,11 +143,21 @@ def storage_prefix_per_test(): @pytest.fixture(autouse=True) def cache_per_test(settings): caches = copy.deepcopy(settings.CACHES) - for cache in caches.values(): - cache["KEY_PREFIX"] = f"{uuid.uuid4()}" + for cache_config in caches.values(): + cache_config["KEY_PREFIX"] = f"{uuid.uuid4()}" settings.CACHES = caches +@pytest.fixture(autouse=True) +def cached_announce_campaign(): + """ + Populates cache for AnnouncementCampaign to avoid an extra database hit in many tests + """ + cache.set(CACHE_ACTIVE_ANNOUNCEMENTS_KEY, None, None) + yield + cache.delete(CACHE_ACTIVE_ANNOUNCEMENTS_KEY) + + @pytest.fixture def temporary_bucket(): with override_settings(AWS_STORAGE_BUCKET_NAME=f"tests-{uuid.uuid4()}"): diff --git a/tests/utils/tests.py b/tests/utils/tests.py index 954fed72f7..b1f635f92b 100644 --- a/tests/utils/tests.py +++ b/tests/utils/tests.py @@ -21,6 +21,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.messages.middleware import MessageMiddleware from django.contrib.sessions.middleware import SessionMiddleware +from django.core.cache import cache from django.core.exceptions import ValidationError from django.http import HttpResponse from django.template import Context, Template @@ -38,6 +39,7 @@ from itou.asp.models import Commune from itou.cities.models import City from itou.common_apps.address.departments import DEPARTMENTS, department_from_postcode +from itou.communications.cache import CACHE_ACTIVE_ANNOUNCEMENTS_KEY from itou.companies.enums import CompanyKind from itou.companies.models import Company, CompanyMembership from itou.job_applications.enums import JobApplicationState @@ -73,6 +75,7 @@ validate_siret, ) from tests.approvals.factories import SuspensionFactory +from tests.communications.factories import AnnouncementCampaignFactory from tests.companies.factories import CompanyFactory, CompanyMembershipFactory, CompanyPendingGracePeriodFactory from tests.institutions.factories import ( InstitutionFactory, @@ -1442,6 +1445,15 @@ def test_job_application_state_badge_oob_swap(snapshot): assert job_applications.state_badge(job_application, hx_swap_oob=True) == snapshot +def test_active_announcement_campaign_context_processor(client): + cache.delete(CACHE_ACTIVE_ANNOUNCEMENTS_KEY) + campaign = AnnouncementCampaignFactory(with_item=True) + + response = client.get(reverse("search:employers_home")) + assert response.status_code == 200 + assert response.context["active_campaign_announce"] == campaign + + class UtilsParseResponseToSoupTest(TestCase): def test_parse_wo_selector(self): html = '
bar
'