[ADD] sipreco_purchase_web: publicación web de Solicitudes de Compra#603
[ADD] sipreco_purchase_web: publicación web de Solicitudes de Compra#603iga-adhoc wants to merge 21 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Se incorpora el módulo sipreco_purchase_web para exponer públicamente Solicitudes de Compra (purchase.requisition) en el sitio web (ruta /compras), con detalle, adjuntos descargables y captura opcional de email antes de la descarga.
Changes:
- Nuevos campos y acciones en
purchase.requisitionpara control de publicación web, metadatos públicos, adjuntos y adjudicatarios. - Controladores y plantillas website para listado/detalle y flujo de descarga (con “email gate” opcional).
- Seguridad básica (ACLs/regla) y vistas backoffice para administrar la información web.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| sipreco_purchase_web/views/website_templates.xml | Plantillas QWeb para listado, detalle y formulario de email previo a descarga. |
| sipreco_purchase_web/views/website_menu.xml | Alta de menú en website apuntando a /compras. |
| sipreco_purchase_web/views/purchase_web_award_views.xml | Vista form para modelo de adjudicatarios web. |
| sipreco_purchase_web/views/purchase_web_attachment_views.xml | Vista form para modelo de adjuntos públicos web. |
| sipreco_purchase_web/views/purchase_requisition_web_views.xml | Herencia de vistas de purchase.requisition (form/list/search) para publicación web. |
| sipreco_purchase_web/security/sipreco_purchase_web_security.xml | Regla de registro para limitar lectura pública de requisiciones publicadas. |
| sipreco_purchase_web/security/ir.model.access.csv | ACLs para modelos purchase.web.attachment y purchase.web.award (usuario compra y público). |
| sipreco_purchase_web/README.rst | README del módulo. |
| sipreco_purchase_web/models/purchase_web_award.py | Modelo para adjudicatarios visibles en web. |
| sipreco_purchase_web/models/purchase_web_attachment.py | Modelo para adjuntos públicos (con flag de requerir email). |
| sipreco_purchase_web/models/purchase_requisition.py | Extensión de purchase.requisition con campos/acciones/computes para web. |
| sipreco_purchase_web/models/init.py | Export de modelos del módulo. |
| sipreco_purchase_web/i18n/es.po | Traducciones (ES) de etiquetas del módulo. |
| sipreco_purchase_web/controllers/main.py | Rutas web /compras + descarga de adjuntos y POST de email. |
| sipreco_purchase_web/controllers/init.py | Export de controladores. |
| sipreco_purchase_web/manifest.py | Manifest del módulo (depends/data). |
| sipreco_purchase_web/init.py | Inicialización del módulo. |
Comments suppressed due to low confidence (4)
sipreco_purchase_web/views/purchase_requisition_web_views.xml:144
- En la vista de búsqueda heredada se define un
<search>completo dentro de uninherit_id. En herencia de vistas debe modificarse el search existente (por ejemplo insertando filtros conposition="after"sobre un filtro existente o conxpath), como se hace ensipreco_purchase/views/purchase_requisition_views.xml:178-195. Si se deja así, es probable que el arch falle o que se reemplace el search de forma no intencional.
<record model="ir.ui.view" id="view_purchase_requisition_web_search">
<field name="name">purchase.requisition.web.search</field>
<field name="model">purchase.requisition</field>
<field name="priority">70</field>
<field name="inherit_id" ref="purchase_requisition.view_purchase_requisition_filter"/>
<field name="arch" type="xml">
<search>
<filter string="Publicables en web" name="web_publishable"
domain="[('web_publishable', '=', True)]"/>
<filter string="Publicadas en web" name="website_published"
domain="[('website_published', '=', True)]"/>
</search>
</field>
sipreco_purchase_web/controllers/main.py:89
- En la descarga de adjuntos se valida la compra solo con
website_published=True, pero no se validaweb_publishable=True(a diferencia de/comprasy/compras/<id>). Para mantener la misma política de publicación y evitar inconsistencias, añadir el mismo criterio en la búsqueda depurchase(y/o validar ambos flags antes de servir el archivo).
Requisition = request.env['purchase.requisition'].sudo()
purchase = Requisition.search([
('id', '=', purchase_id),
('website_published', '=', True),
], limit=1)
if not purchase:
sipreco_purchase_web/controllers/main.py:143
- El redirect construye la URL con el email sin URL-encoding (
...?email=%s). Esto puede generar URLs inválidas y permite inyectar caracteres especiales en la querystring. Además, el email queda expuesto en la URL (logs/proxies). Como mínimo, codificar el parámetro correctamente; idealmente, evitar pasar el email en la URL (p. ej. guardarlo en sesión o emitir un token).
# Validación básica del email recibido por POST
email = email.strip()
if not email or '@' not in email:
return request.redirect(
'/compras/%d/descargar/%d' % (purchase_id, attachment_line_id)
)
# Registrar y redirigir con el email como parámetro para que el
# controlador principal sirva el archivo
_logger.info(
'Email registrado para descarga de archivo (requisition=%d, attachment=%d): %s',
purchase_id, attachment_line_id, email
)
return http.redirect_with_hash(
'/compras/%d/descargar/%d?email=%s' % (purchase_id, attachment_line_id, email)
)
sipreco_purchase_web/models/purchase_requisition.py:132
- El override de
write()mutavalsuna sola vez para todo el recordset. Siselfcontiene múltiples requisiciones y solo algunas están publicadas,self.filtered('website_published')será truthy y se setearáweb_last_updatepara todas las requisiciones del batch, incluso las no publicadas. Para evitarlo, aplicar la actualización solo sobre el subconjunto publicado (p. ej. separar recordsets o post-procesar).
def write(self, vals):
web_fields = {
'web_object', 'web_amount', 'web_amount_manual',
'web_opening_datetime', 'web_state', 'web_observations',
}
if vals.keys() & web_fields and self.filtered('website_published'):
vals.setdefault('web_last_update', fields.Datetime.now())
return super().write(vals)
| <record model="ir.ui.view" id="view_purchase_requisition_web_list"> | ||
| <field name="name">purchase.requisition.web.list</field> | ||
| <field name="model">purchase.requisition</field> | ||
| <field name="priority">70</field> | ||
| <field name="inherit_id" ref="purchase_requisition.view_purchase_requisition_tree"/> | ||
| <field name="arch" type="xml"> | ||
| <list> | ||
| <field name="web_publishable" optional="show"/> | ||
| <field name="website_published" optional="show"/> | ||
| </list> | ||
| </field> |
| # Si se viene de un formulario de email para descarga de legajo | ||
| if email and kwargs.get('attachment_id'): | ||
| return self._handle_attachment_download( | ||
| purchase, int(kwargs['attachment_id']), email | ||
| ) | ||
|
|
| web_amount = fields.Monetary( | ||
| string='Valor oficial', | ||
| currency_field='currency_id', | ||
| ) | ||
| web_amount_manual = fields.Boolean( | ||
| string='Monto manual', | ||
| default=False, | ||
| help='Si está activo, el valor oficial no se toma de la SC sino que se carga manualmente.', | ||
| ) |
| access_purchase_web_attachment_public,purchase.web.attachment.public,model_purchase_web_attachment,base.group_public,1,0,0,0 | ||
| access_purchase_web_award_user,purchase.web.award.user,model_purchase_web_award,purchase.group_purchase_user,1,1,1,1 | ||
| access_purchase_web_award_public,purchase.web.award.public,model_purchase_web_award,base.group_public,1,0,0,0 |
| @@ -0,0 +1 @@ | |||
| # Sipreco Purchase Web Publication | |||
| <t t-if="purchase.web_amount"> | ||
| <dt class="col-sm-4">Valor oficial</dt> | ||
| <dd class="col-sm-8"> | ||
| <t t-esc="purchase.currency_id.symbol"/>  | ||
| <t t-esc="'{:,.2f}'.format(purchase.web_amount)"/> | ||
| </dd> | ||
| </t> | ||
| <t t-if="purchase.web_opening_datetime"> | ||
| <dt class="col-sm-4">Fecha y hora de Apertura</dt> | ||
| <dd class="col-sm-8"> | ||
| <t t-esc="purchase.web_opening_datetime"/> | ||
| </dd> | ||
| </t> | ||
| <t t-if="purchase.web_publication_date"> | ||
| <dt class="col-sm-4">Fecha de publicación</dt> | ||
| <dd class="col-sm-8"> | ||
| <t t-esc="purchase.web_publication_date"/> | ||
| </dd> | ||
| </t> | ||
| <t t-if="purchase.web_last_update"> | ||
| <dt class="col-sm-4">Última actualización</dt> | ||
| <dd class="col-sm-8"> | ||
| <t t-esc="purchase.web_last_update"/> |
10ea786 to
846f461
Compare
a46eb78 to
19d9642
Compare
ec46599 to
03de9ec
Compare
task: 67731 Nuevo módulo que permite publicar Solicitudes de Compra (purchase.requisition) en una página web pública de compras y contrataciones, accesible en /compras. Funcionalidades incluidas: - Campo booleano "Publicable en web" en la Solicitud de Compra. - Solapa "Datos para página web" visible solo cuando el booleano está activo, con: número, objeto, tipo de compra, valor oficial (manual o desde la SC), fecha/hora de apertura, estado público (Para Apertura, En evaluación, Adjudicada, Finalizada, Desierta, Fracasada, Suspendida), observaciones (rich text), archivos públicos descargables (pliego, circulares, DDJJ) y sección de adjudicatarios con valor total adjudicado (solo estado Finalizada). - Fechas de publicación y última actualización generadas automáticamente. - Botones "Publicar en web" y "Despublicar" en la botonera del formulario. - Página pública /compras con listado y detalle de cada llamado. - Descarga de archivos con opción de solicitar email previo a la descarga. - Reglas de seguridad: el público solo accede a registros publicados; escritura restringida a purchase.group_purchase_user. Depende de: sipreco_purchase, website, mail.
…t_update tracking and stat button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…edirect (Odoo 18) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…response for Odoo 18 compatibility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…teo por archivo Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… views to resolve xmlid dependency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…y on attachment lines Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…plicate field label warning Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…se list page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…vista inline Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hivo - Usar attachment_fname (nombre real del archivo) en lugar de name (descripción) - Pasar solo fname a content_disposition, no fname + mimetype que explota con None Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
03de9ec to
cdd62b9
Compare
Agrega parámetro ?embed=1 a las rutas /compras y /compras/<id>. Cuando está activo, renderiza templates sin website.layout (solo Bootstrap + FA vía estáticos de Odoo) y setea los headers X-Frame-Options: ALLOWALL y Content-Security-Policy: frame-ancestors * para permitir el embedding desde dominios externos. Los links internos (filtros, "Ver detalle", breadcrumb) propagan ?embed=1 para mantener el modo dentro del iframe.
95aba5b to
fe09017
Compare
fe09017 to
39b44e7
Compare
| } | ||
|
|
||
|
|
||
| def _set_embed_headers(response): |
There was a problem hiding this comment.
[SECURITY] X-Frame-Options: ALLOWALL es un valor no estándar
ALLOWALL no es un valor válido de la spec de X-Frame-Options (solo acepta DENY, SAMEORIGIN o ALLOW-FROM). Los browsers que no lo reconocen pueden ignorarlo o tratarlo como DENY, lo que rompe el embed mode que este helper intenta habilitar. El header correcto para permitir framing desde cualquier origen es simplemente no setear X-Frame-Options (o eliminarlo si viene de Odoo). La directiva CSP frame-ancestors * sí es correcta y suficiente.
def _set_embed_headers(response):
# X-Frame-Options: ALLOWALL no existe en el estándar; basta con CSP
response.headers.pop("X-Frame-Options", None)
response.headers["Content-Security-Policy"] = "frame-ancestors *"| ) | ||
| ) | ||
| if not attachment_line: | ||
| return request.not_found() |
There was a problem hiding this comment.
[SECURITY] Email gate bypaseable vía parámetro GET
El handler GET lee email directo de kwargs (query string). Cualquier usuario puede saltear el formulario POST haciendo:
GET /compras/<id>/descargar/<att_id>?email=x@y.com
El gate no se muestra, se crea un log con email arbitrario y el archivo se entrega. El POST valida @ en el email, pero es un paso opcional que el cliente puede omitir completamente construyendo la URL a mano.
Si el objetivo es registrar el email antes de la descarga (no bloquearlo), el mecanismo actual cumple ese objetivo con este flujo. Pero si la intención es verificar un email real antes de la descarga, hace falta un token de sesión o similar que vincule el POST al GET posterior.
| "attachment_line_id": attachment_line.id, | ||
| "email": email, | ||
| } | ||
| ) |
There was a problem hiding this comment.
[PERFORMANCE] Binary field cargado completo en memoria para cada descarga
base64.b64decode(attachment_line.attachment) lee el campo Binary completo del ORM (ya decodificado en Python), lo vuelve a decodificar y lo pasa como bytes a make_response. Para archivos grandes (pliegos, planos) esto carga el archivo entero en la RAM del worker por cada request concurrente.
La alternativa es usar ir.attachment de Odoo con soporte de filestore + streaming, o al menos evitar la doble decodificación. Si el campo sigue siendo Binary, considerar un Content-Length header calculado sobre el dato ya en memoria y eventual uso de werkzeug.wsgi.wrap_file para streaming.
| @@ -0,0 +1,6 @@ | |||
| id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink | |||
| access_purchase_web_attachment_user,purchase.web.attachment.user,model_purchase_web_attachment,purchase.group_purchase_user,1,1,1,1 | |||
| access_purchase_web_attachment_public,purchase.web.attachment.public,model_purchase_web_attachment,base.group_public,1,0,0,0 | |||
There was a problem hiding this comment.
[SECURITY] ACL pública sin ir.rule equivalente en los modelos hijo
base.group_public tiene READ sobre purchase.web.attachment y purchase.web.award, pero la ir.rule purchase_requisition_public_rule solo restringe purchase.requisition. Los modelos hijo no tienen regla de dominio que filtre por el estado de publicación del padre.
Un usuario público puede llamar a la ORM directamente (ej. /web/dataset/call_kw con model='purchase.web.attachment', method='search_read') y obtener attachments o adjudicatarios de licitaciones no publicadas si conoce el ID. En un entorno con datos sensibles antes de publicación esto es una fuga de información.
Agregar ir.rule para purchase.web.attachment y purchase.web.award filtrando por requisition_id.website_published = True AND requisition_id.web_publishable = True para base.group_public.
| if not self.web_publishable and self.website_published: | ||
| self.website_published = False | ||
|
|
||
| def write(self, vals): |
There was a problem hiding this comment.
[BUG] write() no actualiza web_last_update en transiciones de publicación/despublicación
Hay dos escenarios en los que web_last_update no se actualiza aunque el estado público cambia:
-
web_publishableen vals: el código seteawebsite_published = Falseperoweb_publishableno está enweb_fields, por lo que el timestamp no se toca. Una licitación despublicada queda conweb_last_updatedel último campo editado. -
website_publishedescrito directamente (desde otro módulo, script o action server):'website_published'tampoco está enweb_fields, yself.filtered('website_published')evalúa el estado antes del write — si pasaba deFalseaTrueel filtro retorna vacío y el timestamp no se actualiza. Una licitación recién publicada queda conweb_last_updatenulo.
Sugerencia: incluir 'website_published' y 'web_publishable' en la condición (o manejarlos explícitamente), y evaluar el estado post-write para el caso de publicación nueva.

task: 67731
Nuevo módulo que permite publicar Solicitudes de Compra (purchase.requisition) en una página web pública de compras y contrataciones, accesible en /compras.
Funcionalidades incluidas:
Depende de: sipreco_purchase, website, mail.