Compare commits

...

6 commits

Author SHA1 Message Date
GitHub Copilot
5a2f5d120f [ADD] website_sale_aplicoop: serve Eskaera per website
A co-op can run the plain shop on one website and the group orders on another,
but routes are registered process-wide: every /eskaera page answered on every
website of the database, and Odoo had already copied the Eskaera menu to all of
them.

`website.eskaera_enabled` decides which websites serve it, on by default so
installing changes nothing. It reaches the settings screen through `website_id`,
so it follows the website selector there. Where it is off the routes raise
NotFound and the menu is hidden.

The menu is hidden rather than deleted, by extending `_compute_visible`. That
keeps the record and any manual rename or reordering, so switching the feature
back on restores it as it was.

Note that a JSON route reports the 404 inside the JSON-RPC payload and still
answers HTTP 200; that is the transport, not a hole in the guard, and a test
pins it so the next reader does not take it for one.

The three remaining settings (lazy loading, products per page, low stock
threshold) are still `config_parameter`, so they stay global to the database.
Two websites share their values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:31:03 +02:00
GitHub Copilot
817ff31d39 [REF] website_sale_aplicoop: price through website_sale instead of privately
Eskaera carried its own pricing path: a `website_sale_aplicoop.pricelist_id`
setting resolved inside the Eskaera controllers, plus a local reimplementation
of the tax and discount maths. The pricelist part meant /shop and Eskaera could
quote different prices on the same website, and the maths part had drifted from
the original it was copied from.

Pricing is now the standard one. The setting is gone (a migration drops the
parameter, which would otherwise linger looking like live configuration) and
`_resolve_pricelist` is `request.website.pricelist_id`. Scoping a pricelist to
a website is already core's job through `product.pricelist.website_id`, so
running the plain shop on one website and the co-op on another needs no code
here.

Taxes are applied by `product.template._apply_taxes_to_price`, which fixes a
real defect: it calls `_get_tax_included_unit_price_from_price` first, and this
module did not. With a fiscal position remapping a tax-included tax, a product
at 121 (100 + 21%) was displayed at 121 instead of 110, because the price was
handed to the mapped tax as if it were already that tax's gross amount. The
helper is a no-op without a remapping, so ordinary pricing is untouched. It
also means the website's `show_line_subtotals_tax_selection` is respected
rather than overridden with a hardcoded tax-included display.

Listing a page now costs one `_compute_price_rule` call instead of one per
product, which matters on the lazy-loading path.

Two smaller things found on the way. `_get_product_price_rule` was being passed
`target_currency=`, which is not one of its arguments: it fell into **kwargs
and was ignored, so the conversion never happened. And `_compute_price_info`
resolved `product.product_variant_ids[0]`, but it receives variants, so a
multi-variant template was priced from its first variant rather than the one
asked for.

The delivery display price loses its hardcoded 5.74 fallback and reuses the
same helpers as everything else.

Kept local, because none of it is pricing: the /Kg and /L suffixes, the 0.1
quantity step for bulk goods, the base unit price and the supplier name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:28:51 +02:00
GitHub Copilot
3e4bd5e5db [ADD] website_sale_aplicoop: restore the test coverage lost with the dead code
The dead-code cleanup dropped 11 test files that were never wired into
tests/__init__.py. Reviewing what they covered turned up real holes, so the
worthwhile ones come back, rewritten against the current schema.

Blacklists were the serious gap: product, supplier and category exclusions
have absolute priority over product discovery and nothing exercised them.
The old file had two separate defects. Its supplier fixtures wrote
`main_seller_id` directly, but product_main_seller computes that field from
`variant_seller_ids`, so the compute reset it to False and the blacklist had
nothing to exclude; they now create real supplierinfo records. Worse, four
whole classes asserted against `group_order.product_ids` -- the m2m *input* --
instead of the discovery result, so they set `category_ids` and then checked a
field they never touched. Those go through `_get_products_for_group_order`
now, and three tests that had no assertions at all got some.

The remaining three failures were test bugs too, all Odoo 17->18 leftovers:

* Date cases assumed `pickup_date` derives from `start_date`. The chain is
  cutoff -> pickup -> delivery, and a recurring order whose start date has
  passed rolls forward to the current cycle, so a 2024 order has no 2024
  pickup. The new file anchors on future dates and finds the next 29 February
  dynamically, with a class documenting the roll-forward itself.
* `/eskaera/labels` is `type="json"`; the old test hit it with a plain GET and
  read the resulting 400 as a bug. It is called over JSON-RPC now, and a test
  pins the 400 so nobody repeats it. Also `uom.uom.categ` -> `uom.category`.
* `price_include` is computed in 18.0, so fixtures must set
  `price_include_override`. On top of that `_get_price` filters taxes by
  company and defaults to `env.company`, not the fixture's, which left the
  tax list empty -- `tax_included` was False for the wrong reason.

Two of the portal tests were passing for the wrong reason as well: the access
guard bounced them to /eskaera, which also answers 200. Membership has to be
set from the member side with `is_group`, and a new test checks the final URL
rather than the status alone. Each fixture that can silently build the wrong
thing now carries a guard test.

Left out on purpose: three files were unimplemented placeholders, and
test_draft_persistence still deserves recovering (see the notes file).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:24:31 +02:00
GitHub Copilot
3eb79e2431 [IMP] docker-compose: host paths in .env, override merged in
The OCA sources living outside this repo were mounted from a hardcoded
/media/snt/data/odoo in a local, untracked docker-compose.override.yml.
The host root now comes from ODOO_SRC in .env (gitignored, .env.example
added), so the mounts can live in docker-compose.yml itself and be shared.

Also parameterized: the published ports (ODOO_HTTP_PORT /
ODOO_LONGPOLLING_PORT) and the in-container addons path
(ODOO_ADDONS_PATH), the latter being the single source of truth for the
`command` and for manual `docker-compose run odoo odoo ...` invocations.

Missing variables fail with an explicit message instead of mounting empty
paths. `docker-compose config` resolves exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:57:25 +02:00
GitHub Copilot
b3999e2283 [REM] website_sale_aplicoop: remove dead code and orphaned tests
Duplicate _translate_labels fallback, unreachable /eskaera/add-to-cart and
/eskaera/save-cart routes (the frontend cart is localStorage-only and uses
save-order), redundant pickup wrappers, unused pagination/count helpers and
fields, deprecated JS shims, and the already-empty checkout_summary.js and
i18n key/init leftovers.

Also drops 11 tests/*.py never wired into tests/__init__.py: three were
unimplemented placeholders (setUp with no assertions), and the other eight
had real assertions but were bit-rotted against the current schema (e.g.
res.partner.is_supplier no longer exists) — wiring them in surfaced 43
failures unrelated to this cleanup. Verified 0 failed/0 errors of 270 tests
both before and after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 22:31:13 +02:00
GitHub Copilot
6ba554c91b [ADD] website_sale_aplicoop: online payment per group order
Members can now pay their eskaera at checkout, through the standard Odoo
payment machinery. Enabled per group order with a new `online_payment`
boolean, off by default: an order without it behaves exactly as before,
members save a draft and the cutoff cron confirms them in bulk.

The flow mirrors website_sale's: the checkout button becomes "Confirm and
pay", saving the cart redirects to a new /eskaera/<slug>/payment step that
renders `payment.form` from `sale`'s `_get_payment_values`, and the standard
/my/orders/<id>/transaction route takes it from there. This addon ships no
provider and configures none; the co-op publishes whichever it wants.

`website_sale`'s `_get_shop_payment_values` is deliberately not reused: it
runs `_get_shop_payment_errors`, which blocks on shippable products without a
delivery method — exactly an eskaera order, collected at the co-op with no
carrier. For the same reason the transaction route stays the portal one,
which does not call `_check_cart_is_ready_to_be_paid()`.

Payment confirms the order, which has three consequences handled here:

* `payment.transaction._check_amount_and_confirm_order` now confirms group
  orders with `from_orderpoint=True`, the way the cutoff cron already does.
  Without it a product with a broken replenishment route raises inside
  `_post_process`, and `/payment/status/poll` rolls back and re-raises: the
  member sees a payment error over a `done` transaction and the retry cron
  fails forever.
* `_confirm_linked_sale_orders` also sweeps the cycle's already confirmed
  orders into the picking batch, scoped by `pickup_date`. Its early return on
  "no drafts" ran before any batching, so a fully prepaid cycle produced no
  batch at all. `_cron_batch_paid_orders_of_closed_cycles` covers the same
  hole for cycles closed by hand.
* A duplicate-order guard answers 409 on save-order, add-to-cart and
  load-draft, and shows a notice on the shop, so a member whose order is
  already placed cannot build and pay for a second one.

The payment policy lives in the model rather than the controller: there are
three sale.order creation paths and two are live, so `_compute_require_payment`
and `_compute_prepayment_percent` are extended instead of patching five vals
dicts. Orders are also created under the group order's company, which is what
filters the payment providers.

Along the way: eskaera drafts were invisible in /my/orders. The portal rule is
`message_partner_ids child_of` and sale.order only subscribes the customer on
send or confirm, never on a draft create, so the `_prepare_orders_domain`
override that includes drafts never had any effect. Fixed with an explicit
`message_subscribe`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:59:35 +02:00
55 changed files with 4060 additions and 5225 deletions

22
.env.example Normal file
View file

@ -0,0 +1,22 @@
# Local environment for docker-compose. Copy to `.env` and adjust the paths:
#
# cp .env.example .env
#
# `.env` is gitignored: every developer points these at their own checkouts.
# docker-compose reads it automatically. For manual `docker-compose run` calls
# that need $ODOO_ADDONS_PATH in the shell, load it first:
#
# set -a; . ./.env; set +a
# Host directory holding the read-only Odoo/OCA sources (OCB + the OCA repos
# listed in oca_dependencies.txt). Each repo is mounted at /mnt/oca/<repo>.
ODOO_SRC=/media/snt/data/odoo
# Host ports published by the odoo service (web / longpolling).
ODOO_HTTP_PORT=8069
ODOO_LONGPOLLING_PORT=8072
# Addons path *inside* the container. Single source of truth: used by the
# `command` in docker-compose.yml and by manual `docker-compose run`
# invocations (`odoo --addons-path=$ODOO_ADDONS_PATH ...`).
ODOO_ADDONS_PATH=/mnt/extra-addons,/mnt/oca/account-invoice-reporting,/mnt/oca/account-invoicing,/mnt/oca/bank-payment,/mnt/oca/community-data-files,/mnt/oca/l10n-spain,/mnt/oca/pos,/mnt/oca/product-attribute,/mnt/oca/purchase-workflow,/mnt/oca/sale-workflow,/mnt/oca/stock-logistics-workflow,/usr/lib/python3/dist-packages/odoo/addons

View file

@ -1,3 +1,14 @@
# Host-specific values (paths, ports) live in `.env` — see `.env.example`:
#
# cp .env.example .env
#
# Note: `docker-compose run odoo odoo ...` replaces the `command` below, so the
# addons path has to be repeated there. Load `.env` into the shell first:
#
# set -a; . ./.env; set +a
# docker-compose run --rm odoo odoo -c /etc/odoo/odoo.conf \
# --addons-path=$ODOO_ADDONS_PATH \
# -d odoo -u <addon> --test-enable --stop-after-init
services:
db:
image: postgres:15
@ -21,8 +32,8 @@ services:
db:
condition: service_healthy
ports:
- "8069:8069"
- "8072:8072"
- "${ODOO_HTTP_PORT:-8069}:8069"
- "${ODOO_LONGPOLLING_PORT:-8072}:8072"
environment:
HOST: 0.0.0.0
PORT: "8069"
@ -30,7 +41,22 @@ services:
- ./:/mnt/extra-addons/
- ./odoo.conf:/etc/odoo/odoo.conf:ro
- odoo_data_addons_cm:/var/lib/odoo
command: odoo -c /etc/odoo/odoo.conf
# OCA sources living outside this repo (dependencies that are not
# here: `pos_payment_method_cashdro`, `account_banking_mandate`, ...),
# mounted read-only from $ODOO_SRC.
- ${ODOO_SRC:?set ODOO_SRC in .env, see .env.example}/account-invoice-reporting:/mnt/oca/account-invoice-reporting:ro
- ${ODOO_SRC}/account-invoicing:/mnt/oca/account-invoicing:ro
- ${ODOO_SRC}/bank-payment:/mnt/oca/bank-payment:ro
- ${ODOO_SRC}/community-data-files:/mnt/oca/community-data-files:ro
- ${ODOO_SRC}/l10n-spain:/mnt/oca/l10n-spain:ro
- ${ODOO_SRC}/pos:/mnt/oca/pos:ro
- ${ODOO_SRC}/product-attribute:/mnt/oca/product-attribute:ro
- ${ODOO_SRC}/purchase-workflow:/mnt/oca/purchase-workflow:ro
- ${ODOO_SRC}/sale-workflow:/mnt/oca/sale-workflow:ro
- ${ODOO_SRC}/stock-logistics-workflow:/mnt/oca/stock-logistics-workflow:ro
command: >
odoo -c /etc/odoo/odoo.conf
--addons-path=${ODOO_ADDONS_PATH:?set ODOO_ADDONS_PATH in .env, see .env.example}
volumes:
postgres_data_addons_cm:

View file

@ -57,8 +57,8 @@ To check the three fixes on a PoS with a CashDro terminal:
configured host.
The Python side is covered by the tests. The base module lives outside this
repo, so the OCA sources have to be on the addons path (see
``docker-compose.override.yml``, which is local and untracked)::
repo, so the OCA sources have to be on the addons path (mounted by
``docker-compose.yml`` from ``ODOO_SRC``, set in your local ``.env``)::
docker-compose run --rm odoo odoo -c /etc/odoo/odoo.conf \
--addons-path=/mnt/extra-addons,/mnt/oca/pos,/usr/lib/python3/dist-packages/odoo/addons \

View file

@ -13,8 +13,8 @@ To check the three fixes on a PoS with a CashDro terminal:
configured host.
The Python side is covered by the tests. The base module lives outside this
repo, so the OCA sources have to be on the addons path (see
``docker-compose.override.yml``, which is local and untracked)::
repo, so the OCA sources have to be on the addons path (mounted by
``docker-compose.yml`` from ``ODOO_SRC``, set in your local ``.env``)::
docker-compose run --rm odoo odoo -c /etc/odoo/odoo.conf \
--addons-path=/mnt/extra-addons,/mnt/oca/pos,/usr/lib/python3/dist-packages/odoo/addons \

View file

@ -0,0 +1,147 @@
# Cobertura de tests — website_sale_aplicoop
Estado tras recuperar y reescribir los tests que se habían perdido con la limpieza
de código muerto (commit `b3999e2`).
La suite quedó en verde y creció en unos 90 tests respecto a los 270 que había
tras la limpieza.
Los ficheros originales, si alguna vez hacen falta:
```bash
git show b3999e2^:website_sale_aplicoop/tests/<fichero>.py
```
---
## Recuperado y arreglado
### 1. `test_product_discovery.py` — blacklists y descubrimiento (46 tests)
Era el hueco grave: **ningún test cubría las tres blacklists**, que son lógica viva
con "prioridad absoluta" sobre el descubrimiento
([group_order.py:571-627](models/group_order.py#L571-L627)).
Dos bugs distintos en el fichero original:
- **`TestSupplierBlacklist` (6 fallos)**: el fixture escribía `main_seller_id` en el
`create` del template, pero en el addon OCA `product_main_seller` ese campo es
*computed + store* derivado de `variant_seller_ids` — el compute lo pisaba a `False`,
los productos quedaban sin proveedor principal y la blacklist no tenía nada que
excluir. Arreglado creando `product.supplierinfo` de verdad, más un test-guarda
(`test_main_seller_is_computed_from_supplierinfo`) para que no vuelva a pasar en
silencio.
- **Cuatro clases enteras no probaban el descubrimiento**: hacían aserciones contra
`group_order.product_ids`, que es el campo m2m de *entrada*, no el resultado. Es
decir, asignaban `category_ids` y comprobaban `product_ids`, así que sólo podían
fallar; y algunas "pasaban" por el motivo equivocado (el campo estaba vacío de
todos modos). Tres tests no tenían ni una aserción. Todo reescrito contra
`_get_products_for_group_order`.
Añadido de paso: productos archivados, pedido sin ninguna fuente, id inexistente, y
el contrato de ordenación real (`is_out_of_stock`, `website_sequence`, nombre).
### 2. `test_date_edge_cases.py` — calendario (12 tests)
Sustituye al antiguo `test_edge_cases.py`. Los 4 fallos que había eran **del test**,
no de producción: daban por hecho que `pickup_date` se calcula desde `start_date`,
cuando en realidad la cadena es `cutoff_date``pickup_date``delivery_date`, y
para un pedido recurrente con `start_date` pasada la referencia pasa a ser **hoy**
(el ciclo avanza al actual). Un pedido de 2024 no tiene fecha de recogida en 2024.
Los nuevos anclan en fechas **futuras** para que el avance de ciclo no mueva el caso
bajo los pies, y buscan el próximo 29-F dinámicamente (no hay fechas quemadas que
caduquen). Cubren: recogida justo en 29-F, ciclo que cruza el 29-F, entrega el 1-M,
cruce de mes y de año, último día de mes, rejilla mensual desde un ancla de 31-E y
en febrero bisiesto. `TestRollForwardBehaviour` documenta el avance de ciclo, que es
justo lo que el test viejo malinterpretaba.
### 3. `test_portal_routes.py` — humo HTTP con usuario portal (8 tests)
Une los tres ficheros portal borrados. Los fallos eran del test:
- **`/eskaera/labels` devolvía 400**: la ruta es `type="json"` y el test la llamaba
con un GET plano. Odoo responde 400 correctamente. Ahora se llama por JSON-RPC
(`make_jsonrpc_request`), y hay un test que fija ese 400 del GET para que nadie
repita el error.
- **`uom.uom.categ` no existe** en Odoo 18 (es `uom.category`), y `factor_inv`
`factor`.
- **La afiliación al grupo no se aplicaba**: hay que poner `is_group=True` en el
grupo y asignar `group_ids` desde el *miembro* (es el idioma que usa
`test_record_rules.py`). Sin eso el guard rebotaba a `/eskaera`.
Ojo con esto último: el smoke test original sólo miraba `status_code == 200`, y el
rebote a la lista **también** devuelve 200 — pasaba sin que el socio entrase nunca a
la tienda. Añadido `test_shop_page_is_not_bounced_to_the_list`, que comprueba la URL
final.
### 4. `test_price_with_taxes_included.py` — impuestos (14 tests)
Dos causas, ambas del test:
- **`price_include` es computed en Odoo 18**, derivado de `price_include_override`
(Selection `tax_included`/`tax_excluded`). El fixture escribía `price_include=True`
y se descartaba en silencio. Añadido `test_price_include_is_driven_by_the_override`
como guarda.
- **`_get_price` filtra los impuestos por compañía**, y su parámetro `company` cae
por defecto en `self.env.company`. Como el fixture crea una compañía propia, la
lista de impuestos salía **vacía** y `tax_included` era siempre `False`. Nótese que
`test_oca_get_price_returns_base_without_tax` pasaba por este motivo equivocado.
Ahora todas las llamadas van por un helper que pasa `company=self.company`.
---
## Sin recuperar (no había nada que rescatar)
Tres ficheros eran plantillas sin implementar — setUp seguido de métodos con
`# Placeholder: will be implemented...` y cero aserciones, o aserciones sobre
diccionarios simulados en vez de llamar al controlador:
- `test_endpoints.py`
- `test_helper_methods_phase1.py`
- `test_phase2_eskaera_shop.py`
---
## Pendiente
### `test_draft_persistence.py` — persistencia de borradores
Lo único de los 11 ficheros que aún no se ha rehecho. Cubría casos que hoy no cubre
nadie (`test_save_order_endpoints.py` y `test_group_order_status_endpoint.py` cubren
el guardado y el ciclo, no esto):
- **el precio del borrador es una foto fija**: cambiar `list_price` después no altera
el borrador ya guardado. El más valioso — es una garantía de negocio real (al socio
se le respeta el precio que vio) y ahora mismo nada la protege.
- borrador con producto archivado después de guardarlo
- borrador de un pedido ya cerrado / de hace 6 meses
- un borrador no es visible para otro usuario
- recuento de borradores por usuario
Sólo 2 de sus 14 tests fallaban, así que la recuperación debería ser barata.
### Huecos menores
- Transiciones de estado **ilegales** (draft→closed, cancelled→open). La suite cubre
las legales en `test_group_order.py`.
- `available_products_count` fuera del contexto de blacklists (dentro de ellas ya
está cubierto).
Nota: las constraints `_check_company_groups` y `_check_dates` **sí** están cubiertas
(`test_multi_company.py:147`, `test_group_order.py:66`) — no son hueco.
---
## Conclusión sobre los "posibles bugs de producción"
Los tres que quedaron marcados para investigar (fechas 29-F, `/eskaera/labels` 400,
`tax_included`) resultaron ser **todos bugs de los tests**, en su mayoría restos de la
migración 17→18: `is_supplier`, `main_seller_id`, `uom.uom.categ`, `factor_inv`,
`price_include`. No apareció ningún bug de producción.
Lo que sí apareció, y es más incómodo, son **tests que pasaban por el motivo
equivocado** — el smoke test del portal que se conformaba con el rebote, y
`test_oca_get_price_returns_base_without_tax` que verificaba `tax_included == False`
sobre una lista de impuestos vacía. Por eso cada arreglo lleva ahora un test-guarda
que falla si el fixture deja de construir lo que dice construir.

View file

@ -3,7 +3,7 @@
{ # noqa: B018
"name": "Website Sale - Aplicoop",
"version": "18.0.1.13.0",
"version": "18.0.1.15.0",
"category": "Website/Sale",
"summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders",
"author": "Odoo Community Association (OCA), Criptomart",
@ -13,6 +13,7 @@
"depends": [
"website_sale",
"website_sale_stock",
"payment",
"product",
"sale",
"stock",
@ -59,10 +60,6 @@
# Demo: Sale Orders
"demo/sale_order_demo.xml",
],
"i18n": [
"i18n/es.po",
"i18n/eu_ES.po",
],
"external_dependencies": {
"python": [],
},
@ -76,7 +73,7 @@
"website_sale_aplicoop/static/src/js/website_sale.js",
"website_sale_aplicoop/static/src/js/checkout_labels.js",
"website_sale_aplicoop/static/src/js/home_delivery.js",
"website_sale_aplicoop/static/src/js/checkout_summary.js",
"website_sale_aplicoop/static/src/js/eskaera_payment.js",
# Search and pagination
"website_sale_aplicoop/static/src/js/infinite_scroll.js",
"website_sale_aplicoop/static/src/js/realtime_search.js",

File diff suppressed because it is too large Load diff

View file

@ -91,6 +91,13 @@ def _get_translated_labels(self, lang=None, request_obj=None):
"save_draft": tr("Save Draft"),
"save_order_as_draft": tr("Save order as draft"),
"order_saved_as_draft": tr("Order saved as draft"),
"confirm_and_pay": tr("Confirm and pay"),
"confirm_and_pay_hint": tr("Confirm the order and go to payment"),
"order_ready_for_payment": tr("Order ready for payment"),
"already_placed": tr("You already placed an order for this cycle."),
"no_payment_method": tr(
"Online payment is not available right now. Please contact your group."
),
"save_cart": tr("Save Cart"),
"reload_cart": tr("Reload Cart"),
"proceed_to_checkout": tr("Proceed to Checkout"),
@ -142,18 +149,3 @@ def _get_translated_labels(self, lang=None, request_obj=None):
}
return labels
def _translate_labels(self, labels_dict, lang):
# Minimal fallback translator kept here; prefers env translations
translations = {
"es_ES": {},
}
lang_translations = translations.get(lang, {})
translated = {}
for key, english_label in labels_dict.items():
translated[key] = lang_translations.get(english_label, english_label)
_logger.info(
"[_translate_labels] Language: %s, Translated %d labels", lang, len(translated)
)
return translated

View file

@ -6,42 +6,6 @@ from odoo.http import request
_logger = logging.getLogger(__name__)
def _resolve_pricelist(self, request_obj=None):
try:
req = request_obj or request
env = req.env
website = req.website
except RuntimeError:
env = getattr(self, "env", None) or self.env
website = env["website"].get_current_website()
pricelist = None
try:
param_value = (
env["ir.config_parameter"]
.sudo()
.get_param("website_sale_aplicoop.pricelist_id")
)
if param_value:
pricelist = (
env["product.pricelist"].browse(int(param_value)).exists() or None
)
except Exception as e:
_logger.warning("_resolve_pricelist: error reading config param: %s", e)
if not pricelist:
try:
pricelist = website._get_current_pricelist()
except Exception as e:
_logger.warning(
"_resolve_pricelist: fallback to website pricelist failed: %s", e
)
if not pricelist:
pricelist = env["product.pricelist"].sudo().search([], limit=1)
return pricelist
def _prepare_product_display_info(self, product, product_price_info, request_obj=None):
price_data = product_price_info.get(product.id, {})
price = (
@ -119,132 +83,182 @@ def _prepare_product_display_info(self, product, product_price_info, request_obj
}
def _get_pricing_info(
self,
product,
pricelist,
quantity=1.0,
partner=None,
request_obj=None,
):
req = request_obj or request
def _pricing_context(record, request_obj=None):
"""Return (env, website), both under an HTTP request and from the cron."""
try:
env = req.env
website = req.website
req = request_obj or request
return req.env, req.website
except RuntimeError:
env = product.env
website = env["website"].get_current_website()
env = record.env
return env, env["website"].get_current_website()
partner = partner or env.user.partner_id
currency = pricelist.currency_id
def _pricing_company(product, website, env):
website_company = (
website.company_id
if website and getattr(website, "company_id", False)
else False
)
company = website_company or product.company_id or env.company
return website_company or product.company_id or env.company
price, rule_id = pricelist._get_product_price_rule(
product=product, quantity=quantity, target_currency=currency
def _display_taxes(product, website, company):
"""Product taxes, and those same taxes after the website fiscal position."""
product_taxes = product.sudo().taxes_id._filter_taxes_by_company(company)
if not product_taxes:
return product_taxes, product_taxes
fiscal_position = (
website.fiscal_position_id.sudo()
if website and getattr(website, "fiscal_position_id", False)
else product.env["account.fiscal.position"].sudo()
)
return product_taxes, fiscal_position.map_tax(product_taxes)
def _display_price(product, price, currency, product_taxes, taxes, website):
"""Price as the shop displays it, computed by website_sale itself.
`product.template._apply_taxes_to_price` rebases the price onto the taxes
the fiscal position mapped to (which matters for tax-included taxes) and
then honours the website's `show_line_subtotals_tax_selection`.
"""
return product.product_tmpl_id._apply_taxes_to_price(
price, currency, product_taxes, taxes, product, website=website
)
def _build_pricing_info(
product, price, pricelist_item, currency, quantity, website, company
):
"""Assemble the pricing dict from an already resolved pricelist price."""
price_before_discount = price
pricelist_item = env["product.pricelist.item"].sudo().browse(rule_id)
if pricelist_item and pricelist_item._show_discount_on_shop():
price_before_discount = pricelist_item._compute_price_before_discount(
product=product,
quantity=quantity or 1.0,
date=fields.Date.context_today(pricelist),
date=fields.Date.context_today(product),
uom=product.uom_id,
currency=currency,
)
has_discounted_price = price_before_discount > price
fiscal_position = (
website.fiscal_position_id.sudo()
if website and getattr(website, "fiscal_position_id", False)
else env["account.fiscal.position"].sudo()
product_taxes, taxes = _display_taxes(product, website, company)
display_price = _display_price(
product, price, currency, product_taxes, taxes, website
)
display_list_price = _display_price(
product, price_before_discount, currency, product_taxes, taxes, website
)
product_taxes = product.sudo().taxes_id._filter_taxes_by_company(company)
taxes = fiscal_position.map_tax(product_taxes) if product_taxes else product_taxes
tax_display = "total_included"
def compute_display(amount):
if not taxes:
return amount
return taxes.compute_all(amount, currency, 1, product, partner)[tax_display]
display_price = compute_display(price)
display_list_price = compute_display(price_before_discount)
return {
"price_unit": price,
"price": display_price,
"list_price": display_list_price,
"has_discounted_price": has_discounted_price,
"has_discounted_price": price_before_discount > price,
"discount": display_list_price - display_price,
"tax_included": tax_display == "total_included",
"tax_included": _tax_included(website),
}
def _tax_included(website):
"""Whether displayed prices carry tax, per the website setting."""
if not website:
return True
return website.show_line_subtotals_tax_selection != "tax_excluded"
def _get_pricing_info(
self,
product,
pricelist,
quantity=1.0,
request_obj=None,
):
"""Price one product.
No partner argument: website_sale bills the tax display to the current
user's partner itself, which is the same partner every caller here passed.
"""
env, website = _pricing_context(product, request_obj)
currency = pricelist.currency_id
# No currency kwarg: _compute_price_rule already defaults to the
# pricelist's own currency, which is what we want here.
price, rule_id = pricelist._get_product_price_rule(product, quantity)
return _build_pricing_info(
product,
price,
env["product.pricelist.item"].sudo().browse(rule_id),
currency,
quantity,
website,
_pricing_company(product, website, env),
)
def _pricing_variant(product):
"""The variant to price: the record itself when it already is one."""
if product._name == "product.product":
return product
return product.product_variant_ids[:1]
def _fallback_pricing(product, website):
"""Plain list price, used when there is no pricelist or pricing failed."""
price = product.list_price
return {
"price_unit": price,
"price": price,
"list_price": price,
"has_discounted_price": False,
"discount": 0.0,
"tax_included": _tax_included(website),
}
def _compute_price_info(self, products, pricelist, request_obj=None):
"""Price a whole page of products with a single pricelist resolution."""
if not products:
return {}
env, website = _pricing_context(products, request_obj)
currency = pricelist.currency_id if pricelist else None
variants = {product.id: _pricing_variant(product) for product in products}
priceable = env["product.product"].browse()
for variant in variants.values():
priceable |= variant
# One pricelist resolution for the whole page rather than one per product.
rules = (
pricelist._compute_price_rule(priceable, 1.0) if pricelist and priceable else {}
)
product_price_info = {}
def _tax_included_default(product_record):
try:
req = request_obj or request
return req.website.show_line_subtotals_tax_selection != "tax_excluded"
except RuntimeError:
website = product_record.env["website"].get_current_website()
if not website:
return True
return website.show_line_subtotals_tax_selection != "tax_excluded"
for product in products:
product_variant = (
product.product_variant_ids[0] if product.product_variant_ids else False
)
if product_variant and pricelist:
try:
try:
req = request_obj or request
partner = req.env.user.partner_id
except RuntimeError:
partner = product_variant.env.user.partner_id
pricing = _get_pricing_info(
self,
product_variant,
pricelist,
quantity=1.0,
partner=partner,
request_obj=request_obj,
)
product_price_info[product.id] = pricing
except Exception as e:
_logger.warning(
"_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.",
product.name,
product.id,
str(e),
)
product_price_info[product.id] = {
"price_unit": product.list_price,
"price": product.list_price,
"list_price": product.list_price,
"has_discounted_price": False,
"discount": 0.0,
"tax_included": _tax_included_default(product),
}
else:
product_price_info[product.id] = {
"price_unit": product.list_price,
"price": product.list_price,
"list_price": product.list_price,
"has_discounted_price": False,
"discount": 0.0,
"tax_included": _tax_included_default(product),
}
variant = variants[product.id]
if not variant or variant.id not in rules:
product_price_info[product.id] = _fallback_pricing(product, website)
continue
try:
price, rule_id = rules[variant.id]
product_price_info[product.id] = _build_pricing_info(
variant,
price,
env["product.pricelist.item"].sudo().browse(rule_id),
currency,
1.0,
website,
_pricing_company(variant, website, env),
)
except Exception as e:
_logger.warning(
"_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.",
product.name,
product.id,
str(e),
)
product_price_info[product.id] = _fallback_pricing(product, website)
return product_price_info
@ -265,46 +279,27 @@ def _get_delivery_product_display_price(
self, delivery_product, pricelist=None, request_obj=None
):
if not delivery_product:
return 5.74
return 0.0
try:
base_price = float(delivery_product.list_price or 0.0)
try:
req = request_obj or request
website = req.website
partner = req.env.user.partner_id
company = (
website.company_id or delivery_product.company_id or req.env.company
)
except RuntimeError:
env = delivery_product.env
website = env["website"].get_current_website()
partner = env.user.partner_id
company = website.company_id or delivery_product.company_id or env.company
product_taxes = delivery_product.sudo().taxes_id._filter_taxes_by_company(
company
)
fiscal_position = (
website.fiscal_position_id.sudo()
if website and getattr(website, "fiscal_position_id", False)
else delivery_product.env["account.fiscal.position"]
)
taxes = (
fiscal_position.map_tax(product_taxes) if product_taxes else product_taxes
)
env, website = _pricing_context(delivery_product, request_obj)
company = _pricing_company(delivery_product, website, env)
product_taxes, taxes = _display_taxes(delivery_product, website, company)
if not taxes:
return base_price
currency = website.currency_id
totals = taxes.compute_all(
base_price,
currency=currency,
quantity=1.0,
product=delivery_product,
partner=partner,
return float(
_display_price(
delivery_product,
base_price,
website.currency_id,
product_taxes,
taxes,
website,
)
or 0.0
)
return float(totals.get("total_included", base_price) or 0.0)
except Exception as e:
_logger.warning(
"_get_delivery_product_display_price: Error computing delivery display price for product %s (id=%s): %s. Using list_price fallback.",

View file

@ -62,26 +62,32 @@ def _get_salesperson_for_order(self, partner):
return False
def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
"""Return the active-cycle draft sale.order for the partner, or empty.
def _find_cycle_sale_order(
self, partner_id, group_order, states=("draft",), request_obj=None
):
"""Return the partner's sale.order for the active cycle, or empty.
A draft only counts as "current cycle" when it satisfies BOTH of these
An order only counts as "current cycle" when it satisfies BOTH of these
(neither is sufficient on its own see the regression each one guards):
1) create_date falls within the active window derived from
group_order.cutoff_date and the order period (7 days weekly, 14
biweekly, one month monthly; one-time orders use a single cycle
starting at start_date). Without this, a draft whose pickup_date
starting at start_date). Without this, an order whose pickup_date
happens to match the current one only because pickup_date froze
across cycles (observed in production) would be wrongly reused.
2) When group_order.pickup_date is set, the draft's pickup_date matches
it exactly. Without this, a draft created "now" for a stale/previous
across cycles (observed in production) would be wrongly matched.
2) When group_order.pickup_date is set, the order's pickup_date matches
it exactly. Without this, an order created "now" for a stale/previous
pickup_date but still inside the current create_date window
would be wrongly reused instead of starting a fresh cart.
would be wrongly matched instead of starting a fresh cart.
Drafts failing either check belong to a previous cycle and must not be
reused otherwise stale carts come back when the user re-enters the
order page.
Orders failing either check belong to a previous cycle.
The upper bound of the create_date window only applies to drafts. Nothing
stops a member from ordering between the cutoff date and the cron run
that closes the cycle, so a *placed* order created in that gap is still
part of this cycle dropping the bound is what keeps the duplicate-order
guard from letting them order twice.
"""
req = request_obj or request
@ -102,12 +108,14 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
else: # once: single cycle, bounded by start_date when set
period_start = group_order.start_date
states = tuple(states)
domain = [
("partner_id", "=", partner_id),
("group_order_id", "=", group_order.id),
("state", "=", "draft"),
("create_date", "<=", f"{period_end} 23:59:59"),
("state", "in", list(states)),
]
if states == ("draft",):
domain.append(("create_date", "<=", f"{period_end} 23:59:59"))
if period_start:
domain.append(("create_date", ">=", f"{period_start} 00:00:00"))
if group_order.pickup_date:
@ -118,6 +126,34 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
)
def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
"""Return the active-cycle draft sale.order for the partner, or empty.
Draft-only wrapper over `_find_cycle_sale_order`. Callers rely on this
never returning a placed order `/eskaera/clear-cart` cancels whatever
it gets back, so widening it would cancel paid orders.
"""
return _find_cycle_sale_order(
self, partner_id, group_order, states=("draft",), request_obj=request_obj
)
def _find_placed_cycle_order(self, partner_id, group_order, request_obj=None):
"""Return the partner's already-placed order for the active cycle.
Used by the duplicate-order guard: once a member has paid, their order is
confirmed, so no draft remains and nothing else would stop them from
building and paying for a second order in the same cycle.
"""
return _find_cycle_sale_order(
self,
partner_id,
group_order,
states=("sale", "done"),
request_obj=request_obj,
)
def _validate_confirm_request(self, data, request_obj=None):
req = request_obj or request
order_id = data.get("order_id")

View file

@ -2362,3 +2362,169 @@ msgid ""
msgstr ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Lliurament a domicili</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary
msgid "<span class=\"total-label\">Total</span>:"
msgstr "<span class=\"total-label\">Total</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Back to Checkout</span>"
msgstr "<span>Torna al checkout</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Back to Orders</span>"
msgstr "<span>Torna a les comandes</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Order</span>"
msgstr "<span>Comanda</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Payment in progress</span>"
msgstr "<span>Pagament en curs</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Reference</span>:"
msgstr "<span>Referència</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Thank you, your order is confirmed</span>"
msgstr "<span>Gràcies, la teva comanda està confirmada</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>View my order</span>"
msgstr "<span>Veure la meva comanda</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Your payment is being processed</span>"
msgstr "<span>El teu pagament s'està processant</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "<strong>You already placed an order for this cycle.</strong>"
msgstr "<strong>Ja has fet una comanda per a aquest cicle.</strong>"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Confirm and pay"
msgstr "Confirma i paga"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Confirm the order and go to payment"
msgstr "Confirma la comanda i ves al pagament"
#. module: website_sale_aplicoop
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment
msgid ""
"Let members pay their order online when they place it. Payment providers are"
" configured globally (Settings > Payment Providers); this only decides "
"whether this group order offers them. When enabled, paying is the only way "
"to place an order from the checkout page."
msgstr ""
"Permet que els socis paguin la seva comanda en línia en fer-la. Els "
"proveïdors de pagament es configuren globalment (Configuració > Proveïdors "
"de pagament); això només decideix si aquesta comanda de grup els ofereix. "
"Amb l'opció activa, pagar és l'única manera de fer la comanda des de la "
"pàgina de checkout."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid ""
"Members must pay online to place their order in this cycle. The\n"
" available methods come from the payment providers published on the\n"
" website (Settings &gt; Payment Providers); this order does not\n"
" configure any of them."
msgstr ""
"Els socis han de pagar en línia per fer la seva comanda en aquest cicle. Les"
" formes de pagament disponibles surten dels proveïdors publicats al lloc web"
" (Configuració > Proveïdors de pagament); aquesta comanda no en configura "
"cap."
#. module: website_sale_aplicoop
#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid "Online Payment"
msgstr "Pagament en línia"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Online payment is not available right now. Please contact your group."
msgstr ""
"El pagament en línia no està disponible ara mateix. Contacta amb el teu "
"grup."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "Order Confirmed:"
msgstr "Comanda confirmada:"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Order ready for payment"
msgstr "Comanda a punt per pagar"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "Pay Order:"
msgstr "Paga la comanda:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "Payment Method"
msgstr "Forma de pagament"
#. module: website_sale_aplicoop
#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction
msgid "Payment Transaction"
msgstr "Transacció de pagament"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "Pickup"
msgstr "Recollida"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "View my order"
msgstr "Veure la meva comanda"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid ""
"We are still waiting for your payment to be confirmed. Please do not pay "
"again."
msgstr ""
"Encara estem esperant la confirmació del teu pagament. No tornis a pagar."
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "You already placed an order for this cycle."
msgstr "Ja has fet una comanda per a aquest cicle."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid ""
"Your order will be confirmed as soon as we receive the payment. You can "
"follow it from your orders page."
msgstr ""
"La teva comanda es confirmarà tan bon punt rebem el pagament. La pots seguir"
" des de la pàgina de comandes."

View file

@ -2358,3 +2358,165 @@ msgid ""
msgstr ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Entrega a Casa</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary
msgid "<span class=\"total-label\">Total</span>:"
msgstr "<span class=\"total-label\">Total</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Back to Checkout</span>"
msgstr "<span>Volver al checkout</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Back to Orders</span>"
msgstr "<span>Volver a los pedidos</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Order</span>"
msgstr "<span>Pedido</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Payment in progress</span>"
msgstr "<span>Pago en curso</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Reference</span>:"
msgstr "<span>Referencia</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Thank you, your order is confirmed</span>"
msgstr "<span>Gracias, tu pedido está confirmado</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>View my order</span>"
msgstr "<span>Ver mi pedido</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Your payment is being processed</span>"
msgstr "<span>Tu pago se está procesando</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "<strong>You already placed an order for this cycle.</strong>"
msgstr "<strong>Ya has hecho un pedido para este ciclo.</strong>"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Confirm and pay"
msgstr "Confirmar y pagar"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Confirm the order and go to payment"
msgstr "Confirmar el pedido e ir al pago"
#. module: website_sale_aplicoop
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment
msgid ""
"Let members pay their order online when they place it. Payment providers are"
" configured globally (Settings > Payment Providers); this only decides "
"whether this group order offers them. When enabled, paying is the only way "
"to place an order from the checkout page."
msgstr ""
"Permite que los socios paguen su pedido online al hacerlo. Los proveedores "
"de pago se configuran de forma global (Ajustes > Proveedores de pago); esto "
"solo decide si este pedido de grupo los ofrece. Con la opción activa, pagar "
"es la única forma de hacer el pedido desde la página de checkout."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid ""
"Members must pay online to place their order in this cycle. The\n"
" available methods come from the payment providers published on the\n"
" website (Settings &gt; Payment Providers); this order does not\n"
" configure any of them."
msgstr ""
"Los socios deben pagar online para hacer su pedido en este ciclo. Las formas"
" de pago disponibles salen de los proveedores publicados en el sitio web "
"(Ajustes > Proveedores de pago); este pedido no configura ninguno."
#. module: website_sale_aplicoop
#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid "Online Payment"
msgstr "Pago online"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Online payment is not available right now. Please contact your group."
msgstr "El pago online no está disponible ahora mismo. Contacta con tu grupo."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "Order Confirmed:"
msgstr "Pedido confirmado:"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Order ready for payment"
msgstr "Pedido listo para pagar"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "Pay Order:"
msgstr "Pagar pedido:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "Payment Method"
msgstr "Forma de pago"
#. module: website_sale_aplicoop
#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction
msgid "Payment Transaction"
msgstr "Transacción de pago"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "Pickup"
msgstr "Recogida"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "View my order"
msgstr "Ver mi pedido"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid ""
"We are still waiting for your payment to be confirmed. Please do not pay "
"again."
msgstr ""
"Todavía estamos esperando la confirmación de tu pago. No vuelvas a pagar."
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "You already placed an order for this cycle."
msgstr "Ya has hecho un pedido para este ciclo."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid ""
"Your order will be confirmed as soon as we receive the payment. You can "
"follow it from your orders page."
msgstr ""
"Tu pedido se confirmará en cuanto recibamos el pago. Puedes seguirlo desde "
"tu página de pedidos."

View file

@ -2358,3 +2358,168 @@ msgid ""
msgstr ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Etxerako Entrega</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary
msgid "<span class=\"total-label\">Total</span>:"
msgstr "<span class=\"total-label\">Guztira</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Back to Checkout</span>"
msgstr "<span>Itzuli checkout-era</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Back to Orders</span>"
msgstr "<span>Itzuli eskaeretara</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Order</span>"
msgstr "<span>Eskaera</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Payment in progress</span>"
msgstr "<span>Ordainketa abian</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Reference</span>:"
msgstr "<span>Erreferentzia</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Thank you, your order is confirmed</span>"
msgstr "<span>Eskerrik asko, zure eskaera berretsita dago</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>View my order</span>"
msgstr "<span>Ikusi nire eskaera</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Your payment is being processed</span>"
msgstr "<span>Zure ordainketa prozesatzen ari da</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "<strong>You already placed an order for this cycle.</strong>"
msgstr "<strong>Dagoeneko eskaera bat egin duzu ziklo honetarako.</strong>"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Confirm and pay"
msgstr "Berretsi eta ordaindu"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Confirm the order and go to payment"
msgstr "Eskaera berretsi eta ordainketara joan"
#. module: website_sale_aplicoop
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment
msgid ""
"Let members pay their order online when they place it. Payment providers are"
" configured globally (Settings > Payment Providers); this only decides "
"whether this group order offers them. When enabled, paying is the only way "
"to place an order from the checkout page."
msgstr ""
"Utzi bazkideei beren eskaera linean ordaintzen egiten dutenean. Ordainketa "
"hornitzaileak orokorrean konfiguratzen dira (Ezarpenak > Ordainketa "
"hornitzaileak); honek talde eskaera honek eskaintzen dituen ala ez "
"erabakitzen du soilik. Aktibatuta dagoenean, ordaintzea da checkout orritik "
"eskaera egiteko modu bakarra."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid ""
"Members must pay online to place their order in this cycle. The\n"
" available methods come from the payment providers published on the\n"
" website (Settings &gt; Payment Providers); this order does not\n"
" configure any of them."
msgstr ""
"Bazkideek linean ordaindu behar dute ziklo honetan eskaera egiteko. "
"Erabilgarri dauden moduak webgunean argitaratutako ordainketa "
"hornitzaileetatik datoz (Ezarpenak > Ordainketa hornitzaileak); eskaera "
"honek ez du bat ere konfiguratzen."
#. module: website_sale_aplicoop
#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid "Online Payment"
msgstr "Lineako ordainketa"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Online payment is not available right now. Please contact your group."
msgstr ""
"Lineako ordainketa ez dago erabilgarri orain. Jarri harremanetan zure "
"taldearekin."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "Order Confirmed:"
msgstr "Eskaera berretsita:"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Order ready for payment"
msgstr "Eskaera ordaintzeko prest"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "Pay Order:"
msgstr "Ordaindu eskaera:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "Payment Method"
msgstr "Ordainketa modua"
#. module: website_sale_aplicoop
#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction
msgid "Payment Transaction"
msgstr "Ordainketa transakzioa"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "Pickup"
msgstr "Jasotzea"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "View my order"
msgstr "Ikusi nire eskaera"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid ""
"We are still waiting for your payment to be confirmed. Please do not pay "
"again."
msgstr "Zure ordainketa berresteko zain gaude oraindik. Ez ordaindu berriro."
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "You already placed an order for this cycle."
msgstr "Dagoeneko eskaera bat egin duzu ziklo honetarako."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid ""
"Your order will be confirmed as soon as we receive the payment. You can "
"follow it from your orders page."
msgstr ""
"Zure eskaera ordainketa jaso bezain laster berretsiko da. Zure eskaeren "
"orritik jarrai dezakezu."

View file

@ -0,0 +1,39 @@
"""Drop the Eskaera-specific pricelist setting.
Eskaera used to resolve its own pricelist from
`website_sale_aplicoop.pricelist_id`, which let it quote prices the rest of the
shop did not use. Pricing is now the standard website_sale one, so the setting
is gone and its parameter would otherwise linger in the database, looking like
live configuration.
"""
import logging
from odoo import SUPERUSER_ID
from odoo import api
_logger = logging.getLogger(__name__)
OBSOLETE_PARAM = "website_sale_aplicoop.pricelist_id"
def migrate(cr, version):
if not version:
return
env = api.Environment(cr, SUPERUSER_ID, {})
param = (
env["ir.config_parameter"]
.sudo()
.search([("key", "=", OBSOLETE_PARAM)], limit=1)
)
if not param:
return
_logger.info(
"Removing obsolete config parameter %s (was pointing at pricelist %s). "
"Eskaera now prices with the website pricelist.",
OBSOLETE_PARAM,
param.value,
)
param.unlink()

View file

@ -1,9 +1,11 @@
from . import group_order # noqa: F401
from . import group_order_slot # noqa: F401
from . import payment_transaction # noqa: F401
from . import product_category_extension # noqa: F401
from . import product_extension # noqa: F401
from . import res_config_settings # noqa: F401
from . import res_partner_extension # noqa: F401
from . import sale_order_extension # noqa: F401
from . import stock_picking_extension # noqa: F401
from . import website # noqa: F401
from . import js_translations # noqa: F401

View file

@ -188,6 +188,16 @@ class GroupOrder(models.Model):
help="Calculated delivery date (pickup date + 1 day)",
)
# === Online payment ===
online_payment = fields.Boolean(
tracking=True,
help="Let members pay their order online when they place it. Payment "
"providers are configured globally (Settings > Payment Providers); "
"this only decides whether this group order offers them. When "
"enabled, paying is the only way to place an order from the "
"checkout page.",
)
# === Computed date fields ===
pickup_date = fields.Date(
compute="_compute_pickup_date",
@ -274,23 +284,6 @@ class GroupOrder(models.Model):
help="Image displayed alongside the consumer group order name",
attachment=True,
)
display_image = fields.Binary(
compute="_compute_display_image",
store=True,
help="Image to display: uses consumer group order image if set, otherwise group image",
attachment=True,
)
@api.depends("image", "group_ids")
def _compute_display_image(self):
"""Use order image if set, otherwise use first group image."""
for record in self:
if record.image:
record.display_image = record.image
elif record.group_ids and record.group_ids[0].image_1920:
record.display_image = record.group_ids[0].image_1920
else:
record.display_image = False
@api.depends("delivery_product_id")
def _compute_home_delivery(self):
@ -691,31 +684,6 @@ class GroupOrder(models.Model):
)
return demand
def _get_products_paginated(self, order_id, page=1, per_page=20):
"""Get paginated products for a group order.
Args:
order_id: ID of the group order
page: Page number (1-indexed)
per_page: Number of products per page
Returns:
tuple: (products_page, total_count, has_next)
- products_page: recordset of product.product for this page
- total_count: total number of products in order
- has_next: boolean indicating if there are more pages
"""
all_products = self._get_products_for_group_order(order_id)
total_count = len(all_products)
# Calculate pagination
offset = (page - 1) * per_page
products_page = all_products[offset : offset + per_page]
has_next = offset + per_page < total_count
return products_page, total_count, has_next
# === Pickup slots helpers ===
pickup_slot_ids = fields.One2many(
"group.order.slot",
@ -725,12 +693,6 @@ class GroupOrder(models.Model):
tracking=True,
)
pickup_slots_count = fields.Integer(
compute="_compute_pickup_slots_count",
store=False,
help="Number of pickup slots configured for this order",
)
next_pickup_slot_id = fields.Many2one(
"group.order.slot",
string="Next Pickup Slot",
@ -746,12 +708,6 @@ class GroupOrder(models.Model):
help="Datetime of the next pickup occurrence for the selected slot",
)
@api.depends("pickup_slot_ids")
def _compute_pickup_slots_count(self):
"""Simple count of configured slots for quick UI badges."""
for record in self:
record.pickup_slots_count = len(record.pickup_slot_ids or [])
@api.depends(
"pickup_slot_ids",
"pickup_slot_ids.start_hour",
@ -1106,6 +1062,69 @@ class GroupOrder(models.Model):
failed_orders,
)
self._cron_batch_paid_orders_of_closed_cycles()
@api.model
def _cron_batch_paid_orders_of_closed_cycles(self):
"""Batch paid orders of group orders that were closed by hand.
The loop above only walks draft/open group orders. Closing an order
manually after a member has paid would otherwise leave that member's
picking out of every batch, because the confirmation already happened
at payment time and the cron never looks at closed cycles.
"""
closed_orders = self.search(
[("state", "=", "closed"), ("online_payment", "=", True)]
)
for order in closed_orders:
try:
order._batch_paid_sale_orders()
except Exception:
_logger.exception(
"Cron: Error batching paid sale orders of closed group order "
"%s (%s)",
order.id,
order.name,
)
def _batch_paid_sale_orders(self):
"""Create the picking batches of orders already confirmed by payment.
The same sweep `_confirm_linked_sale_orders` does, minus the
confirmation step. Drafts are deliberately left alone: closing a group
order by hand is how a co-op calls a cycle off, and this must not
resurrect the orders it meant to drop.
"""
self.ensure_one()
batches = self.env["stock.picking.batch"]
if not self.pickup_date:
return batches
paid_sale_orders = (
self.env["sale.order"]
.sudo()
.search(
[
("group_order_id", "=", self.id),
("state", "in", ["sale", "done"]),
("pickup_date", "=", self.pickup_date),
]
)
)
if not paid_sale_orders:
return batches
batches = self._create_picking_batches_for_sale_orders(paid_sale_orders)
if batches:
_logger.info(
"Cron: Batched %d paid sale order(s) of closed group order %s (%s)",
len(paid_sale_orders),
self.id,
self.name,
)
return batches
def _close_one_time_order_if_ended(self):
"""Close one-time orders once their end_date has passed.
@ -1179,7 +1198,25 @@ class GroupOrder(models.Model):
]
)
if not sale_orders:
# Orders paid online are confirmed the moment their transaction is
# done, long before this runs, so they are not in the search above —
# but their pickings still have to end up in this cycle's batch.
#
# pickup_date is what scopes them to this cycle: the same group.order
# record is reused every cycle, and _cron_update_dates() calls this
# method BEFORE recomputing the dates, so self.pickup_date is still
# the closing cycle's, exactly the value stamped on the order when it
# was saved. Backorders from previous cycles hang off orders with an
# older pickup_date, so they are not swept in either.
already_confirmed = SaleOrder.search(
[
("group_order_id", "=", self.id),
("state", "in", ["sale", "done"]),
("pickup_date", "=", self.pickup_date),
]
)
if not sale_orders and not already_confirmed:
_logger.info(
"Cron: No sale orders to confirm for group order %s (%s)",
self.id,
@ -1188,10 +1225,12 @@ class GroupOrder(models.Model):
return
_logger.info(
"Cron: Confirming %d sale orders for group order %s (%s)",
"Cron: Confirming %d sale orders for group order %s (%s); "
"%d already confirmed by online payment",
len(sale_orders),
self.id,
self.name,
len(already_confirmed),
)
try:
@ -1264,13 +1303,33 @@ class GroupOrder(models.Model):
)
batches = self.env["stock.picking.batch"]
if confirmed_sale_orders:
# Create picking batches only for confirmed sale orders
# One call with both sets: _create_picking_batches_for_sale_orders
# groups by picking type and skips pickings that already have a
# batch, so this yields one batch per type for the whole cycle.
batchable_sale_orders = confirmed_sale_orders | already_confirmed
if batchable_sale_orders:
batches = self._create_picking_batches_for_sale_orders(
confirmed_sale_orders
batchable_sale_orders
)
# Only the orders confirmed right now: re-reporting the ones
# confirmed in an earlier run would repeat the same warnings
# on every cron pass.
self._log_missing_procurement_warnings(confirmed_sale_orders)
if already_confirmed and not batches:
# Paid orders that never made it into a batch are an
# operational hole, and everything here runs inside a
# try/except that only logs. Say so loudly.
_logger.warning(
"Cron: %d already confirmed sale order(s) of group order %s (%s) "
"produced no picking batch. Their pickings may already be "
"batched, done or cancelled — check manually. ids=%s",
len(already_confirmed),
self.id,
self.name,
already_confirmed.ids,
)
if failed_sale_orders:
_logger.warning(
"Cron: %d/%d sale orders failed during confirmation for group order %s (%s). "

View file

@ -55,20 +55,3 @@ class GroupOrderSlot(models.Model):
sequence = fields.Integer(string="Sequence", default=10)
active = fields.Boolean(default=True)
def _get_display_label(self):
"""Return a fallback display label combining weekday and hours.
This is a small helper used by views or when a specific `label` is
not provided.
"""
self.ensure_one()
if self.label:
return self.label
# Fallback: simple numeric representation
sh = "%02d:%02d" % (
int(self.start_hour or 0),
int((self.start_hour or 0) % 1 * 60),
)
eh = "%02d:%02d" % (int(self.end_hour or 0), int((self.end_hour or 0) % 1 * 60))
return f"{self.weekday} {sh}-{eh}"

View file

@ -40,6 +40,15 @@ def _register_translations():
_("Load Draft")
_("Browse Product Categories")
# ========================
# Online Payment Labels
# ========================
_("Confirm and pay")
_("Confirm the order and go to payment")
_("Order ready for payment")
_("You already placed an order for this cycle.")
_("Online payment is not available right now. Please contact your group.")
# ========================
# Draft Modal Labels
# ========================

View file

@ -0,0 +1,50 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class PaymentTransaction(models.Model):
_inherit = "payment.transaction"
def _check_amount_and_confirm_order(self):
"""Confirm group order sales the way the cutoff cron already does.
``group.order._confirm_linked_sale_orders`` confirms with
``from_orderpoint=True`` on purpose: ``stock.move._action_confirm``
forwards ``raise_user_error=not from_orderpoint`` to
``procurement.group.run``, so a product with a broken replenishment
route does not block the sale, and the missing moves are reported
operationally instead.
The standard payment post-processing confirms without that context,
and ``/payment/status/poll`` rolls back and re-raises anything
``_post_process`` throws. Without this override, one misconfigured
product turns a successful payment into an error page while the
transaction is already ``done``, and the retry cron keeps failing on
it. Orders born from a group order follow the cron's operational
rules, so they get the cron's context.
"""
eskaera_txs = self.filtered(lambda tx: tx.sale_order_ids.group_order_id)
if not eskaera_txs:
return super()._check_amount_and_confirm_order()
confirmed_orders = super(
PaymentTransaction, self - eskaera_txs
)._check_amount_and_confirm_order()
_logger.info(
"[PAYMENT] Confirming %d group order transaction(s) with "
"from_orderpoint=True: %s",
len(eskaera_txs),
eskaera_txs.ids,
)
confirmed_orders |= super(
PaymentTransaction, eskaera_txs.with_context(from_orderpoint=True)
)._check_amount_and_confirm_order()
return confirmed_orders

View file

@ -7,10 +7,11 @@ from odoo import models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
aplicoop_pricelist_id = fields.Many2one(
"product.pricelist",
config_parameter="website_sale_aplicoop.pricelist_id",
help="Pricelist to use for Aplicoop group orders. If not set, will use website default.",
# Per website: a co-op can run the plain shop on one and Eskaera on
# another. The settings below are still global to the database.
eskaera_enabled = fields.Boolean(
related="website_id.eskaera_enabled",
readonly=False,
)
eskaera_lazy_loading_enabled = fields.Boolean(
@ -34,15 +35,3 @@ class ResConfigSettings(models.TransientModel):
help="Products with stock below or equal to this value will show 'Low Stock' ribbon. "
"Products with stock = 0 will show 'Out of Stock' ribbon and cannot be added to cart.",
)
@staticmethod
def _get_products_per_page_selection(records):
"""Return default page sizes."""
return [
(5, "5"),
(10, "10"),
(15, "15"),
(20, "20"),
(30, "30"),
(50, "50"),
]

View file

@ -62,6 +62,33 @@ class SaleOrder(models.Model):
help="Whether this order includes home delivery",
)
@api.depends("company_id", "group_order_id", "group_order_id.online_payment")
def _compute_require_payment(self): # pylint: disable=missing-return
"""Let the group order decide whether its members pay online.
Orders outside a group order keep the company default.
No return: compute methods assign fields, and pylint-odoo's
`missing-return` does not know that about a `super()` call.
"""
super()._compute_require_payment()
for order in self:
if order.group_order_id:
order.require_payment = order.group_order_id.online_payment
@api.depends("require_payment", "group_order_id")
def _compute_prepayment_percent(self): # pylint: disable=missing-return
"""Group orders are paid in full, never with a down payment.
Written together with `require_payment` on purpose: the core compute
would otherwise pull `company_id.prepayment_percent`, which
`_check_prepayment_percent` rejects unless it is in (0, 1].
"""
super()._compute_prepayment_percent()
for order in self:
if order.group_order_id and order.require_payment:
order.prepayment_percent = 1.0
@api.depends(
"group_order_id",
"group_order_id.next_pickup_slot_id",

View file

@ -0,0 +1,40 @@
# Copyright 2025-Today Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import fields
from odoo import models
ESKAERA_URL_PREFIX = "/eskaera"
class Website(models.Model):
_inherit = "website"
eskaera_enabled = fields.Boolean(
string="Eskaera Group Orders",
default=True,
help="Serve the Eskaera collaborative purchasing pages on this website. "
"Turn it off on a website that only runs the regular shop: its "
"/eskaera pages answer 404 and its Eskaera menu is hidden.",
)
class WebsiteMenu(models.Model):
_inherit = "website.menu"
def _compute_visible(self):
"""Hide the Eskaera menu on websites that do not serve it.
Hiding rather than deleting the menu keeps the entry (and any manual
rename or reordering) around, so switching the feature back on
restores it as it was.
"""
res = super()._compute_visible()
for menu in self:
if not menu.is_visible or not menu.website_id:
continue
if menu.website_id.eskaera_enabled:
continue
if (menu.url or "").startswith(ESKAERA_URL_PREFIX):
menu.is_visible = False
return res

View file

@ -58,3 +58,24 @@ Odoo then serves the pages on the new prefix, rewrites the links in the
templates and redirects the old URLs. The AJAX endpoints
(``/eskaera/labels``, ``/eskaera/save-order``…) are never shown in the
address bar and do not need a rule.
**Online payment (v18.0.1.14.0+):**
Payment is enabled per group order, and off by default: an order without it
behaves exactly as before, with members saving a draft that the cutoff cron
confirms in bulk.
#. Configure the providers first, where Odoo always keeps them:
Settings → Payment Providers. Enable and publish at least one (bank
transfer, Redsys, Stripe...). This module ships no provider of its own and
configures none; it only shows whichever ones are compatible.
#. On a multi-website database, leave a provider's *Website* field empty to
offer it everywhere, or set it to restrict the provider to one site.
#. Open the group order form → *Online Payment* tab → tick **Online payment**.
With the flag on, the checkout button becomes "Confirm and pay" and paying is
the only way to place an order: the member goes through
``/eskaera/<slug>/payment``, picks a method, and their sale order is confirmed
as soon as the transaction completes. Members who have not paid by the cutoff
date still get their draft confirmed by the cron, exactly as they do today —
the flag decides how orders are placed, not who gets served.

View file

@ -10,5 +10,6 @@ This module replaces the legacy Aplicoop application with a modern, scalable sol
* **Multi-language Support**: Full internationalization with translations for 7 languages (ES, EU, CA, GL, PT, FR, IT)
* **Email Notifications**: Automatic notifications on order state changes
* **Financial Tracking**: Track orders and payments per group member
* **Online Payment**: Optional per group order — members pay at checkout through the standard Odoo payment providers, and their order is confirmed as soon as the transaction completes (v18.0.1.14.0+)
* **Product Integration**: Compatible with product ribbons, pricing, and margin modules
* **OCA Compliant**: AGPL-3.0 licensed, follows OCA standards for documentation, testing, and code structure

View file

@ -43,3 +43,21 @@ Order States
* **Confirmed**: Order open for shopping
* **Collected**: Orders received from supplier
* **Completed**: All members have picked up their orders
Paying an order online
~~~~~~~~~~~~~~~~~~~~~~
When the group order has online payment enabled:
#. Build the cart in ``/eskaera/<slug>`` as usual
#. Go to the checkout, review the summary and choose home delivery if offered
#. Press **Confirm and pay**: the order is saved and the payment step opens
#. Pick a payment method and pay; you are sent to the provider and back
#. The confirmation page shows the outcome and empties the local cart
The order is confirmed the moment the payment goes through, so it can no
longer be edited. Coming back to the shop shows a notice with a link to the
order instead of an empty cart, so nobody pays twice for the same cycle.
If the payment is still being processed when you come back, the page says so
and the order is confirmed as soon as the provider settles it.

View file

@ -1,9 +0,0 @@
/** AGPL-3.0
* NOTE: Checkout summary rendering is now handled by checkout_labels.js
* This file is kept for backwards compatibility but is no longer needed.
* The main renderSummary() logic is in checkout_labels.js
*/
(function () {
"use strict";
// Checkout rendering is handled by checkout_labels.js
})();

View file

@ -0,0 +1,35 @@
/*
* Copyright 2026 Criptomart
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
*
* Frees the localStorage cart once the order has been placed and paid.
*/
(function () {
"use strict";
document.addEventListener("DOMContentLoaded", function () {
var page = document.querySelector("[data-clear-cart-order-id]");
if (!page) {
return;
}
var orderId = page.getAttribute("data-clear-cart-order-id");
if (!orderId) {
return;
}
// Client side only. /eskaera/clear-cart would also cancel the sale
// order, which is exactly the wrong thing to do to an order that was
// just paid for.
try {
localStorage.removeItem("eskaera_" + orderId + "_cart");
localStorage.removeItem("eskaera_" + orderId + "_cart_cycle");
} catch (e) {
// localStorage unavailable (private mode, quota). The cart is
// server-side irrelevant at this point; the duplicate-order guard
// is what actually protects the member.
console.warn("[ESKAERA PAYMENT] Could not clear the local cart:", e);
}
});
})();

View file

@ -226,10 +226,6 @@
}, 50);
},
renderCheckoutSummary: function () {
// Stub - now handled by global window.renderCheckoutSummary
},
addDeliveryProduct: function () {
if (!this.deliveryProductId) {
console.warn("[HomeDelivery] Delivery product ID not found");

View file

@ -9,9 +9,6 @@
* OLD: window.getCheckoutLabels()
* NEW: i18nManager.getAll()
*
* OLD: window.formatCurrency(amount)
* NEW: i18nManager.formatCurrency(amount)
*
* Copyright 2025 Criptomart
* License AGPL-3.0 or later
*/
@ -35,32 +32,5 @@
return key ? key : {};
};
/**
* DEPRECATED - Use i18nManager.getAll() instead
*/
window.getSearchLabels = function () {
if (window.i18nManager && window.i18nManager.initialized) {
return {
searchPlaceholder: window.i18nManager.get("search_products"),
noResults: window.i18nManager.get("no_results"),
};
}
return {
searchPlaceholder: "Search products...",
noResults: "No products found",
};
};
/**
* DEPRECATED - Use i18nManager.formatCurrency(amount) instead
*/
window.formatCurrency = function (amount) {
if (window.i18nManager) {
return window.i18nManager.formatCurrency(amount);
}
// Fallback
return "€" + parseFloat(amount).toFixed(2);
};
console.log("[i18n_helpers] DEPRECATED - Use i18n_manager.js instead");
})();

View file

@ -103,39 +103,6 @@
}
return this.labels;
},
/**
* Check if a specific label exists
*/
has: function (key) {
if (!this.initialized) return false;
return key in this.labels;
},
/**
* Format currency to Euro format
*/
formatCurrency: function (amount) {
try {
return new Intl.NumberFormat(document.documentElement.lang || "es_ES", {
style: "currency",
currency: "EUR",
}).format(amount);
} catch (e) {
// Fallback to simple Euro format
return "€" + parseFloat(amount).toFixed(2);
}
},
/**
* Escape HTML to prevent XSS
*/
escapeHtml: function (text) {
if (!text) return "";
var div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
},
};
// Auto-initialize on DOM ready

View file

@ -592,6 +592,38 @@
}
},
// The member already has a placed order for this cycle (409). Their
// local cart is stale, so drop it and send them to that order rather
// than let them build — and pay for — a duplicate.
_handleAlreadyPlacedResponse: function (xhr) {
if (!xhr || xhr.status !== 409) {
return false;
}
var data;
try {
data = JSON.parse(xhr.responseText || "{}");
} catch (e) {
return false;
}
if (!data.already_placed) {
return false;
}
var labels = this._getLabels();
this._clearCurrentOrderCartSilently();
this._updateCartDisplay();
this._showNotification(
data.error || labels.already_placed || "You already placed an order.",
"warning",
6000
);
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
return true;
},
_checkGroupOrderStatus: function (callback) {
var self = this;
var done = function () {
@ -783,8 +815,18 @@
var tooltipText = null;
var labelKey = null;
// An explicit key on the element wins over the static map: the
// checkout button carries a different label depending on
// whether the group order takes online payments, and the
// server is the one that knows.
var declaredKey = element.getAttribute("data-tooltip-key");
if (declaredKey && labels[declaredKey]) {
labelKey = declaredKey;
tooltipText = labels[declaredKey];
}
// Check ID-based mapping
if (element.id && tooltipMap[element.id]) {
if (!tooltipText && element.id && tooltipMap[element.id]) {
labelKey = tooltipMap[element.id];
tooltipText = labels[labelKey];
}
@ -1329,160 +1371,6 @@
};
},
/**
* Update DOM elements with translated labels
*/
_updateDOMLabels: function (labels) {
console.log("[UPDATE_LABELS] Starting DOM update with labels:", labels);
// Map of element ID to label key
var elementLabelMap = {
"label-home-delivery": "home_delivery",
"label-delivery-information": "delivery_information",
"label-important": "important",
"label-confirm-warning": "confirm_order_warning",
};
// Update each element
for (var elementId in elementLabelMap) {
var element = document.getElementById(elementId);
var labelKey = elementLabelMap[elementId];
var translatedText = labels[labelKey];
console.log(
"[UPDATE_LABELS] Element:",
elementId,
"| Exists:",
!!element,
"| Label Key:",
labelKey,
"| Translated:",
translatedText
);
if (element && translatedText) {
var oldText = element.textContent;
element.textContent = translatedText;
console.log(
"[UPDATE_LABELS] ✅ Updated #" +
elementId +
': "' +
oldText +
'" → "' +
translatedText +
'"'
);
} else if (!element) {
console.log("[UPDATE_LABELS] ❌ Element not found: #" + elementId);
} else if (!translatedText) {
console.log(
"[UPDATE_LABELS] ❌ Label not found: " +
labelKey +
" (available keys: " +
Object.keys(labels).join(", ") +
")"
);
}
}
// Update delivery day text if available
if (
window.groupOrderShop &&
window.groupOrderShop.labels &&
window.groupOrderShop.labels.delivery_info_template
) {
var deliveryDayText = document.getElementById("delivery-day-text");
console.log("[UPDATE_LABELS] Delivery day text element exists:", !!deliveryDayText);
if (deliveryDayText) {
// Get delivery data from window.deliveryData first, then fallback to attributes
var pickupDayIndex = "";
var pickupDate = "";
var deliveryNotice = "";
if (window.deliveryData) {
console.log(
"[UPDATE_LABELS] Using window.deliveryData:",
window.deliveryData
);
pickupDayIndex = window.deliveryData.pickupDay || "";
pickupDate = window.deliveryData.pickupDate || "";
deliveryNotice = window.deliveryData.deliveryNotice || "";
} else {
console.log(
"[UPDATE_LABELS] window.deliveryData not found, using data attributes"
);
var wrap = document.getElementById("wrap");
pickupDayIndex = wrap ? wrap.getAttribute("data-pickup-day") : "";
pickupDate = wrap ? wrap.getAttribute("data-pickup-date") : "";
deliveryNotice = wrap ? wrap.getAttribute("data-delivery-notice") : "";
}
// Normalize: convert "undefined" strings and null to empty for processing
if (pickupDayIndex === "undefined" || pickupDayIndex === null)
pickupDayIndex = "";
if (pickupDate === "undefined" || pickupDate === null) pickupDate = "";
if (deliveryNotice === "undefined" || deliveryNotice === null)
deliveryNotice = "";
console.log("[UPDATE_LABELS] Delivery data (final):", {
pickupDayIndex: pickupDayIndex,
pickupDate: pickupDate,
deliveryNotice: deliveryNotice,
});
// Day names mapping
var dayNames = {
0: "Monday",
1: "Tuesday",
2: "Wednesday",
3: "Thursday",
4: "Friday",
5: "Saturday",
6: "Sunday",
};
// Get translated day names if available
if (window.groupOrderShop && window.groupOrderShop.day_names) {
dayNames = window.groupOrderShop.day_names;
}
// Get the day name from index
var pickupDayName =
pickupDayIndex && dayNames[pickupDayIndex]
? dayNames[pickupDayIndex]
: pickupDayIndex;
// Build message from template
var msg = window.groupOrderShop.labels.delivery_info_template;
msg = msg.replace("{pickup_day}", pickupDayName);
msg = msg.replace("{pickup_date}", pickupDate);
console.log("[UPDATE_LABELS] Built delivery message:", msg);
// Build final HTML output
var htmlOutput = msg;
if (deliveryNotice) {
// Replace newlines with <br> tags for HTML display
htmlOutput =
msg.replace(/\n/g, "<br>") +
"<br><br>" +
deliveryNotice.replace(/\n/g, "<br>");
console.log("[UPDATE_LABELS] Final HTML with notice:", htmlOutput);
} else {
htmlOutput = msg.replace(/\n/g, "<br>");
}
deliveryDayText.innerHTML = htmlOutput;
console.log(
"[UPDATE_LABELS] ✅ Updated delivery day text with translated template"
);
}
} else {
console.log("[UPDATE_LABELS] ❌ delivery_info_template label not found");
}
},
_attachLoadMoreListener: function () {
var self = this;
var btn = document.getElementById("load-more-btn");
@ -1790,6 +1678,9 @@
self._updateCartDisplay();
return;
}
if (self._handleAlreadyPlacedResponse(xhr)) {
return;
}
try {
var errorData = JSON.parse(xhr.responseText);
self._showNotification(
@ -1914,6 +1805,9 @@
self._updateCartDisplay();
return;
}
if (self._handleAlreadyPlacedResponse(xhr)) {
return;
}
try {
var errorData = JSON.parse(xhr.responseText);
self._showNotification(
@ -2036,10 +1930,17 @@
if (data.success) {
var successMsg =
data.message ||
labels.draft_saved_success ||
labels.draft_saved ||
"Order saved as draft successfully";
self._showNotification("\u2713 " + successMsg, "success", 5000);
// With online payment on, the server answers with the
// payment step URL: saving the cart is only half of
// placing the order.
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
} else {
self._showNotification(
"Error: " + (data.error || labels.error_unknown || "Unknown error"),
@ -2056,6 +1957,9 @@
self._updateCartDisplay();
return;
}
if (self._handleAlreadyPlacedResponse(xhr)) {
return;
}
try {
var errorData = JSON.parse(xhr.responseText);
console.error("HTTP error:", xhr.status, errorData);

View file

@ -18,3 +18,10 @@ from . import test_cron_picking_batch # noqa: F401
from . import test_group_order_status_endpoint # noqa: F401
from . import test_home_delivery # noqa: F401
from . import test_forecasted_stock # noqa: F401
from . import test_online_payment # noqa: F401
from . import test_product_discovery # noqa: F401
from . import test_date_edge_cases # noqa: F401
from . import test_portal_routes # noqa: F401
from . import test_price_with_taxes_included # noqa: F401
from . import test_pricing_delegation # noqa: F401
from . import test_website_enabled # noqa: F401

View file

@ -0,0 +1,280 @@
# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
"""Calendar edge cases for the cutoff/pickup/delivery date chain.
The date chain is `cutoff_date` -> `pickup_date` -> `delivery_date`:
- `cutoff_date` is the next occurrence of `cutoff_day`, measured from
`start_date` when it is in the future, or from **today** when `start_date`
already passed. Recurring orders therefore roll forward to the current
cycle instead of staying on the cycle they were created in.
- `pickup_date` is the next occurrence of `pickup_day` *strictly after*
`cutoff_date`.
- `delivery_date` is `pickup_date` + 1 day.
Every fixture below anchors on a **future** start date so the roll-forward
does not move the cycle under the test, which keeps the arithmetic exact and
the assertions stable over time. `test_past_weekly_order_rolls_forward`
covers the roll-forward itself.
"""
from datetime import date
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo.tests.common import TransactionCase
class DateEdgeCaseCommon:
"""Helpers to build orders anchored on chosen calendar dates."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
def _create_order(self, start_date, pickup_day, cutoff_day, **overrides):
vals = {
"name": "Date Edge Case Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": str(pickup_day),
"cutoff_day": str(cutoff_day),
}
vals.update(overrides)
return self.env["group.order"].create(vals)
@staticmethod
def _next_leap_day(reference):
"""First 29 February strictly after `reference`."""
year = reference.year
while True:
year += 1
try:
candidate = date(year, 2, 29)
except ValueError:
continue
if candidate > reference:
return candidate
@staticmethod
def _expected_cutoff(start_date, cutoff_day):
"""Mirror of `_compute_cutoff_date` for a future weekly start."""
days_ahead = cutoff_day - start_date.weekday()
if days_ahead < 0:
days_ahead += 7
return start_date + timedelta(days=days_ahead)
@staticmethod
def _expected_pickup(cutoff_date, pickup_day):
"""Mirror of `_compute_pickup_date`: strictly after the cutoff."""
days_ahead = pickup_day - cutoff_date.weekday()
if days_ahead <= 0:
days_ahead += 7
return cutoff_date + timedelta(days=days_ahead)
class TestLeapYearHandling(DateEdgeCaseCommon, TransactionCase):
"""A 29 February inside the cycle must not shift the dates."""
def test_pickup_lands_on_leap_day(self):
"""A pickup that falls exactly on 29 February is computed as such."""
leap_day = self._next_leap_day(date.today())
# Anchor the cycle so the cutoff is the day before the leap day; the
# next pickup weekday is then the leap day itself.
cutoff = leap_day - timedelta(days=1)
start = cutoff - timedelta(days=1)
order = self._create_order(
start_date=start,
pickup_day=leap_day.weekday(),
cutoff_day=cutoff.weekday(),
)
self.assertEqual(order.cutoff_date, cutoff)
self.assertEqual(order.pickup_date, leap_day)
self.assertEqual(order.pickup_date.day, 29)
self.assertEqual(order.pickup_date.month, 2)
def test_cycle_spans_the_leap_day(self):
"""A cycle crossing 29 February keeps the 7-day pickup spacing."""
leap_day = self._next_leap_day(date.today())
start = leap_day - timedelta(days=3)
order = self._create_order(
start_date=start,
pickup_day=(start.weekday() + 4) % 7,
cutoff_day=start.weekday(),
)
# cutoff is the start day itself (days_ahead == 0 is allowed)
self.assertEqual(order.cutoff_date, start)
self.assertEqual(order.pickup_date, start + timedelta(days=4))
# The leap day is inside the cycle, so it was counted as a real day.
self.assertLess(order.cutoff_date, leap_day)
self.assertGreater(order.pickup_date, leap_day)
def test_delivery_after_leap_day_pickup(self):
"""Delivery is pickup + 1 day even when pickup is 29 February."""
leap_day = self._next_leap_day(date.today())
cutoff = leap_day - timedelta(days=1)
start = cutoff - timedelta(days=1)
order = self._create_order(
start_date=start,
pickup_day=leap_day.weekday(),
cutoff_day=cutoff.weekday(),
)
self.assertEqual(order.pickup_date, leap_day)
# 1 March in a leap year.
self.assertEqual(order.delivery_date, date(leap_day.year, 3, 1))
class TestMonthAndYearBoundaries(DateEdgeCaseCommon, TransactionCase):
"""Cycles crossing a month or year boundary."""
def _future_day(self, target_day, month_offset=1):
"""A future date landing on `target_day` of some upcoming month."""
candidate = date.today().replace(day=1) + relativedelta(months=month_offset)
return candidate.replace(day=target_day)
def test_pickup_crosses_month_boundary(self):
"""A pickup in the month after the cutoff is computed correctly."""
# Anchor on the 28th so a few days forward always lands next month.
start = self._future_day(28)
pickup_day = (start.weekday() + 5) % 7
order = self._create_order(
start_date=start,
pickup_day=pickup_day,
cutoff_day=start.weekday(),
)
expected_pickup = start + timedelta(days=5)
self.assertEqual(order.cutoff_date, start)
self.assertEqual(order.pickup_date, expected_pickup)
self.assertNotEqual(order.pickup_date.month, order.cutoff_date.month)
def test_pickup_crosses_year_boundary(self):
"""A cycle spanning 31 December rolls into the next year."""
today = date.today()
start = date(today.year + 1, 12, 29)
pickup_day = (start.weekday() + 4) % 7
order = self._create_order(
start_date=start,
pickup_day=pickup_day,
cutoff_day=start.weekday(),
)
self.assertEqual(order.cutoff_date, start)
self.assertEqual(order.pickup_date, start + timedelta(days=4))
self.assertEqual(order.pickup_date.year, start.year + 1)
self.assertEqual(order.pickup_date.month, 1)
def test_last_day_of_month_pickup(self):
"""A pickup landing on the last day of the month is kept intact."""
start = self._future_day(24)
last_day = start + relativedelta(day=31)
cutoff = last_day - timedelta(days=1)
order = self._create_order(
start_date=start,
pickup_day=last_day.weekday(),
cutoff_day=cutoff.weekday(),
)
self.assertEqual(order.cutoff_date, cutoff)
self.assertEqual(order.pickup_date, last_day)
# Next day already belongs to the following month.
self.assertEqual(order.delivery_date.day, 1)
class TestMonthlyRecurrenceBoundaries(DateEdgeCaseCommon, TransactionCase):
"""The monthly grid must survive short months and leap years."""
def test_monthly_grid_from_january_31_anchor(self):
"""A 31 January anchor advances into February without overflowing."""
today = date.today()
anchor = date(today.year + 1, 1, 31)
order = self._create_order(
start_date=anchor,
pickup_day=(anchor.weekday() + 2) % 7,
cutoff_day=anchor.weekday(),
period="monthly",
end_date=anchor + relativedelta(months=6),
)
# First grid point is the anchor itself (already on cutoff_day).
self.assertEqual(order.cutoff_date, anchor)
self.assertEqual(order.pickup_date, anchor + timedelta(days=2))
def test_monthly_grid_lands_in_february_of_a_leap_year(self):
"""A February cycle in a leap year stays inside February."""
leap_day = self._next_leap_day(date.today())
anchor = date(leap_day.year, 2, 1)
# Only meaningful while the anchor is still ahead of us.
if anchor <= date.today():
self.skipTest("The next leap February already started")
order = self._create_order(
start_date=anchor,
pickup_day=(anchor.weekday() + 1) % 7,
cutoff_day=anchor.weekday(),
period="monthly",
end_date=anchor + relativedelta(months=6),
)
self.assertEqual(order.cutoff_date, anchor)
self.assertEqual(order.cutoff_date.month, 2)
self.assertEqual(order.pickup_date, anchor + timedelta(days=1))
class TestRollForwardBehaviour(DateEdgeCaseCommon, TransactionCase):
"""Past-dated recurring orders move to the current cycle."""
def test_past_weekly_order_rolls_forward(self):
"""A weekly order created long ago computes dates for today's cycle.
This is the documented behaviour of `_compute_cutoff_date`: once
`start_date` is in the past the reference becomes today, so the order
never keeps stale dates from the cycle it was created in.
"""
today = date.today()
long_ago = today - relativedelta(years=2)
order = self._create_order(
start_date=long_ago,
pickup_day=3,
cutoff_day=0,
end_date=long_ago + timedelta(days=7),
)
expected_cutoff = self._expected_cutoff(today, 0)
self.assertEqual(order.cutoff_date, expected_cutoff)
self.assertEqual(order.pickup_date, self._expected_pickup(expected_cutoff, 3))
# The stale cycle is gone: nothing points back at the creation year.
self.assertGreaterEqual(order.cutoff_date, today)
self.assertGreater(order.pickup_date, today)
def test_future_weekly_order_keeps_its_own_cycle(self):
"""A future order is anchored on its start date, not on today."""
start = date.today() + timedelta(days=30)
order = self._create_order(start_date=start, pickup_day=3, cutoff_day=0)
expected_cutoff = self._expected_cutoff(start, 0)
self.assertEqual(order.cutoff_date, expected_cutoff)
self.assertEqual(order.pickup_date, self._expected_pickup(expected_cutoff, 3))
self.assertGreaterEqual(order.cutoff_date, start)

View file

@ -1,667 +0,0 @@
# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
"""
Test suite for cart/draft persistence in website_sale_aplicoop.
Coverage:
- Save draft order (empty, with items)
- Load draft order
- Draft consistency (prices don't change unexpectedly)
- Product archived in draft (handling)
- Merge inconsistent drafts
- Draft timeline (very old draft, recent draft)
"""
from datetime import datetime
from datetime import timedelta
from odoo.tests.common import TransactionCase
class TestSaveDraftOrder(TransactionCase):
"""Test saving draft orders."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member_partner.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"email": "testuser@test.com",
"partner_id": self.member_partner.id,
}
)
self.category = self.env["product.category"].create(
{
"name": "Test Category",
}
)
self.product1 = self.env["product.product"].create(
{
"name": "Product 1",
"type": "consu",
"list_price": 10.0,
"categ_id": self.category.id,
}
)
self.product2 = self.env["product.product"].create(
{
"name": "Product 2",
"type": "consu",
"list_price": 20.0,
"categ_id": self.category.id,
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"pickup_date": start_date + timedelta(days=3),
"cutoff_day": "0",
}
)
self.group_order.action_open()
self.group_order.product_ids = [(4, self.product1.id), (4, self.product2.id)]
def test_save_draft_with_items(self):
"""Test saving draft order with products."""
draft_order = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
"order_line": [
(
0,
0,
{
"product_id": self.product1.id,
"product_qty": 2,
"price_unit": self.product1.list_price,
},
),
(
0,
0,
{
"product_id": self.product2.id,
"product_qty": 1,
"price_unit": self.product2.list_price,
},
),
],
}
)
self.assertTrue(draft_order.exists())
self.assertEqual(draft_order.state, "draft")
self.assertEqual(len(draft_order.order_line), 2)
def test_save_draft_empty_order(self):
"""Test saving draft order without items."""
# Edge case: empty draft
empty_draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
"order_line": [],
}
)
# Should be valid (user hasn't added products yet)
self.assertTrue(empty_draft.exists())
self.assertEqual(len(empty_draft.order_line), 0)
def test_save_draft_updates_existing(self):
"""Test that saving draft updates existing draft, not creates new."""
# Create initial draft
draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
"order_line": [
(
0,
0,
{
"product_id": self.product1.id,
"product_qty": 1,
},
)
],
}
)
draft_id = draft.id
# Simulate "save" with different quantity
draft.order_line[0].product_qty = 5
# Should be same draft, not new one
updated_draft = self.env["sale.order"].browse(draft_id)
self.assertTrue(updated_draft.exists())
self.assertEqual(updated_draft.order_line[0].product_qty, 5)
def test_save_draft_preserves_group_order_reference(self):
"""Test that group_order_id is preserved when saving."""
draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
}
)
# Link must be preserved
self.assertEqual(draft.group_order_id, self.group_order)
def test_save_draft_preserves_pickup_date(self):
"""Test that pickup_date is preserved in draft."""
draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"pickup_date": self.group_order.pickup_date,
"state": "draft",
}
)
self.assertEqual(draft.pickup_date, self.group_order.pickup_date)
class TestLoadDraftOrder(TransactionCase):
"""Test loading (retrieving) draft orders."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member_partner.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"email": "testuser@test.com",
"partner_id": self.member_partner.id,
}
)
self.product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "consu",
"list_price": 10.0,
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.group_order.action_open()
def test_load_existing_draft(self):
"""Test loading an existing draft order."""
# Create draft
draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_qty": 3,
},
)
],
}
)
# Load it
loaded = self.env["sale.order"].search(
[
("id", "=", draft.id),
("partner_id", "=", self.member_partner.id),
("state", "=", "draft"),
]
)
self.assertEqual(len(loaded), 1)
self.assertEqual(loaded[0].order_line[0].product_qty, 3)
def test_load_draft_not_visible_to_other_user(self):
"""Test that draft from one user not accessible to another."""
# Create draft for member_partner
draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
}
)
# Create another user/partner
other_partner = self.env["res.partner"].create(
{
"name": "Other Member",
"email": "other@test.com",
}
)
self.env["res.users"].create(
{
"name": "Other User",
"login": "other@test.com",
"partner_id": other_partner.id,
}
)
# Other user should not see original draft
other_drafts = self.env["sale.order"].search(
[
("id", "=", draft.id),
("partner_id", "=", other_partner.id),
]
)
self.assertEqual(len(other_drafts), 0)
def test_load_draft_from_expired_order(self):
"""Test loading draft from closed/expired group order."""
# Close the group order
self.group_order.action_close()
# Create draft before closure (simulated)
draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
}
)
# Draft should still be loadable (but should warn)
loaded = self.env["sale.order"].browse(draft.id)
self.assertTrue(loaded.exists())
# Controller should check: group_order.state and warn if closed
class TestDraftConsistency(TransactionCase):
"""Test that draft prices remain consistent across saves."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member_partner.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"partner_id": self.member_partner.id,
}
)
self.product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "consu",
"list_price": 100.0,
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.group_order.action_open()
def test_draft_price_snapshot(self):
"""Test that draft captures price at time of save."""
original_price = self.product.list_price
# Save draft with current price
draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_qty": 1,
"price_unit": original_price,
},
)
],
}
)
saved_price = draft.order_line[0].price_unit
# Change product price
self.product.list_price = 150.0
# Draft should still have original price
self.assertEqual(draft.order_line[0].price_unit, saved_price)
self.assertNotEqual(draft.order_line[0].price_unit, self.product.list_price)
def test_draft_quantity_consistency(self):
"""Test that quantities are preserved across saves."""
# Save draft
draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_qty": 5,
},
)
],
}
)
# Re-load draft
reloaded = self.env["sale.order"].browse(draft.id)
self.assertEqual(reloaded.order_line[0].product_qty, 5)
class TestProductArchivedInDraft(TransactionCase):
"""Test handling when product in draft gets archived."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member_partner.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"partner_id": self.member_partner.id,
}
)
self.product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "consu",
"list_price": 10.0,
"active": True,
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.group_order.action_open()
def test_load_draft_with_archived_product(self):
"""Test loading draft when product has been archived."""
# Create draft with active product
draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_qty": 2,
},
)
],
}
)
# Archive the product
self.product.active = False
# Load draft - should still work (historical data)
loaded = self.env["sale.order"].browse(draft.id)
self.assertTrue(loaded.exists())
# But product may not be editable/accessible
class TestDraftTimeline(TransactionCase):
"""Test very old vs recent drafts."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member_partner.id)]
self.product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "consu",
"list_price": 10.0,
}
)
def test_draft_from_current_week(self):
"""Test draft from current/open group order."""
start_date = datetime.now().date()
current_order = self.env["group.order"].create(
{
"name": "Current Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
current_order.action_open()
draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": current_order.id,
"state": "draft",
}
)
# Should be accessible and valid
self.assertTrue(draft.exists())
self.assertEqual(draft.group_order_id.state, "open")
def test_draft_from_old_order_6_months_ago(self):
"""Test draft from order that was 6 months ago."""
old_start = datetime.now().date() - timedelta(days=180)
old_end = old_start + timedelta(days=7)
old_order = self.env["group.order"].create(
{
"name": "Old Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": old_start,
"end_date": old_end,
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
old_order.action_open()
old_order.action_close()
old_draft = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": old_order.id,
"state": "draft",
}
)
# Should still exist but be inaccessible (order closed)
self.assertTrue(old_draft.exists())
self.assertEqual(old_order.state, "closed")
def test_draft_order_count_for_user(self):
"""Test counting total drafts for a user."""
# Create multiple orders and drafts
orders = []
for i in range(3):
start = datetime.now().date() + timedelta(days=i * 7)
order = self.env["group.order"].create(
{
"name": f"Order {i}",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": start + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
order.action_open()
orders.append(order)
# Create draft for each
for order in orders:
self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": order.id,
"state": "draft",
}
)
# Count drafts for user
user_drafts = self.env["sale.order"].search(
[
("partner_id", "=", self.member_partner.id),
("state", "=", "draft"),
]
)
self.assertEqual(len(user_drafts), 3)

View file

@ -1,506 +0,0 @@
# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
"""
Test suite for edge cases involving dates, times, and calendar calculations.
Coverage:
- Leap year (Feb 29) handling
- Long-duration orders (entire year)
- Pickup day boundary conditions
- Orders with future start dates
- Orders without end dates
- Extreme dates (year 1900, year 2099)
"""
from datetime import date
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo.exceptions import ValidationError # noqa: F401
from odoo.tests.common import TransactionCase
class TestLeapYearHandling(TransactionCase):
"""Test date calculations with leap year (Feb 29)."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
def test_order_spans_leap_day(self):
"""Test order that includes Feb 29 (leap year)."""
# 2024 is a leap year
start = date(2024, 2, 25)
end = date(2024, 3, 3) # Spans Feb 29
order = self.env["group.order"].create(
{
"name": "Leap Year Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": "2", # Wednesday (Feb 28 or 29 depending on week)
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
# Should correctly calculate pickup date
self.assertTrue(order.pickup_date)
def test_pickup_day_on_feb_29(self):
"""Test setting pickup_day to land on Feb 29."""
# 2024 Feb 29 is a Thursday (day 3)
start = date(2024, 2, 26) # Monday
end = date(2024, 3, 3)
order = self.env["group.order"].create(
{
"name": "Feb 29 Pickup",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": "3", # Thursday = Feb 29
"cutoff_day": "0",
}
)
self.assertEqual(order.pickup_date, date(2024, 2, 29))
def test_order_before_leap_day(self):
"""Test order in non-leap year (no Feb 29)."""
# 2023 is NOT a leap year
start = date(2023, 2, 25)
end = date(2023, 3, 3)
order = self.env["group.order"].create(
{
"name": "Non-Leap Year Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": "2",
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
# Pickup should be Feb 28 (last day of Feb)
self.assertIn(order.pickup_date.month, [2, 3])
class TestLongDurationOrders(TransactionCase):
"""Test orders spanning very long periods."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
def test_order_spans_entire_year(self):
"""Test order running for 365 days."""
start = date(2024, 1, 1)
end = date(2024, 12, 31)
order = self.env["group.order"].create(
{
"name": "Year-Long Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": "3", # Same day each week
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
# Should handle 52+ weeks correctly
days_diff = (end - start).days
self.assertEqual(days_diff, 365)
def test_order_multiple_years(self):
"""Test order spanning multiple years (2+ years)."""
start = date(2024, 1, 1)
end = date(2026, 12, 31) # 3 years
order = self.env["group.order"].create(
{
"name": "Multi-Year Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "monthly",
"pickup_day": "15",
"cutoff_day": "10",
}
)
self.assertTrue(order.exists())
days_diff = (end - start).days
self.assertGreater(days_diff, 700) # More than 2 years
def test_order_one_day_duration(self):
"""Test order with start_date == end_date (single day)."""
same_day = date(2024, 2, 15)
order = self.env["group.order"].create(
{
"name": "One-Day Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "once",
"start_date": same_day,
"end_date": same_day,
"period": "once",
"pickup_day": "0",
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
class TestPickupDayBoundary(TransactionCase):
"""Test pickup_day calculations at boundaries."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
def test_pickup_day_same_as_start_date(self):
"""Test when pickup_day equals start date (today)."""
today = date.today()
start = today
end = today + timedelta(days=7)
order = self.env["group.order"].create(
{
"name": "Today Pickup",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": str(start.weekday()), # Same as start
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
# Pickup should be today
self.assertEqual(order.pickup_date, start)
def test_pickup_day_last_day_of_month(self):
"""Test pickup day on last day of month (Jan 31, Feb 28/29, etc)."""
# Start on Jan 24, pickup on Jan 31
start = date(2024, 1, 24)
end = date(2024, 2, 1)
order = self.env["group.order"].create(
{
"name": "Month-End Pickup",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "once",
"pickup_day": "2", # Wednesday = Jan 31
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
def test_pickup_day_month_boundary(self):
"""Test when pickup crosses month boundary."""
# Start Jan 28, pickup might be in February
start = date(2024, 1, 28)
end = date(2024, 2, 5)
order = self.env["group.order"].create(
{
"name": "Month Boundary Pickup",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": "4", # Friday (Feb 2)
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
# Pickup should be in Feb
self.assertEqual(order.pickup_date.month, 2)
def test_all_seven_days_as_pickup(self):
"""Test each day of week (0-6) as valid pickup_day."""
start = date(2024, 1, 1) # Monday
end = date(2024, 1, 8)
for day_num in range(7):
order = self.env["group.order"].create(
{
"name": f"Pickup Day {day_num}",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": str(day_num),
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
# Each should have valid pickup_date
self.assertTrue(order.pickup_date)
class TestFutureStartDateOrders(TransactionCase):
"""Test orders that start in the future."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
def test_order_starts_tomorrow(self):
"""Test order starting tomorrow."""
today = date.today()
start = today + timedelta(days=1)
end = start + timedelta(days=7)
order = self.env["group.order"].create(
{
"name": "Future Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
self.assertGreater(order.start_date, today)
def test_order_starts_6_months_future(self):
"""Test order starting 6 months from now."""
today = date.today()
start = today + relativedelta(months=6)
end = start + timedelta(days=30)
order = self.env["group.order"].create(
{
"name": "Far Future Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "monthly",
"pickup_day": "15",
"cutoff_day": "10",
}
)
self.assertTrue(order.exists())
class TestExtremeDate(TransactionCase):
"""Test edge cases with very old or very new dates."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
def test_order_year_2000(self):
"""Test order in year 2000 (Y2K edge case)."""
start = date(2000, 1, 1)
end = date(2000, 12, 31)
order = self.env["group.order"].create(
{
"name": "Y2K Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
def test_order_far_future_2099(self):
"""Test order in far future (year 2099)."""
start = date(2099, 1, 1)
end = date(2099, 12, 31)
order = self.env["group.order"].create(
{
"name": "Far Future Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
def test_order_crossing_century(self):
"""Test order spanning century boundary (Dec 1999 to Jan 2000)."""
start = date(1999, 12, 26)
end = date(2000, 1, 2)
order = self.env["group.order"].create(
{
"name": "Century Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": "6", # Saturday
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
# Should handle date arithmetic correctly across years
self.assertEqual(order.start_date.year, 1999)
self.assertEqual(order.end_date.year, 2000)
class TestOrderWithoutEndDate(TransactionCase):
"""Test orders without explicit end_date (permanent/ongoing)."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
def test_permanent_order_with_null_end_date(self):
"""Test order with end_date = NULL (ongoing order)."""
start = date.today()
self.env["group.order"].create(
{
"name": "Permanent Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": False, # No end date
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
# If supported, should handle gracefully
# Otherwise, may be optional validation
class TestPickupCalculationAccuracy(TransactionCase):
"""Test accuracy of pickup_date calculations."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
def test_pickup_date_calculation_multiple_weeks(self):
"""Test pickup_date calculation over multiple weeks."""
# Week 1: Jan 1-7 (Mon-Sun), pickup Thursday = Jan 4
start = date(2024, 1, 1)
end = date(2024, 1, 22)
order = self.env["group.order"].create(
{
"name": "Multi-Week Pickup",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "weekly",
"pickup_day": "3", # Thursday
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
# First pickup should be first Thursday on or after start
self.assertEqual(order.pickup_date.weekday(), 3)
def test_monthly_order_pickup_date(self):
"""Test pickup_date for monthly orders."""
# Order runs Feb 1 - Mar 31, pickup on 15th
start = date(2024, 2, 1)
end = date(2024, 3, 31)
order = self.env["group.order"].create(
{
"name": "Monthly Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start,
"end_date": end,
"period": "monthly",
"pickup_day": "15",
"cutoff_day": "10",
}
)
self.assertTrue(order.exists())
# First pickup should be Feb 15
self.assertGreaterEqual(order.pickup_date.day, 15)

View file

@ -1,613 +0,0 @@
# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
"""
Test suite for HTTP endpoints in website_sale_aplicoop controllers.
Coverage:
- /eskaera (GET) - View all group orders
- /eskaera/<id> (GET) - View specific group order
- /eskaera/<id>/add-to-cart (POST) - Add product to cart
- /eskaera/<id>/checkout (GET) - Checkout page
- /eskaera/<id>/checkout (POST) - Save cart items
- /eskaera/confirm (POST) - Confirm order
- /eskaera/<id>/confirm/<sale_id> (POST) - Confirm order from portal
- /eskaera/<id>/load-from-history/<sale_id> (POST) - Load draft order
- /eskaera/labels (GET) - Get translated labels
"""
from datetime import datetime
from datetime import timedelta
from odoo.exceptions import AccessError # noqa: F401
from odoo.exceptions import ValidationError # noqa: F401
from odoo.tests.common import HttpCase # noqa: F401
from odoo.tests.common import TransactionCase
class TestEskaearaListEndpoint(TransactionCase):
"""Test /eskaera endpoint (list all group orders)."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
"email": "group@test.com",
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member_partner.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"email": "testuser@test.com",
"partner_id": self.member_partner.id,
}
)
# Create multiple group orders (some open, some closed)
start_date = datetime.now().date()
self.open_order = self.env["group.order"].create(
{
"name": "Open Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.open_order.action_open()
self.draft_order = self.env["group.order"].create(
{
"name": "Draft Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date - timedelta(days=14),
"end_date": start_date - timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
# Stay in draft
self.closed_order = self.env["group.order"].create(
{
"name": "Closed Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date - timedelta(days=21),
"end_date": start_date - timedelta(days=14),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.closed_order.action_open()
self.closed_order.action_close()
def test_eskaera_list_shows_only_open_and_draft_orders(self):
"""Test that /eskaera shows only open/draft orders, not closed."""
# In controller context, only open and draft should be visible to members
# This is business logic: closed orders are historical
visible_orders = self.env["group.order"].search(
[
("state", "in", ["open", "draft"]),
("group_ids", "in", self.group.id),
]
)
self.assertIn(self.open_order, visible_orders)
self.assertIn(self.draft_order, visible_orders)
self.assertNotIn(self.closed_order, visible_orders)
def test_eskaera_list_filters_by_user_groups(self):
"""Test that user only sees orders from their groups."""
other_group = self.env["res.partner"].create(
{
"name": "Other Group",
"is_company": True,
"email": "other@test.com",
}
)
other_order = self.env["group.order"].create(
{
"name": "Other Group Order",
"group_ids": [(6, 0, [other_group.id])],
"type": "regular",
"start_date": datetime.now().date(),
"end_date": datetime.now().date() + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
other_order.action_open()
# User should not see orders from groups they're not in
user_groups = self.member_partner.group_ids
visible_orders = self.env["group.order"].search(
[
("state", "in", ["open", "draft"]),
("group_ids", "in", user_groups.ids),
]
)
self.assertNotIn(other_order, visible_orders)
class TestAddToCartEndpoint(TransactionCase):
"""Test /eskaera/<id>/add-to-cart endpoint."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
"email": "group@test.com",
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member_partner.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"email": "testuser@test.com",
"partner_id": self.member_partner.id,
}
)
self.category = self.env["product.category"].create(
{
"name": "Test Category",
}
)
# Published product
self.product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "consu",
"list_price": 10.0,
"categ_id": self.category.id,
"sale_ok": True,
"is_published": True,
}
)
# Unpublished product (should not be available)
self.unpublished_product = self.env["product.product"].create(
{
"name": "Unpublished Product",
"type": "consu",
"list_price": 15.0,
"categ_id": self.category.id,
"sale_ok": False,
"is_published": False,
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.group_order.action_open()
self.group_order.product_ids = [(4, self.product.id)]
def test_add_to_cart_published_product(self):
"""Test adding published product to cart."""
# Simulate controller logic
cart_line = {
"product_id": self.product.id,
"quantity": 2,
"group_order_id": self.group_order.id,
"partner_id": self.member_partner.id,
}
# Should succeed
self.assertTrue(cart_line["product_id"])
def test_add_to_cart_zero_quantity(self):
"""Test that adding zero quantity is rejected."""
# Edge case: quantity = 0
quantity = 0
# Controller should validate: quantity > 0
self.assertFalse(quantity > 0)
def test_add_to_cart_negative_quantity(self):
"""Test that negative quantity is rejected."""
quantity = -5
# Controller should validate: quantity > 0
self.assertFalse(quantity > 0)
def test_add_to_cart_unpublished_product(self):
"""Test that unpublished products cannot be added."""
# Product must be published and sale_ok=True
self.assertFalse(self.unpublished_product.is_published)
self.assertFalse(self.unpublished_product.sale_ok)
def test_add_to_cart_product_not_in_order(self):
"""Test that products not in the order cannot be added."""
# Create a product NOT associated with group_order
other_product = self.env["product.product"].create(
{
"name": "Other Product",
"type": "consu",
"list_price": 25.0,
}
)
# Controller should check: product in group_order.product_ids
self.assertNotIn(other_product, self.group_order.product_ids)
def test_add_to_cart_order_closed(self):
"""Test that adding to closed order is rejected."""
self.group_order.action_close()
# Controller should check: order.state == 'open'
self.assertEqual(self.group_order.state, "closed")
class TestCheckoutEndpoint(TransactionCase):
"""Test /eskaera/<id>/checkout endpoint."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
"email": "group@test.com",
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member_partner.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"email": "testuser@test.com",
"partner_id": self.member_partner.id,
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"pickup_date": start_date + timedelta(days=3),
"cutoff_day": "0",
}
)
self.group_order.action_open()
def test_checkout_page_loads(self):
"""Test that checkout page renders correctly."""
# Controller should render template with group_order context
self.assertTrue(self.group_order.exists())
def test_checkout_displays_pickup_date(self):
"""Test that checkout shows correct pickup date."""
# Controller should calculate pickup_date from pickup_day
self.assertTrue(self.group_order.pickup_date)
def test_checkout_displays_home_delivery_option(self):
"""Test that checkout shows home delivery option."""
# Controller should pass home_delivery flag to template
self.assertIsNotNone(self.group_order.home_delivery)
def test_checkout_order_without_products(self):
"""Test checkout when no products available."""
# Order with empty product_ids
empty_order = self.env["group.order"].create(
{
"name": "Empty Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": datetime.now().date(),
"end_date": datetime.now().date() + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
empty_order.action_open()
# Should handle gracefully
self.assertEqual(len(empty_order.product_ids), 0)
class TestConfirmOrderEndpoint(TransactionCase):
"""Test /eskaera/confirm endpoint (confirm final order)."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
"email": "group@test.com",
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member_partner.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"email": "testuser@test.com",
"partner_id": self.member_partner.id,
}
)
self.category = self.env["product.category"].create(
{
"name": "Test Category",
}
)
self.product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "consu",
"list_price": 10.0,
"categ_id": self.category.id,
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"pickup_date": start_date + timedelta(days=3),
"cutoff_day": "0",
}
)
self.group_order.action_open()
self.group_order.product_ids = [(4, self.product.id)]
# Create a draft sale order
self.draft_sale = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"pickup_date": self.group_order.pickup_date,
"state": "draft",
}
)
def test_confirm_order_creates_sale_order(self):
"""Test that confirming creates a confirmed sale.order."""
# Controller should change state from draft to sale
self.draft_sale.action_confirm()
self.assertEqual(self.draft_sale.state, "sale")
def test_confirm_empty_order(self):
"""Test confirming order without items fails."""
# Order with no order_lines should fail
empty_sale = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
}
)
# Should validate: must have at least one line
self.assertEqual(len(empty_sale.order_line), 0)
def test_confirm_order_wrong_group(self):
"""Test that user cannot confirm order from different group."""
other_group = self.env["res.partner"].create(
{
"name": "Other Group",
"is_company": True,
}
)
self.env["group.order"].create(
{
"name": "Other Order",
"group_ids": [(6, 0, [other_group.id])],
"type": "regular",
"start_date": datetime.now().date(),
"end_date": datetime.now().date() + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
# User should not be in other_group
self.assertNotIn(self.member_partner, other_group.member_ids)
class TestLoadDraftEndpoint(TransactionCase):
"""Test /eskaera/<id>/load-from-history/<sale_id> endpoint."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
"email": "group@test.com",
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member_partner.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"email": "testuser@test.com",
"partner_id": self.member_partner.id,
}
)
self.category = self.env["product.category"].create(
{
"name": "Test Category",
}
)
self.product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "consu",
"list_price": 10.0,
"categ_id": self.category.id,
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"pickup_date": start_date + timedelta(days=3),
"cutoff_day": "0",
}
)
self.group_order.action_open()
self.group_order.product_ids = [(4, self.product.id)]
def test_load_draft_from_history(self):
"""Test loading a previous draft order."""
# Create old draft sale
old_sale = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
}
)
# Should be able to load
self.assertTrue(old_sale.exists())
def test_load_draft_not_owned_by_user(self):
"""Test that user cannot load draft from other user."""
other_partner = self.env["res.partner"].create(
{
"name": "Other Member",
"email": "other@test.com",
}
)
other_sale = self.env["sale.order"].create(
{
"partner_id": other_partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
}
)
# User should not be able to load other's draft
self.assertNotEqual(other_sale.partner_id, self.member_partner)
def test_load_draft_expired_order(self):
"""Test loading draft from expired group order."""
old_start = datetime.now().date() - timedelta(days=30)
old_end = datetime.now().date() - timedelta(days=23)
expired_order = self.env["group.order"].create(
{
"name": "Expired Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": old_start,
"end_date": old_end,
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
expired_order.action_open()
expired_order.action_close()
self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": expired_order.id,
"state": "draft",
}
)
# Should warn: order expired
self.assertEqual(expired_order.state, "closed")

View file

@ -63,6 +63,8 @@ class TestGroupOrderStatusEndpoint(TransactionCase):
data=json.dumps(payload).encode("utf-8"),
),
make_response=_make_response,
# The Eskaera routes check that this website serves Eskaera.
website=SimpleNamespace(eskaera_enabled=True),
)
def test_check_group_order_status_open(self):

View file

@ -1,353 +0,0 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
"""
Test suite for Phase 1 refactoring helper methods.
Tests for extracted helper methods that reduce cyclomatic complexity:
- _resolve_pricelist(): Consolidate pricelist resolution logic
- _validate_confirm_request(): Validate confirm order request
- _validate_draft_request(): Validate draft order request
"""
from datetime import datetime
from datetime import timedelta
from odoo.tests.common import TransactionCase
class TestResolvePricelist(TransactionCase):
"""Test _resolve_pricelist() helper method."""
def setUp(self):
super().setUp()
self.pricelist_aplicoop = self.env["product.pricelist"].create(
{
"name": "Aplicoop Pricelist",
"currency_id": self.env.company.currency_id.id,
}
)
self.pricelist_website = self.env["product.pricelist"].create(
{
"name": "Website Pricelist",
"currency_id": self.env.company.currency_id.id,
}
)
self.website = self.env["website"].get_current_website()
self.website.pricelist_id = self.pricelist_website.id
def test_resolve_pricelist_aplicoop_configured(self):
"""Test pricelist resolution when Aplicoop pricelist is configured."""
# Set Aplicoop pricelist in config
self.env["ir.config_parameter"].sudo().set_param(
"website_sale_aplicoop.pricelist_id", str(self.pricelist_aplicoop.id)
)
# When calling _resolve_pricelist, should return Aplicoop pricelist
# Placeholder: will be implemented with actual controller call
def test_resolve_pricelist_fallback_to_website(self):
"""Test fallback to website pricelist when Aplicoop not configured."""
# Don't set Aplicoop pricelist in config (leave empty)
self.env["ir.config_parameter"].sudo().set_param(
"website_sale_aplicoop.pricelist_id", ""
)
# When calling _resolve_pricelist, should return website pricelist
# Placeholder: will be implemented with actual controller call
def test_resolve_pricelist_fallback_to_first_active(self):
"""Test final fallback to first active pricelist."""
# Remove both configured pricelists
self.env["ir.config_parameter"].sudo().set_param(
"website_sale_aplicoop.pricelist_id", ""
)
self.website.pricelist_id = False
# When calling _resolve_pricelist, should return first active pricelist
# Placeholder: will be implemented with actual controller call
class TestValidateConfirmRequest(TransactionCase):
"""Test _validate_confirm_request() helper method."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
self.member = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"email": "testuser@test.com",
"partner_id": self.member.id,
}
)
self.product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "product",
"list_price": 100.0,
}
)
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(4, self.group.id)],
"start_date": datetime.now().date(),
"end_date": datetime.now().date() + timedelta(days=7),
"pickup_day": "3",
"cutoff_day": "0",
"state": "open",
}
)
def test_validate_confirm_valid_request(self):
"""Test validation passes for valid confirm request."""
_ = {
"order_id": str(self.group_order.id),
"items": [
{
"product_id": str(self.product.id),
"quantity": 1.0,
"product_price": 100.0,
}
],
"is_delivery": False,
}
# Validation should pass without raising exception
# Placeholder: will be implemented with actual controller call
def test_validate_confirm_missing_order_id(self):
"""Test validation fails when order_id missing."""
_ = {
"items": [{"product_id": "1", "quantity": 1.0}],
}
# Validation should raise ValueError: "order_id is required"
# Placeholder: will be implemented with actual controller call
def test_validate_confirm_invalid_order_id(self):
"""Test validation fails for invalid order_id format."""
_ = {
"order_id": "invalid",
"items": [{"product_id": "1", "quantity": 1.0}],
}
# Validation should raise ValueError with "Invalid order_id format"
# Placeholder: will be implemented with actual controller call
def test_validate_confirm_nonexistent_order(self):
"""Test validation fails when order doesn't exist."""
_ = {
"order_id": "99999",
"items": [{"product_id": "1", "quantity": 1.0}],
}
# Validation should raise ValueError with "not found"
# Placeholder: will be implemented with actual controller call
def test_validate_confirm_closed_order(self):
"""Test validation fails when order is closed."""
self.group_order.state = "confirmed"
_ = {
"order_id": str(self.group_order.id),
"items": [{"product_id": "1", "quantity": 1.0}],
}
# Validation should raise ValueError with "not available"
# Placeholder: will be implemented with actual controller call
def test_validate_confirm_no_items(self):
"""Test validation fails when no items provided."""
_ = {
"order_id": str(self.group_order.id),
"items": [],
}
# Validation should raise ValueError with "No items in cart"
# Placeholder: will be implemented with actual controller call
def test_validate_confirm_user_no_partner(self):
"""Test validation fails when user has no partner_id."""
_ = self.env["res.users"].create(
{
"name": "User No Partner",
"login": "nopartner@test.com",
"email": "nopartner@test.com",
}
)
_ = {
"order_id": str(self.group_order.id),
"items": [{"product_id": "1", "quantity": 1.0}],
}
# Validation should raise ValueError with "no associated partner"
# Placeholder: will be implemented with actual controller call
class TestValidateDraftRequest(TransactionCase):
"""Test _validate_draft_request() helper method."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
self.member = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"email": "testuser@test.com",
"partner_id": self.member.id,
}
)
self.product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "product",
"list_price": 100.0,
}
)
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(4, self.group.id)],
"start_date": datetime.now().date(),
"end_date": datetime.now().date() + timedelta(days=7),
"pickup_day": "3",
"cutoff_day": "0",
"state": "open",
}
)
def test_validate_draft_valid_request(self):
"""Test validation passes for valid draft request."""
_ = {
"order_id": str(self.group_order.id),
"items": [
{
"product_id": str(self.product.id),
"quantity": 1.0,
"product_price": 100.0,
}
],
}
# Validation should pass without raising exception
# Placeholder: will be implemented with actual controller call
def test_validate_draft_missing_order_id(self):
"""Test validation fails when order_id missing."""
_ = {
"items": [{"product_id": "1", "quantity": 1.0}],
}
# Validation should raise ValueError: "order_id is required"
# Placeholder: will be implemented with actual controller call
def test_validate_draft_invalid_order_id(self):
"""Test validation fails for invalid order_id."""
_ = {
"order_id": "invalid",
"items": [{"product_id": "1", "quantity": 1.0}],
}
# Validation should raise ValueError with "Invalid order_id format"
# Placeholder: will be implemented with actual controller call
def test_validate_draft_nonexistent_order(self):
"""Test validation fails when order doesn't exist."""
_ = {
"order_id": "99999",
"items": [{"product_id": "1", "quantity": 1.0}],
}
# Validation should raise ValueError with "not found"
# Placeholder: will be implemented with actual controller call
def test_validate_draft_no_items(self):
"""Test validation fails when no items."""
_ = {
"order_id": str(self.group_order.id),
"items": [],
}
# Validation should raise ValueError with "No items in cart"
# Placeholder: will be implemented with actual controller call
def test_validate_draft_user_no_partner(self):
"""Test validation fails when user has no partner."""
_ = self.env["res.users"].create(
{
"name": "User No Partner",
"login": "nopartner@test.com",
"email": "nopartner@test.com",
}
)
_ = {
"order_id": str(self.group_order.id),
"items": [{"product_id": "1", "quantity": 1.0}],
}
# Validation should raise ValueError with "no associated partner"
# Placeholder: will be implemented with actual controller call
def test_validate_draft_with_merge_action(self):
"""Test validation passes when merge_action is specified."""
_ = {
"order_id": str(self.group_order.id),
"items": [{"product_id": "1", "quantity": 1.0}],
"merge_action": "merge",
"existing_draft_id": "123",
}
# Validation should pass and return merge_action and existing_draft_id
# Placeholder: will be implemented with actual controller call
def test_validate_draft_with_replace_action(self):
"""Test validation passes when replace_action is specified."""
_ = {
"order_id": str(self.group_order.id),
"items": [{"product_id": "1", "quantity": 1.0}],
"merge_action": "replace",
"existing_draft_id": "123",
}
# Validation should pass and return merge_action and existing_draft_id
# Placeholder: will be implemented with actual controller call

View file

@ -0,0 +1,568 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import patch
from odoo import fields
from odoo.tests.common import HttpCase
from odoo.tests.common import TransactionCase
from odoo.tests.common import tagged
from odoo.addons.website_sale_aplicoop.controllers import (
website_sale_validators as validators,
)
@tagged("post_install", "-at_install", "eskaera_online_payment")
class TestOnlinePayment(TransactionCase):
"""Online payment for group orders: policy, guards and batching."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.consumer_group = cls.env["res.partner"].create(
{
"name": "Payment Consumer Group",
"is_company": True,
"is_group": True,
}
)
cls.member = cls.env["res.partner"].create(
{
"name": "Paying Member",
"email": "paying.member@test.com",
"parent_id": cls.consumer_group.id,
}
)
cls.other_member = cls.env["res.partner"].create(
{
"name": "Other Member",
"email": "other.member@test.com",
"parent_id": cls.consumer_group.id,
}
)
cls.product = cls.env["product.product"].create(
{
"name": "Payable Product",
"is_storable": True,
"list_price": 10.0,
}
)
# The validator helpers only ever reach for `request.env`, so a
# namespace stands in for the HTTP request outside a web context.
cls.fake_request = SimpleNamespace(env=cls.env)
# === Helpers ===
def _create_group_order(self, online_payment=True, cutoff_in_past=False):
"""One-time group order whose cycle ends in the past or the future.
One-time orders derive `cutoff_date` from `end_date`, so the cycle is
steered here through `end_date`.
"""
today = fields.Date.today()
end_date = (
today - timedelta(days=1) if cutoff_in_past else today + timedelta(days=2)
)
return self.env["group.order"].create(
{
"name": "Payment Group Order",
"group_ids": [(6, 0, [self.consumer_group.id])],
"period": "once",
"pickup_day": "2", # Wednesday
"state": "open",
"end_date": end_date,
"online_payment": online_payment,
}
)
def _create_sale_order(self, group_order, partner=None, pickup_date=None):
return self.env["sale.order"].create(
{
"partner_id": (partner or self.member).id,
"group_order_id": group_order.id,
"consumer_group_id": self.consumer_group.id,
"pickup_date": pickup_date or group_order.pickup_date,
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom_qty": 1,
"price_unit": 10.0,
},
)
],
}
)
def _create_done_transaction(self, sale_order):
"""A `done` transaction covering the order's full amount.
`payment.method` records ship archived until a provider module is
installed, so the lookup has to ignore the active flag: the test only
needs a well-formed transaction, not a usable payment route.
"""
provider = self.env["payment.provider"].search([], limit=1)
method = (
self.env["payment.method"]
.with_context(active_test=False)
.search([("primary_payment_method_id", "=", False)], limit=1)
)
transaction = self.env["payment.transaction"].create(
{
"provider_id": provider.id,
"payment_method_id": method.id,
"reference": f"TEST-{sale_order.id}",
"amount": sale_order.amount_total,
"currency_id": sale_order.currency_id.id,
"partner_id": sale_order.partner_id.id,
"sale_order_ids": [(6, 0, sale_order.ids)],
}
)
transaction.write({"state": "done"})
return transaction
# === Payment policy on the sale order ===
def test_require_payment_follows_group_order(self):
"""A group order with online payment makes its orders payable."""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
self.assertTrue(sale_order.require_payment)
self.assertEqual(sale_order.prepayment_percent, 1.0)
self.assertTrue(
sale_order._has_to_be_paid(),
"A draft order of a paying cycle must be payable",
)
def test_require_payment_off_without_online_payment(self):
"""Without the flag nothing changes: no payment is required."""
group_order = self._create_group_order(online_payment=False)
sale_order = self._create_sale_order(group_order)
self.assertFalse(sale_order.require_payment)
self.assertFalse(sale_order._has_to_be_paid())
def test_toggling_group_order_clears_require_payment(self):
"""Turning the flag off mid-cycle must free the existing drafts."""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
self.assertTrue(sale_order.require_payment)
group_order.online_payment = False
sale_order.invalidate_recordset()
self.assertFalse(
sale_order.require_payment,
"An existing draft must stop requiring payment when the group "
"order stops offering it",
)
def test_non_group_orders_keep_company_default(self):
"""Orders outside a group order are left alone."""
plain_order = self.env["sale.order"].create({"partner_id": self.member.id})
self.assertEqual(
plain_order.require_payment,
plain_order.company_id.portal_confirmation_pay,
)
# === Confirmation through payment ===
def test_payment_confirms_with_from_orderpoint(self):
"""Paying must confirm the way the cutoff cron does.
Without `from_orderpoint`, a product with a broken replenishment route
raises during post-processing, `/payment/status/poll` rolls the whole
thing back and the member sees an error over a `done` transaction.
"""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
transaction = self._create_done_transaction(sale_order)
captured = {}
def _fake_confirm(order_self):
captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint")
return True
with patch.object(
type(self.env["sale.order"]), "action_confirm", _fake_confirm
):
transaction._check_amount_and_confirm_order()
self.assertTrue(
captured.get("from_orderpoint"),
"Group order confirmations triggered by payment must carry "
"from_orderpoint=True",
)
def test_payment_confirms_the_order(self):
"""The standard machinery confirms the order once paid."""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
transaction = self._create_done_transaction(sale_order)
transaction._check_amount_and_confirm_order()
sale_order.invalidate_recordset()
self.assertEqual(sale_order.state, "sale")
def test_plain_orders_confirm_without_from_orderpoint(self):
"""Orders unrelated to a group order keep the core behaviour."""
plain_order = self.env["sale.order"].create(
{
"partner_id": self.member.id,
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom_qty": 1,
"price_unit": 10.0,
},
)
],
}
)
transaction = self._create_done_transaction(plain_order)
captured = {}
def _fake_confirm(order_self):
captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint")
return True
with patch.object(
type(self.env["sale.order"]), "action_confirm", _fake_confirm
):
transaction._check_amount_and_confirm_order()
self.assertFalse(captured.get("from_orderpoint"))
# === Cycle lookup helpers ===
def test_draft_lookup_ignores_placed_orders(self):
"""`_find_recent_draft_order` must never return a placed order.
`/eskaera/clear-cart` cancels whatever this returns, so widening it
would cancel orders that are already paid for.
"""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
sale_order.action_confirm()
found = validators._find_recent_draft_order(
None, self.member.id, group_order, request_obj=self.fake_request
)
self.assertFalse(found)
def test_placed_lookup_finds_confirmed_order(self):
"""The duplicate guard sees the member's confirmed order."""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
sale_order.action_confirm()
found = validators._find_placed_cycle_order(
None, self.member.id, group_order, request_obj=self.fake_request
)
self.assertEqual(found, sale_order)
def test_placed_lookup_survives_orders_created_after_cutoff(self):
"""Nothing blocks ordering between the cutoff and the cron run.
The draft window caps `create_date` at the cutoff date; the placed
lookup must not, or an order paid in that gap would slip past the
guard and let the member order twice.
"""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
sale_order = self._create_sale_order(group_order)
sale_order.action_confirm()
found = validators._find_placed_cycle_order(
None, self.member.id, group_order, request_obj=self.fake_request
)
self.assertEqual(found, sale_order)
def test_placed_lookup_ignores_other_cycles(self):
"""An order frozen on another pickup date belongs to another cycle."""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(
group_order, pickup_date=group_order.pickup_date - timedelta(days=7)
)
sale_order.action_confirm()
found = validators._find_placed_cycle_order(
None, self.member.id, group_order, request_obj=self.fake_request
)
self.assertFalse(found)
# === Batching at cutoff ===
def test_cron_batches_a_fully_prepaid_cycle(self):
"""A cycle where everybody paid early still gets its batch.
The confirmation loop used to bail out when it found no draft, which
with online payment is the normal case: every order is confirmed the
moment its transaction completes.
"""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
sale_order = self._create_sale_order(group_order)
sale_order.action_confirm()
self.assertFalse(
self.env["sale.order"].search(
[("group_order_id", "=", group_order.id), ("state", "=", "draft")]
),
"This cycle must have no drafts left for the test to mean anything",
)
group_order._confirm_linked_sale_orders()
self.assertTrue(
sale_order.picking_ids.batch_id,
"The picking of an order paid before the cutoff must still be "
"batched by the cron",
)
def test_cron_batches_paid_and_draft_orders_together(self):
"""Paid and cron-confirmed orders share one batch per picking type."""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
paid_order = self._create_sale_order(group_order, partner=self.member)
paid_order.action_confirm()
draft_order = self._create_sale_order(group_order, partner=self.other_member)
group_order._confirm_linked_sale_orders()
draft_order.invalidate_recordset()
self.assertEqual(draft_order.state, "sale")
batches = paid_order.picking_ids.batch_id | draft_order.picking_ids.batch_id
self.assertEqual(
len(batches),
1,
"Both orders belong to the same cycle and picking type, so they "
"must land in a single batch",
)
def test_cron_ignores_paid_orders_of_previous_cycles(self):
"""A previous cycle's order must not be swept into this batch."""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
stale_order = self._create_sale_order(
group_order, pickup_date=group_order.pickup_date - timedelta(days=7)
)
stale_order.action_confirm()
stale_order.picking_ids.batch_id = False
group_order._confirm_linked_sale_orders()
self.assertFalse(
stale_order.picking_ids.batch_id,
"An order frozen on a previous pickup date is not part of this "
"cycle and must be left out of its batch",
)
def test_closed_cycle_still_batches_paid_orders(self):
"""Closing a group order by hand must not strand a paid order."""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
sale_order = self._create_sale_order(group_order)
sale_order.action_confirm()
group_order.action_close()
self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles()
self.assertTrue(
sale_order.picking_ids.batch_id,
"A paid order of a manually closed cycle must still be batched",
)
def test_closed_cycle_leaves_drafts_alone(self):
"""Closing a cycle by hand is how a co-op calls it off."""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
draft_order = self._create_sale_order(group_order)
group_order.action_close()
self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles()
draft_order.invalidate_recordset()
self.assertEqual(
draft_order.state,
"draft",
"The closed-cycle sweep must only batch, never confirm",
)
@tagged("post_install", "-at_install", "eskaera_online_payment")
class TestOnlinePaymentRoutes(HttpCase):
"""The payment step and its landing page, over HTTP."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Payment Routes Group",
"is_company": True,
"is_group": True,
"email": "payment-routes-group@test.com",
}
)
self.member_partner = self.env["res.partner"].create(
{"name": "Payment Routes Member", "email": "payment-routes@test.com"}
)
self.group.member_ids = [(4, self.member_partner.id)]
login = "portal.payment@test.com"
self.portal_user = self.env["res.users"].create(
{
"name": "Portal Payment User",
"login": login,
"password": login,
"partner_id": self.member_partner.id,
"groups_id": [(4, self.env.ref("base.group_portal").id)],
}
)
self.product = self.env["product.product"].create(
{"name": "Route Product", "is_storable": True, "list_price": 10.0}
)
start_date = fields.Date.today()
self.group_order = self.env["group.order"].create(
{
"name": "Payment Routes Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
"online_payment": True,
}
)
self.group_order.action_open()
def _create_draft(self):
return self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"consumer_group_id": self.group.id,
"pickup_date": self.group_order.pickup_date,
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom_qty": 1,
"price_unit": 10.0,
},
)
],
}
)
def _slug_url(self, suffix=""):
return f"/eskaera/{self.group_order.slug}{suffix}"
def test_payment_page_renders(self):
"""The payment step renders for a member with a draft in the cycle."""
self._create_draft()
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/payment"), allow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn(
'data-name="Eskaera Payment"',
response.text,
"The payment step should render its own page, not redirect away",
)
def test_payment_page_needs_a_draft(self):
"""With nothing in the cart there is nothing to pay for."""
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/payment"), allow_redirects=False)
self.assertEqual(response.status_code, 303)
self.assertTrue(response.headers["Location"].endswith("/checkout"))
def test_payment_page_off_without_online_payment(self):
"""The step does not exist for a group order that takes no payments."""
self.group_order.online_payment = False
self._create_draft()
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/payment"), allow_redirects=False)
self.assertEqual(response.status_code, 303)
self.assertTrue(response.headers["Location"].endswith("/checkout"))
def test_checkout_offers_payment(self):
"""The checkout button turns into the 'confirm and pay' variant.
Asserted on `data-tooltip-key` rather than the label: the website runs
in whatever language the visitor picked, and the label is translated.
"""
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn('data-tooltip-key="confirm_and_pay"', response.text)
def test_checkout_keeps_save_draft_without_online_payment(self):
"""With the flag off the checkout is exactly what it was."""
self.group_order.online_payment = False
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn('data-tooltip-key="save_draft"', response.text)
self.assertNotIn('data-tooltip-key="confirm_and_pay"', response.text)
def test_confirmation_page_renders_for_the_owner(self):
"""The landing page reports the order back to the member."""
order = self._create_draft()
order.action_confirm()
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(
self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=True
)
self.assertEqual(response.status_code, 200)
self.assertIn(order.name, response.text)
def test_confirmation_page_rejects_other_partners(self):
"""Nobody gets to read someone else's order through this page."""
other_partner = self.env["res.partner"].create({"name": "Somebody Else"})
order = self._create_draft()
order.partner_id = other_partner
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(
self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=False
)
self.assertEqual(response.status_code, 303)
self.assertTrue(response.headers["Location"].endswith("/eskaera"))
def test_checkout_redirects_once_the_order_is_placed(self):
"""A member who already paid cannot build a second order."""
order = self._create_draft()
order.action_confirm()
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/checkout"), allow_redirects=False)
self.assertEqual(response.status_code, 303)
self.assertIn(f"/payment/confirmation/{order.id}", response.headers["Location"])

View file

@ -1,286 +0,0 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
"""
Test suite for Phase 2 refactoring of eskaera_shop() method.
Tests for refactored eskaera_shop using extracted helpers:
- Usage of _resolve_pricelist() instead of inline 3-tier fallback
- Extracted category filtering logic
- Price calculation with pricelist
- Search and category filter functionality
"""
from datetime import datetime
from datetime import timedelta
from odoo.tests.common import TransactionCase
class TestEskaeraShopobjInit(TransactionCase):
"""Test eskaera_shop() initial validation and setup."""
def setUp(self):
super().setUp()
self.pricelist = self.env["product.pricelist"].create(
{
"name": "Test Pricelist",
"currency_id": self.env.company.currency_id.id,
}
)
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
self.member = self.env["res.partner"].create(
{
"name": "Group Member",
"email": "member@test.com",
}
)
self.group.member_ids = [(4, self.member.id)]
self.user = self.env["res.users"].create(
{
"name": "Test User",
"login": "testuser@test.com",
"email": "testuser@test.com",
"partner_id": self.member.id,
}
)
self.category = self.env["product.category"].create(
{
"name": "Test Category",
}
)
self.product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "product",
"list_price": 100.0,
"categ_id": self.category.id,
}
)
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(4, self.group.id)],
"start_date": datetime.now().date(),
"end_date": datetime.now().date() + timedelta(days=7),
"pickup_day": "3",
"cutoff_day": "0",
"state": "open",
"category_ids": [(4, self.category.id)],
}
)
def test_eskaera_shop_order_not_found(self):
"""Test that eskaera_shop redirects when order doesn't exist."""
# Nonexistent order_id should redirect to /eskaera
# Placeholder: will be tested via HttpCase with request.Client
def test_eskaera_shop_order_not_open(self):
"""Test that eskaera_shop redirects when order is not open."""
self.group_order.state = "confirmed"
# Should redirect to /eskaera
# Placeholder: will be tested via HttpCase with request.Client
def test_eskaera_shop_uses_resolve_pricelist(self):
"""Test that eskaera_shop uses _resolve_pricelist() helper."""
# Configure Aplicoop pricelist
self.env["ir.config_parameter"].sudo().set_param(
"website_sale_aplicoop.pricelist_id", str(self.pricelist.id)
)
# When eskaera_shop is called, should use _resolve_pricelist()
# Placeholder: will verify via mock or direct method call
class TestEskaeraShopcategoryHierarchy(TransactionCase):
"""Test eskaera_shop category hierarchy building."""
def setUp(self):
super().setUp()
self.parent_category = self.env["product.category"].create(
{
"name": "Parent Category",
}
)
self.child_category = self.env["product.category"].create(
{
"name": "Child Category",
"parent_id": self.parent_category.id,
}
)
self.product1 = self.env["product.product"].create(
{
"name": "Product in Parent",
"type": "product",
"list_price": 100.0,
"categ_id": self.parent_category.id,
}
)
self.product2 = self.env["product.product"].create(
{
"name": "Product in Child",
"type": "product",
"list_price": 200.0,
"categ_id": self.child_category.id,
}
)
def test_category_hierarchy_includes_parents(self):
"""Test that available_categories includes parent categories."""
# When products have categories, category hierarchy should include parents
# Placeholder: verify category tree structure
def test_category_filter_includes_descendants(self):
"""Test that category filter includes child categories."""
# When filtering by parent category, should include products from children
# Placeholder: verify filtered products
class TestEskaeraShopriceCalculation(TransactionCase):
"""Test eskaera_shop price calculation with pricelist."""
def setUp(self):
super().setUp()
self.pricelist = self.env["product.pricelist"].create(
{
"name": "Test Pricelist",
"currency_id": self.env.company.currency_id.id,
}
)
self.category = self.env["product.category"].create(
{
"name": "Test Category",
}
)
self.product_no_tax = self.env["product.product"].create(
{
"name": "Product No Tax",
"type": "product",
"list_price": 100.0,
"categ_id": self.category.id,
"taxes_id": False,
}
)
# Create tax
self.tax = self.env["account.tax"].create(
{
"name": "Test Tax",
"type_tax_use": "sale",
"amount": 21.0,
"amount_type": "percent",
}
)
self.product_with_tax = self.env["product.product"].create(
{
"name": "Product With Tax",
"type": "product",
"list_price": 100.0,
"categ_id": self.category.id,
"taxes_id": [(4, self.tax.id)],
}
)
def test_price_calculation_uses_pricelist(self):
"""Test that product prices are calculated using configured pricelist."""
# Configure Aplicoop pricelist
self.env["ir.config_parameter"].sudo().set_param(
"website_sale_aplicoop.pricelist_id", str(self.pricelist.id)
)
# When eskaera_shop renders, should calculate prices via pricelist
# Placeholder: verify price_info dict populated
def test_price_info_structure(self):
"""Test that product_price_info has correct structure."""
# product_price_info should have: price, list_price, has_discounted_price, discount, tax_included
# Placeholder: verify dict structure
class TestEskaeraShoosearch(TransactionCase):
"""Test eskaera_shop search functionality."""
def setUp(self):
super().setUp()
self.category = self.env["product.category"].create(
{
"name": "Test Category",
}
)
self.product1 = self.env["product.product"].create(
{
"name": "Apple Juice",
"type": "product",
"list_price": 10.0,
"categ_id": self.category.id,
}
)
self.product2 = self.env["product.product"].create(
{
"name": "Orange Juice",
"type": "product",
"list_price": 12.0,
"categ_id": self.category.id,
"description": "Fresh orange juice from Spain",
}
)
self.product3 = self.env["product.product"].create(
{
"name": "Water",
"type": "product",
"list_price": 2.0,
"categ_id": self.category.id,
}
)
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"start_date": datetime.now().date(),
"end_date": datetime.now().date() + timedelta(days=7),
"pickup_day": "3",
"cutoff_day": "0",
"state": "open",
"category_ids": [(4, self.category.id)],
}
)
def test_search_filters_by_name(self):
"""Test that search query filters products by name."""
# When search='apple', should return only Apple Juice
# Placeholder: verify filtered products
def test_search_filters_by_description(self):
"""Test that search query filters products by description."""
# When search='spain', should return Orange Juice (matches description)
# Placeholder: verify filtered products
def test_search_case_insensitive(self):
"""Test that search is case insensitive."""
# search='APPLE' should match 'Apple Juice'
# Placeholder: verify filtered products
def test_search_empty_returns_all(self):
"""Test that empty search returns all products."""
# When search='', should return all products
# Placeholder: verify all products returned

View file

@ -58,11 +58,19 @@ def _build_request_mock(env, payload=None, website=None):
call pricing helpers).
"""
if website is None:
# `pricelist_id` is what the pricing helpers read now that Eskaera
# prices with the standard website pricelist. Ordered by id on
# purpose: the multilang cases build an env whose language is not
# installed, and the default order is by the translated `name`.
website = SimpleNamespace(
_get_current_pricelist=lambda: False,
pricelist_id=env["product.pricelist"]
.sudo()
.search([], limit=1, order="id"),
show_line_subtotals_tax_selection="tax_excluded",
fiscal_position_id=False,
company_id=False,
# The Eskaera routes check that this website serves Eskaera.
eskaera_enabled=True,
)
request_mock = SimpleNamespace(
env=env,

View file

@ -1,83 +0,0 @@
# Copyright 2026
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from datetime import datetime
from datetime import timedelta
from odoo.tests import tagged
from odoo.tests.common import HttpCase
@tagged("post_install", "-at_install")
class TestPortalAccess(HttpCase):
"""Verifica que un usuario portal pueda acceder a la página de un pedido (eskaera)."""
def setUp(self):
super().setUp()
# Create a consumer group and a member partner
self.group = self.env["res.partner"].create(
{
"name": "Portal Test Group",
"is_company": True,
"email": "portal-group@test.com",
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Portal Member",
"email": "portal-member@test.com",
}
)
# Add member to the group
self.group.member_ids = [(4, self.member_partner.id)]
# Create a portal user (password = login for HttpCase.authenticate convenience)
login = "portal.user@test.com"
self.portal_user = self.env["res.users"].create(
{
"name": "Portal User",
"login": login,
"password": login,
"partner_id": self.member_partner.id,
# Add portal group
"groups_id": [(4, self.env.ref("base.group_portal").id)],
}
)
# Create and open a group.order belonging to the same company
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Portal Access Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.group_order.action_open()
def test_portal_user_can_view_eskaera_page(self):
"""El endpoint /eskaera/<id> debe ser accesible por un usuario portal que pertenezca a la compañía."""
# Authenticate as portal user
self.authenticate(self.portal_user.login, self.portal_user.login)
# Request the eskaera page
response = self.url_open(
f"/eskaera/{self.group_order.id}", allow_redirects=True
)
# Should return 200 OK and not redirect to login
self.assertEqual(response.status_code, 200)
# Simple sanity: page should contain the group order name
content = (
response.get_data(as_text=True)
if hasattr(response, "get_data")
else getattr(response, "text", "")
)
self.assertIn(self.group_order.name, content)

View file

@ -1,85 +0,0 @@
# Copyright 2026
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from datetime import datetime
from datetime import timedelta
from odoo.tests import tagged
from odoo.tests.common import HttpCase
@tagged("post_install", "-at_install")
class TestPortalGetRoutes(HttpCase):
"""Comprueba que las rutas GET principales devuelvan 200 para un usuario portal."""
def setUp(self):
super().setUp()
# Create a consumer group and a member partner
self.group = self.env["res.partner"].create(
{
"name": "Portal Routes Group",
"is_company": True,
"email": "routes-group@test.com",
}
)
self.member_partner = self.env["res.partner"].create(
{"name": "Routes Member", "email": "routes-member@test.com"}
)
self.group.member_ids = [(4, self.member_partner.id)]
# Create a portal user (password = login for HttpCase.authenticate convenience)
login = "portal.routes@test.com"
self.portal_user = self.env["res.users"].create(
{
"name": "Portal Routes User",
"login": login,
"password": login,
"partner_id": self.member_partner.id,
"groups_id": [(4, self.env.ref("base.group_portal").id)],
}
)
# Create and open a minimal group.order
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Routes Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.group_order.action_open()
def test_portal_get_routes_return_200(self):
"""Verifica que las rutas principales GET devuelvan 200 para usuario portal."""
# Authenticate as portal user
self.authenticate(self.portal_user.login, self.portal_user.login)
routes = [
"/eskaera",
f"/eskaera/{self.group_order.id}",
f"/eskaera/{self.group_order.id}/checkout",
f"/eskaera/{self.group_order.id}/load-page?page=1",
"/eskaera/labels",
]
for route in routes:
response = self.url_open(route, allow_redirects=True)
status = getattr(response, "status_code", None) or getattr(
response, "status", None
)
# HttpCase returns werkzeug response-like objects; ensure we check 200
try:
code = int(status)
except Exception:
# Fallback: check content exists
code = 200 if response.get_data(as_text=True) else 500
self.assertEqual(code, 200, msg=f"Ruta {route} devolvió {code}")

View file

@ -1,101 +0,0 @@
# Copyright 2026
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from datetime import datetime
from datetime import timedelta
from odoo.tests import tagged
from odoo.tests.common import HttpCase
@tagged("post_install", "-at_install")
class TestPortalProductUoMAccess(HttpCase):
"""Verifica que un usuario portal pueda acceder a la página de tienda (eskaera)
y que la lectura de UoM para display no provoque AccessError.
"""
def setUp(self):
super().setUp()
# Grupo / partner / usuario portal (reusa patrón del otro test)
self.group = self.env["res.partner"].create(
{"name": "Portal UoM Group", "is_company": True}
)
self.member_partner = self.env["res.partner"].create(
{"name": "Portal UoM Member"}
)
self.group.member_ids = [(4, self.member_partner.id)]
login = "portal.uom@test.com"
self.portal_user = self.env["res.users"].create(
{
"name": "Portal UoM User",
"login": login,
"password": login,
"partner_id": self.member_partner.id,
"groups_id": [(4, self.env.ref("base.group_portal").id)],
}
)
# Crear una categoría de UoM y una UoM personalizada (posible restringida)
uom_cat = self.env["uom.uom.categ"].create({"name": "Test UoM Cat"})
self.uom = self.env["uom.uom"].create(
{
"name": "Test UoM",
"uom_type": "reference",
"factor_inv": 1.0,
"category_id": uom_cat.id,
}
)
# Crear producto y asignar la UoM creada
self.product = self.env["product.product"].create(
{
"name": "Producto UoM Test",
"type": "consu",
"list_price": 12.5,
"uom_id": self.uom.id,
"active": True,
}
)
# Publicar el template para que aparezca en la tienda
self.product.product_tmpl_id.write({"is_published": True, "sale_ok": True})
# Crear order y añadir producto
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Portal UoM Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
"product_ids": [(6, 0, [self.product.id])],
}
)
self.group_order.action_open()
def test_portal_user_can_view_shop_with_uom(self):
# Authenticate as portal user
self.authenticate(self.portal_user.login, self.portal_user.login)
# Request the eskaera page which renders product cards (and reads uom)
response = self.url_open(
f"/eskaera/{self.group_order.id}", allow_redirects=True
)
# Debe retornar 200 OK
self.assertEqual(response.status_code, 200)
content = (
response.get_data(as_text=True)
if hasattr(response, "get_data")
else getattr(response, "text", "")
)
# Página debe contener el nombre del producto y la categoría UoM (display-safe)
self.assertIn(self.product.name, content)
self.assertIn("Test UoM Cat", content)

View file

@ -0,0 +1,207 @@
# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
"""Smoke tests for the Eskaera pages as seen by a plain portal user.
Covers that the main pages answer 200 and that reading a product's UoM for
display does not raise an AccessError for a portal user.
`/eskaera/labels` and `/eskaera/i18n` are `type="json"` routes: a bare GET is
answered with 400 by design, so they are exercised through a JSON-RPC call.
"""
from datetime import datetime
from datetime import timedelta
from odoo.tests import tagged
from odoo.tests.common import HttpCase
class PortalRoutesCommon:
"""Build a portal user that belongs to an open group order."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Portal Routes Group",
"is_company": True,
"is_group": True,
"email": "routes-group@test.com",
}
)
# The shop guard reads `partner_id.group_ids`, so the membership has
# to be set from the member side to be visible right away.
self.member_partner = self.env["res.partner"].create(
{
"name": "Routes Member",
"email": "routes-member@test.com",
"group_ids": [(6, 0, [self.group.id])],
}
)
# HttpCase.authenticate() wants the password, so reuse the login.
self.portal_login = "portal.routes@test.com"
self.portal_user = self.env["res.users"].create(
{
"name": "Portal Routes User",
"login": self.portal_login,
"password": self.portal_login,
"partner_id": self.member_partner.id,
"groups_id": [(4, self.env.ref("base.group_portal").id)],
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Routes Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.group_order.action_open()
def _login_portal(self):
self.authenticate(self.portal_login, self.portal_login)
@tagged("post_install", "-at_install")
class TestPortalGetRoutes(PortalRoutesCommon, HttpCase):
"""The main GET pages answer 200 for a portal user."""
def test_portal_get_routes_return_200(self):
"""Every public Eskaera page renders for a portal user."""
self._login_portal()
routes = [
"/eskaera",
f"/eskaera/{self.group_order.id}",
f"/eskaera/{self.group_order.id}/checkout",
f"/eskaera/{self.group_order.id}/load-page?page=1",
]
for route in routes:
response = self.url_open(route, allow_redirects=True)
self.assertEqual(
response.status_code, 200, msg=f"Route {route} returned an error"
)
def test_shop_page_is_not_bounced_to_the_list(self):
"""A member reaches the shop itself, not the "/eskaera" fallback.
The access guard redirects non-members to the list page, which also
answers 200 -- so a plain status check would pass even when the member
never got in.
"""
self._login_portal()
response = self.url_open(
f"/eskaera/{self.group_order.id}", allow_redirects=True
)
self.assertEqual(response.status_code, 200)
self.assertTrue(
response.url.endswith(f"/eskaera/{self.group_order.slug}"),
msg=f"Bounced to {response.url} instead of the shop page",
)
def test_slug_urls_answer_for_portal_user(self):
"""The canonical slug URLs answer too, not just the numeric ones."""
self._login_portal()
for suffix in ("", "/checkout"):
route = f"/eskaera/{self.group_order.slug}{suffix}"
response = self.url_open(route, allow_redirects=True)
self.assertEqual(
response.status_code, 200, msg=f"Route {route} returned an error"
)
@tagged("post_install", "-at_install")
class TestPortalLabelsEndpoint(PortalRoutesCommon, HttpCase):
"""`/eskaera/labels` is a JSON-RPC endpoint, not a plain GET page."""
def test_labels_endpoint_returns_translations(self):
"""A JSON-RPC call returns the label dictionary."""
self._login_portal()
labels = self.make_jsonrpc_request("/eskaera/labels")
self.assertIsInstance(labels, dict)
# A few keys the checkout summary relies on.
for key in ("product", "quantity", "price", "subtotal", "total"):
self.assertIn(key, labels)
def test_i18n_alias_returns_the_same_payload(self):
"""`/eskaera/i18n` is an alias of `/eskaera/labels`."""
self._login_portal()
labels = self.make_jsonrpc_request("/eskaera/labels")
alias = self.make_jsonrpc_request("/eskaera/i18n")
self.assertEqual(labels, alias)
def test_labels_endpoint_is_public(self):
"""The endpoint answers without logging in (auth="public")."""
labels = self.make_jsonrpc_request("/eskaera/labels")
self.assertIsInstance(labels, dict)
self.assertTrue(labels)
def test_plain_get_is_rejected(self):
"""A bare GET is not a valid call for a JSON route.
Guards the mistake this test file used to make: asserting 200 on a
plain GET against `type="json"`, which Odoo answers with 400.
"""
self._login_portal()
response = self.url_open("/eskaera/labels", allow_redirects=True)
self.assertEqual(response.status_code, 400)
@tagged("post_install", "-at_install")
class TestPortalProductUoMAccess(PortalRoutesCommon, HttpCase):
"""Rendering the shop must not need UoM read rights beyond the portal's."""
def setUp(self):
super().setUp()
uom_category = self.env["uom.category"].create({"name": "Test UoM Cat"})
self.uom = self.env["uom.uom"].create(
{
"name": "Test UoM",
"uom_type": "reference",
"factor": 1.0,
"category_id": uom_category.id,
}
)
self.product = self.env["product.product"].create(
{
"name": "Portal UoM Product",
"type": "consu",
"list_price": 10.0,
"is_published": True,
"sale_ok": True,
"uom_id": self.uom.id,
"uom_po_id": self.uom.id,
}
)
self.group_order.product_ids = [(4, self.product.id)]
def test_portal_user_can_view_shop_with_uom(self):
"""The shop page renders for a portal user with a custom UoM."""
self._login_portal()
response = self.url_open(
f"/eskaera/{self.group_order.id}", allow_redirects=True
)
self.assertEqual(response.status_code, 200)
self.assertIn("Portal UoM Product", response.text)

View file

@ -1,11 +1,15 @@
# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
"""
Test suite for price calculations WITH taxes included.
"""Price calculations around included/excluded taxes.
This test verifies that the _compute_price_with_taxes method correctly
calculates prices including taxes for display in the online shop.
Checks how `account.tax.compute_all` and the OCA helper
`product.product._get_price` (from `product_get_price_helper`) behave for the
shop, including the tax breakdown and fiscal-position mapping.
Odoo 18 note: `account.tax.price_include` is a *computed* boolean derived from
`price_include_override` (and the company default). Tax fixtures must set
`price_include_override` writing `price_include` directly is discarded.
"""
from odoo.tests import tagged
@ -19,14 +23,12 @@ class TestPriceWithTaxesIncluded(TransactionCase):
def setUp(self):
super().setUp()
# Create test company
self.company = self.env["res.company"].create(
{
"name": "Test Company Tax Included",
}
)
# Get or create default tax group
tax_group = self.env["account.tax.group"].search(
[("company_id", "=", self.company.id)], limit=1
)
@ -38,100 +40,30 @@ class TestPriceWithTaxesIncluded(TransactionCase):
}
)
# Get default country (Spain)
country_es = self.env.ref("base.es")
# Create tax (21% IVA) - price_include=False (default)
self.tax_21 = self.env["account.tax"].create(
{
"name": "IVA 21%",
"amount": 21.0,
"amount_type": "percent",
"type_tax_use": "sale",
"price_include": False, # Explicit: tax NOT included in price
"company_id": self.company.id,
"country_id": country_es.id,
"tax_group_id": tax_group.id,
}
self.tax_21 = self._create_tax("IVA 21%", 21.0, tax_group, country_es)
self.tax_10 = self._create_tax("IVA 10%", 10.0, tax_group, country_es)
self.tax_21_included = self._create_tax(
"IVA 21% Incluido", 21.0, tax_group, country_es, included=True
)
# Create tax (10% IVA reducido)
self.tax_10 = self.env["account.tax"].create(
{
"name": "IVA 10%",
"amount": 10.0,
"amount_type": "percent",
"type_tax_use": "sale",
"price_include": False,
"company_id": self.company.id,
"country_id": country_es.id,
"tax_group_id": tax_group.id,
}
)
# Create tax with price_include=True for comparison
self.tax_21_included = self.env["account.tax"].create(
{
"name": "IVA 21% Incluido",
"amount": 21.0,
"amount_type": "percent",
"type_tax_use": "sale",
"price_include": True, # Tax IS included in price
"company_id": self.company.id,
"country_id": country_es.id,
"tax_group_id": tax_group.id,
}
)
# Create product category
self.category = self.env["product.category"].create(
{
"name": "Test Category Tax Included",
}
)
# Create test products with different tax configurations
self.product_21 = self.env["product.product"].create(
{
"name": "Product With 21% Tax",
"list_price": 100.0,
"categ_id": self.category.id,
"taxes_id": [(6, 0, [self.tax_21.id])],
"company_id": self.company.id,
}
self.product_21 = self._create_product("Product With 21% Tax", self.tax_21)
self.product_10 = self._create_product("Product With 10% Tax", self.tax_10)
self.product_no_tax = self._create_product("Product Without Tax", None)
self.product_tax_included = self._create_product(
# 100 + 21% = 121, already carrying the tax.
"Product With Tax Included",
self.tax_21_included,
list_price=121.0,
)
self.product_10 = self.env["product.product"].create(
{
"name": "Product With 10% Tax",
"list_price": 100.0,
"categ_id": self.category.id,
"taxes_id": [(6, 0, [self.tax_10.id])],
"company_id": self.company.id,
}
)
self.product_no_tax = self.env["product.product"].create(
{
"name": "Product Without Tax",
"list_price": 100.0,
"categ_id": self.category.id,
"taxes_id": False,
"company_id": self.company.id,
}
)
self.product_tax_included = self.env["product.product"].create(
{
"name": "Product With Tax Included",
"list_price": 121.0, # 100 + 21% = 121
"categ_id": self.category.id,
"taxes_id": [(6, 0, [self.tax_21_included.id])],
"company_id": self.company.id,
}
)
# Create pricelist
self.pricelist = self.env["product.pricelist"].create(
{
"name": "Test Pricelist",
@ -139,125 +71,129 @@ class TestPriceWithTaxesIncluded(TransactionCase):
}
)
def test_price_with_21_percent_tax(self):
"""Test that 21% tax is correctly added to base price."""
# Base price: 100.0
# Expected with 21% tax: 121.0
taxes = self.product_21.taxes_id.filtered(
lambda t: t.company_id == self.company
def _create_tax(self, name, amount, tax_group, country, included=False):
"""Create a sale tax, choosing included/excluded via the override."""
return self.env["account.tax"].create(
{
"name": name,
"amount": amount,
"amount_type": "percent",
"type_tax_use": "sale",
"price_include_override": (
"tax_included" if included else "tax_excluded"
),
"company_id": self.company.id,
"country_id": country.id,
"tax_group_id": tax_group.id,
}
)
base_price = 100.0
tax_result = taxes.compute_all(
def _create_product(self, name, tax, list_price=100.0):
return self.env["product.product"].create(
{
"name": name,
"list_price": list_price,
"categ_id": self.category.id,
"taxes_id": [(6, 0, [tax.id])] if tax else False,
"company_id": self.company.id,
}
)
def _company_taxes(self, product):
return product.taxes_id.filtered(lambda t: t.company_id == self.company)
def _get_price(self, product, fposition=False):
"""Call the OCA helper for the fixture company.
`_get_price` defaults `company` to `self.env.company` and filters the
product taxes by it, so omitting it here would silently drop every tax
of the test company and make `tax_included` always False.
"""
return product._get_price(
qty=1.0,
pricelist=self.pricelist,
fposition=fposition,
company=self.company,
)
def _compute_all(self, product, base_price=100.0, taxes=None):
taxes = self._company_taxes(product) if taxes is None else taxes
return taxes.compute_all(
base_price,
currency=self.env.company.currency_id,
quantity=1.0,
product=self.product_21,
product=product,
)
price_with_tax = tax_result["total_included"]
def test_price_include_is_driven_by_the_override(self):
"""Guard: the fixtures really are included/excluded as intended.
`price_include` is computed in Odoo 18, so a fixture that sets the
wrong field silently produces excluded taxes everywhere.
"""
self.assertFalse(self.tax_21.price_include)
self.assertFalse(self.tax_10.price_include)
self.assertTrue(self.tax_21_included.price_include)
def test_price_with_21_percent_tax(self):
"""Test that 21% tax is correctly added to base price."""
result = self._compute_all(self.product_21)
self.assertAlmostEqual(
price_with_tax, 121.0, places=2, msg="100 + 21% should equal 121.0"
result["total_included"], 121.0, places=2, msg="100 + 21% should be 121.0"
)
def test_price_with_10_percent_tax(self):
"""Test that 10% tax is correctly added to base price."""
# Base price: 100.0
# Expected with 10% tax: 110.0
taxes = self.product_10.taxes_id.filtered(
lambda t: t.company_id == self.company
)
base_price = 100.0
tax_result = taxes.compute_all(
base_price,
currency=self.env.company.currency_id,
quantity=1.0,
product=self.product_10,
)
price_with_tax = tax_result["total_included"]
result = self._compute_all(self.product_10)
self.assertAlmostEqual(
price_with_tax, 110.0, places=2, msg="100 + 10% should equal 110.0"
result["total_included"], 110.0, places=2, msg="100 + 10% should be 110.0"
)
def test_price_without_tax(self):
"""Test that product without tax returns base price unchanged."""
# Base price: 100.0
# Expected with no tax: 100.0
taxes = self.product_no_tax.taxes_id.filtered(
lambda t: t.company_id == self.company
"""A product with no taxes keeps its base price."""
self.assertFalse(
self._company_taxes(self.product_no_tax), "Product should have no taxes"
)
# No taxes, so tax_result would be empty
self.assertFalse(taxes, "Product should have no taxes")
price_info = self._get_price(self.product_no_tax)
# Without taxes, price should remain base price
base_price = 100.0
expected_price = 100.0
self.assertEqual(
base_price,
expected_price,
msg="Product without tax should have unchanged price",
)
self.assertAlmostEqual(price_info["value"], 100.0, places=2)
self.assertFalse(price_info["tax_included"])
def test_oca_get_price_returns_base_without_tax(self):
"""Test that OCA _get_price returns base price WITHOUT taxes by default."""
# This verifies our understanding of OCA behavior
"""OCA `_get_price` returns the base price for tax-excluded products."""
price_info = self._get_price(self.product_21)
price_info = self.product_21._get_price(
qty=1.0,
pricelist=self.pricelist,
fposition=False,
)
# OCA should return base price (100.0) WITHOUT tax
self.assertAlmostEqual(
price_info["value"],
100.0,
places=2,
msg="OCA _get_price should return base price without tax",
)
# tax_included should be False for price_include=False taxes
self.assertFalse(
price_info.get("tax_included", False),
msg="tax_included should be False when price_include=False",
price_info["tax_included"],
msg="tax_included should be False for a tax-excluded tax",
)
def test_oca_get_price_with_included_tax(self):
"""Test OCA behavior with price_include=True tax."""
"""OCA `_get_price` flags tax_included for a tax-included product."""
price_info = self._get_price(self.product_tax_included)
price_info = self.product_tax_included._get_price(
qty=1.0,
pricelist=self.pricelist,
fposition=False,
)
# With price_include=True, the price should already include tax
# list_price is 121.0 (100 + 21%)
self.assertAlmostEqual(
price_info["value"],
121.0,
places=2,
msg="Price with included tax should be 121.0",
msg="Price with included tax should stay at 121.0",
)
# tax_included should be True
self.assertTrue(
price_info.get("tax_included", False),
msg="tax_included should be True when price_include=True",
price_info["tax_included"],
msg="tax_included should be True for a tax-included tax",
)
def test_compute_all_with_multiple_taxes(self):
"""Test tax calculation with multiple taxes."""
# Create product with both 21% and 10% taxes
product_multi = self.env["product.product"].create(
{
"name": "Product With Multiple Taxes",
@ -268,26 +204,15 @@ class TestPriceWithTaxesIncluded(TransactionCase):
}
)
taxes = product_multi.taxes_id.filtered(lambda t: t.company_id == self.company)
result = self._compute_all(product_multi)
base_price = 100.0
tax_result = taxes.compute_all(
base_price,
currency=self.env.company.currency_id,
quantity=1.0,
product=product_multi,
)
price_with_taxes = tax_result["total_included"]
# 100 + 21% + 10% = 100 + 21 + 10 = 131.0
# 100 + 21 + 10 = 131.0
self.assertAlmostEqual(
price_with_taxes, 131.0, places=2, msg="100 + 21% + 10% should equal 131.0"
result["total_included"], 131.0, places=2, msg="100 + 21% + 10% = 131.0"
)
def test_compute_all_with_fiscal_position(self):
"""Test tax calculation with fiscal position mapping."""
# Create fiscal position that maps 21% to 10%
"""A fiscal position remapping 21% to 10% changes the total."""
fiscal_position = self.env["account.fiscal.position"].create(
{
"name": "Test Fiscal Position",
@ -302,88 +227,72 @@ class TestPriceWithTaxesIncluded(TransactionCase):
}
)
# Get taxes and apply fiscal position
taxes = self.product_21.taxes_id.filtered(
lambda t: t.company_id == self.company
)
mapped_taxes = fiscal_position.map_tax(taxes)
mapped_taxes = fiscal_position.map_tax(self._company_taxes(self.product_21))
# Should be mapped to 10% tax
self.assertEqual(len(mapped_taxes), 1)
self.assertEqual(mapped_taxes[0].id, self.tax_10.id)
self.assertEqual(mapped_taxes, self.tax_10)
base_price = 100.0
tax_result = mapped_taxes.compute_all(
base_price,
currency=self.env.company.currency_id,
quantity=1.0,
product=self.product_21,
)
result = self._compute_all(self.product_21, taxes=mapped_taxes)
price_with_tax = tax_result["total_included"]
# Should be 110.0 (10% instead of 21%)
self.assertAlmostEqual(
price_with_tax, 110.0, places=2, msg="Fiscal position should map to 10% tax"
result["total_included"],
110.0,
places=2,
msg="Fiscal position should map to the 10% tax",
)
def test_tax_amount_details(self):
"""Test that compute_all provides detailed tax breakdown."""
taxes = self.product_21.taxes_id.filtered(
lambda t: t.company_id == self.company
)
base_price = 100.0
tax_result = taxes.compute_all(
base_price,
currency=self.env.company.currency_id,
quantity=1.0,
product=self.product_21,
)
# Verify structure of tax_result
self.assertIn("total_included", tax_result)
self.assertIn("total_excluded", tax_result)
self.assertIn("taxes", tax_result)
# total_excluded should be base price
self.assertAlmostEqual(tax_result["total_excluded"], 100.0, places=2)
# total_included should be base + tax
self.assertAlmostEqual(tax_result["total_included"], 121.0, places=2)
# taxes should contain tax details
self.assertEqual(len(tax_result["taxes"]), 1)
tax_detail = tax_result["taxes"][0]
self.assertAlmostEqual(tax_detail["amount"], 21.0, places=2)
def test_zero_price_with_tax(self):
"""Test tax calculation on free product."""
free_product = self.env["product.product"].create(
def test_get_price_applies_fiscal_position(self):
"""`_get_price` honours the fiscal position it is handed."""
fiscal_position = self.env["account.fiscal.position"].create(
{
"name": "Free Product With Tax",
"list_price": 0.0,
"categ_id": self.category.id,
"taxes_id": [(6, 0, [self.tax_21.id])],
"name": "Map To Included Tax",
"company_id": self.company.id,
}
)
taxes = free_product.taxes_id.filtered(lambda t: t.company_id == self.company)
base_price = 0.0
tax_result = taxes.compute_all(
base_price,
currency=self.env.company.currency_id,
quantity=1.0,
product=free_product,
self.env["account.fiscal.position.tax"].create(
{
"position_id": fiscal_position.id,
"tax_src_id": self.tax_21.id,
"tax_dest_id": self.tax_21_included.id,
}
)
price_with_tax = tax_result["total_included"]
price_info = self._get_price(self.product_21, fposition=fiscal_position)
# The mapped tax is tax-included, so the helper must say so.
self.assertTrue(price_info["tax_included"])
def test_tax_amount_details(self):
"""Test that compute_all provides a detailed tax breakdown."""
result = self._compute_all(self.product_21)
self.assertIn("total_included", result)
self.assertIn("total_excluded", result)
self.assertIn("taxes", result)
self.assertAlmostEqual(result["total_excluded"], 100.0, places=2)
self.assertAlmostEqual(result["total_included"], 121.0, places=2)
self.assertEqual(len(result["taxes"]), 1)
self.assertAlmostEqual(result["taxes"][0]["amount"], 21.0, places=2)
def test_included_tax_breakdown_keeps_the_gross_price(self):
"""For an included tax, the gross price is the one the member sees."""
result = self._compute_all(self.product_tax_included, base_price=121.0)
self.assertAlmostEqual(result["total_included"], 121.0, places=2)
self.assertAlmostEqual(result["total_excluded"], 100.0, places=2)
self.assertAlmostEqual(result["taxes"][0]["amount"], 21.0, places=2)
def test_zero_price_with_tax(self):
"""Test tax calculation on a free product."""
free_product = self._create_product(
"Free Product With Tax", self.tax_21, list_price=0.0
)
result = self._compute_all(free_product, base_price=0.0)
# 0 + 21% = 0
self.assertAlmostEqual(
price_with_tax,
result["total_included"],
0.0,
places=2,
msg="Free product with tax should still be free",
@ -391,35 +300,11 @@ class TestPriceWithTaxesIncluded(TransactionCase):
def test_high_precision_price_with_tax(self):
"""Test tax calculation with high precision prices."""
precise_product = self.env["product.product"].create(
{
"name": "Precise Price Product",
"list_price": 99.99,
"categ_id": self.category.id,
"taxes_id": [(6, 0, [self.tax_21.id])],
"company_id": self.company.id,
}
precise_product = self._create_product(
"Precise Price Product", self.tax_21, list_price=99.99
)
taxes = precise_product.taxes_id.filtered(
lambda t: t.company_id == self.company
)
result = self._compute_all(precise_product, base_price=99.99)
base_price = 99.99
tax_result = taxes.compute_all(
base_price,
currency=self.env.company.currency_id,
quantity=1.0,
product=precise_product,
)
price_with_tax = tax_result["total_included"]
# 99.99 + 21% = 120.9879 ≈ 120.99
expected = 99.99 * 1.21
self.assertAlmostEqual(
price_with_tax,
expected,
places=2,
msg=f"Expected {expected}, got {price_with_tax}",
)
# 99.99 + 21% = 120.9879 -> 120.99
self.assertAlmostEqual(result["total_included"], 99.99 * 1.21, places=2)

View file

@ -0,0 +1,280 @@
# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
"""Pricing that is delegated to website_sale instead of reimplemented.
Three things this pins down:
- Eskaera prices with the standard website pricelist. The addon no longer
carries a pricelist setting of its own, nor overrides how the website
resolves one, so `/shop` and Eskaera cannot quote different prices.
- taxes are applied by `product.template._apply_taxes_to_price`, so a fiscal
position that remaps a tax-included tax rebases the price -- the step this
addon used to skip.
- a page of products costs one pricelist resolution, not one per product.
"""
from types import SimpleNamespace
from unittest.mock import patch
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from ..controllers.website_sale import AplicoopWebsiteSale
REQUEST_PATCH_TARGET = (
"odoo.addons.website_sale_aplicoop.controllers.website_sale.request"
)
class PricingDelegationCommon:
"""A company, a website and a tax-included product to price."""
def setUp(self):
super().setUp()
self.controller = AplicoopWebsiteSale()
self.company = self.env["res.company"].create({"name": "Pricing Deleg Co"})
self.website = self.env.ref("website.default_website").sudo()
self.website.write(
{
"company_id": self.company.id,
"show_line_subtotals_tax_selection": "tax_included",
}
)
self.tax_group = self.env["account.tax.group"].create(
{"name": "IVA Deleg", "company_id": self.company.id}
)
self.country_es = self.env.ref("base.es")
def _create_pricelist(self, name, percent=None):
vals = {"name": name, "company_id": self.company.id}
if percent is not None:
vals["item_ids"] = [
(
0,
0,
{
"compute_price": "percentage",
"percent_price": percent,
"applied_on": "3_global",
},
)
]
return self.env["product.pricelist"].create(vals)
def _create_tax(self, name, amount, included=False):
return self.env["account.tax"].create(
{
"name": name,
"amount": amount,
"amount_type": "percent",
"type_tax_use": "sale",
"price_include_override": (
"tax_included" if included else "tax_excluded"
),
"company_id": self.company.id,
"country_id": self.country_es.id,
"tax_group_id": self.tax_group.id,
}
)
def _create_product(self, name, list_price, tax=None):
return self.env["product.product"].create(
{
"name": name,
"type": "consu",
"list_price": list_price,
"is_published": True,
"sale_ok": True,
"company_id": self.company.id,
"taxes_id": [(6, 0, [tax.id])] if tax else False,
}
)
@tagged("post_install", "-at_install")
class TestStandardPricelistResolution(PricingDelegationCommon, TransactionCase):
"""Eskaera prices with the website pricelist, like the rest of the shop."""
def test_no_aplicoop_pricelist_setting_is_left(self):
"""The old per-addon pricelist setting is gone for good.
It used to let Eskaera quote a different pricelist from `/shop`, which
is exactly the drift this addon should not introduce.
"""
self.assertNotIn(
"aplicoop_pricelist_id", self.env["res.config.settings"]._fields
)
def test_website_resolution_is_left_untouched(self):
"""`_get_current_pricelist` is core's; the addon does not override it."""
website_cls = type(self.env["website"])
self.assertNotIn("_get_current_pricelist", website_cls.__dict__)
self.assertEqual(
self.website.pricelist_id, self.website._get_current_pricelist()
)
def test_the_controller_quotes_the_website_pricelist(self):
"""The Eskaera helper hands back exactly what the website resolved."""
expected = self._create_pricelist("Website PL", percent=15.0)
request_mock = SimpleNamespace(
env=self.env,
website=SimpleNamespace(pricelist_id=expected),
)
with patch(REQUEST_PATCH_TARGET, request_mock):
resolved = self.controller._resolve_pricelist()
self.assertEqual(resolved, expected)
@tagged("post_install", "-at_install")
class TestTaxIncludedFiscalPosition(PricingDelegationCommon, TransactionCase):
"""A remapped tax-included tax must rebase the displayed price."""
def setUp(self):
super().setUp()
self.pricelist = self._create_pricelist("Plain PL")
self.tax_21_incl = self._create_tax("IVA 21% incl", 21.0, included=True)
self.tax_10_incl = self._create_tax("IVA 10% incl", 10.0, included=True)
# 100 net + 21% already inside the price.
self.product = self._create_product("Tax Incl Product", 121.0, self.tax_21_incl)
def _fiscal_position(self):
position = self.env["account.fiscal.position"].create(
{"name": "Remap 21->10", "company_id": self.company.id}
)
self.env["account.fiscal.position.tax"].create(
{
"position_id": position.id,
"tax_src_id": self.tax_21_incl.id,
"tax_dest_id": self.tax_10_incl.id,
}
)
return position
def test_price_is_rebased_onto_the_mapped_tax(self):
"""121 gross at 21% becomes 110 gross at 10%, not 121.
Without `_get_tax_included_unit_price_from_price` the 121 was handed
straight to the mapped tax, which read it as already being the 10%
gross price and displayed 121.
"""
self.website.fiscal_position_id = self._fiscal_position()
pricing = self.controller._get_pricing_info(
self.product, self.pricelist, quantity=1.0
)
self.assertAlmostEqual(pricing["price"], 110.0, places=2)
def test_price_is_untouched_without_a_fiscal_position(self):
"""The rebasing is a no-op in the ordinary case."""
self.website.fiscal_position_id = False
pricing = self.controller._get_pricing_info(
self.product, self.pricelist, quantity=1.0
)
self.assertAlmostEqual(pricing["price"], 121.0, places=2)
@tagged("post_install", "-at_install")
class TestPricingBatching(PricingDelegationCommon, TransactionCase):
"""A product listing resolves the pricelist once for the whole page."""
def setUp(self):
super().setUp()
self.pricelist = self._create_pricelist("Batch PL", percent=10.0)
self.tax = self._create_tax("IVA 21%", 21.0)
self.products = self.env["product.product"]
for index in range(5):
self.products |= self._create_product(
f"Batch Product {index}", 100.0 + index, self.tax
)
def test_pricelist_is_resolved_once_for_the_page(self):
"""One `_compute_price_rule` call, not one per product."""
calls = []
original = type(self.pricelist)._compute_price_rule
def counting_compute_price_rule(pricelist_self, products, *args, **kwargs):
calls.append(len(products))
return original(pricelist_self, products, *args, **kwargs)
self.patch(
type(self.pricelist), "_compute_price_rule", counting_compute_price_rule
)
self.controller._compute_price_info(self.products, self.pricelist)
self.assertEqual(
calls,
[len(self.products)],
msg=f"Expected a single batched call, got {calls}",
)
def test_batched_prices_match_the_single_product_helper(self):
"""Batching must not change any number it produces."""
batched = self.controller._compute_price_info(self.products, self.pricelist)
for product in self.products:
single = self.controller._get_pricing_info(
product, self.pricelist, quantity=1.0
)
self.assertAlmostEqual(
batched[product.id]["price"], single["price"], places=2
)
self.assertAlmostEqual(
batched[product.id]["price_unit"], single["price_unit"], places=2
)
def test_prices_the_given_variant(self):
"""A variant is priced as itself, not as its template's first one."""
attribute = self.env["product.attribute"].create(
{
"name": "Size Deleg",
"value_ids": [
(0, 0, {"name": "Small"}),
(0, 0, {"name": "Large"}),
],
}
)
template = self.env["product.template"].create(
{
"name": "Multi Variant Product",
"type": "consu",
"list_price": 100.0,
"is_published": True,
"sale_ok": True,
"company_id": self.company.id,
"attribute_line_ids": [
(
0,
0,
{
"attribute_id": attribute.id,
"value_ids": [(6, 0, attribute.value_ids.ids)],
},
)
],
}
)
small, large = template.product_variant_ids[0], template.product_variant_ids[1]
# Make the two variants cost visibly different amounts.
large.write({"list_price": 100.0})
self.env["product.pricelist.item"].create(
{
"pricelist_id": self.pricelist.id,
"applied_on": "0_product_variant",
"product_id": large.id,
"compute_price": "fixed",
"fixed_price": 500.0,
}
)
priced = self.controller._compute_price_info(large, self.pricelist)
self.assertAlmostEqual(priced[large.id]["price_unit"], 500.0, places=2)
self.assertNotEqual(large.id, small.id)

View file

@ -500,8 +500,8 @@ class TestPricingWithPricelist(TransactionCase):
# If it raises, that's also acceptable behavior
self.assertTrue(True, "Negative quantity properly rejected")
def test_pricing_helper_uses_config_pricelist_and_taxes(self):
"""Pricing helper must apply configured pricelist and include taxes for display."""
def test_pricing_helper_applies_pricelist_and_taxes(self):
"""Pricing helper must apply the given pricelist and include taxes."""
website = self.env.ref("website.default_website").sudo()
website.write(
@ -529,17 +529,12 @@ class TestPricingWithPricelist(TransactionCase):
}
)
self.env["ir.config_parameter"].sudo().set_param(
"website_sale_aplicoop.pricelist_id", pricelist_discount.id
)
product = self.product_with_tax # 100€ + 21%
pricing = self.controller._get_pricing_info(
product,
pricelist_discount,
quantity=1.0,
partner=self.partner,
)
# price_unit uses pricelist (10% discount)

File diff suppressed because it is too large Load diff

View file

@ -1,367 +0,0 @@
# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
"""
Test suite for validations and constraints in website_sale_aplicoop.
Coverage:
- group.order constraint: same company for all groups
- group.order constraint: start_date < end_date
- group.order computed field: image_1920 fallback logic
- group.order computed field: product count
- res.partner validation: user without partner_id
- group.order state transitions: illegal transitions
"""
from datetime import datetime
from datetime import timedelta
from odoo.exceptions import UserError
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase
class TestGroupOrderValidations(TransactionCase):
"""Test constraints and validations for group.order model."""
def setUp(self):
super().setUp()
self.company1 = self.env.company
self.company2 = self.env["res.company"].create(
{
"name": "Company 2",
}
)
self.group_c1 = self.env["res.partner"].create(
{
"name": "Group Company 1",
"is_company": True,
"company_id": self.company1.id,
}
)
self.group_c2 = self.env["res.partner"].create(
{
"name": "Group Company 2",
"is_company": True,
"company_id": self.company2.id,
}
)
def test_group_order_same_company_constraint(self):
"""Test that all groups in an order must be from same company."""
start_date = datetime.now().date()
# Creating order with groups from different companies should fail
with self.assertRaises(ValidationError):
self.env["group.order"].create(
{
"name": "Multi-Company Order",
"group_ids": [(6, 0, [self.group_c1.id, self.group_c2.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
def test_group_order_same_company_mixed_single(self):
"""Test that single company group is valid."""
start_date = datetime.now().date()
# Single company should pass
order = self.env["group.order"].create(
{
"name": "Single Company Order",
"group_ids": [(6, 0, [self.group_c1.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
def test_group_order_date_validation_start_after_end(self):
"""Test that start_date must be before end_date."""
start_date = datetime.now().date()
end_date = start_date - timedelta(days=1) # End before start
with self.assertRaises(ValidationError):
self.env["group.order"].create(
{
"name": "Bad Dates Order",
"group_ids": [(6, 0, [self.group_c1.id])],
"type": "regular",
"start_date": start_date,
"end_date": end_date,
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
def test_group_order_date_validation_same_date(self):
"""Test that start_date == end_date is allowed (single-day order)."""
same_date = datetime.now().date()
order = self.env["group.order"].create(
{
"name": "Same Day Order",
"group_ids": [(6, 0, [self.group_c1.id])],
"type": "regular",
"start_date": same_date,
"end_date": same_date,
"period": "once",
"pickup_day": "0",
"cutoff_day": "0",
}
)
self.assertTrue(order.exists())
class TestGroupOrderImageFallback(TransactionCase):
"""Test image_1920 computed field fallback logic."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
def test_image_fallback_order_image_first(self):
"""Test that order image takes priority over group image."""
# Set both order and group image
test_image = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
self.group_order.image_1920 = test_image
self.group.image_1920 = test_image
# Order image should be returned
computed_image = self.group_order.image_1920
self.assertEqual(computed_image, test_image)
def test_image_fallback_group_image_when_no_order_image(self):
"""Test fallback to group image when order has no image."""
test_image = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
# Only set group image
self.group_order.image_1920 = False
self.group.image_1920 = test_image
# Group image should be returned as fallback
# Note: This requires the computed field logic to be tested
# after field recalculation
def test_image_fallback_none_when_no_images(self):
"""Test that None is returned when no image available."""
# No images set
self.group_order.image_1920 = False
self.group.image_1920 = False
# Should be empty/False
computed_image = self.group_order.image_1920
self.assertFalse(computed_image)
class TestGroupOrderProductCount(TransactionCase):
"""Test product_count computed field."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.product1 = self.env["product.product"].create(
{
"name": "Product 1",
"type": "consu",
"list_price": 10.0,
}
)
self.product2 = self.env["product.product"].create(
{
"name": "Product 2",
"type": "consu",
"list_price": 20.0,
}
)
def test_product_count_initial_zero(self):
"""Test that new order has zero products."""
self.assertEqual(self.group_order.product_count, 0)
def test_product_count_increments_on_add(self):
"""Test that product_count increases when adding products."""
self.group_order.product_ids = [(4, self.product1.id)]
self.assertEqual(self.group_order.product_count, 1)
self.group_order.product_ids = [(4, self.product2.id)]
self.assertEqual(self.group_order.product_count, 2)
def test_product_count_decrements_on_remove(self):
"""Test that product_count decreases when removing products."""
self.group_order.product_ids = [(6, 0, [self.product1.id, self.product2.id])]
self.assertEqual(self.group_order.product_count, 2)
self.group_order.product_ids = [(3, self.product1.id)]
self.assertEqual(self.group_order.product_count, 1)
def test_product_count_all_removed(self):
"""Test that product_count is zero when all removed."""
self.group_order.product_ids = [(6, 0, [self.product1.id, self.product2.id])]
self.group_order.product_ids = [(6, 0, [])]
self.assertEqual(self.group_order.product_count, 0)
class TestStateTransitions(TransactionCase):
"""Test group.order state transition validation."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
start_date = datetime.now().date()
self.order = self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
def test_illegal_transition_draft_to_closed(self):
"""Test that Draft -> Closed transition is not allowed."""
# Should not allow skipping Open state
self.assertEqual(self.order.state, "draft")
# Calling action_close() without action_open() should fail
with self.assertRaises((ValidationError, UserError)):
self.order.action_close()
def test_illegal_transition_cancelled_to_open(self):
"""Test that Cancelled -> Open transition is not allowed."""
self.order.action_cancel()
self.assertEqual(self.order.state, "cancelled")
# Should not allow re-opening cancelled order
with self.assertRaises((ValidationError, UserError)):
self.order.action_open()
def test_legal_transition_draft_open_closed(self):
"""Test that Draft -> Open -> Closed is allowed."""
self.assertEqual(self.order.state, "draft")
self.order.action_open()
self.assertEqual(self.order.state, "open")
self.order.action_close()
self.assertEqual(self.order.state, "closed")
def test_transition_draft_to_cancelled(self):
"""Test that Draft -> Cancelled is allowed."""
self.assertEqual(self.order.state, "draft")
self.order.action_cancel()
self.assertEqual(self.order.state, "cancelled")
def test_transition_open_to_cancelled(self):
"""Test that Open -> Cancelled is allowed (emergency stop)."""
self.order.action_open()
self.assertEqual(self.order.state, "open")
self.order.action_cancel()
self.assertEqual(self.order.state, "cancelled")
class TestUserPartnerValidation(TransactionCase):
"""Test validation when user has no partner_id."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
# Create user without partner (edge case)
self.user_no_partner = self.env["res.users"].create(
{
"name": "User No Partner",
"login": "noparnter@test.com",
"partner_id": False, # Explicitly no partner
}
)
def test_user_without_partner_cannot_access_order(self):
"""Test that user without partner_id has no access to orders."""
start_date = datetime.now().date()
self.env["group.order"].create(
{
"name": "Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
# User without partner should not have access
# This should be validated in controller
self.assertFalse(self.user_no_partner.partner_id)

View file

@ -0,0 +1,208 @@
# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
"""Eskaera is served per website, not database-wide.
A co-op can run the plain shop on one website and the group orders on another.
`website.eskaera_enabled` decides which is which: where it is off the /eskaera
routes answer 404 and the Eskaera menu is hidden.
"""
from datetime import datetime
from datetime import timedelta
from odoo.tests import tagged
from odoo.tests.common import HttpCase
from odoo.tests.common import TransactionCase
class EskaeraWebsiteCommon:
def setUp(self):
super().setUp()
self.website = self.env.ref("website.default_website")
def _eskaera_menus(self, website):
return self.env["website.menu"].search(
[("url", "=like", "/eskaera%"), ("website_id", "=", website.id)]
)
@tagged("post_install", "-at_install")
class TestEskaeraEnabledField(EskaeraWebsiteCommon, TransactionCase):
"""The switch itself, and what it does to the menu."""
def test_enabled_by_default(self):
"""Installing the addon leaves every website serving Eskaera."""
self.assertTrue(self.website.eskaera_enabled)
self.assertTrue(
all(website.eskaera_enabled for website in self.env["website"].search([]))
)
def test_new_website_serves_eskaera(self):
"""A website created later also starts with Eskaera on."""
new_website = self.env["website"].create({"name": "Brand New Site"})
self.assertTrue(new_website.eskaera_enabled)
def test_menu_is_visible_while_enabled(self):
"""The Eskaera menu shows on a website that serves it."""
menus = self._eskaera_menus(self.website)
self.assertTrue(menus, "the website should have an Eskaera menu")
self.assertTrue(all(menu.is_visible for menu in menus))
def test_menu_is_hidden_when_disabled(self):
"""Switching Eskaera off hides its menu on that website only."""
other_website = self.env["website"].create({"name": "Shop Only Site"})
self.website.eskaera_enabled = False
self.env["website.menu"].invalidate_model(["is_visible"])
self.assertFalse(any(m.is_visible for m in self._eskaera_menus(self.website)))
# The other website is untouched.
other_menus = self._eskaera_menus(other_website)
if other_menus:
self.assertTrue(all(menu.is_visible for menu in other_menus))
def test_menu_is_kept_not_deleted(self):
"""Turning the feature back on restores the menu as it was."""
menus = self._eskaera_menus(self.website)
menu_ids = menus.ids
self.website.eskaera_enabled = False
self.website.eskaera_enabled = True
self.env["website.menu"].invalidate_model(["is_visible"])
restored = self._eskaera_menus(self.website)
self.assertEqual(restored.ids, menu_ids)
self.assertTrue(all(menu.is_visible for menu in restored))
def test_other_menus_are_left_alone(self):
"""The override must not touch menus that are not Eskaera's."""
self.website.eskaera_enabled = False
self.env["website.menu"].invalidate_model(["is_visible"])
home = self.env["website.menu"].search(
[("url", "=", "/"), ("website_id", "=", self.website.id)], limit=1
)
if home:
self.assertTrue(home.is_visible)
def test_setting_is_exposed_per_website(self):
"""The switch reaches the settings screen through website_id."""
settings = self.env["res.config.settings"].create(
{"website_id": self.website.id}
)
self.assertTrue(settings.eskaera_enabled)
settings.eskaera_enabled = False
settings.execute()
self.assertFalse(self.website.eskaera_enabled)
@tagged("post_install", "-at_install")
class TestEskaeraRoutesPerWebsite(EskaeraWebsiteCommon, HttpCase):
"""The /eskaera routes answer only where the feature is on."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Enabled Test Group",
"is_company": True,
"is_group": True,
}
)
self.member_partner = self.env["res.partner"].create(
{
"name": "Enabled Test Member",
"group_ids": [(6, 0, [self.group.id])],
}
)
self.login = "enabled.member@test.com"
self.env["res.users"].create(
{
"name": "Enabled Test Member",
"login": self.login,
"password": self.login,
"partner_id": self.member_partner.id,
"groups_id": [(4, self.env.ref("base.group_portal").id)],
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Enabled Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
}
)
self.group_order.action_open()
def _routes(self):
return [
"/eskaera",
f"/eskaera/{self.group_order.slug}",
f"/eskaera/{self.group_order.slug}/checkout",
]
def test_routes_answer_when_enabled(self):
"""With the feature on, the pages render."""
self.authenticate(self.login, self.login)
for route in self._routes():
response = self.url_open(route, allow_redirects=True)
self.assertEqual(response.status_code, 200, msg=f"{route} should be served")
def test_routes_are_404_when_disabled(self):
"""With the feature off, the pages are gone from that website."""
self.website.eskaera_enabled = False
self.authenticate(self.login, self.login)
for route in self._routes():
response = self.url_open(route, allow_redirects=True)
self.assertEqual(
response.status_code, 404, msg=f"{route} should not be served"
)
def _call_labels(self):
return self.url_open(
"/eskaera/labels",
data=b'{"jsonrpc": "2.0", "method": "call", "params": {}}',
headers={"Content-Type": "application/json"},
).json()
def test_json_endpoint_answers_when_enabled(self):
"""With the feature on, the labels endpoint returns its dictionary."""
self.authenticate(self.login, self.login)
payload = self._call_labels()
self.assertIn("result", payload)
self.assertIn("total", payload["result"])
def test_json_endpoint_is_gated_when_disabled(self):
"""The JSON endpoints are gated too, not just the pages.
A JSON route reports the 404 inside the payload and still answers
HTTP 200: that is how Odoo's JSON-RPC transport surfaces exceptions.
"""
self.website.eskaera_enabled = False
self.authenticate(self.login, self.login)
payload = self._call_labels()
self.assertNotIn("result", payload)
self.assertEqual(payload["error"]["code"], 404)
self.assertEqual(
payload["error"]["data"]["name"], "werkzeug.exceptions.NotFound"
)

View file

@ -18,6 +18,7 @@
<field name="end_date" optional="show"/>
<field name="home_delivery" optional="hide"/>
<field name="delivery_product_id" optional="hide"/>
<field name="online_payment" optional="hide"/>
<field name="state" optional="show"/>
</list>
</field>
@ -84,6 +85,15 @@
<field name="delivery_product_id" help="Product to use for home delivery. Setting this enables home delivery."/>
<field name="delivery_notice" placeholder="Information about home delivery..." nolabel="1"/>
</page>
<page string="Online Payment" name="online_payment">
<field name="online_payment"/>
<div class="text-muted" invisible="not online_payment">
Members must pay online to place their order in this cycle. The
available methods come from the payment providers published on the
website (Settings &gt; Payment Providers); this order does not
configure any of them.
</div>
</page>
<page string="Product Catalog">
<group string="Included Products" col="2">
<field name="supplier_ids" widget="many2many_tags" help="All products from these suppliers will be included"/>

View file

@ -6,19 +6,18 @@
<field name="inherit_id" ref="website.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@id='website_info_settings']" position="after">
<h2>Aplicoop Settings</h2>
<div class="row mt16 o_settings_container" id="aplicoop_settings">
<h2>Eskaera</h2>
<div class="row mt16 o_settings_container" id="eskaera_website_settings">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_left_pane">
<field name="eskaera_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="aplicoop_pricelist_id" string="Aplicoop Pricelist"/>
<label for="eskaera_enabled" string="Eskaera Group Orders"/>
<div class="text-muted">
Pricelist used for Aplicoop group orders
</div>
<div class="content-group">
<div class="mt16">
<field name="aplicoop_pricelist_id" class="oe_inline"/>
</div>
Serve the collaborative purchasing pages on this
website. Turn it off on a website that only runs
the regular shop.
</div>
</div>
</div>

View file

@ -107,12 +107,6 @@
</script>
</t>
</template>
<template id="confirm_message_snippet" name="Confirm Message Snippet">
<t>Thank you! Your order has been confirmed.</t>
</template>
<template id="confirm_pickup_label_snippet" name="Confirm Pickup Label Snippet">
<t>Pickup Day</t>
</template>
<template id="eskaera_order_card_meta" name="Order Card Metadata">
<div class="card-meta-compact mt-3">
<div class="meta-grid">
@ -214,6 +208,18 @@
<t t-call="website_sale_aplicoop.order_header">
<t t-set="header_class" t-value="'eskaera-order-header'" />
</t>
<!-- Paying confirms the order, so no draft is left to
reuse and nothing else would stop a second, also
payable, order for the same cycle. -->
<t t-if="placed_order">
<div class="alert alert-info d-flex flex-column flex-md-row gap-2 align-items-md-center" role="alert">
<div class="flex-grow-1">
<strong>You already placed an order for this cycle.</strong>
<span t-esc="placed_order.name" />
</div>
<a t-att-href="placed_order_url" class="btn btn-sm btn-outline-primary">View my order</a>
</div>
</t>
<div class="eskaera-order-header">
<!-- Name/value pairs: a description list, so assistive tech
announces each value with its label. The 4 → 2 → 1 column
@ -560,10 +566,13 @@
</div>
</div>
</t>
<t t-if="online_payment and not payment_available">
<div class="alert alert-warning" role="alert" t-esc="no_payment_method_message" />
</t>
<div class="checkout-actions d-grid gap-3" id="checkout-form-labels">
<button class="btn btn-success btn-lg" id="confirm-order-btn" t-attf-data-order-id="{{ group_order.id }}" t-att-data-confirmed-label="labels.get('order_saved_as_draft', 'Order saved as draft')" t-att-data-pickup-label="labels.get('pickup_day_label', 'Pickup Day')" t-att-aria-label="labels.get('save_order_as_draft', 'Save order as draft')" t-att-data-bs-title="labels.get('save_draft', 'Save Draft')" data-bs-toggle="tooltip">
<i class="fa fa-save" aria-hidden="true" t-translation="off" />
<span t-esc="labels.get('save_draft', 'Save Draft')" />
<button class="btn btn-success btn-lg" id="confirm-order-btn" t-attf-data-order-id="{{ group_order.id }}" t-att-data-confirmed-label="checkout_button['done_label']" t-att-data-pickup-label="labels.get('pickup_day_label', 'Pickup Day')" t-att-aria-label="checkout_button['hint']" t-att-data-bs-title="checkout_button['label']" t-att-data-tooltip-key="checkout_button['tooltip_key']" t-att-disabled="online_payment and not payment_available" data-bs-toggle="tooltip">
<i t-attf-class="fa {{ checkout_button['icon'] }}" aria-hidden="true" t-translation="off" />
<span t-esc="checkout_button['label']" />
</button>
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
@ -618,6 +627,172 @@
</div>
</t>
</template>
<template id="eskaera_order_lines_summary" name="Placed Order Summary">
<!-- Server-side twin of eskaera_checkout_summary: from the payment
step on, the amounts shown must be the ones that will be
charged, so they come from the sale.order and not from the
localStorage cart. -->
<div class="checkout-summary-container" role="region" tabindex="0" aria-label="Order summary">
<table class="table table-hover checkout-summary-table">
<caption class="visually-hidden">Products in this order, with quantity, price and subtotal</caption>
<thead class="table-dark">
<tr>
<th scope="col" class="col-name">Product</th>
<th scope="col" class="col-qty text-center">Quantity</th>
<th scope="col" class="col-subtotal text-end">Subtotal</th>
</tr>
</thead>
<tbody>
<tr t-foreach="sale_order.order_line" t-as="line">
<td class="col-name" t-esc="line.name" />
<td class="col-qty text-center" t-esc="line.product_uom_qty" />
<td class="col-subtotal text-end">
<span t-field="line.price_total" t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}" />
</td>
</tr>
</tbody>
</table>
<div class="checkout-total-section">
<div class="total-row">
<span class="total-label">Total</span>:
<span class="total-amount">
<span t-field="sale_order.amount_total" t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}" />
</span>
</div>
</div>
</div>
</template>
<template id="eskaera_payment" name="Eskaera Payment">
<t t-call="website.layout">
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Payment">
<div class="container mt-5">
<div class="row">
<div class="col-lg-10 offset-lg-1">
<div class="mb-4">
<t t-call="website_sale_aplicoop.order_header">
<t t-set="header_class" t-value="'checkout-header'" />
<t t-set="header_title">Pay Order: <t t-esc="group_order.name" />
</t>
</t>
</div>
<h4 class="summary-heading mb-3">Order Summary</h4>
<div class="mb-5">
<t t-call="website_sale_aplicoop.eskaera_order_lines_summary" />
</div>
<t t-if="pending_transaction">
<div class="alert alert-info" role="alert">
<h5 class="alert-heading">
<i class="fa fa-clock-o me-2" aria-hidden="true" t-translation="off" />
<span>Payment in progress</span>
</h5>
<p class="mb-1">We are still waiting for your payment to be confirmed. Please do not pay again.</p>
<p class="mb-0">
<span>Reference</span>:
<span t-esc="pending_transaction.reference" />
</p>
</div>
<div class="checkout-actions d-grid gap-3">
<a t-att-href="sale_order.get_portal_url()" class="btn btn-outline-secondary btn-lg">
<i class="fa fa-file-text-o" aria-hidden="true" t-translation="off" />
<span>View my order</span>
</a>
</div>
</t>
<t t-else="">
<h4 class="summary-heading mb-3">Payment Method</h4>
<!-- The whole provider UI is payment.form; this addon
configures no provider of its own. -->
<div id="payment_method" class="o_not_editable mb-4">
<t t-call="payment.form" />
</div>
<!-- Deliberately outside #o_payment_form: website_sale's
payment_form.js binds every
[name="o_payment_submit_button"] in the document, on
top of payment_form.js's own delegated handler inside
the form. A button inside would get both listeners and
one click would open two transactions. -->
<div class="checkout-actions d-grid gap-3">
<t t-call="payment.submit_button" />
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-outline-secondary btn-lg">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
<span>Back to Checkout</span>
</a>
</div>
</t>
</div>
</div>
</div>
</div>
</t>
</template>
<template id="eskaera_payment_confirmation" name="Eskaera Payment Confirmation">
<t t-call="website.layout">
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Payment Confirmation" t-attf-data-clear-cart-order-id="{{ group_order.id }}">
<div class="container mt-5">
<div class="row">
<div class="col-lg-10 offset-lg-1">
<div class="mb-4">
<t t-call="website_sale_aplicoop.order_header">
<t t-set="header_class" t-value="'checkout-header'" />
<t t-set="header_title">Order Confirmed: <t t-esc="group_order.name" />
</t>
</t>
</div>
<!-- The browser lands here from /payment/status once
post-processing has run, but that is asynchronous, so
the page reports the order's actual state instead of
assuming it was confirmed. -->
<t t-if="is_paid">
<div class="alert alert-success" role="alert">
<h5 class="alert-heading">
<i class="fa fa-check-circle me-2" aria-hidden="true" t-translation="off" />
<span>Thank you, your order is confirmed</span>
</h5>
<p class="mb-0">
<span>Order</span>
<span t-esc="sale_order.name" />
</p>
</div>
</t>
<t t-else="">
<div class="alert alert-info" role="alert">
<h5 class="alert-heading">
<i class="fa fa-clock-o me-2" aria-hidden="true" t-translation="off" />
<span>Your payment is being processed</span>
</h5>
<p class="mb-0">Your order will be confirmed as soon as we receive the payment. You can follow it from your orders page.</p>
</div>
</t>
<t t-if="sale_order.pickup_slot_label">
<div class="order-info-card card border-0 shadow-sm mb-4">
<div class="card-body">
<dl class="info-pair mb-0">
<dt class="info-label">Pickup</dt>
<dd class="info-value" t-esc="sale_order.pickup_slot_label" />
</dl>
</div>
</div>
</t>
<h4 class="summary-heading mb-3">Order Summary</h4>
<div class="mb-5">
<t t-call="website_sale_aplicoop.eskaera_order_lines_summary" />
</div>
<div class="checkout-actions d-grid gap-3">
<a t-att-href="sale_order.get_portal_url()" class="btn btn-primary btn-lg">
<i class="fa fa-file-text-o" aria-hidden="true" t-translation="off" />
<span>View my order</span>
</a>
<a href="/eskaera" class="btn btn-outline-secondary btn-lg">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
<span>Back to Orders</span>
</a>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
<template id="category_hierarchy_options" name="Category Hierarchy Options">
<t t-foreach="categories" t-as="cat">
<t t-set="padding_px" t-value="depth * 20" />