first commit

This commit is contained in:
Suherdy Yacob 2026-06-11 14:13:20 +07:00
commit 3fdf07577f
16 changed files with 1060 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# Python
__pycache__/
*.py[cod]
*.pyo
# Odoo
*.pyc
.DS_Store
# IDE
.vscode/
.idea/
*.swp
*.swo
# Environment
.env
*.env.local
# Logs
*.log

74
README.md Normal file
View File

@ -0,0 +1,74 @@
# Google Map Review — Odoo 19 Custom Module
**Author:** Suherdy Yacob | **Version:** 19.0.1.0.0
## Overview
Integrates the Google Business Profile API into Odoo 19 to automatically crawl
customer reviews for each branch company and display them as structured records.
## Features
- `google_business_id` field on `res.company` (Google Business Location ID)
- Hourly scheduled cron fetching reviews via **Google Business Profile API (My Business v4)**
- Review records with: author, rating (★★★★★), text, owner reply, date, language
- List / Form / Kanban reporting views
- Smart button on company form showing review count
- General Settings section for OAuth2 credentials (Client ID, Client Secret, Refresh Token)
## Authentication Strategy
Uses **OAuth2 refresh token** stored in Odoo General Settings.
The cron exchanges the refresh token for a fresh access token on each run — no browser needed.
## One-Time Setup
### 1. Google Cloud Console
1. Create/select a Google Cloud project.
2. Enable: **My Business API**, **Business Profile API**.
3. Create an OAuth 2.0 Client ID (Desktop or Web app type).
4. Note the **Client ID** and **Client Secret**.
### 2. Get Refresh Token
Run `authorize.py` from the `google_map_review` standalone project on any machine with a browser:
```bash
cd /path/to/google_map_review
python authorize.py
```
Copy the `refresh_token` value from the generated `token.json`.
### 3. Configure Odoo
Go to **Settings → General Settings → Google Business Reviews**
Enter Client ID, Client Secret, and Refresh Token → Save.
### 4. Set Place ID on Each Branch
Run `list_locations.py` to find location IDs, then:
**Settings → Companies → [Company] → Google Reviews tab → Google Business Place ID**
### 5. Run the Crawler
Runs automatically every hour, or trigger manually:
**Settings → Technical → Scheduled Actions → Google Reviews: Fetch from Business Profile API → Run Manually**
## Views
| Menu | View |
|------|------|
| Google Reviews → All Reviews | List (color-coded by rating) |
| Google Reviews → By Branch (Kanban) | Kanban grouped by company |
| Company form → Smart button | Filtered list for that branch |
## Dependencies
- Odoo modules: `base`, `mail`
- Python: `requests` (standard in Odoo)
## License
LGPL-3

108
README.rst Normal file
View File

@ -0,0 +1,108 @@
Google Map Review
=================
**Author:** Suherdy Yacob
**Version:** 19.0.1.0.0
**Category:** Reporting
Overview
--------
This module integrates the Google Business Profile API into Odoo 19.
It automatically crawls customer reviews for each branch company and
displays them as structured records inside Odoo.
Features
--------
- Google Business Place ID field on each ``res.company`` record
- Hourly scheduled cron (configurable) to fetch reviews via the
Google Business Profile API (My Business API v4)
- Review records stored and linked to each branch company
- Owner reply stored alongside each review
- List, Form, and Kanban reporting views
- Search filters: by branch, rating, date range, reply status
- Group-by: branch, rating, month, language
- Smart button on company form showing review count
- General Settings section for OAuth2 credentials
Authentication Strategy
-----------------------
The module uses OAuth2 with a long-lived **refresh token** stored in
Odoo's General Settings. This allows the headless cron job to obtain
fresh access tokens without any browser interaction.
One-Time Setup
--------------
Step 1 — Google Cloud Console
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1. Create (or use existing) Google Cloud project.
2. Enable: **My Business API**, **Business Profile API**.
3. Go to **APIs & Services → Credentials → Create OAuth 2.0 Client ID**
(Application type: Desktop app or Web app).
4. Download or note your **Client ID** and **Client Secret**.
Step 2 — Obtain Refresh Token
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Run the existing ``authorize.py`` script from the
``google_map_review`` standalone project on any machine with a browser::
cd /path/to/google_map_review
python authorize.py
After completing the OAuth2 flow in the browser, open the generated
``token.json`` file and copy the ``refresh_token`` value.
Step 3 — Configure Odoo
~~~~~~~~~~~~~~~~~~~~~~~~~
1. Go to **Settings → General Settings → Google Business Reviews**.
2. Enter **Client ID**, **Client Secret**, and **Refresh Token**.
3. Click **Save**.
Step 4 — Set Place ID on Each Branch
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1. Run ``list_locations.py`` to list all your Google Business locations.
2. Open each company in **Settings → Companies**.
3. Go to the **Google Reviews** tab.
4. Paste the Location ID into **Google Business Place ID**.
5. Save.
Step 5 — Run the Crawler
~~~~~~~~~~~~~~~~~~~~~~~~~
The hourly cron runs automatically. To run it manually:
1. Go to **Settings → Technical → Scheduled Actions**.
2. Find **Google Reviews: Fetch from Business Profile API**.
3. Click **Run Manually**.
Reporting
---------
Access reviews via the **Google Reviews** top-level app menu:
- **All Reviews** — list view with color-coded rows (green=5★, red=1-2★)
- **By Branch (Kanban)** — kanban grouped by company, showing star ratings
Filter and group by rating, branch, month, or language directly from
the search bar.
Dependencies
------------
- ``base``
- ``mail``
- Python ``requests`` library (standard in Odoo environment)
License
-------
LGPL-3

1
__init__.py Normal file
View File

@ -0,0 +1 @@
from . import models

30
__manifest__.py Normal file
View File

@ -0,0 +1,30 @@
{
'name': 'Google Map Review',
'version': '19.0.1.0.0',
'summary': 'Crawl and display Google Business Profile reviews per branch company',
'description': """
Integrates Google Business Profile API to automatically crawl customer reviews
for each branch (res.company) and display them inside Odoo.
Features:
- Google Business Place ID field on each company
- Hourly scheduled cron to fetch reviews via Business Profile API
- Review records linked to each company
- List, Form, Kanban reporting views
- General Settings section for OAuth2 credentials
""",
'author': 'Suherdy Yacob',
'category': 'Reporting',
'depends': ['base', 'mail'],
'data': [
'security/google_map_review_groups.xml',
'security/ir.model.access.csv',
'data/google_map_review_cron.xml',
'views/res_config_settings_views.xml',
'views/res_company_views.xml',
'views/google_map_review_views.xml',
],
'installable': True,
'application': True,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="ir_cron_google_map_review_fetch" model="ir.cron">
<field name="name">Google Reviews: Fetch from Business Profile API</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="model_id" ref="model_google_map_review"/>
<field name="state">code</field>
<field name="code">model.action_fetch_all_reviews()</field>
</record>
</odoo>

3
models/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from . import res_company
from . import google_map_review
from . import res_config_settings

299
models/google_map_review.py Normal file
View File

@ -0,0 +1,299 @@
import logging
import requests
from datetime import datetime, timedelta, timezone
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
RATING_MAP = {
'ONE': 1,
'TWO': 2,
'THREE': 3,
'FOUR': 4,
'FIVE': 5,
}
STAR_DISPLAY = {
1: '★☆☆☆☆',
2: '★★☆☆☆',
3: '★★★☆☆',
4: '★★★★☆',
5: '★★★★★',
}
class GoogleMapReview(models.Model):
_name = 'google.map.review'
_description = 'Google Business Profile Review'
_order = 'publish_date desc'
_rec_name = 'name'
name = fields.Char(
string='Review',
compute='_compute_name',
store=True,
)
company_id = fields.Many2one(
'res.company',
string='Branch / Company',
required=True,
ondelete='cascade',
index=True,
)
review_id = fields.Char(
string='Google Review ID',
index=True,
copy=False,
help='Unique review identifier returned by the Google Business Profile API.',
)
author_name = fields.Char(string='Reviewer', index=True)
rating = fields.Float(
string='Average Rating',
group_operator='avg',
digits=(16, 1),
help='Star rating 15.',
)
rating_display = fields.Char(
string='Stars',
compute='_compute_rating_display',
store=True,
)
review_text = fields.Text(string='Review Text')
reply_text = fields.Text(
string="Owner's Reply",
help='The business owner reply fetched from Google.',
)
publish_date = fields.Datetime(string='Published', index=True)
language = fields.Char(string='Language', size=10)
@api.depends('author_name', 'publish_date')
def _compute_name(self):
for rec in self:
date_str = rec.publish_date.strftime('%Y-%m-%d') if rec.publish_date else ''
rec.name = f"{rec.author_name or 'Unknown'} {date_str}"
@api.depends('rating')
def _compute_rating_display(self):
for rec in self:
rec.rating_display = STAR_DISPLAY.get(int(rec.rating or 0), '')
# ------------------------------------------------------------------
# OAuth2 helpers
# ------------------------------------------------------------------
def _get_api_credentials(self):
"""Return (client_id, client_secret, refresh_token) from system params."""
ICP = self.env['ir.config_parameter'].sudo()
client_id = ICP.get_param('google_map_review.client_id', '')
client_secret = ICP.get_param('google_map_review.client_secret', '')
refresh_token = ICP.get_param('google_map_review.refresh_token', '')
if not all([client_id, client_secret, refresh_token]):
raise UserError(_(
'Google Business Profile credentials are not configured. '
'Please go to Settings → General Settings → Google Business Reviews '
'and fill in the Client ID, Client Secret and Refresh Token.'
))
return client_id, client_secret, refresh_token
def _get_access_token(self):
"""Exchange refresh_token for a fresh access_token."""
client_id, client_secret, refresh_token = self._get_api_credentials()
resp = requests.post(
'https://oauth2.googleapis.com/token',
data={
'grant_type': 'refresh_token',
'client_id': client_id,
'client_secret': client_secret,
'refresh_token': refresh_token,
},
timeout=30,
)
resp.raise_for_status()
token_data = resp.json()
access_token = token_data.get('access_token')
if not access_token:
raise UserError(_('Failed to obtain access token from Google: %s') % resp.text)
return access_token
def _get_auth_headers(self, access_token):
return {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
'Accept-Language': 'id',
}
def _get_account_name(self, headers):
url = 'https://mybusinessaccountmanagement.googleapis.com/v1/accounts'
resp = requests.get(url, headers=headers, timeout=30)
resp.raise_for_status()
accounts = resp.json().get('accounts', [])
if not accounts:
raise UserError(_('No Google Business accounts found for the provided credentials.'))
return accounts[0]['name']
# ------------------------------------------------------------------
# Text cleaning helpers
# ------------------------------------------------------------------
@staticmethod
def _clean_review_text(comment):
"""Strip Google-translated prefixes/suffixes from review text."""
if not comment:
return comment
if '(Original)' in comment:
parts = comment.split('(Original)')
if len(parts) > 1:
return parts[-1].strip()
if '(Diterjemahkan oleh Google)' in comment:
parts = comment.split('\n\n(Diterjemahkan oleh Google)')
if len(parts) > 1:
return parts[0].strip()
parts = comment.split('(Diterjemahkan oleh Google)')
return parts[0].strip()
return comment
# ------------------------------------------------------------------
# Main crawl method (called by cron)
# ------------------------------------------------------------------
def action_fetch_all_reviews(self):
"""
Cron entry-point. Fetches reviews from the Google Business Profile API
for every res.company that has a google_business_id set.
Only reviews published within the last 90 days are processed.
"""
try:
access_token = self._get_access_token()
except UserError as e:
_logger.error('Google Map Review: %s', e)
return
except Exception as e:
_logger.error('Google Map Review: Failed to get access token: %s', e)
return
headers = self._get_auth_headers(access_token)
try:
account_name = self._get_account_name(headers)
except Exception as e:
_logger.error('Google Map Review: Failed to get account name: %s', e)
return
companies = self.env['res.company'].sudo().search([
('google_business_id', '!=', False),
('google_business_id', '!=', ''),
])
if not companies:
_logger.info('Google Map Review: No companies with google_business_id found.')
return
cutoff = datetime.now(timezone.utc) - timedelta(days=90)
_logger.info('Google Map Review: Processing %d companies. Cutoff: %s UTC',
len(companies), cutoff.strftime('%Y-%m-%d'))
total_upserted = 0
for company in companies:
location_id = company.google_business_id.strip()
base_url = (
f'https://mybusiness.googleapis.com/v4/'
f'{account_name}/locations/{location_id}/reviews'
)
page_token = None
company_upserted = 0
company_skipped = 0
stop_pagination = False
_logger.info('Google Map Review: Crawling "%s" (location: %s)',
company.name, location_id)
while not stop_pagination:
params = {}
if page_token:
params['pageToken'] = page_token
try:
response = requests.get(base_url, headers=headers,
params=params, timeout=30)
response.raise_for_status()
data = response.json()
except Exception as e:
_logger.warning(
'Google Map Review: Error fetching reviews for "%s": %s',
company.name, e,
)
break
reviews = data.get('reviews', [])
if not reviews:
break
for review in reviews:
# Parse publish time
create_time_str = review.get('createTime', '')
publish_time = None
if create_time_str:
try:
clean = create_time_str.split('.')[0].replace('Z', '')
publish_time = datetime.strptime(
clean, '%Y-%m-%dT%H:%M:%S'
).replace(tzinfo=timezone.utc)
except ValueError:
pass
# 90-day filter
if publish_time and publish_time < cutoff:
company_skipped += 1
stop_pagination = True
continue
review_id = review.get('reviewId')
comment = self._clean_review_text(review.get('comment', ''))
author_name = review.get('reviewer', {}).get('displayName', 'Unknown')
rating_str = review.get('starRating', '')
rating = RATING_MAP.get(rating_str, 0)
# Owner reply
reply_comment = review.get('reviewReply', {}).get('comment', '')
reply_text = reply_comment.strip() if reply_comment else False
# Upsert
publish_dt = publish_time.replace(tzinfo=None) if publish_time else False
existing = self.sudo().search([
('review_id', '=', review_id),
('company_id', '=', company.id),
], limit=1)
vals = {
'company_id': company.id,
'review_id': review_id,
'author_name': author_name,
'rating': rating,
'review_text': comment,
'reply_text': reply_text,
'publish_date': publish_dt,
}
if existing:
existing.sudo().write(vals)
else:
self.sudo().create(vals)
company_upserted += 1
total_upserted += 1
page_token = data.get('nextPageToken')
if not page_token:
break
self.env.cr.commit()
_logger.info(
'Google Map Review: "%s"%d upserted, %d skipped (>90 days)',
company.name, company_upserted, company_skipped,
)
_logger.info('Google Map Review: Crawl complete. Total upserted: %d', total_upserted)

39
models/res_company.py Normal file
View File

@ -0,0 +1,39 @@
from odoo import models, fields, api
class ResCompany(models.Model):
_inherit = 'res.company'
google_business_id = fields.Char(
string='Google Business Place ID',
help=(
'The Google Business Profile Location ID for this branch. '
'You can find it by running list_locations.py from the '
'google_map_review crawler project, or from the Google Business '
'Profile dashboard. Example: 12345678901234567890'
),
)
google_review_count = fields.Integer(
string='Google Reviews',
compute='_compute_google_review_count',
)
@api.depends('google_business_id')
def _compute_google_review_count(self):
Review = self.env['google.map.review']
for company in self:
company.google_review_count = Review.search_count([
('company_id', '=', company.id),
])
def action_view_google_reviews(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Google Reviews {self.name}',
'res_model': 'google.map.review',
'view_mode': 'list,kanban,form',
'domain': [('company_id', '=', self.id)],
'context': {'default_company_id': self.id},
}

View File

@ -0,0 +1,29 @@
from odoo import models, fields, api
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
google_map_review_client_id = fields.Char(
string='Client ID',
config_parameter='google_map_review.client_id',
help=(
'OAuth2 Client ID from your Google Cloud Console project. '
'Found in: APIs & Services → Credentials → OAuth 2.0 Client IDs.'
),
)
google_map_review_client_secret = fields.Char(
string='Client Secret',
config_parameter='google_map_review.client_secret',
help='OAuth2 Client Secret from your Google Cloud Console project.',
)
google_map_review_refresh_token = fields.Char(
string='Refresh Token',
config_parameter='google_map_review.refresh_token',
help=(
'Long-lived OAuth2 refresh token. '
'Obtain by running authorize.py from the google_map_review '
'standalone project, then copy the refresh_token value from '
'the generated token.json file.'
),
)

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="module_category_google_map_review" model="ir.module.category">
<!-- Defining a new category for the app -->
<field name="name">Google Reviews</field>
<field name="sequence">50</field>
</record>
<record id="res_groups_privilege_google_map_review" model="res.groups.privilege">
<field name="name">Google Reviews</field>
<field name="sequence">1</field>
<field name="category_id" ref="module_category_google_map_review"/>
</record>
<record id="group_google_review_user" model="res.groups">
<field name="name">User</field>
<field name="sequence">10</field>
<field name="privilege_id" ref="res_groups_privilege_google_map_review"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="group_google_review_manager" model="res.groups">
<field name="name">Manager</field>
<field name="sequence">20</field>
<field name="privilege_id" ref="res_groups_privilege_google_map_review"/>
<field name="implied_ids" eval="[(4, ref('group_google_review_user'))]"/>
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
<!-- Multi-Company Rule: Users can only see reviews from their allowed companies -->
<record id="google_map_review_rule_multi_company" model="ir.rule">
<field name="name">Google Map Review Multi-Company</field>
<field name="model_id" ref="model_google_map_review"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_google_map_review_manager,google.map.review.manager,model_google_map_review,google_map_review.group_google_review_manager,1,1,1,1
access_google_map_review_user,google.map.review.user,model_google_map_review,google_map_review.group_google_review_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_google_map_review_manager google.map.review.manager model_google_map_review google_map_review.group_google_review_manager 1 1 1 1
3 access_google_map_review_user google.map.review.user model_google_map_review google_map_review.group_google_review_user 1 0 0 0

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 90" width="90" height="90">
<rect width="90" height="90" rx="18" fill="#4285F4"/>
<text x="45" y="60" font-size="50" text-anchor="middle" fill="#FBC02D" font-family="Arial"></text>
</svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@ -0,0 +1,263 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- ====================== LIST VIEW ====================== -->
<record id="view_google_map_review_list" model="ir.ui.view">
<field name="name">google.map.review.list</field>
<field name="model">google.map.review</field>
<field name="arch" type="xml">
<list string="Google Reviews" default_order="publish_date desc"
decoration-success="rating == 5"
decoration-info="rating == 4"
decoration-warning="rating == 3"
decoration-danger="rating &lt;= 2">
<field name="publish_date" string="Date" optional="show"/>
<field name="company_id" string="Branch" optional="show"/>
<field name="author_name" string="Reviewer" optional="show"/>
<field name="rating_display" string="Rating" optional="show"/>
<field name="rating" string="Stars (num)" optional="hide"/>
<field name="review_text" string="Review" optional="show"/>
<field name="language" string="Language" optional="hide"/>
<field name="reply_text" string="Owner Reply" optional="hide"/>
</list>
</field>
</record>
<!-- ====================== FORM VIEW ====================== -->
<record id="view_google_map_review_form" model="ir.ui.view">
<field name="name">google.map.review.form</field>
<field name="model">google.map.review</field>
<field name="arch" type="xml">
<form string="Google Review">
<sheet>
<div class="oe_title">
<h1>
<field name="rating_display" readonly="1"
style="font-size:2rem; color:#f4c430;"/>
</h1>
</div>
<group>
<group string="Review Details">
<field name="company_id"/>
<field name="author_name"/>
<field name="publish_date"/>
<field name="language"/>
</group>
<group string="Rating">
<field name="rating"
widget="priority"
readonly="1"/>
<field name="review_id" string="Google Review ID"/>
</group>
</group>
<notebook>
<page string="Review Text" name="review_text_page">
<field name="review_text"
readonly="1"
style="min-height:120px;"/>
</page>
<page string="Owner Reply" name="reply_page"
invisible="not reply_text">
<field name="reply_text"
readonly="1"
style="min-height:80px;"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ====================== KANBAN VIEW ====================== -->
<record id="view_google_map_review_kanban" model="ir.ui.view">
<field name="name">google.map.review.kanban</field>
<field name="model">google.map.review</field>
<field name="arch" type="xml">
<kanban default_group_by="company_id"
class="o_kanban_small_column"
quick_create="false">
<field name="company_id"/>
<field name="author_name"/>
<field name="rating"/>
<field name="rating_display"/>
<field name="review_text"/>
<field name="publish_date"/>
<field name="reply_text"/>
<templates>
<t t-name="card">
<div t-attf-class="oe_kanban_card oe_kanban_global_click
#{record.rating.raw_value >= 4 ? 'border-success' :
record.rating.raw_value == 3 ? 'border-warning' :
'border-danger'}">
<div class="o_kanban_record_top mb4">
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title">
<field name="author_name"/>
</strong>
<span class="o_kanban_record_subtitle text-muted">
<field name="publish_date"
widget="date"/>
</span>
</div>
<span style="font-size:1.1rem; color:#f4c430;">
<field name="rating_display"/>
</span>
</div>
<div class="o_kanban_record_body">
<p class="text-muted" style="font-size:0.85rem;
max-height:80px;
overflow:hidden;">
<field name="review_text"/>
</p>
</div>
<div t-if="record.reply_text.raw_value"
class="o_kanban_record_bottom mt4">
<small class="text-info">
<i class="fa fa-reply"/> Owner replied
</small>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ====================== SEARCH VIEW ====================== -->
<record id="view_google_map_review_search" model="ir.ui.view">
<field name="name">google.map.review.search</field>
<field name="model">google.map.review</field>
<field name="arch" type="xml">
<search string="Search Google Reviews">
<field name="author_name" string="Reviewer"/>
<field name="company_id" string="Branch"/>
<field name="review_text" string="Review Text"/>
<filter name="filter_5_stars" string="5 Stars (★★★★★)"
domain="[('rating', '=', 5)]"/>
<filter name="filter_4_stars" string="4 Stars"
domain="[('rating', '=', 4)]"/>
<filter name="filter_high" string="High (≥ 4 Stars)"
domain="[('rating', '&gt;=', 4)]"/>
<filter name="filter_low" string="Low (≤ 2 Stars)"
domain="[('rating', '&lt;=', 2)]"/>
<separator/>
<filter name="filter_has_reply" string="Has Owner Reply"
domain="[('reply_text', '!=', False)]"/>
<filter name="filter_no_reply" string="No Owner Reply"
domain="[('reply_text', '=', False)]"/>
<separator/>
<filter name="filter_this_month" string="This Month"
domain="[('publish_date', '&gt;=', '=1d')]"/>
<filter name="filter_last_30" string="Last 30 Days"
domain="[('publish_date', '&gt;=', '-30d')]"/>
<filter name="filter_last_90" string="Last 90 Days"
domain="[('publish_date', '&gt;=', '-90d')]"/>
<group>
<filter name="group_by_company" string="Branch"
context="{'group_by': 'company_id'}"/>
<filter name="group_by_rating" string="Rating"
context="{'group_by': 'rating'}"/>
<filter name="group_by_month" string="Month"
context="{'group_by': 'publish_date:month'}"/>
<filter name="group_by_language" string="Language"
context="{'group_by': 'language'}"/>
</group>
</search>
</field>
</record>
<!-- ====================== PIVOT VIEW ====================== -->
<record id="view_google_map_review_pivot" model="ir.ui.view">
<field name="name">google.map.review.pivot</field>
<field name="model">google.map.review</field>
<field name="arch" type="xml">
<pivot string="Reviews Analysis" sample="1">
<field name="company_id" type="row"/>
<field name="publish_date" interval="month" type="col"/>
<field name="rating" type="measure"/>
</pivot>
</field>
</record>
<!-- ====================== GRAPH VIEW ====================== -->
<record id="view_google_map_review_graph" model="ir.ui.view">
<field name="name">google.map.review.graph</field>
<field name="model">google.map.review</field>
<field name="arch" type="xml">
<graph string="Reviews Analysis" type="bar" sample="1">
<field name="company_id" type="row"/>
<field name="rating" type="measure"/>
</graph>
</field>
</record>
<!-- ====================== ACTIONS ====================== -->
<record id="action_google_map_review" model="ir.actions.act_window">
<field name="name">Google Reviews</field>
<field name="res_model">google.map.review</field>
<field name="view_mode">list,kanban,pivot,graph,form</field>
<field name="search_view_id" ref="view_google_map_review_search"/>
<field name="context">{}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No Google reviews yet!
</p>
<p>
Set a <b>Google Business Place ID</b> on each company branch,
then trigger the scheduled action or wait for the hourly cron.
</p>
</field>
</record>
<!-- ====================== MENUS ====================== -->
<!-- Top-level app menu -->
<menuitem id="menu_google_review_root"
name="Google Reviews"
web_icon="google_map_review,static/description/icon.svg"
groups="google_map_review.group_google_review_user"
sequence="100"/>
<!-- Sub-menu: Reviews list -->
<menuitem id="menu_google_review_list"
name="All Reviews"
parent="menu_google_review_root"
action="action_google_map_review"
sequence="10"/>
<!-- Sub-menu: Reporting (kanban grouped by company) -->
<record id="action_google_map_review_report" model="ir.actions.act_window">
<field name="name">Reviews by Branch</field>
<field name="res_model">google.map.review</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_google_map_review_search"/>
<field name="context">{'search_default_group_by_company': 1}</field>
</record>
<menuitem id="menu_google_review_report"
name="By Branch (Kanban)"
parent="menu_google_review_root"
action="action_google_map_review_report"
sequence="20"/>
<!-- Sub-menu: Reporting (pivot analysis) -->
<record id="action_google_map_review_pivot_report" model="ir.actions.act_window">
<field name="name">Reviews Analysis</field>
<field name="res_model">google.map.review</field>
<field name="view_mode">pivot,graph,list,form</field>
<field name="search_view_id" ref="view_google_map_review_search"/>
<field name="context">{}</field>
</record>
<menuitem id="menu_google_review_pivot_report"
name="Reviews Analysis"
parent="menu_google_review_root"
action="action_google_map_review_pivot_report"
sequence="30"/>
</data>
</odoo>

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!--
Inherit res.company form view:
- Add "Google Reviews" tab with the Place ID field
- Add smart button showing total review count
-->
<record id="view_company_form_google_review" model="ir.ui.view">
<field name="name">res.company.form.google.review</field>
<field name="model">res.company</field>
<field name="inherit_id" ref="base.view_company_form"/>
<field name="arch" type="xml">
<!-- Smart button area -->
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button"
type="object"
name="action_view_google_reviews"
icon="fa-star"
invisible="not google_business_id">
<field name="google_review_count"
widget="statinfo"
string="Google Reviews"/>
</button>
</xpath>
<!-- New tab: Google Reviews -->
<xpath expr="//page[@name='general_info']" position="after">
<page name="google_reviews_tab" string="Google Reviews">
<group>
<group string="Google Business Profile">
<field name="google_business_id"
placeholder="e.g. 12345678901234567890"/>
</group>
<group string="How to find your Place ID">
<div colspan="2" class="text-muted">
<ol>
<li>
Run <code>list_locations.py</code> from the
google_map_review project on a machine with
browser access.
</li>
<li>
Copy the long numeric Location ID shown in the
output for this branch.
</li>
<li>
Paste it in the "Google Business Place ID"
field above and save.
</li>
<li>
The scheduled crawl will automatically pick
up this branch on its next run.
</li>
</ol>
</div>
</group>
</group>
</page>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="res_config_settings_view_form_google_review" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.google.map.review</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<div class="app_settings_block" data-string="Google Reviews"
string="Google Reviews" data-key="google_map_review">
<h2>Google Business Reviews</h2>
<p class="text-muted">
Configure your Google Cloud OAuth2 credentials to enable
automated review crawling from the Google Business Profile API.
Obtain the refresh token by running <code>authorize.py</code>
from the standalone google_map_review project on any machine
with a browser, then copy the value from <code>token.json</code>.
</p>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="google_map_review_client_id" string="Client ID"/>
<div class="text-muted">
OAuth2 Client ID from Google Cloud Console →
APIs &amp; Services → Credentials.
</div>
<field name="google_map_review_client_id"
placeholder="xxxx.apps.googleusercontent.com"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="google_map_review_client_secret" string="Client Secret"/>
<div class="text-muted">
OAuth2 Client Secret for your project.
</div>
<field name="google_map_review_client_secret"
password="True"
placeholder="GOCSPX-…"/>
</div>
</div>
<div class="col-12 col-lg-12 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="google_map_review_refresh_token" string="Refresh Token"/>
<div class="text-muted">
Long-lived OAuth2 refresh token. Run
<code>authorize.py</code> once, then copy
<code>refresh_token</code> from <code>token.json</code>.
</div>
<field name="google_map_review_refresh_token"
password="True"
placeholder="1//0g…"/>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</data>
</odoo>