diff --git a/contributors_github/README.rst b/contributors_github/README.rst new file mode 100644 index 0000000..390c561 --- /dev/null +++ b/contributors_github/README.rst @@ -0,0 +1,123 @@ +=================== +Contributors Github +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a8e0f9c1d4e4a52ce80f62adac679c2c5ee3902079c4f2a398abbff7c6cebd92 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA--contributors%2Fcontributors--module-lightgray.png?logo=github + :target: https://github.com/OCA-contributors/contributors-module/tree/18.0/contributors_github + :alt: OCA-contributors/contributors-module + +|badge1| |badge2| |badge3| + +This module allows to get data from github and show it in your odoo +instance. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Access the Contributors / Organization module. + +Add your organization and one ore more API Keys (it might be faster if +you add several ones). + +Update the repositories. + +The system will start fetching the data automatically. + +First Load +---------- + +Usually, the first load can take a lot of time and you could find time +constrains on your Odoo database. For this reason we recommend to stop +the cron in this first load and execute the following code in your shell +once you have all the repositories created: + +.. code:: python + + from datetime import date, datetime, timedelta + import time + self = self.env["contributors.organization"].search([], limit=1) # Use the organization you prefer + clients = self._get_clients() + FIXED_DATE = "2026-01-01" + repositories = self.env["contributors.repository"].search([("from_date", "<", FIXED_DATE), ("organization_id", "=", self.id)], order="from_date asc") + i = 0 + while repositories: + try: + for repository in repositories: + i = (i+1) % len(clients) + repository.update_information(client_for_search=i) + self.env.cr.commit() + except Exception: + date = False + for client in clients: + rate = client.rate_limit() + print(rate) + for code in rate["resources"]: + if rate["resources"][code]["remaining"] == 0: + date = max(date or 0, rate["resources"][code]["reset"]) + if not date: + raise + print("%s - Sleeping for %s seconds" % (datetime.now().isoformat(), date - time.mktime(datetime.now().timetuple()))) + time.sleep(date - time.mktime(datetime.now().timetuple())) + time.sleep(10) + repositories = self.env["contributors.repository"].search([("from_date", "<", FIXED_DATE), ("organization_id", "=", self.id)], order="from_date asc") + +Once it has finished, restart the cron. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Dixmit + +Contributors +------------ + +- `Dixmit `__ + + - Enric Tobella + +Maintainers +----------- + +.. |maintainer-etobella| image:: https://github.com/etobella.png?size=40px + :target: https://github.com/etobella + :alt: etobella + +Current maintainer: + +|maintainer-etobella| + +This module is part of the `OCA-contributors/contributors-module `_ project on GitHub. + +You are welcome to contribute. diff --git a/contributors_github/__init__.py b/contributors_github/__init__.py new file mode 100644 index 0000000..91c5580 --- /dev/null +++ b/contributors_github/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/contributors_github/__manifest__.py b/contributors_github/__manifest__.py new file mode 100644 index 0000000..e4a8a9c --- /dev/null +++ b/contributors_github/__manifest__.py @@ -0,0 +1,38 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Contributors Github", + "summary": """Get Contributors and Contributions from Github""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Dixmit, Odoo Community Association (OCA)", + "website": "https://github.com/OCA-contributors/contributors-module", + "maintainers": ["etobella"], + "depends": ["portal", "website_partner"], + "data": [ + "security/ir.model.access.csv", + "views/menu.xml", + "views/contributors_comment.xml", + "views/contributors_review.xml", + "views/contributors_branch.xml", + "views/contributors_pull_request.xml", + "views/contributors_repository.xml", + "views/contributors_organization.xml", + "views/res_partner.xml", + "data/ir_cron.xml", + "templates/templates.xml", + ], + "external_dependencies": {"python": ["github3.py", "PyYAML"]}, + "demo": [], + "assets": { + "web.assets_frontend": [ + "contributors_github/static/src/components/**/*.esm.js", + "contributors_github/static/src/components/**/*.xml", + "contributors_github/static/src/components/**/*.scss", + ], + "web.assets_tests": [ + "contributors_github/static/tests/**/*", + ], + }, +} diff --git a/contributors_github/controllers/__init__.py b/contributors_github/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/contributors_github/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/contributors_github/controllers/main.py b/contributors_github/controllers/main.py new file mode 100644 index 0000000..65f848b --- /dev/null +++ b/contributors_github/controllers/main.py @@ -0,0 +1,216 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from math import sqrt + +from markupsafe import Markup + +from odoo import _, http +from odoo.http import request + +from odoo.addons.portal.controllers.portal import CustomerPortal + + +class ContributorsController(CustomerPortal): + @http.route( + [ + "/contributors", + "/contributors/", + ], + type="http", + auth="user", + website=True, + ) + def contributors_organization(self, organization=None): + values = self._prepare_portal_layout_values() + values.update(self._prepare_home_portal_values([])) + if organization is None: + organizations = request.env["contributors.organization"].search([]) + return request.render( + "contributors_github.contributors_template", + {"organizations": organizations, **values}, + ) + organization_id = ( + request.env["contributors.organization"] + .sudo() + .search([("name", "=ilike", organization)], limit=1) + .id + ) + return request.render( + "contributors_github.contributors_organization_template", + {"organization": organization_id, **values}, + ) + + def _get_index(self, data): + return round( + sqrt(data["created_pull_requests"]) + + data["merged_pull_requests"] + + sqrt(data["comments"]) + + data["reviews"], + 2, + ) + + def _get_field(self, kind): + if kind == "contributors": + return "partner_id" + elif kind == "organizations": + return "organization_id" + elif kind == "repositories": + return "repository_id" + return False + + @http.route(["/contributors/fetch"], type="json", auth="user", readonly=True) + def fetch_data(self, organization_id, year, month, kind, period, **values): + organization = ( + request.env["contributors.organization"].browse(organization_id).exists() + ) + if not organization: + return [] + start, end = organization._get_dates(year, month, period, **values) + data = organization._generate_data( + start, end, self._get_field(kind), kind, **values + ) + return { + "columns": self._get_columns(kind), + "data": self._improve_data(data, kind, **values), + } + + def _get_columns(self, kind): + if kind == "contributors": + return [ + {"field": "name", "title": _("Name"), "kind": "name"}, + { + "field": "index", + "title": _("Contributor Index"), + "kind": "float", + "decimals": 2, + "tooltip": Markup( + request.env["ir.qweb"]._render( + "contributors_github.contributor_index_tooltip", {} + ) + ), + }, + { + "field": "created_pull_requests", + "title": _("Created Pull Requests"), + "kind": "float", + "decimals": 0, + }, + { + "field": "merged_pull_requests", + "title": _("Merged Pull Requests"), + "kind": "float", + "decimals": 0, + }, + { + "field": "comments", + "title": _("Comments"), + "kind": "float", + "decimals": 0, + }, + { + "field": "reviews", + "title": _("Reviews"), + "kind": "float", + "decimals": 0, + }, + ] + elif kind == "organizations": + return [ + {"field": "name", "title": _("Organization Name"), "kind": "name"}, + { + "field": "created_pull_requests", + "title": _("Created Pull Requests"), + "kind": "float", + "decimals": 0, + }, + { + "field": "merged_pull_requests", + "title": _("Merged Pull Requests"), + "kind": "float", + "decimals": 0, + }, + { + "field": "comments", + "title": _("Comments"), + "kind": "float", + "decimals": 0, + }, + { + "field": "reviews", + "title": _("Reviews"), + "kind": "float", + "decimals": 0, + }, + { + "field": "developers", + "title": _("Developers"), + "kind": "float", + "decimals": 0, + }, + ] + elif kind == "repositories": + return [ + {"field": "name", "title": _("Repository Name"), "kind": "name"}, + { + "field": "index", + "title": _("Repository Index"), + "kind": "float", + "decimals": 2, + "tooltip": Markup( + request.env["ir.qweb"]._render( + "contributors_github.contributor_index_tooltip", {} + ) + ), + }, + { + "field": "created_pull_requests", + "title": _("Created Pull Requests"), + "kind": "float", + "decimals": 0, + }, + { + "field": "merged_pull_requests", + "title": _("Merged Pull Requests"), + "kind": "float", + "decimals": 0, + }, + { + "field": "comments", + "title": _("Comments"), + "kind": "float", + "decimals": 0, + }, + { + "field": "reviews", + "title": _("Reviews"), + "kind": "float", + "decimals": 0, + }, + { + "field": "developers", + "title": _("Developers"), + "kind": "float", + "decimals": 0, + }, + ] + return [] + + def _improve_data(self, data, kind, **kwargs): + for key, values in data.items(): + if kind == "contributors": + partner = request.env["res.partner"].browse(key) + values["name"] = partner._get_contributors_name(kind, **kwargs) + values["url"] = partner._get_contributor_url() + values["index"] = self._get_index(values) + elif kind == "organizations": + organization = request.env["res.partner"].browse(key) + values["name"] = organization._get_contributors_name(kind, **kwargs) + values["url"] = organization._get_contributor_url() + elif kind == "repositories": + repository = request.env["contributors.repository"].browse(key) + values["name"] = repository.name + github_name = f"{repository.organization_id.name}/{repository.name}" + values["url"] = f"https://github.com/{github_name}" + values["index"] = self._get_index(values) + return data diff --git a/contributors_github/data/ir_cron.xml b/contributors_github/data/ir_cron.xml new file mode 100644 index 0000000..5420b5a --- /dev/null +++ b/contributors_github/data/ir_cron.xml @@ -0,0 +1,23 @@ + + + + + GitHub Repository Update + + code + model._cron_update_repositories() + 1 + minutes + False + + + GitHub Organization Update + + code + model._cron_update_organizations() + 1 + days + False + + diff --git a/contributors_github/models/__init__.py b/contributors_github/models/__init__.py new file mode 100644 index 0000000..2cccabd --- /dev/null +++ b/contributors_github/models/__init__.py @@ -0,0 +1,7 @@ +from . import res_partner +from . import contributors_organization +from . import contributors_repository +from . import contributors_pull_request +from . import contributors_branch +from . import contributors_review +from . import contributors_comment diff --git a/contributors_github/models/contributors_branch.py b/contributors_github/models/contributors_branch.py new file mode 100644 index 0000000..29421f0 --- /dev/null +++ b/contributors_github/models/contributors_branch.py @@ -0,0 +1,19 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ContributorsBranch(models.Model): + _name = "contributors.branch" + _description = "Contributors Branch" # TODO + + name = fields.Char(required=True) + organization_id = fields.Many2one( + comodel_name="contributors.organization", + string="Organization", + required=True, + ) + _sql_constraints = [ + ("name_uniq", "unique(name, organization_id)", "Branch name must be unique.") + ] diff --git a/contributors_github/models/contributors_comment.py b/contributors_github/models/contributors_comment.py new file mode 100644 index 0000000..59e4741 --- /dev/null +++ b/contributors_github/models/contributors_comment.py @@ -0,0 +1,36 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ContributorsComment(models.Model): + _name = "contributors.comment" + _description = "Contributors Comment" # TODO + + github_id = fields.Char(readonly=True, required=True, index=True) + body = fields.Html(readonly=True) + partner_id = fields.Many2one( + comodel_name="res.partner", + readonly=True, + ) + organization_id = fields.Many2one( + related="pull_request_id.organization_id", + readonly=True, + store=True, + ) + repository_id = fields.Many2one( + related="pull_request_id.repository_id", + readonly=True, + store=True, + ) + created_at = fields.Datetime(readonly=True) + updated_at = fields.Datetime(readonly=True) + pull_request_id = fields.Many2one( + comodel_name="contributors.pull.request", + string="Pull Request", + readonly=True, + ) + _sql_constraints = [ + ("github_id_uniq", "unique(github_id)", "GitHub ID must be unique.") + ] diff --git a/contributors_github/models/contributors_organization.py b/contributors_github/models/contributors_organization.py new file mode 100644 index 0000000..ed1dfcf --- /dev/null +++ b/contributors_github/models/contributors_organization.py @@ -0,0 +1,245 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import base64 +import logging +from collections import defaultdict +from datetime import datetime +from math import sqrt + +import github3 +import requests +from dateutil.relativedelta import relativedelta + +from odoo import fields, models, tools + +_logger = logging.getLogger(__name__) + + +class ContributorsOrganization(models.Model): + _name = "contributors.organization" + _description = "Contributors Organization" # TODO + + name = fields.Char(required=True) + description = fields.Char(readonly=True) + short_description = fields.Char(readonly=True) + branch_ids = fields.One2many( + comodel_name="contributors.branch", + inverse_name="organization_id", + string="Branches", + readonly=True, + ) + repository_ids = fields.One2many( + comodel_name="contributors.repository", + inverse_name="organization_id", + string="Repositories", + readonly=True, + ) + key_ids = fields.One2many( + comodel_name="contributors.organization.key", + inverse_name="organization_id", + string="API Keys", + ) + last_update = fields.Datetime(readonly=True) + active = fields.Boolean(default=True) + update_interval_days = fields.Integer(default=3) + image_1920 = fields.Image() + image_128 = fields.Image( + max_width=128, + max_height=128, + store=True, + related="image_1920", + string="Image 128", + ) + image_64 = fields.Image( + max_width=64, max_height=64, store=True, related="image_1920", string="Image 64" + ) + + def _get_clients(self): + git = [] + for key in self.key_ids: + git.append(github3.login(token=key.name)) + return git + + def update_information(self): + self.ensure_one() + clients = self._get_clients() + org = clients[0].organization(self.name) + self.short_description = org.name + self.description = org.description + if org.avatar_url: + response = requests.get(org.avatar_url, timeout=10) + response.raise_for_status() + self.image_1920 = base64.b64encode(response.content) + repos = org.repositories() + for repo in repos: + self.env["contributors.repository"]._update_repository(repo, self) + self.last_update = fields.Datetime.now() + + def _cron_update_organizations(self): + for organization in self.search([("key_ids", "!=", False)]): + try: + organization.update_information() + except Exception as e: + _logger.error( + "Error updating organization %s: %s", organization.name, str(e) + ) + + @tools.ormcache("self.id", "name") + def _get_branch(self, name): + branch = self.env["contributors.branch"].search( + [("organization_id", "=", self.id), ("name", "=", name)], + limit=1, + ) + if not branch: + branch = ( + self.env["contributors.branch"] + .sudo() + .create( + { + "organization_id": self.id, + "name": name, + } + ) + ) + return branch.id + + def _get_merged_domain(self, start, end, **values): + return [ + ("repository_id.organization_id", "in", self.ids), + ("is_merged", "=", True), + ("closed_at", ">=", start), + ("closed_at", "<", end), + ] + + def _get_created_domain(self, start, end, **values): + return [ + ("repository_id.organization_id", "in", self.ids), + ("created_at", ">=", start), + ("created_at", "<", end), + ] + + def _get_comments_domain(self, start, end, **values): + return [ + ("pull_request_id.repository_id.organization_id", "in", self.ids), + ("created_at", ">=", start), + ("created_at", "<", end), + ] + + def _get_reviews_domain(self, start, end, **values): + return [ + ("pull_request_id.repository_id.organization_id", "in", self.ids), + ("submitted_at", ">=", start), + ("submitted_at", "<", end), + ] + + def _get_index(self, data): + return round( + sqrt(data["created_pull_requests"]) + + data["merged_pull_requests"] + + sqrt(data["comments"]) + + data["reviews"], + 2, + ) + + def _get_default_data(self, start, end, field, kind, **values): + return { + "name": "", + "github_name": "", + "index": 0, + "created_pull_requests": 0, + "merged_pull_requests": 0, + "comments": 0, + "reviews": 0, + "developers": 0, + } + + def _generate_data(self, start, end, field, kind, extra_domain=None, **values): + if extra_domain is None: + extra_domain = [] + default_dict = self._get_default_data(start, end, field, kind, **values) + data = defaultdict(lambda: default_dict.copy()) + if not field: + return data + for merged in ( + self.env["contributors.pull.request"] + .sudo() + .read_group( + self._get_merged_domain(start, end, **values) + + extra_domain + + [(field, "!=", False)], + [field], + [field], + ) + ): + data[merged[field][0]]["merged_pull_requests"] = merged[f"{field}_count"] + for pr in ( + self.env["contributors.pull.request"] + .sudo() + .read_group( + self._get_created_domain(start, end, **values) + + extra_domain + + [(field, "!=", False)], + [field, "partner_id:count_distinct"] + if field != "partner_id" + else [field], + [field], + ) + ): + data[pr[field][0]]["created_pull_requests"] = pr[f"{field}_count"] + if field != "partner_id": + data[pr[field][0]]["developers"] = pr["partner_id"] + for comment in ( + self.env["contributors.comment"] + .sudo() + .read_group( + self._get_comments_domain(start, end, **values) + + extra_domain + + [(field, "!=", False)], + [field], + [field], + ) + ): + data[comment[field][0]]["comments"] = comment[f"{field}_count"] + for review in ( + self.env["contributors.review"] + .sudo() + .read_group( + self._get_reviews_domain(start, end, **values) + + extra_domain + + [(field, "!=", False)], + [field], + [field], + ) + ): + data[review[field][0]]["reviews"] = review[f"{field}_count"] + return data + + def _get_dates(self, year, month, period, **values): + if month == 12: + end = datetime(year + 1, 1, 1, 0, 0, 0) + else: + end = datetime(year, month + 1, 1, 0, 0, 0) + if period == "YTD": + start = datetime(year, 1, 1, 0, 0, 0) + elif period == "MAT": + start = end - relativedelta(years=1) + else: + start = datetime(year, month, 1, 0, 0, 0) + return start, end + + +class ContributorsOrganizationKey(models.Model): + _name = "contributors.organization.key" + _description = "Contributors Organization API Key" # TODO + + organization_id = fields.Many2one( + comodel_name="contributors.organization", + string="Organization", + required=True, + ondelete="cascade", + ) + name = fields.Char(required=True) + + _sql_constraints = [ + ("name_uniq", "unique(name, organization_id)", "API Key must be unique.") + ] diff --git a/contributors_github/models/contributors_pull_request.py b/contributors_github/models/contributors_pull_request.py new file mode 100644 index 0000000..5b440ee --- /dev/null +++ b/contributors_github/models/contributors_pull_request.py @@ -0,0 +1,68 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models, tools + + +class ContributorsPullRequest(models.Model): + _name = "contributors.pull.request" + _description = "Contributors Pull Request" # TODO + + github_id = fields.Char(string="GitHub ID", readonly=True, index=True) + name = fields.Char(readonly=True) + partner_id = fields.Many2one( + comodel_name="res.partner", + string="Contributor", + readonly=True, + ) + repository_id = fields.Many2one( + comodel_name="contributors.repository", + readonly=True, + ondelete="cascade", + ) + branch_id = fields.Many2one( + comodel_name="contributors.branch", + readonly=True, + ondelete="restrict", + ) + organization_id = fields.Many2one( + comodel_name="res.partner", + readonly=True, + ) + url = fields.Char(readonly=True) + state = fields.Char(readonly=True) + is_merged = fields.Boolean(readonly=True) + created_at = fields.Datetime(readonly=True) + updated_at = fields.Datetime(readonly=True) + closed_at = fields.Datetime(readonly=True) + number = fields.Integer(readonly=True) + label_ids = fields.Many2many( + comodel_name="contributors.pull.request.label", + string="Labels", + readonly=True, + ) + commits = fields.Integer(readonly=True) + additions = fields.Integer(readonly=True) + deletions = fields.Integer(readonly=True) + total_comments = fields.Integer(readonly=True) + review_comments = fields.Integer(readonly=True) + + _sql_constraints = [ + ("github_id_uniq", "unique(github_id)", "GitHub ID must be unique.") + ] + + +class ContributorsPullRequestLabel(models.Model): + _name = "contributors.pull.request.label" + _description = "Contributors Pull Request Label" # TODO + + name = fields.Char(required=True) + + _sql_constraints = [("name_uniq", "unique(name)", "Label name must be unique.")] + + @tools.ormcache("name") + def _get_label(self, name): + label = self.search([("name", "=", name)], limit=1) + if not label: + label = self.sudo().create({"name": name}) + return label.id diff --git a/contributors_github/models/contributors_repository.py b/contributors_github/models/contributors_repository.py new file mode 100644 index 0000000..00207f2 --- /dev/null +++ b/contributors_github/models/contributors_repository.py @@ -0,0 +1,247 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging +from datetime import datetime, timedelta + +import github3 +from github3 import pulls +from pytz import UTC + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class ContributorsRepository(models.Model): + _name = "contributors.repository" + _description = "Contributors Repository" + + name = fields.Char(required=True, index=True) + description = fields.Char(readonly=True) + organization_id = fields.Many2one( + comodel_name="contributors.organization", + string="Organization", + required=True, + ) + created_at = fields.Datetime(readonly=True) + stargazers_count = fields.Integer(readonly=True) + fork_count = fields.Integer(readonly=True) + watchers_count = fields.Integer(readonly=True) + from_date = fields.Datetime(readonly=True, required=True) + pull_request_ids = fields.One2many( + "contributors.pull.request", inverse_name="repository_id" + ) + pull_request_count = fields.Integer(compute="_compute_pull_request_count") + active = fields.Boolean(default=True) + + @api.depends("pull_request_ids") + def _compute_pull_request_count(self): + for record in self: + record.pull_request_count = len(record.pull_request_ids) + + def parse_pr(self, pr, client): + origin_data = pr.as_dict() + comments_url = pr.comments_url + comments_req = client.session.get(comments_url) + comments = comments_req.json() + while comments_req.links.get("next"): + comments_url = comments_req.links["next"]["url"] + comments_req = client.session.get(comments_url) + comments += comments_req.json() + reviews_url = pr.reviews().url + reviews_req = client.session.get(reviews_url) + reviews = reviews_req.json() + while reviews_req.links.get("next"): + reviews_url = reviews_req.links["next"]["url"] + reviews_req = client.session.get(reviews_url) + reviews += reviews_req.json() + return ( + str(pr.id), + { + "partner_id": self.env["res.partner"]._get_github_user(pr.user, client), + "repository_id": self.id, + "branch_id": self.organization_id._get_branch(pr.base.ref), + "organization_id": self.env["res.partner"]._get_github_organization( + pr.head.repo[0], client + ), + "url": pr.html_url, + "state": pr.state, + "name": pr.title, + "is_merged": any(label["name"] == "merged 🎉" for label in pr.labels) + or pr.is_merged(), + "created_at": self._parse_date(origin_data["created_at"]), + "closed_at": self._parse_date(origin_data["closed_at"]), + "number": pr.number, + "updated_at": self._parse_date(origin_data["updated_at"]), + "label_ids": [fields.Command.clear()] + + [ + fields.Command.link( + self.env["contributors.pull.request.label"]._get_label( + label["name"] + ) + ) + for label in origin_data["labels"] + ], + "commits": origin_data["commits"], + "total_comments": origin_data["comments"], + "review_comments": origin_data["review_comments"], + "additions": origin_data["additions"], + "deletions": origin_data["deletions"], + }, + [ + { + "id": str(c["id"]), + "partner_id": c.get("user") + and self.env["res.partner"]._get_github_user( + c["user"].get("login"), client + ), + "body": c["body"], + "created_at": self._parse_date(c["created_at"]), + "updated_at": self._parse_date(c["updated_at"]), + } + for c in comments + ], + [ + { + "id": str(r["id"]), + "partner_id": r.get("user") + and self.env["res.partner"]._get_github_user( + r["user"].get("login"), client + ), + "body": r["body"], + "submitted_at": self._parse_date(r.get("submitted_at")), + "state": r["state"]["keyword"] + if isinstance(r["state"], dict) + else r["state"], + } + for r in reviews + ], + ) + + def force_update_information(self): + self.update_information(update_interval_days=365) + + def update_information(self, update_interval_days=None, client_for_search=0): + self.ensure_one() + clients = self.organization_id._get_clients() + try: + start = UTC.localize(self.from_date) + end = min( + start + + timedelta( + days=update_interval_days + or self.organization_id.update_interval_days + ), + UTC.localize(datetime.now()), + ) + start += timedelta( + days=-1 + ) # Add buffer day to avoid missing PRs on boundary dates + i = client_for_search % len(clients) + for pr in clients[i].search_issues( + f"is:pr repo:{self.organization_id.name}/{self.name} " + f"updated:{start.isoformat()}..{end.isoformat()}" + ): + i = (1 + i) % len(clients) + pr_id, pr_data, comments, reviews = self.parse_pr( + clients[i]._instance_or_null( + pulls.PullRequest, + clients[i]._json( + pr.issue._get(pr.issue.pull_request_urls.get("url")), 200 + ), + ), + clients[i], + ) + opr = self.env["contributors.pull.request"].search( + [("github_id", "=", pr_id)], limit=1 + ) + if not opr: + opr = ( + self.env["contributors.pull.request"] + .sudo() + .create({"github_id": pr_id, **pr_data}) + ) + else: + opr.sudo().write(pr_data) + for comment in comments: + comment_id = comment.pop("id") + ocomment = self.env["contributors.comment"].search( + [("github_id", "=", comment_id)], limit=1 + ) + if not ocomment: + self.env["contributors.comment"].sudo().create( + { + "github_id": comment_id, + "pull_request_id": opr.id, + **comment, + } + ) + else: + ocomment.sudo().write(comment) + for review in reviews: + review_id = review.pop("id") + oreview = self.env["contributors.review"].search( + [("github_id", "=", review_id)], limit=1 + ) + if not oreview: + self.env["contributors.review"].sudo().create( + { + "github_id": review_id, + "pull_request_id": opr.id, + **review, + } + ) + else: + oreview.sudo().write(review) + self.sudo().from_date = end.replace(tzinfo=None) + except github3.exceptions.ForbiddenError as e: + _logger.error(e) + rate = clients[i].rate_limit() + reset = fields.Datetime.to_string( + datetime.utcfromtimestamp(rate["resources"]["core"]["reset"]) + ) + raise ValidationError(self.env._(f"Reset on {reset}")) from e + + def _parse_date(self, date): + if not date: + return False + return UTC.normalize( + datetime.fromisoformat(date.replace("Z", "+00:00")) + ).replace(tzinfo=None) + + def _update_repository(self, repo, organization): + vals = { + "created_at": self._parse_date(repo.created_at), + "stargazers_count": repo.stargazers_count, + "fork_count": repo.forks_count, + "watchers_count": repo.watchers_count, + "description": repo.description, + } + repository = self.search( + [ + ("name", "=", repo.name), + ("organization_id", "=", organization.id), + ], + limit=1, + ) + if not repository: + repository = ( + self.env["contributors.repository"] + .sudo() + .create( + { + "name": repo.name, + "organization_id": organization.id, + "from_date": vals.get("created_at"), + **vals, + } + ) + ) + else: + repository.sudo().write(vals) + + def _cron_update_repositories(self, limit=1): + repositories = self.search([], limit=limit, order="from_date ASC") + for repository in repositories: + repository.update_information() diff --git a/contributors_github/models/contributors_review.py b/contributors_github/models/contributors_review.py new file mode 100644 index 0000000..bf615fa --- /dev/null +++ b/contributors_github/models/contributors_review.py @@ -0,0 +1,33 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ContributorsReview(models.Model): + _name = "contributors.review" + _description = "Contributors Review" # TODO + + github_id = fields.Char(readonly=True, required=True, index=True) + body = fields.Html(readonly=True) + state = fields.Char(readonly=True) + partner_id = fields.Many2one("res.partner", readonly=True) + submitted_at = fields.Datetime(readonly=True) + pull_request_id = fields.Many2one( + "contributors.pull.request", + readonly=True, + ) + organization_id = fields.Many2one( + related="pull_request_id.organization_id", + readonly=True, + store=True, + ) + repository_id = fields.Many2one( + related="pull_request_id.repository_id", + readonly=True, + store=True, + ) + + _sql_constraints = [ + ("github_id_uniq", "unique(github_id)", "GitHub ID must be unique.") + ] diff --git a/contributors_github/models/res_partner.py b/contributors_github/models/res_partner.py new file mode 100644 index 0000000..201e916 --- /dev/null +++ b/contributors_github/models/res_partner.py @@ -0,0 +1,179 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import github3 + +from odoo import api, fields, models, tools + + +class ResPartner(models.Model): + _inherit = "res.partner" + + github_name = fields.Char( + string="GitHub Username", + help="GitHub username of the contributor or organization", + readonly=True, + ) + github_organization = fields.Boolean( + string="Is GitHub Organization", + help="Check if this partner represents a GitHub organization", + readonly=True, + ) + github_user = fields.Boolean( + string="Is GitHub User", + help="Check if this partner represents a GitHub user", + readonly=True, + ) + github_merged_pull_requests = fields.Integer( + compute="_compute_github_contributions", + string="Merged Pull Requests", + prefetch=False, + ) + github_created_pull_requests = fields.Integer( + compute="_compute_github_contributions", + string="Created Pull Requests", + prefetch=False, + ) + github_comments = fields.Integer( + compute="_compute_github_contributions", string="Comments", prefetch=False + ) + github_reviews = fields.Integer( + compute="_compute_github_contributions", string="Reviews", prefetch=False + ) + + _sql_constraints = [ + ( + "github_name_uniq", + "unique(github_name)", + "The GitHub username must be unique across partners.", + ), + ] + + @api.depends("github_name") + def _compute_github_contributions(self): + self.filtered(lambda p: p.github_user)._compute_github_contributions_field( + "partner_id" + ) + self.filtered(lambda p: not p.github_user)._compute_github_contributions_field( + "organization_id" + ) + + @api.model + def _get_contributors_field_map(self): + return { + "github_merged_pull_requests": "merged_pull_requests", + "github_created_pull_requests": "created_pull_requests", + "github_comments": "comments", + "github_reviews": "reviews", + } + + def _compute_github_contributions_field(self, field): + today = fields.Date.today() + start, end = self.env["contributors.organization"]._get_dates( + today.year, today.month, "MAT" + ) + data = ( + self.env["contributors.organization"] + .search([]) + ._generate_data( + start=start, end=end, field=field, kind="user", extra_domain=[] + ) + ) + field_map = self._get_contributors_field_map() + for partner in self: + partner.update( + {key: data[partner.id].get(field_map[key], 0) for key in field_map} + ) + + @tools.ormcache("github_login") + def _get_github_user_id(self, github_login): + partner = self.with_context(active_test=False).search( + [("github_name", "=ilike", github_login)], limit=1 + ) + if not partner: + return False + if not partner.github_user: + partner.github_user = True + return partner.id + + @tools.ormcache("github_login") + def _get_github_organization_id(self, github_login): + partner = self.with_context(active_test=False).search( + [("github_name", "=ilike", github_login)], limit=1 + ) + if not partner: + return False + if not partner.github_organization: + partner.github_organization = True + return partner.id + + def _get_github_user(self, gh, client): + if not gh: + return False + if isinstance(gh, github3.users.User): + github_login = gh.login + else: + github_login = str(gh) + partner = self._get_github_user_id(github_login) + if partner: + return partner + if isinstance(gh, str): + try: + gh = client.user(github_login) + name = gh.name or github_login + except github3.exceptions.NotFoundError: + name = gh + else: + if hasattr(gh, "name"): + name = gh.name or github_login + else: + name = github_login + self.env.registry.clear_cache() + return self.create( + { + "name": name, + "github_name": github_login, + "github_user": True, + } + ).id + + def _get_github_organization(self, gh, client): + if not gh: + return False + partner = self._get_github_organization_id(str(gh)) + if partner: + return partner + self.env.registry.clear_cache() + try: + org = client.organization(str(gh)) + + return self.create( + { + "name": org.name or str(gh), + "github_name": str(gh), + "github_organization": True, + } + ).id + except github3.exceptions.NotFoundError: + user = client.user(str(gh)) + name = user.name or str(gh) + except github3.exceptions.ForbiddenError: + name = str(gh) + return self.create( + { + "name": name, + "github_name": str(gh), + "github_user": True, + "github_organization": True, + } + ).id + + def _get_contributor_url(self): + if self.is_published and self.website_url: + return self.website_url + if self.github_name: + return f"https://github.com/{self.github_name}" + return False + + def _get_contributors_name(self, kind, **kwargs): + return self.name diff --git a/contributors_github/pyproject.toml b/contributors_github/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/contributors_github/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/contributors_github/readme/CONTRIBUTORS.md b/contributors_github/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..2c066ba --- /dev/null +++ b/contributors_github/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Dixmit](https://www.dixmit.com) + - Enric Tobella diff --git a/contributors_github/readme/DESCRIPTION.md b/contributors_github/readme/DESCRIPTION.md new file mode 100644 index 0000000..24b67f4 --- /dev/null +++ b/contributors_github/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module allows to get data from github and show it in your odoo instance. diff --git a/contributors_github/readme/USAGE.md b/contributors_github/readme/USAGE.md new file mode 100644 index 0000000..c6b6dc3 --- /dev/null +++ b/contributors_github/readme/USAGE.md @@ -0,0 +1,44 @@ +Access the Contributors / Organization module. + +Add your organization and one ore more API Keys (it might be faster if you add several ones). + +Update the repositories. + +The system will start fetching the data automatically. + +## First Load + +Usually, the first load can take a lot of time and you could find time constrains on your Odoo database. +For this reason we recommend to stop the cron in this first load and execute the following code in your shell once you have all the repositories created: + +``` python +from datetime import date, datetime, timedelta +import time +self = self.env["contributors.organization"].search([], limit=1) # Use the organization you prefer +clients = self._get_clients() +FIXED_DATE = "2026-01-01" +repositories = self.env["contributors.repository"].search([("from_date", "<", FIXED_DATE), ("organization_id", "=", self.id)], order="from_date asc") +i = 0 +while repositories: + try: + for repository in repositories: + i = (i+1) % len(clients) + repository.update_information(client_for_search=i) + self.env.cr.commit() + except Exception: + date = False + for client in clients: + rate = client.rate_limit() + print(rate) + for code in rate["resources"]: + if rate["resources"][code]["remaining"] == 0: + date = max(date or 0, rate["resources"][code]["reset"]) + if not date: + raise + print("%s - Sleeping for %s seconds" % (datetime.now().isoformat(), date - time.mktime(datetime.now().timetuple()))) + time.sleep(date - time.mktime(datetime.now().timetuple())) + time.sleep(10) + repositories = self.env["contributors.repository"].search([("from_date", "<", FIXED_DATE), ("organization_id", "=", self.id)], order="from_date asc") +``` + +Once it has finished, restart the cron. diff --git a/contributors_github/security/ir.model.access.csv b/contributors_github/security/ir.model.access.csv new file mode 100644 index 0000000..eaf4351 --- /dev/null +++ b/contributors_github/security/ir.model.access.csv @@ -0,0 +1,11 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_organization,Access Organization,model_contributors_organization,base.group_user,1,0,0,0 +access_organization_portal,Access Organization,model_contributors_organization,base.group_portal,1,0,0,0 +manage_organization,Manage Organization,model_contributors_organization,base.group_system,1,1,1,0 +manage_organization_key,Manage Organization Keys,model_contributors_organization_key,base.group_system,1,1,1,1 +access_branch,Access Branch,model_contributors_branch,base.group_user,1,0,0,0 +access_repository,Access Repository,model_contributors_repository,base.group_user,1,0,0,0 +access_pull_request,Access Pull Requests,model_contributors_pull_request,base.group_user,1,0,0,0 +access_pull_request_label,Access Pull Requests Labels,model_contributors_pull_request_label,base.group_user,1,0,0,0 +access_review,Access Reviews,model_contributors_review,base.group_user,1,0,0,0 +access_comment,Access Comments,model_contributors_comment,base.group_user,1,0,0,0 diff --git a/contributors_github/static/description/icon.png b/contributors_github/static/description/icon.png new file mode 100644 index 0000000..1fece97 Binary files /dev/null and b/contributors_github/static/description/icon.png differ diff --git a/contributors_github/static/description/icon.svg b/contributors_github/static/description/icon.svg new file mode 100644 index 0000000..261d62c --- /dev/null +++ b/contributors_github/static/description/icon.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + diff --git a/contributors_github/static/description/index.html b/contributors_github/static/description/index.html new file mode 100644 index 0000000..db583b7 --- /dev/null +++ b/contributors_github/static/description/index.html @@ -0,0 +1,471 @@ + + + + + +Contributors Github + + + +
+

Contributors Github

+ + +

Beta License: AGPL-3 OCA-contributors/contributors-module

+

This module allows to get data from github and show it in your odoo +instance.

+

Table of contents

+ +
+

Usage

+

Access the Contributors / Organization module.

+

Add your organization and one ore more API Keys (it might be faster if +you add several ones).

+

Update the repositories.

+

The system will start fetching the data automatically.

+
+

First Load

+

Usually, the first load can take a lot of time and you could find time +constrains on your Odoo database. For this reason we recommend to stop +the cron in this first load and execute the following code in your shell +once you have all the repositories created:

+
+from datetime import date, datetime, timedelta
+import time
+self = self.env["contributors.organization"].search([], limit=1) # Use the organization you prefer
+clients = self._get_clients()
+FIXED_DATE = "2026-01-01"
+repositories = self.env["contributors.repository"].search([("from_date", "<", FIXED_DATE), ("organization_id", "=", self.id)], order="from_date asc")
+i = 0
+while repositories:
+    try:
+        for repository in repositories:
+            i = (i+1) % len(clients)
+            repository.update_information(client_for_search=i)
+            self.env.cr.commit()
+    except Exception:
+        date = False
+        for client in clients:
+            rate = client.rate_limit()
+            print(rate)
+            for code in rate["resources"]:
+                if rate["resources"][code]["remaining"] == 0:
+                    date = max(date or 0, rate["resources"][code]["reset"])
+        if not date:
+            raise
+        print("%s - Sleeping for %s seconds" % (datetime.now().isoformat(), date - time.mktime(datetime.now().timetuple())))
+        time.sleep(date - time.mktime(datetime.now().timetuple()))
+        time.sleep(10)
+    repositories = self.env["contributors.repository"].search([("from_date", "<", FIXED_DATE), ("organization_id", "=", self.id)], order="from_date asc")
+
+

Once it has finished, restart the cron.

+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Dixmit
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

Current maintainer:

+

etobella

+

This module is part of the OCA-contributors/contributors-module project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/contributors_github/static/src/components/contributors_render/contributors_render.esm.js b/contributors_github/static/src/components/contributors_render/contributors_render.esm.js new file mode 100644 index 0000000..bf2512e --- /dev/null +++ b/contributors_github/static/src/components/contributors_render/contributors_render.esm.js @@ -0,0 +1,124 @@ +import {Component, markup, onMounted, useState} from "@odoo/owl"; +import {Dropdown} from "@web/core/dropdown/dropdown"; +import {DropdownItem} from "@web/core/dropdown/dropdown_item"; +import {PopoverTooltip} from "../popover_tooltip/popover_tooltip.esm"; +import {formatFloat} from "@web/core/utils/numbers"; +import {registry} from "@web/core/registry"; +import {renderToString} from "@web/core/utils/render"; +import {rpc} from "@web/core/network/rpc"; +import {usePopover} from "@web/core/popover/popover_hook"; + +export class ContributorsRender extends Component { + static template = "contributors_github.ContributorsRender"; + setup() { + const year = new Date().getFullYear(); + const month = new Date().getMonth() + 1; + this.state = useState({ + sort: { + contributors: "index", + organizations: "merged_pull_requests", + repositories: "merged_pull_requests", + }, + columns: {}, + period: "YTD", + contributors: [], + organizations: [], + repositories: [], + year: month === 1 ? year - 1 : year, + month: (month === 1 ? 12 : month - 1).toString(), + kind: "contributors", + }); + onMounted(this.fetchData.bind(this)); + this.popover = usePopover(PopoverTooltip); + } + selectPeriod(period) { + this.state.period = period; + this.fetchData(); + } + initColumnTooltip(ev, column) { + this.popover.open(ev.currentTarget, { + content: markup(column.tooltip), + }); + } + initDateTooltip(ev) { + this.popover.open(ev.currentTarget, { + content: markup(renderToString("contributors_github.DateSelectionTooltip")), + }); + } + async fetchData() { + const data = await rpc("/contributors/fetch", this.getParameters()); + if (this.state.kind === "contributors") { + this.state.contributors = data.data; + } else if (this.state.kind === "organizations") { + this.state.organizations = data.data; + } else if (this.state.kind === "repositories") { + this.state.repositories = data.data; + } + this.state.columns = data.columns; + return data; + } + getParameters() { + return { + year: parseInt(this.state.year, 10), + month: parseInt(this.state.month, 10), + organization_id: this.props.organization, + kind: this.state.kind, + period: this.state.period, + }; + } + get rowIds() { + if (this.state.kind === "contributors") { + return Object.keys(this.state.contributors).sort( + (a, b) => + this.state.contributors[b][this.state.sort.contributors] - + this.state.contributors[a][this.state.sort.contributors] + ); + } else if (this.state.kind === "organizations") { + return Object.keys(this.state.organizations).sort( + (a, b) => + this.state.organizations[b][this.state.sort.organizations] - + this.state.organizations[a][this.state.sort.organizations] + ); + } else if (this.state.kind === "repositories") { + return Object.keys(this.state.repositories).sort( + (a, b) => + this.state.repositories[b][this.state.sort.repositories] - + this.state.repositories[a][this.state.sort.repositories] + ); + } + return []; + } + getRowData(row_id) { + if (this.state.kind === "organizations") { + return this.state.organizations[row_id]; + } + if (this.state.kind === "repositories") { + return this.state.repositories[row_id]; + } + if (this.state.kind === "contributors") { + return this.state.contributors[row_id]; + } + return {}; + } + formatFloat(value, digits) { + return formatFloat(value, {digits: [digits, digits]}); + } + setKind(kind) { + this.state.kind = kind; + this.fetchData(); + } + sortBy(field) { + this.state.sort[this.state.kind] = field; + } +} + +ContributorsRender.props = { + organization: Number, +}; +ContributorsRender.components = { + Dropdown, + DropdownItem, +}; +registry + .category("public_components") + .add("contributors_github.ContributorsRender", ContributorsRender); diff --git a/contributors_github/static/src/components/contributors_render/contributors_render.scss b/contributors_github/static/src/components/contributors_render/contributors_render.scss new file mode 100644 index 0000000..75ce0ce --- /dev/null +++ b/contributors_github/static/src/components/contributors_render/contributors_render.scss @@ -0,0 +1,31 @@ +.o_contributors_render { + .o_contributors_render_table { + .o_contributors_render_table_header { + &.sortable { + cursor: pointer; + } + } + } + .o_input { + input { + display: block; + width: 100%; + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + color: #212529; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: white; + background-clip: padding-box; + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); + transition: + background-color 0.05s ease-in-out, + border-color 0.05s ease-in-out, + box-shadow 0.05s ease-in-out; + } + } +} diff --git a/contributors_github/static/src/components/contributors_render/contributors_render.xml b/contributors_github/static/src/components/contributors_render/contributors_render.xml new file mode 100644 index 0000000..b2a723b --- /dev/null +++ b/contributors_github/static/src/components/contributors_render/contributors_render.xml @@ -0,0 +1,148 @@ + + +
+ Select the period for which you want to see the contributors statistics. + Options are: +
+
    +
  • MTD: Month-To-Date
  • +
  • YTD: Year-To-Date - From January to the selected month
  • +
  • MAT: Moving Annual Total - Last 12 Months
  • +
+
+ +
+

Contributors Statistics

+
+
+ +
+
+ +
+
+ + + + MTD + YTD + MAT + + +
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + + + + +
+
+
+ +
diff --git a/contributors_github/static/src/components/popover_tooltip/popover_tooltip.esm.js b/contributors_github/static/src/components/popover_tooltip/popover_tooltip.esm.js new file mode 100644 index 0000000..bc1b371 --- /dev/null +++ b/contributors_github/static/src/components/popover_tooltip/popover_tooltip.esm.js @@ -0,0 +1,8 @@ +import {Component} from "@odoo/owl"; + +export class PopoverTooltip extends Component { + static template = "contributors_github.PopoverTooltip"; + static props = { + content: String, + }; +} diff --git a/contributors_github/static/src/components/popover_tooltip/popover_tooltip.xml b/contributors_github/static/src/components/popover_tooltip/popover_tooltip.xml new file mode 100644 index 0000000..d2390b3 --- /dev/null +++ b/contributors_github/static/src/components/popover_tooltip/popover_tooltip.xml @@ -0,0 +1,7 @@ + + +
+ +
+
+
diff --git a/contributors_github/static/tests/tours/portal.esm.js b/contributors_github/static/tests/tours/portal.esm.js new file mode 100644 index 0000000..05040c3 --- /dev/null +++ b/contributors_github/static/tests/tours/portal.esm.js @@ -0,0 +1,102 @@ +import {registry} from "@web/core/registry"; + +registry.category("web_tour.tours").add("portal_load_contributors_github", { + url: "/my", + steps: () => [ + { + content: "Check portal is loaded and Find Contributors menu", + trigger: 'a[href*="/contributors"]:contains("Contributors"):first', + run: "click", + expectUnloadPage: true, + }, + { + content: "Check Contributors Page", + trigger: 'a[href*="/contributors/oca"]:contains("OCA"):first', + run: "click", + expectUnloadPage: true, + }, + { + content: "Check Contributors Page", + trigger: "owl-component", + }, + { + content: "Check Etobella", + trigger: 'a[href*="https://github.com/etobella"]:first', + }, + { + content: "Check LuisDixmit", + trigger: 'a[href*="https://github.com/luisDixmit"]:first', + }, + { + content: "Check JordiBForgeFlow", + trigger: 'a[href*="https://github.com/JordiBForgeFlow"]:first', + }, + { + content: "Check Etobella Value", + trigger: + 'tr:has(a[href*="https://github.com/etobella"]) td:nth-child(2):contains("4.00"):first', + }, + { + content: "Check LuisDixmit Value", + trigger: + 'tr:has(a[href*="https://github.com/luisDixmit"]) td:nth-child(2):contains("1.00"):first', + }, + { + content: "Check JordiBForgeFlow Value", + trigger: + 'tr:has(a[href*="https://github.com/JordiBForgeFlow"]) td:nth-child(2):contains("1.00"):first', + }, + { + content: "Change to Repositories", + trigger: ".o_contributors_repositories button", + run: "click", + }, + { + content: "Check Repository", + trigger: 'a[href*="https://github.com/oca/contributors-module"]:first', + }, + { + content: "Check Created Pull Requests Value", + trigger: + 'tr:has(a[href*="https://github.com/oca/contributors-module"]) td:nth-child(3):contains("3"):first', + }, + { + content: "Check Merged Pull Requests Value", + trigger: + 'tr:has(a[href*="https://github.com/oca/contributors-module"]) td:nth-child(4):contains("1"):first', + }, + { + content: "Change to Organizations", + trigger: ".o_contributors_organizations button", + run: "click", + }, + { + content: "Check Dixmit", + trigger: 'tr:has(a[href*="https://github.com/dixmit"]):first', + }, + { + content: "Check ForgeFlow", + trigger: 'tr:has(a[href*="https://github.com/ForgeFlow"]):first', + }, + { + content: "Check Dixmit Created Pull Requests Value", + trigger: + 'tr:has(a[href*="https://github.com/dixmit"]) td:nth-child(2):contains("2"):first', + }, + { + content: "Check ForgeFlow Created Pull Requests Value", + trigger: + 'tr:has(a[href*="https://github.com/ForgeFlow"]) td:nth-child(2):contains("1"):first', + }, + { + content: "Check Dixmit Merged Pull Requests Value", + trigger: + 'tr:has(a[href*="https://github.com/dixmit"]) td:nth-child(3):contains("1"):first', + }, + { + content: "Check ForgeFlow Merged Pull Requests Value", + trigger: + 'tr:has(a[href*="https://github.com/ForgeFlow"]) td:nth-child(3):contains("0"):first', + }, + ], +}); diff --git a/contributors_github/templates/templates.xml b/contributors_github/templates/templates.xml new file mode 100644 index 0000000..abf3d15 --- /dev/null +++ b/contributors_github/templates/templates.xml @@ -0,0 +1,133 @@ + + + + + + + + + + diff --git a/contributors_github/tests/__init__.py b/contributors_github/tests/__init__.py new file mode 100644 index 0000000..34441bf --- /dev/null +++ b/contributors_github/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_github +from . import test_portal diff --git a/contributors_github/tests/test_github.py b/contributors_github/tests/test_github.py new file mode 100644 index 0000000..e057e7f --- /dev/null +++ b/contributors_github/tests/test_github.py @@ -0,0 +1,117 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +from unittest.mock import MagicMock, patch + +from odoo.fields import Command +from odoo.tests.common import TransactionCase + + +class TestGithub(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.organization = cls.env["contributors.organization"].create( + { + "name": "oca", + "key_ids": [ + Command.create({"name": "ghp_exampletoken1234567890abcdef"}) + ], + } + ) + + def test_update_organization_and_logo(self): + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + with ( + patch( + "odoo.addons.contributors_github.models.contributors_organization.github3" + ) as mock_github3, + patch( + "odoo.addons.contributors_github.models.contributors_organization.requests.get" + ) as mock_requests_get, + ): + mock_client = MagicMock() + mock_org = MagicMock() + mock_org.name = "Odoo Community Association" + mock_org.avatar_url = f"{base_url}/logo.png" + mock_requests_get.return_value.content = base64.b64decode( + self.env.company.logo + ) + mock_client.organization.return_value = mock_org + mock_github3.login.return_value = mock_client + self.organization.update_information() + self.assertEqual( + self.organization.short_description, "Odoo Community Association" + ) + mock_github3.login.assert_called_once_with( + token="ghp_exampletoken1234567890abcdef" + ) + mock_client.organization.assert_called_once_with("oca") + + def test_update_organization_with_repository(self): + with patch( + "odoo.addons.contributors_github.models.contributors_organization.github3" + ) as mock_github3: + mock_client = MagicMock() + mock_org = MagicMock() + mock_org.name = "Odoo Community Association" + mock_org.avatar_url = False + mock_repo1 = MagicMock() + mock_repo1.name = "server-tools" + mock_repo1.created_at = "2020-01-01T00:00:00Z" + mock_repo2 = MagicMock() + mock_repo2.name = "server-brand" + mock_repo2.created_at = "2021-01-01T10:00:00Z" + mock_org.repositories.return_value = [mock_repo1, mock_repo2] + mock_client.organization.return_value = mock_org + mock_github3.login.return_value = mock_client + self.organization.update_information() + self.assertEqual(len(self.organization.repository_ids), 2) + repo_names = {repo.name for repo in self.organization.repository_ids} + self.assertSetEqual(repo_names, {"server-tools", "server-brand"}) + mock_github3.login.assert_called_once_with( + token="ghp_exampletoken1234567890abcdef" + ) + mock_client.organization.assert_called_once_with("oca") + return self.organization.repository_ids.filtered( + lambda r: r.name == "server-tools" + ) + + def test_update_repository(self): + repository = self.test_update_organization_with_repository() + self.assertFalse(repository.pull_request_ids) + with patch( + "odoo.addons.contributors_github.models.contributors_repository.github3.login" + ) as mock_client: + mock_login = MagicMock() + mock_client.return_value = mock_login + mock_login.session.get.return_value = MagicMock( + links={}, + json=lambda: [], + ) + mock_issue_request = MagicMock( + as_dict=lambda: { + "id": 1, + "user": {"login": "contributor1"}, + "base": {"ref": "main"}, + "head": {"repo": [MagicMock()]}, + "html_url": "https://github.com/oca/server-tools/pull/1", + "state": "closed", + "title": "Fix issue", + "labels": [{"name": "merged 🎉"}], + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-03T00:00:00Z", + "closed_at": "2023-01-02T00:00:00Z", + "commits": 3, + "comments": 2, + "review_comments": 1, + "additions": 150, + "deletions": 50, + "number": 1, + } + ) + mock_login._instance_or_null.return_value = mock_issue_request + mock_login.search_issues.return_value = [mock_issue_request] + repository.update_information() + self.assertTrue(repository.pull_request_ids) diff --git a/contributors_github/tests/test_portal.py b/contributors_github/tests/test_portal.py new file mode 100644 index 0000000..210c9aa --- /dev/null +++ b/contributors_github/tests/test_portal.py @@ -0,0 +1,135 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo.fields import Date +from odoo.tests import tagged + +from odoo.addons.base.tests.common import HttpCaseWithUserDemo, HttpCaseWithUserPortal + + +@tagged("post_install", "-at_install") +class TestUi(HttpCaseWithUserDemo, HttpCaseWithUserPortal): + @classmethod + def setUpClass(cls): + super().setUpClass() + # be sure some expected values are set otherwise homepage may fail + date = Date.today() + date = date - timedelta(days=date.day) + cls.partner_portal.write( + { + "city": "Bayonne", + "company_name": "YourCompany", + "country_id": cls.env.ref("base.us").id, + "phone": "(683)-556-5104", + "street": "858 Lynn Street", + "zip": "07002", + } + ) + organization = cls.env["contributors.organization"].create( + { + "name": "oca", + "short_description": "OCA", + "description": "OCA", + } + ) + repository = cls.env["contributors.repository"].create( + { + "name": "contributors-module", + "description": "OCA/contributors-module", + "organization_id": organization.id, + "from_date": date, + } + ) + user_01 = cls.env["res.partner"].create( + { + "name": "Enric Tobella", + "github_name": "etobella", + "github_user": True, + } + ) + user_02 = cls.env["res.partner"].create( + { + "name": "Luis Rodriguez", + "github_name": "luisDixmit", + "github_user": True, + } + ) + user_03 = cls.env["res.partner"].create( + { + "name": "Jordi Ballester", + "github_name": "JordiBForgeFlow", + "github_user": True, + } + ) + org_01 = cls.env["res.partner"].create( + { + "name": "Dixmit", + "github_name": "dixmit", + "github_organization": True, + } + ) + org_02 = cls.env["res.partner"].create( + { + "name": "ForgeFlow", + "github_name": "ForgeFlow", + "github_organization": True, + } + ) + pull_request_01 = cls.env["contributors.pull.request"].create( + { + "github_id": 1, + "name": "Test PR", + "repository_id": repository.id, + "partner_id": user_01.id, + "organization_id": org_01.id, + "created_at": date, + "closed_at": date, + "is_merged": True, + } + ) + cls.env["contributors.pull.request"].create( + { + "github_id": 2, + "name": "Test PR", + "repository_id": repository.id, + "partner_id": user_02.id, + "organization_id": org_01.id, + "created_at": date, + "is_merged": False, + } + ) + cls.env["contributors.pull.request"].create( + { + "github_id": 3, + "name": "Test PR", + "repository_id": repository.id, + "partner_id": user_03.id, + "organization_id": org_02.id, + "created_at": date, + "is_merged": False, + } + ) + cls.env["contributors.review"].create( + { + "github_id": 1, + "body": "Test Review", + "state": "APPROVED", + "pull_request_id": pull_request_01.id, + "partner_id": user_01.id, + "submitted_at": date, + } + ) + cls.env["contributors.comment"].create( + { + "github_id": 1, + "body": "Test Comment", + "pull_request_id": pull_request_01.id, + "partner_id": user_01.id, + "created_at": date, + } + ) + + def test_01_portal_load_tour(self): + self.start_tour("/", "portal_load_contributors_github", login="portal") diff --git a/contributors_github/views/contributors_branch.xml b/contributors_github/views/contributors_branch.xml new file mode 100644 index 0000000..b7cb376 --- /dev/null +++ b/contributors_github/views/contributors_branch.xml @@ -0,0 +1,27 @@ + + + + + contributors.branch + +
+
+ + + + + + + + + + + contributors.branch + + + + + + + diff --git a/contributors_github/views/contributors_comment.xml b/contributors_github/views/contributors_comment.xml new file mode 100644 index 0000000..ef7ff26 --- /dev/null +++ b/contributors_github/views/contributors_comment.xml @@ -0,0 +1,26 @@ + + + + + contributors.comment + +
+
+ + + + + + + + + + contributors.comment + + + + + + + diff --git a/contributors_github/views/contributors_organization.xml b/contributors_github/views/contributors_organization.xml new file mode 100644 index 0000000..4f6f1ba --- /dev/null +++ b/contributors_github/views/contributors_organization.xml @@ -0,0 +1,82 @@ + + + + + contributors.organization + +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + contributors.organization + + + + + + + + + + contributors.organization + + + + + + + + + Organizations + contributors-organizations + contributors.organization + list,form + [] + {} + + + + Organizations + + + + + diff --git a/contributors_github/views/contributors_pull_request.xml b/contributors_github/views/contributors_pull_request.xml new file mode 100644 index 0000000..7698c61 --- /dev/null +++ b/contributors_github/views/contributors_pull_request.xml @@ -0,0 +1,94 @@ + + + + + contributors.pull.request + +
+
+ + + + + + + + + + + + + + + + + + + + + + + contributors.pull.request + + + + + + + + + + + + + contributors.pull.request + + + + + + + + + + + + + + Pull Requests + contributors.pull.request + list,form + [] + {} + + + + Pull Requests + contributors-pull-requests + contributors.pull.request + list,form + [("repository_id", "=", active_id)] + {} + + + + Pull Requests + + + + + diff --git a/contributors_github/views/contributors_repository.xml b/contributors_github/views/contributors_repository.xml new file mode 100644 index 0000000..ff97d28 --- /dev/null +++ b/contributors_github/views/contributors_repository.xml @@ -0,0 +1,102 @@ + + + + + contributors.repository + +
+
+
+ +
+ + + + +
+ + + + + +
+
+
+
+ + + contributors.repository + + + + + + + + + + contributors.repository + + + + + + + + + + + + Repositories + contributors-repositories + contributors.repository + list,form + [] + {} + + + + Repositories + + + + +
diff --git a/contributors_github/views/contributors_review.xml b/contributors_github/views/contributors_review.xml new file mode 100644 index 0000000..1fe2fa5 --- /dev/null +++ b/contributors_github/views/contributors_review.xml @@ -0,0 +1,27 @@ + + + + + contributors.review + +
+
+ + + + + + + + + + + contributors.review + + + + + + + diff --git a/contributors_github/views/menu.xml b/contributors_github/views/menu.xml new file mode 100644 index 0000000..af4c2a1 --- /dev/null +++ b/contributors_github/views/menu.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/contributors_github/views/res_partner.xml b/contributors_github/views/res_partner.xml new file mode 100644 index 0000000..1a5f48d --- /dev/null +++ b/contributors_github/views/res_partner.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/contributors_github_psc/README.rst b/contributors_github_psc/README.rst new file mode 100644 index 0000000..d7b1832 --- /dev/null +++ b/contributors_github_psc/README.rst @@ -0,0 +1,65 @@ +======================= +Contributors Github Psc +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:183c6a4f3366dc8045913f644b065e91fe8cd0cc8f869dc7b02ca87df5267e50 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA--contributors%2Fcontributors--module-lightgray.png?logo=github + :target: https://github.com/OCA-contributors/contributors-module/tree/18.0/contributors_github_psc + :alt: OCA-contributors/contributors-module + +|badge1| |badge2| |badge3| + +This module adds PSCs information to users by following a repository of +configuration that executes + +https://github.com/OCA/repo-maintainer + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Dixmit + +Contributors +------------ + +- Firstname Lastname email.address@example.org (optional company website + url) +- Second Person second.person@example.org (optional company website url) + +Maintainers +----------- + +This module is part of the `OCA-contributors/contributors-module `_ project on GitHub. + +You are welcome to contribute. diff --git a/contributors_github_psc/__init__.py b/contributors_github_psc/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/contributors_github_psc/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/contributors_github_psc/__manifest__.py b/contributors_github_psc/__manifest__.py new file mode 100644 index 0000000..87b25ce --- /dev/null +++ b/contributors_github_psc/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Contributors Github Psc", + "summary": """Integrate PSCs""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Dixmit,Odoo Community Association (OCA)", + "website": "https://github.com/OCA-contributors/contributors-module", + "depends": ["contributors_github"], + "data": [ + "views/contributors_repository.xml", + "security/ir.model.access.csv", + "views/contributors_organization_psc.xml", + "views/contributors_organization.xml", + "templates/templates.xml", + ], + "assets": { + "web.assets_frontend": [ + "contributors_github_psc/static/src/components/**/*.esm.js", + "contributors_github_psc/static/src/components/**/*.xml", + ], + }, + "demo": [], +} diff --git a/contributors_github_psc/models/__init__.py b/contributors_github_psc/models/__init__.py new file mode 100644 index 0000000..608b406 --- /dev/null +++ b/contributors_github_psc/models/__init__.py @@ -0,0 +1,4 @@ +from . import contributors_organization +from . import contributors_organization_psc +from . import contributors_repository +from . import res_partner diff --git a/contributors_github_psc/models/contributors_organization.py b/contributors_github_psc/models/contributors_organization.py new file mode 100644 index 0000000..160045a --- /dev/null +++ b/contributors_github_psc/models/contributors_organization.py @@ -0,0 +1,127 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import requests +import yaml + +from odoo import fields, models + + +class ContributorsOrganization(models.Model): + _inherit = "contributors.organization" + + psc_repository = fields.Char() + psc_repository_conf = fields.Char() + psc_ids = fields.One2many( + "contributors.organization.psc", + inverse_name="organization_id", + readonly=True, + ) + + def update_information(self): + res = super().update_information() + if self.psc_repository: + client = self._get_clients()[0] + repository = client.repository(self.name, self.psc_repository) + psc_files = repository.directory_contents(f"{self.psc_repository_conf}/psc") + repository_files = repository.directory_contents( + f"{self.psc_repository_conf}/repo" + ) + for _psc_filename, psc_file in psc_files: + req = requests.get(psc_file.download_url, timeout=10) + req.raise_for_status() + psc_data = yaml.safe_load(req.content) + for psc_name in psc_data: + psc_record = self.env["contributors.organization.psc"].search( + [ + ("organization_id", "=", self.id), + ("key", "=", psc_name), + ], + limit=1, + ) + if not psc_record: + psc_record = ( + self.env["contributors.organization.psc"] + .sudo() + .create( + { + "organization_id": self.id, + "key": psc_name, + "name": psc_data[psc_name].get("name", psc_name), + } + ) + ) + else: + psc_record.sudo().write( + { + "name": psc_data[psc_name].get("name", psc_name), + } + ) + member_logins = psc_data[psc_name].get("members", []) + psc_data[ + psc_name + ].get("representatives", []) + members = [] + for member in member_logins: + members.append( + self.env["res.partner"]._get_github_user(member, client) + ) + psc_record.sudo().member_ids = self.env["res.partner"].browse( + members + ) + for _repo_filename, repo_file in repository_files: + req = requests.get(repo_file.download_url, timeout=10) + req.raise_for_status() + repo_data = yaml.safe_load(req.content) + for repo_name in repo_data: + psc_name = repo_data[repo_name].get("psc") + psc_record = self.env["contributors.organization.psc"].search( + [ + ("organization_id", "=", self.id), + ("key", "=", psc_name), + ], + limit=1, + ) + if not psc_record: + continue + repo_record = ( + self.env["contributors.repository"] + .sudo() + .search( + [ + ("organization_id", "=", self.id), + ("name", "=", repo_name), + ], + limit=1, + ) + ) + if repo_record: + repo_record.sudo().write( + { + "psc_id": psc_record.id, + } + ) + return res + + def _get_merged_domain(self, start, end, psc_id=None, **values): + result = super()._get_merged_domain(start, end, psc_id=psc_id, **values) + if psc_id: + result.append(("repository_id.psc_id", "=", int(psc_id))) + return result + + def _get_created_domain(self, start, end, psc_id=None, **values): + result = super()._get_created_domain(start, end, psc_id=psc_id, **values) + if psc_id: + result.append(("repository_id.psc_id", "=", int(psc_id))) + return result + + def _get_comments_domain(self, start, end, psc_id=None, **values): + result = super()._get_comments_domain(start, end, psc_id=psc_id, **values) + if psc_id: + result.append(("repository_id.psc_id", "=", int(psc_id))) + return result + + def _get_reviews_domain(self, start, end, psc_id=None, **values): + result = super()._get_reviews_domain(start, end, psc_id=psc_id, **values) + if psc_id: + result.append(("repository_id.psc_id", "=", int(psc_id))) + return result diff --git a/contributors_github_psc/models/contributors_organization_psc.py b/contributors_github_psc/models/contributors_organization_psc.py new file mode 100644 index 0000000..2221357 --- /dev/null +++ b/contributors_github_psc/models/contributors_organization_psc.py @@ -0,0 +1,27 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ContributorsOrganizationPsc(models.Model): + _name = "contributors.organization.psc" + _description = "Contributors Organization Psc" # TODO + + organization_id = fields.Many2one( + comodel_name="contributors.organization", + string="Organization", + required=True, + ) + name = fields.Char(required=True) + key = fields.Char(required=True) + member_ids = fields.Many2many( + "res.partner", + readonly=True, + ) + url = fields.Char(compute="_compute_url") + + @api.depends("organization_id", "key") + def _compute_url(self): + for record in self: + record.url = f"https://github.com/orgs/{record.organization_id.name}/projects/{record.key}" diff --git a/contributors_github_psc/models/contributors_repository.py b/contributors_github_psc/models/contributors_repository.py new file mode 100644 index 0000000..955a593 --- /dev/null +++ b/contributors_github_psc/models/contributors_repository.py @@ -0,0 +1,13 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ContributorsRepository(models.Model): + _inherit = "contributors.repository" + + psc_id = fields.Many2one( + "contributors.organization.psc", + readonly=True, + ) diff --git a/contributors_github_psc/models/res_partner.py b/contributors_github_psc/models/res_partner.py new file mode 100644 index 0000000..d389c90 --- /dev/null +++ b/contributors_github_psc/models/res_partner.py @@ -0,0 +1,19 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + psc_ids = fields.Many2many( + comodel_name="contributors.organization.psc", + readonly=True, + ) + + def _get_contributors_name(self, kind, psc_id=None, **kwargs): + name = super()._get_contributors_name(kind, **kwargs) + if kind == "contributors" and psc_id and psc_id in self.psc_ids.ids: + name += " ★" + return name diff --git a/contributors_github_psc/pyproject.toml b/contributors_github_psc/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/contributors_github_psc/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/contributors_github_psc/readme/CONTRIBUTORS.md b/contributors_github_psc/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..7be72fb --- /dev/null +++ b/contributors_github_psc/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Firstname Lastname (optional company website url) +- Second Person (optional company website url) diff --git a/contributors_github_psc/readme/DESCRIPTION.md b/contributors_github_psc/readme/DESCRIPTION.md new file mode 100644 index 0000000..026a305 --- /dev/null +++ b/contributors_github_psc/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module adds PSCs information to users by following a repository of configuration that executes + +https://github.com/OCA/repo-maintainer diff --git a/contributors_github_psc/security/ir.model.access.csv b/contributors_github_psc/security/ir.model.access.csv new file mode 100644 index 0000000..b3d9c91 --- /dev/null +++ b/contributors_github_psc/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_organization_psc,Access Organization PSC,model_contributors_organization_psc,base.group_user,1,0,0,0 +access_organization_portal_psc,Access Organization PSC,model_contributors_organization_psc,base.group_portal,1,0,0,0 diff --git a/contributors_github_psc/static/description/icon.png b/contributors_github_psc/static/description/icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/contributors_github_psc/static/description/icon.png differ diff --git a/contributors_github_psc/static/description/index.html b/contributors_github_psc/static/description/index.html new file mode 100644 index 0000000..1692f8b --- /dev/null +++ b/contributors_github_psc/static/description/index.html @@ -0,0 +1,420 @@ + + + + + +Contributors Github Psc + + + +
+

Contributors Github Psc

+ + +

Beta License: AGPL-3 OCA-contributors/contributors-module

+

This module adds PSCs information to users by following a repository of +configuration that executes

+

https://github.com/OCA/repo-maintainer

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Dixmit
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the OCA-contributors/contributors-module project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/contributors_github_psc/static/src/components/contributors_render/contributors_render.esm.js b/contributors_github_psc/static/src/components/contributors_render/contributors_render.esm.js new file mode 100644 index 0000000..dc60812 --- /dev/null +++ b/contributors_github_psc/static/src/components/contributors_render/contributors_render.esm.js @@ -0,0 +1,69 @@ +import {AutoComplete} from "@web/core/autocomplete/autocomplete"; +import {ContributorsRender} from "@contributors_github/components/contributors_render/contributors_render.esm"; +import {_t} from "@web/core/l10n/translation"; +import {onMounted} from "@odoo/owl"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; + +patch(ContributorsRender, { + components: { + ...ContributorsRender.components, + AutoComplete, + }, +}); +patch(ContributorsRender.prototype, { + setup() { + super.setup(); + this.state.psc = ""; + this.state.pscs = []; + this.state.pscId = null; + this.orm = useService("orm"); + this.selectPsc = _t("Select PSC"); + onMounted(this.fetchPscs.bind(this)); + }, + async fetchPscs() { + const pscs = await this.orm.searchRead( + "contributors.organization.psc", + [["organization_id", "=", this.props.organization]], + ["id", "name", "member_ids"] + ); + this.state.rawPscs = Object.fromEntries(pscs.map((psc) => [psc.id, psc])); + this.state.pscs = pscs.map((psc) => { + return { + label: psc.name, + value: psc.id, + }; + }); + }, + get pscSources() { + const pscs = this.state.pscs; + return [ + { + async options(query) { + return pscs.filter((psc) => + psc.label.toLowerCase().includes(query.toLowerCase()) + ); + }, + }, + ]; + }, + onChangePsc(event) { + if (event.inputValue === "") { + this.state.pscId = null; + this.state.psc = ""; + this.fetchData(); + } + }, + onSelectPsc(option) { + this.state.pscId = option.value; + this.state.psc = this.state.rawPscs[option.value].name; + this.fetchData(); + }, + getParameters() { + const params = super.getParameters(); + if (this.state.pscId) { + params.psc_id = this.state.pscId; + } + return params; + }, +}); diff --git a/contributors_github_psc/static/src/components/contributors_render/contributors_render.xml b/contributors_github_psc/static/src/components/contributors_render/contributors_render.xml new file mode 100644 index 0000000..e4d271c --- /dev/null +++ b/contributors_github_psc/static/src/components/contributors_render/contributors_render.xml @@ -0,0 +1,21 @@ + + + + + +
+ +
+
+
+ +
diff --git a/contributors_github_psc/templates/templates.xml b/contributors_github_psc/templates/templates.xml new file mode 100644 index 0000000..c9d278a --- /dev/null +++ b/contributors_github_psc/templates/templates.xml @@ -0,0 +1,24 @@ + + + + diff --git a/contributors_github_psc/views/contributors_organization.xml b/contributors_github_psc/views/contributors_organization.xml new file mode 100644 index 0000000..edf61cf --- /dev/null +++ b/contributors_github_psc/views/contributors_organization.xml @@ -0,0 +1,23 @@ + + + + + contributors.organization + + + + + + + + + + + + + + diff --git a/contributors_github_psc/views/contributors_organization_psc.xml b/contributors_github_psc/views/contributors_organization_psc.xml new file mode 100644 index 0000000..d69f97f --- /dev/null +++ b/contributors_github_psc/views/contributors_organization_psc.xml @@ -0,0 +1,31 @@ + + + + + contributors.organization.psc + +
+
+ + + + + + + + + + + + + contributors.organization.psc + + + + + + + + + diff --git a/contributors_github_psc/views/contributors_repository.xml b/contributors_github_psc/views/contributors_repository.xml new file mode 100644 index 0000000..8f2287e --- /dev/null +++ b/contributors_github_psc/views/contributors_repository.xml @@ -0,0 +1,17 @@ + + + + + contributors.repository + + + + + + + + diff --git a/contributors_weblate/README.rst b/contributors_weblate/README.rst new file mode 100644 index 0000000..628d216 --- /dev/null +++ b/contributors_weblate/README.rst @@ -0,0 +1,80 @@ +==================== +Contributors Weblate +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:625192af696f9a4fab610449af3ab38f84397ea8822a8986bbbee7aac43b23f1 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA--contributors%2Fcontributors--module-lightgray.png?logo=github + :target: https://github.com/OCA-contributors/contributors-module/tree/18.0/contributors_weblate + :alt: OCA-contributors/contributors-module + +|badge1| |badge2| |badge3| + +Get Weblate data and integrate it inside your contributors information. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Just access your organization and fill the data from your weblate in the +specific tab. + +For the first loading, might be faster to execute the code in shell by +using the following commands (increase the delta if you want): + +.. code:: python + + self = self.env["contributors.organization"].search([], limit=1) # Use your organization here + for i in range(1,100): + self.get_weblate_data() + self.env.cr.commit() + +Once it has finished, restart the cron. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Dixmit + +Contributors +------------ + +- `Dixmit `__ + + - Enric Tobella + +Maintainers +----------- + +This module is part of the `OCA-contributors/contributors-module `_ project on GitHub. + +You are welcome to contribute. diff --git a/contributors_weblate/__init__.py b/contributors_weblate/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/contributors_weblate/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/contributors_weblate/__manifest__.py b/contributors_weblate/__manifest__.py new file mode 100644 index 0000000..a604454 --- /dev/null +++ b/contributors_weblate/__manifest__.py @@ -0,0 +1,29 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Contributors Weblate", + "summary": """Get Weblate information and integrate it in the system""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Dixmit,Odoo Community Association (OCA)", + "website": "https://github.com/OCA-contributors/contributors-module", + "depends": [ + "contributors_github", + ], + "data": [ + "security/ir.model.access.csv", + "data/ir_cron.xml", + "views/contributors_translation.xml", + "views/contributors_repository.xml", + "views/contributors_organization.xml", + "templates/templates.xml", + ], + "demo": [], + "assets": { + "web.assets_frontend": [ + "contributors_weblate/static/src/**/*.xml", + "contributors_weblate/static/src/**/*.esm.js", + ], + }, +} diff --git a/contributors_weblate/controllers/__init__.py b/contributors_weblate/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/contributors_weblate/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/contributors_weblate/controllers/main.py b/contributors_weblate/controllers/main.py new file mode 100644 index 0000000..aa0766c --- /dev/null +++ b/contributors_weblate/controllers/main.py @@ -0,0 +1,34 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import _ +from odoo.http import request + +from odoo.addons.contributors_github.controllers.main import ContributorsController + + +class ContributorsPSCController(ContributorsController): + def _get_columns(self, kind): + columns = super()._get_columns(kind) + if kind == "translations": + columns = [ + {"field": "name", "title": _("Name"), "kind": "name"}, + ] + columns + if kind in ["contributors", "repositories", "translations"]: + columns.append( + { + "field": "translations", + "title": _("Translations"), + "kind": "float", + "decimals": 0, + } + ) + return columns + + def _improve_data(self, data, kind, **kwargs): + data = super()._improve_data(data, kind, **kwargs) + if kind == "translations": + for key, values in data.items(): + partner = request.env["res.partner"].browse(key) + values["name"] = self._get_partner_name(partner, **kwargs) + values["url"] = partner._get_contributor_url() + return data diff --git a/contributors_weblate/data/ir_cron.xml b/contributors_weblate/data/ir_cron.xml new file mode 100644 index 0000000..6ea474b --- /dev/null +++ b/contributors_weblate/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + + Weblate Organization Update + + code + model._cron_weblate_data() + 1 + minutes + False + + diff --git a/contributors_weblate/models/__init__.py b/contributors_weblate/models/__init__.py new file mode 100644 index 0000000..680796e --- /dev/null +++ b/contributors_weblate/models/__init__.py @@ -0,0 +1,4 @@ +from . import contributors_organization +from . import contributors_translation +from . import contributors_repository +from . import res_partner diff --git a/contributors_weblate/models/contributors_organization.py b/contributors_weblate/models/contributors_organization.py new file mode 100644 index 0000000..fdf8210 --- /dev/null +++ b/contributors_weblate/models/contributors_organization.py @@ -0,0 +1,242 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging +import re +from datetime import timedelta + +import requests + +from odoo import _, api, fields, models, tools +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class ContributorsOrganization(models.Model): + _inherit = "contributors.organization" + + weblate_url = fields.Char() + weblate_auth_token = fields.Char() + weblate_last_update = fields.Datetime() + weblate_load_delay = fields.Integer( + help="Number of days to load each time", default=7 + ) + + @api.model + def _translation_actions(self): + return ["2", "5", "8", "25", "27"] + + def get_weblate_data(self): + self.ensure_one() + if not self.weblate_last_update: + raise ValidationError( + _("Please set the last update date for Weblate data.") + ) + auth = {} + if self.weblate_auth_token: + auth = {"Authorization": f"Token {self.weblate_auth_token}"} + before_date = min( + self.weblate_last_update + timedelta(days=self.weblate_load_delay), + fields.Datetime.now(), + ) + req = requests.get( + f"{self.weblate_url}/api/changes/", + params={ + "timestamp_after": ( + self.weblate_last_update + timedelta(days=-1) + ).isoformat(), + "timestamp_before": before_date.isoformat(), + "format": "json", + "action": self._translation_actions(), + }, + timeout=10, + headers=auth, + ) + req.raise_for_status() + # self.weblate_last_update = before_date + total = 0 + client = self._get_clients()[0] + while True: + if req.json().get("errors"): + raise ValidationError( + _( + "Error while fetching Weblate data for organization " + f"{self.name}: {req.json().get('errors')}" + ) + ) + results = req.json().get("results", []) + max_total = req.json().get("count", 0) + for result in results: + translation = self.env["contributors.translation"].search( + [("integration_id", "=", str(result["id"]))] + ) + partner = False + if result["user"]: + splitted = result["user"].split("/") + if len(splitted) >= 2: + partner = self.env["res.partner"]._get_github_user( + splitted[-2], client + ) + vals = { + "integration_id": result["id"], + "unit": result["unit"], + "translation": result["translation"], + "component": result["component"], + "organization_id": self.id, + "partner_id": partner, + "date": self.env["contributors.repository"]._parse_date( + result["timestamp"] + ), + "action": str(result["action"]), + "repository_id": self._get_repository(result["component"]), + "branch_id": self._get_weblate_branch(result["component"]), + "lang_id": self._get_lang(result["translation"]), + } + if not translation: + translation = ( + self.env["contributors.translation"].sudo().create(vals) + ) + else: + translation.sudo().write(vals) + total += 1 + if total % 100 == 0: + _logger.debug( + f"Processed {total} of {max_total} Weblate translation changes" + ) + if req.json().get("next"): + req = requests.get(req.json().get("next"), timeout=10, headers=auth) + req.raise_for_status() + else: + break + self.weblate_last_update = before_date + _logger.info(f"Processed a total of {total} Weblate translation changes") + + @tools.ormcache("self.id", "translation") + def _get_lang(self, translation): + if not translation: + return False + parts = translation.split("/") + lang = ( + self.env["res.lang"] + .search( + [("iso_code", "=", parts[-2]), ("active", "in", [True, False])], limit=1 + ) + .id + ) + if lang: + return lang + if len(parts[-2]) == 2: + lang = ( + self.env["res.lang"] + .search( + [ + ("iso_code", "=ilike", parts[-2] + "_%"), + ("active", "in", [True, False]), + ], + limit=1, + ) + .id + ) + if lang: + return lang + return ( + self.env["res.lang"] + .search( + [("code", "=", parts[-2]), ("active", "in", [True, False])], limit=1 + ) + .id + ) + + @tools.ormcache("self.id", "component") + def _get_weblate_branch(self, component): + if not component: + return False + parts = component.split("/") + if len(parts) < 3: + return False + found = re.match(r"^[\w-]+-(\d+-\d)$", parts[-3]) + if not found: + return False + return self._get_branch(found.group(1)) + + @tools.ormcache("self.id", "component") + def _get_repository(self, component): + if not component: + return False + parts = component.split("/") + if len(parts) < 3: + return False + found = re.match(r"^([\w-]+)-\d+-\d$", parts[-3]) + if not found: + return False + return ( + self.env["contributors.repository"] + .search( + [("name", "=", found.group(1)), ("organization_id", "=", self.id)], + limit=1, + ) + .id + ) + + def _cron_weblate_data(self): + organizations = self.search([("weblate_url", "!=", False)]) + for organization in organizations: + try: + organization.get_weblate_data() + except Exception as e: + _logger.error( + "Error while updating Weblate data for organization " + f"{organization.name}: {e}" + ) + + def _get_default_data(self, start, end, field, kind, **values): + data = super()._get_default_data(start, end, field, kind, **values) + data["translations"] = 0 + return data + + def _get_translation_domain( + self, + start, + end, + psc_id=None, + lang_id=None, + actions=None, + **values, + ): + if actions is None: + actions = self._translation_actions() + domain = [ + ("organization_id", "in", self.ids), + ("date", ">=", start), + ("date", "<", end), + ("action", "in", actions), + ] + if lang_id: + domain.append(("lang_id", "=", lang_id)) + if psc_id and "psc_id" in self.env["contributors.repository"]._fields: + # To avoid an extra module we add the psc_id filter here + domain.append(("repository_id.psc_id", "=", int(psc_id))) + return domain + + def _generate_data(self, start, end, field, kind, lang_id=None, **values): + data = super()._generate_data(start, end, field, kind, **values) + if kind == "translations" and not field: + field = "partner_id" + if kind in ["contributors", "repositories", "translations"]: + for merged in ( + self.env["contributors.translation"] + .sudo() + .read_group( + self._get_translation_domain( + start, + end, + lang_id=lang_id if kind == "translations" else None, + **values, + ) + + [(field, "!=", False)], + [field], + [field], + ) + ): + data[merged[field][0]]["translations"] = merged[f"{field}_count"] + return data diff --git a/contributors_weblate/models/contributors_repository.py b/contributors_weblate/models/contributors_repository.py new file mode 100644 index 0000000..c333b70 --- /dev/null +++ b/contributors_weblate/models/contributors_repository.py @@ -0,0 +1,10 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ContributorsRepository(models.Model): + _inherit = "contributors.repository" + + weblate_url = fields.Char(related="organization_id.weblate_url") diff --git a/contributors_weblate/models/contributors_translation.py b/contributors_weblate/models/contributors_translation.py new file mode 100644 index 0000000..2de59a9 --- /dev/null +++ b/contributors_weblate/models/contributors_translation.py @@ -0,0 +1,107 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ContributorsTranslation(models.Model): + _name = "contributors.translation" + _description = "Contributors Translation" # TODO + + integration_id = fields.Char(required=True, index=True) + unit = fields.Char() + translation = fields.Char() + component = fields.Char() + partner_id = fields.Many2one("res.partner") + organization_id = fields.Many2one("contributors.organization") + repository_id = fields.Many2one("contributors.repository") + branch_id = fields.Many2one("contributors.branch") + date = fields.Datetime() + lang_id = fields.Many2one("res.lang") + action = fields.Selection( + [ + ("0", "Resource updated"), + ("1", "Translation completed"), + ("2", "Translation changed"), + ("3", "Comment added"), + ("4", "Suggestion added"), + ("5", "Translation added"), + ("6", "Automatically translated"), + ("7", "Suggestion accepted"), + ("8", "Translation reverted"), + ("9", "Translation uploaded"), + ("13", "Source string added"), + ("14", "Component locked"), + ("15", "Component unlocked"), + ("17", "Changes committed"), + ("18", "Changes pushed"), + ("19", "Repository reset"), + ("20", "Repository merged"), + ("21", "Repository rebased"), + ("22", "Repository merge failed"), + ("23", "Repository rebase failed"), + ("24", "Parsing failed"), + ("25", "Translation removed"), + ("26", "Suggestion removed"), + ("27", "Translation replaced"), + ("28", "Repository push failed"), + ("29", "Suggestion removed during cleanup"), + ("30", "Source string changed"), + ("31", "String added"), + ("32", "Bulk status changed"), + ("33", "Visibility changed"), + ("34", "User added"), + ("35", "User removed"), + ("36", "Translation approved"), + ("37", "Marked for edit"), + ("38", "Component removed"), + ("39", "Project removed"), + ("41", "Project renamed"), + ("42", "Component renamed"), + ("43", "Moved component"), + ("45", "Contributor joined"), + ("46", "Announcement posted"), + ("47", "Alert triggered"), + ("48", "Language added"), + ("49", "Language requested"), + ("50", "Project created"), + ("51", "Component created"), + ("52", "User invited"), + ("53", "Repository notification received"), + ("54", "Translation replaced file by upload"), + ("55", "License changed"), + ("56", "Contributor license agreement changed"), + ("57", "Screenshot added"), + ("58", "Screenshot uploaded"), + ("59", "String updated in the repository"), + ("60", "Add-on installed"), + ("61", "Add-on configuration changed"), + ("62", "Add-on uninstalled"), + ("63", "String removed"), + ("64", "Comment removed"), + ("65", "Comment resolved"), + ("66", "Explanation updated"), + ("67", "Category removed"), + ("68", "Category renamed"), + ("69", "Category moved"), + ("70", "Saving string failed"), + ("71", "String added in the repository"), + ("72", "String updated in the upload"), + ("73", "String added in the upload"), + ("74", "Translation updated by source upload"), + ("75", "Component translation completed"), + ("76", "Applied enforced check"), + ("77", "Propagated change"), + ("78", "File uploaded"), + ("79", "Extra flags updated"), + ] + ) + details = fields.Json() + + _sql_constraints = [ + ( + "integration_id_uniq", + "unique(integration_id, organization_id)", + "The integration ID must be unique.", + ), + ] diff --git a/contributors_weblate/models/res_partner.py b/contributors_weblate/models/res_partner.py new file mode 100644 index 0000000..54ff814 --- /dev/null +++ b/contributors_weblate/models/res_partner.py @@ -0,0 +1,18 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + weblate_translations = fields.Integer( + compute="_compute_github_contributions", string="Translations", prefetch=False + ) + + @api.model + def _get_contributors_field_map(self): + result = super()._get_contributors_field_map() + result["weblate_translations"] = "translations" + return result diff --git a/contributors_weblate/pyproject.toml b/contributors_weblate/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/contributors_weblate/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/contributors_weblate/readme/CONTRIBUTORS.md b/contributors_weblate/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..2c066ba --- /dev/null +++ b/contributors_weblate/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Dixmit](https://www.dixmit.com) + - Enric Tobella diff --git a/contributors_weblate/readme/DESCRIPTION.md b/contributors_weblate/readme/DESCRIPTION.md new file mode 100644 index 0000000..fca9cea --- /dev/null +++ b/contributors_weblate/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Get Weblate data and integrate it inside your contributors information. diff --git a/contributors_weblate/readme/USAGE.md b/contributors_weblate/readme/USAGE.md new file mode 100644 index 0000000..e10c629 --- /dev/null +++ b/contributors_weblate/readme/USAGE.md @@ -0,0 +1,12 @@ +Just access your organization and fill the data from your weblate in the specific tab. + +For the first loading, might be faster to execute the code in shell by using the following commands (increase the delta if you want): + +``` python +self = self.env["contributors.organization"].search([], limit=1) # Use your organization here +for i in range(1,100): + self.get_weblate_data() + self.env.cr.commit() +``` + +Once it has finished, restart the cron. \ No newline at end of file diff --git a/contributors_weblate/security/ir.model.access.csv b/contributors_weblate/security/ir.model.access.csv new file mode 100644 index 0000000..39fa2dc --- /dev/null +++ b/contributors_weblate/security/ir.model.access.csv @@ -0,0 +1,2 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_translations,Access Translation,model_contributors_translation,base.group_user,1,0,0,0 diff --git a/contributors_weblate/static/description/icon.png b/contributors_weblate/static/description/icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/contributors_weblate/static/description/icon.png differ diff --git a/contributors_weblate/static/description/index.html b/contributors_weblate/static/description/index.html new file mode 100644 index 0000000..8e9864e --- /dev/null +++ b/contributors_weblate/static/description/index.html @@ -0,0 +1,434 @@ + + + + + +Contributors Weblate + + + +
+

Contributors Weblate

+ + +

Beta License: AGPL-3 OCA-contributors/contributors-module

+

Get Weblate data and integrate it inside your contributors information.

+

Table of contents

+ +
+

Usage

+

Just access your organization and fill the data from your weblate in the +specific tab.

+

For the first loading, might be faster to execute the code in shell by +using the following commands (increase the delta if you want):

+
+self = self.env["contributors.organization"].search([], limit=1) # Use your organization here
+for i in range(1,100):
+    self.get_weblate_data()
+    self.env.cr.commit()
+
+

Once it has finished, restart the cron.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Dixmit
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the OCA-contributors/contributors-module project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/contributors_weblate/static/src/components/contributors_render/contributors_render.esm.js b/contributors_weblate/static/src/components/contributors_render/contributors_render.esm.js new file mode 100644 index 0000000..fdf5701 --- /dev/null +++ b/contributors_weblate/static/src/components/contributors_render/contributors_render.esm.js @@ -0,0 +1,95 @@ +import {AutoComplete} from "@web/core/autocomplete/autocomplete"; +import {ContributorsRender} from "@contributors_github/components/contributors_render/contributors_render.esm"; +import {_t} from "@web/core/l10n/translation"; +import {onMounted} from "@odoo/owl"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; + +patch(ContributorsRender, { + components: { + ...ContributorsRender.components, + AutoComplete, + }, +}); +patch(ContributorsRender.prototype, { + setup() { + super.setup(); + this.state.rawLangs = []; + this.state.lang = ""; + this.state.langs = []; + this.state.langId = null; + this.state.translations = []; + this.state.sort.translations = "translations"; + this.selectLanguage = _t("Select Language"); + this.orm = useService("orm"); + onMounted(this.fetchLangs.bind(this)); + }, + async fetchData() { + const data = await super.fetchData(); + if (this.state.kind === "translations") { + this.state.translations = data.data; + } + return data; + }, + async fetchLangs() { + const langs = await this.orm.searchRead( + "res.lang", + [["active", "in", [true, false]]], + ["id", "name"] + ); + this.state.rawLangs = Object.fromEntries(langs.map((lang) => [lang.id, lang])); + this.state.langs = langs.map((lang) => { + return { + label: lang.name, + value: lang.id, + }; + }); + }, + getRowData(row_id) { + if (this.state.kind === "translations") { + return this.state.translations[row_id]; + } + return super.getRowData(row_id); + }, + get rowIds() { + if (this.state.kind === "translations") { + return Object.keys(this.state.translations).sort( + (a, b) => + this.state.translations[b][this.state.sort.translations] - + this.state.translations[a][this.state.sort.translations] + ); + } + return super.rowIds; + }, + get langSources() { + const langs = this.state.langs; + return [ + { + async options(query) { + return langs.filter((lang) => + lang.label.toLowerCase().includes(query.toLowerCase()) + ); + }, + }, + ]; + }, + onChangeLang(event) { + if (event.inputValue === "") { + this.state.langId = null; + this.state.lang = ""; + this.fetchData(); + } + }, + onSelectLang(option) { + this.state.langId = option.value; + this.state.lang = this.state.rawLangs[option.value].name; + this.fetchData(); + }, + getParameters() { + const params = super.getParameters(); + if (this.state.langId) { + params.lang_id = this.state.langId; + } + return params; + }, +}); diff --git a/contributors_weblate/static/src/components/contributors_render/contributors_render.xml b/contributors_weblate/static/src/components/contributors_render/contributors_render.xml new file mode 100644 index 0000000..50a6904 --- /dev/null +++ b/contributors_weblate/static/src/components/contributors_render/contributors_render.xml @@ -0,0 +1,29 @@ + + + + + +
+ +
+
+ +
+ +
+
+
+ +
diff --git a/contributors_weblate/templates/templates.xml b/contributors_weblate/templates/templates.xml new file mode 100644 index 0000000..538f1cf --- /dev/null +++ b/contributors_weblate/templates/templates.xml @@ -0,0 +1,14 @@ + + + + diff --git a/contributors_weblate/views/contributors_organization.xml b/contributors_weblate/views/contributors_organization.xml new file mode 100644 index 0000000..7f9e088 --- /dev/null +++ b/contributors_weblate/views/contributors_organization.xml @@ -0,0 +1,43 @@ + + + + + contributors.organization + + + +
+
+ + + + + + + + + + +
+
+
diff --git a/contributors_weblate/views/contributors_repository.xml b/contributors_weblate/views/contributors_repository.xml new file mode 100644 index 0000000..b2ecde6 --- /dev/null +++ b/contributors_weblate/views/contributors_repository.xml @@ -0,0 +1,24 @@ + + + + + contributors.repository + + +
+
+
+
+
diff --git a/contributors_weblate/views/contributors_translation.xml b/contributors_weblate/views/contributors_translation.xml new file mode 100644 index 0000000..d69c579 --- /dev/null +++ b/contributors_weblate/views/contributors_translation.xml @@ -0,0 +1,90 @@ + + + + + contributors.translation + +
+
+ + + + + + + + + + + + + + + + + contributors.translation + + + + + + + + + + + + + + + + + contributors.translation + + + + + + + + + + + + + + Translations + contributors.translation + list,form + [('organization_id', '=', active_id)] + {} + + + + Translations + contributors.translation + list,form + [('repository_id', '=', active_id)] + {} + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8fbceba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# generated from manifests external_dependencies +PyYAML +github3.py