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 -*- # -*- coding: utf-8 -*-
{ {
'name': "Mapan Loyalty Push Notifications", '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': """ 'description': """
Integrates Odoo with Firebase Cloud Messaging (FCM) to send push notifications directly Content Management System for the Mie Mapan Loyalty Flutter App.
to customers' Android devices. Maps FCM tokens to res_partner records. 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", 'author': "Suherdy Yacob",
'category': 'Marketing', 'category': 'Marketing',
'version': '1.0', 'version': '1.1',
'depends': ['base', 'loyalty', 'pos_loyalty', 'sale_loyalty'], 'depends': ['base', 'loyalty', 'pos_loyalty', 'sale_loyalty'],
'data': [ 'data': [
'security/mapan_loyalty_push_security.xml', 'security/mapan_loyalty_push_security.xml',
@ -16,6 +21,9 @@
'wizard/push_wizard_views.xml', 'wizard/push_wizard_views.xml',
'views/res_partner_views.xml', 'views/res_partner_views.xml',
'views/app_notification_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, 'installable': True,
'application': False, 'application': False,

View File

@ -2,6 +2,7 @@
from odoo import http from odoo import http
from odoo.http import request from odoo.http import request
def normalize_phone_search(phone): def normalize_phone_search(phone):
if not phone: if not phone:
return [] return []
@ -20,6 +21,7 @@ def normalize_phone_search(phone):
candidates.append('62' + digits) candidates.append('62' + digits)
return list(set(candidates)) return list(set(candidates))
class AppNotificationController(http.Controller): class AppNotificationController(http.Controller):
@http.route('/api/loyalty/fetch_notifications', type='jsonrpc', auth='user', methods=['POST'], csrf=False) @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. Endpoint for the Flutter app Background Task and In-App notification center.
Returns image_128 (base64 thumbnail) for list display. Returns image_128 (base64 thumbnail) for list display.
The Flutter detail screen loads the full image via /web/image/ with session cookie. 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 import base64 as b64mod
user = request.env.user user = request.env.user
@ -58,21 +61,144 @@ class AppNotificationController(http.Controller):
elif not img: elif not img:
notif['image_128'] = None notif['image_128'] = None
notif['has_image'] = bool(notif['image_128']) notif['has_image'] = bool(notif['image_128'])
# body is HTML string — pass through as-is
if notif.get('body') is False:
notif['body'] = ''
return { return {
'status': 'success', 'status': 'success',
'data': notifications '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) @http.route('/api/loyalty/branches', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
def fetch_branches(self, **kw): def fetch_branches(self, **kw):
""" """
Public endpoint for the Flutter app to get branches without exposing API keys. 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: try:
branches = request.env['res.company'].sudo().search_read( branches = request.env['res.company'].sudo().search_read(
[('parent_id', '!=', False)], [('parent_id', '!=', False)],
['name', 'street', 'city', 'phone'], ['name', 'street', 'city', 'phone', 'partner_latitude', 'partner_longitude'],
limit=50 limit=50
) )
return {'status': 'success', 'data': branches} return {'status': 'success', 'data': branches}

View File

@ -1,7 +1,8 @@
from . import res_partner from . import res_partner
from . import app_notification from . import app_notification
from . import app_carousel
from . import app_promo
from . import app_cms_config
from . import res_users from . import res_users
from . import loyalty_card from . import loyalty_card
from . import loyalty_verification_otp 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' _order = 'create_date desc'
title = fields.Char(string='Notification Title', required=True) 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 # Optional promotional image — Odoo auto-generates image_128 thumbnail
image = fields.Image( 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_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_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_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_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_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 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_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_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 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="name">mapan.app.notification.tree</field>
<field name="model">mapan.app.notification</field> <field name="model">mapan.app.notification</field>
<field name="arch" type="xml"> <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="create_date" string="Sent On"/>
<field name="title"/> <field name="title"/>
<field name="body"/>
<field name="is_global"/> <field name="is_global"/>
</list> </list>
</field> </field>
@ -19,27 +18,32 @@
<field name="name">mapan.app.notification.form</field> <field name="name">mapan.app.notification.form</field>
<field name="model">mapan.app.notification</field> <field name="model">mapan.app.notification</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<form string="App Notification" create="false" edit="false" delete="false"> <form string="App Notification">
<sheet> <sheet>
<div class="oe_title"> <div class="oe_title">
<h1> <h1>
<field name="title" readonly="1"/> <field name="title" placeholder="Notification Title"/>
</h1> </h1>
</div> </div>
<group> <group>
<group> <group>
<field name="is_global" readonly="1"/> <field name="is_global"/>
</group>
<group>
<field name="create_date" string="Sent On" readonly="1"/> <field name="create_date" string="Sent On" readonly="1"/>
</group> </group>
</group> <group>
<group> <field name="image" widget="image" options="{'size': [0, 160]}"/>
<field name="body" readonly="1"/> </group>
</group> </group>
<group string="Sent To" invisible="is_global == True"> <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> </group>
<notebook>
<page string="Message Content">
<field name="body" widget="html"
placeholder="Enter the notification message content here..."/>
</page>
</notebook>
</sheet> </sheet>
</form> </form>
</field> </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' _description = 'Send Mobile App Notification'
title = fields.Char(string='Notification Title', required=True) 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( image = fields.Image(
string='Notification Image (optional)', string='Notification Image (optional)',
max_width=1920, max_width=1920,

View File

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