feat: implement CMS modules for carousel, promos, and app config, and enable HTML support for push notifications.

This commit is contained in:
Suherdy Yacob 2026-06-14 09:09:57 +07:00
parent b38484a343
commit 2e189dbe6a
14 changed files with 521 additions and 68 deletions

View File

@ -1,14 +1,19 @@
# -*- coding: utf-8 -*-
{
'name': "Mapan Loyalty Push Notifications",
'summary': "Send push notifications to the Loyalty Flutter App",
'summary': "Mobile App CMS: Push notifications, carousel, promos and app configuration",
'description': """
Integrates Odoo with Firebase Cloud Messaging (FCM) to send push notifications directly
to customers' Android devices. Maps FCM tokens to res_partner records.
Content Management System for the Mie Mapan Loyalty Flutter App.
Features:
- Send push notifications to loyalty app users
- Manage carousel banner slides (home screen)
- Manage promo highlight cards (home screen, with rich text detail)
- Configure App Settings (About Us URL, Contact Us URL)
- API endpoints for Flutter app: notifications, CMS content, order history, branches
""",
'author': "Suherdy Yacob",
'category': 'Marketing',
'version': '1.0',
'version': '1.1',
'depends': ['base', 'loyalty', 'pos_loyalty', 'sale_loyalty'],
'data': [
'security/mapan_loyalty_push_security.xml',
@ -16,6 +21,9 @@
'wizard/push_wizard_views.xml',
'views/res_partner_views.xml',
'views/app_notification_views.xml',
'views/app_carousel_views.xml',
'views/app_promo_views.xml',
'views/app_cms_config_views.xml',
],
'installable': True,
'application': False,

View File

@ -2,6 +2,7 @@
from odoo import http
from odoo.http import request
def normalize_phone_search(phone):
if not phone:
return []
@ -20,6 +21,7 @@ def normalize_phone_search(phone):
candidates.append('62' + digits)
return list(set(candidates))
class AppNotificationController(http.Controller):
@http.route('/api/loyalty/fetch_notifications', type='jsonrpc', auth='user', methods=['POST'], csrf=False)
@ -28,6 +30,7 @@ class AppNotificationController(http.Controller):
Endpoint for the Flutter app Background Task and In-App notification center.
Returns image_128 (base64 thumbnail) for list display.
The Flutter detail screen loads the full image via /web/image/ with session cookie.
Body is now HTML from the rich text editor returned as-is.
"""
import base64 as b64mod
user = request.env.user
@ -58,21 +61,144 @@ class AppNotificationController(http.Controller):
elif not img:
notif['image_128'] = None
notif['has_image'] = bool(notif['image_128'])
# body is HTML string — pass through as-is
if notif.get('body') is False:
notif['body'] = ''
return {
'status': 'success',
'data': notifications
}
@http.route('/api/loyalty/cms_content', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
def fetch_cms_content(self, **kw):
"""
Public endpoint to fetch carousel slides and promo highlights for the Flutter app home screen.
"""
import base64 as b64mod
from datetime import date
today = date.today()
# --- Carousel Slides ---
carousel_domain = [('is_active', '=', True)]
carousel_items = request.env['mapan.app.carousel'].sudo().search_read(
carousel_domain,
['id', 'name', 'image', 'image_url', 'link_url', 'sequence'],
order='sequence, id'
)
for item in carousel_items:
img = item.get('image')
if img and isinstance(img, bytes):
item['image'] = img.decode('utf-8')
elif not img:
item['image'] = None
# --- Promo Highlights ---
promo_domain = [('is_active', '=', True)]
promos = request.env['mapan.app.promo'].sudo().search_read(
promo_domain,
['id', 'name', 'body', 'image_128', 'date_start', 'date_end', 'sequence'],
order='sequence, id'
)
active_promos = []
for promo in promos:
# Filter by validity date client-side after fetch
ds = promo.get('date_start')
de = promo.get('date_end')
if ds and ds > today:
continue
if de and de < today:
continue
img = promo.get('image_128')
if img and isinstance(img, bytes):
promo['image_128'] = img.decode('utf-8')
elif not img:
promo['image_128'] = None
# Convert date objects to string
promo['date_start'] = str(promo['date_start']) if promo.get('date_start') else None
promo['date_end'] = str(promo['date_end']) if promo.get('date_end') else None
if promo.get('body') is False:
promo['body'] = ''
active_promos.append(promo)
return {
'status': 'success',
'carousel': carousel_items,
'promos': active_promos,
}
@http.route('/api/loyalty/app_config', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
def fetch_app_config(self, **kw):
"""
Public endpoint to fetch app configuration (About Us URL, Contact Us URL).
"""
config = request.env['mapan.app.config'].sudo().search([], limit=1)
if not config:
config = request.env['mapan.app.config'].sudo().create({'name': 'App Configuration'})
return {
'status': 'success',
'about_us_url': config.about_us_url or '',
'contact_us_url': config.contact_us_url or '',
}
@http.route('/api/loyalty/order_history', type='jsonrpc', auth='user', methods=['POST'], csrf=False)
def fetch_order_history(self, **kw):
"""
Authenticated endpoint to fetch loyalty point history for the current user.
Queries loyalty.history which tracks earn/spend events.
Positive points = earned, negative points = spent.
"""
user = request.env.user
partner = user.partner_id
# Get all loyalty cards for this partner
cards = request.env['loyalty.card'].sudo().search([('partner_id', '=', partner.id)])
card_ids = cards.ids
if not card_ids:
return {'status': 'success', 'data': []}
history_records = request.env['loyalty.history'].sudo().search_read(
[('card_id', 'in', card_ids)],
['id', 'card_id', 'points', 'date', 'order_id'],
order='date desc',
limit=100
)
result = []
for rec in history_records:
points = rec.get('points') or 0
point_type = 'earn' if points >= 0 else 'spend'
order_ref = ''
order_id = rec.get('order_id')
if order_id and isinstance(order_id, (list, tuple)) and len(order_id) > 1:
order_ref = str(order_id[1])
elif order_id:
order_ref = str(order_id)
result.append({
'id': rec['id'],
'date': str(rec.get('date') or ''),
'points': round(float(points), 2),
'type': point_type,
'order_ref': order_ref,
'card_id': rec['card_id'][0] if isinstance(rec.get('card_id'), (list, tuple)) else rec.get('card_id'),
})
return {'status': 'success', 'data': result}
@http.route('/api/loyalty/branches', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
def fetch_branches(self, **kw):
"""
Public endpoint for the Flutter app to get branches without exposing API keys.
Includes latitude/longitude for geolocation-based distance sorting on the client.
"""
try:
branches = request.env['res.company'].sudo().search_read(
[('parent_id', '!=', False)],
['name', 'street', 'city', 'phone'],
['name', 'street', 'city', 'phone', 'partner_latitude', 'partner_longitude'],
limit=50
)
return {'status': 'success', 'data': branches}

View File

@ -1,7 +1,8 @@
from . import res_partner
from . import app_notification
from . import app_carousel
from . import app_promo
from . import app_cms_config
from . import res_users
from . import loyalty_card
from . import loyalty_verification_otp

32
models/app_carousel.py Normal file
View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class AppCarousel(models.Model):
"""Carousel slides managed from the Odoo backend, displayed on the Flutter app home screen."""
_name = 'mapan.app.carousel'
_description = 'Mobile App Carousel Slide'
_order = 'sequence, id'
name = fields.Char(string='Slide Title', required=True)
sequence = fields.Integer(string='Sequence', default=10)
is_active = fields.Boolean(string='Active', default=True)
# Image: uploaded from Odoo backend
image = fields.Image(
string='Banner Image',
max_width=1920,
max_height=720,
help='Banner image for the carousel slide (recommended ratio 16:6).'
)
# External image URL (alternative to uploaded image)
image_url = fields.Char(
string='External Image URL',
help='If set and no image is uploaded, the app will load the image from this URL.'
)
link_url = fields.Char(
string='Tap URL',
help='URL to open when the user taps this carousel slide. Leave empty for no action.'
)

27
models/app_cms_config.py Normal file
View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class AppCmsConfig(models.Model):
"""Singleton model for Mobile App global configuration."""
_name = 'mapan.app.config'
_description = 'Mobile App Configuration'
name = fields.Char(default='App Configuration', readonly=True)
about_us_url = fields.Char(
string='About Us URL',
help='URL to open when user taps "About Us" in the app account menu.'
)
contact_us_url = fields.Char(
string='Contact Us URL',
help='URL to open when user taps "Contact Us" in the app account menu.'
)
@api.model
def get_config(self):
"""Always return the single config record, creating it if it does not exist."""
config = self.search([], limit=1)
if not config:
config = self.create({'name': 'App Configuration'})
return config

View File

@ -6,7 +6,7 @@ class AppNotification(models.Model):
_order = 'create_date desc'
title = fields.Char(string='Notification Title', required=True)
body = fields.Text(string='Notification Body', required=True)
body = fields.Html(string='Notification Body', required=True)
# Optional promotional image — Odoo auto-generates image_128 thumbnail
image = fields.Image(

42
models/app_promo.py Normal file
View File

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class AppPromo(models.Model):
"""Highlight Promo items managed from the Odoo backend.
Displayed as a horizontal scrollable row below the carousel on the Flutter app home screen.
When tapped, the app shows a detail screen with title, image, and the rich-text body.
"""
_name = 'mapan.app.promo'
_description = 'Mobile App Promo Highlight'
_order = 'sequence, id'
name = fields.Char(string='Promo Title', required=True)
sequence = fields.Integer(string='Sequence', default=10)
is_active = fields.Boolean(string='Active', default=True)
# Rich text body (like the notification body)
body = fields.Html(
string='Promo Detail Content',
help='Rich text content shown when the user taps on this promo highlight.'
)
# Thumbnail image for the list/card view
image = fields.Image(
string='Promo Image',
max_width=800,
max_height=800,
help='Image shown on the promo card tile. Square format recommended.'
)
image_128 = fields.Image(
string='Thumbnail',
related='image',
max_width=128,
max_height=128,
store=True,
readonly=True,
)
date_start = fields.Date(string='Valid From', help='Leave empty for no start restriction.')
date_end = fields.Date(string='Valid Until', help='Leave empty for no expiry.')

View File

@ -3,6 +3,15 @@ access_mapan_push_wizard_manager,mapan_push_wizard_manager,model_mapan_push_wiza
access_mapan_app_notification_user,mapan_app_notification_user,model_mapan_app_notification,mapan_loyalty_push.group_mapan_loyalty_push_user,1,0,0,0
access_mapan_app_notification_manager,mapan_app_notification_manager,model_mapan_app_notification,mapan_loyalty_push.group_mapan_loyalty_push_manager,1,1,1,1
access_mapan_app_notification_portal,mapan_app_notification_portal,model_mapan_app_notification,base.group_portal,1,0,0,0
access_mapan_app_carousel_user,mapan_app_carousel_user,model_mapan_app_carousel,mapan_loyalty_push.group_mapan_loyalty_push_user,1,0,0,0
access_mapan_app_carousel_manager,mapan_app_carousel_manager,model_mapan_app_carousel,mapan_loyalty_push.group_mapan_loyalty_push_manager,1,1,1,1
access_mapan_app_carousel_portal,mapan_app_carousel_portal,model_mapan_app_carousel,base.group_portal,1,0,0,0
access_mapan_app_promo_user,mapan_app_promo_user,model_mapan_app_promo,mapan_loyalty_push.group_mapan_loyalty_push_user,1,0,0,0
access_mapan_app_promo_manager,mapan_app_promo_manager,model_mapan_app_promo,mapan_loyalty_push.group_mapan_loyalty_push_manager,1,1,1,1
access_mapan_app_promo_portal,mapan_app_promo_portal,model_mapan_app_promo,base.group_portal,1,0,0,0
access_mapan_app_config_user,mapan_app_config_user,model_mapan_app_config,mapan_loyalty_push.group_mapan_loyalty_push_user,1,0,0,0
access_mapan_app_config_manager,mapan_app_config_manager,model_mapan_app_config,mapan_loyalty_push.group_mapan_loyalty_push_manager,1,1,1,1
access_mapan_app_config_portal,mapan_app_config_portal,model_mapan_app_config,base.group_portal,1,0,0,0
access_loyalty_card_portal,loyalty.card.portal,loyalty.model_loyalty_card,base.group_portal,1,0,0,0
access_loyalty_program_portal,loyalty.program.portal,loyalty.model_loyalty_program,base.group_portal,1,0,0,0
access_loyalty_history_portal,loyalty.history.portal,loyalty.model_loyalty_history,base.group_portal,1,0,0,0
@ -10,4 +19,3 @@ access_loyalty_reward_portal,loyalty.reward.portal,loyalty.model_loyalty_reward,
access_loyalty_rule_portal,loyalty.rule.portal,loyalty.model_loyalty_rule,base.group_portal,1,0,0,0
access_loyalty_verification_otp_admin,loyalty.verification.otp.admin,model_loyalty_verification_otp,base.group_system,1,1,1,1
access_loyalty_verification_otp_portal,loyalty.verification.otp.portal,model_loyalty_verification_otp,base.group_portal,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_mapan_app_notification_user mapan_app_notification_user model_mapan_app_notification mapan_loyalty_push.group_mapan_loyalty_push_user 1 0 0 0
4 access_mapan_app_notification_manager mapan_app_notification_manager model_mapan_app_notification mapan_loyalty_push.group_mapan_loyalty_push_manager 1 1 1 1
5 access_mapan_app_notification_portal mapan_app_notification_portal model_mapan_app_notification base.group_portal 1 0 0 0
6 access_mapan_app_carousel_user mapan_app_carousel_user model_mapan_app_carousel mapan_loyalty_push.group_mapan_loyalty_push_user 1 0 0 0
7 access_mapan_app_carousel_manager mapan_app_carousel_manager model_mapan_app_carousel mapan_loyalty_push.group_mapan_loyalty_push_manager 1 1 1 1
8 access_mapan_app_carousel_portal mapan_app_carousel_portal model_mapan_app_carousel base.group_portal 1 0 0 0
9 access_mapan_app_promo_user mapan_app_promo_user model_mapan_app_promo mapan_loyalty_push.group_mapan_loyalty_push_user 1 0 0 0
10 access_mapan_app_promo_manager mapan_app_promo_manager model_mapan_app_promo mapan_loyalty_push.group_mapan_loyalty_push_manager 1 1 1 1
11 access_mapan_app_promo_portal mapan_app_promo_portal model_mapan_app_promo base.group_portal 1 0 0 0
12 access_mapan_app_config_user mapan_app_config_user model_mapan_app_config mapan_loyalty_push.group_mapan_loyalty_push_user 1 0 0 0
13 access_mapan_app_config_manager mapan_app_config_manager model_mapan_app_config mapan_loyalty_push.group_mapan_loyalty_push_manager 1 1 1 1
14 access_mapan_app_config_portal mapan_app_config_portal model_mapan_app_config base.group_portal 1 0 0 0
15 access_loyalty_card_portal loyalty.card.portal loyalty.model_loyalty_card base.group_portal 1 0 0 0
16 access_loyalty_program_portal loyalty.program.portal loyalty.model_loyalty_program base.group_portal 1 0 0 0
17 access_loyalty_history_portal loyalty.history.portal loyalty.model_loyalty_history base.group_portal 1 0 0 0
19 access_loyalty_rule_portal loyalty.rule.portal loyalty.model_loyalty_rule base.group_portal 1 0 0 0
20 access_loyalty_verification_otp_admin loyalty.verification.otp.admin model_loyalty_verification_otp base.group_system 1 1 1 1
21 access_loyalty_verification_otp_portal loyalty.verification.otp.portal model_loyalty_verification_otp base.group_portal 1 1 1 1

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- List View -->
<record id="view_mapan_app_carousel_tree" model="ir.ui.view">
<field name="name">mapan.app.carousel.tree</field>
<field name="model">mapan.app.carousel</field>
<field name="arch" type="xml">
<list string="Carousel Slides" editable="top">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="image" widget="image" options="{'size': [48, 48]}"/>
<field name="image_url"/>
<field name="link_url"/>
<field name="is_active"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_mapan_app_carousel_form" model="ir.ui.view">
<field name="name">mapan.app.carousel.form</field>
<field name="model">mapan.app.carousel</field>
<field name="arch" type="xml">
<form string="Carousel Slide">
<sheet>
<div class="oe_button_box" name="button_box"/>
<div class="oe_title">
<h1>
<field name="name" placeholder="Slide Title"/>
</h1>
</div>
<group>
<group>
<field name="sequence"/>
<field name="is_active"/>
<field name="link_url" placeholder="https://..."/>
</group>
<group>
<field name="image" widget="image" options="{'size': [0, 200]}"/>
</group>
</group>
<group string="External Image (Alternative to Uploaded Image)">
<field name="image_url"
placeholder="https://example.com/banner.jpg"
help="If no image is uploaded above, the app will load the image from this URL."/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_mapan_app_carousel" model="ir.actions.act_window">
<field name="name">Carousel Slides</field>
<field name="res_model">mapan.app.carousel</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No carousel slides yet!
</p>
<p>
Add slides to display on the app home screen banner carousel.
Drag the sequence handle to reorder slides.
</p>
</field>
</record>
<menuitem id="menu_mapan_app_carousel"
name="Carousel Slides"
parent="menu_mapan_mobile_app_root"
action="action_mapan_app_carousel"
groups="mapan_loyalty_push.group_mapan_loyalty_push_manager"
sequence="30"/>
<record id="menu_mapan_app_carousel" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [ref('mapan_loyalty_push.group_mapan_loyalty_push_manager')])]"/>
</record>
</odoo>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form View (singleton-style) -->
<record id="view_mapan_app_config_form" model="ir.ui.view">
<field name="name">mapan.app.config.form</field>
<field name="model">mapan.app.config</field>
<field name="arch" type="xml">
<form string="App Settings">
<sheet>
<div class="oe_title">
<h1>Mobile App Settings</h1>
</div>
<group string="Account Menu Links">
<field name="about_us_url"
placeholder="https://yourwebsite.com/about"
help="URL opened when the user taps About Us in the app."/>
<field name="contact_us_url"
placeholder="https://yourwebsite.com/contact"
help="URL opened when the user taps Contact Us in the app."/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Singleton action: always opens the single config record -->
<record id="action_mapan_app_config" model="ir.actions.act_window">
<field name="name">App Settings</field>
<field name="res_model">mapan.app.config</field>
<field name="view_mode">form</field>
<field name="target">inline</field>
<!-- Open existing record if it exists -->
<field name="domain">[]</field>
</record>
<menuitem id="menu_mapan_app_config"
name="App Settings"
parent="menu_mapan_mobile_app_root"
action="action_mapan_app_config"
groups="mapan_loyalty_push.group_mapan_loyalty_push_manager"
sequence="50"/>
<record id="menu_mapan_app_config" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [ref('mapan_loyalty_push.group_mapan_loyalty_push_manager')])]"/>
</record>
</odoo>

View File

@ -5,10 +5,9 @@
<field name="name">mapan.app.notification.tree</field>
<field name="model">mapan.app.notification</field>
<field name="arch" type="xml">
<list string="App Notifications" default_order="create_date desc" create="false" edit="false" delete="false">
<list string="App Notifications" default_order="create_date desc">
<field name="create_date" string="Sent On"/>
<field name="title"/>
<field name="body"/>
<field name="is_global"/>
</list>
</field>
@ -19,27 +18,32 @@
<field name="name">mapan.app.notification.form</field>
<field name="model">mapan.app.notification</field>
<field name="arch" type="xml">
<form string="App Notification" create="false" edit="false" delete="false">
<form string="App Notification">
<sheet>
<div class="oe_title">
<h1>
<field name="title" readonly="1"/>
<field name="title" placeholder="Notification Title"/>
</h1>
</div>
<group>
<group>
<field name="is_global" readonly="1"/>
</group>
<group>
<field name="is_global"/>
<field name="create_date" string="Sent On" readonly="1"/>
</group>
</group>
<group>
<field name="body" readonly="1"/>
<group>
<field name="image" widget="image" options="{'size': [0, 160]}"/>
</group>
</group>
<group string="Sent To" invisible="is_global == True">
<field name="partner_ids" widget="many2many_tags" readonly="1" nolabel="1"/>
<field name="partner_ids" widget="many2many_tags" nolabel="1"
options="{'no_create': True, 'no_open': True}"/>
</group>
<notebook>
<page string="Message Content">
<field name="body" widget="html"
placeholder="Enter the notification message content here..."/>
</page>
</notebook>
</sheet>
</form>
</field>

80
views/app_promo_views.xml Normal file
View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- List View -->
<record id="view_mapan_app_promo_tree" model="ir.ui.view">
<field name="name">mapan.app.promo.tree</field>
<field name="model">mapan.app.promo</field>
<field name="arch" type="xml">
<list string="Promo Highlights" editable="top">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="image" widget="image" options="{'size': [48, 48]}"/>
<field name="date_start"/>
<field name="date_end"/>
<field name="is_active"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_mapan_app_promo_form" model="ir.ui.view">
<field name="name">mapan.app.promo.form</field>
<field name="model">mapan.app.promo</field>
<field name="arch" type="xml">
<form string="Promo Highlight">
<sheet>
<field name="image" widget="image" options="{'size': [0, 180]}"
class="oe_avatar" style="max-width:200px"/>
<div class="oe_title">
<h1>
<field name="name" placeholder="Promo Title"/>
</h1>
</div>
<group>
<group>
<field name="sequence"/>
<field name="is_active"/>
</group>
<group>
<field name="date_start"/>
<field name="date_end"/>
</group>
</group>
<notebook>
<page string="Detail Content">
<field name="body" widget="html"
placeholder="Enter the promo detail content shown when the user taps this card..."/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_mapan_app_promo" model="ir.actions.act_window">
<field name="name">Promo Highlights</field>
<field name="res_model">mapan.app.promo</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No promo highlights yet!
</p>
<p>
Add promo items to display below the carousel on the app home screen.
Users can tap a promo to read the full detail.
</p>
</field>
</record>
<menuitem id="menu_mapan_app_promo"
name="Promo Highlights"
parent="menu_mapan_mobile_app_root"
action="action_mapan_app_promo"
groups="mapan_loyalty_push.group_mapan_loyalty_push_manager"
sequence="40"/>
<record id="menu_mapan_app_promo" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [ref('mapan_loyalty_push.group_mapan_loyalty_push_manager')])]"/>
</record>
</odoo>

View File

@ -6,7 +6,7 @@ class PushNotificationWizard(models.TransientModel):
_description = 'Send Mobile App Notification'
title = fields.Char(string='Notification Title', required=True)
body = fields.Text(string='Notification Body', required=True)
body = fields.Html(string='Notification Body', required=True)
image = fields.Image(
string='Notification Image (optional)',
max_width=1920,

View File

@ -7,7 +7,8 @@
<form string="Send Push Notification">
<group>
<field name="title"/>
<field name="body"/>
<field name="body" widget="html"
placeholder="Enter notification message content..."/>
<field name="image" widget="image" options="{'size': [0, 200]}"
help="Optional promotional image (JPG/PNG). Displayed in the notification detail screen."/>
<field name="recipient_type" widget="radio" options="{'horizontal': true}"/>