[MERGE] account: add invoice page view on customer portal

This merge adds a real view of invoices on customer portal. It is based
on the invoice report layout and replaces the pdf-only support of current
customer portal. Future commits will add payment means notably in invoice
portal and therefore further enhance the feature.

Closes #19015.
This commit is contained in:
Thibault Delavallée
2017-08-25 15:37:11 +02:00
14 changed files with 332 additions and 57 deletions
+30 -6
View File
@@ -2,10 +2,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http, _
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
from odoo.exceptions import AccessError
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
from odoo.tools import consteq
class PortalAccount(CustomerPortal):
@@ -23,6 +23,10 @@ class PortalAccount(CustomerPortal):
values['invoice_count'] = invoice_count
return values
# ------------------------------------------------------------
# My Invoices
# ------------------------------------------------------------
@http.route(['/my/invoices', '/my/invoices/page/<int:page>'], type='http', auth="user", website=True)
def portal_my_invoices(self, page=1, date_begin=None, date_end=None, sortby=None, **kw):
values = self._prepare_portal_layout_values()
@@ -74,15 +78,31 @@ class PortalAccount(CustomerPortal):
})
return request.render("account.portal_my_invoices", values)
@http.route(['/my/invoices/<int:invoice_id>'], type='http', auth="user", website=True)
def portal_my_invoices_report(self, invoice_id, **kw):
@http.route(['/my/invoices/<int:invoice_id>'], type='http', auth="public", website=True)
def portal_my_invoice_detail(self, invoice_id, access_token=None, **kw):
invoice = request.env['account.invoice'].browse(invoice_id)
try:
invoice.check_access_rights('read')
invoice.check_access_rule('read')
# TDE FIXME: does not exist, or maybe, don't know
except AccessError:
return request.redirect('/my')
if not access_token or not consteq(invoice.sudo().access_token, access_token):
return request.redirect('/my')
values = {
'page_name': 'invoice',
'invoice': invoice.sudo(),
}
return request.render("account.portal_invoice_page", values)
@http.route(['/my/invoices/pdf/<int:invoice_id>'], type='http', auth="public", website=True)
def portal_my_invoice_report(self, invoice_id, access_token=None, **kw):
invoice = request.env['account.invoice'].browse(invoice_id)
try:
invoice.check_access_rights('read')
invoice.check_access_rule('read')
except AccessError:
if not access_token or not consteq(invoice.sudo().access_token, access_token):
return request.redirect('/my')
# print report as sudo, since it require access to taxes, payment term, ... and portal
# does not have those access rights.
@@ -93,6 +113,10 @@ class PortalAccount(CustomerPortal):
]
return request.make_response(pdf, headers=pdfhttpheaders)
# ------------------------------------------------------------
# My Home
# ------------------------------------------------------------
def details_form_validate(self, data):
error, error_message = super(PortalAccount, self).details_form_validate(data)
# prevent VAT/name change if invoices exist
+1 -1
View File
@@ -20,7 +20,7 @@
<p>Dear ${object.partner_id.name}
% set access_action = object.with_context(force_website=True).get_access_action()
% set is_online = access_action and access_action['type'] == 'ir.actions.act_url'
% set access_url = is_online and object.get_mail_url() or '/report/pdf/account.report_invoice/' + str(object.id)
% set access_url = object.get_mail_url()
% if object.partner_id.parent_id:
(<i>${object.partner_id.parent_id.name}</i>)
@@ -38,6 +38,7 @@ class AccountConfigSettings(models.TransientModel):
module_account_asset = fields.Boolean(string='Assets Management')
module_account_deferred_revenue = fields.Boolean(string="Revenue Recognition")
module_account_budget = fields.Boolean(string='Budget Management')
module_account_payment = fields.Boolean(string='Online Payment')
module_account_reports = fields.Boolean("Dynamic Reports")
module_account_reports_followup = fields.Boolean("Enable payment followup management")
default_sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax",
+47 -11
View File
@@ -2,6 +2,8 @@
import json
import re
import uuid
from lxml import etree
from datetime import datetime
from dateutil.relativedelta import relativedelta
@@ -38,10 +40,13 @@ MAGIC_COLUMNS = ('id', 'create_uid', 'create_date', 'write_uid', 'write_date')
class AccountInvoice(models.Model):
_name = "account.invoice"
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'portal.mixin']
_description = "Invoice"
_order = "date_invoice desc, number desc, id desc"
def _get_default_access_token(self):
return str(uuid.uuid4())
@api.one
@api.depends('invoice_line_ids.price_subtotal', 'tax_line_ids.amount', 'currency_id', 'company_id', 'date_invoice', 'type')
def _compute_amount(self):
@@ -231,6 +236,9 @@ class AccountInvoice(models.Model):
], readonly=True, index=True, change_default=True,
default=lambda self: self._context.get('type', 'out_invoice'),
track_visibility='always')
access_token = fields.Char(
'Security Token', copy=False,
default=_get_default_access_token)
refund_invoice_id = fields.Many2one('account.invoice', string="Invoice for which this invoice is the credit note")
number = fields.Char(related='move_id.name', store=True, readonly=True, copy=False)
@@ -353,6 +361,11 @@ class AccountInvoice(models.Model):
('number_uniq', 'unique(number, company_id, journal_id, type)', 'Invoice Number must be unique per Company!'),
]
def _compute_portal_url(self):
super(AccountInvoice, self)._compute_portal_url()
for order in self:
order.portal_url = '/my/invoices/%s' % (order.id)
def _no_existing_validated_invoice(self):
self.ensure_one()
if self.journal_id.refund_sequence:
@@ -480,6 +493,27 @@ class AccountInvoice(models.Model):
view_id = get_view_id('invoice_form', 'account.invoice.form').id
return super(AccountInvoice, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
@api.model_cr_context
def _init_column(self, column_name):
""" Initialize the value of the given column for existing rows.
Overridden here because we need to generate different access tokens
and by default _init_column calls the default method once and applies
it for every record.
"""
if column_name != 'access_token':
super(AccountInvoice, self)._init_column(column_name)
else:
query = """UPDATE %(table_name)s
SET %(column_name)s = md5(md5(random()::varchar || id::varchar) || clock_timestamp()::varchar)::uuid::varchar
WHERE %(column_name)s IS NULL
""" % {'table_name': self._name, 'column_name': column_name}
self.env.cr.execute(query)
def _generate_access_token(self):
for invoice in self:
invoice.access_token = self._get_default_access_token()
@api.multi
def invoice_print(self):
""" Print the invoice and mark it as sent, so that we can see more
@@ -692,7 +726,6 @@ class AccountInvoice(models.Model):
raise UserError(_("Invoice must be in draft or open state in order to be cancelled."))
return self.action_cancel()
@api.multi
def _notification_recipients(self, message, groups):
groups = super(AccountInvoice, self)._notification_recipients(message, groups)
@@ -715,24 +748,26 @@ class AccountInvoice(models.Model):
try:
record.check_access_rule('read')
except exceptions.AccessError:
pass
if self.env.context.get('force_website'):
return {
'type': 'ir.actions.act_url',
'url': '/my/invoices/%s' % self.id,
'target': 'self',
'res_id': self.id,
}
else:
pass
else:
return {
'type': 'ir.actions.act_url',
'url': '/my/invoices?', # No controller /my/invoices/<int>, only a report pdf
'url': '/my/invoices/%s?access_token=%s' % (self.id, self.access_token),
'target': 'self',
'res_id': self.id,
}
return super(AccountInvoice, self).get_access_action(access_uid)
def get_mail_url(self):
self.ensure_one()
params = {
'model': self._name,
'res_id': self.id,
}
params.update(self.partner_id.signup_get_auth_param()[self.partner_id.id])
return '/mail/view?' + url_encode(params)
return self.get_share_url()
@api.multi
def get_formview_id(self, access_uid=None):
@@ -1367,6 +1402,7 @@ class AccountInvoiceLine(models.Model):
ondelete='set null', index=True, oldname='uos_id')
product_id = fields.Many2one('product.product', string='Product',
ondelete='restrict', index=True)
product_image = fields.Binary('Product Image', related="product_id.image", store=False)
account_id = fields.Many2one('account.account', string='Account',
required=True, domain=[('deprecated', '=', False)],
default=_default_account,
@@ -225,6 +225,17 @@
</div>
</div>
</div>
<div class="col-xs-12 col-md-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="module_account_payment"/>
</div>
<div class="o_setting_right_pane">
<label string="Online Payment"/>
<div class="text-muted">
Let your customers pay their invoices online
</div>
</div>
</div>
<div class="col-xs-12 col-md-6 o_setting_box" groups="account.group_account_user">
<div class="o_setting_left_pane">
<field name="module_account_batch_deposit" widget="upgrade_boolean"/>
@@ -368,6 +368,8 @@
</div>
<field name="has_outstanding" invisible="1"/>
<sheet string="Invoice">
<div class="oe_button_box" name="button_box">
</div>
<label string="Credit Note" attrs="{'invisible': ['|',('state','=','draft'), ('type','!=','out_refund')]}"/>
<h1 class="mt0">
<div attrs="{'invisible': [('sequence_number_next_prefix','!=',False)]}">
@@ -4,6 +4,9 @@
<li t-if="page_name == 'invoice'" class="active">
Invoices
</li>
<li t-if="invoice">
<t t-esc="invoice.number"/>
</li>
</xpath>
</template>
@@ -25,7 +28,7 @@
<p>There are currently no invoices and payments for your account.</p>
</t>
<div t-if="invoices" class="panel panel-default">
<div class="table-responsive"><table class="table table-hover o_my_status_table">
<div class="table-responsive"><table class="table table-hover o_portal_my_doc_table">
<thead>
<tr class="active">
<th>Invoice #</th>
@@ -38,7 +41,7 @@
<t t-foreach="invoices" t-as="invoice">
<tr>
<td>
<a t-att-href="'/my/invoices/'+str(invoice.id)" t-att-title="invoice.number"><t t-esc="invoice.number"/></a>
<a t-attf-href="/my/invoices/#{invoice.id}" t-att-title="invoice.number"><t t-esc="invoice.number"/></a>
</td>
<td><span t-field="invoice.date_invoice"/></td>
<td class='hidden-xs'><span t-field="invoice.date_due"/></td>
@@ -63,4 +66,199 @@
</div>
</t>
</template>
</odoo>
<template id="portal_invoice_page" name="Invoice Portal Template">
<t t-call="portal.portal_layout">
<div class="container">
<div class="panel panel-default">
<div class="panel-heading">
<div class="row">
<div class="col-md-6">
<h4>
<t t-call='portal.record_pager'/>
Invoice
<span t-esc="invoice.number"/>
</h4>
</div>
<div class="col-md-6 col-sm-6 col-xs-12 hidden-print" id="action_buttons">
<ul class="list-inline pull-right">
<li class="o_download_button">
<a target="_blank" t-att-href="'/my/invoices/pdf/%s' % invoice.id" title="Download">
<i class="fa fa-download fa-2x" aria-hidden="true"/>
</a>
</li>
<li class="o_print_button">
<a id="print_iframe" title="Print">
<i class="fa fa-print fa-2x" aria-hidden="true"/>
</a>
</li>
</ul>
</div>
</div>
</div>
<div class="panel-body">
<t t-call="account.portal_invoice_report"/>
</div>
</div>
</div>
<div class="oe_structure mb32"/>
</t>
</template>
<template id="portal_invoice_report" name="Invoice Portal Report">
<div class="row">
<div class="col-xs-5 col-xs-offset-7">
<address t-field="invoice.partner_id"
t-options='{"widget": "contact", "fields": ["address", "name"], "no_marker": True}' />
<div t-if="invoice.partner_id.vat" class="mt16">VAT: <span t-field="invoice.partner_id.vat"/></div>
</div>
</div>
<h2>
<span t-if="invoice.type == 'out_invoice' and (invoice.state == 'open' or invoice.state == 'paid')">Invoice</span>
<span t-if="invoice.type == 'out_invoice' and invoice.state == 'draft'">Draft Invoice</span>
<span t-if="invoice.type == 'out_invoice' and invoice.state == 'cancel'">Cancelled Invoice</span>
<span t-if="invoice.type == 'out_refund'">Credit Note</span>
<span t-if="invoice.type == 'in_refund'">Vendor Credit Note</span>
<span t-if="invoice.type == 'in_invoice'">Vendor Bill</span>
<span t-field="invoice.number"/>
</h2>
<div class="row mt32 mb32">
<div class="col-xs-2" t-if="invoice.name">
<strong>Description:</strong>
<p t-field="invoice.name"/>
</div>
<div class="col-xs-2" t-if="invoice.date_invoice">
<strong>Invoice Date:</strong>
<p t-field="invoice.date_invoice"/>
</div>
<div class="col-xs-2" t-if="invoice.date_due and invoice.type == 'out_invoice' and (invoice.state == 'open' or invoice.state == 'paid')">
<strong>Due Date:</strong>
<p t-field="invoice.date_due"/>
</div>
<div class="col-xs-2" t-if="invoice.origin">
<strong>Source:</strong>
<p t-field="invoice.origin"/>
</div>
<div class="col-xs-2" t-if="invoice.partner_id.ref">
<strong>Customer Code:</strong>
<p t-field="invoice.partner_id.ref"/>
</div>
<div name="reference" class="col-xs-2" t-if="invoice.reference">
<strong>Reference:</strong>
<p t-field="invoice.reference"/>
</div>
</div>
<!-- Is there a discount on at least one line? -->
<t t-set="display_discount" t-value="any([l.discount for l in invoice.invoice_line_ids])"/>
<table class="table table-condensed">
<thead>
<tr>
<th>Description</th>
<th class="hidden">Source Document</th>
<th class="text-right">Quantity</th>
<th class="text-right">Unit Price</th>
<th t-if="display_discount" class="text-right">Disc.(%)</th>
<th class="text-right">Taxes</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody class="o_portal_report_tbody">
<tr t-foreach="invoice.invoice_line_ids" t-as="l">
<td><span t-field="l.name"/></td>
<td class="hidden"><span t-field="l.origin"/></td>
<td class="text-right">
<span t-field="l.quantity"/>
<span t-field="l.uom_id" groups="product.group_uom"/>
</td>
<td class="text-right">
<span t-field="l.price_unit"/>
</td>
<td t-if="display_discount" class="text-right">
<span t-field="l.discount"/>
</td>
<td class="text-right">
<span t-esc="', '.join(map(lambda x: (x.description or x.name), l.invoice_line_tax_ids))"/>
</td>
<td class="text-right" id="subtotal">
<span t-field="l.price_subtotal"
t-options='{"widget": "monetary", "display_currency": invoice.currency_id}'/>
</td>
</tr>
<tr t-foreach="range(max(5-len(invoice.invoice_line_ids),0))" t-as="l">
<td>&amp;nbsp;</td>
<td class="hidden"></td>
<td></td>
<td></td>
<td t-if="display_discount" groups="sale.group_discount_per_so_line"></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
<div id="total" class="row">
<div class="col-xs-4 pull-right">
<table class="table table-condensed">
<tr class="border-black">
<td><strong>Subtotal</strong></td>
<td class="text-right">
<span t-field="invoice.amount_untaxed" t-options='{"widget": "monetary", "display_currency": invoice.currency_id}'/>
</td>
</tr>
<t t-foreach="invoice._get_tax_amount_by_group()" t-as="amount_by_group">
<tr>
<td><span t-esc="amount_by_group[0] if len(invoice.tax_line_ids) &gt; 1 else (invoice.tax_line_ids.tax_id.description or invoice.tax_line_ids.tax_id.name)"/></td>
<td class="text-right">
<span t-esc="amount_by_group[1]" t-options='{"widget": "monetary", "display_currency": invoice.currency_id}'/>
</td>
</tr>
</t>
<tr class="border-black">
<td><strong>Total</strong></td>
<td class="text-right">
<span t-field="invoice.amount_total" t-options='{"widget": "monetary", "display_currency": invoice.currency_id}'/>
</td>
</tr>
</table>
</div>
</div>
<!-- DO NOT REMOVE THIS TABLE. MANDATORY IN SOME COUNTRIES -->
<div class="row" t-if="len(invoice.tax_line_ids) &gt; 0">
<div class="col-xs-6">
<table class="table table-condensed">
<thead>
<tr>
<th>Tax</th>
<th class="text-right">Base</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
<tr t-foreach="invoice.tax_line_ids" t-as="t">
<td><span t-field="t.tax_id.description"/></td>
<td class="text-right">
<span t-field="t.base" t-options='{"widget": "monetary", "display_currency": invoice.currency_id}'/>
</td>
<td class="text-right">
<span t-field="t.amount" t-options='{"widget": "monetary", "display_currency": invoice.currency_id}'/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p t-if="invoice.comment">
<span t-field="invoice.comment"/>
</p>
<p t-if="invoice.payment_term_id">
<span t-field="invoice.payment_term_id.note"/>
</p>
<p t-if="invoice.fiscal_position_id.note">
<span t-field="invoice.fiscal_position_id.note"/>
</p>
</template>
</odoo>
@@ -1,7 +1,7 @@
<odoo>
<template id="portal_my_invoices_payment" name="Payment on My Invoices" inherit_id="account.portal_my_invoices">
<xpath expr="//table[hasclass('o_my_status_table')]/thead/tr/th[last()]" position="before">
<th></th>
<xpath expr="//table[hasclass('o_portal_my_doc_table')]/thead/tr/th[last()]" position="before">
<th>Status</th>
</xpath>
<xpath expr="//t[@t-foreach='invoices']/tr/td[last()]" position="before">
<td>
@@ -1,31 +1,38 @@
.o_portal {
.breadcrumb {
margin-bottom: 0px;
display: inline-block;
}
}
.o_my_status_table {
text-align: right;
tr > :first-child {
text-align: left;
}
tr th {
text-align: center;
> tbody.o_portal_report_tbody {
vertical-align: middle;
}
}
.o_my_home_content > .page-header > a:hover {
text-decoration: none;
}
.o_portal_wrap {
.o_portal_my_home > .page-header > a:hover {
text-decoration: none;
}
.portal-wrap table.table tr {
word-wrap: break-word;
}
.o_portal_my_doc_table {
td, th {
text-align: right;
}
tr > :first-child {
text-align: left;
}
}
.search_panel_fixed_width {
overflow: hidden;
max-width: 125px;
text-overflow: ellipsis;
white-space: nowrap;
.o_portal_search_panel_fixed_width {
overflow: hidden;
max-width: 125px;
text-overflow: ellipsis;
white-space: nowrap;
}
table.table tr {
word-wrap: break-word;
}
}
.oe_attachments .o_image_small {
@@ -33,10 +40,6 @@
width: 50px;
}
.breadcrumb {
display: inline-block;
}
.o_portal_chatter {
padding: 10px;
+5 -5
View File
@@ -74,7 +74,7 @@
</div>
</div>
</div>
<div id="wrap" class='portal-wrap'>
<div id="wrap" class='o_portal_wrap'>
<div class="container mb64">
<t t-if="my_details">
<div class="row">
@@ -117,7 +117,7 @@
<template id="portal_my_home" name="My Portal">
<t t-call="portal.portal_layout">
<t t-set="my_details" t-value="True"/>
<div class="o_my_home_content">
<div class="o_portal_my_home">
<div class="oe_structure"></div>
<h3 class="page-header">Your Documents</h3>
<ul class="o_portal_docs list-group">
@@ -159,7 +159,7 @@
</li>
</ul>
</div>
<input type="text" class="form-control search_panel_fixed_width" t-att-value='search' name="search"/>
<input type="text" class="form-control o_portal_search_panel_fixed_width" t-att-value='search' name="search"/>
<span class="input-group-btn">
<button class="btn btn-default search-submit" type="button"><span class="fa fa-search"></span></button>
</span>
@@ -167,7 +167,7 @@
</div>
</t>
<div t-if="searchbar_sortings" class="dropdown pull-right mr4">
<button id="portal_searchbar_sortby" class="search_panel_fixed_width btn btn-default" type="button" data-toggle="dropdown">
<button id="portal_searchbar_sortby" class="o_portal_search_panel_fixed_width btn btn-default" type="button" data-toggle="dropdown">
<span class="fa fa-sort fa-lg" />
<span class='hidden-xs hidden-sm hidden-md' t-esc="searchbar_sortings[sortby].get('label', 'Newest')"/>
<span class="caret"></span>
@@ -181,7 +181,7 @@
</ul>
</div>
<div t-if="searchbar_filters" class="dropdown pull-right mr4">
<button id="portal_searchbar_filters" class="search_panel_fixed_width btn btn-default" type="button" data-toggle="dropdown">
<button id="portal_searchbar_filters" class="o_portal_search_panel_fixed_width btn btn-default" type="button" data-toggle="dropdown">
<span class="fa fa-filter fa-lg" />
<span class='hidden-xs hidden-sm hidden-md' t-esc="searchbar_filters[filterby].get('label', 'All')"/>
<span class="caret"></span>
@@ -43,7 +43,7 @@
</div>
</t>
<div t-if="projects" class="panel panel-default">
<div class="table-responsive"><table class="table table-hover o_my_status_table">
<div class="table-responsive"><table class="table table-hover o_portal_my_doc_table">
<thead>
<tr class="active">
<th class="col-md-6">Name</th>
@@ -136,7 +136,7 @@
</div>
</t>
<div t-if="tasks" class="panel panel-default">
<div class="table-responsive"><table class="table table-hover o_my_status_table">
<div class="table-responsive"><table class="table table-hover o_portal_my_doc_table">
<thead>
<tr class="active">
<th class="col-md-10">Task</th>
+1 -1
View File
@@ -28,7 +28,7 @@
<t t-call="portal.portal_searchbar"/>
</h3>
<div t-if="orders" class="panel panel-default">
<div class="table-responsive"><table class="table table-hover o_my_status_table">
<div class="table-responsive"><table class="table table-hover o_portal_my_doc_table">
<thead>
<tr class="active">
<th>Purchase Orders #</th>
+2 -2
View File
@@ -46,7 +46,7 @@
<p>There are currently no quotations for your account.</p>
</t>
<div t-if="quotations" class="panel panel-default">
<div class="table-responsive"><table class="table table-hover o_my_status_table">
<div class="table-responsive"><table class="table table-hover o_portal_my_doc_table">
<thead>
<tr class="active">
<th>Quotation #</th>
@@ -92,7 +92,7 @@
<p>There are currently no orders for your account.</p>
</t>
<div t-if="orders" class="panel panel-default">
<div class="table-responsive"><table class="table table-hover o_my_status_table">
<div class="table-responsive"><table class="table table-hover o_portal_my_doc_table">
<thead>
<tr class="active">
<th>
+1 -1
View File
@@ -32,7 +32,7 @@ class PaymentTransaction(models.Model):
tx = getattr(self, tx_find_method_name)(data)
_logger.info('<%s> transaction processed: tx ref:%s, tx amount: %s', acquirer_name, tx.reference if tx else 'n/a', tx.amount if tx else 'n/a')
if tx:
if tx and tx.sale_order_id:
# Auto-confirm SO if necessary
tx._confirm_so()