Custom Email and Page Templates¶
This system allows you to override default email templates and page templates with your own custom designs while maintaining backward compatibility.
How It Works¶
The system checks for your custom templates first, then falls back to the default templates if yours don't exist. This means:
- No custom templates? Everything works exactly as before
- Custom templates? Your designs are used instead of the defaults
- Missing some custom templates? Only the ones you created are used, others fall back to defaults
Setup¶
1. Configure Your Settings¶
Add these optional settings to your settings.py:
# Custom template directories (optional - these are the defaults)
APPOINTMENT_CUSTOM_TEMPLATES_DIR = 'custom' # Directory for page templates
APPOINTMENT_CUSTOM_EMAILS_DIR = 'emails' # Directory for email templates
# Make sure your templates directory is configured
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'templates'), # Your templates directory
],
'APP_DIRS': True,
# ... rest of your template config
},
]
2. Create Your Directory Structure¶
your_project/
├── templates/
│ ├── custom/ # HTML Page templates (configurable via APPOINTMENT_CUSTOM_TEMPLATES_DIR)
│ │ ├── admin/ # (optional) staff pages can go here...
│ │ ├── booking/ # (optional) ...and client pages here
│ │ ├── appointments.html
│ │ ├── appointment_client_information.html
│ │ ├── verification_code.html
│ │ ├── thank_you_page.html
│ │ ├── rescheduling_thank_you.html
│ │ ├── password_form.html
│ │ ├── password_success.html
│ │ ├── password_error.html
│ │ ├── 304_already_submitted.html
│ │ ├── 403_forbidden.html
│ │ ├── 403_forbidden_rescheduling.html
│ │ ├── 404_not_found.html
│ │ ├── staff_index.html
│ │ ├── display_appointment.html
│ │ ├── manage_staff_member.html
│ │ ├── manage_staff_personal_info.html
│ │ ├── email_change_verification_code.html
│ │ ├── manage_service.html
│ │ ├── service_list.html
│ │ ├── staff_list.html
│ │ ├── user_profile.html
│ │ ├── manage_day_off.html
│ │ ├── manage_unavailability.html
│ │ ├── manage_working_hours.html
│ │ └── email_preview.html
│ └── emails/ # Email templates (configurable via APPOINTMENT_CUSTOM_EMAILS_DIR)
│ ├── thank_you.html
│ ├── password_reset.html
│ ├── verification.html
│ ├── reminder_email.html
│ ├── new_appointment_admin_notification.html
│ ├── reschedule.html
│ └── reschedule_admin.html
└── settings.py
You only create the files you actually want to override — everything else keeps using the defaults.
Folders. Since 3.13.0, a page is looked for in a subfolder first, then directly in custom/:
- staff pages (the
administration/defaults) incustom/admin/, - client pages (the
appointment/defaults) incustom/booking/, - error pages in the folder of the page that shows them:
custom/admin/404_not_found.htmlfor a staff page,custom/booking/404_not_found.htmlfor a booking link.
So custom/admin/staff_list.html wins over custom/staff_list.html, and a flat custom/ folder keeps working.
Names. A few pages have an older custom name that differs from their default template's name. Both names work now, and the default template's name wins when both files exist:
| Default template's name | Older name, still accepted |
|---|---|
default_thank_you.html |
thank_you_page.html |
set_password.html |
password_form.html |
enter_verification_code.html |
verification_code.html |
Your Base Template¶
The package pages don't draw your site's navbar or footer. They render inside your own base template, through a small package layout:
your page override or package page
└── extends appointment/layout.html (package, shared by every page, no site chrome)
└── extends APPOINTMENT_BASE_TEMPLATE / APPOINTMENT_ADMIN_BASE_TEMPLATE (your base)
APPOINTMENT_BASE_TEMPLATE = 'base.html' # booking, thank-you and error pages
APPOINTMENT_ADMIN_BASE_TEMPLATE = 'staff_base.html' # staff pages (optional, defaults to the same file)
Your base must define the customMetaTag (inside <head>), customCSS, title, description, body and
customJS blocks, and load jQuery and Bootstrap 5 (CSS and JS) before customJS. Put your navbar and footer around
{% block body %}. Without these settings, the package falls back to base_templates/base.html, a bare page with no
navbar.
The layout fills your body block with a <div class="djappt"> wrapper, and the pages put their content in its
djappt_content block. The package CSS only styles what's inside that wrapper: it never changes your navbar, footer or
other pages, and your own CSS keeps working around it.
Don't override appointment/layout.html: it holds what every package page needs. To change a page, override it as
shown below; to change the page shell, change your base. Your page overrides can extend appointment/layout.html too
(put the content in {% block djappt_content %} so it gets the wrapper), or extend BASE_TEMPLATE directly as before.
Colours¶
The pages use your site's font and Bootstrap 5 form fields, with the package's own colours (a navy accent by default). To match your brand, set the accent colour in your own CSS. Buttons, links, the selected day and slot, and the highlights all follow it (their hover and light shades are computed from it):
Every variable below can be set the same way, in a .djappt { ... } rule in your own CSS. The package declares them
with zero specificity, so your rule wins wherever your stylesheet is loaded.
| Variable | Default | Used for |
|---|---|---|
--djappt-accent |
#00307c |
Buttons, links, step numbers, selected day and time slot, small headings |
--djappt-accent-hover |
the accent, 18% darker | Button hover and the pressed state |
--djappt-accent-soft |
the accent at 10% on white | Light highlights: hovered day and slot, notices, field focus ring, icon backs |
--djappt-accent-contrast |
#fff |
Text on accent backgrounds (buttons, selected day and slot) |
--djappt-ink |
#0c1b33 |
Titles and main text |
--djappt-ink-soft |
#33415c |
Field labels, notice text, chips |
--djappt-muted |
#64748b |
Secondary text: descriptions, summary labels, weekday names, past days |
--djappt-line |
#e3e8ef |
Borders and separators |
--djappt-surface |
#fff |
Card, chip and time slot backgrounds (fields keep Bootstrap's) |
--djappt-danger |
#c53030 |
Error text (no availability, day off...) |
--djappt-danger-soft |
#fdecec |
Error message background |
--djappt-success |
#1d8055 |
Success icon on the thank-you pages |
--djappt-success-soft |
#e7f5ee |
Success icon background |
--djappt-radius |
12px |
Card corners |
--djappt-radius-sm |
8px |
Buttons, fields, time slots, notices |
--djappt-shadow |
a light two-layer shadow | Cards (none removes it) |
--djappt-accent-hover and --djappt-accent-soft follow --djappt-accent unless you set them yourself. The other
colours assume a light card background: on a dark site, set the surface, line, text and soft colours together, for
example as below. Form fields are Bootstrap's, so they follow your Bootstrap theme (data-bs-theme="dark", Bootstrap
5.3 or later).
.djappt {
--djappt-accent: #7cb3ff;
--djappt-accent-contrast: #0c1b33;
--djappt-accent-soft: #1f2d45;
--djappt-surface: #151d2b;
--djappt-line: #2a3547;
--djappt-ink: #f1f5f9;
--djappt-ink-soft: #cbd5e1;
--djappt-muted: #94a3b8;
--djappt-danger: #ff9b9b;
--djappt-danger-soft: #3a1d22;
--djappt-success: #6ee7b7;
--djappt-success-soft: #173328;
}
The package pages never set a page background: they sit on your site's own.
Available Templates¶
Page Templates (Custom Directory)¶
These are the HTML pages users see in their browser.
Every page below also receives the generic context: BASE_TEMPLATE, user, is_superuser, locale and
localized_formats. Only the page-specific variables are listed.
localized_formats is what the date and time pickers are configured from — it holds Django's TIME_INPUT_FORMATS,
DATE_INPUT_FORMATS and DATETIME_INPUT_FORMATS for the active locale, plus js_timepicker_display_format and
js_datepicker_display_format, the Moment.js equivalents. Keep using it in any template of yours that renders a
picker, or the widget will fall back to a format the server may not parse back.
Booking flow¶
| Template Name | When Used | Page-specific Context Variables | Original Template |
|---|---|---|---|
appointments.html |
Slot picker, for both booking and rescheduling | service, staff_member, all_staff_members, page_title, page_description, available_slots, date_chosen, locale, timezoneTxt, label — plus rescheduled_date, page_header, ar_id_request when rescheduling |
appointment/appointments.html |
appointment_client_information.html |
Client information form page | ar, APPOINTMENT_PAYMENT_URL, form, client_data_form, service_name, has_required_email_reminder_config (show form.<field>.errors and client_data_form.<field>.errors: the default page lists them under each field) |
appointment/appointment_client_information.html |
enter_verification_code.html (or verification_code.html) |
Email verification code entry page | appointment_request_id, id_request |
appointment/enter_verification_code.html |
default_thank_you.html (or thank_you_page.html) |
Thank you page after appointment booking | appointment |
appointment/default_thank_you.html |
rescheduling_thank_you.html |
Thank you page after rescheduling | (generic context only) | appointment/rescheduling_thank_you.html |
Password reset¶
| Template Name | When Used | Page-specific Context Variables | Original Template |
|---|---|---|---|
set_password.html (or password_form.html) |
Password reset form page | form, page_title, page_message, page_description |
appointment/set_password.html |
password_success.html |
After successful password reset | page_title, page_message, page_description |
appointment/thank_you.html |
password_error.html |
When password reset fails | page_title, page_message, page_description |
appointment/thank_you.html |
Error pages¶
| Template Name | When Used | Page-specific Context Variables | Original Template |
|---|---|---|---|
304_already_submitted.html |
Client re-submits an appointment request that was already sent | service_id |
error_pages/304_already_submitted.html |
403_forbidden.html |
User tries to act on something they don't own | message, back_url, BASE_TEMPLATE |
error_pages/403_forbidden.html |
403_forbidden_rescheduling.html |
Appointment can't be rescheduled (not allowed, or limit reached) | url |
error_pages/403_forbidden_rescheduling.html |
404_not_found.html |
Appointment, service, or reschedule link not found | error_message (reschedule links only) |
error_pages/404_not_found.html |
Administration¶
Since 3.13.0, every staff page also gets page_title, page_description and back_url (the page its Back link
goes to), so an override doesn't have to work them out. The table lists the other keys.
| Template Name | When Used | Page-specific Context Variables | Original Template |
|---|---|---|---|
staff_index.html |
Staff/admin calendar dashboard | appointments (JSON string), calendar_options |
administration/staff_index.html |
display_appointment.html |
Detail page for a single appointment | appointment, page_title, page_description |
administration/display_appointment.html |
manage_staff_member.html |
Add or edit a staff member's appointment settings | form |
administration/manage_staff_member.html |
manage_staff_personal_info.html |
Add or edit a staff member's personal information | form, btn_text |
administration/manage_staff_personal_info.html |
email_change_verification_code.html |
Verification code entry after an email change | (generic context only) | administration/email_change_verification_code.html |
manage_service.html |
Add, edit, or view a service | form, btn_text, mode (create, edit or view), service (not when adding), offered_by_me (view mode only) |
administration/manage_service.html |
service_list.html |
List of all services | services |
administration/service_list.html |
staff_list.html |
List of all staff members, shown to a superuser | staff_members, btn_staff_me, btn_staff_me_link, admin_is_staff |
administration/staff_list.html |
user_profile.html |
A staff member's profile page | superuser, profile_user (the staff member; user holds it too, as before), staff_member, days_off, unavailabilities, working_hours, services_offered, staff_member_not_found, buffer_time_help_text, slot_duration_help_text, service_msg |
administration/user_profile.html |
manage_day_off.html |
Add or edit a day off | button_text, day_off_form, form_action |
administration/manage_day_off.html |
manage_unavailability.html |
Add or edit an unavailability | button_text, unavailability_form, form_action, staff_user_id, entity_instance and entity_id (edit only) |
administration/manage_unavailability.html |
manage_working_hours.html |
Add or edit working hours | button_text, working_hours_form, form_action, staff_user_id, entity_instance and entity_id (edit only) |
administration/manage_working_hours.html |
Note:
user-profile/serves two different templates. A superuser visiting it without astaff_user_idgetsstaff_list.html; everyone else, and a superuser visiting a specific member, getsuser_profile.html. Override whichever you need — they are independent.The three
manage_*forms post toform_actionwith the model's field names and ISO values:start_date,end_dateanddescriptionfor a day off;date(YYYY-MM-DD),start_time,end_time(HH:MM) anddescriptionfor an unavailability;day_of_week(0for Sunday to6),start_timeandend_timefor working hours. The browser's<input type="date">and<input type="time">send exactly that. A wrong value comes back as JSON with status 400,{"errors": {"<field>": ["..."]}}, and the default script shows each message under its field. The olddate_raw,start_time_rawandend_time_rawfields are still read when a template sends them.Every delete and remove action (
delete_service,delete_appointment,delete_day_off,delete_unavailability,delete_working_hours,remove_staff_member) and the staff-me toggle (btn_staff_me_link) only accept POST; a GET returns405. Use a small<form method="post">with{% csrf_token %}, or the defaultmodal/confirm_modal.htmlwithjs/modal/show_modal.js, which submits one for you. Every staff page already has both (see Confirm dialogs).
staff_index.htmlbuilds the appointment modal's form in three functions the page defines,createCommonInputFields,generateModalContentandprepareCreateAppointmentModalContent, andjs/app_admin/staff_index.jscalls them. A replacement page must define them too, and keep the inputs'names, theserviceSelect/staffSelectdropdown ids and the element ids the script uses (calendar,event-list-container,customContextMenu,newAppointmentOption, and the modal'seventModalLabel,eventModalBodyand buttons). Copying the packaged template and changing its markup is the easiest start. The modal's Close button is found bydata-djappt="close", and a field's wrapper bydata-djappt="field"(the end time's wrapper is hidden while editing); the older.btn-secondary[data-bs-dismiss='modal']and.djappt-fieldstill work. See The calendar for its options.
Email Templates (Emails Directory)¶
These are HTML emails sent to users:
| Template Name | When Sent | Fallback Behavior | Context Variables |
|---|---|---|---|
thank_you.html |
Appointment confirmation | Uses default HTML template | first_name, message_1, current_year, company, more_details, account_details, message_2, month_year, day, activation_link, main_title, reschedule_link |
password_reset.html |
Staff member password setup | Uses default HTML template | first_name, current_year, company, activation_link, account_details, username, login_instruction, user, website_name |
verification.html |
Email address verification | Uses default HTML template | user, first_name, verification_code, company |
reminder_email.html |
24h appointment reminder (Django Q) | Uses default HTML template | first_name (empty in the admin copy), client_first_name (admin copy), appointment, reschedule_link, recipient_type ('client' or 'admin') |
new_appointment_admin_notification.html |
Admin/staff notified of a new booking | Uses default HTML template | recipient_name, client_name, appointment, is_staff_member, staff_member_name |
reschedule.html |
Reschedule confirmation request | Uses default HTML template | is_confirmation, first_name, old_date, reschedule_date, old_start_time, start_time, old_end_time, end_time, confirmation_link, company |
reschedule_admin.html |
Admin notification of reschedule | Uses default HTML template | is_confirmation, client_name, service_name, reason_for_rescheduling, old_date, reschedule_date, old_start_time, start_time, old_end_time, end_time, company |
Accepted aliases: for historical reasons the two reschedule emails are also looked up under their internal names, which take precedence if both files exist:
reschedule.html— also accepted asreschedule_confirmation_email.htmlreschedule_admin.html— also accepted asnotify_admin_about_reschedule_email.htmlUse the short names for new projects.
Plain-text part: every HTML email is also sent with a plain-text version. It is rendered from a
.txttemplate with the same name next to the HTML one when there is one (emails/thank_you.txtforemails/thank_you.html, with the same context), and derived from the HTML otherwise.Subject: a
.subject.txttemplate next to the HTML one replaces the subject set by the package. Its first line that isn't empty is used, with the same context:emails/thank_you.subject.txtcould holdYour booking at {{ company }} on {{ appointment_request.date|date }}.
Context shared by every email. Since 3.13.0, each email also gets the keys below, on top of the ones in the table (the table's keys win when the names are the same).
| Key | What it holds |
|---|---|
company |
The website name (Config model, or APPOINTMENT_WEBSITE_NAME) |
site_url |
The site's address without a trailing slash (https://www.example.com), taken from the request |
current_year |
The current year |
dashboard_url |
Full link to the staff calendar |
appointment_request, service, service_name, staff_member |
When the email is about an appointment |
reschedule_url |
Full link to reschedule it |
appointment, client, client_name, appointment_url |
When the appointment exists; appointment_url is the full link to its staff page |
The links start with the address of the request that triggered the email, like the set-password and reschedule
links always have. The reminder is sent later by Django Q, without a request: it uses the address of the booking
request, saved when the reminder was scheduled. Behind a proxy, set Django's USE_X_FORWARDED_HOST and
SECURE_PROXY_SSL_HEADER so the address is right.
Preview. With DEBUG = True, a superuser can open app-admin/email-preview/ to see every email the package sends,
built for the latest appointment: its subject, the template used (so you can check your override is picked), and its
HTML and text parts. The page returns 404 when DEBUG is off.
Building Staff Pages¶
These help when you override the staff pages. They are all new in 3.13.0.
Named blocks¶
Every staff page puts its Back link in {% block djappt_back %}, its title in {% block djappt_heading %} and its
buttons next to the title in {% block djappt_actions %}. To change only those, extend the default page instead of
copying it:
{% extends "administration/service_list.html" %}
{% block djappt_heading %}<h1>Our treatments</h1>{% endblock %}
{% block djappt_actions %}{% endblock %}
Save it as custom/admin/service_list.html (or custom/service_list.html).
Messages¶
Every page shows the Django messages through one file, appointment/_messages.html. Override that file
(templates/appointment/_messages.html) to restyle them everywhere. If your base template already shows the messages,
override it with an empty file and the package pages leave them to your base. In your own page overrides, include it
the same way: {% include "appointment/_messages.html" %} (options: wrapper_class, alert_class, and
dismissible=False to keep the messages on screen).
Confirm dialogs¶
Every staff page has one confirm dialog (modal/confirm_modal.html) and its script. A button opens it with data
attributes, and the dialog then POSTs to the URL you give:
<button type="button" class="btn btn-danger"
data-djappt-confirm="{% url 'appointment:delete_service' service.id %}"
data-confirm-title="{% translate 'Confirm Deletion' %}"
data-confirm-message="{% translate 'Are you sure you want to delete this service?' %}"
data-confirm-action="{% translate 'Delete' %}">{% translate 'Delete' %}</button>
The old onclick="showModal(...)" still works.
Your own context¶
To add your own keys to the package pages, use a Django
context processor.
It runs for every page, and request.resolver_match.url_name tells you which one ('get_service_list',
'user_profile'...).
The calendar¶
APPOINTMENT_CALENDAR_OPTIONS sets up the staff calendar (staff_index.html). The page prints it with
{{ calendar_options|json_script:"djappt-calendar-options" }}; keep that line in an override.
APPOINTMENT_CALENDAR_OPTIONS = {
'height': 'fill', # 'fill' (default), 'auto', a number of pixels, or any CSS height
'fillRatio': 1, # with 'fill': end the calendar at this share of the window height (default 0.95)
'bottomOffset': 80, # with 'fill': pixels to leave below it, for a footer
'minHeight': 500, # with 'fill': never shorter than this
'initialView': 'timeGridWeek', # any other key is passed to FullCalendar as is
'firstDay': 1,
}
Forms¶
APPOINTMENT_FORM_CLASSESswaps the Bootstrap classes the package puts on its form fields for yours. An empty string removes a class:- The service
durationis two inputs,duration_hoursandduration_minutes({{ form.duration }}draws both). A singledurationvalue inHH:MM:SS, as older templates send, still works. services_offeredis a list of checkboxes:{{ form.services_offered }}, or loop over it to draw your own.- The service image uses Django's
ClearableFileInput: the file input is namedimage, and the "clear" checkboximage-clear. A custom template that draws its own input must keep those names. - The price and down payment are never localized, so
<input type="number">always gets a dot (150.50).
Template Examples¶
The following are just examples. You can customize them as you see fit. Or you can create your own designs from scratch.
Client Information Form (templates/custom/appointment_client_information.html)¶
{% extends "your_base_template.html" %}
{% load i18n %}
{% block title %}Client Information{% endblock %}
{% block content %}
<div class="client-info-container">
<h1>Your Information</h1>
<div class="appointment-summary">
<h3>Appointment Summary</h3>
<p><strong>Service:</strong> {{ service_name }}</p>
<p><strong>Date:</strong> {{ ar.date }}</p>
<p><strong>Time:</strong> {{ ar.start_time }} - {{ ar.end_time }}</p>
</div>
<form method="post" class="client-form">
{% csrf_token %}
<div class="form-section">
<h3>Personal Information</h3>
{{ client_data_form.as_p }}
</div>
<div class="form-section">
<h3>Appointment Details</h3>
{{ form.as_p }}
</div>
{% if ar.service.is_a_paid_service %}
<div class="payment-section">
<h3>Payment Option</h3>
<input type="radio" name="payment_type" value="full" id="full_payment" checked>
<label for="full_payment">Full Payment ({{ ar.service.get_price_text }})</label>
{% if ar.service.accepts_down_payment %}
<input type="radio" name="payment_type" value="down" id="down_payment">
<label for="down_payment">Down Payment ({{ ar.service.get_down_payment_text }})</label>
{% endif %}
</div>
{% endif %}
<button type="submit" class="submit-btn">Continue</button>
</form>
</div>
{% endblock %}
Password Reset Form (templates/custom/password_form.html)¶
<!DOCTYPE html>
<html>
<head>
<title>{{ page_title }}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: Arial, sans-serif;
max-width: 500px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.form-group {
margin: 20px 0;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
button {
background: #007bff;
color: white;
padding: 12px 24px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
width: 100%;
}
button:hover {
background: #0056b3;
}
.alert {
padding: 12px;
margin: 15px 0;
border-radius: 4px;
border: 1px solid;
}
.alert-danger {
background: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.alert-success {
background: #d4edda;
color: #155724;
border-color: #c3e6cb;
}
</style>
</head>
<body>
<div class="container">
<h1>{{ page_title }}</h1>
<p>{{ page_description }}</p>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Set Password</button>
</form>
</div>
</body>
</html>
Password Reset Email (templates/emails/password_reset.html)¶
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Password Setup - {{ company }}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 8px 8px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to {{ company }}</h1>
</div>
<div style="background: white; padding: 30px; border-radius: 0 0 8px 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<p style="font-size: 18px; margin-bottom: 20px;">Hello {{ first_name }},</p>
<p>You've been added as a staff member to {{ company }}. To get started, you'll need to set up your
password.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ activation_link }}"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; padding: 15px 30px; text-decoration: none;
border-radius: 25px; display: inline-block; font-weight: bold;
font-size: 16px; box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);">
Set My Password
</a>
</div>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 25px 0; border-left: 4px solid #667eea;">
<h4 style="margin-top: 0; color: #667eea;">Your Login Details</h4>
<p style="margin: 5px 0;"><strong>Username:</strong> <code
style="background: #e9ecef; padding: 2px 6px; border-radius: 3px;">{{ username }}</code></p>
<p style="margin: 5px 0;"><strong>Email:</strong> {{ user.email }}</p>
<p style="margin-bottom: 0; font-size: 14px; color: #666;">You can use either your username or email to log
in</p>
</div>
<div style="border-top: 1px solid #eee; padding-top: 20px; margin-top: 30px;">
<p style="font-size: 14px; color: #666;">If the button doesn't work, copy and paste this link:</p>
<p style="word-break: break-all; background: #f8f9fa; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 12px;">
{{ activation_link }}
</p>
</div>
</div>
<div style="text-align: center; margin-top: 20px; color: #666; font-size: 14px;">
{{ company }} © {{ current_year }}
</div>
</div>
</body>
</html>
Email Verification (templates/emails/verification.html)¶
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Email Verification - {{ company }}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 8px 8px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 28px;">Email Verification</h1>
</div>
<div style="background: white; padding: 30px; border-radius: 0 0 8px 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<p style="font-size: 18px; margin-bottom: 20px;">Hello {{ first_name }},</p>
<p>Please use this verification code to confirm your email address:</p>
<div style="text-align: center; margin: 30px 0;">
<div style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: 3px solid #28a745; padding: 25px; border-radius: 15px;
display: inline-block; box-shadow: 0 4px 15px rgba(40, 167, 69, 0.2);">
<span style="font-size: 36px; font-weight: bold; color: #28a745;
letter-spacing: 8px; font-family: monospace;">{{ verification_code }}</span>
</div>
</div>
<div style="background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; color: #856404; font-size: 14px;">
<strong>⏰ This code will expire soon</strong> - please use it right away to verify your email address.
</p>
</div>
<p style="color: #666; font-size: 14px; margin-top: 30px;">
If you didn't request this verification, you can safely ignore this email.
</p>
</div>
<div style="text-align: center; margin-top: 20px; color: #666; font-size: 14px;">
{{ company }} © {{ current_year }}
</div>
</div>
</body>
</html>
Important Notes¶
- Template names are fixed - You must use the exact names listed above
- Directory names are configurable - Change
APPOINTMENT_CUSTOM_TEMPLATES_DIRandAPPOINTMENT_CUSTOM_EMAILS_DIRin yoursettings.pyif you prefer different folder names. - Partial implementation is fine - You can create only some templates; others will use defaults
- Context variables - Use the provided context variables in your templates
- Form requirements - Keep form field names and IDs intact for proper functionality
- Fallback behavior - A template that is missing, or that fails to compile (an unclosed
{% if %}, an unknown tag, a bad{% load %}), is skipped and the default is used instead. Compile failures are logged as a warning naming the template, so check your logs if an override seems to be ignored. Errors that only surface while the page is being rendered — a template filter raising on the data it is given, for example — cannot be caught this way and will still propagate.
Testing Your Templates¶
- Create your custom templates
- Trigger the relevant actions (booking, password reset, etc.), or, for emails, open the preview page
(
app-admin/email-preview/,DEBUGonly) - Check that your templates are being used
- Verify fallback works by temporarily renaming your template files
Your custom templates give you complete control over the look and feel while maintaining the system's functionality.