diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 697c987..c1c2d44 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -36,6 +36,7 @@ jobs: - name: Run mypy run: uv run mypy . env: + DEBUG: "true" SECRET_KEY: "" DATABASE_URL: "" @@ -70,5 +71,6 @@ jobs: - name: Run Django tests run: uv run --no-sync python manage.py test env: + DEBUG: "true" SECRET_KEY: "dummySecretKey" DATABASE_URL: "sqlite:///:memory:" diff --git a/README.md b/README.md index 1a9529e..7e836ce 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This is a project to create a single personal portfolio page with a clear and si - 🎨 **Modern UI** with Tailwind CSS and DaisyUI - 🔐 **Cookie consent management** with django-cooco - 📝 **Admin-editable content** - No code changes needed to update your portfolio +- 📧 **Contact form** with email notifications and database storage - 📱 **Fully responsive** design - 🐳 **Docker-ready** for easy deployment @@ -91,6 +92,9 @@ This is a project to create a single personal portfolio page with a clear and si DEBUG=true SECRET_KEY= DATABASE_URL=sqlite:/// + + # Email Configuration (optional for development - emails print to console) + CONTACT_EMAIL=contact@localhost ``` 8. Run migrations @@ -148,6 +152,16 @@ This is a project to create a single personal portfolio page with a clear and si POSTGRES_DB= POSTGRES_USER= POSTGRES_PASSWORD= + + # Email Configuration (required for contact form) + EMAIL_HOST=smtp.your-provider.com + EMAIL_PORT=587 + EMAIL_USE_TLS=True + EMAIL_USE_SSL=False + EMAIL_HOST_USER=your-username + EMAIL_HOST_PASSWORD=your-password + DEFAULT_FROM_EMAIL=noreply@your-domain.com + CONTACT_EMAIL=contact@your-domain.com ``` 4. Create a folder called `ssl` and store there your `cert.pem` and `key.pem` files diff --git a/changelog.md b/changelog.md index abb4e60..e5def76 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,7 @@ ## Next Release - Improve UI +- Add contact form page - Change the structure of cookie consent banner - Split experience timeline template into different cotton components diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 5d5f303..89e8b49 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -28,6 +28,14 @@ services: SECRET_KEY: ${SECRET_KEY} DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} ALLOWED_HOSTS: ${SERVER_NAMES} + EMAIL_HOST: ${EMAIL_HOST} + EMAIL_PORT: ${EMAIL_PORT} + EMAIL_USE_TLS: ${EMAIL_USE_TLS} + EMAIL_USE_SSL: ${EMAIL_USE_SSL} + EMAIL_HOST_USER: ${EMAIL_HOST_USER} + EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD} + DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL} + CONTACT_EMAIL: ${CONTACT_EMAIL} nginx: image: nginx:1.27.3-alpine diff --git a/src/base/templatetags/base_tags.py b/src/base/templatetags/base_tags.py index 807e1d6..21d1e78 100644 --- a/src/base/templatetags/base_tags.py +++ b/src/base/templatetags/base_tags.py @@ -33,6 +33,10 @@ class NavbarDataDict(TypedDict): name=gettext_lazy("My Career"), url="my-career", ), + LinksDict( + name=gettext_lazy("Contact"), + url="contact", + ), ) ) diff --git a/src/contact/__init__.py b/src/contact/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/contact/admin.py b/src/contact/admin.py new file mode 100644 index 0000000..ec6d202 --- /dev/null +++ b/src/contact/admin.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from django.contrib.admin import ModelAdmin, register + +from .models import ContactMessage + + +@register(ContactMessage) +class ContactMessageAdmin(ModelAdmin[ContactMessage]): + """Admin interface for ContactMessage model.""" + + readonly_fields = ( + "name", + "email", + "subject", + "created_at", + "message", + "error", + ) + + search_fields = ( + "name", + "email", + "subject", + "message", + ) + + date_hierarchy = "created_at" diff --git a/src/contact/apps.py b/src/contact/apps.py new file mode 100644 index 0000000..7a5bfdb --- /dev/null +++ b/src/contact/apps.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from django.apps import AppConfig + + +class ContactConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "contact" diff --git a/src/contact/forms.py b/src/contact/forms.py new file mode 100644 index 0000000..95e923e --- /dev/null +++ b/src/contact/forms.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from types import MappingProxyType + +from django import forms +from django.utils.translation import gettext_lazy + +from contact.models import ContactMessage + +MINIMUM_MESSAGE_LENGTH = 10 + +CONTACT_FORM_FIELD_NAME = "name" +CONTACT_FORM_FIELD_EMAIL = "email" +CONTACT_FORM_FIELD_SUBJECT = "subject" +CONTACT_FORM_FIELD_MESSAGE = "message" + + +class ContactForm(forms.ModelForm[ContactMessage]): + """Contact form for visitors to send messages.""" + + class Meta: + model = ContactMessage + fields = ( + CONTACT_FORM_FIELD_NAME, + CONTACT_FORM_FIELD_EMAIL, + CONTACT_FORM_FIELD_SUBJECT, + CONTACT_FORM_FIELD_MESSAGE, + ) + widgets = MappingProxyType( + { + CONTACT_FORM_FIELD_NAME: forms.TextInput( + attrs={ + "class": ( + "input input-bordered w-full focus:outline-none focus:border-primary/50" + " focus:ring-2 focus:ring-primary/20 transition-all duration-200" + ), + "placeholder": gettext_lazy("Your name"), + } + ), + CONTACT_FORM_FIELD_EMAIL: forms.EmailInput( + attrs={ + "class": ( + "input input-bordered w-full focus:outline-none focus:border-primary/50" + " focus:ring-2 focus:ring-primary/20 transition-all duration-200" + ), + "placeholder": gettext_lazy("your.email@example.com"), + } + ), + CONTACT_FORM_FIELD_SUBJECT: forms.TextInput( + attrs={ + "class": ( + "input input-bordered w-full focus:outline-none focus:border-primary/50" + " focus:ring-2 focus:ring-primary/20 transition-all duration-200" + ), + "placeholder": gettext_lazy("Message subject"), + } + ), + CONTACT_FORM_FIELD_MESSAGE: forms.Textarea( + attrs={ + "class": ( + "textarea textarea-bordered w-full h-32 focus:outline-none focus:border-primary/50" + " focus:ring-2 focus:ring-primary/20 transition-all duration-200" + ), + "placeholder": gettext_lazy("Write your message here..."), + "rows": 6, + } + ), + } + ) + labels = MappingProxyType( + { + CONTACT_FORM_FIELD_NAME: gettext_lazy("Name"), + CONTACT_FORM_FIELD_EMAIL: gettext_lazy("Email"), + CONTACT_FORM_FIELD_SUBJECT: gettext_lazy("Subject"), + CONTACT_FORM_FIELD_MESSAGE: gettext_lazy("Message"), + } + ) + + def clean_message(self) -> str: + """Validate message length.""" + message = self.cleaned_data.get("message", "") + + if not isinstance(message, str): + raise forms.ValidationError(gettext_lazy("Invalid message.")) + + if len(message) < MINIMUM_MESSAGE_LENGTH: + raise forms.ValidationError( + gettext_lazy("Message must be at least %(min_length)d characters long.") + % {"min_length": MINIMUM_MESSAGE_LENGTH} + ) + + return message diff --git a/src/contact/migrations/0001_initial.py b/src/contact/migrations/0001_initial.py new file mode 100644 index 0000000..594f0ff --- /dev/null +++ b/src/contact/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.8 on 2026-01-09 12:54 +from __future__ import annotations + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = () + + operations = ( + migrations.CreateModel( + name="ContactMessage", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=100)), + ("email", models.EmailField(max_length=254)), + ("subject", models.CharField(max_length=200)), + ("message", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("is_read", models.BooleanField(default=False)), + ("error", models.TextField(blank=True)), + ], + options={ + "ordering": ("-created_at",), + }, + ), + ) diff --git a/src/contact/migrations/__init__.py b/src/contact/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/contact/models.py b/src/contact/models.py new file mode 100644 index 0000000..b7d740e --- /dev/null +++ b/src/contact/models.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from django.db import models + + +class ContactMessage(models.Model): + """Model to store contact form submissions.""" + + name = models.CharField(max_length=100) + email = models.EmailField() + subject = models.CharField(max_length=200) + message = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + is_read = models.BooleanField(default=False) + error = models.TextField(blank=True) + + class Meta: + ordering = ("-created_at",) + + def __str__(self) -> str: + return f"{self.name} - {self.subject}" diff --git a/src/contact/templates/contact.html b/src/contact/templates/contact.html new file mode 100644 index 0000000..80d564d --- /dev/null +++ b/src/contact/templates/contact.html @@ -0,0 +1,167 @@ +{% load i18n %} + + +
+
+ + + +
+

+ {% translate "Do you have a question or want to work together? Feel free to send me a message and I will respond as soon as possible." %} +

+
+ + {% if messages %} +
+ {% for message in messages %} +
+ + {% if message.tags == 'success' %} + + {% else %} + + {% endif %} + + {{ message }} +
+ {% endfor %} +
+ {% endif %} + + +
+
+ +
+ {% csrf_token %} + + +
+ + {{ form.name }} + {% if form.name.errors %} + + {% endif %} +
+ + +
+ + {{ form.email }} + {% if form.email.errors %} + + {% endif %} +
+ + +
+ + {{ form.subject }} + {% if form.subject.errors %} + + {% endif %} +
+ + +
+ + {{ form.message }} + {% if form.message.errors %} + + {% endif %} +
+ + +
+ +
+
+
+
+ + +
+

+ {% translate "Your information is safe and will only be used to respond to your inquiry." %} +

+
+
+
+
diff --git a/src/contact/tests/__init__.py b/src/contact/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/contact/tests/test_models/__init__.py b/src/contact/tests/test_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/contact/tests/test_models/test_contact_message_model.py b/src/contact/tests/test_models/test_contact_message_model.py new file mode 100644 index 0000000..edbc458 --- /dev/null +++ b/src/contact/tests/test_models/test_contact_message_model.py @@ -0,0 +1,77 @@ +"""Tests for ContactMessage model.""" + +from __future__ import annotations + +from typing import ClassVar + +from django.test import TestCase + +from contact.models import ContactMessage + +# Constants +TEST_NAME_1 = "Test Name 1" +TEST_EMAIL_1 = "test1@example.com" +TEST_SUBJECT_1 = "Test Subject 1" +TEST_MESSAGE_1 = "Test message content 1" + +TEST_NAME_2 = "Test Name 2" +TEST_EMAIL_2 = "test2@example.com" +TEST_SUBJECT_2 = "Test Subject 2" +TEST_MESSAGE_2 = "Test message content 2" + + +class TestContactMessageModel(TestCase): + """Test cases for the ContactMessage model.""" + + message1: ClassVar[ContactMessage] + message2: ClassVar[ContactMessage] + + @classmethod + def setUpTestData(cls) -> None: + cls.message1 = ContactMessage.objects.create( + name=TEST_NAME_1, + email=TEST_EMAIL_1, + subject=TEST_SUBJECT_1, + message=TEST_MESSAGE_1, + ) + cls.message2 = ContactMessage.objects.create( + name=TEST_NAME_2, + email=TEST_EMAIL_2, + subject=TEST_SUBJECT_2, + message=TEST_MESSAGE_2, + ) + + def test_str(self) -> None: + """Test the string representation of ContactMessage.""" + self.assertEqual( + returned_str := str(self.message1), + expected_str := f"{TEST_NAME_1} - {TEST_SUBJECT_1}", + f"The __str__ method is returning '{returned_str}' instead of expected value '{expected_str}'", + ) + + def test_default_is_read(self) -> None: + """Test that is_read defaults to False.""" + self.assertFalse(self.message1.is_read) + + def test_created_at_auto_set(self) -> None: + """Test that created_at is automatically set.""" + self.assertIsNotNone(self.message1.created_at) + + def test_error_field(self) -> None: + """Test the error field functionality.""" + self.assertEqual(self.message1.error, "", "The error field should be an empty string by default") + + self.message1.error = "Sample error message" + self.message1.save() + self.message1.refresh_from_db() + + self.assertEqual(self.message1.error, "Sample error message", "The error field did not update correctly") + + def test_ordering(self) -> None: + """Test that messages are ordered by created_at descending.""" + ordered_messages = list(ContactMessage.objects.all()) + self.assertEqual( + ordered_messages, + [self.message2, self.message1], + "The messages are not ordered by created_at descending as expected", + ) diff --git a/src/contact/tests/test_views/__init__.py b/src/contact/tests/test_views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/contact/tests/test_views/base_view_test.py b/src/contact/tests/test_views/base_view_test.py new file mode 100644 index 0000000..19847a9 --- /dev/null +++ b/src/contact/tests/test_views/base_view_test.py @@ -0,0 +1,14 @@ +"""Base test class for contact views.""" + +from __future__ import annotations + +from utils.test_utils.base_view_test_case import BaseViewTestCase + + +class BaseContactViewTest(BaseViewTestCase): + """Base class for testing contact view content.""" + + @classmethod + def init_db(cls) -> None: + """Initialize database - no specific data needed for contact page.""" + pass diff --git a/src/contact/tests/test_views/test_contact_view.py b/src/contact/tests/test_views/test_contact_view.py new file mode 100644 index 0000000..3505a14 --- /dev/null +++ b/src/contact/tests/test_views/test_contact_view.py @@ -0,0 +1,473 @@ +"""Tests for contact view.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest import mock + +from django.core import mail + +import contact.tests.test_views.utils.constants as test_view_constants +from contact.models import ContactMessage +from contact.tests.test_views.base_view_test import BaseContactViewTest +from utils.test_utils.base_view_test_case import ElementText, get_beautiful_soup_from_response +from utils.test_utils.constants import ATTR_PLACEHOLDER, HtmlTag, Language + +if TYPE_CHECKING: + from bs4 import Tag + + +class BaseTestContactViewContent(BaseContactViewTest): + """Base class for testing contact view content.""" + + request_path = "contact/" + + @classmethod + def init_db(cls) -> None: + """Initialize database - no specific data needed for contact page.""" + pass + + def test_response(self) -> None: + """Test that the contact page loads successfully.""" + self._assert_reponse_status_code(expected_status_code=200) + self._assert_template_is_used("contact.html") + self._assert_template_is_used("cotton/base.html") + + def test_json_ld_contact_page_schema(self) -> None: + """Test that contact view includes valid JSON-LD ContactPage schema.""" + data = self._get_json_ld_data() + + # Verify @context structure + self.assertIn("@context", data) + self.assertIsInstance(data["@context"], dict) + self.assertEqual(data["@context"]["@vocab"], "https://schema.org/") + + # Verify language + self.assertEqual(data["@context"]["@language"], self.language) + + # Verify @type + self.assertEqual(data["@type"], "ContactPage") + + # Verify fields + self.assertEqual(data["name"], test_view_constants.META_TITLE[self.language]) + self.assertEqual(data["description"], test_view_constants.META_DESCRIPTION[self.language]) + + def test_meta_tags(self) -> None: + """Test that meta tags have correct values for contact page.""" + self._assert_text_of_element( + self._find_element_by_html_tag(self.response_data.soup, HtmlTag.TITLE), + test_view_constants.META_TITLE[self.language], + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_attribute(self.response_data.soup, HtmlTag.META, "name", "description"), + "content", + test_view_constants.META_DESCRIPTION[self.language], + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_attribute(self.response_data.soup, HtmlTag.META, "name", "keywords"), + "content", + test_view_constants.META_KEYWORDS[self.language], + ) + + def test_seo_open_graph_tags(self) -> None: + """Test that Open Graph tags have correct values.""" + self._assert_attribute_of_element( + self._find_element_by_tag_and_attribute(self.response_data.soup, HtmlTag.META, "property", "og:title"), + "content", + test_view_constants.META_TITLE[self.language], + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_attribute( + self.response_data.soup, HtmlTag.META, "property", "og:description" + ), + "content", + test_view_constants.META_DESCRIPTION[self.language], + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_attribute(self.response_data.soup, HtmlTag.META, "property", "og:image"), + "content", + "http://testserver/media/background_preview.jpg", + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_attribute(self.response_data.soup, HtmlTag.META, "property", "og:url"), + "content", + f"http://testserver/{self.language}/contact/", + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_attribute(self.response_data.soup, HtmlTag.META, "property", "og:type"), + "content", + "profile", + ) + + def test_seo_twitter_card(self) -> None: + """Test that Twitter card meta tags have correct values.""" + self._assert_attribute_of_element( + self._find_element_by_tag_and_attribute(self.response_data.soup, HtmlTag.META, "name", "twitter:card"), + "content", + "summary_large_image", + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_attribute(self.response_data.soup, HtmlTag.META, "name", "twitter:title"), + "content", + test_view_constants.META_TITLE[self.language], + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_attribute( + self.response_data.soup, HtmlTag.META, "name", "twitter:description" + ), + "content", + test_view_constants.META_DESCRIPTION[self.language], + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_attribute(self.response_data.soup, HtmlTag.META, "name", "twitter:image"), + "content", + "http://testserver/media/background_preview.jpg", + ) + + def __check_the_elements_in_contact_container(self, contact_container: Tag) -> None: + self._assert_text_of_elements( + contact_container, + ElementText( + html_tag=HtmlTag.H1, + element_id=test_view_constants.CONTACT_TITLE_ID, + expected_text=test_view_constants.CONTACT_PAGE_TITLE[self.language], + ), + ElementText( + html_tag=HtmlTag.P, + element_id="contact-description", + expected_text=test_view_constants.CONTACT_PAGE_DESCRIPTION[self.language], + ), + ) + + form = self._find_element_by_tag_and_id(contact_container, HtmlTag.FORM, test_view_constants.CONTACT_FORM_ID) + + self._assert_text_of_element_by_tag_and_id( + form, + HtmlTag.LABEL, + test_view_constants.CONTACT_FORM_NAME_LABEL_ID, + expected_text=test_view_constants.LABEL_NAME_TEXT[self.language], + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_id(form, HtmlTag.INPUT, test_view_constants.CONTACT_FORM_NAME_ID), + ATTR_PLACEHOLDER, + test_view_constants.INPUT_NAME_PLACEHOLDER[self.language], + ) + + self._assert_text_of_element_by_tag_and_id( + form, + HtmlTag.LABEL, + test_view_constants.CONTACT_FORM_EMAIL_LABEL_ID, + expected_text=test_view_constants.LABEL_EMAIL_TEXT[self.language], + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_id(form, HtmlTag.INPUT, test_view_constants.CONTACT_FORM_EMAIL_ID), + ATTR_PLACEHOLDER, + test_view_constants.INPUT_EMAIL_PLACEHOLDER[self.language], + ) + + self._assert_text_of_element_by_tag_and_id( + form, + HtmlTag.LABEL, + test_view_constants.CONTACT_FORM_SUBJECT_LABEL_ID, + expected_text=test_view_constants.LABEL_SUBJECT_TEXT[self.language], + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_id(form, HtmlTag.INPUT, test_view_constants.CONTACT_FORM_SUBJECT_ID), + ATTR_PLACEHOLDER, + test_view_constants.INPUT_SUBJECT_PLACEHOLDER[self.language], + ) + + self._assert_text_of_element_by_tag_and_id( + form, + HtmlTag.LABEL, + test_view_constants.CONTACT_FORM_MESSAGE_LABEL_ID, + expected_text=test_view_constants.LABEL_MESSAGE_TEXT[self.language], + ) + self._assert_attribute_of_element( + self._find_element_by_tag_and_id(form, HtmlTag.TEXTAREA, test_view_constants.CONTACT_FORM_MESSAGE_ID), + ATTR_PLACEHOLDER, + test_view_constants.INPUT_MESSAGE_PLACEHOLDER[self.language], + ) + + self._assert_text_of_element_by_tag_and_id( + form, + HtmlTag.BUTTON, + test_view_constants.CONTACT_FORM_SUBMIT_BUTTON_ID, + expected_text=test_view_constants.SUBMIT_BUTTON_TEXT[self.language], + ) + + self._assert_text_of_element_by_tag_and_id( + contact_container, + HtmlTag.P, + test_view_constants.CONTACT_ADDITIONAL_INFO_ID, + expected_text=test_view_constants.ADDITIONAL_INFO_TEXT[self.language], + ) + + def test_contact_page_elements(self) -> None: + """Test that the contact page contains the correct elements.""" + self.__check_the_elements_in_contact_container( + self._find_element_by_tag_and_id( + self.response_data.soup, HtmlTag.DIV, test_view_constants.CONTACT_CONTAINER_ID + ) + ) + + def test_valid_form_submission(self) -> None: + """Test successful form submission.""" + form_data = { + "name": test_view_constants.TEST_NAME, + "email": test_view_constants.TEST_EMAIL, + "subject": test_view_constants.TEST_SUBJECT, + "message": test_view_constants.TEST_MESSAGE, + } + + with mock.patch( + "django.utils.timezone.now", + # get_datetime_with_mocked_now(test_view_constants.MOCKED_NOW), + mock.Mock(return_value=test_view_constants.MOCKED_NOW), + ): + response = self.client.post(f"/{self.language}/{self.request_path}", data=form_data) + + # Check redirect after successful submission + self.assertRedirects(response, f"/{self.language}/{self.request_path}", status_code=302, target_status_code=200) + + # Check message was saved to database + self.assertEqual(ContactMessage.objects.count(), 1, "ContactMessage was not created in the database") + message = ContactMessage.objects.first() + + assert message is not None, "ContactMessage retrieved from database is None" + + self.assertEqual( + message.name, + test_view_constants.TEST_NAME, + f"Name field mismatch: expected '{test_view_constants.TEST_NAME}', got '{message.name}'", + ) + self.assertEqual( + message.email, + test_view_constants.TEST_EMAIL, + f"Email field mismatch: expected '{test_view_constants.TEST_EMAIL}', got '{message.email}'", + ) + self.assertEqual( + message.subject, + test_view_constants.TEST_SUBJECT, + f"Subject field mismatch: expected '{test_view_constants.TEST_SUBJECT}', got '{message.subject}'", + ) + self.assertEqual( + message.message, + test_view_constants.TEST_MESSAGE, + f"Message field mismatch: expected '{test_view_constants.TEST_MESSAGE}', got '{message.message}'", + ) + self.assertEqual( + message.created_at, + test_view_constants.MOCKED_NOW, + f"Created_at field mismatch: expected '{test_view_constants.MOCKED_NOW}', got '{message.created_at}'", + ) + self.assertFalse( + message.is_read, + "is_read field should be False for new messages", + ) + self.assertEqual( + message.error, + "", + "Error field should be empty for valid submissions", + ) + + # Check email was sent + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].to, + [test_view_constants.TO_EMAIL_ADDRESS], + f"Email to address mismatch: expected '{test_view_constants.TO_EMAIL_ADDRESS}', got '{mail.outbox[0].to}'", + ) + self.assertEqual( + mail.outbox[0].from_email, + test_view_constants.FROM_EMAIL_ADDRESS, + ( + f"Email from address mismatch: expected '{test_view_constants.FROM_EMAIL_ADDRESS}'," + f" got '{mail.outbox[0].from_email}'" + ), + ) + self.assertEqual( + mail.outbox[0].reply_to, + [test_view_constants.TEST_EMAIL], + f"Email reply-to mismatch: expected '{test_view_constants.TEST_EMAIL}', got '{mail.outbox[0].reply_to}'", + ) + + expected_subject = f"[Portfolio Contact] {test_view_constants.TEST_SUBJECT}" + self.assertEqual( + mail.outbox[0].subject, + expected_subject, + f"Email subject mismatch: expected '{expected_subject}', got '{mail.outbox[0].subject}'", + ) + + expected_body = test_view_constants.EMAIL_BODY_TEMPLATE.format( + name=test_view_constants.TEST_NAME, + email=test_view_constants.TEST_EMAIL, + subject=test_view_constants.TEST_SUBJECT, + message=test_view_constants.TEST_MESSAGE, + ) + + self.assertEqual( + mail.outbox[0].body, + expected_body, + f"Email body mismatch: expected '{expected_body}', got '{mail.outbox[0].body}'", + ) + + def test_contact_page_after_submission(self) -> None: + """Test that success message is displayed after valid submission.""" + form_data = { + "name": test_view_constants.TEST_NAME, + "email": test_view_constants.TEST_EMAIL, + "subject": test_view_constants.TEST_SUBJECT, + "message": test_view_constants.TEST_MESSAGE, + } + + # Submit form and follow redirect + response = self.client.post(f"/{self.language}/{self.request_path}", data=form_data, follow=True) + + contact_container = self._find_element_by_tag_and_id( + get_beautiful_soup_from_response(response), HtmlTag.DIV, test_view_constants.CONTACT_CONTAINER_ID + ) + response_alerts = self._find_element_by_tag_and_id( + contact_container, HtmlTag.DIV, test_view_constants.CONTACT_RESPONSE_ALERTS_ID + ).find_all("div", class_="alert") + + self.assertEqual( + len(response_alerts), + 1, + f"There should be exactly one alert message displayed, found {len(response_alerts)}", + ) + + response_alert = response_alerts[0] + self._assert_element_contains_class_name(response_alert, "alert-success") + self._assert_text_of_element(response_alert, test_view_constants.SUCCESS_MESSAGE[self.language]) + + self.__check_the_elements_in_contact_container(contact_container) + + def test_invalid_form_invalid_email(self) -> None: + """Test form submission with invalid email.""" + form_data = { + "name": test_view_constants.TEST_NAME, + "email": test_view_constants.TEST_INVALID_EMAIL, + "subject": test_view_constants.TEST_SUBJECT, + "message": test_view_constants.TEST_MESSAGE, + } + + response = self.client.post(f"/{self.language}/{self.request_path}", data=form_data) + + # Should not redirect, should show form with errors + self.assertEqual(response.status_code, 200) + + # Check no message was saved + self.assertEqual(ContactMessage.objects.count(), 0) + + # Check no email was sent + self.assertEqual(len(mail.outbox), 0) + + contact_container = self._find_element_by_tag_and_id( + get_beautiful_soup_from_response(response), HtmlTag.DIV, test_view_constants.CONTACT_CONTAINER_ID + ) + + self._assert_text_of_element_by_tag_and_id( + contact_container, + HtmlTag.SPAN, + test_view_constants.CONTACT_FORM_EMAIL_ERROR_ID, + expected_text=test_view_constants.VALIDATION_ERROR_INVALID_EMAIL[self.language], + ) + + self.__check_the_elements_in_contact_container(contact_container) + + def test_invalid_form_short_message(self) -> None: + """Test form submission with message that's too short.""" + form_data = { + "name": test_view_constants.TEST_NAME, + "email": test_view_constants.TEST_EMAIL, + "subject": test_view_constants.TEST_SUBJECT, + "message": test_view_constants.TEST_SHORT_MESSAGE, + } + + response = self.client.post(f"/{self.language}/{self.request_path}", data=form_data) + + # Should not redirect, should show form with errors + self.assertEqual(response.status_code, 200) + + # Check no message was saved + self.assertEqual(ContactMessage.objects.count(), 0) + + # Check no email was sent + self.assertEqual(len(mail.outbox), 0) + + contact_container = self._find_element_by_tag_and_id( + get_beautiful_soup_from_response(response), HtmlTag.DIV, test_view_constants.CONTACT_CONTAINER_ID + ) + + self._assert_text_of_element_by_tag_and_id( + contact_container, + HtmlTag.SPAN, + test_view_constants.CONTACT_FORM_MESSAGE_ERROR_ID, + expected_text=test_view_constants.VALIDATION_ERROR_SHORT_MESSAGE[self.language], + ) + + self.__check_the_elements_in_contact_container(contact_container) + + def test_email_sending_error(self) -> None: + """Test that email sending errors are handled gracefully.""" + form_data = { + "name": test_view_constants.TEST_NAME, + "email": test_view_constants.TEST_EMAIL, + "subject": test_view_constants.TEST_SUBJECT, + "message": test_view_constants.TEST_MESSAGE, + } + + # Mock EmailMessage.send to raise an exception + with mock.patch( + "contact.views.EmailMessage.send", side_effect=Exception(test_view_constants.MOCKED_ERROR_MESSAGE) + ): + response = self.client.post(f"/{self.language}/{self.request_path}", data=form_data) + + # Should still redirect (user shouldn't see the error) + self.assertRedirects(response, f"/{self.language}/{self.request_path}", status_code=302, target_status_code=200) + + # Check message was saved to database + self.assertEqual(ContactMessage.objects.count(), 1, "ContactMessage was not created in the database") + message = ContactMessage.objects.first() + + assert message is not None, "ContactMessage retrieved from database is None" + + # Check the basic fields are correct + self.assertEqual(message.name, test_view_constants.TEST_NAME) + self.assertEqual(message.email, test_view_constants.TEST_EMAIL) + self.assertEqual(message.subject, test_view_constants.TEST_SUBJECT) + self.assertEqual(message.message, test_view_constants.TEST_MESSAGE) + self.assertFalse(message.is_read) + + # Check that the error was saved + self.assertNotEqual( + message.error, + "", + "Error field should contain the exception details when email sending fails", + ) + self.assertIn( + test_view_constants.MOCKED_ERROR_MESSAGE, + message.error, + f"Error field should contain the exception message, found: '{message.error}'", + ) + self.assertIn( + "Traceback", + message.error, + "Error field should contain the full traceback for debugging", + ) + + # Check that no email was sent (since send() was mocked to fail) + self.assertEqual(len(mail.outbox), 0, "No email should have been sent when send() raises an exception") + + +class TestContactViewContentEnglish(BaseTestContactViewContent): + """Test contact view content in English.""" + + language = Language.ENGLISH + + +class TestContactViewContentSpanish(BaseTestContactViewContent): + """Test contact view content in Spanish.""" + + language = Language.SPANISH diff --git a/src/contact/tests/test_views/utils/__init__.py b/src/contact/tests/test_views/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/contact/tests/test_views/utils/constants.py b/src/contact/tests/test_views/utils/constants.py new file mode 100644 index 0000000..a4efb06 --- /dev/null +++ b/src/contact/tests/test_views/utils/constants.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from utils.test_utils.constants import Language + +# Contact page ids +CONTACT_CONTAINER_ID = "contact" + +CONTACT_TITLE_ID = "get-in-touch-title" + +CONTACT_RESPONSE_ALERTS_ID = "response-alerts" + +CONTACT_FORM_ID = "contact-form" +CONTACT_FORM_NAME_LABEL_ID = "contact-form-name-label" +CONTACT_FORM_EMAIL_LABEL_ID = "contact-form-email-label" +CONTACT_FORM_SUBJECT_LABEL_ID = "contact-form-subject-label" +CONTACT_FORM_MESSAGE_LABEL_ID = "contact-form-message-label" +CONTACT_FORM_SUBMIT_BUTTON_ID = "contact-form-submit-button" + +# Form field ids (Django auto-generates these as id_) +CONTACT_FORM_NAME_ID = "id_name" +CONTACT_FORM_EMAIL_ID = "id_email" +CONTACT_FORM_SUBJECT_ID = "id_subject" +CONTACT_FORM_MESSAGE_ID = "id_message" + +CONTACT_FORM_NAME_ERROR_ID = "contact-form-name-error" +CONTACT_FORM_EMAIL_ERROR_ID = "contact-form-email-error" +CONTACT_FORM_SUBJECT_ERROR_ID = "contact-form-subject-error" +CONTACT_FORM_MESSAGE_ERROR_ID = "contact-form-message-error" + + +CONTACT_ADDITIONAL_INFO_ID = "contact-additional-info" + +# Test data +TEST_NAME = "Test Name" +TEST_EMAIL = "test@example.com" +TEST_SUBJECT = "Test Subject" +TEST_MESSAGE = "This is a test message with enough characters to pass validation." +TEST_SHORT_MESSAGE = "Short" # Less than 10 characters +TEST_INVALID_EMAIL = "invalid-email" + +TO_EMAIL_ADDRESS = "contact@localhost" +FROM_EMAIL_ADDRESS = "noreply@localhost" + +EMAIL_BODY_TEMPLATE = "New contact message from {name}\n\nEmail: {email}\nSubject: {subject}\n\nMessage:\n{message}\n" + +# Mocked now datetime +MOCKED_NOW = datetime(2026, 1, 10, 12, 21, 43, 123456, tzinfo=timezone.utc) + +MOCKED_ERROR_MESSAGE = "Test error message" + +# Expected texts +CONTACT_PAGE_TITLE = { + Language.ENGLISH: "Get in Touch", + Language.SPANISH: "Contactar conmigo", +} + +CONTACT_PAGE_DESCRIPTION = { + Language.ENGLISH: ( + "Do you have a question or want to work together?" + " Feel free to send me a message and I will respond as soon as possible." + ), + Language.SPANISH: ( + "¿Tienes alguna pregunta o quieres trabajar conmigo? Envíame un mensaje y te responderé lo antes posible." + ), +} + +SUCCESS_MESSAGE = { + Language.ENGLISH: "Thank you for your message! I'll get back to you as soon as possible.", + Language.SPANISH: "¡Gracias por tu mensaje! Te responderé lo antes posible.", +} + +LABEL_NAME_TEXT = { + Language.ENGLISH: "Name", + Language.SPANISH: "Nombre", +} +LABEL_EMAIL_TEXT = { + Language.ENGLISH: "Email", + Language.SPANISH: "Correo electrónico", +} +LABEL_SUBJECT_TEXT = { + Language.ENGLISH: "Subject", + Language.SPANISH: "Asunto", +} +LABEL_MESSAGE_TEXT = { + Language.ENGLISH: "Message", + Language.SPANISH: "Mensaje", +} + +INPUT_NAME_PLACEHOLDER = { + Language.ENGLISH: "Your name", + Language.SPANISH: "Tu nombre", +} +INPUT_EMAIL_PLACEHOLDER = { + Language.ENGLISH: "your.email@example.com", + Language.SPANISH: "tu.email@ejemplo.com", +} +INPUT_SUBJECT_PLACEHOLDER = { + Language.ENGLISH: "Message subject", + Language.SPANISH: "Asunto del mensaje", +} +INPUT_MESSAGE_PLACEHOLDER = { + Language.ENGLISH: "Write your message here...", + Language.SPANISH: "Escribe aquí tu mensaje...", +} + +VALIDATION_ERROR_INVALID_EMAIL = { + Language.ENGLISH: "Enter a valid email address.", + Language.SPANISH: "Introduzca una dirección de correo electrónico válida.", +} +VALIDATION_ERROR_SHORT_MESSAGE = { + Language.ENGLISH: "Message must be at least 10 characters long.", + Language.SPANISH: "El mensaje debe tener al menos 10 caracteres.", +} + +SUBMIT_BUTTON_TEXT = { + Language.ENGLISH: "Send Message", + Language.SPANISH: "Enviar Mensaje", +} + +ADDITIONAL_INFO_TEXT = { + Language.ENGLISH: "Your information is safe and will only be used to respond to your inquiry.", + Language.SPANISH: "Tu información está segura y sólo se utilizará para responder a tu consulta.", +} + +# Metadata constants +META_TITLE = { + Language.ENGLISH: "Contact | Portfolio", + Language.SPANISH: "Contacto | Portfolio", +} + +META_DESCRIPTION = { + Language.ENGLISH: "Get in touch with me. Send me a message and I'll respond as soon as possible.", + Language.SPANISH: "Ponte en contacto conmigo. Envíame un mensaje y te responderé lo antes posible.", +} + +# Base keywords from template (portfolio, CV, biography, career) + page-specific keywords +META_KEYWORDS = { + Language.ENGLISH: "portfolio, CV, biography, career, contact, get in touch, message, email", + Language.SPANISH: "portfolio, CV, biografía, carrera, contacto, contactar, mensaje, correo electrónico, email", +} diff --git a/src/contact/urls.py b/src/contact/urls.py new file mode 100644 index 0000000..5ccb787 --- /dev/null +++ b/src/contact/urls.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from django.urls import path + +from .views import ContactView + +urlpatterns = [ + path("", ContactView.as_view(), name="contact"), +] diff --git a/src/contact/views.py b/src/contact/views.py new file mode 100644 index 0000000..210ced0 --- /dev/null +++ b/src/contact/views.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import json +import traceback +from typing import TYPE_CHECKING, Any, TypedDict + +from django.contrib import messages +from django.core.mail import EmailMessage +from django.shortcuts import redirect, render +from django.utils.safestring import mark_safe +from django.utils.translation import get_language, gettext +from django.views import View + +from core import settings +from utils.types import PageMetadata + +from .forms import ContactForm + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + + from contact.models import ContactMessage + + +class ContactViewContext(TypedDict): + """Context for the ContactView.""" + + page_metadata: PageMetadata + form: ContactForm + + +class ContactView(View): + """View to handle contact form submissions.""" + + def __get_page_metadata(self) -> PageMetadata: + """Get page metadata for SEO purposes. + + Returns: + A PageMetadata dictionary with the metadata for the contact page. + """ + + page_title = gettext("Contact") + " | Portfolio" + page_description = gettext("Get in touch with me. Send me a message and I'll respond as soon as possible.") + page_keywords = gettext("contact, get in touch, message, email") + + # JSON-LD structured data + json_ld: dict[str, Any] = { + "@context": { + "@vocab": "https://schema.org/", + "@language": get_language(), + }, + "@type": "ContactPage", + "name": page_title, + "description": page_description, + } + + return PageMetadata( + page_title=page_title, + page_description=page_description, + page_keywords=page_keywords, + json_ld=mark_safe(json.dumps(json_ld, ensure_ascii=False)), + ) + + def __get_view_context(self, form: ContactForm) -> ContactViewContext: + """Get context for the contact template. + + Args: + form: The contact form instance. + + Returns: + A ContactViewContext dictionary with the context data for the contact template. + """ + + return ContactViewContext( + form=form, + page_metadata=self.__get_page_metadata(), + ) + + def __send_email_notification(self, contact_message: ContactMessage) -> None: + """Send email notification about new contact message. + + It will also handle any exceptions during email sending, + saving the error details to the contact message and logging the error. + + Args: + contact_message: The ContactMessage instance containing the message details. + """ + try: + # Prepare email to site owner + subject = f"[Portfolio Contact] {contact_message.subject}" + message_body = ( + f"New contact message from {contact_message.name}\n\n" + f"Email: {contact_message.email}\n" + f"Subject: {contact_message.subject}\n\n" + f"Message:\n{contact_message.message}\n" + ) + + email = EmailMessage( + subject=subject, + body=message_body, + from_email=settings.DEFAULT_FROM_EMAIL, + to=(settings.CONTACT_EMAIL,), + reply_to=(contact_message.email,), + ) + email.send(fail_silently=False) + + except Exception as e: + # Save the error to the contact message for later review + contact_message.error = traceback.format_exc() + contact_message.save(update_fields=("error",)) + + # Log the error + # TODO: Replace with proper logging + print(f"Error sending email: {e}") + + def get(self, request: HttpRequest) -> HttpResponse: + """Deal with GET requests to the contact page. + + Display the contact form. + + Args: + request: The HTTP request object. + + Returns: + An HttpResponse with the rendered contact form. + """ + form = ContactForm() + return render(request, "contact.html", self.__get_view_context(form)) + + def post(self, request: HttpRequest) -> HttpResponse: + """Deal with POST requests to the contact page. + + Process the contact form submission. + + Args: + request: The HTTP request object. + + Returns: + An HttpResponse redirecting on success or rendering the form with errors. + """ + form = ContactForm(request.POST) + + if form.is_valid(): + contact_message = form.save() + + self.__send_email_notification(contact_message) + + messages.success( + request, + gettext("Thank you for your message! I'll get back to you as soon as possible."), + ) + return redirect("contact") + + return render(request, "contact.html", self.__get_view_context(form)) diff --git a/src/core/settings.py b/src/core/settings.py index 8de8571..2963f20 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -64,6 +64,7 @@ "base", "cookie_consent", "home", + "contact", ) MIGRATION_MODULES = { @@ -167,5 +168,26 @@ TEST_RUNNER = "utils.test_utils.custom_test_runner.CustomTestRunner" +COTTON_BASE_DIR = BASE_DIR / "core" + +# Email Configuration for the contact form + +if DEBUG: + # In development, print emails to console + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + DEFAULT_FROM_EMAIL = "noreply@localhost" + CONTACT_EMAIL = env("CONTACT_EMAIL", default="contact@localhost") +else: + # In production, use SMTP backend + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + EMAIL_HOST = env("EMAIL_HOST") + EMAIL_PORT = env.int("EMAIL_PORT", default=587) + EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=True) + EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", default=False) + EMAIL_HOST_USER = env("EMAIL_HOST_USER") + EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") + DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL") + CONTACT_EMAIL = env("CONTACT_EMAIL") + if DEBUG: CACHE_MIDDLEWARE_SECONDS = 0 diff --git a/src/core/sitemaps.py b/src/core/sitemaps.py index 5487bcd..c3b4ba6 100644 --- a/src/core/sitemaps.py +++ b/src/core/sitemaps.py @@ -24,6 +24,7 @@ def items(self) -> Iterable[str]: return ( "home", "my-career", + "contact", ) def location(self, item: str) -> str: diff --git a/src/core/templates/cotton/heading.html b/src/core/templates/cotton/heading.html new file mode 100644 index 0000000..4ee83ff --- /dev/null +++ b/src/core/templates/cotton/heading.html @@ -0,0 +1,15 @@ +
+
+ + + +

+ {{ title }} +

+
+
diff --git a/src/core/tests/test_seo_views.py b/src/core/tests/test_seo_views.py index 2e949b5..f1501be 100644 --- a/src/core/tests/test_seo_views.py +++ b/src/core/tests/test_seo_views.py @@ -8,6 +8,15 @@ from home.models import PersonalInfo +EXPECTED_SITEMAP_URLS = ( + "/en/", + "/es/", + "/en/my-career/", + "/es/my-career/", + "/en/contact/", + "/es/contact/", +) + class TestRobotsTxt(TestCase): def test_robots_txt_accessible(self) -> None: @@ -66,11 +75,12 @@ def test_sitemap_contains_expected_urls(self) -> None: locations = [loc.text for loc in root.findall(".//ns:loc", namespace)] # Check that we have entries for both languages - self.assertEqual(len(locations), 4, "Sitemap should contain 4 URLs") - self.assertIn("https://testserver/en/", locations) - self.assertIn("https://testserver/es/", locations) - self.assertIn("https://testserver/en/my-career/", locations) - self.assertIn("https://testserver/es/my-career/", locations) + self.assertEqual( + len(locations), len(EXPECTED_SITEMAP_URLS), f"Sitemap should contain {len(EXPECTED_SITEMAP_URLS)} URLs" + ) + for expected_url in EXPECTED_SITEMAP_URLS: + full_url = f"https://testserver{expected_url}" + self.assertIn(full_url, locations) def test_sitemap_structure(self) -> None: """Test that sitemap has correct structure.""" diff --git a/src/core/urls.py b/src/core/urls.py index d7f1fbc..71652dc 100644 --- a/src/core/urls.py +++ b/src/core/urls.py @@ -35,6 +35,7 @@ *i18n_patterns( path("admin/", admin.site.urls), path("", include("home.urls")), + path("contact/", include("contact.urls")), path("i18n/", include("django.conf.urls.i18n")), ), path("robots.txt", RobotsTxtView.as_view(), name="robots_txt"), diff --git a/src/home/templates/index.html b/src/home/templates/index.html index cf45e34..b7dff11 100644 --- a/src/home/templates/index.html +++ b/src/home/templates/index.html @@ -77,7 +77,17 @@ -