From 535c445afd802137f9b5413e82f5d693ccd29211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Fern=C3=A1ndez=20Iglesias?= Date: Fri, 9 Jan 2026 23:37:15 +0100 Subject: [PATCH 1/4] Add contact form page --- README.md | 14 ++ changelog.md | 1 + deploy/docker-compose.yml | 8 + src/base/templatetags/base_tags.py | 4 + src/contact/__init__.py | 0 src/contact/admin.py | 28 ++++ src/contact/apps.py | 8 + src/contact/forms.py | 92 ++++++++++ src/contact/migrations/0001_initial.py | 29 ++++ src/contact/migrations/__init__.py | 0 src/contact/models.py | 21 +++ src/contact/templates/contact.html | 157 ++++++++++++++++++ src/contact/urls.py | 9 + src/contact/views.py | 154 +++++++++++++++++ src/core/settings.py | 22 +++ src/core/sitemaps.py | 1 + src/core/templates/cotton/heading.html | 15 ++ src/core/tests/test_seo_views.py | 20 ++- src/core/urls.py | 1 + src/home/templates/index.html | 12 +- src/home/templates/my-career.html | 39 +---- .../tests/test_views/test_my_career_view.py | 2 +- src/home/tests/test_views/utils/constants.py | 2 +- src/home/views.py | 10 +- src/locale/es/LC_MESSAGES/django.mo | Bin 2835 -> 4460 bytes src/locale/es/LC_MESSAGES/django.po | 108 ++++++++++-- src/utils/types.py | 15 ++ tailwind.config.js | 2 + 28 files changed, 716 insertions(+), 58 deletions(-) create mode 100644 src/contact/__init__.py create mode 100644 src/contact/admin.py create mode 100644 src/contact/apps.py create mode 100644 src/contact/forms.py create mode 100644 src/contact/migrations/0001_initial.py create mode 100644 src/contact/migrations/__init__.py create mode 100644 src/contact/models.py create mode 100644 src/contact/templates/contact.html create mode 100644 src/contact/urls.py create mode 100644 src/contact/views.py create mode 100644 src/core/templates/cotton/heading.html create mode 100644 src/utils/types.py 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..734e8a0 --- /dev/null +++ b/src/contact/templates/contact.html @@ -0,0 +1,157 @@ +{% 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/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 @@ -