Skip to content

[ADD] sipreco_purchase_web: publicación web de Solicitudes de Compra#603

Open
iga-adhoc wants to merge 21 commits into
ingadhoc:18.0from
adhoc-dev:18.0-t-67731-iga
Open

[ADD] sipreco_purchase_web: publicación web de Solicitudes de Compra#603
iga-adhoc wants to merge 21 commits into
ingadhoc:18.0from
adhoc-dev:18.0-t-67731-iga

Conversation

@iga-adhoc

Copy link
Copy Markdown
Contributor

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.

Copilot AI review requested due to automatic review settings May 13, 2026 20:12
@roboadhoc

Copy link
Copy Markdown
Contributor

Pull request status dashboard

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.requisition para 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 un inherit_id. En herencia de vistas debe modificarse el search existente (por ejemplo insertando filtros con position="after" sobre un filtro existente o con xpath), como se hace en sipreco_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 valida web_publishable=True (a diferencia de /compras y /compras/<id>). Para mantener la misma política de publicación y evitar inconsistencias, añadir el mismo criterio en la búsqueda de purchase (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() muta vals una sola vez para todo el recordset. Si self contiene múltiples requisiciones y solo algunas están publicadas, self.filtered('website_published') será truthy y se seteará web_last_update para 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)

Comment on lines +118 to +128
<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>
Comment on lines +63 to +68
# 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
)

Comment on lines +32 to +40
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.',
)
Comment on lines +3 to +5
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
Comment thread sipreco_purchase_web/README.rst Outdated
@@ -0,0 +1 @@
# Sipreco Purchase Web Publication
Comment on lines +124 to +146
<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"/>&#160;
<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"/>
@iga-adhoc iga-adhoc force-pushed the 18.0-t-67731-iga branch 9 times, most recently from a46eb78 to 19d9642 Compare May 29, 2026 16:43
@iga-adhoc iga-adhoc force-pushed the 18.0-t-67731-iga branch 7 times, most recently from ec46599 to 03de9ec Compare June 17, 2026 18:39
iga-adhoc and others added 9 commits June 17, 2026 15:51
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>
iga-adhoc and others added 9 commits June 17, 2026 15:51
…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>
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.
@ica-adhoc ica-adhoc self-requested a review June 23, 2026 15:48
}


def _set_embed_headers(response):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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,
}
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. web_publishable en vals: el código setea website_published = False pero web_publishable no está en web_fields, por lo que el timestamp no se toca. Una licitación despublicada queda con web_last_update del último campo editado.

  2. website_published escrito directamente (desde otro módulo, script o action server): 'website_published' tampoco está en web_fields, y self.filtered('website_published') evalúa el estado antes del write — si pasaba de False a True el filtro retorna vacío y el timestamp no se actualiza. Una licitación recién publicada queda con web_last_update nulo.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants