7 | AppManager is an Odoo app management tool that automatically checks for app updates so you don't have to!
8 |
9 | ## Table of Contents
10 |
11 | - [Introduction](#TLDR)
12 | - [Installation](#installation)
13 | - [Features](#features)
14 | - [Issues](#issues-and-bugs)
15 |
16 | ## TLDR
17 | AppManager automatically checks your database it's apps to see if there are newer app versions available remotely.
18 | We compare the installed versions on your database with the latest available apps on The Odoo Store.
19 | Since The Odoo Store automatically syncs thousands of apps on a daily basis, through a direct Github connector, we always know about updates!
20 | AppManager will auto-check your apps against our platform and if updates are available you will be notified!
21 |
22 | ## Installation
23 | 1. Navigate into your custom addons path and clone this repository
24 | ```
25 | cd /odoo14/custom/addons
26 | git clone https://github.com/theodoostore/AppManager.git
27 | ```
28 |
29 | 2. Checkout the right directory depending on the version of your Odoo instance:
30 | ```
31 | git checkout 14.0
32 | ```
33 |
34 | 3. Add the Github repository into your Odoo configuration file.
35 | ```
36 | addons_path=/odoo14/odoo14-server,/some/other/paths/you/have,/odoo14/custom/addons/AppManager
37 | ```
38 |
39 | 4. Restart your Odoo instance so our new module is available
40 | ```
41 | sudo service odoo14-server restart
42 | ```
43 |
44 | 5. Go to Apps and click on "Update Apps List"
45 | 6. Search for the app `app_manager` and install it.
46 | 7. Congrats, that's it! You now see the AppManager app available on your main Odoo screen.
47 |
48 |
49 | ## Features
50 |
51 |
52 | - Quick overview of apps and their statusses
53 | - Quick button to instantly download the latest version from The Odoo Store
54 | - Quick button to instantly download and update your app within the database Note: this is at your own risk!
55 | - Quick navigation the details about the app on The Odoo Store
56 | - See if apps are certified and up-to-date
57 | - See if apps have security issues (CVE's)
58 | - Get automatically notified about available updates
59 |
60 |
61 | ## Issues and bugs
62 | Have an issue or a bug? Please create a new report under the "Issues" section.
63 |
--------------------------------------------------------------------------------
/app_manager/views/res_config_settings_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | res.config.settings.view.form.inherit.app.manager
5 | res.config.settings
6 |
7 |
8 |
9 |
10 |
13 |
App configuration
14 |
15 |
16 |
18 |
19 | Fill in the path to the folder location on your server where you want to download
20 | the apps too.
21 |
22 | For example: /odoo14/custom/addons/your_custom_apps/
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
E-mail notifications
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | Receive e-mails about available app updates.
40 |
41 |
42 |
43 |
44 |
45 |
47 |
48 | User to receive e-mails about app updates
49 |
50 |
51 |
52 |
53 |
54 |
55 |
57 |
58 | E-mail template which will be sent to notify users about app updates
59 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
--------------------------------------------------------------------------------
/app_manager/models/ir_module_module.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import requests
3 | import json
4 | import logging
5 | import tempfile
6 | import os
7 | import io
8 | import shutil
9 | import zipfile
10 | import odoo
11 | from odoo.exceptions import UserError, AccessDenied
12 | from odoo import models, fields, api, _, tools, modules
13 |
14 | _logger = logging.getLogger(__name__)
15 |
16 |
17 | def backup(path, raise_exception=True):
18 | """
19 | Creates a temp backup of the code in case anything fails. It will be removed again if all went well.
20 | """
21 | path = os.path.normpath(path)
22 | if not os.path.exists(path):
23 | if not raise_exception:
24 | return None
25 | raise OSError("The path '%s' does not exist on the server." % path)
26 | cnt = 1
27 | while True:
28 | bck = '%s~%d' % (path, cnt)
29 | if not os.path.exists(bck):
30 | shutil.move(path, bck)
31 | return bck
32 | cnt += 1
33 |
34 |
35 | class IrModuleModule(models.Model):
36 | _inherit = 'ir.module.module'
37 |
38 | store_version = fields.Char(
39 | string='Remote version'
40 | )
41 |
42 | store_url = fields.Char(
43 | string='Store URL'
44 | )
45 |
46 | store_download_url = fields.Char(
47 | string='Store download URL'
48 | )
49 |
50 | store_is_free = fields.Boolean(
51 | string='Is free'
52 | )
53 |
54 | store_app = fields.Boolean(
55 | string='Store app'
56 | )
57 |
58 | store_update_available = fields.Boolean(
59 | string='Remote update available'
60 | )
61 |
62 | store_is_certified = fields.Boolean(
63 | string='Certified app'
64 | )
65 |
66 | store_has_security_issue = fields.Boolean(
67 | string='Has security issue'
68 | )
69 |
70 | @api.model
71 | def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
72 | """
73 | Override of the fields_view_get to inject our custom XML view if the view is "store_manager".
74 | """
75 | res = super(IrModuleModule, self).fields_view_get(view_id, view_type, toolbar=toolbar, submenu=False)
76 | if view_type == 'kanban' and "store_manager" in res.get('arch'):
77 | res['arch'] = self.env['app.manager'].sudo().search([], limit=1).xml_kanban
78 | return res
79 |
80 | @api.model
81 | def get_app_store_server(self):
82 | """
83 | Returns the URL endpoint to The Odoo Store
84 | """
85 | return 'https://www.theodoostore.com'
86 |
87 | def _download_remote_app_code(self):
88 | """
89 | Makes a call to the external apps server, requests the zipped app, puts it in our local path and unzips it.
90 | """
91 | tmp = tempfile.mkdtemp()
92 | download_url = self.store_download_url
93 |
94 | try:
95 | _logger.info('Downloading module `%s` from appstore.', self.name)
96 | response = requests.get(download_url)
97 | response.raise_for_status()
98 | content = response.content
99 | except Exception:
100 | _logger.exception('Failed to fetch module %s from %s', self.name, download_url)
101 | raise UserError(
102 | _('The `%s` module appears to be unavailable at the moment. Please try again later.') % self.name)
103 | else:
104 | zipfile.ZipFile(io.BytesIO(content)).extractall(tmp)
105 | assert os.path.isdir(os.path.join(tmp, self.name))
106 |
107 | module_path = modules.get_module_path(self.name, downloaded=True, display_warning=False)
108 |
109 | bck = backup(module_path, False)
110 | _logger.info('Copy downloaded module `%s` to `%s`', self.name, module_path)
111 | shutil.move(os.path.join(tmp, self.name), module_path)
112 | if bck:
113 | # All went well - removing the backup
114 | shutil.rmtree(bck)
115 |
116 | self.update_list()
117 |
118 | def _update_app_code(self):
119 | """
120 | Triggers an actual update of the code and updates the database thanks to the button_immediate_install().
121 | """
122 | downloaded = self.search([('name', '=', self.name)])
123 | installed = self.search([('id', 'in', downloaded.ids), ('state', '=', 'installed')])
124 |
125 | to_install = self.search([('name', 'in', list(self.name)), ('state', '=', 'uninstalled')])
126 | post_install_action = to_install.button_immediate_install()
127 |
128 | if installed or to_install:
129 | # in this case, force server restart to reload python code...
130 | self._cr.commit()
131 | odoo.service.server.restart()
132 | return {
133 | 'type': 'ir.actions.client',
134 | 'tag': 'home',
135 | 'params': {'wait': True},
136 | }
137 |
138 | return post_install_action
139 |
140 | def _validate_configuration_and_access(self):
141 | """
142 | Checks if the AppManager has been configured and if the user pressing update buttons has enough rights.
143 | """
144 | # See if the user configured a module path in our configuration view.
145 | app_storage_location = self.env['ir.config_parameter'].sudo().get_param('app_manager.app_storage_location')
146 | if not app_storage_location:
147 | raise UserError(_('We cannot download this app as you haven\'t configured a download path yet.\n'
148 | 'You can do this from AppManager > Settings.'))
149 |
150 | # We'll only allow people with group_system rights to do these kind of operations.
151 | if not self.env.user.has_group('base.group_system'):
152 | raise AccessDenied()
153 |
154 | # Check if the directory exists and if we have enough access rights.
155 | if not os.access(app_storage_location, os.W_OK):
156 | raise UserError(_('The location specified is not accessible by the Odoo user.\n'
157 | 'Please make sure the directory exists and is writable.'))
158 |
159 | def download_remote_app(self):
160 | """
161 | Main function to handle app downloading logic. Does the following:
162 | 1. Check if user has enough access and if everything is configured well.
163 | 2. Download the remote code (ZIP), unpack it in the specified path and update the apps list
164 | 3. If the button "Download & update app" was triggered we will also do an actual app update for the db.
165 | Notice that this is only for people who really know what they do!
166 | """
167 | self._validate_configuration_and_access()
168 | self._download_remote_app_code()
169 | if self.env.context.get('update_app'):
170 | # TODO: should we trigger a wizard asking for double verification here?
171 | post_install_action = self._update_app_code()
172 | return post_install_action
173 |
174 | def _check_kanban_update(self, kanban_view_details):
175 | """
176 | Checks if the database version of our Kanban view is outdated or not. If it is we store the new
177 | (remote) version in our local database.
178 |
179 | Args:
180 | kanban_view_details (dictionary): dictionary with the remote Kanban XML & the version
181 | Returns:
182 | None
183 | """
184 | remote_kanban_version = float(kanban_view_details.get('version'))
185 | remote_kanban_view = kanban_view_details.get('kanban')
186 |
187 | # We will only keep one record in the db!
188 | app_manager_view = self.env['app.manager'].sudo().search([], limit=1)
189 |
190 | # Updated remote version - saving in our database for rendering!
191 | if app_manager_view.kanban_version < remote_kanban_version:
192 | app_manager_view.write({
193 | 'xml_kanban': remote_kanban_view,
194 | 'kanban_version': remote_kanban_version
195 | })
196 |
197 | def _prepare_local_app_details(self):
198 | """
199 | Gets all locally installed modules and stores them into a dictionary along with the Odoo version.
200 | This is used to compare local app details with remote details.
201 |
202 | returns:
203 | app_dictionary (dictionary): dict with the most important details of the installed apps along with the db
204 | it's version
205 | """
206 | installed_modules = self.env['ir.module.module'].search_read(
207 | [('state', '=', 'installed')], ["id", "name", "installed_version", "latest_version", "state", "author"]
208 | )
209 |
210 | # Don't worry we are not going to do anything unethical with this. In fact, right now we do not do anything
211 | # with it. We're just including this for possible feature services related to your specific database.
212 | database_uuid = self.env['ir.config_parameter'].sudo().get_param('database.uuid')
213 |
214 | app_dictionary = json.dumps({
215 | 'modules': installed_modules,
216 | 'version': odoo.service.common.exp_version(),
217 | 'database_uuid': database_uuid
218 | })
219 |
220 | return app_dictionary
221 |
222 | def check_external_app_updates(self):
223 | """
224 | Main function which will get all details about apps from The Odoo Store.
225 | 1. Gets The Odoo Store URL
226 | 2. Stores the details about installed apps on the customer database in JSON format
227 | 3. Posts the data and gets back details about these apps from The Odoo Store server
228 | This is details such as the certification, if there is a remote update, security issues, ...
229 | 4. Stores the details in the local db for showing a visual UI to the end user about all apps
230 | """
231 | app_store_server_url = self.get_app_store_server()
232 |
233 | # Prepares all local app details and converts them into a JSON
234 | app_dictionary = self._prepare_local_app_details()
235 |
236 | headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
237 | response = requests.post(
238 | url=app_store_server_url + '/api/get_apps_to_update', data=app_dictionary, headers=headers
239 | )
240 |
241 | # Failsafe for a down server, maintenance, ... Either way The Odoo Store API is not reachable.
242 | if response.status_code != 200:
243 | _logger.critical("We could not connect to '%s'. Falling back to old view/values." % app_store_server_url)
244 | return self.env.ref('app_manager.open_store_module_action').read()[0]
245 | else:
246 | _logger.info(
247 | "We could connect to the API's from '%s'. Fetching possible view updates & app details." %
248 | app_store_server_url
249 | )
250 |
251 | # We should have a good JSON load now - let's parse it
252 | json_response = json.loads(response.text).get('result')
253 |
254 | # Return the actual view (which now has updated database values for our apps & the (possible) new Kanban view
255 |
256 | # Failsafe in case we do not get any good response
257 | # or because we do not yet have an XML architecture tested/supported for this Odoo version.
258 | if not json_response or json_response.get('version_not_supported'):
259 | # For sure no new view or changes - let's fall back to our already stored view.
260 | return self.env.ref('app_manager.open_store_module_action').read()[0]
261 |
262 | # Checks if our remote server has a new XML update for the Kanban design or not
263 | self._check_kanban_update(json_response.get('views'))
264 |
265 | apps_to_update = json_response.get('modules')
266 | to_update_apps = []
267 | if apps_to_update:
268 | for app_to_update in apps_to_update:
269 | app = self.browse(int(app_to_update.get('id')))
270 | app.update({
271 | 'store_app': True,
272 | 'store_is_certified': app_to_update.get('is_certified'),
273 | 'store_is_free': True if app_to_update.get('app_type') == 'Free' else False,
274 | 'store_url': app_to_update.get('store_url'),
275 | 'store_download_url': app_to_update.get('download_url'),
276 | 'store_version': app_to_update.get('remote_version'),
277 | 'store_update_available': True if app_to_update.get('remote_version') != app.installed_version else False,
278 | 'store_has_security_issue': True if app_to_update.get('has_security_issue') else False,
279 | })
280 | to_update_apps += app
281 | self.env['ir.module.module'].write(to_update_apps)
282 |
283 | # We (might have had) a view update - let's call the action.
284 | # This has to be done after our remote sync so we 'get' the possible new remote view.
285 | action = self.env.ref('app_manager.open_store_module_action').read()[0]
286 | return action
287 |
288 | def get_updatable_apps(self):
289 | """
290 | Fetches details about all apps that have updates available on The Odoo Store and passes the details
291 | along as a list. This is used for the email template.
292 | """
293 | module_values = []
294 | # There is a reason why we search apps & then convert the values we need in a dictionary!
295 | # Odoo is unable to fetch fields on the base model in a new module within Jinja2. This is the easiest workaround
296 | modules = self.search([
297 | ('store_update_available', '=', True)
298 | ])
299 |
300 | for module in modules:
301 | module_values.append({
302 | 'name': module.shortdesc,
303 | 'technical_name': module.name,
304 | 'app_icon': module.icon,
305 | 'installed_version': module.installed_version,
306 | 'store_version': module.store_version,
307 | 'store_url': module.store_url,
308 | 'store_has_security_issue': module.store_has_security_issue
309 | })
310 | return module_values
311 |
312 | def send_app_update_email(self):
313 | """
314 | Automatically sends an e-mail to the user which is configured under AppManager > Settings.
315 | """
316 | # Check if this database should send an e-mail or not
317 | notify_by_email = self.env['ir.config_parameter'].sudo().get_param('app_manager.receive_app_update_mails')
318 |
319 | if notify_by_email:
320 | # Get all the details that we need for sending out the email
321 | app_update_email_template = self.env['ir.config_parameter'].sudo().get_param(
322 | 'app_manager.app_update_mail_template_id')
323 | user_id = self.env['ir.config_parameter'].sudo().get_param('app_manager.app_update_user_id')
324 | user_email = self.env['res.users'].browse(int(user_id)).partner_id.email
325 | # Just a placeholder for generating an unique id
326 | app_manager = self.env['app.manager'].search([], limit=1).id
327 |
328 | mail_template = self.env['mail.template'].sudo().browse(int(app_update_email_template))
329 | mail_template['email_to'] = user_email
330 | mail_template.send_mail(app_manager, force_send=True)
331 |
--------------------------------------------------------------------------------