Skip to content

Commit 7e4b72b

Browse files
committed
Add Email Templates editor to categories, and events
1 parent 11a73e2 commit 7e4b72b

17 files changed

Lines changed: 782 additions & 17 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Add email template model
2+
3+
Revision ID: 86873b143da2
4+
Revises: 932389d22b1f
5+
Create Date: 2025-09-12 21:42:26.537009
6+
"""
7+
8+
import sqlalchemy as sa
9+
from alembic import op
10+
from sqlalchemy.dialects import postgresql
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = '86873b143da2'
15+
down_revision = '932389d22b1f'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.create_table('email_templates',
22+
sa.Column('id', sa.Integer(), nullable=False),
23+
sa.Column('title', sa.String(), nullable=False),
24+
sa.Column('type', sa.String(), nullable=False),
25+
sa.Column('status', sa.String(), nullable=False),
26+
sa.Column('subject', sa.String(), nullable=False),
27+
sa.Column('body', sa.Text(), nullable=False),
28+
sa.Column('event_id', sa.Integer(), nullable=True, index=True),
29+
sa.Column('category_id', sa.Integer(), nullable=True, index=True),
30+
sa.Column('rules', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
31+
sa.Column('is_active', sa.Boolean(), nullable=False),
32+
sa.Column('is_system_template', sa.Boolean(), nullable=False),
33+
sa.CheckConstraint('(event_id IS NULL) != (category_id IS NULL)',
34+
name='event_xor_category_id_null'),
35+
sa.ForeignKeyConstraint(['category_id'], ['categories.categories.id']),
36+
sa.ForeignKeyConstraint(['event_id'], ['events.events.id']),
37+
sa.PrimaryKeyConstraint('id'),
38+
schema='indico')
39+
40+
41+
def downgrade():
42+
op.drop_table('email_templates', schema='indico')

indico/modules/categories/models/categories.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ def __table_args__(cls):
281281
# relationship backrefs:
282282
# - attachment_folders (AttachmentFolder.category)
283283
# - designer_templates (DesignerTemplate.category)
284+
# - email_templates (EmailTemplate.category)
284285
# - event_move_requests (EventMoveRequest.category)
285286
# - events (Event.category)
286287
# - favorite_of (User.favorite_categories)

indico/modules/designer/controllers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,13 @@ class CloneTemplateMixin(TargetFromURLMixin):
197197
def _check_access(self):
198198
if not self.target.can_manage(session.user):
199199
raise Forbidden
200+
elif isinstance(self.target, Event):
201+
check_event_locked(self, self.target)
200202

201203
def _process_args(self):
202204
self.template = DesignerTemplate.get_or_404(request.view_args['template_id'])
205+
if self.target.is_deleted:
206+
raise NotFound
203207

204208
def clone_template(self, target=None):
205209
title = f'{self.template.title} (copy)'
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# This file is part of Indico.
2+
# Copyright (C) 2002 - 2025 CERN
3+
#
4+
# Indico is free software; you can redistribute it and/or
5+
# modify it under the terms of the MIT License; see the
6+
# LICENSE file for more details.
7+
8+
from flask import session
9+
10+
from indico.core import signals
11+
from indico.util.i18n import _
12+
from indico.web.flask.util import url_for
13+
from indico.web.menu import SideMenuItem
14+
15+
16+
@signals.menu.items.connect_via('event-management-sidemenu')
17+
def _event_sidemenu_items(sender, event, **kwargs):
18+
if event.can_manage(session.user):
19+
return SideMenuItem('email_templates', _('Email Templates'),
20+
url_for('email_templates.email_template_list', event),
21+
section='customization')
22+
23+
24+
@signals.menu.items.connect_via('category-management-sidemenu')
25+
def _category_sidemenu_items(sender, category, **kwargs):
26+
if category.can_manage(session.user):
27+
return SideMenuItem('email_templates', _('Email Templates'),
28+
url_for('email_templates.email_template_list', category),
29+
weight=25, icon='mail')
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# This file is part of Indico.
2+
# Copyright (C) 2002 - 2025 CERN
3+
#
4+
# Indico is free software; you can redistribute it and/or
5+
# modify it under the terms of the MIT License; see the
6+
# LICENSE file for more details.
7+
8+
from indico.modules.email_templates.controllers import (RHAddCategoryEmailTemplate, RHAddEventEmailTemplate,
9+
RHCloneCategoryEmailTemplate, RHCloneEventEmailTemplate,
10+
RHDeleteEmailTemplate, RHEditEmailTemplate,
11+
RHListCategoryEmailTemplates, RHListEventEmailTemplates)
12+
from indico.util.caching import memoize
13+
from indico.web.flask.util import make_view_func
14+
from indico.web.flask.wrappers import IndicoBlueprint
15+
16+
17+
_bp = IndicoBlueprint('email_templates', __name__, template_folder='templates',
18+
virtual_template_folder='email_templates')
19+
20+
21+
@memoize
22+
def _dispatch(event_rh, category_rh):
23+
event_view = make_view_func(event_rh)
24+
categ_view = make_view_func(category_rh)
25+
26+
def view_func(**kwargs):
27+
return categ_view(**kwargs) if kwargs['object_type'] == 'category' else event_view(**kwargs)
28+
29+
return view_func
30+
31+
32+
for object_type in ('event', 'category'):
33+
prefix = f'/{object_type}/<int:{object_type}_id>/manage/email_templates'
34+
_bp.add_url_rule(f'{prefix}/', 'email_template_list',
35+
_dispatch(RHListEventEmailTemplates, RHListCategoryEmailTemplates),
36+
defaults={'object_type': object_type})
37+
_bp.add_url_rule(f'{prefix}/add', 'add_email_template',
38+
_dispatch(RHAddEventEmailTemplate, RHAddCategoryEmailTemplate),
39+
defaults={'object_type': object_type}, methods=('GET', 'POST'))
40+
_bp.add_url_rule(f'{prefix}/<int:email_template_id>/', 'edit_email_template',
41+
RHEditEmailTemplate, defaults={'object_type': object_type}, methods=('GET', 'POST'))
42+
_bp.add_url_rule(f'{prefix}/<int:email_template_id>', 'delete_email_template',
43+
RHDeleteEmailTemplate, defaults={'object_type': object_type}, methods=('DELETE',))
44+
_bp.add_url_rule(f'{prefix}/<int:email_template_id>/clone', 'clone_email_template',
45+
_dispatch(RHCloneEventEmailTemplate, RHCloneCategoryEmailTemplate),
46+
defaults={'object_type': object_type}, methods=('GET', 'POST'))
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
# This file is part of Indico.
2+
# Copyright (C) 2002 - 2025 CERN
3+
#
4+
# Indico is free software; you can redistribute it and/or
5+
# modify it under the terms of the MIT License; see the
6+
# LICENSE file for more details.
7+
8+
from copy import deepcopy
9+
10+
from flask import flash, request, session
11+
from markupsafe import Markup
12+
from werkzeug.exceptions import Forbidden, NotFound
13+
14+
from indico.core.db import db
15+
from indico.modules.categories import Category
16+
from indico.modules.categories.controllers.base import RHManageCategoryBase
17+
from indico.modules.designer.forms import CloneTemplateForm
18+
from indico.modules.email_templates.forms import CreateEmailTemplatesForm, EditEmailTemplatesForm
19+
from indico.modules.email_templates.models.email_templates import EmailTemplate
20+
from indico.modules.email_templates.util import get_inherited_templates
21+
from indico.modules.email_templates.views import WPCategoryManagementEmailTemplate, WPEventManagementEmailTemplate
22+
from indico.modules.events import Event
23+
from indico.modules.events.management.controllers import RHManageEventBase
24+
from indico.modules.events.util import check_event_locked
25+
from indico.util.i18n import _
26+
from indico.web.flask.templating import get_template_module
27+
from indico.web.flask.util import url_for
28+
from indico.web.rh import RHProtected
29+
from indico.web.util import jsonify_data, jsonify_form
30+
31+
32+
def _render_template_list(target, event=None):
33+
tpl = get_template_module('email_templates/_list.html')
34+
return tpl.render_email_template_list(target.email_templates, target, event=event,
35+
inherited_templates=get_inherited_templates(target))
36+
37+
38+
class EmailTemplateMixin:
39+
"""Basic class for all template designer mixins.
40+
41+
It resolves the target object type from the blueprint URL.
42+
"""
43+
44+
@property
45+
def object_type(self):
46+
"""Figure out whether we're targeting an event or category, based on URL info."""
47+
return request.view_args['object_type']
48+
49+
@property
50+
def event_or_none(self):
51+
return self.target if self.object_type == 'event' else None
52+
53+
def _render_template(self, tpl_name, **kwargs):
54+
view_class = (WPEventManagementEmailTemplate
55+
if self.object_type == 'event' else WPCategoryManagementEmailTemplate)
56+
return view_class.render_template(tpl_name, self.target, 'email_templates',
57+
target=self.target, **kwargs)
58+
59+
60+
class SpecificEmailTemplateMixin(EmailTemplateMixin):
61+
"""Mixin that accepts a target template passed in the URL.
62+
63+
The target category/event will be the owner of that template.
64+
"""
65+
66+
normalize_url_spec = {
67+
'locators': {
68+
lambda self: self.email_tpl
69+
}
70+
}
71+
72+
@property
73+
def target(self):
74+
return self.email_tpl.owner
75+
76+
def _check_access(self):
77+
self._require_user()
78+
if not self.target.can_manage(session.user):
79+
raise Forbidden
80+
elif isinstance(self.target, Event):
81+
check_event_locked(self, self.target)
82+
83+
def _process_args(self):
84+
self.email_tpl = EmailTemplate.get_or_404(request.view_args['email_template_id'])
85+
if self.target.is_deleted:
86+
raise NotFound
87+
88+
89+
class TargetFromURLMixin(EmailTemplateMixin):
90+
"""Mixin that takes the target event/category from the URL that is passed."""
91+
92+
@property
93+
def target_dict(self):
94+
return {'event': self.event} if self.object_type == 'event' else {'category': self.category}
95+
96+
@property
97+
def target(self):
98+
return self.event if self.object_type == 'event' else self.category
99+
100+
101+
class EmailTemplateListMixin(TargetFromURLMixin):
102+
def _process(self):
103+
inherited_templates = get_inherited_templates(self.target)
104+
return self._render_template('list.html', inherited_templates=inherited_templates)
105+
106+
107+
class AddEmailTemplateMixin(TargetFromURLMixin):
108+
def _check_access(self):
109+
if not self.target.can_manage(session.user):
110+
raise Forbidden
111+
112+
def _process(self):
113+
form = CreateEmailTemplatesForm(target=self.target)
114+
if form.validate_on_submit():
115+
new_email_tpl = EmailTemplate(**self.target_dict)
116+
new_email_tpl.rules = {'status': form.status.data}
117+
form.populate_obj(new_email_tpl)
118+
flash(_("Added a new email template '{}'").format(new_email_tpl.title), 'success')
119+
return jsonify_data(html=_render_template_list(self.target, event=self.event_or_none))
120+
return jsonify_form(form, disabled_until_change=False)
121+
122+
123+
class RHListEventEmailTemplates(EmailTemplateListMixin, RHManageEventBase):
124+
pass
125+
126+
127+
class RHListCategoryEmailTemplates(EmailTemplateListMixin, RHManageCategoryBase):
128+
pass
129+
130+
131+
class RHAddEventEmailTemplate(AddEmailTemplateMixin, RHManageEventBase):
132+
pass
133+
134+
135+
class RHAddCategoryEmailTemplate(AddEmailTemplateMixin, RHManageCategoryBase):
136+
pass
137+
138+
139+
class RHModifyEmailTemplateBase(SpecificEmailTemplateMixin, RHProtected):
140+
def _check_access(self):
141+
RHProtected._check_access(self)
142+
SpecificEmailTemplateMixin._check_access(self)
143+
144+
145+
class RHEditEmailTemplate(RHModifyEmailTemplateBase):
146+
def _process(self):
147+
form = EditEmailTemplatesForm(target=self.target, obj=self.email_tpl)
148+
if form.validate_on_submit():
149+
self.email_tpl.rules = {'status': form.status.data}
150+
form.populate_obj(self.email_tpl)
151+
flash(_("Edited email template '{}'").format(self.email_tpl.title), 'success')
152+
return jsonify_data(html=_render_template_list(self.target, event=self.event_or_none))
153+
return jsonify_form(form, disabled_until_change=False)
154+
155+
156+
class RHDeleteEmailTemplate(RHModifyEmailTemplateBase):
157+
def _process(self):
158+
db.session.delete(self.email_tpl)
159+
db.session.flush()
160+
flash(_('The email template has been deleted'), 'success')
161+
return jsonify_data(html=_render_template_list(self.target, event=self.event_or_none))
162+
163+
164+
class CloneTemplateMixin(TargetFromURLMixin):
165+
cloneable_elsewhere = False
166+
167+
def _check_access(self):
168+
if not self.target.can_manage(session.user):
169+
raise Forbidden
170+
elif isinstance(self.target, Event):
171+
check_event_locked(self, self.target)
172+
173+
def _process_args(self):
174+
self.email_tpl = EmailTemplate.get_or_404(request.view_args['email_template_id'])
175+
if self.target.is_deleted:
176+
raise NotFound
177+
178+
def is_active(self):
179+
email_templates = [email_tpl for email_tpl in self.target.email_templates
180+
if email_tpl.type == self.email_tpl.type]
181+
if status := self.email_tpl.rules.get('status'):
182+
email_templates = [email_tpl for email_tpl in email_templates
183+
if email_tpl.rules and email_tpl.rules.get('status') == status]
184+
if email_templates:
185+
return not any(tpl.is_active for tpl in email_templates)
186+
return self.email_tpl.is_active
187+
188+
def clone_template(self, target=None):
189+
title = f'{self.email_tpl.title} (copy)'
190+
target_dict = target or self.target_dict
191+
new_email_tpl = EmailTemplate(title=title, type=self.email_tpl.type, **target_dict)
192+
new_email_tpl.subject = self.email_tpl.subject
193+
new_email_tpl.body = self.email_tpl.body
194+
new_email_tpl.is_system_template = self.email_tpl.is_system_template
195+
new_email_tpl.is_active = self.is_active()
196+
new_email_tpl.rules = deepcopy(self.email_tpl.rules)
197+
message = _("Created copy of template '{}'").format(self.email_tpl.title)
198+
if target_dict != self.target_dict:
199+
message += Markup(' (<a href="{}">{}</a>)').format(url_for('email_templates.email_template_list',
200+
target_dict['category']), _('Go to category'))
201+
flash(message, 'success')
202+
return jsonify_data(html=_render_template_list(self.target, event=self.event_or_none))
203+
204+
def _process(self):
205+
if self.cloneable_elsewhere:
206+
category = (self.target if isinstance(self.target, Category) and
207+
self.target.can_manage(session.user) else None)
208+
form = CloneTemplateForm(category=category)
209+
if form.validate_on_submit():
210+
return self.clone_template(target=form.data)
211+
return jsonify_form(form, submit=_('Clone'), disabled_until_change=False)
212+
if request.method == 'POST':
213+
return self.clone_template()
214+
215+
216+
class RHCloneEventEmailTemplate(CloneTemplateMixin, RHManageEventBase):
217+
def _process_args(self):
218+
RHManageEventBase._process_args(self)
219+
CloneTemplateMixin._process_args(self)
220+
221+
222+
class RHCloneCategoryEmailTemplate(CloneTemplateMixin, RHManageCategoryBase):
223+
cloneable_elsewhere = True
224+
225+
def _process_args(self):
226+
RHManageCategoryBase._process_args(self)
227+
CloneTemplateMixin._process_args(self)

0 commit comments

Comments
 (0)