first commit
This commit is contained in:
commit
3fdf07577f
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
74
README.md
Normal 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
108
README.rst
Normal 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
1
__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import models
|
||||
30
__manifest__.py
Normal file
30
__manifest__.py
Normal 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',
|
||||
}
|
||||
12
data/google_map_review_cron.xml
Normal file
12
data/google_map_review_cron.xml
Normal 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
3
models/__init__.py
Normal 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
299
models/google_map_review.py
Normal 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 1–5.',
|
||||
)
|
||||
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
39
models/res_company.py
Normal 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},
|
||||
}
|
||||
29
models/res_config_settings.py
Normal file
29
models/res_config_settings.py
Normal 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.'
|
||||
),
|
||||
)
|
||||
39
security/google_map_review_groups.xml
Normal file
39
security/google_map_review_groups.xml
Normal 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>
|
||||
3
security/ir.model.access.csv
Normal file
3
security/ir.model.access.csv
Normal 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
|
||||
|
6
static/description/icon.svg
Normal file
6
static/description/icon.svg
Normal 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 |
263
views/google_map_review_views.xml
Normal file
263
views/google_map_review_views.xml
Normal 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 <= 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', '>=', 4)]"/>
|
||||
<filter name="filter_low" string="Low (≤ 2 Stars)"
|
||||
domain="[('rating', '<=', 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', '>=', '=1d')]"/>
|
||||
<filter name="filter_last_30" string="Last 30 Days"
|
||||
domain="[('publish_date', '>=', '-30d')]"/>
|
||||
<filter name="filter_last_90" string="Last 90 Days"
|
||||
domain="[('publish_date', '>=', '-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>
|
||||
64
views/res_company_views.xml
Normal file
64
views/res_company_views.xml
Normal 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>
|
||||
69
views/res_config_settings_views.xml
Normal file
69
views/res_config_settings_views.xml
Normal 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 & 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>
|
||||
Loading…
Reference in New Issue
Block a user