Skip to content

Commit

Permalink
Feature/email templates (#499)
Browse files Browse the repository at this point in the history
Added basic email templates implementation, whole email system need revamp though
  • Loading branch information
TreyWW committed Sep 28, 2024
1 parent baef6df commit 8356b61
Show file tree
Hide file tree
Showing 45 changed files with 694 additions and 157 deletions.
43 changes: 30 additions & 13 deletions backend/api/base/modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from backend.api.public.permissions import SCOPE_DESCRIPTIONS
from backend.api.public.models import APIAuthToken
from backend.models import Client, Receipt, User
from backend.models import Client, Receipt, User, InvoiceURL
from backend.models import Invoice
from backend.models import QuotaLimit
from backend.models import Organization
Expand Down Expand Up @@ -67,18 +67,18 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value
if invoice.client_to:
context["to_name"] = invoice.client_to.name
context["to_company"] = invoice.client_to.company
context["to_email"] = invoice.client_to.email
context["to_address"] = invoice.client_to.address
context["existing_client_id"] = invoice.client_to.id
# context["to_city"] = invoice.client_to.city
# context["to_county"] = invoice.client_to.county
# context["to_country"] = invoice.client_to.country
context["existing_client_id"] = (
invoice.client_to.id
) # context["to_city"] = invoice.client_to.city # context["to_county"] = invoice.client_to.county # context["to_country"] = invoice.client_to.country
else:
context["to_name"] = invoice.client_name
context["to_company"] = invoice.client_company
context["to_address"] = invoice.client_address
# context["to_city"] = invoice.client_city
# context["to_county"] = invoice.client_county
# context["to_country"] = invoice.client_country
context["to_email"] = invoice.client_email
context["to_address"] = (
invoice.client_address
) # context["to_city"] = invoice.client_city # context["to_county"] = invoice.client_county # context["to_country"] = invoice.client_country
elif context_type == "edit_invoice_from":
invoice = context_value
try:
Expand Down Expand Up @@ -134,8 +134,7 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value

# above_quota_usage = False # quota_usage_check_under(request, "invoices-schedules", api=True, htmx=True)

# if not isinstance(above_quota_usage, bool):
# context["above_quota_usage"] = True
# if not isinstance(above_quota_usage, bool): # context["above_quota_usage"] = True

else:
context[context_type] = context_value
Expand All @@ -147,8 +146,26 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value
context["content_min_length"] = 64
quota = QuotaLimit.objects.prefetch_related("quota_overrides").get(slug="emails-email_character_count")
context["content_max_length"] = quota.get_quota_limit(user=request.user, quota_limit=quota)
clients = Client.filter_by_owner(owner=request.actor).filter(email__isnull=False)
context["email_list"] = clients
context["email_list"] = Client.filter_by_owner(owner=request.actor).filter(email__isnull=False).values_list("email", flat=True)

if context_type == "invoice_code_send":
invoice_url: InvoiceURL | None = InvoiceURL.objects.filter(uuid=context_value).prefetch_related("invoice").first()

if not invoice_url or not invoice_url.invoice.has_access(request.user):
messages.error(request, "You don't have access to this invoice")
return render(request, "base/toast.html", {"autohide": False})

context["invoice"] = invoice_url.invoice
context["selected_clients"] = [
invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email
for value in [
invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email
]
if value is not None
]

context["email_list"] = list(context["email_list"]) + context["selected_clients"]

elif modal_name == "invoices_to_destination":
if existing_client := request.GET.get("client"):
context["existing_client_id"] = existing_client
Expand Down
59 changes: 45 additions & 14 deletions backend/api/emails/send.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from dataclasses import dataclass

from collections.abc import Iterator
from string import Template

from django.contrib import messages
from django.core.exceptions import ValidationError
Expand All @@ -14,6 +15,7 @@
from django.views.decorators.http import require_POST
from mypy_boto3_sesv2.type_defs import BulkEmailEntryResultTypeDef

from backend.data.default_email_templates import email_footer
from backend.decorators import feature_flag_check, web_require_scopes
from backend.decorators import htmx_only
from backend.models import Client
Expand All @@ -23,8 +25,9 @@
from backend.types.emails import (
BulkEmailEmailItem,
)
from backend.types.requests import WebRequest

from settings.helpers import send_email, send_templated_bulk_email
from settings.helpers import send_email, send_templated_bulk_email, get_var
from backend.types.htmx import HtmxHttpRequest


Expand All @@ -41,7 +44,7 @@ class Invalid:
@htmx_only("emails:dashboard")
@feature_flag_check("areUserEmailsAllowed", status=True, api=True, htmx=True)
@web_require_scopes("emails:send", False, False, "emails:dashboard")
def send_single_email_view(request: HtmxHttpRequest) -> HttpResponse:
def send_single_email_view(request: WebRequest) -> HttpResponse:
# check_usage = False # quota_usage_check_under(request, "emails-single-count", api=True, htmx=True)
# if not isinstance(check_usage, bool):
# return check_usage
Expand All @@ -53,7 +56,7 @@ def send_single_email_view(request: HtmxHttpRequest) -> HttpResponse:
@htmx_only("emails:dashboard")
@feature_flag_check("areUserEmailsAllowed", status=True, api=True, htmx=True)
@web_require_scopes("emails:send", False, False, "emails:dashboard")
def send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse:
def send_bulk_email_view(request: WebRequest) -> HttpResponse:
# email_count = len(request.POST.getlist("emails")) - 1

# check_usage = quota_usage_check_under(request, "emails-single-count", add=email_count, api=True, htmx=True)
Expand All @@ -62,10 +65,12 @@ def send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse:
return _send_bulk_email_view(request)


def _send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse:
def _send_bulk_email_view(request: WebRequest) -> HttpResponse:
emails: list[str] = request.POST.getlist("emails")
subject: str = request.POST.get("subject", "")
message: str = request.POST.get("content", "")
cc_yourself = True if request.POST.get("cc_yourself") else False
bcc_yourself = True if request.POST.get("bcc_yourself") else False

if request.user.logged_in_as_team:
clients = Client.objects.filter(organization=request.user.logged_in_as_team, email__in=emails)
Expand All @@ -78,23 +83,48 @@ def _send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse:
messages.error(request, validated_bulk)
return render(request, "base/toast.html")

message += email_footer()
message_single_line_html = message.replace("\r\n", "<br>").replace("\n", "<br>")

email_list: list[BulkEmailEmailItem] = []

for email in emails:
client = clients.get(email=email)
client = clients.filter(email=email).first()

email_data = {
"users_name": client.name.split()[0] if client else "User",
"first_name": client.name.split()[0] if client else "User",
"company_name": request.actor.name,
} # todo: add all variables from https://docs.myfinances.cloud/user-guide/emails/templates/

email_list.append(
BulkEmailEmailItem(
destination=email,
cc=[request.user.email] if cc_yourself else [],
bcc=[request.user.email] if bcc_yourself else [],
template_data={
"users_name": client.name.split()[0],
"content_text": message.format(users_name=client.name.split()[0]),
"content_html": message_single_line_html.format(users_name=client.name.split()[0]),
"users_name": client.name.split()[0] if client else "User",
"content_text": Template(message).substitute(email_data),
"content_html": Template(message_single_line_html).substitute(email_data),
},
)
)

if get_var("DEBUG", "").lower() == "true":
print(
{
"email_list": email_list,
"template_name": "user_send_client_email",
"default_template_data": {
"sender_name": request.user.first_name or request.user.email,
"sender_id": request.user.id,
"subject": subject,
},
}
)
messages.success(request, f"Successfully emailed {len(email_list)} people.")
return render(request, "base/toast.html")

EMAIL_SENT = send_templated_bulk_email(
email_list=email_list,
template_name="user_send_client_email",
Expand Down Expand Up @@ -160,7 +190,7 @@ def _send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse:
return render(request, "base/toast.html")


def _send_single_email_view(request: HtmxHttpRequest) -> HttpResponse:
def _send_single_email_view(request: WebRequest) -> HttpResponse:
email: str = str(request.POST.get("email", "")).strip()
subject: str = request.POST.get("subject", "")
message: str = request.POST.get("content", "")
Expand All @@ -176,8 +206,11 @@ def _send_single_email_view(request: HtmxHttpRequest) -> HttpResponse:
messages.error(request, validated_single)
return render(request, "base/toast.html")

message += email_footer()
message_single_line_html = message.replace("\r\n", "<br>").replace("\n", "<br>")

email_data = {"company_name": request.actor.name}

EMAIL_SENT = send_email(
destination=email,
subject=subject,
Expand All @@ -187,8 +220,8 @@ def _send_single_email_view(request: HtmxHttpRequest) -> HttpResponse:
"subject": subject,
"sender_name": request.user.first_name or request.user.email,
"sender_id": request.user.id,
"content_text": message,
"content_html": message_single_line_html,
"content_text": Template(message).substitute(email_data),
"content_html": Template(message_single_line_html).substitute(email_data),
},
},
)
Expand Down Expand Up @@ -222,7 +255,7 @@ def validate_bulk_inputs(*, request, emails, clients, message, subject) -> str |
def run_validations():
yield validate_bulk_quotas(request=request, emails=emails)
yield validate_email_list(emails=emails)
yield validate_client_list(clients=clients, emails=emails)
# yield validate_client_list(clients=clients, emails=emails)
yield validate_email_content(message=message, request=request)
yield validate_email_subject(subject=subject)

Expand Down Expand Up @@ -306,8 +339,6 @@ def validate_email_list(emails: list[str]) -> str | None:
def validate_client_list(clients: QuerySet[Client], emails: list[str]) -> str | None:
for email in emails:
if not clients.filter(email=email).exists():
# if not client.email_verified:
# return f"Client {email} isn't yet verified so we can't send them an email yet!"
return f"Could not find client object for {email}"
return None

Expand Down
2 changes: 1 addition & 1 deletion backend/api/invoices/create/set_destination.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from backend.models import Client
from backend.types.htmx import HtmxHttpRequest

to_get = ["name", "address", "city", "country", "company", "is_representative"]
to_get = ["name", "address", "city", "country", "company", "is_representative", "email"]


@require_http_methods(["POST"])
Expand Down
1 change: 1 addition & 0 deletions backend/api/invoices/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def edit_invoice(request: HtmxHttpRequest):
"date_issued": request.POST.get("date_issued"),
"client_name": request.POST.get("to_name"),
"client_company": request.POST.get("to_company"),
"client_email": request.POST.get("to_email"),
"client_address": request.POST.get("to_address"),
"client_city": request.POST.get("to_city"),
"client_county": request.POST.get("to_county"),
Expand Down
1 change: 1 addition & 0 deletions backend/api/invoices/recurring/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def edit_invoice_recurring_profile_endpoint(request: WebRequest, invoice_profile
"date_issued": request.POST.get("date_issued"),
"client_name": request.POST.get("to_name"),
"client_company": request.POST.get("to_company"),
"client_email": request.POST.get("to_email"),
"client_address": request.POST.get("to_address"),
"client_city": request.POST.get("to_city"),
"client_county": request.POST.get("to_county"),
Expand Down
12 changes: 6 additions & 6 deletions backend/api/invoices/recurring/generate_next_invoice_now.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from backend.decorators import web_require_scopes, htmx_only
from backend.models import InvoiceRecurringProfile, Invoice
from backend.service.defaults.get import get_account_defaults
from backend.service.invoices.recurring.generation.next_invoice import generate_next_invoice_service
from backend.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service
from backend.types.requests import WebRequest

import logging
Expand All @@ -24,7 +24,7 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id):

if not invoice_recurring_profile:
messages.error(request, "Failed to fetch next invoice; cannot find Invoice recurring profile.")
return render(request, "base/toast.html")
return render(request, "base/toast.html", {"autohide": False})

if invoice_recurring_profile.client_to:
account_defaults = get_account_defaults(invoice_recurring_profile.owner, invoice_recurring_profile.client_to)
Expand All @@ -33,11 +33,11 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id):

if not invoice_recurring_profile.has_access(request.user):
messages.error(request, "You do not have permission to modify this invoice recurring profile.")
return render(request, "base/toast.html")
return render(request, "base/toast.html", {"autohide": False})

next_invoice_issue_date = invoice_recurring_profile.next_invoice_issue_date()

svc_resp = generate_next_invoice_service(
svc_resp = safe_generate_next_invoice_service(
invoice_recurring_profile=invoice_recurring_profile, issue_date=next_invoice_issue_date, account_defaults=account_defaults
)

Expand All @@ -58,5 +58,5 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id):
)
else:
logger.info(svc_resp.error)
messages.error(request, "Failed to fetch next invoice; cannot find invoice recurring profile.")
return render(request, "base/toast.html")
messages.error(request, f"Failed to fetch next invoice; {svc_resp.error}")
return render(request, "base/toast.html", {"autohide": False})
Empty file.
1 change: 1 addition & 0 deletions backend/api/public/endpoints/Invoices/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def edit_invoice_endpoint(request: APIRequest):
"date_issued": request.POST.get("date_issued"),
"client_name": request.POST.get("to_name"),
"client_company": request.POST.get("to_company"),
"client_email": request.POST.get("to_email"),
"client_address": request.POST.get("to_address"),
"client_city": request.POST.get("to_city"),
"client_county": request.POST.get("to_county"),
Expand Down
5 changes: 5 additions & 0 deletions backend/api/public/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"team_permissions:write",
"team:invite",
"team:kick",
"email_templates:read",
"email_templates:write",
}

SCOPES_TREE = {
Expand All @@ -36,6 +38,8 @@
"team_permissions:write": {"team_permissions:read", "team_permissions:write"},
"team:invite": {"team:invite"},
"team:kick": {"team:kick", "team:invite"},
"email_templates:read": {"email_templates:read"},
"email_templates:write": {"email_templates:read", "email_templates:write"},
}

SCOPE_DESCRIPTIONS = {
Expand All @@ -45,6 +49,7 @@
"api_keys": {"description": "Access API keys", "options": {"read": "Read only", "write": "Read and write"}},
"team_permissions": {"description": "Access team permissions", "options": {"read": "Read only", "write": "Read and write"}},
"team": {"description": "Invite team members", "options": {"invite": "Invite members"}},
"email_templates": {"description": "Access email templates", "options": {"read": "Read only", "write": "Read and write"}},
}

if settings.BILLING_ENABLED:
Expand Down
9 changes: 9 additions & 0 deletions backend/api/settings/email_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.views.decorators.http import require_GET

from backend.decorators import web_require_scopes
from backend.types.requests import WebRequest


@require_GET
@web_require_scopes("email_templates:read")
def get_current_email_template(request: WebRequest): ...
Loading

0 comments on commit 8356b61

Please sign in to comment.