From 2e189dbe6afd4281259536fa1f94bd6cb9d9e501 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Sun, 14 Jun 2026 09:09:57 +0700 Subject: [PATCH] feat: implement CMS modules for carousel, promos, and app config, and enable HTML support for push notifications. --- __manifest__.py | 16 ++- controllers/main.py | 212 ++++++++++++++++++++++++------- models/__init__.py | 5 +- models/app_carousel.py | 32 +++++ models/app_cms_config.py | 27 ++++ models/app_notification.py | 2 +- models/app_promo.py | 42 ++++++ security/ir.model.access.csv | 10 +- views/app_carousel_views.xml | 78 ++++++++++++ views/app_cms_config_views.xml | 46 +++++++ views/app_notification_views.xml | 34 ++--- views/app_promo_views.xml | 80 ++++++++++++ wizard/push_wizard.py | 2 +- wizard/push_wizard_views.xml | 3 +- 14 files changed, 521 insertions(+), 68 deletions(-) create mode 100644 models/app_carousel.py create mode 100644 models/app_cms_config.py create mode 100644 models/app_promo.py create mode 100644 views/app_carousel_views.xml create mode 100644 views/app_cms_config_views.xml create mode 100644 views/app_promo_views.xml diff --git a/__manifest__.py b/__manifest__.py index 24c4dac..37273c6 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -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, diff --git a/controllers/main.py b/controllers/main.py index 751f65f..691a63e 100644 --- a/controllers/main.py +++ b/controllers/main.py @@ -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} @@ -88,10 +214,10 @@ class AppNotificationController(http.Controller): phone = kw.get('phone') phone_or_email = kw.get('phone_or_email') otp_type = kw.get('type') - + if not otp_type: return {'status': 'error', 'message': 'OTP type is required.'} - + if otp_type == 'signup': if not email or not phone: return {'status': 'error', 'message': 'Email and Phone are required for sign-up.'} @@ -102,13 +228,13 @@ class AppNotificationController(http.Controller): ], limit=1) if existing_partner: return {'status': 'error', 'message': 'A member with this phone number is already registered.'} - + existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([ '|', ('login', '=', phone), ('login', 'in', candidates), ('email', '=', email) ], limit=1) if existing_user: return {'status': 'error', 'message': 'A user with this phone number or email already exists.'} - + elif otp_type == 'activation': if not email or not phone: return {'status': 'error', 'message': 'Email and Phone are required for activation.'} @@ -119,21 +245,21 @@ class AppNotificationController(http.Controller): ], limit=1) if not partner: return {'status': 'error', 'message': 'Member with this phone number not found. Please contact support or register.'} - + # Check if user already active user = request.env['res.users'].sudo().with_context(active_test=False).search([('partner_id', '=', partner.id)], limit=1) if user and user.active: return {'status': 'error', 'message': 'Account is already active. Please login or reset password.'} - + # Check if email is already taken by another active user existing_email_user = request.env['res.users'].sudo().search([('email', '=', email), ('partner_id', '!=', partner.id)], limit=1) if existing_email_user: return {'status': 'error', 'message': 'This email address is already registered to another account.'} - + elif otp_type == 'reset_password': if not phone_or_email: return {'status': 'error', 'message': 'Phone number or Email is required.'} - + # Find user user = None if '@' in phone_or_email: @@ -147,23 +273,23 @@ class AppNotificationController(http.Controller): ], limit=1) if partner: user = request.env['res.users'].sudo().search([('partner_id', '=', partner.id)], limit=1) - + if not user: return {'status': 'error', 'message': 'User account not found.'} - + email = user.email if not email or '@miemapan.com' in email or '@' not in email: return {'status': 'error', 'message': 'Your account does not have a valid email set. Please contact support.'} - + phone = user.login - + try: request.env['loyalty.verification.otp'].sudo().generate_otp(email=email, phone=phone, otp_type=otp_type) # Mask email for privacy response parts = email.split('@') masked_email = parts[0][:2] + '*' * (len(parts[0]) - 2) + '@' + parts[1] if len(parts[0]) > 2 else email return { - 'status': 'success', + 'status': 'success', 'message': f'Verification code sent to {masked_email}.', 'email': email } @@ -180,35 +306,35 @@ class AppNotificationController(http.Controller): password = kw.get('password') birth_date = kw.get('birth_date') otp = kw.get('otp') - + if not phone or not password or not email or not otp: return {'status': 'error', 'message': 'Phone, Email, Password, and Verification Code (OTP) are required.'} - + # Verify OTP first otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp( otp_code=otp, email=email, phone=phone, otp_type='activation' ) if not otp_verified: return {'status': 'error', 'message': 'Invalid or expired verification code.'} - + candidates = normalize_phone_search(phone) partner = request.env['res.partner'].sudo().search([ '|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates) ], limit=1) - + if not partner: return {'status': 'error', 'message': 'Member with this phone number not found. Please contact support or register.'} - + # Verify birth date if set on the partner if partner.birth_date: if not birth_date: return {'status': 'error', 'message': 'Birth Date is required for verification.'} if str(partner.birth_date) != str(birth_date): return {'status': 'error', 'message': 'Birth Date does not match our records.'} - + # Update email on partner partner.sudo().write({'email': email}) - + # Find or create user user = request.env['res.users'].sudo().with_context(active_test=False).search([('partner_id', '=', partner.id)], limit=1) if user: @@ -228,7 +354,7 @@ class AppNotificationController(http.Controller): existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([('login', '=', phone)], limit=1) if existing_user: return {'status': 'error', 'message': 'A user with this phone number login already exists.'} - + try: user = portal_template.sudo().with_context(no_reset_password=True).copy({ 'name': partner.name, @@ -250,7 +376,7 @@ class AppNotificationController(http.Controller): user.sudo().write({'group_ids': [(4, portal_group.id)]}) except Exception as e: return {'status': 'error', 'message': f'Failed to create user: {str(e)}'} - + return {'status': 'success', 'message': 'Account activated successfully. You can now login.'} @http.route('/api/loyalty/signup_member', type='jsonrpc', auth='public', methods=['POST'], csrf=False) @@ -265,31 +391,31 @@ class AppNotificationController(http.Controller): gender = kw.get('gender') password = kw.get('password') otp = kw.get('otp') - + if not name or not phone or not email or not password or not otp: return {'status': 'error', 'message': 'Name, Phone, Email, Password, and Verification Code (OTP) are required.'} - + # Verify OTP first otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp( otp_code=otp, email=email, phone=phone, otp_type='signup' ) if not otp_verified: return {'status': 'error', 'message': 'Invalid or expired verification code.'} - + candidates = normalize_phone_search(phone) - + existing_partner = request.env['res.partner'].sudo().search([ '|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates) ], limit=1) if existing_partner: return {'status': 'error', 'message': 'A member with this phone number is already registered.'} - + existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([ '|', ('login', '=', phone), ('login', 'in', candidates), ('email', '=', email) ], limit=1) if existing_user: return {'status': 'error', 'message': 'A user with this phone number or email already exists.'} - + try: partner_vals = { 'name': name, @@ -300,7 +426,7 @@ class AppNotificationController(http.Controller): 'company_id': request.env.company.id, } partner = request.env['res.partner'].sudo().create(partner_vals) - + portal_template = request.env.ref('base.template_portal_user_id') user = portal_template.sudo().with_context(no_reset_password=True).copy({ 'name': partner.name, @@ -320,7 +446,7 @@ class AppNotificationController(http.Controller): portal_group = request.env.ref('base.group_portal') if portal_group not in user.group_ids: user.sudo().write({'group_ids': [(4, portal_group.id)]}) - + return {'status': 'success', 'message': 'Account registered successfully.'} except Exception as e: return {'status': 'error', 'message': f'Registration failed: {str(e)}'} @@ -333,10 +459,10 @@ class AppNotificationController(http.Controller): phone_or_email = kw.get('phone_or_email') otp = kw.get('otp') new_password = kw.get('password') - + if not phone_or_email or not otp or not new_password: return {'status': 'error', 'message': 'Phone/Email, OTP, and New Password are required.'} - + # Find user user = None if '@' in phone_or_email: @@ -350,17 +476,17 @@ class AppNotificationController(http.Controller): ], limit=1) if partner: user = request.env['res.users'].sudo().search([('partner_id', '=', partner.id)], limit=1) - + if not user: return {'status': 'error', 'message': 'User account not found.'} - + # Verify OTP otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp( otp_code=otp, email=user.email, phone=user.login, otp_type='reset_password' ) if not otp_verified: return {'status': 'error', 'message': 'Invalid or expired verification code.'} - + try: user.sudo().write({ 'password': new_password, @@ -377,19 +503,19 @@ class AppNotificationController(http.Controller): """ user = request.env.user password = kw.get('password') - + if not password: return {'status': 'error', 'message': 'Password is required.'} - + try: user.with_user(user)._check_credentials({'type': 'password', 'password': password}, {'interactive': True}) except Exception: return {'status': 'error', 'message': 'Incorrect password.'} - + partner = user.partner_id - + user.sudo().write({'active': False}) - + partner.sudo().write({ 'name': f'Deleted Member {partner.id}', 'phone': False, @@ -398,10 +524,10 @@ class AppNotificationController(http.Controller): 'gender': False, 'active': False, }) - + partner.loyalty_card_ids.sudo().write({ 'active': False, 'points': 0.0 }) - + return {'status': 'success', 'message': 'Account deleted successfully.'} diff --git a/models/__init__.py b/models/__init__.py index 44c88b8..d3eb7ca 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -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 - - diff --git a/models/app_carousel.py b/models/app_carousel.py new file mode 100644 index 0000000..70c4d7d --- /dev/null +++ b/models/app_carousel.py @@ -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.' + ) diff --git a/models/app_cms_config.py b/models/app_cms_config.py new file mode 100644 index 0000000..347d5da --- /dev/null +++ b/models/app_cms_config.py @@ -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 diff --git a/models/app_notification.py b/models/app_notification.py index f76a411..320bb24 100644 --- a/models/app_notification.py +++ b/models/app_notification.py @@ -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( diff --git a/models/app_promo.py b/models/app_promo.py new file mode 100644 index 0000000..0fb7bb3 --- /dev/null +++ b/models/app_promo.py @@ -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.') diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv index 2cb7df4..db6be50 100644 --- a/security/ir.model.access.csv +++ b/security/ir.model.access.csv @@ -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 - diff --git a/views/app_carousel_views.xml b/views/app_carousel_views.xml new file mode 100644 index 0000000..0a5775c --- /dev/null +++ b/views/app_carousel_views.xml @@ -0,0 +1,78 @@ + + + + + mapan.app.carousel.tree + mapan.app.carousel + + + + + + + + + + + + + + + mapan.app.carousel.form + mapan.app.carousel + +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + Carousel Slides + mapan.app.carousel + list,form + +

+ No carousel slides yet! +

+

+ Add slides to display on the app home screen banner carousel. + Drag the sequence handle to reorder slides. +

+
+
+ + + + + + + diff --git a/views/app_cms_config_views.xml b/views/app_cms_config_views.xml new file mode 100644 index 0000000..84d6243 --- /dev/null +++ b/views/app_cms_config_views.xml @@ -0,0 +1,46 @@ + + + + + mapan.app.config.form + mapan.app.config + +
+ +
+

Mobile App Settings

+
+ + + + +
+
+
+
+ + + + App Settings + mapan.app.config + form + inline + + [] + + + + + + + +
diff --git a/views/app_notification_views.xml b/views/app_notification_views.xml index 8860141..1cab7a6 100644 --- a/views/app_notification_views.xml +++ b/views/app_notification_views.xml @@ -5,10 +5,9 @@ mapan.app.notification.tree mapan.app.notification - + - @@ -19,27 +18,32 @@ mapan.app.notification.form mapan.app.notification -
+

- +

- - - + - - - + + + - + + + + + +
@@ -60,10 +64,10 @@ - diff --git a/views/app_promo_views.xml b/views/app_promo_views.xml new file mode 100644 index 0000000..e1aacc8 --- /dev/null +++ b/views/app_promo_views.xml @@ -0,0 +1,80 @@ + + + + + mapan.app.promo.tree + mapan.app.promo + + + + + + + + + + + + + + + mapan.app.promo.form + mapan.app.promo + +
+ + +
+

+ +

+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + + Promo Highlights + mapan.app.promo + list,form + +

+ No promo highlights yet! +

+

+ Add promo items to display below the carousel on the app home screen. + Users can tap a promo to read the full detail. +

+
+
+ + + + + + +
diff --git a/wizard/push_wizard.py b/wizard/push_wizard.py index d8c0115..7debfca 100644 --- a/wizard/push_wizard.py +++ b/wizard/push_wizard.py @@ -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, diff --git a/wizard/push_wizard_views.xml b/wizard/push_wizard_views.xml index c53e060..9a7534c 100644 --- a/wizard/push_wizard_views.xml +++ b/wizard/push_wizard_views.xml @@ -7,7 +7,8 @@
- +