mirror of
https://github.com/OCA/knowledge.git
synced 2025-07-26 10:28:40 -06:00
Reworking of some methods and views
This commit is contained in:
parent
f5d264b36c
commit
1cfaf44b25
60
external_file_location/README.rst
Normal file
60
external_file_location/README.rst
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
|
||||||
|
:alt: License
|
||||||
|
|
||||||
|
External File Location
|
||||||
|
======================
|
||||||
|
|
||||||
|
This module was written to extend the functionality of ir.attachment to support remote communication and allow you to import/export file to a remote server
|
||||||
|
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
To install this module, you need to:
|
||||||
|
|
||||||
|
* FTPUtil python module
|
||||||
|
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
|
||||||
|
To use this module, you need to:
|
||||||
|
|
||||||
|
* Add a location with your server infos
|
||||||
|
* Create a task with your file info and remote communication method
|
||||||
|
* A cron task will trigger each task
|
||||||
|
|
||||||
|
For further information, please visit:
|
||||||
|
|
||||||
|
* https://www.odoo.com/forum/help-1
|
||||||
|
|
||||||
|
Known issues / Roadmap
|
||||||
|
======================
|
||||||
|
|
||||||
|
* add SFTP protocol
|
||||||
|
* add FILESTORE protocol
|
||||||
|
|
||||||
|
Credits
|
||||||
|
=======
|
||||||
|
|
||||||
|
* Joel Grand-Guillaume Camptocamp
|
||||||
|
* initOS <http://initos.com>
|
||||||
|
* Valentin CHEMIERE <valentin.chemiere@akretion.com>
|
||||||
|
|
||||||
|
Contributors
|
||||||
|
------------
|
||||||
|
|
||||||
|
* Sebastien BEAU <sebastian.beau@akretion.com>
|
||||||
|
|
||||||
|
Maintainer
|
||||||
|
----------
|
||||||
|
|
||||||
|
* Valentin CHEMIERE <valentin.chemiere@akretion.com>
|
||||||
|
|
||||||
|
.. image:: http://odoo-community.org/logo.png
|
||||||
|
:alt: Odoo Community Association
|
||||||
|
:target: http://odoo-community.org
|
||||||
|
|
||||||
|
This module is maintained by the OCA.
|
||||||
|
|
||||||
|
OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.
|
||||||
|
|
||||||
|
To contribute to this module, please visit http://odoo-community.org.
|
@ -27,13 +27,14 @@
|
|||||||
'website': 'www.akretion.com',
|
'website': 'www.akretion.com',
|
||||||
'license': 'AGPL-3',
|
'license': 'AGPL-3',
|
||||||
'category': 'Generic Modules',
|
'category': 'Generic Modules',
|
||||||
'description': """
|
|
||||||
File exchange system with multiple protocol (SFTP, FTP, Filestore)
|
|
||||||
""",
|
|
||||||
'depends': [
|
'depends': [
|
||||||
'base',
|
|
||||||
'ir_attachment_metadata',
|
'ir_attachment_metadata',
|
||||||
],
|
],
|
||||||
|
'external_dependencies': {
|
||||||
|
'python': [
|
||||||
|
'ftputil',
|
||||||
|
],
|
||||||
|
},
|
||||||
'data': [
|
'data': [
|
||||||
'menu.xml',
|
'menu.xml',
|
||||||
'attachment_view.xml',
|
'attachment_view.xml',
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
#
|
#
|
||||||
# Module for OpenERP
|
# Module for OpenERP
|
||||||
# Copyright (C) 2014 Akretion (http://www.akretion.com).
|
# Copyright (C) 2015 Akretion (http://www.akretion.com).
|
||||||
# @author Sébastien BEAU <sebastien.beau@akretion.com>
|
# @author Valentin CHEMIERE <valentin.chemiere@akretion.com>
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
@ -41,7 +41,6 @@ def itersubclasses(cls, _seen=None):
|
|||||||
>>> [cls.__name__ for cls in itersubclasses(object)] #doctest: +ELLIPSIS
|
>>> [cls.__name__ for cls in itersubclasses(object)] #doctest: +ELLIPSIS
|
||||||
['type', ...'tuple', ...]
|
['type', ...'tuple', ...]
|
||||||
"""
|
"""
|
||||||
#import pdb; pdb.set_trace()
|
|
||||||
if not isinstance(cls, type):
|
if not isinstance(cls, type):
|
||||||
raise TypeError('itersubclasses must be called with '
|
raise TypeError('itersubclasses must be called with '
|
||||||
'new-style classes, not %.100r' % cls
|
'new-style classes, not %.100r' % cls
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
#
|
#
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
from openerp import models, fields
|
from openerp import models, fields, api
|
||||||
from .abstract_task import AbstractTask
|
from .abstract_task import AbstractTask
|
||||||
from .helper import itersubclasses
|
from .helper import itersubclasses
|
||||||
|
|
||||||
@ -28,11 +28,11 @@ class Location(models.Model):
|
|||||||
_name = 'external.file.location'
|
_name = 'external.file.location'
|
||||||
_description = 'Description'
|
_description = 'Description'
|
||||||
|
|
||||||
name = fields.Char(string='Name')
|
name = fields.Char(string='Name', required=True)
|
||||||
protocol = fields.Selection(selection='_get_protocol')
|
protocol = fields.Selection(selection='_get_protocol', required=True)
|
||||||
address = fields.Char(string='Address')
|
address = fields.Char(string='Address', required=True)
|
||||||
port = fields.Integer()
|
port = fields.Integer(required=True)
|
||||||
login = fields.Char()
|
login = fields.Char(required=True)
|
||||||
password = fields.Char()
|
password = fields.Char()
|
||||||
task_ids = fields.One2many('external.file.task', 'location_id')
|
task_ids = fields.One2many('external.file.task', 'location_id')
|
||||||
|
|
||||||
@ -45,3 +45,9 @@ class Location(models.Model):
|
|||||||
res.append(cls_info)
|
res.append(cls_info)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
@api.onchange('protocol')
|
||||||
|
def get_default_port(self):
|
||||||
|
for cls in itersubclasses(AbstractTask):
|
||||||
|
if cls._key == self.protocol:
|
||||||
|
self.port = cls._default_port
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
<sheet>
|
<sheet>
|
||||||
<group col="4">
|
<group col="4">
|
||||||
<div class="oe_title" style="width: 390px;" colspan="4">
|
<div class="oe_title" style="width: 390px;" colspan="4">
|
||||||
<label class="oe_edit_only" for="name" string="Location Name"/>
|
<label class="oe_edit_only" for="name" string="Name"/>
|
||||||
<h1><field name="name" class="oe_inline"/></h1>
|
<h1><field name="name" class="oe_inline"/></h1>
|
||||||
</div>
|
</div>
|
||||||
<newline/>
|
<newline/>
|
||||||
@ -20,8 +20,9 @@
|
|||||||
<field name="login" colspan="2"/>
|
<field name="login" colspan="2"/>
|
||||||
<field name="password" password="1" colspan="2"/>
|
<field name="password" password="1" colspan="2"/>
|
||||||
<separator string="Tasks" colspan="4"/>
|
<separator string="Tasks" colspan="4"/>
|
||||||
<field name="task_ids" colspan="4" nolabel="1">
|
<field name="task_ids" colspan="4" nolabel="1" context="{'hide_location': True}">
|
||||||
<tree>
|
<tree>
|
||||||
|
<field name="name"/>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="method"/>
|
<field name="method"/>
|
||||||
<field name="filename"/>
|
<field name="filename"/>
|
||||||
@ -62,3 +63,4 @@
|
|||||||
|
|
||||||
</data>
|
</data>
|
||||||
</openerp>
|
</openerp>
|
||||||
|
|
||||||
|
@ -29,17 +29,25 @@ class Task(models.Model):
|
|||||||
_name = 'external.file.task'
|
_name = 'external.file.task'
|
||||||
_description = 'Description'
|
_description = 'Description'
|
||||||
|
|
||||||
name = fields.Char()
|
name = fields.Char(required=True)
|
||||||
method = fields.Selection(selection='_get_method')
|
method = fields.Selection(selection='_get_method', required=True,
|
||||||
|
help='procotol and trasmitting info')
|
||||||
method_type = fields.Char()
|
method_type = fields.Char()
|
||||||
filename = fields.Char()
|
filename = fields.Char(help='File name which is imported')
|
||||||
filepath = fields.Char()
|
filepath = fields.Char(help='Path to imported file')
|
||||||
location_id = fields.Many2one('external.file.location', string='Location')
|
location_id = fields.Many2one('external.file.location', string='Location',
|
||||||
|
required=True)
|
||||||
attachment_ids = fields.One2many('ir.attachment', 'task_id',
|
attachment_ids = fields.One2many('ir.attachment', 'task_id',
|
||||||
string='Attachment')
|
string='Attachment')
|
||||||
delete_file = fields.Boolean(string='Delete file')
|
move_path = fields.Char(string='Move path',
|
||||||
move_file = fields.Boolean(string='Move file')
|
help='Imported File will be moved to this path')
|
||||||
move_path = fields.Char(string='Move path')
|
md5_check = fields.Boolean(help='Control file integrity after import with'
|
||||||
|
' a md5 file')
|
||||||
|
after_import = fields.Selection(selection='_get_action',
|
||||||
|
help='Action after import a file')
|
||||||
|
|
||||||
|
def _get_action(self):
|
||||||
|
return [('move', 'Move'), ('delete', 'Delete')]
|
||||||
|
|
||||||
def _get_method(self):
|
def _get_method(self):
|
||||||
res = []
|
res = []
|
||||||
@ -51,7 +59,7 @@ class Task(models.Model):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
@api.onchange('method')
|
@api.onchange('method')
|
||||||
def onchage_method(self):
|
def onchange_method(self):
|
||||||
if self.method:
|
if self.method:
|
||||||
if 'import' in self.method:
|
if 'import' in self.method:
|
||||||
self.method_type = 'import'
|
self.method_type = 'import'
|
||||||
@ -82,8 +90,8 @@ class Task(models.Model):
|
|||||||
'attachment_ids': self.attachment_ids,
|
'attachment_ids': self.attachment_ids,
|
||||||
'task': self,
|
'task': self,
|
||||||
'move_path': self.move_path,
|
'move_path': self.move_path,
|
||||||
'delete_file': self.delete_file,
|
'after_import': self.after_import,
|
||||||
'move_file': self.move_file,
|
'md5_check': self.md5_check,
|
||||||
}
|
}
|
||||||
conn = method_class(self.env, config)
|
conn = method_class(self.env, config)
|
||||||
conn.run()
|
conn.run()
|
||||||
|
@ -6,25 +6,23 @@
|
|||||||
<field name="model">external.file.task</field>
|
<field name="model">external.file.task</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form string="Tasks" version="7.0">
|
<form string="Tasks" version="7.0">
|
||||||
|
<header>
|
||||||
|
<button name="run" type="object" string="Run" icon="gtk-execute"/>
|
||||||
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<field name="method_type" invisible="1"/>
|
<field name="method_type" invisible="1"/>
|
||||||
<group col="4">
|
<group col="4">
|
||||||
<div class="oe_title" style="width: 390px;" colspan="4">
|
<div class="oe_title" style="width: 390px;" colspan="4">
|
||||||
<label class="oe_edit_only" for="name" string="Product Name"/>
|
<label class="oe_edit_only" for="name" string="Name"/>
|
||||||
<h1><field name="name" class="oe_inline"/></h1>
|
<h1><field name="name" class="oe_inline"/></h1>
|
||||||
</div>
|
</div>
|
||||||
<field name="method" colspan="2"/>
|
<field name="method" colspan="2"/>
|
||||||
<field name="location_id" colspan="2"/>
|
<span colspan="2"/>
|
||||||
<field name="filename" colspan="4" attrs="{'invisible':[('method_type','!=','import')]}"/>
|
<field name="filename" colspan="4" attrs="{'invisible':[('method_type','!=','import')], 'required':[('method_type', '=', 'import')]}"/>
|
||||||
<field name="filepath" colspan="4" />
|
<field name="filepath" colspan="4" />
|
||||||
<field name="move_file" colspan="2" attrs="{'invisible':[('method_type','!=','import')]}"/>
|
<field name="after_import" colspan="2"/>
|
||||||
<field name="move_path" colspan="2" attrs="{'invisible':['|', ('move_file','=',False), ('method_type','!=','import')]}"/>
|
<field name="move_path" colspan="2" attrs="{'invisible':['|', ('after_import','!=','move'), ('method_type','!=','import')]}"/>
|
||||||
<field name="delete_file" colspan="2" attrs="{'invisible':[('method_type','!=','import')]}"/>
|
<field name="md5_check" colspan="2" attrs="{'invisible':[('method_type','!=','import')]}"/>
|
||||||
<group attrs="{'invisible':[('method_type','!=','export')]}" colspan="4">
|
|
||||||
<separator string="Attachments" colspan="4" />
|
|
||||||
<field name="attachment_ids" colspan="4" nolabel="1"/>
|
|
||||||
</group>
|
|
||||||
<button name="run" type="object" string="Run" icon="gtk-execute"/>
|
|
||||||
</group>
|
</group>
|
||||||
</sheet>
|
</sheet>
|
||||||
</form>
|
</form>
|
||||||
@ -44,18 +42,5 @@
|
|||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="action_task" model="ir.actions.act_window">
|
|
||||||
<field name="name">Tasks</field>
|
|
||||||
<field name="type">ir.actions.act_window</field>
|
|
||||||
<field name="res_model">external.file.task</field>
|
|
||||||
<field name="view_type">form</field>
|
|
||||||
<field name="view_id" eval="False"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<menuitem id="menu_external_file_task"
|
|
||||||
parent="menu_file_exchange"
|
|
||||||
sequence="20"
|
|
||||||
action="action_task"/>
|
|
||||||
|
|
||||||
</data>
|
</data>
|
||||||
</openerp>
|
</openerp>
|
||||||
|
@ -25,8 +25,7 @@ import ftputil
|
|||||||
import ftputil.session
|
import ftputil.session
|
||||||
from ftputil.error import FTPIOError
|
from ftputil.error import FTPIOError
|
||||||
import logging
|
import logging
|
||||||
from base64 import b64encode
|
import os
|
||||||
import hashlib
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -35,6 +34,7 @@ class FtpTask(AbstractTask):
|
|||||||
_key = 'ftp'
|
_key = 'ftp'
|
||||||
_name = 'FTP'
|
_name = 'FTP'
|
||||||
_synchronize_type = None
|
_synchronize_type = None
|
||||||
|
_default_port = 21
|
||||||
|
|
||||||
def __init__(self, env, config):
|
def __init__(self, env, config):
|
||||||
self.env = env
|
self.env = env
|
||||||
@ -44,13 +44,13 @@ class FtpTask(AbstractTask):
|
|||||||
self.port = config.get('port', '')
|
self.port = config.get('port', '')
|
||||||
self.allow_dir_creation = config.get('allow_dir_creation', '')
|
self.allow_dir_creation = config.get('allow_dir_creation', '')
|
||||||
self.file_name = config.get('file_name', '')
|
self.file_name = config.get('file_name', '')
|
||||||
self.path = config.get('path', '.')
|
self.path = config.get('path') or '.'
|
||||||
self.move_path = config.get('move_path', '')
|
self.move_path = config.get('move_path', '')
|
||||||
self.move_file = config.get('move_file', False)
|
self.after_import = config.get('after_import', False)
|
||||||
self.delete_file = config.get('delete_file', False)
|
|
||||||
self.attachment_ids = config.get('attachment_ids', False)
|
self.attachment_ids = config.get('attachment_ids', False)
|
||||||
self.task = config.get('task', False)
|
self.task = config.get('task', False)
|
||||||
self.ext_hash = False
|
self.ext_hash = False
|
||||||
|
self.md5_check = config.get('md5_check', False)
|
||||||
|
|
||||||
|
|
||||||
class FtpImportTask(FtpTask):
|
class FtpImportTask(FtpTask):
|
||||||
@ -77,22 +77,55 @@ class FtpImportTask(FtpTask):
|
|||||||
|
|
||||||
def _source_name(self, download_directory, file_name):
|
def _source_name(self, download_directory, file_name):
|
||||||
"""helper to get the full name"""
|
"""helper to get the full name"""
|
||||||
return download_directory + '/' + file_name
|
return os.path.join(download_directory, file_name)
|
||||||
|
|
||||||
def _move_file(self, ftp_conn, source, target):
|
def _move_file(self, ftp_conn, source, target):
|
||||||
"""Moves a file on the FTP server"""
|
"""Moves a file on the FTP server"""
|
||||||
_logger.info('Moving file %s %s' % (source, target))
|
_logger.info('Moving file %s %s' % (source, target))
|
||||||
ftp_conn.rename(source, target)
|
ftp_conn.rename(source, target)
|
||||||
|
if self.md5_check:
|
||||||
|
ftp_conn.rename(source + '.md5', target + '.md5')
|
||||||
|
|
||||||
def _delete_file(self, ftp_conn, source):
|
def _delete_file(self, ftp_conn, source):
|
||||||
"""Deletes a file from the FTP server"""
|
"""Deletes a file from the FTP server"""
|
||||||
_logger.info('Deleting file %s' % source)
|
_logger.info('Deleting file %s' % source)
|
||||||
ftp_conn.remove(source)
|
ftp_conn.remove(source)
|
||||||
|
if self.md5_check:
|
||||||
|
ftp_conn.remove(source + '.md5')
|
||||||
|
|
||||||
@staticmethod
|
def _get_hash(self, file_name, ftp_conn):
|
||||||
def _get_hash(file_name, ftp_conn):
|
hash_file_name = file_name + '.md5'
|
||||||
with ftp_conn.open(file_name, 'rb') as f:
|
with ftp_conn.open(hash_file_name, 'rb') as f:
|
||||||
return hashlib.md5(f.read()).hexdigest()
|
return f.read().rstrip('\r\n')
|
||||||
|
|
||||||
|
def _get_files(self, conn, path):
|
||||||
|
process_files = []
|
||||||
|
files_list = conn.listdir(path)
|
||||||
|
for file in files_list:
|
||||||
|
if file == self.file_name:
|
||||||
|
source_name = self._source_name(self.path, self.file_name)
|
||||||
|
process_files.append((file, source_name))
|
||||||
|
return process_files
|
||||||
|
|
||||||
|
def _process_file(self, conn, file_to_process):
|
||||||
|
if self.md5_check:
|
||||||
|
self.ext_hash = self._get_hash(file_to_process[1], conn)
|
||||||
|
self._handle_new_source(
|
||||||
|
conn,
|
||||||
|
self.path,
|
||||||
|
self.file_name,
|
||||||
|
self.move_path)
|
||||||
|
|
||||||
|
# Move/delete files only after all files have been processed.
|
||||||
|
if self.after_import == 'delete':
|
||||||
|
self._delete_file(conn, file_to_process[1])
|
||||||
|
elif self.after_import == 'move':
|
||||||
|
if not conn.path.exists(self.move_path):
|
||||||
|
conn.mkdir(self.move_path)
|
||||||
|
self._move_file(
|
||||||
|
conn,
|
||||||
|
file_to_process[1],
|
||||||
|
self._source_name(self.move_path, file_to_process[0]))
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
port_session_factory = ftputil.session.session_factory(
|
port_session_factory = ftputil.session.session_factory(
|
||||||
@ -102,34 +135,9 @@ class FtpImportTask(FtpTask):
|
|||||||
session_factory=port_session_factory) as ftp_conn:
|
session_factory=port_session_factory) as ftp_conn:
|
||||||
|
|
||||||
path = self.path or '.'
|
path = self.path or '.'
|
||||||
file_list = ftp_conn.listdir(path)
|
files_to_process = self._get_files(ftp_conn, path)
|
||||||
downloaded_files = []
|
for file_to_process in files_to_process:
|
||||||
for ftpfile in file_list:
|
self._process_file(ftp_conn, file_to_process)
|
||||||
source_name = self._source_name(path, self.file_name)
|
|
||||||
if ftp_conn.path.isfile(source_name) and \
|
|
||||||
ftpfile == self.file_name:
|
|
||||||
self.ext_hash = self._get_hash(source_name, ftp_conn)
|
|
||||||
self._handle_new_source(
|
|
||||||
ftp_conn,
|
|
||||||
path,
|
|
||||||
self.file_name,
|
|
||||||
self.move_path)
|
|
||||||
downloaded_files.append(self.file_name)
|
|
||||||
|
|
||||||
# Move/delete files only after all files have been processed.
|
|
||||||
if self.delete_file:
|
|
||||||
for ftpfile in downloaded_files:
|
|
||||||
self._delete_file(ftp_conn,
|
|
||||||
self._source_name(path,
|
|
||||||
ftpfile))
|
|
||||||
elif self.move_path:
|
|
||||||
if not ftp_conn.path.exists(self.move_path):
|
|
||||||
ftp_conn.mkdir(self.move_path)
|
|
||||||
for ftpfile in downloaded_files:
|
|
||||||
self._move_file(
|
|
||||||
ftp_conn,
|
|
||||||
self._source_name(path, ftpfile),
|
|
||||||
self._source_name(self.move_path, ftpfile))
|
|
||||||
|
|
||||||
|
|
||||||
class FtpExportTask(FtpTask):
|
class FtpExportTask(FtpTask):
|
||||||
|
51
ir_attachment_metadata/README.rst
Normal file
51
ir_attachment_metadata/README.rst
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
|
||||||
|
:alt: License
|
||||||
|
|
||||||
|
Attachment Metadata
|
||||||
|
======================
|
||||||
|
|
||||||
|
This module was written to extend the functionality of ir.attachment
|
||||||
|
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
Installable without any requirements
|
||||||
|
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
|
||||||
|
THe module just add some field to ir.attachment
|
||||||
|
|
||||||
|
For further information, please visit:
|
||||||
|
|
||||||
|
* https://www.odoo.com/forum/help-1
|
||||||
|
|
||||||
|
Known issues / Roadmap
|
||||||
|
======================
|
||||||
|
|
||||||
|
Credits
|
||||||
|
=======
|
||||||
|
|
||||||
|
* Joel Grand-Guillaume Camptocamp
|
||||||
|
* initOS <http://initos.com>
|
||||||
|
* Valentin CHEMIERE <valentin.chemiere@akretion.com>
|
||||||
|
|
||||||
|
Contributors
|
||||||
|
------------
|
||||||
|
|
||||||
|
* Sebastien BEAU <sebastian.beau@akretion.com>
|
||||||
|
|
||||||
|
Maintainer
|
||||||
|
----------
|
||||||
|
|
||||||
|
* Valentin CHEMIERE <valentin.chemiere@akretion.com>
|
||||||
|
|
||||||
|
.. image:: http://odoo-community.org/logo.png
|
||||||
|
:alt: Odoo Community Association
|
||||||
|
:target: http://odoo-community.org
|
||||||
|
|
||||||
|
This module is maintained by the OCA.
|
||||||
|
|
||||||
|
OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.
|
||||||
|
|
||||||
|
To contribute to this module, please visit http://odoo-community.org.
|
@ -2,6 +2,8 @@
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
#
|
#
|
||||||
# Module for OpenERP
|
# Module for OpenERP
|
||||||
|
# Copyright 2011-2012 Camptocamp SA
|
||||||
|
# @author: Joel Grand-Guillaume
|
||||||
# Copyright (C) 2015 Akretion (http://www.akretion.com).
|
# Copyright (C) 2015 Akretion (http://www.akretion.com).
|
||||||
# @author Valentin CHEMIERE <valentin.chemiere@akretion.com>
|
# @author Valentin CHEMIERE <valentin.chemiere@akretion.com>
|
||||||
#
|
#
|
||||||
|
Loading…
Reference in New Issue
Block a user