[ADD] ci: CI de Forgejo, bots y pre-commit en todo el repo #19

Merged
snt merged 7 commits from 18.0-ci-forgejo-bots into 18.0 2026-10-04 17:42:04 +00:00
45 changed files with 330 additions and 295 deletions
Showing only changes of commit fbd43bb459 - Show all commits

View file

@ -68,8 +68,9 @@ repos:
# lines of a diff are a single space, stripping it breaks them # lines of a diff are a single space, stripping it breaks them
exclude: /README\.rst$|\.pot?$|\.patch$ exclude: /README\.rst$|\.pot?$|\.patch$
- id: end-of-file-fixer - id: end-of-file-fixer
# exclude autogenerated files # exclude autogenerated files, and patches: they are kept as
exclude: /README\.rst$|\.pot?$ # git format-patch wrote them
exclude: /README\.rst$|\.pot?$|\.patch$
- id: debug-statements - id: debug-statements
- id: check-case-conflict - id: check-case-conflict
- id: check-docstring-first - id: check-docstring-first

View file

@ -1,6 +1,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models from odoo import fields
from odoo import models
class ResConfigSettings(models.TransientModel): class ResConfigSettings(models.TransientModel):

View file

@ -3,7 +3,8 @@
import logging import logging
from datetime import timedelta from datetime import timedelta
from odoo import fields, models from odoo import fields
from odoo import models
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)

View file

@ -13,7 +13,4 @@
"membership_variable_period", "membership_variable_period",
"account", "account",
], ],
"data": [],
"installable": True,
"application": False,
} }

View file

@ -4,7 +4,8 @@
import math import math
from datetime import timedelta from datetime import timedelta
from odoo import fields, models from odoo import fields
from odoo import models
class AccountMove(models.Model): class AccountMove(models.Model):
@ -74,7 +75,11 @@ class AccountMove(models.Model):
return fields.Date.today() return fields.Date.today()
def _compute_membership_dates( def _compute_membership_dates(
self, partner, product, payment_date, quantity=1.0, self,
partner,
product,
payment_date,
quantity=1.0,
exclude_line_ids=None, exclude_line_ids=None,
): ):
"""Calculate date_from and date_to for a membership line. """Calculate date_from and date_to for a membership line.

View file

@ -4,11 +4,10 @@
from datetime import date from datetime import date
from freezegun import freeze_time from freezegun import freeze_time
from psycopg2 import ProgrammingError from psycopg2 import ProgrammingError
from odoo import fields from odoo.tests import Form
from odoo.tests import Form, common from odoo.tests import common
@freeze_time("2026-01-15") @freeze_time("2026-01-15")
@ -123,9 +122,7 @@ class TestMembershipPaymentDate(common.TransactionCase):
def _create_invoice(self, product, invoice_date, quantity=1.0): def _create_invoice(self, product, invoice_date, quantity=1.0):
"""Create and return a draft out_invoice with the given product.""" """Create and return a draft out_invoice with the given product."""
invoice_form = Form( invoice_form = Form(
self.env["account.move"].with_context( self.env["account.move"].with_context(default_move_type="out_invoice")
default_move_type="out_invoice"
)
) )
invoice_form.invoice_date = invoice_date invoice_form.invoice_date = invoice_date
invoice_form.partner_id = self.partner invoice_form.partner_id = self.partner

View file

@ -1,7 +1,9 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, http from odoo import _
from odoo.exceptions import AccessError, MissingError from odoo import http
from odoo.exceptions import AccessError
from odoo.exceptions import MissingError
from odoo.http import request from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal from odoo.addons.portal.controllers.portal import CustomerPortal

View file

@ -2,33 +2,32 @@ import { TicketScreen } from "@point_of_sale/app/screens/ticket_screen/ticket_sc
import { patch } from "@web/core/utils/patch"; import { patch } from "@web/core/utils/patch";
patch(TicketScreen.prototype, { patch(TicketScreen.prototype, {
/** /**
* Mark every refundable line of the selected order for a full refund and * Mark every refundable line of the selected order for a full refund and
* trigger the standard refund flow. * trigger the standard refund flow.
* *
* `onDoRefund` is what the "Refund" action of the ticket screen calls: it * `onDoRefund` is what the "Refund" action of the ticket screen calls: it
* builds the negative lines, links the combo children, carries over the * builds the negative lines, links the combo children, carries over the
* fiscal position and the partner, and switches to the destination order. * fiscal position and the partner, and switches to the destination order.
*/ */
async onDoFullRefund() { async onDoFullRefund() {
const order = this.getSelectedOrder(); const order = this.getSelectedOrder();
if (!order) { if (!order) {
return; return;
}
for (const line of order.lines) {
const toRefundDetails = line
.getAllLinesInCombo()
.map((comboLine) => this.getToRefundDetail(comboLine));
for (const toRefundDetail of toRefundDetails) {
// Per line, and net of what was already refunded: the same
// guard the core applies in `_onUpdateSelectedOrderline`.
const refundableQty =
toRefundDetail.line.qty - toRefundDetail.line.refunded_qty;
if (refundableQty > 0) {
toRefundDetail.qty = refundableQty;
} }
} for (const line of order.lines) {
} const toRefundDetails = line
await this.onDoRefund(); .getAllLinesInCombo()
}, .map((comboLine) => this.getToRefundDetail(comboLine));
for (const toRefundDetail of toRefundDetails) {
// Per line, and net of what was already refunded: the same
// guard the core applies in `_onUpdateSelectedOrderline`.
const refundableQty = toRefundDetail.line.qty - toRefundDetail.line.refunded_qty;
if (refundableQty > 0) {
toRefundDetail.qty = refundableQty;
}
}
}
await this.onDoRefund();
},
}); });

View file

@ -14,27 +14,24 @@ import { registry } from "@web/core/registry";
* negated at its full quantity, without touching the numpad. * negated at its full quantity, without touching the numpad.
*/ */
registry.category("web_tour.tours").add("pos_full_refund_tour", { registry.category("web_tour.tours").add("pos_full_refund_tour", {
steps: () => steps: () =>
[ [
Chrome.startPoS(), Chrome.startPoS(),
Dialog.confirm("Open Register"), Dialog.confirm("Open Register"),
ProductScreen.addOrderline("Desk Pad", "2", "3"), ProductScreen.addOrderline("Desk Pad", "2", "3"),
ProductScreen.addOrderline("Letter Tray", "3", "2"), ProductScreen.addOrderline("Letter Tray", "3", "2"),
ProductScreen.clickPayButton(), ProductScreen.clickPayButton(),
PaymentScreen.clickPaymentMethod("Bank"), PaymentScreen.clickPaymentMethod("Bank"),
PaymentScreen.clickValidate(), PaymentScreen.clickValidate(),
ReceiptScreen.isShown(), ReceiptScreen.isShown(),
ReceiptScreen.clickNextOrder(), ReceiptScreen.clickNextOrder(),
ProductScreen.clickRefund(), ProductScreen.clickRefund(),
TicketScreen.selectOrder("-0001"), TicketScreen.selectOrder("-0001"),
TicketScreen.clickControlButton("Do Full Refund"), TicketScreen.clickControlButton("Do Full Refund"),
{ ...ProductScreen.back(), isActive: ["mobile"] }, { ...ProductScreen.back(), isActive: ["mobile"] },
ProductScreen.isShown(), ProductScreen.isShown(),
inLeftSide([ inLeftSide([...Order.hasLine("Desk Pad", "-2"), ...Order.hasLine("Letter Tray", "-3")]),
...Order.hasLine("Desk Pad", "-2"), ].flat(),
...Order.hasLine("Letter Tray", "-3"),
]),
].flat(),
}); });

View file

@ -4,64 +4,64 @@ import { Component } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/store/pos_hook"; import { usePos } from "@point_of_sale/app/store/pos_hook";
export class ReorderButton extends Component { export class ReorderButton extends Component {
static template = "ReorderButton"; static template = "ReorderButton";
setup() { setup() {
this.pos = usePos(); this.pos = usePos();
}
get isEmptyOrder() {
if (!this.props.order) return true;
return this.props.order.is_empty();
}
_reOrder() {
if (this.isEmptyOrder) {
return;
} }
const order = this.props.order; get isEmptyOrder() {
const pos = this.pos; if (!this.props.order) return true;
const partner = order.get_partner(); return this.props.order.is_empty();
const newOrder = pos.add_new_order();
if (partner) {
newOrder.set_partner(partner);
} }
if (order.fiscal_position) { _reOrder() {
newOrder.fiscal_position = order.fiscal_position; if (this.isEmptyOrder) {
return;
}
const order = this.props.order;
const pos = this.pos;
const partner = order.get_partner();
const newOrder = pos.add_new_order();
if (partner) {
newOrder.set_partner(partner);
}
if (order.fiscal_position) {
newOrder.fiscal_position = order.fiscal_position;
}
if (order.pricelist) {
newOrder.set_pricelist(order.pricelist);
}
const lines = order.get_orderlines();
for (var i = 0; i < lines.length; i++) {
const line = lines[i];
const new_line = this.props.order.models["pos.order.line"].create(
this._prepareReorderLineVals(newOrder, line)
);
if (line.pack_lot_lines) {
new_line.setPackLotLines({
modifiedPackLotLines: [],
newPackLotLines: (line.lot_names || []).map((name) => ({
lot_name: name,
})),
});
}
new_line.set_unit_price(line.get_unit_price());
new_line.set_quantity(line.get_quantity());
new_line.set_discount(line.get_discount());
}
this.pos.closeScreen();
} }
if (order.pricelist) { _prepareReorderLineVals(order, line) {
newOrder.set_pricelist(order.pricelist); return {
order_id: order,
product_id: line.product_id,
description: line.name,
price_unit: line.price_unit,
tax_ids: line.tax_ids.map((tax) => ["link", tax]),
price_manually_set: true,
customer_note: line.customer_note,
};
} }
const lines = order.get_orderlines(); _onClick() {
for (var i = 0; i < lines.length; i++) { this._reOrder();
const line = lines[i];
const new_line = this.props.order.models["pos.order.line"].create(
this._prepareReorderLineVals(newOrder, line)
);
if (line.pack_lot_lines) {
new_line.setPackLotLines({
modifiedPackLotLines: [],
newPackLotLines: (line.lot_names || []).map((name) => ({
lot_name: name,
})),
});
}
new_line.set_unit_price(line.get_unit_price());
new_line.set_quantity(line.get_quantity());
new_line.set_discount(line.get_discount());
} }
this.pos.closeScreen();
}
_prepareReorderLineVals(order, line) {
return {
order_id: order,
product_id: line.product_id,
description: line.name,
price_unit: line.price_unit,
tax_ids: line.tax_ids.map((tax) => ["link", tax]),
price_manually_set: true,
customer_note: line.customer_note,
};
}
_onClick() {
this._reOrder();
}
} }

View file

@ -3,5 +3,5 @@ import { patch } from "@web/core/utils/patch";
import { ReorderButton } from "@pos_order_reorder/js/Screens/TicketScreen/ControlButtons/ReorderButton.esm"; import { ReorderButton } from "@pos_order_reorder/js/Screens/TicketScreen/ControlButtons/ReorderButton.esm";
patch(TicketScreen, { patch(TicketScreen, {
components: { ...TicketScreen.components, ReorderButton }, components: { ...TicketScreen.components, ReorderButton },
}); });

View file

@ -19,5 +19,4 @@
"pos_payment_method_cashdro_fix/static/src/js/models_fix.esm.js", "pos_payment_method_cashdro_fix/static/src/js/models_fix.esm.js",
], ],
}, },
"installable": True,
} }

View file

@ -17,6 +17,4 @@
"views/res_config_settings_views.xml", "views/res_config_settings_views.xml",
"views/account_move_views.xml", "views/account_move_views.xml",
], ],
"installable": True,
"application": False,
} }

View file

@ -1,6 +1,8 @@
# Copyright 2024 Criptomart # Copyright 2024 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, fields, models from odoo import api
from odoo import fields
from odoo import models
class AccountMove(models.Model): class AccountMove(models.Model):

View file

@ -1,6 +1,7 @@
# Copyright 2024 Criptomart # Copyright 2024 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models from odoo import fields
from odoo import models
class ResCompany(models.Model): class ResCompany(models.Model):

View file

@ -1,6 +1,7 @@
# Copyright 2024 Criptomart # Copyright 2024 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, fields, models from odoo import fields
from odoo import models
class ResConfigSettings(models.TransientModel): class ResConfigSettings(models.TransientModel):

View file

@ -1,6 +1,7 @@
# Copyright 2024 Criptomart # Copyright 2024 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models from odoo import fields
from odoo import models
class ResPartner(models.Model): class ResPartner(models.Model):

View file

@ -4,6 +4,7 @@
from odoo import fields from odoo import fields
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.tests import tagged from odoo.tests import tagged
from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.addons.account.tests.common import AccountTestInvoicingCommon

View file

@ -2,7 +2,10 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from collections import defaultdict from collections import defaultdict
from odoo import _, api, fields, models from odoo import _
from odoo import api
from odoo import fields
from odoo import models
from odoo.exceptions import UserError from odoo.exceptions import UserError
@ -52,8 +55,7 @@ class AccountMoveRappelWizard(models.TransientModel):
# Filter only posted vendor bills and refunds # Filter only posted vendor bills and refunds
valid_moves = moves.filtered( valid_moves = moves.filtered(
lambda m: m.move_type in ("in_invoice", "in_refund") lambda m: m.move_type in ("in_invoice", "in_refund") and m.state == "posted"
and m.state == "posted"
) )
if not valid_moves: if not valid_moves:
raise UserError( raise UserError(

View file

@ -13,5 +13,4 @@
"membership", "membership",
"website_membership", "website_membership",
], ],
"data": [],
} }

View file

@ -1,7 +1,8 @@
# Copyright 2026 Criptomart # Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, http from odoo import fields
from odoo import http
from odoo.http import request from odoo.http import request
from odoo.tools.translate import _ from odoo.tools.translate import _
@ -28,9 +29,7 @@ class WebsiteMembership(BaseWebsiteMembership):
self._add_associate_members(response, membership_id, country_id, page, post) self._add_associate_members(response, membership_id, country_id, page, post)
return response return response
def _add_associate_members( def _add_associate_members(self, response, membership_id, country_id, page, post):
self, response, membership_id, country_id, page, post
):
values = response.qcontext values = response.qcontext
memberships_partner_ids = values.get("memberships_partner_ids", {}) memberships_partner_ids = values.get("memberships_partner_ids", {})
if not memberships_partner_ids: if not memberships_partner_ids:
@ -67,25 +66,19 @@ class WebsiteMembership(BaseWebsiteMembership):
if associate.id not in new_memberships_partner_ids[key]: if associate.id not in new_memberships_partner_ids[key]:
new_memberships_partner_ids[key].append(associate.id) new_memberships_partner_ids[key].append(associate.id)
values["memberships_partner_ids"] = { values["memberships_partner_ids"] = {
key: ids key: ids for key, ids in new_memberships_partner_ids.items() if ids
for key, ids in new_memberships_partner_ids.items()
if ids
} }
displayed_ids = set( displayed_ids = {
pid pid for ids in values["memberships_partner_ids"].values() for pid in ids
for ids in values["memberships_partner_ids"].values() }
for pid in ids
)
values["partners"] = { values["partners"] = {
p.id: p p.id: p
for p in request.env["res.partner"] for p in request.env["res.partner"].sudo().browse(list(displayed_ids))
.sudo()
.browse(list(displayed_ids))
} }
all_host_ids, all_free_host_ids, all_associates = ( all_host_ids, all_free_host_ids, all_associates = self._get_all_visible_ids(
self._get_all_visible_ids(membership_id, country_id, post_name) membership_id, country_id, post_name
) )
self._recompute_countries( self._recompute_countries(
values, all_host_ids, all_free_host_ids, all_associates, country_id values, all_host_ids, all_free_host_ids, all_associates, country_id
@ -134,9 +127,7 @@ class WebsiteMembership(BaseWebsiteMembership):
if membership_id == "free": if membership_id == "free":
all_host_ids = set() all_host_ids = set()
else: else:
all_host_ids = self._get_all_host_ids( all_host_ids = self._get_all_host_ids(membership_id, country_id, post_name)
membership_id, country_id, post_name
)
all_free_host_ids = ( all_free_host_ids = (
self._get_all_free_host_ids(country_id, post_name) self._get_all_free_host_ids(country_id, post_name)
if free_selected if free_selected
@ -214,8 +205,7 @@ class WebsiteMembership(BaseWebsiteMembership):
if country_id: if country_id:
current_country = Country.browse(country_id).read(["id", "name"])[0] current_country = Country.browse(country_id).read(["id", "name"])[0]
if not any( if not any(
x["country_id"] and x["country_id"][0] == country_id x["country_id"] and x["country_id"][0] == country_id for x in countries
for x in countries
): ):
countries.append( countries.append(
{ {
@ -243,16 +233,12 @@ class WebsiteMembership(BaseWebsiteMembership):
return return
partners = request.env["res.partner"].sudo().browse(map_ids) partners = request.env["res.partner"].sudo().browse(map_ids)
if country_id: if country_id:
partners = partners.filtered( partners = partners.filtered(lambda p: p.country_id.id == country_id)
lambda p: p.country_id.id == country_id
)
if post_name: if post_name:
partners = partners.filtered( partners = partners.filtered(
lambda p: self._partner_matches_search(p, post_name) lambda p: self._partner_matches_search(p, post_name)
) )
values["google_map_partner_ids"] = ",".join( values["google_map_partner_ids"] = ",".join(str(pid) for pid in partners.ids)
str(pid) for pid in partners.ids
)
def _update_pager( def _update_pager(
self, self,
@ -268,9 +254,7 @@ class WebsiteMembership(BaseWebsiteMembership):
if membership_id == "free": if membership_id == "free":
displayed_host_ids = set() displayed_host_ids = set()
else: else:
host_records = request.env["res.partner"].sudo().browse( host_records = request.env["res.partner"].sudo().browse(list(all_host_ids))
list(all_host_ids)
)
displayed_host_ids = { displayed_host_ids = {
host.id host.id
for host in host_records for host in host_records
@ -278,7 +262,7 @@ class WebsiteMembership(BaseWebsiteMembership):
} }
count_members = len(displayed_host_ids) + len(all_free_host_ids) count_members = len(displayed_host_ids) + len(all_free_host_ids)
limit = self._references_per_page limit = self._references_per_page
base_url = "/members%s%s" % ( base_url = "/members{}{}".format(
"/association/%s" % membership_id if membership_id else "", "/association/%s" % membership_id if membership_id else "",
"/country/%s" % country_id if country_id else "", "/country/%s" % country_id if country_id else "",
) )

View file

@ -1,7 +1,8 @@
# Copyright 2026 Criptomart # Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, models from odoo import api
from odoo import models
from odoo.osv import expression from odoo.osv import expression
@ -41,10 +42,7 @@ class MembershipLine(models.Model):
("website_description", "ilike", post_name), ("website_description", "ilike", post_name),
] ]
host_ids = ( host_ids = (
self.env["res.partner"] self.env["res.partner"].sudo().search(associate_domain).associate_member.ids
.sudo()
.search(associate_domain)
.associate_member.ids
) )
if not host_ids: if not host_ids:
return domain return domain
@ -84,8 +82,7 @@ class MembershipLine(models.Model):
AND p.is_company AND p.is_company
AND m.id IN %s AND m.id IN %s
ORDER BY p.id ORDER BY p.id
""" """ + limit_clause,
+ limit_clause,
(tuple(self.ids),), (tuple(self.ids),),
) )
return [row[0] for row in self.env.cr.fetchall()] return [row[0] for row in self.env.cr.fetchall()]

View file

@ -2,7 +2,8 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields from odoo import fields
from odoo.tests import HttpCase, tagged from odoo.tests import HttpCase
from odoo.tests import tagged
from odoo.addons.membership.tests.common import TestMembershipCommon from odoo.addons.membership.tests.common import TestMembershipCommon
@ -125,9 +126,7 @@ class TestWebsiteMembershipAssociatePagination(HttpCase, TestMembershipCommon):
for i in range(20) for i in range(20)
] ]
cls.paginated_hosts = cls.env["res.partner"].create(host_vals) cls.paginated_hosts = cls.env["res.partner"].create(host_vals)
invoices = cls.paginated_hosts.create_membership_invoice( invoices = cls.paginated_hosts.create_membership_invoice(cls.membership_1, 75.0)
cls.membership_1, 75.0
)
invoices.action_post() invoices.action_post()
cls.env["account.payment.register"].with_context( cls.env["account.payment.register"].with_context(
active_model="account.move", active_ids=invoices.ids active_model="account.move", active_ids=invoices.ids

View file

@ -6,7 +6,7 @@
"version": "18.0.1.0.1", "version": "18.0.1.0.1",
"category": "Website/Sale", "category": "Website/Sale",
"summary": "Force a clean /web/signup step before adding membership " "summary": "Force a clean /web/signup step before adding membership "
"products to cart on a b2b website (EMES-style flow).", "products to cart on a b2b website (EMES-style flow).",
"author": "Criptomart", "author": "Criptomart",
"website": "https://git.criptomart.net/criptomart/addons-cm", "website": "https://git.criptomart.net/criptomart/addons-cm",
"license": "AGPL-3", "license": "AGPL-3",
@ -25,7 +25,4 @@
"website_membership_signup_required/static/src/js/website_membership_signup_required.js", "website_membership_signup_required/static/src/js/website_membership_signup_required.js",
], ],
}, },
"external_dependencies": {},
"installable": True,
"application": False,
} }

View file

@ -1,11 +1,13 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, http from odoo import _
from odoo.addons.auth_signup.controllers.main import AuthSignupHome from odoo import http
from odoo.addons.web.controllers.home import SIGN_UP_REQUEST_PARAMS
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.http import request from odoo.http import request
from odoo.addons.auth_signup.controllers.main import AuthSignupHome
from odoo.addons.web.controllers.home import SIGN_UP_REQUEST_PARAMS
# partner_firstname exposes `firstname` / `lastname` on res.users and # partner_firstname exposes `firstname` / `lastname` on res.users and
# res.partner; we need them as accepted qcontext params so the signup form # res.partner; we need them as accepted qcontext params so the signup form
# roundtrips them transparently through `get_auth_signup_qcontext`. # roundtrips them transparently through `get_auth_signup_qcontext`.
@ -18,7 +20,10 @@ SIGN_UP_REQUEST_PARAMS.add("lastname")
SIGN_UP_REQUEST_PARAMS.add("accepted_terms") SIGN_UP_REQUEST_PARAMS.add("accepted_terms")
# Session keys used by the membership signup flow. # Session keys used by the membership signup flow.
MEMBERSHIP_SIGNUP_SESSION_KEYS = ("membership_signup_active", "membership_signup_product_id") MEMBERSHIP_SIGNUP_SESSION_KEYS = (
"membership_signup_active",
"membership_signup_product_id",
)
class AuthSignupHomeMembership(AuthSignupHome): class AuthSignupHomeMembership(AuthSignupHome):
@ -71,9 +76,7 @@ class AuthSignupHomeMembership(AuthSignupHome):
values["firstname"] = firstname values["firstname"] = firstname
values["lastname"] = lastname values["lastname"] = lastname
partner_model = request.env["res.partner"] partner_model = request.env["res.partner"]
values["name"] = partner_model._get_computed_name( values["name"] = partner_model._get_computed_name(lastname, firstname)
lastname, firstname
)
return values return values
@http.route() @http.route()

View file

@ -3,9 +3,10 @@
from urllib.parse import urlencode from urllib.parse import urlencode
from odoo import http from odoo import http
from odoo.addons.website_sale.controllers.main import WebsiteSale
from odoo.http import request from odoo.http import request
from odoo.addons.website_sale.controllers.main import WebsiteSale
SIGNUP_FLAG = "membership_signup=1" SIGNUP_FLAG = "membership_signup=1"
@ -36,24 +37,37 @@ class WebsiteSaleMembershipSignup(WebsiteSale):
@http.route() @http.route()
def cart_update( def cart_update(
self, product_id, add_qty=1, set_qty=0, self,
product_id,
add_qty=1,
set_qty=0,
product_custom_attribute_values=None, product_custom_attribute_values=None,
no_variant_attribute_value_ids=None, **kwargs no_variant_attribute_value_ids=None,
**kwargs,
): ):
if self._membership_signup_is_required(product_id): if self._membership_signup_is_required(product_id):
self._membership_signup_mark_session(product_id) self._membership_signup_mark_session(product_id)
return request.redirect(self._membership_signup_redirect_url(product_id)) return request.redirect(self._membership_signup_redirect_url(product_id))
return super().cart_update( return super().cart_update(
product_id=product_id, add_qty=add_qty, set_qty=set_qty, product_id=product_id,
add_qty=add_qty,
set_qty=set_qty,
product_custom_attribute_values=product_custom_attribute_values, product_custom_attribute_values=product_custom_attribute_values,
no_variant_attribute_value_ids=no_variant_attribute_value_ids, no_variant_attribute_value_ids=no_variant_attribute_value_ids,
**kwargs **kwargs,
) )
@http.route() @http.route()
def cart_update_json( def cart_update_json(
self, product_id, line_id=None, add_qty=None, set_qty=None, display=True, self,
product_custom_attribute_values=None, no_variant_attribute_value_ids=None, **kwargs product_id,
line_id=None,
add_qty=None,
set_qty=None,
display=True,
product_custom_attribute_values=None,
no_variant_attribute_value_ids=None,
**kwargs,
): ):
if self._membership_signup_is_required(product_id): if self._membership_signup_is_required(product_id):
self._membership_signup_mark_session(product_id) self._membership_signup_mark_session(product_id)
@ -90,11 +104,14 @@ class WebsiteSaleMembershipSignup(WebsiteSale):
"notification_info": {"lines": [], "warning": ""}, "notification_info": {"lines": [], "warning": ""},
} }
return super().cart_update_json( return super().cart_update_json(
product_id=product_id, line_id=line_id, add_qty=add_qty, product_id=product_id,
set_qty=set_qty, display=display, line_id=line_id,
add_qty=add_qty,
set_qty=set_qty,
display=display,
product_custom_attribute_values=product_custom_attribute_values, product_custom_attribute_values=product_custom_attribute_values,
no_variant_attribute_value_ids=no_variant_attribute_value_ids, no_variant_attribute_value_ids=no_variant_attribute_value_ids,
**kwargs **kwargs,
) )
def _get_mandatory_address_fields(self, country_sudo): def _get_mandatory_address_fields(self, country_sudo):

View file

@ -1,6 +1,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models from odoo import fields
from odoo import models
class ResConfigSettings(models.TransientModel): class ResConfigSettings(models.TransientModel):

View file

@ -1,6 +1,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, models from odoo import api
from odoo import models
class ResPartner(models.Model): class ResPartner(models.Model):

View file

@ -1,6 +1,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, models from odoo import api
from odoo import models
from odoo.http import request from odoo.http import request

View file

@ -1,6 +1,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models from odoo import fields
from odoo import models
class Website(models.Model): class Website(models.Model):
@ -9,8 +10,8 @@ class Website(models.Model):
membership_signup_required = fields.Boolean( membership_signup_required = fields.Boolean(
string="Force signup for membership products", string="Force signup for membership products",
help="When enabled, anonymous users trying to add a membership product " help="When enabled, anonymous users trying to add a membership product "
"to cart are redirected to /web/signup before the product is " "to cart are redirected to /web/signup before the product is "
"added. Only affects the e-commerce flow.", "added. Only affects the e-commerce flow.",
default=True, default=True,
) )

View file

@ -64,8 +64,9 @@ registry.category("web_tour.tours").add("website_membership_signup_required_tour
run: "click", run: "click",
}, },
{ {
content: "Submit the signup form -> redirect straight to cart " content:
+ "with the product already added (post-login auto-add)", "Submit the signup form -> redirect straight to cart " +
"with the product already added (post-login auto-add)",
trigger: ".oe_signup_form button[type='submit']", trigger: ".oe_signup_form button[type='submit']",
run: "click", run: "click",
expectUnloadPage: true, expectUnloadPage: true,

View file

@ -4,15 +4,16 @@ import werkzeug.datastructures
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.fields import Date from odoo.fields import Date
from odoo.tests import TransactionCase
from odoo.tests import tagged
from odoo.addons.website.tools import MockRequest
from odoo.addons.website_membership_signup_required.controllers.main import ( from odoo.addons.website_membership_signup_required.controllers.main import (
AuthSignupHomeMembership as AuthSignupHome, AuthSignupHomeMembership as AuthSignupHome,
) )
from odoo.addons.website_membership_signup_required.controllers.website_sale import ( from odoo.addons.website_membership_signup_required.controllers.website_sale import (
WebsiteSaleMembershipSignup, WebsiteSaleMembershipSignup,
) )
from odoo.addons.website.tools import MockRequest
from odoo.tests import TransactionCase, tagged
@tagged("post_install", "-at_install") @tagged("post_install", "-at_install")
@ -24,22 +25,28 @@ class TestMembershipSignup(TransactionCase):
cls.website = cls.env.ref("website.default_website") cls.website = cls.env.ref("website.default_website")
cls.website.auth_signup_uninvited = "b2b" cls.website.auth_signup_uninvited = "b2b"
today = Date.to_date(Date.today()) today = Date.to_date(Date.today())
cls.membership_product = cls.env["product.product"].create({ cls.membership_product = cls.env["product.product"].create(
"name": "Membership Card", {
"membership": True, "name": "Membership Card",
"list_price": 50.0, "membership": True,
"membership_date_from": today.replace(month=1, day=1), "list_price": 50.0,
"membership_date_to": today.replace(month=12, day=31), "membership_date_from": today.replace(month=1, day=1),
}) "membership_date_to": today.replace(month=12, day=31),
cls.regular_product = cls.env["product.product"].create({ }
"name": "Regular Mug", )
"membership": False, cls.regular_product = cls.env["product.product"].create(
"list_price": 10.0, {
}) "name": "Regular Mug",
cls.no_user_partner = cls.env["res.partner"].create({ "membership": False,
"firstname": "Backend", "list_price": 10.0,
"lastname": "Partner", }
}) )
cls.no_user_partner = cls.env["res.partner"].create(
{
"firstname": "Backend",
"lastname": "Partner",
}
)
# -- RF-07 / RF-default ------------------------------------------------ # -- RF-07 / RF-default ------------------------------------------------
@ -198,13 +205,21 @@ class TestMembershipSignup(TransactionCase):
# -- RF-08 ------------------------------------------------------------- # -- RF-08 -------------------------------------------------------------
def test_08_backend_sale_order_with_membership_product_without_user(self): def test_08_backend_sale_order_with_membership_product_without_user(self):
order = self.env["sale.order"].create({ order = self.env["sale.order"].create(
"partner_id": self.no_user_partner.id, {
"order_line": [(0, 0, { "partner_id": self.no_user_partner.id,
"product_id": self.membership_product.id, "order_line": [
"product_uom_qty": 1.0, (
})], 0,
}) 0,
{
"product_id": self.membership_product.id,
"product_uom_qty": 1.0,
},
)
],
}
)
self.assertTrue(order) self.assertTrue(order)
self.assertEqual(order.order_line.product_id, self.membership_product) self.assertEqual(order.order_line.product_id, self.membership_product)
# Confirming the SO drives membership creation (core `membership`). # Confirming the SO drives membership creation (core `membership`).
@ -276,12 +291,14 @@ class TestMembershipSignup(TransactionCase):
The invited partner must NOT have a user yet, otherwise the token The invited partner must NOT have a user yet, otherwise the token
payload check (`user_ids`) won't match. payload check (`user_ids`) won't match.
""" """
partner = self.env["res.partner"].create({ partner = self.env["res.partner"].create(
"firstname": "Invited", {
"lastname": "Researcher", "firstname": "Invited",
"email": "invited.researcher@example.com", "lastname": "Researcher",
"signup_type": "signup", "email": "invited.researcher@example.com",
}) "signup_type": "signup",
}
)
self.assertFalse(partner.user_ids, "test precondition: no user yet") self.assertFalse(partner.user_ids, "test precondition: no user yet")
token = partner._generate_signup_token() token = partner._generate_signup_token()
info = self.env["res.partner"]._signup_retrieve_info(token) info = self.env["res.partner"]._signup_retrieve_info(token)
@ -297,11 +314,13 @@ class TestMembershipSignup(TransactionCase):
still works — the form will show empty readonly inputs for the split still works — the form will show empty readonly inputs for the split
fields, which is the best we can do without re-splitting. fields, which is the best we can do without re-splitting.
""" """
partner = self.env["res.partner"].create({ partner = self.env["res.partner"].create(
"name": "Legacy Researcher", {
"email": "legacy@example.com", "name": "Legacy Researcher",
"signup_type": "signup", "email": "legacy@example.com",
}) "signup_type": "signup",
}
)
# partner_firstname's post_create hook may or may not have split # partner_firstname's post_create hook may or may not have split
# `name` automatically; we just check the override does not crash # `name` automatically; we just check the override does not crash
# and returns the contract keys. # and returns the contract keys.

View file

@ -7,8 +7,8 @@ import time
from urllib.parse import unquote from urllib.parse import unquote
from odoo.fields import Date from odoo.fields import Date
from odoo.tests import HttpCase
from odoo.tests import HttpCase, tagged from odoo.tests import tagged
@tagged("post_install", "-at_install") @tagged("post_install", "-at_install")
@ -21,28 +21,34 @@ class TestMembershipSignupTour(HttpCase):
cls.website.auth_signup_uninvited = "b2b" cls.website.auth_signup_uninvited = "b2b"
cls.website.membership_signup_required = True cls.website.membership_signup_required = True
today = Date.to_date(Date.today()) today = Date.to_date(Date.today())
cls.membership_product = cls.env["product.template"].create({ cls.membership_product = cls.env["product.template"].create(
"name": "Membership Test Product", {
"membership": True, "name": "Membership Test Product",
"list_price": 60.0, "membership": True,
"is_published": True, "list_price": 60.0,
"sale_ok": True, "is_published": True,
"website_id": cls.website.id, "sale_ok": True,
"membership_date_from": today.replace(month=1, day=1), "website_id": cls.website.id,
"membership_date_to": today.replace(month=12, day=31), "membership_date_from": today.replace(month=1, day=1),
}) "membership_date_to": today.replace(month=12, day=31),
cls.regular_product = cls.env["product.template"].create({ }
"name": "Regular Test Product", )
"membership": False, cls.regular_product = cls.env["product.template"].create(
"list_price": 5.0, {
"is_published": True, "name": "Regular Test Product",
"sale_ok": True, "membership": False,
"website_id": cls.website.id, "list_price": 5.0,
}) "is_published": True,
"sale_ok": True,
"website_id": cls.website.id,
}
)
# Enable the custom/transfer payment provider if it is installed so the # Enable the custom/transfer payment provider if it is installed so the
# cart page renders fully (no checkout/payment is exercised by the # cart page renders fully (no checkout/payment is exercised by the
# tours, but it avoids warnings during cart rendering). # tours, but it avoids warnings during cart rendering).
provider = cls.env.ref("payment.payment_provider_transfer", raise_if_not_found=False) provider = cls.env.ref(
"payment.payment_provider_transfer", raise_if_not_found=False
)
if provider and provider.state != "enabled": if provider and provider.state != "enabled":
provider.write({"state": "enabled", "is_published": True}) provider.write({"state": "enabled", "is_published": True})
@ -54,20 +60,24 @@ class TestMembershipSignupTour(HttpCase):
) )
# The tour submitted the signup form, so a new portal user must exist # The tour submitted the signup form, so a new portal user must exist
# and the cart of that user must contain the membership product. # and the cart of that user must contain the membership product.
users = self.env["res.users"].search([ users = self.env["res.users"].search(
("login", "like", "membership.tour.%@test.example.com"), [
]) ("login", "like", "membership.tour.%@test.example.com"),
]
)
self.assertTrue(users, "Tour should have created a user via /web/signup") self.assertTrue(users, "Tour should have created a user via /web/signup")
first = users.sorted("create_date")[-1] first = users.sorted("create_date")[-1]
self.assertTrue(first.partner_id.firstname) self.assertTrue(first.partner_id.firstname)
self.assertTrue(first.partner_id.lastname) self.assertTrue(first.partner_id.lastname)
carts = self.env["sale.order"].search([ carts = self.env["sale.order"].search(
("partner_id", "=", first.partner_id.id), [
("state", "=", "draft"), ("partner_id", "=", first.partner_id.id),
]) ("state", "=", "draft"),
]
)
self.assertTrue(carts) self.assertTrue(carts)
order_lines = carts.mapped("order_line").filtered( order_lines = carts.mapped("order_line").filtered(
lambda l: l.product_id.product_tmpl_id == self.membership_product lambda line: line.product_id.product_tmpl_id == self.membership_product
) )
self.assertTrue(order_lines, "Cart must contain the membership product") self.assertTrue(order_lines, "Cart must contain the membership product")
@ -93,7 +103,7 @@ class TestMembershipSignupTour(HttpCase):
return None return None
secret = self.env["ir.config_parameter"].sudo().get_param("database.secret") secret = self.env["ir.config_parameter"].sudo().get_param("database.secret")
max_ts = int(time.time() + 31536000) # 1y, matches Odoo's default. max_ts = int(time.time() + 31536000) # 1y, matches Odoo's default.
msg = f"{sid}{max_ts}".encode("utf-8") msg = f"{sid}{max_ts}".encode()
hm = hmac.new(secret.encode("ascii"), msg, hashlib.sha1).hexdigest() hm = hmac.new(secret.encode("ascii"), msg, hashlib.sha1).hexdigest()
return f"{hm}o{max_ts}" return f"{hm}o{max_ts}"
@ -106,8 +116,10 @@ class TestMembershipSignupTour(HttpCase):
product_url = "/shop/%s" % self.env["ir.http"]._slug(product) product_url = "/shop/%s" % self.env["ir.http"]._slug(product)
page = self.url_open(product_url, allow_redirects=True) page = self.url_open(product_url, allow_redirects=True)
self.assertEqual( self.assertEqual(
page.status_code, 200, page.status_code,
"Product page must render (got %s for %s)" % (page.status_code, product_url), 200,
"Product page must render (got %s for %s)"
% (page.status_code, product_url),
) )
match = self._CSRF_RE.search(page.text) match = self._CSRF_RE.search(page.text)
csrf_token = match.group(1) if match else self._compute_csrf_token() csrf_token = match.group(1) if match else self._compute_csrf_token()

View file

@ -18,5 +18,4 @@
"views/res_config_settings_views.xml", "views/res_config_settings_views.xml",
"views/website_sale_disable_cart_templates.xml", "views/website_sale_disable_cart_templates.xml",
], ],
"installable": True,
} }

View file

@ -24,5 +24,4 @@
"website_sale_lazy_loading/static/src/js/website_sale_lazy_loading.js", "website_sale_lazy_loading/static/src/js/website_sale_lazy_loading.js",
], ],
}, },
"installable": True,
} }

View file

@ -76,7 +76,7 @@ class WebsiteSaleLazyLoading(WebsiteSale):
query = urlencode(params, doseq=True) query = urlencode(params, doseq=True)
if not query: if not query:
return LAZY_LOADING_ROUTE return LAZY_LOADING_ROUTE
return "%s?%s" % (LAZY_LOADING_ROUTE, query) return f"{LAZY_LOADING_ROUTE}?{query}"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# AJAX endpoint # AJAX endpoint

View file

@ -47,10 +47,10 @@ class TestLazyLoading(HttpCase):
def _get_lazy_page(self, page, **params): def _get_lazy_page(self, page, **params):
"""Call the AJAX endpoint and return its decoded payload.""" """Call the AJAX endpoint and return its decoded payload."""
query = "&".join("%s=%s" % (key, value) for key, value in params.items()) query = "&".join(f"{key}={value}" for key, value in params.items())
url = "/shop/lazy_products?page=%s" % page url = "/shop/lazy_products?page=%s" % page
if query: if query:
url = "%s&%s" % (url, query) url = f"{url}&{query}"
response = self.url_open(url) response = self.url_open(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
return response.json() return response.json()