From 3fdf07577fa492c3de843665df968097adb1d906 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Thu, 11 Jun 2026 14:13:20 +0700 Subject: [PATCH] first commit --- .gitignore | 21 ++ README.md | 74 +++++++ README.rst | 108 ++++++++++ __init__.py | 1 + __manifest__.py | 30 +++ data/google_map_review_cron.xml | 12 ++ models/__init__.py | 3 + models/google_map_review.py | 299 ++++++++++++++++++++++++++ models/res_company.py | 39 ++++ models/res_config_settings.py | 29 +++ security/google_map_review_groups.xml | 39 ++++ security/ir.model.access.csv | 3 + static/description/icon.svg | 6 + views/google_map_review_views.xml | 263 ++++++++++++++++++++++ views/res_company_views.xml | 64 ++++++ views/res_config_settings_views.xml | 69 ++++++ 16 files changed, 1060 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 README.rst create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 data/google_map_review_cron.xml create mode 100644 models/__init__.py create mode 100644 models/google_map_review.py create mode 100644 models/res_company.py create mode 100644 models/res_config_settings.py create mode 100644 security/google_map_review_groups.xml create mode 100644 security/ir.model.access.csv create mode 100644 static/description/icon.svg create mode 100644 views/google_map_review_views.xml create mode 100644 views/res_company_views.xml create mode 100644 views/res_config_settings_views.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac198e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo + +# Odoo +*.pyc +.DS_Store + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +*.env.local + +# Logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e6d90f --- /dev/null +++ b/README.md @@ -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 diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..2ac5d3f --- /dev/null +++ b/README.rst @@ -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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..8d6ba47 --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/data/google_map_review_cron.xml b/data/google_map_review_cron.xml new file mode 100644 index 0000000..fa0e613 --- /dev/null +++ b/data/google_map_review_cron.xml @@ -0,0 +1,12 @@ + + + + Google Reviews: Fetch from Business Profile API + + 1 + hours + + code + model.action_fetch_all_reviews() + + diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..9490c3e --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +from . import res_company +from . import google_map_review +from . import res_config_settings diff --git a/models/google_map_review.py b/models/google_map_review.py new file mode 100644 index 0000000..3bdda65 --- /dev/null +++ b/models/google_map_review.py @@ -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) diff --git a/models/res_company.py b/models/res_company.py new file mode 100644 index 0000000..1f05144 --- /dev/null +++ b/models/res_company.py @@ -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}, + } diff --git a/models/res_config_settings.py b/models/res_config_settings.py new file mode 100644 index 0000000..c3fc2bc --- /dev/null +++ b/models/res_config_settings.py @@ -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.' + ), + ) diff --git a/security/google_map_review_groups.xml b/security/google_map_review_groups.xml new file mode 100644 index 0000000..595b845 --- /dev/null +++ b/security/google_map_review_groups.xml @@ -0,0 +1,39 @@ + + + + + + Google Reviews + 50 + + + + Google Reviews + 1 + + + + + User + 10 + + + + + + Manager + 20 + + + + + + + + Google Map Review Multi-Company + + + [('company_id', 'in', company_ids)] + + + diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..0f7bb48 --- /dev/null +++ b/security/ir.model.access.csv @@ -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 diff --git a/static/description/icon.svg b/static/description/icon.svg new file mode 100644 index 0000000..d804fa0 --- /dev/null +++ b/static/description/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/views/google_map_review_views.xml b/views/google_map_review_views.xml new file mode 100644 index 0000000..7ef3e5c --- /dev/null +++ b/views/google_map_review_views.xml @@ -0,0 +1,263 @@ + + + + + + + google.map.review.list + google.map.review + + + + + + + + + + + + + + + + + google.map.review.form + google.map.review + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + google.map.review.kanban + google.map.review + + + + + + + + + + + +
+
+
+ + + + + + +
+ + + +
+
+

+ +

+
+
+ + Owner replied + +
+
+
+
+
+
+
+ + + + google.map.review.search + google.map.review + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + google.map.review.pivot + google.map.review + + + + + + + + + + + + google.map.review.graph + google.map.review + + + + + + + + + + + Google Reviews + google.map.review + list,kanban,pivot,graph,form + + {} + +

+ No Google reviews yet! +

+

+ Set a Google Business Place ID on each company branch, + then trigger the scheduled action or wait for the hourly cron. +

+
+
+ + + + + + + + + + + Reviews by Branch + google.map.review + kanban,list,form + + {'search_default_group_by_company': 1} + + + + + + + Reviews Analysis + google.map.review + pivot,graph,list,form + + {} + + + + +
+
diff --git a/views/res_company_views.xml b/views/res_company_views.xml new file mode 100644 index 0000000..5d0ec98 --- /dev/null +++ b/views/res_company_views.xml @@ -0,0 +1,64 @@ + + + + + + res.company.form.google.review + res.company + + + + + + + + + + + + + + + +
+
    +
  1. + Run list_locations.py from the + google_map_review project on a machine with + browser access. +
  2. +
  3. + Copy the long numeric Location ID shown in the + output for this branch. +
  4. +
  5. + Paste it in the "Google Business Place ID" + field above and save. +
  6. +
  7. + The scheduled crawl will automatically pick + up this branch on its next run. +
  8. +
+
+
+
+
+
+
+
+
+
diff --git a/views/res_config_settings_views.xml b/views/res_config_settings_views.xml new file mode 100644 index 0000000..61c9ac1 --- /dev/null +++ b/views/res_config_settings_views.xml @@ -0,0 +1,69 @@ + + + + + res.config.settings.view.form.inherit.google.map.review + res.config.settings + + + +
+ +

Google Business Reviews

+

+ Configure your Google Cloud OAuth2 credentials to enable + automated review crawling from the Google Business Profile API. + Obtain the refresh token by running authorize.py + from the standalone google_map_review project on any machine + with a browser, then copy the value from token.json. +

+ +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+ + + + +