From 9e26e51cc414bd1309654cd13140cfa75daa9b1e Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Tue, 28 May 2024 10:55:58 +0200 Subject: [PATCH 01/15] [CHG] Added global context accessibility in template tags Updated tests --- firm_info/factories.py | 32 +++++++++++++- firm_info/templatetags/firm_info.py | 54 +++++++++++++---------- tests/admin.py | 12 ++--- tests/conftest.py | 67 ++++------------------------ tests/constantes.py | 42 ++++++++++++++++++ tests/serializers.py | 35 ++++++++++++--- tests/templatetags.py | 68 +++++++++++++++++++---------- 7 files changed, 193 insertions(+), 117 deletions(-) create mode 100644 tests/constantes.py diff --git a/firm_info/factories.py b/firm_info/factories.py index 72d6384..f2c705a 100644 --- a/firm_info/factories.py +++ b/firm_info/factories.py @@ -7,7 +7,7 @@ import factory from firm_info.models import Tracking -from .models import AppsBanner +from .models import AppsBanner, FirmContact def create_image_file(filename=None, size=(100, 100), color="blue", @@ -104,3 +104,33 @@ def image(self): """ return create_image_file() + + +class FirmContactFactory(factory.django.DjangoModelFactory): + """ + Factory to create instance of a FirmContact. + """ + + phone_number = factory.Faker("phone_number") + email = factory.Faker("email") + address = factory.Faker("address") + postal_code = factory.Faker("postcode") + city = factory.Faker("city") + country = factory.Faker("country") + baseline = factory.Faker("text", max_nb_chars=255) + short_description = factory.Faker("text") + + class Meta: + model = FirmContact + + @factory.lazy_attribute + def logo(self): + return create_image_file() + + @factory.lazy_attribute + def logo_invert(self): + return create_image_file() + + @factory.lazy_attribute + def favicon(self): + return create_image_file() diff --git a/firm_info/templatetags/firm_info.py b/firm_info/templatetags/firm_info.py index 32eacfa..e1423dc 100644 --- a/firm_info/templatetags/firm_info.py +++ b/firm_info/templatetags/firm_info.py @@ -13,8 +13,8 @@ register = Library() -@register.simple_tag(name="firm_contact") -def firm_contact(template_path): +@register.simple_tag(takes_context=True, name="firm_contact") +def firm_contact(context, template_path): """ Renders the template which path is provided as param using FirmContact only instance serialized contact data. @@ -36,15 +36,16 @@ def firm_contact(template_path): qs_firm_info = FirmContact.objects.all() if qs_firm_info.exists(): template = loader.get_template(template_path) - context = serialize_firm_info(qs_firm_info) - rendered = template.render(context) + specific_context = serialize_firm_info(qs_firm_info) + combined_context = {**context.flatten(), **specific_context} + rendered = template.render(combined_context) return rendered else: return '' -@register.simple_tag(name="firm_social_links") -def firm_social_links(template_path): +@register.simple_tag(takes_context=True, name="firm_social_links") +def firm_social_links(context, template_path): """ Renders the template which path is provided as param using all social network link objects serialized data @@ -67,15 +68,16 @@ def firm_social_links(template_path): links = Link.objects.all() if links.exists(): template = loader.get_template(template_path) - context = serialize_firm_social(links) - rendered = template.render(context) + specific_context = serialize_firm_social(links) + combined_context = {**context.flatten(), **specific_context} + rendered = template.render(combined_context) return rendered else: return '' -@register.simple_tag(name="firm_description") -def firm_description(template_path): +@register.simple_tag(takes_context=True, name="firm_description") +def firm_description(context, template_path): """ Renders the template which path is provided as param using FirmContact only instance serialized description data. @@ -97,15 +99,16 @@ def firm_description(template_path): qs_firm_info = FirmContact.objects.all() if qs_firm_info.exists(): template = loader.get_template(template_path) - context = serialize_firm_description(qs_firm_info) - rendered = template.render(context) + specific_context = serialize_firm_description(qs_firm_info) + combined_context = {**context.flatten(), **specific_context} + rendered = template.render(combined_context) return rendered else: return '' -@register.simple_tag(name="firm_logos") -def firm_logos(template_path): +@register.simple_tag(takes_context=True, name="firm_logos") +def firm_logos(context, template_path): """ Renders the firm logos using the specified template. @@ -126,19 +129,20 @@ def firm_logos(template_path): firm_instance = FirmContact.objects.first() if firm_instance: template = loader.get_template(template_path) - context = { + specific_context = { "logo": getattr(firm_instance, "logo", None), "logo_invert": getattr(firm_instance, "logo_invert", None), "favicon": getattr(firm_instance, "favicon", None), } - rendered = template.render(context) + combined_context = {**context.flatten(), **specific_context} + rendered = template.render(combined_context) return rendered else: return '' -@register.simple_tag(name="firm_social_shares") -def firm_social_shares(template_path): +@register.simple_tag(takes_context=True, name="firm_social_shares") +def firm_social_shares(context, template_path): """ Renders the template which path is provided as param using all social network shares link objects serialized data @@ -162,8 +166,9 @@ def firm_social_shares(template_path): if social_shares: template = loader.get_template(template_path) - context = serialize_firm_social_sharing(social_shares) - rendered = template.render(context) + specific_context = serialize_firm_social_sharing(social_shares) + combined_context = {**context.flatten(), **specific_context} + rendered = template.render(combined_context) return rendered else: @@ -193,8 +198,8 @@ def firm_tag_analytic(value=None): return Tracking.objects.first().tag_analytic if Tracking.objects.exists() else "" -@register.simple_tag(name="app_banner") -def app_banner(app_type, template_path): +@register.simple_tag(takes_context=True, name="app_banner") +def app_banner(context, app_type, template_path): """ Renders the app banner using the specified template and application type. @@ -218,7 +223,8 @@ def app_banner(app_type, template_path): with contextlib.suppress(ObjectDoesNotExist): app_banner = AppsBanner.objects.get(application_type=app_type) - context = serialize_firm_apps_banner(app_banner) - rendered = template.render(context) + specific_context = serialize_firm_apps_banner(app_banner) + combined_context = {**context.flatten(), **specific_context} + rendered = template.render(combined_context) return rendered diff --git a/tests/admin.py b/tests/admin.py index ad04517..a7d0c95 100644 --- a/tests/admin.py +++ b/tests/admin.py @@ -5,6 +5,8 @@ from firm_info.models import FirmContact from tests.utils import get_admin_add_url +from .constantes import RAW_CONTACT + User = get_user_model() @@ -23,7 +25,7 @@ def admin_client(client, admin_user): return client -def test_firm_contact_create(db, admin_client, raw_contact): +def test_firm_contact_create(db, admin_client): # Check that admin client can access the admin interface url = reverse("admin:index") response = admin_client.get(url) @@ -35,11 +37,11 @@ def test_firm_contact_create(db, admin_client, raw_contact): assert response.status_code == 200 # needed for post in admin chg - raw_contact.update({ + RAW_CONTACT.update({ "link_set-TOTAL_FORMS": 1, "link_set-INITIAL_FORMS": 0 }) - response = admin_client.post(url, raw_contact) + response = admin_client.post(url, RAW_CONTACT) assert response.status_code == 302 # Check that the FirmContact instance was created @@ -55,11 +57,11 @@ def test_firm_contact_create(db, admin_client, raw_contact): ) assert qs_firm_contact_values is not None assert all(( - item in raw_contact.items() + item in RAW_CONTACT.items() for item in qs_firm_contact_values[0].items() )) # Check that the admin can't create another instance - response = admin_client.post(url, raw_contact) + response = admin_client.post(url, RAW_CONTACT) assert response.status_code == 403 assert FirmContact.objects.filter().count() == 1 diff --git a/tests/conftest.py b/tests/conftest.py index 8df74d7..b394eea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,67 +1,18 @@ import pytest -from firm_info.models import FirmContact, Link +from firm_info.factories import FirmContactFactory +from firm_info.models import Link +from .constantes import RAW_SOCIAL_LINKS -@pytest.fixture -def raw_contact(): - return { - "phone_number": "1234567890", - "email": "contact@example.com", - "address": "1234 Main St", - "postal_code": "12345", - "city": "Anytown", - "country": "USA", - "baseline": "Non eram nescius, Brute, cum, quae summis ingeniis", - "short_description": "Quamquam, si plane sic verterem Platonem" - } +@pytest.fixture() +def firm_contact_obj(db): + return FirmContactFactory() -@pytest.fixture -def raw_social_links(): - return [ - {"name": "facebook", "url": "http://facebook.com/example"}, - {"name": "twitter", "url": "http://twitter.com/example"}, - ] - - -@pytest.fixture -def serialized_contact(): - return { - "email": "contact@example.com", - "phone": "1234567890", - 'address': '1234 Main St', - 'city': 'Anytown', - 'country': 'USA', - 'full_address': '1234 Main St, 12345 Anytown USA', - 'postal_code': '12345', - } - - -@pytest.fixture -def serialized_social_links(): - return { - "facebook": "http://facebook.com/example", - "twitter": "http://twitter.com/example", - } - - -@pytest.fixture -def serialized_firm_description(): - return { - "baseline": "Non eram nescius, Brute, cum, quae summis ingeniis", - "short_description": "Quamquam, si plane sic verterem Platonem" - } - - -@pytest.fixture -def firm_contact_obj(raw_contact): - return FirmContact.objects.create(**raw_contact) - - -@pytest.fixture -def firm_social_links_objs(firm_contact_obj, raw_social_links): +@pytest.fixture() +def firm_social_links_objs(db, firm_contact_obj): links = [ - Link(client_contact=firm_contact_obj, **data) for data in raw_social_links + Link(client_contact=firm_contact_obj, **data) for data in RAW_SOCIAL_LINKS ] Link.objects.bulk_create(links) return Link.objects.filter(client_contact=firm_contact_obj) diff --git a/tests/constantes.py b/tests/constantes.py new file mode 100644 index 0000000..1170a26 --- /dev/null +++ b/tests/constantes.py @@ -0,0 +1,42 @@ +RAW_CONTACT = { + "phone_number": "1234567890", + "email": "contact@example.com", + "address": "1234 Main St", + "postal_code": "12345", + "city": "Anytown", + "country": "USA", + "baseline": "Non eram nescius, Brute, cum, quae summis ingeniis", + "short_description": "Quamquam, si plane sic verterem Platonem", + "logo": "", + "logo_invert": "", + "favicon": "", +} + + +RAW_SOCIAL_LINKS = [ + {"name": "facebook", "url": "http://facebook.com/example"}, + {"name": "twitter", "url": "http://twitter.com/example"}, +] + + +SERIALIZED_CONTACT = { + "email": "contact@example.com", + "phone": "1234567890", + 'address': '1234 Main St', + 'city': 'Anytown', + 'country': 'USA', + 'full_address': '1234 Main St, 12345 Anytown USA', + 'postal_code': '12345', +} + + +SERILAIZED_SOCIAL_LINKS = { + "facebook": "http://facebook.com/example", + "twitter": "http://twitter.com/example", +} + + +SERIALIZED_FIRM_DESCRIPTION = { + "baseline": "Non eram nescius, Brute, cum, quae summis ingeniis", + "short_description": "Quamquam, si plane sic verterem Platonem" +} diff --git a/tests/serializers.py b/tests/serializers.py index b3951e6..bce03b5 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -1,24 +1,47 @@ from firm_info.models import FirmContact, Link from firm_info.serializers import ( + _format_address, serialize_firm_description, serialize_firm_info, serialize_firm_social, ) +from .constantes import ( + SERILAIZED_SOCIAL_LINKS +) + -def test_serialize_firm_info(db, firm_contact_obj, serialized_contact): +def test_serialize_firm_info(db, firm_contact_obj): queryset = FirmContact.objects.all() - expected_output = serialized_contact + assert queryset.count() == 1 + expected_output = { + "email": firm_contact_obj.email, + "phone": firm_contact_obj.phone_number, + "address": firm_contact_obj.address, + "city": firm_contact_obj.city, + "country": firm_contact_obj.country, + "full_address": _format_address( + queryset + .values( + "phone_number", "email", "address", "postal_code", "city", "country" + ) + .first() + ), + "postal_code": firm_contact_obj.postal_code, + } assert serialize_firm_info(queryset) == expected_output -def test_serialize_firm_social(db, firm_social_links_objs, serialized_social_links): +def test_serialize_firm_social(db, firm_social_links_objs): queryset = Link.objects.all() - expected_output = serialized_social_links + expected_output = SERILAIZED_SOCIAL_LINKS assert serialize_firm_social(queryset) == expected_output -def test_serialize_firm_description(db, firm_contact_obj, serialized_firm_description): +def test_serialize_firm_description(db, firm_contact_obj): queryset = FirmContact.objects.all() - expected_output = serialized_firm_description + expected_output = { + "baseline": firm_contact_obj.baseline, + "short_description": firm_contact_obj.short_description, + } assert serialize_firm_description(queryset) == expected_output diff --git a/tests/templatetags.py b/tests/templatetags.py index 09cf19d..c039b3c 100644 --- a/tests/templatetags.py +++ b/tests/templatetags.py @@ -1,5 +1,7 @@ +from django.test import RequestFactory import pytest -from django.template import Context, Template + +from django.template import RequestContext, Template from firm_info.models import FirmContact, Link from firm_info.factories import TrackingFactory from firm_info.templatetags.firm_info import ( @@ -9,50 +11,67 @@ firm_tag_analytic, ) +from .constantes import SERILAIZED_SOCIAL_LINKS + -def test_firm_contact_tag(db, firm_contact_obj, serialized_contact): +def test_firm_contact_tag(db, firm_contact_obj): template_path = "tests/templatetags/firm_info/test_firm_contact.html" - context = Context() + + request = RequestFactory().get('/') + context = RequestContext(request) + context["firm"] = firm_contact_obj - output = firm_contact(template_path) + output = firm_contact(context, template_path) template = Template(output) rendered = template.render(context) expected_output = "\n".join([ - f"

Email: {serialized_contact['email']}

", - f"

Phone: {serialized_contact['phone']}

", - f"

Full address: {serialized_contact['full_address']}

", - f"

Address: {serialized_contact['address']}

", - f"

city: {serialized_contact['city']}

", - f"

postal code: {serialized_contact['postal_code']}

", - f"

country: {serialized_contact['country']}

" + "

Email: {}

".format(firm_contact_obj.email), + "

Phone: {}

".format(firm_contact_obj.phone_number), + "

Full address: {}, {} {} {}

".format( + firm_contact_obj.address, + firm_contact_obj.postal_code, + firm_contact_obj.city, + firm_contact_obj.country, + ), + "

Address: {}

".format(firm_contact_obj.address), + "

city: {}

".format(firm_contact_obj.city), + "

postal code: {}

".format(firm_contact_obj.postal_code), + "

country: {}

".format(firm_contact_obj.country) ]) assert rendered == expected_output -def test_firm_social_links_tag(db, firm_social_links_objs, serialized_social_links): +def test_firm_social_links_tag(db, firm_social_links_objs): template_path = "tests/templatetags/firm_info/test_links.html" - context = Context() + + request = RequestFactory().get('/') + context = RequestContext(request) + context["links"] = firm_social_links_objs - output = firm_social_links(template_path) + output = firm_social_links(context, template_path) template = Template(output) rendered = template.render(context) expected_output = "\n".join([ - f"facebook
", - f"twitter
" + f"facebook
", + f"twitter
" ]) assert rendered == expected_output -def test_firm_description_tag(db, firm_contact_obj, serialized_firm_description): +def test_firm_description_tag(firm_contact_obj): template_path = "tests/templatetags/firm_info/test_firm_description.html" - context = Context() + + factory = RequestFactory() + request = factory.get('/') + context = RequestContext(request) + context["description"] = firm_contact_obj - output = firm_description(template_path) + output = firm_description(context, template_path) template = Template(output) rendered = template.render(context) expected_output = "\n".join([ - f"

Baseline: {serialized_firm_description['baseline']}

", - f"

Short_description: {serialized_firm_description['short_description']}

", + f"

Baseline: {firm_contact_obj.baseline}

", + f"

Short_description: {firm_contact_obj.short_description}

", ]) assert rendered == expected_output @@ -67,9 +86,12 @@ def test_firm_description_tag(db, firm_contact_obj, serialized_firm_description) ) def test_not_rendered_without_objs(db, template_path, Model): template_path = template_path - context = Context() + + request = RequestFactory().get('/') + context = RequestContext(request) + context["firm"] = Model.objects.none() - output = firm_contact(template_path) + output = firm_contact(context, template_path) template = Template(output) rendered = template.render(context) assert rendered == "" From 147a3d224f1a5e3968e657914e67e1ac3011799c Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Tue, 28 May 2024 10:56:30 +0200 Subject: [PATCH 02/15] [DOC] Added exceptions and manager documentation --- docs/firm_info/exceptions.rst | 9 +++++++++ docs/firm_info/index.rst | 4 ++++ docs/firm_info/managers.rst | 9 +++++++++ docs/firm_info/serializers.rst | 2 +- 4 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 docs/firm_info/exceptions.rst create mode 100644 docs/firm_info/managers.rst diff --git a/docs/firm_info/exceptions.rst b/docs/firm_info/exceptions.rst new file mode 100644 index 0000000..68213e8 --- /dev/null +++ b/docs/firm_info/exceptions.rst @@ -0,0 +1,9 @@ +.. _intro_firm-info_exceptions: + +========== +Exceptions +========== + +.. automodule:: firm_info.exceptions + :members: SerializeFirmError + :exclude-members: MyAppBaseException \ No newline at end of file diff --git a/docs/firm_info/index.rst b/docs/firm_info/index.rst index 7746d92..19fa759 100644 --- a/docs/firm_info/index.rst +++ b/docs/firm_info/index.rst @@ -12,3 +12,7 @@ Django firm info serializers.rst templatetags.rst + + exceptions.rst + + managers.rst \ No newline at end of file diff --git a/docs/firm_info/managers.rst b/docs/firm_info/managers.rst new file mode 100644 index 0000000..c8a07d9 --- /dev/null +++ b/docs/firm_info/managers.rst @@ -0,0 +1,9 @@ +.. _intro_firm-info_managers: + +======= +Manager +======= + +.. autoclass:: firm_info.managers.SingletonManager + :members: + :exclude-members: create diff --git a/docs/firm_info/serializers.rst b/docs/firm_info/serializers.rst index ab8b98a..066a60f 100644 --- a/docs/firm_info/serializers.rst +++ b/docs/firm_info/serializers.rst @@ -5,5 +5,5 @@ Serializers =========== .. automodule:: firm_info.serializers - :members: SerializeFirmError, _format_address, serialize_firm_info, serialize_firm_social, serialize_firm_description, serialize_firm_social_sharing, serialize_firm_apps_banner + :members: _format_address, serialize_firm_info, serialize_firm_social, serialize_firm_description, serialize_firm_social_sharing, serialize_firm_apps_banner :exclude-members: DoesNotExist, MultipleObjectsReturned \ No newline at end of file From adbcff9576efca6393063baa9e911c4fbbb36fc3 Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Tue, 28 May 2024 10:57:20 +0200 Subject: [PATCH 03/15] [FIX] Added default autofield in settings --- sandbox/settings/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sandbox/settings/base.py b/sandbox/settings/base.py index 7d5b83e..3e13a30 100644 --- a/sandbox/settings/base.py +++ b/sandbox/settings/base.py @@ -7,6 +7,7 @@ SECRET_KEY = "***TOPSECRET***" +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Root of project repository BASE_DIR = Path(__file__).parents[2] From 5acbb147daae1faa8d1f0218aaa1e1e9fe8ed32a Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Tue, 28 May 2024 10:58:10 +0200 Subject: [PATCH 04/15] [CHG] Moved serializer exception to proper module --- firm_info/exceptions.py | 5 +++-- firm_info/serializers.py | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/firm_info/exceptions.py b/firm_info/exceptions.py index c82c4df..79087fe 100644 --- a/firm_info/exceptions.py +++ b/firm_info/exceptions.py @@ -13,8 +13,9 @@ class MyAppBaseException(Exception): pass -class DummyError(MyAppBaseException): +class SerializeFirmError(MyAppBaseException): """ - Dummy exception sample to raise from your code. + Exceptions related to FirmContact serialization errors + during template tag generation. """ pass diff --git a/firm_info/serializers.py b/firm_info/serializers.py index 9e211c2..19a5867 100644 --- a/firm_info/serializers.py +++ b/firm_info/serializers.py @@ -1,5 +1,4 @@ -class SerializeFirmError(Exception): - pass +from firm_info.exceptions import SerializeFirmError def _format_address(firm_info: dict) -> str: From 68b8e7ee0c21fa8803e55c7021bd4570beb09711 Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Tue, 28 May 2024 10:58:36 +0200 Subject: [PATCH 05/15] [DOC] Added singleton manager docstring --- firm_info/managers.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/firm_info/managers.py b/firm_info/managers.py index 92932a7..7ab60d5 100644 --- a/firm_info/managers.py +++ b/firm_info/managers.py @@ -3,6 +3,17 @@ class SingletonManager(models.Manager): + """ + A manager to ensure that only one instance of the model exists. + + This manager overrides the `create` method to enforce a singleton pattern + on the associated model. If an instance of the model already exists, + attempting to create another instance will raise a `ValueError`. + + Methods: + create(**kwargs): Creates a new instance of the model if none exists. + Raises `ValueError` if an instance already exists. + """ def create(self, **kwargs): if self.model.objects.exists(): error_message = _("Model {model_name} has already one instance") From 7dc1708e657855ac819fda33c87b8b9fef2aae83 Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Tue, 28 May 2024 10:59:41 +0200 Subject: [PATCH 06/15] [CHG] Reworked flake8 exclusions --- sandbox/settings/tests.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sandbox/settings/tests.py b/sandbox/settings/tests.py index b04f3e8..c22362d 100644 --- a/sandbox/settings/tests.py +++ b/sandbox/settings/tests.py @@ -1,7 +1,8 @@ """ Django settings for tests """ -from sandbox.settings.base import * # noqa: F403 +# flake8: noqa: F403,F405 +from sandbox.settings.base import * DATABASES = { "default": { @@ -12,4 +13,4 @@ # Media directory dedicated to tests to avoid polluting other environment # media directory -MEDIA_ROOT = VAR_PATH / "media-tests" # noqa: F405 +MEDIA_ROOT = VAR_PATH / "media-tests" From 8c12a628da6d03f24b54ec9be104e457a59cee7d Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Tue, 28 May 2024 11:00:00 +0200 Subject: [PATCH 07/15] [CHG] Added frozen deps --- frozen.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 frozen.txt diff --git a/frozen.txt b/frozen.txt new file mode 100644 index 0000000..07e2871 --- /dev/null +++ b/frozen.txt @@ -0,0 +1,15 @@ +# Frozen requirement versions from '0.1.4' installation +Django==4.2.13 +django-smart-media==0.3.1 +djangocms-text-ckeditor==5.1.5 +factory-boy==3.3.0 +flake8==7.0.0 +freezegun==1.5.1 +livereload==2.6.3 +pyquery==2.0.0 +pytest==8.2.1 +pytest-django==4.8.0 +Sphinx==5.3.0 +sphinx-rtd-theme==1.1.0 +tox==4.15.0 +twine==5.1.0 \ No newline at end of file From 59f2f8963c8cd051a45e2728506c56f105dc5c01 Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Thu, 30 May 2024 11:35:18 +0200 Subject: [PATCH 08/15] [FIX] Fixed rtd build --- .readthedocs.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index edd98c3..1fe041e 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,6 +4,10 @@ # Required version: 2 +build: + os: ubuntu-20.04 + tools: + python: "3.8" # Build documentation in the docs/ directory with Sphinx sphinx: @@ -11,6 +15,5 @@ sphinx: # Optionally set the version of Python and requirements required to build your docs python: - version: 3.8 install: - requirements: docs/requirements.txt From 6baf21f1b78e3f225fd96cad2b1f05b4861477aa Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Thu, 30 May 2024 12:00:16 +0200 Subject: [PATCH 09/15] [FIX] Fixed failing test for py310-django42 rendered html was not escaped --- tests/constantes.py | 2 +- tests/serializers.py | 4 ++-- tests/templatetags.py | 23 +++++++++++++---------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/tests/constantes.py b/tests/constantes.py index 1170a26..bd77ed0 100644 --- a/tests/constantes.py +++ b/tests/constantes.py @@ -30,7 +30,7 @@ } -SERILAIZED_SOCIAL_LINKS = { +SERIALIZED_SOCIAL_LINKS = { "facebook": "http://facebook.com/example", "twitter": "http://twitter.com/example", } diff --git a/tests/serializers.py b/tests/serializers.py index bce03b5..924808e 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -7,7 +7,7 @@ ) from .constantes import ( - SERILAIZED_SOCIAL_LINKS + SERIALIZED_SOCIAL_LINKS ) @@ -34,7 +34,7 @@ def test_serialize_firm_info(db, firm_contact_obj): def test_serialize_firm_social(db, firm_social_links_objs): queryset = Link.objects.all() - expected_output = SERILAIZED_SOCIAL_LINKS + expected_output = SERIALIZED_SOCIAL_LINKS assert serialize_firm_social(queryset) == expected_output diff --git a/tests/templatetags.py b/tests/templatetags.py index c039b3c..e4e76ad 100644 --- a/tests/templatetags.py +++ b/tests/templatetags.py @@ -1,7 +1,10 @@ -from django.test import RequestFactory import pytest +from html import escape + from django.template import RequestContext, Template +from django.test import RequestFactory + from firm_info.models import FirmContact, Link from firm_info.factories import TrackingFactory from firm_info.templatetags.firm_info import ( @@ -11,7 +14,7 @@ firm_tag_analytic, ) -from .constantes import SERILAIZED_SOCIAL_LINKS +from .constantes import SERIALIZED_SOCIAL_LINKS def test_firm_contact_tag(db, firm_contact_obj): @@ -33,10 +36,10 @@ def test_firm_contact_tag(db, firm_contact_obj): firm_contact_obj.city, firm_contact_obj.country, ), - "

Address: {}

".format(firm_contact_obj.address), - "

city: {}

".format(firm_contact_obj.city), - "

postal code: {}

".format(firm_contact_obj.postal_code), - "

country: {}

".format(firm_contact_obj.country) + "

Address: {}

".format(escape(firm_contact_obj.address)), + "

city: {}

".format(escape(firm_contact_obj.city)), + "

postal code: {}

".format(escape(firm_contact_obj.postal_code)), + "

country: {}

".format(escape(firm_contact_obj.country)) ]) assert rendered == expected_output @@ -52,8 +55,8 @@ def test_firm_social_links_tag(db, firm_social_links_objs): template = Template(output) rendered = template.render(context) expected_output = "\n".join([ - f"facebook
", - f"twitter
" + f"facebook
", + f"twitter
" ]) assert rendered == expected_output @@ -70,8 +73,8 @@ def test_firm_description_tag(firm_contact_obj): template = Template(output) rendered = template.render(context) expected_output = "\n".join([ - f"

Baseline: {firm_contact_obj.baseline}

", - f"

Short_description: {firm_contact_obj.short_description}

", + f"

Baseline: {escape(firm_contact_obj.baseline)}

", + f"

Short_description: {escape(firm_contact_obj.short_description)}

", ]) assert rendered == expected_output From ad56554a6ff34c3c17ac6d87d9c0f82afde1f7d7 Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Thu, 30 May 2024 14:56:54 +0200 Subject: [PATCH 10/15] [CHG] Removed dj4.0 and dj4.1 from tox tests --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 0441bea..5078336 100644 --- a/setup.cfg +++ b/setup.cfg @@ -95,7 +95,7 @@ testpaths = [tox:tox] minversion = 3.4.0 -envlist = py{38,39,310}-django{32,40,41,42} +envlist = py{38,39,310}-django{32,42} [gh-actions] python = From 7f2ecbb719b1a83f6664edd3dd968e0073e18f94 Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Thu, 30 May 2024 15:01:20 +0200 Subject: [PATCH 11/15] [FIX] Unescaped django rendered templates in templattags tests --- tests/templatetags.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/templatetags.py b/tests/templatetags.py index e4e76ad..5719afd 100644 --- a/tests/templatetags.py +++ b/tests/templatetags.py @@ -1,6 +1,6 @@ import pytest -from html import escape +from html import unescape from django.template import RequestContext, Template from django.test import RequestFactory @@ -36,12 +36,12 @@ def test_firm_contact_tag(db, firm_contact_obj): firm_contact_obj.city, firm_contact_obj.country, ), - "

Address: {}

".format(escape(firm_contact_obj.address)), - "

city: {}

".format(escape(firm_contact_obj.city)), - "

postal code: {}

".format(escape(firm_contact_obj.postal_code)), - "

country: {}

".format(escape(firm_contact_obj.country)) + "

Address: {}

".format(firm_contact_obj.address), + "

city: {}

".format(firm_contact_obj.city), + "

postal code: {}

".format(firm_contact_obj.postal_code), + "

country: {}

".format(firm_contact_obj.country) ]) - assert rendered == expected_output + assert unescape(rendered) == expected_output def test_firm_social_links_tag(db, firm_social_links_objs): @@ -73,10 +73,10 @@ def test_firm_description_tag(firm_contact_obj): template = Template(output) rendered = template.render(context) expected_output = "\n".join([ - f"

Baseline: {escape(firm_contact_obj.baseline)}

", - f"

Short_description: {escape(firm_contact_obj.short_description)}

", + f"

Baseline: {firm_contact_obj.baseline}

", + f"

Short_description: {firm_contact_obj.short_description}

", ]) - assert rendered == expected_output + assert unescape(rendered) == expected_output @pytest.mark.parametrize( From ce37d691b0bb87b915ec62dfb1d812274bbd488f Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Thu, 30 May 2024 15:25:18 +0200 Subject: [PATCH 12/15] Merge tag '0.1.5' into dev --- docs/history.rst | 15 +++++++++++++++ setup.cfg | 6 +----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/history.rst b/docs/history.rst index 9fc5599..281bb32 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -4,6 +4,21 @@ History ======= +0.1.5 (2024-05-30) +------------------ + +Changes +~~~~~~~ +* [CHG] Added global context accessibility in template tags (Ticket #5289453) [Samy Saad] + + +Other +~~~~~ +* [CHG] Removed dj4.0 and dj4.1 from tox tests [Samy Saad] +* [FIX] Added default autofield in settings [Samy Saad] +* [FIX] Fixed rtd build [Samy Saad] +* [DOC] Updated doc [Samy Saad] + 0.1.4 (2023-09-21) ------------------ diff --git a/setup.cfg b/setup.cfg index 5078336..3f31d4d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ ;; [metadata] name = django-firm-info -version = 0.1.4 +version = 0.1.5 description = A Django application package for storage firm info long_description = file:README.rst long_description_content_type = text/x-rst @@ -29,8 +29,6 @@ classifiers = Programming Language :: Python :: 3.10 Framework :: Django Framework :: Django :: 3.2 - Framework :: Django :: 4.0 - Framework :: Django :: 4.1 Framework :: Django :: 4.2 Intended Audience :: Developers Topic :: Internet :: WWW/HTTP @@ -107,8 +105,6 @@ python = deps = django32: Django>=3.2,<4.0 - django40: Django>=4.0,<4.1 - django41: Django>=4.1,<4.2 django42: Django>=4.2,<5.0 py38-django32: backports.zoneinfo From 188e4b1880ac5d49c41e3f4db45075a011402f32 Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Tue, 25 Jun 2024 17:12:19 +0200 Subject: [PATCH 13/15] [CHG] Enhanced code&tests quality Added test for app_banner templatetag + test template Added test for firm_logos templatetag + test template Added test for social_sharing templatetag + test template Added Singleton tests Added post_delete and post_save signals tests Added SingletonManager for SocialSharing and Tracking models Added UniqueModelAdmin that reflect the behaviour of a singleton in admin Improved AppsBannerFactory Added SocialSharingFactory Added firm_logos serializer --- firm_info/admin.py | 49 ++++++--- firm_info/exceptions.py | 9 +- firm_info/factories.py | 39 ++++++- firm_info/managers.py | 6 +- firm_info/models.py | 12 +-- firm_info/serializers.py | 50 ++++++--- .../firm_info/test_app_banner.html | 5 + .../firm_info/test_firm_logos.html | 3 + .../firm_info/test_firm_social_shares.html | 5 + firm_info/templatetags/firm_info.py | 15 ++- tests/conftest.py | 8 +- tests/models.py | 39 +++++++ tests/serializers.py | 45 +++++++- tests/signals.py | 74 +++++++++++++ tests/templatetags.py | 101 ++++++++++++++---- 15 files changed, 376 insertions(+), 84 deletions(-) create mode 100644 firm_info/templates/tests/templatetags/firm_info/test_app_banner.html create mode 100644 firm_info/templates/tests/templatetags/firm_info/test_firm_logos.html create mode 100644 firm_info/templates/tests/templatetags/firm_info/test_firm_social_shares.html create mode 100644 tests/models.py create mode 100644 tests/signals.py diff --git a/firm_info/admin.py b/firm_info/admin.py index f47c279..10932b9 100644 --- a/firm_info/admin.py +++ b/firm_info/admin.py @@ -6,6 +6,36 @@ from .models import AppsBanner, FirmContact, Link, SocialSharing, Tracking +class UniqueModelAdmin(admin.ModelAdmin): + """ + A custom ModelAdmin that restricts the addition of model instances to only one. + + This admin class overrides the default add permission to ensure that only one + instance of the associated model can exist at any given time. + If an instance already exists, it prohibits adding new instances. + + Methods: + has_add_permission(self, request): Checks if adding a new instance is + permissible. + clean(self): Validates that there is not more than one instance of the model. + """ + + def has_add_permission(self, request): + existing_count = self.model.objects.count() + if existing_count == 0: + return super().has_add_permission(request) + else: + return False + + def clean(self): + existing_count = self.model.objects.count() + if existing_count > 1: + # raise validation error if there is more than one firm contact + raise ValidationError( + _("Only one {} instance allowed.").format(self.model.__name__) + ) + + @admin.register(Link) class LinkAdmin(admin.ModelAdmin): pass @@ -17,31 +47,18 @@ class LinkInline(admin.TabularInline): @admin.register(FirmContact) -class ClientContactAdmin(admin.ModelAdmin): +class ClientContactAdmin(UniqueModelAdmin): inlines = [LinkInline] formfield_overrides = SmartModelAdmin.formfield_overrides - def has_add_permission(self, request): - existing_count = FirmContact.objects.count() - if existing_count == 0: - return super().has_add_permission(request) - else: - return False - - def clean(self): - existing_count = FirmContact.objects.count() - if existing_count > 1: - # raise validation error if there is more than one firm contact - raise ValidationError(_("Only one FirmContact instance allowed.")) - @admin.register(SocialSharing) -class SocialSharingAdmin(admin.ModelAdmin): +class SocialSharingAdmin(UniqueModelAdmin): pass @admin.register(Tracking) -class TrackingAdmin(admin.ModelAdmin): +class TrackingAdmin(UniqueModelAdmin): pass diff --git a/firm_info/exceptions.py b/firm_info/exceptions.py index 79087fe..789a07d 100644 --- a/firm_info/exceptions.py +++ b/firm_info/exceptions.py @@ -3,7 +3,7 @@ """ -class MyAppBaseException(Exception): +class FirmInfoException(Exception): """ Exception base. @@ -13,9 +13,6 @@ class MyAppBaseException(Exception): pass -class SerializeFirmError(MyAppBaseException): - """ - Exceptions related to FirmContact serialization errors - during template tag generation. - """ +class SerializeFirmError(FirmInfoException): + """Exception raised when serializing firm data encounters an error.""" pass diff --git a/firm_info/factories.py b/firm_info/factories.py index f2c705a..393f463 100644 --- a/firm_info/factories.py +++ b/firm_info/factories.py @@ -5,9 +5,13 @@ from django.core.files import File import factory -from firm_info.models import Tracking -from .models import AppsBanner, FirmContact +from firm_info.models import ( + AppsBanner, + FirmContact, + SocialSharing, + Tracking +) def create_image_file(filename=None, size=(100, 100), color="blue", @@ -83,6 +87,10 @@ class Meta: model = Tracking +def get_application_type(choice): + return choice[0] + + class AppsBannerFactory(factory.django.DjangoModelFactory): """ Factory to create instance of a AppsBanner. @@ -90,6 +98,10 @@ class AppsBannerFactory(factory.django.DjangoModelFactory): title = factory.Faker("text", max_nb_chars=150) description = factory.Faker("text", max_nb_chars=150) + application_type = factory.Iterator( + AppsBanner.APPS_CHOICES, + getter=get_application_type + ) class Meta: model = AppsBanner @@ -134,3 +146,26 @@ def logo_invert(self): @factory.lazy_attribute def favicon(self): return create_image_file() + + +class SocialSharingFactory(factory.django.DjangoModelFactory): + """ + Factory to create instance of a AppsBanner. + """ + + og_twitter_site = factory.Faker("text", max_nb_chars=100) + og_description = factory.Faker("text", max_nb_chars=180) + + class Meta: + model = SocialSharing + + @factory.lazy_attribute + def og_image(self): + """ + Fill file field with generated image. + + Returns: + django.core.files.File: File object. + """ + + return create_image_file() diff --git a/firm_info/managers.py b/firm_info/managers.py index 7ab60d5..c2ddf07 100644 --- a/firm_info/managers.py +++ b/firm_info/managers.py @@ -2,6 +2,9 @@ from django.utils.translation import gettext_lazy as _ +SINGLETON_ERROR = _("Model {model_name} has already one instance") + + class SingletonManager(models.Manager): """ A manager to ensure that only one instance of the model exists. @@ -16,8 +19,7 @@ class SingletonManager(models.Manager): """ def create(self, **kwargs): if self.model.objects.exists(): - error_message = _("Model {model_name} has already one instance") raise ValueError( - error_message.format(model_name=self.model._meta.verbose_name) + SINGLETON_ERROR.format(model_name=self.model._meta.verbose_name) ) return super().create(**kwargs) diff --git a/firm_info/models.py b/firm_info/models.py index fb4ee00..6612bf4 100644 --- a/firm_info/models.py +++ b/firm_info/models.py @@ -11,9 +11,6 @@ class FirmContact(models.Model): """ Represents the contact information for a firm. - Args: - models.Model: The base model class provided by Django. - Attributes: phone_number (CharField): The phone number of the firm. email (EmailField): The email address of the firm. @@ -131,6 +128,7 @@ class SocialSharing(models.Model): og_image (SmartMediaField): The OG image for social media sharing. og_description (TextField): The OG description for social media sharing. og_twitter_site (CharField): The OG Twitter site for social media sharing. + objects (SingletonManager): The manager for the FirmContact model. """ og_image = SmartMediaField( @@ -154,6 +152,8 @@ class SocialSharing(models.Model): verbose_name=_("OG Twitter Site"), ) + objects = SingletonManager() + class Meta: verbose_name = _("Social media share") verbose_name_plural = _("Social media shares") @@ -180,6 +180,7 @@ class Tracking(models.Model): Attributes: tag_analytic (CharField): The tag analytic for tracking. + objects (SingletonManager): The manager for the FirmContact model. """ tag_analytic = models.CharField( @@ -190,6 +191,8 @@ class Tracking(models.Model): verbose_name=_("Tag Analytic"), ) + objects = SingletonManager() + class Meta: verbose_name = _("Tracking") verbose_name_plural = _("Tracks") @@ -199,9 +202,6 @@ class AppsBanner(models.Model): """ Represents an app banner in the Django firm_info models. - Args: - models.Model: The base model class provided by Django. - Attributes: APPS_CHOICES (list): A list of tuples representing the available choices for the application type. diff --git a/firm_info/serializers.py b/firm_info/serializers.py index 19a5867..fdea090 100644 --- a/firm_info/serializers.py +++ b/firm_info/serializers.py @@ -18,10 +18,10 @@ def _format_address(firm_info: dict) -> str: `"{address}, {postal_code} {city} {country}"`. """ return "{}, {} {} {}".format( - firm_info.get("address"), - firm_info.get("postal_code"), - firm_info.get("city"), - firm_info.get("country"), + firm_info.get("address", "NOTFOUND"), + firm_info.get("postal_code", "NOTFOUND"), + firm_info.get("city", "NOTFOUND"), + firm_info.get("country", "NOTFOUND"), ) @@ -61,7 +61,8 @@ def serialize_firm_info(queryset): "country": firm_info.get("country"), } except Exception as err: - raise SerializeFirmError from err + error_msg = "Failed to serialize firm contact." + raise SerializeFirmError(error_msg) from err def serialize_firm_social(queryset): @@ -80,18 +81,14 @@ def serialize_firm_social(queryset): """ try: - firm_socials = list( - queryset.values( - "name", - "url", - ) - ) + firm_socials = queryset.values("name", "url") return { social.get("name", "NOTFOUND"): social.get("url", "NOTFOUND") for social in firm_socials } except Exception as err: - raise SerializeFirmError from err + error_msg = "Failed to serialize firm social media information." + raise SerializeFirmError(error_msg) from err def serialize_firm_description(queryset): @@ -117,7 +114,8 @@ def serialize_firm_description(queryset): "short_description": firm_info.get("short_description"), } except Exception as err: - raise SerializeFirmError from err + error_msg = "Failed to serialize firm description." + raise SerializeFirmError(error_msg) from err def serialize_firm_social_sharing(obj): @@ -146,7 +144,8 @@ def serialize_firm_social_sharing(obj): } except Exception as err: - raise SerializeFirmError from err + error_msg = "Failed to serialize social sharing information." + raise SerializeFirmError(error_msg) from err def serialize_firm_apps_banner(obj): @@ -175,4 +174,25 @@ def serialize_firm_apps_banner(obj): } except Exception as err: - raise SerializeFirmError from err + error_msg = "Failed to serialize apps banner information." + raise SerializeFirmError(error_msg) from err + + +def serialize_firm_logos(obj): + """ + Serializes logo information from a FirmContact instance. + + Args: + firm_instance (FirmContact): An instance of FirmContact containing logos. + + Returns: + dict: A dictionary with logo details suitable for rendering in templates. + """ + if obj is None: + return {} + + return { + "logo": getattr(obj, "logo", None), + "logo_invert": getattr(obj, "logo_invert", None), + "favicon": getattr(obj, "favicon", None), + } diff --git a/firm_info/templates/tests/templatetags/firm_info/test_app_banner.html b/firm_info/templates/tests/templatetags/firm_info/test_app_banner.html new file mode 100644 index 0000000..f8babc0 --- /dev/null +++ b/firm_info/templates/tests/templatetags/firm_info/test_app_banner.html @@ -0,0 +1,5 @@ +
+{{ title }} +

{{ title }}

+

{{ description }}

+
\ No newline at end of file diff --git a/firm_info/templates/tests/templatetags/firm_info/test_firm_logos.html b/firm_info/templates/tests/templatetags/firm_info/test_firm_logos.html new file mode 100644 index 0000000..e5019c9 --- /dev/null +++ b/firm_info/templates/tests/templatetags/firm_info/test_firm_logos.html @@ -0,0 +1,3 @@ +Logo +Inverted Logo + \ No newline at end of file diff --git a/firm_info/templates/tests/templatetags/firm_info/test_firm_social_shares.html b/firm_info/templates/tests/templatetags/firm_info/test_firm_social_shares.html new file mode 100644 index 0000000..7ef98cf --- /dev/null +++ b/firm_info/templates/tests/templatetags/firm_info/test_firm_social_shares.html @@ -0,0 +1,5 @@ +
+Social Sharing Image +

Description: {{ og_description }}

+

Twitter Site: {{ og_twitter_site }}

+
\ No newline at end of file diff --git a/firm_info/templatetags/firm_info.py b/firm_info/templatetags/firm_info.py index e1423dc..309752f 100644 --- a/firm_info/templatetags/firm_info.py +++ b/firm_info/templatetags/firm_info.py @@ -6,6 +6,7 @@ serialize_firm_apps_banner, serialize_firm_description, serialize_firm_info, + serialize_firm_logos, serialize_firm_social, serialize_firm_social_sharing, ) @@ -129,11 +130,7 @@ def firm_logos(context, template_path): firm_instance = FirmContact.objects.first() if firm_instance: template = loader.get_template(template_path) - specific_context = { - "logo": getattr(firm_instance, "logo", None), - "logo_invert": getattr(firm_instance, "logo_invert", None), - "favicon": getattr(firm_instance, "favicon", None), - } + specific_context = serialize_firm_logos(firm_instance) combined_context = {**context.flatten(), **specific_context} rendered = template.render(combined_context) return rendered @@ -162,11 +159,12 @@ def firm_social_shares(context, template_path): {% firm_social_shares "path/to/template.html" %} """ - social_shares = SocialSharing.objects.first() + social_shares = SocialSharing.objects.all() - if social_shares: + if social_shares.exists(): + social_share = social_shares.first() template = loader.get_template(template_path) - specific_context = serialize_firm_social_sharing(social_shares) + specific_context = serialize_firm_social_sharing(social_share) combined_context = {**context.flatten(), **specific_context} rendered = template.render(combined_context) @@ -218,7 +216,6 @@ def app_banner(context, app_type, template_path): {% app_banner "path/to/template.html" %} """ - context = {} template = loader.get_template(template_path) with contextlib.suppress(ObjectDoesNotExist): diff --git a/tests/conftest.py b/tests/conftest.py index b394eea..bfe150b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,12 +5,8 @@ @pytest.fixture() -def firm_contact_obj(db): - return FirmContactFactory() - - -@pytest.fixture() -def firm_social_links_objs(db, firm_contact_obj): +def firm_social_links_objs(db): + firm_contact_obj = FirmContactFactory() links = [ Link(client_contact=firm_contact_obj, **data) for data in RAW_SOCIAL_LINKS ] diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000..1f684a4 --- /dev/null +++ b/tests/models.py @@ -0,0 +1,39 @@ +import pytest + +from firm_info.factories import ( + FirmContactFactory, SocialSharingFactory, TrackingFactory +) +from firm_info.managers import SINGLETON_ERROR + + +def test_singleton_firm_contact(db): + FirmContactFactory() + with pytest.raises( + ValueError, + match=SINGLETON_ERROR.format( + model_name=FirmContactFactory._meta.model._meta.verbose_name + ) + ): + FirmContactFactory() + + +def test_singleton_social_sharing(db): + SocialSharingFactory() + with pytest.raises( + ValueError, + match=SINGLETON_ERROR.format( + model_name=SocialSharingFactory._meta.model._meta.verbose_name + ) + ): + SocialSharingFactory() + + +def test_singleton_tracking(db): + TrackingFactory() + with pytest.raises( + ValueError, + match=SINGLETON_ERROR.format( + model_name=TrackingFactory._meta.model._meta.verbose_name + ) + ): + TrackingFactory() diff --git a/tests/serializers.py b/tests/serializers.py index 924808e..e7a5ba6 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -1,9 +1,15 @@ +from firm_info.factories import ( + AppsBannerFactory, FirmContactFactory, SocialSharingFactory +) from firm_info.models import FirmContact, Link from firm_info.serializers import ( _format_address, + serialize_firm_apps_banner, serialize_firm_description, serialize_firm_info, + serialize_firm_logos, serialize_firm_social, + serialize_firm_social_sharing, ) from .constantes import ( @@ -11,7 +17,8 @@ ) -def test_serialize_firm_info(db, firm_contact_obj): +def test_serialize_firm_info(db): + firm_contact_obj = FirmContactFactory() queryset = FirmContact.objects.all() assert queryset.count() == 1 expected_output = { @@ -38,10 +45,44 @@ def test_serialize_firm_social(db, firm_social_links_objs): assert serialize_firm_social(queryset) == expected_output -def test_serialize_firm_description(db, firm_contact_obj): +def test_serialize_firm_description(db): + firm_contact_obj = FirmContactFactory() queryset = FirmContact.objects.all() expected_output = { "baseline": firm_contact_obj.baseline, "short_description": firm_contact_obj.short_description, } assert serialize_firm_description(queryset) == expected_output + + +def test_serialize_firm_social_sharing(db): + SocialSharingFactory() + obj = SocialSharingFactory._meta.model.objects.first() + expected_output = { + "og_image": obj.og_image, + "og_description": obj.og_description, + "og_twitter_site": obj.og_twitter_site, + } + assert serialize_firm_social_sharing(obj) == expected_output + + +def test_serialize_firm_apps_banner(db): + AppsBannerFactory() + obj = AppsBannerFactory._meta.model.objects.first() + expected_output = { + "title": obj.title, + "description": obj.description, + "image": obj.image, + } + assert serialize_firm_apps_banner(obj) == expected_output + + +def test_serialize_firm_logos(db): + FirmContactFactory() + obj = FirmContactFactory._meta.model.objects.first() + expected_output = { + "logo": obj.logo, + "logo_invert": obj.logo_invert, + "favicon": obj.favicon, + } + assert serialize_firm_logos(obj) == expected_output diff --git a/tests/signals.py b/tests/signals.py new file mode 100644 index 0000000..002764e --- /dev/null +++ b/tests/signals.py @@ -0,0 +1,74 @@ +from os.path import exists + +from firm_info.factories import ( + AppsBannerFactory, + SocialSharingFactory, + create_image_file, + FirmContactFactory, +) + + +def post_delete_FirmContact(db): + firm_contact = FirmContactFactory() + files = ["logo", "logo_invert", "favicon"] + for file in files: + assert exists(getattr(firm_contact, file).path) + firm_contact.delete() + for file in files: + assert not exists(getattr(firm_contact, file).path) + + +def post_save_FirmContact(db): + firm_contact = FirmContactFactory() + files = ["logo", "logo_invert", "favicon"] + + old_paths = [] + for file in files: + path = getattr(firm_contact, file).path + assert exists(path) + old_paths.append(path) + + firm_contact.logo = create_image_file() + firm_contact.logo_invert = create_image_file() + firm_contact.favicon = create_image_file() + + firm_contact.save() + + for path in old_paths: + assert not exists(path) + + +def test_post_delete_AppsBanner(db): + appbanner = AppsBannerFactory() + assert exists(appbanner.image.path) + appbanner.delete() + assert not exists(appbanner.image.path) + + +def test_post_save_AppsBanner(db): + appbanner = AppsBannerFactory() + old_path = appbanner.image.path + assert exists(old_path) + + appbanner.image = create_image_file() + appbanner.save() + + assert not exists(old_path) + + +def test_post_delete_SocialSharing(db): + socialsharing = SocialSharingFactory() + assert exists(socialsharing.og_image.path) + socialsharing.delete() + assert not exists(socialsharing.og_image.path) + + +def test_post_save_SocialSharing(db): + socialsharing = SocialSharingFactory() + old_path = socialsharing.og_image.path + assert exists(old_path) + + socialsharing.og_image = create_image_file() + socialsharing.save() + + assert not exists(old_path) diff --git a/tests/templatetags.py b/tests/templatetags.py index 5719afd..eda418f 100644 --- a/tests/templatetags.py +++ b/tests/templatetags.py @@ -5,19 +5,22 @@ from django.template import RequestContext, Template from django.test import RequestFactory +from firm_info.factories import AppsBannerFactory, FirmContactFactory, SocialSharingFactory from firm_info.models import FirmContact, Link -from firm_info.factories import TrackingFactory from firm_info.templatetags.firm_info import ( + app_banner, firm_contact, firm_description, + firm_logos, firm_social_links, - firm_tag_analytic, + firm_social_shares, ) from .constantes import SERIALIZED_SOCIAL_LINKS -def test_firm_contact_tag(db, firm_contact_obj): +def test_firm_contact_tag(db): + firm_contact_obj = FirmContactFactory() template_path = "tests/templatetags/firm_info/test_firm_contact.html" request = RequestFactory().get('/') @@ -61,7 +64,8 @@ def test_firm_social_links_tag(db, firm_social_links_objs): assert rendered == expected_output -def test_firm_description_tag(firm_contact_obj): +def test_firm_description_tag(db): + firm_contact_obj = FirmContactFactory() template_path = "tests/templatetags/firm_info/test_firm_description.html" factory = RequestFactory() @@ -79,6 +83,79 @@ def test_firm_description_tag(firm_contact_obj): assert unescape(rendered) == expected_output +def test_firm_logos_tag(db): + firm_contact_obj = FirmContactFactory() + template_path = "tests/templatetags/firm_info/test_firm_logos.html" + + request = RequestFactory().get('/') + context = RequestContext(request) + + context["firm"] = firm_contact_obj + output = firm_logos(context, template_path) + template = Template(output) + rendered = template.render(context) + expected_output = "\n".join([ + f"\"Logo\"", + f"\"Inverted", + f"" + ]) + assert unescape(rendered) == expected_output + + +@pytest.mark.parametrize( + "app_type", + [ + AppsBannerFactory._meta.model.APPS_CHOICES[0][0], + AppsBannerFactory._meta.model.APPS_CHOICES[1][0] + ] +) +def test_app_banner(db, app_type): + template_path = "tests/templatetags/firm_info/test_app_banner.html" + + request = RequestFactory().get('/') + context = RequestContext(request) + + instance = AppsBannerFactory(application_type=app_type) + context["app_banner"] = instance + output = app_banner(context, app_type, template_path) + template = Template(output) + rendered = template.render(context) + expected_output = "\n".join([ + "
", + f"\"{instance.title}\"", + f"

{instance.title}

", + f"

{instance.description}

", + "
" + ]) + + assert unescape(rendered).strip() == expected_output.strip() + assert unescape(rendered).strip() == expected_output.strip() + + +def test_firm_social_shares_tag(db): + template_path = "tests/templatetags/firm_info/test_firm_social_shares.html" + + request = RequestFactory().get('/') + context = RequestContext(request) + + # Assuming there is at least one SocialSharing instance. + social_sharing = SocialSharingFactory() + + if social_sharing: + context["social_sharing"] = social_sharing + output = firm_social_shares(context, template_path) + template = Template(output) + rendered = template.render(context) + expected_output = "\n".join([ + "
", + f"\"Social", + f"

Description: {social_sharing.og_description}

", + f"

Twitter Site: {social_sharing.og_twitter_site}

", + "
" + ]) + assert unescape(rendered) == expected_output + + @pytest.mark.parametrize( "template_path,Model", [ @@ -98,19 +175,3 @@ def test_not_rendered_without_objs(db, template_path, Model): template = Template(output) rendered = template.render(context) assert rendered == "" - - -def test_firm_tag_analytic(db): - # test on empty db - assert "" == firm_tag_analytic() - # add on tag - tracking = TrackingFactory(tag_analytic="G-XXX-YYY") - assert tracking.tag_analytic == firm_tag_analytic() - # add a second tag - tracking2 = TrackingFactory(tag_analytic="B-XXX-YYY") - # main tag is still the first one - assert tracking2.tag_analytic != firm_tag_analytic() - assert tracking.tag_analytic == firm_tag_analytic() - # remove the first tag, tracking2 is the new main tag - tracking.delete() - assert tracking2.tag_analytic == firm_tag_analytic() From 611b4b80b52e4adb26bb0d3f818bcbd2dd6da0b2 Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Wed, 26 Jun 2024 10:19:36 +0200 Subject: [PATCH 14/15] [NEW] Added complete FirmContact templatetag --- firm_info/factories.py | 2 +- firm_info/serializers.py | 21 +++++++++++ .../firm_info/test_firm_complete_info.html | 8 ++++ firm_info/templatetags/firm_info.py | 23 ++++++++++++ tests/serializers.py | 28 ++++++++++++++ tests/templatetags.py | 37 ++++++++++++++++++- 6 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 firm_info/templates/tests/templatetags/firm_info/test_firm_complete_info.html diff --git a/firm_info/factories.py b/firm_info/factories.py index 393f463..461f2ef 100644 --- a/firm_info/factories.py +++ b/firm_info/factories.py @@ -125,7 +125,7 @@ class FirmContactFactory(factory.django.DjangoModelFactory): phone_number = factory.Faker("phone_number") email = factory.Faker("email") - address = factory.Faker("address") + address = factory.Faker("street_address") postal_code = factory.Faker("postcode") city = factory.Faker("city") country = factory.Faker("country") diff --git a/firm_info/serializers.py b/firm_info/serializers.py index fdea090..3330165 100644 --- a/firm_info/serializers.py +++ b/firm_info/serializers.py @@ -196,3 +196,24 @@ def serialize_firm_logos(obj): "logo_invert": getattr(obj, "logo_invert", None), "favicon": getattr(obj, "favicon", None), } + + +def serialize_firm_complete_info(queryset): + """ + Serializes complete firm information from a FirmContact instance. + + Args: + firm_instance (FirmContact): An instance of FirmContact. + + Returns: + dict: The serialized complete firm information, including logos and + descriptions. + """ + if not queryset.exists(): + return {} + + return { + **serialize_firm_info(queryset), + **serialize_firm_description(queryset), + **serialize_firm_logos(queryset.first()), + } diff --git a/firm_info/templates/tests/templatetags/firm_info/test_firm_complete_info.html b/firm_info/templates/tests/templatetags/firm_info/test_firm_complete_info.html new file mode 100644 index 0000000..85e4edd --- /dev/null +++ b/firm_info/templates/tests/templatetags/firm_info/test_firm_complete_info.html @@ -0,0 +1,8 @@ +

Email: {{ email }}

+

Phone: {{ phone }}

+

Full address: {{ full_address }}

+

Baseline: {{ baseline }}

+

Description: {{ short_description }}

+Logo +Inverted Logo + \ No newline at end of file diff --git a/firm_info/templatetags/firm_info.py b/firm_info/templatetags/firm_info.py index 309752f..62c46b9 100644 --- a/firm_info/templatetags/firm_info.py +++ b/firm_info/templatetags/firm_info.py @@ -4,6 +4,7 @@ from firm_info.models import AppsBanner, FirmContact, Link, SocialSharing, Tracking from firm_info.serializers import ( serialize_firm_apps_banner, + serialize_firm_complete_info, serialize_firm_description, serialize_firm_info, serialize_firm_logos, @@ -225,3 +226,25 @@ def app_banner(context, app_type, template_path): rendered = template.render(combined_context) return rendered + + +@register.simple_tag(takes_context=True, name="firm_complete_info") +def firm_complete_info(context, template_path): + """ + Renders the firm's complete information using the specified template. + + Args: + template_path (str): The path to the template file. + + Returns: + str: The rendered HTML output of the complete firm information. + """ + qs_firm_info = FirmContact.objects.all() + if qs_firm_info.exists(): + template = loader.get_template(template_path) + specific_context = serialize_firm_complete_info(qs_firm_info) + combined_context = {**context.flatten(), **specific_context} + rendered = template.render(combined_context) + return rendered + else: + return '' diff --git a/tests/serializers.py b/tests/serializers.py index e7a5ba6..e88584a 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -5,6 +5,7 @@ from firm_info.serializers import ( _format_address, serialize_firm_apps_banner, + serialize_firm_complete_info, serialize_firm_description, serialize_firm_info, serialize_firm_logos, @@ -86,3 +87,30 @@ def test_serialize_firm_logos(db): "favicon": obj.favicon, } assert serialize_firm_logos(obj) == expected_output + + +def test_serialize_firm_complete_info(db): + firm_contact_obj = FirmContactFactory() + queryset = FirmContact.objects.all() + assert queryset.count() == 1 + + expected_output = { + "address": firm_contact_obj.address, + "email": firm_contact_obj.email, + "phone": firm_contact_obj.phone_number, + "full_address": _format_address( + queryset.values( + "phone_number", "email", "address", "postal_code", "city", "country" + ).first() + ), + "baseline": firm_contact_obj.baseline, + "city": firm_contact_obj.city, + "country": firm_contact_obj.country, + "postal_code": firm_contact_obj.postal_code, + "short_description": firm_contact_obj.short_description, + "logo": firm_contact_obj.logo, + "logo_invert": firm_contact_obj.logo_invert, + "favicon": firm_contact_obj.favicon, + } + serialized_data = serialize_firm_complete_info(queryset) + assert serialized_data == expected_output diff --git a/tests/templatetags.py b/tests/templatetags.py index eda418f..2cb4c9b 100644 --- a/tests/templatetags.py +++ b/tests/templatetags.py @@ -5,10 +5,16 @@ from django.template import RequestContext, Template from django.test import RequestFactory -from firm_info.factories import AppsBannerFactory, FirmContactFactory, SocialSharingFactory +from firm_info.factories import ( + AppsBannerFactory, + FirmContactFactory, + SocialSharingFactory +) from firm_info.models import FirmContact, Link +from firm_info.serializers import _format_address from firm_info.templatetags.firm_info import ( app_banner, + firm_complete_info, firm_contact, firm_description, firm_logos, @@ -175,3 +181,32 @@ def test_not_rendered_without_objs(db, template_path, Model): template = Template(output) rendered = template.render(context) assert rendered == "" + + +def test_firm_complete_info_tag(db): + firm_contact_obj = FirmContactFactory() + template_path = "tests/templatetags/firm_info/test_firm_complete_info.html" + + request = RequestFactory().get('/') + context = RequestContext(request) + + context["firm"] = firm_contact_obj + output = firm_complete_info(context, template_path) + template = Template(output) + rendered = template.render(context) + expected_output = "\n".join([ + "

Email: {}

".format(firm_contact_obj.email), + "

Phone: {}

".format(firm_contact_obj.phone_number), + "

Full address: {}, {} {} {}

".format( + firm_contact_obj.address, + firm_contact_obj.postal_code, + firm_contact_obj.city, + firm_contact_obj.country, + ), + "

Baseline: {}

".format(firm_contact_obj.baseline), + "

Description: {}

".format(firm_contact_obj.short_description), + "\"Logo\"".format(firm_contact_obj.logo.url), + "\"Inverted".format(firm_contact_obj.logo_invert.url), + "".format(firm_contact_obj.favicon.url) + ]) + assert unescape(rendered) == expected_output From c1c09c7f83bc932e473bb7c735d8c2843617222d Mon Sep 17 00:00:00 2001 From: Samy Saad Date: Wed, 26 Jun 2024 10:53:02 +0200 Subject: [PATCH 15/15] [DOC] Improved documentation --- docs/firm_info/admin.rst | 9 ++++++++ docs/firm_info/exceptions.rst | 4 ++-- docs/firm_info/factories.rst | 9 ++++++++ docs/firm_info/index.rst | 6 +++++ docs/firm_info/models.rst | 2 +- docs/firm_info/serializers.rst | 2 +- docs/firm_info/settings.rst | 9 ++++++++ docs/firm_info/templatetags.rst | 2 +- firm_info/admin.py | 5 ---- firm_info/serializers.py | 36 ++++++++++++++++++++--------- firm_info/templatetags/firm_info.py | 8 +++++++ frozen.txt | 2 +- tests/templatetags.py | 5 ++-- 13 files changed, 75 insertions(+), 24 deletions(-) create mode 100644 docs/firm_info/admin.rst create mode 100644 docs/firm_info/factories.rst create mode 100644 docs/firm_info/settings.rst diff --git a/docs/firm_info/admin.rst b/docs/firm_info/admin.rst new file mode 100644 index 0000000..697ac01 --- /dev/null +++ b/docs/firm_info/admin.rst @@ -0,0 +1,9 @@ +.. _intro_firm-admin: + +===== +Admin +===== + +.. automodule:: firm_info.admin + :members: UniqueModelAdmin + :exclude-members: has_add_permission \ No newline at end of file diff --git a/docs/firm_info/exceptions.rst b/docs/firm_info/exceptions.rst index 68213e8..1a8ec59 100644 --- a/docs/firm_info/exceptions.rst +++ b/docs/firm_info/exceptions.rst @@ -5,5 +5,5 @@ Exceptions ========== .. automodule:: firm_info.exceptions - :members: SerializeFirmError - :exclude-members: MyAppBaseException \ No newline at end of file + :members: + :exclude-members: \ No newline at end of file diff --git a/docs/firm_info/factories.rst b/docs/firm_info/factories.rst new file mode 100644 index 0000000..6f09730 --- /dev/null +++ b/docs/firm_info/factories.rst @@ -0,0 +1,9 @@ +.. _intro_firm-factories: + +========= +Factories +========= + +.. automodule:: firm_info.factories + :members: + :exclude-members: \ No newline at end of file diff --git a/docs/firm_info/index.rst b/docs/firm_info/index.rst index 19fa759..01e747a 100644 --- a/docs/firm_info/index.rst +++ b/docs/firm_info/index.rst @@ -9,6 +9,12 @@ Django firm info models.rst + admin.rst + + factories.rst + + settings.rst + serializers.rst templatetags.rst diff --git a/docs/firm_info/models.rst b/docs/firm_info/models.rst index b389c55..c0ce189 100644 --- a/docs/firm_info/models.rst +++ b/docs/firm_info/models.rst @@ -5,5 +5,5 @@ Models ====== .. automodule:: firm_info.models - :members: FirmContact, Link, SocialSharing, Tracking, AppsBanner, + :members: :exclude-members: DoesNotExist, MultipleObjectsReturned diff --git a/docs/firm_info/serializers.rst b/docs/firm_info/serializers.rst index 066a60f..96ea40d 100644 --- a/docs/firm_info/serializers.rst +++ b/docs/firm_info/serializers.rst @@ -5,5 +5,5 @@ Serializers =========== .. automodule:: firm_info.serializers - :members: _format_address, serialize_firm_info, serialize_firm_social, serialize_firm_description, serialize_firm_social_sharing, serialize_firm_apps_banner + :members: :exclude-members: DoesNotExist, MultipleObjectsReturned \ No newline at end of file diff --git a/docs/firm_info/settings.rst b/docs/firm_info/settings.rst new file mode 100644 index 0000000..6228be3 --- /dev/null +++ b/docs/firm_info/settings.rst @@ -0,0 +1,9 @@ +.. _intro_firm-settings: + +======== +Settings +======== + +.. automodule:: firm_info.settings + :members: + :exclude-members: \ No newline at end of file diff --git a/docs/firm_info/templatetags.rst b/docs/firm_info/templatetags.rst index eeb7894..97d6c1d 100644 --- a/docs/firm_info/templatetags.rst +++ b/docs/firm_info/templatetags.rst @@ -5,5 +5,5 @@ Templatetags ============ .. automodule:: firm_info.templatetags.firm_info - :members: firm_contact, firm_social_links, firm_description, firm_logos, firm_social_shares, firm_tag_analytic, app_banner + :members: :exclude-members: DoesNotExist, MultipleObjectsReturned \ No newline at end of file diff --git a/firm_info/admin.py b/firm_info/admin.py index 10932b9..6b10b45 100644 --- a/firm_info/admin.py +++ b/firm_info/admin.py @@ -13,11 +13,6 @@ class UniqueModelAdmin(admin.ModelAdmin): This admin class overrides the default add permission to ensure that only one instance of the associated model can exist at any given time. If an instance already exists, it prohibits adding new instances. - - Methods: - has_add_permission(self, request): Checks if adding a new instance is - permissible. - clean(self): Validates that there is not more than one instance of the model. """ def has_add_permission(self, request): diff --git a/firm_info/serializers.py b/firm_info/serializers.py index 3330165..e2ae186 100644 --- a/firm_info/serializers.py +++ b/firm_info/serializers.py @@ -187,15 +187,22 @@ def serialize_firm_logos(obj): Returns: dict: A dictionary with logo details suitable for rendering in templates. + + Raises: + SerializeFirmError: Raised when an error occurs during serialization. """ if obj is None: return {} - return { - "logo": getattr(obj, "logo", None), - "logo_invert": getattr(obj, "logo_invert", None), - "favicon": getattr(obj, "favicon", None), - } + try: + return { + "logo": getattr(obj, "logo", None), + "logo_invert": getattr(obj, "logo_invert", None), + "favicon": getattr(obj, "favicon", None), + } + except Exception as err: + error_msg = "Failed to serialize firm social logos." + raise SerializeFirmError(error_msg) from err def serialize_firm_complete_info(queryset): @@ -206,14 +213,21 @@ def serialize_firm_complete_info(queryset): firm_instance (FirmContact): An instance of FirmContact. Returns: - dict: The serialized complete firm information, including logos and + dict: The serialized complete firm information, including logos and descriptions. + + Raises: + SerializeFirmError: Raised when an error occurs during serialization. """ if not queryset.exists(): return {} - return { - **serialize_firm_info(queryset), - **serialize_firm_description(queryset), - **serialize_firm_logos(queryset.first()), - } + try: + return { + **serialize_firm_info(queryset), + **serialize_firm_description(queryset), + **serialize_firm_logos(queryset.first()), + } + except Exception as err: + error_msg = "Failed to serialize firm social complete information." + raise SerializeFirmError(error_msg) from err diff --git a/firm_info/templatetags/firm_info.py b/firm_info/templatetags/firm_info.py index 62c46b9..08ba165 100644 --- a/firm_info/templatetags/firm_info.py +++ b/firm_info/templatetags/firm_info.py @@ -238,6 +238,14 @@ def firm_complete_info(context, template_path): Returns: str: The rendered HTML output of the complete firm information. + + Usage: + + .. code-block:: html + + {% load firm_info %} + {% firm_complete_info "path/to/template.html" %} + """ qs_firm_info = FirmContact.objects.all() if qs_firm_info.exists(): diff --git a/frozen.txt b/frozen.txt index 07e2871..6db5618 100644 --- a/frozen.txt +++ b/frozen.txt @@ -1,4 +1,4 @@ -# Frozen requirement versions from '0.1.4' installation +# Frozen requirement versions from '0.1.5' installation Django==4.2.13 django-smart-media==0.3.1 djangocms-text-ckeditor==5.1.5 diff --git a/tests/templatetags.py b/tests/templatetags.py index 2cb4c9b..41443c5 100644 --- a/tests/templatetags.py +++ b/tests/templatetags.py @@ -11,7 +11,6 @@ SocialSharingFactory ) from firm_info.models import FirmContact, Link -from firm_info.serializers import _format_address from firm_info.templatetags.firm_info import ( app_banner, firm_complete_info, @@ -206,7 +205,9 @@ def test_firm_complete_info_tag(db): "

Baseline: {}

".format(firm_contact_obj.baseline), "

Description: {}

".format(firm_contact_obj.short_description), "\"Logo\"".format(firm_contact_obj.logo.url), - "\"Inverted".format(firm_contact_obj.logo_invert.url), + "\"Inverted".format( + firm_contact_obj.logo_invert.url + ), "".format(firm_contact_obj.favicon.url) ]) assert unescape(rendered) == expected_output