IAP doc: tutorial (#20317)

* [ADD] doc: iap tutorial

Should be easier to approach than the raw APIDoc. 
contains:
- overview of the flow
- tutorial (coalroller) with guidelines
- technical description of objects/helpers
This commit is contained in:
xmo-odoo
2017-11-02 19:06:33 +01:00
committed by rar-odoo
parent 9818f1c535
commit b62bb5add2
21 changed files with 569 additions and 63 deletions
+1
View File
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from .models.iap import jsonrpc, charge, InsufficientCreditError
+8 -1
View File
@@ -82,7 +82,14 @@ def charge(env, key, account_token, credit, description=None, credit_template=No
:param str key: service identifier
:param str account_token: user identifier
:param int credit: cost of the body's operation
:param str description:
:param description: a description of the purpose of the charge,
the user will be able to see it in their
dashboard
:type description: str
:param credit_template: a QWeb template to render and show to the
user if their account does not have enough
credits for the requested operation
:type credit_template: str
"""
endpoint = get_endpoint(env)
params = {
+4
View File
@@ -381,6 +381,10 @@ class BootstrapTranslator(nodes.NodeVisitor, object):
"Unsupported alignment value \"%s\"" % node['align'],
location=doc
)
elif 'align' in node.parent and node.parent['align'] == 'center':
# figure > image
attrs['class'] += ' center-block'
# todo: explicit width/height/scale?
self.body.append(self.starttag(node, 'img', **attrs))
def depart_image(self, node): pass
-1
View File
@@ -22,4 +22,3 @@ diff --git a/academy/security/ir.model.access.csv b/academy/security/ir.model.ac
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_academy_teachers,access_academy_teachers,model_academy_teachers,,1,0,0,0
+access_academy_courses,access_academy_courses,model_academy_courses,,1,0,0,0
\ No newline at end of file
+2 -5
View File
@@ -9,7 +9,7 @@ diff --git a/academy/__manifest__.py b/academy/__manifest__.py
'security/ir.model.access.csv',
'templates.xml',
'views.xml',
+ 'data.xml',
+ 'data.xml',
],
# only loaded in demonstration mode
'demo': [
@@ -79,11 +79,8 @@ diff --git a/academy/security/ir.model.access.csv b/academy/security/ir.model.ac
+++ b/academy/security/ir.model.access.csv
@@ -1,3 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
-access_academy_teachers,access_academy_teachers,model_academy_teachers,,1,0,0,0
access_academy_teachers,access_academy_teachers,model_academy_teachers,,1,0,0,0
-access_academy_courses,access_academy_courses,model_academy_courses,,1,0,0,0
\ No newline at end of file
+access_academy_teachers,access_academy_teachers,model_academy_teachers,,1,0,0,0
\ No newline at end of file
diff --git a/academy/views.xml b/academy/views.xml
--- a/academy/views.xml
+++ b/academy/views.xml
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+338 -56
View File
@@ -1,10 +1,41 @@
:types: api
:code-column:
.. _webservices/iap:
.. using sphinx-patchqueue:
* the "queue" directive selects a *series* file which lists the patches in
the patch queue, in order of application (from top to bottom). The
corresponding patch files should be in the same directory.
* the "patch" directive steps to the next patch in the queue, applies it
and reifies its content (depending on the extension's configuration, by
default it shows the changed files post-diff application, slicing to
only display sections affecte by the file)
.. while it's technically possible to apply and update patches by hand, it's
finnicky work and easy to break.
.. the easiest way is to install quilt (http://savannah.nongnu.org/projects/quilt),
go to the directory where you want to reify the addon, then create a
"patches" symlink to the patches directory (the iap/ folder next to this
file) or set QUILT_PATCHES to that folder.
.. at that point you have a "primed" queue with no patch applied, and you can
move within the queue with "quilt push" and "quilt pop".
* "quilt new" creates a new empty patch at the top of the stack
* "quilt add" tells quilt to start tracking the file, quilt add *works per
patch*, it must be called *every time you want to alter a file within a
patch*: quilt is not a full VCS (since it's intended to sit on top of
an existing source) and does not do permanent tracking of files
* "quilt edit" is a shorthand to "quilt add" then open the file in your
editor, I suggest you use that rather than open the edited module
normally, it avoids forgetting to "quilt add" before doing your
modifications (at which point your modifications are untracked,
invisible and depending on your editor may be a PITA to revert & redo)
* "quilt refresh" updates the current patch to include pending changes
.. see "man quilt" for the rest of the subcommands. FWIW I could not get
"quilt setup" to do anything useful.
================
In-App Purchases
================
@@ -13,11 +44,12 @@ IAP allow providers of ongoing services through Odoo apps to be compensated
for ongoing service use rather than — and possibly instead of — a sole initial
purchase.
In that context, Odoo acts mostly as a *broker* between the service user
(client) and the service provider (Odoo App developer):
In that context, Odoo acts mostly as a *broker* between a client and an Odoo
App Developer:
* users purchase service tokens from Odoo
* service providers draw tokens from the user's Odoo account
* service providers draw tokens from the user's Odoo account when service
is requested
.. attention::
@@ -25,15 +57,256 @@ In that context, Odoo acts mostly as a *broker* between the service user
which can be done either via direct JSON-RPC2_ or if you are using Odoo
using the convenience helpers it provides.
.. image:: flow.png
Overview
========
.. figure:: images/players.png
:align: center
.. contents::
:local:
The Players
* The Service Provider is (probably) you the reader, you will be providing
value to the client in the form of a service paid per-use
* The Client installed your Odoo App, and from there will request services
* Odoo brokers crediting, the Client adds credit to their account, and you
can draw credits from there to provide services
* The External Service is an optional player: *you* can either provide a
service directly, or you can delegate the actual service acting as a
bridge/translator between an Odoo system and the actual service
.. note:: in the following explanations we will ignore the External Service,
they're just a detail of the service you provide
.. figure:: images/normal.png
:align: center
A "normal" service flow
If everything goes well, the normal flow is:
1. the Client requests a service of some sort
2. the Service Provider asks Odoo if there are enough credits for the
service in the Client's account, and creates a transaction over that
amount
3. the Service Provider provides the service (either on their own or
calling to External Services)
4. the Service Provider goes back to Odoo to capture (if the service could
be provided) or cancel (if the service could not be provided) the
transaction created at step 2
5. finally the Service Provider notifies the Client that the service has
been rendered, possibly (depending on the service) displaying or
storing its results in the client's system
.. figure:: images/no-credit.png
:align: center
Insufficient Credits
If the Client's account lacks credits for the service, however
1. the Client requests a service as previously
2. the Service Provider asks Odoo if there are enough credits on the
Client's account and gets a negative reply
3. this is signaled back to the Client
4. who is redirected to their Odoo account to credit it and re-try
Building your service
=====================
For this example, the service we will provide is ~~mining dogecoins~~ burning
10 seconds of CPU for a credit. For your own services, you could for example:
* provide an online service yourself (e.g. convert quotations to faxes for
business in Japan)
* provide an *offline* service yourself (e.g. provide accountancy service)
* act as intermediary to an other service provider (e.g. bridge to an MMS
gateway)
Register the service on Odoo
----------------------------
.. queue:: iap_service/series
.. todo:: complete this part with screenshots
The first step is to register your service on the IAP endpoint (production
and/or test) before you can actually query user accounts. To create a service,
go to your *Portal Account* on the IAP endpoint (https://iap.odoo.com for
production, http://iap-sandbox.odoo.com for testing, the endpoints are
*independent* and *not synchronized*).
Log in then go to :menuselection:`My Account --> Your InApp Services`, click
Create and provide the name of your service.
The now created service has *two* important fields:
* :samp:`name` - :class:`ServiceName`: this will identify your service in the
client's :ref:`app <iap-odoo-app>` communicates directly with IAP.
* :samp:`key` - :class:`ServiceKey`: the developer key that identifies you in
IAP (see :ref:`your service <iap-service>`) and allows to draw credits from the client's account.
.. warning::
The :class:`ServiceName` is unique and should usually match the name of your
Odoo App.
.. danger::
Your :class:`ServiceKey` *is a secret*, leaking your service key
allows other application developers to draw credits bought for
your service(s).
.. image:: images/service_select.png
:align: center
.. image:: images/service_create.png
:align: center
.. image:: images/service_packs.png
:align: center
You can then create *credit packs* which clients can purchase in order to
use your service.
.. _iap-odoo-app:
Odoo App
--------
.. queue:: iap/series
.. todo:: does this actually require apps?
The second step is to develop an `Odoo App`_ which clients can install in their
Odoo instance and through which they can *request* services you will provide.
Our app will just add a button to the Partners form which lets a user request
burning some CPU time on the server.
First, we'll create an *odoo module* depending on ``iap``. IAP is a standard
V11 module and the dependency ensures a local account is properly set up and
we will have access to some necessary views and useful helpers
.. patch::
Second, the "local" side of the integration, here we will only be adding an
action button to the partners view, but you can of course provide significant
local value via your application and additional parts via a remote service.
.. patch::
.. image:: images/button.png
:align: center
We can now implement the action method/callback. This will *call our own
server*.
There are no requirements when it comes to the server or the communication
protocol between the app and our server, but ``iap`` provides a
:func:`~odoo.addons.iap.jsonrpc` helper to call a JSON-RPC2_ endpoint on an
other Odoo instance and transparently re-raise relevant Odoo exceptions
(:class:`~odoo.addons.iap.models.iap.InsufficientCreditError`,
:class:`odoo.exceptions.AccessError` and :class:`odoo.exceptions.UserError`).
In that call, we will need to provide:
* any relevant client parameter (none here)
* the :class:`token <UserToken>` of the current client, this is provided by
the ``iap.account`` model's ``account_token`` field. You can retrieve the
account for your service by calling :samp:`env['iap.account'].get({service_name})`
where :class:`service_name <ServiceName>` is the name of the service registered
on IAP endpoint.
.. patch::
.. note::
``iap`` automatically handles
:class:`~odoo.addons.iap.models.iap.InsufficientCreditError` coming from the action
and prompts the user to add credits to their account.
:func:`~odoo.addons.iap.jsonrpc` takes care of re-raising
:class:`~odoo.addons.iap.models.iap.InsufficientCreditError` for you.
.. danger::
If you are not using :func:`~odoo.addons.iap.jsonrpc` you *must* be
careful to re-raise
:class:`~odoo.addons.iap.models.iap.InsufficientCreditError` in your handler
otherwise the user will not be prompted to credit their account, and the
next call will fail the same way.
.. _iap-service:
Service
-------
.. queue:: iap_service/series
Though that is not *required*, since ``iap`` provides both a client helper
for JSON-RPC2_ calls (:func:`~odoo.addons.iap.jsonrpc`) and a service helper
for transactions (:class:`~odoo.addons.iap.models.iap.charge`) we will also be
implementing the service side as an Odoo module:
.. patch::
Since the query from the client comes as JSON-RPC2_ we will need the
corresponding controller which can call :class:`~odoo.addons.iap.models.iap.charge` and
perform the service within:
.. patch::
.. todo:: for the actual IAP will the "portal" page be on odoo.com or iap.odoo.com?
.. todo:: "My Account" > "Your InApp Services"?
The :class:`~odoo.addons.iap.models.iap.charge` helper will:
1. authorize (create) a transaction with the specified number of credits,
if the account does not have enough credits it will raise the relevant
error
2. execute the body of the ``with`` statement
3. if the body of the ``with`` executes succesfully, capture (confirm) the
transaction
4. otherwise if an error is raised from the body of the ``with`` cancel the
transaction (and release the hold on the credits)
.. danger::
By default, :class:`~odoo.addons.iap.models.iap.charge` contacts the *production*
IAP endpoint, https://iap.odoo.com. While developing and testing your
service you may want to point it towards the *development* IAP endpoint
https://iap-sandbox.odoo.com.
To do so, set the ``iap.endpoint`` config parameter in your service
Odoo: in debug/developer mode, :menuselection:`Setting --> Technical -->
Parameters --> System Parameters`, just define an entry for the key
``iap.endpoint`` if none already exists).
The :class:`~odoo.addons.iap.models.iap.charge` helper has two additional optional
parameters we can use to make things clearer to the end-user:
``description``
is a message which will be associated with the transaction and will be
displayed in the user's dashboard, it is useful to remind the user why
the charge exists
``credit_template``
is the name of a :ref:`reference/qweb` template which will be rendered
and shown to the user if their account has less credit available than the
service provider is requesting, its purpose is to tell your users why
they should be interested in your IAP offers
.. patch::
.. TODO:: how do you test your service?
JSON-RPC2_ Transaction API
==========================
.. image:: images/flow.png
:align: center
* The IAP transaction API does not require using Odoo when implementing your
server gateway, calls are standard JSON-RPC2_.
* Calls use different *endpoints* but the same *method* on all endpoints
@@ -41,39 +314,6 @@ JSON-RPC2_ Transaction API
* Exceptions are returned as JSON-RPC2_ errors, the formal exception name is
available on ``data.name`` for programmatic manipulation.
.. class:: ServiceKey
Identifier generated for the provider's service. Each key (and service)
matches a token of a fixed value, as generated by the service provide.
Multiple types of tokens correspond to multiple services e.g. SMS and MMS
could either be the same service (with an MMS being "worth" multiple SMS)
or could be separate services at separate price points.
.. danger:: your service key *is a secret*, leaking your service key
allows other application developers to draw credits bought for
your service(s)
.. class:: UserToken
Identifier for a user account.
.. class:: TransactionToken
Transaction identifier, returned by the authorization process and consumed
by either capturing or cancelling the transaction
.. exception:: odoo.addons.iap.models.iap.NoCreditError
Raised during transaction authorization if the credits requested are not
currently available on the account (either not enough credits or too many
pending transactions/existing holds).
.. exception:: odoo.addons.iap.models.iap.BadAuthError
Raised by any operation to which a service token is required, if the
service token is invalid.
Authorize
---------
@@ -94,12 +334,10 @@ Authorize
:param str description: optional, helps users identify the reason for
charges on their accounts.
:returns: :class:`TransactionToken` if the authorization succeeded.
:raises: :class:`~odoo.addons.iap.models.iap.BadAuthError` if the service token is invalid
:raises: :class:`~odoo.addons.iap.models.iap.NoCreditError` if the account does
:raises: :class:`~odoo.exceptions.AccessError` if the service token is invalid
:raises: :class:`~odoo.addons.iap.models.iap.InsufficientCreditError` if the account does
:raises: ``TypeError`` if the ``credit`` value is not an integer
.. rst-class:: doc-aside
.. code-block:: python
r = requests.post(ODOO + '/iap/1/authorize', json={
@@ -132,9 +370,7 @@ Capture
:param TransactionToken token:
:param ServiceKey key:
:raises: :class:`~odoo.addons.iap.models.iap.BadAuthError`
.. rst-class:: doc-aside
:raises: :class:`~odoo.exceptions.AccessError`
.. code-block:: python
@@ -164,9 +400,7 @@ Cancel
:param TransactionToken token:
:param ServiceKey key:
:raises: :class:`~odoo.addons.iap.models.iap.BadAuthError`
.. rst-class:: doc-aside
:raises: :class:`~odoo.exceptions.AccessError`
.. code-block:: python
@@ -183,6 +417,56 @@ Cancel
# handle cancel error
# otherwise transaction is cancelled
Types
-----
Exceptions aside, these are *abstract types* used for clarity, you should not
care how they are implemented
.. class:: ServiceName
String identifying your service on https://iap.odoo.com (production) as well
as the account related to your service in the client's database.
.. class:: ServiceKey
Identifier generated for the provider's service. Each key (and service)
matches a token of a fixed value, as generated by the service provide.
Multiple types of tokens correspond to multiple services e.g. SMS and MMS
could either be the same service (with an MMS being "worth" multiple SMS)
or could be separate services at separate price points.
.. danger:: your service key *is a secret*, leaking your service key
allows other application developers to draw credits bought for
your service(s).
.. class:: UserToken
Identifier for a user account.
.. class:: TransactionToken
Transaction identifier, returned by the authorization process and consumed
by either capturing or cancelling the transaction
.. exception:: odoo.addons.iap.models.iap.InsufficientCreditError
Raised during transaction authorization if the credits requested are not
currently available on the account (either not enough credits or too many
pending transactions/existing holds).
.. exception:: odoo.exceptions.AccessError
Raised by:
* any operation to which a service token is required, if the service token is invalid.
* any failure in an inter-server call. (typically, in :func:`~odoo.addons.iap.jsonrpc`)
.. exception:: odoo.exceptions.UserError
Raised by any unexpeted behaviour at the discretion of the App developer (*you*).
Odoo Helpers
============
@@ -192,7 +476,7 @@ module provides a few helpers to make IAP flow even simpler:
Charging
--------
.. class:: odoo.addons.iap.models.iap.charge(env, key, account_token, credit[, description])
.. class:: odoo.addons.iap.models.iap.charge(env, key, account_token, credit[, description, credit_template])
A *context manager* for authorizing and automatically capturing or
cancelling transactions for use in the backend/proxy.
@@ -211,8 +495,6 @@ Charging
:param int credit:
:param str description:
.. rst-class:: doc-aside
.. code-block:: python
@route('/deathstar/superlaser', type='json')
@@ -231,5 +513,5 @@ Charging
('name', '=', 'Alderaan'),
]).unlink()
.. _JSON-RPC2: http://www.jsonrpc.org/specification
.. _Odoo App: https://www.odoo.com/apps
+16
View File
@@ -0,0 +1,16 @@
Index: coalroller/coalroller/__manifest__.py
===================================================================
--- /dev/null
+++ coalroller/coalroller/__manifest__.py
@@ -0,0 +1,5 @@
+{
+ 'name': "Coal Roller",
+ 'category': 'Tools',
+ 'depends': ['iap'],
+}
Index: coalroller/coalroller/__init__.py
===================================================================
--- /dev/null
+++ coalroller/coalroller/__init__.py
@@ -0,0 +1 @@
+# -*- coding: utf-8 -*-
+34
View File
@@ -0,0 +1,34 @@
Index: coalroller/coalroller/__manifest__.py
===================================================================
--- coalroller.orig/coalroller/__manifest__.py
+++ coalroller/coalroller/__manifest__.py
@@ -2,4 +2,7 @@
'name': "Coal Roller",
'category': 'Tools',
'depends': ['iap'],
+ 'data': [
+ 'views/views.xml',
+ ],
}
Index: coalroller/coalroller/views/views.xml
===================================================================
--- /dev/null
+++ coalroller/coalroller/views/views.xml
@@ -0,0 +1,17 @@
+<odoo>
+ <record model="ir.ui.view" id="partner_form_coalroll">
+ <field name="name">partner.form.coalroll</field>
+ <field name="model">res.partner</field>
+ <field name="inherit_id" ref="base.view_partner_form" />
+ <field name="arch" type="xml">
+ <xpath expr="//div[@name='button_box']">
+ <button type="object" name="action_partner_coalroll"
+ class="oe_stat_button" icon="fa-gears">
+ <div class="o_form_field o_stat_info">
+ <span class="o_stat_text">Roll Coal</span>
+ </div>
+ </button>
+ </xpath>
+ </field>
+ </record>
+</odoo>
+40
View File
@@ -0,0 +1,40 @@
Index: coalroller/coalroller/__init__.py
===================================================================
--- coalroller.orig/coalroller/__init__.py
+++ coalroller/coalroller/__init__.py
@@ -1 +1,2 @@
# -*- coding: utf-8 -*-
+from . import models
Index: coalroller/coalroller/models/__init__.py
===================================================================
--- /dev/null
+++ coalroller/coalroller/models/__init__.py
@@ -0,0 +1 @@
+from . import res_partner
Index: coalroller/coalroller/models/res_partner.py
===================================================================
--- /dev/null
+++ coalroller/coalroller/models/res_partner.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+from odoo import api, models
+from odoo.addons.iap import jsonrpc, InsufficientCreditError
+
+# whichever URL you deploy the service at, here we will run the remote
+# service in a local Odoo bound to the port 8070
+DEFAULT_ENDPOINT = 'http://localhost:8070'
+class Partner(models.Model):
+ _inherit = 'res.partner'
+ @api.multi
+ def action_partner_coalroll(self):
+ # fetch the user's token for our service
+ user_token = self.env['iap.account'].get('coalroller')
+ params = {
+ # we don't have any parameter to provide
+ 'account_token': user_token.account_token
+ }
+ # ir.config_parameter allows locally overriding the endpoint
+ # for testing & al
+ endpoint = self.env['ir.config_parameter'].sudo().get_param('coalroller.endpoint', DEFAULT_ENDPOINT)
+ jsonrpc(endpoint + '/roll', params=params)
+ return True
+3
View File
@@ -0,0 +1,3 @@
01-init
02-button
03-callback
+16
View File
@@ -0,0 +1,16 @@
Index: coalroller_service/coalroller_service/__init__.py
===================================================================
--- /dev/null
+++ coalroller_service/coalroller_service/__init__.py
@@ -0,0 +1 @@
+# -*- encoding: utf-8 -*-
Index: coalroller_service/coalroller_service/__manifest__.py
===================================================================
--- /dev/null
+++ coalroller_service/coalroller_service/__manifest__.py
@@ -0,0 +1,5 @@
+{
+ 'name': "Coal Roller Service",
+ 'category': 'Tools',
+ 'depends': ['iap'],
+}
+48
View File
@@ -0,0 +1,48 @@
Index: coalroller_service/coalroller_service/controllers/main.py
===================================================================
--- /dev/null
+++ coalroller_service/coalroller_service/controllers/main.py
@@ -0,0 +1,29 @@
+import time
+
+from passlib import pwd, hash
+
+from odoo import http
+from odoo.addons.iap import charge
+
+class CoalBurnerController(http.Controller):
+ @http.route('/roll', type='json', auth='none', csrf='false')
+ def roll(self, account_token):
+ # the service key *is a secret*, it should not be committed in
+ # the source
+ service_key = self.env['ir.config_parameter'].sudo().get_param('coalroller.service_key')
+
+ # we charge 1 credit for 10 seconds of CPU
+ cost = 1
+ # TODO: allow the user to specify how many (tens of seconds) of CPU they want to use
+ with charge(http.request.env, service_key, account_token, cost):
+
+ # 10 seconds of CPU per credit
+ end = time.time() + (10 * cost)
+ while time.time() < end:
+ # we will use CPU doing useful things: generating and
+ # hashing passphrases
+ p = pwd.genphrase()
+ h = hash.pbkdf2_sha512.hash(p)
+ # here we don't have anything useful to the client, an error
+ # will be raised & transmitted in case of issue, if no error
+ # is raised we did the job
Index: coalroller_service/coalroller_service/controllers/__init__.py
===================================================================
--- /dev/null
+++ coalroller_service/coalroller_service/controllers/__init__.py
@@ -0,0 +1,2 @@
+# -*- encoding: utf-8 -*-
+from . import main
Index: coalroller_service/coalroller_service/__init__.py
===================================================================
--- coalroller_service.orig/coalroller_service/__init__.py
+++ coalroller_service/coalroller_service/__init__.py
@@ -1 +1,2 @@
# -*- encoding: utf-8 -*-
+from . import controllers
+56
View File
@@ -0,0 +1,56 @@
Index: coalroller_service/coalroller_service/controllers/main.py
===================================================================
--- coalroller_service.orig/coalroller_service/controllers/main.py
+++ coalroller_service/coalroller_service/controllers/main.py
@@ -10,12 +10,14 @@ class CoalBurnerController(http.Controll
def roll(self, account_token):
# the service key *is a secret*, it should not be committed in
# the source
- service_key = self.env['ir.config_parameter'].sudo().get_param('coalroller.service_key')
+ service_key = http.request.env['ir.config_parameter'].sudo().get_param('coalroller.service_key')
# we charge 1 credit for 10 seconds of CPU
cost = 1
# TODO: allow the user to specify how many (tens of seconds) of CPU they want to use
- with charge(http.request.env, service_key, account_token, cost):
+ with charge(http.request.env, service_key, account_token, cost,
+ description="We're just obeying orders",
+ credit_template='coalroller_service.no_credit'):
# 10 seconds of CPU per credit
end = time.time() + (10 * cost)
Index: coalroller_service/coalroller_service/views/no-credit.xml
===================================================================
--- /dev/null
+++ coalroller_service/coalroller_service/views/no-credit.xml
@@ -0,0 +1,18 @@
+<odoo>
+ <template id="no_credit" name="No credit warning">
+ <div>
+ <div class="container-fluid">
+ <div class="row">
+ <div class="col-sm-7 col-md-offset-1 mt32 mb32">
+ <h2>Consume electricity doing nothing useful!</h2>
+ <ul>
+ <li>Heat our state of the art data center for no reason</li>
+ <li>Use multiple watts for only 0.1€</li>
+ <li>Roll coal without going outside</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+</odoo>
Index: coalroller_service/coalroller_service/__manifest__.py
===================================================================
--- coalroller_service.orig/coalroller_service/__manifest__.py
+++ coalroller_service/coalroller_service/__manifest__.py
@@ -2,4 +2,7 @@
'name': "Coal Roller Service",
'category': 'Tools',
'depends': ['iap'],
+ 'data': [
+ 'views/no-credit.xml',
+ ],
}
+3
View File
@@ -0,0 +1,3 @@
01-init
02-tnx
03-template
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB