Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
- name: Run mypy
run: uv run mypy .
env:
DEBUG: "true"
SECRET_KEY: ""
DATABASE_URL: ""

Expand Down Expand Up @@ -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:"
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -91,6 +92,9 @@ This is a project to create a single personal portfolio page with a clear and si
DEBUG=true
SECRET_KEY=<your dev secret key>
DATABASE_URL=sqlite:///<path to db.sqlite3 file>

# Email Configuration (optional for development - emails print to console)
CONTACT_EMAIL=contact@localhost
```

8. Run migrations
Expand Down Expand Up @@ -148,6 +152,16 @@ This is a project to create a single personal portfolio page with a clear and si
POSTGRES_DB=<name of the postgres database>
POSTGRES_USER=<name of the postgres user>
POSTGRES_PASSWORD=<name of the postgres password for the given user>

# 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
Expand Down
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/base/templatetags/base_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class NavbarDataDict(TypedDict):
name=gettext_lazy("My Career"),
url="my-career",
),
LinksDict(
name=gettext_lazy("Contact"),
url="contact",
),
)
)

Expand Down
Empty file added src/contact/__init__.py
Empty file.
28 changes: 28 additions & 0 deletions src/contact/admin.py
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions src/contact/apps.py
Original file line number Diff line number Diff line change
@@ -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"
92 changes: 92 additions & 0 deletions src/contact/forms.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions src/contact/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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",),
},
),
)
Empty file.
21 changes: 21 additions & 0 deletions src/contact/models.py
Original file line number Diff line number Diff line change
@@ -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}"
Loading