initiating django package from a work in progress lib
This commit is contained in:
13
README.md
13
README.md
@ -1,3 +1,14 @@
|
|||||||
# django-courriel
|
# django-courriel
|
||||||
|
|
||||||
A django app allowing to generate email messages frommarkdown templates
|
A django app allowing to generate email messages from markdown templates
|
||||||
|
|
||||||
|
|
||||||
|
## installation and tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
poetry env use python@3.12
|
||||||
|
poetry install --with test
|
||||||
|
# run tests with
|
||||||
|
poetry run pytest
|
||||||
|
poetry run pytest --cov=django_courriel # with coverage report
|
||||||
|
```
|
||||||
|
|||||||
0
django_courriel/__init__.py
Normal file
0
django_courriel/__init__.py
Normal file
7
django_courriel/apps.py
Normal file
7
django_courriel/apps.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CourrielConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "django_courriel"
|
||||||
|
verbose_name = "Courriel"
|
||||||
332
django_courriel/md.py
Normal file
332
django_courriel/md.py
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
"""
|
||||||
|
markdown mail template parsing
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import warnings
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import frontmatter
|
||||||
|
import markdown
|
||||||
|
from django.apps import apps
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template import Context, Template
|
||||||
|
from django.template.base import Variable, VariableNode
|
||||||
|
from django.template.loader import get_template, render_to_string
|
||||||
|
|
||||||
|
from .validation import (
|
||||||
|
CourrielError,
|
||||||
|
InvalidAddressError,
|
||||||
|
InvalidFrontMatter,
|
||||||
|
InvalidGabarit,
|
||||||
|
InvalidRecipientsError,
|
||||||
|
InvalidSenderError,
|
||||||
|
MarkdownEmailError,
|
||||||
|
UnresolvedVariableError,
|
||||||
|
ValidAddress,
|
||||||
|
ValidAddressList,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Courriel(EmailMultiAlternatives):
|
||||||
|
"""
|
||||||
|
django EmailMultiAlternatives with a french touch.
|
||||||
|
shall not accept invalid addresses or from_email.
|
||||||
|
And no empty subjects nor body, we're not barbarians.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kw):
|
||||||
|
super().__setattr__('_from_email', None)
|
||||||
|
self._to = ValidAddressList()
|
||||||
|
self._cc = ValidAddressList()
|
||||||
|
self._bcc = ValidAddressList()
|
||||||
|
self._reply_to = ValidAddressList()
|
||||||
|
super().__init__(*args, **kw)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def from_email(self):
|
||||||
|
return self._from_email
|
||||||
|
|
||||||
|
@from_email.setter
|
||||||
|
def from_email(self, value):
|
||||||
|
self._from_email = ValidAddress.factory(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownTemplate:
|
||||||
|
"""
|
||||||
|
Modèle de mail Markdown avec Front Matter YAML.
|
||||||
|
S'initialise avec le nom d'un gabarit sans contexte en validant sa structure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, template_name: str):
|
||||||
|
# find template file
|
||||||
|
# parse markdown, separate metadata from body
|
||||||
|
# validate minimal requirements
|
||||||
|
# validate and import hard coded addresses
|
||||||
|
# resolve, validate and import group addresses
|
||||||
|
self.template_name = template_name
|
||||||
|
self.raw_content = self.load_raw_template(template_name)
|
||||||
|
self.metadata, self.md_body = self.parse_template(self.raw_content)
|
||||||
|
self.validate()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_raw_template(cls, template_name: str) -> str:
|
||||||
|
"""Charge le code source brut du template sans l'évaluer."""
|
||||||
|
template_obj = get_template(template_name)
|
||||||
|
raw_content = getattr(getattr(template_obj, "template", template_obj), "source", "")
|
||||||
|
if not raw_content and hasattr(template_obj, "render"):
|
||||||
|
raw_content = template_obj.render({})
|
||||||
|
if not raw_content:
|
||||||
|
raise MarkdownEmailError(f"Le gabarit '{template_name}' est introuvable ou vide.")
|
||||||
|
return raw_content
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse_template(cls, raw_content: str) -> tuple[dict[str, Any], str]:
|
||||||
|
"""Extrait le Front Matter YAML et le corps Markdown."""
|
||||||
|
try:
|
||||||
|
post = frontmatter.loads(raw_content)
|
||||||
|
return post.metadata or {}, post.content or ""
|
||||||
|
except Exception as err:
|
||||||
|
raise InvalidFrontMatter(f"Failed to parse Front Matter YAML : {err}") from err
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""Vérifie la validité structurelle intrinsèque du modèle (métadonnées existantes)."""
|
||||||
|
if not self.metadata:
|
||||||
|
raise InvalidFrontMatter(
|
||||||
|
f"Le gabarit '{self.template_name}' ne contient pas de Front Matter YAML valide (délimité par ---)."
|
||||||
|
)
|
||||||
|
elif 'subject' not in self.metadata:
|
||||||
|
raise InvalidFrontMatter(f"Missing a 'subject' key in {self.template_name} front matter")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def extract_template_variable_names(cls, template_obj: Template) -> set[str]:
|
||||||
|
"""Extrait les noms de variables de premier niveau via l'API native des nœuds de template Django."""
|
||||||
|
variables = set()
|
||||||
|
for node in template_obj.nodelist.get_nodes_by_type(VariableNode):
|
||||||
|
var_obj = node.filter_expression.var
|
||||||
|
if isinstance(var_obj, Variable) and var_obj.literal is None and var_obj.var:
|
||||||
|
root_var = str(var_obj.var).split(".")[0]
|
||||||
|
variables.add(root_var)
|
||||||
|
return variables
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render_template_scalar(
|
||||||
|
cls,
|
||||||
|
template_str: str,
|
||||||
|
context: dict[str, Any],
|
||||||
|
field_name: str,
|
||||||
|
required: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Rend une chaîne scalaire avec le contexte en gérant les variables manquantes."""
|
||||||
|
if not isinstance(template_str, str):
|
||||||
|
return template_str
|
||||||
|
|
||||||
|
template = Template(template_str)
|
||||||
|
vars_used = cls.extract_template_variable_names(template)
|
||||||
|
unresolved = [v for v in vars_used if v not in context or context[v] is None]
|
||||||
|
|
||||||
|
if unresolved:
|
||||||
|
if required:
|
||||||
|
raise UnresolvedVariableError(
|
||||||
|
f"Variable requise manquante ou non résolue pour '{field_name}': {', '.join(unresolved)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
warnings.warn(
|
||||||
|
f"Variable optionnelle non résolue pour '{field_name}': {', '.join(unresolved)}",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = template.render(Context(context)).strip()
|
||||||
|
|
||||||
|
if required and not result:
|
||||||
|
raise UnresolvedVariableError(f"Le champ requis '{field_name}' ne peut être vide.")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def resolve_group_emails(cls, group_name: str, required: bool = False) -> list[str]:
|
||||||
|
"""Résout les adresses e-mail des utilisateurs actifs appartenant à un groupe Django auth."""
|
||||||
|
Group = apps.get_model("auth", "Group")
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
try:
|
||||||
|
group = Group.objects.get(name=group_name)
|
||||||
|
except Group.DoesNotExist:
|
||||||
|
if required:
|
||||||
|
raise UnresolvedVariableError(f"Le groupe Django auth '{group_name}' n'existe pas.")
|
||||||
|
warnings.warn(
|
||||||
|
f"Le groupe Django auth optionnel '{group_name}' n'existe pas.",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
emails = list(
|
||||||
|
User.objects.filter(groups=group, is_active=True)
|
||||||
|
.exclude(email="")
|
||||||
|
.exclude(email__isnull=True)
|
||||||
|
.values_list("email", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if required and not emails:
|
||||||
|
raise UnresolvedVariableError(
|
||||||
|
f"Le groupe requis '{group_name}' ne contient aucun utilisateur actif avec une adresse e-mail."
|
||||||
|
)
|
||||||
|
|
||||||
|
return emails
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize_recipient_list(
|
||||||
|
cls,
|
||||||
|
raw_val: str | list[str] | None,
|
||||||
|
context: dict[str, Any],
|
||||||
|
field_name: str,
|
||||||
|
required: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Normalise un champ de destinataires (chaîne ou liste) en liste d'adresses résolues."""
|
||||||
|
if not raw_val:
|
||||||
|
if required:
|
||||||
|
raise UnresolvedVariableError(f"Le champ '{field_name}' est obligatoire et vide.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
items = [raw_val] if isinstance(raw_val, str) else list(raw_val)
|
||||||
|
resolved_list = []
|
||||||
|
for item in items:
|
||||||
|
rendered = cls.render_template_scalar(item, context, field_name=field_name, required=required)
|
||||||
|
if rendered:
|
||||||
|
resolved_list.append(rendered)
|
||||||
|
|
||||||
|
if required and not resolved_list:
|
||||||
|
raise UnresolvedVariableError(f"Aucune adresse valide résolue pour '{field_name}'.")
|
||||||
|
|
||||||
|
return resolved_list
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def resolve_groups_recipients(
|
||||||
|
cls,
|
||||||
|
raw_groups: str | list[str] | None,
|
||||||
|
field_name: str,
|
||||||
|
required: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Résout une liste de groupes d'utilisateurs en adresses e-mail uniques."""
|
||||||
|
if not raw_groups:
|
||||||
|
return []
|
||||||
|
|
||||||
|
groups = [raw_groups] if isinstance(raw_groups, str) else list(raw_groups)
|
||||||
|
resolved_emails = []
|
||||||
|
for grp in groups:
|
||||||
|
emails = cls.resolve_group_emails(grp, required=required)
|
||||||
|
resolved_emails.extend(emails)
|
||||||
|
|
||||||
|
return list(dict.fromkeys(resolved_emails))
|
||||||
|
|
||||||
|
def build_message(
|
||||||
|
self,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
subject: str | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
from_email: str | None = None,
|
||||||
|
to: list[str] | None = None,
|
||||||
|
bcc: list[str] | None = None,
|
||||||
|
connection: Any = None,
|
||||||
|
attachments: Any = None,
|
||||||
|
headers: dict[str, Any] | None = None,
|
||||||
|
cc: list[str] | None = None,
|
||||||
|
reply_to: list[str] | None = None,
|
||||||
|
format: str | None = None,
|
||||||
|
) -> Courriel:
|
||||||
|
"""Génère et retourne une instance de Courriel prête à l'envoi pour le contexte donné."""
|
||||||
|
ctx = context.copy() if context else {}
|
||||||
|
|
||||||
|
# 1. Sujet
|
||||||
|
rendered_subject = subject or self.render_template_scalar(
|
||||||
|
self.metadata.get("subject", ""), ctx, field_name="subject", required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Destinataires directs et groupes
|
||||||
|
direct_to = to or self.normalize_recipient_list(
|
||||||
|
self.metadata.get("to"), ctx, field_name="to", required=False
|
||||||
|
)
|
||||||
|
group_to = self.resolve_groups_recipients(
|
||||||
|
self.metadata.get("to_groups"),
|
||||||
|
field_name="to_groups",
|
||||||
|
required=bool(not direct_to),
|
||||||
|
)
|
||||||
|
final_to = list(dict.fromkeys(direct_to + group_to))
|
||||||
|
if not final_to:
|
||||||
|
raise UnresolvedVariableError("Aucun destinataire 'to' spécifié ou résolu.")
|
||||||
|
|
||||||
|
direct_cc = cc or self.normalize_recipient_list(self.metadata.get("cc"), ctx, field_name="cc", required=False)
|
||||||
|
group_cc = self.resolve_groups_recipients(self.metadata.get("cc_groups"), field_name="cc_groups", required=False)
|
||||||
|
final_cc = list(dict.fromkeys(direct_cc + group_cc))
|
||||||
|
|
||||||
|
direct_bcc = bcc or self.normalize_recipient_list(self.metadata.get("bcc"), ctx, field_name="bcc", required=False)
|
||||||
|
group_bcc = self.resolve_groups_recipients(self.metadata.get("bcc_groups"), field_name="bcc_groups", required=False)
|
||||||
|
final_bcc = list(dict.fromkeys(direct_bcc + group_bcc))
|
||||||
|
|
||||||
|
final_reply_to = reply_to or self.normalize_recipient_list(
|
||||||
|
self.metadata.get("reply_to"), ctx, field_name="reply_to", required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
final_from_email = from_email or self.render_template_scalar(
|
||||||
|
self.metadata.get("from_email", ""), ctx, field_name="from_email", required=False
|
||||||
|
) or None
|
||||||
|
|
||||||
|
# 3. Format de message (both, html, text)
|
||||||
|
mail_format = format or self.metadata.get("format", "both")
|
||||||
|
|
||||||
|
# 4. Évaluation du corps Markdown avec le contexte
|
||||||
|
rendered_markdown_body = body or Template(self.md_body).render(Context(ctx)).strip()
|
||||||
|
|
||||||
|
# 5. Rendu HTML Markdown
|
||||||
|
extensions = getattr(settings, "MARKDOWN_EMAIL_EXTENSIONS", ["extra", "nl2br"])
|
||||||
|
html_body = markdown.markdown(rendered_markdown_body, extensions=extensions)
|
||||||
|
|
||||||
|
# 6. Layout HTML optionnel
|
||||||
|
layout_template = self.metadata.get(
|
||||||
|
"layout",
|
||||||
|
getattr(settings, "MARKDOWN_EMAIL_DEFAULT_LAYOUT", "courriel/base.dj.html"),
|
||||||
|
)
|
||||||
|
if layout_template and layout_template != "none":
|
||||||
|
html_body = render_to_string(layout_template, {"content_html": html_body, **ctx})
|
||||||
|
|
||||||
|
# 7. Inlining CSS avec premailer si disponible
|
||||||
|
try:
|
||||||
|
from premailer import transform
|
||||||
|
html_body = transform(html_body)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 8. Instanciation de Courriel
|
||||||
|
if mail_format == "text":
|
||||||
|
main_body = rendered_markdown_body
|
||||||
|
alternatives = []
|
||||||
|
elif mail_format == "html":
|
||||||
|
main_body = html_body
|
||||||
|
alternatives = []
|
||||||
|
else: # "both"
|
||||||
|
main_body = rendered_markdown_body
|
||||||
|
alternatives = [(html_body, "text/html")]
|
||||||
|
|
||||||
|
message = Courriel(
|
||||||
|
subject=rendered_subject,
|
||||||
|
body=main_body,
|
||||||
|
from_email=final_from_email,
|
||||||
|
to=final_to,
|
||||||
|
bcc=final_bcc,
|
||||||
|
connection=connection,
|
||||||
|
attachments=attachments,
|
||||||
|
headers=headers,
|
||||||
|
cc=final_cc,
|
||||||
|
reply_to=final_reply_to if final_reply_to else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
for content, mimetype in alternatives:
|
||||||
|
message.attach_alternative(content, mimetype)
|
||||||
|
|
||||||
|
return message
|
||||||
11
django_courriel/templates/courriel/base.dj.html
Normal file
11
django_courriel/templates/courriel/base.dj.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>{% block style %}{% endblock %}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block body %}{{ content_html|safe }}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
107
django_courriel/validation.py
Normal file
107
django_courriel/validation.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
from email import errors as email_errors
|
||||||
|
from email import headerregistry as email_reg
|
||||||
|
from email import utils as email_utils
|
||||||
|
|
||||||
|
from django.core import exceptions, validators
|
||||||
|
|
||||||
|
|
||||||
|
class CourrielError(Exception):
|
||||||
|
""" Something went wrong and you'll have to take a guess """
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidAddressError(exceptions.ValidationError, CourrielError):
|
||||||
|
""" Failed to parse an address definition """
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRecipientsError(CourrielError):
|
||||||
|
""" Can't be send to recipients """
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidSenderError(CourrielError):
|
||||||
|
""" Missing or invalid sender """
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidGabarit(CourrielError):
|
||||||
|
""" Something is wrong with the template """
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidFrontMatter(InvalidGabarit):
|
||||||
|
""" Failed to parse template front matter """
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownEmailError(InvalidGabarit):
|
||||||
|
""" Error in markdown email or template loading """
|
||||||
|
|
||||||
|
|
||||||
|
class UnresolvedVariableError(CourrielError):
|
||||||
|
""" A template variable could not be resolved """
|
||||||
|
|
||||||
|
|
||||||
|
class ValidAddress(email_reg.Address):
|
||||||
|
""" extends email.headerregistry.Address so it can be used where django expect an email address.
|
||||||
|
Except it should never be invalid """
|
||||||
|
|
||||||
|
def __new__(cls, display_name='', username='', domain='', addr_spec=None):
|
||||||
|
""" Builds a ValidAddress from either an Address or a string, or raise InvalidAddressError """
|
||||||
|
try:
|
||||||
|
spec = addr_spec or f"{username}@{domain}"
|
||||||
|
cls._validate_spec(spec)
|
||||||
|
instance = super().__new__(cls)
|
||||||
|
return instance
|
||||||
|
except email_errors.InvalidHeaderDefect as e:
|
||||||
|
raise InvalidAddressError(f"{spec}") from e
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_spec(cls, spec):
|
||||||
|
try:
|
||||||
|
validators.validate_email(spec)
|
||||||
|
except exceptions.ValidationError as e:
|
||||||
|
raise InvalidAddressError(f"{spec}") from e
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def factory(cls, input) -> 'ValidAddress':
|
||||||
|
""" ensure Address instance from str, None or Address """
|
||||||
|
if isinstance(input, ValidAddress):
|
||||||
|
return input
|
||||||
|
if isinstance(input, email_reg.Address): # converting to subclass
|
||||||
|
return ValidAddress(display_name=input.display_name, username=input.username, domain=input.domain)
|
||||||
|
if input is None:
|
||||||
|
return ValidAddress(display_name='')
|
||||||
|
if isinstance(input, str):
|
||||||
|
try:
|
||||||
|
name, spec = email_utils.parseaddr(input)
|
||||||
|
return cls(name, addr_spec=spec)
|
||||||
|
except email_errors.InvalidHeaderDefect:
|
||||||
|
# parsing failed and may have split username, let's keep full input as display name
|
||||||
|
return cls(display_name=input)
|
||||||
|
raise TypeError(f"Cannot create ValidAddress from {type(input).__name__}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def email(self):
|
||||||
|
""" shorthand for addr_spec"""
|
||||||
|
return self.addr_spec
|
||||||
|
|
||||||
|
|
||||||
|
class ValidAddressList(list):
|
||||||
|
""" only accepts valid addresses """
|
||||||
|
|
||||||
|
def _validate(self, value):
|
||||||
|
""" converts to ValidAddress, may raise InvalidAddress """
|
||||||
|
return ValidAddress.factory(value)
|
||||||
|
|
||||||
|
def __init__(self, iterable=()):
|
||||||
|
validated = [self._validate(i) for i in iterable]
|
||||||
|
super().__init__(validated)
|
||||||
|
|
||||||
|
def append(self, item):
|
||||||
|
super().append(self._validate(item))
|
||||||
|
|
||||||
|
def insert(self, index, item):
|
||||||
|
super().insert(index, self._validate(item))
|
||||||
|
|
||||||
|
def extend(self, items):
|
||||||
|
for item in items:
|
||||||
|
super().append(self._validate(item))
|
||||||
|
|
||||||
|
def __setitem__(self, index, item):
|
||||||
|
super().__setitem__(index, self._validate(item))
|
||||||
1049
poetry.lock
generated
Normal file
1049
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
pyproject.toml
Normal file
33
pyproject.toml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
[project]
|
||||||
|
name = "django-courriel"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A django app for generating email from markdown templates including frofrontmatter data"
|
||||||
|
authors = [
|
||||||
|
{name = "Jérémie Ducastel",email = "jeremie@ducastel.work"}
|
||||||
|
]
|
||||||
|
license = "EUPL"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"python-frontmatter (>=1.3.0,<2.0.0)",
|
||||||
|
"premailer (>=3.10.0,<4.0.0)",
|
||||||
|
"django (>=4.2,<5.0)",
|
||||||
|
"markdown (>=3.11,<4.0)"
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
test = [
|
||||||
|
"pytest",
|
||||||
|
"pytest-django",
|
||||||
|
"pytest-cov"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
DJANGO_SETTINGS_MODULE = "tests.settings"
|
||||||
|
python_files = ["tests.py", "test_*.py", "*_test.py"]
|
||||||
|
addopts = "--verbose"
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Test configuration package
|
||||||
66
tests/settings.py
Normal file
66
tests/settings.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Django settings for testing django-courriel package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
SECRET_KEY = 'test-secret-key-for-django-courriel-testing-only'
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django_courriel',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = []
|
||||||
|
|
||||||
|
ROOT_URLCONF = ''
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/6.1/ref/settings/#databases
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': ':memory:',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# Django templates
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [
|
||||||
|
os.path.join(BASE_DIR, 'django_courriel', 'templates'),
|
||||||
|
os.path.join(BASE_DIR, 'tests', 'templates'),
|
||||||
|
],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Markdown email settings
|
||||||
|
MARKDOWN_EMAIL_EXTENSIONS = ['extra', 'nl2br']
|
||||||
|
MARKDOWN_EMAIL_DEFAULT_LAYOUT = 'courriel/base.dj.html'
|
||||||
|
|
||||||
|
# Use in-memory storage for testing
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
|
||||||
4
tests/templates/invalid_front_matter.md
Normal file
4
tests/templates/invalid_front_matter.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
subject is invalid
|
||||||
|
---
|
||||||
|
This shall raise a InvalidFrontMatter exception
|
||||||
4
tests/templates/minimal.md
Normal file
4
tests/templates/minimal.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
subject: "Hello"
|
||||||
|
---
|
||||||
|
Hello world !
|
||||||
2
tests/templates/missing_front_matter.md
Normal file
2
tests/templates/missing_front_matter.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Gabarit sans front matter délimité
|
||||||
|
Ce fichier n'a pas de front matter valide.
|
||||||
5
tests/templates/missing_front_matter_subject.md
Normal file
5
tests/templates/missing_front_matter_subject.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
to: "test@example.com"
|
||||||
|
cc: "another@example.com"
|
||||||
|
---
|
||||||
|
This shall raise a MissingSubject exception
|
||||||
8
tests/templates/test_groups.md
Normal file
8
tests/templates/test_groups.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
subject: "Notification de groupe"
|
||||||
|
to_groups:
|
||||||
|
- "managers"
|
||||||
|
cc:
|
||||||
|
- "{{ extra_email }}"
|
||||||
|
---
|
||||||
|
Message envoyé au groupe.
|
||||||
5
tests/templates/test_missing_subject_var.md
Normal file
5
tests/templates/test_missing_subject_var.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
subject: "Notification pour {{ missing_user }}"
|
||||||
|
to: "test@example.com"
|
||||||
|
---
|
||||||
|
Corps de test.
|
||||||
5
tests/templates/test_missing_to_var.md
Normal file
5
tests/templates/test_missing_to_var.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
subject: "Sujet présent"
|
||||||
|
to: "{{ missing_recipient }}"
|
||||||
|
---
|
||||||
|
Corps de test.
|
||||||
14
tests/templates/test_nominal.md
Normal file
14
tests/templates/test_nominal.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
subject: "Bienvenue à la session {{ session_title }}"
|
||||||
|
to: "{{ recipient_email }}"
|
||||||
|
reply_to: "contact@gobelins.fr"
|
||||||
|
format: both
|
||||||
|
---
|
||||||
|
# Formation {{ session_title }}
|
||||||
|
|
||||||
|
Bonjour {{ recipient_name }},
|
||||||
|
|
||||||
|
Voici le programme :
|
||||||
|
{% for item in items %}
|
||||||
|
* {{ item }}
|
||||||
|
{% endfor %}
|
||||||
6
tests/templates/test_nonexistent_group.md
Normal file
6
tests/templates/test_nonexistent_group.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
subject: "Notification inexistante"
|
||||||
|
to_groups:
|
||||||
|
- "inexistant"
|
||||||
|
---
|
||||||
|
Message.
|
||||||
6
tests/templates/test_optional_warning.md
Normal file
6
tests/templates/test_optional_warning.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
subject: "Sujet présent"
|
||||||
|
to: "{{ recipient_email }}"
|
||||||
|
reply_to: "{{ missing_reply_to }}"
|
||||||
|
---
|
||||||
|
Corps de test.
|
||||||
16
tests/test_md.py
Normal file
16
tests/test_md.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownTemplateTest(SimpleTestCase):
|
||||||
|
|
||||||
|
def test_init(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def test_minimal(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def test_subject_rendering(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def test_addresses(self):
|
||||||
|
raise NotImplementedError()
|
||||||
155
tests/test_validation.py
Normal file
155
tests/test_validation.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
from email.headerregistry import Address
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from django_courriel.validation import (
|
||||||
|
CourrielError,
|
||||||
|
InvalidAddressError,
|
||||||
|
InvalidRecipientsError,
|
||||||
|
InvalidSenderError,
|
||||||
|
ValidAddress,
|
||||||
|
ValidAddressList,
|
||||||
|
)
|
||||||
|
|
||||||
|
# test data
|
||||||
|
valid_inputs = (
|
||||||
|
ValidAddress('Boris Vian', 'boris', 'vian.jazz'),
|
||||||
|
'antoine@saint-exupery.aero',
|
||||||
|
Address('Savinien de Cyrano', 'savinien', 'cyrano.lune'),
|
||||||
|
'Aimé Césaire <cesaire@cahiers.net>',
|
||||||
|
'Victor Hugo <victor-hugo@les-misérables.fr>',
|
||||||
|
)
|
||||||
|
|
||||||
|
invalid_inputs = (
|
||||||
|
Address(),
|
||||||
|
Address('Charles Baudelaire'),
|
||||||
|
Address('Charles', 'charles', 'les fleurs du mal'),
|
||||||
|
'Vernon Sullivan',
|
||||||
|
'simone@bovary',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidAddress(SimpleTestCase):
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.invalid_specs = [
|
||||||
|
'boris@vian',
|
||||||
|
'bad adress',
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_interface(self):
|
||||||
|
""" respects parent email.headerregistry.Address interface """
|
||||||
|
try:
|
||||||
|
a = ValidAddress('Boris Vian', 'boris', 'vian.jazz')
|
||||||
|
self.assertEqual(a.display_name, 'Boris Vian')
|
||||||
|
self.assertEqual(a.addr_spec, 'boris@vian.jazz')
|
||||||
|
self.assertEqual(a.username, 'boris')
|
||||||
|
self.assertEqual(a.domain, 'vian.jazz')
|
||||||
|
self.assertEqual(str(a), 'Boris Vian <boris@vian.jazz>')
|
||||||
|
except Exception as e:
|
||||||
|
self.fail(f"should not have raised {e}")
|
||||||
|
with self.assertRaises(AttributeError): # should not be mutable
|
||||||
|
a.addr_spec = "<vernon@affreux.org>"
|
||||||
|
|
||||||
|
def test_new(self):
|
||||||
|
""" __new__ shall raise InvalidAddress errors """
|
||||||
|
with self.assertRaises(InvalidAddressError):
|
||||||
|
ValidAddress()
|
||||||
|
with self.assertRaises(InvalidAddressError):
|
||||||
|
ValidAddress('Simone Veil')
|
||||||
|
with self.assertRaises(InvalidAddressError):
|
||||||
|
ValidAddress(username='Simone Veil', domain='cafe.philo')
|
||||||
|
with self.assertRaises(InvalidAddressError):
|
||||||
|
ValidAddress(username='simone', domain='veil')
|
||||||
|
# some more bad specs
|
||||||
|
for spec in self.invalid_specs:
|
||||||
|
with self.assertRaises(InvalidAddressError):
|
||||||
|
ValidAddress(addr_spec=spec)
|
||||||
|
|
||||||
|
def test_factory(self):
|
||||||
|
""" builder from string or Address """
|
||||||
|
# invalid cases
|
||||||
|
inputs = self.invalid_specs + [None, Address()]
|
||||||
|
for input in inputs:
|
||||||
|
with self.assertRaises(InvalidAddressError):
|
||||||
|
ValidAddress.factory(input)
|
||||||
|
# valid cases
|
||||||
|
try:
|
||||||
|
a = ValidAddress.factory('antoine@saint-exupery.aero')
|
||||||
|
self.assertEqual(a.addr_spec, 'antoine@saint-exupery.aero')
|
||||||
|
a = ValidAddress.factory('"Victor Hugo" <hugo@miserables.paris>')
|
||||||
|
self.assertEqual(a.display_name, 'Victor Hugo')
|
||||||
|
self.assertEqual(a.addr_spec, 'hugo@miserables.paris')
|
||||||
|
# from an address
|
||||||
|
old = Address(display_name='Savinien de Cyrano', username='savinien', domain='cyrano.lune')
|
||||||
|
a = ValidAddress.factory(old)
|
||||||
|
self.assertEqual(str(a), 'Savinien de Cyrano <savinien@cyrano.lune>')
|
||||||
|
except InvalidAddressError as e:
|
||||||
|
self.fail(f"should not have raised {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidAddressList(SimpleTestCase):
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.test_list = ValidAddressList()
|
||||||
|
|
||||||
|
def test__init(self):
|
||||||
|
# valid values
|
||||||
|
try:
|
||||||
|
ValidAddressList()
|
||||||
|
ValidAddressList(['victor@hugo.paris', 'Aimé Césaire <cesaire@cahiers.net>'])
|
||||||
|
except InvalidAddressError as e:
|
||||||
|
self.fail(f"should not have raised {e}")
|
||||||
|
# invalid values
|
||||||
|
with self.assertRaises(InvalidAddressError):
|
||||||
|
ValidAddressList(['invalid@address'])
|
||||||
|
|
||||||
|
def test_append(self):
|
||||||
|
# valid values
|
||||||
|
try:
|
||||||
|
for i in valid_inputs:
|
||||||
|
self.test_list.append(i)
|
||||||
|
except InvalidAddressError as e:
|
||||||
|
self.fail(f"should not have raised {e} when appending {i}")
|
||||||
|
# invalid values
|
||||||
|
for i in invalid_inputs:
|
||||||
|
with self.assertRaises(InvalidAddressError):
|
||||||
|
self.test_list.append(i)
|
||||||
|
# checking conversions
|
||||||
|
for item in self.test_list:
|
||||||
|
self.assertIsInstance(item, ValidAddress)
|
||||||
|
|
||||||
|
def test_insert(self):
|
||||||
|
for i in valid_inputs:
|
||||||
|
try:
|
||||||
|
self.test_list.insert(0, i)
|
||||||
|
except InvalidAddressError as e:
|
||||||
|
self.fail(f"should not have raised {e} when inserting {i}")
|
||||||
|
for i in invalid_inputs:
|
||||||
|
with self.assertRaises(InvalidAddressError):
|
||||||
|
self.test_list.insert(0, i)
|
||||||
|
# checking conversions
|
||||||
|
for item in self.test_list:
|
||||||
|
self.assertIsInstance(item, ValidAddress)
|
||||||
|
|
||||||
|
def test_set_item(self):
|
||||||
|
# cannot set item on empty list !?
|
||||||
|
self.test_list = ValidAddressList(['virginie@subutex.livre'])
|
||||||
|
for i in valid_inputs:
|
||||||
|
try:
|
||||||
|
self.test_list[0] = i
|
||||||
|
except InvalidAddressError as e:
|
||||||
|
self.fail(f"should not have raised {e} when inserting {i}")
|
||||||
|
for i in invalid_inputs:
|
||||||
|
with self.assertRaises(InvalidAddressError):
|
||||||
|
self.test_list[0] = i
|
||||||
|
# checking conversions
|
||||||
|
for item in self.test_list:
|
||||||
|
self.assertIsInstance(item, ValidAddress)
|
||||||
|
|
||||||
|
def test_extend(self):
|
||||||
|
try:
|
||||||
|
self.test_list.extend(valid_inputs)
|
||||||
|
except InvalidAddressError as e:
|
||||||
|
self.fail(f"should not have raised {e} when extending {valid_inputs}")
|
||||||
|
with self.assertRaises(InvalidAddressError):
|
||||||
|
self.test_list.extend(invalid_inputs)
|
||||||
Reference in New Issue
Block a user